Skip to content

Repository files navigation

qec-canvas

CI Release Python 3.11+ License: MIT

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.

Twelve-round memory experiment with labelled faults, syndromes, decoder windows, and logical-observable flip parity

Install

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.whl

For source development, use the setup under Development.

Quick start

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 == 1

Validation 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.

Built-in code families

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 == 1

Data 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.

Visualise codes and checks

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.

Distance-three rotated planar code with checks, boundaries, and logical representatives

Selected X3 check with spatial support and ordered extraction circuit

Both checked-in images can be reproduced together:

python examples/code_visualisations.py --output-directory docs/images

Extraction 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.

Plan experiments and inspect traces

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 == 2

ExperimentTrace 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.

Sample stochastic errors

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.

Run the Python reference backend

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.

Decode a complete trace

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.

Decode incrementally with bounded lookahead

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.

Complete memory example

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.png

It 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.

When decoding produces a logical failure

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.png

Those 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.

Distance-three memory experiment in which two data errors cause an undetected logical-X residual

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.

What v0.1 provides

The first release provides one small, explainable end-to-end path:

  1. define a distance-three rotated planar surface code;
  2. inspect its checks, logical operators, and syndrome-extraction schedule;
  3. run a repeated memory experiment with reproducible Pauli and measurement noise;
  4. decode the syndrome history with a transparent reference decoder, in batch or sliding-window mode; and
  5. visualise the faults, measurements, matching decisions, and resulting software Pauli frame from a common experiment trace; and
  6. 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.

Design principles

  • 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.

Non-goals for v0.1

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.

Development

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.

Research provenance

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.

Licence

qec-canvas is available under the MIT License.

About

An extensible visual workbench for small, explainable quantum error-correction experiments.

Topics

Resources

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages