Skip to content

Conversation

@jviszlai
Copy link
Collaborator

@jviszlai jviszlai commented Nov 2, 2025

Addresses #299 and adds window decoding support.

  • Refactors much of SequentialSinterDecoder into a more general WindowSinterDecoder which allows only a subset of detectors in the segment/window to correspond to errors whose decoded results are committed.
  • The existing SequentialSinterDecoder is preserved as a subclass of WindowSinterDecoder where all detectors in the window are part of the commit region.
  • Adds a new SlidingWindowSinterDecoder that creates a sequence of overlapping windows from a DetectorErrorModel based on time coordinates.
  • Minor bugfix of time coordinate code for readout detectors in memory experiments (previously set as num_rounds but this was additive to the SHIFT_COORDS in the repeat block)
  • Adds example use to examples/logical_error_rates.ipynb

I'd appreciate thoughts on the interaction between WindowSinterDecoder, SequentialSinterDecoder, and SlidingWindowSinterDecoder currently. Specifically, constructing the windows for SlidingWindowSinterDecoder in compile_decoder_for_dem feels awkward when WindowSinterDecoder and SequentialSinterDecoder both construct it in __init__, but I couldn't think of a cleaner solution.

Also I think more general window decoding (e.g. parallel window decoding) should be easy to implement, but I'll save that for a separate PR.

@jviszlai jviszlai requested a review from perlinm as a code owner November 2, 2025 01:32
@perlinm
Copy link
Collaborator

perlinm commented Nov 3, 2025

This looks great, thanks! Acknowledging receipt and intent to review 🙂

@perlinm
Copy link
Collaborator

perlinm commented Nov 11, 2025

Regarding the interaction between WindowSinterDecoder and SequentialSinterDecoder: I am kind of leaning towards renaming WindowSinterDecoder to SequentialSinterDecoder, and making window_commit_detectors: Sequence[Collection[int]] | None = None. If None, the window_commit_detectors are simply set to the window_detectors (in the current naming scheme). I like the emphasis on the fact that this decoder is sequential, which is in contrast to parallel window decoding, for example.

SlidingWindowSinterDecoder would keep its name. Thoughts?

(Note: I tentatively renamed WindowSinterDecoder to SequentialSinterDecoder, but I could be convinced to change it back.)

Regarding the construction of windows in SlidingWindowSinterDecoder.compile_decoder_for_dem, I think that's okay. We could pass detector coordinate data when constructing SlidingWindowSinterDecoder, I suppose, but I think that would would add a needless burden on the user to provide data that the decoder will anyways later get access to.

@perlinm
Copy link
Collaborator

perlinm commented Nov 11, 2025

I suppose we could also call it a SequentialWindowSinterDecoder, but that might be too verbose. Can we get some external opinions?

@beitom do you have any thoughts? Context: we're deciding between WindowSinterDecoder and SequentialSinterDecoder for a decoder that sequentially decodes "windows". Each window is defined by a "detection region" that it uses to decode, and "commit region" to which it commits corrections. A sliding window decoder is a special case. A parallel window decoder would be related but distinct.

@perlinm
Copy link
Collaborator

perlinm commented Nov 11, 2025

One weird thing I noticed in the logical_error_rates.ipynb is that the surface code memory experiment seems to have better (lower) logical error rates with a SlidingWindowDecoder than with the SubgraphSinterDecoder. That is quite unexpected, as I would think that jointly decoding the full QEC cycle should do better than decoding it in parts.

Any idea what is going on?

@beitom
Copy link
Collaborator

beitom commented Nov 11, 2025

I suppose we could also call it a SequentialWindowSinterDecoder, but that might be too verbose. Can we get some external opinions?

@beitom do you have any thoughts? Context: we're deciding between WindowSinterDecoder and SequentialSinterDecoder for a decoder that sequentially decodes "windows". Each window is defined by a "detection region" that it uses to decode, and "commit region" to which it commits corrections. A sliding window decoder is a special case. A parallel window decoder would be related but distinct.

I would actually go with SequentialWindowDecoder as this describes the functionality and is a plausible sounding English phrase. Sinter only describe something relevant to the implementation. But of terms like sinter and stim are already used heavily in existing naming conventions then SequentialWindowSinterDecoder is my top pic.

