Error correction¶
blueqat.qec keeps four things apart: a code says what to measure, a
circuit says how, a decoder says what the outcomes meant, and an
experiment puts them together. Each can be replaced without the others
noticing – which is what makes a decoder checkable against a reference.
from blueqat.qec import repetition_code, memory_experiment, PhenomenologicalNoise
code = repetition_code(5)
result = memory_experiment(code, rounds=5, shots=2000, seed=1,
noise=PhenomenologicalNoise(p_data=0.02, p_measure=0.02))
result.logical_error_rate
Codes¶
repetition_code() and
rotated_surface_code() return a
StabilizerCode, which carries the stabilizer generators,
the logical operators, and the data/ancilla layout. Pauli strings are indexed by
qubit – character q acts on data qubit q.
code.check() raises unless the generators really commute and the logicals
really pair up, and code.logical_weight() brute-forces the code distance for
small codes. Both exist because a layout mistake otherwise surfaces only as a
quietly wrong threshold:
rotated_surface_code(3).logical_weight() # 3
repetition_code(3).logical_weight() # 1 -- it stops bit flips only
Syndrome circuits¶
syndrome_round() measures every stabilizer with its own
ancilla, prepared in |+>, coupled by a controlled Pauli, rotated back and
measured. Measurements are keyed "s{index}_r{round}" so rounds stay
distinguishable.
The interaction order is ascending data-qubit index and is documented rather
than assumed, because on a surface code that order decides which two-qubit
errors propagate into weight-2 data errors – hook errors – and so decides
whether the circuit-level distance is d or only (d+1)/2. A schedule
chosen for that reason belongs to the experiment, so it is passed in:
syndrome_round(code, order=my_schedule) # my_schedule(code, index) -> qubits
What counts as a detector¶
A detector is a syndrome bit differing from the same bit in the previous round.
The first round has no previous round, and there the initial state matters: all
data qubits start in |0>, which fixes every Z-only stabilizer at +1 but
leaves an X-type check a fair coin even with no errors at all. So a Z-type
check’s first outcome is compared against its known value, and an X-type
check’s first outcome is not a detector – only its change from the second
round onward is. deterministic_stabilizers() says which is
which.
Getting this wrong is not subtle in its symptoms: the error-free run starts
firing detectors half the time, which
build_detector_graph() refuses outright rather than
building a graph around it.
Decoders¶
A decoder is anything with decode(detectors) -> 0 or 1, where detectors
is the ids of the detectors that fired – not a bit string over all of
them. An empty list means nothing fired.
MatchingDecoder does exact minimum-weight perfect matching
over a DetectorGraph.
That graph is not written out per code. build_detector_graph()
injects each error location on its own into an otherwise perfect run and reads
off which detectors fire and whether the observable flips – errors compose
linearly on a stabilizer circuit, so single-error responses are the whole story.
A new code therefore needs no new geometry code, and no new chance to get the
geometry wrong.
Thresholds¶
A memory experiment holds a logical |0> for some rounds and counts the shots
where the decoder’s verdict disagrees with what actually happened. Sweeping the
physical error rate and the distance shows the threshold directly – measured
here on the repetition code, rounds = d, 4000 shots:
|
|
|
|
|---|---|---|---|
0.02 |
0.0077 |
0.0018 |
0.0005 |
0.05 |
0.0455 |
0.0283 |
0.0163 |
0.10 |
0.1378 |
0.1398 |
0.1495 |
0.20 |
0.3360 |
0.4005 |
0.4400 |
Below about 10% a longer code fails less; above it, longer fails more. That crossing is the threshold, and for this code and noise model it should sit near 10.9%, where the equivalent random-bond Ising model orders.
The rotated surface code behaves the same way, rounds = d, 1500 shots:
|
|
|
|---|---|---|
0.005 |
0.0013 |
0.0007 |
0.010 |
0.0087 |
0.0053 |
0.020 |
0.0280 |
0.0333 |
0.050 |
0.1600 |
0.2453 |
The crossing here sits just below 2%, against the ~3% this model is usually quoted at. Only two distances are being compared, so finite-size effects are large – worth knowing before reading a number off a run like this.
Circuit-level noise¶
CircuitLevelNoise puts faults where they actually happen:
after every gate, at every measurement, at every reset, and on idle data
qubits.
from blueqat.qec import CircuitLevelNoise
memory_experiment(code, rounds=3, shots=6000, seed=4,
noise=CircuitLevelNoise.uniform(0.005))
# or, closer to hardware, a two-qubit rate an order of magnitude larger:
CircuitLevelNoise(p1=0.001, p2=0.01, p_measure=0.01)
The difference from the phenomenological model is not just “more places to go
wrong”. A fault landing between an ancilla’s two-qubit gates rides the rest of
them out onto several data qubits – a hook error – so one fault can
become a weight-2 data error. Which faults do that depends on the order the
ancilla visits its data qubits, and that order is an argument precisely so the
question can be asked. Measured on the surface code, d=3, p=0.003,
3000 shots:
Interaction order |
Logical error rate |
|---|---|
ascending index |
0.0197 |
reversed |
0.0167 |
|
0.0103 |
Nearly a factor of two from nothing but the order. Enumerating every single
fault instead of sampling shows why, and splits the damage in two. Some faults
the matching graph can represent are still decoded wrongly, because two
faults share a detector signature and disagree about the observable: no decoder
reading only that signature can tell them apart, and their product is an
undetectable logical error. The rest fire three or more detectors, which
matching has no edge for at all; those are counted in graph.hyperedges and
left out rather than mangled into one. Over all 1299 single faults, the
ascending order is decoded wrongly 76 times and 0,2,1,3 38 times.
That second group is the ordinary state of a matching decoder under circuit-level noise, not something to paper over – a hypergraph decoder would recover them.
The time boundary¶
Some failures are neither, and no interaction order removes them. In the last
detector layer, a fault on an ancilla and a fault on a boundary data qubit can
light the same single detector: one flips the observable and the other does
not, and the syndrome cannot tell them apart. Every round but the last is
checked twice in time – by itself and by the round after – and the last one is
not, so the effective distance halves there. Enumerating all 1299 single faults
on the d=3 surface code, thirteen such collisions survive under
0,2,1,3; they are present under the ascending order too, alongside the
order-dependent ones.
More rounds dilute their share, and a different treatment of the final layer avoids them. Changing the gate order does not, because they do not come from gate order.
Edge weights¶
Passing the noise model to build_detector_graph() weights
each edge by -log(p / (1 - p)) instead of giving every fault the same
weight, so matching prefers the more probable explanation when several faults
fire the same detectors.
It is worth saying what that bought here, which was nothing measurable. On the
repetition code at d = 3, 5, 7, both under a uniform rate and under a
two-qubit rate ten times the one-qubit rate, weighted and flat decoding agreed
to within the shot noise of 12000 shots – sometimes one ahead, sometimes the
other. The weights are principled and the machinery is there for models where
the rates differ more sharply; on this evidence they are not what stands
between these thresholds and the textbook ones.
What the weights do decide is which explanation wins when two faults fire the same detectors and disagree about the observable. That makes it essential that the graph enumerate exactly the faults the noise model can produce and no others: a fault the sampler will never generate still gets a vote, and can carry an edge the wrong way.
It also means a decoder belongs to the rate it was built at. Weights are
-log(p / (1 - p)), so changing p moves them by different amounts, and one
explanation can overtake another. On the d=3 surface code the detector pair
{12, 16} is close enough to such a crossover to fall on either side of it:
|
Direct edge |
Two boundaries |
Cheaper explanation |
|---|---|---|---|
0.005 |
8.0060342 |
8.7055254 |
the direct edge, by 0.6995 |
0.010 |
7.3125535 |
7.2908808 |
the boundaries, by 0.0217 |
The single fault that actually joins 12 and 16 does not cross the
observable; going out to the boundary twice does. So the same syndrome decodes
correctly at p = 0.005 and wrongly at p = 0.01, purely because the
weights reordered.
Neither answer is a bug – minimum-weight decoding is optimal on average, not
fault by fault – but it does mean two runs whose graphs were built at
different rates are not comparable, however identical everything else looks.
memory_experiment() builds its decoder from the noise it is
given, so it is consistent by construction; a decoder cached across a sweep of
p is not.