Add a New Decoder

SkillDev tools

Integrate a new decoder into LightStim's decoder backend so it works with `SimulationPipeline` and `DecoderConfig`. Use this skill whenever the user asks to add a custom decoder, wrap a third-party decoder library (e.g. a neural decoder, a research-paper decoder), register a CPU or GPU decoder backend, build a decoder from scratch using a DEM, or extend the existing BPOSD/PyMatching/MWPF stack with a new variant.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Add a New Decoder skill

What this skill tells your AI

The instructions your AI receives, as published by qutone/lightstim in skills/extend-new-decoder/SKILL.md and read by ahel’s review.

LightStim's decoder backend is a registry of decoder classes. Register a class under a name + backend (cpu / gpu / fpga), and DecoderConfig(name="…", backend="…", params={…}) finds it at runtime — everywhere: pipeline, server, notebooks, benchmarks.

Adding a decoder is always three things:

  1. One new file in lightstim/simulation/decoder_backend/decoders/.
  2. One soft-import line in decoders/__init__.py.
  3. One smoke test in tests/test_simulation_backend_quality.py.

A runnable version of everything below (with live benchmarks) is tutorials/how-to-add-new-decoders.ipynb.

Which pattern?

SituationPattern
Upstream library ships a sinter.DecoderA — register the class directly
Anything elseC — subclass ExternalDecoder (recommended)
You need to own the unpack → decode → pack loop yourselfB — build from the DEM matrices

The contract (duck-typed)

The pipeline never checks isinstance(..., sinter.Decoder). A decoder is any object with:

class MyDecoder:                       # sinter.Decoder base optional
    def compile_decoder_for_dem(self, *, dem: stim.DetectorErrorModel):
        return _MyCompiled(...)        # one-time prep per DEM

class _MyCompiled:                     # sinter.CompiledDecoder base optional
    def decode_shots_bit_packed(self, *, bit_packed_detection_event_data):
        # in:  (n_shots, ceil(n_detectors/8))   uint8, LSB-first
        # out: (n_shots, ceil(n_observables/8)) uint8, LSB-first
        ...

Subclassing the sinter bases is the repo convention and the documented API, but it is not a runtime requirement — raw sinter.collect accepts duck-typed classes too (verified on sinter 1.15/1.16). Matching this signature is why sinter-native decoders plug in unchanged.

Pattern C skips this contract entirely — the ExternalDecoder facade implements it for you; you only write methods on plain unpacked arrays.


Pattern A — sinter-native: register directly

# decoders/my_decoder.py
from ..registry import register_decoder

try:
    from mwpf import SinterMWPFDecoder
    register_decoder("mwpf", SinterMWPFDecoder)
except ImportError:
    pass

That's the entire built-in mwpf.py; relay_bp.py is the same shape. User params flow straight through DecoderConfig(params={…}) to the upstream class — don't add a translation layer for a single library.

Lazy variant (tesseract.py): when the upstream package is a native extension whose import can fail on a given host, don't import it at module load. Register a thin sinter.Decoder subclass that imports inside __init__ and delegates compile_decoder_for_dem to the upstream object, guarded by importlib.util.find_spec so a broken wheel breaks only DecoderConfig("tesseract") and not the whole registry. This also covers the case — as with Tesseract — where the upstream class merely duck-types the sinter interface instead of subclassing it.

Need renamed params or defaults? Give a thin wrapper class an __init__(**params) that merges defaults and renames keys before constructing the inner decoder, and delegate compile_decoder_for_dem to it. bposd.py does this to share one parameter vocabulary between its CPU (stimbposd) and GPU (cudaq_qec) backends.


Pattern C — ExternalDecoder facade (recommended for non-sinter)

Subclass, declare output_type, build in setup, implement one of decode_single / decode_batch. The facade owns bit-packing, the observable-matrix multiply, worker plumbing, and failure-flag routing.

import numpy as np
from ..external import ExternalDecoder
from ..registry import register_decoder

class MyDecoder(ExternalDecoder):
    output_type = "correction"   # REQUIRED, no default — see below

    def setup(self, *, H, obs_matrix, priors, num_detectors,
              num_observables, dem):
        # Called once per DEM. self.params holds DecoderConfig(params=...).
        # H / obs_matrix are scipy CSR; .toarray() only for small DEMs.
        self._inner = my_lib.Decoder(H, priors, **self.params)

    def decode_single(self, syndrome):        # (n_dets,) uint8, unpacked
        correction, converged = self._inner.run(syndrome)
        return correction, converged          # flag: True/None ok, False failed

register_decoder("my-decoder", MyDecoder, backend="cpu")

Three knobs:

  • output_type"correction": you return a correction over error mechanisms, length H.shape[1], and LightStim computes the observable flips. "observables": you return the logical flips directly, length n_obs. Forgetting it raises at compile time.
  • single vs batch — implement whichever is natural (decode_batch(syndromes) -> (preds, flags)); LightStim bridges the other.
  • failure flags — return False for shots the decoder failed on (e.g. BP non-convergence), True/None otherwise. DecoderConfig(on_decode_failure=...) decides what a False means: "error" (default, count as logical error), "discard" (herald it out of the denominator), "ignore" (trust the prediction).

