Skip to content

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:

NameWhat it isPauli onlyNeeds
mwpmminimum-weight perfect matching; graphlike models onlyyespymatching
bposdbelief propagation + ordered-statistics decodingyesldpc
mldexact max-likelihood tensor-network contractionno-
tnthe same contraction, truncatable via max_bondno-
coherentnon-Pauli / coherent TN, contracted on the circuitno-
colorexact min-weight search; takes color-code hyperedgesyes-

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.

GeneratorArgumentsDescription
repetition_code(distance, rounds, p)code distance, syndrome rounds, per-round noise pbit-flip repetition-code Z memory
rotated_surface_code(distance, rounds, p)distance, rounds, depolarizing protated planar surface-code Z memory (square; pass rows, cols, rounds, p for a rectangle)
unrotated_surface_code(distance, rounds, p)distance, rounds, depolarizing punrotated (CSS) planar surface-code memory
toric_code(distance, rounds, p)distance, rounds, depolarizing pperiodic toric-code memory
hex_color_code(distance, rounds, p)distance, rounds, depolarizing p6.6.6 honeycomb color code
triangular_code(distance, rounds, p)distance, rounds, depolarizing ptriangular-axis color/surface family
kagome_code(distance, rounds, p)distance, rounds, depolarizing pkagome-axis color/surface family
python
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.

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

It 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 Z checks, so its dual has no syndrome at all.

Some families are each other's dual rather than self-dual. triangular_code and kagome_code are medial duals with (dZ,dX)=(3,6) and (6,3) at the same distance=3, so triangular's X-memory under Z noise reproduces kagome's Z-memory under X noise exactly.

WARNING

A memory cannot see noise that commutes with its own basis. A Z memory under Z_ERROR, RZ, or a PAULI_CHANNEL_1 with only pz live fires no detectors and reports a logical error rate of exactly zero at any p -- a degenerate run that reads like a perfect one. qliff warns when you build such a circuit, and again if the resulting detector error model is empty or has no fault that can reach an observable. Run the dual basis instead.

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.

KnobValuesEffect
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
prepFalse (default), Trueprepend 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 r⋅cols+c↦c⋅rows+r: same check kinds, same supports, and every weight-4 face's CX ladder carried across with them. Checked generator-by-generator on every patch from 2x2 to 6x6, and on the detector error model (mechanism and detector-degree spectra) under depolarizing and X-biased noise.

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:

edgetracked logical runs alongits weightdfail
"even"a columnrowscols
"odd"a rowcolsrows
python
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, transposed

On 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 ⌈dfail/2⌉ (see rare-event splitting).

Noise bias ​

Every code builder takes bias and bias_axis alongside channel and p. bias is the η of a single-axis split of the total rate (see Anisotropy): η=12 is depolarizing, η→∞ is pure single-axis noise, and bias_axis is "X", "Y" or "Z".

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

bias / 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 X and Z are exact duals. Swapping bias_axis and memory together gives a bit-identical detector error model. A Z-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.
  • bias shapes 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 DEPOLARIZE2 at η=12. Passing bias with 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 p; 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 -- X, Y, Z or mixed -- is measured each round and feeds the detector graph, and each logical is tracked end to end, so codes with no pure-Z stabiliser run too. The logical observable(s) are derived automatically when omitted.

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.

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

Decoding 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 d×d Bacon-Shor gauge group directly.

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

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

python
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).

OutputShapeMeaning
dets(shots, n_detectors)detection events
obs(shots, n_observables)logical-observable flips (the labels)
python
dets, obs = rep.detector_sampler().sample(10000, seed=0)

These feed a decoder directly, or serve as a training set for an ML decoder.

SamplerReturnsNoise
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 k

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/MethodReturnsDescription
mechanismslist(probability, detectors, observables) per mechanism
check_matrix()(H, priors, obs_matrix)F2 parity-check H (detectors x mechanisms), priors, observable matrix -- for BP
weights(clip=0.0)ndarrayper-mechanism MWPM weights log⁡1−pp
max_degree(), is_graphlike()int, boolmost detectors one mechanism flips; whether that is ≤2
graphlike_edges()listmechanisms flipping ≤2 detectors, as (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 1−LER.

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

Below 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 1/shots -- neither route resolves anything. 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:

WarningMeaning
TwirlWarningmake(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 exactnessthe 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.unresolvedsyndromes 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.