| name | extend-new-code |
| description | 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.
|
| user-invocable | true |
Extend with a New QEC Code
Every LightStim code requires two classes:
QECPatch subclass — geometry (qubit positions) + physics (stabilizers + logicals)
- 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']
def build(self):
d = self.distance
self.add_qubit(x, y, role='data')
self.add_qubit(x, y, role='syndrome_z')
self.add_qubit(x, y, role='syndrome_x')
self.create_stim_stabilizer(
target_dict={(x1, y1): 'Z', (x2, y2): 'Z'},
syn_coord=(sx, sy),
type='Z',
)
self.create_stim_logical({(x, y): 'Z', ...}, op_type='Z')
self.create_stim_logical({(x, y): 'X', ...}, op_type='X')
self.num_logicals = 1
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
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)
c.append("TICK", tag="SE_start")
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")
if syn_x:
c.append("H", syn_x)
c.append("TICK")
if syn_z: c.append("M", syn_z)
if syn_x: c.append("MX", syn_x)
Critical constraints on the SE block
-
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.
-
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.
-
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.
-
Use global indices from system.index_map[(x, y)] or stab['syn_idx'].
Never use local patch indices in the SE block circuit.
-
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:
patch = MyCode(distance=3)
print(patch.num_qubits, len(patch.stabilizers), patch.num_logicals)
system = QECSystem()
system.add_patch(patch, name="p")
se = MyCodeExtractionBlock(system)
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})
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