Skip to content

Quantum Error Correction with lightrider

An introduction to running circuit-level QEC experiments with the lightrider SDK: building encoded circuits for several stabilizer codes, injecting noise, simulating on the local stabilizer backend, and decoding with PyMatching to measure logical fidelity before and after correction.

Installation

bash
pip install lightrider pymatching

pymatching is required for the MWPM decoding used by build_matching() and logical_fidelity().

The QEC test workflow

Every code class follows the same five-step pattern:

python
from lightrider import RotatedSurface_Code, inject_sc_noise, draw_circuit, get_backend

1. Build the code and its QEC-test circuit (prepare logical |0>, run syndrome rounds, measure all data qubits):

python
RScode = RotatedSurface_Code(distance=3, rounds=1)
RScirc = RScode.build_circuit_QECtest()

2. Inject circuit-level Pauli noise at physical error rate p:

python
p = 1e-4
noisy_RScirc = inject_sc_noise(RScirc, p=p)

3. (Optional) visualize the circuit:

python
draw_circuit(noisy_RScirc.to_text(), save_path="out.png")

Example result (d=3 rotated surface code, 1 round):

Rotated surface code QEC-test circuit diagram

4. Simulate on the local stabilizer backend:

python
result = get_backend('stabilizer').run(noisy_RScirc, shots=20000).result()
shots = RScode.counts_to_shots(result.counts)

5. Decode and compare logical fidelity with and without correction:

python
res = RScode.logical_fidelity(shots, 'Z', p=p)   # MWPM via pymatching
print(f"Raw Fidelity: {res['fidelity_before']:.6f} (err {res['error_before']:.2e})")
print(f"QEC Fidelity: {res['fidelity_after']:.6f} (err {res['error_after']:.2e})")

Typical output (d=3 rotated surface code, p=1e-4, 20k shots):

Raw Fidelity: 0.997250 (err 2.75e-03)
QEC Fidelity: 0.998800 (err 1.20e-03)

