extend-new-decoder · git:20260603.dbc60ac · 2026-06-03 · sha256 1998114e940fff02

extend-new-decoder git:20260603.dbc60acA

Immutable. This exact content is served forever at /api/v1/blob/1998114e940fff02.

---
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 small registry of `sinter.Decoder`
subclasses. Each decoder is registered under a name + backend (`cpu`,
`gpu`, or `fpga`), then `DecoderConfig(name="…", backend="…", params={})`
looks it up at runtime.

Adding a decoder is **always one new file** in
`lightstim/simulation/decoder_backend/decoders/`, plus **one line** in
`decoders/__init__.py` to soft-import it, plus **one smoke test** in
`tests/test_simulation_backend_quality.py`.

This skill walks through three real patterns in the repo, ordered by
complexity. Pick the one that matches your situation.

---

## The contract

Every decoder must subclass `sinter.Decoder` and implement
`compile_decoder_for_dem`. That method takes a `stim.DetectorErrorModel`
and returns a `sinter.CompiledDecoder`:

```python
import sinter
import stim

class MyDecoder(sinter.Decoder):
    def compile_decoder_for_dem(
        self, *, dem: stim.DetectorErrorModel
    ) -> sinter.CompiledDecoder:
        ...   # one-time prep per DEM
```

The compiled decoder must implement
`decode_shots_bit_packed(*, bit_packed_detection_event_data: np.ndarray)
-> np.ndarray` — bit-packed in, bit-packed out:

```python
class _MyCompiledDecoder(sinter.CompiledDecoder):
    def decode_shots_bit_packed(
        self, *, bit_packed_detection_event_data: np.ndarray
    ) -> np.ndarray:
        # input  shape: (n_shots, ceil(n_detectors / 8)), uint8, LSB-first
        # output shape: (n_shots, ceil(n_observables / 8)), uint8, LSB-first
        ...
```

If your underlying library already implements `sinter.Decoder` and
`sinter.CompiledDecoder` correctly (e.g. `stimbposd.SinterDecoder_BPOSD`,
`mwpf.SinterMWPFDecoder`), **you don't need to write a CompiledDecoder
yourself** — see Pattern A.

---

## Pattern A: thin wrapper around an existing `sinter.Decoder`

**When to use**: the upstream library already implements `sinter.Decoder`.
You just want it registered under a LightStim name.

**Real example**: `lightstim/simulation/decoder_backend/decoders/mwpf.py`
(30 lines including imports). It's literally:

```python
from ..registry import register_decoder

try:
    from mwpf import SinterMWPFDecoder
    _AVAILABLE = True
except ImportError:
    _AVAILABLE = False

if _AVAILABLE:
    register_decoder("mwpf", SinterMWPFDecoder, aliases=[])
```

That's it. No subclass needed.

---

## Pattern B: wrapper with parameter translation

**When to use**: the upstream library implements `sinter.Decoder`, but its
parameter names don't match LightStim's user-facing conventions, or you
want to set defaults.

**Real example**:
`lightstim/simulation/decoder_backend/decoders/bposd.py`. The user passes
`max_iterations`, `bp_method='min_sum'`, etc., but `stimbposd`'s API uses
`max_bp_iters` and `bp_method='minimum_sum'`. The wrapper translates:

```python
class BpOsdCpuDecoder(sinter.Decoder):
    def __init__(self, **params):
        translated = _unified_to_cpu({**_DEFAULTS, **params})
        self._inner = SinterDecoder_BPOSD(**translated)

    def compile_decoder_for_dem(self, *, dem):
        return self._inner.compile_decoder_for_dem(dem=dem)

if _BPOSD_AVAILABLE:
    register_decoder("bposd", BpOsdCpuDecoder, aliases=["bp_osd"], backend="cpu")
```

Key things to copy from this pattern:
1. `__init__(self, **params)` — accept arbitrary kwargs so users can pass
   anything through `DecoderConfig(params={...})`.
2. `_DEFAULTS` dict at module level — defines sensible defaults; user
   params override them via dict-merge.
3. A `_translate(params: dict) -> dict` function — rename keys, normalize
   case, drop irrelevant ones.
4. Hold the wrapped decoder in `self._inner` and delegate
   `compile_decoder_for_dem` to it.

---

## Pattern C: custom decoder from a DEM matrix

**When to use**: the upstream library is **not** sinter-compatible (most
research-paper decoders, GPU libraries, neural networks). You need to
parse the DEM into matrices yourself and write the decode loop.

**Real example**:
`lightstim/simulation/decoder_backend/decoders/cudaqx.py` — wraps NVIDIA's
`cudaq_qec` GPU decoder, which takes raw H matrices, not stim DEMs.

The pattern has three parts:

### 1. DEM → matrices

