Extend with a New QEC Code

SkillDev tools

Add a new QEC code to LightStim by implementing QECPatch and a syndrome extraction block. Use this skill whenever the user asks to implement a new quantum error correcting code, extend LightStim with a custom code family, define stabilizers and logical operators for a new code, create a new SE_block, or understand the minimal interface a code needs to satisfy.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Extend with a New QEC Code skill

What this skill tells your AI

The instructions your AI receives, as published by qutone/lightstim in skills/extend-new-code/SKILL.md and read by ahel’s review.

Every LightStim code requires two classes:

  1. QECPatch subclass — geometry (qubit positions) + physics (stabilizers + logicals)
  2. Extraction block — builds one noiseless SE round as a stim.Circuit

Part 1: QECPatch subclass

The three build phases

from lightstim.ir.qec_patch import QECPatch

class MyCode(QECPatch):
    def _process_params(self):
        self.distance = self.params['distance']  # validate params here

    def build(self):
        d = self.distance

        # Phase 1: geometry — register every qubit at its (x, y) coordinate
        self.add_qubit(x, y, role='data')         # data qubit
        self.add_qubit(x, y, role='syndrome_z')   # Z-ancilla
        self.add_qubit(x, y, role='syndrome_x')   # X-ancilla

        # Phase 2: physics — one stabilizer per ancilla
        # target_dict: {(x, y): 'X'|'Z'|'Y'} for each data qubit in the stabilizer support
        self.create_stim_stabilizer(
            target_dict={(x1, y1): 'Z', (x2, y2): 'Z'},
            syn_coord=(sx, sy),   # coordinate of the ancilla qubit
            type='Z',             # 'X' or 'Z' (or 'Mixed' for YY etc.)
        )

        # Phase 3: logical operators (must register both X and Z representatives)
        self.create_stim_logical({(x, y): 'Z', ...}, op_type='Z')
        self.create_stim_logical({(x, y): 'X', ...}, op_type='X')
        self.num_logicals = 1     # k = number of encoded logical qubits

Key invariants

  • add_qubit role must be one of 'data', 'syndrome_x', 'syndrome_z'. Both syndrome_x and syndrome_z populate syndrome_indices_x / syndrome_indices_z — the SE block uses these to emit Hadamard gates and distinguish CX vs CZ layers.
  • create_stim_stabilizer looks up syn_coord via self.index_map — register the ancilla with add_qubit BEFORE calling it.
  • create_stim_logical only takes data qubit coordinates — syndrome coords are ignored.
  • You MUST set self.num_logicals in build().

CSS vs non-CSS

For CSS codes (most common: SC, BB, toric), stabilizers are either all-X or all-Z. Use type='X' or type='Z' respectively. The SE block will separate them.

For non-CSS codes with Y stabilizers, use type='Mixed' and handle gate decomposition in the SE block manually.


Part 2: Syndrome Extraction block

The SE block generates exactly one noiseless SE round as a stim.Circuit. Noise is injected later by builder.build_noisy_circuit() — never put noise here.

Structure

import stim

class MyCodeExtractionBlock:
    def __init__(self, system):
        self.system = system
        self.circuit = stim.Circuit()
        self._build()

    def _build(self):
        c = self.circuit

        # 1. Reset all active syndrome qubits (both X and Z ancillas)
        syn_z = sorted(self.system.active_syndrome_indices_z)
        syn_x = sorted(self.system.active_syndrome_indices_x)
        c.append("R",  syn_z)
        c.append("RX", syn_x)

        # 2. TICK with SE_start tag — marks the idle window for noise injection
        c.append("TICK", tag="SE_start")

        # 3. Gate layers — one TICK between each layer
        # For Z-stabilizers: CX(data → ancilla)  [ancilla is "target" of CX]
        # For X-stabilizers: CX(ancilla → data)  [ancilla is "control" of CX]
        #
        # Ordering matters for hook errors — follow the code's canonical gate schedule.
        # Read system.active_stabilizers_z / _x and system.index_map to get global indices.
        for stab in self.system.active_stabilizers_z:
            for data_idx in stab['data_indices']:
                c.append("CX", [data_idx, stab['syn_idx']])
        c.append("TICK")

        for stab in self.system.active_stabilizers_x:
            for data_idx in stab['data_indices']:
                c.append("CX", [stab['syn_idx'], data_idx])
        c.append("TICK")

        # 4. Basis change for X-ancillas: H before measure
        if syn_x:
            c.append("H", syn_x)
            c.append("TICK")

        # 5. Measure — MUST be the LAST instruction (CircuitBuilder analyzes it)
        #    Z-ancillas measured in Z basis (M), X-ancillas in X basis (MX)
        if syn_z: c.append("M",  syn_z)
        if syn_x: c.append("MX", syn_x)

Critical constraints on the SE block

  1. The last instruction must be M or MX on syndrome qubits. CircuitBuilder._get_back_propagated_pauli() reads this last instruction to determine the measurement basis and which qubits were measured.

  2. Gate ordering within one SE round affects hook errors. For an unrotated SC, the canonical order is: North → East → West → South neighbors. For your code, pick an order and be consistent across all rounds.

  3. Use system.active_stabilizers_z / _x not system.stabilizers. When couplers are active, active_stabilizers includes coupler stabilizers. The SE block must work correctly in both coupled and uncoupled regimes.

  4. Use global indices from system.index_map[(x, y)] or stab['syn_idx']. Never use local patch indices in the SE block circuit.

  5. No noise in the SE block. The noise injector handles it via the SE_start tag.


File layout

lightstim/qec_code/<your-code>/
├── __init__.py        # export patch class + extraction block
├── code_patch.py      # QECPatch subclass
└── SE_block.py        # extraction block class

Verification sequence

After writing both classes, verify in this order:

# 1. Patch builds without errors
patch = MyCode(distance=3)
print(patch.num_qubits, len(patch.stabilizers), patch.num_logicals)

# 2. System registration
system = QECSystem()
system.add_patch(patch, name="p")

# 3. SE block builds without errors
se = MyCodeExtractionBlock(system)

# 4. Full noiseless circuit (use builder directly)
from lightstim.ir.tracker import SyndromeTracker
from lightstim.ir.builder import CircuitBuilder
tracker = SyndromeTracker(system.num_qubits, expected_num_logicals=system.num_logicals)
builder = CircuitBuilder(tracker, system)
builder.write_coordinates()
builder.initialize({q: "Z" for q in system.data_indices}, n=system.num_qubits)
builder.apply_syndrome_extraction(se.circuit, rounds=3)
builder.apply_data_readout({q: "Z" for q in system.data_indices})

# 5. Must see 0 detection events and 0 observable flips
dets, obs = builder.circuit.compile_detector_sampler().sample(100, separate_observables=True)
assert not dets.any(),  "Noiseless circuit fires detectors — stabilizer or SE bug"
assert not obs.any(),   "Noiseless circuit flips observable — logical operator bug"

Working examples

  • lightstim/qec_code/repetition/repetition.py + SE_block.py — simplest complete QECPatch implementation to read first
  • lightstim/qec_code/surface_code/rotated/ — full-featured code with rectangular distance support

Signals

GitHub stars
46
Forks
6
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
extend-new-code
Source
github.com/qutone/lightstim