logical_fidelity() returns a dict with fidelity_before / error_before (raw logical readout, no decoding) and fidelity_after / error_after (after XORing in the decoder's predicted logical flip). "Fidelity" here is the logical bit fidelity, 1 - logical error rate.

Available codes

Rotated surface code

Advantages: the field's workhorse — only weight-4 (bulk) and weight-2 (boundary) checks on a square lattice, the highest known error threshold among practical codes, and a mature MWPM decoder that runs in near-linear time.

python
from lightrider import RotatedSurface_Code

RScode = RotatedSurface_Code(distance=3, rounds=1)
res = RScode.logical_fidelity(shots, 'Z', p=p)    # basis: 'Z' or 'X'

[[d^2, 1, d]]: logical Z is the bottom row (d qubits) and logical X is the left column (d qubits) — marked in the figure below by the blue and green dashed loops, overlapping at qubit 0; the small green/purple dots between data qubits are the weight-4 (bulk) and weight-2 (boundary) X- and Z-stabilizers, with each one's light green/purple fill showing exactly which qubits it checks (a square in the bulk, a triangle poking outward at the boundary).

Decoded with MWPM on the multi-round matching graph; a virtual final round is reconstructed from the data-qubit readout (the standard boundary trick for memory experiments).

Rotated surface code qubit layout, d=3 and d=5

Repetition code

Advantages: minimal — only nearest-neighbor weight-2 checks and 1D connectivity, making it the cheapest possible sanity check for a new device or noise model before moving to a real 2D code.

python
from lightrider import Repetition_Code

Repcode = Repetition_Code(n=3, rounds=1)
res = Repcode.logical_fidelity(shots, p=p)

[[n, 1, n]]: the logical qubit is a single data qubit (index 0, circled in the figure) — under the ZZ stabilizer group (the purple z dots between neighbors) every physical Z_i is equivalent to logical Z-bar, so any one qubit's readout already carries the encoded value when no errors occur.

Bit-flip repetition code — the simplest matchable code, useful as a sanity check.

Repetition code qubit layout, d=3 and d=5

Five-qubit code

Advantages: the smallest possible code protecting against an arbitrary single-qubit error — useful whenever qubit count is the bottleneck and full 2D-lattice overhead isn't affordable.

python
from lightrider import FiveQubit_Code

FQcode = FiveQubit_Code(rounds=1)
res = FQcode.logical_fidelity(shots, expected=0)

[[5, 1, 3]]: logical X, Y, and Z each act on all 5 data qubits, circled together by the single red loop in the figure; the 4 ancillas (a0-a3) each realize a weight-4 stabilizer mixing X, Y, and Z across different qubits (solid edge = X, dashed edge = Z, per the caption).

The perfect [[5,1,3]] code. Its errors flip more than two stabilizers, so it is not decodable by plain matching — decoding is handled internally (no p/matching argument).

Five-qubit code stabilizer layout

6-6-6 color code

Advantages: transversal implementation of the full Clifford group (H, S, and CNOT all bias-free), unlike the surface code, which needs lattice surgery or magic-state tricks for non-Clifford/S gates — valuable when logical gate overhead matters more than physical qubit count.

python
from lightrider import Color_Code

Colcode = Color_Code(distance=3, rounds=1)
res = Colcode.logical_fidelity(shots, p=p)

[[(3d^2+1)/4, 1, d]]: logical X and Z (self-dual, so they share support; logical Y is their product) sit on the boundary row of d qubits circled by the red dashed loop. Each face is one stabilizer, shown as a light red/green/blue tile per the 3-coloring, with its x/z ancilla dots marking the X- and Z-type check sharing that face's qubit support.

Triangular 6-6-6 color code, decoded with a restriction decoder: three color-restricted matching subgraphs whose corrections are combined. Note the round-count constraint — fidelity readout needs a deterministic logical-Z observable, which occurs when rounds % 3 == 2 (rounds = 2, 5, 8, ...; at least one Z-type syndrome round is also needed, so rounds >= 5 in practice).

Only distance=3 is available on real QPU hardware (with 4 connections per qubit); distance > 3 (e.g. d=5) can only be run on the local 'stabilizer' simulator backend.

6-6-6 color code qubit layout, d=3 and d=5

Building custom circuits (logical gates between rounds)

Instead of build_circuit_QECtest(), you can assemble the circuit round by round and insert logical operations, e.g. a transversal logical Hadamard between two blocks of syndrome extraction:

python
RScodeH = RotatedSurface_Code(distance=3, rounds=1)
RScodeH.circ = lightrider.Circuit(RScodeH.total_qubits, RScodeH.total_clbits, name="surf_test")
for i in range(1):
    RScodeH.syndrome_round(i)
RScodeH.logical_H()
draw_circuit(RScodeH.circ.to_text(), save_path="RS_LH.png")

Example result (d=3 rotated surface code, 1 round, followed by a transversal logical H):

Rotated surface code circuit with a transversal logical Hadamard

Available logical gates:

Logical gateEffect
logical_X()Transversal logical Pauli-X: flips the encoded qubit's Z-basis readout, applied as physical x gates across the logical-X operator's support.
logical_Y()Transversal logical Pauli-Y: composition of logical_X() and logical_Z(); flips the readout in both bases.
logical_Z()Transversal logical Pauli-Z: flips the encoded qubit's X-basis readout, applied as physical z gates across the logical-Z operator's support.
logical_H()Transversal logical Hadamard: swaps the roles of the X and Z stabilizers, and of the logical X/Z operators.
logical_S()Logical phase gate: maps the logical X operator to Y (and Y to −X). Not transversal for CSS codes like the surface code — currently only implemented for the repetition code, as decode → single physical s → re-encode.

logical_X() / logical_Y() / logical_Z() / logical_H() are transversal (one physical gate per qubit in the relevant operator's support) and are tracked by the class so logical_support() stays correct afterward. logical_H() is the odd one out among these four — it also swaps which physical stabilizers are "X-type" and "Z-type" for every later round, whereas the Pauli gates leave the stabilizer frame unchanged. Because the decoder helpers assume a single stabilizer frame across all rounds, decoding a history that spans a logical_H() call needs separate treatment for the rounds before and after the swap.

logical_S() has no transversal implementation on CSS codes (the S gate is not a stabilizer automorphism for the surface or color code), so it is currently only supported for the repetition code, where it is realized by decoding to the logical qubit, applying a single physical s, then re-encoding — not a fault-tolerant transversal operation.

Using a different decoder

build_matching() / logical_fidelity() are PyMatching-specific conveniences. To plug in any other decoder (fusion-blossom, BP-OSD, a custom matcher), export the decoding problem as plain matrices:

python
problem = RScode.decoding_problem('Z', p=p)
problem['check_matrix']        # (num_detectors x num_faults) sparse GF(2) matrix
problem['observable_matrix']   # (1 x num_faults): which faults flip the logical
problem['weights']             # log((1-p)/p) edge weights
problem['num_detectors'], problem['num_rounds']

Per-shot detector vectors to feed such a decoder come from:

python
dets = RScode.extract_detectors(single_shot_clbits, 'Z')

Detectors are XORs of consecutive syndrome rounds (plus the virtual final round for 'Z'), so they fire only when an error occurs — the right input for any syndrome decoder.

Practical tips

  • Restart your kernel after (re)installing the package; imports are cached per session.
  • At low p the error rates are small — 20k shots gives only a handful of error events. Use 1e5–1e6 shots (or a larger p) for statistically meaningful before/after comparisons.
  • Expect error_after well below error_before for matchable codes at low p; if they're equal, check that shots, the matching, and the code instance all come from the same distance/rounds configuration.
  • All simulation here runs locally on the 'stabilizer' backend (Clifford-only, scales to hundreds of qubits). The same circuits can be submitted to IQM hardware via get_backend('iqm', ...).