Convert a `stim.DetectorErrorModel` into the (H, observable matrix,
priors) triple your decoder needs. Steal this verbatim from `cudaqx.py`
(`_dem_to_matrices`); it handles flattened DEMs, detector targets,
observable targets, and decomposed errors.

```python
def _dem_to_matrices(dem: stim.DetectorErrorModel):
    """Returns (H, obs_matrix, priors) — see cudaqx.py for full impl."""
    n_dets = dem.num_detectors
    n_obs = dem.num_observables
    error_cols = []
    for inst in dem.flattened():
        if inst.type != "error": continue
        p = inst.args_copy()[0]
        dets, obs = [], []
        for t in inst.targets_copy():
            if t.is_relative_detector_id():    dets.append(t.val)
            elif t.is_logical_observable_id(): obs.append(t.val)
        error_cols.append({"p": p, "dets": dets, "obs": obs})
    n_err = len(error_cols)
    H   = np.zeros((n_dets, n_err), dtype=np.uint8, order="C")
    obs = np.zeros((n_obs,  n_err), dtype=np.uint8, order="C")
    p   = np.zeros(n_err, dtype=np.float64)
    for e, col in enumerate(error_cols):
        p[e] = col["p"]
        for d in col["dets"]: H[d, e] = 1
        for o in col["obs"]:  obs[o, e] = 1
    return H, obs, p
```

### 2. Custom `CompiledDecoder`

Holds your already-prepared decoder instance + the observable matrix.
Implements the unpack → decode → pack loop:

```python
class _MyCompiledDecoder(sinter.CompiledDecoder):
    def __init__(self, inner, obs_matrix, n_detectors):
        self._inner = inner
        self._obs_matrix = obs_matrix
        self._n_dets = n_detectors

    def decode_shots_bit_packed(self, *, bit_packed_detection_event_data):
        # 1. Unpack syndromes: bit_packed → (n_shots, n_dets) uint8
        syndromes = np.unpackbits(
            bit_packed_detection_event_data, axis=1, bitorder="little"
        )[:, : self._n_dets]
        # 2. Call your underlying decoder shot-by-shot or batched
        predictions = self._inner.decode_batch(syndromes)  # (n_shots, n_err) uint8
        # 3. Compute which observables flipped: obs_flip = predictions @ obs_matrix.T  mod 2
        obs_flips = (predictions @ self._obs_matrix.T) & 1
        # 4. Pack back: (n_shots, n_obs) → bit-packed uint8
        return np.packbits(obs_flips.astype(np.uint8), axis=1, bitorder="little")
```

### 3. Top-level `sinter.Decoder`

Calls `_dem_to_matrices`, constructs the inner decoder, returns the
`CompiledDecoder`:

```python
class MyDecoder(sinter.Decoder):
    def __init__(self, **params):
        self._params = params

    def compile_decoder_for_dem(self, *, dem):
        H, obs_matrix, priors = _dem_to_matrices(dem)
        inner = my_lib.Decoder(H=H, priors=priors, **self._params)
        return _MyCompiledDecoder(inner, obs_matrix, dem.num_detectors)
```

---

## Registration

After your decoder file is written, add one call at module bottom:

```python
register_decoder(
    name="my-decoder",          # canonical name; user types DecoderConfig(name="my-decoder")
    decoder_class=MyDecoder,
    aliases=["my_dec", "mwpm2"],  # optional; excluded from list_decoders()
    backend="cpu",              # or "gpu" or "fpga"
)
```

A single name can have multiple backends. E.g. `cudaqx.py` registers both
the GPU `nv-qldpc-decoder` name and a `bposd` `backend="gpu"` override, so
`DecoderConfig(name="bposd", backend="cpu")` hits the CPU implementation
and `backend="gpu"` hits the GPU one.

---

## Wire it into discovery

`lightstim/simulation/decoder_backend/decoders/__init__.py` does
**soft imports** so missing libraries don't crash the registry. Follow
the existing pattern — add your module behind an `importlib.util.find_spec`
guard:

```python
# In decoders/__init__.py, append:
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)
else:
    _log.debug("my_lib not installed; skipping my-decoder")
```

This is mandatory. If you `import` your module unconditionally and the
underlying library isn't installed, every LightStim user will hit an
`ImportError` at startup.

---

## Smoke test

Add to `tests/test_simulation_backend_quality.py`. The minimum bar: your
decoder must appear in `list_decoders()` and successfully decode the
trivial single-qubit observable circuit defined at the top of that file.

```python
def test_my_decoder_registered_and_runs():
    from lightstim.simulation.decoder_backend import list_decoders
    assert "my-decoder" in list_decoders()

    pipeline = SimulationPipeline(
        decoder_config=DecoderConfig("my-decoder"),
        max_shots=100,
        max_errors=1,
        batch_size=50,
        num_workers=1,
        print_progress=False,
    )
    stats = pipeline.run(_simple_observable_circuit(error_probability=0.1))
    assert stats.shots > 0
```

