Quantum Error-Correction Workflow¶
Quantum Error correction is the canonical stress test for hybrid Qiskit-in-QUA design: repeated syndrome measurement, classical decoding, and recovery inside a long-running QUA program while circuits stay authored in Qiskit.
For method signatures, see the Parameter Table API reference and Backend API reference.
Purpose¶
Plain circuit submission breaks down for QEC because you need:
Many cycles of measure → decode → recover, not one shot per circuit variant.
Streaming classical data (syndrome integers, detection events) between QUA and a host decoder.
Stable parameter names across Qiskit recovery circuits and QUA variables.
Why the QEC loop belongs in QUA¶
Qiskit exposes a real-time ForLoopOp that can compile a fixed number of cycles into a native QUA for_ loop. It does not, however, expose a way to stream intermediate measurement outcomes to the host or to stream-processing within that loop. That leaves two unattractive options:
Approach |
Problem |
|---|---|
Reuse the same classical bit each cycle |
Intermediate outcomes are overwritten; only the last round is retained |
Declare one classical variable per cycle |
Outcomes are preserved, but memory use scales with cycle count |
The recommended pattern is to author a single template cycle (syndrome measure, optional recovery) as a Qiskit circuit, embed it in a QUA for_ loop, and stream or derive classical data each iteration via ParameterTable — not to unroll the full QEC schedule as one giant Qiskit circuit.
ParameterTable and Parameter make that classical-quantum contract explicit. QuaCircuitCompilation.outputs wires syndrome bits from an embedded circuit into QUA variables in the same with program(): block.
Measurement outcomes vs what you stream to OPNIC¶
Hybrid QEC involves two separate classical pipelines:
Pipeline |
Where it lives |
What it holds |
|---|---|---|
Circuit measurement |
Raw discriminated bits from the embedded syndrome circuit — compiler-local, not OPNIC-emitted |
|
Host transport |
Runtime |
Whatever classical quantity you choose to assign and |
Matching an OPNIC struct field name to a creg name is optional. Even when the names match, they refer to different QUA variables. The struct that streams back to OPNIC is not required — and often should not — be a 1:1 copy of the raw measurement register.
Typical QEC quantities streamed to the host include:
Detection events — per-bit XOR between consecutive syndrome rounds (did this stabilizer flip?)
Packed syndrome integers — compact encoding for a classical decoder lookup table
Aggregated counters or flags — derived entirely in QUA from measurement bits
The pattern is: read raw outcomes from comp.outputs, process in QUA, then assign the derived result into a runtime ParameterTable and call stream_back(). See Measurement outputs — QEC for the locality model.
Why hybrid QEC is hard without this toolbox¶
Qiskit models a QuantumCircuit as a self-contained unit: gates, classical registers, and optional control flow all live inside the circuit boundary. QEC cycles do not fit that picture. Each round is really three phases:
Syndrome measurement — a circuit fragment on the OPX.
Classical decoding — often heavy (MWPM, lookup tables, …), running on a host or server, outside any circuit.
Recovery — a second circuit fragment whose parameters depend on the decoder output.
Qiskit has no first-class way to express that sandwich: two distinct circuit parts linked by real-time classical I/O. Unrolling every branch into one giant circuit, or gluing phases together with ad-hoc re-submission scripts, obscures the measure → decode → recover contract and scales poorly as cycle count or decoder latency grows.
QUA + ParameterTable is aimed at exactly this gap — a stable boundary between circuit fragments and the classical processing that sits between them:
Phase |
Role of this toolbox |
|---|---|
Syndrome measure |
Embed a template Qiskit circuit in a QUA |
Stream to decoder |
|
Classical decode |
Host-side (as intended) — not forced into the circuit |
Push recovery |
|
Recovery |
Embed the recovery template with the updated |
The same syndrome and recovery circuits are reused every cycle; only the streamed classical data and pushed recovery parameters change.
ParameterTable roles in QEC¶
Declare the contract once¶
from qiskit.circuit.classical.expr import Var
from qiskit_qm_provider import Parameter, ParameterTable, Direction, InputType
num_cregs = len(syndrome_circuit.cregs)
syndrome_data = Parameter(
"syndrome_data",
[0] * num_cregs, # one packed state_int slot per classical register
input_type=InputType.INPUT_STREAM, # or InputType.OPNIC
direction=Direction.INCOMING,
)
recovery_vars = ParameterTable.from_qiskit(
recovery_circuit,
input_type=InputType.INPUT_STREAM,
filter_function=lambda p: isinstance(p, Var), # real-time Vars only, not compile-time Parameters
)
Inside QUA:
recovery_vars.declare()
syndrome_data.declare(declare_stream=True)
Stream syndrome out (same program, right after embedding)¶
quantum_circuit_to_qua returns a QuaCircuitCompilation. Classical outcomes are exposed on comp.outputs (MeasurementOutcomeTable) — compiler-local handles wired from result_program, distinct from the runtime syndrome_data parameter you stream to OPNIC.
from qm.qua import program, assign
ancilla_creg = syndrome_circuit.cregs[0]
with program() as qec_prog:
comp = backend.quantum_circuit_to_qua(syndrome_circuit)
# Bridge: copy packed syndrome from compilation outputs into OPNIC transport
assign(syndrome_data.var[0], comp.outputs.state_ints[ancilla_creg.name])
syndrome_data.stream_back(reset=True)
For multiple classical registers, index comp.outputs per creg name (or use the bulk accessor):
for i, creg in enumerate(syndrome_circuit.cregs):
assign(syndrome_data.var[i], comp.outputs.state_ints[creg.name])
syndrome_data.stream_back(reset=True)
Access on |
Role in QEC |
|---|---|
|
Per-bit QUA bool array — raw stabilizer readout |
|
Lazy-packed |
|
Number of syndrome bits in register |
|
Per-field stream for |
See Measurement outputs guide for the full accessor contract. Legacy get_measurement_outcomes returns the same handles via a dict shim (meas[creg.name]["state_int"] ≡ comp.outputs.state_ints[creg.name]).
This streams the packed raw syndrome each round. When the decoder needs detection events (bit flips between rounds) instead, see Detection events — consecutive-round XOR below.
Detection events — consecutive-round XOR¶
Many decoders consume detection events (syndrome changes between rounds), not the raw stabilizer readout each round. Compute them in QUA and stream only the derived bits to OPNIC.
Setup (Python, before with program():): declare two runtime tables — one per classical register in the syndrome circuit:
from qiskit_qm_provider import ParameterTable, InputType, Direction
def _bool_table(name: str, circuit, *, stream: bool = False):
"""One bool array field per creg, sized to match the circuit."""
fields = {}
for creg in circuit.cregs:
spec: tuple = ([False] * creg.size, bool)
if stream:
spec = ([False] * creg.size, bool, InputType.INPUT_STREAM, Direction.INCOMING)
fields[creg.name] = spec
return ParameterTable(fields, name=name)
previous_measurement_outcomes = _bool_table("prev_meas", syndrome_circuit)
syndrome_data = _bool_table("syndrome_data", syndrome_circuit, stream=True)
previous_measurement_outcomes— last round’s raw bits (updated in-place each round).syndrome_data— detection events sent to the host viastream_back().Current-round raw bits come directly from
comp.outputsafter eachquantum_circuit_to_quacall — no separate staging table required.
In-QUA update (inside with program():, after each syndrome measurement):
from qm.qua import declare, for_, assign
def update_syndrome_streams(
circuit,
comp,
previous_measurement_outcomes: ParameterTable,
syndrome_data: ParameterTable,
):
"""Update syndrome streams for a given circuit."""
j = declare(int)
for creg in circuit.cregs:
meas_reg = comp.outputs[creg.name]
syndrome_param = syndrome_data[creg.name]
prev_meas = previous_measurement_outcomes[creg.name]
with for_(j, 0, j < creg.size, j + 1):
assign(
syndrome_param[j],
prev_meas[j] ^ meas_reg[j],
)
assign(prev_meas[j], meas_reg[j])
syndrome_data.stream_back(reset=True)
Walkthrough:
Embed and measure —
comp = backend.quantum_circuit_to_qua(syndrome_circuit)runs the syndrome circuit;comp.outputs[creg.name]holds fresh bool outcomes for this round.XOR for detection events — for each bit
j,syndrome_param[j] = prev_meas[j] ^ meas_reg[j]. A1means the stabilizer outcome changed since the last round;0means no flip. This is the quantity many MWPM / lookup decoders expect.Advance history —
prev_meas[j] = meas_reg[j]so the next round compares against this round’s raw readout fromcomp.outputs.Stream derived data only —
syndrome_data.stream_back(reset=True)pushes detection events to OPNIC, not the rawcomp.outputsregister. The host decoder never needs the compiler-local handles.
On the first round, initialize previous_measurement_outcomes to your experiment’s baseline (often all zeros) before entering the QEC loop.
Full loop sketch:
from qm.qua import program, declare, for_
with program() as qec_prog:
previous_measurement_outcomes.declare()
syndrome_data.declare(declare_stream=True)
round = declare(int)
with for_(round, 0, round < num_cycles, round + 1):
comp = backend.quantum_circuit_to_qua(syndrome_circuit)
update_syndrome_streams(
syndrome_circuit,
comp,
previous_measurement_outcomes,
syndrome_data,
)
# host decodes detection events → push recovery_vars ...
Packed state_int for large registers¶
When a classical register has many bits, streaming or retaining the full bool chain is expensive on FPGA memory and host buffers. Prefer the lazy-packed state_int scalar from comp.outputs.state_ints — one int per creg, LSB = bit index 0 (Qiskit convention). See Measurement outputs — accessor contract.
Use state_int when you need a compact label for the outcome, not individual bit logic:
Stream one integer per round to OPNIC instead of
creg.sizebool streams.Index a QUA histogram or lookup table of size
2**creg.sizeon device.Host-side
stream_processing()with a 1-D buffer of length2**creg.sizeinstead of a(n_rounds, creg.size)bool array.
Example — stream packed syndromes and accumulate histograms in QUA:
from qm.qua import program, declare, for_, assign, stream_processing
# Python: one int field per creg for OPNIC transport
syndrome_int = Parameter(
"syndrome_int",
0,
input_type=InputType.INPUT_STREAM,
direction=Direction.INCOMING,
)
# Python: per-creg histogram on device (size 2**n_bits)
hist_table = ParameterTable({
creg.name: ([0] * (2 ** creg.size), int)
for creg in syndrome_circuit.cregs
}, name="syndrome_hist")
ancilla = syndrome_circuit.cregs[0].name
with program() as qec_prog:
syndrome_int.declare(declare_stream=True)
hist_table.declare()
round = declare(int)
with for_(round, 0, round < num_cycles, round + 1):
comp = backend.quantum_circuit_to_qua(syndrome_circuit)
for creg in syndrome_circuit.cregs:
state_int = comp.outputs.state_ints[creg.name]
# Compact integer label — avoids retaining creg.size bool vars for streaming
assign(hist_table[creg.name][state_int], hist_table[creg.name][state_int] + 1)
assign(syndrome_int.var, comp.outputs.state_ints[ancilla])
syndrome_int.stream_back(reset=True)
with stream_processing():
for creg in syndrome_circuit.cregs:
target_dim = 2 ** creg.size
hist_table.get_parameter(creg.name).stream_processing(buffer=(target_dim,))
syndrome_int.stream_processing()
When to use which representation:
Representation |
Best for |
|---|---|
Per-bit bool ( |
In-QUA bit logic — XOR detection events, parity checks, conditional feedback on individual stabilizers |
Packed |
Streaming to host, histogramming, decoder lookup tables, any workflow where the full bit string is treated as one label |
You can combine both: use bool arrays (or XOR) for real-time feedback inside the QUA loop, and assign state_int into an OPNIC Parameter when the host only needs the compact syndrome label.
Push recovery parameters in¶
recovery_vars.push_to_opx(param_dict, job, qm, verbosity=0)
In QUA:
recovery_vars.rcv()
backend.quantum_circuit_to_qua(recovery_circuit, recovery_vars)
End-to-end loop¶
Author syndrome and recovery circuits in Qiskit.
Declare
ParameterTable/Parameterfor the derived classical quantities you will stream (detection events, packed integers, etc.) — not necessarily raw measurement registers.Inside
with program():, callquantum_circuit_to_quafor the syndrome circuit and read outcomes fromcomp.outputs.Process outcomes in QUA (XOR for detection events, or
comp.outputs.state_ints[...]for packed syndromes), thenstream_back()from the runtime table.On the host, decode and
push_to_opx()recovery parameters.rcv()on the recovery table and embed the recovery circuit.
The full QEC program skeleton lives in the repository README.