@perlinm
Copy link
Collaborator

perlinm commented Nov 12, 2025

Thanks for the input @beitom!

I've been thinking of dropping Sinter from these names anyways, so maybe we should go ahead and do that. My primary hesitation has been the awkward fact that none of the decoders in sinter.py are qldpc.decoders.Decoders, so I've tried to make an explicit distinction between a Decoder and and a SinterDecoder.

Thinking out loud, though, one possibility is to remedy the aforementioned awkward fact by just making every SinterDecoder a bona-fide Decoder. Or rather, every Compiled*Decoder would be a Decoder that would implement a .decode method to decode a single shot, while a non-compiled decoder would implement .decode, but throw an error asking the user to compile with .compile_decoder_for_dem. This would shorten otherwise lengthy names while mitigating potential confusion.

@beitom
Copy link
Collaborator

beitom commented Nov 13, 2025

out loud, though, one pos

I like this idea, keeps the API more clean and succinct

Copy link
Collaborator

@perlinm perlinm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@beitom sounds good. I'll make a separate issue (#399) to clean up the API and naming of SinterDecoders. No need to bog down this PR with it.

Getting back to code review (@jviszlai): this PR looks to be in pretty good shape! One final idea for substantive changes:

Using a detector coordinate works for the memory experiments produced by qldpc, but it would be valuable to make the SlidingWindowDecoder usable for externally generated circuits as well (such as those produced by tqec). Maybe instead of passing a time_idx to SlidingWindowDecoder at initialization time, we can pass a get_detector_time: Callable[[int], int] | None (naming tentative) that maps a detector to a time coordinate. If None, this would default to using the first detector coordinate. Thoughts?

@jviszlai
Copy link
Collaborator Author

Regarding the interaction between WindowSinterDecoder and SequentialSinterDecoder: I am kind of leaning towards renaming WindowSinterDecoder to SequentialSinterDecoder, and making window_commit_detectors: Sequence[Collection[int]] | None = None. If None, the window_commit_detectors are simply set to the window_detectors (in the current naming scheme). I like the emphasis on the fact that this decoder is sequential, which is in contrast to parallel window decoding, for example.

SlidingWindowSinterDecoder would keep its name. Thoughts?

This sounds good to me. I do think there might be a slightly different interpretation of SequentialSinterDecoder though. Currently the sequence of windows is the order they are decoded, so a window doesn't actually need to be contiguous with the next window in the sequence. This is useful for parallel window decoding as we could implement it by having the sequence be all A layer windows first then all B layer windows.

One weird thing I noticed in the logical_error_rates.ipynb is that the surface code memory experiment seems to have better (lower) logical error rates with a SlidingWindowDecoder than with the SubgraphSinterDecoder. That is quite unexpected, as I would think that jointly decoding the full QEC cycle should do better than decoding it in parts.

Any idea what is going on?

I think this is because for the SlidingWindowDecoder experiments I reported the logical error rate per round rather than the overall logical error rate. I can clarify this in the notebook.

Getting back to code review (@jviszlai): this PR looks to be in pretty good shape! One final idea for substantive changes:

Using a detector coordinate works for the memory experiments produced by qldpc, but it would be valuable to make the SlidingWindowDecoder usable for externally generated circuits as well (such as those produced by tqec). Maybe instead of passing a time_idx to SlidingWindowDecoder at initialization time, we can pass a get_detector_time: Callable[[int], int] | None (naming tentative) that maps a detector to a time coordinate. If None, this would default to using the first detector coordinate. Thoughts?

I like this idea. In the future, it could also be nice to support spatial window decoding where windows are split along a spatial coordinate instead of a time coordinate.

@jviszlai
Copy link
Collaborator Author

Ok just finished some of the small changes. I might add another example to logical_error_rates.ipynb, but that might take a bit to run.

@perlinm
Copy link
Collaborator

perlinm commented Nov 24, 2025

Taking a bit to run is okay. We'll just save outputs to the notebook in the repo, and add a warning/comment for the user.

Anyways sorry I've been taking so long to get to this! Lots of November deadlines for me 🙂. I promise to get back to this soon (likely next week).

@jviszlai
Copy link
Collaborator Author