What setup receives: the facade calls dem_to_matrices(dem, sparse=True, merge_duplicates=True), so H and obs_matrix are CSR and duplicate-footprint mechanisms are already merged — H.shape[1] can be smaller than dem.num_errors (gotcha 2).

Working examples: decoders/ldpc_bp.py (correction + real convergence flags), and the tests/test_simulation_backend_quality.py externals.


Pattern B — custom decoder from the DEM (full control)

Only when you must own the CompiledDecoder lifecycle (e.g. GPU memory management). Reference implementation: decoders/cudaqx.py.

Get matrices from the shared helper — never re-derive the conversion:

from ..dem_matrices import dem_to_matrices

H, obs_matrix, priors = dem_to_matrices(dem)              # dense uint8, C-order
H, obs_matrix, priors = dem_to_matrices(dem, sparse=True) # scipy CSR

Two behaviours it encodes that hand-rolled versions get wrong:

  • Parity, not assignment. stim cancels a target listed an even number of times (error(0.1) D0 D0 D1 flips only D1); the helper XORs. Writing H[d, e] = 1 yourself silently builds a wrong matrix.
  • merge_duplicates=True (default) changes the column count. Mechanisms with identical (detector, observable) footprints fuse into one column, priors combined with the XOR rule p1(1-p2) + p2(1-p1) — stim leaves such duplicates in z_only-style circuits and they measurably degrade BP. Pass merge_duplicates=False if you need DEM-column alignment.

Then write the compiled decoder: unpack syndromes (np.unpackbits(..., bitorder="little")[:, :n_dets]), decode, compute (predictions @ obs_matrix.T) & 1 if your decoder returns corrections, and np.packbits(..., bitorder="little") the result.


Ship it

Soft-import in decoders/__init__.py so missing libraries never break the registry:

if importlib.util.find_spec("my_lib") is not None:
    try:
        from . import my_decoder  # noqa: F401 — registers my-decoder/cpu
    except ImportError as exc:
        _log.debug("my_decoder import failed: %s", exc)

Smoke test in tests/test_simulation_backend_quality.py:

def test_my_decoder_registered_and_runs():
    importorskip_safe("my_lib")   # or pytest.importorskip
    assert "my-decoder" in list_decoders()
    stats = SimulationPipeline(
        decoder_config=DecoderConfig("my-decoder"),
        max_shots=200, max_errors=10_000, batch_size=100,
        num_workers=1, print_progress=False,
    ).run(_simple_observable_circuit(error_probability=0.1))
    assert stats.shots > 0

Run venv/bin/python -m pytest tests/ -m "not slow" -q before opening a PR, and document any new params in lightstim/simulation/README.md.


Gotchas

  1. Bit order is little-endian (LSB-first) for syndromes in and predictions out. Big-endian gives the right shapes and wrong answers. (Pattern C handles this for you.)
  2. H.shape[1] is the mechanism count, not dem.num_errors — duplicate merging can shrink it (a chen_p96 z_only DEM had ~36k duplicate columns). Sizing a "correction" output from the DEM gives wrong-length predictions.
  3. C-contiguous matrices for GPU/C extensionsdem_to_matrices dense output is order="C"; keep it that way or the decode silently transposes.
  4. GPU decoders need num_workers=1cudaq_qec pre-allocates GPU memory per compile; more workers OOM or corrupt results.
  5. Failure flags need SimulationPipeline — they ride an in-process side channel (compiled.last_flags), not the bit-packed return, so raw sinter.collect never sees them.

Where things live

FileWhat it is
decoder_backend/registry.pyregister_decoder(), get_decoder(), list_decoders()
decoder_backend/decoders/__init__.pysoft-import dispatcher — add your import here
decoder_backend/decoders/{mwpf,relay_bp}.pyPattern A references — direct registration
decoder_backend/decoders/tesseract.pyPattern A lazy variant — deferred native import
decoder_backend/decoders/bposd.pyPattern A + param translation
decoder_backend/decoders/cudaqx.pyPattern B reference (DEM matrices + GPU)
decoder_backend/external.pyPattern C — the ExternalDecoder facade
decoder_backend/decoders/ldpc_bp.pyPattern C reference (flags, serial BP)
decoder_backend/decoders/chain.pymulti-level chain over registered decoders
decoder_backend/dem_matrices.pydem_to_matrices(dem, *, sparse, merge_duplicates)
decoder_backend/pcm.pyolder dem_to_check_matrices — merges priors by summing; prefer dem_to_matrices
decoder_backend/config.pyDecoderConfig (incl. on_decode_failure)
tutorials/how-to-add-new-decoders.ipynbrunnable walkthrough of all three patterns

Signals

GitHub stars
46
Forks
6
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
extend-new-decoder
Source
github.com/qutone/lightstim