extend-new-decoder · git:20260808.cfe5e96 · 2026-08-08 · sha256 077d662c5be4a1fc

extend-new-decoder git:20260808.cfe5e96A

Immutable. This exact content is served forever at /api/v1/blob/077d662c5be4a1fc.

---
name: extend-new-decoder
description: >
  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.
user-invocable: true
---

# Add a New Decoder

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?

| Situation | Pattern |
|---|---|
| Upstream library ships a `sinter.Decoder` | **A** — register the class directly |
| Anything else | **C** — subclass `ExternalDecoder` (recommended) |
| You need to own the unpack → decode → pack loop yourself | **B** — build from the DEM matrices |

---

## The contract (duck-typed)

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

```python
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

```python
# 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.

```python
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:

```python
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:

```python
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`:

```python
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 extensions** — `dem_to_matrices` dense
   output is `order="C"`; keep it that way or the decode silently transposes.
4. **GPU decoders need `num_workers=1`** — `cudaq_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

| File | What it is |
|---|---|
| `decoder_backend/registry.py` | `register_decoder()`, `get_decoder()`, `list_decoders()` |
| `decoder_backend/decoders/__init__.py` | soft-import dispatcher — add your import here |
| `decoder_backend/decoders/{mwpf,relay_bp}.py` | Pattern A references — direct registration |
| `decoder_backend/decoders/tesseract.py` | Pattern A lazy variant — deferred native import |
| `decoder_backend/decoders/bposd.py` | Pattern A + param translation |
| `decoder_backend/decoders/cudaqx.py` | Pattern B reference (DEM matrices + GPU) |
| `decoder_backend/external.py` | Pattern C — the `ExternalDecoder` facade |
| `decoder_backend/decoders/ldpc_bp.py` | Pattern C reference (flags, serial BP) |
| `decoder_backend/decoders/chain.py` | multi-level chain over registered decoders |
| `decoder_backend/dem_matrices.py` | `dem_to_matrices(dem, *, sparse, merge_duplicates)` |
| `decoder_backend/pcm.py` | older `dem_to_check_matrices` — merges priors by **summing**; prefer `dem_to_matrices` |
| `decoder_backend/config.py` | `DecoderConfig` (incl. `on_decode_failure`) |
| `tutorials/how-to-add-new-decoders.ipynb` | runnable walkthrough of all three patterns |