Just added the new plot which doesn't take too long to run actually. I also had to do some debugging and added some edge case handling in case the number of rounds in the experiment is smaller than the window size.

And no worries at all! I've also been a bit swamped with deadlines.

@perlinm
Copy link
Collaborator

perlinm commented Dec 9, 2025

@jviszlai I did a cleanup + simplify pass through the SlidingWindowDecoder, but I ended up with a different treatment of what happens with the "remainder" num_time_steps % window_size time steps at the end. Specifically, your treatment seems to append the remainder to the last window, whereas I make another smaller window to cover the remainder.

A test script to compare implementations:

from qldpc import circuits, codes, decoders
circuit = circuits.get_memory_experiment(codes.SurfaceCode(2), num_rounds=9)
decoder = decoders.SlidingWindowDecoder(4, 2)
decoder.compile_decoder_for_dem(circuit.detector_error_model())
for window in decoder.windows:
    print(window)

Previous implementation (inside SlidingWindowDecoder.compile_decoder_for_dem):

        # create windows based on a time coordinate of the detectors in the DetectorErrorModel
        self.windows = []
        for detectors in self.detector_subsets or [range(dem.num_detectors)]:
            # collect detectors according to their time index
            time_to_detectors: dict[int, list[int]] = collections.defaultdict(list)
            for detector in detectors:
                time_to_detectors[self.detector_to_time(detector)].append(detector)

            # special case: only one window that covers all detectors
            if max(time_to_detectors) < self.window_size:
                window_dets = [det for dets in time_to_detectors.values() for det in dets]
                commit_dets = window_dets
                self.windows.append((window_dets, commit_dets))
                continue

            # add windows one at a time
            for t in range(0, max(time_to_detectors) - self.window_size + 1, self.stride):
                window_dets = []
                commit_dets = []
                for i in range(t, t + self.window_size):
                    window_dets.extend(time_to_detectors[i])
                    if i < t + self.stride:
                        commit_dets.extend(time_to_detectors[i])
                self.windows.append((window_dets, commit_dets))

            # add trailing detectors to the last window
            for i in range(t + self.stride, t + self.window_size):
                self.windows[-1][1].extend(time_to_detectors[i])
            for i in range(t + self.window_size, max(time_to_detectors) + 1):
                self.windows[-1][0].extend(time_to_detectors[i])
                self.windows[-1][1].extend(time_to_detectors[i])

for which the test script prints

([0, 1, 2, 3], [0, 1])
([2, 3, 4, 5], [2, 3])
([4, 5, 6, 7, 8, 9], [4, 5, 6, 7, 8, 9])

New implementation:

        # construct windows defined by "detection" and "commit" regions
        self.windows = []
        for detectors in self.detector_subsets or [range(dem.num_detectors)]:
            # collect detectors according to their time index
            time_to_dets: dict[int, list[int]] = collections.defaultdict(list)
            for detector in detectors:
                time_to_dets[self.detector_to_time(detector)].append(detector)

            # add one window at a time
            for start in range(0, max(time_to_dets) + 1, self.stride):
                window_time_to_dets = [time_to_dets[start + dt] for dt in range(self.window_size)]
                window = (  # defined by (detection, commit) regions
                    [det for dets in window_time_to_dets for det in dets],
                    [det for dets in window_time_to_dets[: self.stride] for det in dets],
                )
                self.windows.append(window)

which prints

([0, 1, 2, 3], [0, 1])
([2, 3, 4, 5], [2, 3])
([4, 5, 6, 7], [4, 5])
([6, 7, 8, 9], [6, 7])
([8, 9], [8, 9])

Which is the desired behavior? And is it easy to understand why the remainder is treated the way it is? If so, I'd like to explain this in the docstring.

Might be worth mentioning that there is another possible treatment of the remainder, which would look like

([0, 1, 2, 3], [0, 1])
([2, 3, 4, 5], [2, 3])
([4, 5, 6, 7], [4, 5])
([6, 7, 8, 9], [6, 7, 8, 9])

Also, thanks for the example notebook addition!

@beitom
Copy link
Collaborator

beitom commented Dec 10, 2025

Hi All, not sure what the timeline is for wanting to be be merged but if it can wait 1-2 weeks I'm happy to also help review.

