qec-canvas is an extensible visual workbench for defining, validating,
simulating, and decoding small quantum error-correcting codes.
The project is organised around one question:
What happened during this QEC experiment, and why did the decoder choose this correction?
The public package is Python-first. Its domain models and result records are independent of any simulator, decoder, plotting library, or geometric convention. An optional Julia/ITensor backend may later provide tensor-network simulation without exposing ITensor objects through the Python API.
Important
v0.1 is an alpha release focused on small, explainable experiments. Its
reference simulator and decoder are correctness and teaching tools with
explicit scaling and physics limits, not threshold-scale production engines.
At a glance, the package lets a user:
- define and validate stabilizer codes, geometry, and extraction schedules;
- run reproducible memory experiments with explicit data and measurement faults; and
- inspect why a batch or sliding-window decoder changed its software frame, including controlled logical failures.
v0.1 is distributed through
GitHub Releases, not PyPI.
Download the wheel for the release and install it with Python 3.11 or newer:
python -m pip install qec_canvas-0.1.0-py3-none-any.whlFor source development, use the setup under Development.
Define a three-qubit repetition code directly from Pauli checks:
from qec_canvas import (
Check,
LogicalOperator,
PauliString,
UserDefinedCode,
)
code = UserDefinedCode.from_pauli_checks(
data_qubits=("q0", "q1", "q2"),
checks=(
Check("S0", PauliString({"q0": "Z", "q1": "Z"})),
Check("S1", PauliString({"q1": "Z", "q2": "Z"})),
),
logical_operators=(
LogicalOperator(
"logical_x",
PauliString({"q0": "X", "q1": "X", "q2": "X"}),
axis="X",
),
LogicalOperator(
"logical_z",
PauliString({"q0": "Z"}),
axis="Z",
),
),
)
report = code.validate()
assert report.is_valid
assert report.stabilizer_rank == 2
assert report.logical_qubit_count == 1Validation returns all independent errors and warnings in a structured report.
Call report.raise_if_invalid() when fail-fast behaviour is preferable at an
application boundary.
The first built-in family implements the package's versioned staggered-boundary convention:
from qec_canvas import RotatedPlanarCode
surface_code = RotatedPlanarCode(distance=3)
report = surface_code.validate()
assert len(surface_code.data_qubits) == 9
assert len(surface_code.x_checks) == 4
assert len(surface_code.z_checks) == 4
assert report.stabilizer_rank == 8
assert report.logical_qubit_count == 1Data identifiers are row-major from the bottom-left, so q4 is the centre
qubit at distance three. The convention, check ordering, boundaries, and
logical representatives are specified in
ADR-003.
The built-in family includes a planar embedding and a default one-ancilla-per-check extraction schedule:
from qec_canvas import RotatedPlanarCode
code = RotatedPlanarCode(distance=3)
code_figure = code.visualise(label_qubits=True)
code_figure.savefig("distance-three-code.png", dpi=160)
check_figure = code.visualise_syndrome_extraction(check="X3")
check_figure.savefig("x3-syndrome-extraction.png", dpi=160)The code view distinguishes data qubits, X- and Z-check ancillas, coupling edges, X- and Z-check boundaries, and logical representatives using both colour and shape or line style. The selected-check inspector separates the abstract Pauli support from the physical CNOT order and shows ancilla preparation, measurement basis, and classical output.
Both checked-in images can be reproduced together:
python examples/code_visualisations.py --output-directory docs/imagesExtraction details are also available without plotting:
x3 = code.syndrome_extraction().for_check("X3")
assert x3.data_qubits == ("q7", "q8")
assert x3.ancilla_id == "a_X3"
assert x3.classical_bit == "s_X3"UserDefinedCode accepts optional Embedding and SyndromeExtraction
objects. When geometry is absent, the static renderer uses a deterministic
Tanner-graph layout instead of imposing planar coordinates on the universal
code model.
MemoryExperiment is an immutable description of what should run. Fault
locations are explicit because applying the same Pauli before and after
syndrome extraction describes different experiments:
from qec_canvas import MemoryExperiment, RotatedPlanarCode
code = RotatedPlanarCode(distance=3)
base = MemoryExperiment(code, rounds=4)
experiment = base.inject_pauli(
round=2,
qubit="q4",
pauli="X",
location="before_syndrome_extraction",
)
assert base.faults == ()
assert experiment.faults[0].round == 2ExperimentTrace is the separate record of what was measured. Simulator
adapters can construct one directly; the mapping factory is convenient for
fixtures and imported results:
from qec_canvas import ExperimentTrace
trace = ExperimentTrace.from_syndrome_rounds(
experiment,
{
1: {"Z0": 0, "Z1": 0},
2: {"Z0": 1, "Z1": 1},
3: {"Z0": 1, "Z1": 1},
4: {"Z0": 0, "Z1": 0},
},
backend_name="example-fixture",
)
assert trace.syndromes(round=2) == {"Z0": 1, "Z1": 1}
assert {(event.round, event.check_id) for event in trace.detector_events} == {
(2, "Z0"),
(2, "Z1"),
(4, "Z0"),
(4, "Z1"),
}
trace_figure = trace.visualise(checks=("Z0", "Z1"))
trace_figure.savefig("memory-trace.svg")Syndrome bits use 0 = +1 and 1 = -1. Round one establishes the measured
baseline; a detector event is derived only when the same check changes across
two consecutive recorded rounds. This avoids silently assuming a preparation
boundary that a future backend may not have simulated. See
ADR-005 for the contract.
Noise models consume explicit ErrorLocation values and produce typed
FaultEvent records. The first built-ins cover independent data-qubit Paulis,
classical measurement flips, and their phenomenological combination:
from qec_canvas import PhenomenologicalNoise
noisy_experiment = experiment.with_noise(
PhenomenologicalNoise(
data_error_probability=0.001,
measurement_error_probability=0.002,
pauli_distribution={"X": 1 / 3, "Y": 1 / 3, "Z": 1 / 3},
)
)
sampled_faults = noisy_experiment.sample_noise(seed=1234)
trace = ExperimentTrace.from_syndrome_rounds(
noisy_experiment,
{
1: {"Z0": 0, "Z1": 0},
2: {"Z0": 1, "Z1": 1},
3: {"Z0": 1, "Z1": 1},
4: {"Z0": 0, "Z1": 0},
},
sampled_faults=sampled_faults,
seed=1234,
backend_name="example-fixture",
)IIDPauliNoise and MeasurementBitFlipNoise are available when only one
category is required. StochasticErrorModel is the extension point for custom
discrete models.
For the first release, the experiment declares one data-error opportunity per
qubit before each extraction round and one classical flip opportunity per
measured check afterwards. sample_noise() samples those opportunities; it
does not simulate the quantum state. A trace records both the seed and the
actual sampled events, so the event record remains authoritative if a sampler
implementation changes. See
ADR-006.
PythonReferenceBackend turns an experiment into a complete, decoder-ready
trace without a heavyweight quantum SDK:
from qec_canvas import PythonReferenceBackend
backend = PythonReferenceBackend()
trace = backend.run(noisy_experiment, seed=1234)
assert trace.backend_name == "python-reference"
assert trace.seed == 1234
print(trace.sampled_faults)The backend is exact within its deliberately small model: it composes phase-free Pauli errors, computes stabilizer outcomes by commutation, and applies explicit classical measurement flips. Deterministic faults before extraction affect the current measurement; faults after extraction first appear in the following round.
Capability reporting keeps that claim bounded:
from qec_canvas import SimulationFeature
assert backend.capabilities.supports("seeded_sampling")
assert not backend.capabilities.supports(SimulationFeature.COHERENT_NOISE)
print(backend.capabilities.limitations)The reference backend does not evolve a quantum state or model extraction gates, circuit-level noise, coherent channels, Kraus channels, leakage, or continuous time. See ADR-009.
ReferenceDecoder exhaustively searches the experiment's declared data-Pauli
and measurement-flip opportunities. It is intentionally bounded and
explainable: results retain candidate events, tied minimum-fault hypotheses,
the selected phase-free correction, and the resulting software Pauli-frame
update.
from qec_canvas import ReferenceDecoder
result = ReferenceDecoder(max_faults=2).decode(trace)
if result.is_success:
assert result.chosen_hypothesis is not None
print(result.chosen_hypothesis.events)
print(result.correction)
print(result.hypothesis_count)
else:
print(result.failure_reason)The default CODE_STATE baseline assumes the memory experiment began in the
stabilizer +1 eigenspace. For imported traces where round one is only an
observed reference, select
ReferenceDecoder(baseline="first_measurement"). Decoding requires every
check in every round; sparse traces are rejected rather than silently filled
with zeros.
The returned correction is a PauliString, and any PauliFrameUpdate is
software bookkeeping—not a physical correction gate. The exhaustive decoder
is designed for demonstrations, fixtures, and comparison with future scalable
decoders. Its explicit limits prevent accidental threshold-scale use. See
ADR-007 for the full
contract.
SlidingWindow applies the same batch decoder to a bounded active buffer. The
commit and lookahead regions are separate parameters, and every decision
records both ranges:
from qec_canvas import ReferenceDecoder, SlidingWindow
streaming = SlidingWindow(
decoder=ReferenceDecoder(),
commit_rounds=3,
lookahead_rounds=2,
)
streaming_result = streaming.decode(trace)
for decision in streaming_result.decisions:
print(
decision.window_start_round,
decision.window_end_round,
decision.commit_through_round,
decision.committed_events,
decision.frame_after,
)
print(streaming_result.final_frame)For live input, call streaming.start(experiment), push complete syndrome
mappings in strict round order, and call flush() at the end. push() and
flush() return only newly committed PauliFrameUpdate values; the session's
decisions property retains the full explanation. Lookahead faults are not
committed early, and later windows are normalised by the already committed
frame so the same persistent data error is not decoded twice. See
ADR-008.
Every WindowDecision records the complete software frame after its commit.
The decoder plot converts that frame into a running parity for each declared
logical representative. For example, flip(X)=1 means that the inferred frame
anticommutes with logical X and would flip the sign of a known logical-X
eigenvalue. These are inferred observable flips, not logical measurements; an
initial logical value must be known before a bit value can be predicted. See
ADR-011.
Run the checked-in example to execute the full public path and save a deterministic figure:
python examples/memory_experiment.py --output docs/images/memory-experiment.pngIt builds a distance-three code, injects a known fault, samples seeded
phenomenological noise, runs the Python reference backend, decodes in sliding
windows, and renders the exact trace and result used by the assertions. The
twelve-round fixture contains a sampled Y data error, a sampled X0
measurement flip, and an explicitly injected X data error.
Open the full-resolution memory-experiment timeline.
Solid decoder regions have been committed; hatched regions are retained lookahead; plus markers are software Pauli-frame updates. The measurement error is labelled in the fault lane and changes one classical outcome without adding to the Pauli frame. Each committed region also shows the decoder's running logical-frame parity. The complete, copyable walkthrough is in the end-to-end tutorial.
A distance-three code is guaranteed to correct one error, not every pair of
errors. The second checked-in experiment deliberately applies X to q0 and
q3 in the same round:
python examples/logical_failure.py --output docs/images/logical-failure.pngThose two errors have the same syndrome as a single X on q6. The
minimum-weight decoder therefore reports a successful syndrome explanation and
tracks Xq6, but the residual Xq0 Xq3 Xq6 is the code's logical X operator.
For a memory prepared with logical Z value 0, the decoder predicts 0 while
the actual value is 1.
The decoder lane correctly contains one red Pauli-frame-update marker, at round
6. At the round-3 boundary, the inferred round-4 Xq6 fault is still in
lookahead and cannot be committed. Round 6 commits that fault and changes the
frame from I to Xq6. Rounds 9 and 12 carry the existing frame forward
without committing another Pauli event, so no additional plus marker appears.
This is a logical failure, not a decoder crash: result.is_success means that
the decoder found a compatible fault hypothesis. It does not mean the
hypothesis matches inaccessible ground truth. The complete derivation is in
the logical-failure example.
The first release provides one small, explainable end-to-end path:
- define a distance-three rotated planar surface code;
- inspect its checks, logical operators, and syndrome-extraction schedule;
- run a repeated memory experiment with reproducible Pauli and measurement noise;
- decode the syndrome history with a transparent reference decoder, in batch or sliding-window mode; and
- visualise the faults, measurements, matching decisions, and resulting software Pauli frame from a common experiment trace; and
- inspect a controlled beyond-distance failure where a valid minimum-weight decision leaves a logical operator.
That path is implemented, tested, documented, and exercised by the checked-in example and deterministic figure.
- Keep mathematical domain objects independent of backend representations.
- Distinguish a check operator, its measurement circuit, and the classical value produced by measurement.
- Offer convenient built-in code families without baking planar geometry into the universal interface.
- Prefer immutable experiment plans and explicit seeded randomness.
- Make scaling limits, approximations, and unsupported effects visible.
- Keep production logic in the package; notebooks are examples and consumers.
The dependency rules and backend boundary are described in the architecture overview. Significant decisions are recorded in architecture decision records.
The API map, changelog, security policy, and contribution guide define the public maintenance surface.
The first release will not claim threshold-scale performance, circuit-level hardware accuracy, arbitrary lattice surgery, a complete leakage model, non-Markovian simulation, production decoder latency, a browser application, or C++ acceleration.
These are possible later directions, not prerequisites for a trustworthy small experiment.
The project requires Python 3.11 or newer. Create and activate a virtual environment, then install the package and development tools:
python -m pip install --upgrade pip
python -m pip install --editable ".[dev]"Run the same checks enforced in CI:
ruff format --check .
ruff check .
mypy
pytest
python -m build
python -m twine check dist/*See CONTRIBUTING.md for the engineering expectations behind these commands.
The initial fixtures and design lessons are being extracted from Quantum-Playground. That repository remains the exploratory research record; this repository owns the stable public contracts, tests, documentation, and release history.
Canonical background sources and the exact boundaries of their relationship to the package are listed in Scientific references. The central Pauli algebra and distance-three single-error syndrome table are also cross-checked against an independent implementation; see Independent Stim conformance.
qec-canvas is available under the MIT License.