If your decoder needs an optional dependency, gate the test:

```python
my_lib = pytest.importorskip("my_lib")
```

so CI doesn't fail when the library isn't installed.

---

## Where things live (quick reference)

| File | What it is |
|---|---|
| `lightstim/simulation/decoder_backend/registry.py` | The registry — `register_decoder()`, `get_decoder()`, `list_decoders()` |
| `lightstim/simulation/decoder_backend/decoders/__init__.py` | Soft-import dispatcher — add your import here |
| `lightstim/simulation/decoder_backend/decoders/pymatching.py` | **Pattern A reference** (30 lines, simplest) |
| `lightstim/simulation/decoder_backend/decoders/mwpf.py` | **Pattern A reference** (no subclass at all) |
| `lightstim/simulation/decoder_backend/decoders/bposd.py` | **Pattern B reference** — param translation |
| `lightstim/simulation/decoder_backend/decoders/cudaqx.py` | **Pattern C reference** — DEM matrix + custom CompiledDecoder + GPU |
| `lightstim/simulation/decoder_backend/pipeline.py` | Consumer of the registry; you shouldn't need to touch it |
| `lightstim/simulation/decoder_backend/config.py` | `DecoderConfig` dataclass |
| `tests/test_simulation_backend_quality.py` | Decoder smoke tests live here |

---

## Gotchas

### 1. Bit-packing is little-endian (LSB first)
`bit_packed_detection_event_data` and the predictions you return both
use `np.unpackbits(..., bitorder="little")` / `np.packbits(...,
bitorder="little")`. Use big-endian by accident and every observable
prediction will be wrong but the shape will be right — a silent
correctness bug.

### 2. C-contiguous matrices for GPU
Pattern C constructs `H` and `obs_matrix` with `order="C"`. Most C++/CUDA
extensions assume row-major; passing Fortran-ordered arrays silently
transposes the decode and produces nonsense predictions.

### 3. GPU decoders must use `num_workers=1`
`cudaq_qec` decoders pre-allocate GPU memory in `compile_decoder_for_dem`.
Running with `num_workers > 1` either OOMs or returns garbage. Document
this in your decoder's docstring and in the smoke test.

### 4. `sinter.collect` is bypassed for GPU
If your decoder needs GPU resources, `SimulationPipeline` already handles
this — see the custom decode loop in `pipeline.py` and `worker.py`. You
don't need to do anything special, but **do not** rely on
`sinter.collect` semantics in your `compile_decoder_for_dem`.

### 5. Soft-import discipline
Inside your decoder file, the `try: import upstream_lib` block must
catch `ImportError`. Use `pytest.importorskip` in tests. Otherwise a user
without your library gets a hard import failure when they call
`SimulationPipeline(...)`, not your decoder.

### 6. Post-selection: usually free
If your decoder just consumes syndromes and emits predictions,
post-selection (state injection, distillation) works automatically — the
pipeline filters shots before they reach you. If you need to inspect the
post-selection mask, see `lightstim/simulation/decoder_backend/post_select.py`.

---

## End-to-end checklist

Before opening a PR:

- [ ] One new file in `lightstim/simulation/decoder_backend/decoders/`.
- [ ] `register_decoder(name, cls, aliases=[...], backend="…")` at module bottom.
- [ ] Soft-import hook added to `decoders/__init__.py` behind a
      `importlib.util.find_spec` guard.
- [ ] Smoke test added to `tests/test_simulation_backend_quality.py` that
      verifies registration and decodes the trivial circuit.
- [ ] CI passes: `venv/bin/python -m pytest tests/ -m "not slow" -q`.
- [ ] If the decoder needs special hardware (GPU/FPGA), document the
      `num_workers` constraint in the docstring.
- [ ] If your decoder needs new parameters in `DecoderConfig.params`,
      document them in `lightstim/simulation/README.md` (the decoder
      backend reference).

---

## Working examples

The simplest possible decoder to extend from is **PyMatching** — read
`lightstim/simulation/decoder_backend/decoders/pymatching.py` end to end
(it's 56 lines including imports). For parameter handling, read
`bposd.py`. For an end-to-end custom decoder with DEM parsing and GPU
integration, read `cudaqx.py`.

Once your decoder is registered, it's usable from anywhere:

```python
from lightstim.simulation.decoder_backend import SimulationPipeline, DecoderConfig

pipeline = SimulationPipeline(
    decoder_config=DecoderConfig(
        name="my-decoder",
        backend="cpu",
        params={"max_iterations": 500},
    ),
    max_shots=100_000,
    max_errors=200,
    num_workers=4,
)
stats = pipeline.run(noisy_circuit)
print(f"LER: {stats.logical_error_rate:.3e}")
```

No other code changes are required. The HTTP server, notebooks, and
benchmark scripts all go through `DecoderConfig`, so they pick up your
new decoder by name with no further plumbing.