@perlinm
Copy link
Collaborator

perlinm commented Dec 11, 2025

@beitom we would be happy to wait for your review 🙂

" max_shots: int = 10**5,\n",
" max_errors: int = 100,\n",
" distance_trials: int = 100,\n",
" **decoding_kwargs: object,\n",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could remove object annotations for kwargs

" tasks = []\n",
" custom_decoders = {} # each code is going to need its own decoder\n",
" for code_index, code in enumerate(codes_to_simulate):\n",
" distance = code.get_distance(bound=distance_trials)\n",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed code.get_distance has return type int | float which causes type checking errors for other functions that expect int args, is there any reason distance will be a float?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is because distance is undefined for trivial codes (with only one code word), in which case get_distance returns np.nan, which the type checker interprets as a float. I'm open to other ideas for dealing with this edge case.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the notebook example it's fairly harmless, Ill take a look if this comes up anywhere in the source code and we can add a value error if distance comes in as nan.

if isinstance(distance, float):
    raise ValueError(f"Code {code} has unknown distance")

I'll let you decide if you want to add this to the notebook or ignore the minor warnings

subgraph_decoders = []
for detectors, observables in zip(self.subgraph_detectors, subgraph_observables):
# identify the error mechanisms that flip these detectors
errors = dem_arrays.detector_flip_matrix.getnnz(axis=0) != 0
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jviszlai should this be errors = dem_arrays.detector_flip_matrix[detectors].getnnz(axis=0) != 0 only keep errors that flip at least one detector in this subgraph

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
errors = dem_arrays.detector_flip_matrix[detectors].getnnz(axis=0) != 0

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that sounds correct, thanks!

}
],
"source": [
"codes_to_simulate = [codes.SurfaceCode(dist, rotated=True) for dist in (3, 5, 7)]\n",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An error comes up here

     89 # collect statistics for Z and X logicals (which are corrupted, respectively, by X and Z errors)
---> 90 stats_z = run_memory_experiments(
     91     codes_to_simulate, Pauli.Z, error_rates, max_shots, max_errors, distance_trials, **decoding_kwargs
     92 )
     93 stats_x = run_memory_experiments(
     94     codes_to_simulate, Pauli.X, error_rates, max_shots, max_errors, distance_trials, **decoding_kwargs
     95 )
...
  File "/home/tom/qLDPC/.venv/lib/python3.11/site-packages/sinter/_data/_anon_task_stats.py", line 37, in __post_init__
    assert isinstance(self.errors, int)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^

After some investigation I think this is a sinter/numpy 2.x issues where numpy 2.x np.count_nonzero() returns numpy.int64 and isinstance(numpy.int64, int) is false in numpy 2.x so sinter's assert isinstance(self.errors, int) returns false. @perlinm do you know if we can force numpy<2.0

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this by chance related to this: quantumlib/Stim#961 ?

If so, we can update Stim requirements to 1.16 when it's released

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, it's exactly that. Is there an expected timeline for 1.16? If not, we can ask @Strilanc for a version bump.

decoders. Default: False (unless decoding with MWPM).
**decoder_kwargs: Arguments to pass to qldpc.decoders.get_decoder when compiling a
custom decoder from a detector error model.
"""
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest adding parameter validation

Suggested change
"""
"""
if window_size <= 0:
raise ValueError("window_size must be positive")
if stride <= 0:
raise ValueError("stride must be positive")
if stride > window_size:
raise ValueError("stride must be less than or equal to window_size")

Comment on lines +577 to +579
if not self.detector_to_time:
dem_coords = dem.get_detector_coordinates()
self.detector_to_time = lambda det: int(dem_coords[det][0])
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error prone if called with with different DEMs will use the first DEM's coordinates since self.detector_to_time is set.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you clarify what you mean?

Or: are you referring to the possibility that a detector_to_time passed to SlidingWindowDecoder.__init__ is incompatible with a dem passed to SlidingWindowDecoder.compile_decoder_for_dem? If so, perhaps we could validate with something like

if self.detector_to_time is not None and not all(
    isinstance(self.detector_to_time(det), int) for det in range(dem.num_detectors)
):
    raise ValueError(...)

qubit_ids: QubitIDs | None = None,
syndrome_measurement_strategy: SyndromeMeasurementStrategy = EdgeColoring(),
) -> stim.Circuit:
f"""Construct a circuit for testing the performance of a code as a quantum memory.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unused f

Suggested change
"""Construct a circuit for testing the performance of a code as a quantum memory.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the string formatting is used later in the comment block for DEFAULT_IMMUNE_OP_TAG.

*,
qubit_ids: QubitIDs | None = None,
syndrome_measurement_strategy: SyndromeMeasurementStrategy = EdgeColoring(),
) -> tuple[stim.Circuit, stim.Circuit, stim.Circuit, MeasurementRecord, DetectorRecord, QubitIDs]:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest using a data class for such complex return types e.g.

@dataclasses.dataclass
class MemoryExperimentParts:
    initialization: stim.Circuit
    qec_cycle: stim.Circuit
    readout: stim.Circuit
    measurement_record: MeasurementRecord
    detector_record: DetectorRecord
    qubit_ids: QubitIDs

construct the syndrome for segment S_2, namely s_2 = s_1 + H @ e_1. More generally, the
syndrome for segment S_(j+1) is s_(j+1) = s_j + H @ e_j = s_1 + H @ sum_(k=1)^j e_k. After
decoding all segments, the net error sum_(j=1)^n e_j is used to predict observable flips.
A SequentialWindowDecoder splits a detector error model into (possibly overlapping) "windows".
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be a nice place to draw a diagram in the docstring as is done in other parts of qldpc, here's a sample I made with claude if you guys like it

      Detectors: -------------------------------------------------------------->

      Window i:           [ ........... Detection Region ........... ]
                          [ Commit ]
                              |
                              +---> 1. Decode errors in Detection Region.
                                    2. Commit to errors in Commit Region.
                                    3. Update syndromes in future windows
                                       based on committed errors.
                                    4. Slide window forward.
                                              |
                                              v
      Window i+1:                   [ ........... Detection Region ........... ]
                                    [ Commit ]
                                        |
                                        v
                                       ...

Returns:
A Stim circuit of OBSERVABLE_INCLUDE instructions.
"""
assert basis is Pauli.X or basis is Pauli.Z or basis is None
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor comment: this assert and most others on input validation should be a ValueErrors

matrix_x = code.field.Zeros((0, len(code))) if basis is Pauli.Z else code.matrix
code = codes.CSSCode(matrix_x, matrix_z)

if code.is_subsystem_code:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should add an issue to work on this. Would be great to try and run SHYPS codes with these new features. @jviszlai probably has the best idea of what would need to be done, something on the order of constructing gauge check detectors and finding the classical mapping from those to the stabilizer detectors.

Copy link
Collaborator

@perlinm perlinm Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an issue for this: #348. I would certainly appreciate help with it! The problem may be trickers than it seems at a glance.

(Thinking out loud:) The subsystem code case requires careful choice of both:

  1. The measurement schedule, which is tricky because simply measuring all gauge operators in a random order may not provide enough information to construct a complete set of detectors (which can diagnose all detectable/correctable errors).
  2. The definition of detectors, which generally depends on the measurement schedule. This choice has no analog for "ordinary" (Abelian) stabilizer codes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the most straightforward subsystem schedule is alternating all Z gauges in one round and then all X gauges in the next round. The detectors would then be the parity of all gauges in each stabilizer between the two rounds they were measured. I agree though that other measurement schedules could perform better and would admit different detectors. Perhaps this is a similar situation to the different options for SyndromeMeasurementStrategy?

In terms of implementing, I think we would need to either generalize the way detectors are constructed in qldpc.circuits.memory and create new subsystem-specific SyndromeMeasurementStrategy or just create separate functionality for subsystem codes entirely.

@jviszlai
Copy link
Collaborator Author

jviszlai commented Jan 9, 2026

Which is the desired behavior? And is it easy to understand why the remainder is treated the way it is? If so, I'd like to explain this in the docstring.

I think we can choose either behavior. If choosing the smaller remainder preserves the logical error rate I'd vote for that. I think my original choice of the larger remainder was to cautiously keep > d rounds per window, but this might be unnecessary given the last round is reconstructed syndromes from data measurements.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants