Skip to content

feat(bp): add BP+OSD decoder package and wire into pipeline dispatcher - #9

Merged
PlayerPlanet merged 3 commits into
mainfrom
feat/bp-decoder
Jun 6, 2026
Merged

PlayerPlanet merged 3 commits into
mainfrom
feat/bp-decoder

Conversation

@MillaSigrid

Copy link
Copy Markdown
Collaborator

Summary

Adds a bp/ package that decodes syndromes with belief propagation (BP+OSD) on the same stim DEM that MWPM uses. Mirrors the diffqec and mwpm pipeline contracts so all three decoders can be swapped through decode_hardware_results(decoder=...).

  • bp/integrate.py: dem_to_pcm(circuit) (stim DEM -> (H, O, channel_probs)), build_bp_decoder(circuit) (returns a BpOsdDecoder + observable matrix; amortize), decode_hardware_results_bp(syndromes, decoder=..., O=...) (decode + LER + soft confidence).
  • bp/__init__.py: re-exports.
  • bp/smoke_test.py: one-shot runnable with side-by-side BP-vs-MWPM comparison.
  • bp/README.md: usage + the BP-vs-BP+OSD caveat + comparison table.
  • run_on_hardware.py: fills the decoder="bp" branch in decode_hardware_results, adds two kwargs (bp_decoder, bp_O) for the LUMI-sweep case.
  • requirements.txt + pyproject.toml: add ldpc>=2.4 (BpOsdDecoder lives in v2.x).

Why BP+OSD, not plain BP

Vanilla loopy BP converges to a stable but sub-optimal fixed point on surface codes because of short cycles in the factor graph. Empirically 3-4x worse than MWPM at d=3, p=0.001. BP+OSD with osd_order=2 post-processes the BP result and matches MWPM exactly. This is the standard practical fix in the QEC literature; the convergence issue on qLDPC codes is documented in Yao et al. 2023 (arXiv:2312.10950). Set osd_order=0 to disable OSD and study the failure modes.

Why BP+OSD, not just MWPM

Same factor graph as MWPM, but with soft outputs: confidence[i, j] is P(observable_j = predicted_j | syndrome_i), derived from the LLR of the BP result projected onto the observable. This unlocks the analysis axes that the bonus criteria mention:

  • Per-shot confidence correlates with syndrome weight -> identify "hard shots" automatically.
  • Hard shots cluster spatially -> map them to chip regions via the qubit layout.
  • Hard shots cluster temporally -> correlate with calibration drift on Resonance.
  • Low-confidence filter on aggregation -> lower-variance LER estimate from the same data.

Verification

Smoke test (d=3, 3 rounds, p=0.001, 5000 shots, stim built-in):

  • BP-OSD LER: 0.0012 +/- 0.0005 (6/5000)
  • MWPM LER: 0.0012 +/- 0.0005 (6/5000)
  • Identical. Decode time: BP-OSD ~0.6s, MWPM ~0.01s (BP is per-shot Python loop; could be parallelised for sweeps).

The dispatcher (decode_hardware_results(decoder="bp", stim_circuit_noisy=...) and decoder="bp", bp_decoder=..., bp_O=...)) was also tested and returns the same LER for both kwargs. Unknown decoder raises ValueError.

On the diffqec mismatch

Like mwpm/, this package does not mirror diffqec's full file layout: there is no model, no training, no data.py, no soft confidence wrapper. Just two functions in one module. The README documents this in a comparison table.

Dependency chain

This PR is stacked on top of feat/mwpm-decoder (PR #6) since the run_on_hardware.py changes are layered. If PR #6 is merged first, this one can be rebased onto main cleanly.

from run_on_hardware import decode_hardware_results

# BP-OSD
ler, err = decode_hardware_results(syndromes, decoder="bp",
                                    stim_circuit_noisy=circuit)

# Pre-built decoder for sweeps
from bp.integrate import build_bp_decoder
bp_decoder, bp_O = build_bp_decoder(circuit)
ler, err = decode_hardware_results(syndromes, decoder="bp",
                                    bp_decoder=bp_decoder, bp_O=bp_O)

MillaSigrid and others added 3 commits June 6, 2026 11:06
Belief propagation on the surface-code detector graph, via the
`ldpc` library. Mirrors the public interface of the `mwpm` package
so the two decoders can be swapped through decode_hardware_results().

By default build_bp_decoder() returns an ldpc.BpOsdDecoder with
osd_order=2 (BP+OSD, the practical working point for QEC). Plain
loopy BP converges to a stable but sub-optimal fixed point on
surface codes because of short cycles; OSD post-processes the BP
result and matches MWPM exactly at d=3, p=0.001. Set osd_order=0
to disable OSD and study the failure modes.

Three public functions:
- dem_to_pcm(circuit)        -- stim DEM -> (H, O, channel_probs)
- build_bp_decoder(circuit)  -- returns (BpOsdDecoder, O); amortize.
- decode_hardware_results_bp(syndromes, decoder=..., O=...) -> (ler,
  err, confidence). Confidence is a soft LLR-derived indicator, not
  a calibrated posterior.

Smoke test (python -m bp.smoke_test) at d=3, 3 rounds, p=0.001,
5000 shots:
- BP-OSD LER = 0.0012 +/- 0.0005 (6/5000)
- MWPM  LER = 0.0012 +/- 0.0005 (6/5000)
- Identical.

The package intentionally does not mirror diffqec's full file
layout: there is no model, no training, no data.py, no soft
confidence wrapper. Just two functions in one module. The README
documents this in a comparison table, same as mwpm/README.md.
decode_hardware_results() now accepts decoder="bp". Two new kwargs
(bp_decoder, bp_O) let callers pre-build the BpOsdDecoder and
observable matrix, the LUMI-sweep pattern. The dispatcher
ValueError message is updated to list all three supported decoders.

ldpc>=2.4 is added to both requirements.txt and pyproject.toml; the
BpOsdDecoder class is in ldpc v2.x and not in v1.
@PlayerPlanet
PlayerPlanet merged commit d004c73 into main Jun 6, 2026
1 check passed
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.

2 participants