Error Correction
qliff.qec turns a noisy Circuit into decoder inputs: detection events, a detector error model, and the parity-check / matching exports built from it. The exports drop straight into an external matching, belief-propagation or ML decoder. Ready-made code circuits give a logical-error-rate curve without writing a layout by hand.
Six decoders ship in qliff.qec.decoder, reached by make(name, target, max_bond=None, twirl=False), where target is a Circuit or a DetectorErrorModel:
| Name | What it is | Pauli only | Needs |
|---|---|---|---|
mwpm | minimum-weight perfect matching; graphlike models only | yes | pymatching |
bposd | belief propagation + ordered-statistics decoding | yes | ldpc |
mld | exact max-likelihood tensor-network contraction | no | - |
tn | the same contraction, truncatable via max_bond | no | - |
coherent | non-Pauli / coherent TN, contracted on the circuit | no | - |
color | exact min-weight search; takes color-code hyperedges | yes | - |
qliff itself requires only numpy. mwpm and bposd import pymatching and ldpc lazily, so neither is paid for until asked for.
Code circuits
Each generator returns a Circuit under data noise (channel="DEPOLARIZE1" by default) with its detectors and logical observables already declared. The surface, toric and color logicals are derived from the checks and weight-reduced: the same logical class as a boundary line, but the observable's qubit support need not be a border line.
| Generator | Arguments | Description |
|---|---|---|
repetition_code(distance, rounds, p) | code distance, syndrome rounds, per-round noise | bit-flip repetition-code |
rotated_surface_code(distance, rounds, p) | distance, rounds, depolarizing | rotated planar surface-code rows, cols, rounds, p for a rectangle) |
unrotated_surface_code(distance, rounds, p) | distance, rounds, depolarizing | unrotated (CSS) planar surface-code memory |
toric_code(distance, rounds, p) | distance, rounds, depolarizing | periodic toric-code memory |
hex_color_code(distance, rounds, p) | distance, rounds, depolarizing | 6.6.6 honeycomb color code |
triangular_code(distance, rounds, p) | distance, rounds, depolarizing | triangular-axis color/surface family |
kagome_code(distance, rounds, p) | distance, rounds, depolarizing | kagome-axis color/surface family |
from qliff.qec import repetition_code, rotated_surface_code, toric_code, hex_color_code
rep = repetition_code(distance=3, rounds=3, p=0.05)
sur = rotated_surface_code(3, 3, 0.01) # distance, rounds, p (square)
tor = toric_code(distance=4, rounds=4, p=0.01)
col = hex_color_code(distance=3, rounds=3, p=0.01)Memory basis
Every family whose code has an X/Z dual takes memory="Z" (the default) or memory="X". The knob picks which checks are decoded round to round and which logicals are tracked; the two bases are genuine duals, not a relabel.
z_mem = rotated_surface_code(3, 3, 0.01, channel="X_ERROR") # sees X noise
x_mem = rotated_surface_code(3, 3, 0.01, channel="Z_ERROR", memory="X") # sees Z noiseIt is available on rotated_surface_code, unrotated_surface_code, toric_code, color_code and its hex_color_code / triangular_code / kagome_code partials, build_circuit, bacon_shor_code, hypergraph_product_code, bivariate_bicycle_code, and from_stabilisers / from_gauges (module-level or as Circuit static methods, which forward every argument). repetition_code has none: it declares only
Some families are each other's dual rather than self-dual. triangular_code and kagome_code are medial duals with distance=3, so triangular's X-memory under
WARNING
A memory cannot see noise that commutes with its own basis. A Z_ERROR, RZ, or a PAULI_CHANNEL_1 with only
Surface variants
rotated_surface_code and unrotated_surface_code take these layout knobs beyond memory. Each selects a variant of the same family, not a different code.
| Knob | Values | Effect |
|---|---|---|
pattern | "css" (default), "xzzx" | "xzzx" puts one data sublattice in the Hadamard frame |
start | "Z" (default), "X" | recolours the check checkerboard |
edge | "even" (default), "odd" | which alternating boundary-edge set becomes stabilisers; rotated family only |
deform | {qubit: "I" | "H" | "H_YZ"} | explicit per-qubit Clifford frame, replacing pattern |
prep | False (default), True | prepend one noiseless extraction round, so the first noise layer hits a code state |
prep also exists on toric_code and color_code (and its partials). unrotated_surface_code has no edge: the standard layout promotes every star and plaquette, so it has no alternate boundary set. On rotated_surface_code every knob, channel included, is keyword-only; on unrotated_surface_code, prep, memory, deform, bias and bias_axis are, and on toric_code, prep, bias and bias_axis.
edge is an orientation, not a second code
edge="even" and edge="odd" are the same code read along the two grid directions. Transposing a rows x cols patch built with edge="even" reproduces the cols x rows patch built with edge="odd" exactly, as a pure qubit permutation
The two check sublattices are not read in the same order. One reads its weight-4 face in N order (NW, SW, NE, SE) and the other in Z order (NW, NE, SW, SE), so the two ladders are transposes of each other. ("N order" and "Z order" are this project's own shorthand for the two ways of tracing the four corners, not established terms.) Which sublattice is the transposed one depends on the edge axis, because edge is what orients the logical.
The reason is the hook error. A fault midway through an ancilla's CX ladder propagates to a weight-2 data error, and that error has to run perpendicular to the logical. Read both sublattices in the same order and the hook runs parallel to it instead: the circuit-level distance falls while the code distance is unchanged. Under data-only noise there are no ancilla faults to propagate, so the failure is invisible there.
What the knob does select on a rectangle is which grid direction the protected logical runs along, and therefore the distance that limits failure:
edge | tracked logical runs along | its weight | |
|---|---|---|---|
"even" | a column | rows | cols |
"odd" | a row | cols | rows |
rotated_surface_code(2, 12, 3, 0.01) # edge="even": d_fail = 12
rotated_surface_code(12, 2, 3, 0.01, edge="odd") # the same circuit, transposedOn a square patch the two are the same circuit. On a rectangle, pick the one that runs the logical along the direction to be protected; the splitting estimator's measured slopes track
Noise bias
Every code builder takes bias and bias_axis alongside channel and p. bias is the bias_axis is "X", "Y" or "Z".
rotated_surface_code(5, 5, 0.01, bias=100.0, bias_axis="X", memory="Z")
rotated_surface_code(5, 5, 0.01, bias=100.0, bias_axis="Z", memory="X") # same DEMbias / bias_axis reach rotated_surface_code, unrotated_surface_code, repetition_code, toric_code, color_code and its partials, build_circuit, from_stabilisers, from_gauges, bacon_shor_code, hypergraph_product_code and bivariate_bicycle_code.
Two constraints:
- The axis is a knob because
and are exact duals. Swapping bias_axisandmemorytogether gives a bit-identical detector error model. A-locked bias made that experiment unreachable; with the axis free, a biased-noise sweep can be run in either basis and cross-checked against the other. biasshapes the 1-qubit Pauli channels only. There is no canonical two-qubit lift of a one-qubit: the tensor square of a biased 1Q channel does not reduce to DEPOLARIZE2at. Passing biaswith a two-qubit channel raises and names the dict form instead of quietly meaning something else.
A memory is blind to noise along its own axis, so bias_axis="Z" with the default memory="Z" drives the reported rate to zero at any qliff warns rather than reporting the zero silently (see the memory basis above).
Custom codes from stabilisers
Circuit.from_stabilisers(stabilisers, observables=None, *, rounds=1, channel="DEPOLARIZE1", p=0.0, bias=None, bias_axis="Z", memory="Z") builds a full-memory circuit for any stabiliser code -- CSS or not -- from its Pauli stabilisers alone. Give each stabiliser as a Pauli string "XZZXI" or a per-qubit letter list ["X", "Z", "Z", "X", "I"] (case and a leading +/- are ignored). Every check --
The memory opens with one noiseless extraction round and ends, as stim's generated memories do, with a transversal readout of the data qubits. Each qubit's readout basis is derived from the logicals on it; the observables come from that readout, and a final detector closes only for a stabiliser diagonal in those bases. When two logicals need different bases on one qubit, the memory instead ends with a noiseless closing round and reads each logical onto its own ancilla. from_gauges, bacon_shor_code and the qLDPC builders end the same way.
from qliff.circuit import Circuit
# the five-qubit perfect code [[5,1,3]] -- cyclic shifts of XZZXI (non-CSS)
stabs = ["XZZXI", "IXZZX", "XIXZZ", "ZXIXZ"]
five = Circuit.from_stabilisers(stabs, rounds=1, p=0.03)
# per-qubit letters are equivalent; observables= sets the tracked logical
Circuit.from_stabilisers([["Z", "Z", "I"], ["I", "Z", "Z"]]) # repetition codeDecoding every check makes the error model dense (usually non-graphlike), so pair it with BP+OSD or the exact ML decoder rather than MWPM.
Subsystem (gauge) codes
Circuit.from_gauges(gauges, observables=None, *, rounds=1, channel="DEPOLARIZE1", p=0.0, bias=None, bias_axis="Z", memory="Z") builds a memory for a subsystem code from its non-abelian gauge group. Unlike from_stabilisers, the generators may (and should) anticommute: each round measures the low-weight gauge operators, and the protected stabilisers -- the centre of the gauge group -- are decoded from the products of gauge outcomes that reconstruct them. The schedule is checked with qec.check_detectors(circuit, observables=True) before the circuit is returned. The named helper bacon_shor_code(distance) generates the
from qliff.circuit import Circuit
from qliff.qec import bacon_shor_code
# distance-3 Bacon-Shor: weight-2 XX / ZZ gauges, [[9,1,3]] stabiliser centre
bacon = bacon_shor_code(3, rounds=1, p=0.03)
# or from raw gauge generators (here two weight-2 checks that anticommute)
Circuit.from_gauges(["XXI", "IZZ"], rounds=2, p=0.02)Gauge codes decode every centre check, so their error models are dense; use bposd or the ML/TN decoder, not MWPM.
channel may be non-Pauli here, as on the template families:
Circuit.from_gauges(["XXI", "IZZ"], rounds=2, p=0.02, channel="AMPLITUDE_DAMP")
bacon_shor_code(3, rounds=1, p=0.02, channel="RZ")qLDPC codes
Quantum LDPC codes are sparse CSS stabiliser codes, so they run through from_stabilisers once their checks are generated. Two builders ship for the standard constructions:
from qliff.qec import hypergraph_product_code, bivariate_bicycle_code
# hypergraph product of two classical parity checks (rings -> toric code)
ring = [[1, 1, 0], [0, 1, 1], [1, 0, 1]]
toric = hypergraph_product_code(ring, ring, rounds=2, p=0.03)
# bivariate-bicycle family; the defaults at l = m = 6 are the [[72,12,6]] code
bb72 = bivariate_bicycle_code(6, 6, rounds=1, p=0.01) # k = 12
# [[144,12,12]] gross code: l=12, m=6 with the same default polynomials
big = bivariate_bicycle_code(12, 6, rounds=1, p=0.01)Both are high-rate and non-graphlike; decode with bposd.
You can also declare a code by hand on any circuit with c.detector(*recs) and c.observable(index, *recs), using stim's rec[-1] negative indexing into the measurement record (see Circuit).
DetectorSampler
circuit.detector_sampler() returns a DetectorSampler(circuit) whose sample(shots, seed=None) yields two numpy uint8 arrays: the syndrome and the logical labels. A detection event is a detector's measured parity XORed with its noiseless value, so a clean run produces all-zero syndromes. sample is Pauli-only and raises ValueError on other noise, as does the constructor on a detector whose noiseless parity is random (pass the keyword-only allow_gauge_detectors=True to accept one).
| Output | Shape | Meaning |
|---|---|---|
dets | (shots, n_detectors) | detection events |
obs | (shots, n_observables) | logical-observable flips (the labels) |
dets, obs = rep.detector_sampler().sample(10000, seed=0)These feed a decoder directly, or serve as a training set for an ML decoder.
| Sampler | Returns | Noise |
|---|---|---|
DetectorSampler(circuit).sample(shots, seed) | (dets, obs) | Pauli only |
WeightedDetectorSampler(circuit).sample(shots, seed) | (dets, obs, weights) | any; weights are signed importance weights |
DetectorSampler(circuit).sample_strata(counts, seed) | (dets, obs, signs, strata) | any; counts is {k: shots} over fault counts |
Both constructors take an optional simulator(num_qubits, seed) factory, which moves sampling onto per-shot Python trajectories (the leakage sampler is one).
DetectorErrorModel
circuit.dem() builds the error model by propagating each Pauli fault branch sign-free to the end of the circuit, recording which detectors and observables it flips. Mechanisms with identical signatures merge as independent errors. It is exact for Pauli noise and raises ValueError on any other channel (decode those with mld, tn or coherent, or pass qec.twirl(circuit)).
| Property/Method | Returns | Description |
|---|---|---|
mechanisms | list | (probability, detectors, observables) per mechanism |
check_matrix() | (H, priors, obs_matrix) | H (detectors x mechanisms), priors, observable matrix -- for BP |
weights(clip=0.0) | ndarray | per-mechanism MWPM weights |
max_degree(), is_graphlike() | int, bool | most detectors one mechanism flips; whether that is |
graphlike_edges() | list | mechanisms flipping (dets, obs, weight) -- a matching graph |
Decoding and logical error rate
Wire the exports into pymatching, decode the sampled syndromes, and compare to the labels. logical_fidelity(predictions, observed) returns
from pymatching import Matching
from qliff.qec import repetition_code, logical_fidelity
rep = repetition_code(distance=3, rounds=3, p=0.05)
dem = rep.dem()
H, priors, obs_matrix = dem.check_matrix()
matching = Matching.from_check_matrix(
H, weights=dem.weights(), faults_matrix=obs_matrix
)
dets, obs = rep.detector_sampler().sample(20000, seed=0)
predicted = matching.decode_batch(dets)
fidelity = logical_fidelity(predicted, obs) # 1 - logical error rateBelow threshold, the logical error rate falls as distance grows -- the signature of working error correction. logical_error_rate and sweep give the numbers with Wilson intervals across distances and rates.
NOTE
The detector error model and DetectorSampler.sample are Pauli-only. For logical error rates under coherent or damping noise, use logical_error_rate below, which samples with WeightedDetectorSampler, or estimate the logical observable directly with the importance sampler.
Below the direct-MC floor -- roughly SplittingEstimator reaches those rates on a fixed decode budget, at the cost of being Pauli-only: see rare-event splitting.
Reading the reported rate
qec.threshold.logical_error_rate(circuit, decoder="mwpm", shots=10000, seed=None, max_bond=None, stratify=False) returns (rate, stderr); a list of decoders is scored on one shared batch and returns a list. sweep / isweep repeat it across physical rates. Under non-Pauli noise the estimator is a signed quasiprobability average, so:
- the rate can legitimately be negative. It is returned raw, because clamping it destroys unbiasedness. Use
qec.threshold.clamped_rate(rate, stderr)for a display view, and never average or fit clamped values. - the error bar is wide, and that is honest. Every shot carries the same weight magnitude
, so the spread of the signed contributions -- not a binomial count -- is what sets the uncertainty.
When a decoder is not decoding what you asked for
Three cases warn rather than returning a quiet number:
| Warning | Meaning |
|---|---|
TwirlWarning | make(name, circuit, twirl=True) built a Pauli-only decoder (mwpm, bposd, color) on non-Pauli noise, so it decodes the Pauli twirl of the circuit. The twirl throws away coherence, and the rate reported for it can be worse than predicting nothing. Without twirl=True the same request raises ValueError; logical_error_rate always passes twirl=True. |
CoherentDecoder exactness | the coherent decoder is exact for Pauli noise, and otherwise when no fault outlives the measurements, no branch reads a random record and no two noise locations couple. Outside that it warns, sets decoder.exactness = "approximate", and tells you to decode the twirl for a defensible number. |
decoder.unresolved | syndromes a decoder (for example the color min-weight search) could not explain. They are scored as no flip, but counted and warned about rather than passed over silently. |
make("mld", circuit) and make("tn", circuit) route to the coherent decoder automatically on non-Pauli noise; circuit.nonpauli() names the first non-Pauli noise location, or returns None.
NOTE
The bundled code circuits carry data noise only -- no gate, measurement or reset noise. The detector error model is complete with respect to the circuits qliff builds, but those circuits are a phenomenological model rather than a circuit-level one.