From d8ae687b4a84b4f454e7e34b7e07a2f82b7ab2fa Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Thu, 18 Jun 2026 10:36:16 -0700 Subject: [PATCH 001/157] add better error message --- deq/deq/transpiler/code_validation.py | 66 ++++++++++ deq/deq/transpiler/jit_library_builder.py | 9 ++ deq/tests/transpiler/test_code_validation.py | 129 +++++++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 deq/tests/transpiler/test_code_validation.py diff --git a/deq/deq/transpiler/code_validation.py b/deq/deq/transpiler/code_validation.py index 5bb0ae4a..05405956 100644 --- a/deq/deq/transpiler/code_validation.py +++ b/deq/deq/transpiler/code_validation.py @@ -3,6 +3,7 @@ Checks that stabilizers and logical operators satisfy the required commutation relations of a valid stabilizer code: +- All stabilizer and logical operator qubit indices are within ``[0, n)``. - All stabilizers commute pairwise. - All stabilizers commute with every logical operator. - Within each logical qubit, the X and Z operators anticommute. @@ -28,12 +29,77 @@ def _pauli_products_commute(a: PauliProduct, b: PauliProduct) -> bool: return anticommuting_count % 2 == 0 +def _code_location(code: CodeDefinition) -> str: + """Return a ``" at file:line"`` suffix when source info is known, else ``""``.""" + if code.source_file is not None and code.source_line is not None: + return f" at {code.source_file}:{code.source_line}" + if code.source_line is not None: + return f" at line {code.source_line}" + return "" + + +def _check_qubit_indices_in_range(code: CodeDefinition) -> None: + """Raise ``ValueError`` if any qubit index in stabilizers or logicals is >= n. + + This catches a common authoring mistake where ``[[n,k,d]]`` does not + match the qubit indices actually used in the ``STABILIZER`` / + ``LOGICAL`` declarations. Without this check the offending index + later triggers a deep ``IndexError`` from the JIT transpiler with no + indication of which CODE or operator is at fault. + """ + n = code.n + operators: list[tuple[str, PauliProduct]] = [] + for idx, stab in enumerate(code.stabilizers): + operators.append((f"STABILIZER #{idx} ({stab})", stab)) + for idx, logical in enumerate(code.logicals): + operators.append( + (f"LOGICAL X{idx} ({logical.x_operator})", logical.x_operator) + ) + operators.append( + (f"LOGICAL Z{idx} ({logical.z_operator})", logical.z_operator) + ) + + max_used = -1 + for _, op in operators: + for term in op.terms: + if term.pauli == "I": + continue + if term.index > max_used: + max_used = term.index + + if max_used < n: + return + + for label, op in operators: + for term in op.terms: + if term.pauli == "I": + continue + if term.index >= n: + required_n = max_used + 1 + d_str = f",{code.d}" if code.d is not None else "" + raise ValueError( + f"CODE {code.name!r}{_code_location(code)} declares " + f"[[{n},{code.k}{d_str}]] (n={n} physical qubit" + f"{'s' if n != 1 else ''}), but {label} uses qubit " + f"index {term.index}, which is out of range [0, {n}).\n" + f" Hint: either increase n in the [[n,k,d]] header " + f"(need n >= {required_n} for the qubit indices used " + f"in this CODE) or remove the offending qubit index." + ) + + def validate_code(code: CodeDefinition) -> None: """Validate the algebraic consistency of a ``CodeDefinition``. Raises ``ValueError`` with a descriptive message on the first violation found. """ + # 0. Qubit indices in stabilizers and logicals are within [0, n). + # This must run before commutation checks; otherwise an out-of-range + # index later surfaces as a bare ``IndexError`` from the JIT + # transpiler with no hint at the offending CODE. + _check_qubit_indices_in_range(code) + # 1. Stabilizers commute pairwise. for i, si in enumerate(code.stabilizers): for j in range(i + 1, len(code.stabilizers)): diff --git a/deq/deq/transpiler/jit_library_builder.py b/deq/deq/transpiler/jit_library_builder.py index b0c009cd..d84b840e 100644 --- a/deq/deq/transpiler/jit_library_builder.py +++ b/deq/deq/transpiler/jit_library_builder.py @@ -308,6 +308,15 @@ def _validate_port_qubit_count( kind: str, ) -> None: """Raise if the port declares a different number of qubits than the code's ``n``.""" + if port.code_name not in codes: + known = sorted(codes) + known_str = ", ".join(repr(name) for name in known) if known else "(none)" + raise ValueError( + f"{kind} port in GADGET {gadget_name!r} references undefined " + f"CODE {port.code_name!r}. Known CODE names: {known_str}.\n" + f" Hint: define a 'CODE {port.code_name} [[n,k,d]] {{ ... }}' " + f"block, or change the {kind} port to reference an existing code." + ) code = codes[port.code_name] if len(port.qubit_indices) != code.n: raise ValueError( diff --git a/deq/tests/transpiler/test_code_validation.py b/deq/tests/transpiler/test_code_validation.py new file mode 100644 index 00000000..95c4b6d7 --- /dev/null +++ b/deq/tests/transpiler/test_code_validation.py @@ -0,0 +1,129 @@ +"""Tests for friendly error messages from CODE/port validation. + +These messages exist to help users locate authoring mistakes in +``.deq`` files (e.g. ``CODE Foo[[1,1,1]]`` whose stabilizers actually +reference qubit indices 1 and 2). Without them, the underlying +problems surface as deep ``IndexError`` / ``KeyError`` exceptions +from the JIT transpiler that give no hint at the offending CODE, +operator, or GADGET. +""" + +import pytest + +from deq.circuit.parser import parse +from deq.transpiler.code_validation import validate_code +from deq.transpiler.jit_library_builder import build_jit_library + + +class TestQubitIndexRangeValidation: + """``validate_code`` rejects stabilizer/logical qubit indices >= n.""" + + def test_stabilizer_index_out_of_range_message(self) -> None: + source = """ + CODE RepetitionCode[[1,1,1]] { + LOGICAL X0 Z0 + STABILIZER Z0*Z1 Z1*Z2 + } + """ + qfile = parse(source) + code = qfile.definitions[0] + with pytest.raises(ValueError) as exc: + validate_code(code) + msg = str(exc.value) + assert "RepetitionCode" in msg + assert "STABILIZER" in msg + assert "out of range" in msg + assert "[0, 1)" in msg + assert "n >= 3" in msg + + def test_logical_index_out_of_range_message(self) -> None: + source = """ + CODE WrongN[[1,1,1]] { + LOGICAL X0*X1*X2 Z0 + STABILIZER + } + """ + qfile = parse(source) + code = qfile.definitions[0] + with pytest.raises(ValueError) as exc: + validate_code(code) + msg = str(exc.value) + assert "WrongN" in msg + assert "LOGICAL X0" in msg + assert "out of range" in msg + assert "n >= 3" in msg + + def test_qubit_index_range_check_raised_via_build_jit_library(self) -> None: + source = """ + CODE RepetitionCode[[1,1,1]] { + LOGICAL X0*X1*X2 Z0 + STABILIZER Z0*Z1 Z1*Z2 + } + """ + qfile = parse(source) + with pytest.raises(ValueError, match="out of range"): + build_jit_library(qfile) + + def test_in_range_indices_pass(self) -> None: + source = """ + CODE RepetitionCode[[3,1,1]] { + LOGICAL X0*X1*X2 Z0 + STABILIZER Z0*Z1 Z1*Z2 + } + """ + qfile = parse(source) + code = qfile.definitions[0] + validate_code(code) + + def test_error_message_mentions_source_line(self) -> None: + source = "\n\n\nCODE Tiny[[1,1,1]] {\n LOGICAL X0 Z0\n STABILIZER Z0*Z3\n}\n" + qfile = parse(source) + code = qfile.definitions[0] + with pytest.raises(ValueError) as exc: + validate_code(code) + msg = str(exc.value) + assert "line" in msg + + +class TestUnknownPortCodeName: + """``_validate_port_qubit_count`` reports a friendly error for unknown CODE.""" + + def test_unknown_input_port_lists_known_codes(self) -> None: + source = """ + CODE Real[[1,1,1]] { + LOGICAL X0 Z0 + STABILIZER + } + GADGET G { + INPUT Trivial 0 + OUTPUT Real 0 + } + """ + qfile = parse(source) + with pytest.raises(ValueError) as exc: + build_jit_library(qfile) + msg = str(exc.value) + assert "INPUT" in msg + assert "GADGET 'G'" in msg + assert "'Trivial'" in msg + assert "Known CODE names" in msg + assert "'Real'" in msg + + def test_unknown_output_port_message(self) -> None: + source = """ + CODE Real[[1,1,1]] { + LOGICAL X0 Z0 + STABILIZER + } + GADGET G { + INPUT Real 0 + OUTPUT Missing 0 + } + """ + qfile = parse(source) + with pytest.raises(ValueError) as exc: + build_jit_library(qfile) + msg = str(exc.value) + assert "OUTPUT" in msg + assert "'Missing'" in msg + assert "Known CODE names" in msg From 426ebf027bd5bef3f695c27e6c708f075b6d2b7d Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Thu, 18 Jun 2026 14:58:46 -0700 Subject: [PATCH 002/157] add support for REPROPAGATE and --keep-noise --- deq/deq/cli/annotate.py | 13 +- deq/deq/transpiler/check_plugins/__init__.py | 2 +- deq/deq/transpiler/compose_builder.py | 525 +++++++++++++++++- deq/deq/transpiler/jit_annotate.py | 502 ++++++----------- deq/deq/transpiler/jit_library_builder.py | 26 +- deq/deq/transpiler/jit_noise_builder.py | 26 +- deq/tests/circuit/fixtures/teleportation.deq | 57 ++ .../repetition_code/repetition_code.deq | 1 + deq/tests/circuit/test_annotate_keep_noise.py | 209 +++++++ .../transpiler/jit_library_builder_test.py | 8 +- deq/tests/transpiler/jit_propagate_test.py | 25 + .../transpiler/test_compose_repropagate.py | 390 +++++++++++++ 12 files changed, 1440 insertions(+), 344 deletions(-) create mode 100644 deq/tests/circuit/fixtures/teleportation.deq create mode 100644 deq/tests/circuit/test_annotate_keep_noise.py create mode 100644 deq/tests/transpiler/test_compose_repropagate.py diff --git a/deq/deq/cli/annotate.py b/deq/deq/cli/annotate.py index fc35d523..58c67d15 100644 --- a/deq/deq/cli/annotate.py +++ b/deq/deq/cli/annotate.py @@ -25,6 +25,9 @@ def annotate( skip_mako_warning: bool = False, #: skip verification that annotated output transpiles identically no_verify: bool = False, + #: keep noise instructions verbatim instead of commenting them + #: out and emitting expanded ERROR rows + keep_noise: bool = False, ) -> None: """ Rewrite a .deq file to mirror the structure of its compiled .deq.jit. @@ -44,6 +47,14 @@ def annotate( error probabilities are reproducible. Use ``--no-verify`` to skip this (faster but no correctness guarantee). + With ``--keep-noise``, noise instructions (``X_ERROR``, + ``DEPOLARIZE1/2``, noisy measurements, etc.) are emitted verbatim + in the annotated output and the corresponding ``ERROR(p) ...`` + rows are *not* emitted. Re-transpilation of the annotated file + re-derives those ERROR rows from the kept noise instructions. + This is the recommended mode for producing a Stim-simulatable + annotated file. + Args: deq_file: path to the input .deq file. out: path to write the annotated output to. Defaults to @@ -65,7 +76,7 @@ def annotate( skip_mako_warning=skip_mako_warning, ) - rendered = _annotate_impl(qfile) + rendered = _annotate_impl(qfile, keep_noise=keep_noise) # Determine output path. if out is None: diff --git a/deq/deq/transpiler/check_plugins/__init__.py b/deq/deq/transpiler/check_plugins/__init__.py index ca629da8..e11026fa 100644 --- a/deq/deq/transpiler/check_plugins/__init__.py +++ b/deq/deq/transpiler/check_plugins/__init__.py @@ -152,7 +152,7 @@ def _load_builtin(name: str) -> None: # Known decorator names per definition type. _KNOWN_GADGET_DECORATORS = frozenset({"GTYPE", "CHECKS"}) _KNOWN_CODE_DECORATORS = frozenset({"PTYPE"}) -_KNOWN_COMPOSE_DECORATORS = frozenset({"GTYPE"}) +_KNOWN_COMPOSE_DECORATORS = frozenset({"GTYPE", "REPROPAGATE"}) def warn_unrecognized_decorators( diff --git a/deq/deq/transpiler/compose_builder.py b/deq/deq/transpiler/compose_builder.py index a1836eb0..c1dcc5c9 100644 --- a/deq/deq/transpiler/compose_builder.py +++ b/deq/deq/transpiler/compose_builder.py @@ -11,7 +11,7 @@ # pylint: disable=no-member -from typing import Callable, Mapping +from typing import Callable, Mapping, Sequence import deq.proto.deq_bin_pb2 as pb import deq.proto.deq_jit_pb2 as jit_pb @@ -20,17 +20,21 @@ from deq.circuit.model import ( CodeDefinition, ComposeDefinition, + ComposeStatement, GadgetApplication, GadgetDefinition, + GadgetStatement, InputPort, Instruction, OutputPort, + PauliTarget, QubitTarget, RepeatBlock, + Target, ) from deq.compiler.jit_compiler import static_jit_compiler from deq.spec.canonical import merge -from deq.transpiler.jit_transpiler import num_frame_columns +from deq.transpiler.jit_transpiler import flatten_body, num_frame_columns # --------------------------------------------------------------------------- # COMPOSE validation @@ -54,14 +58,23 @@ def validate_compose( declared* gadget/compose names visible to ``compose``. Names declared later in the file are not visible. """ - unsupported = [d for d in compose.decorators if d.name != "GTYPE"] + unsupported = [ + d for d in compose.decorators if d.name not in ("GTYPE", "REPROPAGATE") + ] if unsupported: names = ", ".join(d.name for d in unsupported) raise ValueError( - f"COMPOSE {compose.name!r}: only @GTYPE is supported " - f"on COMPOSE definitions (got @{names})" + f"COMPOSE {compose.name!r}: only @GTYPE and @REPROPAGATE are " + f"supported on COMPOSE definitions (got @{names})" ) + for deco in compose.decorators: + if deco.name == "REPROPAGATE" and deco.arguments: + raise ValueError( + f"COMPOSE {compose.name!r}: @REPROPAGATE takes no arguments " + f"(got {deco.arguments!r})" + ) + declared_names = set(gadget_definitions) | set(compose_definitions) def _lookup(name: str) -> GadgetDefinition | ComposeDefinition: @@ -400,6 +413,447 @@ def _get_def(name: str) -> GadgetDefinition | ComposeDefinition: raise ValueError("\n".join(msg_lines)) +# =================================================================== +# Compose body expansion (recursive, with dense qubit remapping) +# =================================================================== +# +# These helpers inline a COMPOSE body into a flat +# (input_ports, circuit, output_ports) triple as if it were a single +# GADGET. They are used both by the annotate tool (to render a COMPOSE +# as an inlined GADGET block) and by the @REPROPAGATE compose path +# (to recompute propagation matrices from circuit flow). + + +def _flatten_compose_apps_with_bindings( + body: Sequence[ComposeStatement], + known_names: set[str], +) -> list[tuple[str, list[int], list[int]]]: + """Flatten a compose body into ``(name, in_wires, out_wires)`` tuples. + + ``REPEAT`` blocks are unrolled. Shortcut applications (``Idle 0``) + are recognized by matching the instruction name against *known_names*. + """ + result: list[tuple[str, list[int], list[int]]] = [] + for stmt in body: + if isinstance(stmt, RepeatBlock): + sub = _flatten_compose_apps_with_bindings(list(stmt.body), known_names) + for _ in range(stmt.count): + result.extend(sub) + elif isinstance(stmt, GadgetApplication): + in_wires = list(stmt.in_indices) if stmt.in_indices is not None else [] + out_wires = list(stmt.out_indices) if stmt.out_indices is not None else [] + result.append((stmt.gadget_name, in_wires, out_wires)) + elif isinstance(stmt, Instruction) and stmt.name in known_names: + wires = [t.index for t in stmt.targets if isinstance(t, QubitTarget)] + result.append((stmt.name, wires, wires)) + return result + + +def _expand_definition( + name: str, + gadget_defs: Mapping[str, GadgetDefinition], + compose_defs: Mapping[str, ComposeDefinition], + known_names: set[str], + codes: Mapping[str, CodeDefinition], +) -> tuple[list[InputPort], list[GadgetStatement], list[OutputPort]]: + """Expand a single definition into ``(input_ports, circuit, output_ports)``. + + For a ``GADGET``, returns its raw ports and Stim instructions. + For a ``COMPOSE``, recursively expands with qubit remapping. + """ + if name in gadget_defs: + gadget = gadget_defs[name] + flat = flatten_body(list(gadget.body)) + inputs = [s for s in flat if isinstance(s, InputPort)] + outputs = [s for s in flat if isinstance(s, OutputPort)] + circuit: list[GadgetStatement] = [s for s in flat if isinstance(s, Instruction)] + return inputs, circuit, outputs + if name in compose_defs: + return expand_compose_circuit( + compose_defs[name], gadget_defs, compose_defs, known_names, codes + ) + return [], [], [] + + +def expand_compose_circuit( + compose: ComposeDefinition, + gadget_defs: Mapping[str, GadgetDefinition], + compose_defs: Mapping[str, ComposeDefinition], + known_names: set[str], + codes: Mapping[str, CodeDefinition], +) -> tuple[list[InputPort], list[GadgetStatement], list[OutputPort]]: + """Recursively expand a compose with dense qubit remapping. + + Port data qubits are numbered ``0 .. total_data-1`` (dense). + Ancilla qubits follow starting at ``total_data``. + """ + compose_inputs = compose.input_ports + compose_outputs = compose.output_ports + + # Determine all compose-level wires and their qubit ranges. + # Wires are discovered from compose INPUT/OUTPUT ports and from + # sub-gadget application bindings (for composes without explicit ports). + wire_code: dict[int, str] = {} + for port in compose_inputs: + for wire_idx in port.qubit_indices: + wire_code[wire_idx] = port.code_name + for port in compose_outputs: + for wire_idx in port.qubit_indices: + wire_code.setdefault(wire_idx, port.code_name) + + # Infer wire codes from sub-gadget bindings when compose has no + # explicit INPUT/OUTPUT for a wire. + apps = _flatten_compose_apps_with_bindings(list(compose.body), known_names) + for app_name, in_wires, out_wires in apps: + sub_def_inputs: list[InputPort] = [] + sub_def_outputs: list[OutputPort] = [] + if app_name in gadget_defs: + flat = flatten_body(list(gadget_defs[app_name].body)) + sub_def_inputs = [s for s in flat if isinstance(s, InputPort)] + sub_def_outputs = [s for s in flat if isinstance(s, OutputPort)] + elif app_name in compose_defs: + sub_body = compose_defs[app_name].body + sub_def_inputs = [s for s in sub_body if isinstance(s, InputPort)] + sub_def_outputs = [s for s in sub_body if isinstance(s, OutputPort)] + for port_idx, wire_idx in enumerate(in_wires): + if wire_idx not in wire_code and port_idx < len(sub_def_inputs): + wire_code[wire_idx] = sub_def_inputs[port_idx].code_name + for port_idx, wire_idx in enumerate(out_wires): + if wire_idx not in wire_code and port_idx < len(sub_def_outputs): + wire_code[wire_idx] = sub_def_outputs[port_idx].code_name + + sorted_wires = sorted(wire_code) + wire_n = {w: codes[wire_code[w]].n for w in sorted_wires} + + # A wire's code (and therefore its qubit count) can change as + # sub-gadgets run. Pre-compute the + # maximum qubit count each wire ever holds so we can allocate + # enough contiguous dense indices to cover its peak size. + wire_max_n: dict[int, int] = dict(wire_n) + for app_name, _in_wires, out_wires in apps: + sub_def_outputs2: list[OutputPort] = [] + if app_name in gadget_defs: + flat = flatten_body(list(gadget_defs[app_name].body)) + sub_def_outputs2 = [s for s in flat if isinstance(s, OutputPort)] + elif app_name in compose_defs: + sub_def_outputs2 = [ + s for s in compose_defs[app_name].body if isinstance(s, OutputPort) + ] + for port_idx, wire_idx in enumerate(out_wires): + if port_idx < len(sub_def_outputs2) and wire_idx in wire_max_n: + new_n = codes[sub_def_outputs2[port_idx].code_name].n + if new_n > wire_max_n[wire_idx]: + wire_max_n[wire_idx] = new_n + + wire_offset: dict[int, int] = {} + cursor = 0 + for w in sorted_wires: + wire_offset[w] = cursor + cursor += wire_max_n[w] + total_data = cursor + + # Build compose-level ports with dense qubit indices. + dense_inputs: list[InputPort] = [] + for port in compose_inputs: + wire_idx = port.qubit_indices[0] + off = wire_offset[wire_idx] + n = wire_n[wire_idx] + dense_inputs.append( + InputPort(code_name=port.code_name, qubit_indices=list(range(off, off + n))) + ) + dense_outputs: list[OutputPort] = [] + for port in compose_outputs: + wire_idx = port.qubit_indices[0] + off = wire_offset[wire_idx] + n = wire_n[wire_idx] + dense_outputs.append( + OutputPort( + code_name=port.code_name, qubit_indices=list(range(off, off + n)) + ) + ) + + if not apps: + return dense_inputs, [], dense_outputs + + # Track the current dense qubit indices for each wire, updated after + # each sub-gadget to reflect output port permutations. + wire_qubits: dict[int, list[int]] = {} + for w in sorted_wires: + off = wire_offset[w] + n = wire_n[w] + wire_qubits[w] = list(range(off, off + n)) + + circuit: list[GadgetStatement] = [] + for app_name, in_wires, out_wires in apps: + sub_inputs, sub_stmts, sub_outputs = _expand_definition( + app_name, gadget_defs, compose_defs, known_names, codes + ) + + # Build qubit remapping: port qubits → dense data indices. + qmap: dict[int, int] = {} + for port_idx, wire_idx in enumerate(in_wires): + if port_idx < len(sub_inputs): + current = wire_qubits[wire_idx] + for local_i, phys_q in enumerate(sub_inputs[port_idx].qubit_indices): + if local_i < len(current): + qmap[phys_q] = current[local_i] + for port_idx, wire_idx in enumerate(out_wires): + if port_idx < len(sub_outputs): + out_phys_qs = sub_outputs[port_idx].qubit_indices + current = wire_qubits[wire_idx] + # If the sub-gadget's output port carries more qubits + # than the wire currently holds, extend the wire into its + # reserved dense block. + offset = wire_offset[wire_idx] + while len(current) < len(out_phys_qs): + current.append(offset + len(current)) + for local_i, phys_q in enumerate(out_phys_qs): + qmap.setdefault(phys_q, current[local_i]) + + # Non-port qubits → ancilla indices after data qubits. + all_qs = _collect_qubit_indices_from_stmts(sub_stmts) + ancilla_cursor = total_data + for q in sorted(all_qs): + if q not in qmap: + qmap[q] = ancilla_cursor + ancilla_cursor += 1 + + for stmt in sub_stmts: + if isinstance(stmt, Instruction): + circuit.append(_remap_instruction(stmt, qmap)) + else: + circuit.append(stmt) + + # Update wire qubit maps based on output port permutations. + # The output port's qubit_indices define which sub-gadget-local + # qubits map to each position in the wire. After remapping + # through qmap, we get the new dense qubit order for that wire. + for port_idx, wire_idx in enumerate(out_wires): + if port_idx < len(sub_outputs): + new_order: list[int] = [] + for phys_q in sub_outputs[port_idx].qubit_indices: + new_order.append(qmap[phys_q]) + wire_qubits[wire_idx] = new_order + + # Rebuild dense_outputs using the final wire qubit order (after + # all permutations have been applied by sub-gadgets). + dense_outputs = [] + for port in compose_outputs: + wire_idx = port.qubit_indices[0] + dense_outputs.append( + OutputPort( + code_name=port.code_name, qubit_indices=list(wire_qubits[wire_idx]) + ) + ) + + return dense_inputs, circuit, dense_outputs + + +def _collect_qubit_indices_from_stmts( + stmts: Sequence[GadgetStatement], +) -> set[int]: + """Collect all qubit indices referenced in instructions.""" + indices: set[int] = set() + for stmt in stmts: + if isinstance(stmt, Instruction): + for t in stmt.targets: + if isinstance(t, (QubitTarget, PauliTarget)): + indices.add(t.index) + return indices + + +def _remap_instruction(stmt: Instruction, qmap: dict[int, int]) -> Instruction: + """Return a copy of *stmt* with qubit indices remapped via *qmap*.""" + new_targets: list[Target] = [] + for t in stmt.targets: + if isinstance(t, QubitTarget): + new_targets.append( + QubitTarget(index=qmap.get(t.index, t.index), inverted=t.inverted) + ) + elif isinstance(t, PauliTarget): + new_targets.append( + PauliTarget( + pauli=t.pauli, index=qmap.get(t.index, t.index), inverted=t.inverted + ) + ) + else: + new_targets.append(t) + return Instruction( + name=stmt.name, + tag=stmt.tag, + arguments=list(stmt.arguments), + targets=new_targets, + ) + + +# =================================================================== +# @REPROPAGATE: inline-circuit compose builder +# =================================================================== + + +def has_repropagate(compose: ComposeDefinition) -> bool: + """Return ``True`` if *compose* carries an ``@REPROPAGATE`` decorator.""" + return any(d.name == "REPROPAGATE" for d in compose.decorators) + + +def compose_to_synthetic_gadget( + compose: ComposeDefinition, + gadget_definitions: Mapping[str, GadgetDefinition], + compose_definitions: Mapping[str, ComposeDefinition], + codes: Mapping[str, CodeDefinition], +) -> GadgetDefinition: + """Inline a COMPOSE body into a flat synthetic ``GadgetDefinition``. + + The synthetic gadget has the same name as *compose*; its body is + ``input_ports + circuit + output_ports`` produced by + :func:`expand_compose_circuit`. Decorators are dropped — the caller + is responsible for re-attaching ``@GTYPE``/``@CHECKS`` on the + pipeline side as needed. + + Used by ``@REPROPAGATE`` composes (both at build time and at + annotate time) so propagation matrices and noise-derived ERRORs + are computed from circuit flow rather than from sub-gadget matrix + composition. + """ + known_names = set(gadget_definitions) | set(compose_definitions) + input_ports, circuit, output_ports = expand_compose_circuit( + compose, + gadget_definitions, + compose_definitions, + known_names, + codes, + ) + body: list = [*input_ports, *circuit, *output_ports] + return GadgetDefinition( + name=compose.name, + body=body, + decorators=[], + source_file=compose.source_file, + source_line=compose.source_line, + ) + + +def _build_repropagated_compose( + compose: ComposeDefinition, + *, + gtype: int, + gadget_definitions: Mapping[str, GadgetDefinition], + compose_definitions: Mapping[str, ComposeDefinition], + jit_gadget_types_by_name: Mapping[str, jit_pb.JitGadgetType], + codes: Mapping[str, CodeDefinition], + ptype_of_code: Mapping[str, int], + port_types: list[jit_pb.JitPortType], +) -> jit_pb.JitGadgetType: + """Build a JitGadgetType for an ``@REPROPAGATE`` COMPOSE. + + Routes the COMPOSE through *both* pipelines and combines them: + + * The merge() / Rust JIT compiler pipeline produces the + *structural* output: measurements, finished/unfinished checks, + and readouts. These reflect the sub-gadget composition + (e.g. round-to-round comparison checks across repeated syndrome + extraction) and must be preserved — otherwise users who add + ``@REPROPAGATE`` silently lose the check basis their sub-gadgets + define. + * The flat-circuit pipeline (inlining the body into a synthetic + :class:`GadgetDefinition` and running + :func:`_build_jit_gadget_type`) produces the *propagation* + output: ``correction_propagation``, ``physical_correction``, + ``logical_correction``, and the noise-derived ``ERROR`` rows. + These come from circuit-flow analysis on the inlined body and + cover conditional logical corrections that matrix composition + cannot represent (the teleportation case). + + The merge-derived check basis is fed into ``_build_jit_gadget_type`` + via its ``check_override`` parameter so the propagation/error + derivation references the *same* check indices the merge pipeline + produces. This keeps everything self-consistent. + + Note: a deferred import is used to avoid a circular dependency + between this module and ``jit_library_builder``. + """ + from deq.transpiler.jit_library_builder import ( # local import: cycle + _build_jit_gadget_type, + ) + + merge_jt = _build_merge_compose( + compose, + gtype=gtype, + gadget_definitions=gadget_definitions, + compose_definitions=compose_definitions, + jit_gadget_types_by_name=jit_gadget_types_by_name, + codes=codes, + ptype_of_code=ptype_of_code, + port_types=port_types, + ) + synthetic = compose_to_synthetic_gadget( + compose, gadget_definitions, compose_definitions, codes + ) + finished, unfinished = _check_basis_from_jit_gadget_type( + merge_jt, synthetic, codes + ) + return _build_jit_gadget_type( + synthetic, + gtype, + dict(ptype_of_code), + dict(codes), + check_override=(finished, unfinished), + ) + + +def _check_basis_from_jit_gadget_type( + jt: jit_pb.JitGadgetType, + synthetic: GadgetDefinition, + codes: Mapping[str, CodeDefinition], +) -> tuple[list[tuple[frozenset[int], bool]], list[tuple[frozenset[int], bool]]]: + """Recover the ``(members, parity)`` check basis from a JitGadgetType. + + Inverts the encoding done by ``_build_jit_gadget_type._build_check``: + converts each :class:`JitGadgetType.Check`'s ``PresentMeasurement`` + list back into a ``frozenset`` of global measurement indices, and + re-adds the implicit output-virtual index for each unfinished check. + + The global indexing matches what ``resolve_gadget_checks`` returns + for *synthetic*: ``[input-virtual | internal | output-virtual]`` in + that order, with input-virtual measurements grouped per input port. + """ + input_ports = synthetic.input_ports + output_ports = synthetic.output_ports + input_stab_counts = [len(codes[p.code_name].stabilizers) for p in input_ports] + iv_count = sum(input_stab_counts) + internal_count = len(jt.base.measurements) + ov_start = iv_count + internal_count + num_ov = sum(len(codes[p.code_name].stabilizers) for p in output_ports) + + def members_of(check: jit_pb.JitGadgetType.Check) -> set[int]: + members: set[int] = set() + for m in check.measurements: + if m.HasField("input_port"): + global_idx = ( + sum(input_stab_counts[: m.input_port]) + m.measurement_index + ) + else: + global_idx = iv_count + m.measurement_index + members.add(global_idx) + return members + + finished: list[tuple[frozenset[int], bool]] = [ + (frozenset(members_of(c)), bool(c.base.naturally_flipped)) + for c in jt.finished_checks + ] + unfinished: list[tuple[frozenset[int], bool]] = [] + for k, c in enumerate(jt.unfinished_checks): + if k >= num_ov: + raise ValueError( + f"merge() produced more unfinished checks ({len(jt.unfinished_checks)}) " + f"than the synthetic gadget has output-virtual measurements ({num_ov})" + ) + members = members_of(c) + members.add(ov_start + k) + unfinished.append((frozenset(members), bool(c.base.naturally_flipped))) + return finished, unfinished + + # =================================================================== # Public API — JIT-based compose builder # =================================================================== @@ -415,20 +869,69 @@ def build_compose_jit_gadget_type( codes: Mapping[str, CodeDefinition], ptype_of_code: Mapping[str, int], port_types: list[jit_pb.JitPortType], +) -> jit_pb.JitGadgetType: + """Build a composed JitGadgetType. + + By default uses the merge() / Rust JIT compiler pipeline (see + :func:`_build_merge_compose`). When the COMPOSE has the + ``@REPROPAGATE`` decorator, a hybrid path is taken: the structural + output (measurements, checks, readouts) still comes from + merge(), but the propagation matrices and noise-derived ERRORs are + recomputed from circuit flow on the inlined body. See + :func:`_build_repropagated_compose` for details. + """ + validate_compose( + compose, + gadget_definitions=gadget_definitions, + compose_definitions=compose_definitions, + ) + + if has_repropagate(compose): + return _build_repropagated_compose( + compose, + gtype=gtype, + gadget_definitions=gadget_definitions, + compose_definitions=compose_definitions, + jit_gadget_types_by_name=jit_gadget_types_by_name, + codes=codes, + ptype_of_code=ptype_of_code, + port_types=port_types, + ) + + return _build_merge_compose( + compose, + gtype=gtype, + gadget_definitions=gadget_definitions, + compose_definitions=compose_definitions, + jit_gadget_types_by_name=jit_gadget_types_by_name, + codes=codes, + ptype_of_code=ptype_of_code, + port_types=port_types, + ) + + +def _build_merge_compose( + compose: ComposeDefinition, + *, + gtype: int, + gadget_definitions: Mapping[str, GadgetDefinition], + compose_definitions: Mapping[str, ComposeDefinition], + jit_gadget_types_by_name: Mapping[str, jit_pb.JitGadgetType], + codes: Mapping[str, CodeDefinition], + ptype_of_code: Mapping[str, int], + port_types: list[jit_pb.JitPortType], ) -> jit_pb.JitGadgetType: """Build a composed JitGadgetType using mock gadgets + JIT compiler + merge. - 1. Validate and expand the COMPOSE body. + 1. Expand the COMPOSE body and validate port-type compatibility + and dangling outputs. 2. Construct mock boundary gadgets to close off dangling ports. 3. Run the Rust JIT compiler to produce a complete Library. 4. Call ``merge()`` on only the real gadgets (excluding mocks). 5. Convert the ``MergedGadget`` to a ``JitGadgetType``. + + The caller is expected to have already run :func:`validate_compose`. """ - validate_compose( - compose, - gadget_definitions=gadget_definitions, - compose_definitions=compose_definitions, - ) inputs, outputs, apps = _expand_compose_body( list(compose.body), gadget_definitions=gadget_definitions, diff --git a/deq/deq/transpiler/jit_annotate.py b/deq/deq/transpiler/jit_annotate.py index 377a2e13..08322e62 100644 --- a/deq/deq/transpiler/jit_annotate.py +++ b/deq/deq/transpiler/jit_annotate.py @@ -35,12 +35,10 @@ CheckStatement, CodeDefinition, ComposeDefinition, - ComposeStatement, ConditionalStatement, Decorator, ErrorStatement, VirtualLogicalStatement, - GadgetApplication, GadgetDefinition, GadgetStatement, InputPort, @@ -48,24 +46,27 @@ KeywordArg, OutputPort, PauliProduct, - PauliTarget, PreselectStatement, ProgramDefinition, PropagateStatement, DeqFile, - QubitTarget, ReadoutStatement, RepeatBlock, - Target, ) from deq.transpiler.jit_transpiler import ( Check, PortColumnLayout, flatten_body, - num_frame_columns, select_stabilizer_generators, ) from deq.transpiler.check_plugins import compute_layout, resolve_gadget_checks +from deq.transpiler.code_validation import validate_code +from deq.transpiler.compose_builder import ( + _check_basis_from_jit_gadget_type, + compose_to_synthetic_gadget, + expand_compose_circuit, + has_repropagate, +) from deq.transpiler.jit_library_builder import ( build_jit_library, build_readouts, @@ -90,10 +91,23 @@ from deq.transpiler.stim_constants import qubit_indices as _qubit_indices -def annotate(qfile: DeqFile) -> str: - """Render ``qfile`` as annotated ``.deq`` source mirroring its JIT form.""" - from deq.transpiler.code_validation import validate_code - +def annotate(qfile: DeqFile, *, keep_noise: bool = False) -> str: + """Render ``qfile`` as annotated ``.deq`` source mirroring its JIT form. + + Parameters + ---------- + qfile: + The parsed ``.deq`` file to annotate. + keep_noise: + When ``True``, noise instructions (and noise on noisy + measurements) are emitted verbatim into the annotated output + and the ``ERROR(p) ...`` rows derived from those noise + instructions are *not* emitted. Re-transpilation of the + annotated file re-derives the same ERRORs from the kept noise + instructions. When ``False`` (default), noise instructions + are commented out and the corresponding ERROR rows are emitted + explicitly. + """ codes: dict[str, CodeDefinition] = { d.name: d for d in qfile.definitions if isinstance(d, CodeDefinition) } @@ -119,6 +133,10 @@ def annotate(qfile: DeqFile) -> str: pt.base.name: pt.base.ptype for pt in library.port_types } + # COMPOSE definitions visible up to (and including) each compose, + # used by ``compose_to_synthetic_gadget`` for nested @REPROPAGATE. + compose_so_far: dict[str, ComposeDefinition] = {} + blocks: list[str] = [] for definition in qfile.definitions: if isinstance(definition, CodeDefinition): @@ -131,18 +149,55 @@ def annotate(qfile: DeqFile) -> str: if definition.name in jit_by_name else None ) - blocks.append(_annotate_gadget(definition, codes, gtype=gtype)) - elif isinstance(definition, ComposeDefinition): blocks.append( - _render_composed_gadget( - jit_by_name[definition.name], - stab_count_of_ptype, - definition, - gadget_defs, - compose_defs, - codes, + _annotate_gadget( + definition, codes, gtype=gtype, keep_noise=keep_noise ) ) + elif isinstance(definition, ComposeDefinition): + if has_repropagate(definition): + # @REPROPAGATE: render via the standard GADGET pipeline so + # propagation matrices and ERROR rows come from circuit + # flow on the inlined body, not from sub-gadget matrix + # composition. The check basis, however, comes from + # the merge() pipeline (already grafted onto + # ``jit_by_name[name]`` by ``build_jit_library``); we + # extract it and pass it through so the emitted CHECK + # statements and the internally derived propagation / + # ERROR rows reference the same check indices. + synthetic = compose_to_synthetic_gadget( + definition, gadget_defs, compose_so_far, codes + ) + gtype = ( + jit_by_name[definition.name].base.gtype + if definition.name in jit_by_name + else None + ) + check_override = _check_basis_from_jit_gadget_type( + jit_by_name[definition.name], synthetic, codes + ) + blocks.append( + _annotate_gadget( + synthetic, + codes, + gtype=gtype, + keep_noise=keep_noise, + check_override=check_override, + ) + ) + else: + blocks.append( + _render_composed_gadget( + jit_by_name[definition.name], + stab_count_of_ptype, + definition, + gadget_defs, + compose_defs, + codes, + keep_noise=keep_noise, + ) + ) + compose_so_far[definition.name] = definition elif isinstance(definition, ProgramDefinition): blocks.append(_emit_program(definition)) return "\n\n".join(blocks) + "\n" @@ -214,7 +269,22 @@ def _annotate_gadget( codes: dict[str, CodeDefinition], *, gtype: int | None = None, + keep_noise: bool = False, + check_override: tuple[ + list[tuple[frozenset[int], bool]], + list[tuple[frozenset[int], bool]], + ] + | None = None, ) -> str: + """Render *gadget* as a ``@CHECKS("manual", verify=0)`` GADGET block. + + When *check_override* is provided as ``(finished, unfinished)``, it + replaces what :func:`resolve_gadget_checks` would derive from the + gadget body. Used by ``@REPROPAGATE`` composes so the emitted + CHECK statements match the merge-derived check basis (the same + one the build pipeline grafts onto the flat-circuit propagation / + error derivation). + """ flat_body = flatten_body(list(gadget.body)) # Walk the body once to label every body position with the running @@ -227,10 +297,16 @@ def _annotate_gadget( running_counts.append(running) # Use the plugin system to derive the final check basis, - # respecting the gadget's @CHECKS decorator. - check_result = resolve_gadget_checks(gadget, codes) - finished = check_result.finished - unfinished = check_result.unfinished + # respecting the gadget's @CHECKS decorator. When the caller + # supplies an override (used by the @REPROPAGATE compose path to + # keep the merge-derived basis), use that instead. + if check_override is not None: + finished = check_override[0] + unfinished = check_override[1] + else: + check_result = resolve_gadget_checks(gadget, codes) + finished = check_result.finished + unfinished = check_result.unfinished # Emit ALL plugin-derived checks (finished + unfinished). # User-written CHECKs are dropped from the body and replaced by @@ -271,7 +347,9 @@ def _annotate_gadget( cp_pb, pc_pb, input_virtual_count, - ) = _compute_gadget_runtime_data(gadget, codes) + ) = _compute_gadget_runtime_data( + gadget, codes, check_override=check_override + ) # Compute column layouts for output and input ports. output_ports = gadget.output_ports @@ -313,17 +391,22 @@ def _annotate_gadget( lines.append(f" {_render_readout_statement(stmt, comment)}") readout_counter += 1 else: - for line in _render_body_statement(stmt, pre_running, codes): + for line in _render_body_statement(stmt, keep_noise=keep_noise): lines.append(line) - for error_row in noise_errors_at.get(body_index, []): - lines.append( - " " - + _render_jit_error_to_source( - error_row, - num_finished=num_finished, - layout=output_col_layout, + # When ``keep_noise`` is set, the noise instructions are emitted + # verbatim, so re-transpilation will re-derive the same ERROR + # rows from circuit flow. Emitting them here as well would + # duplicate them. + if not keep_noise: + for error_row in noise_errors_at.get(body_index, []): + lines.append( + " " + + _render_jit_error_to_source( + error_row, + num_finished=num_finished, + layout=output_col_layout, + ) ) - ) pre_running = running_counts[body_index] if body_index == finished_at_position: for check in finished: @@ -446,10 +529,16 @@ def _render_jit_error_to_source( def _render_body_statement( stmt: GadgetStatement, - pre_running: int, - codes: dict[str, CodeDefinition], + *, + keep_noise: bool = False, ) -> list[str]: - """Render a single body statement as one or more lines (already indented).""" + """Render a single body statement as one or more lines (already indented). + + When ``keep_noise`` is ``True``, noise instructions are emitted + verbatim and noisy measurements keep their probability arguments, + so re-transpilation re-derives the original ERROR rows from + circuit flow. + """ if isinstance(stmt, InputPort): return [_render_input_or_output(stmt, "INPUT")] if isinstance(stmt, OutputPort): @@ -469,6 +558,8 @@ def _render_body_statement( if isinstance(stmt, Instruction): name = stmt.name.upper() if name in NOISE_INSTRUCTIONS: + if keep_noise: + return [f" {stmt}"] return [f" # {stmt}"] # Noisy measurement: comment out original, emit clean version. if ( @@ -476,6 +567,8 @@ def _render_body_statement( and stmt.arguments[0] != 0 and name in NOISY_MEASUREMENT_INSTRUCTIONS ): + if keep_noise: + return [f" {stmt}"] clean = Instruction( name=stmt.name, tag=stmt.tag, arguments=[], targets=list(stmt.targets) ) @@ -740,6 +833,12 @@ def _render_auto_check( def _compute_gadget_runtime_data( gadget: GadgetDefinition, codes: dict[str, CodeDefinition], + *, + check_override: tuple[ + list[tuple[frozenset[int], bool]], + list[tuple[frozenset[int], bool]], + ] + | None = None, ) -> tuple[ dict[int, list["jit_pb.JitGadgetType.Error"]], int, @@ -769,9 +868,13 @@ def _compute_gadget_runtime_data( output_ports = gadget.output_ports layout = compute_layout(gadget, codes) - check_result = resolve_gadget_checks(gadget, codes) - finished = check_result.finished - unfinished = check_result.unfinished + if check_override is not None: + finished = check_override[0] + unfinished = check_override[1] + else: + check_result = resolve_gadget_checks(gadget, codes) + finished = check_result.finished + unfinished = check_result.unfinished _readouts_pb, _propagation, readouts_info = build_readouts( gadget, @@ -843,12 +946,20 @@ def _render_composed_gadget( gadget_defs: dict[str, GadgetDefinition], compose_defs: dict[str, ComposeDefinition], codes: dict[str, CodeDefinition], + *, + keep_noise: bool = False, ) -> str: """Render a composed ``JitGadgetType`` as a ``GADGET`` block. Instead of opaque placeholders, the actual circuit of each sub-gadget is inlined (noise instructions are commented out). Port qubits are densely numbered starting at 0; ancilla qubits follow. + + When ``keep_noise`` is ``True``, the noise instructions are emitted + verbatim and the merge-derived ``ERROR`` rows are skipped, so + re-transpilation re-derives them from the inlined circuit. This + is orthogonal to whether the COMPOSE has ``@REPROPAGATE``: it + only changes the noise rendering, not the propagation matrices. """ base = gadget.base name = base.name or f"AnonymousGadget{base.gtype}" @@ -880,15 +991,21 @@ def _render_composed_gadget( if isinstance(stmt, Instruction): name = stmt.name.upper() if name in NOISE_INSTRUCTIONS: - lines.append(f" # {stmt}") + if keep_noise: + lines.append(f" {stmt}") + else: + lines.append(f" # {stmt}") elif stmt.arguments and name in NOISY_MEASUREMENT_INSTRUCTIONS: - # Strip noise arguments from measurements (e.g. M(0.01) → M) - # so re-transpilation doesn't generate extra noise errors. - clean = Instruction( - name=stmt.name, - targets=stmt.targets, - ) - lines.append(f" {clean}") + if keep_noise: + lines.append(f" {stmt}") + else: + # Strip noise arguments from measurements (e.g. M(0.01) → M) + # so re-transpilation doesn't generate extra noise errors. + clean = Instruction( + name=stmt.name, + targets=stmt.targets, + ) + lines.append(f" {clean}") else: lines.append(f" {stmt}") @@ -950,17 +1067,21 @@ def _render_composed_gadget( ) lines.extend(propagate_lines) - # ERROR statements. - num_finished = len(gadget.finished_checks) - for error_row in gadget.errors: - lines.append( - " " - + _render_jit_error_to_source( - error_row, - num_finished=num_finished, - layout=output_col_layout, + # ERROR statements. When ``keep_noise`` is set, the noise + # instructions above are emitted verbatim, so re-transpilation + # re-derives the same ERROR rows from circuit flow — emitting + # them here as well would duplicate them. + if not keep_noise: + num_finished = len(gadget.finished_checks) + for error_row in gadget.errors: + lines.append( + " " + + _render_jit_error_to_source( + error_row, + num_finished=num_finished, + layout=output_col_layout, + ) ) - ) # Statistics summary lines.append("") @@ -976,273 +1097,6 @@ def _render_composed_gadget( return "\n".join(lines) -# --------------------------------------------------------------------------- -# COMPOSE circuit expansion helpers -# --------------------------------------------------------------------------- - - -def _flatten_compose_apps_with_bindings( - body: Sequence[ComposeStatement], - known_names: set[str], -) -> list[tuple[str, list[int], list[int]]]: - """Flatten a compose body into ``(name, in_wires, out_wires)`` tuples. - - ``REPEAT`` blocks are unrolled. Shortcut applications (``Idle 0``) - are recognized by matching the instruction name against *known_names*. - """ - result: list[tuple[str, list[int], list[int]]] = [] - for stmt in body: - if isinstance(stmt, RepeatBlock): - sub = _flatten_compose_apps_with_bindings(list(stmt.body), known_names) - for _ in range(stmt.count): - result.extend(sub) - elif isinstance(stmt, GadgetApplication): - in_wires = list(stmt.in_indices) if stmt.in_indices is not None else [] - out_wires = list(stmt.out_indices) if stmt.out_indices is not None else [] - result.append((stmt.gadget_name, in_wires, out_wires)) - elif isinstance(stmt, Instruction) and stmt.name in known_names: - wires = [t.index for t in stmt.targets if isinstance(t, QubitTarget)] - result.append((stmt.name, wires, wires)) - return result - - -def _expand_definition( - name: str, - gadget_defs: dict[str, GadgetDefinition], - compose_defs: dict[str, ComposeDefinition], - known_names: set[str], - codes: dict[str, CodeDefinition], -) -> tuple[list[InputPort], list[GadgetStatement], list[OutputPort]]: - """Expand a single definition into ``(input_ports, circuit, output_ports)``. - - For a ``GADGET``, returns its raw ports and Stim instructions. - For a ``COMPOSE``, recursively expands with qubit remapping. - """ - if name in gadget_defs: - gadget = gadget_defs[name] - flat = flatten_body(list(gadget.body)) - inputs = [s for s in flat if isinstance(s, InputPort)] - outputs = [s for s in flat if isinstance(s, OutputPort)] - circuit: list[GadgetStatement] = [s for s in flat if isinstance(s, Instruction)] - return inputs, circuit, outputs - if name in compose_defs: - return expand_compose_circuit( - compose_defs[name], gadget_defs, compose_defs, known_names, codes - ) - return [], [], [] - - -def expand_compose_circuit( - compose: ComposeDefinition, - gadget_defs: dict[str, GadgetDefinition], - compose_defs: dict[str, ComposeDefinition], - known_names: set[str], - codes: dict[str, CodeDefinition], -) -> tuple[list[InputPort], list[GadgetStatement], list[OutputPort]]: - """Recursively expand a compose with dense qubit remapping. - - Port data qubits are numbered ``0 .. total_data-1`` (dense). - Ancilla qubits follow starting at ``total_data``. - """ - compose_inputs = compose.input_ports - compose_outputs = compose.output_ports - - # Determine all compose-level wires and their qubit ranges. - # Wires are discovered from compose INPUT/OUTPUT ports and from - # sub-gadget application bindings (for composes without explicit ports). - wire_code: dict[int, str] = {} - for port in compose_inputs: - for wire_idx in port.qubit_indices: - wire_code[wire_idx] = port.code_name - for port in compose_outputs: - for wire_idx in port.qubit_indices: - wire_code.setdefault(wire_idx, port.code_name) - - # Infer wire codes from sub-gadget bindings when compose has no - # explicit INPUT/OUTPUT for a wire. - apps = _flatten_compose_apps_with_bindings(list(compose.body), known_names) - for app_name, in_wires, out_wires in apps: - sub_def_inputs: list[InputPort] = [] - sub_def_outputs: list[OutputPort] = [] - if app_name in gadget_defs: - flat = flatten_body(list(gadget_defs[app_name].body)) - sub_def_inputs = [s for s in flat if isinstance(s, InputPort)] - sub_def_outputs = [s for s in flat if isinstance(s, OutputPort)] - elif app_name in compose_defs: - sub_body = compose_defs[app_name].body - sub_def_inputs = [s for s in sub_body if isinstance(s, InputPort)] - sub_def_outputs = [s for s in sub_body if isinstance(s, OutputPort)] - for port_idx, wire_idx in enumerate(in_wires): - if wire_idx not in wire_code and port_idx < len(sub_def_inputs): - wire_code[wire_idx] = sub_def_inputs[port_idx].code_name - for port_idx, wire_idx in enumerate(out_wires): - if wire_idx not in wire_code and port_idx < len(sub_def_outputs): - wire_code[wire_idx] = sub_def_outputs[port_idx].code_name - - sorted_wires = sorted(wire_code) - wire_n = {w: codes[wire_code[w]].n for w in sorted_wires} - - # A wire's code (and therefore its qubit count) can change as - # sub-gadgets run. Pre-compute the - # maximum qubit count each wire ever holds so we can allocate - # enough contiguous dense indices to cover its peak size. - wire_max_n: dict[int, int] = dict(wire_n) - for app_name, _in_wires, out_wires in apps: - sub_def_outputs: list[OutputPort] = [] - if app_name in gadget_defs: - flat = flatten_body(list(gadget_defs[app_name].body)) - sub_def_outputs = [s for s in flat if isinstance(s, OutputPort)] - elif app_name in compose_defs: - sub_def_outputs = [ - s for s in compose_defs[app_name].body if isinstance(s, OutputPort) - ] - for port_idx, wire_idx in enumerate(out_wires): - if port_idx < len(sub_def_outputs) and wire_idx in wire_max_n: - new_n = codes[sub_def_outputs[port_idx].code_name].n - if new_n > wire_max_n[wire_idx]: - wire_max_n[wire_idx] = new_n - - wire_offset: dict[int, int] = {} - cursor = 0 - for w in sorted_wires: - wire_offset[w] = cursor - cursor += wire_max_n[w] - total_data = cursor - - # Build compose-level ports with dense qubit indices. - dense_inputs: list[InputPort] = [] - for port in compose_inputs: - wire_idx = port.qubit_indices[0] - off = wire_offset[wire_idx] - n = wire_n[wire_idx] - dense_inputs.append( - InputPort(code_name=port.code_name, qubit_indices=list(range(off, off + n))) - ) - dense_outputs: list[OutputPort] = [] - for port in compose_outputs: - wire_idx = port.qubit_indices[0] - off = wire_offset[wire_idx] - n = wire_n[wire_idx] - dense_outputs.append( - OutputPort( - code_name=port.code_name, qubit_indices=list(range(off, off + n)) - ) - ) - - if not apps: - return dense_inputs, [], dense_outputs - - # Track the current dense qubit indices for each wire, updated after - # each sub-gadget to reflect output port permutations. - wire_qubits: dict[int, list[int]] = {} - for w in sorted_wires: - off = wire_offset[w] - n = wire_n[w] - wire_qubits[w] = list(range(off, off + n)) - - circuit: list[GadgetStatement] = [] - for app_name, in_wires, out_wires in apps: - sub_inputs, sub_stmts, sub_outputs = _expand_definition( - app_name, gadget_defs, compose_defs, known_names, codes - ) - - # Build qubit remapping: port qubits → dense data indices. - qmap: dict[int, int] = {} - for port_idx, wire_idx in enumerate(in_wires): - if port_idx < len(sub_inputs): - current = wire_qubits[wire_idx] - for local_i, phys_q in enumerate(sub_inputs[port_idx].qubit_indices): - if local_i < len(current): - qmap[phys_q] = current[local_i] - for port_idx, wire_idx in enumerate(out_wires): - if port_idx < len(sub_outputs): - out_phys_qs = sub_outputs[port_idx].qubit_indices - current = wire_qubits[wire_idx] - # If the sub-gadget's output port carries more qubits - # than the wire currently holds, extend the wire into its - # reserved dense block. - offset = wire_offset[wire_idx] - while len(current) < len(out_phys_qs): - current.append(offset + len(current)) - for local_i, phys_q in enumerate(out_phys_qs): - qmap.setdefault(phys_q, current[local_i]) - - # Non-port qubits → ancilla indices after data qubits. - all_qs = _collect_qubit_indices_from_stmts(sub_stmts) - ancilla_cursor = total_data - for q in sorted(all_qs): - if q not in qmap: - qmap[q] = ancilla_cursor - ancilla_cursor += 1 - - for stmt in sub_stmts: - if isinstance(stmt, Instruction): - circuit.append(_remap_instruction(stmt, qmap)) - else: - circuit.append(stmt) - - # Update wire qubit maps based on output port permutations. - # The output port's qubit_indices define which sub-gadget-local - # qubits map to each position in the wire. After remapping - # through qmap, we get the new dense qubit order for that wire. - for port_idx, wire_idx in enumerate(out_wires): - if port_idx < len(sub_outputs): - new_order: list[int] = [] - for phys_q in sub_outputs[port_idx].qubit_indices: - new_order.append(qmap[phys_q]) - wire_qubits[wire_idx] = new_order - - # Rebuild dense_outputs using the final wire qubit order (after - # all permutations have been applied by sub-gadgets). - dense_outputs = [] - for port in compose_outputs: - wire_idx = port.qubit_indices[0] - dense_outputs.append( - OutputPort( - code_name=port.code_name, qubit_indices=list(wire_qubits[wire_idx]) - ) - ) - - return dense_inputs, circuit, dense_outputs - - -def _collect_qubit_indices_from_stmts( - stmts: Sequence[GadgetStatement], -) -> set[int]: - """Collect all qubit indices referenced in instructions.""" - indices: set[int] = set() - for stmt in stmts: - if isinstance(stmt, Instruction): - for t in stmt.targets: - if isinstance(t, (QubitTarget, PauliTarget)): - indices.add(t.index) - return indices - - -def _remap_instruction(stmt: Instruction, qmap: dict[int, int]) -> Instruction: - """Return a copy of *stmt* with qubit indices remapped via *qmap*.""" - new_targets: list[Target] = [] - for t in stmt.targets: - if isinstance(t, QubitTarget): - new_targets.append( - QubitTarget(index=qmap.get(t.index, t.index), inverted=t.inverted) - ) - elif isinstance(t, PauliTarget): - new_targets.append( - PauliTarget( - pauli=t.pauli, index=qmap.get(t.index, t.index), inverted=t.inverted - ) - ) - else: - new_targets.append(t) - return Instruction( - name=stmt.name, - tag=stmt.tag, - arguments=list(stmt.arguments), - targets=new_targets, - ) - - def _format_composed_check( check: jit_pb.JitGadgetType.Check, ov_index: int | None, diff --git a/deq/deq/transpiler/jit_library_builder.py b/deq/deq/transpiler/jit_library_builder.py index d84b840e..ecc63e75 100644 --- a/deq/deq/transpiler/jit_library_builder.py +++ b/deq/deq/transpiler/jit_library_builder.py @@ -359,7 +359,24 @@ def _build_jit_gadget_type( gtype: int, ptype_of_code: dict[str, int], codes: dict[str, CodeDefinition], + *, + check_override: tuple[ + list[tuple[frozenset[int], bool]], + list[tuple[frozenset[int], bool]], + ] + | None = None, ) -> jit_pb.JitGadgetType: + """Build a ``JitGadgetType`` from a ``GadgetDefinition``. + + When *check_override* is provided as ``(finished, unfinished)``, it + replaces what :func:`resolve_gadget_checks` would derive from the + gadget body. The propagation matrices and noise-derived ERROR + rows are then computed against this externally supplied check basis + so all downstream check indices remain self-consistent. This is + used by the ``@REPROPAGATE`` compose path to graft the merge() + pipeline's check structure onto a flat-circuit propagation / + error derivation. + """ input_ports = gadget.input_ports output_ports = gadget.output_ports @@ -411,9 +428,12 @@ def _build_jit_gadget_type( pb.GadgetType.Port(ptype=ptype_of_code[p.code_name]) for p in output_ports ] - check_result = resolve_gadget_checks(gadget, codes) - finished = check_result.finished - unfinished = check_result.unfinished + if check_override is not None: + finished, unfinished = check_override + else: + check_result = resolve_gadget_checks(gadget, codes) + finished = check_result.finished + unfinished = check_result.unfinished total = ( input_virtual_count + internal_count diff --git a/deq/deq/transpiler/jit_noise_builder.py b/deq/deq/transpiler/jit_noise_builder.py index eabd7049..6f2b8532 100644 --- a/deq/deq/transpiler/jit_noise_builder.py +++ b/deq/deq/transpiler/jit_noise_builder.py @@ -1525,6 +1525,26 @@ def _propagation_row_vector( return v +def _repropagate_hint(gadget_name: str) -> str: + """Suffix appended to PROPAGATE-mismatch errors. + + A PROPAGATE row that disagrees with the canonical flow-derived + value typically means the gadget came from a COMPOSE block whose + matrix-composed propagation cannot be expressed as circuit flow + (e.g. teleportation-style conditional logical correction). The + fix is to add ``@REPROPAGATE`` to the COMPOSE so it is built via + the flat-circuit pipeline. + """ + return ( + f"\n Hint: if {gadget_name!r} was generated by 'deq annotate' " + f"from a COMPOSE block, add the @REPROPAGATE decorator to that " + f"COMPOSE. @REPROPAGATE switches the COMPOSE build to the " + f"flat-circuit pipeline so its propagation matrices come from " + f"actual circuit flow on the inlined body, not from sub-gadget " + f"matrix composition." + ) + + def _validate_and_apply_propagations( *, gadget_name: str, @@ -1623,7 +1643,8 @@ def _validate_and_apply_propagations( f"in GADGET {gadget_name!r}: PROPAGATE for output row {row} " f"({resolved.statement.target}) does not match the unique " f"flow-derived value and there is no basis-freedom available " - f"to absorb the difference" + f"to absorb the difference." + f"{_repropagate_hint(gadget_name)}" ) alpha = solve(basis_matrix, delta) if alpha is None: @@ -1633,7 +1654,8 @@ def _validate_and_apply_propagations( f"basis-freedom span of that row; the spec differs from the " f"canonical flow-derived value by {delta.weight} bit(s) " f"that cannot be expressed as any XOR of input-stabilizers, " - f"output-stabilizer joint rows, or finished-check parities" + f"output-stabilizer joint rows, or finished-check parities." + f"{_repropagate_hint(gadget_name)}" ) cp_entries -= {(row, c) for c in flow_cp_cols} diff --git a/deq/tests/circuit/fixtures/teleportation.deq b/deq/tests/circuit/fixtures/teleportation.deq new file mode 100644 index 00000000..8c484a5a --- /dev/null +++ b/deq/tests/circuit/fixtures/teleportation.deq @@ -0,0 +1,57 @@ +# teleportation-style error correction prepares a resource state and then use teleporatation to measure the syndrome. +# This usually involves composing multiple gadgets to perform a single gadget. +# However, the default COMPOSE mode doesn't work here for two reasons: +# 1. The composed gadget can infer conditional logical correction because it has the full circuit picture, while the current COMPOSE block only run JIT compiler without knowing those conditional corrections. +# There should be a decorator like "@REPROPAGATE" on the COMPOSE block to allow the composed gadget to recalculate the propagation matrices from the circuit instead of using matrix composition. +# 2. Related to the first issue, the errors in the composed gadget can have different correction frames (it depends on the concrete propagation matrices). Plus, sometimes we want to keep the noise instructions +# as is, instead of expanding to ERROR statements that are no longer simulatable. There should be a flag to the annotate tool like "--keep-noise" to prevent the expansion of noise instructions. + +# code layout +# 0 1 +# Z X Z +# 2 3 +CODE Code[[4,1,2]] { + LOGICAL X0*X2 Z0*Z1 + STABILIZER Z0*Z2 Z1*Z3 X0*X1*X2*X3 +} + +GADGET PrepareZero { + R 0 1 2 3 + X_ERROR(0.05) 0 1 2 3 + MPP X0*X1*X2*X3 + OUTPUT Code 0 1 2 3 +} + +GADGET CNOT { + INPUT Code 0 1 2 3 + INPUT Code 4 5 6 7 + CX 0 4 1 5 2 6 3 7 + DEPOLARIZE2(0.01) 0 4 1 5 2 6 3 7 + OUTPUT Code 0 1 2 3 + OUTPUT Code 4 5 6 7 +} + +GADGET MeasureX { + INPUT Code 0 1 2 3 + MX(0.02) 0 1 2 3 + READOUT M0 M2 +} + +@REPROPAGATE +COMPOSE Teleporatation { + INPUT Code 0 + PrepareZero 1 + CNOT 0 1 + MeasureX 0 + OUTPUT Code 1 +} + +# this should work, however, the check structure may be suboptimal +GADGET TeleporatationRaw { + INPUT Code 0 1 2 3 + R 4 5 6 7 + MPP X4*X5*X6*X7 + CX 0 4 1 5 2 6 3 7 + MX 0 1 2 3 + OUTPUT Code 4 5 6 7 +} diff --git a/deq/tests/circuit/repetition_code/repetition_code.deq b/deq/tests/circuit/repetition_code/repetition_code.deq index 81a77400..9d4d0c17 100644 --- a/deq/tests/circuit/repetition_code/repetition_code.deq +++ b/deq/tests/circuit/repetition_code/repetition_code.deq @@ -55,6 +55,7 @@ COMPOSE FTSyndrome { OUTPUT RepetitionCode 0 } +@REPROPAGATE COMPOSE FTPrepareZ { PrepareZ 0 REPEAT ${d} { diff --git a/deq/tests/circuit/test_annotate_keep_noise.py b/deq/tests/circuit/test_annotate_keep_noise.py new file mode 100644 index 00000000..7a5c9a30 --- /dev/null +++ b/deq/tests/circuit/test_annotate_keep_noise.py @@ -0,0 +1,209 @@ +"""Tests for the ``--keep-noise`` flag of ``deq annotate``. + +When the flag is on, noise instructions (``X_ERROR``, ``DEPOLARIZE1/2``, +noisy measurements, etc.) are emitted verbatim in the annotated output +and the corresponding ``ERROR(p) ...`` rows are *not* emitted. +Re-transpilation re-derives the same ERROR rows from the kept noise +instructions, so the JIT library round-trips byte-equivalently. +""" + +import pytest + +from deq.cli.strip_tags import strip_jit_library +from deq.circuit.parser import parse +from deq.transpiler.jit_annotate import annotate as render_annotated +from deq.transpiler.jit_library_builder import build_jit_library + + +_NOISY_GADGET_SRC = """ +CODE C[[1,1,1]] { + LOGICAL X0 Z0 + STABILIZER +} + +GADGET Prep { + R 0 + X_ERROR(0.05) 0 + OUTPUT C 0 +} + +GADGET Idle { + INPUT C 0 + DEPOLARIZE1(0.01) 0 + OUTPUT C 0 +} + +GADGET Meas { + INPUT C 0 + M(0.02) 0 + READOUT M0 +} +""" + + +class TestKeepNoiseGadget: + """``--keep-noise`` keeps noise verbatim and skips noise-origin ERRORs.""" + + def test_default_comments_noise_and_emits_errors(self) -> None: + rendered = render_annotated(parse(_NOISY_GADGET_SRC)) + assert "# X_ERROR(0.05) 0" in rendered + assert "# DEPOLARIZE1(0.01) 0" in rendered + # Default mode emits explicit ERROR(p) rows. + assert "ERROR(0.05)" in rendered + + def test_keep_noise_keeps_verbatim_and_no_explicit_errors(self) -> None: + rendered = render_annotated(parse(_NOISY_GADGET_SRC), keep_noise=True) + # Noise instructions appear uncommented. + assert "\n X_ERROR(0.05) 0" in rendered + assert "\n DEPOLARIZE1(0.01) 0" in rendered + # The noisy measurement keeps its probability argument. + assert "M(0.02) 0" in rendered + # No standalone ``ERROR(p) ...`` lines are emitted (substring + # matches like ``X_ERROR(0.05)`` are excluded by checking that + # no line *starts* with ``ERROR(`` after stripping indent). + for line in rendered.splitlines(): + stripped = line.lstrip() + assert not stripped.startswith("ERROR("), ( + f"unexpected explicit ERROR line under --keep-noise: {line!r}" + ) + + def test_keep_noise_round_trips_byte_equivalent(self) -> None: + qfile = parse(_NOISY_GADGET_SRC) + rendered = render_annotated(qfile, keep_noise=True) + orig_lib = build_jit_library(qfile) + anno_lib = build_jit_library(parse(rendered)) + orig_stripped, _ = strip_jit_library(orig_lib) + anno_stripped, _ = strip_jit_library(anno_lib) + assert ( + orig_stripped.SerializeToString() + == anno_stripped.SerializeToString() + ) + + +_REPROPAGATE_TELEPORT_SRC = """ +CODE Code[[4,1,2]] { + LOGICAL X0*X2 Z0*Z1 + STABILIZER Z0*Z2 Z1*Z3 X0*X1*X2*X3 +} + +GADGET PrepareZero { + R 0 1 2 3 + X_ERROR(0.05) 0 1 2 3 + MPP X0*X1*X2*X3 + OUTPUT Code 0 1 2 3 +} + +GADGET CNOT { + INPUT Code 0 1 2 3 + INPUT Code 4 5 6 7 + CX 0 4 1 5 2 6 3 7 + DEPOLARIZE2(0.01) 0 4 1 5 2 6 3 7 + OUTPUT Code 0 1 2 3 + OUTPUT Code 4 5 6 7 +} + +GADGET MeasureX { + INPUT Code 0 1 2 3 + MX(0.02) 0 1 2 3 + READOUT M0 M2 +} + +@REPROPAGATE +COMPOSE Teleport { + INPUT Code 0 + PrepareZero 1 + CNOT 0 1 + MeasureX 0 + OUTPUT Code 1 +} +""" + + +class TestKeepNoiseRepropagateCompose: + """``--keep-noise`` combined with ``@REPROPAGATE`` on the user's + teleportation example.""" + + def test_keep_noise_repropagate_round_trips(self) -> None: + qfile = parse(_REPROPAGATE_TELEPORT_SRC) + rendered = render_annotated(qfile, keep_noise=True) + # The composed gadget is rendered as a flat GADGET block. + assert "GADGET Teleport {" in rendered + # Noise instructions are present verbatim. + assert "X_ERROR(0.05)" in rendered + assert "DEPOLARIZE2(0.01)" in rendered + assert "MX(0.02)" in rendered + # Re-transpile and compare. + orig_lib = build_jit_library(qfile) + anno_lib = build_jit_library(parse(rendered)) + orig_stripped, _ = strip_jit_library(orig_lib) + anno_stripped, _ = strip_jit_library(anno_lib) + assert ( + orig_stripped.SerializeToString() + == anno_stripped.SerializeToString() + ) + + def test_default_mode_repropagate_round_trips(self) -> None: + """Without ``--keep-noise``, an @REPROPAGATE compose still + round-trips: noise is commented and ERROR rows are recomputed + from the new propagation matrix.""" + qfile = parse(_REPROPAGATE_TELEPORT_SRC) + rendered = render_annotated(qfile, keep_noise=False) + assert "GADGET Teleport {" in rendered + # Noise commented out. + assert "# X_ERROR(0.05)" in rendered + assert "# DEPOLARIZE2(0.01)" in rendered + # ERROR rows are present (recomputed from flat circuit). + assert "ERROR(" in rendered + # Round-trip verification. + orig_lib = build_jit_library(qfile) + anno_lib = build_jit_library(parse(rendered)) + orig_stripped, _ = strip_jit_library(orig_lib) + anno_stripped, _ = strip_jit_library(anno_lib) + assert ( + orig_stripped.SerializeToString() + == anno_stripped.SerializeToString() + ) + + +_NON_REPROPAGATE_NOISY_COMPOSE_SRC = """ +CODE C[[1,1,1]] { + LOGICAL X0 Z0 + STABILIZER +} + +GADGET Idle { + INPUT C 0 + DEPOLARIZE1(0.01) 0 + OUTPUT C 0 +} + +COMPOSE Sequence { + INPUT C 0 + Idle 0 + OUTPUT C 0 +} +""" + + +class TestKeepNoiseNonRepropagateCompose: + """``--keep-noise`` is orthogonal to ``@REPROPAGATE``. + + A COMPOSE whose merge-derived propagation is already consistent + with the flat-circuit flow (i.e. no teleportation-style conditional + correction) round-trips byte-equivalently with or without + ``--keep-noise`` — even though the COMPOSE has noise instructions + and lacks ``@REPROPAGATE``. + """ + + @pytest.mark.parametrize("keep_noise", [False, True]) + def test_round_trips(self, keep_noise: bool) -> None: + qfile = parse(_NON_REPROPAGATE_NOISY_COMPOSE_SRC) + rendered = render_annotated(qfile, keep_noise=keep_noise) + orig_lib = build_jit_library(qfile) + anno_lib = build_jit_library(parse(rendered)) + orig_stripped, _ = strip_jit_library(orig_lib) + anno_stripped, _ = strip_jit_library(anno_lib) + assert ( + orig_stripped.SerializeToString() + == anno_stripped.SerializeToString() + ) diff --git a/deq/tests/transpiler/jit_library_builder_test.py b/deq/tests/transpiler/jit_library_builder_test.py index d984bb0c..ce246657 100644 --- a/deq/tests/transpiler/jit_library_builder_test.py +++ b/deq/tests/transpiler/jit_library_builder_test.py @@ -713,7 +713,9 @@ def test_compose_rejects_non_gtype_decorator() -> None: """ with warnings.catch_warnings(): warnings.simplefilter("ignore") - with pytest.raises(ValueError, match="only @GTYPE is supported"): + with pytest.raises( + ValueError, match="only @GTYPE and @REPROPAGATE are supported" + ): build_jit_library(parse(source)) @@ -809,7 +811,9 @@ def test_unrecognized_compose_decorator_raises() -> None: """ with warnings.catch_warnings(): warnings.simplefilter("ignore") - with pytest.raises(ValueError, match="only @GTYPE is supported"): + with pytest.raises( + ValueError, match="only @GTYPE and @REPROPAGATE are supported" + ): build_jit_library(parse(source)) diff --git a/deq/tests/transpiler/jit_propagate_test.py b/deq/tests/transpiler/jit_propagate_test.py index b94c2057..15e97245 100644 --- a/deq/tests/transpiler/jit_propagate_test.py +++ b/deq/tests/transpiler/jit_propagate_test.py @@ -127,6 +127,31 @@ def test_propagate_out_of_span_rejected() -> None: build_jit_library(parse(src)) +def test_propagate_out_of_span_error_suggests_repropagate() -> None: + """The PROPAGATE-out-of-span error mentions the @REPROPAGATE decorator. + + PROPAGATE statements that do not lie in the canonical flow's + basis-freedom span are typically emitted by ``deq annotate`` when + rendering a COMPOSE whose merge-derived propagation cannot be + expressed as circuit flow on the inlined body (e.g. teleportation). + The user fix is to add ``@REPROPAGATE`` to the COMPOSE source, so + the error message must point at that decorator by name. + """ + src = REP_CODE_DECLS + """ +@GTYPE(1) +GADGET Identity { + INPUT Rep 0 1 2 + OUTPUT Rep 0 1 2 + PROPAGATE LZ0 FROM LX0 +} +""" + with pytest.raises(ValueError) as excinfo: + build_jit_library(parse(src)) + msg = str(excinfo.value) + assert "@REPROPAGATE" in msg + assert "COMPOSE" in msg + + def test_propagate_duplicate_row_rejected() -> None: """Two PROPAGATE statements for the same output row error.""" src = TINY_CODE_DECLS + """ diff --git a/deq/tests/transpiler/test_compose_repropagate.py b/deq/tests/transpiler/test_compose_repropagate.py new file mode 100644 index 00000000..f971c517 --- /dev/null +++ b/deq/tests/transpiler/test_compose_repropagate.py @@ -0,0 +1,390 @@ +"""Tests for the ``@REPROPAGATE`` decorator on COMPOSE definitions. + +The decorator switches the COMPOSE build path from "compose sub-gadget +matrices via merge()" to "inline body into a flat circuit and run it +through the standard GADGET pipeline". This lets composes whose +effective input→output Pauli flow includes conditional logical +corrections (e.g. teleportation) get propagation matrices that match +what circuit-flow analysis derives from the inlined circuit. +""" + +import pytest + +from deq.cli.strip_tags import strip_jit_library +from deq.circuit.parser import parse +from deq.transpiler.compose_builder import ( + compose_to_synthetic_gadget, + has_repropagate, +) +from deq.transpiler.jit_library_builder import build_jit_library + + +_TELEPORTATION_SOURCE = """ +CODE Code[[4,1,2]] { + LOGICAL X0*X2 Z0*Z1 + STABILIZER Z0*Z2 Z1*Z3 X0*X1*X2*X3 +} + +GADGET PrepareZero { + R 0 1 2 3 + MPP X0*X1*X2*X3 + OUTPUT Code 0 1 2 3 +} + +GADGET CNOT { + INPUT Code 0 1 2 3 + INPUT Code 4 5 6 7 + CX 0 4 1 5 2 6 3 7 + OUTPUT Code 0 1 2 3 + OUTPUT Code 4 5 6 7 +} + +GADGET MeasureX { + INPUT Code 0 1 2 3 + MX 0 1 2 3 + READOUT M0 M2 +} +""" + + +class TestHasRepropagate: + def test_present(self) -> None: + src = ( + _TELEPORTATION_SOURCE + + """ + @REPROPAGATE + COMPOSE C { + INPUT Code 0 + PrepareZero 1 + CNOT 0 1 + MeasureX 0 + OUTPUT Code 1 + } + """ + ) + compose = next( + d for d in parse(src).definitions if d.__class__.__name__ == "ComposeDefinition" + ) + assert has_repropagate(compose) + + def test_absent(self) -> None: + src = ( + _TELEPORTATION_SOURCE + + """ + COMPOSE C { + INPUT Code 0 + PrepareZero 1 + CNOT 0 1 + MeasureX 0 + OUTPUT Code 1 + } + """ + ) + compose = next( + d for d in parse(src).definitions if d.__class__.__name__ == "ComposeDefinition" + ) + assert not has_repropagate(compose) + + +class TestRepropagateRejectsArguments: + def test_decorator_with_args_rejected(self) -> None: + src = ( + _TELEPORTATION_SOURCE + + """ + @REPROPAGATE("yes") + COMPOSE C { + INPUT Code 0 + PrepareZero 1 + CNOT 0 1 + MeasureX 0 + OUTPUT Code 1 + } + """ + ) + with pytest.raises(ValueError, match="@REPROPAGATE takes no arguments"): + build_jit_library(parse(src)) + + +class TestRepropagateBuildPath: + """``build_jit_library`` runs the GADGET pipeline for @REPROPAGATE composes.""" + + def test_teleportation_compose_builds(self) -> None: + src = ( + _TELEPORTATION_SOURCE + + """ + @REPROPAGATE + COMPOSE Teleport { + INPUT Code 0 + PrepareZero 1 + CNOT 0 1 + MeasureX 0 + OUTPUT Code 1 + } + """ + ) + lib = build_jit_library(parse(src)) + names = {g.base.name for g in lib.gadget_types} + assert "Teleport" in names + + def test_repropagate_compose_matches_handwritten_flat_gadget(self) -> None: + """The @REPROPAGATE COMPOSE produces the same JitGadgetType (after + tag stripping and gtype normalization) as a hand-written flat GADGET + with the same inlined body.""" + compose_src = ( + _TELEPORTATION_SOURCE + + """ + @REPROPAGATE + COMPOSE Teleport { + INPUT Code 0 + PrepareZero 1 + CNOT 0 1 + MeasureX 0 + OUTPUT Code 1 + } + """ + ) + # Hand-written equivalent: same circuit, written as a flat GADGET. + # The body was derived by tracing the COMPOSE inlining by hand: + # qubit 0 = input wire 0 (4 qubits dense: 0..3), + # qubit 1 = wire 1 (4 qubits: 4..7). + flat_src = ( + _TELEPORTATION_SOURCE + + """ + GADGET Teleport { + INPUT Code 0 1 2 3 + R 4 5 6 7 + MPP X4*X5*X6*X7 + CX 0 4 1 5 2 6 3 7 + MX 0 1 2 3 + READOUT M0 M2 + OUTPUT Code 4 5 6 7 + } + """ + ) + lib_compose = build_jit_library(parse(compose_src)) + lib_flat = build_jit_library(parse(flat_src)) + + compose_gt = next(g for g in lib_compose.gadget_types if g.base.name == "Teleport") + flat_gt = next(g for g in lib_flat.gadget_types if g.base.name == "Teleport") + + # Compare structural fields (gtype is allowed to differ since + # the two libraries are independent). + assert compose_gt.base.measurements == flat_gt.base.measurements + assert list(compose_gt.base.inputs) == list(flat_gt.base.inputs) + assert list(compose_gt.base.outputs) == list(flat_gt.base.outputs) + assert ( + compose_gt.base.correction_propagation + == flat_gt.base.correction_propagation + ) + assert ( + compose_gt.base.physical_correction + == flat_gt.base.physical_correction + ) + assert list(compose_gt.finished_checks) == list(flat_gt.finished_checks) + assert list(compose_gt.unfinished_checks) == list(flat_gt.unfinished_checks) + + +class TestComposeToSyntheticGadget: + def test_synthetic_gadget_has_compose_name_and_no_decorators(self) -> None: + src = ( + _TELEPORTATION_SOURCE + + """ + @REPROPAGATE + @GTYPE(7) + COMPOSE T { + INPUT Code 0 + PrepareZero 1 + CNOT 0 1 + MeasureX 0 + OUTPUT Code 1 + } + """ + ) + qfile = parse(src) + from deq.circuit.model import ( + CodeDefinition, + ComposeDefinition, + GadgetDefinition, + ) + + codes = {d.name: d for d in qfile.definitions if isinstance(d, CodeDefinition)} + gadgets = { + d.name: d for d in qfile.definitions if isinstance(d, GadgetDefinition) + } + composes = { + d.name: d for d in qfile.definitions if isinstance(d, ComposeDefinition) + } + synthetic = compose_to_synthetic_gadget(composes["T"], gadgets, {}, codes) + assert synthetic.name == "T" + assert synthetic.decorators == [] + assert synthetic.input_ports + assert synthetic.output_ports + + +class TestRepropagatePreservesMergeChecks: + """``@REPROPAGATE`` recomputes propagation matrices and ERRORs from + the inlined circuit, but the *check structure* (which measurements + each finished/unfinished check XORs together, the parities, the + finished/unfinished split) must match what the normal + ``merge()``-based COMPOSE pipeline produces. Otherwise users + relying on a specific check basis from their sub-gadgets would see + it silently change as soon as they add ``@REPROPAGATE``. + """ + + @staticmethod + def _strip_check_tag(check): + out = type(check)() + out.CopyFrom(check) + out.base.tag = "" + return out + + @classmethod + def _checks_equal(cls, a, b) -> bool: + return [ + cls._strip_check_tag(c).SerializeToString() for c in a + ] == [cls._strip_check_tag(c).SerializeToString() for c in b] + + def _assert_checks_match(self, base: str, compose_body: str, name: str) -> None: + lib_merge = build_jit_library(parse(base + compose_body)) + lib_repro = build_jit_library( + parse(base + "@REPROPAGATE\n" + compose_body) + ) + gt_merge = next(g for g in lib_merge.gadget_types if g.base.name == name) + gt_repro = next(g for g in lib_repro.gadget_types if g.base.name == name) + + assert len(gt_merge.finished_checks) == len(gt_repro.finished_checks) + assert len(gt_merge.unfinished_checks) == len(gt_repro.unfinished_checks) + assert self._checks_equal( + gt_merge.finished_checks, gt_repro.finished_checks + ) + assert self._checks_equal( + gt_merge.unfinished_checks, gt_repro.unfinished_checks + ) + + def test_teleportation_checks_match(self) -> None: + compose = ( + "COMPOSE Teleport {\n" + " INPUT Code 0\n" + " PrepareZero 1\n" + " CNOT 0 1\n" + " MeasureX 0\n" + " OUTPUT Code 1\n" + "}\n" + ) + self._assert_checks_match(_TELEPORTATION_SOURCE, compose, "Teleport") + + def test_simple_cycle_checks_match(self) -> None: + base = """ + CODE C[[3,1,3]] { + LOGICAL X0*X1*X2 Z0*Z1*Z2 + STABILIZER Z0*Z1 Z1*Z2 + } + GADGET Idle { + INPUT C 0 1 2 + OUTPUT C 0 1 2 + } + GADGET Syndrome { + INPUT C 0 1 2 + CX 0 3 1 3 1 4 2 4 + M 3 4 + OUTPUT C 0 1 2 + } + """ + compose = ( + "COMPOSE Cycle {\n" + " INPUT C 0\n" + " Idle 0\n" + " Syndrome 0\n" + " Idle 0\n" + " OUTPUT C 0\n" + "}\n" + ) + self._assert_checks_match(base, compose, "Cycle") + + def test_repeated_syndrome_rounds_preserve_round_to_round_checks(self) -> None: + """Multi-round syndrome extraction (the FTPrepareZ / repetition-code + memory case from the user's repro): merge() derives weight-2 + round-to-round comparisons, while the auto plugin on the flat + circuit derives weight-1 single-measurement checks. + + @REPROPAGATE must preserve the merge-derived weight-2 structure + — otherwise users who add @REPROPAGATE silently lose the + round-to-round comparison checks that decoders rely on. + """ + base = """ + CODE C[[3,1,3]] { + LOGICAL X0*X1*X2 Z0 + STABILIZER Z0*Z1 Z1*Z2 + } + GADGET PrepareZ { + R 0 1 2 + OUTPUT C 0 1 2 + } + GADGET Syndrome { + INPUT C 0 2 4 + R 1 3 + CX 0 1 2 3 + CX 2 1 4 3 + M 1 3 + OUTPUT C 0 2 4 + } + """ + compose = ( + "COMPOSE FTPrepareZ {\n" + " PrepareZ 0\n" + " REPEAT 3 { Syndrome 0 }\n" + " OUTPUT C 0\n" + "}\n" + ) + # First check the property the test is meant to lock in: merge() + # produces the round-to-round structure. This guards against a + # regression in the merge() path itself silently making this + # test trivially pass. + lib_merge = build_jit_library(parse(base + compose)) + gt_merge = next( + g for g in lib_merge.gadget_types if g.base.name == "FTPrepareZ" + ) + merge_finished_weights = sorted( + len(c.measurements) for c in gt_merge.finished_checks + ) + assert merge_finished_weights == [1, 1, 2, 2, 2, 2], ( + "regression: merge() pipeline no longer produces round-to-round " + f"checks; got weights {merge_finished_weights!r}" + ) + + # Now the actual property: @REPROPAGATE preserves them. + self._assert_checks_match(base, compose, "FTPrepareZ") + + +class TestRepropagateAnnotateRoundtrip: + """``deq annotate`` on @REPROPAGATE composes round-trips successfully.""" + + def test_annotate_then_retranspile_byte_equivalent(self) -> None: + from deq.transpiler.jit_annotate import annotate as render_annotated + + src = ( + _TELEPORTATION_SOURCE + + """ + @REPROPAGATE + COMPOSE Teleport { + INPUT Code 0 + PrepareZero 1 + CNOT 0 1 + MeasureX 0 + OUTPUT Code 1 + } + """ + ) + qfile = parse(src) + rendered = render_annotated(qfile) + # The annotated COMPOSE is rendered as a regular GADGET block. + assert "GADGET Teleport {" in rendered + # Re-transpile and compare (after tag-stripping). + orig_lib = build_jit_library(qfile) + anno_lib = build_jit_library(parse(rendered)) + orig_stripped, _ = strip_jit_library(orig_lib) + anno_stripped, _ = strip_jit_library(anno_lib) + assert ( + orig_stripped.SerializeToString() + == anno_stripped.SerializeToString() + ) From 3d09909b9a206a8f1dff23310c38060a82463554 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Thu, 18 Jun 2026 16:30:12 -0700 Subject: [PATCH 003/157] add tutorial check --- .github/workflows/build.yaml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b83f34f9..8ce2573e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -175,6 +175,23 @@ jobs: npx vite build --emptyOutDir test -f ../deq/visual/static/lib.js || { echo "lib.js was not produced by vite build"; exit 1; } + - name: Check tutorial examples and highlights are up to date + if: runner.os == 'Linux' + shell: bash + working-directory: deq + run: | + source ../qdk_env/bin/activate + pushd documents/tutorial/scripts + npm install --ignore-scripts + python run_generators.py + python highlight_deq.py + rm -rf node_modules + popd + git diff --exit-code -- documents/tutorial/ ':!documents/tutorial/scripts/package-lock.json' || { + echo "::error::Tutorial examples or highlights are out of date. Run 'python run_generators.py && python highlight_deq.py' in deq/documents/tutorial/scripts and commit the changes." + exit 1 + } + - name: Run stubtest for binar (Linux/Mac) if: runner.os != 'Windows' working-directory: binar/bindings/python From afa25af29b8089afbd1e2de84661e978518ee817 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Thu, 18 Jun 2026 17:08:36 -0700 Subject: [PATCH 004/157] add tutorial chapter --- deq/documents/tutorial/README.md | 1 + .../tutorial/chapters/compose-repropagate.md | 365 ++++++++++++++++++ .../01_teleport_logical.deq | 58 +++ .../02_teleport_repropagate.deq | 52 +++ .../gen_compose_repropagate.py | 139 +++++++ 5 files changed, 615 insertions(+) create mode 100644 deq/documents/tutorial/chapters/compose-repropagate.md create mode 100644 deq/documents/tutorial/examples/compose-repropagate/01_teleport_logical.deq create mode 100644 deq/documents/tutorial/examples/compose-repropagate/02_teleport_repropagate.deq create mode 100644 deq/documents/tutorial/examples/compose-repropagate/gen_compose_repropagate.py diff --git a/deq/documents/tutorial/README.md b/deq/documents/tutorial/README.md index 8358cb63..1452586c 100644 --- a/deq/documents/tutorial/README.md +++ b/deq/documents/tutorial/README.md @@ -114,6 +114,7 @@ Once you become comfortable with the basics, let's look at some advanced topics: - [Codes with redundant stabilizers](chapters/codes-redundant-stabilizers.md) - [Logical operation with multiple inputs and outputs](chapters/multi-port-gadgets.md) - [Floquet codes and dynamically generated logical qubits](chapters/floquet-code.md) + - [Logical Teleportation in COMPOSE: the `@REPROPAGATE` Decorator](chapters/compose-repropagate.md) - [Parametrization with Mako](chapters/mako-parametrization.md) - [Debugging your .deq program](chapters/debug-deq-program.md) - [Steane-style syndrome extraction](chapters/steane-style-ec.md) diff --git a/deq/documents/tutorial/chapters/compose-repropagate.md b/deq/documents/tutorial/chapters/compose-repropagate.md new file mode 100644 index 00000000..36eb642d --- /dev/null +++ b/deq/documents/tutorial/chapters/compose-repropagate.md @@ -0,0 +1,365 @@ +# Logical Teleportation in COMPOSE: the `@REPROPAGATE` Decorator + +The [COMPOSE chapter](compose-gadgets.md) showed that chaining sub-gadgets through +`COMPOSE` keeps each sub-gadget's checks and errors **local** by construction. The +mechanism that makes that locality work is **the JIT compiler** (the same Rust pipeline +that the runtime decoder uses): it assembles the composed gadget from individual +sub-gadget pieces and inherits each sub-gadget's local check structure verbatim. It is +not tied to any particular way of computing the *propagation matrices* that go +alongside the checks. + +By default the COMPOSE pipeline computes those propagation matrices by **matrix +composition** of the sub-gadgets' individual propagation matrices. That is the natural +choice because it mirrors what happens at runtime: the runtime decoder chains the +same matrices step by step as instances of these gadgets stream in. But matrix +composition is a *convenient default*, not a fundamental property of COMPOSE. As soon +as matrix composition produces a row that the static verifier cannot reproduce on the +inlined flat circuit, we need a different way to fill in that row — *without* giving up +the JIT compiler's check structure. + +The textbook example where this happens is **logical teleportation**: the input state +is recovered on a different code block only after a classical-feed-forward Pauli +correction conditioned on a mid-circuit measurement. The correction lives at the +*global* circuit level — no individual sub-gadget can see it, so matrix composition +produces a propagation row that flat-circuit analysis cannot derive on its own. + +The `@REPROPAGATE` decorator is the fix. It swaps just the propagation-derivation +strategy from matrix composition to circuit-flow analysis on the inlined body, while +leaving the JIT compiler's check structure untouched. This chapter shows what goes +wrong without it, why, and exactly which pieces the decorator changes. + +--- + +## A logical teleportation COMPOSE + +A [[4,1,2]] code block can be initialised in $|+\rangle_L$ by `PrepareZero` (initialise +the data qubits in $|0\rangle$, then measure $X_0 X_1 X_2 X_3$). Composing that with a +transversal `CNOT` and an `X`-basis measurement of the first block implements logical +teleportation from port 0 to port 1: + +[Teleportation COMPOSE — without `@REPROPAGATE`](../examples/compose-repropagate/01_teleport_logical.deq) + +
# Logical teleportation realised with a COMPOSE block.
+#
+# This file is the *negative* example: the COMPOSE has no @REPROPAGATE
+# decorator, so `deq annotate` will fail at the verification step.  See
+# 02_teleport_repropagate.deq for the working version.
+#
+# Code layout:  4 physical qubits per logical qubit.
+#     0   1
+#   Z   X   Z
+#     2   3
+
+CODE Code [[4,1,2]] {
+    LOGICAL X0*X2 Z0*Z1
+    STABILIZER Z0*Z2 Z1*Z3 X0*X1*X2*X3
+}
+
+GADGET PrepareZero {
+    R 0 1 2 3
+    MPP X0*X1*X2*X3
+    OUTPUT Code 0 1 2 3
+}
+
+GADGET CNOT {
+    INPUT Code 0 1 2 3
+    INPUT Code 4 5 6 7
+    CX 0 4 1 5 2 6 3 7
+    OUTPUT Code 0 1 2 3
+    OUTPUT Code 4 5 6 7
+}
+
+GADGET MeasureX {
+    INPUT Code 0 1 2 3
+    MX 0 1 2 3
+    READOUT M0 M2
+}
+
+# Logical teleportation: |psi> in port 0, |+_L> prepared on port 1,
+# transversal CNOT, measure X on port 0 -> the input logical state ends
+# up on port 1 (possibly up to a conditional logical Z).
+#
+# Without @REPROPAGATE, the COMPOSE pipeline composes the
+# *propagation matrices* of PrepareZero, CNOT and MeasureX, which
+# cannot represent the conditional logical Pauli correction that
+# teleportation implicitly requires.  `deq annotate` therefore
+# rejects the rendered PROPAGATE statements during verification.
+COMPOSE Teleport {
+    INPUT Code 0
+    PrepareZero 1
+    CNOT 0 1
+    MeasureX 0
+    OUTPUT Code 1
+}
+
+PROGRAM Simulation {
+    PrepareZero 0
+    Teleport 0
+    MeasureX 0
+}
+ + +The COMPOSE block on its own is what we care about: + +[Teleport COMPOSE block](../examples/compose-repropagate/snippet_teleport_compose.deq) + +
COMPOSE Teleport {
+    INPUT Code 0
+    PrepareZero 1
+    CNOT 0 1
+    MeasureX 0
+    OUTPUT Code 1
+}
+ + +| Step | Effect | +| ------------- | ------------------------------------------------------------------- | +| `INPUT Code 0` | Logical state $|\psi\rangle_L$ arrives on code block 0 | +| `PrepareZero 1` | Prepare $|+\rangle_L$ on code block 1 | +| `CNOT 0 1` | Transversal CNOT — entangles the two blocks | +| `MeasureX 0` | Measure code block 0 in the $X$ basis (Bell-style projection) | +| `OUTPUT Code 1` | Logical output is now on code block 1 | + +Mathematically this implements $\bar{I}$ from port 0 to port 1, but the equality is +*conditional*: depending on the parity of the `MeasureX 0` outcome, the output state on +port 1 may differ from the input by a logical $\bar{Z}$. In a real quantum circuit you +either apply a corrective $\bar{Z}$ classically or absorb it into the Pauli frame. Either +way, the relationship between input and output observables is not pure matrix +composition — it depends on a measurement outcome that only the composed circuit, not +any individual sub-gadget, has access to. Matrix composition of the three sub-gadgets' +propagation matrices can still produce *a* propagation row (the runtime would compute +it the same way), but the resulting row no longer matches what static analysis of the +inlined flat circuit would derive. The next section shows exactly that mismatch. + +--- + +## What goes wrong without `@REPROPAGATE` + +Run the annotator on this file: + +```sh +deq annotate 01_teleport_logical.deq +``` + +After writing the annotated output, `deq annotate` re-transpiles it to verify +round-trip equivalence — and that verification fails: + +```text +ValueError: in GADGET 'Teleport': PROPAGATE for output row 0 (OUT0.LZ0) does not lie in the basis-freedom span of that row; the spec differs from the canonical flow-derived value by 3 bit(s) that cannot be expressed as any XOR of input-stabilizers, output-stabilizer joint rows, or finished-check parities. + Hint: if 'Teleport' was generated by 'deq annotate' from a COMPOSE block, add the @REPROPAGATE decorator to that COMPOSE. @REPROPAGATE switches the COMPOSE build to the flat-circuit pipeline so its propagation matrices come from actual circuit flow on the inlined body, not from sub-gadget matrix composition. +``` + +(The exact text is captured into +[`01_teleport_annotate_error.txt`](../examples/compose-repropagate/01_teleport_annotate_error.txt) +by the chapter's generator script, so the build catches any drift.) + +The failing check is the `PROPAGATE` statement for `OUT0.LZ0` (the logical $\bar{Z}$ +column of port 0's output frame). At COMPOSE build time the JIT compiler chained the +three sub-gadgets' propagation matrices and produced a `PROPAGATE OUT0.LZ0 FROM ...` +row whose right-hand side includes contributions from internal measurements — a faithful +representation of the conditional correction. When `deq annotate` rewrites the COMPOSE +as a flat `GADGET` and the verifier re-transpiles it, the only information the verifier +has is the inlined circuit; it cannot recover the matrix-composed row from circuit flow +alone, and reports that 3 bits of the spec "cannot be expressed as any XOR of +input-stabilizers, output-stabilizer joint rows, or finished-check parities". + +In other words: matrix composition and circuit-flow analysis are two *different* ways +of producing a propagation matrix. They agree on most COMPOSEs — which is why the +default matrix-composition path works almost everywhere — but for teleportation-style +operations the two strategies produce rows that the verifier knows are equivalent only +if you can already see the underlying measurement-conditioned Pauli, and the +flat-circuit pipeline cannot. + +The hint at the bottom of the error message points at the fix: add `@REPROPAGATE` to +the COMPOSE. + +--- + +## The fix: `@REPROPAGATE` + +The corrected file adds a single decorator line on top of the COMPOSE block: + +[Teleport COMPOSE block — with `@REPROPAGATE`](../examples/compose-repropagate/snippet_teleport_compose_repropagate.deq) + +
@REPROPAGATE
+COMPOSE Teleport {
+    INPUT Code 0
+    PrepareZero 1
+    CNOT 0 1
+    MeasureX 0
+    OUTPUT Code 1
+}
+ + +Full file: + +[Teleportation COMPOSE — with `@REPROPAGATE`](../examples/compose-repropagate/02_teleport_repropagate.deq) + +
# Logical teleportation realised with a COMPOSE block — fixed version.
+#
+# Identical to 01_teleport_logical.deq except that ``@REPROPAGATE`` is
+# attached to the COMPOSE block.  The decorator tells the transpiler to
+# recompute the propagation matrices from circuit flow on the inlined
+# body so the conditional logical Pauli that teleportation implies can
+# be derived automatically.  ``deq annotate`` then verifies cleanly.
+#
+# Code layout:  4 physical qubits per logical qubit.
+#     0   1
+#   Z   X   Z
+#     2   3
+
+CODE Code [[4,1,2]] {
+    LOGICAL X0*X2 Z0*Z1
+    STABILIZER Z0*Z2 Z1*Z3 X0*X1*X2*X3
+}
+
+GADGET PrepareZero {
+    R 0 1 2 3
+    MPP X0*X1*X2*X3
+    OUTPUT Code 0 1 2 3
+}
+
+GADGET CNOT {
+    INPUT Code 0 1 2 3
+    INPUT Code 4 5 6 7
+    CX 0 4 1 5 2 6 3 7
+    OUTPUT Code 0 1 2 3
+    OUTPUT Code 4 5 6 7
+}
+
+GADGET MeasureX {
+    INPUT Code 0 1 2 3
+    MX 0 1 2 3
+    READOUT M0 M2
+}
+
+@REPROPAGATE
+COMPOSE Teleport {
+    INPUT Code 0
+    PrepareZero 1
+    CNOT 0 1
+    MeasureX 0
+    OUTPUT Code 1
+}
+
+PROGRAM Simulation {
+    PrepareZero 0
+    Teleport 0
+    MeasureX 0
+}
+ + +`deq annotate` now succeeds: + +```sh +deq annotate 02_teleport_repropagate.deq +# Wrote 02_teleport_repropagate.annotated.deq +# Verifying annotated output is equivalent to original (pass --no-verify to skip)... +# Verification passed. +``` + +The annotated COMPOSE renders as a flat `GADGET Teleport` block: + +[Annotated Teleport GADGET](../examples/compose-repropagate/snippet_teleport_annotated.deq) + +
@GTYPE(4)
+@CHECKS("manual", verify=0)
+GADGET Teleport {
+    INPUT Code 0 1 2 3
+    R 4 5 6 7
+    MPP X4*X5*X6*X7
+    CX 0 4 1 5 2 6 3 7
+    MX 0 1 2 3
+    CHECK M4 M3 M2 M1 M0 IN0.S2
+    OUTPUT Code 4 5 6 7
+    CHECK OUT0.S0 IN0.S0
+    CHECK OUT0.S1 IN0.S1
+    CHECK OUT0.S2 M0
+    PROPAGATE OUT0.LZ0 FROM IN0.LZ0 M1 M3
+    PROPAGATE OUT0.LX0 FROM IN0.LX0
+
+    # --- statistics ---
+    # finished checks: 1
+    #   weight distribution: { 6:1 }
+    # unfinished checks: 3
+    #   weight distribution: { 2:3 }
+    # errors: 0
+}
+ + +The decisive line is + +```text +PROPAGATE OUT0.LZ0 FROM IN0.LZ0 M1 M3 +``` + +The trailing `M1 M3` are internal-measurement references that encode the conditional +logical $\bar{Z}$: when the parity of those two measurements is `1`, the output frame's +$\bar{Z}$ column is flipped. `@REPROPAGATE` derives this directly from the *inlined* +circuit (it can see the `MX 0 1 2 3` and trace the resulting Pauli frame forwards), +which is exactly the same derivation the verifier runs — so build and verifier now +agree. + +--- + +## What `@REPROPAGATE` keeps vs. changes + +A common worry: "if `@REPROPAGATE` recompiles from a flat circuit, do I lose the +structural benefits that made me choose `COMPOSE` in the first place?" No. The check +structure is produced by the JIT compiler regardless of which propagation strategy is +in use; `@REPROPAGATE` only swaps the propagation-derivation strategy on the *side*, +from matrix composition to circuit-flow analysis. The local check structure — the +exact reason to use `COMPOSE` over a flat GADGET, as the +[COMPOSE chapter](compose-gadgets.md) explains in detail — is preserved verbatim. + +| Aspect | Plain `COMPOSE` | `@REPROPAGATE COMPOSE` | +| -------------------------------------------------- | ------------------------------------- | --------------------------------------- | +| Finished / unfinished `CHECK`s | From the JIT compiler | Same — from the JIT compiler | +| Measurements, readouts, input/output ports | From the JIT compiler | Same — from the JIT compiler | +| `correction_propagation`, `physical_correction` | Matrix-composed from sub-gadgets | **Recomputed from inlined circuit flow** | +| `ERROR(p) ...` rows derived from noise | From the JIT compiler | **Recomputed against the new propagation** | + +Only the bottom two rows change. The JIT compiler's check structure encodes the +sub-gadget composition — e.g., for multi-round syndrome extraction it produces the +weight-2 round-to-round comparison checks decoders rely on, not weight-1 single-shot +checks. `@REPROPAGATE` keeps those checks verbatim and only patches the +propagation/error side, which is the side that could not handle the conditional Pauli. + +--- + +## When to reach for it + +Use `@REPROPAGATE` whenever a COMPOSE block implements a logical operation that +**depends on a measurement outcome via classical feed-forward**, including: + +- logical teleportation (the example above); +- gate teleportation of Clifford or non-Clifford gates; +- lattice surgery with conditional logical Pauli corrections; +- magic-state injection followed by a conditional Clifford fix-up; +- any other pattern where the input→output Pauli flow has a row that is only + determined after looking at internal measurement outcomes. + +A reliable diagnostic recipe: + +1. Write the `COMPOSE` block first, **without** `@REPROPAGATE`. +2. Run `deq annotate`. If verification passes, the default matrix-composition strategy + was sufficient for this COMPOSE — you are done. +3. If verification fails with + ``` + PROPAGATE for output row ... does not lie in the basis-freedom span + ``` + add `@REPROPAGATE` to the COMPOSE. The error message itself names the decorator. + +--- + +## Summary + +| Concept | Purpose | +| ---------------------------------- | --------------------------------------------------------------------------------------------- | +| Check locality in `COMPOSE` | Comes from the **JIT compiler**, independent of how propagation matrices are derived | +| Default propagation strategy | Matrix composition of sub-gadget propagation matrices (mirrors runtime composition) | +| `@REPROPAGATE COMPOSE Name { ... }` | Swap the propagation strategy to circuit-flow analysis on the inlined body | +| What changes | Only `correction_propagation`, `physical_correction`, and the noise-derived `ERROR` rows | +| What stays the same | Checks, measurements, readouts, ports — all still produced by the JIT compiler | +| When you need it | Logical operations whose Pauli flow depends on classical feed-forward (e.g. teleportation) | +| How to diagnose | If `deq annotate` rejects a PROPAGATE row's basis-freedom span, add `@REPROPAGATE` | diff --git a/deq/documents/tutorial/examples/compose-repropagate/01_teleport_logical.deq b/deq/documents/tutorial/examples/compose-repropagate/01_teleport_logical.deq new file mode 100644 index 00000000..c84ee78a --- /dev/null +++ b/deq/documents/tutorial/examples/compose-repropagate/01_teleport_logical.deq @@ -0,0 +1,58 @@ +# Logical teleportation realised with a COMPOSE block. +# +# This file is the *negative* example: the COMPOSE has no @REPROPAGATE +# decorator, so `deq annotate` will fail at the verification step. See +# 02_teleport_repropagate.deq for the working version. +# +# Code layout: 4 physical qubits per logical qubit. +# 0 1 +# Z X Z +# 2 3 + +CODE Code [[4,1,2]] { + LOGICAL X0*X2 Z0*Z1 + STABILIZER Z0*Z2 Z1*Z3 X0*X1*X2*X3 +} + +GADGET PrepareZero { + R 0 1 2 3 + MPP X0*X1*X2*X3 + OUTPUT Code 0 1 2 3 +} + +GADGET CNOT { + INPUT Code 0 1 2 3 + INPUT Code 4 5 6 7 + CX 0 4 1 5 2 6 3 7 + OUTPUT Code 0 1 2 3 + OUTPUT Code 4 5 6 7 +} + +GADGET MeasureX { + INPUT Code 0 1 2 3 + MX 0 1 2 3 + READOUT M0 M2 +} + +# Logical teleportation: |psi> in port 0, |+_L> prepared on port 1, +# transversal CNOT, measure X on port 0 -> the input logical state ends +# up on port 1 (possibly up to a conditional logical Z). +# +# Without @REPROPAGATE, the COMPOSE pipeline composes the +# *propagation matrices* of PrepareZero, CNOT and MeasureX, which +# cannot represent the conditional logical Pauli correction that +# teleportation implicitly requires. `deq annotate` therefore +# rejects the rendered PROPAGATE statements during verification. +COMPOSE Teleport { + INPUT Code 0 + PrepareZero 1 + CNOT 0 1 + MeasureX 0 + OUTPUT Code 1 +} + +PROGRAM Simulation { + PrepareZero 0 + Teleport 0 + MeasureX 0 +} diff --git a/deq/documents/tutorial/examples/compose-repropagate/02_teleport_repropagate.deq b/deq/documents/tutorial/examples/compose-repropagate/02_teleport_repropagate.deq new file mode 100644 index 00000000..2262fc91 --- /dev/null +++ b/deq/documents/tutorial/examples/compose-repropagate/02_teleport_repropagate.deq @@ -0,0 +1,52 @@ +# Logical teleportation realised with a COMPOSE block — fixed version. +# +# Identical to 01_teleport_logical.deq except that ``@REPROPAGATE`` is +# attached to the COMPOSE block. The decorator tells the transpiler to +# recompute the propagation matrices from circuit flow on the inlined +# body so the conditional logical Pauli that teleportation implies can +# be derived automatically. ``deq annotate`` then verifies cleanly. +# +# Code layout: 4 physical qubits per logical qubit. +# 0 1 +# Z X Z +# 2 3 + +CODE Code [[4,1,2]] { + LOGICAL X0*X2 Z0*Z1 + STABILIZER Z0*Z2 Z1*Z3 X0*X1*X2*X3 +} + +GADGET PrepareZero { + R 0 1 2 3 + MPP X0*X1*X2*X3 + OUTPUT Code 0 1 2 3 +} + +GADGET CNOT { + INPUT Code 0 1 2 3 + INPUT Code 4 5 6 7 + CX 0 4 1 5 2 6 3 7 + OUTPUT Code 0 1 2 3 + OUTPUT Code 4 5 6 7 +} + +GADGET MeasureX { + INPUT Code 0 1 2 3 + MX 0 1 2 3 + READOUT M0 M2 +} + +@REPROPAGATE +COMPOSE Teleport { + INPUT Code 0 + PrepareZero 1 + CNOT 0 1 + MeasureX 0 + OUTPUT Code 1 +} + +PROGRAM Simulation { + PrepareZero 0 + Teleport 0 + MeasureX 0 +} diff --git a/deq/documents/tutorial/examples/compose-repropagate/gen_compose_repropagate.py b/deq/documents/tutorial/examples/compose-repropagate/gen_compose_repropagate.py new file mode 100644 index 00000000..ad6df37a --- /dev/null +++ b/deq/documents/tutorial/examples/compose-repropagate/gen_compose_repropagate.py @@ -0,0 +1,139 @@ +"""Generate outputs for the @REPROPAGATE tutorial chapter. + +Runs the CLI commands referenced in ``compose-repropagate.md`` so that +breaking changes are caught by ``make tutorial``: + +* transpile both .deq files; +* annotate the *passing* file (with @REPROPAGATE) and write the + .annotated.deq output; +* annotate the *failing* file (without @REPROPAGATE), capture the + user-visible error message, and write it to a .txt fixture that the + chapter shows verbatim; +* extract the inlined Teleport GADGET block from the annotated output + as a snippet for inline display. +""" + +import os +import re +import subprocess +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from snippet_utils import extract_block # noqa: E402 + +this_dir = os.path.dirname(os.path.abspath(__file__)) + + +def run_cli(description: str, args: list[str], *, allow_failure: bool = False): + """Run a ``python -m deq ...`` command and return (returncode, stdout, stderr).""" + print(f" {description}...") + result = subprocess.run( + [sys.executable, "-m", "deq"] + args, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0 and not allow_failure: + sys.stderr.write(result.stderr) + raise RuntimeError(f"command failed: {' '.join(args)}") + return result.returncode, result.stdout, result.stderr + + +def write(path: str, content: str) -> None: + with open(path, "w", encoding="utf-8") as f: + f.write(content) + print(f" -> {os.path.basename(path)}") + + +# ── Transpile both files (succeeds in both cases) ──────────────────── + +for name in ("01_teleport_logical.deq", "02_teleport_repropagate.deq"): + path = os.path.join(this_dir, name) + out = os.path.join(this_dir, f"{name}.jit") + run_cli( + f"transpile {name}", + ["transpile", path, "--out", out, "--program", "Simulation"], + allow_failure=True, # stim export may complain on COMPOSE + ) + + +# ── Annotate the passing file (@REPROPAGATE) ───────────────────────── + +annotated_02 = os.path.join(this_dir, "02_teleport_repropagate.annotated.deq") +run_cli( + "annotate 02_teleport_repropagate.deq", + [ + "annotate", + os.path.join(this_dir, "02_teleport_repropagate.deq"), + "--out", + annotated_02, + ], +) + + +# ── Annotate the failing file (no @REPROPAGATE) ────────────────────── +# +# `deq annotate` is expected to fail here at the round-trip verification +# step. We capture the trailing user-visible error message (everything +# from the final ``ValueError:`` line to the end of stderr) and pin it +# in a text fixture so the chapter's quoted output stays in sync. + +error_fixture = os.path.join(this_dir, "01_teleport_annotate_error.txt") +returncode, _stdout, stderr = run_cli( + "annotate 01_teleport_logical.deq (expected to fail)", + [ + "annotate", + os.path.join(this_dir, "01_teleport_logical.deq"), + "--out", + os.path.join(this_dir, "01_teleport_logical.annotated.deq"), + ], + allow_failure=True, +) +if returncode == 0: + raise RuntimeError( + "expected `deq annotate` on 01_teleport_logical.deq to fail " + "(no @REPROPAGATE), but it succeeded; the chapter's narrative " + "no longer matches actual behaviour" + ) + +match = re.search(r"^ValueError: .*\Z", stderr, flags=re.MULTILINE | re.DOTALL) +if match is None: + sys.stderr.write(stderr) + raise RuntimeError( + "could not find the final ValueError in `deq annotate` stderr" + ) +error_text = match.group(0).rstrip() + "\n" +write(error_fixture, error_text) + + +# ── Extract snippets ───────────────────────────────────────────────── +# +# All ``snippet_*.deq`` files in ``examples/`` are gitignored — they are +# expected to be regenerated by the chapter's generator on each build. +# We extract them from the source .deq files using the shared +# ``extract_block`` helper so they always stay in sync. + +with open( + os.path.join(this_dir, "01_teleport_logical.deq"), encoding="utf-8" +) as f: + src_01 = f.read() +write( + os.path.join(this_dir, "snippet_teleport_compose.deq"), + extract_block(src_01, "COMPOSE", "Teleport"), +) + +with open( + os.path.join(this_dir, "02_teleport_repropagate.deq"), encoding="utf-8" +) as f: + src_02 = f.read() +write( + os.path.join(this_dir, "snippet_teleport_compose_repropagate.deq"), + extract_block(src_02, "COMPOSE", "Teleport"), +) + +with open(annotated_02, encoding="utf-8") as f: + annotated_text = f.read() +write( + os.path.join(this_dir, "snippet_teleport_annotated.deq"), + extract_block(annotated_text, "GADGET", "Teleport"), +) From 20bfddffb84a74b461fff2e4628273d58d3a7fd8 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Fri, 19 Jun 2026 08:58:46 -0700 Subject: [PATCH 005/157] add support for conditional --- deq/deq/circuit/deq.lark | 3 + deq/deq/circuit/model.py | 37 +- deq/deq/circuit/transformer.py | 40 + .../vscode-deq/syntaxes/deq.tmLanguage.json | 23 + deq/deq/cli/jit.py | 130 +++ deq/deq/spec/canonical.py | 263 ++++- deq/deq/spec/program_equivalence.py | 9 +- deq/deq/spec/program_identicalness.py | 10 +- deq/deq/transpiler/compose_builder.py | 469 ++++++++- deq/deq/transpiler/jit_annotate.py | 32 +- deq/deq/transpiler/jit_library_builder.py | 120 ++- deq/deq/transpiler/jit_noise_builder.py | 140 ++- deq/deq_runtime/src/proto/deq.bin.rs | 11 + deq/proto/deq_bin.proto | 10 + deq/tests/circuit/fixtures/teleportation.deq | 9 + .../surface_code/lattice_surgery_d3.deq | 284 +++++ .../circuit/surface_code/teleportation_d3.deq | 187 ++++ deq/tests/circuit/test_annotate.py | 27 + deq/tests/circuit/test_deq.py | 63 ++ deq/tests/cli/jit_test.py | 970 ++++++++++++++++++ deq/tests/spec/canonical_test.py | 109 +- deq/tests/spec/program_identicalness_test.py | 11 +- .../transpiler/jit_library_builder_test.py | 279 +++++ 23 files changed, 3126 insertions(+), 110 deletions(-) create mode 100644 deq/tests/circuit/surface_code/lattice_surgery_d3.deq create mode 100644 deq/tests/circuit/surface_code/teleportation_d3.deq diff --git a/deq/deq/circuit/deq.lark b/deq/deq/circuit/deq.lark index 69bd4526..cf76606a 100644 --- a/deq/deq/circuit/deq.lark +++ b/deq/deq/circuit/deq.lark @@ -58,6 +58,7 @@ _compose_body_item: repeat_block_compose | input_port | output_port | gadget_application + | conditional_correction | instruction | decorator @@ -73,6 +74,7 @@ _program_body_item: repeat_block_program | gadget_application | assert_statement | virtual_correction + | conditional_correction | instruction | decorator @@ -146,6 +148,7 @@ error_pauli_target: PAULI_OPERATOR assert_statement: "ASSERT_EQ" target INT virtual_correction: VIRTUAL_KW PAULI_OPERATOR (COMBINER PAULI_OPERATOR)* INT +conditional_correction: "CONDITIONAL" MEASUREMENT_RECORD_TARGET PAULI_OPERATOR (COMBINER PAULI_OPERATOR)* INT VIRTUAL_KW.3: "VIRTUAL" // ── Stim instructions (embedded) ──────────────────────────────── diff --git a/deq/deq/circuit/model.py b/deq/deq/circuit/model.py index d30c98ef..02e0762d 100644 --- a/deq/deq/circuit/model.py +++ b/deq/deq/circuit/model.py @@ -613,11 +613,6 @@ def __str__(self) -> str: return f"{decos}ASSERT_EQ {self.target} {self.expected_value}" -ComposeStatement = ( - GadgetApplication | Instruction | RepeatBlock | InputPort | OutputPort -) - - @dataclass class VirtualCorrection: """A ``VIRTUAL X0*Y1 wire`` Pauli correction pseudo-instruction.""" @@ -630,10 +625,42 @@ def __str__(self) -> str: return f"VIRTUAL {parts} {self.wire}" +@dataclass +class ConditionalCorrection: + """A ``CONDITIONAL rec[-k] X0*Y1 wire`` conditional Pauli correction. + + Applies the logical Pauli product ``paulis`` to ``wire`` conditioned + on the ``readout_offset``-th most recent logical readout in + program/compose order (i.e. ``rec[-readout_offset]``). + + Each ``(pauli_letter, logical_qubit_index)`` entry is a logical Pauli + on a logical qubit of the code carried by ``wire``. + """ + + readout_offset: int + paulis: list[tuple[str, int]] + wire: int + + def __str__(self) -> str: + parts = "*".join(f"{p}{q}" for p, q in self.paulis) + return f"CONDITIONAL rec[-{self.readout_offset}] {parts} {self.wire}" + + +ComposeStatement = ( + GadgetApplication + | ConditionalCorrection + | Instruction + | RepeatBlock + | InputPort + | OutputPort +) + + ProgramStatement = ( GadgetApplication | AssertStatement | VirtualCorrection + | ConditionalCorrection | Instruction | RepeatBlock | InputPort diff --git a/deq/deq/circuit/transformer.py b/deq/deq/circuit/transformer.py index e8ffc3e1..363494d8 100644 --- a/deq/deq/circuit/transformer.py +++ b/deq/deq/circuit/transformer.py @@ -12,6 +12,7 @@ CodeDefinition, CombinerTarget, ComposeDefinition, + ConditionalCorrection, ConditionalStatement, DestabilizerTarget, Decorator, @@ -658,6 +659,45 @@ def virtual_correction(self, items: list[Any]) -> VirtualCorrection: raise SyntaxError("VIRTUAL requires at least one Pauli operator") return VirtualCorrection(paulis=paulis, wire=wire) + def conditional_correction(self, items: list[Any]) -> ConditionalCorrection: + # Grammar: MEASUREMENT_RECORD_TARGET PAULI_OPERATOR (COMBINER PAULI_OPERATOR)* INT + rec_token = items[0] + m = _REC_RE.match(str(rec_token)) + if not m: + raise SyntaxError( + f"CONDITIONAL requires a rec[-k] readout reference; got {rec_token!r}" + ) + readout_offset = int(m.group(1)) + if readout_offset < 1: + raise SyntaxError( + f"CONDITIONAL rec[-k] requires k >= 1; got rec[-{readout_offset}]" + ) + # Last item is the wire integer; intermediate items are Pauli operators + # (and COMBINER tokens which are filtered out). + wire_token = items[-1] + if not str(wire_token).isdigit(): + raise SyntaxError( + f"CONDITIONAL requires a non-negative wire integer at the end; " + f"got {wire_token!r}" + ) + wire = int(wire_token) + paulis: list[tuple[str, int]] = [] + for item in items[1:-1]: + tok = str(item) + if tok == "*": + continue + pm = _PAULI_RE.match(tok) + if pm: + paulis.append((pm.group(1), int(pm.group(2)))) + if not paulis: + raise SyntaxError( + "CONDITIONAL requires at least one Pauli operator between " + "the readout reference and the wire" + ) + return ConditionalCorrection( + readout_offset=readout_offset, paulis=paulis, wire=wire + ) + # ── Stim instructions ──────────────────────────────────────────── def instruction(self, items: list[Any]) -> Instruction: diff --git a/deq/deq/circuit/vscode-deq/syntaxes/deq.tmLanguage.json b/deq/deq/circuit/vscode-deq/syntaxes/deq.tmLanguage.json index 67feeaf9..aa68f581 100644 --- a/deq/deq/circuit/vscode-deq/syntaxes/deq.tmLanguage.json +++ b/deq/deq/circuit/vscode-deq/syntaxes/deq.tmLanguage.json @@ -336,6 +336,9 @@ { "include": "#port-statement" }, + { + "include": "#conditional-correction" + }, { "include": "#gadget-application" }, @@ -370,6 +373,9 @@ { "include": "#virtual-correction" }, + { + "include": "#conditional-correction" + }, { "include": "#gadget-application" }, @@ -747,6 +753,23 @@ } } }, + "conditional-correction": { + "match": "\\b(CONDITIONAL)\\s+(rec\\[-\\d+\\])\\s+((?:[XYZ]\\d+)(?:\\*[XYZ]\\d+)*)\\s+(\\d+)", + "captures": { + "1": { + "name": "keyword.directive.deq" + }, + "2": { + "name": "variable.other.deq" + }, + "3": { + "name": "support.type.pauli.deq" + }, + "4": { + "name": "constant.numeric.deq" + } + } + }, "gadget-application": { "begin": "((?:[a-zA-Z][a-zA-Z0-9_]*|\\$\\{[^}]*\\})+)\\s+(?=(IN|OUT)\\(|\\(\\)|\\d|\\$\\{)", "beginCaptures": { diff --git a/deq/deq/cli/jit.py b/deq/deq/cli/jit.py index 61266784..2685a4cd 100644 --- a/deq/deq/cli/jit.py +++ b/deq/deq/cli/jit.py @@ -234,6 +234,20 @@ def _format_source_line(line_no: int | None, program_def: object) -> str: return f" (line {line_no})" +def _is_synthesised_identity_gadget(name: str) -> bool: + """Return ``True`` if *name* is a synthesised identity gadget + emitted by :func:`emit_conditional_correction_instruction`. + + Such gadgets host a ``remote_conditional_correction`` modifier + that applies a Pauli frame correction conditioned on a previous + logical readout. They have no source ``GadgetDefinition`` (they + are created on the fly by the COMPOSE / PROGRAM compiler), no + measurements, and pass each input wire's physical qubits straight + through to the matching output port. + """ + return name.startswith("__identity_pt") and name.endswith("__") + + def _program_source_lines( program_def: object, applications: list[object], @@ -567,12 +581,16 @@ def compile_program_for_jit( """ from deq.circuit.model import ( AssertStatement, + ConditionalCorrection, GadgetApplication, Instruction, MeasurementRecordTarget, QubitTarget, VirtualCorrection, ) + from deq.transpiler.compose_builder import ( + emit_conditional_correction_instruction, + ) gtype_of_name: dict[str, int] = { gt.base.name: gt.base.gtype for gt in jit_library.gadget_types @@ -598,6 +616,14 @@ def compile_program_for_jit( gid_to_gadget_type: dict[int, jit_pb.JitGadgetType] = {} # gid -> list of (row, col) toggles for correction_propagation pauli_toggles: dict[int, list[tuple[int, int]]] = {} + # absolute readout index -> (gid, local_readout_index) for resolving rec[-k] + # in CONDITIONAL statements. + readout_history: list[tuple[int, int]] = [] + # ptype -> synthesized identity gadget gtype (lazily created on first use) + identity_gtype_of_ptype: dict[int, int] = {} + next_synthetic_gtype = ( + max((gt.base.gtype for gt in jit_library.gadget_types), default=0) + 1 + ) # Pre-expand sub-program calls and REPEAT blocks. body: list[object] = list(program_def.body) @@ -705,6 +731,72 @@ def compile_program_for_jit( ) continue + # CONDITIONAL pseudo-instruction: ``CONDITIONAL rec[-k] X0*Y1 wire``. + # Emits a synthesized "identity" gadget that consumes the wire from + # its current producer and re-outputs it, carrying a + # ``remote_conditional_correction`` modifier conditioned on the + # k-th most recent logical readout. + if isinstance(stmt, ConditionalCorrection): + wire = stmt.wire + if wire not in wire_producer: + raise ValueError( + f"PROGRAM {program_def.name!r}: {stmt} references " + f"wire {wire} which has no producer" + ) + producer = wire_producer[wire] + if producer.ptype not in port_types_by_ptype: + raise ValueError( + f"PROGRAM {program_def.name!r}: {stmt} references wire " + f"{wire} whose port type {producer.ptype} is not in the " + f"JIT library" + ) + + gid = next_gid + next_gid += 1 + instruction, new_identity_gt, next_synthetic_gtype = ( + emit_conditional_correction_instruction( + conditional=stmt, + error_context=f"PROGRAM {program_def.name!r}", + wire_ptype=producer.ptype, + wire_source=(producer.gid, producer.port), + readout_history=readout_history, + port_types_by_ptype=port_types_by_ptype, + identity_gtype_of_ptype=identity_gtype_of_ptype, + next_synthetic_gtype=next_synthetic_gtype, + gid=gid, + ) + ) + if new_identity_gt is not None: + jit_library.gadget_types.append(new_identity_gt) + gadget_types_by_gtype[new_identity_gt.base.gtype] = new_identity_gt + + identity_jit_gt = gadget_types_by_gtype[ + identity_gtype_of_ptype[producer.ptype] + ] + + # Synthesize a GadgetApplication for the returned tuple (used by + # downstream rendering/logging). It re-uses ``wire`` as the + # single in/out binding. + synthetic_app = GadgetApplication( + gadget_name=identity_jit_gt.base.name, + in_indices=[wire], + out_indices=[wire], + ) + instructions.append((instruction, synthetic_app)) + gid_to_index[gid] = len(instructions) - 1 + gid_to_gadget_type[gid] = identity_jit_gt + + # Identity gadget has no readouts; do not update readout_history. + + # The identity gadget becomes the new producer of the wire. + wire_producer[wire] = _WireProducer( + gid=gid, + port=0, + ptype=producer.ptype, + desc=f"step {gid} (CONDITIONAL {stmt!s}, identity gadget output port 0)", + ) + continue + if not isinstance(stmt, GadgetApplication): if isinstance(stmt, Instruction): raise ValueError( @@ -769,6 +861,8 @@ def compile_program_for_jit( gid_to_index[gid] = len(instructions) - 1 gid_to_gadget_type[gid] = gadget_type running_readouts += len(gadget_type.base.readouts) + for local_r in range(len(gadget_type.base.readouts)): + readout_history.append((gid, local_r)) for slot, wire in enumerate(out_indices): if wire in wire_producer: @@ -896,6 +990,42 @@ def export_program_stim( gid = jit_instr.gadget.gid gtype = jit_instr.gadget.gtype name = gtype_to_name.get(gtype, f"") + + # Synthesised identity gadgets host the + # ``remote_conditional_correction`` modifier emitted from a + # PROGRAM-level or COMPOSE-level ``CONDITIONAL`` statement + # (see :func:`emit_conditional_correction_instruction`). + # They are purely propagation nodes — no measurements, no + # circuit instructions — and pass each input wire's physical + # qubits straight through to the matching output port. The + # conditional Pauli the modifier applies is a *frame* + # correction that lives in the JIT propagation matrices, not + # in the stim circuit, so the exported stim circuit need only + # forward the physicals. + if name not in gadgets_by_name and _is_synthesised_identity_gadget(name): + if len(jit_instr.gadget.connectors) != 1: + raise ValueError( + f"G{gid}/{name}: synthesised identity gadget must " + f"have exactly 1 connector, got " + f"{len(jit_instr.gadget.connectors)}" + ) + conn = jit_instr.gadget.connectors[0] + key = (conn.gid, conn.port) + if key not in output_physicals: + raise ValueError( + f"G{gid}/{name}: input port references " + f"(gid={conn.gid}, port={conn.port}) which has no " + "registered output physicals" + ) + producer_phys = list(output_physicals[key]) + output_physicals[(gid, 0)] = producer_phys + chunks.append( + f"# G{gid}: {name} " + f"(synthesised identity — CONDITIONAL passthrough)" + f"{_format_source_line(source_line, program_def)}" + ) + continue + if name not in gadgets_by_name: raise ValueError( f"cannot export stim: gadget {name!r} (gtype={gtype}) " diff --git a/deq/deq/spec/canonical.py b/deq/deq/spec/canonical.py index 601165d7..21d58bb5 100644 --- a/deq/deq/spec/canonical.py +++ b/deq/deq/spec/canonical.py @@ -23,6 +23,34 @@ we can assure that all the remote gadgets and remote check models can be expanded, i.e., there \ remains no remote references in the global check model and global error model +Design note: ``logical_correction`` absorption +============================================== + +The canonical gadget type produced by :func:`merge` always has an EMPTY +``logical_correction`` matrix. This is by design. Conditional output +flips that the runtime would otherwise express as +``residual ^= lc · readouts`` are *absorbed* into ``correction_propagation`` +and ``physical_correction`` (and into per-error ``residual``) during the +merge pipeline (see "step 9" inside :func:`merge`). + +Motivation: the original encoding had redundancy — the same output flip +could be expressed via ``logical_correction × readout_propagation`` +(input side) and ``logical_correction × readout.measurement_indices`` +(measurement side) OR via ``correction_propagation`` and +``physical_correction`` directly. Two byte-different libraries could +encode identical behavior. Absorbing collapses this redundancy into a +single canonical representation: every static input → output and +measurement → output flip lives in ``cp`` / ``pc``; the merged +``logical_correction`` is reserved as "always empty" so equivalence +checks reduce to byte comparisons of the absorbed matrices. + +The ``logical_correction`` field is NOT removed from the proto. It is +still populated by individual GADGET types (via authoring constructs +like ``CONDITIONAL R L

``); the absorption only happens when +those GADGETs are merged. Existing serialized libraries that pre-date +this absorption still load and execute correctly because the runtime +formula ``residual ^= lc · readouts`` is a no-op when ``lc`` is empty. + """ # pylint: disable=no-member @@ -553,12 +581,9 @@ def merge( global_ri = readout_map.atob[local_ri].readout_index rp_set ^= {(global_ri, col)} - correction_propagation = util_pb.BitMatrix( - rows=num_output_obs, - cols=num_input_obs + 1, - i=[r for r, _ in sorted(cp_set)], - j=[c for _, c in sorted(cp_set)], - ) + # NOTE: ``correction_propagation`` BitMatrix is built later (step 9) + # after the absorption pass extends ``cp_set`` with the absorbed + # contributions from ``cc_set`` (via ``rp_set``). readout_propagation = util_pb.BitMatrix( rows=num_readouts, cols=num_input_obs + 1, @@ -567,6 +592,15 @@ def merge( ) # ── 5a. Logical correction ──────────────────────────────────── + # We build ``cc_set`` here but defer finalization of the + # ``logical_correction`` matrix until after step 8 (errors), because + # step 9 absorbs every cc entry into ``cp_set`` / ``pc_set`` and into + # each error's ``residual`` and then clears ``cc_set``. The final + # merged ``logical_correction`` matrix is always empty by design; + # this removes the matrix-redundancy between (logical_correction × + # readout_propagation) and (correction_propagation) and between + # (logical_correction × readout.measurement_indices) and + # (physical_correction). cc_set: set[tuple[int, int]] = set() # Local conditional corrections (logical_correction matrix) @@ -610,12 +644,8 @@ def merge( for obs_idx in global_obs_set: cc_set ^= {(obs_idx, global_readout_idx)} - logical_correction = util_pb.BitMatrix( - rows=num_output_obs, - cols=num_readouts, - i=[r for r, _ in sorted(cc_set)], - j=[c for _, c in sorted(cc_set)], - ) + # NOTE: ``logical_correction`` BitMatrix is built later (step 9) + # after the absorption pass clears ``cc_set``. # ── 5b. Physical correction ────────────────────────────────────── pc_set: set[tuple[int, int]] = set() # (row=global_obs, col=global_meas) @@ -639,12 +669,9 @@ def merge( pc_set ^= {(global_obs, global_m)} num_measurements = len(measurement_map) - physical_correction = util_pb.BitMatrix( - rows=num_output_obs, - cols=num_measurements, - i=[r for r, _ in sorted(pc_set)], - j=[c for _, c in sorted(pc_set)], - ) + # NOTE: ``physical_correction`` BitMatrix is built later (step 9) + # after the absorption pass extends ``pc_set`` with the absorbed + # contributions from ``cc_set``. # ── 5c. Track measurement deps per output observable ──────────── # For each gadget's output observable, track which global measurements @@ -714,6 +741,62 @@ def merge( key = (gid, out_local.port, out_local.observable_index) obs_meas_deps[key] ^= readout_meas + # 6. Add remote_conditional_correction: PROGRAM-/COMPOSE-level + # ``CONDITIONAL`` synthesises an identity host whose modifier + # conditionally flips one of its output observables based on a + # *remote* gadget's readout. Track the dependency by folding + # the remote readout's measurement set into the flipped output + # observable's deps; this lets downstream gadgets — including + # MeasureZ/MeasureX-style readout-only gadgets — inherit the + # correction via the usual propagation paths. + if gid in program.expanded_remote_conditional_corrections: + expanded_readouts, remote_cc = ( + program.expanded_remote_conditional_corrections[gid] + ) + col_to_remote_meas: list[set[int]] = [] + for local_readout in expanded_readouts: + if local_readout not in readout_map.atob: + col_to_remote_meas.append(set()) + continue + remote_gid = local_readout.gid + remote_readout_idx = local_readout.readout_index + remote_gadget = program.gadgets[remote_gid] + remote_gadget_type = program.gadget_types[remote_gadget.gtype] + remote_orig = remote_gadget_type.readouts[remote_readout_idx] + remote_matrices = propagator.expanded_matrices[remote_gid] + remote_meas_set: set[int] = set() + for local_mi in remote_orig.measurement_indices: + lm = MeasurementIndex( + gid=remote_gid, measurement_index=local_mi + ) + if lm in measurement_map.atob: + remote_meas_set ^= { + measurement_map.atob[lm].measurement_index + } + # Inherited deps from the remote gadget's input + # observables that feed its readout via + # ``readout_propagation``. + for input_local, readout_set in ( + remote_matrices.readout_propagation.items() + ): + if remote_readout_idx not in readout_set: + continue + remote_connector = remote_gadget.connectors[input_local.port] + pred_key = ( + remote_connector.gid, + int(remote_connector.port), + input_local.observable_index, + ) + remote_meas_set ^= obs_meas_deps.get(pred_key, set()) + col_to_remote_meas.append(remote_meas_set) + + for row, col in zip(remote_cc.correction.i, remote_cc.correction.j): + if col >= len(col_to_remote_meas): + continue + out_local = matrices.output_observables[row] + key = (gid, out_local.port, out_local.observable_index) + obs_meas_deps[key] ^= col_to_remote_meas[col] + # ── 6. Build measurements and readouts ─────────────────────────── merged_measurements: list[pb.GadgetType.Measurement] = [] for gid in ordered_gids: @@ -953,6 +1036,90 @@ def _resolve_measurement_ref( ) ) + # ── 9. Absorb logical_correction into cp/pc and per-error residual ── + # The runtime evaluates + # readouts[c] = raw[c] ^ decoded.readouts[c] ^ (rp · input)[c] + # residual ^= lc · readouts + # which factors as + # residual += (lc · rp_input) · input + # + (lc · R) · measurements # R = readout's measurement_indices + # + lc · decoded.readouts # decoder-internal channel + # The first two terms are purely static and identical to populating + # cp / pc directly. The third term is routed through per-error + # residual updates (each error's readout_flips contribution to residual + # is folded into error.residual). After this pass the merged + # logical_correction is always empty by design, eliminating the + # redundancy between (lc × rp, lc × measurement_indices) and (cp, pc). + if cc_set: + affine_col = num_input_obs + + # readout_index -> {input observable cols}; the affine column is + # split off so we can apply it at most once per (out_row, readout). + rp_input_by_readout: dict[int, set[int]] = {} + rp_affine_readouts: set[int] = set() + for readout_index, in_col in rp_set: + if in_col == affine_col: + rp_affine_readouts.add(readout_index) + else: + rp_input_by_readout.setdefault(readout_index, set()).add(in_col) + + # readout_index -> {measurement indices} that the runtime XORs into + # the readout bit (R in the docstring above). + readout_to_meas: list[set[int]] = [ + set(readout.measurement_indices) for readout in merged_readouts + ] + + # Built lazily inside the absorption loop, consumed afterwards by + # the per-error residual fold-in. + rows_by_readout: dict[int, set[int]] = {} + for out_row, readout_index in cc_set: + rows_by_readout.setdefault(readout_index, set()).add(out_row) + # (a) absorb lc · rp_input into cp. + cp_set.symmetric_difference_update( + (out_row, in_col) + for in_col in rp_input_by_readout.get(readout_index, ()) + ) + # (a') absorb lc · rp_affine into cp's affine column. + if readout_index in rp_affine_readouts: + cp_set.symmetric_difference_update([(out_row, affine_col)]) + # (b) absorb lc · R into pc. + pc_set.symmetric_difference_update( + (out_row, meas) for meas in readout_to_meas[readout_index] + ) + + # (c) absorb lc · decoded.readouts via error.residual updates. + for err in merged_errors: + if not err.readout_flips: + continue + residual = set(err.residual) + for readout_index in err.readout_flips: + residual.symmetric_difference_update( + rows_by_readout.get(readout_index, ()) + ) + err.residual = sorted(residual) + + cc_set.clear() + + # Build final BitMatrices now that cc_set has been absorbed. + correction_propagation = util_pb.BitMatrix( + rows=num_output_obs, + cols=num_input_obs + 1, + i=[r for r, _ in sorted(cp_set)], + j=[c for _, c in sorted(cp_set)], + ) + logical_correction = util_pb.BitMatrix( + rows=num_output_obs, + cols=num_readouts, + i=[r for r, _ in sorted(cc_set)], + j=[c for _, c in sorted(cc_set)], + ) + physical_correction = util_pb.BitMatrix( + rows=num_output_obs, + cols=num_measurements, + i=[r for r, _ in sorted(pc_set)], + j=[c for _, c in sorted(pc_set)], + ) + return MergedGadget( input_ptypes=[ip.ptype for ip in input_ports], output_ptypes=[op.ptype for op in output_ports], @@ -983,9 +1150,28 @@ def _classify_merge_ports( references a non-merge gadget becomes a merge input port. An output port that is unconnected or connects to a non-merge gadget becomes a merge output port. + + Output ports that connect to a non-merge consumer are ordered by the + consumer's ``(gid, input_port_index)`` pair (consumer gids ordered by + instantiation order); unconnected outputs follow in producer + ``(gid, port_index)`` order. This makes the merged gadget's output + port ordering match the *consumer's* expected input port layout, so + callers that build a synthetic consumer (e.g. an ``__output_mock__`` + aggregating compose-level OUTPUT declarations) get back outputs in + the order they wired the mock's inputs — even when intermediate + gadgets (such as ``CONDITIONAL`` synthesised identity hosts) push + some producers to higher gids than others. """ input_ports: list[_MergeInputPort] = [] - output_ports: list[_MergeOutputPort] = [] + + # Pre-compute instantiation order for non-merge gids so we can sort + # connected outputs by their consumer's position in the program. + gid_order = { + gid: idx for idx, gid in enumerate(_gids_in_instantiation_order(program)) + } + + connected_outputs: list[tuple[int, int, _MergeOutputPort]] = [] + unconnected_outputs: list[_MergeOutputPort] = [] for gid in _gids_in_instantiation_order(program): if gid not in merge_gids: @@ -993,7 +1179,6 @@ def _classify_merge_ports( gadget = program.gadgets[gid] gadget_type = program.gadget_types[gadget.gtype] - # Check input ports for port_idx, (connector, port_spec) in enumerate( zip(gadget.connectors, gadget_type.inputs) ): @@ -1008,28 +1193,28 @@ def _classify_merge_ports( ) ) - # Check output ports for port_idx, port_spec in enumerate(gadget_type.outputs): out_instance = OutputPortIndex(gid=gid, port_index=port_idx) + merge_out = _MergeOutputPort( + merge_gid=gid, + port_index=port_idx, + ptype=port_spec.ptype, + ) if out_instance not in program.peer_input: - # Unconnected output - output_ports.append( - _MergeOutputPort( - merge_gid=gid, - port_index=port_idx, - ptype=port_spec.ptype, - ) - ) - else: - peer = program.peer_input[out_instance] - if peer.gid not in merge_gids: - output_ports.append( - _MergeOutputPort( - merge_gid=gid, - port_index=port_idx, - ptype=port_spec.ptype, - ) - ) + unconnected_outputs.append(merge_out) + continue + peer = program.peer_input[out_instance] + if peer.gid in merge_gids: + continue + connected_outputs.append( + (gid_order.get(peer.gid, len(gid_order)), peer.port_index, merge_out) + ) + + connected_outputs.sort(key=lambda triple: (triple[0], triple[1])) + output_ports: list[_MergeOutputPort] = [ + merge_out for _, _, merge_out in connected_outputs + ] + output_ports.extend(unconnected_outputs) return input_ports, output_ports diff --git a/deq/deq/spec/program_equivalence.py b/deq/deq/spec/program_equivalence.py index 52a42ded..3c67f64f 100644 --- a/deq/deq/spec/program_equivalence.py +++ b/deq/deq/spec/program_equivalence.py @@ -32,8 +32,13 @@ 5. (ProgEq 2.5) The canonical gadgets must have the same static readout values,\ as defined by the :code:`readout_propagation` field (a single-column matrix) 6. (ProgEq 2.6) The canonical gadgets must have the same conditional correction,\ - as defined by the :code:`logical_correction` field. This now includes both\ - local conditional corrections and remote conditional corrections (XORed together). + as defined by the :code:`logical_correction` field. After the merge() absorption\ + pass (canonical.py step 9), the merged :code:`logical_correction` is always\ + empty by design — local and remote conditional corrections are absorbed into\ + :code:`correction_propagation` and :code:`physical_correction` (and into\ + per-error :code:`residual`). This check therefore reduces to verifying that\ + both canonical forms have empty :code:`logical_correction`, which is trivially\ + satisfied. 7. Note that we do NOT require the same number of checks in the two canonical forms,\ because checks can be linearly combined to form new checks, and thus the number\ of checks can be different while the overall effect is the same. We will elaborate more\ diff --git a/deq/deq/spec/program_identicalness.py b/deq/deq/spec/program_identicalness.py index cce6359d..5ac387b8 100644 --- a/deq/deq/spec/program_identicalness.py +++ b/deq/deq/spec/program_identicalness.py @@ -30,8 +30,14 @@ 5. (ProgId 2.5) The canonical gadgets must have the same static readout values,\ as defined by the :code:`readout_propagation` field (a single-column matrix) 6. (ProgId 2.6) The canonical gadgets must have the same conditional correction,\ - as defined by the :code:`logical_correction` field. This now includes both\ - local conditional corrections and remote conditional corrections (XORed together). + as defined by the :code:`logical_correction` field. After the merge() absorption\ + pass (canonical.py step 9), the merged :code:`logical_correction` is always\ + empty by design — local and remote conditional corrections are absorbed into\ + :code:`correction_propagation` and :code:`physical_correction` (and into\ + per-error :code:`residual`). This check therefore reduces to verifying that\ + both canonical forms have empty :code:`logical_correction`, which is trivially\ + satisfied; the real content of the conditional correction is compared via\ + ProgId 2.3 (cp) and ProgId 2.7 (pc). 7. (ProgId 2.7) The canonical gadgets must have the same physical conditional correction,\ as defined by the :code:`physical_correction` field 8. (ProgId 2.8) The canonical check models must have the same number of checks.\ diff --git a/deq/deq/transpiler/compose_builder.py b/deq/deq/transpiler/compose_builder.py index 8c51df6d..5129f2e4 100644 --- a/deq/deq/transpiler/compose_builder.py +++ b/deq/deq/transpiler/compose_builder.py @@ -21,14 +21,19 @@ CodeDefinition, ComposeDefinition, ComposeStatement, + ConditionalCorrection, + ConditionalStatement, GadgetApplication, GadgetDefinition, GadgetStatement, InputPort, Instruction, + LogicalPauliTarget, OutputPort, PauliTarget, QubitTarget, + ReadoutStatement, + ReadoutTarget, RepeatBlock, Target, ) @@ -98,6 +103,8 @@ def _check_body(items: list, where: str) -> None: continue if isinstance(stmt, (InputPort, OutputPort)): continue + if isinstance(stmt, ConditionalCorrection): + continue if isinstance(stmt, Instruction): if stmt.name in declared_names: _check_shortcut_application(compose, stmt, _lookup, where) @@ -209,10 +216,21 @@ def _expand_compose_body( *, gadget_definitions: Mapping[str, GadgetDefinition], compose_definitions: Mapping[str, ComposeDefinition], -) -> tuple[list[InputPort], list[OutputPort], list[GadgetApplication]]: +) -> tuple[ + list[InputPort], + list[OutputPort], + list[GadgetApplication | ConditionalCorrection], +]: + """Flatten a compose body into ``(inputs, outputs, ordered_items)``. + + ``ordered_items`` preserves source order across both gadget + applications and ``CONDITIONAL`` pseudo-instructions, so the + consumer can interleave synthetic identity gadgets at the right + program positions. + """ inputs: list[InputPort] = [] outputs: list[OutputPort] = [] - apps: list[GadgetApplication] = [] + items: list[GadgetApplication | ConditionalCorrection] = [] def _lookup(name: str) -> GadgetDefinition | ComposeDefinition | None: if name in gadget_definitions: @@ -221,8 +239,8 @@ def _lookup(name: str) -> GadgetDefinition | ComposeDefinition | None: return compose_definitions[name] return None - def _walk(items: list) -> None: - for stmt in items: + def _walk(stmts: list) -> None: + for stmt in stmts: if isinstance(stmt, InputPort): inputs.append(stmt) elif isinstance(stmt, OutputPort): @@ -231,12 +249,12 @@ def _walk(items: list) -> None: if stmt.is_shortcut: sub_def = _lookup(stmt.gadget_name) if sub_def is None: - apps.append(stmt) + items.append(stmt) else: indices = list(stmt.in_indices or []) n_in = len(sub_def.input_ports) n_out = len(sub_def.output_ports) - apps.append( + items.append( GadgetApplication( gadget_name=stmt.gadget_name, in_indices=indices[:n_in], @@ -244,17 +262,19 @@ def _walk(items: list) -> None: ) ) else: - apps.append(stmt) + items.append(stmt) + elif isinstance(stmt, ConditionalCorrection): + items.append(stmt) elif isinstance(stmt, Instruction): sub_def = _lookup(stmt.name) if sub_def is not None: - apps.append(_instruction_to_application(stmt, sub_def=sub_def)) + items.append(_instruction_to_application(stmt, sub_def=sub_def)) elif isinstance(stmt, RepeatBlock): for _ in range(stmt.count): _walk(stmt.body) _walk(body) - return inputs, outputs, apps + return inputs, outputs, items # =================================================================== @@ -769,6 +789,155 @@ def has_repropagate(compose: ComposeDefinition) -> bool: return any(d.name == "REPROPAGATE" for d in compose.decorators) +def _count_readouts_recursive( + name: str, + gadget_defs: Mapping[str, GadgetDefinition], + compose_defs: Mapping[str, ComposeDefinition], + known_names: set[str], +) -> int: + """Count READOUT statements produced by gadget *name*, recursing into + nested COMPOSEs. + + Used to resolve ``rec[-k]`` in COMPOSE-level ``ConditionalCorrection`` + statements to absolute readout indices in the synthetic flat body + produced by :func:`expand_compose_circuit`. + """ + if name in gadget_defs: + return sum( + 1 + for s in flatten_body(list(gadget_defs[name].body)) + if isinstance(s, ReadoutStatement) + ) + if name in compose_defs: + compose = compose_defs[name] + total = 0 + for stmt in compose.body: + total += _count_readouts_in_compose_stmt( + stmt, gadget_defs, compose_defs, known_names + ) + return total + return 0 + + +def _count_readouts_in_compose_stmt( + stmt: ComposeStatement, + gadget_defs: Mapping[str, GadgetDefinition], + compose_defs: Mapping[str, ComposeDefinition], + known_names: set[str], +) -> int: + """Count READOUT statements contributed by *stmt* (a single COMPOSE + body statement). + + Handles ``RepeatBlock`` (multiplies by iteration count), + ``GadgetApplication`` (recurses into the named gadget/compose), and + shortcut ``Instruction`` applications (where the instruction name + matches a known gadget). + """ + if isinstance(stmt, RepeatBlock): + per_iter = sum( + _count_readouts_in_compose_stmt( + s, gadget_defs, compose_defs, known_names + ) + for s in stmt.body + ) + return per_iter * stmt.count + if isinstance(stmt, GadgetApplication): + return _count_readouts_recursive( + stmt.gadget_name, gadget_defs, compose_defs, known_names + ) + if isinstance(stmt, Instruction) and stmt.name in known_names: + return _count_readouts_recursive( + stmt.name, gadget_defs, compose_defs, known_names + ) + return 0 + + +def _translate_compose_conditionals( + compose: ComposeDefinition, + gadget_defs: Mapping[str, GadgetDefinition], + compose_defs: Mapping[str, ComposeDefinition], + known_names: set[str], +) -> list[ConditionalStatement]: + """Translate ``ConditionalCorrection`` statements in *compose*'s body + into GADGET-level ``ConditionalStatement(R)`` entries that + reference absolute readout indices in the synthetic flat body + produced by :func:`expand_compose_circuit`. + + Each ``CONDITIONAL rec[-k] `` becomes a + ``CONDITIONAL R OUT

.L

...`` where ``j`` is the absolute + readout index and ``OUT

`` is the synthetic GADGET's output port + that contains *wire*. + + Top-level only: nested ``ConditionalCorrection`` inside sub-COMPOSE + bodies is handled by their own merge() pipelines and propagated + through sub-gadget composition, not re-emitted here. + """ + wire_to_output_port_idx: dict[int, int] = {} + for port_idx, port in enumerate(compose.output_ports): + for wire in port.qubit_indices: + wire_to_output_port_idx[wire] = port_idx + + result: list[ConditionalStatement] = [] + running_readouts = 0 + + def walk(body: Sequence[ComposeStatement]) -> None: + nonlocal running_readouts + for stmt in body: + if isinstance(stmt, RepeatBlock): + # Unroll the REPEAT so each iteration's + # ConditionalCorrection statements emit their own + # ConditionalStatement with the correct absolute + # readout index for that iteration. + for _ in range(stmt.count): + walk(list(stmt.body)) + continue + if isinstance(stmt, GadgetApplication): + running_readouts += _count_readouts_recursive( + stmt.gadget_name, gadget_defs, compose_defs, known_names + ) + continue + if isinstance(stmt, Instruction) and stmt.name in known_names: + running_readouts += _count_readouts_recursive( + stmt.name, gadget_defs, compose_defs, known_names + ) + continue + if isinstance(stmt, ConditionalCorrection): + k = stmt.readout_offset + j = running_readouts - k + if j < 0: + raise ValueError( + f"COMPOSE {compose.name!r}: CONDITIONAL " + f"rec[-{k}] references readout index {j} " + f"(only {running_readouts} readouts produced " + f"so far)" + ) + if stmt.wire not in wire_to_output_port_idx: + raise ValueError( + f"COMPOSE {compose.name!r}: CONDITIONAL on " + f"wire {stmt.wire} but no OUTPUT port covers " + f"this wire" + ) + port_idx = wire_to_output_port_idx[stmt.wire] + targets = [ + LogicalPauliTarget( + pauli=p, + index=qi, + port_kind="OUT", + port_index=port_idx, + ) + for p, qi in stmt.paulis + ] + result.append( + ConditionalStatement( + condition=ReadoutTarget(index=j), + targets=targets, + ) + ) + + walk(list(compose.body)) + return result + + def compose_to_synthetic_gadget( compose: ComposeDefinition, gadget_definitions: Mapping[str, GadgetDefinition], @@ -778,15 +947,31 @@ def compose_to_synthetic_gadget( """Inline a COMPOSE body into a flat synthetic ``GadgetDefinition``. The synthetic gadget has the same name as *compose*; its body is - ``input_ports + circuit + output_ports`` produced by - :func:`expand_compose_circuit`. Decorators are dropped — the caller - is responsible for re-attaching ``@GTYPE``/``@CHECKS`` on the + ``input_ports + circuit + output_ports + conditionals`` produced + by :func:`expand_compose_circuit` and + :func:`_translate_compose_conditionals`. ``ConditionalStatement`` + entries follow the OUTPUTs, matching the convention used by hand- + written GADGET bodies (see ``tests/circuit/fixtures/example.deq`` + for ``Ejection``). Decorators are dropped — the caller is + responsible for re-attaching ``@GTYPE``/``@CHECKS`` on the pipeline side as needed. + Each ``ConditionalCorrection`` in the COMPOSE body is translated + into a GADGET-level ``ConditionalStatement(R)`` so the synthetic + GADGET preserves the conditional logical-frame correction. The + propagation validator uses the resulting ``logical_correction`` + matrix to extend its basis-freedom for PROPAGATE rows when the + natural Heisenberg of the inlined body does not capture the + CONDITIONAL effect (e.g. lattice surgery split-measurement frame + corrections). + Used by ``@REPROPAGATE`` composes (both at build time and at annotate time) so propagation matrices and noise-derived ERRORs are computed from circuit flow rather than from sub-gadget matrix - composition. + composition. Also used by the non-``@REPROPAGATE`` annotate + pathway to recover the original ``CONDITIONAL`` statements that + ``merge()`` has folded into the COMPOSE's matrices, so the + rendered GADGET round-trips through ``deq transpile``. """ known_names = set(gadget_definitions) | set(compose_definitions) input_ports, circuit, output_ports = expand_compose_circuit( @@ -796,7 +981,13 @@ def compose_to_synthetic_gadget( known_names, codes, ) - body: list = [*input_ports, *circuit, *output_ports] + conditionals = _translate_compose_conditionals( + compose, + gadget_definitions, + compose_definitions, + known_names, + ) + body: list = [*input_ports, *circuit, *output_ports, *conditionals] return GadgetDefinition( name=compose.name, body=body, @@ -1005,11 +1196,15 @@ def _build_merge_compose( The caller is expected to have already run :func:`validate_compose`. """ - inputs, outputs, apps = _expand_compose_body( + inputs, outputs, items = _expand_compose_body( list(compose.body), gadget_definitions=gadget_definitions, compose_definitions=compose_definitions, ) + # The validators only care about real gadget applications; conditional + # corrections sit between gadgets and do not change the wire's port + # type or producer/consumer count. + apps = [it for it in items if isinstance(it, GadgetApplication)] # ── Validate port-type compatibility between consecutive gadgets ── _validate_compose_port_types( @@ -1089,14 +1284,67 @@ def _build_merge_compose( # wrote to it. Connectors reference this mapping instead of blindly # pointing at the previous gadget. wire_source: dict[int, tuple[int, int]] = {} # wire → (gid, port) + # Track which port type each wire currently carries (so we can build + # the identity-gadget modifier for a ConditionalCorrection). + wire_ptype: dict[int, int] = {} + # Track logical readout history for resolving ``rec[-k]`` in + # CONDITIONAL statements: absolute_index → (gid, local_readout_index). + readout_history: list[tuple[int, int]] = [] + # Lazily synthesized identity gadget types, keyed by port type. + identity_gtype_of_ptype: dict[int, int] = {} + next_synthetic_gtype = max(gt_map, default=0) + 1 + + port_types_by_ptype: dict[int, jit_pb.JitPortType] = { + pt.base.ptype: pt for pt in port_types + } if has_input_mock: for i, inp in enumerate(inputs): mock_gt = mock_base + i prog.append(jit_pb.JitInstruction(gadget=pb.Gadget(gtype=mock_gt, gid=gid))) wire_source[inp.qubit_indices[0]] = (gid, 0) + wire_ptype[inp.qubit_indices[0]] = in_ptypes[i] gid += 1 - for app_idx, app in enumerate(apps): + for item in items: + if isinstance(item, ConditionalCorrection): + wire = item.wire + if wire not in wire_source: + raise ValueError( + f"COMPOSE {compose.name!r}: {item} references wire " + f"{wire} which has no producer" + ) + wire_pt = wire_ptype[wire] + if wire_pt not in port_types_by_ptype: + raise ValueError( + f"COMPOSE {compose.name!r}: {item} references wire " + f"{wire} whose port type {wire_pt} is unknown" + ) + instruction, new_identity_gt, next_synthetic_gtype = ( + emit_conditional_correction_instruction( + conditional=item, + error_context=f"COMPOSE {compose.name!r}", + wire_ptype=wire_pt, + wire_source=wire_source[wire], + readout_history=readout_history, + port_types_by_ptype=port_types_by_ptype, + identity_gtype_of_ptype=identity_gtype_of_ptype, + next_synthetic_gtype=next_synthetic_gtype, + gid=gid, + ) + ) + if new_identity_gt is not None: + gt_map[new_identity_gt.base.gtype] = new_identity_gt + prog.append(instruction) + real_gids.add(gid) + # Identity gadget preserves the port type, so wire_ptype[wire] + # stays as is; only the source pointer needs updating. + wire_source[wire] = (gid, 0) + gid += 1 + continue + + # item is a GadgetApplication + app = item + sub_jit = jit_gadget_types_by_name[app.gadget_name] in_wires = list(app.in_indices or []) out_wires = list(app.out_indices or []) connectors = [] @@ -1106,16 +1354,18 @@ def _build_merge_compose( prog.append( jit_pb.JitInstruction( gadget=pb.Gadget( - gtype=sub_jits[app_idx].base.gtype, + gtype=sub_jit.base.gtype, gid=gid, connectors=connectors, ) ) ) real_gids.add(gid) - # Update wire_source: output port i writes to out_wires[i]. + for local_r in range(len(sub_jit.base.readouts)): + readout_history.append((gid, local_r)) for port_idx, wire in enumerate(out_wires): wire_source[wire] = (gid, port_idx) + wire_ptype[wire] = sub_jit.base.outputs[port_idx].ptype gid += 1 # The output mock consumes one input port per declared compose # OUTPUT wire, each connected to whichever sub-gadget last wrote @@ -1212,3 +1462,186 @@ def _mk_output_mock( ), finished_checks=fin, ) + + +def mk_identity_gadget_type( + gtype: int, + ptype: int, + n_obs: int, + stab_count: int, +) -> jit_pb.JitGadgetType: + """Build a ``JitGadgetType`` for a measurement-free identity gadget. + + The identity gadget has a single INPUT port and a single OUTPUT + port, both of type ``ptype``. Its ``correction_propagation`` is + the identity matrix on the port observables (with a zero affine + column), so the wire frame passes through unchanged. Other + propagation matrices are empty. + + It is used as a placeholder host for + :class:`pb.GadgetModifier.remote_conditional_correction` modifiers + derived from ``CONDITIONAL`` statements inside ``COMPOSE`` or + ``PROGRAM`` bodies. Because the gadget is inserted into the JIT + program *after* the gadget whose readout it conditions on, the + program validator's ordering constraint is naturally satisfied. + + The ``stab_count`` output stabilizers each become an unfinished + check linking the matching input-virtual stabilizer to the implicit + output-virtual stabilizer — bridging stabilizer measurements across + the gadget so the JIT compiler can chain them with downstream + consumers. + """ + identity_cp = util_pb.BitMatrix( + rows=n_obs, + cols=n_obs + 1, + i=list(range(n_obs)), + j=list(range(n_obs)), + ) + return jit_pb.JitGadgetType( + base=pb.GadgetType( + gtype=gtype, + name=f"__identity_pt{ptype}__", + measurements=[], + inputs=[pb.GadgetType.Port(ptype=ptype)], + outputs=[pb.GadgetType.Port(ptype=ptype)], + correction_propagation=identity_cp, + readout_propagation=util_pb.BitMatrix(cols=n_obs + 1), + logical_correction=util_pb.BitMatrix(rows=n_obs), + physical_correction=util_pb.BitMatrix(rows=n_obs), + ), + unfinished_checks=[ + jit_pb.JitGadgetType.Check( + base=pb.CheckModelType.Check(), + measurements=[ + jit_pb.JitGadgetType.PresentMeasurement( + input_port=0, measurement_index=s + ) + ], + ) + for s in range(stab_count) + ], + ) + + +def _single_column_bitmatrix( + rows: int, flipped_rows: Sequence[int] +) -> util_pb.BitMatrix: + """Build a 1-column ``BitMatrix`` that flips the given rows. + + Used for the ``correction`` field of + :class:`pb.RemoteConditionalCorrection` modifiers, whose layout is + always a single column (the modifier is conditioned on a single + readout bit). + """ + flipped_list = list(flipped_rows) + return util_pb.BitMatrix( + rows=rows, + cols=1, + i=flipped_list, + j=[0] * len(flipped_list), + ) + + +def emit_conditional_correction_instruction( + *, + conditional: ConditionalCorrection, + error_context: str, + wire_ptype: int, + wire_source: tuple[int, int], + readout_history: Sequence[tuple[int, int]], + port_types_by_ptype: Mapping[int, jit_pb.JitPortType], + identity_gtype_of_ptype: dict[int, int], + next_synthetic_gtype: int, + gid: int, +) -> tuple[jit_pb.JitInstruction, jit_pb.JitGadgetType | None, int]: + """Build the JIT instruction realising one ``CONDITIONAL`` statement. + + The instruction is a synthesised identity gadget that consumes the + wire from its current producer (``wire_source``) and re-emits it + with a :class:`pb.RemoteConditionalCorrection` modifier conditioned + on ``readout_history[-conditional.readout_offset]``. + + Returns a triple: + + * the new :class:`jit_pb.JitInstruction` (caller appends it to its + own program stream and bumps its own ``gid`` counter); + * a newly created :class:`jit_pb.JitGadgetType` to register in the + library, or ``None`` if the cache (``identity_gtype_of_ptype``) + already contained an identity gadget for this port type; + * the updated ``next_synthetic_gtype`` counter. + + Mutates ``identity_gtype_of_ptype`` (registering the gtype on first + use of each port type). + + The caller is responsible for: + + * resolving ``conditional.wire`` to ``wire_ptype`` / ``wire_source`` + and raising any "wire has no producer" error *before* calling; + * verifying ``wire_ptype`` actually appears in + ``port_types_by_ptype``; + * updating its own wire bookkeeping after the call so subsequent + connectors reference ``(gid, 0)``. + + ``error_context`` is a free-form prefix used in raised + :class:`ValueError` messages (e.g. ``"PROGRAM 'foo'"`` or + ``"COMPOSE 'bar'"``). + """ + from deq.transpiler.jit_library_builder import pauli_to_observable_flips + + if conditional.readout_offset < 1: + raise ValueError( + f"{error_context}: {conditional} requires k >= 1 in " + f"rec[-k]; got rec[-{conditional.readout_offset}]" + ) + if conditional.readout_offset > len(readout_history): + raise ValueError( + f"{error_context}: {conditional} references " + f"rec[-{conditional.readout_offset}] but only " + f"{len(readout_history)} logical readout(s) have been " + f"produced so far" + ) + + remote_gid, remote_local_readout = readout_history[ + len(readout_history) - conditional.readout_offset + ] + + jit_port_type = port_types_by_ptype[wire_ptype] + n_obs = len(jit_port_type.base.observables) + stab_count = len(jit_port_type.stabilizers) + flip_rows = pauli_to_observable_flips(conditional.paulis, jit_port_type.k) + + newly_created: jit_pb.JitGadgetType | None = None + if wire_ptype in identity_gtype_of_ptype: + identity_gtype = identity_gtype_of_ptype[wire_ptype] + else: + identity_gtype = next_synthetic_gtype + next_synthetic_gtype += 1 + identity_gtype_of_ptype[wire_ptype] = identity_gtype + newly_created = mk_identity_gadget_type( + gtype=identity_gtype, + ptype=wire_ptype, + n_obs=n_obs, + stab_count=stab_count, + ) + + src_gid, src_port = wire_source + modifier = pb.GadgetModifier( + remote_conditional_correction=pb.RemoteConditionalCorrection( + remote_readouts=[ + pb.RemoteConditionalCorrection.RemoteReadout( + gid=remote_gid, + readout_index=remote_local_readout, + ) + ], + correction=_single_column_bitmatrix(n_obs, flip_rows), + ) + ) + instruction = jit_pb.JitInstruction( + gadget=pb.Gadget( + gtype=identity_gtype, + gid=gid, + connectors=[pb.Gadget.Connector(gid=src_gid, port=src_port)], + modifier=modifier, + ) + ) + return instruction, newly_created, next_synthetic_gtype diff --git a/deq/deq/transpiler/jit_annotate.py b/deq/deq/transpiler/jit_annotate.py index 08322e62..1981ca13 100644 --- a/deq/deq/transpiler/jit_annotate.py +++ b/deq/deq/transpiler/jit_annotate.py @@ -63,6 +63,7 @@ from deq.transpiler.code_validation import validate_code from deq.transpiler.compose_builder import ( _check_basis_from_jit_gadget_type, + _translate_compose_conditionals, compose_to_synthetic_gadget, expand_compose_circuit, has_repropagate, @@ -1054,10 +1055,16 @@ def _render_composed_gadget( suffix = f" {comment}" if comment else "" lines.append(" READOUT " + " ".join(rec_refs) + suffix) - # PROPAGATE statements pin every output logical row to the cp/pc - # representative the COMPOSE pipeline picked, so re-transpilation - # of the rendered GADGET produces a byte-identical - # ``correction_propagation`` and ``physical_correction``. + # PROPAGATE statements pin every output logical row. + # After the ``merge()`` absorption pass (canonical.py step 9), the + # composed gadget's ``correction_propagation`` and + # ``physical_correction`` already contain all the input-frame and + # measurement contributions, including those absorbed from any + # ``CONDITIONAL rec[-k] `` in the COMPOSE body. The + # merged ``logical_correction`` is always empty by design. We can + # therefore render PROPAGATE directly from ``base`` and rely on the + # round-trip property that re-transpiling the rendered GADGET + # reproduces these same matrices byte-for-byte. output_col_layout = PortColumnLayout(output_ports, codes) propagate_lines = _format_propagate_statements( base.correction_propagation, @@ -1067,6 +1074,23 @@ def _render_composed_gadget( ) lines.extend(propagate_lines) + # CONDITIONAL R emission: each ``ConditionalCorrection`` in the + # COMPOSE body becomes a GADGET-level ``CONDITIONAL R`` here. + # The merged ``logical_correction`` is empty (absorbed by step 9 of + # ``merge()``), but the validator on re-transpilation needs to see + # these statements so it can extend its basis-freedom with each + # CONDITIONAL's absorption pattern — without this round trip the + # rendered gadget would reject COMPOSEs whose flat-circuit + # Heisenberg does not naturally include the conditional logical- + # frame correction (e.g. lattice surgery). + known = set(gadget_defs) | set(compose_defs) + conditional_stmts = _translate_compose_conditionals( + compose, gadget_defs, compose_defs, known + ) + for cstmt in conditional_stmts: + targets_str = " ".join(str(t) for t in cstmt.targets) + lines.append(f" CONDITIONAL {cstmt.condition} {targets_str}") + # ERROR statements. When ``keep_noise`` is set, the noise # instructions above are emitted verbatim, so re-transpilation # re-derives the same ERROR rows from circuit flow — emitting diff --git a/deq/deq/transpiler/jit_library_builder.py b/deq/deq/transpiler/jit_library_builder.py index ecc63e75..4af84be3 100644 --- a/deq/deq/transpiler/jit_library_builder.py +++ b/deq/deq/transpiler/jit_library_builder.py @@ -505,6 +505,57 @@ def _build_check( input_virtual_count=input_virtual_count, ov_start=ov_start, ) + + # Collect CONDITIONAL R entries from the body so the validator + # can extend its basis-freedom with each statement's absorption + # pattern. Without this, COMPOSEs whose flat-circuit Heisenberg + # does not naturally include the conditional logical-frame + # correction (e.g. lattice surgery) would have to use + # ``@REPROPAGATE`` to round-trip through ``deq annotate``. + # + # We silently skip any CONDITIONAL whose target indices are out of + # range here; ``_build_logical_correction`` (run later) will raise + # the proper ``ValueError`` with full diagnostic context. + conditional_basis_info: list[tuple[frozenset[int], int]] = [] + num_logicals_total = sum(len(codes[p.code_name].logicals) for p in output_ports) + for stmt in flatten_body(list(gadget.body)): + if not isinstance(stmt, ConditionalStatement): + continue + if not isinstance(stmt.condition, ReadoutTarget): + continue + readout_idx = stmt.condition.index + if readout_idx < 0 or readout_idx >= len(readouts_pb): + continue + flipped: set[int] = set() + targets_valid = True + for target in stmt.targets: + if target.port_kind is None: + if target.index < 0 or target.index >= num_logicals_total: + targets_valid = False + break + else: + if target.port_kind != "OUT": + targets_valid = False + break + if ( + target.port_index is None + or target.port_index < 0 + or target.port_index >= len(output_ports) + ): + targets_valid = False + break + port_code = codes[output_ports[target.port_index].code_name] + if target.index < 0 or target.index >= len(port_code.logicals): + targets_valid = False + break + flipped.update(conditional_flipped_rows(target, output_ports, codes)) + if targets_valid and flipped: + conditional_basis_info.append((frozenset(flipped), readout_idx)) + + readout_measurement_indices = [ + list(info.measurement_indices) for info in readouts_info + ] + correction_propagation_pb, logical_physical_entries = ( compute_correction_propagation( gadget, @@ -516,10 +567,24 @@ def _build_check( input_virtual_count=input_virtual_count, ov_start=ov_start, propagations=propagations, + conditional_basis_info=conditional_basis_info, + readout_propagation=readout_propagation_pb, + readout_measurement_indices=readout_measurement_indices, ) ) + # Rows that the validator absorbed via user-supplied PROPAGATE + # statements have their CONDITIONAL contributions folded into + # ``correction_propagation`` / ``physical_correction``; keeping the + # corresponding ``logical_correction`` entries would double-count + # the readout's effect at runtime. + propagated_rows = set(propagations.keys()) logical_correction_pb = _build_logical_correction( - gadget, num_output_observables, len(readouts_pb), output_ports, codes + gadget, + num_output_observables, + len(readouts_pb), + output_ports, + codes, + skip_rows=propagated_rows, ) physical_conditionals_raw = collect_physical_conditionals( @@ -689,6 +754,7 @@ def _build_logical_correction( num_readouts: int, output_ports: list[OutputPort], codes: dict[str, CodeDefinition], + skip_rows: set[int] | None = None, ) -> util_pb.BitMatrix: """Build the ``logical_correction`` matrix from CONDITIONAL statements. @@ -704,6 +770,13 @@ def _build_logical_correction( physical qubit's anti-commuting observable is flipped individually. Multiple CONDITIONAL statements XOR into the matrix. + + When *skip_rows* is provided, CONDITIONAL contributions to rows in + that set are omitted from the matrix. The validator already + folded those rows' CONDITIONAL contributions into + ``correction_propagation`` / ``physical_correction`` via the user- + supplied PROPAGATE statements; keeping the corresponding lc entries + would double-count the readout's effect at runtime. """ entries: set[tuple[int, int]] = set() @@ -731,6 +804,8 @@ def _build_logical_correction( ) flipped = conditional_flipped_rows(target, output_ports, codes) for row in flipped: + if skip_rows is not None and row in skip_rows: + continue entries.symmetric_difference_update({(row, readout_col)}) sorted_entries = sorted(entries) @@ -744,6 +819,49 @@ def _build_logical_correction( ) +def pauli_to_observable_flips( + paulis: list[tuple[str, int]], + num_logical_qubits: int, +) -> list[int]: + """Compute the column indices of a port's observable layout that flip + when applying the logical Pauli product ``paulis``. + + Each entry ``(pauli_letter, logical_qubit_index)`` is a logical Pauli + on one logical qubit of the port's code (``X``, ``Y``, or ``Z``). + The flips follow the standard symplectic-pair convention (matching + :func:`conditional_flipped_rows` and the layout described in + :mod:`deq.transpiler.jit_transpiler`): + + * ``X`` on logical ``k`` flips the Z column (``z_column(k) = 2*k + 1``) + * ``Z`` on logical ``k`` flips the X column (``x_column(k) = 2*k``) + * ``Y`` on logical ``k`` flips both columns + + Stabilizer-generator columns are never flipped by a logical Pauli + (logical operators commute with all stabilizers by construction). + + Multiple Paulis compose by XOR: ``X1 * X1`` cancels out, etc. The + return value is a sorted list of column indices. + """ + flips: set[int] = set() + for pauli_letter, logical_idx in paulis: + pauli = pauli_letter.upper() + if pauli not in ("X", "Y", "Z"): + raise ValueError( + f"unsupported Pauli letter {pauli_letter!r}; " + f"expected 'X', 'Y', or 'Z'" + ) + if not 0 <= logical_idx < num_logical_qubits: + raise ValueError( + f"logical qubit index {logical_idx} out of range; " + f"port has {num_logical_qubits} logical qubit(s)" + ) + if pauli in ("X", "Y"): + flips ^= {z_column(logical_idx)} + if pauli in ("Z", "Y"): + flips ^= {x_column(logical_idx)} + return sorted(flips) + + def conditional_flipped_rows( target: LogicalPauliTarget, output_ports: list[OutputPort], diff --git a/deq/deq/transpiler/jit_noise_builder.py b/deq/deq/transpiler/jit_noise_builder.py index e6282df5..1e02cd56 100644 --- a/deq/deq/transpiler/jit_noise_builder.py +++ b/deq/deq/transpiler/jit_noise_builder.py @@ -1459,8 +1459,8 @@ def _build_propagation_basis_freedom( n_cp: int, n_pc: int, flip_col: int, -) -> tuple[BitMatrix, list[str]]: - """Build the basis-freedom matrix used to validate PROPAGATE specs. +) -> tuple[list[BitVector], list[str]]: + """Build the basis-freedom column list used to validate PROPAGATE specs. The freedom basis contains: @@ -1476,9 +1476,12 @@ def _build_propagation_basis_freedom( check is naturally flipped. Selecting one is harmless because finished checks always evaluate to zero structurally. - Returns the basis as a binar :class:`BitMatrix` (one column per - basis vector, ``n_cp + n_pc`` rows) plus a list of per-column - descriptions for diagnostics. + Returns the basis as a list of column :class:`BitVector` (each of + length ``n_cp + n_pc``) plus a parallel list of per-column + descriptions for diagnostics. Per-row CONDITIONAL R + contributions are added separately by the caller when validating + each PROPAGATE row, since they are valid only for the rows the + CONDITIONAL flips. """ columns: list[BitVector] = [] descriptions: list[str] = [] @@ -1523,14 +1526,67 @@ def _build_propagation_basis_freedom( columns.append(v) descriptions.append(f"finished-check #{fc_idx}") + return columns, descriptions + + +def _columns_to_basis_matrix( + columns: Sequence[BitVector], + n_total: int, +) -> BitMatrix: + """Stack a list of column :class:`BitVector` into a single + :class:`BitMatrix` of size ``n_total × len(columns)``.""" if not columns: - return BitMatrix.zeros(rows=n_total, columns=0), [] + return BitMatrix.zeros(rows=n_total, columns=0) matrix = BitMatrix.zeros(rows=n_total, columns=len(columns)) for j, v in enumerate(columns): for i in range(n_total): if v[i]: matrix[(i, j)] = True - return matrix, descriptions + return matrix + + +def _build_conditional_basis_vector( + *, + readout_index: int, + readout_propagation: util_pb.BitMatrix, + readout_measurement_indices: Sequence[int], + n_cp: int, + n_pc: int, + flip_col: int, +) -> BitVector: + """Build the basis-freedom vector for a ``CONDITIONAL R`` entry. + + Encodes the absorption pattern that step 9 of + :func:`deq.spec.canonical.merge` would apply when expanding + ``logical_correction[r, j] = 1`` into ``correction_propagation`` and + ``physical_correction``: + + * ``cp[r, in_col]`` flips for every input observable column where + ``readout_propagation[j, in_col] = 1`` (i.e. the readout depends + on that input observable), + * the affine ``flip_col`` flips when the readout's affine bit is + set, + * ``pc[r, m_idx]`` flips for every measurement index in the + readout's ``measurement_indices`` (the body measurements XORed + into the raw readout bit). + + Returns a single column vector of length ``n_cp + n_pc`` (rows of + the basis matrix), with the row component (which row ``r`` is + flipped) supplied by the caller as a separate per-row gate. + """ + v = BitVector.zeros(n_cp + n_pc) + affine_col = n_cp - 1 + for entry_index in range(len(readout_propagation.i)): + if readout_propagation.i[entry_index] != readout_index: + continue + col = readout_propagation.j[entry_index] + if col == affine_col: + v[flip_col] = True + elif col < n_cp: + v[col] = True + for m_idx in readout_measurement_indices: + v[n_cp + m_idx] = True + return v def _propagation_row_vector( @@ -1590,6 +1646,9 @@ def _validate_and_apply_propagations( n_cp: int, n_pc: int, flip_col: int, + conditional_basis_info: Sequence[tuple[frozenset[int], int]] = (), + readout_propagation: util_pb.BitMatrix | None = None, + readout_measurement_indices: Sequence[Sequence[int]] = (), ) -> tuple[set[tuple[int, int]], list[tuple[int, int]]]: """Validate each PROPAGATE row and substitute it for the flow result. @@ -1597,6 +1656,15 @@ def _validate_and_apply_propagations( user-specified row in place of the flow-derived row, after confirming the substitution lies in the basis-freedom span. + ``conditional_basis_info`` lists each ``CONDITIONAL R L

`` + statement in the body as ``(flipped_rows, readout_index)`` pairs. + For PROPAGATE rows that are flipped by such a CONDITIONAL, the + basis-freedom is extended with the absorption pattern (``rp[j, *]`` + in cp + ``R[j]`` in pc + the affine bit) so the user's PROPAGATE + can express the absorbed form even when the flow-derived + propagation does not naturally include it (e.g. lattice-surgery + split-measurement frame corrections). + Returns the updated ``(cp_entries, logical_physical)``. """ if not propagations: @@ -1622,7 +1690,7 @@ def _validate_and_apply_propagations( if parity: flip_stab_rows.add(out_row) - basis_matrix, basis_descriptions = _build_propagation_basis_freedom( + base_columns, base_descriptions = _build_propagation_basis_freedom( input_layout=input_layout, cp_stab_rows=cp_stab_rows, pc_stab_rows=pc_stab_rows, @@ -1635,6 +1703,24 @@ def _validate_and_apply_propagations( flip_col=flip_col, ) + n_total = n_cp + n_pc + cond_vectors_by_readout: dict[int, BitVector] = {} + if conditional_basis_info and readout_propagation is not None: + used_readouts = {j for _, j in conditional_basis_info} + for j in used_readouts: + if j < 0 or j >= len(readout_measurement_indices): + continue + cond_vectors_by_readout[j] = _build_conditional_basis_vector( + readout_index=j, + readout_propagation=readout_propagation, + readout_measurement_indices=readout_measurement_indices[j], + n_cp=n_cp, + n_pc=n_pc, + flip_col=flip_col, + ) + + base_matrix = _columns_to_basis_matrix(base_columns, n_total) + flow_cp_per_row: dict[int, set[int]] = {} for r, c in flow_cp_entries: flow_cp_per_row.setdefault(r, set()).add(c) @@ -1666,7 +1752,25 @@ def _validate_and_apply_propagations( delta = flow_vec ^ user_vec if delta.weight == 0: continue - if basis_matrix.column_count == 0: + + row_extras: list[BitVector] = [] + row_extra_descs: list[str] = [] + for flipped_rows, j in conditional_basis_info: + if row not in flipped_rows: + continue + v = cond_vectors_by_readout.get(j) + if v is None: + continue + row_extras.append(v) + row_extra_descs.append(f"CONDITIONAL R{j} (flips row {row})") + + if row_extras: + row_columns = list(base_columns) + row_extras + row_matrix = _columns_to_basis_matrix(row_columns, n_total) + else: + row_matrix = base_matrix + + if row_matrix.column_count == 0: raise ValueError( f"in GADGET {gadget_name!r}: PROPAGATE for output row {row} " f"({resolved.statement.target}) does not match the unique " @@ -1674,15 +1778,21 @@ def _validate_and_apply_propagations( f"to absorb the difference." f"{_repropagate_hint(gadget_name)}" ) - alpha = solve(basis_matrix, delta) + alpha = solve(row_matrix, delta) if alpha is None: + extra_clause = ( + ", or CONDITIONAL R contributions" + if row_extra_descs + else "" + ) raise ValueError( f"in GADGET {gadget_name!r}: PROPAGATE for output row {row} " f"({resolved.statement.target}) does not lie in the " f"basis-freedom span of that row; the spec differs from the " f"canonical flow-derived value by {delta.weight} bit(s) " f"that cannot be expressed as any XOR of input-stabilizers, " - f"output-stabilizer joint rows, or finished-check parities." + f"output-stabilizer joint rows, finished-check parities" + f"{extra_clause}." f"{_repropagate_hint(gadget_name)}" ) @@ -1696,7 +1806,7 @@ def _validate_and_apply_propagations( for c in sorted(resolved.pc_internal_cols): logical_physical.append((row, c)) - _ = basis_descriptions + _ = base_descriptions return cp_entries, logical_physical @@ -1758,6 +1868,9 @@ def compute_correction_propagation( input_virtual_count: int, ov_start: int | None = None, propagations: dict[int, ResolvedPropagation] | None = None, + conditional_basis_info: Sequence[tuple[frozenset[int], int]] = (), + readout_propagation: util_pb.BitMatrix | None = None, + readout_measurement_indices: Sequence[Sequence[int]] = (), ) -> tuple[util_pb.BitMatrix, list[tuple[int, int]]]: """Compute the ``correction_propagation`` matrix. @@ -1874,6 +1987,9 @@ def compute_correction_propagation( n_cp=cols, n_pc=ov_start - input_virtual_count, flip_col=constant_col, + conditional_basis_info=conditional_basis_info, + readout_propagation=readout_propagation, + readout_measurement_indices=readout_measurement_indices, ) sorted_entries = sorted(entries) diff --git a/deq/deq_runtime/src/proto/deq.bin.rs b/deq/deq_runtime/src/proto/deq.bin.rs index 13b28fd7..457dcd38 100644 --- a/deq/deq_runtime/src/proto/deq.bin.rs +++ b/deq/deq_runtime/src/proto/deq.bin.rs @@ -75,6 +75,17 @@ pub struct GadgetType { /// mapping from logical readouts to output observables (feed-forward Pauli) /// size = |output_observables| rows x |readouts| columns /// formerly named "conditional_correction" + /// + /// NOTE: In the canonical / merged form produced by `canonical.merge()`, + /// this matrix is always empty. Conditional corrections from + /// `logical_correction` and from `GadgetModifier.remote_conditional_correction` + /// are absorbed into `correction_propagation` and `physical_correction` + /// (and into per-error `residual`) during the merge. The field remains + /// useful for: + /// + /// * per-gadget authoring (e.g. `CONDITIONAL R L

` in a GADGET); + /// * runtime feed-forward when the runtime applies a `GadgetModifier` + /// `remote_conditional_correction` to a gadget instance. #[prost(message, optional, tag = "10")] pub logical_correction: ::core::option::Option, /// mapping from internal measurements to output observable corrections diff --git a/deq/proto/deq_bin.proto b/deq/proto/deq_bin.proto index fe70b4a9..00872c48 100644 --- a/deq/proto/deq_bin.proto +++ b/deq/proto/deq_bin.proto @@ -108,6 +108,16 @@ message GadgetType { // mapping from logical readouts to output observables (feed-forward Pauli) // size = |output_observables| rows x |readouts| columns // formerly named "conditional_correction" + // + // NOTE: In the canonical / merged form produced by ``canonical.merge()``, + // this matrix is always empty. Conditional corrections from + // ``logical_correction`` and from ``GadgetModifier.remote_conditional_correction`` + // are absorbed into ``correction_propagation`` and ``physical_correction`` + // (and into per-error ``residual``) during the merge. The field remains + // useful for: + // * per-gadget authoring (e.g. ``CONDITIONAL R L

`` in a GADGET); + // * runtime feed-forward when the runtime applies a ``GadgetModifier`` + // ``remote_conditional_correction`` to a gadget instance. deq.util.BitMatrix logical_correction = 10; // Transparent gadget can be useful to dynamically insert Pauli frame updates diff --git a/deq/tests/circuit/fixtures/teleportation.deq b/deq/tests/circuit/fixtures/teleportation.deq index 8c484a5a..2e51ea5e 100644 --- a/deq/tests/circuit/fixtures/teleportation.deq +++ b/deq/tests/circuit/fixtures/teleportation.deq @@ -46,6 +46,15 @@ COMPOSE Teleporatation { OUTPUT Code 1 } +COMPOSE Teleporatation2 { + INPUT Code 0 + PrepareZero 1 + CNOT 0 1 + MeasureX 0 + CONDITIONAL rec[-1] Z0 1 + OUTPUT Code 1 +} + # this should work, however, the check structure may be suboptimal GADGET TeleporatationRaw { INPUT Code 0 1 2 3 diff --git a/deq/tests/circuit/surface_code/lattice_surgery_d3.deq b/deq/tests/circuit/surface_code/lattice_surgery_d3.deq new file mode 100644 index 00000000..e591de81 --- /dev/null +++ b/deq/tests/circuit/surface_code/lattice_surgery_d3.deq @@ -0,0 +1,284 @@ +# ============================================================================= +# Lattice surgery on the rotated d=3 surface code. +# ============================================================================= +# +# Implements true lattice surgery (Horsman, Fowler, Devitt, Van Meter, +# NJP 2012; see also Chatterjee et al. "Lattice Surgery for Dummies", +# arXiv:2404.13202): two surface-code patches are spatially merged into +# one bigger code patch by turning on bulk stabilizers across an +# intermediate strip of data qubits, and split back via a basis-aligned +# destructive measurement of the intermediate strip. The state stays +# put on each patch — there is NO transversal CNOT and no Bell pair — +# while a joint Pauli operator is non-destructively extracted through +# the merged code's boundary syndromes. +# +# This is structurally different from the Bell-pair logical +# teleportation in ``teleportation_d3.deq``, which uses transversal +# CNOTs to move the logical state from one patch onto another. +# +# ── Geometry (MZZ merge) ──────────────────────────────────────────── +# +# Two patches A and B placed horizontally side-by-side with an +# intermediate column of three data qubits (q18, q19, q20) between them: +# +# cols 0 1 2 3 4 5 6 +# row 0 q0 q1 q2 q18 q9 q10 q11 +# row 1 q3 q4 q5 q19 q12 q13 q14 +# row 2 q6 q7 q8 q20 q15 q16 q17 +# +# The plaquette parity (X/Z) follows the existing rotated-surface-code +# checkerboard (Z when row+col is even, X when odd). Inside the seam, +# the four NEW bulk plaquettes are: +# +# rows 0-1, cols 2-3 → Z plaq Z2 Z5 Z18 Z19 [NEW] +# rows 1-2, cols 2-3 → X plaq X5 X8 X19 X20 [NEW] +# rows 0-1, cols 3-4 → X plaq X9 X12 X18 X19 [NEW] +# rows 1-2, cols 3-4 → Z plaq Z12 Z15 Z19 Z20 [NEW] +# +# ── MZZ merge mechanics ───────────────────────────────────────────── +# +# 1. Initialize the 3 intermediate data qubits q18 q19 q20 in |+⟩ +# (RX 18 19 20). This pins X18 = X19 = X20 = +1. +# 2. Measure the 4 new bulk plaquettes (one round; this fixture +# exists to exercise the COMPOSE pipeline on a spatially-merged +# surgery, not to claim distance-3 fault tolerance). +# +# Each new Z plaquette (the two with Z18 Z19 / Z19 Z20) anti- +# commutes with the |+⟩ stabilizers of the intermediate column: +# measuring it destabilizes one of {X18, X19, X20} and replaces +# it with the new merge stabilizer. Each new X plaquette is the +# product of A's (or B's) right (or left) boundary X 2-body and +# the |+⟩ X stabilizers, so its outcome deterministically equals +# the input boundary stabilizer. +# +# 3. Split: measure the intermediate column in the X basis +# (MX 18 19 20). This re-installs the |+⟩-style X stabilizers, +# destabilizes the new merge Z plaquettes and recovers A's and +# B's original boundary stabilizers up to a Pauli frame correction +# derived from the merge measurements. +# +# 4. Frame correction: the X-basis split measurements introduce a +# Pauli frame correction on LZ_A given by m_X19 ⊕ m_X20. We +# apply it via Stim's classically-conditioned Z (``CZ rec[-k] q``, +# = Pauli Z applied to qubit q if measurement record k is 1) on +# the qubits forming a representative of LZ_A. After the +# correction, the gadget acts as logical identity on both patches +# with NO measurement-dependent frame leakage. +# +# Note on CONDITIONAL vs. inline feedforward: +# ``CONDITIONAL rec[-k] `` in a COMPOSE block expresses a +# logical-level Pauli correction by injecting a synthesized identity +# gadget; the merge() canonicalizer then absorbs the contribution into +# ``correction_propagation`` / ``physical_correction``. This works +# cleanly when the CONDITIONAL ADDS contributions that the natural +# flat-circuit Heisenberg derivation also produces (e.g. the +# Bell-pair teleportation correction on the OUTPUT of a transversal +# CNOT — see ``teleportation_d3.deq``). However, when the +# CONDITIONAL is meant to CANCEL a frame correction inherent to the +# circuit (as is the case for lattice-surgery split measurements), +# the merge-with-CONDITIONAL absorbed propagation differs from the +# flat-circuit Heisenberg result by exactly the cancelled deps, and +# the noise-builder validator rejects the rendered GADGET unless +# ``@REPROPAGATE`` is added. The ``CZ rec[-k] q`` feedforward used +# below is the operationally-honest equivalent: it expresses the +# correction as a real circuit operation, so the natural Heisenberg +# derivation handles it correctly without any extra COMPOSE-level +# annotation. +# +# Note on the rotated-surface-code "hourglass" boundary: A's bottom- +# edge Z 2-body Z6*Z7 (cols 0-1) and B's top-edge Z 2-body Z10*Z11 +# (cols 1-2) sit on different columns; the merge does NOT add a new +# top/bottom 2-body Z stabilizer across the seam in this fixture. A +# textbook fault-tolerant lattice surgery either mirrors one of the +# patches or uses a 2-column-wide intermediate strip; this minimal +# version focuses on the bulk-plaquette-only construction so the +# COMPOSE pipeline has a concrete spatially-merged fixture to exercise. +# ============================================================================= + +IMPORT "surface_code_d3.deq" + +# ----------------------------------------------------------------------------- +# 1. MergeMZZ — single-shot lattice-surgery joint Z⊗Z merge-and-split +# of two surface-code patches. The frame correction is left visible +# as a logical readout (``READOUT M5 M6``); downstream code is +# responsible for tracking it. See ``MergeMZZCorrected`` below for +# the variant where the correction is applied in-circuit. +# ----------------------------------------------------------------------------- +GADGET MergeMZZ { + INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 # patch A + INPUT SurfaceCode 9 10 11 12 13 14 15 16 17 # patch B + + # Initialize intermediate column data qubits in |+⟩. + RX 18 19 20 + + # MERGE: measure the four new bulk plaquettes spanning the seam. + MPP Z2*Z5*Z18*Z19 + MPP X5*X8*X19*X20 + MPP X9*X12*X18*X19 + MPP Z12*Z15*Z19*Z20 + + # SPLIT: destructively measure the intermediate column in X basis. + MX 18 19 20 + + # Logical readout = m_X19 ⊕ m_X20: the Pauli frame correction bit + # on LZ_A that the lattice surgery introduces. + READOUT M5 M6 + + OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 + OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17 +} + +# ----------------------------------------------------------------------------- +# 2. MergeMZZCorrected — same lattice surgery, with the frame +# correction applied in-circuit via Stim's ``CZ rec[-k] q`` +# classically-conditioned Z. +# +# ``LZ_A`` representative: ``Z0 * Z3 * Z6`` (left column of patch A). +# Applying Z to qubits {0, 3, 6} anti-commutes with LX_A (one +# overlap at q0) but commutes with all four of patch A's X +# stabilizers, so the correction is a clean logical-level Z +# application that leaves A's stabilizer structure untouched. +# +# We feed-forward TWICE — once on rec[-2] (= m_X19) and once on +# rec[-1] (= m_X20) — because the frame-correction bit is the +# XOR of the two split measurements and Stim's CZ feedforward +# conditions on a single measurement record. +# ----------------------------------------------------------------------------- +GADGET MergeMZZCorrected { + INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 # patch A + INPUT SurfaceCode 9 10 11 12 13 14 15 16 17 # patch B + + RX 18 19 20 + + MPP Z2*Z5*Z18*Z19 + MPP X5*X8*X19*X20 + MPP X9*X12*X18*X19 + MPP Z12*Z15*Z19*Z20 + + MX 18 19 20 + + # In-circuit Pauli frame correction on patch A's LZ representative + # (qubits 0, 3, 6), conditioned on the X-basis split measurements + # of the intermediate column's middle and bottom data qubits. + CZ rec[-2] 0 rec[-2] 3 rec[-2] 6 + CZ rec[-1] 0 rec[-1] 3 rec[-1] 6 + + OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 + OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17 +} + +# ----------------------------------------------------------------------------- +# 3. Three equivalent COMPOSE wrappers exercising the COMPOSE pipeline. +# +# ``LSMergePassthrough`` uses the default (sub-gadget composition) +# COMPOSE pathway — merge() takes ``MergeMZZ``'s propagation +# matrices as-is. The Pauli frame correction is exposed as a +# logical readout on the COMPOSE. +# +# ``LSMergeCorrected`` wraps the in-circuit-corrected variant. +# +# ``LSMergeConditional`` applies the same Pauli frame correction +# via a COMPOSE-level ``CONDITIONAL rec[-1] Z0 0`` instead of an +# in-circuit ``CZ rec[...]``. This exercises the validator's +# basis-freedom extension: the natural Heisenberg of MergeMZZ's +# flat body does NOT reproduce the ``M5 ⊕ M6 → LZ_A`` dependency +# in the same canonical form that the merge() absorption pass +# produces, so the rendered GADGET's PROPAGATE statements differ +# from the flat-circuit Heisenberg by exactly the CONDITIONAL's +# absorption pattern; the validator accepts the difference because +# the synthetic body carries a corresponding ``CONDITIONAL R`` +# statement. No ``@REPROPAGATE`` decorator is required. +# +# All three COMPOSE pathways produce gadgets with empty +# ``logical_correction`` after the merge() absorption pass. +# ----------------------------------------------------------------------------- +COMPOSE LSMergePassthrough { + INPUT SurfaceCode 0 + INPUT SurfaceCode 1 + MergeMZZ 0 1 + OUTPUT SurfaceCode 0 + OUTPUT SurfaceCode 1 +} + +COMPOSE LSMergeCorrected { + INPUT SurfaceCode 0 + INPUT SurfaceCode 1 + MergeMZZCorrected 0 1 + OUTPUT SurfaceCode 0 + OUTPUT SurfaceCode 1 +} + +COMPOSE LSMergeConditional { + INPUT SurfaceCode 0 + INPUT SurfaceCode 1 + MergeMZZ 0 1 + CONDITIONAL rec[-1] Z0 0 + OUTPUT SurfaceCode 0 + OUTPUT SurfaceCode 1 +} + +# ----------------------------------------------------------------------------- +# 4. End-to-end memory programs that exercise the corrected variants +# of the lattice-surgery merge as logical identity on the Z basis +# of both patches. +# +# After applying the frame correction (in-circuit via +# ``MergeMZZCorrected`` for ``LSMergeCorrected``, COMPOSE-level +# ``CONDITIONAL`` for ``LSMergeConditional``, or PROGRAM-level +# ``CONDITIONAL`` for ``LSMergeProgramConditional``), the merge +# gadget acts as logical identity on the Z observables of both +# patches. Preparing both patches in ``|0_L⟩`` and measuring in +# the Z basis after the merge must give ``0`` deterministically on +# each patch. +# +# X-basis memory tests are intentionally NOT included: the MZZ +# merge measurement randomises ``LX_A`` (because the new bulk +# Z-plaquettes anti-commute with patch A's logical X +# representative); only the product ``LX_A · LX_B`` is preserved +# and bare ``ASSERT_EQ rec[-k] 0`` cannot express that joint +# parity. See ``teleportation_d3.deq`` for X-basis memory +# programs, where Bell-pair teleportation preserves both bases +# individually. +# +# ``LSMergePassthrough`` is intentionally NOT covered here — it +# exposes the ``M5⊕M6`` frame correction as a logical readout +# that downstream code is expected to track and apply explicitly, +# so a bare ``ASSERT_EQ`` is not the right shape of test for it. +# ----------------------------------------------------------------------------- +PROGRAM LSMergeCorrectedMemoryZ { + PrepareZ 0 + PrepareZ 1 + LSMergeCorrected 0 1 + MeasureZ 0 + MeasureZ 1 + ASSERT_EQ rec[-2] 0 + ASSERT_EQ rec[-1] 0 +} + +PROGRAM LSMergeConditionalMemoryZ { + PrepareZ 0 + PrepareZ 1 + LSMergeConditional 0 1 + MeasureZ 0 + MeasureZ 1 + ASSERT_EQ rec[-2] 0 + ASSERT_EQ rec[-1] 0 +} + +# Same lattice-surgery memory test, but the CONDITIONAL Pauli frame +# correction lives directly in the PROGRAM body rather than inside a +# wrapping COMPOSE. This exercises the +# :func:`emit_conditional_correction_instruction` PROGRAM-level +# pathway (see ``deq/cli/jit.py``), which is structurally identical +# to the COMPOSE-level pathway but reaches it via the program +# compiler instead of the compose canonicaliser. +PROGRAM LSMergeProgramConditionalMemoryZ { + PrepareZ 0 + PrepareZ 1 + MergeMZZ 0 1 + CONDITIONAL rec[-1] Z0 0 + MeasureZ 0 + MeasureZ 1 + ASSERT_EQ rec[-2] 0 + ASSERT_EQ rec[-1] 0 +} diff --git a/deq/tests/circuit/surface_code/teleportation_d3.deq b/deq/tests/circuit/surface_code/teleportation_d3.deq new file mode 100644 index 00000000..6ff6d9bf --- /dev/null +++ b/deq/tests/circuit/surface_code/teleportation_d3.deq @@ -0,0 +1,187 @@ +# ============================================================================= +# Surface-code logical teleportation through a Bell pair (d=3). +# ============================================================================= +# +# NOTE: this is *teleportation* (logical teleportation through transversal +# operations on a Bell pair of patches), NOT lattice surgery. Lattice +# surgery merges two patches into a single larger code patch and reads out +# joint Pauli operators from boundary stabilizers; here we never merge +# patches. See ``lattice_surgery_d3.deq`` for the proper lattice-surgery +# construction. +# +# This fixture exercises the COMPOSE-with-CONDITIONAL pipeline using gadget +# composition only: +# +# INPUT patch ─→ MeasureBell ─→ [classical bits] ─→ CONDITIONAL ─→ OUTPUT patch +# ↑ ↑ +# │ │ +# └─── one half of PrepareBell ─────┘ +# (the other half is the OUTPUT patch) +# +# Two equivalent ways to express the conditional Pauli frame update are +# provided so we exercise both new features introduced in this branch: +# +# 1. ``@REPROPAGATE`` re-derives the propagation matrix from the +# inlined flat circuit, automatically absorbing the conditional +# teleportation corrections. +# 2. Explicit ``CONDITIONAL rec[-k] `` statements emit a +# synthesized identity gadget that hosts a +# ``remote_conditional_correction`` modifier; the canonicalizer +# absorbs the resulting ``logical_correction`` into +# ``correction_propagation`` and ``physical_correction`` during +# ``merge()``. +# +# After absorption the two variants are canonically equivalent. +# ============================================================================= + +IMPORT "surface_code_d3.deq" + +# ----------------------------------------------------------------------------- +# 1. Idle — a patch that simply persists for one syndrome round. +# ----------------------------------------------------------------------------- +COMPOSE Idle { + INPUT SurfaceCode 0 + Syndrome 0 + OUTPUT SurfaceCode 0 +} + +# ----------------------------------------------------------------------------- +# 2. PrepareBell — prepare logical |Φ⁺⟩ = (|0_L 0_L⟩ + |1_L 1_L⟩) / √2 on +# two adjacent patches. +# +# Construction: +# a) PrepareX on patch 0 (|+_L⟩) +# b) PrepareZ on patch 1 (|0_L⟩) +# c) Transversal CNOT 0 → 1 +# +# The transversal CNOT carries LX_0 → LX_0 · LX_1 and LZ_1 → LZ_0 · LZ_1, +# yielding the +1 eigenspace of {LX_0 LX_1, LZ_0 LZ_1} = |Φ⁺⟩_L. +# ----------------------------------------------------------------------------- +COMPOSE PrepareBell { + PrepareX 0 + PrepareZ 1 + TransversalCNOT 0 1 + OUTPUT SurfaceCode 0 + OUTPUT SurfaceCode 1 +} + +# ----------------------------------------------------------------------------- +# 3. MeasureBell — destructively measure two patches in the logical +# Bell basis. +# +# Construction (inverse of PrepareBell): +# a) Transversal CNOT 0 → 1 +# b) MeasureX patch 0 → reads m_XX = ⟨LX_0 LX_1⟩ +# c) MeasureZ patch 1 → reads m_ZZ = ⟨LZ_0 LZ_1⟩ +# +# Readout order (within the composed gadget): rec[-2] = m_XX, +# rec[-1] = m_ZZ. +# ----------------------------------------------------------------------------- +COMPOSE MeasureBell { + INPUT SurfaceCode 0 + INPUT SurfaceCode 1 + TransversalCNOT 0 1 + MeasureX 0 + MeasureZ 1 +} + +# ----------------------------------------------------------------------------- +# 4. TeleportRepropagate — Bell-pair teleportation, letting +# ``@REPROPAGATE`` infer the conditional correction from the full +# inlined circuit. +# +# The compose builder rebuilds the propagation matrix on the flat +# equivalent circuit, so the canonical form already contains the +# ``m_XX → Z_out`` and ``m_ZZ → X_out`` contributions in cp/pc +# without any user-visible CONDITIONAL statement. +# ----------------------------------------------------------------------------- +@REPROPAGATE +COMPOSE TeleportRepropagate { + INPUT SurfaceCode 0 + PrepareBell 1 2 + MeasureBell 0 1 + OUTPUT SurfaceCode 2 +} + +# ----------------------------------------------------------------------------- +# 5. TeleportConditional — same teleportation written with explicit +# ``CONDITIONAL`` statements (no ``@REPROPAGATE``). After ``merge()`` +# step 9 absorbs the conditional contributions into cp/pc, the +# resulting ``JitGadgetType`` is canonically equivalent to +# ``TeleportRepropagate``. +# +# Standard teleportation correction: +# m_XX = 1 ⇒ apply Z on output patch (wire 2) +# m_ZZ = 1 ⇒ apply X on output patch (wire 2) +# ----------------------------------------------------------------------------- +COMPOSE TeleportConditional { + INPUT SurfaceCode 0 + PrepareBell 1 2 + MeasureBell 0 1 + CONDITIONAL rec[-2] Z0 2 + CONDITIONAL rec[-1] X0 2 + OUTPUT SurfaceCode 2 +} + +# ----------------------------------------------------------------------------- +# 6. PROGRAM-level deterministic checks. +# +# Prepare |0_L⟩, teleport through the pipe, then measure in the Z +# basis. In the absence of decoherence the post-correction logical +# state is |0_L⟩, so ``MeasureZ`` must read 0 deterministically. +# Likewise for |+_L⟩ → ``MeasureX`` → 0. +# ----------------------------------------------------------------------------- +PROGRAM TeleportRepropagateMemoryZ { + PrepareZ 0 + TeleportRepropagate 0 + MeasureZ 0 + ASSERT_EQ rec[-1] 0 +} + +PROGRAM TeleportConditionalMemoryZ { + PrepareZ 0 + TeleportConditional 0 + MeasureZ 0 + ASSERT_EQ rec[-1] 0 +} + +PROGRAM TeleportRepropagateMemoryX { + PrepareX 0 + TeleportRepropagate 0 + MeasureX 0 + ASSERT_EQ rec[-1] 0 +} + +PROGRAM TeleportConditionalMemoryX { + PrepareX 0 + TeleportConditional 0 + MeasureX 0 + ASSERT_EQ rec[-1] 0 +} + +# Same Bell-pair teleportation memory test, but the CONDITIONAL Pauli +# frame corrections live directly in the PROGRAM body rather than +# inside a wrapping COMPOSE. This exercises the PROGRAM-level +# CONDITIONAL pathway (:func:`emit_conditional_correction_instruction` +# invoked from the program compiler in ``deq/cli/jit.py``); the +# behaviour must match ``TeleportConditionalMemoryZ`` / +# ``TeleportConditionalMemoryX`` end-to-end. +PROGRAM TeleportProgramConditionalMemoryZ { + PrepareZ 0 + PrepareBell 1 2 + MeasureBell 0 1 + CONDITIONAL rec[-2] Z0 2 + CONDITIONAL rec[-1] X0 2 + MeasureZ 2 + ASSERT_EQ rec[-1] 0 +} + +PROGRAM TeleportProgramConditionalMemoryX { + PrepareX 0 + PrepareBell 1 2 + MeasureBell 0 1 + CONDITIONAL rec[-2] Z0 2 + CONDITIONAL rec[-1] X0 2 + MeasureX 2 + ASSERT_EQ rec[-1] 0 +} diff --git a/deq/tests/circuit/test_annotate.py b/deq/tests/circuit/test_annotate.py index 42652334..655e7690 100644 --- a/deq/tests/circuit/test_annotate.py +++ b/deq/tests/circuit/test_annotate.py @@ -117,3 +117,30 @@ def test_annotate_trivial_gadgets() -> None: def test_annotate_floquet666() -> None: _assert_annotate_roundtrip(CIRCUIT_DIR / "fixtures" / "floquet666.deq") + + +def test_annotate_teleportation_d3() -> None: + """Surface-code logical teleportation through a Bell pair. + + Exercises both ``@REPROPAGATE`` (inferred conditional correction) + and explicit ``CONDITIONAL`` statements on a single fixture. + """ + _assert_annotate_roundtrip( + CIRCUIT_DIR / "surface_code" / "teleportation_d3.deq" + ) + + +def test_annotate_lattice_surgery_d3() -> None: + """True lattice surgery on the d=3 rotated surface code. + + Exercises the COMPOSE / @REPROPAGATE pipeline on an MZZ + merge-and-split gadget that spatially merges two surface-code + patches via an intermediate column of |+⟩ data qubits, measures + the four new bulk plaquettes spanning the seam, and splits the + intermediate column back out via X-basis measurement. The + transpiler must derive the correct Pauli frame correction + (``OUT0.LZ0 = IN0.LZ0 ⊕ m_X19 ⊕ m_X20``) automatically. + """ + _assert_annotate_roundtrip( + CIRCUIT_DIR / "surface_code" / "lattice_surgery_d3.deq" + ) diff --git a/deq/tests/circuit/test_deq.py b/deq/tests/circuit/test_deq.py index ad15e073..2a4d321e 100644 --- a/deq/tests/circuit/test_deq.py +++ b/deq/tests/circuit/test_deq.py @@ -25,11 +25,13 @@ MeasurementRecordTarget, Decorator, KeywordArg, + ConditionalCorrection, ConditionalStatement, DestabilizerTarget, PropagateStatement, ReadoutTarget, LogicalPauliTarget, + VirtualCorrection, ) DEQ_FILE = Path(__file__).parent / "fixtures" / "example.deq" @@ -429,6 +431,23 @@ def test_pauli_correction(self): assert len(instrs) == 1 assert instrs[0].name == "Z" + def test_conditional_correction_in_compose(self): + text = """COMPOSE C { + INPUT Code 0 + MeasZ IN(0) + PrepZ OUT(0) + CONDITIONAL rec[-1] X0*Y1 0 + OUTPUT Code 0 +} +""" + deq = parse(text) + compose = deq.definitions[0] + conds = [s for s in compose.body if isinstance(s, ConditionalCorrection)] + assert len(conds) == 1 + assert conds[0].readout_offset == 1 + assert conds[0].paulis == [("X", 0), ("Y", 1)] + assert conds[0].wire == 0 + class TestProgramParsing: def test_simple_program(self): @@ -446,6 +465,50 @@ def test_simple_program(self): asserts = [s for s in prog.body if isinstance(s, AssertStatement)] assert asserts[0].expected_value == 0 + def test_conditional_correction_single_pauli(self): + text = """PROGRAM P { + Prep OUT(0) + Meas IN(0) + CONDITIONAL rec[-1] X0 0 +} +""" + deq = parse(text) + prog = deq.definitions[0] + conds = [s for s in prog.body if isinstance(s, ConditionalCorrection)] + assert len(conds) == 1 + assert conds[0].readout_offset == 1 + assert conds[0].paulis == [("X", 0)] + assert conds[0].wire == 0 + + def test_conditional_correction_multi_pauli(self): + text = """PROGRAM P { + Prep OUT(0) + Meas IN(0) + CONDITIONAL rec[-2] X1*Z2*Y3 5 +} +""" + deq = parse(text) + prog = deq.definitions[0] + conds = [s for s in prog.body if isinstance(s, ConditionalCorrection)] + assert len(conds) == 1 + assert conds[0].readout_offset == 2 + assert conds[0].paulis == [("X", 1), ("Z", 2), ("Y", 3)] + assert conds[0].wire == 5 + + def test_conditional_correction_roundtrip(self): + """str(ConditionalCorrection) parses back to an equivalent node.""" + original = ConditionalCorrection( + readout_offset=3, paulis=[("X", 0), ("Y", 1)], wire=7 + ) + text = f"PROGRAM P {{\n Prep OUT(7)\n Meas IN(7)\n {original}\n}}" + deq = parse(text) + prog = deq.definitions[0] + conds = [s for s in prog.body if isinstance(s, ConditionalCorrection)] + assert len(conds) == 1 + assert conds[0].readout_offset == original.readout_offset + assert conds[0].paulis == original.paulis + assert conds[0].wire == original.wire + class TestEmptyFile: def test_empty(self): diff --git a/deq/tests/cli/jit_test.py b/deq/tests/cli/jit_test.py index aedaf828..941c1199 100644 --- a/deq/tests/cli/jit_test.py +++ b/deq/tests/cli/jit_test.py @@ -505,6 +505,270 @@ def test_multi_pauli_equivalent_to_separate( assert list(t1.j) == list(t2.j) +class TestConditionalCorrections: + """Test CONDITIONAL rec[-k] pauli wire in PROGRAM bodies.""" + + def test_emits_identity_gadget_with_modifier( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """CONDITIONAL inserts a synthesized identity gadget instance with + a remote_conditional_correction modifier.""" + instructions = parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ OUT(0)\n" + "PrepareZ OUT(1)\n" + "MeasureZ IN(1)\n" + "CONDITIONAL rec[-1] X0 0\n" + "MeasureZ IN(0)", + ) + + # Expected: 5 instructions = 2 PrepareZ + 1 MeasureZ + identity + MeasureZ + assert len(instructions) == 5 + cond_instr = instructions[3] + assert cond_instr.gadget.HasField("modifier") + modifier = cond_instr.gadget.modifier + assert modifier.HasField("remote_conditional_correction") + rcc = modifier.remote_conditional_correction + # Reference is the most recent logical readout (MeasureZ on wire 1 + # emits 1 logical readout = XOR of the 3 physical measurements). + assert len(rcc.remote_readouts) == 1 + assert rcc.remote_readouts[0].gid == 3 + assert rcc.remote_readouts[0].readout_index == 0 + # X on logical qubit 0 flips the LZ_0 column (= z_column(0) = 1). + assert list(rcc.correction.i) == [1] + assert list(rcc.correction.j) == [0] + + def test_identity_gadget_chains_correctly( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """The identity gadget consumes the wire from the previous producer + and the next gadget consumes from the identity gadget.""" + instructions = parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ OUT(0)\n" + "PrepareZ OUT(1)\n" + "MeasureZ IN(1)\n" + "CONDITIONAL rec[-1] X0 0\n" + "MeasureZ IN(0)", + ) + + # gid 1 = PrepareZ (wire 0); gid 2 = PrepareZ (wire 1); + # gid 3 = MeasureZ on wire 1; gid 4 = identity; gid 5 = MeasureZ on wire 0. + # The identity gadget (gid 4) must connect to gid 1 (wire 0's producer). + assert instructions[3].gadget.gid == 4 + assert len(instructions[3].gadget.connectors) == 1 + assert instructions[3].gadget.connectors[0].gid == 1 + assert instructions[3].gadget.connectors[0].port == 0 + # The final MeasureZ (gid 5) must connect to the identity gadget (gid 4). + assert instructions[4].gadget.gid == 5 + assert len(instructions[4].gadget.connectors) == 1 + assert instructions[4].gadget.connectors[0].gid == 4 + assert instructions[4].gadget.connectors[0].port == 0 + + def test_identity_gadget_type_added_to_library( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """The synthesized identity gadget type is appended to the library.""" + # Snapshot the gtypes before compile. + before = {gt.base.gtype for gt in trivial_code_k3_jit_library.gadget_types} + parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ OUT(0)\n" + "MeasureZ IN(0)\n" # produces readouts so rec[-1] resolves + "PrepareZ OUT(0)\n" + "CONDITIONAL rec[-1] X0 0\n" + "MeasureZ IN(0)", + ) + after = {gt.base.gtype for gt in trivial_code_k3_jit_library.gadget_types} + new_gtypes = after - before + assert len(new_gtypes) == 1 + new_gt = next( + gt + for gt in trivial_code_k3_jit_library.gadget_types + if gt.base.gtype in new_gtypes + ) + assert new_gt.base.name.startswith("__identity_") + assert len(new_gt.base.measurements) == 0 + assert len(new_gt.base.inputs) == 1 + assert len(new_gt.base.outputs) == 1 + assert new_gt.base.inputs[0].ptype == new_gt.base.outputs[0].ptype + + def test_identity_gadget_reused_for_same_ptype( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """Multiple CONDITIONALs on the same ptype reuse the same identity gtype.""" + before = len(trivial_code_k3_jit_library.gadget_types) + parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ OUT(0)\n" + "PrepareZ OUT(1)\n" + "MeasureZ IN(1)\n" + "CONDITIONAL rec[-1] X0 0\n" + "CONDITIONAL rec[-1] Z0 0\n" + "MeasureZ IN(0)", + ) + after = len(trivial_code_k3_jit_library.gadget_types) + # Only ONE new gtype should be added even for multiple CONDITIONALs on + # the same port type. + assert after - before == 1 + + def test_multi_pauli_product( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """CONDITIONAL rec[-k] X0*Z1 wire flips both LZ_0 and LX_1.""" + instructions = parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ OUT(0)\n" + "PrepareZ OUT(1)\n" + "MeasureZ IN(1)\n" + "CONDITIONAL rec[-1] X0*Z1 0\n" + "MeasureZ IN(0)", + ) + rcc = instructions[3].gadget.modifier.remote_conditional_correction + # X0 flips LZ_0 (col 1), Z1 flips LX_1 (col 2). Sorted: [1, 2]. + assert list(rcc.correction.i) == [1, 2] + assert list(rcc.correction.j) == [0, 0] + + def test_y_pauli_flips_both( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """CONDITIONAL rec[-k] Y wire flips both LX_i and LZ_i columns.""" + instructions = parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ OUT(0)\n" + "MeasureZ IN(0)\n" + "PrepareZ OUT(0)\n" + "CONDITIONAL rec[-1] Y2 0\n" + "MeasureZ IN(0)", + ) + rcc = instructions[3].gadget.modifier.remote_conditional_correction + # Y2 = X2 * Z2; flips LZ_2 (col 5) and LX_2 (col 4). Sorted: [4, 5]. + assert list(rcc.correction.i) == [4, 5] + + def test_pauli_cancellation( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """CONDITIONAL rec[-k] X0*X0 wire has no effect (cancellation).""" + instructions = parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ OUT(0)\n" + "MeasureZ IN(0)\n" + "PrepareZ OUT(0)\n" + "CONDITIONAL rec[-1] X0*X0 0\n" + "MeasureZ IN(0)", + ) + rcc = instructions[3].gadget.modifier.remote_conditional_correction + # X0 * X0 = identity; correction matrix is empty. + assert list(rcc.correction.i) == [] + assert list(rcc.correction.j) == [] + + def test_multiple_conditionals_on_same_wire_chain( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """Multiple CONDITIONALs on the same wire chain through identity gadgets.""" + instructions = parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ OUT(0)\n" + "PrepareZ OUT(1)\n" + "PrepareZ OUT(2)\n" + "MeasureZ IN(1)\n" # readout #0 (gid=4) + "MeasureZ IN(2)\n" # readout #1 (gid=5) + "CONDITIONAL rec[-1] X0 0\n" # condition on readout #1 (gid=5) + "CONDITIONAL rec[-2] Z0 0\n" # condition on readout #0 (gid=4) + "MeasureZ IN(0)", + ) + # 8 instructions: 3 Prep + 2 MeasZ + 2 identity + 1 MeasZ. + assert len(instructions) == 8 + # Identity #1 (gid 6) consumes from gid 1 (PrepareZ for wire 0). + assert instructions[5].gadget.gid == 6 + assert instructions[5].gadget.connectors[0].gid == 1 + # Identity #2 (gid 7) consumes from gid 6 (Identity #1). + assert instructions[6].gadget.gid == 7 + assert instructions[6].gadget.connectors[0].gid == 6 + # Final MeasZ (gid 8) consumes from gid 7 (Identity #2). + assert instructions[7].gadget.gid == 8 + assert instructions[7].gadget.connectors[0].gid == 7 + # Identity #1's remote ref = gid 5's readout 0. + rcc1 = instructions[5].gadget.modifier.remote_conditional_correction + assert rcc1.remote_readouts[0].gid == 5 + assert rcc1.remote_readouts[0].readout_index == 0 + # Identity #2's remote ref = gid 4's readout 0. + rcc2 = instructions[6].gadget.modifier.remote_conditional_correction + assert rcc2.remote_readouts[0].gid == 4 + assert rcc2.remote_readouts[0].readout_index == 0 + + def test_rec_offset_out_of_range_raises( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """CONDITIONAL rec[-k] with k > number of readouts so far raises.""" + with pytest.raises(ValueError, match="readout"): + parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ OUT(0)\n" + "CONDITIONAL rec[-1] X0 0\n" # no readouts yet + "MeasureZ IN(0)", + ) + + def test_unknown_wire_raises( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """CONDITIONAL on a wire that has no producer raises.""" + with pytest.raises(ValueError, match="wire"): + parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ OUT(0)\n" + "MeasureZ IN(0)\n" + "CONDITIONAL rec[-1] X0 99\n", # wire 99 has no producer + ) + + def test_logical_qubit_index_out_of_range_raises( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """CONDITIONAL with a logical qubit index >= code.k raises.""" + # ThreeQubitCode has k=3, so logical qubit 99 is out of range. + with pytest.raises(ValueError, match="logical qubit"): + parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ OUT(0)\n" + "MeasureZ IN(0)\n" + "PrepareZ OUT(0)\n" + "CONDITIONAL rec[-1] X99 0\n" + "MeasureZ IN(0)", + ) + + def test_end_to_end_static_jit_compile( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """A PROGRAM with CONDITIONAL compiles cleanly through static_jit_compile.""" + instructions = parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ OUT(0)\n" + "PrepareZ OUT(1)\n" + "MeasureZ IN(1)\n" + "CONDITIONAL rec[-1] X0 0\n" + "MeasureZ IN(0)", + ) + # parse_jit_program already mutated trivial_code_k3_jit_library to add + # the identity gadget type. Now we can run the full static compile. + lib = jit_pb.JitLibrary() + lib.CopyFrom(trivial_code_k3_jit_library) + lib.ClearField("program") + for instr in instructions: + lib.program.append(instr) + deq_bin = static_jit_compiler(lib) + # Sanity check: the modifier is preserved in the compiled output. + found_modifier = False + for instr in deq_bin.program: + if instr.HasField("gadget") and instr.gadget.HasField("modifier"): + mod = instr.gadget.modifier + if mod.HasField("remote_conditional_correction"): + found_modifier = True + break + assert found_modifier, ( + "remote_conditional_correction modifier was lost in static_jit_compile" + ) + + class TestRepeatInProgram: """Test REPEAT blocks inside PROGRAM bodies.""" @@ -842,3 +1106,709 @@ def test_stim_export_remaps_mpp_pauli_targets() -> None: # Must contain physical indices 4,5,6,7 not local indices 0,1,2,3. assert "X4" in mpp_line, f"expected remapped indices in: {mpp_line}" assert "X0" not in mpp_line, f"local index leaked through in: {mpp_line}" + + +# --------------------------------------------------------------------------- +# Surface-code logical teleportation (d=3) — end-to-end PROGRAM +# compilation for both @REPROPAGATE and explicit-CONDITIONAL variants. +# --------------------------------------------------------------------------- + +TELEPORTATION_D3_DEQ = ( + Path(__file__).resolve().parents[1] + / "circuit" + / "surface_code" + / "teleportation_d3.deq" +) + + +@pytest.fixture(scope="module") +def teleportation_d3_setup() -> tuple[jit_pb.JitLibrary, dict[str, object]]: + """Parse ``teleportation_d3.deq`` and build its JIT library. + + Returns ``(jit_library, program_defs_by_name)``. ``program_defs`` + keys: ``TeleportRepropagateMemoryZ``, ``TeleportConditionalMemoryZ``, + ``TeleportRepropagateMemoryX``, ``TeleportConditionalMemoryX``. + """ + from deq.circuit.model import ProgramDefinition + from deq.circuit.parser import parse_file + + merged = parse_file(str(TELEPORTATION_D3_DEQ)) + jit_library = build_jit_library(merged) + program_defs = { + d.name: d for d in merged.definitions if isinstance(d, ProgramDefinition) + } + return jit_library, program_defs + + +class TestTeleportationD3: + """End-to-end compilation of surface-code logical teleportation PROGRAMs. + + Exercises both new branch features on the same surface-code fixture: + + * ``@REPROPAGATE`` (``TeleportRepropagate*`` programs) infers the + conditional teleportation correction from the inlined flat circuit. + * Explicit ``CONDITIONAL`` (``TeleportConditional*`` programs) emits + synthesized identity gadgets that the canonicalizer's step-9 + absorption folds back into the propagation/correction matrices. + + Both encodings must produce ``static_jit_compile``-able binaries that + pass the physical validator. + """ + + @pytest.mark.parametrize( + "program_name", + [ + "TeleportRepropagateMemoryZ", + "TeleportConditionalMemoryZ", + "TeleportRepropagateMemoryX", + "TeleportConditionalMemoryX", + ], + ) + def test_program_compiles_to_valid_binary( + self, + teleportation_d3_setup: tuple[jit_pb.JitLibrary, dict[str, object]], + program_name: str, + ) -> None: + from deq.cli.jit import compile_program_for_jit + + jit_library, program_defs = teleportation_d3_setup + program_def = program_defs[program_name] + + compiled, assertions = compile_program_for_jit(jit_library, program_def) + + # The program must emit at least one ASSERT_EQ (rec[-1] 0) and + # one JIT instruction per gadget application in the body. + assert len(assertions) == 1 + assert assertions[0][1] is False # expected_value=0 + + # Build a fresh library that includes the program stream and run + # the static JIT compiler. is_valid_and_physical sanity-checks + # the produced deq.bin. + lib = jit_pb.JitLibrary() + lib.CopyFrom(jit_library) + lib.ClearField("program") + for instr, _src in compiled: + lib.program.append(instr) + deq_bin = static_jit_compiler(lib) + assert is_valid_and_physical(deq_bin) + + def test_repropagate_and_conditional_emit_same_propagation( + self, + teleportation_d3_setup: tuple[jit_pb.JitLibrary, dict[str, object]], + ) -> None: + """``TeleportRepropagate`` and ``TeleportConditional`` are + operationally equivalent but have *structurally* different + canonical forms: + + * ``TeleportRepropagate`` rebuilds the GADGET from the flat + inlined circuit, so the ``MeasureBell`` sub-gadget's logical + readouts are absorbed away (0 readouts on the composed + GADGET). + * ``TeleportConditional`` keeps ``MeasureBell``'s 2 readouts + visible because the ``CONDITIONAL`` statements explicitly + reference them via ``rec[-1]`` / ``rec[-2]``; the resulting + conditional-correction contribution lives in + ``physical_correction`` (via measurement_indices) rather than + a separate ``logical_correction`` matrix. + + What MUST agree across both variants: + + * input / output port counts (same COMPOSE signature); + * empty ``logical_correction`` (the canonical absorption pass + clears it on both paths). + """ + jit_library, _ = teleportation_d3_setup + repro = next( + gt for gt in jit_library.gadget_types if gt.base.name == "TeleportRepropagate" + ) + cond = next( + gt for gt in jit_library.gadget_types if gt.base.name == "TeleportConditional" + ) + + # Same compose-level signature. + assert len(repro.base.inputs) == len(cond.base.inputs) == 1 + assert len(repro.base.outputs) == len(cond.base.outputs) == 1 + assert repro.base.inputs[0].ptype == cond.base.inputs[0].ptype + assert repro.base.outputs[0].ptype == cond.base.outputs[0].ptype + + # Both must have an empty logical_correction (absorbed away). + assert len(cond.base.logical_correction.i) == 0 + assert len(repro.base.logical_correction.i) == 0 + + # The CONDITIONAL form preserves MeasureBell's 2 logical readouts; + # the REPROPAGATE form folds them into the flat-circuit analysis. + assert len(cond.base.readouts) == 2 + assert len(repro.base.readouts) == 0 + + +# --------------------------------------------------------------------------- +# Lattice surgery (d=3 surface code) — true spatial merge-and-split test. +# --------------------------------------------------------------------------- + +LATTICE_SURGERY_D3_DEQ = ( + Path(__file__).resolve().parents[1] + / "circuit" + / "surface_code" + / "lattice_surgery_d3.deq" +) + + +@pytest.fixture(scope="module") +def lattice_surgery_d3_library() -> jit_pb.JitLibrary: + """Parse ``lattice_surgery_d3.deq`` and build its JIT library.""" + from deq.circuit.parser import parse_file + + return build_jit_library(parse_file(str(LATTICE_SURGERY_D3_DEQ))) + + +@pytest.fixture(scope="module") +def lattice_surgery_d3_setup() -> tuple[jit_pb.JitLibrary, dict[str, object]]: + """Parse ``lattice_surgery_d3.deq`` and return library + PROGRAMs. + + Returns ``(jit_library, program_defs_by_name)``. ``program_defs`` + keys: ``LSMergeCorrectedMemoryZ``, ``LSMergeConditionalMemoryZ``, + ``LSMergeCorrectedMemoryX``, ``LSMergeConditionalMemoryX``. + """ + from deq.circuit.model import ProgramDefinition + from deq.circuit.parser import parse_file + + merged = parse_file(str(LATTICE_SURGERY_D3_DEQ)) + jit_library = build_jit_library(merged) + program_defs = { + d.name: d for d in merged.definitions if isinstance(d, ProgramDefinition) + } + return jit_library, program_defs + + +class TestLatticeSurgeryD3: + """Verify the structural properties of the d=3 lattice-surgery MZZ + gadgets. + + Unlike the Bell-pair teleportation in ``teleportation_d3.deq``, + this fixture spatially merges two surface-code patches via an + intermediate column of |+⟩ data qubits, measures the four new bulk + plaquettes spanning the seam, and splits the intermediate column + back out via X-basis measurement. + + Two flavors of the same surgery are exercised: + + * ``MergeMZZ`` — leaves the lattice-surgery Pauli frame correction + visible as a logical readout (``m_X19 ⊕ m_X20``); + * ``MergeMZZCorrected`` — applies the frame correction in-circuit + via Stim's ``CZ rec[-k] q`` classically-conditioned Z, so the + gadget acts as logical identity on both patches with NO + measurement-dependent frame leakage. + + The COMPOSE wrappers (``LSMergePassthrough``, ``LSMergeCorrected``) + wrap each variant through the default merge() pathway and must + produce gadgets with empty ``logical_correction`` after the + absorption pass. + """ + + def test_merge_mzz_has_two_input_two_output_ports( + self, + lattice_surgery_d3_library: jit_pb.JitLibrary, + ) -> None: + """``MergeMZZ`` is a 2-input, 2-output gadget — both patches + survive the merge-and-split (it is non-destructive on logical + information except for the joint frame correction).""" + merge = next( + gt for gt in lattice_surgery_d3_library.gadget_types if gt.base.name == "MergeMZZ" + ) + assert len(merge.base.inputs) == 2 + assert len(merge.base.outputs) == 2 + # Both ports are the same SurfaceCode port type. + assert merge.base.inputs[0].ptype == merge.base.inputs[1].ptype + assert merge.base.outputs[0].ptype == merge.base.outputs[1].ptype + assert merge.base.inputs[0].ptype == merge.base.outputs[0].ptype + + def test_merge_mzz_exposes_frame_correction_readout( + self, + lattice_surgery_d3_library: jit_pb.JitLibrary, + ) -> None: + """``MergeMZZ`` exposes the Pauli frame correction bit as a + single logical readout (= m_X19 ⊕ m_X20 from the X-basis split + measurements of the intermediate column).""" + merge = next( + gt for gt in lattice_surgery_d3_library.gadget_types if gt.base.name == "MergeMZZ" + ) + assert len(merge.base.readouts) == 1 + # The readout reads two measurement records (the M5, M6 of the + # MX 18 19 20 split). + assert len(merge.base.readouts[0].measurement_indices) == 2 + + def test_merge_mzz_corrected_has_no_readouts( + self, + lattice_surgery_d3_library: jit_pb.JitLibrary, + ) -> None: + """``MergeMZZCorrected`` applies the frame correction + in-circuit via ``CZ rec`` feedforward, so it has NO logical + readout — the gadget is logical identity on both patches. + """ + merge = next( + gt + for gt in lattice_surgery_d3_library.gadget_types + if gt.base.name == "MergeMZZCorrected" + ) + assert len(merge.base.readouts) == 0 + + def test_merge_mzz_corrected_acts_as_identity_on_logicals( + self, + lattice_surgery_d3_library: jit_pb.JitLibrary, + ) -> None: + """After the in-circuit correction, ``MergeMZZCorrected`` has a + diagonal correction_propagation matrix on both patches' + logical observables and no measurement contributions on + ``physical_correction`` for those rows. + """ + merge = next( + gt + for gt in lattice_surgery_d3_library.gadget_types + if gt.base.name == "MergeMZZCorrected" + ) + cp = merge.base.correction_propagation + pc = merge.base.physical_correction + # The 4 logical observable rows (LX_A=0, LZ_A=1, LX_B=10, LZ_B=11) + # should have only the identity entry in cp (diagonal) and no + # entries in pc. + cp_pairs = set(zip(cp.i, cp.j)) + pc_pairs = set(zip(pc.i, pc.j)) + for logical_row in (0, 1, 10, 11): + assert (logical_row, logical_row) in cp_pairs, ( + f"row {logical_row}: missing identity in correction_propagation" + ) + pc_row = {(r, c) for (r, c) in pc_pairs if r == logical_row} + assert pc_row == set(), ( + f"row {logical_row}: unexpected pc entries {pc_row}; " + f"in-circuit correction should fully absorb them" + ) + + def test_compose_pathways_produce_empty_logical_correction( + self, + lattice_surgery_d3_library: jit_pb.JitLibrary, + ) -> None: + """Both COMPOSE pathways produce gadgets with an empty + ``logical_correction`` matrix: the merge() absorption pass + folds any conditional contribution into ``correction_propagation`` + / ``physical_correction``. + """ + for name in ("LSMergePassthrough", "LSMergeCorrected", "LSMergeConditional"): + gt = next( + g for g in lattice_surgery_d3_library.gadget_types if g.base.name == name + ) + assert len(gt.base.logical_correction.i) == 0, ( + f"{name}: logical_correction should be empty after merge() absorption" + ) + + def test_compose_pathways_have_two_input_two_output_ports( + self, + lattice_surgery_d3_library: jit_pb.JitLibrary, + ) -> None: + """All three COMPOSE wrappers preserve the 2-in / 2-out + signature of their underlying merge gadget.""" + for name in ("LSMergePassthrough", "LSMergeCorrected", "LSMergeConditional"): + gt = next( + g for g in lattice_surgery_d3_library.gadget_types if g.base.name == name + ) + assert len(gt.base.inputs) == 2, f"{name} should have 2 inputs" + assert len(gt.base.outputs) == 2, f"{name} should have 2 outputs" + + def test_ls_merge_conditional_matches_corrected( + self, + lattice_surgery_d3_library: jit_pb.JitLibrary, + ) -> None: + """``LSMergeConditional`` applies the Pauli frame correction + via a COMPOSE-level ``CONDITIONAL rec[-1] Z0 0`` rather than + an in-circuit ``CZ rec[...]``. After the merge() absorption + pass the resulting propagation matrices must match the + in-circuit variant ``LSMergeCorrected``: the logical rows of + both patches end up with no measurement contributions on + ``physical_correction`` (the frame correction is fully + absorbed) and the correction_propagation is the identity on + logical observables and passthrough stabs. + """ + conditional = next( + g + for g in lattice_surgery_d3_library.gadget_types + if g.base.name == "LSMergeConditional" + ) + corrected = next( + g + for g in lattice_surgery_d3_library.gadget_types + if g.base.name == "LSMergeCorrected" + ) + cond_cp = set( + zip( + conditional.base.correction_propagation.i, + conditional.base.correction_propagation.j, + ) + ) + corr_cp = set( + zip( + corrected.base.correction_propagation.i, + corrected.base.correction_propagation.j, + ) + ) + cond_pc = set( + zip( + conditional.base.physical_correction.i, + conditional.base.physical_correction.j, + ) + ) + corr_pc = set( + zip( + corrected.base.physical_correction.i, + corrected.base.physical_correction.j, + ) + ) + assert cond_cp == corr_cp, ( + "LSMergeConditional.correction_propagation should match " + "LSMergeCorrected after CONDITIONAL absorption" + ) + assert cond_pc == corr_pc, ( + "LSMergeConditional.physical_correction should match " + "LSMergeCorrected after CONDITIONAL absorption" + ) + + def test_ls_merge_conditional_has_readout( + self, + lattice_surgery_d3_library: jit_pb.JitLibrary, + ) -> None: + """``LSMergeConditional`` preserves the underlying + ``MergeMZZ`` readout (the COMPOSE-level CONDITIONAL is + absorbed into ``correction_propagation`` / + ``physical_correction`` but does not eliminate the readout + itself — the decoder still needs the measurement bit to apply + the correction).""" + conditional = next( + g + for g in lattice_surgery_d3_library.gadget_types + if g.base.name == "LSMergeConditional" + ) + assert len(conditional.base.readouts) == 1 + assert len(conditional.base.readouts[0].measurement_indices) == 2 + + +class TestLatticeSurgeryD3Programs: + """End-to-end compilation of lattice-surgery memory PROGRAMs. + + These programs are the lattice-surgery analogues of the + ``Teleport*Memory*`` programs in ``TestTeleportationD3``: they + verify that both the in-circuit feedforward variant + (``LSMergeCorrected``) and the COMPOSE-level ``CONDITIONAL`` variant + (``LSMergeConditional``) compile to a valid binary that the static + JIT compiler / physical validator accept. + + Each program prepares two surface-code patches in ``|0_L⟩`` (or + ``|+_L⟩``), applies the lattice-surgery merge, then measures each + patch in the matching basis. The merge is logical identity on + both patches once the frame correction is applied, so each + ``MeasureZ`` (or ``MeasureX``) outcome must read ``0`` + deterministically — encoded as two ``ASSERT_EQ rec[-k] 0`` + statements. + """ + + @pytest.mark.parametrize( + "program_name", + [ + "LSMergeCorrectedMemoryZ", + "LSMergeConditionalMemoryZ", + "LSMergeProgramConditionalMemoryZ", + ], + ) + def test_program_compiles_to_valid_binary( + self, + lattice_surgery_d3_setup: tuple[jit_pb.JitLibrary, dict[str, object]], + program_name: str, + ) -> None: + from deq.cli.jit import compile_program_for_jit + + jit_library, program_defs = lattice_surgery_d3_setup + program_def = program_defs[program_name] + + compiled, assertions = compile_program_for_jit(jit_library, program_def) + + # Each memory program asserts both ``MeasureZ`` readouts equal 0. + assert len(assertions) == 2 + for assertion in assertions: + # ``compile_program_for_jit`` returns ``(abs_index, expected, + # source)`` tuples; we only care that both are ``ASSERT_EQ ... 0``. + assert assertion[1] is False + + # Re-run the static JIT compiler with the program stream to make + # sure the produced deq.bin is physically valid (no dangling + # measurements, no missing CONDITIONAL absorption, etc.). + lib = jit_pb.JitLibrary() + lib.CopyFrom(jit_library) + lib.ClearField("program") + for instr, _src in compiled: + lib.program.append(instr) + deq_bin = static_jit_compiler(lib) + assert is_valid_and_physical(deq_bin) + + def test_corrected_and_conditional_programs_have_same_assertions( + self, + lattice_surgery_d3_setup: tuple[jit_pb.JitLibrary, dict[str, object]], + ) -> None: + """All three Z-basis variants — in-circuit ``CZ rec[...]`` + (``LSMergeCorrectedMemoryZ``), COMPOSE-level ``CONDITIONAL`` + (``LSMergeConditionalMemoryZ``), and PROGRAM-level + ``CONDITIONAL`` (``LSMergeProgramConditionalMemoryZ``) — reach + the same logical state by different routes and therefore + produce the same number of ``ASSERT_EQ rec[-k] 0`` assertions + with the same expected values. + + Neither absolute measurement offsets nor JIT instruction + counts are compared: the in-circuit variant folds the merge + readout away via feedforward, the COMPOSE-level CONDITIONAL + preserves the readout but absorbs into the COMPOSE matrices + (no extra JIT instruction), and the PROGRAM-level CONDITIONAL + emits an extra synthesised identity gadget instruction. The + end-to-end behaviour (deterministic ``MeasureZ = 0``) is the + same for all three, verified by the sample/simulate tests in + ``TestConditionalEndToEnd``. + """ + from deq.cli.jit import compile_program_for_jit + + jit_library, program_defs = lattice_surgery_d3_setup + program_names = [ + "LSMergeCorrectedMemoryZ", + "LSMergeConditionalMemoryZ", + "LSMergeProgramConditionalMemoryZ", + ] + results = [ + compile_program_for_jit(jit_library, program_defs[name]) + for name in program_names + ] + assertion_counts = [len(asserts) for _, asserts in results] + assertion_values = [ + tuple(a[1] for a in asserts) for _, asserts in results + ] + assert assertion_counts == [2, 2, 2], ( + f"expected 2 assertions per variant; got " + f"{dict(zip(program_names, assertion_counts))}" + ) + assert len(set(assertion_values)) == 1, ( + f"assertion expected values differ across variants: " + f"{dict(zip(program_names, assertion_values))}" + ) + + +# --------------------------------------------------------------------------- +# End-to-end ``deq sample`` + ``deq simulate ler`` smoke tests for both +# COMPOSE-level and PROGRAM-level ``CONDITIONAL`` correction pathways. +# --------------------------------------------------------------------------- + + +CONDITIONAL_E2E_PROGRAMS: list[tuple[str, Path]] = [ + # (program_name, .deq source file). All listed programs encode a + # logical-memory experiment whose ``ASSERT_EQ rec[-k] 0`` statements + # must hold on every noiseless sample. The mid-circuit measurement + # outcomes (Bell-pair / lattice-surgery merge readouts) are + # individually random; ``ASSERT_EQ`` checks the *corrected* logical + # readout, which the CONDITIONAL pathway must fold into the readout's + # measurement set. + ("TeleportConditionalMemoryZ", TELEPORTATION_D3_DEQ), + ("TeleportConditionalMemoryX", TELEPORTATION_D3_DEQ), + ("TeleportProgramConditionalMemoryZ", TELEPORTATION_D3_DEQ), + ("TeleportProgramConditionalMemoryX", TELEPORTATION_D3_DEQ), + ("LSMergeConditionalMemoryZ", LATTICE_SURGERY_D3_DEQ), + ("LSMergeProgramConditionalMemoryZ", LATTICE_SURGERY_D3_DEQ), +] + + +def _evaluate_assertions_on_sample( + deq_file: Path, + program_name: str, + *, + shots: int, + seed: int, +) -> tuple[int, int]: + """Compile *program_name* from *deq_file*, sample *shots* shots of + its noiseless stim circuit, and evaluate the program's + ``ASSERT_EQ`` statements on every shot. + + Returns ``(total_assertions, failed_assertions)``. A passing + program has ``failed_assertions == 0``. + """ + import tempfile + + from deq.cli.jit import compile_program_for_jit + from deq.cli.sample import ( + _compile_deq_to_stim_and_bin, + _sample_stim_text, + _strip_noise_text, + ) + from deq.cli.util import parse_bits + from deq.circuit.model import ProgramDefinition + from deq.circuit.parser import parse_file + from deq.spec.canonical import canonicalize + import deq.proto.deq_bin_pb2 as pb + + with tempfile.TemporaryDirectory() as tmpdir: + stim_path, bin_path = _compile_deq_to_stim_and_bin( + (str(deq_file),), + tmpdir, + program=program_name, + jit=None, + jobs=1, + plugin=None, + mako=None, + skip_mako_warning=True, + ) + with open(stim_path, encoding="utf-8") as f: + stim_text = _strip_noise_text(f.read()) + with open(bin_path, "rb") as f: + lib = pb.Library.FromString(f.read()) + + hex_samples = _sample_stim_text(stim_text, shots, seed) + canonical_form = canonicalize(lib) + gt = canonical_form.gadget_type + num_meas = len(gt.measurements) + + # The canonical readout_propagation's last column is the affine + # (constant) column: a 1 entry there means the readout is + # deterministically flipped (e.g. from a VIRTUAL Pauli correction). + # ``interpret_measurements`` applies this when computing readout + # values; we mirror it here so the sample-check matches the + # decoder's interpretation. + rp = gt.readout_propagation + affine_col = rp.cols - 1 if rp.cols > 0 else -1 + readout_affine: list[bool] = [False] * len(gt.readouts) + for r, c in zip(rp.i, rp.j): + if c == affine_col: + readout_affine[r] = not readout_affine[r] + + parsed = parse_file(str(deq_file)) + program_defs = { + d.name: d + for d in parsed.definitions + if isinstance(d, ProgramDefinition) + } + jit_lib = build_jit_library(parsed) + _, assertions = compile_program_for_jit(jit_lib, program_defs[program_name]) + + if not assertions: + raise AssertionError( + f"PROGRAM {program_name!r} has no ASSERT_EQ statements — " + f"the sample-check test would vacuously pass" + ) + + total = 0 + failed = 0 + for hex_meas in hex_samples: + bits = parse_bits(hex_meas, num_meas) + readout_values = [] + for idx, r in enumerate(gt.readouts): + parity = 0 + for mi in r.measurement_indices: + parity ^= bits[mi] + if readout_affine[idx]: + parity ^= 1 + readout_values.append(parity) + for abs_index, expected_value, _src in assertions: + total += 1 + actual = readout_values[abs_index] + if actual != (1 if expected_value else 0): + failed += 1 + return total, failed + + +class TestConditionalEndToEnd: + """``deq sample`` + ``deq simulate ler`` end-to-end smoke tests for + every CONDITIONAL correction pathway exercised in this branch. + + Each program is run through: + + * :func:`_evaluate_assertions_on_sample` — pulls 20 noiseless + samples from the program's stim circuit, evaluates the canonical + readout values, and asserts that every ``ASSERT_EQ rec[-k] 0`` + statement holds on every shot. This validates that the *deq* + side of the pipeline (transpilation, compose canonicalisation, + program-level remote-conditional-correction absorption) folds + the CONDITIONAL contribution into the readout's measurement + set, so the deterministic logical bit comes out as expected + despite the random mid-circuit-measurement values. + * ``deq simulate ler`` (invoked as a subprocess) — runs the same + program through the full *deq_runtime* decoder for 20 shots and + asserts zero logical errors. This validates that the *deq + runtime* side of the pipeline (decoder + classical correction + application) is consistent with the canonicalisation that + ``deq sample`` exercises. + """ + + @pytest.mark.parametrize( + "program_name,deq_file", + CONDITIONAL_E2E_PROGRAMS, + ids=lambda v: v if isinstance(v, str) else v.stem, + ) + def test_sample_20_shots_all_assertions_pass( + self, + program_name: str, + deq_file: Path, + ) -> None: + total, failed = _evaluate_assertions_on_sample( + deq_file, program_name, shots=20, seed=42 + ) + assert failed == 0, ( + f"{program_name}: {failed}/{total} ASSERT_EQ checks failed " + f"across 20 noiseless samples — the CONDITIONAL correction " + f"is not folded into the canonical readout" + ) + + @pytest.mark.parametrize( + "program_name,deq_file", + CONDITIONAL_E2E_PROGRAMS, + ids=lambda v: v if isinstance(v, str) else v.stem, + ) + def test_simulate_ler_20_shots_zero_logical_errors( + self, + program_name: str, + deq_file: Path, + ) -> None: + import re + import subprocess + import sys + + result = subprocess.run( + [ + sys.executable, + "-m", + "deq", + "simulate", + "ler", + str(deq_file), + "--program", + program_name, + "--shots", + "20", + "--errors", + "100", + "--batch-size", + "20", + "--seed", + "42", + "--jobs", + "1", + ], + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, ( + f"{program_name}: 'deq simulate ler' exited {result.returncode}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + m_shots = re.search(r"Shots:\s+(\d+)", result.stdout) + m_errs = re.search(r"Logical errors:\s+(\d+)", result.stdout) + assert m_shots is not None and m_errs is not None, ( + f"{program_name}: could not parse simulator output:\n" + f"{result.stdout}" + ) + assert int(m_shots.group(1)) == 20, ( + f"{program_name}: expected 20 shots, got {m_shots.group(1)}" + ) + assert int(m_errs.group(1)) == 0, ( + f"{program_name}: expected 0 logical errors over 20 noiseless " + f"shots, got {m_errs.group(1)}" + ) diff --git a/deq/tests/spec/canonical_test.py b/deq/tests/spec/canonical_test.py index 3db5a9a6..1468b1d4 100644 --- a/deq/tests/spec/canonical_test.py +++ b/deq/tests/spec/canonical_test.py @@ -188,15 +188,20 @@ def test_canonical_no_measurement() -> None: gtype=1, measurements=[pb.GadgetType.Measurement()] * 4, outputs=[pb.GadgetType.Port(ptype=1)], - correction_propagation=util_pb.BitMatrix( - rows=2, cols=1, i=[0], j=[0] - ), + # After merge() absorption: the original + # ``correction_propagation = [(0, affine)]`` (constant + # flip on output 0) XORs with ``lc[0, 0] · rp[0, affine]`` + # = 1 · 1 = 1, cancelling to empty. + correction_propagation=util_pb.BitMatrix(rows=2, cols=1), readouts=[pb.GadgetType.Readout(measurement_indices=[2, 3])], readout_propagation=util_pb.BitMatrix(rows=1, cols=1, i=[0], j=[0]), - logical_correction=util_pb.BitMatrix( - rows=2, cols=1, i=[0], j=[0] + # Absorbed: ``lc`` is always empty in the merged form. + logical_correction=util_pb.BitMatrix(rows=2, cols=1), + # Absorbed: ``pc[0, m] ^= lc[0, 0] · R[0, m]`` for each + # ``m`` in the readout's measurement_indices = [2, 3]. + physical_correction=util_pb.BitMatrix( + rows=2, cols=4, i=[0, 0], j=[2, 3] ), - physical_correction=util_pb.BitMatrix(rows=2, cols=4), ) ], check_model_types=[ @@ -473,8 +478,17 @@ def test_canonical_with_gadget_modifier_toggle_then_overwrite() -> None: def test_canonical_remote_conditional_correction() -> None: - """Test that remote_conditional_correction is XORed into the - canonical logical_correction.""" + """Test that remote_conditional_correction is absorbed into the + canonical correction_propagation / physical_correction matrices. + + After the absorption pass in ``merge()`` (canonical.py step 9), the + merged ``logical_correction`` matrix is always empty by design. + A modifier ``residual ^= remote_readouts[k]`` is rewritten as + ``residual ^= rp[k] · input + R[k] · measurements`` where ``R`` is + the readout's ``measurement_indices``. Here gid=1's readout reads + its own measurement M0 (so ``R[0] = {0}``), making the absorbed + effect visible as a single ``physical_correction[0, 0] = 1`` entry. + """ library = pb.Library( port_types=[ pb.PortType( @@ -487,7 +501,9 @@ def test_canonical_remote_conditional_correction() -> None: gtype=1, measurements=[pb.GadgetType.Measurement(tag="m1")], outputs=[pb.GadgetType.Port(ptype=1)], - readouts=[pb.GadgetType.Readout(tag="r1")], + readouts=[ + pb.GadgetType.Readout(tag="r1", measurement_indices=[0]), + ], correction_propagation=util_pb.BitMatrix(rows=1, cols=1), readout_propagation=util_pb.BitMatrix(rows=1, cols=1), logical_correction=util_pb.BitMatrix(rows=1, cols=1), @@ -538,11 +554,25 @@ def test_canonical_remote_conditional_correction() -> None: canonical_gadget_type = canonical_form.library.gadget_types[0] assert canonical_gadget_type.readouts, "Should have readouts in canonical form" - cond_corr = canonical_gadget_type.logical_correction - assert cond_corr.rows == 1, "Should have 1 output observable" - assert cond_corr.cols == 1, "Should have 1 readout" - assert list(cond_corr.i) == [0], "Observable 0 should be corrected" - assert list(cond_corr.j) == [0], "Based on readout 0" + + # The merged ``logical_correction`` is always empty after absorption. + lc = canonical_gadget_type.logical_correction + assert len(lc.i) == 0 and len(lc.j) == 0, ( + f"logical_correction must be empty after absorption; got " + f"i={list(lc.i)} j={list(lc.j)}" + ) + + # The remote_conditional_correction's effect is absorbed into + # physical_correction: residual[0] ^= R[0] · measurements = M0. + pc = canonical_gadget_type.physical_correction + assert pc.rows == 1, "Should have 1 output observable" + # M0 is the first measurement; gtype=2 also has a measurement (M1 globally), + # so cols = 2. + assert pc.cols == 2, "Should have 2 measurements total" + assert set(zip(pc.i, pc.j)) == {(0, 0)}, ( + "Observable 0 should be flipped by measurement M0 (absorbed from the " + "remote conditional correction on readout 0 = parity of [M0])" + ) def test_canonical_remote_conditional_correction_xor() -> None: @@ -603,7 +633,16 @@ def test_canonical_remote_conditional_correction_xor() -> None: def test_canonical_remote_conditional_correction_multiple_gadgets() -> None: - """Test remote_conditional_correction with multiple gadgets in a chain.""" + """Test remote_conditional_correction with multiple gadgets in a chain. + + After the absorption pass in ``merge()`` (canonical.py step 9), the + merged ``logical_correction`` is always empty. Each readout that + the modifier references contributes to the absorbed + ``physical_correction`` via the readout's ``measurement_indices`` + (when non-empty). We give each upstream gadget a single measurement + and bind its readout to that measurement so the absorbed effect is + a visible per-readout entry in ``physical_correction``. + """ library = pb.Library( port_types=[ pb.PortType( @@ -614,22 +653,28 @@ def test_canonical_remote_conditional_correction_multiple_gadgets() -> None: gadget_types=[ pb.GadgetType( gtype=1, + measurements=[pb.GadgetType.Measurement(tag="m1")], outputs=[pb.GadgetType.Port(ptype=1)], - readouts=[pb.GadgetType.Readout(tag="r1")], + readouts=[ + pb.GadgetType.Readout(tag="r1", measurement_indices=[0]), + ], correction_propagation=util_pb.BitMatrix(rows=1, cols=1), readout_propagation=util_pb.BitMatrix(rows=1, cols=1), logical_correction=util_pb.BitMatrix(rows=1, cols=1), - physical_correction=util_pb.BitMatrix(rows=1, cols=0), + physical_correction=util_pb.BitMatrix(rows=1, cols=1), ), pb.GadgetType( gtype=2, + measurements=[pb.GadgetType.Measurement(tag="m2")], inputs=[pb.GadgetType.Port(ptype=1)], outputs=[pb.GadgetType.Port(ptype=1)], - readouts=[pb.GadgetType.Readout(tag="r2")], + readouts=[ + pb.GadgetType.Readout(tag="r2", measurement_indices=[0]), + ], correction_propagation=util_pb.BitMatrix(rows=1, cols=2), readout_propagation=util_pb.BitMatrix(rows=1, cols=2), logical_correction=util_pb.BitMatrix(rows=1, cols=1), - physical_correction=util_pb.BitMatrix(rows=1, cols=0), + physical_correction=util_pb.BitMatrix(rows=1, cols=1), ), pb.GadgetType( gtype=3, @@ -685,9 +730,23 @@ def test_canonical_remote_conditional_correction_multiple_gadgets() -> None: canonical_gadget_type = canonical_form.library.gadget_types[0] assert len(canonical_gadget_type.readouts) == 2, "Should have 2 readouts total" - cond_corr = canonical_gadget_type.logical_correction - assert cond_corr.rows == 1, "Should have 1 output observable" - assert cond_corr.cols == 2, "Should have 2 readouts" - correction_set = set(zip(cond_corr.i, cond_corr.j)) - assert (0, 0) in correction_set, "Observable 0 corrected by readout 0" - assert (0, 1) in correction_set, "Observable 0 corrected by readout 1" + + # The merged ``logical_correction`` is always empty after absorption. + lc = canonical_gadget_type.logical_correction + assert len(lc.i) == 0 and len(lc.j) == 0, ( + f"logical_correction must be empty after absorption; got " + f"i={list(lc.i)} j={list(lc.j)}" + ) + + # Each of the two readouts has measurement_indices=[its own measurement], + # so absorption produces ``pc[0, m]`` entries for each. The global + # measurement indices for the merged library are 0 (gid=1's M0) and + # 1 (gid=2's M0), giving pc entries at columns 0 and 1. + pc = canonical_gadget_type.physical_correction + assert pc.rows == 1, "Should have 1 output observable" + assert pc.cols == 2, "Should have 2 measurements total" + assert set(zip(pc.i, pc.j)) == {(0, 0), (0, 1)}, ( + "Observable 0 should be flipped by both M0 (gid=1) and M1 (gid=2), " + "absorbed from the two readout references in the remote conditional " + "correction" + ) diff --git a/deq/tests/spec/program_identicalness_test.py b/deq/tests/spec/program_identicalness_test.py index bbfae14d..fbfad571 100644 --- a/deq/tests/spec/program_identicalness_test.py +++ b/deq/tests/spec/program_identicalness_test.py @@ -78,10 +78,17 @@ def test_program_identicalness_prog_id_2_2() -> None: def test_program_identicalness_prog_id_2_3_and_2_5_and_2_6() -> None: + # ProgId 2.6 (logical_correction differs) is no longer reachable after + # the merge() absorption pass (canonical.py step 9) — the merged + # ``logical_correction`` is always empty by design, so two canonical + # forms will always agree on it. We still assert that + # ``correction_propagation`` (ProgId 2.3) and + # ``readout_propagation`` (ProgId 2.5) differences are reported. + # To trigger ProgId 2.3 we set ``cp[0, 0] = 1`` (affine flip) without + # any compensating ``lc`` that would absorb it back to empty. assert ( "(ProgId 2.3) static correction propagation differ", "(ProgId 2.5) static readout propagation differ", - "(ProgId 2.6) logical correction differ", ) in are_programs_identical( library_with_observables, pb.Library( @@ -96,7 +103,7 @@ def test_program_identicalness_prog_id_2_3_and_2_5_and_2_6() -> None: correction_propagation=util_pb.BitMatrix( rows=1, cols=1, i=[0], j=[0] ), - logical_correction=util_pb.BitMatrix(rows=1, cols=1, i=[0], j=[0]), + logical_correction=util_pb.BitMatrix(rows=1, cols=1), physical_correction=util_pb.BitMatrix(rows=1, cols=7), ) ], diff --git a/deq/tests/transpiler/jit_library_builder_test.py b/deq/tests/transpiler/jit_library_builder_test.py index 0cebe910..64e9173e 100644 --- a/deq/tests/transpiler/jit_library_builder_test.py +++ b/deq/tests/transpiler/jit_library_builder_test.py @@ -1107,3 +1107,282 @@ def test_conditional_invalid_logical_index() -> None: """ with pytest.raises(ValueError, match="LX5 out of range"): build_jit_library(parse(source)) + + +# --------------------------------------------------------------------------- +# COMPOSE CONDITIONAL — synthesizes identity gadget and folds into +# composed logical_correction via merge(). +# --------------------------------------------------------------------------- + +_COND_COMPOSE_DEQ = """ +CODE Rep [[3,1,3]] { + LOGICAL X0*X1*X2 Z0*Z1*Z2 + STABILIZER Z0*Z1 Z1*Z2 +} + +GADGET PrepZ { + R 0 1 2 + OUTPUT Rep 0 1 2 +} + +GADGET MeasZ { + INPUT Rep 0 1 2 + M 0 1 2 + READOUT rec[-1] +} +""" + + +def test_compose_conditional_folds_into_logical_correction() -> None: + """A COMPOSE block with ``CONDITIONAL rec[-1] X0 `` after a + measurement gadget absorbs the conditional correction into the + composed gadget's ``correction_propagation`` and + ``physical_correction`` matrices (the merged ``logical_correction`` + is always empty by design — see canonical.py step 9). + + Applying logical X on logical qubit 0 flips the LZ_0 row + (= z_column(0) = 1). Through absorption, this becomes: + * ``pc[1, m] ^= 1`` for each ``m`` in the readout's + ``measurement_indices``; + * ``cp[1, c] ^= 1`` for each input column ``c`` in + ``rp[readout, *]``. + """ + source = ( + _COND_COMPOSE_DEQ + + """ +COMPOSE C { + INPUT Rep 5 + MeasZ IN(5) + PrepZ OUT(5) + CONDITIONAL rec[-1] X0 5 + OUTPUT Rep 5 +} +""" + ) + library = build_jit_library(parse(source)) + composed = next(gt for gt in library.gadget_types if gt.base.name == "C") + assert len(composed.base.inputs) == 1 + assert len(composed.base.outputs) == 1 + assert len(composed.base.readouts) == 1 + + # The merged logical_correction is always empty after absorption. + lc = composed.base.logical_correction + assert sorted(zip(lc.i, lc.j)) == [] + + # Absorbed effect: row 1 (LZ_0) flipped by the readout's measurement(s) + # and by the input cols feeding the readout via rp. + readout = composed.base.readouts[0] + rp = composed.base.readout_propagation + pc = composed.base.physical_correction + cp = composed.base.correction_propagation + rp_input_cols_for_r0 = {c for r, c in zip(rp.i, rp.j) if r == 0} + expected_pc_for_row1 = {(1, m) for m in readout.measurement_indices} + expected_cp_for_row1 = {(1, c) for c in rp_input_cols_for_r0} + actual_pc_for_row1 = {(r, c) for r, c in zip(pc.i, pc.j) if r == 1} + actual_cp_for_row1 = {(r, c) for r, c in zip(cp.i, cp.j) if r == 1} + assert actual_pc_for_row1 == expected_pc_for_row1 + assert actual_cp_for_row1 == expected_cp_for_row1 + # And no other rows acquired absorbed entries for this conditional. + assert {r for r, _ in zip(pc.i, pc.j)} <= {1} + assert {r for r, _ in zip(cp.i, cp.j)} <= {1} + + +def test_compose_conditional_y_flips_both_columns() -> None: + """``CONDITIONAL rec[-1] Y0 `` absorbs into BOTH the row-0 + (LX_0) and row-1 (LZ_0) of the composed cp/pc matrices (Y = X·Z up + to phase, so both symplectic partner columns flip).""" + source = ( + _COND_COMPOSE_DEQ + + """ +COMPOSE C { + INPUT Rep 5 + MeasZ IN(5) + PrepZ OUT(5) + CONDITIONAL rec[-1] Y0 5 + OUTPUT Rep 5 +} +""" + ) + library = build_jit_library(parse(source)) + composed = next(gt for gt in library.gadget_types if gt.base.name == "C") + lc = composed.base.logical_correction + assert sorted(zip(lc.i, lc.j)) == [] # lc is empty after absorption + + # Y0 absorbs into rows 0 (LX_0) and 1 (LZ_0). + readout = composed.base.readouts[0] + rp = composed.base.readout_propagation + pc = composed.base.physical_correction + rp_input_cols_for_r0 = {c for r, c in zip(rp.i, rp.j) if r == 0} + pc_rows = {r for r, _ in zip(pc.i, pc.j)} + assert pc_rows == {0, 1}, "Y0 absorbs into both row 0 (LX_0) and row 1 (LZ_0)" + for target_row in (0, 1): + expected = {(target_row, m) for m in readout.measurement_indices} + actual = {(r, c) for r, c in zip(pc.i, pc.j) if r == target_row} + assert actual == expected + + +def test_compose_conditional_multi_pauli() -> None: + """``CONDITIONAL rec[-1] X0*Z0 `` absorbs into rows 0 (LX_0) + and 1 (LZ_0) — same effect as Y0 for a single-logical-qubit code.""" + source = ( + _COND_COMPOSE_DEQ + + """ +COMPOSE C { + INPUT Rep 5 + MeasZ IN(5) + PrepZ OUT(5) + CONDITIONAL rec[-1] X0*Z0 5 + OUTPUT Rep 5 +} +""" + ) + library = build_jit_library(parse(source)) + composed = next(gt for gt in library.gadget_types if gt.base.name == "C") + lc = composed.base.logical_correction + assert sorted(zip(lc.i, lc.j)) == [] # lc is empty after absorption + + pc = composed.base.physical_correction + pc_rows = {r for r, _ in zip(pc.i, pc.j)} + assert pc_rows == {0, 1} + + +def test_compose_conditional_cancellation() -> None: + """``CONDITIONAL rec[-1] X0*X0 `` is a no-op (XOR cancellation): + no absorbed entries appear in cp/pc/lc beyond what would be there + without the conditional.""" + # Build the composition WITHOUT the cancelling conditional as a + # reference for comparing absorbed matrices. + reference = build_jit_library( + parse( + _COND_COMPOSE_DEQ + + """ +COMPOSE C { + INPUT Rep 5 + MeasZ IN(5) + PrepZ OUT(5) + OUTPUT Rep 5 +} +""" + ) + ) + ref = next(gt for gt in reference.gadget_types if gt.base.name == "C") + + source = ( + _COND_COMPOSE_DEQ + + """ +COMPOSE C { + INPUT Rep 5 + MeasZ IN(5) + PrepZ OUT(5) + CONDITIONAL rec[-1] X0*X0 5 + OUTPUT Rep 5 +} +""" + ) + library = build_jit_library(parse(source)) + composed = next(gt for gt in library.gadget_types if gt.base.name == "C") + # Self-cancelling Pauli → identical matrices to the reference. + assert composed.base.SerializeToString() == ref.base.SerializeToString(), ( + "self-cancelling CONDITIONAL X0*X0 should produce matrices " + "identical to the no-conditional reference" + ) + + +def test_compose_conditional_multiple_corrections_xor() -> None: + """Two CONDITIONALs on the same wire/readout combine via XOR. + X0 absorbs into row 1; Z0 absorbs into row 0; together both rows + acquire entries.""" + source = ( + _COND_COMPOSE_DEQ + + """ +COMPOSE C { + INPUT Rep 5 + MeasZ IN(5) + PrepZ OUT(5) + CONDITIONAL rec[-1] X0 5 + CONDITIONAL rec[-1] Z0 5 + OUTPUT Rep 5 +} +""" + ) + library = build_jit_library(parse(source)) + composed = next(gt for gt in library.gadget_types if gt.base.name == "C") + lc = composed.base.logical_correction + assert sorted(zip(lc.i, lc.j)) == [] # lc is empty after absorption + + pc = composed.base.physical_correction + pc_rows = {r for r, _ in zip(pc.i, pc.j)} + # X0 → row 1; Z0 → row 0; combined → both rows have entries. + assert pc_rows == {0, 1} + + +def test_compose_conditional_independent_readouts() -> None: + """Two CONDITIONALs conditioned on different readouts each absorb + into the cp/pc rows they target, indexed by their respective + readouts' measurement_indices.""" + source = ( + _COND_COMPOSE_DEQ + + """ +COMPOSE C { + INPUT Rep 5 + MeasZ IN(5) + PrepZ OUT(5) + MeasZ IN(5) + PrepZ OUT(5) + CONDITIONAL rec[-1] X0 5 + CONDITIONAL rec[-2] Z0 5 + OUTPUT Rep 5 +} +""" + ) + library = build_jit_library(parse(source)) + composed = next(gt for gt in library.gadget_types if gt.base.name == "C") + lc = composed.base.logical_correction + assert sorted(zip(lc.i, lc.j)) == [] # lc is empty after absorption + assert len(composed.base.readouts) == 2 + + pc = composed.base.physical_correction + rp = composed.base.readout_propagation + readout0 = composed.base.readouts[0] + readout1 = composed.base.readouts[1] + + # Z0 conditioned on rec[-2] = readout 0 → row 0 (LX_0). + # X0 conditioned on rec[-1] = readout 1 → row 1 (LZ_0). + # The absorbed pc entries for each row come from each readout's own + # measurement_indices, so the row→measurement mapping splits cleanly: + pc_for_row0 = {(r, c) for r, c in zip(pc.i, pc.j) if r == 0} + pc_for_row1 = {(r, c) for r, c in zip(pc.i, pc.j) if r == 1} + assert pc_for_row0 == {(0, m) for m in readout0.measurement_indices} + assert pc_for_row1 == {(1, m) for m in readout1.measurement_indices} + + +def test_compose_conditional_rec_out_of_range_raises() -> None: + """rec[-k] referencing a future or non-existent readout raises.""" + source = ( + _COND_COMPOSE_DEQ + + """ +COMPOSE C { + INPUT Rep 5 + CONDITIONAL rec[-1] X0 5 + MeasZ IN(5) +} +""" + ) + with pytest.raises(ValueError, match="readout"): + build_jit_library(parse(source)) + + +def test_compose_conditional_unknown_wire_raises() -> None: + """CONDITIONAL on a wire with no producer raises.""" + source = ( + _COND_COMPOSE_DEQ + + """ +COMPOSE C { + INPUT Rep 5 + MeasZ IN(5) + CONDITIONAL rec[-1] X0 99 +} +""" + ) + with pytest.raises(ValueError, match="wire"): + build_jit_library(parse(source)) From ba496861fdc4ee0e9334eba77d25608df44e59cd Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Fri, 19 Jun 2026 09:04:53 -0700 Subject: [PATCH 006/157] fill test gap --- deq/tests/cli/jit_test.py | 58 ++++ .../transpiler/test_compose_repropagate.py | 261 ++++++++++++++++++ 2 files changed, 319 insertions(+) diff --git a/deq/tests/cli/jit_test.py b/deq/tests/cli/jit_test.py index 941c1199..59a8fba7 100644 --- a/deq/tests/cli/jit_test.py +++ b/deq/tests/cli/jit_test.py @@ -1812,3 +1812,61 @@ def test_simulate_ler_20_shots_zero_logical_errors( f"{program_name}: expected 0 logical errors over 20 noiseless " f"shots, got {m_errs.group(1)}" ) + + +_FLIP_READOUT_FIXTURE_SOURCE = """\ +CODE TrivialCode [[1,1]] { + LOGICAL X0 Z0 +} + +# Prepare |+> and measure in the X basis. Raw MX outcome is +# deterministically 0; ``FLIP`` marks the readout as naturally +# flipped, so its canonical value is 1. The ASSERT_EQ in +# ``TestFlippedReadout`` then verifies that the canonical readout +# evaluation applies the affine bit correctly. +GADGET PreparePlusMeasureXFlipped { + RX 0 + MX 0 + READOUT rec[-1] FLIP +} + +PROGRAM TestFlippedReadout { + PreparePlusMeasureXFlipped + ASSERT_EQ rec[-1] 1 +} +""" + + +class TestReadoutAffineFlip: + """Regression tests for the readout affine-flip + (``READOUT ... FLIP``) handling in + :func:`_evaluate_assertions_on_sample`. + + The canonical readout's last ``readout_propagation`` column is the + affine bit: when set, the readout's value is deterministically + flipped before any decoder correction. The sample-check helper + mirrors ``deq.cli.interpret.interpret_measurements`` and must XOR + that bit into the computed readout value; otherwise ``ASSERT_EQ + rec[-k] 1`` against a FLIP'd readout would always look like a + bit-flip error to the sample checker. + """ + + def test_flipped_readout_assertion_passes_on_all_shots( + self, tmp_path: Path + ) -> None: + """``TestFlippedReadout`` asserts ``rec[-1] == 1`` against a + readout whose raw bit is deterministically 0 and whose canonical + value is flipped to 1 by the ``READOUT ... FLIP`` marker. Every + sampled shot must pass the ASSERT_EQ check.""" + deq_path = tmp_path / "flip_readout_fixture.deq" + deq_path.write_text(_FLIP_READOUT_FIXTURE_SOURCE, encoding="utf-8") + + total, failed = _evaluate_assertions_on_sample( + deq_path, "TestFlippedReadout", shots=20, seed=42 + ) + assert total == 20, f"expected 20 assertion evaluations, got {total}" + assert failed == 0, ( + f"FLIP readout assertion failed on {failed}/{total} shots — " + f"the affine bit handling in the sample-check helper is " + f"missing or wrong" + ) diff --git a/deq/tests/transpiler/test_compose_repropagate.py b/deq/tests/transpiler/test_compose_repropagate.py index f971c517..357e071d 100644 --- a/deq/tests/transpiler/test_compose_repropagate.py +++ b/deq/tests/transpiler/test_compose_repropagate.py @@ -13,6 +13,7 @@ from deq.cli.strip_tags import strip_jit_library from deq.circuit.parser import parse from deq.transpiler.compose_builder import ( + _translate_compose_conditionals, compose_to_synthetic_gadget, has_repropagate, ) @@ -388,3 +389,263 @@ def test_annotate_then_retranspile_byte_equivalent(self) -> None: orig_stripped.SerializeToString() == anno_stripped.SerializeToString() ) + + +class TestTranslateComposeConditionals: + """Unit tests for ``_translate_compose_conditionals`` — the helper + that turns COMPOSE-body ``ConditionalCorrection`` statements into + GADGET-body ``ConditionalStatement(R)`` entries on the synthetic + flat body. + """ + + def test_repeat_block_unrolls_conditional_per_iteration(self) -> None: + """A ``REPEAT N`` block containing a sub-gadget plus a + ``CONDITIONAL rec[-1] X0 0`` must emit ``N`` separate + ``ConditionalStatement`` entries, each referencing the *correct* + absolute readout index for its iteration (R0, R1, … R(N-1)). + + Regression test for an earlier bug where the walker advanced + ``running_readouts`` past the REPEAT block but only emitted one + copy of the CONDITIONAL (the first iteration's); the remaining + ``count - 1`` iterations were silently dropped. + """ + from deq.circuit.model import ( + ComposeDefinition, + ConditionalCorrection, + GadgetApplication, + GadgetDefinition, + InputPort, + Instruction, + OutputPort, + QubitTarget, + ReadoutStatement, + ReadoutTarget, + RepeatBlock, + MeasurementRecordTarget, + ) + + sub = GadgetDefinition( + name="OneReadoutSub", + body=[ + InputPort( + code_name="RepetitionCode", + qubit_indices=[0, 1, 2], + ), + Instruction( + name="M", + targets=[QubitTarget(0), QubitTarget(1), QubitTarget(2)], + ), + ReadoutStatement( + targets=[MeasurementRecordTarget(offset=3)] + ), + OutputPort( + code_name="RepetitionCode", + qubit_indices=[0, 1, 2], + ), + ], + ) + compose = ComposeDefinition( + name="RepeatedRoundCond", + body=[ + InputPort(code_name="RepetitionCode", qubit_indices=[0]), + RepeatBlock( + count=3, + body=[ + GadgetApplication( + gadget_name="OneReadoutSub", + in_indices=[0], + out_indices=[0], + ), + ConditionalCorrection( + readout_offset=1, + paulis=[("X", 0)], + wire=0, + ), + ], + ), + OutputPort(code_name="RepetitionCode", qubit_indices=[0]), + ], + ) + + stmts = _translate_compose_conditionals( + compose, + gadget_defs={"OneReadoutSub": sub}, + compose_defs={}, + known_names={"OneReadoutSub"}, + ) + + assert len(stmts) == 3, ( + f"REPEAT 3 with a CONDITIONAL inside should emit 3 " + f"ConditionalStatement entries (one per unrolled iteration); " + f"got {len(stmts)}" + ) + # Each iteration's CONDITIONAL references rec[-1] = the readout + # from THAT iteration's sub-gadget, which is R0 / R1 / R2 after + # 1 / 2 / 3 sub-gadgets have produced their readouts. + for iter_idx, stmt in enumerate(stmts): + assert stmt.condition == ReadoutTarget(index=iter_idx), ( + f"iteration {iter_idx}: expected R{iter_idx}, got " + f"{stmt.condition}" + ) + assert len(stmt.targets) == 1 + target = stmt.targets[0] + assert target.pauli == "X" + assert target.index == 0 + assert target.port_kind == "OUT" + assert target.port_index == 0 + + def test_nested_repeat_block_unrolls_correctly(self) -> None: + """``REPEAT 2 { REPEAT 3 { sub; CONDITIONAL rec[-1] X0 0 } }`` + must emit 6 ``ConditionalStatement`` entries (= 2 * 3) with + readout indices R0..R5. + + Verifies that the outer REPEAT also unrolls the inner REPEAT, + and that ``running_readouts`` correctly tracks the cumulative + readout count across nested iterations. + """ + from deq.circuit.model import ( + ComposeDefinition, + ConditionalCorrection, + GadgetApplication, + GadgetDefinition, + InputPort, + Instruction, + OutputPort, + QubitTarget, + ReadoutStatement, + ReadoutTarget, + RepeatBlock, + MeasurementRecordTarget, + ) + + sub = GadgetDefinition( + name="OneReadoutSub", + body=[ + InputPort( + code_name="RepetitionCode", + qubit_indices=[0, 1, 2], + ), + Instruction( + name="M", + targets=[QubitTarget(0), QubitTarget(1), QubitTarget(2)], + ), + ReadoutStatement( + targets=[MeasurementRecordTarget(offset=3)] + ), + OutputPort( + code_name="RepetitionCode", + qubit_indices=[0, 1, 2], + ), + ], + ) + compose = ComposeDefinition( + name="NestedRepeatCond", + body=[ + InputPort(code_name="RepetitionCode", qubit_indices=[0]), + RepeatBlock( + count=2, + body=[ + RepeatBlock( + count=3, + body=[ + GadgetApplication( + gadget_name="OneReadoutSub", + in_indices=[0], + out_indices=[0], + ), + ConditionalCorrection( + readout_offset=1, + paulis=[("X", 0)], + wire=0, + ), + ], + ), + ], + ), + OutputPort(code_name="RepetitionCode", qubit_indices=[0]), + ], + ) + + stmts = _translate_compose_conditionals( + compose, + gadget_defs={"OneReadoutSub": sub}, + compose_defs={}, + known_names={"OneReadoutSub"}, + ) + + assert len(stmts) == 6 + for iter_idx, stmt in enumerate(stmts): + assert stmt.condition == ReadoutTarget(index=iter_idx) + + def test_conditional_after_repeat_uses_post_repeat_indices(self) -> None: + """A ``CONDITIONAL`` that follows a ``REPEAT`` block must see + the post-unroll running readout count, so its ``rec[-k]`` + resolves to a readout produced *during* the REPEAT. + """ + from deq.circuit.model import ( + ComposeDefinition, + ConditionalCorrection, + GadgetApplication, + GadgetDefinition, + InputPort, + Instruction, + OutputPort, + QubitTarget, + ReadoutStatement, + ReadoutTarget, + RepeatBlock, + MeasurementRecordTarget, + ) + + sub = GadgetDefinition( + name="OneReadoutSub", + body=[ + InputPort( + code_name="RepetitionCode", + qubit_indices=[0, 1, 2], + ), + Instruction( + name="M", + targets=[QubitTarget(0), QubitTarget(1), QubitTarget(2)], + ), + ReadoutStatement( + targets=[MeasurementRecordTarget(offset=3)] + ), + OutputPort( + code_name="RepetitionCode", + qubit_indices=[0, 1, 2], + ), + ], + ) + compose = ComposeDefinition( + name="PostRepeatCond", + body=[ + InputPort(code_name="RepetitionCode", qubit_indices=[0]), + RepeatBlock( + count=4, + body=[ + GadgetApplication( + gadget_name="OneReadoutSub", + in_indices=[0], + out_indices=[0], + ), + ], + ), + # rec[-1] is the LAST readout from the 4 unrolled iterations + # = R3. + ConditionalCorrection( + readout_offset=1, paulis=[("X", 0)], wire=0 + ), + OutputPort(code_name="RepetitionCode", qubit_indices=[0]), + ], + ) + + stmts = _translate_compose_conditionals( + compose, + gadget_defs={"OneReadoutSub": sub}, + compose_defs={}, + known_names={"OneReadoutSub"}, + ) + + assert len(stmts) == 1 + assert stmts[0].condition == ReadoutTarget(index=3) From f5264172fb08f3c5a5de17360397185d5336cbea Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Fri, 26 Jun 2026 15:23:51 -0700 Subject: [PATCH 007/157] update teleportation part --- deq/deq/cli/annotate.py | 14 +++- deq/deq/spec/canonical.py | 83 +++++++++++++++++++ .../circuit/surface_code/teleportation_d3.deq | 40 ++++++--- 3 files changed, 125 insertions(+), 12 deletions(-) diff --git a/deq/deq/cli/annotate.py b/deq/deq/cli/annotate.py index 58c67d15..6812fe71 100644 --- a/deq/deq/cli/annotate.py +++ b/deq/deq/cli/annotate.py @@ -6,6 +6,7 @@ from deq.circuit.parser import render_and_parse_file, parse as parse_deq from deq.cli.strip_tags import strip_jit_library +from deq.spec.canonical import absorb_logical_correction_library from deq.transpiler.jit_annotate import annotate as _annotate_impl from deq.transpiler.jit_library_builder import build_jit_library from deq.circuit.mako_support import parse_mako_vars @@ -92,7 +93,14 @@ def annotate( if no_verify: return - # Verify: transpile the annotated output and compare. + # Verify: transpile the annotated output and compare. We absorb + # ``logical_correction`` into ``correction_propagation`` / + # ``physical_correction`` before comparing so a GADGET that + # authors ``CONDITIONAL R`` statements (non-empty + # logical_correction) is treated as equivalent to one whose + # canonical merge has already absorbed those into the propagation + # matrices (empty logical_correction). See + # :func:`deq.spec.canonical.absorb_logical_correction` for details. print( f"Verifying annotated output is equivalent to original", f"(pass --no-verify to skip)...", @@ -100,6 +108,8 @@ def annotate( ) orig_lib = build_jit_library(qfile) anno_lib = build_jit_library(parse_deq(rendered)) + absorb_logical_correction_library(orig_lib) + absorb_logical_correction_library(anno_lib) orig_stripped, _ = strip_jit_library(orig_lib) anno_stripped, _ = strip_jit_library(anno_lib) if orig_stripped.SerializeToString() == anno_stripped.SerializeToString(): @@ -107,7 +117,7 @@ def annotate( else: print( "ERROR: annotated output is not byte-equivalent to original" - " after tag stripping.", + " after tag stripping and canonical absorption.", file=sys.stderr, ) raise SystemExit(1) diff --git a/deq/deq/spec/canonical.py b/deq/deq/spec/canonical.py index 21d58bb5..8873b912 100644 --- a/deq/deq/spec/canonical.py +++ b/deq/deq/spec/canonical.py @@ -76,6 +76,8 @@ CheckIndex, ErrorIndex, OutputPortIndex, + bitmatrix_of, + bitmatrix_to_proto, ) @@ -223,6 +225,87 @@ def from_gadget_type( ) +# =================================================================== +# absorb_logical_correction() — single-gadget canonical absorption +# =================================================================== + + +def absorb_logical_correction(gt: jit_pb.JitGadgetType) -> None: + """In-place: absorb ``base.logical_correction`` into + ``correction_propagation`` / ``physical_correction`` / per-error + ``residual`` on a single :class:`JitGadgetType`, then clear it. + + This is the single-gadget version of the absorption pass that + :func:`merge` runs on the composed result (see "step 9" inside + ``merge``). A GADGET that authors ``CONDITIONAL R L

`` + statements has a non-empty ``logical_correction``; the canonical + composed form has it empty. Two gadgets are equivalent up to + runtime semantics iff their absorbed forms are byte-equal — this + helper performs that absorption so equivalence checks (e.g. + ``deq annotate``'s verification) can reduce to a byte-compare. + + The absorption mirrors the runtime formula + ``residual ^= lc · readouts`` decomposed by data flow: + + * ``cp[r, *] ^= rp[j, *]`` for every ``(r, j)`` in ``lc`` + (absorbs the input-observable and affine columns); + * ``pc[r, m] ^= 1`` for every ``m`` in + ``readouts[j].measurement_indices`` for every ``(r, j)`` in ``lc``; + * for every error with non-empty ``readout_flips``, + ``residual ^= {rows flipped by lc · readout_flips}``. + + No-op when ``logical_correction`` is already empty. + """ + base = gt.base + lc = base.logical_correction + if not lc.i: + return + + rp = base.readout_propagation + readouts = list(base.readouts) + + cp = bitmatrix_of(base.correction_propagation) + pc = bitmatrix_of(base.physical_correction) + + rp_cols_by_readout: dict[int, set[int]] = {} + for r, c in zip(rp.i, rp.j): + rp_cols_by_readout.setdefault(r, set()).add(c) + + rows_by_readout: dict[int, set[int]] = {} + + for out_row, readout_idx in zip(lc.i, lc.j): + rows_by_readout.setdefault(readout_idx, set()).add(out_row) + for in_col in rp_cols_by_readout.get(readout_idx, ()): + cp[out_row, in_col] ^= True + for meas in readouts[readout_idx].measurement_indices: + pc[out_row, meas] ^= True + + for err in gt.errors: + if not err.base.readout_flips: + continue + residual: set[int] = set(err.base.residual) + for readout_idx in err.base.readout_flips: + residual.symmetric_difference_update( + rows_by_readout.get(readout_idx, ()) + ) + del err.base.residual[:] + err.base.residual.extend(sorted(residual)) + + base.correction_propagation.CopyFrom(bitmatrix_to_proto(cp)) + base.physical_correction.CopyFrom(bitmatrix_to_proto(pc)) + base.logical_correction.CopyFrom(util_pb.BitMatrix(rows=lc.rows, cols=lc.cols)) + + +def absorb_logical_correction_library(lib: jit_pb.JitLibrary) -> None: + """Apply :func:`absorb_logical_correction` to every gadget type in *lib*. + + Convenience wrapper for round-trip equivalence checks that operate + on whole :class:`JitLibrary` protos. + """ + for gt in lib.gadget_types: + absorb_logical_correction(gt) + + # =================================================================== # merge() — merge a subset of gadgets into a single MergedGadget # =================================================================== diff --git a/deq/tests/circuit/surface_code/teleportation_d3.deq b/deq/tests/circuit/surface_code/teleportation_d3.deq index 6ff6d9bf..b134fdee 100644 --- a/deq/tests/circuit/surface_code/teleportation_d3.deq +++ b/deq/tests/circuit/surface_code/teleportation_d3.deq @@ -2,13 +2,6 @@ # Surface-code logical teleportation through a Bell pair (d=3). # ============================================================================= # -# NOTE: this is *teleportation* (logical teleportation through transversal -# operations on a Bell pair of patches), NOT lattice surgery. Lattice -# surgery merges two patches into a single larger code patch and reads out -# joint Pauli operators from boundary stabilizers; here we never merge -# patches. See ``lattice_surgery_d3.deq`` for the proper lattice-surgery -# construction. -# # This fixture exercises the COMPOSE-with-CONDITIONAL pipeline using gadget # composition only: # @@ -19,7 +12,7 @@ # (the other half is the OUTPUT patch) # # Two equivalent ways to express the conditional Pauli frame update are -# provided so we exercise both new features introduced in this branch: +# provided: # # 1. ``@REPROPAGATE`` re-derives the propagation matrix from the # inlined flat circuit, automatically absorbing the conditional @@ -28,8 +21,7 @@ # synthesized identity gadget that hosts a # ``remote_conditional_correction`` modifier; the canonicalizer # absorbs the resulting ``logical_correction`` into -# ``correction_propagation`` and ``physical_correction`` during -# ``merge()``. +# ``correction_propagation`` and ``physical_correction``. # # After absorption the two variants are canonically equivalent. # ============================================================================= @@ -123,6 +115,34 @@ COMPOSE TeleportConditional { OUTPUT SurfaceCode 2 } +COMPOSE DoubleTeleportRepropagate { + INPUT SurfaceCode 0 + TeleportRepropagate 0 + TeleportRepropagate 0 + OUTPUT SurfaceCode 0 +} + +COMPOSE DoubleTeleportConditional { + INPUT SurfaceCode 0 + TeleportConditional 0 + TeleportConditional 0 + OUTPUT SurfaceCode 0 +} + +COMPOSE TripleTeleportRepropagate { + INPUT SurfaceCode 0 + DoubleTeleportRepropagate 0 + TeleportRepropagate 0 + OUTPUT SurfaceCode 0 +} + +COMPOSE TripleTeleportConditional { + INPUT SurfaceCode 0 + DoubleTeleportConditional 0 + TeleportConditional 0 + OUTPUT SurfaceCode 0 +} + # ----------------------------------------------------------------------------- # 6. PROGRAM-level deterministic checks. # From 457eb9a4dfdcef31a3be7c07929391bbd6007002 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Wed, 1 Jul 2026 19:14:07 -0700 Subject: [PATCH 008/157] update test deq files --- deq/documents/tutorial/README.md | 1 + .../circuit/fixtures/trivial_surgery.deq | 281 +++++++++ .../surface_code/lattice_surgery_d3.deq | 546 +++++++++++------- 3 files changed, 619 insertions(+), 209 deletions(-) create mode 100644 deq/tests/circuit/fixtures/trivial_surgery.deq diff --git a/deq/documents/tutorial/README.md b/deq/documents/tutorial/README.md index 405aa947..a9599403 100644 --- a/deq/documents/tutorial/README.md +++ b/deq/documents/tutorial/README.md @@ -116,6 +116,7 @@ Once you become comfortable with the basics, let's look at some advanced topics: - [Logical operation with multiple inputs and outputs](chapters/multi-port-gadgets.md) - [Floquet codes and dynamically generated logical qubits](chapters/floquet-code.md) - [Logical Teleportation in COMPOSE: the `@REPROPAGATE` Decorator](chapters/compose-repropagate.md) + - [Conditional Pauli Corrections: the `CONDITIONAL` Statement](chapters/conditional-correction.md) - [Parametrization with Mako](chapters/mako-parametrization.md) - [Plug in your own decoder in Python](chapters/python-decoder.md) - [Driving the runtime from Python](chapters/python-runtime.md) diff --git a/deq/tests/circuit/fixtures/trivial_surgery.deq b/deq/tests/circuit/fixtures/trivial_surgery.deq new file mode 100644 index 00000000..2d5d3122 --- /dev/null +++ b/deq/tests/circuit/fixtures/trivial_surgery.deq @@ -0,0 +1,281 @@ + +# ============================================================================= +# Trivial-code analogue of the surface-code lattice-surgery test suite in +# ``tests/circuit/surface_code/lattice_surgery_d3.deq``. +# ============================================================================= + +CODE One [[1,1,1]] { + LOGICAL X0 Z0 +} + +GADGET PrepareZ { + RZ 0 + OUTPUT One 0 +} + +GADGET PrepareX { + RX 0 + OUTPUT One 0 +} + +GADGET MeasureZ { + INPUT One 0 + MZ 0 + READOUT rec[-1] +} + +GADGET MeasureX { + INPUT One 0 + MX 0 + READOUT rec[-1] +} + +GADGET LogicalX { + INPUT One 0 + OUTPUT One 0 + VIRTUAL LX0 +} + +GADGET LogicalZ { + INPUT One 0 + OUTPUT One 0 + VIRTUAL LZ0 +} + +GADGET TransversalCNOT { + INPUT One 0 + INPUT One 1 + CX 0 1 + OUTPUT One 0 + OUTPUT One 1 +} + +GADGET TwoMZZ { + INPUT One 0 + INPUT One 2 + + RX 1 + + MPP Z0*Z1 Z1*Z2 + READOUT M0 M1 + + MX 1 + + OUTPUT One 0 + OUTPUT One 2 + + CONDITIONAL R0 OUT1.LX0 + + @OVERRIDE + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M2 + @OVERRIDE + PROPAGATE OUT1.LZ0 FROM +} + +CODE Two [[3,1,1]] { + LOGICAL X0*X1*X2 Z0 + STABILIZER Z0*Z1 Z1*Z2 +} + +GADGET TwoMerge { + INPUT One 0 + INPUT One 2 + + RX 1 + + MPP Z0*Z1 Z1*Z2 + READOUT M0 M1 + + OUTPUT Two 0 1 2 +} + +GADGET TwoSplit { + INPUT Two 0 1 2 + + MX 1 + + OUTPUT One 0 + OUTPUT One 2 + + @OVERRIDE + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 M0 + @OVERRIDE + PROPAGATE OUT1.LZ0 FROM +} + +COMPOSE TwoMZZCompose { + INPUT One 0 + INPUT One 1 + + TwoMerge 0 1 + TwoSplit 0 1 + CONDITIONAL rec[-1] X0 1 + + OUTPUT One 0 + OUTPUT One 1 +} + +# ── End-to-end programs exercising both joint-Z merge variants ────── +# +# Every PROGRAM below is emitted twice by the Mako loop: once against +# the raw ``TwoMZZ`` gadget (used directly, no COMPOSE wrapper) and +# once against ``TwoMZZCompose`` (``TwoMerge`` + ``TwoSplit`` + +# post-split ``CONDITIONAL``). +<% +suffixes = ["", "Compose"] +%> +% for suffix in suffixes: + +# ── ``TwoMZZ${suffix}`` variant ────────────────────────────────── + +# Z-basis memory program: prepare both patches in |0_L⟩, run the +# merge, then measure both in the Z basis. |0_L⟩|0_L⟩ is a +1 +# eigenstate of ``LZ_A · LZ_B``, so the joint readout is +# deterministically 0 and each individual ``MeasureZ`` reads 0. +PROGRAM TwoMZZMemoryZ${suffix} { + PrepareZ 0 + PrepareZ 1 + TwoMZZ${suffix} 0 1 + MeasureZ 0 + MeasureZ 1 + ASSERT_EQ rec[-3] 0 # joint LZ_A·LZ_B parity = +1 + ASSERT_EQ rec[-2] 0 # MeasureZ patch A + ASSERT_EQ rec[-1] 0 # MeasureZ patch B +} + +# Bell-pair joint-Z measurement. |Φ⁺⟩ = (|00⟩ + |11⟩)/√2 is a +1 +# eigenstate of ``LZ_A · LZ_B``, so the joint readout is 0 +# deterministically; individual ``MeasureZ`` bits are perfectly +# correlated (only 000 and 011 sample outcomes appear). +PROGRAM BellPairJointZZ${suffix} { + PrepareX 0 + PrepareZ 1 + TransversalCNOT 0 1 + TwoMZZ${suffix} 0 1 + MeasureZ 0 + MeasureZ 1 + ASSERT_EQ rec[-3] 0 # joint LZ_A·LZ_B parity = +1 → readout 0 +} + +# Same Bell pair, virtually rotated to |Ψ⁺⟩ before the merge. The +# corrected-frame state is |Ψ⁺⟩ = X⊗I · |Φ⁺⟩, a −1 eigenstate of +# ``LZ_A · LZ_B`` — joint readout is 1 deterministically. +PROGRAM BellPairWithLogicalXJointZZ${suffix} { + PrepareX 0 + PrepareZ 1 + TransversalCNOT 0 1 + LogicalX 0 + TwoMZZ${suffix} 0 1 + MeasureZ 0 + MeasureZ 1 + ASSERT_EQ rec[-3] 1 # joint LZ_A·LZ_B parity = −1 → readout 1 +} + +# Product-state discriminators for the ``CONDITIONAL R0 OUT1.LX0`` +# byproduct. All four programs start in a computational-basis +# product state (|0_L 0_L⟩ with zero / one / two virtual logical Xs +# applied), so every ``MeasureZ`` outcome is deterministic and +# pinned by ``ASSERT_EQ``. Together they fix ``OUT1`` (patch B) +# rather than ``OUT0`` (patch A) as the side carrying the byproduct. +# +# state (corrected frame) | joint | A | B | program +# ------------------------+-------+---+---+---------------------------- +# |0_L 0_L⟩ | 0 | 0 | 0 | ProductZZ_00 +# |1_L 0_L⟩ (X on A) | 1 | 1 | 0 | ProductZZ_VirtualXA +# |0_L 1_L⟩ (X on B) | 1 | 0 | 1 | ProductZZ_VirtualXB +# |1_L 1_L⟩ (X on both) | 0 | 1 | 1 | ProductZZ_VirtualXBoth +PROGRAM ProductZZ_00${suffix} { + PrepareZ 0 + PrepareZ 1 + TwoMZZ${suffix} 0 1 + MeasureZ 0 + MeasureZ 1 + ASSERT_EQ rec[-3] 0 # joint LZ = +1 + ASSERT_EQ rec[-2] 0 # MeasureZ A + ASSERT_EQ rec[-1] 0 # MeasureZ B +} + +PROGRAM ProductZZ_VirtualXA${suffix} { + PrepareZ 0 + PrepareZ 1 + LogicalX 0 + TwoMZZ${suffix} 0 1 + MeasureZ 0 + MeasureZ 1 + ASSERT_EQ rec[-3] 1 # joint LZ = −1 + ASSERT_EQ rec[-2] 1 # MeasureZ A = |1_L⟩ + ASSERT_EQ rec[-1] 0 # MeasureZ B = |0_L⟩ +} + +PROGRAM ProductZZ_VirtualXB${suffix} { + PrepareZ 0 + PrepareZ 1 + LogicalX 1 + TwoMZZ${suffix} 0 1 + MeasureZ 0 + MeasureZ 1 + ASSERT_EQ rec[-3] 1 # joint LZ = −1 + ASSERT_EQ rec[-2] 0 # MeasureZ A = |0_L⟩ + ASSERT_EQ rec[-1] 1 # MeasureZ B = |1_L⟩ +} + +PROGRAM ProductZZ_VirtualXBoth${suffix} { + PrepareZ 0 + PrepareZ 1 + LogicalX 0 + LogicalX 1 + TwoMZZ${suffix} 0 1 + MeasureZ 0 + MeasureZ 1 + ASSERT_EQ rec[-3] 0 # joint LZ = +1 (two flips cancel) + ASSERT_EQ rec[-2] 1 # MeasureZ A = |1_L⟩ + ASSERT_EQ rec[-1] 1 # MeasureZ B = |1_L⟩ +} + +# Logical-X survival across the joint Z measurement: run the +# post-merge state through a second ``TransversalCNOT`` and read +# patch A in the X basis. Without ``LogicalZ`` the discriminator +# ``MeasureX A`` reads 0 (|+_A⟩); with ``LogicalZ`` it reads 1 +# (|−_A⟩), confirming that patch A's X-frame survives the joint Z +# measurement whether the Z flip is declared before or after the +# merge. +PROGRAM BellPairNoLogicalZSurvivesMerge${suffix} { + PrepareX 0 + PrepareZ 1 + TransversalCNOT 0 1 + TwoMZZ${suffix} 0 1 + TransversalCNOT 0 1 + MeasureX 0 + MeasureX 1 + ASSERT_EQ rec[-3] 0 # joint LZ_A·LZ_B parity = +1 + ASSERT_EQ rec[-2] 0 # MeasureX A = 0 (state is |+_A⟩ post-CNOT) +} + +PROGRAM BellPairLogicalZBeforeMergeSurvives${suffix} { + PrepareX 0 + PrepareZ 1 + TransversalCNOT 0 1 + LogicalZ 0 # |Φ⁺⟩ → |Φ⁻⟩ (Z on patch A) + TwoMZZ${suffix} 0 1 # joint Z still +1 on |Φ⁻⟩, R0 = 0 + TransversalCNOT 0 1 # |Φ⁻⟩ → |−_A⟩|0_B⟩ + MeasureX 0 + MeasureX 1 + ASSERT_EQ rec[-3] 0 # joint readout + ASSERT_EQ rec[-2] 1 # MeasureX A = 1 — Z survived the merge +} + +PROGRAM BellPairLogicalZAfterMergeSurvives${suffix} { + PrepareX 0 + PrepareZ 1 + TransversalCNOT 0 1 + TwoMZZ${suffix} 0 1 # R0 = 0 + LogicalZ 0 # post-merge logical Z on patch A + TransversalCNOT 0 1 # |Φ⁻⟩ → |−_A⟩|0_B⟩ + MeasureX 0 + MeasureX 1 + ASSERT_EQ rec[-3] 0 # joint readout + ASSERT_EQ rec[-2] 1 # MeasureX A = 1 — Z propagated through +} +% endfor + + diff --git a/deq/tests/circuit/surface_code/lattice_surgery_d3.deq b/deq/tests/circuit/surface_code/lattice_surgery_d3.deq index e591de81..055cb6aa 100644 --- a/deq/tests/circuit/surface_code/lattice_surgery_d3.deq +++ b/deq/tests/circuit/surface_code/lattice_surgery_d3.deq @@ -2,283 +2,411 @@ # Lattice surgery on the rotated d=3 surface code. # ============================================================================= # -# Implements true lattice surgery (Horsman, Fowler, Devitt, Van Meter, -# NJP 2012; see also Chatterjee et al. "Lattice Surgery for Dummies", -# arXiv:2404.13202): two surface-code patches are spatially merged into -# one bigger code patch by turning on bulk stabilizers across an -# intermediate strip of data qubits, and split back via a basis-aligned -# destructive measurement of the intermediate strip. The state stays -# put on each patch — there is NO transversal CNOT and no Bell pair — -# while a joint Pauli operator is non-destructively extracted through -# the merged code's boundary syndromes. +# Implements a non-destructive joint logical Z measurement of two surface-code +# patches. Two patches A and B sit horizontally side-by-side +# with an intermediate column of three data qubits initialized in +# |+⟩; six new merge stabilizers (four bulk plaquettes spanning the +# seam plus two Z-type boundary 2-bodies completing the merged +# checkerboard) are measured; the intermediate column is then +# destructively measured in the X basis to split the patches back +# apart. The merge measurement record contains the joint +# ``LZ_A · LZ_B`` parity directly — see the body comment on +# ``MZZ`` below for the algebraic derivation. # # This is structurally different from the Bell-pair logical # teleportation in ``teleportation_d3.deq``, which uses transversal -# CNOTs to move the logical state from one patch onto another. +# CNOTs to MOVE the logical state from one patch onto another. Here +# the state stays put on each patch while a joint Pauli operator is +# non-destructively extracted through the merged code's boundary +# syndromes. # -# ── Geometry (MZZ merge) ──────────────────────────────────────────── +# ── Geometry ──────────────────────────────────────────────────────── # # Two patches A and B placed horizontally side-by-side with an # intermediate column of three data qubits (q18, q19, q20) between them: # -# cols 0 1 2 3 4 5 6 -# row 0 q0 q1 q2 q18 q9 q10 q11 -# row 1 q3 q4 q5 q19 q12 q13 q14 -# row 2 q6 q7 q8 q20 q15 q16 q17 +# Before merge: +# cols 0 1 2 3 4 5 6 +# Z Z +# row 0 q0 q1 q2 q9 q10 q11 +# X Z X X Z X +# row 1 q3 q4 q5 q12 q13 q14 +# X Z X X Z X +# row 2 q6 q7 q8 q15 q16 q17 +# Z Z +# +# After merge: +# cols 0 1 2 3 4 5 6 +# Z [Z] Z +# row 0 q0 q1 q2 q18 q9 q10 q11 +# X Z X [Z] [X] Z X +# row 1 q3 q4 q5 q19 q12 q13 q14 +# X Z [X] [Z] X Z X +# row 2 q6 q7 q8 q20 q15 q16 q17 +# Z [Z] Z # # The plaquette parity (X/Z) follows the existing rotated-surface-code # checkerboard (Z when row+col is even, X when odd). Inside the seam, -# the four NEW bulk plaquettes are: -# -# rows 0-1, cols 2-3 → Z plaq Z2 Z5 Z18 Z19 [NEW] -# rows 1-2, cols 2-3 → X plaq X5 X8 X19 X20 [NEW] -# rows 0-1, cols 3-4 → X plaq X9 X12 X18 X19 [NEW] -# rows 1-2, cols 3-4 → Z plaq Z12 Z15 Z19 Z20 [NEW] +# the NEW bulk plaquettes are marked [X] or [Z]. # -# ── MZZ merge mechanics ───────────────────────────────────────────── +# ── MZZ mechanics ───────────────────────────────────────────── # # 1. Initialize the 3 intermediate data qubits q18 q19 q20 in |+⟩ # (RX 18 19 20). This pins X18 = X19 = X20 = +1. -# 2. Measure the 4 new bulk plaquettes (one round; this fixture -# exists to exercise the COMPOSE pipeline on a spatially-merged -# surgery, not to claim distance-3 fault tolerance). -# -# Each new Z plaquette (the two with Z18 Z19 / Z19 Z20) anti- -# commutes with the |+⟩ stabilizers of the intermediate column: -# measuring it destabilizes one of {X18, X19, X20} and replaces -# it with the new merge stabilizer. Each new X plaquette is the -# product of A's (or B's) right (or left) boundary X 2-body and -# the |+⟩ X stabilizers, so its outcome deterministically equals -# the input boundary stabilizer. -# +# 2. Measure the six new merge stabilizers (four bulk plaquettes and +# two boundary 2-bodies, marked in [X] or [Z]). # 3. Split: measure the intermediate column in the X basis -# (MX 18 19 20). This re-installs the |+⟩-style X stabilizers, -# destabilizes the new merge Z plaquettes and recovers A's and -# B's original boundary stabilizers up to a Pauli frame correction -# derived from the merge measurements. -# -# 4. Frame correction: the X-basis split measurements introduce a -# Pauli frame correction on LZ_A given by m_X19 ⊕ m_X20. We -# apply it via Stim's classically-conditioned Z (``CZ rec[-k] q``, -# = Pauli Z applied to qubit q if measurement record k is 1) on -# the qubits forming a representative of LZ_A. After the -# correction, the gadget acts as logical identity on both patches -# with NO measurement-dependent frame leakage. -# -# Note on CONDITIONAL vs. inline feedforward: -# ``CONDITIONAL rec[-k] `` in a COMPOSE block expresses a -# logical-level Pauli correction by injecting a synthesized identity -# gadget; the merge() canonicalizer then absorbs the contribution into -# ``correction_propagation`` / ``physical_correction``. This works -# cleanly when the CONDITIONAL ADDS contributions that the natural -# flat-circuit Heisenberg derivation also produces (e.g. the -# Bell-pair teleportation correction on the OUTPUT of a transversal -# CNOT — see ``teleportation_d3.deq``). However, when the -# CONDITIONAL is meant to CANCEL a frame correction inherent to the -# circuit (as is the case for lattice-surgery split measurements), -# the merge-with-CONDITIONAL absorbed propagation differs from the -# flat-circuit Heisenberg result by exactly the cancelled deps, and -# the noise-builder validator rejects the rendered GADGET unless -# ``@REPROPAGATE`` is added. The ``CZ rec[-k] q`` feedforward used -# below is the operationally-honest equivalent: it expresses the -# correction as a real circuit operation, so the natural Heisenberg -# derivation handles it correctly without any extra COMPOSE-level -# annotation. +# (MX 18 19 20). This re-installs the |+⟩-style X stabilizers +# and recovers A's and B's output stabilizer values through the +# cross-gadget checks the transpiler emits. # -# Note on the rotated-surface-code "hourglass" boundary: A's bottom- -# edge Z 2-body Z6*Z7 (cols 0-1) and B's top-edge Z 2-body Z10*Z11 -# (cols 1-2) sit on different columns; the merge does NOT add a new -# top/bottom 2-body Z stabilizer across the seam in this fixture. A -# textbook fault-tolerant lattice surgery either mirrors one of the -# patches or uses a 2-column-wide intermediate strip; this minimal -# version focuses on the bulk-plaquette-only construction so the -# COMPOSE pipeline has a concrete spatially-merged fixture to exercise. +# The joint measurement IS the operation: its readout is the joint +# ``LZ_A · LZ_B`` parity, individual ``LZ_A`` and ``LZ_B`` outputs +# become correlated through that readout, and only the joint +# ``LX_A · LX_B`` parity survives on output (each individual LX +# anti-commutes with the joint Z measurement and is randomized). No +# downstream Pauli frame correction can be automatically inferred, so +# user must make a choice here: to propagate the input LX_A · LX_B frame +# to one of the LX_A or LX_B outputs so that their product is preserved. +# Note that the choice doesn't make a difference here, because after +# measuring joint LZ_A · LZ_B, applying LZ_A · LZ_B does not change the +# state, and this operator will transfer frame of LX_A to LX_B and vice versa. # ============================================================================= IMPORT "surface_code_d3.deq" -# ----------------------------------------------------------------------------- -# 1. MergeMZZ — single-shot lattice-surgery joint Z⊗Z merge-and-split -# of two surface-code patches. The frame correction is left visible -# as a logical readout (``READOUT M5 M6``); downstream code is -# responsible for tracking it. See ``MergeMZZCorrected`` below for -# the variant where the correction is applied in-circuit. -# ----------------------------------------------------------------------------- -GADGET MergeMZZ { +GADGET MZZ { INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 # patch A INPUT SurfaceCode 9 10 11 12 13 14 15 16 17 # patch B - # Initialize intermediate column data qubits in |+⟩. + # Initialize the intermediate column data qubits in |+⟩. This + # pins X18 = X19 = X20 = +1. RX 18 19 20 - # MERGE: measure the four new bulk plaquettes spanning the seam. - MPP Z2*Z5*Z18*Z19 - MPP X5*X8*X19*X20 - MPP X9*X12*X18*X19 - MPP Z12*Z15*Z19*Z20 + # Merge — measure the four new bulk plaquettes plus the two new + # boundary 2-bodies spanning the seam. + MPP Z2*Z5*Z18*Z19 # M0 + MPP X5*X8*X19*X20 # M1 + MPP X9*X12*X18*X19 # M2 + MPP Z12*Z15*Z19*Z20 # M3 + MPP Z9*Z18 # M4 + MPP Z8*Z20 # M5 - # SPLIT: destructively measure the intermediate column in X basis. - MX 18 19 20 + # Split — destructively measure the intermediate column in the X + # basis. This re-installs the |+⟩-style X stabilizers and recovers + # A's and B's original boundary stabilizers. + MX 18 19 20 # M6 M7 M8 - # Logical readout = m_X19 ⊕ m_X20: the Pauli frame correction bit - # on LZ_A that the lattice surgery introduces. - READOUT M5 M6 + # Joint LZ_A · LZ_B parity. Deterministic when the input is in a + # ± 1 eigenstate of LZ_A · LZ_B (e.g. both patches prepared in + # |0_L⟩ for the memory program below). + READOUT M0 M3 M4 M5 OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17 -} -# ----------------------------------------------------------------------------- -# 2. MergeMZZCorrected — same lattice surgery, with the frame -# correction applied in-circuit via Stim's ``CZ rec[-k] q`` -# classically-conditioned Z. -# -# ``LZ_A`` representative: ``Z0 * Z3 * Z6`` (left column of patch A). -# Applying Z to qubits {0, 3, 6} anti-commutes with LX_A (one -# overlap at q0) but commutes with all four of patch A's X -# stabilizers, so the correction is a clean logical-level Z -# application that leaves A's stabilizer structure untouched. -# -# We feed-forward TWICE — once on rec[-2] (= m_X19) and once on -# rec[-1] (= m_X20) — because the frame-correction bit is the -# XOR of the two split measurements and Stim's CZ feedforward -# conditions on a single measurement record. -# ----------------------------------------------------------------------------- -GADGET MergeMZZCorrected { - INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 # patch A - INPUT SurfaceCode 9 10 11 12 13 14 15 16 17 # patch B + # Measurement-induced byproduct on patch B's logical-X frame. + # When the joint readout ``R0`` is ``1`` the merge has projected + # onto the ZZ = −1 branch; applying the patch-B logical-X + # correction aligns the framework's post-merge representatives + # with the corrected-frame measurement outcomes. + # + # Why patch B and not patch A? The ``ProductZZ_VirtualXA`` and + # ``ProductZZ_VirtualXB`` programs further down in this file + # discriminate the two choices: both prepare an R0=1 input state + # (|1_L 0_L⟩ and |0_L 1_L⟩ respectively), but with opposite + # individual ``MeasureZ`` patterns (A=1, B=0 vs A=0, B=1). Only + # a correction on the patch-B frame produces both deterministic + # outcome pairs from the same R0=1 reading; switching to + # ``CONDITIONAL R0 OUT0.LX0`` flips both predictions in lockstep + # and fails the ``ASSERT_EQ`` checks in those programs. + CONDITIONAL R0 OUT1.LX0 - RX 18 19 20 + # Joint logical-X survival byproduct (manual override). + # + # The merge body's per-port Heisenberg flow has no solution for + # ``OUT0.LX0 = X0*X1*X2`` alone — that Pauli anti-commutes with + # the merge plaquette ``M0 = Z2*Z5*Z18*Z19``. + # The honest physical observable that survives is the JOINT + # logical X ``LX_A · LX_B = X0*X1*X2 · X9*X10*X11``, which DOES + # have a flow with the first MX seam outcome (``M6 = MX 18``) + # absorbing the measurement-induced sign correction. + # + # In the framework's symplectic algebra the joint observable + # ``LX_A · LX_B`` decomposes as the XOR of the two ports' + # X-direction trackers (``OUT0.LZ0 ⊕ OUT1.LZ0`` in the rendered + # label convention). What needs to hold is that the XOR of the + # two ports' rows reproduces ``IN0.LZ0 ⊕ IN1.LZ0 ⊕ M6``; one + # clean way to satisfy that is to put the full expression on a + # single port and leave the other empty. Below we put it on + # ``OUT0.LZ0`` and leave ``OUT1.LZ0`` empty. The choice doesn't + # matter here because they differ by a LZ_A · LX_A operator, + # but we are already in the +1 or -1 eigenstate of that operator. + # + # The ``@OVERRIDE`` decorator tells the validator to install + # these values verbatim, bypassing the basis-freedom check — + # the per-port flow solver legitimately cannot derive them. + @OVERRIDE + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M6 + @OVERRIDE + PROPAGATE OUT1.LZ0 FROM +} - MPP Z2*Z5*Z18*Z19 - MPP X5*X8*X19*X20 - MPP X9*X12*X18*X19 - MPP Z12*Z15*Z19*Z20 +# COMPOSE wrapper exercising the COMPOSE pipeline on the joint merge. +COMPOSE ComposeMZZ { + INPUT SurfaceCode 0 + INPUT SurfaceCode 1 + MZZ 0 1 + OUTPUT SurfaceCode 0 + OUTPUT SurfaceCode 1 +} - MX 18 19 20 +# End-to-end Z-basis memory program for the joint merge. +# +# Both patches are prepared in ``|0_L⟩``, so the joint +# ``LZ_A · LZ_B`` parity is deterministically ``+1`` and the joint +# readout reads ``0``. Individual ``MeasureZ`` outcomes are also +# ``0`` deterministically because the joint measurement leaves +# ``|0_L⟩_A ⊗ |0_L⟩_B`` invariant (it is already a joint-parity +# eigenstate). +# +# X-basis memory tests are intentionally NOT included: the joint Z +# measurement anti-commutes with each of ``LX_A`` / ``LX_B``, so the +# post-merge individual ``LX_A`` / ``LX_B`` outcomes are random; only +# the joint ``LX_A · LX_B`` parity survives and a bare +# ``ASSERT_EQ rec[-k] 0`` cannot express that joint parity. See +# ``teleportation_d3.deq`` for measurement-based logical operations +# that preserve both bases individually. +PROGRAM ComposeMZZMemoryZ { + PrepareZ 0 + PrepareZ 1 + ComposeMZZ 0 1 + MeasureZ 0 + MeasureZ 1 + ASSERT_EQ rec[-3] 0 # joint LZ_A·LZ_B parity = +1 + ASSERT_EQ rec[-2] 0 # MeasureZ patch A + ASSERT_EQ rec[-1] 0 # MeasureZ patch B +} - # In-circuit Pauli frame correction on patch A's LZ representative - # (qubits 0, 3, 6), conditioned on the X-basis split measurements - # of the intermediate column's middle and bottom data qubits. - CZ rec[-2] 0 rec[-2] 3 rec[-2] 6 - CZ rec[-1] 0 rec[-1] 3 rec[-1] 6 +# ----------------------------------------------------------------------------- +# ``LogicalX`` — virtual logical X on a surface-code patch. +# +# Applies a pure Pauli-frame flip: the deq-level ``VIRTUAL LX0`` +# directive flips the affine column of the output's +# ``correction_propagation`` matrix for every output observable that +# anti-commutes with the patch's logical X (i.e. the patch's logical +# Z). Physically the gadget is a no-op (no instructions in the +# body); operationally it inverts the patch's ``LZ`` eigenvalue +# inside the decoder's frame so downstream readouts see the X-rotated +# state. +# ----------------------------------------------------------------------------- +GADGET LogicalX { + INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 + OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 + VIRTUAL LX0 +} +# ----------------------------------------------------------------------------- +# ``LogicalZ`` — virtual logical Z on a surface-code patch. +# +# Symmetric counterpart of ``LogicalX`` above: the deq-level +# ``VIRTUAL LZ0`` directive flips the affine column of the output's +# ``correction_propagation`` matrix for the anti-commuting output +# observables (here patch's logical X). Physically the gadget is a +# no-op; operationally it inverts the patch's ``LX`` eigenvalue in +# the decoder's frame, so downstream X-basis readouts see the +# Z-rotated state. +# ----------------------------------------------------------------------------- +GADGET LogicalZ { + INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 - OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17 + VIRTUAL LZ0 } # ----------------------------------------------------------------------------- -# 3. Three equivalent COMPOSE wrappers exercising the COMPOSE pipeline. +# ``BellPairJointZZ`` — joint Z measurement on a logical Bell pair. # -# ``LSMergePassthrough`` uses the default (sub-gadget composition) -# COMPOSE pathway — merge() takes ``MergeMZZ``'s propagation -# matrices as-is. The Pauli frame correction is exposed as a -# logical readout on the COMPOSE. +# Build the logical Bell pair |Φ⁺⟩ = (|0_L 0_L⟩ + |1_L 1_L⟩)/√2 via # -# ``LSMergeCorrected`` wraps the in-circuit-corrected variant. +# PrepareX 0 # patch A in |+_L⟩ +# PrepareZ 1 # patch B in |0_L⟩ +# TransversalCNOT 0 1 # control A, target B # -# ``LSMergeConditional`` applies the same Pauli frame correction -# via a COMPOSE-level ``CONDITIONAL rec[-1] Z0 0`` instead of an -# in-circuit ``CZ rec[...]``. This exercises the validator's -# basis-freedom extension: the natural Heisenberg of MergeMZZ's -# flat body does NOT reproduce the ``M5 ⊕ M6 → LZ_A`` dependency -# in the same canonical form that the merge() absorption pass -# produces, so the rendered GADGET's PROPAGATE statements differ -# from the flat-circuit Heisenberg by exactly the CONDITIONAL's -# absorption pattern; the validator accepts the difference because -# the synthetic body carries a corresponding ``CONDITIONAL R`` -# statement. No ``@REPROPAGATE`` decorator is required. +# then measure the joint ``LZ_A · LZ_B`` parity with ``ComposeMZZ``. +# |Φ⁺⟩ is a +1 eigenstate of ``LZ_A · LZ_B``, so the joint readout +# (``rec[-3]`` after the two single-patch ``MeasureZ`` gadgets) is +# deterministically ``0``. # -# All three COMPOSE pathways produce gadgets with empty -# ``logical_correction`` after the merge() absorption pass. +# The subsequent ``MeasureZ 0`` / ``MeasureZ 1`` outcomes are each +# individually random and perfectly correlated: ``rec[-2] == rec[-1]`` +# (both 0 or both 1, 50 / 50 across shots). Neither individual bit +# is deterministic so ``ASSERT_EQ`` cannot pin them down, but the +# correlation pattern is verifiable with ``deq sample`` — over many +# shots only outcomes ``000`` and ``011`` appear. # ----------------------------------------------------------------------------- -COMPOSE LSMergePassthrough { - INPUT SurfaceCode 0 - INPUT SurfaceCode 1 - MergeMZZ 0 1 - OUTPUT SurfaceCode 0 - OUTPUT SurfaceCode 1 -} - -COMPOSE LSMergeCorrected { - INPUT SurfaceCode 0 - INPUT SurfaceCode 1 - MergeMZZCorrected 0 1 - OUTPUT SurfaceCode 0 - OUTPUT SurfaceCode 1 +PROGRAM BellPairJointZZ { + PrepareX 0 + PrepareZ 1 + TransversalCNOT 0 1 + ComposeMZZ 0 1 + MeasureZ 0 + MeasureZ 1 + ASSERT_EQ rec[-3] 0 # joint LZ_A·LZ_B parity = +1 → readout 0 } -COMPOSE LSMergeConditional { - INPUT SurfaceCode 0 - INPUT SurfaceCode 1 - MergeMZZ 0 1 - CONDITIONAL rec[-1] Z0 0 - OUTPUT SurfaceCode 0 - OUTPUT SurfaceCode 1 +# ----------------------------------------------------------------------------- +# ``BellPairWithLogicalXJointZZ`` — same Bell pair, virtually rotated +# to |Ψ⁺⟩ before the joint Z measurement. +# +# Applying ``LogicalX 0`` (= ``VIRTUAL LX0``) declares a Pauli-frame +# flip on patch A's logical X. The corrected-frame state is +# |Ψ⁺⟩ = (|1_L 0_L⟩ + |0_L 1_L⟩)/√2 = X⊗I · |Φ⁺⟩, which is the −1 +# eigenstate of ``LZ_A · LZ_B``, so the joint readout flips to ``1`` +# deterministically. Thanks to the merge's +# ``CONDITIONAL R0 OUT1.LX0`` byproduct, the post-split ``MeasureZ 0`` +# and ``MeasureZ 1`` outcomes are now anti-correlated +# (``rec[-2] != rec[-1]``): over many shots only outcomes ``101`` and +# ``110`` appear, with each individual bit a 50 / 50 random Bell-pair +# branch label. +# ----------------------------------------------------------------------------- +PROGRAM BellPairWithLogicalXJointZZ { + PrepareX 0 + PrepareZ 1 + TransversalCNOT 0 1 + LogicalX 0 + ComposeMZZ 0 1 + MeasureZ 0 + MeasureZ 1 + ASSERT_EQ rec[-3] 1 # joint LZ_A·LZ_B parity = −1 → readout 1 } # ----------------------------------------------------------------------------- -# 4. End-to-end memory programs that exercise the corrected variants -# of the lattice-surgery merge as logical identity on the Z basis -# of both patches. -# -# After applying the frame correction (in-circuit via -# ``MergeMZZCorrected`` for ``LSMergeCorrected``, COMPOSE-level -# ``CONDITIONAL`` for ``LSMergeConditional``, or PROGRAM-level -# ``CONDITIONAL`` for ``LSMergeProgramConditional``), the merge -# gadget acts as logical identity on the Z observables of both -# patches. Preparing both patches in ``|0_L⟩`` and measuring in -# the Z basis after the merge must give ``0`` deterministically on -# each patch. +# Product-state discriminators for the merge byproduct. # -# X-basis memory tests are intentionally NOT included: the MZZ -# merge measurement randomises ``LX_A`` (because the new bulk -# Z-plaquettes anti-commute with patch A's logical X -# representative); only the product ``LX_A · LX_B`` is preserved -# and bare ``ASSERT_EQ rec[-k] 0`` cannot express that joint -# parity. See ``teleportation_d3.deq`` for X-basis memory -# programs, where Bell-pair teleportation preserves both bases -# individually. +# These four programs start in computational-basis product states +# (|0_L 0_L⟩, with zero / one / two virtual logical Xs applied), so +# every ``MeasureZ`` outcome is deterministic and can be pinned by +# ``ASSERT_EQ``. Together they fix the correct CONDITIONAL byproduct +# inside ``MZZ`` — switching to ``CONDITIONAL R0 OUT0.LX0`` +# (correction on patch A instead of B) flips the predicted A / B +# outcomes for the single-virtual-X programs and fails their +# ``ASSERT_EQ`` checks, confirming that the byproduct must live on +# patch B's logical X frame. # -# ``LSMergePassthrough`` is intentionally NOT covered here — it -# exposes the ``M5⊕M6`` frame correction as a logical readout -# that downstream code is expected to track and apply explicitly, -# so a bare ``ASSERT_EQ`` is not the right shape of test for it. +# state (corrected frame) | joint | A | B | program +# ------------------------+-------+---+---+---------------------------- +# |0_L 0_L⟩ | 0 | 0 | 0 | ProductZZ_00 +# |1_L 0_L⟩ (X on A) | 1 | 1 | 0 | ProductZZ_VirtualXA +# |0_L 1_L⟩ (X on B) | 1 | 0 | 1 | ProductZZ_VirtualXB +# |1_L 1_L⟩ (X on both) | 0 | 1 | 1 | ProductZZ_VirtualXBoth # ----------------------------------------------------------------------------- -PROGRAM LSMergeCorrectedMemoryZ { +PROGRAM ProductZZ_00 { PrepareZ 0 PrepareZ 1 - LSMergeCorrected 0 1 + ComposeMZZ 0 1 MeasureZ 0 MeasureZ 1 - ASSERT_EQ rec[-2] 0 - ASSERT_EQ rec[-1] 0 + ASSERT_EQ rec[-3] 0 # joint LZ = +1 + ASSERT_EQ rec[-2] 0 # MeasureZ A + ASSERT_EQ rec[-1] 0 # MeasureZ B } -PROGRAM LSMergeConditionalMemoryZ { +PROGRAM ProductZZ_VirtualXA { PrepareZ 0 PrepareZ 1 - LSMergeConditional 0 1 + LogicalX 0 + ComposeMZZ 0 1 MeasureZ 0 MeasureZ 1 - ASSERT_EQ rec[-2] 0 - ASSERT_EQ rec[-1] 0 + ASSERT_EQ rec[-3] 1 # joint LZ = −1 + ASSERT_EQ rec[-2] 1 # MeasureZ A = |1_L⟩ + ASSERT_EQ rec[-1] 0 # MeasureZ B = |0_L⟩ } -# Same lattice-surgery memory test, but the CONDITIONAL Pauli frame -# correction lives directly in the PROGRAM body rather than inside a -# wrapping COMPOSE. This exercises the -# :func:`emit_conditional_correction_instruction` PROGRAM-level -# pathway (see ``deq/cli/jit.py``), which is structurally identical -# to the COMPOSE-level pathway but reaches it via the program -# compiler instead of the compose canonicaliser. -PROGRAM LSMergeProgramConditionalMemoryZ { +PROGRAM ProductZZ_VirtualXB { PrepareZ 0 PrepareZ 1 - MergeMZZ 0 1 - CONDITIONAL rec[-1] Z0 0 + LogicalX 1 + ComposeMZZ 0 1 MeasureZ 0 MeasureZ 1 - ASSERT_EQ rec[-2] 0 - ASSERT_EQ rec[-1] 0 + ASSERT_EQ rec[-3] 1 # joint LZ = −1 + ASSERT_EQ rec[-2] 0 # MeasureZ A = |0_L⟩ + ASSERT_EQ rec[-1] 1 # MeasureZ B = |1_L⟩ +} + +PROGRAM ProductZZ_VirtualXBoth { + PrepareZ 0 + PrepareZ 1 + LogicalX 0 + LogicalX 1 + ComposeMZZ 0 1 + MeasureZ 0 + MeasureZ 1 + ASSERT_EQ rec[-3] 0 # joint LZ = +1 (two flips cancel) + ASSERT_EQ rec[-2] 1 # MeasureZ A = |1_L⟩ + ASSERT_EQ rec[-1] 1 # MeasureZ B = |1_L⟩ +} + +# ----------------------------------------------------------------------------- +# Logical-X survival across the joint Z measurement. +# +# The joint MZZ measurement only extracts the joint ``LZ_A · LZ_B`` +# parity; everything orthogonal to that parity (in particular the +# joint ``LX_A · LX_B`` X-direction) must remain coherent on the +# post-merge state — exactly as a physical ``MPP Z0*Z1`` on a Bell +# pair preserves the joint XX while pinning down ZZ. +# +# These three programs test that survival by running the post-merge +# state through a second transversal CNOT and reading patch A in the +# X basis: +# +# 1. Build the logical Bell pair |Φ⁺⟩ = (|0_L 0_L⟩ + |1_L 1_L⟩)/√2. +# 2. Apply the joint Z measurement (``ComposeMZZ``). |Φ⁺⟩ is a +# +1 eigenstate, so ``R0 = 0`` deterministically and the state +# is unchanged. +# 3. (Optional) ``LogicalZ 0``: maps |Φ⁺⟩ → |Φ⁻⟩ = (|00⟩ − |11⟩)/√2. +# 4. ``TransversalCNOT 0 1`` again: +# CNOT|Φ⁺⟩ = (|00⟩ + |10⟩)/√2 = |+_A⟩|0_B⟩. +# CNOT|Φ⁻⟩ = (|00⟩ − |10⟩)/√2 = |−_A⟩|0_B⟩. +# 5. ``MeasureX 0`` is the discriminator: +# without LogicalZ → |+_A⟩ → 0 deterministically. +# with LogicalZ → |−_A⟩ → 1 deterministically. +# ----------------------------------------------------------------------------- +PROGRAM BellPairNoLogicalZSurvivesMerge { + PrepareX 0 + PrepareZ 1 + TransversalCNOT 0 1 + ComposeMZZ 0 1 + TransversalCNOT 0 1 + MeasureX 0 + MeasureX 1 + ASSERT_EQ rec[-3] 0 # joint LZ_A·LZ_B parity = +1 + ASSERT_EQ rec[-2] 0 # MeasureX A = 0 (state is |+_A⟩ post-CNOT) +} + +PROGRAM BellPairLogicalZBeforeMergeSurvives { + PrepareX 0 + PrepareZ 1 + TransversalCNOT 0 1 + LogicalZ 0 # |Φ⁺⟩ → |Φ⁻⟩ (Z on patch A) + ComposeMZZ 0 1 # joint Z still +1 on |Φ⁻⟩, R0 = 0 + TransversalCNOT 0 1 # |Φ⁻⟩ → |−_A⟩|0_B⟩ + MeasureX 0 + MeasureX 1 + ASSERT_EQ rec[-3] 0 # joint readout + ASSERT_EQ rec[-2] 1 # MeasureX A = 1 — Z survived the merge +} + +PROGRAM BellPairLogicalZAfterMergeSurvives { + PrepareX 0 + PrepareZ 1 + TransversalCNOT 0 1 + ComposeMZZ 0 1 # R0 = 0 + LogicalZ 0 # post-merge logical Z on patch A + TransversalCNOT 0 1 # |Φ⁻⟩ → |−_A⟩|0_B⟩ + MeasureX 0 + MeasureX 1 + ASSERT_EQ rec[-3] 0 # joint readout + ASSERT_EQ rec[-2] 1 # MeasureX A = 1 — Z propagated through } From ee1268fe37ac8efabd25554e2d43edbaaf4fac46 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Wed, 1 Jul 2026 19:48:42 -0700 Subject: [PATCH 009/157] add jit test --- deq/tests/cli/jit_test.py | 586 +++++++++++++++++++++----------------- 1 file changed, 322 insertions(+), 264 deletions(-) diff --git a/deq/tests/cli/jit_test.py b/deq/tests/cli/jit_test.py index 59a8fba7..4f83baab 100644 --- a/deq/tests/cli/jit_test.py +++ b/deq/tests/cli/jit_test.py @@ -1197,25 +1197,27 @@ def test_repropagate_and_conditional_emit_same_propagation( teleportation_d3_setup: tuple[jit_pb.JitLibrary, dict[str, object]], ) -> None: """``TeleportRepropagate`` and ``TeleportConditional`` are - operationally equivalent but have *structurally* different - canonical forms: - - * ``TeleportRepropagate`` rebuilds the GADGET from the flat - inlined circuit, so the ``MeasureBell`` sub-gadget's logical - readouts are absorbed away (0 readouts on the composed - GADGET). - * ``TeleportConditional`` keeps ``MeasureBell``'s 2 readouts - visible because the ``CONDITIONAL`` statements explicitly - reference them via ``rec[-1]`` / ``rec[-2]``; the resulting - conditional-correction contribution lives in - ``physical_correction`` (via measurement_indices) rather than - a separate ``logical_correction`` matrix. - - What MUST agree across both variants: - - * input / output port counts (same COMPOSE signature); - * empty ``logical_correction`` (the canonical absorption pass - clears it on both paths). + canonically equivalent: both compose pathways must expose the + same compose-level signature, preserve ``MeasureBell``'s two + logical readouts, and produce an empty ``logical_correction`` + (the canonical absorption pass clears it on both paths). + + The two pathways differ only in how the conditional Pauli + frame correction is *expressed*: + + * ``TeleportConditional`` writes the correction as explicit + ``CONDITIONAL rec[-k] `` statements at the + COMPOSE level; ``merge()`` then absorbs them into the + composed matrices. + * ``TeleportRepropagate`` inlines the sub-gadget body and + re-derives the propagation from the flat circuit's + Heisenberg flow, naturally folding the conditional Pauli + updates into ``correction_propagation`` / + ``physical_correction``. + + Both encodings must inherit the inlined ``MeasureBell``'s + two readouts (``m_XX`` and ``m_ZZ``) so downstream code can + still address those classical bits. """ jit_library, _ = teleportation_d3_setup repro = next( @@ -1235,10 +1237,13 @@ def test_repropagate_and_conditional_emit_same_propagation( assert len(cond.base.logical_correction.i) == 0 assert len(repro.base.logical_correction.i) == 0 - # The CONDITIONAL form preserves MeasureBell's 2 logical readouts; - # the REPROPAGATE form folds them into the flat-circuit analysis. + # Both pathways must preserve MeasureBell's two logical + # readouts (m_XX, m_ZZ) at identical measurement indices. assert len(cond.base.readouts) == 2 - assert len(repro.base.readouts) == 0 + assert len(repro.base.readouts) == 2 + assert [list(r.measurement_indices) for r in repro.base.readouts] == [ + list(r.measurement_indices) for r in cond.base.readouts + ] # --------------------------------------------------------------------------- @@ -1266,8 +1271,8 @@ def lattice_surgery_d3_setup() -> tuple[jit_pb.JitLibrary, dict[str, object]]: """Parse ``lattice_surgery_d3.deq`` and return library + PROGRAMs. Returns ``(jit_library, program_defs_by_name)``. ``program_defs`` - keys: ``LSMergeCorrectedMemoryZ``, ``LSMergeConditionalMemoryZ``, - ``LSMergeCorrectedMemoryX``, ``LSMergeConditionalMemoryX``. + keys: ``ComposeMZZMemoryZ`` (the single joint-Z lattice-surgery + memory program). """ from deq.circuit.model import ProgramDefinition from deq.circuit.parser import parse_file @@ -1281,263 +1286,156 @@ def lattice_surgery_d3_setup() -> tuple[jit_pb.JitLibrary, dict[str, object]]: class TestLatticeSurgeryD3: - """Verify the structural properties of the d=3 lattice-surgery MZZ - gadgets. + """Verify the structural properties of the d=3 lattice-surgery + honest joint-Z measurement gadget. Unlike the Bell-pair teleportation in ``teleportation_d3.deq``, this fixture spatially merges two surface-code patches via an - intermediate column of |+⟩ data qubits, measures the four new bulk - plaquettes spanning the seam, and splits the intermediate column - back out via X-basis measurement. - - Two flavors of the same surgery are exercised: - - * ``MergeMZZ`` — leaves the lattice-surgery Pauli frame correction - visible as a logical readout (``m_X19 ⊕ m_X20``); - * ``MergeMZZCorrected`` — applies the frame correction in-circuit - via Stim's ``CZ rec[-k] q`` classically-conditioned Z, so the - gadget acts as logical identity on both patches with NO - measurement-dependent frame leakage. - - The COMPOSE wrappers (``LSMergePassthrough``, ``LSMergeCorrected``) - wrap each variant through the default merge() pathway and must - produce gadgets with empty ``logical_correction`` after the - absorption pass. + intermediate column of |+⟩ data qubits. ``MZZ`` measures + four bulk plaquettes and two Z-type boundary 2-bodies spanning the + seam, then destructively measures the intermediate column in the X + basis. The product of the four Z-type measurement outcomes equals + the joint ``LZ_A · LZ_B`` parity, exposed via ``READOUT M0 M3 M4 M5``. + The ``ComposeMZZ`` COMPOSE wrapper exercises the COMPOSE pipeline + on this gadget. """ def test_merge_mzz_has_two_input_two_output_ports( self, lattice_surgery_d3_library: jit_pb.JitLibrary, ) -> None: - """``MergeMZZ`` is a 2-input, 2-output gadget — both patches - survive the merge-and-split (it is non-destructive on logical - information except for the joint frame correction).""" + """``MZZ`` is a 2-input, 2-output gadget — both + patches survive the merge-and-split (the joint Z measurement + is non-destructive on logical information; only the joint + ``LZ_A · LZ_B`` parity is extracted into the measurement + record).""" merge = next( - gt for gt in lattice_surgery_d3_library.gadget_types if gt.base.name == "MergeMZZ" + gt + for gt in lattice_surgery_d3_library.gadget_types + if gt.base.name == "MZZ" ) assert len(merge.base.inputs) == 2 assert len(merge.base.outputs) == 2 - # Both ports are the same SurfaceCode port type. assert merge.base.inputs[0].ptype == merge.base.inputs[1].ptype assert merge.base.outputs[0].ptype == merge.base.outputs[1].ptype assert merge.base.inputs[0].ptype == merge.base.outputs[0].ptype - def test_merge_mzz_exposes_frame_correction_readout( + def test_merge_mzz_exposes_parity_readout( self, lattice_surgery_d3_library: jit_pb.JitLibrary, ) -> None: - """``MergeMZZ`` exposes the Pauli frame correction bit as a - single logical readout (= m_X19 ⊕ m_X20 from the X-basis split - measurements of the intermediate column).""" - merge = next( - gt for gt in lattice_surgery_d3_library.gadget_types if gt.base.name == "MergeMZZ" - ) - assert len(merge.base.readouts) == 1 - # The readout reads two measurement records (the M5, M6 of the - # MX 18 19 20 split). - assert len(merge.base.readouts[0].measurement_indices) == 2 - - def test_merge_mzz_corrected_has_no_readouts( - self, - lattice_surgery_d3_library: jit_pb.JitLibrary, - ) -> None: - """``MergeMZZCorrected`` applies the frame correction - in-circuit via ``CZ rec`` feedforward, so it has NO logical - readout — the gadget is logical identity on both patches. - """ + """``MZZ`` exposes the joint ``LZ_A · LZ_B`` parity + as a single logical readout built from the four Z-type merge + measurements (``M0 M3 M4 M5``).""" merge = next( gt for gt in lattice_surgery_d3_library.gadget_types - if gt.base.name == "MergeMZZCorrected" + if gt.base.name == "MZZ" ) - assert len(merge.base.readouts) == 0 - - def test_merge_mzz_corrected_acts_as_identity_on_logicals( - self, - lattice_surgery_d3_library: jit_pb.JitLibrary, - ) -> None: - """After the in-circuit correction, ``MergeMZZCorrected`` has a - diagonal correction_propagation matrix on both patches' - logical observables and no measurement contributions on - ``physical_correction`` for those rows. - """ - merge = next( - gt - for gt in lattice_surgery_d3_library.gadget_types - if gt.base.name == "MergeMZZCorrected" - ) - cp = merge.base.correction_propagation - pc = merge.base.physical_correction - # The 4 logical observable rows (LX_A=0, LZ_A=1, LX_B=10, LZ_B=11) - # should have only the identity entry in cp (diagonal) and no - # entries in pc. - cp_pairs = set(zip(cp.i, cp.j)) - pc_pairs = set(zip(pc.i, pc.j)) - for logical_row in (0, 1, 10, 11): - assert (logical_row, logical_row) in cp_pairs, ( - f"row {logical_row}: missing identity in correction_propagation" - ) - pc_row = {(r, c) for (r, c) in pc_pairs if r == logical_row} - assert pc_row == set(), ( - f"row {logical_row}: unexpected pc entries {pc_row}; " - f"in-circuit correction should fully absorb them" - ) + assert len(merge.base.readouts) == 1 + # Four measurement records — M0, M3, M4, M5. + assert len(merge.base.readouts[0].measurement_indices) == 4 - def test_compose_pathways_produce_empty_logical_correction( + def test_ls_merge_compose_has_two_input_two_output_ports( self, lattice_surgery_d3_library: jit_pb.JitLibrary, ) -> None: - """Both COMPOSE pathways produce gadgets with an empty - ``logical_correction`` matrix: the merge() absorption pass - folds any conditional contribution into ``correction_propagation`` - / ``physical_correction``. - """ - for name in ("LSMergePassthrough", "LSMergeCorrected", "LSMergeConditional"): - gt = next( - g for g in lattice_surgery_d3_library.gadget_types if g.base.name == name - ) - assert len(gt.base.logical_correction.i) == 0, ( - f"{name}: logical_correction should be empty after merge() absorption" - ) + """The ``ComposeMZZ`` COMPOSE wrapper preserves the + 2-in / 2-out signature of the underlying joint-merge gadget.""" + gt = next( + g + for g in lattice_surgery_d3_library.gadget_types + if g.base.name == "ComposeMZZ" + ) + assert len(gt.base.inputs) == 2 + assert len(gt.base.outputs) == 2 - def test_compose_pathways_have_two_input_two_output_ports( + def test_ls_merge_compose_preserves_readout( self, lattice_surgery_d3_library: jit_pb.JitLibrary, ) -> None: - """All three COMPOSE wrappers preserve the 2-in / 2-out - signature of their underlying merge gadget.""" - for name in ("LSMergePassthrough", "LSMergeCorrected", "LSMergeConditional"): - gt = next( - g for g in lattice_surgery_d3_library.gadget_types if g.base.name == name - ) - assert len(gt.base.inputs) == 2, f"{name} should have 2 inputs" - assert len(gt.base.outputs) == 2, f"{name} should have 2 outputs" + """``ComposeMZZ`` inherits ``MZZ``'s joint + ``LZ_A · LZ_B`` readout — it is preserved by the COMPOSE + merge() pass because the joint readout IS the operation's + output, not a frame-correction bit that gets absorbed.""" + gt = next( + g + for g in lattice_surgery_d3_library.gadget_types + if g.base.name == "ComposeMZZ" + ) + assert len(gt.base.readouts) == 1 + assert len(gt.base.readouts[0].measurement_indices) == 4 - def test_ls_merge_conditional_matches_corrected( + def test_merge_mzz_has_byproduct_logical_correction( self, lattice_surgery_d3_library: jit_pb.JitLibrary, ) -> None: - """``LSMergeConditional`` applies the Pauli frame correction - via a COMPOSE-level ``CONDITIONAL rec[-1] Z0 0`` rather than - an in-circuit ``CZ rec[...]``. After the merge() absorption - pass the resulting propagation matrices must match the - in-circuit variant ``LSMergeCorrected``: the logical rows of - both patches end up with no measurement contributions on - ``physical_correction`` (the frame correction is fully - absorbed) and the correction_propagation is the identity on - logical observables and passthrough stabs. - """ - conditional = next( - g - for g in lattice_surgery_d3_library.gadget_types - if g.base.name == "LSMergeConditional" - ) - corrected = next( - g - for g in lattice_surgery_d3_library.gadget_types - if g.base.name == "LSMergeCorrected" - ) - cond_cp = set( - zip( - conditional.base.correction_propagation.i, - conditional.base.correction_propagation.j, - ) - ) - corr_cp = set( - zip( - corrected.base.correction_propagation.i, - corrected.base.correction_propagation.j, - ) - ) - cond_pc = set( - zip( - conditional.base.physical_correction.i, - conditional.base.physical_correction.j, - ) - ) - corr_pc = set( - zip( - corrected.base.physical_correction.i, - corrected.base.physical_correction.j, - ) - ) - assert cond_cp == corr_cp, ( - "LSMergeConditional.correction_propagation should match " - "LSMergeCorrected after CONDITIONAL absorption" - ) - assert cond_pc == corr_pc, ( - "LSMergeConditional.physical_correction should match " - "LSMergeCorrected after CONDITIONAL absorption" + """The base ``MZZ`` GADGET carries exactly one + ``logical_correction`` row — the ``CONDITIONAL R0 OUT1.LX0`` + byproduct that re-aligns the post-merge representatives with + the corrected-frame measurement outcomes when the joint + readout fires (joint ZZ = −1 branch). See the fixture's + header comment and the four ``ProductZZ_*`` calibration + programs that pin down ``OUT1`` (patch B) as the correct + side.""" + merge = next( + gt + for gt in lattice_surgery_d3_library.gadget_types + if gt.base.name == "MZZ" ) + lc = merge.base.logical_correction + assert len(lc.i) == 1 + assert lc.cols == 1 # one readout (the joint parity) + assert list(lc.j) == [0] # driven by R0 (the joint readout) - def test_ls_merge_conditional_has_readout( + def test_ls_merge_absorbs_byproduct( self, lattice_surgery_d3_library: jit_pb.JitLibrary, ) -> None: - """``LSMergeConditional`` preserves the underlying - ``MergeMZZ`` readout (the COMPOSE-level CONDITIONAL is - absorbed into ``correction_propagation`` / - ``physical_correction`` but does not eliminate the readout - itself — the decoder still needs the measurement bit to apply - the correction).""" - conditional = next( + """``ComposeMZZ``'s COMPOSE merge() canonicaliser absorbs + the ``MZZ`` byproduct into ``correction_propagation``, + so the final ``logical_correction`` matrix is empty. The + joint readout itself is preserved (it IS the operation's + output, not a frame bit), but the conditional Pauli on + ``OUT1.LX0`` folds cleanly into the COMPOSE-level propagation + of the patch-B logical observables.""" + gt = next( g for g in lattice_surgery_d3_library.gadget_types - if g.base.name == "LSMergeConditional" + if g.base.name == "ComposeMZZ" ) - assert len(conditional.base.readouts) == 1 - assert len(conditional.base.readouts[0].measurement_indices) == 2 + assert len(gt.base.logical_correction.i) == 0 class TestLatticeSurgeryD3Programs: - """End-to-end compilation of lattice-surgery memory PROGRAMs. - - These programs are the lattice-surgery analogues of the - ``Teleport*Memory*`` programs in ``TestTeleportationD3``: they - verify that both the in-circuit feedforward variant - (``LSMergeCorrected``) and the COMPOSE-level ``CONDITIONAL`` variant - (``LSMergeConditional``) compile to a valid binary that the static - JIT compiler / physical validator accept. - - Each program prepares two surface-code patches in ``|0_L⟩`` (or - ``|+_L⟩``), applies the lattice-surgery merge, then measures each - patch in the matching basis. The merge is logical identity on - both patches once the frame correction is applied, so each - ``MeasureZ`` (or ``MeasureX``) outcome must read ``0`` - deterministically — encoded as two ``ASSERT_EQ rec[-k] 0`` - statements. + """End-to-end compilation of the lattice-surgery joint-Z memory + program (``ComposeMZZMemoryZ``). + + Prepares two surface-code patches in ``|0_L⟩``, runs the joint Z + measurement, then measures each patch in the Z basis. Because + ``|0_L⟩|0_L⟩`` is a +1 eigenstate of ``LZ_A · LZ_B``, the joint + readout is deterministically ``0`` and both ``MeasureZ`` outcomes + are ``0`` — encoded as three ``ASSERT_EQ rec[-k] 0`` statements. """ - @pytest.mark.parametrize( - "program_name", - [ - "LSMergeCorrectedMemoryZ", - "LSMergeConditionalMemoryZ", - "LSMergeProgramConditionalMemoryZ", - ], - ) def test_program_compiles_to_valid_binary( self, lattice_surgery_d3_setup: tuple[jit_pb.JitLibrary, dict[str, object]], - program_name: str, ) -> None: from deq.cli.jit import compile_program_for_jit jit_library, program_defs = lattice_surgery_d3_setup - program_def = program_defs[program_name] + program_def = program_defs["ComposeMZZMemoryZ"] compiled, assertions = compile_program_for_jit(jit_library, program_def) - # Each memory program asserts both ``MeasureZ`` readouts equal 0. - assert len(assertions) == 2 + # The memory program asserts three readouts equal 0 (joint + # parity + two ``MeasureZ``). + assert len(assertions) == 3 for assertion in assertions: - # ``compile_program_for_jit`` returns ``(abs_index, expected, - # source)`` tuples; we only care that both are ``ASSERT_EQ ... 0``. assert assertion[1] is False - # Re-run the static JIT compiler with the program stream to make - # sure the produced deq.bin is physically valid (no dangling - # measurements, no missing CONDITIONAL absorption, etc.). + # Verify the produced deq.bin is physically valid. lib = jit_pb.JitLibrary() lib.CopyFrom(jit_library) lib.ClearField("program") @@ -1546,53 +1444,209 @@ def test_program_compiles_to_valid_binary( deq_bin = static_jit_compiler(lib) assert is_valid_and_physical(deq_bin) - def test_corrected_and_conditional_programs_have_same_assertions( + +# --------------------------------------------------------------------------- +# Trivial [[1,1,1]] code — same MZZ merge behavior on a single physical +# qubit per patch, no COMPOSE wrapper (the joint merge gadget is used +# directly inside PROGRAMs). +# --------------------------------------------------------------------------- + +TRIVIAL_SURGERY_DEQ = ( + Path(__file__).resolve().parents[1] + / "circuit" + / "fixtures" + / "trivial_surgery.deq" +) + + +@pytest.fixture(scope="module") +def trivial_surgery_library() -> jit_pb.JitLibrary: + """Parse ``trivial_surgery.deq`` and build its JIT library.""" + from deq.circuit.parser import render_and_parse_file + + return build_jit_library( + render_and_parse_file( + str(TRIVIAL_SURGERY_DEQ), mako_defs=None, skip_mako_warning=True + ) + ) + + +@pytest.fixture(scope="module") +def trivial_surgery_setup() -> tuple[jit_pb.JitLibrary, dict[str, object]]: + """Parse ``trivial_surgery.deq`` and return ``(library, programs)``.""" + from deq.circuit.model import ProgramDefinition + from deq.circuit.parser import render_and_parse_file + + merged = render_and_parse_file( + str(TRIVIAL_SURGERY_DEQ), mako_defs=None, skip_mako_warning=True + ) + jit_library = build_jit_library(merged) + program_defs = { + d.name: d for d in merged.definitions if isinstance(d, ProgramDefinition) + } + return jit_library, program_defs + + +class TestTrivialTwoMZZ: + """Structural properties of the trivial-code joint-Z merge + gadgets — the [[1,1,1]] analogues of the surface-code ``MZZ`` + and ``ComposeMZZ`` merges in ``lattice_surgery_d3.deq``. + + Two single-qubit patches (qubits 0 and 2) are joined by a + ``|+⟩`` ancilla on qubit 1; ``MPP Z0*Z1`` + ``MPP Z1*Z2`` extract + the joint ``LZ_A · LZ_B`` parity as ``READOUT M0 M1`` and the + ancilla is split back out with ``MX 1``. The fixture exposes + two equivalent presentations: + + * ``TwoMZZ`` — the raw joint-Z merge with an inline + ``CONDITIONAL R0 OUT1.LX0`` byproduct. Deferring the + CONDITIONAL absorption to the runtime decoder leaves one + ``logical_correction`` row on the base gadget. + * ``TwoMZZCompose`` — ``TwoMerge`` + ``TwoSplit`` + a + post-split ``CONDITIONAL rec[-1] X0 1`` wrapped in a + ``COMPOSE`` block. COMPOSE canonicalisation absorbs the + byproduct into ``readout_propagation``, so the composed base + gadget has an empty ``logical_correction`` matrix. + """ + + @pytest.mark.parametrize("merge_name", ["TwoMZZ", "TwoMZZCompose"]) + def test_merge_has_two_input_two_output_ports( self, - lattice_surgery_d3_setup: tuple[jit_pb.JitLibrary, dict[str, object]], + trivial_surgery_library: jit_pb.JitLibrary, + merge_name: str, ) -> None: - """All three Z-basis variants — in-circuit ``CZ rec[...]`` - (``LSMergeCorrectedMemoryZ``), COMPOSE-level ``CONDITIONAL`` - (``LSMergeConditionalMemoryZ``), and PROGRAM-level - ``CONDITIONAL`` (``LSMergeProgramConditionalMemoryZ``) — reach - the same logical state by different routes and therefore - produce the same number of ``ASSERT_EQ rec[-k] 0`` assertions - with the same expected values. - - Neither absolute measurement offsets nor JIT instruction - counts are compared: the in-circuit variant folds the merge - readout away via feedforward, the COMPOSE-level CONDITIONAL - preserves the readout but absorbs into the COMPOSE matrices - (no extra JIT instruction), and the PROGRAM-level CONDITIONAL - emits an extra synthesised identity gadget instruction. The - end-to-end behaviour (deterministic ``MeasureZ = 0``) is the - same for all three, verified by the sample/simulate tests in - ``TestConditionalEndToEnd``. - """ - from deq.cli.jit import compile_program_for_jit + """Both merge presentations are 2-input, 2-output gadgets + over the ``One`` port type — both patches survive the + merge-and-split.""" + merge = next( + gt + for gt in trivial_surgery_library.gadget_types + if gt.base.name == merge_name + ) + assert len(merge.base.inputs) == 2 + assert len(merge.base.outputs) == 2 + assert merge.base.inputs[0].ptype == merge.base.inputs[1].ptype + assert merge.base.outputs[0].ptype == merge.base.outputs[1].ptype + assert merge.base.inputs[0].ptype == merge.base.outputs[0].ptype - jit_library, program_defs = lattice_surgery_d3_setup - program_names = [ - "LSMergeCorrectedMemoryZ", - "LSMergeConditionalMemoryZ", - "LSMergeProgramConditionalMemoryZ", - ] - results = [ - compile_program_for_jit(jit_library, program_defs[name]) - for name in program_names - ] - assertion_counts = [len(asserts) for _, asserts in results] - assertion_values = [ - tuple(a[1] for a in asserts) for _, asserts in results - ] - assert assertion_counts == [2, 2, 2], ( - f"expected 2 assertions per variant; got " - f"{dict(zip(program_names, assertion_counts))}" + @pytest.mark.parametrize("merge_name", ["TwoMZZ", "TwoMZZCompose"]) + def test_merge_exposes_parity_readout( + self, + trivial_surgery_library: jit_pb.JitLibrary, + merge_name: str, + ) -> None: + """Both merge presentations expose the joint + ``LZ_A · LZ_B`` parity as a single logical readout built + from the two ``MPP`` outcomes (``M0 M1``).""" + merge = next( + gt + for gt in trivial_surgery_library.gadget_types + if gt.base.name == merge_name ) - assert len(set(assertion_values)) == 1, ( - f"assertion expected values differ across variants: " - f"{dict(zip(program_names, assertion_values))}" + assert len(merge.base.readouts) == 1 + assert len(merge.base.readouts[0].measurement_indices) == 2 + + def test_two_mzz_has_byproduct_logical_correction( + self, + trivial_surgery_library: jit_pb.JitLibrary, + ) -> None: + """The raw ``TwoMZZ`` gadget carries exactly one + ``logical_correction`` row — the ``CONDITIONAL R0 OUT1.LX0`` + byproduct that re-aligns the post-merge representatives with + the corrected-frame measurement outcomes on the joint + ``ZZ = −1`` branch. See the four ``ProductZZ_*`` calibration + programs in ``trivial_surgery.deq`` that pin the byproduct + onto patch B (``OUT1``) rather than patch A (``OUT0``).""" + merge = next( + gt + for gt in trivial_surgery_library.gadget_types + if gt.base.name == "TwoMZZ" + ) + lc = merge.base.logical_correction + assert len(lc.i) == 1 + assert lc.cols == 1 # one readout (the joint parity) + assert list(lc.j) == [0] # driven by R0 (the joint readout) + + def test_two_mzz_compose_absorbs_byproduct( + self, + trivial_surgery_library: jit_pb.JitLibrary, + ) -> None: + """``TwoMZZCompose``'s COMPOSE merge() canonicaliser absorbs + the ``TwoSplit`` byproduct's post-split + ``CONDITIONAL rec[-1] X0 1`` into ``readout_propagation``, + leaving an empty ``logical_correction`` on the composed + base gadget — the same pattern as the surface-code + ``ComposeMZZ`` in ``lattice_surgery_d3.deq``.""" + merge = next( + gt + for gt in trivial_surgery_library.gadget_types + if gt.base.name == "TwoMZZCompose" + ) + assert len(merge.base.logical_correction.i) == 0 + + + +class TestTrivialTwoMZZPrograms: + """End-to-end compilation of the joint-Z merge PROGRAMs in + ``trivial_surgery.deq`` — the [[1,1,1]] analogues of the + surface-code lattice-surgery calibration and Bell-pair programs. + + Every PROGRAM must compile into a physically valid ``.deq.bin`` + and expose at least one ``ASSERT_EQ`` statement. The Mako + ``%for suffix in suffixes`` loop in ``trivial_surgery.deq`` + emits every base program twice — once against raw ``TwoMZZ`` + and once against ``TwoMZZCompose`` — so the parametrisation + covers both variants. Runtime-decoder correctness on noiseless + samples is covered by the ``TestConditionalEndToEnd`` + parametrisation. + """ + + _BASE_NAMES: tuple[str, ...] = ( + "TwoMZZMemoryZ", + "BellPairJointZZ", + "BellPairWithLogicalXJointZZ", + "ProductZZ_00", + "ProductZZ_VirtualXA", + "ProductZZ_VirtualXB", + "ProductZZ_VirtualXBoth", + "BellPairNoLogicalZSurvivesMerge", + "BellPairLogicalZBeforeMergeSurvives", + "BellPairLogicalZAfterMergeSurvives", + ) + + _PROGRAM_NAMES: tuple[str, ...] = tuple( + f"{name}{suffix}" for name in _BASE_NAMES for suffix in ("", "Compose") + ) + + @pytest.mark.parametrize("program_name", sorted(_PROGRAM_NAMES)) + def test_program_compiles_to_valid_binary( + self, + trivial_surgery_setup: tuple[jit_pb.JitLibrary, dict[str, object]], + program_name: str, + ) -> None: + """Every joint-Z merge PROGRAM (raw ``TwoMZZ`` and + ``TwoMZZCompose`` variant) must compile to a physically + valid ``.deq.bin`` with at least one ``ASSERT_EQ`` + statement preserved through compilation.""" + from deq.cli.jit import compile_program_for_jit + + jit_library, program_defs = trivial_surgery_setup + program_def = program_defs[program_name] + + compiled, assertions = compile_program_for_jit(jit_library, program_def) + assert assertions, ( + f"{program_name}: compilation dropped every ASSERT_EQ statement" ) + # Verify the produced deq.bin is physically valid. + lib = jit_pb.JitLibrary() + lib.CopyFrom(jit_library) + lib.ClearField("program") + for instr, _src in compiled: + lib.program.append(instr) + deq_bin = static_jit_compiler(lib) + assert is_valid_and_physical(deq_bin) + # --------------------------------------------------------------------------- # End-to-end ``deq sample`` + ``deq simulate ler`` smoke tests for both @@ -1612,8 +1666,9 @@ def test_corrected_and_conditional_programs_have_same_assertions( ("TeleportConditionalMemoryX", TELEPORTATION_D3_DEQ), ("TeleportProgramConditionalMemoryZ", TELEPORTATION_D3_DEQ), ("TeleportProgramConditionalMemoryX", TELEPORTATION_D3_DEQ), - ("LSMergeConditionalMemoryZ", LATTICE_SURGERY_D3_DEQ), - ("LSMergeProgramConditionalMemoryZ", LATTICE_SURGERY_D3_DEQ), + ("ComposeMZZMemoryZ", LATTICE_SURGERY_D3_DEQ), + ("TwoMZZMemoryZ", TRIVIAL_SURGERY_DEQ), + ("TwoMZZMemoryZCompose", TRIVIAL_SURGERY_DEQ), ] @@ -1641,7 +1696,7 @@ def _evaluate_assertions_on_sample( ) from deq.cli.util import parse_bits from deq.circuit.model import ProgramDefinition - from deq.circuit.parser import parse_file + from deq.circuit.parser import render_and_parse_file from deq.spec.canonical import canonicalize import deq.proto.deq_bin_pb2 as pb @@ -1679,7 +1734,9 @@ def _evaluate_assertions_on_sample( if c == affine_col: readout_affine[r] = not readout_affine[r] - parsed = parse_file(str(deq_file)) + parsed = render_and_parse_file( + str(deq_file), mako_defs=None, skip_mako_warning=True + ) program_defs = { d.name: d for d in parsed.definitions @@ -1790,6 +1847,7 @@ def test_simulate_ler_20_shots_zero_logical_errors( "42", "--jobs", "1", + "--skip-mako-warning", ], capture_output=True, text=True, From 552f6995dd0b3e3e70389c6572d7b0df776c6801 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 6 Jul 2026 09:48:49 -0700 Subject: [PATCH 010/157] update old tutorial chapters to be consistent --- .../chapters/codes-redundant-stabilizers.md | 2 + .../tutorial/chapters/compose-gadgets.md | 9 + .../tutorial/chapters/compose-repropagate.md | 275 ++++++++++++------ .../tutorial/chapters/debug-deq-program.md | 3 + .../tutorial/chapters/floquet-code.md | 1 + .../tutorial/chapters/multi-port-gadgets.md | 2 + .../tutorial/chapters/steane-style-ec.md | 1 + 7 files changed, 210 insertions(+), 83 deletions(-) diff --git a/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md b/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md index 9abe1031..afe15b94 100644 --- a/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md +++ b/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md @@ -81,6 +81,7 @@ The annotated output for the Idle gadget reveals the problem: OUTPUT RepetitionCode 0 2 4 CHECK OUT0.S0 M0 CHECK OUT0.S1 M1 + # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 @@ -150,6 +151,7 @@ The annotated Idle gadget: CHECK OUT0.S0 M0 CHECK OUT0.S1 M1 CHECK OUT0.S2 M2 + # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 diff --git a/deq/documents/tutorial/chapters/compose-gadgets.md b/deq/documents/tutorial/chapters/compose-gadgets.md index 0c7e6246..5887a74b 100644 --- a/deq/documents/tutorial/chapters/compose-gadgets.md +++ b/deq/documents/tutorial/chapters/compose-gadgets.md @@ -91,6 +91,7 @@ The circuit is physically identical to running the Idle gadget 3 times. Running OUTPUT RepetitionCode 0 1 2 CHECK OUT0.S0 CHECK OUT0.S1 + # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM PROPAGATE OUT0.LX0 FROM @@ -148,6 +149,7 @@ The circuit is physically identical to running the Idle gadget 3 times. Running OUTPUT RepetitionCode 0 2 4 CHECK OUT0.S0 M4 CHECK OUT0.S1 M5 + # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 @@ -172,6 +174,7 @@ The circuit is physically identical to running the Idle gadget 3 times. Running READOUT rec[-3] rec[-2] rec[-1] # flipped by: IN0.LX0 CHECK M1 M0 IN0.S0 CHECK M2 M1 IN0.S1 + # PROPAGATE below reflects the joint effect of all statements above # --- statistics --- # finished checks: 2 @@ -324,6 +327,7 @@ Running `annotate` on the COMPOSE version produces a flattened `GADGET` block: OUTPUT RepetitionCode 0 1 2 CHECK OUT0.S0 CHECK OUT0.S1 + # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM PROPAGATE OUT0.LX0 FROM @@ -355,6 +359,7 @@ Running `annotate` on the COMPOSE version produces a flattened `GADGET` block: OUTPUT RepetitionCode 0 2 4 CHECK OUT0.S0 M0 CHECK OUT0.S1 M1 + # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 @@ -379,6 +384,7 @@ Running `annotate` on the COMPOSE version produces a flattened `GADGET` block: READOUT rec[-3] rec[-2] rec[-1] # flipped by: IN0.LX0 CHECK M1 M0 IN0.S0 CHECK M2 M1 IN0.S1 + # PROPAGATE below reflects the joint effect of all statements above # --- statistics --- # finished checks: 2 @@ -682,6 +688,7 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl OUTPUT RepetitionCode 0 1 2 CHECK OUT0.S0 CHECK OUT0.S1 + # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM PROPAGATE OUT0.LX0 FROM @@ -713,6 +720,7 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl OUTPUT RepetitionCode 0 2 4 CHECK OUT0.S0 M0 CHECK OUT0.S1 M1 + # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 @@ -737,6 +745,7 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl READOUT rec[-3] rec[-2] rec[-1] # flipped by: IN0.LX0 CHECK M1 M0 IN0.S0 CHECK M2 M1 IN0.S1 + # PROPAGATE below reflects the joint effect of all statements above # --- statistics --- # finished checks: 2 diff --git a/deq/documents/tutorial/chapters/compose-repropagate.md b/deq/documents/tutorial/chapters/compose-repropagate.md index 36eb642d..75cd7eab 100644 --- a/deq/documents/tutorial/chapters/compose-repropagate.md +++ b/deq/documents/tutorial/chapters/compose-repropagate.md @@ -12,38 +12,51 @@ By default the COMPOSE pipeline computes those propagation matrices by **matrix composition** of the sub-gadgets' individual propagation matrices. That is the natural choice because it mirrors what happens at runtime: the runtime decoder chains the same matrices step by step as instances of these gadgets stream in. But matrix -composition is a *convenient default*, not a fundamental property of COMPOSE. As soon -as matrix composition produces a row that the static verifier cannot reproduce on the -inlined flat circuit, we need a different way to fill in that row — *without* giving up -the JIT compiler's check structure. - -The textbook example where this happens is **logical teleportation**: the input state -is recovered on a different code block only after a classical-feed-forward Pauli -correction conditioned on a mid-circuit measurement. The correction lives at the -*global* circuit level — no individual sub-gadget can see it, so matrix composition -produces a propagation row that flat-circuit analysis cannot derive on its own. - -The `@REPROPAGATE` decorator is the fix. It swaps just the propagation-derivation -strategy from matrix composition to circuit-flow analysis on the inlined body, while -leaving the JIT compiler's check structure untouched. This chapter shows what goes -wrong without it, why, and exactly which pieces the decorator changes. +composition has one blind spot: it cannot invent classical feed-forward. If the +intended logical channel depends on a mid-circuit measurement outcome being XOR'd +back into the output Pauli frame, no chain of sub-gadget matrices — none of which +individually sees both the measurement *and* the output — can ever record that +dependence. Matrix composition happily returns *a* propagation row for the affected +output logical, but the row is **missing the classical correction**, and the composed +gadget silently implements the wrong channel. + +The textbook example is **logical teleportation**: the input state is recovered on a +different code block only after a classical-feed-forward Pauli correction conditioned +on a mid-circuit measurement. Without an explicit `CONDITIONAL` in the COMPOSE body +or an `@REPROPAGATE` decorator that re-derives propagation from the flat inlined +circuit, matrix composition drops that correction and the resulting binary is *not* +the intended logical identity. + +Crucially, `deq annotate` does not fail on the broken COMPOSE — it accepts the +matrix-composed rows because they lie in the basis-freedom span the verifier accepts. +The bug is only visible if you **read the emitted `PROPAGATE` rows**. An empty +right-hand side on an output-logical row that should preserve its input observable is +the diagnostic. This chapter walks through that pattern: the plain-COMPOSE +teleportation, what its emitted `PROPAGATE` reveals, and how `@REPROPAGATE` (or an +explicit `CONDITIONAL`) restores the missing dependence. --- ## A logical teleportation COMPOSE -A [[4,1,2]] code block can be initialised in $|+\rangle_L$ by `PrepareZero` (initialise -the data qubits in $|0\rangle$, then measure $X_0 X_1 X_2 X_3$). Composing that with a -transversal `CNOT` and an `X`-basis measurement of the first block implements logical -teleportation from port 0 to port 1: +A [[4,1,2]] code block can be initialised in $|0\rangle_L$ by `PrepareZero` +(initialise the data qubits in $|0\rangle$, then measure the code stabilizer +$X_0 X_1 X_2 X_3$). Composing that with a transversal `CNOT` and an `X`-basis +measurement of the first block implements logical teleportation from port 0 to +port 1: [Teleportation COMPOSE — without `@REPROPAGATE`](../examples/compose-repropagate/01_teleport_logical.deq) -

# Logical teleportation realised with a COMPOSE block.
+
# Logical teleportation, attempted with the default COMPOSE build path.
 #
-# This file is the *negative* example: the COMPOSE has no @REPROPAGATE
-# decorator, so `deq annotate` will fail at the verification step.  See
-# 02_teleport_repropagate.deq for the working version.
+# ***This file is a NEGATIVE example.***  It compiles and annotates
+# without error, but the resulting composed gadget is not actually a
+# logical identity from port 0 to port 1: matrix composition drops the
+# classical feed-forward that teleportation requires.  Compare the
+# `PROPAGATE OUT0.LZ0 FROM` (empty) row emitted for `Teleport` in
+# `01_teleport_logical.annotated.deq` with the informative
+# `PROPAGATE OUT0.LZ0 FROM IN0.LZ0 M1 M3` row emitted for the
+# `@REPROPAGATE` variant in `02_teleport_repropagate.annotated.deq`.
 #
 # Code layout:  4 physical qubits per logical qubit.
 #     0   1
@@ -75,15 +88,22 @@ teleportation from port 0 to port 1:
     READOUT M0 M2
 }
 
-# Logical teleportation: |psi> in port 0, |+_L> prepared on port 1,
-# transversal CNOT, measure X on port 0 -> the input logical state ends
-# up on port 1 (possibly up to a conditional logical Z).
+# Logical teleportation: |psi> in port 0, |0_L> prepared on port 1,
+# transversal CNOT, measure X on port 0 -> the input logical state
+# should end up on port 1 (possibly up to a conditional logical Z).
 #
-# Without @REPROPAGATE, the COMPOSE pipeline composes the
-# *propagation matrices* of PrepareZero, CNOT and MeasureX, which
-# cannot represent the conditional logical Pauli correction that
-# teleportation implicitly requires.  `deq annotate` therefore
-# rejects the rendered PROPAGATE statements during verification.
+# Without @REPROPAGATE (or an explicit CONDITIONAL), the COMPOSE
+# pipeline composes the propagation matrices of the sub-gadgets.
+# Matrix composition cannot invent classical feed-forward, so the
+# composed row for `OUT0.LZ0` comes out empty: no input logical
+# operator (and no measurement bit) propagates to the output LZ.
+# Since the LZ operator is what flips the X observable, the input's
+# X observable is discarded rather than teleported.  The `LX`
+# operator still propagates cleanly (input LX -> output LX, both
+# flip the Z observable), so the Z observable does survive — but a
+# gadget that only teleports one basis is not the identity.
+#
+# See 02_teleport_repropagate.deq for the @REPROPAGATE fix.
 COMPOSE Teleport {
     INPUT Code 0
     PrepareZero 1
@@ -133,7 +153,7 @@ inlined flat circuit would derive. The next section shows exactly that mismatch.
 
 ---
 
-## What goes wrong without `@REPROPAGATE`
+## What goes wrong
 
 Run the annotator on this file:
 
@@ -141,37 +161,115 @@ Run the annotator on this file:
 deq annotate 01_teleport_logical.deq
 ```
 
-After writing the annotated output, `deq annotate` re-transpiles it to verify
-round-trip equivalence — and that verification fails:
+There is no error. The command silently writes
+`01_teleport_logical.annotated.deq`, and re-transpilation confirms round-trip
+equivalence. But the annotated output for the composed `Teleport` gadget contains
+the diagnostic:
 
-```text
-ValueError: in GADGET 'Teleport': PROPAGATE for output row 0 (OUT0.LZ0) does not lie in the basis-freedom span of that row; the spec differs from the canonical flow-derived value by 3 bit(s) that cannot be expressed as any XOR of input-stabilizers, output-stabilizer joint rows, or finished-check parities.
-  Hint: if 'Teleport' was generated by 'deq annotate' from a COMPOSE block, add the @REPROPAGATE decorator to that COMPOSE.  @REPROPAGATE switches the COMPOSE build to the flat-circuit pipeline so its propagation matrices come from actual circuit flow on the inlined body, not from sub-gadget matrix composition.
-```
+[Annotated Teleport GADGET — plain COMPOSE](../examples/compose-repropagate/snippet_teleport_plain_annotated.deq)
+
+
@GTYPE(4)
+@CHECKS("manual", verify=0)
+GADGET Teleport {
+    INPUT Code 0 1 2 3
+    R 4 5 6 7
+    MPP X4*X5*X6*X7
+    CX 0 4 1 5 2 6 3 7
+    MX 0 1 2 3
+    OUTPUT Code 4 5 6 7
+    CHECK IN0.S2 M0 M1 M2 M3 M4
+    CHECK IN0.S0 OUT0.S0
+    CHECK IN0.S1 OUT0.S1
+    CHECK M0 OUT0.S2
+    READOUT M1 M3  # flipped by: IN0.LZ0
+    PROPAGATE OUT0.LZ0 FROM
+    PROPAGATE OUT0.LX0 FROM IN0.LX0
+
+    # --- statistics ---
+    # finished checks: 1
+    #   weight distribution: { 6:1 }
+    # unfinished checks: 3
+    #   weight distribution: { 1:3 }
+    # errors: 0
+}
+ + +Look at the two `PROPAGATE` rows. A `PROPAGATE` row traces the *forward +Heisenberg propagation of an input logical Pauli operator through the gadget*. +`PROPAGATE OUT0.LX0 FROM IN0.LX0` says the input logical $\bar{X}$ operator +propagates through the gadget to reappear as the output logical $\bar{X}$ +operator (both are the operator that flips their frame's $\bar{Z}$ observable) — +so the $\bar{Z}$ observable on port 1 tracks the input's $\bar{Z}$ observable on +port 0 and that half of the teleport works. + +But **`PROPAGATE OUT0.LZ0 FROM` has an empty right-hand side**: no input operator +(and no XOR with any mid-circuit measurement bit) propagates to the output logical +$\bar{Z}$ operator. Because $\bar{Z}$ is the operator that flips the frame's +$\bar{X}$ observable, the runtime has no expression for the output $\bar{X}$ +observable in terms of the input — the input's $\bar{X}$ information is discarded +rather than teleported. + +To confirm, look at the compiled `correction_propagation` (cp) and +`physical_correction` (pc) matrix rows for `OUT0.LZ0` in the two variants: + +| Variant | cp row (input logical operators) | pc row (mid-circuit measurements) | +| --------------------------------------- | -------------------------------- | --------------------------------- | +| Plain `COMPOSE` (this file) | `{}` (empty) | `{}` (empty) | +| `@REPROPAGATE COMPOSE` (see next file) | `{IN0.LZ0}` | `{M1, M3}` | + +The `@REPROPAGATE` version records the correct propagation +`OUT0.LZ0 = IN0.LZ0 ⊕ M1 ⊕ M3`: the input $\bar{Z}$ operator propagates to the +output $\bar{Z}$ operator, up to a classical XOR with the parity of two +mid-circuit measurements — exactly the feed-forward correction that teleportation +requires. The plain version records nothing on either side. + +Why does matrix composition produce the empty row? Trace the sub-gadgets: + +* `PrepareZero 1` initialises port 1 in $|0\rangle_L$ (a +1 eigenstate of $\bar{Z}$). + It has no INPUT ports, so its propagation matrix has *no* $\bar{Z}$-operator + source at all — nothing to feed into an output $\bar{Z}$ column. +* `CNOT 0 1` propagates $\bar{Z}_1 \to \bar{Z}_1$ (Z on target stays on target), + so any $\bar{Z}$ operator at port 1's output can only come from port 1's *input* + $\bar{Z}$ operator — which `PrepareZero` never supplied. +* `MeasureX 0` reads out port 0 but has no output port, so nothing propagates + through it either. + +Nowhere in this chain does port 0's input $\bar{Z}$ operator meet port 1's output +$\bar{Z}$ operator except via the mid-circuit measurement outcome — and that +meeting is exactly the classical-feed-forward step that matrix composition cannot +invent. + +**The reader's tool for spotting this bug is the annotated `PROPAGATE` row.** +Whenever your COMPOSE is supposed to preserve some input logical operator on some +output port, the emitted `PROPAGATE OUT

.L

` should list that input operator +on its right-hand side (possibly XOR'd with some `M` bits for the classical +correction). An empty right-hand side on a row whose input operator should have +propagated forward means matrix composition has quietly dropped a classical +correction. + +Two ways to add the correction back: + +1. `@REPROPAGATE` — swap the propagation strategy to circuit-flow analysis on the + flat inlined body. The next section shows this in full. +2. Write an explicit `CONDITIONAL rec[-k] ` inside the COMPOSE body. + The canonicalizer's `absorb_logical_correction` step folds that CONDITIONAL into + cp/pc, producing the same binary as `@REPROPAGATE`. Concretely, replacing the + plain COMPOSE with + + ```text + COMPOSE Teleport { + INPUT Code 0 + PrepareZero 1 + CNOT 0 1 + MeasureX 0 + CONDITIONAL rec[-1] Z0 1 + OUTPUT Code 1 + } + ``` -(The exact text is captured into -[`01_teleport_annotate_error.txt`](../examples/compose-repropagate/01_teleport_annotate_error.txt) -by the chapter's generator script, so the build catches any drift.) - -The failing check is the `PROPAGATE` statement for `OUT0.LZ0` (the logical $\bar{Z}$ -column of port 0's output frame). At COMPOSE build time the JIT compiler chained the -three sub-gadgets' propagation matrices and produced a `PROPAGATE OUT0.LZ0 FROM ...` -row whose right-hand side includes contributions from internal measurements — a faithful -representation of the conditional correction. When `deq annotate` rewrites the COMPOSE -as a flat `GADGET` and the verifier re-transpiles it, the only information the verifier -has is the inlined circuit; it cannot recover the matrix-composed row from circuit flow -alone, and reports that 3 bits of the spec "cannot be expressed as any XOR of -input-stabilizers, output-stabilizer joint rows, or finished-check parities". - -In other words: matrix composition and circuit-flow analysis are two *different* ways -of producing a propagation matrix. They agree on most COMPOSEs — which is why the -default matrix-composition path works almost everywhere — but for teleportation-style -operations the two strategies produce rows that the verifier knows are equivalent only -if you can already see the underlying measurement-conditioned Pauli, and the -flat-circuit pipeline cannot. - -The hint at the bottom of the error message points at the fix: add `@REPROPAGATE` to -the COMPOSE. + makes the emitted `PROPAGATE OUT0.LZ0` show `FROM IN0.LZ0 M1 M3` too. The + trade-off: `CONDITIONAL` requires you to name every classical correction + yourself; `@REPROPAGATE` derives them from the flat circuit automatically. --- @@ -270,11 +368,13 @@ The annotated COMPOSE renders as a flat `GADGET Teleport` block: MPP X4*X5*X6*X7 CX 0 4 1 5 2 6 3 7 MX 0 1 2 3 + READOUT rec[-4] rec[-2] # flipped by: IN0.LZ0 CHECK M4 M3 M2 M1 M0 IN0.S2 OUTPUT Code 4 5 6 7 CHECK OUT0.S0 IN0.S0 CHECK OUT0.S1 IN0.S1 CHECK OUT0.S2 M0 + # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 M1 M3 PROPAGATE OUT0.LX0 FROM IN0.LX0 @@ -293,12 +393,16 @@ The decisive line is PROPAGATE OUT0.LZ0 FROM IN0.LZ0 M1 M3 ``` -The trailing `M1 M3` are internal-measurement references that encode the conditional -logical $\bar{Z}$: when the parity of those two measurements is `1`, the output frame's -$\bar{Z}$ column is flipped. `@REPROPAGATE` derives this directly from the *inlined* -circuit (it can see the `MX 0 1 2 3` and trace the resulting Pauli frame forwards), -which is exactly the same derivation the verifier runs — so build and verifier now -agree. +The `IN0.LZ0` token is what was missing from the plain-COMPOSE emission — the +row now says the input logical $\bar{Z}$ operator *does* propagate forward to +the output logical $\bar{Z}$ operator, so the input state is preserved rather +than discarded. The trailing `M1 M3` are internal-measurement references that +encode the conditional correction: when the parity of those two measurements is +$1$, the propagated output $\bar{Z}$ operator is flipped in the Pauli frame. +`@REPROPAGATE` derives all three tokens directly from the inlined circuit (it +can see the `MX 0 1 2 3` and trace the resulting Pauli frame forwards) — exactly +the derivation the verifier would also run, which is why no `@OVERRIDE` +decorator is needed. --- @@ -321,7 +425,7 @@ exact reason to use `COMPOSE` over a flat GADGET, as the Only the bottom two rows change. The JIT compiler's check structure encodes the sub-gadget composition — e.g., for multi-round syndrome extraction it produces the -weight-2 round-to-round comparison checks decoders rely on, not weight-1 single-shot +weight-2 round-to-round comparison checks decoders rely on, not those non-local checks. `@REPROPAGATE` keeps those checks verbatim and only patches the propagation/error side, which is the side that could not handle the conditional Pauli. @@ -329,26 +433,30 @@ propagation/error side, which is the side that could not handle the conditional ## When to reach for it -Use `@REPROPAGATE` whenever a COMPOSE block implements a logical operation that -**depends on a measurement outcome via classical feed-forward**, including: +Reach for `@REPROPAGATE` whenever a COMPOSE block implements a logical operation +that **depends on a measurement outcome via classical feed-forward**, including: - logical teleportation (the example above); -- gate teleportation of Clifford or non-Clifford gates; - lattice surgery with conditional logical Pauli corrections; -- magic-state injection followed by a conditional Clifford fix-up; - any other pattern where the input→output Pauli flow has a row that is only determined after looking at internal measurement outcomes. -A reliable diagnostic recipe: +Because `deq annotate` does **not** raise an error when the classical correction is +missing, the reliable diagnostic recipe is to inspect the emitted `PROPAGATE` rows: 1. Write the `COMPOSE` block first, **without** `@REPROPAGATE`. -2. Run `deq annotate`. If verification passes, the default matrix-composition strategy - was sufficient for this COMPOSE — you are done. -3. If verification fails with - ``` - PROPAGATE for output row ... does not lie in the basis-freedom span - ``` - add `@REPROPAGATE` to the COMPOSE. The error message itself names the decorator. +2. Run `deq annotate` and open the resulting `.annotated.deq`. +3. Locate the `GADGET ` block. For every output logical operator + your COMPOSE is supposed to preserve, check that the corresponding + `PROPAGATE OUT

.L

` line has the matching input operator on its + right-hand side. Also check that any classical corrections you expect are + reflected as `M` tokens. +4. If a state-preserving row has an empty (or otherwise unexpected) right-hand + side, matrix composition has dropped a classical correction. Add + `@REPROPAGATE` to the COMPOSE, or write the correction explicitly as a + compose-level `CONDITIONAL rec[-k] ` statement. Either choice + folds the correction back into cp/pc; the two produce canonically equivalent + binaries. --- @@ -358,8 +466,9 @@ A reliable diagnostic recipe: | ---------------------------------- | --------------------------------------------------------------------------------------------- | | Check locality in `COMPOSE` | Comes from the **JIT compiler**, independent of how propagation matrices are derived | | Default propagation strategy | Matrix composition of sub-gadget propagation matrices (mirrors runtime composition) | +| Matrix composition's blind spot | Cannot invent classical feed-forward — a missing correction shows up as an empty (or unexpected) `PROPAGATE` row in the annotated GADGET | | `@REPROPAGATE COMPOSE Name { ... }` | Swap the propagation strategy to circuit-flow analysis on the inlined body | -| What changes | Only `correction_propagation`, `physical_correction`, and the noise-derived `ERROR` rows | +| Compose-level `CONDITIONAL rec[-k] ` | Alternative fix: name the correction explicitly; canonicalizer folds it into cp/pc | +| What changes with `@REPROPAGATE` | Only `correction_propagation`, `physical_correction`, and the noise-derived `ERROR` rows | | What stays the same | Checks, measurements, readouts, ports — all still produced by the JIT compiler | -| When you need it | Logical operations whose Pauli flow depends on classical feed-forward (e.g. teleportation) | -| How to diagnose | If `deq annotate` rejects a PROPAGATE row's basis-freedom span, add `@REPROPAGATE` | +| How to diagnose a broken COMPOSE | Read the emitted `PROPAGATE OUT

.L

` rows; if an input logical operator that should propagate forward is missing from the corresponding right-hand side, matrix composition dropped a classical correction | diff --git a/deq/documents/tutorial/chapters/debug-deq-program.md b/deq/documents/tutorial/chapters/debug-deq-program.md index b64e6527..62521fe5 100644 --- a/deq/documents/tutorial/chapters/debug-deq-program.md +++ b/deq/documents/tutorial/chapters/debug-deq-program.md @@ -44,6 +44,7 @@ Output: OUTPUT RepetitionCode 0 1 2 CHECK OUT0.S0 CHECK OUT0.S1 + # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM PROPAGATE OUT0.LX0 FROM @@ -75,6 +76,7 @@ Output: OUTPUT RepetitionCode 0 2 4 CHECK OUT0.S0 M0 CHECK OUT0.S1 M1 + # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 @@ -99,6 +101,7 @@ Output: READOUT rec[-3] rec[-2] rec[-1] # flipped by: IN0.LX0 CHECK M1 M0 IN0.S0 CHECK M2 M1 IN0.S1 + # PROPAGATE below reflects the joint effect of all statements above # --- statistics --- # finished checks: 2 diff --git a/deq/documents/tutorial/chapters/floquet-code.md b/deq/documents/tutorial/chapters/floquet-code.md index 360f6c84..0469b4f9 100644 --- a/deq/documents/tutorial/chapters/floquet-code.md +++ b/deq/documents/tutorial/chapters/floquet-code.md @@ -379,6 +379,7 @@ The result for `RoundRed` is: CHECK OUT0.S15 IN0.S15 CHECK OUT0.S16 IN0.S16 CHECK OUT0.S17 IN0.S17 + # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 IN0.DS1 IN0.DS7 PROPAGATE OUT0.LZ1 FROM IN0.LZ1 M6 M7 diff --git a/deq/documents/tutorial/chapters/multi-port-gadgets.md b/deq/documents/tutorial/chapters/multi-port-gadgets.md index e77fa653..575f2f3a 100644 --- a/deq/documents/tutorial/chapters/multi-port-gadgets.md +++ b/deq/documents/tutorial/chapters/multi-port-gadgets.md @@ -76,6 +76,7 @@ The transpiler derives 4 unfinished checks — let's look at the annotated outpu CHECK OUT0.S1 IN0.S1 CHECK OUT1.S0 IN1.S0 IN0.S0 CHECK OUT1.S1 IN1.S1 IN0.S1 + # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 PROPAGATE OUT1.LZ0 FROM IN1.LZ0 @@ -171,6 +172,7 @@ With noise, the error structure reveals the CNOT's impact on decoding: CHECK OUT0.S1 IN0.S1 CHECK OUT1.S0 IN1.S0 IN0.S0 CHECK OUT1.S1 IN1.S1 IN0.S1 + # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 PROPAGATE OUT1.LZ0 FROM IN1.LZ0 diff --git a/deq/documents/tutorial/chapters/steane-style-ec.md b/deq/documents/tutorial/chapters/steane-style-ec.md index f2d74c43..f4d1287d 100644 --- a/deq/documents/tutorial/chapters/steane-style-ec.md +++ b/deq/documents/tutorial/chapters/steane-style-ec.md @@ -128,6 +128,7 @@ Running `deq annotate` on this gadget reveals the check structure: CHECK OUT0.S3 CHECK OUT0.S4 CHECK OUT0.S5 + # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 M6 M7 M8 PROPAGATE OUT0.LX0 FROM IN0.LX0 M13 M14 M15 From 2272f80da1c7e2d3194e8f4922bbd4c84b2c0adf Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 6 Jul 2026 14:19:25 -0700 Subject: [PATCH 011/157] split conditional correction chapter into two --- .../chapters/conditional-correction.md | 475 ++++++++++++++++++ .../01_teleport_logical.deq | 36 +- .../conditional-correction/.gitignore | 1 + .../00_teleportation_library.deq | 51 ++ .../01_teleport_repropagate.deq | 27 + .../02_teleport_compose_conditional.deq | 34 ++ .../03_teleport_program_conditional.deq | 25 + 7 files changed, 637 insertions(+), 12 deletions(-) create mode 100644 deq/documents/tutorial/chapters/conditional-correction.md create mode 100644 deq/documents/tutorial/examples/conditional-correction/.gitignore create mode 100644 deq/documents/tutorial/examples/conditional-correction/00_teleportation_library.deq create mode 100644 deq/documents/tutorial/examples/conditional-correction/01_teleport_repropagate.deq create mode 100644 deq/documents/tutorial/examples/conditional-correction/02_teleport_compose_conditional.deq create mode 100644 deq/documents/tutorial/examples/conditional-correction/03_teleport_program_conditional.deq diff --git a/deq/documents/tutorial/chapters/conditional-correction.md b/deq/documents/tutorial/chapters/conditional-correction.md new file mode 100644 index 00000000..44debfb1 --- /dev/null +++ b/deq/documents/tutorial/chapters/conditional-correction.md @@ -0,0 +1,475 @@ +# Conditional Pauli Corrections: the `CONDITIONAL` Statement + +The [`@REPROPAGATE` chapter](compose-repropagate.md) showed one way to handle a +`COMPOSE` block whose logical output depends on a mid-circuit measurement: switch the +propagation strategy from matrix composition of sub-gadgets to circuit-flow analysis +on the inlined body, so the natural Heisenberg propagation through the inlined +circuit derives the measurement-conditioned Pauli frame for free. + +`CONDITIONAL` is the second way: instead of relying on circuit flow to derive the +correction, you **write it down as a logical-level statement** inside the `COMPOSE` +(or `PROGRAM`) body: + +``` +CONDITIONAL rec[-2] Z0 2 # apply logical Z on wire 2 if measurement record -2 = 1 +``` + +The transpiler injects a synthesized identity-host gadget that carries the +correction, and the merge() canonicalizer folds the readout's measurement set into +the affected output observable's measurement deps — giving the same final +`correction_propagation` / `physical_correction` matrices as the `@REPROPAGATE` +pathway. + +This chapter walks through Bell-pair logical teleportation — the canonical +measurement-based logical operation — and shows three equivalent ways to express +its Pauli frame correction: `@REPROPAGATE`, COMPOSE-level `CONDITIONAL`, and +PROGRAM-level `CONDITIONAL`. We then quantify each variant against physical noise +to show that the choice is purely about *how the correction is expressed in source*; +the runtime behavior is identical. + +--- + +## Bell-pair logical teleportation in one figure + +Three distance-3 rotated surface-code patches — the input patch carrying +$|\psi\rangle_L$ on wire 0 plus two ancillary patches on wires 1 and 2 — interact +as follows: + +![Stim timeline of the Bell-pair teleportation: Bell prep on q1/q2, transversal CNOT and H on q0/q1, two measurements, then `X rec[1]` and `Z rec[0]` feedforward on q2](../examples/conditional-correction/teleport_timeline.png) + +Each `q0` / `q1` / `q2` line in the diagram stands in for a full 9-qubit +distance-3 surface-code patch; the diagram is rendered at the logical-qubit +level so the Bell-pair preparation, Bell-basis measurement, and feedforward +corrections dominate the view rather than the syndrome-extraction noise inside +each patch. + +In words: + +1. The input patch `|ψ⟩_A` is on wire 0. +2. `PrepareBell 1 2` puts wires 1 and 2 into the logical Bell state + $|\Phi^+\rangle_L = (|0_L 0_L\rangle + |1_L 1_L\rangle)/\sqrt{2}$ by preparing wire 1 in + $|+_L\rangle$, wire 2 in $|0_L\rangle$, and applying a transversal logical CNOT + from wire 1 to wire 2. +3. `MeasureBell 0 1` destructively measures wires 0 and 1 in the logical Bell basis + by applying another transversal CNOT and then measuring wire 0 in X (giving + $m_{XX} = \langle \bar X_0 \bar X_1\rangle$) and wire 1 in Z (giving + $m_{ZZ} = \langle \bar Z_0 \bar Z_1\rangle$). +4. The post-measurement state on wire 2 is $X^{m_{ZZ}} Z^{m_{XX}} |\psi\rangle_L$ — + the input state up to a Pauli frame that depends on the random Bell-measurement + outcomes. + +Step 4 is the measurement-conditioned Pauli frame: we need to apply $Z$ on wire 2 +if $m_{XX} = 1$ and $X$ on wire 2 if $m_{ZZ} = 1$ for the gadget to act as logical +identity (i.e. wire 2 carries the same logical state that wire 0 had on input). + +The Bell-pair building blocks are reusable, so we factor them out into the chapter's +shared library: + +[`PrepareBell` and `MeasureBell` in the shared library](../examples/conditional-correction/snippet_prepare_bell.deq) + +

COMPOSE PrepareBell {
+    PrepareX 0
+    PrepareZ 1
+    TransversalCNOT 0 1
+    OUTPUT SurfaceCode 0
+    OUTPUT SurfaceCode 1
+}
+ + +[`MeasureBell` destructively reads the Bell basis](../examples/conditional-correction/snippet_measure_bell.deq) + +
COMPOSE MeasureBell {
+    INPUT SurfaceCode 0
+    INPUT SurfaceCode 1
+    TransversalCNOT 0 1
+    MeasureX 0
+    MeasureZ 1
+}
+ + +--- + +## Three ways to express the correction + +Once the frame-correction bits are available, there are three idiomatic ways to absorb +them into the gadget so downstream code sees a clean logical-identity teleport. + +All three variants below rely on a small but crucial deq feature — **concatenated +COMPOSE**: once you have declared a `COMPOSE` block, its name becomes callable from +inside any *later* `COMPOSE` (or `PROGRAM`) body just like a `GADGET`, so you can +build layered abstractions without inlining everything by hand. We already used it +above: `PrepareBell` and `MeasureBell` are themselves `COMPOSE` blocks assembled +from lower-level gadgets, and the three teleport variants below invoke them by name +in the same way you would invoke a hand-written GADGET. This lets each teleport +variant express the *whole* logical operation in five lines while the underlying +Bell-pair mechanics live once in the shared library. + +### Variant 1 — `@REPROPAGATE`: let the natural Heisenberg derive the correction + +The transversal CNOT in `MeasureBell` already propagates the input Pauli operators +through to the measurement record — `LZ` of the input gets folded into the wire-1 +`MeasureZ` outcome, and `LX` gets folded into the wire-0 `MeasureX` outcome. The +`@REPROPAGATE` decorator tells the compose builder to rebuild the propagation matrix +from the inlined flat circuit instead of matrix-composing the sub-gadgets' +propagation matrices. The natural circuit flow then automatically encodes +"output = input XOR (some measurement outcomes)", and no explicit CONDITIONAL needs to +be written. + +[`TeleportRepropagate` via `@REPROPAGATE`](../examples/conditional-correction/snippet_teleport_repropagate.deq) + +
@REPROPAGATE
+COMPOSE TeleportRepropagate {
+    INPUT SurfaceCode 0
+    PrepareBell 1 2
+    MeasureBell 0 1
+    OUTPUT SurfaceCode 2
+}
+ + +This is the most compact form — the user just writes the COMPOSE body and +decorates it. But `@REPROPAGATE` cannot resolve a more fundamental issue with +measurement-based logical operations: **the same physical circuit realizes many +inequivalent logical actions**, and deq deliberately refuses to guess which one +the user wants. `CONDITIONAL` (and its cousin `VIRTUAL`) are how the user +*picks* one such reading. The lattice-surgery joint-$\bar Z$ merge `MZZ` in +[`tests/circuit/surface_code/lattice_surgery_d3.deq`](../../../tests/circuit/surface_code/lattice_surgery_d3.deq) is +the canonical example — see the [lattice-surgery chapter](lattice-surgery.md) +for the full walkthrough of the ambiguity and how `CONDITIONAL` and +`@OVERRIDE` resolve it. + +### Variant 2 — COMPOSE-level `CONDITIONAL` + +The `CONDITIONAL` statement applies a logical Pauli on a wire of the COMPOSE block, +conditioned on a previous logical readout: + +[`TeleportConditional` with COMPOSE-level `CONDITIONAL`](../examples/conditional-correction/snippet_teleport_conditional.deq) + +
COMPOSE TeleportConditional {
+    INPUT SurfaceCode 0
+    PrepareBell 1 2
+    MeasureBell 0 1
+    CONDITIONAL rec[-2] Z0 2
+    CONDITIONAL rec[-1] X0 2
+    OUTPUT SurfaceCode 2
+}
+ + +Reading the body line by line: + +| Line | Effect | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `PrepareBell 1 2` | prepare $|\Phi^+\rangle_L$ on wires 1 and 2 | +| `MeasureBell 0 1` | destructively measure wires 0 and 1 in the Bell basis (2 logical readouts: $m_{XX}$, $m_{ZZ}$) | +| `CONDITIONAL rec[-2] Z0 2` | apply logical $Z$ on logical qubit 0 of wire 2 iff $m_{XX} = 1$ | +| `CONDITIONAL rec[-1] X0 2` | apply logical $X$ on logical qubit 0 of wire 2 iff $m_{ZZ} = 1$ | +| `OUTPUT SurfaceCode 2` | wire 2 carries the teleported logical state | + +There is no mention of physical qubits anywhere in the body — the correction is +expressed purely at the logical level (which Pauli, which logical qubit, which wire). +The transpiler synthesizes a one-port identity-host gadget carrying a +`remote_conditional_correction` modifier for each CONDITIONAL; the canonicalizer then +folds each readout's measurement set into the affected output observable's +measurement deps. The merged `logical_correction` matrix ends up empty (every +conditional contribution has been absorbed into `correction_propagation` / +`physical_correction`), and the runtime decoder still sees the readouts it needs to +apply the actual frame correction at decode time. + +### Variant 3 — PROGRAM-level `CONDITIONAL` + +The exact same statements can also live directly in a `PROGRAM` body, without a +wrapping `COMPOSE`: + +[PROGRAM-level CONDITIONAL inline](../examples/conditional-correction/snippet_teleport_program_conditional.deq) + +
PROGRAM TeleportProgramConditionalMemoryZ {
+    PrepareZ 0
+    PrepareBell 1 2
+    MeasureBell 0 1
+    CONDITIONAL rec[-2] Z0 2
+    CONDITIONAL rec[-1] X0 2
+    MeasureZ 2
+    ASSERT_EQ rec[-1] 0
+}
+ + +This is convenient for one-off circuits or for orchestrating conditional Pauli fix-ups +between unrelated gadgets, where you don't want to introduce a new named COMPOSE for +the sole purpose of carrying the correction. Operationally, however, a PROGRAM-level +CONDITIONAL is *not* equivalent to its COMPOSE-level cousin: COMPOSE behaves like a +gadget body and the transpiler flattens each CONDITIONAL into the merged gadget's +propagation matrix offline, whereas PROGRAM keeps each gadget invocation as a separate +instruction and dispatches the conditional through the runtime system rather than +folding it into a matrix at transpile time. See the +[`compose-gadgets`](compose-gadgets.md) chapter for the full COMPOSE vs PROGRAM +distinction. + +--- + +## How the three variants compare + +| Aspect | `@REPROPAGATE` (Variant 1) | COMPOSE-level `CONDITIONAL` (Variant 2) | PROGRAM-level `CONDITIONAL` (Variant 3) | +| ------------------------------------- | ----------------------------------------------------------- | --------------------------------------- | --------------------------------------- | +| Where the correction is declared | Implicit — derived from circuit flow | Inside the `COMPOSE` body | Inside the `PROGRAM` body | +| Lines of code in the user-facing block | Smallest (no CONDITIONAL needed) | Two `CONDITIONAL` statements | Two `CONDITIONAL` statements | +| Does the user name physical qubits? | No | No | No | +| Reusable as a sub-gadget? | Yes, via the wrapping COMPOSE | Yes, via the wrapping COMPOSE | No — lives at the top of the program | +| Requires a transversal-gate path | **Yes** — the inlined body must carry the measured Pauli operator to the output qubits | No | No | +| Where the absorption happens | Flat-circuit Heisenberg on the inlined body | `merge()` step 9 absorption pass | Not absorbed offline — runtime dispatches the conditional per invocation | +| Final `correction_propagation` matrix | Folded at transpile time (matches COMPOSE-level) | Folded at transpile time | Unfolded — handled at runtime instead | + +All three produce a COMPOSE or PROGRAM that acts as logical identity on the teleported +state once the runtime applies the frame correction. Variants 1 and 2 are merged +offline into byte-identical `correction_propagation` matrices, while Variant 3 keeps +the conditional as a runtime instruction; the *logical-level* end-to-end behavior is +the same across all three. + +--- + +## End-to-end verification + +The COMPOSE-level CONDITIONAL fixture comes with a memory program that prepares the +input patch in $|0_L\rangle$, teleports it, then measures the output patch in the Z +basis: + +[`TeleportConditionalMemoryZ` PROGRAM](../examples/conditional-correction/02_teleport_compose_conditional.deq) + +
# Variant 2 — COMPOSE-level CONDITIONAL.
+#
+# Same Bell-pair teleportation, but the Pauli frame correction is
+# expressed as an explicit pair of ``CONDITIONAL`` statements at the
+# logical level:
+#
+#     CONDITIONAL rec[-2] Z0 2   # if m_XX = 1, apply Z to output patch
+#     CONDITIONAL rec[-1] X0 2   # if m_ZZ = 1, apply X to output patch
+#
+# No ``@REPROPAGATE`` decorator is needed.  The transpiler injects a
+# synthesized identity-host gadget carrying a
+# ``remote_conditional_correction`` modifier for each statement; the
+# canonicalizer folds the readout's measurement set into the affected
+# output observable's measurement deps, yielding the same
+# ``correction_propagation`` / ``physical_correction`` matrices as
+# ``TeleportRepropagate``.
+
+IMPORT "00_teleportation_library.deq"
+
+COMPOSE TeleportConditional {
+    INPUT SurfaceCode 0
+    PrepareBell 1 2
+    MeasureBell 0 1
+    CONDITIONAL rec[-2] Z0 2
+    CONDITIONAL rec[-1] X0 2
+    OUTPUT SurfaceCode 2
+}
+
+PROGRAM TeleportConditionalMemoryZ {
+    PrepareZ 0
+    TeleportConditional 0
+    MeasureZ 0
+    ASSERT_EQ rec[-1] 0
+}
+ + +After applying the conditional correction the teleport is logical identity on Z, so +the final `MeasureZ` reads `0` deterministically. Running 20 noiseless shots +captures the random Bell-measurement outcomes and the always-zero terminal +measurement: + +```sh +deq sample 02_teleport_compose_conditional.deq \ + --program TeleportConditionalMemoryZ \ + --shots 20 --noiseless --interpret --seed 42 +``` + +The first few shots of `teleport_conditional_sample.txt` (excerpted): + +```text +Readouts: + TeleportConditional: READOUT m0 m1 m2 = 0 (m24 ⊕ m25 ⊕ m26) + TeleportConditional: READOUT m0 m3 m6 = 1 (m33 ⊕ m36 ⊕ m39) + MeasureZ: READOUT m0 m3 m6 = 0 (m33 ⊕ m36 ⊕ m39 ⊕ m42 ⊕ m45 ⊕ m48) +``` + +The two `TeleportConditional` readouts ($m_{XX}$ and $m_{ZZ}$ from `MeasureBell`) +flip randomly across shots, but the final `MeasureZ` reads `0` on every single shot: +the CONDITIONAL absorbed the frame correction into the canonical readout's +measurement set, so the deterministic logical bit comes out unchanged regardless of +which way the random bits fell. + +The end-to-end runtime check is `deq simulate ler` (decoder + classical correction +applied for real): + +```sh +deq simulate ler 02_teleport_compose_conditional.deq \ + --program TeleportConditionalMemoryZ \ + --shots 20 --batch-size 20 --seed 42 +``` + +Output (excerpt of `teleport_conditional_simulate.txt`): + +```text +=== Simulation Results === + Shots: 20 + Logical errors: 0 +``` + +Zero logical errors over 20 noiseless shots — the runtime side of the pipeline +agrees with the canonicalizer-side absorption. + +--- + +## Logical error rate under noise + +The noiseless run above proves the gadget is *semantically* correct. To show it is +also a useful *error-correcting* operation, we sweep the physical error rate of a +Stim SI1000 noise model and measure the surviving logical error rate (LER) of the +same `TeleportConditionalMemoryZ` program. + +### One-command pipeline + +`deq inject si1000` adds depolarizing/measurement noise of strength `p` to every +gate in a `.deq` file; `deq simulate ler` then runs the full transpile → compile → +runtime pipeline against a black-box relay-BP decoder: + +```sh +# 1) Inject noise into the primitives fixture — the only file that +# actually contains physical gates. ``00_teleportation_library.deq`` +# and ``02_teleport_compose_conditional.deq`` contain only GADGET / +# COMPOSE / PROGRAM invocations, so `deq inject si1000` has nothing +# to attach noise to; we just ``cp`` them and rewrite the IMPORT +# chain to redirect to the noisy fixture. All three ``*_noisy.deq`` +# outputs are gitignored per this folder's ``.gitignore`` — +# regenerate them on demand. +deq inject si1000 ../../../../tests/circuit/surface_code/surface_code_d3.deq \ + --p 1e-4 --out surface_code_d3_noisy.deq +cp 00_teleportation_library.deq 00_teleportation_library_noisy.deq +cp 02_teleport_compose_conditional.deq 02_teleport_compose_conditional_noisy.deq +sed -i 's|"../../../../tests/circuit/surface_code/surface_code_d3.deq"|"surface_code_d3_noisy.deq"|' \ + 00_teleportation_library_noisy.deq +sed -i 's|"00_teleportation_library.deq"|"00_teleportation_library_noisy.deq"|' \ + 02_teleport_compose_conditional_noisy.deq + +# 2) Run the LER simulator. +deq simulate ler 02_teleport_compose_conditional_noisy.deq \ + --program TeleportConditionalMemoryZ \ + --shots 3000000 --errors 200 --batch-size 5000 --seed 42 +``` + +At physical error rate $p = 1 \times 10^{-4}$ this prints: + +```text +=== Simulation Results === + Shots: 3000000 + Logical errors: 22 + Error rate: 7.333333e-06 +``` + +so the gadget's surviving LER is $\approx 7.3 \times 10^{-6}$ — **a factor of ≈14 +below the physical error rate**, comfortably more than one order of magnitude. + +### LER vs. physical error rate sweep + +Repeating the sweep over five noise rates traces out the standard sub-threshold +scaling for the rotated $d=3$ surface code under SI1000: + +| Physical rate $p$ | LER $(\bar Z$-basis memory) | $\mathrm{LER}/p$ | +| ----------------- | --------------------------- | ----------------- | +| $1.0\times 10^{-3}$ | $7.85 \times 10^{-4}$ | $0.79$ | +| $5.0\times 10^{-4}$ | $1.98 \times 10^{-4}$ | $0.40$ | +| $3.0\times 10^{-4}$ | $7.09 \times 10^{-5}$ | $0.24$ | +| $2.0\times 10^{-4}$ | $3.17 \times 10^{-5}$ | $0.16$ | +| $1.0\times 10^{-4}$ | $7.33 \times 10^{-6}$ | $0.07$ | + +(3 M shots per row, `--seed 42`, with `--errors 200` early-stop.) + +At $p \approx 10^{-3}$ we are close to the SI1000 threshold and the protection is +weak; halving the noise to $5 \times 10^{-4}$ already brings the LER below physical; +by $10^{-4}$ the gap is over an order of magnitude. The three CONDITIONAL variants +(`@REPROPAGATE`, COMPOSE-level, PROGRAM-level) produce byte-identical merge()- +absorbed matrices, so they share the same LER curve. + +--- + +## Where `CONDITIONAL` isn't enough + +`CONDITIONAL` (in either its COMPOSE or PROGRAM form) is the right tool whenever +the byproduct is a *per-port* logical Pauli that the framework can derive from an +existing readout — every measurement-based teleport in this chapter fits that +mould. Some measurement-based operations exceed even CONDITIONAL's reach: the +byproduct spans more than one output port at once, or the same physical body +admits several distinct logical actions and the framework's per-port flow solver +picks the wrong one. The [lattice-surgery chapter](lattice-surgery.md) works +through the canonical example — a joint-$\bar Z$ merge that needs `CONDITIONAL` +to pin the "honest joint measurement" reading *and* two `@OVERRIDE PROPAGATE` +rows to hand-declare the joint-$\bar X$ preservation the per-port solver misses +— and then shows how to restructure the merge so the resulting fault-tolerance +is genuinely below-threshold at $d = 3$. + +--- + +## When to reach for `CONDITIONAL` vs. `@REPROPAGATE` + +The two features overlap for any operation where the correction can be expressed +either as natural Heisenberg flow or as an explicit logical Pauli statement. When +both work, they produce byte-identical merge()-absorbed matrices, so the choice is +a matter of *which form reads more clearly in source*. + +| Property | `@REPROPAGATE` is the better fit | `CONDITIONAL` is the better fit | +| -------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------- | +| The correction comes from a transversal gate's Heisenberg propagation | ✓ | works, but the user has to spell it out explicitly | +| The correction comes from a measurement that has no transversal Heisenberg path in the inlined body | ✗ (`deq annotate` rejects) | ✓ (synthesizes the identity-host gadget) | +| You want the COMPOSE body to read like a textbook protocol (Bell prep, measure, correct) | works, but the correction is invisible in source | ✓ (CONDITIONAL spells out the correction) | +| You want the absolute minimum number of source lines | ✓ (no explicit correction) | one extra line per CONDITIONAL | +| You don't yet know whether the correction is a real classical Pauli or a Heisenberg-flow artifact | ✗ (transpiler decides for you) | ✓ (explicit declaration) | + +A reliable diagnostic recipe when you are unsure which to use: + +1. Write the `COMPOSE` block first, **without** any decorator and without + `CONDITIONAL`. +2. Run `deq annotate` and read the resulting `PROPAGATE OUT*.L*0` rows: for each + output logical operator, does the right-hand side (the XOR of input columns + and measurement bits) match what you intended the operation to do? The + annotator always produces *some* rows — the default matrix-composition + strategy picks one self-consistent logical action out of the many that the + physical body admits, and it may not be the one you had in mind. +3. If every row matches your intent, the default matrix-composition strategy + happened to pick the reading you wanted — you are done. +4. If some row differs from your intent, decide which mechanism to reach for: + + * If your intended row *can* be derived from a measurement the inlined body + already carries through via transversal-Heisenberg flow (the + transversal-CNOT case), add `@REPROPAGATE` and re-run — the flow analysis + on the flat inlined body will find the row for you. + * If your intended row is the result of a *classical* Pauli the circuit + never applies physically (it lives only in the decoder's frame), spell it + out with a `CONDITIONAL rec[-k] ` statement at the COMPOSE + level (or the PROGRAM level if you don't want a wrapping COMPOSE). + +`@REPROPAGATE` and `CONDITIONAL` are **mutually exclusive within one +COMPOSE**: `deq annotate` rejects a `@REPROPAGATE` COMPOSE that (transitively, +through any sub-COMPOSE or sub-GADGET) contains a `CONDITIONAL` statement, +because the flat-circuit Heisenberg re-derivation cannot reconstruct a frame +flip that lives only in the decoder's classical Pauli record. If a single +operation genuinely needs *both* a flow-derived correction and a +classical-frame correction, drop `@REPROPAGATE` and express every row via +`CONDITIONAL` — the merge-based composition path handles the combined case +uniformly. + +--- + +## Summary + +| Concept | Purpose | +| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | +| Pauli frame correction in measurement-based logical operations | A bit derived from mid-circuit measurements that must be applied to a downstream logical observable | +| `@REPROPAGATE` | Let the inlined-body Heisenberg propagation derive the correction implicitly | +| `CONDITIONAL rec[-k] ` in `COMPOSE` | Logical-level intent; wraps a sub-gadget that exposes the readout, no physical-qubit names at the call site | +| `CONDITIONAL rec[-k] ` in `PROGRAM` | Same expression inline in the program body, no wrapping COMPOSE needed | +| What changes in the merged matrices | The conditional readout's measurement set is folded into the affected logical row of `correction_propagation` / `physical_correction` | +| What stays the same | The readout itself is preserved — the decoder needs it to apply the frame correction at runtime | +| When you reach for `CONDITIONAL` | When you want the correction visible in source, or when no transversal-Heisenberg path exists for `@REPROPAGATE` to derive | +| LER at $d = 3$, $p = 10^{-4}$ | Bell-pair teleport gets $\approx 7 \times 10^{-6}$ — over an order of magnitude below physical | + +Related chapters: + +- [Lattice Surgery: The Joint-$\bar Z$ Measurement](lattice-surgery.md) — the follow-on chapter where a joint-parity merge forces `CONDITIONAL` *and* `@OVERRIDE PROPAGATE`, and where restructuring the merge into single-SE-round GADGETs is what recovers fault tolerance. +- [`@REPROPAGATE`](compose-repropagate.md) — the flow-based alternative for corrections with a transversal-Heisenberg path. diff --git a/deq/documents/tutorial/examples/compose-repropagate/01_teleport_logical.deq b/deq/documents/tutorial/examples/compose-repropagate/01_teleport_logical.deq index c84ee78a..2ea37367 100644 --- a/deq/documents/tutorial/examples/compose-repropagate/01_teleport_logical.deq +++ b/deq/documents/tutorial/examples/compose-repropagate/01_teleport_logical.deq @@ -1,8 +1,13 @@ -# Logical teleportation realised with a COMPOSE block. +# Logical teleportation, attempted with the default COMPOSE build path. # -# This file is the *negative* example: the COMPOSE has no @REPROPAGATE -# decorator, so `deq annotate` will fail at the verification step. See -# 02_teleport_repropagate.deq for the working version. +# ***This file is a NEGATIVE example.*** It compiles and annotates +# without error, but the resulting composed gadget is not actually a +# logical identity from port 0 to port 1: matrix composition drops the +# classical feed-forward that teleportation requires. Compare the +# `PROPAGATE OUT0.LZ0 FROM` (empty) row emitted for `Teleport` in +# `01_teleport_logical.annotated.deq` with the informative +# `PROPAGATE OUT0.LZ0 FROM IN0.LZ0 M1 M3` row emitted for the +# `@REPROPAGATE` variant in `02_teleport_repropagate.annotated.deq`. # # Code layout: 4 physical qubits per logical qubit. # 0 1 @@ -34,15 +39,22 @@ GADGET MeasureX { READOUT M0 M2 } -# Logical teleportation: |psi> in port 0, |+_L> prepared on port 1, -# transversal CNOT, measure X on port 0 -> the input logical state ends -# up on port 1 (possibly up to a conditional logical Z). +# Logical teleportation: |psi> in port 0, |0_L> prepared on port 1, +# transversal CNOT, measure X on port 0 -> the input logical state +# should end up on port 1 (possibly up to a conditional logical Z). # -# Without @REPROPAGATE, the COMPOSE pipeline composes the -# *propagation matrices* of PrepareZero, CNOT and MeasureX, which -# cannot represent the conditional logical Pauli correction that -# teleportation implicitly requires. `deq annotate` therefore -# rejects the rendered PROPAGATE statements during verification. +# Without @REPROPAGATE (or an explicit CONDITIONAL), the COMPOSE +# pipeline composes the propagation matrices of the sub-gadgets. +# Matrix composition cannot invent classical feed-forward, so the +# composed row for `OUT0.LZ0` comes out empty: no input logical +# operator (and no measurement bit) propagates to the output LZ. +# Since the LZ operator is what flips the X observable, the input's +# X observable is discarded rather than teleported. The `LX` +# operator still propagates cleanly (input LX -> output LX, both +# flip the Z observable), so the Z observable does survive — but a +# gadget that only teleports one basis is not the identity. +# +# See 02_teleport_repropagate.deq for the @REPROPAGATE fix. COMPOSE Teleport { INPUT Code 0 PrepareZero 1 diff --git a/deq/documents/tutorial/examples/conditional-correction/.gitignore b/deq/documents/tutorial/examples/conditional-correction/.gitignore new file mode 100644 index 00000000..4ed99b77 --- /dev/null +++ b/deq/documents/tutorial/examples/conditional-correction/.gitignore @@ -0,0 +1 @@ +*_noisy.deq diff --git a/deq/documents/tutorial/examples/conditional-correction/00_teleportation_library.deq b/deq/documents/tutorial/examples/conditional-correction/00_teleportation_library.deq new file mode 100644 index 00000000..813384ee --- /dev/null +++ b/deq/documents/tutorial/examples/conditional-correction/00_teleportation_library.deq @@ -0,0 +1,51 @@ +# Shared library for the conditional-correction chapter. +# +# The physical building blocks (the ``SurfaceCode`` [[9,1,3]] code +# together with ``PrepareZ``, ``PrepareX``, ``MeasureZ``, ``MeasureX``, +# and the ``TransversalCNOT`` layer) are shared with the test suite; +# they live in the canonical fixture at +# ``deq/tests/circuit/surface_code/surface_code_d3.deq`` and are +# imported here to avoid duplicating the physical-gate schedules. +# +# On top of those primitives this library adds the *chapter-specific* +# Bell-pair building blocks (``PrepareBell`` / ``MeasureBell``) that +# the three teleport variants (`01_teleport_repropagate.deq`, +# `02_teleport_compose_conditional.deq`, +# `03_teleport_program_conditional.deq`) all invoke. + +IMPORT "../../../../tests/circuit/surface_code/surface_code_d3.deq" + +# ── Bell-pair preparation and measurement ──────────────────────────── +# +# ``PrepareBell``: |Φ⁺⟩_L = (|0_L 0_L⟩ + |1_L 1_L⟩) / √2 +# +# 1. PrepareX patch 0 → |+_L⟩ +# 2. PrepareZ patch 1 → |0_L⟩ +# 3. TransversalCNOT 0 → 1 +# +# The transversal CNOT carries LX_0 → LX_0·LX_1 and LZ_1 → LZ_0·LZ_1, +# producing the +1 eigenspace of {LX_0 LX_1, LZ_0 LZ_1} = |Φ⁺⟩_L. + +COMPOSE PrepareBell { + PrepareX 0 + PrepareZ 1 + TransversalCNOT 0 1 + OUTPUT SurfaceCode 0 + OUTPUT SurfaceCode 1 +} + +# ``MeasureBell``: destructive logical Bell-basis measurement. +# +# 1. TransversalCNOT 0 → 1 +# 2. MeasureX patch 0 → reads m_XX = ⟨LX_0 LX_1⟩ +# 3. MeasureZ patch 1 → reads m_ZZ = ⟨LZ_0 LZ_1⟩ +# +# Within the composed gadget, rec[-2] = m_XX and rec[-1] = m_ZZ. + +COMPOSE MeasureBell { + INPUT SurfaceCode 0 + INPUT SurfaceCode 1 + TransversalCNOT 0 1 + MeasureX 0 + MeasureZ 1 +} diff --git a/deq/documents/tutorial/examples/conditional-correction/01_teleport_repropagate.deq b/deq/documents/tutorial/examples/conditional-correction/01_teleport_repropagate.deq new file mode 100644 index 00000000..4abe929a --- /dev/null +++ b/deq/documents/tutorial/examples/conditional-correction/01_teleport_repropagate.deq @@ -0,0 +1,27 @@ +# Variant 1 — ``@REPROPAGATE``. +# +# The COMPOSE block declares Bell-pair teleportation as a composition +# of preparation + Bell measurement. No explicit CONDITIONAL is +# written. The ``@REPROPAGATE`` decorator switches the COMPOSE build +# pipeline from matrix composition of sub-gadgets to flat-circuit +# Heisenberg analysis on the inlined body: the transversal CNOT +# carries the Bell measurement outcomes m_XX, m_ZZ through to the +# output observable, so the natural propagation already encodes the +# teleportation Pauli frame correction — no CONDITIONAL needed. + +IMPORT "00_teleportation_library.deq" + +@REPROPAGATE +COMPOSE TeleportRepropagate { + INPUT SurfaceCode 0 + PrepareBell 1 2 + MeasureBell 0 1 + OUTPUT SurfaceCode 2 +} + +PROGRAM TeleportRepropagateMemoryZ { + PrepareZ 0 + TeleportRepropagate 0 + MeasureZ 0 + ASSERT_EQ rec[-1] 0 +} diff --git a/deq/documents/tutorial/examples/conditional-correction/02_teleport_compose_conditional.deq b/deq/documents/tutorial/examples/conditional-correction/02_teleport_compose_conditional.deq new file mode 100644 index 00000000..1fd78f6d --- /dev/null +++ b/deq/documents/tutorial/examples/conditional-correction/02_teleport_compose_conditional.deq @@ -0,0 +1,34 @@ +# Variant 2 — COMPOSE-level CONDITIONAL. +# +# Same Bell-pair teleportation, but the Pauli frame correction is +# expressed as an explicit pair of ``CONDITIONAL`` statements at the +# logical level: +# +# CONDITIONAL rec[-2] Z0 2 # if m_XX = 1, apply Z to output patch +# CONDITIONAL rec[-1] X0 2 # if m_ZZ = 1, apply X to output patch +# +# No ``@REPROPAGATE`` decorator is needed. The transpiler injects a +# synthesized identity-host gadget carrying a +# ``remote_conditional_correction`` modifier for each statement; the +# canonicalizer folds the readout's measurement set into the affected +# output observable's measurement deps, yielding the same +# ``correction_propagation`` / ``physical_correction`` matrices as +# ``TeleportRepropagate``. + +IMPORT "00_teleportation_library.deq" + +COMPOSE TeleportConditional { + INPUT SurfaceCode 0 + PrepareBell 1 2 + MeasureBell 0 1 + CONDITIONAL rec[-2] Z0 2 + CONDITIONAL rec[-1] X0 2 + OUTPUT SurfaceCode 2 +} + +PROGRAM TeleportConditionalMemoryZ { + PrepareZ 0 + TeleportConditional 0 + MeasureZ 0 + ASSERT_EQ rec[-1] 0 +} diff --git a/deq/documents/tutorial/examples/conditional-correction/03_teleport_program_conditional.deq b/deq/documents/tutorial/examples/conditional-correction/03_teleport_program_conditional.deq new file mode 100644 index 00000000..2dcb3c86 --- /dev/null +++ b/deq/documents/tutorial/examples/conditional-correction/03_teleport_program_conditional.deq @@ -0,0 +1,25 @@ +# Variant 3 — PROGRAM-level CONDITIONAL. +# +# The same two CONDITIONAL statements as variant 2, but written +# directly inside the PROGRAM body rather than inside a wrapping +# COMPOSE. Structurally identical to the COMPOSE-level pathway: +# the program compiler invokes +# :func:`emit_conditional_correction_instruction` (see +# ``deq/cli/jit.py``), which synthesizes the same identity-host +# gadget the COMPOSE pathway emits. +# +# This is convenient when the conditional fix-up is a one-off — there +# is no benefit to wrapping it in a re-usable COMPOSE if it only +# applies once in a specific program. + +IMPORT "00_teleportation_library.deq" + +PROGRAM TeleportProgramConditionalMemoryZ { + PrepareZ 0 + PrepareBell 1 2 + MeasureBell 0 1 + CONDITIONAL rec[-2] Z0 2 + CONDITIONAL rec[-1] X0 2 + MeasureZ 2 + ASSERT_EQ rec[-1] 0 +} From b6d13d11253bff811eb8b7d37ee733433b6a57b0 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 6 Jul 2026 16:50:58 -0700 Subject: [PATCH 012/157] add figures --- deq/documents/tutorial/README.md | 1 + .../teleport_timeline.png | Bin 0 -> 41595 bytes .../teleport_timeline.py | 73 +++++ .../lattice-surgery/mzz_stabilizer_flow.png | Bin 0 -> 69588 bytes .../lattice-surgery/stabilizer_flow.py | 264 ++++++++++++++++++ 5 files changed, 338 insertions(+) create mode 100644 deq/documents/tutorial/examples/conditional-correction/teleport_timeline.png create mode 100644 deq/documents/tutorial/examples/conditional-correction/teleport_timeline.py create mode 100644 deq/documents/tutorial/examples/lattice-surgery/mzz_stabilizer_flow.png create mode 100644 deq/documents/tutorial/examples/lattice-surgery/stabilizer_flow.py diff --git a/deq/documents/tutorial/README.md b/deq/documents/tutorial/README.md index a9599403..b1bb4dc4 100644 --- a/deq/documents/tutorial/README.md +++ b/deq/documents/tutorial/README.md @@ -117,6 +117,7 @@ Once you become comfortable with the basics, let's look at some advanced topics: - [Floquet codes and dynamically generated logical qubits](chapters/floquet-code.md) - [Logical Teleportation in COMPOSE: the `@REPROPAGATE` Decorator](chapters/compose-repropagate.md) - [Conditional Pauli Corrections: the `CONDITIONAL` Statement](chapters/conditional-correction.md) + - [Lattice Surgery: The Joint-$\bar Z$ Measurement](chapters/lattice-surgery.md) - [Parametrization with Mako](chapters/mako-parametrization.md) - [Plug in your own decoder in Python](chapters/python-decoder.md) - [Driving the runtime from Python](chapters/python-runtime.md) diff --git a/deq/documents/tutorial/examples/conditional-correction/teleport_timeline.png b/deq/documents/tutorial/examples/conditional-correction/teleport_timeline.png new file mode 100644 index 0000000000000000000000000000000000000000..17b056eafac47c6a1d4b80782cc894e6808fd2a1 GIT binary patch literal 41595 zcmeFZXH=9~w>4S@q(#M6L4pJo5RfDyAX!m?0?8Q(3IdWPOGa&>$x;%OoO8|@6cHs# zmJE_J5``k%wXxrR`<(lJXPj}z_w&|h+lGd!=h=JhwdR_0uE$GGM*I{JB@qULIfa$D ztAN1}ieoT`8Gkzl-x(W`UxL5*ZSJYrC|VfU*gvw;$4Ec2d1`85V``*((N5pW+Q`D3 zi=C5`{Tl1VCpI=ut@$}P%>Mofb_***j^O%43wW2~PbJi?F&MJ5=+7a;=yv$5*eUGY z+e*(O<_GOOO@AEklOUh8|75){}fr7Cw*2 zJ>Uxddd2vzatZwF3;28aC@%irUpgQDBLDBNgrAQy{rjuvTUI20f8{QFkq<4~uU8G@ zYbt_&eSsM{C*gni@2{4R(~BSa`>UV-|HmDy#s9yU2g~q3hRPq9{394l%#FsTCM7$& zqMhAcQgU*&X)BDo{pZi0UpE^xt~zVW@nK?2KQ}ZesHlX{Gt<+59Dm}Sl+(kcKg#WN zuJ&3`R1`awh?qE}PM4GKG6v&(nhv5dQPRL5slB~DVz05KW#gHk%l2AemvL9k^CLb= z#F(d91(8cm8}rQRU1|Lm)s+$x7|c?k>#pPa(y&fjTN}~IlR;g^!Cl6Mj3+DoSm3i3 zxSigt&-L^VFL9f6zojO|V1&H_<1y|E6N}|b-33M+E3BtapH?0F2dsQ>Z{9Q$lVapfe);mH zv}Lou$gJbeX(4(nzsq*P_U2Od2{QJ#7nm?HP3mPfsq{wUp975t%L--b30;10!wzeD zLz<~E`Qwj2Qsm>s!fsl|?Gmnz)d$Rl>bP?9UcSo^ROD8uv$goponoK6!~%i%q>lv*MHrm$o&;AVk%?Nx1fq zOw0OR*WIzi+$l0W8EV7&t1Tw4HQlHkn9cW3VK4&$=$2Wu9ObD6lBB~8wgaXzH41SMq0?>g4A^v)Ooibp7gB2KO!&dB6!|;x z;sM>cdi9#-dJtk|yBqz=g~rMR1O%yO{YAvY1mJ3I8-SG_=D$uXQTQrpiR}6A=+9sHwfps@%}nUF(oFe`b&QS@t1f z>D<|~YadQNf zmO;yUYUB0W10ofOWvGKo%^cF9ny?LTGSsVya?t znrgQ8#?q`xy0WUaL*l0Z+U$J8*7MIy?F(|v`gu-KT)#`oqM~%`mitxJtPHoEG1|T= zm-2p{)tQduby$^j^?Ujy-dITIvz{EkF!e@O80C*L+qu4e$M0T@O2y_{ z5XNvZvmd?AmCYoDKQ7ehs&;crUJzVu;@Tj$&a!9cwHlXD&r~Ti>1lc71S#q~n+Dn> zB~>lk;53y?vKN7lnV094Ry(?9(2~Q0J;#Z4pRvBZK(g z*TZ1syiZ=>X?p!w;<|#O;%Wx3-MpGp<*5rih$P|6Z^!$?=+x|lzek@I?&s@TJaF_*q9udZ7E$#wbi2eo_y z>E~w7u;5}4QI&opBM;*t;x@_*TOxAnBUhJJ^ny8bhTe*}Rdwi9;up&|9ASgFDS6E! z3g47FcaU^saAo(YLlKU5Bjn02NrB_wGd)YB(5)3ZfcJ~cHn;T;zdN1 zz*5DkOzpLkbex=R)7_cj9o89;Mt=3UFGg?1_an(9OLmXaQ?$IZ|^d8Q*Wp zqxUuDc4=S9P9ZUkV9DpDv>wW zF@!C0vD&!n>fp06zXqtoKIhqPxb8f&T^iDwXbkQ7F;>s5_vxh2{?6+5?&h#|G-JTe4mgV;gx&;5`W&oFFq5|)z!VB`|&q~O#CRh z6(C|re*ZlNE&cY|3>;pZ-Qqy%r8^!^>byxyH}+@Kv)2}ihb3`PDibZoKBcK^B}v8}|Cr9VOU*-ReoLa=RIX zj$WN0BZ1;~{M?o4kO$`Su;fMNgK2ONCa}!gaJopLhB7NAjc>Uc8XC^g?*H`s@ZrM^ z`$bKN+rikS*|vDk1fSh~NX}GZZfJ`k+u;&oVlF~jl_fk)gFbKGc4Kic9S$>JK`=@9xo#Vwh&z7#co?5`68e(h=(05{24fJ8PmZiJmm&^jU~IRk&W;Mz-QCAb4P{P6Imrw=uS!>OS`3uP1KusRFd43@JkP2Y z18Bjh*5j1p$`?kI1DI~yC`hq5<$j%=m6f$MkH@U<$=Y-qC7)G7#|nV3gZl!=9?GtD z!)?!*fRM1i5k5aHIm%HIa$iTx?Uy&4HcZ)csx%`7?CtP#PrF_aoUj>o*_^4|9Ll#y zS4i|0-d$77d{8U{k<@Ag$4;#^uwl2M2hDY*`AIsdpZi|>_x%D_lG?% zD1>vHBrR3%54qB{cBU%YLQ+hDt&r(Q_w(ZA%RD}$&d?Cop>DYv1aV9=`Y+HC*B5>O6}I) z45o&wVv|P;bL$-$71jLmQoDd@u~~moqEzT)sY)+YZCDs~Y`+$Fga}w88_II2}`0CK~6w z7^7a(H^1W&H!?DE_~Z=Xz(+4;t(K{R58#7pqWAceyYqy7)o_`uN!1iS+I1TX2`QK! zLdJ>Mt48V+)lE6rp4X4}wuX!g`K5(5SkI&Hwb#AFi20I_z9w(BTqx}6>(SAIzUj`p zm`0APb~36Q_oR2__s)96zb5+z@mU%*JdNl2h~!p*^HY_(Pi1~~ns=`APDDq3~0 z?GuUs2yAS7iRBp8*?2zoU1Dvj`T*JjsG(P2P#VmAZ8c=XsdHoyfK7O2za#{==uMjEM zbj_w?X_Nsn)f)kxD=CRqr0K!i-jUM z>%&nc=SfU$Gg9+h6VI-ebF01X%yp;$&=`e4I$qene*HSMMX>DOmE#MRZ+iI*A^m#{6TFG7de z2b&VbIbq*p9Y7;AFCw!8>*mmX#C?4Haq<)%P&yMozXq2<)7LIl9kb}`OA35bc$TaZ zQAewn;0Fk5n%#6njckjNr zB2AC2-rG(e0&wSSQEV-;pF7pt{2&hwOi;*HR?#}1FQk` zgD~DElh3Nf%=0wuM%b#0HgB$kzv9Vqg(WCX#QlR?uJhk=??gMVc)9NH+Aj=MK7b}( zW-gM?`btj`A(DY$sHYhUWfawX(Qdr5e@kG8(9ZsI{Ppbd>G=h9l?P!^r43Y42H1o*s~J3CRa zl|<0V*|}nG4{x!xwUr9V09s2$=sx|a1(IMb5iN5Dg5XZhu1b9gdT=TwbC z{8TLT+(LGW_D)i z(Q9GR#xS8e+m$R!i}F$R&WaB#$;35f6Zw|Stf+lSNjW*WDE-h+laiHCMx3Tz*ej`0R(SR>{-bD1dhjlF&hg zs?h`OkKO>+fS@g-_ZpLmHEo3SkX=M2@%@mrt(%!_D>RxOD+S1(Z&Q|J)Av$9Dc3r)v2H zUUQ{rVHYduRyQLNqx{~Wckh_7GWZ*+I^r$+^4R@-i0JQn8d@0lo`%w~?rXl99Zepd zo>FBuo97c1F_W7Xtx-ZWZl^-8GChC$_U#k5h}CWO(Bad2p^N#>TUMdNp%qECtoNH7 z>P}#?o=!NUj2+r)66!y~Y%=h})fC|TX~AW8gsk)QrCfH`d-|%~s!=+6_s(e8;`8fE zTc6qsj1&>vLB}JASrLKk-sJB>z&L3?8zoo=AoqCaH_X#FQq}b|{5GE_tWBVf4fc7k z@JddWtd+%mSp1HPuqzkdY`&D^T9Ig_I|1jN2CI;c*|JhTmh7H=%)=J|PA}LJLeftdBaO zIBr*Fm@ttWD;RMFK*@B!v=1vaufORJooluaA+ zd9-266&isp|BYo*K>AfXD^gI{SuS4|g?(%rdH+(m+-^aWPTsmZmt6wR&<*FGPo*RI zl_AM>7n$`VN(I|q;jn6zNll1J^b)ALQy%XpRV`b-O-cEXc!Cq*foa zC-P%!D%$NTA75|O&kAU-t-7`Vng;_%0(G3JMx^a5xRu;y@`b0qRz|!|EjQ1ny9;Sj0wVt?TUU?5qK$nxa{1#cw_7 z3zR`JYG5w#n0^J0GwAi}7&wJSqaR*5j=my8o9Mh$v+>7JrIQJ?AhR9G0V)}4u}~f6 z;G~6Jd-MV!d@R|y_pO4f_X7hg0ezzO0+$e~?TCdq?}lz60XlvbW@apYa|v~7YtVui z)wmzp7@M4!47X5$u7ulRX77kx!$>2OwuMxCo|=Tz7LxdGcy(N|y;PwTeR z<{LV52GWI9_aSLXNri0fN`yosg zNBn~0Qst%%Bx@6>a}4dD{y2UPD9&IU4y9G2h9KsRr4ba{2tXj%=k?)(Bk(h%XU@bz zPlzz1MfGkG0R9YEbrX0$vq%8)bb4U})eO-!#MJx=A3xsB)T!1+tvEF4@jwN3u;uca52(R+g6Um=(JY$? zc~AhY9RrX6z^OaVB!=EF{dR586~AE4M}xsIl^uvs7dfCo-wGilAeaFvt%KTrV6wie=apqZsg@^6qYwbDmG&q2m=nh5r@ zIZEgOx-QtrEJz2v;*a^Pq5GKZA9CAUrA4a&U6_5=b*M^6q}l&jFXBzbff;_f)u$7B zf{3UcmUm@`GgrSc1Tk;uBB*aZy@k%0%MC9sgXkqG4bUv+vdU!;?=8GFelAn1yxUb# z+8>yk-OcL#X8UnPh^7#Bt+1ueD$qX;m=jQ)zOx*`SO>AF$^hBd4!0T$=LLwPs&-W! zmtezPKw`lX7ZCD9s={1mF<>i2)Z5>208z_zcU@HoZ(>bPzty6*fCHv>8Ym>H-N@)Hfg%9$G6wu=Mm zNL>I{!fJsQ+AE;8lkG2;D`=}hI_-iS&J7(t0Hn>0ef`7Dis3yp%!vff`%wfEjJFOQvjpskVj--3zQx|PJrD=Lv#!@oS8tD zJ&Ep~g;e`EQy$~cz~{#Q#=0t7G1(s=e^MZwcp8Atts?s_WmO~Se>YZlp*~JLt%5UO zw(GoPYU9HQXfz^ycXxMTNy=3*g^UErSkMcpAv$RM%bP-@jw||)AE!X+L@dZbU!S3_ z2xum!I;oLOt4s-FhkX+9 zoXE(17n+j7v`oEfJ~8=_)gzc>q#UNt6s$N|Yo;L>DY?i}kLweF1c37G{HG z$`%zrJP?OF*H zC-4fk8}sVWQ>Vb&mq8urN|Yjl!`biJ)M%K(G(YznoHYn^R7;*f65GZvRWoSaHw_@? z6a|L7=ab(Bt=YLlm|I_R$@O=Oj4&9%>0bhknBaO^?e*|+0oUEC8{P^)W<(Y9G=&_fI!apLFyyjeq(D4Jg z9R2Hm=e)qSLq#h9k|@9S;Fr#xduRv$@$3z7G3U$upzE?-=u^fO{9=n(fZv3zWwXMA z(xjX_W~B^z7qCnW*z-Phdi4bE?J@M*htj(==6QsAPuJvw7J_&pI0{CPH2I&7xFeFc zsp&q2M=T&Fkg(9v&&|zEgJ1=L4Ft^(I*S(lMbi6wo7KUQk(o%<0!YOAT=i&;>6$*= zWKSUAq?*T&GI<655Ty`uED#Hz0Rq}xe8+Y#gP!^NHHkveMpCu?TMYz}jZLrV3sZhq zkIOy8RDQDdD}ZLin;}9F$RUEoeCsJ0{8&xREpCe;O(;)F`3BA7Q>x1e@_b%xKxkmw zAt14@kxPZ?%`dl#!;^B;=k!Jo3t86g%@GrHyXp2{X%=?9iwm;sh&k(b>Q?WYkdP#i zy`OhbN_+p3&}TpfSaPH}0JcZUYT07hTqji5SFc|2Wx6@&D&YW+tbx|?%N!#&dGPo0 zW#&|Me%mX`Q@HCSB-6<$MDNo`^n;^m6{(K6^Y&Lbq^|Aff9tmIA-Y#~LZ2fG&7DpPH*bd=NlHqZ zh{!PE^$rllntmh0xL-G1T&Z>3+dZXG&9)SHx12btr}kX;^o)Iv(e%3Q`X@RwEbg#7 z**8cCLm;dOiu#?Yj@k6I@f_!}l*Vd!FLfpzI779;3it1Ev^9{t5PPk!ug_9PL_*{O z9+qVT&8>@}u&;f|YTyMbl8tmK1?Plx*bS`P&JWM-%m;?UL%=iy)DdBBsq2w@L^)Yh zHY(0c8rE(cZPvDtd+F4}?5Zcc=xmU*$M05D0e;WV#cuZ-6Ep&DjNv z)D6ZRsT+I_Yt!<8#%hduWAAvPRs(v%&~HDG4KAhtjB|wV-n~O@$9yY55dCDYHmdVJ z_W*sfDOuS-mw;*vG&otH4`DTx;%0_?P>0kt5 z&OHy&+(m*G4^gkMF5J@PQwD!PgKZ2Ncf&p2`%w9)MNfk>u{4>U% zMkozFz~l>#!Q00tpLP{w(Kdi>NE%+~&9Be2zi@?~J_V!;bCc5;pk|ckdDwS3*MUE^ zX*}%>Wrp4E0u#aE_(U;~tnz7Pw^V2YmPR-1nw2hHluO31@d8ST)vnz3Raw8deQ5l* zQ>VIsuF*^@e>(a>vtx$%0?&PUc?RAzk?N1rpm`t-8W7kV@YcDZU@Dt~blVM>UrSg9 zXwzWahf|d9ys;cW&klWul#j4*f4@HGnQu_Mx?Fp75DHKBH<^1wS}-&X+yWBCC2&{n zzkMj{D@o=i_g2`Y3|vDtz*aU#@a5m+fRnW}Nvk2ca9)s_6%+ILn<)>QX35t_4`5Zh zpnW+jki_C8k45SrvNL(|H->UB_99RfbDxfns2m#iep=N`jjlu&z&6nQ@#3uwzqEms z2c&^lo}MPlBkxNaSb*sWH!s8Dpk&N{@HmCJ)%)nNgK9a({V=o;ZI5O(u$0#xx<5#Y zvH8^X@rgfNxxjtJ;E<3I6Eh|FEDV#Bg#U-d_7irhJ(cf-oQjC4ZeGOZsj-$ps z2Yx5Phit5+n=bfqwF!SRJNO8h@h|KPPhgn7?rg16M?s4#4oogvpdKj?=T$Au+GO~O zxPP)WnxpobB)u5=kZTuA(1#QXZ-Ob{;V$&RLMxx?Hm2Yw0lPoTas=8CM-OF#qqWGY z0q9`_fbWTkl~LKd0x1V{7jtm|0R~q9?Wt~YU|3ihhzVGn$4QFsA%!78jCJW2A6o4- z_24Jg$*2?`2yb#;t#h)O^3$KQ_=v+@7FsUpeH26lOB8ZOLsaoP9T`N-=cXovwO2FW zL_}l&(&jUr>&-Xx`luN6aXOJx%Q1cy`Wc&_%a4Gx%C8w)9IEVRGdXp5nQHqw6hAh& zE~x}9TzpQtR(a8`CA6*QIkYo?UAmbW4yX)-lv$`#8)hv@oxuqpcwrm%1Aew&x4$C3 zyR#$tI9D4}#bQ|CY>66=_PPtu_lkNC(oB`*jqUY^&yF598R*vD%DFWtQ1a@7Xk+*3 z9{vk<*QkXtPm-sbp#}3kqriV~k6HqhZn(cMrKPsb-OCMknU` z70?NF(Nr*%G!`335B=km!@N}W-KkDO_q?n0^ml1p)&ikK22plrS4FwnM=EsW4R&Y` zZ|k#j5?338mIeUK~jaA17g*9(5jJ**nWK? zn5wz-Cx{G9tKUM0Y!?=30e&uD37mMzz2>-?d5_mXE~3BhFovXMf=*mdPj9fVO5kj~ zL)~P90t*Vw5kGP$ejJr+I0SU?)GcJ}nqQmVaaniGLHh~j*YRtl7Qi}06scD^J7C+l zR;N}47ZdgQ)(>Hpl`RC@Rao};6BqC;r0J1f0fow%k-rsx98V7m4R}r8MPzH%*Z1wK z)rJdZWaX$H2yz46MFQvITXY4WL4jkvvs02x30HBe_k%Jql@b^%Aq(_y_h^n4Q~-QO z##6*zfCkoIRSAy4G_V={Vh&gQLZuR8r2j>K@_ z+qcup6#%8ojd}*X(2pnF$aVD!&mCP{Y$^bz~3bGlZY(Pfr zjJbV8%et9+6?R;Okn$t65d>r$ET>%w8X#Xy;B8-yp(# z4TS`2anFjx!5HE;N^wFmi&5MHd|Al#giK7|>wLPPRlPP zgS4x%ZL`LwBYC^%IEgUAAHV?th7BCMjM0q$$Ab}!pJ%gTp0^aY(qU2N(k5o_CS)wsC&%_|lSv-A#1SBgzu~ zaT4s23dJC7Km{ULgvOh8VX#~kpj|gyHnOaI`=*8tJ}@ULSdW87Q{THz_Yzzpyzap` z$i45gj`5)<;LgBPq<;3&A@`mfHP#L8O3l*JqZqlOuRz#WQ2z_J$39M|XI@PYAVsHsiVcb32{_)}W(@(&6fSouf^hYqxCw75y=x;lS>rbEm zZ_KPGXkX)&SsG{6cXQXs$+(6%si(sQh{#VKDbaMId+D{c4;z zGojZ1>-8UIL)<+R6BCU9gis1T%K?jd6EPuXy%>&SUj8x#^^fP(*YJS|i|fdgRCtY(egpX=nE{$h(_kz<&j=l>amFvIcp>;Lm; z)X}$~jAE*oz%V`kI|g$l?%yAuiKwTCF95K-qdttOx%-a|CGD6!SgnK4|GzXt_1iTx z`Q_{f?inCPp8OwT^zVNBzs0Df3EAI^f%(rdnv|4=40T{&f&$t6A7k|4i-Lk1`FAjw zw|}}q8d^9gbj7*{A@tYV|F`#ksG$|}+sWG3>O&VleRFuKBt?^ z!)4KXIa25k?(wM~s_7-{+yXc z<(5rgpM-Q{x3?c-q3#$~XlsLi&{9v!kAfuG;8ffI72B_09&!2g(T7ELT_rZFDn#pL zlS?w2$Aj*>{9GVLe@@Mkrf){g#(2xC6c<9eMz3cV&L=?RUu*0AXfwFArm#+3T$Ab5 z*61Q&Q607xowzi$#F4A_>t`{E)PHA>l9HQvy`NbQEdEb^t~N^DV0KLHFzmRooLfiv zjY}oE>MNG?gL(kTwIlz?{Qu4_ziQBx@E;xKr`%ji^YTvx(BjLt@P$)yajy8B(5v^j zn%PH;{&7Gl4g%)cw{cp#*%rf)cR2FqTubp6WheLVF`iS9_W3I@E#EkpucTRPw!qu8 zO1ZtI_MkVKdcHc#z0Dgbb)Q!N^3Spe@IeNi=$?X%(74rd4jd9h6hgPG2nD})wB{wx z5`Vlvl=jtl@*`qw=b5iu|2V2+8RF1auc1lkV(T$ytP5Hz!iq1p)Jb6bADD+KJ6ac^FtfHu*{UqulF z0mF9_F0A&3fXwo;PG1GTJ5sn!zvbNb=vsxcg{F=V33{(?f=c zqH)$M%reuF?qUh$LaTcpNzfpZ8ohr(!0))0(uikZWSj$UZ8tc6*|1l^jaFvAl!drd z!1oRI!&2wsaY0~w@jAyiJChu(0+g!Ho-W#*aZxK0rn)_+os^9v`gG-25}~;G3oX%I zzI252SXu5rq0z%6ew@@YTwAGkdq*u+)xxMhJrt@soot`|Ch_L#8r0}S91gcOXg_QO zu6{{bjy=Z#aI+zXA1RTAro9TqgLdJ@-5KxFhbDj+1ieNDe9b=K;enSpL`TiN8H(Pp z!e#fpzIQSCUKDw~C+~%vR*C;cL^)9I|3OqM`uYiX3x|!t^|~mTo^!71c5011(Q=_Y z%FV<;B?oK+Hbm~|2}wErb3oJpFeW&9lnE$#kWRL)C<5i}aq0pbh%1ARbD3zw0nHpz zy;0RI08-Tv*ep^?N{tFK1S~I-;o_O(U-Mtd=b zf{Y3Mugpy8do~f*>eD)xN20+wLwW`VBY0m85Y?k>!Als2WK@W7Z$Cd}Ff=2a%fWl{ z4zjy~!=%6s2Btvo6|i4y>XL!0t=ipq0BX1j@W+AG)goR9|J59&7Z1z^UBej8$OgBf z)jWV<)F1+Nn2n~*z_;83B-;X+Euifibec7ppr4q$;hibnn49zRPD)1=Dz*-Fw9m`A zU-xmvTpLC-9WdZmfuvMb2i+WTk07g(l4dc z5d{H{S)&PdWy>&fHZbr*$R~j%vV1|n8D?wDt9R`R&H7c4`4v|DdjXA?{Id(cAftr; zRIiF36l}lM-Wt!=E#?}V5}LJB zR`#17j`?}}(6XePIiH!`Z};+`!n8!lsw%yDn1AOGrX~}vV(JDbVr~#`2d1;Dfny+1 zsBR9sRY$q)Ip7O761Ugq#C0LJVU~Z##ede{`S}9?-CO!pV9~<_?6Y9nC88U+)Iv0p z0)sH$iUX-^t6wsc-LM*qCg|>ex|cy1$BZA^HBNufPw8syA{KC1*m$t}S^gcSyi@&S zwr2QQ15fD2`XQ-7YBTzY$WFpgmyLo*P2~~pb zpeoD{SL*<&48sOnTj-oRToJRjfzg}9am3~#7X+H^KsHm*g^(Wz4M{9I9nx14HSjQ? z;(KUEE$BzxuC0 zs#h;zK1l-VFfIWy26FuqES@`eZfv>4aeY7}!EXMWJd0{pQz0@o*8N%ZkSARF`0>U4 zM}1ISh|DdxEebOy)=Xr zGOc0h*@bSNM^RwaAuacla%;kak7RH~Po*WrE$DKrZ91}RA!B5XxgSUCPLVP3;l^UA7jgf0%6crWV`@9jLcyASCkB|8e&0S>#p&zf0^#U0@e5&XM@~xDO_Xcit<3vU*jurI5Z2nD&K$ zRY`yCFmeLPLY%}gwH<~Nn{gQ7`tKHYN9)EHejLF(Z3EH{Wa9bOmoHxeKV={M?T04T zcd`V0H%is6E}o82Z}pKvvaC-p{>c9O$K&|1>FFn-8+_n_hH`7@rv0r@-d%yQU&ti! zW{$puSO~tKK(35JTX>?Rxc)T8Js~556(s1-j~>7>Nt*HxexMg)_@bMOMvaL3i!6q- zz(5l!;S8UFf%%Bg5*t6<_~V}L6bCR7g8s>l1%p%sc#Y&P=aPcYr~pCO4Ldew=Jq22 z<&|0%1%|EW6tVh-ec$#1GQN6CA2&D4H(38I^TGGM>N{^C2~*dZBZU(Djc@3YXHg}+ z@S7QomZ#U5A6YE4tXE$zvv8=gg=3Yf+rt5Jyv?f-O%q?9S$yT$mgMpQu@9v?Jr{x# zh!^^wv|Me`s+pK3eCQF>Y2)&A}#*) z1tMBcMoul9Q30Cqrk=dh_Z= z9iTY@3%Xfxy<+Fbl!KrLGhZ0US}y%iue5~iVEW+23pEz1tOHK3D$7Pc?_nC;L_e*e zzW&U?7~*1{ej{>Q2xRo*dK}~3c}pv0weI~Z=H|0lS{T^hcc=Gv81IMU@dPrr z`PP|3iHfh5FLtG?Usv345S{zP39VC*`q~lqDX2Rz$_}FPpsVk9m<=m{$z|1WBcsL2 zAIG0E{;Pa{$bvvZ2zz%6W}s5Q!29J9Q5<-OW}g$%$icRN>wO|e=ER&|9P4j|$DaIb zULWk%t~M`JikIxAP(Km#RD+lOU8;!S`*1x%%q?7Ae!jKeYac&9+d0mGK=R-O;0*Yh zK0YS*=oi~WZQ#?pRwTp+rCTq&If+dB*=-orL8uNMegKosX)vHB3(Ot3%ZL>zIIT$Z z>y8xj%5UX-&+a`(K9XGT$;(o5+5LLDZG^pl)yb2~iuv2}1gb|dF(d+3<9`CBL0tsn zc=cur-@bht4J_6cfjz5~K?a>Je~=yDgPUgUn_d8)X+8|fz?_7OWHeY~5NHPl zq1XagV>vh|LNpfYo5o|4VFTZ<3iaf8Nv#tqWyXGC%W5A#ubOIgt)q`b+0^f8bC^x$ z;lr$T##8xZ0;yW7lIGq-`dDaH5srd+22L#vik?DgWdIbojb@gsH$M;0UpqjG%f8V0 z@e2lc0it?V(%)wXR*w3KcnwH`Owzxw;0fTVf`s8g4`>=J6WTCis9azl41vLYs5#s) zLgh!nC8nIF)CSY!dc%zD?Ec(WBya7faT8_Pc&VI`Hzs{AL2F-<U*k7nhFJI8)BQct5dTTjdV&!Y3q&)pik_0zsms_7_bUjxcz+VwQay z&+(96ReCB_&H#IBRUaA^r9Dpm94B_5&I7M|6FtQN4PXO!KpG3ohmih0@^dN=+8w-J zQs+H&8^z+VZI_01N_=*(MlttLq6=krR!lLiMfOl6QRug0ay;s+=l!TjlAW|Q9@#Om z`*@vfbDtY1r?~+0!GHStc7l$c3I`9Yn$zAaSxk1};5uav{Fq{6`Pc+HClYe<6kxK7 zKO&C)A}=69RT$;Hs**8XFyB7@1Ozofk~1Ufs@$0=g*xjUeQe3=?|J*zy9O#HTMPI) ze{AMYPtp|LuzMO&R-Ad&C-GUwkHbt4JT!Gw1EjYVy}rC7P-Q0&8*F!H2D(*W6jZ1| zS2_!Dt0Gf9^zZ$}rF0N@$Y%$GKRy>O!)3T1&Y5KL_6>|*Wuo2dHPU)N~bmrQknal0V~_(IOiN;m)JL1)fTSrgQeT!x-EKfK7oj*0pQk^cZGO=x6(qdqHxR|A~1$}o>gf9EhJ`%$3=x0~w> z_3b}#Tr;_L8Y@$l3nF1k<2-*AH!Cw1|IklG-NC2geY%cedj6@7oIHIia0m6Eu;jL& z!6Polz!TimswuzIBoIWesRX}`%T_i;wc>`Sn}__`+fT&)9dGEu$twS%xoUh~!=>F7 z7f%O#jM5LlEJOffvgJ3uS2TWNX_fui9ba^6c?@gnED3krMkKBrS{ir-pl6Bwk~q+q zI9Na1+r4?IuU06&aSYtn>+?%fW@{=Sr>Tnfx)QL&Dk5?=-aYxBH;{4g`ylT?Xa0@0 zmKSP+_FUC$GG6CFs)cL+Qtp_T@^9VcvUXn=dU_sxWcsgC(*etW#S0EykHy;8&2vsE zx{oEiXtQ-T!}|E~=TDW!vL7_naHT)#57@jbzBt1)_TW0++vqdh(6x+n1)O_w@%pqq`}R8Cf>q+x+Zu0a`DuZVVks8 z_SbLkEz62rY`f2+CJJ6qGQgaoluEAOb&f8pQ?EA6Xs97xqhRuFw}lVVZ6(WIzgw!Z z&HhalDxh)4O|h4bb!jSvPfy>2=L@0NGs1&wO?+Kl9i6r0{0jY{|6;&Z01ao+qEI^6 zn-Y`sSuWOGHhJoPRm>wrS+n4B17SE7_pjfVD?4!H9tF$*1(suM%257gA8BWd9Rc*I zBgg(hg_3?j;#QM!ArN@_=ulld{tuFM(hxb1|MeRA508U;rUo;MG_F4%A>me177W1u zUBCOa!SD>0OqdheBgc$9n15z^Lr+{~RtY%8@8FXRG zoG^7e3lF*w_qV;oEp6etQdnD5zgQ|gk#T7c3WX}v$%m>>dr@#mL8h$=`py++`mkp@YVtP-7h|QDLB!)zJTAAt?}>{jb-5*0Y3! zw9pi8l=BJ}j8)&7r-`UC0 z5C4xMl&E_zT=|Fz7;BKH9IuCQw}}0>(A5mSk6$M|6&j=2zQXkXC}N^G`q%5fJ$$ex z3~ub#E8Fm~p_E}O_6yl#&611o%plLBIulDAQfhpuntaUHMbC@0`YLjn7WqU!KSB1G zUgvdQzL1sXMKTc)X#Ns&_Fax0mQ>}>v$=;FDD-mo@trrN2rq6Z(hc@iHc3pN&%bk+ z>Yn(QrCGZ?8&eLc_+#d>L6khxqdc|X%L6gXH|=vCy-*lA}LVtX8%t&O)7Qcvj@lc;BB)XB_uK9>v|rW_5)71 zHA){5xZufm-9P&EzTTL3#6|AB{9vIOA&N%TGQL|ee=smKzO9z%^(1a|+fhtnkiZ^j z#ICNcvoHm@%ZaHe{+B@CbXS)||9P&JU*7PJY~a}hdNKL)d`38@V6%+eZS4hfh z5o}gxOmSfkZ1oY{g_&v$)#?4Eomp(vt+(8(S=q8Heo{OcQX)DX%5XE~x3(j7hK~)f zU%p(oTMRL)OY`A9l#s-iU9MYbV+woYJa^ zn$F(o;iZaeY+9eTtBFi&zm=#AQN5DiR6&|9n^)y+(NZEvFV7O>oc8Xx4;Y)jww58|~!4C@g9<1xL zH`%yUCVp&ciT*pI!5byZ2n;UHYWOi+g2_Qdhd>uhp9&9`Sy|N+JR6b!6W`cgmFc_X zV?`F_%~A4PQ20oQof%9%8@~YFEApnLC=5g45Rznphc^bPjZm{v_cwma=jhp}X!*hL z>Th8<#zsoUuB=xfJ*l7A5{Mg^8&Jc+vje!%BgTv>0fx@KGmL5jW>lZt!vzmSGorYQ zo=I}mUb7KquARRo`HE=tHh5jwy9zrqF4caqquUaFj-OPnSC}IT^WkdxPWOuxk-BOIszsz6k_;NdE}lKd=ub-7P6^QDhCR$XMhW%YyZqExfsp7t9K z@QQ|tNY#N)>aQdqXH3Bp9u%@zajkQrPlX>{J@)W8b|~kbB+#z2WrVY53O6glUQv@o7f-;b&u~(DXSddB}tY z8jGN#^E1AE!6Yr76+9hBjEuHf$ZOQdg2NG6oxse|nSlL&+I#D$s`I~l^f=boqhca5 zDjW(>pw7Oj9tOG_JrG)Q+S-Q9Kf8}$1;^W10M`|rJL z{XT2W8fG}0Prc*y+ONI$Yy0EM40+H3RP{@jy?4g!KvP**4RWCqO>(0@V?QM_OZbq$O7uU?Tr;p!Kw9y18B zP`$xW{;b!6{FO&Q@`Kefoq(_gW3)u6W#Fc9BGI~gO~3_ z;9q!=U*-4Vr;Mz}!0pLI6l8Y5GhB1A*E+XMIz9BzNRA3J@zF z?XNjMZg`nB55lD7!}S5qPh=0~s*C<{DvKKCvbDA1v2}tzFJ4j0lW&xb9q+NTvYH=_ z)+K>6-?{(T5#Rq4i$=inXZ|6(L}~*0LXDpkDW(U%)De(uU72i60xi>L3i{>;=VDhjFaPo+o%sU4SHN`BD#;qkM)jxX13q=^W!c15UNc@ zQp*>sL=Q(D9}eycarc)j9iJ?!{%pft>hn0sGavxcIq`07gonF!>HTjU5)l+GgeRNq z=X=Yg-((L}J&Y2x5IO6aV6FR&>^Ltk8Vf@aUj zRHQ08_vFvNqEv(H(ZWS|201JXDv`YZqwcSN&cx#T*zwfpvsU9h%0bGpA02|1NollT zQXj=EUEK#+nbiLXM;*`TzLB;OPP_0@g*X1}O@oRl(FC2Uxmz;nt18j_NhBwp4nG# zUi(9bSWk$2YgXXXZ*^Z4|2en(iRZ4dntym^EFJYoVT~L^RBdf-1K2z=g`K&xJ|+M7 z&IcK%I62FLVy9of`${qPaf%36Vh0e@eLdaDWJh&yG^{p1DM=G;Ne?oA+_DRz7{QP_b(xu1%+@ zc)oM2OoH$NZn2cqg&e4^zC4*!SxAcVI6ib*!lFgUx-U1rp}ASK+5ElIRr5FES#f7X zGwKz_4W3oAO(vHHnMUWuFC6Bul+R|3;N*g1B5KHPcQ$jIoHODm9`5n1kcQ)}}% zf_)3dH~C15iyN#Hzhv#P?@j?Rg2Bo{ib8;c72tqIw{xd1hZa=27Xw6&Q&Fe$#Hx2y z@vW=dqGL*tVdh3@-^~APz`u8QhTI#^=5XL1yK8T6cH^3pkV`<7k%(VJlb=&>nAW5! zybweeqYEyk{rZg=83#fL zuz1c`wQz_^a?Yj0YyFl5hs294!THV2G76e~k%3}43F71Vuk1UACuqTmY8QJGOL$*y zWw{crAUdDpzkAce=vOs4na{GDo7>(>asWD000nsfmZ5~(owc}-78lRZX(0n+W$3&t z;kn0(3=9ql9!yZV@)%U)-u~-WgYH7QQr;0zc)oFd_7GX{?^ap9912yE|9SLUO*Lk! zfjXajNn?H4tGW1Lza4TS%3=B{siQAL@{4&3f8BmUdSv3M;K=kf8k3ZmM~}#~+b?XF zxCO$?9<{Oh0{N^W5_;4uYmx~=0Av>8YDtvJxCm+)EY^q|9g;mN4ffA0E(7Pqj$GM& zS7b^ztyY_^u1!Vg=`(*F>)ot?UCu$4i;DhJ7-IiB&6b%pS#T~=wH2d{{WH{IP|B)G zl}HrD6Rn|N)}F8<7_3s3AOZck*KlohYaUU5dLVV?1+;nylWJW=Qg6l6{f z-zI95?(e&rhyn;a6Og$Vvvz(5zk%DJVhL|1P6#lr#8sG&8M-waf5IKFOQ%Ar-_%uKKCB# z7w68M>n1uZR5kj0-6iD-YPyHrBXqp}d(q}sxmCvFO*KD8nT^m(k8lZ=$R|vR4PGQPZ4Gl4HpZ?nRS31@w{u3L|62;g2^r6S6 z2)@~=BFfLtuVm+ZNW#<8lY}NfBIQv-EJ<`f(sHrM_lkN}qXUWzONZpA1V)?QYSGP7 zG@zUCR~G1&=@+71*Hw1S@tbZ{^PB005Z1-kt4BmY%K7wxjy3J)2=EV|1o(cqP-&Rm zuTkv?```o%UL%)2m@10qd+KCJgh#prsL3QH#im6^n~{6)=p!$i6p1YVzbIF&%;Wf+$_# zb%?6pe%Oc~NDZjgr`j@1EeYEkq z;o=yJZQr+h862ORRA@UMGbviR*VlB9tZM1=k8HI@_j7}~F#qy926I}A59@iGtBZ<0 z(4@C{I&T-k8nJ&yV!O1rNt1oh@Va&R7q(c2nOu>lbu{f}GaVc;UU!OU3GaM7ZZ&RijBm&eD4PAV;zTf;L8WIL2i;+AFW2RQePQ8Zmg^Ks_B(cW)$Qt- zSuZY1;ZiQRqu*e$ogpck$K8`Dv|fRO$>A8UmPhq$fc*i1X3oHb*BowqBcA8<`8HXq zRwbKiMe%=*wA|mR_tZPRclfgi&xmBAv7#K!vae$G<%mlcIhw0gEuQVGX!_i7AxbSv zQ#h}q?$N5#xcruu{s3X;TKIsF`?fKaF zwi~BmNp9$p4z}@`!7XihiBIN2UQ}l-pNkS$^UKOP?s+W@7!ky9b(ZrO%$fHKc)jgx z(&C^lk(KuB(=+!H%cC}=D@$a(=a;PD;J;+$?;Ya$?pF)9k*kCm0I&7g)`IFyMFnu8 zO@JfOtc6~A&(5~8>DL;<>@C|4eeV19eojX_I0s9I$NulQ{R_| z^qz$U*!vGNAdA_<{<^OO=D%TGU0r7wZBQnrv9pLjps5$h{Gbg~@qQDo+o|yzl zC{HtXX>eJ;nfsLP_|qclWZm1rNlE?y&9^n%irX`qP|IGUY}u0Qyn2n<7AgJs|3wE$ ze^zr@2T5TCuv+Lr^N!w<<=y+vcs8k@)@JGOmZ#=g?DU`MO^dRIW|=x-`scLP#9WW3 z+B~*U9fLCIXxeGvEo*O&=DiGW9#nd~uQ&Y7N&Bl)*?)YU+%VA_kuuw-bN9d_TjvyB zXNPzYvb#NZ35J!tu-pGPTh4&6!=(d-iS4n!D8)P#@q1?IfN*ZZ9D~r@-@AgRZmZWX z!YkT7USVzZpLgn+^i;UbjPn?Cp1pj9H6=W9G?baT!}PsYY2$+2$XfQ>jcJpt$37&g z{5C(y;IT0NraTS>Hp$bcH`m{%S(vW|Aps2rJ}*p74Y$^T*nrY@dqTAKZc?OKvLR6P z`lkcZ%`x~gpQi!W2>SZ<>zU*yjH04h+f*WVHNCR(yf0{ZaM8+W)7kr?J}$50eqYyF z{-E(oyLaVd`NZ(2jdd&yt%&eU^h05*Ok;1_4U&s|-vod43Rk_aTRv>e1GbiQE{A{d z$?t+Js@ks#b6YRkWh`pHs&XuFExVt&u_-q<=?L57xWE4Tb$WQR_2#XQcj#GVFMT|{ z;5{L!w0hN4_w_U3U((|^mHZ$b>~4R&SjM&`L2=NXhda{LNqY8Xvcz}K;1^vt&UxAN ze_>U#eIMs%WYccbcR{q%jTP$g>5VUbXDa8)5t!@d59wP_pk0bCtxUc84^zp$_rF1m zM>kM6zaq|0pmU7NEY|OS+^V(hay^=u8`W>5>0wQEoH9W^ zErm|3^IgWf-bZgjLk(dS27`1!HRG^YpSqg8MLpU|{h=;LzNmnzYNE-$Yu3NAo<^cE zAvX$r&skCAGlVI9s=e~2F`d$3SaZ#^@37k}YwMH`>aMXd2~L7tlFHMcO7tlV{?c?* zJ8xrl$*eN1jd6ceUF)0tXD``xQmrli)RO({*h26Mzi%41G+?<2qorU(Ymzj$X$KK6 zn}7b=8m^U<35R&n1qJQ`HXzcaM`z9$^g~=O(vWo`uM`$un-~NyVG_zAXmAF_T%D>M z_�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 QuantumCircuit: + """Return the 3-qubit Bell-pair teleportation circuit.""" + q = QuantumRegister(3, "q") + m_xx = ClassicalRegister(1, "m_XX") + m_zz = ClassicalRegister(1, "m_ZZ") + qc = QuantumCircuit(q, m_xx, m_zz) + + qc.h(1) + qc.cx(1, 2) + qc.barrier(label="Bell prep") + + qc.cx(0, 1) + qc.h(0) + qc.barrier(label="Bell meas") + qc.measure(0, m_xx[0]) + qc.measure(1, m_zz[0]) + qc.barrier(label="feedforward") + + with qc.if_test((m_zz, 1)): + qc.x(2) + with qc.if_test((m_xx, 1)): + qc.z(2) + + return qc + + +def generate_teleport_timeline_png(out_path: str = DEFAULT_OUTPUT) -> None: + """Render the teleportation circuit as a PNG using qiskit's mpl drawer.""" + qc = build_teleportation_circuit() + fig = qc.draw("mpl", style="iqp", fold=-1) + fig.savefig(out_path, dpi=150, bbox_inches="tight") + print(f" -> {os.path.basename(out_path)}") + + +if __name__ == "__main__": + generate_teleport_timeline_png() diff --git a/deq/documents/tutorial/examples/lattice-surgery/mzz_stabilizer_flow.png b/deq/documents/tutorial/examples/lattice-surgery/mzz_stabilizer_flow.png new file mode 100644 index 0000000000000000000000000000000000000000..046280f0e6f2586b69e47120db26307fea81ac2a GIT binary patch literal 69588 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®AJ%`RMaNTI*5nU8 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 S*1zcIka{F1kt6o}-Twi?mUtQf literal 0 HcmV?d00001 diff --git a/deq/documents/tutorial/examples/lattice-surgery/stabilizer_flow.py b/deq/documents/tutorial/examples/lattice-surgery/stabilizer_flow.py new file mode 100644 index 00000000..1b26261c --- /dev/null +++ b/deq/documents/tutorial/examples/lattice-surgery/stabilizer_flow.py @@ -0,0 +1,264 @@ +"""Render a before/after PNG diagram of the MZZ merge stabilizer group. + +The output is committed to the repository (like ``teleport_timeline.png`` +in the conditional-correction folder) and referenced by +``chapters/lattice-surgery.md``. Regenerate manually:: + + python stabilizer_flow.py + +Uses Stim's ``detslice-svg`` renderer to draw the stabilizer group of +the merged code at two moments in the merge protocol: + +* **Before merge** — two independent distance-3 rotated surface-code + patches with their own 8-stabilizer groups, separated by the seam + column. +* **After merge** — the same 21 qubits now form one merged code, whose + 20 stabilizers include 4 new bulk plaquettes spanning the seam and + 2 new Z 2-bodies completing the checkerboard at the top and bottom. + +Rendering convention: Stim uses XYZ=RGB, so **red = X-stabilizer, +blue = Z-stabilizer**. +""" + +import io +import os +import re + +import cairosvg +import stim +from PIL import Image, ImageDraw, ImageFont + +THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +OUTPUT_PNG = os.path.join(THIS_DIR, "mzz_stabilizer_flow.png") + +# ── Qubit layout (matches ``00_lattice_surgery_library.deq``) ────── +# +# cols 0 1 2 3 4 5 6 +# row 0 q0 q1 q2 q18 q9 q10 q11 patch A: q0..q8 +# row 1 q3 q4 q5 q19 q12 q13 q14 patch B: q9..q17 +# row 2 q6 q7 q8 q20 q15 q16 q17 +COORDS: dict[int, tuple[float, float]] = {} +for r in range(3): + for c in range(3): + COORDS[r * 3 + c] = (c, r) # patch A (cols 0-2) + COORDS[9 + r * 3 + c] = (c + 4, r) # patch B (cols 4-6) +for r in range(3): + COORDS[18 + r] = (3, r) # seam column + +# Stabilizer generators of the ``SurfaceCode [[9,1,3]]`` patch, offset +# for patch A (qubits 0-8) and patch B (qubits 9-17). +PATCH_A_STABS = [ + "Z1*Z2", "X0*X3", "Z0*Z1*Z3*Z4", "X1*X2*X4*X5", + "X3*X4*X6*X7", "Z4*Z5*Z7*Z8", "X5*X8", "Z6*Z7", +] +PATCH_B_STABS = [ + "Z10*Z11", "X9*X12", "Z9*Z10*Z12*Z13", "X10*X11*X13*X14", + "X12*X13*X15*X16", "Z13*Z14*Z16*Z17", "X14*X17", "Z15*Z16", +] +# Six new stabilizers measured by the merge: four bulk plaquettes +# spanning the seam plus two Z-type boundary 2-bodies completing the +# merged code's checkerboard. +MERGE_STABS = [ + "Z2*Z5*Z18*Z19", # M0: bulk Z, rows 0-1 cols 2-3 + "X5*X8*X19*X20", # M1: bulk X, rows 1-2 cols 2-3 + "X9*X12*X18*X19", # M2: bulk X, rows 0-1 cols 3-4 + "Z12*Z15*Z19*Z20", # M3: bulk Z, rows 1-2 cols 3-4 + "Z9*Z18", # M4: top boundary, cols 3-4 + "Z8*Z20", # M5: bottom boundary, cols 2-3 +] +# The X 2-bodies ``X5*X8`` (patch A right edge) and ``X9*X12`` (patch B +# left edge) get absorbed into the new bulk plaquettes ``X5*X8*X19*X20`` +# and ``X9*X12*X18*X19`` respectively — they are NOT independent +# generators of the merged code. +MERGED_CODE_STABS = [ + s for s in PATCH_A_STABS + PATCH_B_STABS if s not in ("X5*X8", "X9*X12") +] + MERGE_STABS + + +def mpp_targets(term: str): + """Convert a Pauli string like ``Z1*Z2`` into Stim MPP targets.""" + out: list = [] + parts = term.split("*") + for i, p in enumerate(parts): + p = p.strip() + pauli, q = p[0], int(p[1:]) + if pauli == "Z": + out.append(stim.target_z(q)) + elif pauli == "X": + out.append(stim.target_x(q)) + elif pauli == "Y": + out.append(stim.target_y(q)) + else: + raise ValueError(f"Unknown Pauli letter {pauli!r} in {term!r}") + if i < len(parts) - 1: + out.append(stim.target_combiner()) + return out + + +def build_before_circuit() -> stim.Circuit: + """Circuit for the ``tick=1`` "before merge" snapshot. + + Declares exactly 16 detectors, one per patch-A/B stabilizer. Each + detector contributes a small magenta ring around every data qubit + it touches; keeping the detector set minimal keeps the rings from + piling up into thick blobs. + """ + circuit = stim.Circuit() + for q, (x, y) in sorted(COORDS.items()): + circuit.append("QUBIT_COORDS", [q], (x, y)) + circuit.append("R", sorted(COORDS.keys())) + circuit.append("TICK") # tick=1 (before merge) + + pre_stabs = PATCH_A_STABS + PATCH_B_STABS + for term in pre_stabs: + circuit.append("MPP", mpp_targets(term)) + for i in range(len(pre_stabs)): + circuit.append("DETECTOR", [stim.target_rec(-len(pre_stabs) + i)]) + return circuit + + +def build_after_circuit() -> stim.Circuit: + """Circuit for the ``tick=1`` "after merge" snapshot. + + The pre-merge round and merge measurements happen *before* TICK #1 + (undeclared, so they contribute no detectors); a full merged-code + SE round happens after, declaring exactly 20 detectors — one per + generator of the merged code, so ``detslice-svg`` renders the + merged 3x7 code cleanly. + """ + circuit = stim.Circuit() + for q, (x, y) in sorted(COORDS.items()): + circuit.append("QUBIT_COORDS", [q], (x, y)) + circuit.append("R", sorted(COORDS.keys())) + for term in PATCH_A_STABS + PATCH_B_STABS: + circuit.append("MPP", mpp_targets(term)) + circuit.append("RX", [18, 19, 20]) + for term in MERGE_STABS: + circuit.append("MPP", mpp_targets(term)) + circuit.append("TICK") # tick=1 (after merge) + + for term in MERGED_CODE_STABS: + circuit.append("MPP", mpp_targets(term)) + for i in range(len(MERGED_CODE_STABS)): + circuit.append( + "DETECTOR", [stim.target_rec(-len(MERGED_CODE_STABS) + i)] + ) + return circuit + + +def render_detslice_png(circuit: stim.Circuit, tick: int, width: int) -> Image.Image: + """Render a ``detslice-svg`` at *tick* and rasterise to a PIL Image. + + Two SVG post-processing steps happen before rasterisation: + + * The per-detector magenta rings that Stim overlays on every data + qubit in a rendered detector's Pauli support are stripped — + they duplicate the colored polygons and pile up into visual + noise on qubits shared by many stabilizers. + * Each qubit-dot ```` gets a + matching ```` label containing the qubit index ``N``, + positioned just to the upper-right of the dot. + """ + svg = str(circuit.diagram("detslice-svg", tick=tick)) + svg = re.sub(r'\s*', "", svg) + svg = _inject_qubit_labels(svg) + png_bytes = cairosvg.svg2png(bytestring=svg.encode(), output_width=width) + return Image.open(io.BytesIO(png_bytes)).convert("RGBA") + + +_QUBIT_DOT_RE = re.compile( + r'\d+):[^"]*"\s+' + r'cx="(?P[-\d.]+)"\s+cy="(?P[-\d.]+)"[^/]*/>' +) + + +def _inject_qubit_labels(svg: str) -> str: + """Insert a small ```` label with the qubit index next to + each ``qubit_dot`` circle already present in the Stim SVG.""" + labels: list[str] = [] + for m in _QUBIT_DOT_RE.finditer(svg): + idx = int(m.group("idx")) + cx = float(m.group("cx")) + cy = float(m.group("cy")) + # Position label just above-right of the dot (SVG units: + # ~32 units per qubit spacing, so a 6-unit font is unobtrusive). + labels.append( + f'' + f'{idx}' + ) + if not labels: + return svg + # Inject labels just before the closing so they render on top + # of everything else. + return svg.replace("", "\n".join(labels) + "\n", 1) + + +def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + """Load a bold sans-serif font, falling back to the default bitmap. + """ + for candidate in ( + "DejaVuSans-Bold.ttf", # Linux default + "arialbd.ttf", # Windows: Arial Bold + "Arial Bold.ttf", # macOS + ): + try: + return ImageFont.truetype(candidate, size) + except (OSError, IOError): + continue + return ImageFont.load_default() + + +def main() -> None: + panel_width = 900 + title_height = 60 + padding = 30 + + before = render_detslice_png(build_before_circuit(), tick=1, width=panel_width) + after = render_detslice_png(build_after_circuit(), tick=1, width=panel_width) + + # Both panels have the same width; equalise the height by padding the + # shorter panel so the composed figure has a rectangular canvas. + max_h = max(before.height, after.height) + def pad_to_height(img: Image.Image) -> Image.Image: + if img.height == max_h: + return img + canvas = Image.new("RGBA", (img.width, max_h), (255, 255, 255, 255)) + canvas.paste(img, (0, (max_h - img.height) // 2), img) + return canvas + before = pad_to_height(before) + after = pad_to_height(after) + + total_w = padding + panel_width + padding + panel_width + padding + total_h = padding + title_height + max_h + padding + canvas = Image.new("RGBA", (total_w, total_h), (255, 255, 255, 255)) + canvas.paste(before, (padding, padding + title_height), before) + canvas.paste(after, (padding + panel_width + padding, padding + title_height), after) + + draw = ImageDraw.Draw(canvas) + title_font = load_font(30) + subtitle_font = load_font(20) + + def centered(x0: int, x1: int, text: str, y: int, font) -> None: + left, top, right, bottom = draw.textbbox((0, 0), text, font=font) + tw = right - left + draw.text(((x0 + x1 - tw) // 2, y), text, fill=(0, 0, 0, 255), font=font) + + centered(padding, padding + panel_width, + "Before merge", padding // 2, title_font) + centered(padding, padding + panel_width, + "two independent d=3 patches (16 stabilizers)", + padding // 2 + 35, subtitle_font) + centered(padding * 2 + panel_width, padding * 2 + panel_width * 2, + "After merge", padding // 2, title_font) + centered(padding * 2 + panel_width, padding * 2 + panel_width * 2, + "merged 3x7 code (20 stabilizers, 6 new)", + padding // 2 + 35, subtitle_font) + + canvas.convert("RGB").save(OUTPUT_PNG, "PNG", optimize=True) + print(f"Wrote {OUTPUT_PNG}") + + +if __name__ == "__main__": + main() From 863aae030218d05bbbb991ddf33198652924b678 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 7 Jul 2026 09:55:52 -0700 Subject: [PATCH 013/157] update language --- deq/deq/circuit/deq.lark | 32 ++++-- deq/deq/circuit/model.py | 49 +++++--- deq/deq/circuit/transformer.py | 2 + .../vscode-deq/syntaxes/deq.tmLanguage.json | 4 + .../00_lattice_surgery_library.deq | 79 +++++++++++++ .../tutorial/scripts/highlight-deq.mjs | 99 +++++++++++++++- .../tutorial/scripts/highlight_deq.py | 107 +++++++++++++----- 7 files changed, 315 insertions(+), 57 deletions(-) create mode 100644 deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq diff --git a/deq/deq/circuit/deq.lark b/deq/deq/circuit/deq.lark index cf76606a..e738cb4b 100644 --- a/deq/deq/circuit/deq.lark +++ b/deq/deq/circuit/deq.lark @@ -114,17 +114,35 @@ FROM_KW.3: "FROM" _readout_target: target | logical_pauli_target _error_target: check_target | readout_target | logical_pauli_target | error_pauli_target -// PROPAGATE FROM may only reference: logical Paulis, INPUT-port -// destabilizers (``IN

.DS``), and internal physical measurements -// (``rec[-k]`` or the absolute ``M`` form). Virtual stabilizer -// measurements (``IN

.S``, ``OUT

.S``) are NOT allowed in -// PROPAGATE because the propagated frame XORs *anticommuting* operator -// columns, and the destabilizer is the anticommuting operator for an -// input stabilizer. PROPAGATE never refers to output destabilizers. +// PROPAGATE FROM lists the complete XOR formula for one row of the +// output residual operator computed at runtime. The target on the +// left of FROM names the residual operator (e.g. ``PROPAGATE LZ0`` +// declares the logical-Z residual, which flips the LX observable when +// non-zero); the RHS is the XOR of contributions the runtime evaluates: +// +// residual[row] = correction_propagation[row] · input_observables +// ⊕ physical_correction[row] · raw_measurements +// ⊕ logical_correction[row] · decoded_readouts +// +// Term kinds: +// * logical Paulis (input-observable columns of ``correction_propagation``); +// * INPUT-port destabilizers ``IN

.DS`` (destabilizer columns of +// ``correction_propagation`` — the anticommuting operator for an +// input stabilizer; PROPAGATE never refers to output destabilizers); +// * internal physical measurements ``rec[-k]`` / ``M`` (columns of +// ``physical_correction``); +// * decoded readouts ``R`` (columns of ``logical_correction`` — +// the readout-conditioned frame correction the runtime XORs on top); +// * the trailing ``FLIP`` keyword sets the affine constant column of +// ``correction_propagation``. +// +// Virtual stabilizer measurements (``IN

.S``, ``OUT

.S``) +// are NOT allowed in PROPAGATE. _propagate_term: logical_pauli_target | INPUT_DESTAB_TARGET | MEASUREMENT_RECORD_TARGET | PHYS_MEAS_TARGET + | readout_target // A measurement reference may be either the legacy relative form // (``rec[-k]``) or one of the absolute forms (``M`` for the i-th diff --git a/deq/deq/circuit/model.py b/deq/deq/circuit/model.py index 02e0762d..a3721bfb 100644 --- a/deq/deq/circuit/model.py +++ b/deq/deq/circuit/model.py @@ -485,30 +485,49 @@ class VirtualLogicalStatement: decorators: list[Decorator] = field(default_factory=list) -PropagateTerm = LogicalPauliTarget | DestabilizerTarget | MeasurementRefTarget +PropagateTerm = ( + LogicalPauliTarget | DestabilizerTarget | MeasurementRefTarget | ReadoutTarget +) @dataclass class PropagateStatement: """A ``PROPAGATE LX0 FROM ...`` declaration inside a GADGET. - Pins one row of the correction-propagation matrix (cp + pc + flip) - to the explicit XOR of the listed terms. Each target after - ``FROM`` contributes one bit: + Pins one row of the output residual operator the runtime evaluates. + The target on the left of ``FROM`` names the residual operator + (e.g. ``PROPAGATE LZ0`` declares the logical-Z residual, which + flips the ``LX`` observable when non-zero); the RHS is the explicit + XOR of the listed contributions: - * ``LX`` / ``LZ``: input-frame logical column - * ``IN

.DS``: input-frame destabilizer column (the syndrome - bit of stabilizer ``s`` of INPUT port ``p``). PROPAGATE never - refers to OUTPUT destabilizers, so no ``OUT

.DS`` form. - * ``rec[-k]`` or the absolute ``M``: internal physical - measurement. Virtual stabilizer measurements - (``IN

.S`` / ``OUT

.S``) are NOT allowed here. + .. code-block:: text - The optional ``FLIP`` token sets the affine constant column. + residual[row] = correction_propagation[row] · input_observables + ⊕ physical_correction[row] · raw_measurements + ⊕ logical_correction[row] · decoded_readouts - Specs are validated to lie in the basis-freedom span of the row; - out-of-span specs are rejected. Uncovered output observables - fall back to the flow-based derivation. + Each term after ``FROM`` contributes one bit: + + * ``LX`` / ``LZ``: input-frame logical column of + ``correction_propagation`` + * ``IN

.DS``: input-frame destabilizer column of + ``correction_propagation`` (the syndrome bit of stabilizer + ``s`` of INPUT port ``p``). PROPAGATE never refers to OUTPUT + destabilizers, so no ``OUT

.DS`` form. + * ``rec[-k]`` or the absolute ``M``: internal physical + measurement column of ``physical_correction``. Virtual + stabilizer measurements (``IN

.S`` / ``OUT

.S``) are + NOT allowed here. + * ``R``: decoded-readout column of ``logical_correction`` — + the readout-conditioned frame correction the runtime XORs on + top of the natural-Heisenberg residual. + + The optional ``FLIP`` token sets the affine constant column of + ``correction_propagation``. + + Output residual operators not covered by an explicit ``PROPAGATE`` + fall back to the natural-Heisenberg derivation composed with any + ``VIRTUAL`` / ``CONDITIONAL`` shortcut contributions. """ target: LogicalPauliTarget diff --git a/deq/deq/circuit/transformer.py b/deq/deq/circuit/transformer.py index 363494d8..bda98fe4 100644 --- a/deq/deq/circuit/transformer.py +++ b/deq/deq/circuit/transformer.py @@ -583,6 +583,8 @@ def propagate_statement(self, items: list[Any]) -> PropagateStatement: continue if isinstance(item, LogicalPauliTarget): terms.append(item) + elif isinstance(item, ReadoutTarget): + terms.append(item) elif isinstance(item, Token) and item.type == "INPUT_DESTAB_TARGET": m = _INPUT_DESTAB_RE.match(str(item)) if not m: diff --git a/deq/deq/circuit/vscode-deq/syntaxes/deq.tmLanguage.json b/deq/deq/circuit/vscode-deq/syntaxes/deq.tmLanguage.json index aa68f581..970c0695 100644 --- a/deq/deq/circuit/vscode-deq/syntaxes/deq.tmLanguage.json +++ b/deq/deq/circuit/vscode-deq/syntaxes/deq.tmLanguage.json @@ -663,6 +663,10 @@ { "match": "rec\\[-\\d+\\]", "name": "variable.other.deq" + }, + { + "match": "R\\d+", + "name": "support.variable.readout.deq" } ] }, diff --git a/deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq b/deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq new file mode 100644 index 00000000..fbf62920 --- /dev/null +++ b/deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq @@ -0,0 +1,79 @@ +# Shared library for the lattice-surgery chapter. +# +# The physical mechanics of the joint-Z merge (geometry, stabilizer +# derivation, byproduct semantics) are documented in +# ``documents/tutorial/chapters/lattice-surgery.md`` and in the +# annotated fixture at ``tests/circuit/surface_code/lattice_surgery_d3.deq``. + +CODE SurfaceCode [[9,1,3]] { + LOGICAL X0*X1*X2 Z0*Z3*Z6 + STABILIZER Z1*Z2 X0*X3 Z0*Z1*Z3*Z4 X1*X2*X4*X5 X3*X4*X6*X7 Z4*Z5*Z7*Z8 X5*X8 Z6*Z7 +} + +GADGET PrepareZ { + RZ 0 1 2 3 4 5 6 7 8 + + # Single round of syndrome extraction to project into the code space. + MPP Z1*Z2 + MPP X0*X3 + MPP Z0*Z1*Z3*Z4 + MPP X1*X2*X4*X5 + MPP X3*X4*X6*X7 + MPP Z4*Z5*Z7*Z8 + MPP X5*X8 + MPP Z6*Z7 + + OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 +} + +GADGET MeasureZ { + INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 + MZ 0 1 2 3 4 5 6 7 8 + READOUT rec[-9] rec[-6] rec[-3] +} + +GADGET MZZ { + INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 + INPUT SurfaceCode 9 10 11 12 13 14 15 16 17 + + RX 18 19 20 + + MPP Z2*Z5*Z18*Z19 # M0 + MPP X5*X8*X19*X20 # M1 + MPP X9*X12*X18*X19 # M2 + MPP Z12*Z15*Z19*Z20 # M3 + MPP Z9*Z18 # M4 + MPP Z8*Z20 # M5 + + MX 18 19 20 # M6 M7 M8 + + READOUT M0 M3 M4 M5 + + OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 + OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17 + + CONDITIONAL R0 OUT1.LX0 + @OVERRIDE + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M6 + @OVERRIDE + PROPAGATE OUT1.LZ0 FROM +} + +COMPOSE ComposeMZZ { + INPUT SurfaceCode 0 + INPUT SurfaceCode 1 + MZZ 0 1 + OUTPUT SurfaceCode 0 + OUTPUT SurfaceCode 1 +} + +PROGRAM ComposeMZZMemoryZ { + PrepareZ 0 + PrepareZ 1 + ComposeMZZ 0 1 + MeasureZ 0 + MeasureZ 1 + ASSERT_EQ rec[-3] 0 # joint LZ_A*LZ_B parity = +1 + ASSERT_EQ rec[-2] 0 # MeasureZ patch A + ASSERT_EQ rec[-1] 0 # MeasureZ patch B +} diff --git a/deq/documents/tutorial/scripts/highlight-deq.mjs b/deq/documents/tutorial/scripts/highlight-deq.mjs index 4907d33a..88b490fb 100644 --- a/deq/documents/tutorial/scripts/highlight-deq.mjs +++ b/deq/documents/tutorial/scripts/highlight-deq.mjs @@ -6,6 +6,13 @@ // // Usage: // node scripts/highlight-deq.mjs [--theme light|dark] +// [--start N] [--end N] +// +// --start and --end select a 1-indexed inclusive line range. The whole +// file is always highlighted first (so the TextMate grammar sees full +// cross-line context, including containing GADGET / COMPOSE / Mako +// blocks); the resulting HTML is then sliced down to just the requested +// per-line entries emitted by Shiki. // // Prints the highlighted HTML to stdout. @@ -20,12 +27,29 @@ const grammarPath = resolve(tutorialDir, '../../deq/circuit/vscode-deq/syntaxes/ // Parse CLI args const args = process.argv.slice(2); -const themeArg = args.includes('--theme') ? args[args.indexOf('--theme') + 1] : 'light'; + +function optionValue(flag) { + const idx = args.indexOf(flag); + return idx >= 0 ? args[idx + 1] : null; +} + +const themeArg = optionValue('--theme') ?? 'light'; const themeName = themeArg === 'light' ? 'light-plus' : 'github-dark'; -const inputFile = args.find(a => !a.startsWith('--') && args[args.indexOf(a) - 1] !== '--theme'); +const startArg = optionValue('--start'); +const endArg = optionValue('--end'); + +const flagValueIndices = new Set(); +for (const flag of ['--theme', '--start', '--end']) { + const idx = args.indexOf(flag); + if (idx >= 0) flagValueIndices.add(idx + 1); +} + +const inputFile = args.find( + (a, i) => !a.startsWith('--') && !flagValueIndices.has(i) +); if (!inputFile) { - console.error('Usage: node highlight-deq.mjs [--theme light|dark]'); + console.error('Usage: node highlight-deq.mjs [--theme light|dark] [--start N] [--end N]'); process.exit(1); } @@ -39,7 +63,72 @@ const highlighter = await createHighlighter({ ], }); -const code = readFileSync(resolve(inputFile), 'utf-8').trimEnd(); +let code = readFileSync(resolve(inputFile), 'utf-8').replace(/\n+$/, ''); +const totalLines = code.split('\n').length; + +const start = startArg !== null ? parseInt(startArg, 10) : null; +const end = endArg !== null ? parseInt(endArg, 10) : null; +if ( + (start !== null && (Number.isNaN(start) || start < 1)) || + (end !== null && (Number.isNaN(end) || end < 1)) || + (start !== null && end !== null && start > end) +) { + console.error(`Invalid --start/--end range: ${startArg}..${endArg}`); + process.exit(1); +} + const html = highlighter.codeToHtml(code, { lang: 'deq', theme: themeName }); -process.stdout.write(html); +const sliced = + start !== null || end !== null + ? sliceHighlightedLines(html, start ?? 1, end ?? totalLines) + : html; +process.stdout.write(sliced); highlighter.dispose(); + +// Extract only the requested 1-indexed inclusive [start, end] range from +// Shiki's HTML output. Shiki emits one ```` +// per source line inside ``

``. Because token +// spans nested inside a line are also ````, we track +// nesting depth to find the matching close. +function sliceHighlightedLines(fullHtml, sliceStart, sliceEnd) { + const codeOpenTag = ''; + const codeCloseTag = '
'; + const codeOpenIdx = fullHtml.indexOf(codeOpenTag); + const codeCloseIdx = fullHtml.lastIndexOf(codeCloseTag); + if (codeOpenIdx < 0 || codeCloseIdx < 0) { + return fullHtml; + } + const header = fullHtml.slice(0, codeOpenIdx + codeOpenTag.length); + const inner = fullHtml.slice(codeOpenIdx + codeOpenTag.length, codeCloseIdx); + const footer = fullHtml.slice(codeCloseIdx); + + const lineOpenTag = ''; + const spanClose = ''; + const lines = []; + let i = 0; + while (i < inner.length) { + const nextLine = inner.indexOf(lineOpenTag, i); + if (nextLine < 0) break; + let depth = 1; + let j = nextLine + lineOpenTag.length; + while (j < inner.length && depth > 0) { + if (inner.startsWith('', j); + j = gt < 0 ? inner.length : gt + 1; + } else if (inner.startsWith(spanClose, j)) { + depth--; + j += spanClose.length; + } else { + j++; + } + } + lines.push(inner.slice(nextLine, j)); + i = j; + } + + const clampedStart = Math.max(1, sliceStart); + const clampedEnd = Math.min(lines.length, sliceEnd); + const selected = lines.slice(clampedStart - 1, clampedEnd); + return header + selected.join('\n') + footer; +} diff --git a/deq/documents/tutorial/scripts/highlight_deq.py b/deq/documents/tutorial/scripts/highlight_deq.py index 247c49a4..dd521262 100644 --- a/deq/documents/tutorial/scripts/highlight_deq.py +++ b/deq/documents/tutorial/scripts/highlight_deq.py @@ -2,23 +2,29 @@ """Embed syntax-highlighted .deq code blocks into tutorial Markdown files. Scans every ``.md`` file under ``documents/tutorial/`` for Markdown links -whose target ends in ``.deq``:: +whose target ends in ``.deq``, optionally followed by a GitHub-style +line-range fragment:: - [description of the code](path/to/file.deq) + [caption for the whole file](path/to/file.deq) + [caption for a slice](path/to/file.deq#L20-L30) + [caption for one line](path/to/file.deq#L42) For each such link the script: 1. Reads the referenced ``.deq`` file (path resolved relative to the - ``.md`` file that contains the link). + ``.md`` file that contains the link) and, if a range fragment is + present, slices the file down to that 1-indexed inclusive range + before highlighting. 2. Generates syntax-highlighted HTML via Shiki (Node.js subprocess) using the VS Code *Light+* TextMate theme and the project's own ``deq.tmLanguage.json`` grammar. 3. Inserts (or replaces) a fenced HTML block *immediately after* the link - line, delimited by recognisable HTML comments:: + line, delimited by recognisable HTML comments that include the range + fragment (if any) so multiple snippets from the same file coexist:: - +
...
- + The delimiters allow the script to be re-run idempotently: stale blocks are removed and regenerated every time. @@ -48,20 +54,29 @@ HIGHLIGHT_SCRIPT = TUTORIAL_DIR / "scripts" / "highlight-deq.mjs" # ── regex patterns ───────────────────────────────────────────────────── -# Matches a Markdown link whose href ends with .deq, but ONLY when the -# link is the entire content of its line (optionally surrounded by -# whitespace). Inline links embedded mid-sentence are intentionally -# skipped, otherwise injecting a highlighted code block on the next -# line would split a paragraph into nonsense. +# Matches a Markdown link whose href ends with .deq (optionally followed +# by a GitHub-style ``#L[-L]`` line-range fragment), but ONLY +# when the link is the entire content of its line (optionally surrounded +# by whitespace). Inline links embedded mid-sentence are intentionally +# skipped, otherwise injecting a highlighted code block on the next line +# would split a paragraph into nonsense. DEQ_LINK_RE = re.compile( - r"^[ \t]*\[(?P[^\]]*)\]\((?P[^)]+\.deq)\)[ \t]*$", + r"^[ \t]*\[(?P[^\]]*)\]" + r"\((?P[^)#]+\.deq)" + r"(?P#L\d+(?:-L\d+)?)?\)" + r"[ \t]*$", re.MULTILINE, ) -# Matches any highlight block, regardless of which .deq file it points -# at. Used to strip the entire previous run's output before re-injecting, -# so blocks that no longer have a matching own-line link get cleaned up -# instead of becoming orphans. +# Parses a validated fragment like ``#L20-L30`` or ``#L42`` into (start, +# end) 1-indexed inclusive line numbers. A single-line fragment collapses +# to (n, n). +FRAGMENT_RE = re.compile(r"^#L(?P\d+)(?:-L(?P\d+))?$") + +# Matches any highlight block, regardless of which .deq file (or slice) +# it points at. Used to strip the entire previous run's output before +# re-injecting, so blocks that no longer have a matching own-line link +# get cleaned up instead of becoming orphans. ANY_BLOCK_RE = re.compile( r"\n.*?\n?", re.DOTALL, @@ -71,14 +86,43 @@ END_COMMENT = "" -def highlight_deq(deq_file: Path) -> str: - """Return Shiki-highlighted HTML for *deq_file* (Light+ theme).""" - result = subprocess.run( - ["node", str(HIGHLIGHT_SCRIPT), str(deq_file), "--theme", "light"], - capture_output=True, - text=True, - check=True, - ) +def _parse_fragment(frag: str | None) -> tuple[int | None, int | None]: + """Return (start, end) 1-indexed inclusive line numbers, or (None, None).""" + if not frag: + return (None, None) + m = FRAGMENT_RE.match(frag) + if not m: + raise ValueError(f"Invalid .deq line-range fragment: {frag!r}") + start = int(m.group("start")) + end = int(m.group("end")) if m.group("end") else start + if start < 1 or end < start: + raise ValueError(f"Invalid .deq line-range fragment: {frag!r}") + return (start, end) + + +def highlight_deq( + deq_file: Path, + *, + start: int | None = None, + end: int | None = None, +) -> str: + """Return Shiki-highlighted HTML for *deq_file* (Light+ theme). + + When *start* and/or *end* are given, only that 1-indexed inclusive + line range is highlighted. + """ + cmd: list[str] = [ + "node", + str(HIGHLIGHT_SCRIPT), + str(deq_file), + "--theme", + "light", + ] + if start is not None: + cmd.extend(["--start", str(start)]) + if end is not None: + cmd.extend(["--end", str(end)]) + result = subprocess.run(cmd, capture_output=True, text=True, check=True) return result.stdout @@ -87,9 +131,9 @@ def process_markdown(md_path: Path, *, check_only: bool = False) -> bool: original = md_path.read_text(encoding="utf-8") # 1. Strip every existing highlight block, regardless of which .deq - # path it points at. This guarantees orphan blocks (those whose link - # was deleted, edited inline, or otherwise no longer qualifies) get - # cleaned up on every run. + # path (or slice) it points at. This guarantees orphan blocks (those + # whose link was deleted, edited inline, or otherwise no longer + # qualifies) get cleaned up on every run. content = ANY_BLOCK_RE.sub("", original) # 2. Find all .deq links that occupy their own line. ``DEQ_LINK_RE`` @@ -102,6 +146,8 @@ def process_markdown(md_path: Path, *, check_only: bool = False) -> bool: # positions stay valid as we mutate later in the string. for m in reversed(links): deq_rel = m.group("path") + frag = m.group("frag") or "" + start, end = _parse_fragment(frag or None) deq_abs = (md_path.parent / deq_rel).resolve() if not deq_abs.is_file(): @@ -109,13 +155,14 @@ def process_markdown(md_path: Path, *, check_only: bool = False) -> bool: f"{deq_rel} referenced in {md_path.name} does not exist: {deq_abs}" ) - html = highlight_deq(deq_abs) + html = highlight_deq(deq_abs, start=start, end=end) + marker = deq_rel + frag block = ( - BEGIN_COMMENT.format(deq_rel) + BEGIN_COMMENT.format(marker) + "\n" + html + "\n" - + END_COMMENT.format(deq_rel) + + END_COMMENT.format(marker) + "\n" ) From b1e22b779996c82e19d39ef7fdcff18a9d2ea8f1 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 7 Jul 2026 09:56:40 -0700 Subject: [PATCH 014/157] fix language --- deq/documents/tutorial/chapters/compose-repropagate.md | 6 +++--- deq/documents/tutorial/chapters/conditional-correction.md | 6 +++--- .../examples/lattice-surgery/00_lattice_surgery_library.deq | 2 -- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/deq/documents/tutorial/chapters/compose-repropagate.md b/deq/documents/tutorial/chapters/compose-repropagate.md index 75cd7eab..d1fbcac4 100644 --- a/deq/documents/tutorial/chapters/compose-repropagate.md +++ b/deq/documents/tutorial/chapters/compose-repropagate.md @@ -400,9 +400,9 @@ than discarded. The trailing `M1 M3` are internal-measurement references that encode the conditional correction: when the parity of those two measurements is $1$, the propagated output $\bar{Z}$ operator is flipped in the Pauli frame. `@REPROPAGATE` derives all three tokens directly from the inlined circuit (it -can see the `MX 0 1 2 3` and trace the resulting Pauli frame forwards) — exactly -the derivation the verifier would also run, which is why no `@OVERRIDE` -decorator is needed. +can see the `MX 0 1 2 3` and trace the resulting Pauli frame forwards) — the +same derivation the verifier would also run, so the emitted `PROPAGATE` rows +come out as the natural-Heisenberg form of the composed body. --- diff --git a/deq/documents/tutorial/chapters/conditional-correction.md b/deq/documents/tutorial/chapters/conditional-correction.md index 44debfb1..5a5fb49e 100644 --- a/deq/documents/tutorial/chapters/conditional-correction.md +++ b/deq/documents/tutorial/chapters/conditional-correction.md @@ -135,7 +135,7 @@ the user wants. `CONDITIONAL` (and its cousin `VIRTUAL`) are how the user [`tests/circuit/surface_code/lattice_surgery_d3.deq`](../../../tests/circuit/surface_code/lattice_surgery_d3.deq) is the canonical example — see the [lattice-surgery chapter](lattice-surgery.md) for the full walkthrough of the ambiguity and how `CONDITIONAL` and -`@OVERRIDE` resolve it. +hand-written `PROPAGATE` rows resolve it. ### Variant 2 — COMPOSE-level `CONDITIONAL` @@ -399,7 +399,7 @@ byproduct spans more than one output port at once, or the same physical body admits several distinct logical actions and the framework's per-port flow solver picks the wrong one. The [lattice-surgery chapter](lattice-surgery.md) works through the canonical example — a joint-$\bar Z$ merge that needs `CONDITIONAL` -to pin the "honest joint measurement" reading *and* two `@OVERRIDE PROPAGATE` +to pin the "honest joint measurement" reading *and* two hand-written `PROPAGATE` rows to hand-declare the joint-$\bar X$ preservation the per-port solver misses — and then shows how to restructure the merge so the resulting fault-tolerance is genuinely below-threshold at $d = 3$. @@ -471,5 +471,5 @@ uniformly. Related chapters: -- [Lattice Surgery: The Joint-$\bar Z$ Measurement](lattice-surgery.md) — the follow-on chapter where a joint-parity merge forces `CONDITIONAL` *and* `@OVERRIDE PROPAGATE`, and where restructuring the merge into single-SE-round GADGETs is what recovers fault tolerance. +- [Lattice Surgery: The Joint-$\bar Z$ Measurement](lattice-surgery.md) — the follow-on chapter where a joint-parity merge forces `CONDITIONAL` *and* hand-written `PROPAGATE` rows, and where restructuring the merge into single-SE-round GADGETs is what recovers fault tolerance. - [`@REPROPAGATE`](compose-repropagate.md) — the flow-based alternative for corrections with a transversal-Heisenberg path. diff --git a/deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq b/deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq index fbf62920..e10aee2d 100644 --- a/deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq +++ b/deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq @@ -53,9 +53,7 @@ GADGET MZZ { OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17 CONDITIONAL R0 OUT1.LX0 - @OVERRIDE PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M6 - @OVERRIDE PROPAGATE OUT1.LZ0 FROM } From 12adcf7394441ed93605f8c6662734767c83bf36 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Wed, 8 Jul 2026 10:13:17 -0700 Subject: [PATCH 015/157] update test files --- .../00_lattice_surgery_library.deq | 2 +- deq/proto/deq_bin.proto | 16 +++---- .../circuit/fixtures/readout_compose.deq | 37 +++++++++++++++ .../circuit/fixtures/trivial_surgery.deq | 4 -- .../surface_code/lattice_surgery_d3.deq | 9 ++-- .../circuit/surface_code/teleportation_d3.deq | 47 ++++++++++++------- 6 files changed, 79 insertions(+), 36 deletions(-) create mode 100644 deq/tests/circuit/fixtures/readout_compose.deq diff --git a/deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq b/deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq index e10aee2d..dc3b58eb 100644 --- a/deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq +++ b/deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq @@ -52,7 +52,7 @@ GADGET MZZ { OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17 - CONDITIONAL R0 OUT1.LX0 + PROPAGATE OUT1.LX0 FROM IN1.LX0 PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M6 PROPAGATE OUT1.LZ0 FROM } diff --git a/deq/proto/deq_bin.proto b/deq/proto/deq_bin.proto index 00872c48..bd02d546 100644 --- a/deq/proto/deq_bin.proto +++ b/deq/proto/deq_bin.proto @@ -109,15 +109,13 @@ message GadgetType { // size = |output_observables| rows x |readouts| columns // formerly named "conditional_correction" // - // NOTE: In the canonical / merged form produced by ``canonical.merge()``, - // this matrix is always empty. Conditional corrections from - // ``logical_correction`` and from ``GadgetModifier.remote_conditional_correction`` - // are absorbed into ``correction_propagation`` and ``physical_correction`` - // (and into per-error ``residual``) during the merge. The field remains - // useful for: - // * per-gadget authoring (e.g. ``CONDITIONAL R L

`` in a GADGET); - // * runtime feed-forward when the runtime applies a ``GadgetModifier`` - // ``remote_conditional_correction`` to a gadget instance. + // Populated by per-gadget authoring constructs (``CONDITIONAL R + // L

`` inside a GADGET body, ``PROPAGATE ... R`` R-terms) and + // by COMPOSE-level ``CONDITIONAL rec[-k]`` corrections (via + // ``GadgetModifier.remote_conditional_correction``). In the merged + // form produced by ``canonical.merge()`` these entries are preserved + // verbatim — the runtime evaluates the flip via + // ``residual ^= logical_correction · readouts``. deq.util.BitMatrix logical_correction = 10; // Transparent gadget can be useful to dynamically insert Pauli frame updates diff --git a/deq/tests/circuit/fixtures/readout_compose.deq b/deq/tests/circuit/fixtures/readout_compose.deq new file mode 100644 index 00000000..0c0a9c55 --- /dev/null +++ b/deq/tests/circuit/fixtures/readout_compose.deq @@ -0,0 +1,37 @@ +# A corner case where the readout in a COMPOSE is not simply the readout of the underlying gadget. +# +# The key is to let a physical measurement to contribute to the logical frame + +CODE A [[2,1,1]] { + LOGICAL X0*X1 Z0 + STABILIZER Z0*Z1 +} + +CODE B [[1,1,1]] { + LOGICAL X0 Z0 +} + +GADGET X { + INPUT A 0 1 + MX 1 # so that output X observable picks up a phase + OUTPUT B 0 +} + +GADGET Y { + INPUT B 0 + MX 0 + READOUT rec[-1] +} + +COMPOSE XY { + INPUT A 0 + X 0 + Y 0 +} + +@REPROPAGATE +COMPOSE XYre { + INPUT A 0 + X 0 + Y 0 +} diff --git a/deq/tests/circuit/fixtures/trivial_surgery.deq b/deq/tests/circuit/fixtures/trivial_surgery.deq index 2d5d3122..b6b07f11 100644 --- a/deq/tests/circuit/fixtures/trivial_surgery.deq +++ b/deq/tests/circuit/fixtures/trivial_surgery.deq @@ -66,9 +66,7 @@ GADGET TwoMZZ { CONDITIONAL R0 OUT1.LX0 - @OVERRIDE PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M2 - @OVERRIDE PROPAGATE OUT1.LZ0 FROM } @@ -97,9 +95,7 @@ GADGET TwoSplit { OUTPUT One 0 OUTPUT One 2 - @OVERRIDE PROPAGATE OUT0.LZ0 FROM IN0.LZ0 M0 - @OVERRIDE PROPAGATE OUT1.LZ0 FROM } diff --git a/deq/tests/circuit/surface_code/lattice_surgery_d3.deq b/deq/tests/circuit/surface_code/lattice_surgery_d3.deq index 055cb6aa..02163b85 100644 --- a/deq/tests/circuit/surface_code/lattice_surgery_d3.deq +++ b/deq/tests/circuit/surface_code/lattice_surgery_d3.deq @@ -143,12 +143,11 @@ GADGET MZZ { # matter here because they differ by a LZ_A · LX_A operator, # but we are already in the +1 or -1 eigenstate of that operator. # - # The ``@OVERRIDE`` decorator tells the validator to install - # these values verbatim, bypassing the basis-freedom check — - # the per-port flow solver legitimately cannot derive them. - @OVERRIDE + # These PROPAGATE rows are authoritative — they install exactly + # the residual formula the runtime evaluates for these output + # observables, replacing the per-port flow solver's (unsolvable) + # answer. PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M6 - @OVERRIDE PROPAGATE OUT1.LZ0 FROM } diff --git a/deq/tests/circuit/surface_code/teleportation_d3.deq b/deq/tests/circuit/surface_code/teleportation_d3.deq index b134fdee..66effd89 100644 --- a/deq/tests/circuit/surface_code/teleportation_d3.deq +++ b/deq/tests/circuit/surface_code/teleportation_d3.deq @@ -14,16 +14,25 @@ # Two equivalent ways to express the conditional Pauli frame update are # provided: # -# 1. ``@REPROPAGATE`` re-derives the propagation matrix from the -# inlined flat circuit, automatically absorbing the conditional -# teleportation corrections. +# 1. ``@REPROPAGATE`` re-derives the propagation matrices from the +# flat inlined body's Heisenberg flow. The teleportation +# protocol's measurement-dependent Pauli flips (m_XX → Z on the +# output, m_ZZ → X on the output) fall out of that flow as +# ``physical_correction`` entries; no explicit ``CONDITIONAL`` +# statement is present or needed — in fact ``@REPROPAGATE`` +# *rejects* any ``CONDITIONAL`` in the compose or its sub-gadgets +# because the flat-body Heisenberg pass has no way to reconstruct +# a user-authored ``lc`` entry from circuit flow alone. # 2. Explicit ``CONDITIONAL rec[-k] `` statements emit a # synthesized identity gadget that hosts a -# ``remote_conditional_correction`` modifier; the canonicalizer -# absorbs the resulting ``logical_correction`` into -# ``correction_propagation`` and ``physical_correction``. +# ``remote_conditional_correction`` modifier; ``merge()`` preserves +# it verbatim in the merged ``logical_correction`` matrix (runtime +# evaluates the flip via ``residual ^= lc · readouts``). # -# After absorption the two variants are canonically equivalent. +# The two variants are runtime-equivalent — they produce identical +# residuals on every shot — but not byte-identical, because Variant 1 +# folds the correction into ``cp``/``pc`` while Variant 2 keeps it in +# ``lc``. # ============================================================================= IMPORT "surface_code_d3.deq" @@ -79,13 +88,15 @@ COMPOSE MeasureBell { # ----------------------------------------------------------------------------- # 4. TeleportRepropagate — Bell-pair teleportation, letting -# ``@REPROPAGATE`` infer the conditional correction from the full -# inlined circuit. +# ``@REPROPAGATE`` re-derive the propagation matrices from the +# flat inlined body. # -# The compose builder rebuilds the propagation matrix on the flat -# equivalent circuit, so the canonical form already contains the -# ``m_XX → Z_out`` and ``m_ZZ → X_out`` contributions in cp/pc -# without any user-visible CONDITIONAL statement. +# The compose builder inlines every sub-gadget's body and runs +# Heisenberg flow analysis on the resulting flat circuit. The +# teleportation's measurement-conditioned Pauli flips (``m_XX → +# Z_out``, ``m_ZZ → X_out``) fall out as ``physical_correction`` +# entries automatically — no user-visible ``CONDITIONAL`` needed +# (and none permitted: ``@REPROPAGATE`` rejects them). # ----------------------------------------------------------------------------- @REPROPAGATE COMPOSE TeleportRepropagate { @@ -97,10 +108,12 @@ COMPOSE TeleportRepropagate { # ----------------------------------------------------------------------------- # 5. TeleportConditional — same teleportation written with explicit -# ``CONDITIONAL`` statements (no ``@REPROPAGATE``). After ``merge()`` -# step 9 absorbs the conditional contributions into cp/pc, the -# resulting ``JitGadgetType`` is canonically equivalent to -# ``TeleportRepropagate``. +# ``CONDITIONAL`` statements (no ``@REPROPAGATE``). ``merge()`` +# preserves the conditional contributions verbatim in the merged +# ``logical_correction``; the runtime applies them via +# ``residual ^= lc · readouts``, giving the same runtime behavior as +# ``TeleportRepropagate`` (whose corrections were folded into cp/pc +# by @REPROPAGATE's Heisenberg pass). # # Standard teleportation correction: # m_XX = 1 ⇒ apply Z on output patch (wire 2) From 6dedd72e6c738e5ed9b67f216c438edac8f9afd6 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Wed, 8 Jul 2026 11:30:11 -0700 Subject: [PATCH 016/157] update --- deq/deq/transpiler/compose_builder.py | 293 +++++++++++--------------- 1 file changed, 120 insertions(+), 173 deletions(-) diff --git a/deq/deq/transpiler/compose_builder.py b/deq/deq/transpiler/compose_builder.py index 5129f2e4..b22186d5 100644 --- a/deq/deq/transpiler/compose_builder.py +++ b/deq/deq/transpiler/compose_builder.py @@ -28,12 +28,13 @@ GadgetStatement, InputPort, Instruction, - LogicalPauliTarget, + MeasurementRecordTarget, OutputPort, PauliTarget, + PhysicalMeasurementTarget, QubitTarget, ReadoutStatement, - ReadoutTarget, + ReadoutTargetItem, RepeatBlock, Target, ) @@ -551,15 +552,28 @@ def _expand_definition( ) -> tuple[list[InputPort], list[GadgetStatement], list[OutputPort]]: """Expand a single definition into ``(input_ports, circuit, output_ports)``. - For a ``GADGET``, returns its raw ports and Stim instructions. + For a ``GADGET``, returns its raw ports, Stim instructions, and + ``READOUT`` statements (with absolute ``M`` measurement + references rewritten to relative ``rec[-k]`` references so they + remain valid when the body is inlined into a larger COMPOSE). For a ``COMPOSE``, recursively expands with qubit remapping. """ + # Local import to avoid the cycle compose_builder ↔ jit_library_builder. + from deq.transpiler.jit_library_builder import _measurement_count_of + if name in gadget_defs: gadget = gadget_defs[name] flat = flatten_body(list(gadget.body)) inputs = [s for s in flat if isinstance(s, InputPort)] outputs = [s for s in flat if isinstance(s, OutputPort)] - circuit: list[GadgetStatement] = [s for s in flat if isinstance(s, Instruction)] + circuit: list[GadgetStatement] = [] + running = 0 + for s in flat: + if isinstance(s, Instruction): + circuit.append(s) + running += _measurement_count_of(s) + elif isinstance(s, ReadoutStatement): + circuit.append(_relativize_readout(s, running)) return inputs, circuit, outputs if name in compose_defs: return expand_compose_circuit( @@ -568,6 +582,33 @@ def _expand_definition( return [], [], [] +def _relativize_readout(stmt: ReadoutStatement, running: int) -> ReadoutStatement: + """Translate ``M`` targets in *stmt* to ``rec[-(running - i)]``. + + *running* is the number of internal physical measurements emitted + in the enclosing GADGET body strictly before *stmt*. After the + rewrite, the readout's measurement references survive inlining + into a larger COMPOSE body unchanged: ``rec[-k]`` is always + interpreted relative to the position of the READOUT statement at + decode time, so it continues to point at the same physical + measurement event regardless of how much code precedes the sub- + gadget in the inlined body. + """ + new_targets: list[ReadoutTargetItem] = [] + for target in stmt.targets: + if isinstance(target, PhysicalMeasurementTarget): + new_targets.append( + MeasurementRecordTarget(offset=running - target.index) + ) + else: + new_targets.append(target) + return ReadoutStatement( + targets=new_targets, + flip=stmt.flip, + decorators=list(stmt.decorators), + ) + + def expand_compose_circuit( compose: ComposeDefinition, gadget_defs: Mapping[str, GadgetDefinition], @@ -789,153 +830,75 @@ def has_repropagate(compose: ComposeDefinition) -> bool: return any(d.name == "REPROPAGATE" for d in compose.decorators) -def _count_readouts_recursive( - name: str, - gadget_defs: Mapping[str, GadgetDefinition], - compose_defs: Mapping[str, ComposeDefinition], - known_names: set[str], -) -> int: - """Count READOUT statements produced by gadget *name*, recursing into - nested COMPOSEs. - - Used to resolve ``rec[-k]`` in COMPOSE-level ``ConditionalCorrection`` - statements to absolute readout indices in the synthetic flat body - produced by :func:`expand_compose_circuit`. - """ - if name in gadget_defs: - return sum( - 1 - for s in flatten_body(list(gadget_defs[name].body)) - if isinstance(s, ReadoutStatement) - ) - if name in compose_defs: - compose = compose_defs[name] - total = 0 - for stmt in compose.body: - total += _count_readouts_in_compose_stmt( - stmt, gadget_defs, compose_defs, known_names - ) - return total - return 0 - - -def _count_readouts_in_compose_stmt( - stmt: ComposeStatement, - gadget_defs: Mapping[str, GadgetDefinition], - compose_defs: Mapping[str, ComposeDefinition], - known_names: set[str], -) -> int: - """Count READOUT statements contributed by *stmt* (a single COMPOSE - body statement). - - Handles ``RepeatBlock`` (multiplies by iteration count), - ``GadgetApplication`` (recurses into the named gadget/compose), and - shortcut ``Instruction`` applications (where the instruction name - matches a known gadget). - """ - if isinstance(stmt, RepeatBlock): - per_iter = sum( - _count_readouts_in_compose_stmt( - s, gadget_defs, compose_defs, known_names - ) - for s in stmt.body - ) - return per_iter * stmt.count - if isinstance(stmt, GadgetApplication): - return _count_readouts_recursive( - stmt.gadget_name, gadget_defs, compose_defs, known_names - ) - if isinstance(stmt, Instruction) and stmt.name in known_names: - return _count_readouts_recursive( - stmt.name, gadget_defs, compose_defs, known_names - ) - return 0 - - -def _translate_compose_conditionals( +def _reject_conditionals_under_repropagate( compose: ComposeDefinition, - gadget_defs: Mapping[str, GadgetDefinition], - compose_defs: Mapping[str, ComposeDefinition], - known_names: set[str], -) -> list[ConditionalStatement]: - """Translate ``ConditionalCorrection`` statements in *compose*'s body - into GADGET-level ``ConditionalStatement(R)`` entries that - reference absolute readout indices in the synthetic flat body - produced by :func:`expand_compose_circuit`. - - Each ``CONDITIONAL rec[-k] `` becomes a - ``CONDITIONAL R OUT

.L

...`` where ``j`` is the absolute - readout index and ``OUT

`` is the synthetic GADGET's output port - that contains *wire*. - - Top-level only: nested ``ConditionalCorrection`` inside sub-COMPOSE - bodies is handled by their own merge() pipelines and propagated - through sub-gadget composition, not re-emitted here. + gadget_definitions: Mapping[str, GadgetDefinition], + compose_definitions: Mapping[str, ComposeDefinition], +) -> None: + """Raise :class:`ValueError` if *compose* (or any reachable + sub-COMPOSE / sub-GADGET) carries a CONDITIONAL frame correction. + + ``@REPROPAGATE`` recomputes propagation matrices from circuit flow + on the inlined body. Any ``CONDITIONAL rec[-k]`` (COMPOSE-level + :class:`ConditionalCorrection`) or ``CONDITIONAL R`` (GADGET- + level :class:`ConditionalStatement`) is a readout-conditioned frame + flip that lives in ``logical_correction`` — mixing it with + circuit-flow re-derivation has unclear semantics: the CONDITIONAL's + frame flip cannot be reconstructed from the flat inlined body's + Heisenberg propagation alone. + + Rather than silently drop or fragile-inject those contributions, + reject the combination up front and direct the user to the plain + COMPOSE + CONDITIONAL idiom (where the correction is preserved + verbatim in the merged ``logical_correction`` and evaluated at + runtime via ``residual ^= lc · readouts``). """ - wire_to_output_port_idx: dict[int, int] = {} - for port_idx, port in enumerate(compose.output_ports): - for wire in port.qubit_indices: - wire_to_output_port_idx[wire] = port_idx + visited: set[str] = set() - result: list[ConditionalStatement] = [] - running_readouts = 0 + def _reject_in_gadget(gadget: GadgetDefinition) -> None: + for stmt in flatten_body(list(gadget.body)): + if isinstance(stmt, ConditionalStatement): + raise ValueError( + f"COMPOSE {compose.name!r} @REPROPAGATE: sub-GADGET " + f"{gadget.name!r} contains a CONDITIONAL frame " + f"correction, which @REPROPAGATE cannot re-derive " + f"from circuit flow. Either drop @REPROPAGATE and " + f"rely on merge-based composition, or move the " + f"CONDITIONAL out of {gadget.name!r} into a plain " + f"(non-@REPROPAGATE) COMPOSE that wraps it." + ) - def walk(body: Sequence[ComposeStatement]) -> None: - nonlocal running_readouts - for stmt in body: - if isinstance(stmt, RepeatBlock): - # Unroll the REPEAT so each iteration's - # ConditionalCorrection statements emit their own - # ConditionalStatement with the correct absolute - # readout index for that iteration. - for _ in range(stmt.count): - walk(list(stmt.body)) - continue - if isinstance(stmt, GadgetApplication): - running_readouts += _count_readouts_recursive( - stmt.gadget_name, gadget_defs, compose_defs, known_names + def _reject_in_compose(sub: ComposeDefinition, is_root: bool) -> None: + if sub.name in visited: + return + visited.add(sub.name) + for stmt in flatten_body(list(sub.body)): + if isinstance(stmt, ConditionalCorrection): + where = ( + "its own body" + if is_root + else f"sub-COMPOSE {sub.name!r}" ) - continue - if isinstance(stmt, Instruction) and stmt.name in known_names: - running_readouts += _count_readouts_recursive( - stmt.name, gadget_defs, compose_defs, known_names + raise ValueError( + f"COMPOSE {compose.name!r} @REPROPAGATE: {where} " + f"contains a CONDITIONAL frame correction, which " + f"@REPROPAGATE cannot re-derive from circuit flow. " + f"Either drop @REPROPAGATE and rely on merge-based " + f"composition, or restructure to keep CONDITIONAL " + f"statements outside any @REPROPAGATE compose." ) + if isinstance(stmt, GadgetApplication): + name = stmt.gadget_name + elif isinstance(stmt, Instruction): + name = stmt.name + else: continue - if isinstance(stmt, ConditionalCorrection): - k = stmt.readout_offset - j = running_readouts - k - if j < 0: - raise ValueError( - f"COMPOSE {compose.name!r}: CONDITIONAL " - f"rec[-{k}] references readout index {j} " - f"(only {running_readouts} readouts produced " - f"so far)" - ) - if stmt.wire not in wire_to_output_port_idx: - raise ValueError( - f"COMPOSE {compose.name!r}: CONDITIONAL on " - f"wire {stmt.wire} but no OUTPUT port covers " - f"this wire" - ) - port_idx = wire_to_output_port_idx[stmt.wire] - targets = [ - LogicalPauliTarget( - pauli=p, - index=qi, - port_kind="OUT", - port_index=port_idx, - ) - for p, qi in stmt.paulis - ] - result.append( - ConditionalStatement( - condition=ReadoutTarget(index=j), - targets=targets, - ) - ) + if name in gadget_definitions: + _reject_in_gadget(gadget_definitions[name]) + elif name in compose_definitions: + _reject_in_compose(compose_definitions[name], is_root=False) - walk(list(compose.body)) - return result + _reject_in_compose(compose, is_root=True) def compose_to_synthetic_gadget( @@ -947,31 +910,17 @@ def compose_to_synthetic_gadget( """Inline a COMPOSE body into a flat synthetic ``GadgetDefinition``. The synthetic gadget has the same name as *compose*; its body is - ``input_ports + circuit + output_ports + conditionals`` produced - by :func:`expand_compose_circuit` and - :func:`_translate_compose_conditionals`. ``ConditionalStatement`` - entries follow the OUTPUTs, matching the convention used by hand- - written GADGET bodies (see ``tests/circuit/fixtures/example.deq`` - for ``Ejection``). Decorators are dropped — the caller is - responsible for re-attaching ``@GTYPE``/``@CHECKS`` on the + ``input_ports + circuit + output_ports`` produced by + :func:`expand_compose_circuit`. Decorators are dropped — the caller + is responsible for re-attaching ``@GTYPE`` / ``@CHECKS`` on the pipeline side as needed. - Each ``ConditionalCorrection`` in the COMPOSE body is translated - into a GADGET-level ``ConditionalStatement(R)`` so the synthetic - GADGET preserves the conditional logical-frame correction. The - propagation validator uses the resulting ``logical_correction`` - matrix to extend its basis-freedom for PROPAGATE rows when the - natural Heisenberg of the inlined body does not capture the - CONDITIONAL effect (e.g. lattice surgery split-measurement frame - corrections). - - Used by ``@REPROPAGATE`` composes (both at build time and at - annotate time) so propagation matrices and noise-derived ERRORs - are computed from circuit flow rather than from sub-gadget matrix - composition. Also used by the non-``@REPROPAGATE`` annotate - pathway to recover the original ``CONDITIONAL`` statements that - ``merge()`` has folded into the COMPOSE's matrices, so the - rendered GADGET round-trips through ``deq transpile``. + Used exclusively by the ``@REPROPAGATE`` build/annotate path (see + :func:`_build_repropagated_compose`), which requires the body to be + free of any CONDITIONAL frame correction. + :func:`_reject_conditionals_under_repropagate` runs first at the + ``@REPROPAGATE`` dispatch site to enforce that invariant, so no + ``ConditionalStatement`` reconstruction is needed here. """ known_names = set(gadget_definitions) | set(compose_definitions) input_ports, circuit, output_ports = expand_compose_circuit( @@ -981,13 +930,7 @@ def compose_to_synthetic_gadget( known_names, codes, ) - conditionals = _translate_compose_conditionals( - compose, - gadget_definitions, - compose_definitions, - known_names, - ) - body: list = [*input_ports, *circuit, *output_ports, *conditionals] + body: list = [*input_ports, *circuit, *output_ports] return GadgetDefinition( name=compose.name, body=body, @@ -1040,6 +983,10 @@ def _build_repropagated_compose( _build_jit_gadget_type, ) + _reject_conditionals_under_repropagate( + compose, gadget_definitions, compose_definitions + ) + merge_jt = _build_merge_compose( compose, gtype=gtype, From c9cdc59601ab99c4fa80a7059b3ff1f880a2ecd2 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Wed, 8 Jul 2026 16:22:31 -0700 Subject: [PATCH 017/157] update language --- deq/deq/circuit/deq.lark | 2 +- deq/deq/circuit/model.py | 2 +- deq/deq/circuit/transformer.py | 25 +++++++++++++++---- .../vscode-deq/syntaxes/deq.tmLanguage.json | 4 +++ 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/deq/deq/circuit/deq.lark b/deq/deq/circuit/deq.lark index e738cb4b..7179da1b 100644 --- a/deq/deq/circuit/deq.lark +++ b/deq/deq/circuit/deq.lark @@ -112,7 +112,7 @@ propagate_statement: "PROPAGATE" logical_pauli_target FROM_KW _propagate_term* [ FLIP_KW.3: "FLIP" FROM_KW.3: "FROM" -_readout_target: target | logical_pauli_target +_readout_target: target | logical_pauli_target | INPUT_DESTAB_TARGET _error_target: check_target | readout_target | logical_pauli_target | error_pauli_target // PROPAGATE FROM lists the complete XOR formula for one row of the // output residual operator computed at runtime. The target on the diff --git a/deq/deq/circuit/model.py b/deq/deq/circuit/model.py index a3721bfb..49c9d8ae 100644 --- a/deq/deq/circuit/model.py +++ b/deq/deq/circuit/model.py @@ -251,7 +251,7 @@ def __str__(self) -> str: ErrorTarget = CheckTarget | ReadoutTarget | LogicalPauliTarget | PauliTarget -ReadoutTargetItem = Target | LogicalPauliTarget +ReadoutTargetItem = Target | LogicalPauliTarget | DestabilizerTarget # ── Stim-level instructions and circuit ────────────────────────────── diff --git a/deq/deq/circuit/transformer.py b/deq/deq/circuit/transformer.py index bda98fe4..a596b7b6 100644 --- a/deq/deq/circuit/transformer.py +++ b/deq/deq/circuit/transformer.py @@ -515,11 +515,26 @@ def port_binding_out(self, items: list[Token]) -> tuple[str, list[int]]: def readout_statement(self, items: list[Any]) -> ReadoutStatement: flip = any(isinstance(t, Token) and t.type == "FLIP_KW" for t in items) - targets: list[ReadoutTargetItem] = [ - t - for t in items - if t is not None and not (isinstance(t, Token) and t.type == "FLIP_KW") - ] + targets: list[ReadoutTargetItem] = [] + for t in items: + if t is None: + continue + if isinstance(t, Token) and t.type == "FLIP_KW": + continue + if isinstance(t, Token) and t.type == "INPUT_DESTAB_TARGET": + m = _INPUT_DESTAB_RE.match(str(t)) + if not m: + raise SyntaxError( + f"invalid INPUT destabilizer target on READOUT: {t!r}" + ) + targets.append( + DestabilizerTarget( + port_index=int(m.group(1)), + stab_index=int(m.group(2)), + ) + ) + continue + targets.append(t) return ReadoutStatement(targets=targets, flip=flip) def check_statement(self, items: list[Any]) -> CheckStatement: diff --git a/deq/deq/circuit/vscode-deq/syntaxes/deq.tmLanguage.json b/deq/deq/circuit/vscode-deq/syntaxes/deq.tmLanguage.json index 970c0695..7d369d33 100644 --- a/deq/deq/circuit/vscode-deq/syntaxes/deq.tmLanguage.json +++ b/deq/deq/circuit/vscode-deq/syntaxes/deq.tmLanguage.json @@ -511,6 +511,10 @@ "match": "L[XYZ]\\d+", "name": "entity.name.tag.logical.deq" }, + { + "match": "IN\\d+\\.DS\\d+", + "name": "support.class.check.deq" + }, { "include": "#targets" } From 3857d094a9130cf86b94623f08346fac80b670d8 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Wed, 8 Jul 2026 16:24:02 -0700 Subject: [PATCH 018/157] update tutorial chapters --- deq/documents/tutorial/README.md | 1 + .../tutorial/chapters/codes-redundant-stabilizers.md | 2 -- deq/documents/tutorial/chapters/compose-gadgets.md | 9 --------- deq/documents/tutorial/chapters/compose-repropagate.md | 1 - deq/documents/tutorial/chapters/debug-deq-program.md | 3 --- deq/documents/tutorial/chapters/floquet-code.md | 1 - deq/documents/tutorial/chapters/multi-port-gadgets.md | 2 -- deq/documents/tutorial/chapters/steane-style-ec.md | 1 - 8 files changed, 1 insertion(+), 19 deletions(-) diff --git a/deq/documents/tutorial/README.md b/deq/documents/tutorial/README.md index b1bb4dc4..1193d003 100644 --- a/deq/documents/tutorial/README.md +++ b/deq/documents/tutorial/README.md @@ -118,6 +118,7 @@ Once you become comfortable with the basics, let's look at some advanced topics: - [Logical Teleportation in COMPOSE: the `@REPROPAGATE` Decorator](chapters/compose-repropagate.md) - [Conditional Pauli Corrections: the `CONDITIONAL` Statement](chapters/conditional-correction.md) - [Lattice Surgery: The Joint-$\bar Z$ Measurement](chapters/lattice-surgery.md) + - [READOUT Propagation: Logical Dependency vs. Physical Flow](chapters/readout-propagation.md) - [Parametrization with Mako](chapters/mako-parametrization.md) - [Plug in your own decoder in Python](chapters/python-decoder.md) - [Driving the runtime from Python](chapters/python-runtime.md) diff --git a/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md b/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md index afe15b94..9abe1031 100644 --- a/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md +++ b/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md @@ -81,7 +81,6 @@ The annotated output for the Idle gadget reveals the problem: OUTPUT RepetitionCode 0 2 4 CHECK OUT0.S0 M0 CHECK OUT0.S1 M1 - # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 @@ -151,7 +150,6 @@ The annotated Idle gadget: CHECK OUT0.S0 M0 CHECK OUT0.S1 M1 CHECK OUT0.S2 M2 - # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 diff --git a/deq/documents/tutorial/chapters/compose-gadgets.md b/deq/documents/tutorial/chapters/compose-gadgets.md index 5887a74b..0c7e6246 100644 --- a/deq/documents/tutorial/chapters/compose-gadgets.md +++ b/deq/documents/tutorial/chapters/compose-gadgets.md @@ -91,7 +91,6 @@ The circuit is physically identical to running the Idle gadget 3 times. Running OUTPUT RepetitionCode 0 1 2 CHECK OUT0.S0 CHECK OUT0.S1 - # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM PROPAGATE OUT0.LX0 FROM @@ -149,7 +148,6 @@ The circuit is physically identical to running the Idle gadget 3 times. Running OUTPUT RepetitionCode 0 2 4 CHECK OUT0.S0 M4 CHECK OUT0.S1 M5 - # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 @@ -174,7 +172,6 @@ The circuit is physically identical to running the Idle gadget 3 times. Running READOUT rec[-3] rec[-2] rec[-1] # flipped by: IN0.LX0 CHECK M1 M0 IN0.S0 CHECK M2 M1 IN0.S1 - # PROPAGATE below reflects the joint effect of all statements above # --- statistics --- # finished checks: 2 @@ -327,7 +324,6 @@ Running `annotate` on the COMPOSE version produces a flattened `GADGET` block: OUTPUT RepetitionCode 0 1 2 CHECK OUT0.S0 CHECK OUT0.S1 - # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM PROPAGATE OUT0.LX0 FROM @@ -359,7 +355,6 @@ Running `annotate` on the COMPOSE version produces a flattened `GADGET` block: OUTPUT RepetitionCode 0 2 4 CHECK OUT0.S0 M0 CHECK OUT0.S1 M1 - # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 @@ -384,7 +379,6 @@ Running `annotate` on the COMPOSE version produces a flattened `GADGET` block: READOUT rec[-3] rec[-2] rec[-1] # flipped by: IN0.LX0 CHECK M1 M0 IN0.S0 CHECK M2 M1 IN0.S1 - # PROPAGATE below reflects the joint effect of all statements above # --- statistics --- # finished checks: 2 @@ -688,7 +682,6 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl OUTPUT RepetitionCode 0 1 2 CHECK OUT0.S0 CHECK OUT0.S1 - # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM PROPAGATE OUT0.LX0 FROM @@ -720,7 +713,6 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl OUTPUT RepetitionCode 0 2 4 CHECK OUT0.S0 M0 CHECK OUT0.S1 M1 - # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 @@ -745,7 +737,6 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl READOUT rec[-3] rec[-2] rec[-1] # flipped by: IN0.LX0 CHECK M1 M0 IN0.S0 CHECK M2 M1 IN0.S1 - # PROPAGATE below reflects the joint effect of all statements above # --- statistics --- # finished checks: 2 diff --git a/deq/documents/tutorial/chapters/compose-repropagate.md b/deq/documents/tutorial/chapters/compose-repropagate.md index d1fbcac4..be3ccac2 100644 --- a/deq/documents/tutorial/chapters/compose-repropagate.md +++ b/deq/documents/tutorial/chapters/compose-repropagate.md @@ -374,7 +374,6 @@ The annotated COMPOSE renders as a flat `GADGET Teleport` block: CHECK OUT0.S0 IN0.S0 CHECK OUT0.S1 IN0.S1 CHECK OUT0.S2 M0 - # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 M1 M3 PROPAGATE OUT0.LX0 FROM IN0.LX0 diff --git a/deq/documents/tutorial/chapters/debug-deq-program.md b/deq/documents/tutorial/chapters/debug-deq-program.md index 62521fe5..b64e6527 100644 --- a/deq/documents/tutorial/chapters/debug-deq-program.md +++ b/deq/documents/tutorial/chapters/debug-deq-program.md @@ -44,7 +44,6 @@ Output: OUTPUT RepetitionCode 0 1 2 CHECK OUT0.S0 CHECK OUT0.S1 - # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM PROPAGATE OUT0.LX0 FROM @@ -76,7 +75,6 @@ Output: OUTPUT RepetitionCode 0 2 4 CHECK OUT0.S0 M0 CHECK OUT0.S1 M1 - # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 @@ -101,7 +99,6 @@ Output: READOUT rec[-3] rec[-2] rec[-1] # flipped by: IN0.LX0 CHECK M1 M0 IN0.S0 CHECK M2 M1 IN0.S1 - # PROPAGATE below reflects the joint effect of all statements above # --- statistics --- # finished checks: 2 diff --git a/deq/documents/tutorial/chapters/floquet-code.md b/deq/documents/tutorial/chapters/floquet-code.md index 0469b4f9..360f6c84 100644 --- a/deq/documents/tutorial/chapters/floquet-code.md +++ b/deq/documents/tutorial/chapters/floquet-code.md @@ -379,7 +379,6 @@ The result for `RoundRed` is: CHECK OUT0.S15 IN0.S15 CHECK OUT0.S16 IN0.S16 CHECK OUT0.S17 IN0.S17 - # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 IN0.DS1 IN0.DS7 PROPAGATE OUT0.LZ1 FROM IN0.LZ1 M6 M7 diff --git a/deq/documents/tutorial/chapters/multi-port-gadgets.md b/deq/documents/tutorial/chapters/multi-port-gadgets.md index 575f2f3a..e77fa653 100644 --- a/deq/documents/tutorial/chapters/multi-port-gadgets.md +++ b/deq/documents/tutorial/chapters/multi-port-gadgets.md @@ -76,7 +76,6 @@ The transpiler derives 4 unfinished checks — let's look at the annotated outpu CHECK OUT0.S1 IN0.S1 CHECK OUT1.S0 IN1.S0 IN0.S0 CHECK OUT1.S1 IN1.S1 IN0.S1 - # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 PROPAGATE OUT1.LZ0 FROM IN1.LZ0 @@ -172,7 +171,6 @@ With noise, the error structure reveals the CNOT's impact on decoding: CHECK OUT0.S1 IN0.S1 CHECK OUT1.S0 IN1.S0 IN0.S0 CHECK OUT1.S1 IN1.S1 IN0.S1 - # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 PROPAGATE OUT1.LZ0 FROM IN1.LZ0 diff --git a/deq/documents/tutorial/chapters/steane-style-ec.md b/deq/documents/tutorial/chapters/steane-style-ec.md index f4d1287d..f2d74c43 100644 --- a/deq/documents/tutorial/chapters/steane-style-ec.md +++ b/deq/documents/tutorial/chapters/steane-style-ec.md @@ -128,7 +128,6 @@ Running `deq annotate` on this gadget reveals the check structure: CHECK OUT0.S3 CHECK OUT0.S4 CHECK OUT0.S5 - # PROPAGATE below reflects the joint effect of all statements above PROPAGATE OUT0.LZ0 FROM IN0.LZ0 M6 M7 M8 PROPAGATE OUT0.LX0 FROM IN0.LX0 M13 M14 M15 From d915a55cb93390c793ded7c96dec24f345b93033 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Thu, 9 Jul 2026 14:51:06 -0700 Subject: [PATCH 019/157] check in lattice surgery chapter --- .../tutorial/chapters/lattice-surgery.md | 783 ++++++++++++++++++ deq/documents/tutorial/examples/.gitignore | 1 + .../00_lattice_surgery_library.deq | 3 + .../01_mzz_before_conditional.deq | 36 + .../02_ls_merge_multi_round.deq | 134 +++ .../lattice-surgery/gen_lattice_surgery.py | 47 ++ .../circuit/fixtures/trivial_surgery.deq | 50 ++ 7 files changed, 1054 insertions(+) create mode 100644 deq/documents/tutorial/chapters/lattice-surgery.md create mode 100644 deq/documents/tutorial/examples/lattice-surgery/01_mzz_before_conditional.deq create mode 100644 deq/documents/tutorial/examples/lattice-surgery/02_ls_merge_multi_round.deq create mode 100644 deq/documents/tutorial/examples/lattice-surgery/gen_lattice_surgery.py diff --git a/deq/documents/tutorial/chapters/lattice-surgery.md b/deq/documents/tutorial/chapters/lattice-surgery.md new file mode 100644 index 00000000..77c4d374 --- /dev/null +++ b/deq/documents/tutorial/chapters/lattice-surgery.md @@ -0,0 +1,783 @@ +# Lattice Surgery: The Joint-$\bar Z$ Measurement + +The [`CONDITIONAL` chapter](conditional-correction.md) closed with a warning +that the reader can safely ignore for teleportation-style gadgets: **the same +physical circuit realises many inequivalent logical actions**, and deq +deliberately refuses to guess which one you want. For Bell-pair teleportation +the choice is invisible because there is only one natural reading — the flow +solver picks it, `@REPROPAGATE` and `CONDITIONAL` agree with it, and the user +never has to think about the ambiguity. + +Lattice surgery is where the ambiguity stops being an abstraction and starts +biting. The joint-$\bar Z$ merge $\mathrm{MZZ}$ takes two surface-code patches +and reads out the joint parity $\bar Z_A \bar Z_B$ — but its physical body +(six joint-Pauli MPPs plus a destructive MX seam) is *equally consistent* with +several different logical actions. Two ambiguities show up: + +1. **`MZZ` versus `MRZZ`** — the same physical body is consistent with both + a pure joint-Z measurement (`MZZ`: read $\bar Z_A \bar Z_B$, leave the + individual $\bar Z_A$, $\bar Z_B$ frames alone so the post-merge state + stays in whichever $\bar Z_A \bar Z_B = \pm 1$ branch the measurement + projected onto) and a joint-Z measure-and-reset (`MRZZ`: read + $\bar Z_A \bar Z_B$, then classically flip patch B's $\bar Z$ frame + whenever the readout is $1$ so the post-merge state is always in the + $+1$ eigenspace — equivalently, patch B's post-merge $\bar Z$ frame is + always forced to agree with patch A's). Without user guidance, deq's + auto-derived flow silently picks the `MRZZ` reading. A hand-written + `PROPAGATE OUT1.LX0 FROM IN1.LX0` row overrides that and pins the + honest `MZZ` reading (an equivalent `CONDITIONAL R0 OUT1.LX0` byproduct + would do the same job — the two forms are derived to be equivalent + below). (The naming mirrors Stim's single-qubit `MZ` / `MRZ` + distinction — `M*` measures only, `MR*` measures and resets.) +2. **Individual $\bar X$ vs joint $\bar X_A \bar X_B$** — because `MZZ` + outputs two individual `SurfaceCode` ports, deq's per-port flow solver + looks for a $\bar X$-flow on each patch separately. Neither individual + $\bar X_A$ nor $\bar X_B$ has one: both anticommute with the joint-Z + observable, so the merge projection destroys them. What survives + is the *product* $\bar X_A \bar X_B$ (it commutes with $\bar Z_A \bar + Z_B$), and an honest joint-Z measurement must preserve it — so the user + has to declare *how* this joint $\bar X$ contribution is distributed + across the two output ports via a hand-written `PROPAGATE` row, since + neither the per-port solver nor the framework can derive it on its own. + +The rest of this chapter solves these two problems in turn — first the +hand-written `PROPAGATE` fix for Ambiguity 1 (with `CONDITIONAL` covered as +an equivalent alternative), then the hand-written `PROPAGATE` row rewrite +for Ambiguity 2. Declaring both byproducts makes MZZ *semantically* +correct, but a single-round MZZ is not fault-tolerant on its own — a +further refactor into repeated single-SE-round GADGETs at the COMPOSE level +is what restores the $\mathrm{LER} \propto p^{(d+1)/2}$ surface-code scaling. + +**Prerequisites.** Read the [`CONDITIONAL` chapter](conditional-correction.md) +first; this chapter assumes familiarity with `@REPROPAGATE`, +`CONDITIONAL rec[-k] `, and the COMPOSE-level Pauli-frame +correction machinery that both feed into. + +--- + +## The MZZ merge in one figure + +![Stim `detslice-svg` snapshot of the merged-code stabilizer group before and after the MZZ merge. Left: two independent distance-3 rotated surface-code patches with 16 stabilizers (red = X-type, blue = Z-type); the seam column sits bare between them. Right: after the six merge measurements the same 21 qubits form one merged code with 20 stabilizers — four new bulk plaquettes span the seam and two new Z 2-body boundary plaquettes complete the checkerboard at the top and bottom, while the previously-independent X 2-body edge stabs (patch A's `X5*X8` and patch B's `X9*X12`) get absorbed into the new bulk plaquettes. Regenerate with [`stabilizer_flow.py`](../examples/lattice-surgery/stabilizer_flow.py).](../examples/lattice-surgery/mzz_stabilizer_flow.png) + +Two distance-3 rotated surface-code patches sit horizontally side-by-side with +an intermediate column of three data qubits between them. The single-shot +merge does three things: + +1. Initialize the seam column in $|+\rangle$ (`RX 18 19 20`), pinning the + seam-X stabilizers. +2. Measure six new merge stabilizers on the seam (four bulk plaquettes plus + two Z-type boundary 2-bodies) — call the outcomes $M_0 \dots M_5$. +3. Destructively measure the seam column in the X basis (`MX 18 19 20`) to + split the patches back apart — outcomes $M_6, M_7, M_8$. + +The joint $\bar Z_A \bar Z_B$ parity comes out of the Z-type merge +measurements as $M_0 \oplus M_3 \oplus M_4 \oplus M_5$; that XOR is exposed as +the merge's single-bit `READOUT R0`. The full gadget lives in the +lattice-surgery library: + +[`MZZ` and its `ComposeMZZ` wrapper (single-shot merge)](../examples/lattice-surgery/00_lattice_surgery_library.deq) + +

# Shared library for the lattice-surgery chapter.
+#
+# The physical mechanics of the joint-Z merge (geometry, stabilizer
+# derivation, byproduct semantics) are documented in
+# ``documents/tutorial/chapters/lattice-surgery.md`` and in the
+# annotated fixture at ``tests/circuit/surface_code/lattice_surgery_d3.deq``.
+
+CODE SurfaceCode [[9,1,3]] {
+    LOGICAL X0*X1*X2 Z0*Z3*Z6
+    STABILIZER Z1*Z2 X0*X3 Z0*Z1*Z3*Z4 X1*X2*X4*X5 X3*X4*X6*X7 Z4*Z5*Z7*Z8 X5*X8 Z6*Z7
+}
+
+GADGET PrepareZ {
+    RZ 0 1 2 3 4 5 6 7 8
+    I 0 1 2 3 4 5 6 7 8  # so that data-qubit error can be injected
+
+    # Single round of syndrome extraction to project into the code space.
+    MPP Z1*Z2
+    MPP X0*X3
+    MPP Z0*Z1*Z3*Z4
+    MPP X1*X2*X4*X5
+    MPP X3*X4*X6*X7
+    MPP Z4*Z5*Z7*Z8
+    MPP X5*X8
+    MPP Z6*Z7
+
+    OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8
+}
+
+GADGET MeasureZ {
+    INPUT SurfaceCode 0 1 2 3 4 5 6 7 8
+    I 0 1 2 3 4 5 6 7 8  # so that data-qubit error can be injected
+    MZ 0 1 2 3 4 5 6 7 8
+    READOUT rec[-9] rec[-6] rec[-3]
+}
+
+GADGET MZZ {
+    INPUT SurfaceCode 0 1 2 3 4 5 6 7 8
+    INPUT SurfaceCode 9 10 11 12 13 14 15 16 17
+
+    I 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17  # so that data-qubit error can be injected
+    RX 18 19 20
+
+    MPP Z2*Z5*Z18*Z19       # M0
+    MPP X5*X8*X19*X20       # M1
+    MPP X9*X12*X18*X19      # M2
+    MPP Z12*Z15*Z19*Z20     # M3
+    MPP Z9*Z18              # M4
+    MPP Z8*Z20              # M5
+
+    MX 18 19 20             # M6 M7 M8
+
+    READOUT M0 M3 M4 M5
+
+    OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8
+    OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17
+
+    PROPAGATE OUT1.LX0 FROM IN1.LX0
+    PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M6
+    PROPAGATE OUT1.LZ0 FROM
+}
+
+COMPOSE ComposeMZZ {
+    INPUT SurfaceCode 0
+    INPUT SurfaceCode 1
+    MZZ 0 1
+    OUTPUT SurfaceCode 0
+    OUTPUT SurfaceCode 1
+}
+
+PROGRAM ComposeMZZMemoryZ {
+    PrepareZ 0
+    PrepareZ 1
+    ComposeMZZ 0 1
+    MeasureZ 0
+    MeasureZ 1
+    ASSERT_EQ rec[-3] 0   # joint LZ_A*LZ_B parity = +1
+    ASSERT_EQ rec[-2] 0   # MeasureZ patch A
+    ASSERT_EQ rec[-1] 0   # MeasureZ patch B
+}
+ + +The body ends with the three *declarative* statements this chapter is about: + +[`MZZ` body — READOUT, OUTPUT ports, and the three declarative statements](../examples/lattice-surgery/00_lattice_surgery_library.deq#L50-L59) + +

+    MX 18 19 20             # M6 M7 M8
+
+    READOUT M0 M3 M4 M5
+
+    OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8
+    OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17
+
+    PROPAGATE OUT1.LX0 FROM IN1.LX0
+    PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M6
+ + +Together those three hand-written `PROPAGATE` rows tell deq *which logical +action* this merge is supposed to represent. Delete them and the compiler +still accepts the gadget — it just installs a different (and physically +wrong, for the joint-Z measurement we wanted) logical map. + +The rest of this chapter explains why each of those three lines is there. + +--- + +## Ambiguity 1: the branch-dependent frame flip + +### Spotting the problem in `deq annotate`'s naive output + +Delete all three hand-written `PROPAGATE` rows from `MZZ` and let +`deq annotate` derive everything from the physical body alone. A minimal +self-contained copy of that stripped body lives at +[`01_mzz_before_conditional.deq`](../examples/lattice-surgery/01_mzz_before_conditional.deq), +and running the annotator on it produces +[`01_mzz_before_conditional.annotated.deq`](../examples/lattice-surgery/01_mzz_before_conditional.annotated.deq). +Two pieces of that output matter for what follows. + +**Piece 1** — the `READOUT` line, which carries a `# flipped by:` comment +recording how the raw merge readout `R0 = M0 ⊕ M3 ⊕ M4 ⊕ M5` relates to +the input observables (this is what `readout_propagation` computed for +R0): + +[the annotator's READOUT line for the un-fixed `MZZ` gadget](../examples/lattice-surgery/01_mzz_before_conditional.annotated.deq#L27) + +
    READOUT M0 M3 M4 M5  # flipped by: IN0.LX0 IN1.LX0 IN0.DS0 IN0.DS2 IN0.DS5 IN0.DS7
+ + +That comment reflects a division of labour. The user's `READOUT M0 +M3 M4 M5` line declares which physical measurement bits make up +`R0`'s raw value: + +$$R_0^{\text{raw}} \;=\; M_0 \oplus M_3 \oplus M_4 \oplus M_5.$$ + +The transpiler analyzes what those physical measurements actually +measure on the two-patch pre-merge state and reports which input +observables' values would flip `R0`'s raw value if they were flipped +in the pre-merge frame — that's the `# flipped by:` list, and it's +stored on the compiled binary as `readout_propagation`. At runtime, +the framework combines the two to produce the value of `R0` in the +input frame: + +$$R_0 \;=\; R_0^{\text{raw}} \;\oplus\; \bigl(\text{IN0.LX0} \oplus +\text{IN1.LX0} \oplus \text{IN0.DS0} \oplus \text{IN0.DS2} \oplus +\text{IN0.DS5} \oplus \text{IN0.DS7}\bigr).$$ + +(In the noiseless case this equals the true joint parity $\bar Z_A +\bar Z_B$, evaluated on the pre-merge input observables plus their +stabilizer-syndrome bits. The runtime uses exactly this combined +expression whenever it substitutes `R0` into a downstream frame +formula.) + +Two kinds of input bits appear in the "flipped by" list, each with a +distinct physical meaning. + +**The two logical bits (`IN0.LX0`, `IN1.LX0`).** `R0` measures the +joint Z observable $\bar Z_A \bar Z_B$, and applying an X on either +patch's logical qubit anti-commutes with that patch's Z observable — +so of course either patch's X flips R0. In the framework's PROPAGATE +convention `LX` names the row that tracks the Z-observable value +(an X error is what flips a Z outcome), so both input LX rows appear: +one X on either patch is enough to flip R0. + +**The four destabilizer bits (`IN0.DS0`, `IN0.DS2`, `IN0.DS5`, +`IN0.DS7`).** These appear because `R0`'s physical operator on patch A +is *not* the code's natural $\bar Z_A$ representative. Cancelling the +seam-qubit factors from $M_0 \oplus M_3 \oplus M_4 \oplus M_5$ leaves + +$$ +Z_2 Z_5 Z_{18} Z_{19} \cdot Z_{12} Z_{15} Z_{19} Z_{20} \cdot Z_9 Z_{18} \cdot Z_8 Z_{20} +\;=\; \underbrace{Z_2 Z_5 Z_8}_{\text{patch A, right column}} +\cdot \underbrace{Z_9 Z_{12} Z_{15}}_{\text{patch B, left column}} +$$ + +On patch B this is already the code's natural $\bar Z$ representative +— the code declares `LOGICAL ... Z0*Z3*Z6`, which on patch B's qubit +range 9–17 is $Z_9 Z_{12} Z_{15}$ (the left column) — so no shift is +needed and no `IN1.DS*` bits appear. On patch A the code's natural +representative is $Z_0 Z_3 Z_6$ (the left column), but $R_0$ measured +$Z_2 Z_5 Z_8$ (the right column) instead. To translate R0's flip +condition into the natural input frame, the transpiler shifts the +right column back to the left via stabilizers: + +$$ +Z_2 Z_5 Z_8 \;=\; \underbrace{Z_0 Z_3 Z_6}_{\text{LZ}_A} +\;\oplus\; \underbrace{Z_1 Z_2}_{S_0} +\;\oplus\; \underbrace{Z_0 Z_1 Z_3 Z_4}_{S_2} +\;\oplus\; \underbrace{Z_4 Z_5 Z_7 Z_8}_{S_5} +\;\oplus\; \underbrace{Z_6 Z_7}_{S_7}. +$$ + +Every qubit index appears an even number of times on the right except +$\{2, 5, 8\}$, which appear once each — verifying the shift. The +four stabilizers ($S_0, S_2, S_5, S_7$) needed for the shift show up +as the four destabilizer references `IN0.DS0`, `IN0.DS2`, `IN0.DS5`, +`IN0.DS7` in the "flipped by" list. (In the framework's PROPAGATE +algebra, an input stabilizer's measurement-outcome bit is XOR'd in via +the destabilizer column `IN

.DS` of `correction_propagation`.) + +**Piece 2** — the four auto-derived `PROPAGATE` rows at the tail of the +file: + +[the auto-derived `PROPAGATE` rows of the un-fixed `MZZ` gadget](../examples/lattice-surgery/01_mzz_before_conditional.annotated.deq#L48-L52) + +

    PROPAGATE OUT0.LZ0 FROM
+    PROPAGATE OUT0.LX0 FROM IN0.LX0
+    PROPAGATE OUT1.LZ0 FROM
+    PROPAGATE OUT1.LX0 FROM IN0.LX0 IN0.DS0 IN0.DS2 IN0.DS5 IN0.DS7 M0 M3 M4 M5
+
+ + +Stare at those four rows for a moment. `OUT0.LX0 FROM IN0.LX0` is +clean — patch A's pre-merge Z frame propagates to OUT0's post-merge Z +frame untouched. `OUT1.LX0`, by symmetry, ought to read +`FROM IN1.LX0` — patch B's pre-merge Z frame propagates to OUT1's +post-merge Z frame untouched. Instead the annotator produced: + +```text +PROPAGATE OUT1.LX0 FROM IN0.LX0 IN0.DS0 IN0.DS2 IN0.DS5 IN0.DS7 M0 M3 M4 M5 +``` + +**`IN1.LX0` is nowhere in the file.** Patch B's own Z tracker has +vanished from the annotated propagation; patch A's tracker plus a pile +of merge bits and patch-A destabilizers has taken its place. Where did +patch B's Z frame go? + +### Why deq's Heisenberg picks patch A's frame + +The natural-Heisenberg row for `OUT1.LX0` is the flow solver's answer +to *"expressed as an XOR of input observables, destabilizers, and body +measurements, what is a propagated version of patch B's post-merge Z +string $Z_9 Z_{12} Z_{15}$?"*. Two things pin the specific answer down. + +First, **the merge projects the two-patch state onto the joint-Z +eigenspace, so patch A's $\bar Z_A$ and patch B's $\bar Z_B$ are no +longer independent observables of the merged system** — they're equal +modulo the four Z-type merge measurements plus a handful of patch-A +destabilizers, which is exactly the identity from Piece 1 above. In +deq's frame-column convention `LX` names the row that tracks the +$\bar Z$-observable value (an X error is what flips a Z outcome). The solver therefore +has *GF(2) freedom* in which pre-merge $\bar Z$ frame to charge the +row against: `IN0.LX0 ⊕ (merge bits) ⊕ (destabilizer bits)` and +`IN1.LX0` produce the same physical observable on the output. + +Second, `_compute_pc_logical_via_flows` walks the input ports in order +and returns the first valid representative it finds. Port 0 (patch A) +gets tried first, so the row lands on `IN0.LX0 + destabilizer bits + +measurement bits` rather than the mirror-image row on port 1. A +different port ordering would have produced the mirror. + +**Semantic reading**: deq's naive interpretation is *"patch B's +post-merge Z frame equals patch A's pre-merge Z frame ⊕ the joint-parity +readout"* — patch B has been silently rewritten to agree with patch A +up to R0. That's the `MRZZ` measure-and-reset behaviour from +Ambiguity 1's intro. It's a self-consistent logical map, but it's the +wrong one for the joint-measurement gadget we're trying to build; an +honest `MZZ` should leave both individual Z frames alone and expose the +joint parity as a *separate* readout. + +### Two equivalent fixes + +The gadget author has two clean ways to restore `OUT1.LX0 = IN1.LX0`. + +**Fix A: `CONDITIONAL R0 OUT1.LX0`.** Leave the naive `PROPAGATE` row +alone; add an R0 XOR on top via a `CONDITIONAL` statement. At runtime +the framework evaluates + +$$\text{residual}[\text{OUT1.LX0}] +\;=\; \underbrace{cp \cdot \text{inputs} \;\oplus\; pc \cdot \text{raw}}_{\text{the naive PROPAGATE row}} +\;\oplus\; \underbrace{lc \cdot \text{readouts}}_{= \;R_0}.$$ + +**Fix B: `PROPAGATE OUT1.LX0 FROM IN1.LX0`.** Overwrite the residual +directly. Since `PROPAGATE` is authoritative, this replaces the naive +row wholesale — same shape as the auto-derived `OUT0.LX0 FROM IN0.LX0`, +just mirrored to patch B. No `CONDITIONAL`, no R0 arithmetic to +reason about. + +Neither fix changes any physical instruction; both change only the +*logical interpretation* the framework installs. + +### Why the two fixes are equivalent + +We can derive that they produce identical runtime residuals without +running a single shot, using the R0-identity from Piece 1. Start with +the naive row: + +$$\text{naive row for OUT1.LX0} +\;=\; \text{IN0.LX0} \oplus \text{IN0.DS0} \oplus \text{IN0.DS2} +\oplus \text{IN0.DS5} \oplus \text{IN0.DS7} \oplus M_0 \oplus M_3 \oplus M_4 \oplus M_5.$$ + +Fix A instructs the runtime to XOR `R0`'s value on top. Substituting +`R0`'s two-part form ($R_0 = R_0^{\text{raw}} \oplus \text{(input-frame +contribution)}$ from Piece 1): + +$$ +\begin{aligned} +\text{OUT1.LX0} &\;=\; \text{naive row} \;\oplus\; R_0 \\ +&\;=\; \bigl(\text{IN0.LX0} \oplus \text{IN0.DS0} \oplus \dots +\oplus \text{IN0.DS7} \oplus M_0 \oplus M_3 \oplus M_4 \oplus M_5\bigr) \\ +&\phantom{\;=\;} \oplus\; \underbrace{\bigl(M_0 \oplus M_3 \oplus M_4 \oplus M_5\bigr)}_{R_0^{\text{raw}}} \\ +&\phantom{\;=\;} \oplus\; \underbrace{\bigl(\text{IN0.LX0} \oplus \text{IN1.LX0} +\oplus \text{IN0.DS0} \oplus \dots \oplus \text{IN0.DS7}\bigr)}_{\text{readout\_propagation}[R_0] \,\cdot\, \text{inputs}} \\ +&\;=\; \text{IN1.LX0}. +\end{aligned} +$$ + +Every term of the naive row is XOR-cancelled by its counterpart in +`R0`'s two parts, except the single `IN1.LX0` bit that R0's +input-frame contribution carries but the naive row doesn't — that +survives and becomes the final residual. Fix B installs `IN1.LX0` +directly. Same value, at the level the runtime evaluates. + +**Compiled-binary side effect.** The two fixes are not byte-identical +in the compiled matrices: Fix A leaves `cp[OUT1.LX0]` and `pc[OUT1.LX0]` +as the naive row and sets `logical_correction[OUT1.LX0, R0] = 1`; Fix B +pushes `cp[OUT1.LX0]` to `IN1.LX0` and leaves `logical_correction` +empty. The final residual is the same either way — the runtime +evaluates `residual ^= lc · readouts` unconditionally, so Fix A's +`R0` contribution enters at runtime while Fix B pre-computes the same +final residual in `cp` — but the matrices carrying it are different. + +### Which one should you write? + +Both are valid. For most cases we recommend **Fix B (direct +`PROPAGATE` rewrite)**: + +* The mirror-symmetric shape (`OUT0.LX0 FROM IN0.LX0` / + `OUT1.LX0 FROM IN1.LX0`) is self-explanatory: patch A's Z frame + passes to OUT0, patch B's to OUT1. +* No cross-reference to R0's own definition is needed to read the row. +* The correction lives entirely in the static `cp` matrix — no + runtime `lc · readouts` evaluation is needed for this row. + +### Empirical validation + +Both fixes pass the same anti-correlation sanity check, and both +un-fixed variants fail it. With `LogicalX = VIRTUAL LX0` applied to +patch A, the corrected-frame state is +$|\Psi^+\rangle = (|1_L 0_L\rangle + |0_L 1_L\rangle)/\sqrt 2$; the +honest joint measurement predicts anti-correlated individual outcomes +$(joint, A, B) \in \{(1, 0, 1), (1, 1, 0)\}$. +[`PROGRAM BellPairWithLogicalXJointZZ`](../../../tests/circuit/surface_code/lattice_surgery_d3.deq#L271-L281) + +
PROGRAM BellPairWithLogicalXJointZZ {
+    PrepareX 0
+    PrepareZ 1
+    TransversalCNOT 0 1
+    LogicalX 0
+    ComposeMZZ 0 1
+    MeasureZ 0
+    MeasureZ 1
+    ASSERT_EQ rec[-3] 1   # joint LZ_A·LZ_B parity = −1 → readout 1
+}
+
+ +produces exactly that pattern under either fix, and produces the +*correlated* pattern $\{(1, 0, 0), (1, 1, 1)\}$ when both are absent +(the joint readout is right but the individual outcomes agree instead +of disagreeing, exposing the silent patch-B rewrite). + + +## Ambiguity 2: joint $\bar X_A \bar X_B$ preservation + +The mirror-image table above reveals a *second* post-merge invariant that +the framework's per-port flow solver refuses to derive automatically: the +joint logical-X observable $\bar X_A \bar X_B$. Just like the joint $\bar Z$ +sits on `OUT1.LX0` with a measurement-driven sign flip, the joint $\bar X$ +sits on `OUT0.LZ0` and absorbs a single MX-seam outcome: + +[the two joint-$\bar X$ hand-written `PROPAGATE` rows in `MZZ`](../examples/lattice-surgery/00_lattice_surgery_library.deq#L56-L59) + +
    OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17
+
+    PROPAGATE OUT1.LX0 FROM IN1.LX0
+    PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M6
+ + +The question is: why do these rows need to be hand-written at all? + +### Why deq refuses to auto-derive the joint-$\bar X$ flow + +The merge body's per-port Heisenberg analysis (the GF(2) solver in +`_compute_pc_logical_via_flows`) runs **once per output logical column**. +For OUT0's $\bar X$ row (rendered `OUT0.LZ0`) it sets the target Pauli to +`OUT0`'s natural $\bar X$ representative — patch A's top-row X-string +$X_0 X_1 X_2$ — and asks `stim.Circuit.has_flow(...)` whether that specific +Pauli is preserved through the merge. It is not: $X_0 X_1 X_2$ +anti-commutes with the MZZ measurement. The same negative result holds for OUT1's +$\bar X$ candidate $X_9 X_{10} X_{11}$. Neither individual $\bar X$ +survives. + +What *does* survive is the **product** $\bar X_A \cdot \bar X_B = X_0 X_1 +X_2 \cdot X_9 X_{10} X_{11}$ — that's the joint-XX Bell stabilizer, and the +merge preserves it (with one measurement bit absorbing the sign). But this +Pauli *spans both output ports*, and the per-port solver only ever +considers per-port targets, so neither row's solver call finds it. + +The framework deliberately stops here rather than guessing. The user still +*wants* the joint $\bar X_A \bar X_B$ parity preserved even though neither +individual $\bar X$ is determined: it is what turns this gadget into a +genuine joint-$\bar Z$ measurement (which by definition must leave +everything that commutes with joint $\bar Z$ untouched) rather than a +measure-and-then-scramble-X operation. Because the framework's per-port +flow solver cannot express "joint XX on some port" without user input, the +user has to declare that preservation explicitly via a hand-written +`PROPAGATE` row. + +Once both candidate rows fail, the only remaining decisions are +*logical-level conventions* the framework cannot fix from circuit structure +alone: **Which port carries the joint observable?** +Adding the row to *both* +OUT0 and OUT1 would XOR them to zero in the symplectic algebra and erase +the observable. Placing it on OUT0 alone or OUT1 alone is otherwise +equivalent: the two placements differ by a $\bar Z_A \bar Z_B$ operator +(mirror-swapping which port anchors the joint $\bar X$), and the merge +has just projected the state into a $\pm 1$ eigenstate of $\bar Z_A +\bar Z_B$, so that operator acts as a global sign that any downstream +observable sees identically. + +Both decisions belong to the surgery's logical specification, not the +gadget's circuit. The framework leaves the row empty so the test suite +catches the missing declaration loudly (the `BellPair*SurvivesMerge` +programs in `00_lattice_surgery_library.deq` fail at 50% LER without the +declaration), and the user supplies a hand-written `PROPAGATE` row to pin +the conventional choice. + +--- + +## Error suppression: making the MZZ fault-tolerant + +Getting the byproducts right makes `MZZ` *semantically* the correct joint-$\bar +Z$ measurement. It does not yet make it a good *fault-tolerant* joint +measurement — that is a separate question about the underlying physical +circuit. + +### Single-round MZZ is inherently non-fault-tolerant + +`deq inject si1000` layers depolarizing/measurement noise onto the physical +gates in `00_lattice_surgery_library.deq`; `deq simulate ler` then runs the +compiled `ComposeMZZMemoryZ` under a black-box relay-BP decoder. Repeat +one command per noise rate: + +```sh +# Regenerate the noisy library for a given p (both *.noisy.deq are gitignored). +deq inject si1000 00_lattice_surgery_library.deq --p 1e-4 \ + --out 00_lattice_surgery_library.noisy.deq + +deq simulate ler 00_lattice_surgery_library.noisy.deq \ + --program ComposeMZZMemoryZ \ + --shots 30000000 --errors 1000 --batch-size 5000 --seed 42 +``` + +Sweeping $p \in \{1{\times}10^{-3},\, 5{\times}10^{-4},\, 3{\times}10^{-4},\, +2{\times}10^{-4},\, 1{\times}10^{-4}\}$ produces: + +| Physical rate $p$ | LER `ComposeMZZMemoryZ` | +| ------------------- | ----------------------- | +| $1.0\times 10^{-3}$ | $7.44 \times 10^{-3}$ | +| $5.0\times 10^{-4}$ | $3.60 \times 10^{-3}$ | +| $3.0\times 10^{-4}$ | $2.07 \times 10^{-3}$ | +| $2.0\times 10^{-4}$ | $1.32 \times 10^{-3}$ | +| $1.0\times 10^{-4}$ | $6.57 \times 10^{-4}$ | + +`ComposeMZZMemoryZ` stays at $\mathrm{LER} \approx 7 p$ across the +whole range — the log-log slope is $1$, not the $(d+1)/2 = 2$ expected of a +fault-tolerant $d = 3$ protocol. This is not a threshold-crossing problem +that lowering $p$ (or raising $d$) would fix: a single-shot merge cannot +produce round-to-round comparison syndromes at all, so every measurement +error on a merge MPP feeds straight into the logical readout regardless of +code distance. `CONDITIONAL` and the hand-written `PROPAGATE` rows make +the gadget *semantically* correct, but the *structure* of the physical +circuit is what determines whether it is fault-tolerant. + +### Recovering fault tolerance with repeated merge rounds + +The single-round joint merge can be turned into a multi-round one by +repeating the six MPP measurements before the destructive split. Each +repeated measurement gives the decoder a *time-edge* syndrome: round $k$'s +outcome XORed against round $k+1$'s outcome equals zero in the absence of +measurement error, so the decoder can localize and correct a faulty MPP +rather than letting it slip straight into the logical readout. + +Repeating the merge measurements requires the same GADGET / COMPOSE +factoring that turns a single SE round into a fault-tolerant memory: one +GADGET per merged-code SE round, and a COMPOSE-level `REPEAT` around them. +See [Composing Gadgets with COMPOSE](compose-gadgets.md) for the mechanics. +Concretely we factor the merge into three GADGETs operating on a dedicated +`MergedSurface [[21, 1]]` code: + +* `MergeBegin` initializes the seam in $|+\rangle$, measures the six new + merge stabilizers once (their XOR is the joint-parity readout `R0`), and + lifts the two input `SurfaceCode` patches into the merged code. +* `MergedSE` performs a single SE round on the merged code, measuring all + 20 stabilizers. Repeating it gives the decoder round-to-round time + edges on the joint-Z stabilizer. +* `MergeEnd` is just the destructive `MX 18 19 20` of the seam column + that splits the merged code back into two `SurfaceCode` patches; it does + not re-measure any stabilizer (that job belongs to `MergedSE`). + +The example file Mako-parametrizes the SE-round count `r` ($r \geq 0$) +via a COMPOSE-level `REPEAT ${r} { MergedSE }`. Because `MergeBegin`'s +measurements are consumed by the readout, the earliest time edge on the +joint stabilizer is between `MergedSE` rounds — recovering the +$\mathrm{LER} \propto p^2$ scaling requires $r \geq 2$: + +[`MergedSurface` / `MergeBegin` / `MergedSE` / `MergeEnd` / `ComposeMZZR` (Mako-parametric)](../examples/lattice-surgery/02_ls_merge_multi_round.deq) + +
<%
+r = int(context.get('r', 3))
+assert r >= 0, "r must be >= 0 (r is the number of MergedSE rounds between MergeBegin and MergeEnd)"
+inner_rounds = r
+%>
+# Multi-round joint-Z lattice surgery: MergeBegin, MergedSE, MergeEnd
+# on a MergedSurface [[21,1]] code, with COMPOSE-level REPEAT driving
+# the round count.
+#
+# MergeBegin measures the six merge stabilizers once (this defines the
+# joint-parity readout R0) and lifts the two input SurfaceCode patches
+# into the merged code.  MergedSE performs one SE round on the merged
+# code; repeating it gives the decoder round-to-round time edges that
+# catch measurement errors on the joint stabilizer between the merge
+# and the split.  MergeEnd is just the destructive MX of the seam
+# column that splits the merged code back into two SurfaceCode patches;
+# it does not re-measure any stabilizer (that job belongs to
+# MergedSE).  See the "Recovering fault tolerance with repeated merge
+# rounds" section of
+# ``documents/tutorial/chapters/lattice-surgery.md`` for the
+# rationale.
+
+IMPORT "00_lattice_surgery_library.deq"
+
+CODE MergedSurface [[21, 1]] {
+    LOGICAL X3*X4*X5*X19*X12*X13*X14 Z0*Z3*Z6
+    STABILIZER
+        # Patch A (right-edge X 2-body X5*X8 absorbed into new bulk X5*X8*X19*X20).
+        Z1*Z2           X0*X3           Z0*Z1*Z3*Z4    X1*X2*X4*X5
+        X3*X4*X6*X7     Z4*Z5*Z7*Z8     Z6*Z7
+        # Patch B (left-edge X 2-body X9*X12 absorbed into new bulk X9*X12*X18*X19).
+        Z10*Z11         Z9*Z10*Z12*Z13  X10*X11*X13*X14 X12*X13*X15*X16
+        Z13*Z14*Z16*Z17 X14*X17         Z15*Z16
+        # Four new bulk plaquettes spanning the seam.
+        Z2*Z5*Z18*Z19   X5*X8*X19*X20   X9*X12*X18*X19  Z12*Z15*Z19*Z20
+        # Two new Z 2-body boundary plaquettes at top and bottom of seam.
+        Z9*Z18          Z8*Z20
+}
+
+GADGET MergeBegin {
+    INPUT SurfaceCode 0 1 2 3 4 5 6 7 8
+    INPUT SurfaceCode 9 10 11 12 13 14 15 16 17
+
+    I 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17  # so that data-qubit error can be injected
+    RX 18 19 20
+
+    MPP Z2*Z5*Z18*Z19       # M0
+    MPP X5*X8*X19*X20       # M1
+    MPP X9*X12*X18*X19      # M2
+    MPP Z12*Z15*Z19*Z20     # M3
+    MPP Z9*Z18              # M4
+    MPP Z8*Z20              # M5
+
+    READOUT M0 M3 M4 M5
+
+    OUTPUT MergedSurface 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
+}
+
+GADGET MergedSE {
+    INPUT MergedSurface 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
+
+    I 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20  # so that data-qubit error can be injected
+
+    MPP Z1*Z2
+    MPP X0*X3
+    MPP Z0*Z1*Z3*Z4
+    MPP X1*X2*X4*X5
+    MPP X3*X4*X6*X7
+    MPP Z4*Z5*Z7*Z8
+    MPP Z6*Z7
+    MPP Z10*Z11
+    MPP Z9*Z10*Z12*Z13
+    MPP X10*X11*X13*X14
+    MPP X12*X13*X15*X16
+    MPP Z13*Z14*Z16*Z17
+    MPP X14*X17
+    MPP Z15*Z16
+    MPP Z2*Z5*Z18*Z19
+    MPP X5*X8*X19*X20
+    MPP X9*X12*X18*X19
+    MPP Z12*Z15*Z19*Z20
+    MPP Z9*Z18
+    MPP Z8*Z20
+
+    OUTPUT MergedSurface 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
+}
+
+GADGET MergeEnd {
+    INPUT MergedSurface 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
+
+    I 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17  # so that data-qubit error can be injected
+    MX 18 19 20     # M0 M1 M2
+
+    OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8
+    OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17
+
+    # Ambiguity 2 fix: joint LX_A · LX_B preservation via the seam-MX
+    # sign (M0 = MX 18).  Same shape as the single-round MZZ's rows in
+    # 00_lattice_surgery_library.deq, but with IN0.LZ0 alone standing
+    # in for IN0.LZ0 ⊕ IN1.LZ0 because MergeEnd's single MergedSurface
+    # input already carries the joint LX_A · LX_B tracker.
+    PROPAGATE OUT0.LZ0 FROM IN0.LZ0 M0
+    PROPAGATE OUT1.LZ0 FROM
+}
+
+COMPOSE ComposeMZZR {
+    INPUT SurfaceCode 0
+    INPUT SurfaceCode 1
+    MergeBegin IN(0 1) OUT(0)
+% if inner_rounds > 0:
+    REPEAT ${inner_rounds} {
+        MergedSE 0
+    }
+% endif
+    MergeEnd IN(0) OUT(0 1)
+    # Ambiguity 1 fix (MRZZ → MZZ): flip patch B's logical-Z frame
+    # whenever the joint-parity readout R0 (from MergeBegin, at
+    # rec[-1] here) is 1.  Same role as the CONDITIONAL R0 OUT1.LX0
+    # byproduct inside the single-round MZZ.
+    CONDITIONAL rec[-1] X0 1
+    OUTPUT SurfaceCode 0
+    OUTPUT SurfaceCode 1
+}
+
+PROGRAM ComposeMZZRMemoryZ {
+    PrepareZ 0
+    PrepareZ 1
+    ComposeMZZR 0 1
+    MeasureZ 0
+    MeasureZ 1
+    ASSERT_EQ rec[-3] 0   # joint LZ_A*LZ_B parity = +1
+    ASSERT_EQ rec[-2] 0   # MeasureZ patch A
+    ASSERT_EQ rec[-1] 0   # MeasureZ patch B
+}
+ + +Because `02_ls_merge_multi_round.deq` `IMPORT`s +`00_lattice_surgery_library.deq`, both files must be noise-injected at the +same $p$ and the noisy multi-round file's `IMPORT` rewired to the noisy +library: + +```sh +deq inject si1000 00_lattice_surgery_library.deq --p 1e-4 \ + --out 00_lattice_surgery_library.noisy.deq +deq inject si1000 02_ls_merge_multi_round.deq --p 1e-4 --mako r=3 \ + --out 02_ls_merge_multi_round.r3.noisy.deq +sed -i 's|"00_lattice_surgery_library.deq"|"00_lattice_surgery_library.noisy.deq"|' \ + 02_ls_merge_multi_round.r3.noisy.deq + +deq simulate ler 02_ls_merge_multi_round.r3.noisy.deq \ + --program ComposeMZZRMemoryZ \ + --shots 30000000 --errors 1000 --batch-size 5000 --seed 42 +``` + +Sweeping the round count $r$ against the single-round baseline +($r = 1$, from `ComposeMZZ` in `00_lattice_surgery_library.deq`) at five +noise rates: + +| Physical rate $p$ | LER ($r = 1$, single-round) | LER ($r = 3$) | +| ------------------- | --------------------------- | --------------------- | +| $1.0\times 10^{-3}$ | $7.44 \times 10^{-3}$ | $4.59 \times 10^{-4}$ | +| $5.0\times 10^{-4}$ | $3.60 \times 10^{-3}$ | $1.13 \times 10^{-4}$ | +| $3.0\times 10^{-4}$ | $2.07 \times 10^{-3}$ | $4.47 \times 10^{-5}$ | +| $2.0\times 10^{-4}$ | $1.32 \times 10^{-3}$ | $1.75 \times 10^{-5}$ | +| $1.0\times 10^{-4}$ | $6.57 \times 10^{-4}$ | $4.74 \times 10^{-6}$ | + +(Target of 1000 logical errors per row with `--errors 1000` and +`--seed 42`; per-row shot counts range from $\sim 2 \times 10^5$ at the +highest noise rate up to $\sim 2 \times 10^8$ at the lowest.) + +The $r = 1$ column reproduces the non-FT result: $\mathrm{LER} \approx 7 p$ +across the whole range. The $r = 3$ column instead scales as $\mathrm{LER} +\propto p^2$ — the classic $d = 3$ surface-code suppression restored. At +$p = 10^{-4}$ the $r = 3$ merge reaches $\approx 5 \times 10^{-6}$, **more +than an order of magnitude below physical** — the same regime the +single-patch FT memory in `surface_code_d3_noisy.deq` achieves, and the +strongest signature that the merge is now genuinely fault-tolerant. + +--- + +## Summary + +| Concept | Purpose | +| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | +| Physical/logical ambiguity | A single physical body (six MPPs + MX seam) is consistent with many inequivalent logical actions; deq refuses to guess | +| `PROPAGATE OUT1.LX0 FROM IN1.LX0` | Pins the branch-dependent frame flip, selecting the "honest joint measurement" reading (individual $\bar Z$ frames left alone). Equivalent to `CONDITIONAL R0 OUT1.LX0`, chosen here because it matches the compiled form directly | +| Empirical calibration for the fixed-port choice | Product-state discriminators (`ProductZZ_VirtualXA`, `ProductZZ_VirtualXB`) with deterministic outcomes falsify the wrong port | +| Hand-written `PROPAGATE OUT*.LZ0` | Hand-declares the joint $\bar X_A \bar X_B$ preservation that the per-port flow solver misses because the observable spans two ports | +| Single-round MZZ | Structurally correct after the byproducts, but $\mathrm{LER} \approx 7 p$ across all noise rates — not fault-tolerant | +| `MergeBegin` / `MergedSE` / `MergeEnd` refactor | Repeated merge measurements give the decoder temporally local edges, restoring $\mathrm{LER} \propto p^2$ at $d = 3$ (see [Composing Gadgets with COMPOSE](compose-gadgets.md) for the REPEAT mechanics) | +| LER at $d = 3$, $p = 10^{-4}$ | $r = 1$: $\approx 7 \times 10^{-4}$ (above physical); $r = 3$: $\approx 5 \times 10^{-6}$ (more than an order of magnitude below physical) | + +Related chapters: + +- [Conditional Pauli Corrections: the `CONDITIONAL` Statement](conditional-correction.md) — the CONDITIONAL mechanic itself; this chapter builds on it. +- [Logical operations with multiple inputs and outputs](multi-port-gadgets.md) — the general multi-port framework that MZZ instantiates. diff --git a/deq/documents/tutorial/examples/.gitignore b/deq/documents/tutorial/examples/.gitignore index 5277b458..c02f3d8a 100644 --- a/deq/documents/tutorial/examples/.gitignore +++ b/deq/documents/tutorial/examples/.gitignore @@ -1,5 +1,6 @@ *.txt *.annotated.deq +*.noisy.deq snippet_*.deq *.stim !mako/syndrome_body.stim diff --git a/deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq b/deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq index dc3b58eb..f2084b91 100644 --- a/deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq +++ b/deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq @@ -12,6 +12,7 @@ CODE SurfaceCode [[9,1,3]] { GADGET PrepareZ { RZ 0 1 2 3 4 5 6 7 8 + I 0 1 2 3 4 5 6 7 8 # so that data-qubit error can be injected # Single round of syndrome extraction to project into the code space. MPP Z1*Z2 @@ -28,6 +29,7 @@ GADGET PrepareZ { GADGET MeasureZ { INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 + I 0 1 2 3 4 5 6 7 8 # so that data-qubit error can be injected MZ 0 1 2 3 4 5 6 7 8 READOUT rec[-9] rec[-6] rec[-3] } @@ -36,6 +38,7 @@ GADGET MZZ { INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 INPUT SurfaceCode 9 10 11 12 13 14 15 16 17 + I 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # so that data-qubit error can be injected RX 18 19 20 MPP Z2*Z5*Z18*Z19 # M0 diff --git a/deq/documents/tutorial/examples/lattice-surgery/01_mzz_before_conditional.deq b/deq/documents/tutorial/examples/lattice-surgery/01_mzz_before_conditional.deq new file mode 100644 index 00000000..868102c4 --- /dev/null +++ b/deq/documents/tutorial/examples/lattice-surgery/01_mzz_before_conditional.deq @@ -0,0 +1,36 @@ +# MZZ body without CONDITIONAL or hand-written PROPAGATE declarations — +# the un-fixed starting point for the CONDITIONAL derivation walkthrough +# in the "Why patch B is the correct port" subsection of +# ``documents/tutorial/chapters/lattice-surgery.md``. +# +# Running ``deq annotate`` on this file produces the auto-derived PROPAGATE +# rows that the chapter analyzes to identify which port needs the +# ``CONDITIONAL R0 .LX0`` byproduct. Self-contained (no IMPORT) so +# that the annotator output is short and easy to slice-link into the +# chapter. + +CODE SurfaceCode [[9,1,3]] { + LOGICAL X0*X1*X2 Z0*Z3*Z6 + STABILIZER Z1*Z2 X0*X3 Z0*Z1*Z3*Z4 X1*X2*X4*X5 X3*X4*X6*X7 Z4*Z5*Z7*Z8 X5*X8 Z6*Z7 +} + +GADGET MZZ { + INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 + INPUT SurfaceCode 9 10 11 12 13 14 15 16 17 + + RX 18 19 20 + + MPP Z2*Z5*Z18*Z19 # M0 + MPP X5*X8*X19*X20 # M1 + MPP X9*X12*X18*X19 # M2 + MPP Z12*Z15*Z19*Z20 # M3 + MPP Z9*Z18 # M4 + MPP Z8*Z20 # M5 + + MX 18 19 20 # M6 M7 M8 + + READOUT M0 M3 M4 M5 + + OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 + OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17 +} diff --git a/deq/documents/tutorial/examples/lattice-surgery/02_ls_merge_multi_round.deq b/deq/documents/tutorial/examples/lattice-surgery/02_ls_merge_multi_round.deq new file mode 100644 index 00000000..62ccb6f3 --- /dev/null +++ b/deq/documents/tutorial/examples/lattice-surgery/02_ls_merge_multi_round.deq @@ -0,0 +1,134 @@ +<% +r = int(context.get('r', 3)) +assert r >= 0, "r must be >= 0 (r is the number of MergedSE rounds between MergeBegin and MergeEnd)" +inner_rounds = r +%> +# Multi-round joint-Z lattice surgery: MergeBegin, MergedSE, MergeEnd +# on a MergedSurface [[21,1]] code, with COMPOSE-level REPEAT driving +# the round count. +# +# MergeBegin measures the six merge stabilizers once (this defines the +# joint-parity readout R0) and merges the two input SurfaceCode patches +# into the merged code. MergedSE performs one SE round on the merged +# code; repeating it gives the decoder temporally local edges that +# catch measurement errors on the joint stabilizer between the merge +# and the split. MergeEnd is just the destructive MX of the seam +# column that splits the merged code back into two SurfaceCode patches; +# it does not re-measure any stabilizer (that job belongs to +# MergedSE). See the "Recovering fault tolerance with repeated merge +# rounds" section of +# ``documents/tutorial/chapters/lattice-surgery.md`` for the +# rationale. + +IMPORT "00_lattice_surgery_library.deq" + +CODE MergedSurface [[21, 1]] { + LOGICAL X3*X4*X5*X19*X12*X13*X14 Z0*Z3*Z6 + STABILIZER + # Patch A (right-edge X 2-body X5*X8 absorbed into new bulk X5*X8*X19*X20). + Z1*Z2 X0*X3 Z0*Z1*Z3*Z4 X1*X2*X4*X5 + X3*X4*X6*X7 Z4*Z5*Z7*Z8 Z6*Z7 + # Patch B (left-edge X 2-body X9*X12 absorbed into new bulk X9*X12*X18*X19). + Z10*Z11 Z9*Z10*Z12*Z13 X10*X11*X13*X14 X12*X13*X15*X16 + Z13*Z14*Z16*Z17 X14*X17 Z15*Z16 + # Four new bulk plaquettes spanning the seam. + Z2*Z5*Z18*Z19 X5*X8*X19*X20 X9*X12*X18*X19 Z12*Z15*Z19*Z20 + # Two new Z 2-body boundary plaquettes at top and bottom of seam. + Z9*Z18 Z8*Z20 +} + +GADGET MergeBegin { + INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 + INPUT SurfaceCode 9 10 11 12 13 14 15 16 17 + + I 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # so that data-qubit error can be injected + RX 18 19 20 + + MPP Z2*Z5*Z18*Z19 # M0 + MPP X5*X8*X19*X20 # M1 + MPP X9*X12*X18*X19 # M2 + MPP Z12*Z15*Z19*Z20 # M3 + MPP Z9*Z18 # M4 + MPP Z8*Z20 # M5 + + READOUT M0 M3 M4 M5 + + OUTPUT MergedSurface 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 +} + +GADGET MergedSE { + INPUT MergedSurface 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 + + I 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # so that data-qubit error can be injected + + MPP Z1*Z2 + MPP X0*X3 + MPP Z0*Z1*Z3*Z4 + MPP X1*X2*X4*X5 + MPP X3*X4*X6*X7 + MPP Z4*Z5*Z7*Z8 + MPP Z6*Z7 + MPP Z10*Z11 + MPP Z9*Z10*Z12*Z13 + MPP X10*X11*X13*X14 + MPP X12*X13*X15*X16 + MPP Z13*Z14*Z16*Z17 + MPP X14*X17 + MPP Z15*Z16 + MPP Z2*Z5*Z18*Z19 + MPP X5*X8*X19*X20 + MPP X9*X12*X18*X19 + MPP Z12*Z15*Z19*Z20 + MPP Z9*Z18 + MPP Z8*Z20 + + OUTPUT MergedSurface 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 +} + +GADGET MergeEnd { + INPUT MergedSurface 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 + + I 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # so that data-qubit error can be injected + MX 18 19 20 # M0 M1 M2 + + OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 + OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17 + + # Ambiguity 2 fix: joint LX_A · LX_B preservation via the seam-MX + # sign (M0 = MX 18). Same shape as the single-round MZZ's rows in + # 00_lattice_surgery_library.deq, but with IN0.LZ0 alone standing + # in for IN0.LZ0 ⊕ IN1.LZ0 because MergeEnd's single MergedSurface + # input already carries the joint LX_A · LX_B tracker. + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 M0 + PROPAGATE OUT1.LZ0 FROM +} + +COMPOSE ComposeMZZR { + INPUT SurfaceCode 0 + INPUT SurfaceCode 1 + MergeBegin IN(0 1) OUT(0) +% if inner_rounds > 0: + REPEAT ${inner_rounds} { + MergedSE 0 + } +% endif + MergeEnd IN(0) OUT(0 1) + # Ambiguity 1 fix (MRZZ → MZZ): flip patch B's logical-Z frame + # whenever the joint-parity readout R0 (from MergeBegin, at + # rec[-1] here) is 1. Same role as the CONDITIONAL R0 OUT1.LX0 + # byproduct inside the single-round MZZ. + CONDITIONAL rec[-1] X0 1 + OUTPUT SurfaceCode 0 + OUTPUT SurfaceCode 1 +} + +PROGRAM ComposeMZZRMemoryZ { + PrepareZ 0 + PrepareZ 1 + ComposeMZZR 0 1 + MeasureZ 0 + MeasureZ 1 + ASSERT_EQ rec[-3] 0 # joint LZ_A*LZ_B parity = +1 + ASSERT_EQ rec[-2] 0 # MeasureZ patch A + ASSERT_EQ rec[-1] 0 # MeasureZ patch B +} diff --git a/deq/documents/tutorial/examples/lattice-surgery/gen_lattice_surgery.py b/deq/documents/tutorial/examples/lattice-surgery/gen_lattice_surgery.py new file mode 100644 index 00000000..69d893aa --- /dev/null +++ b/deq/documents/tutorial/examples/lattice-surgery/gen_lattice_surgery.py @@ -0,0 +1,47 @@ +"""Generate outputs for the lattice-surgery tutorial chapter. + +Runs ``deq annotate`` on ``01_mzz_before_conditional.deq`` so that the +``.annotated.deq`` file needed by the chapter's slice-link exists at +tutorial-build time. The annotated file is gitignored and expected to +be regenerated by this script on each build (matches the pattern used +by other tutorial example folders such as ``compose-repropagate/``). + +The chapter's "Why patch B is the correct port" subsection slice-links +the naive (pre-CONDITIONAL) PROPAGATE rows the annotator derives from +``01_mzz_before_conditional.deq``. Committing the annotated file would +make the tutorial silently stale whenever the annotator's rendering +changes; regenerating on every ``make tutorial`` run keeps it honest. +""" + +import os +import subprocess +import sys + + +this_dir = os.path.dirname(os.path.abspath(__file__)) + + +def run_cli(description: str, args: list[str]) -> None: + """Run a ``python -m deq ...`` command; propagate failures.""" + print(f" {description}...") + result = subprocess.run( + [sys.executable, "-m", "deq"] + args, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + sys.stderr.write(result.stderr) + raise RuntimeError(f"command failed: {' '.join(args)}") + + +run_cli( + "annotate 01_mzz_before_conditional.deq", + [ + "annotate", + "--skip-mako-warning", + os.path.join(this_dir, "01_mzz_before_conditional.deq"), + "--out", + os.path.join(this_dir, "01_mzz_before_conditional.annotated.deq"), + ], +) diff --git a/deq/tests/circuit/fixtures/trivial_surgery.deq b/deq/tests/circuit/fixtures/trivial_surgery.deq index b6b07f11..25efe55e 100644 --- a/deq/tests/circuit/fixtures/trivial_surgery.deq +++ b/deq/tests/circuit/fixtures/trivial_surgery.deq @@ -111,6 +111,56 @@ COMPOSE TwoMZZCompose { OUTPUT One 1 } +# ── Mixed inner/outer CONDITIONAL fixtures ──────────────────────── +# +# Both COMPOSEs below implement the same operation as ``TwoMZZ`` but +# additionally apply a Z correction on OUT0 (patch A) conditioned on +# the joint readout. The two encodings differ in where the +# CONDITIONAL statements live: +# +# * ``TwoMZZExtraCorrMixed`` wraps ``TwoMZZ`` (whose GADGET-level +# ``CONDITIONAL R0 OUT1.LX0`` is preserved verbatim as an inner +# ``logical_correction`` entry) and adds an *outer* +# ``CONDITIONAL rec[-1] Z0 0`` at the COMPOSE level. This +# exercises the mixed inner/outer CONDITIONAL pattern where the +# outer CONDITIONAL references a ``rec[-k]`` that resolves to a +# sub-gadget readout. +# +# * ``TwoMZZExtraCorrOuter`` builds the same operation via +# ``TwoMerge`` + ``TwoSplit`` (both free of CONDITIONALs) and +# expresses BOTH corrections as outer COMPOSE-level +# ``CONDITIONAL`` statements. +# +# Both apply ``X`` on OUT1 and ``Z`` on OUT0 conditioned on the joint +# parity readout, so their runtime residuals must agree on every +# input observable pattern. This tests that step-9 absorption +# correctly composes an inner (sub-gadget) ``logical_correction`` row +# with additional outer CONDITIONALs targeting the same readout. + +COMPOSE TwoMZZExtraCorrMixed { + INPUT One 0 + INPUT One 1 + + TwoMZZ 0 1 + CONDITIONAL rec[-1] Z0 0 + + OUTPUT One 0 + OUTPUT One 1 +} + +COMPOSE TwoMZZExtraCorrOuter { + INPUT One 0 + INPUT One 1 + + TwoMerge 0 1 + TwoSplit 0 1 + CONDITIONAL rec[-1] X0 1 + CONDITIONAL rec[-1] Z0 0 + + OUTPUT One 0 + OUTPUT One 1 +} + # ── End-to-end programs exercising both joint-Z merge variants ────── # # Every PROGRAM below is emitted twice by the Mako loop: once against From 2a6b41abc21548a68ea3a47bc7917a0f5807ab1d Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Thu, 9 Jul 2026 15:38:30 -0700 Subject: [PATCH 020/157] update test reference files --- .../tutorial/chapters/lattice-surgery.md | 33 ++-- .../code422/code422.auto.ref.deq | 52 ++++-- .../code422/code422.syndrome-meta.ref.deq | 48 +++-- .../code422/code422.syndrome.ref.deq | 48 +++-- .../code422/code422.transversal.ref.deq | 52 ++++-- .../repetition_code_d3.auto.ref.deq | 176 ++++++++++-------- .../repetition_code_d3.syndrome-meta.ref.deq | 170 ++++++++++------- .../repetition_code_d3.syndrome.ref.deq | 158 +++++++++------- .../repetition_code_d3.transversal.ref.deq | 176 ++++++++++-------- .../surface_code/surface_code_d3.auto.ref.deq | 142 +++++++------- .../surface_code_d3.syndrome-meta.ref.deq | 138 +++++++------- .../surface_code_d3.syndrome.ref.deq | 130 +++++++------ .../surface_code_d3.transversal.ref.deq | 142 +++++++------- 13 files changed, 857 insertions(+), 608 deletions(-) diff --git a/deq/documents/tutorial/chapters/lattice-surgery.md b/deq/documents/tutorial/chapters/lattice-surgery.md index 77c4d374..71d8bed2 100644 --- a/deq/documents/tutorial/chapters/lattice-surgery.md +++ b/deq/documents/tutorial/chapters/lattice-surgery.md @@ -196,14 +196,15 @@ and running the annotator on it produces [`01_mzz_before_conditional.annotated.deq`](../examples/lattice-surgery/01_mzz_before_conditional.annotated.deq). Two pieces of that output matter for what follows. -**Piece 1** — the `READOUT` line, which carries a `# flipped by:` comment -recording how the raw merge readout `R0 = M0 ⊕ M3 ⊕ M4 ⊕ M5` relates to -the input observables (this is what `readout_propagation` computed for +**Piece 1** — the `READOUT` line, which carries a `#` comment listing the +input-frame bits that (XOR'd with anything already on the line) give the +full flip set of the raw merge readout `R0 = M0 ⊕ M3 ⊕ M4 ⊕ M5` relative +to the input observables (this is what `readout_propagation` computed for R0): [the annotator's READOUT line for the un-fixed `MZZ` gadget](../examples/lattice-surgery/01_mzz_before_conditional.annotated.deq#L27) -
    READOUT M0 M3 M4 M5  # flipped by: IN0.LX0 IN1.LX0 IN0.DS0 IN0.DS2 IN0.DS5 IN0.DS7
+
    READOUT M0 M3 M4 M5  # IN0.LX0 IN1.LX0 IN0.DS0 IN0.DS2 IN0.DS5 IN0.DS7
That comment reflects a division of labour. The user's `READOUT M0 @@ -215,7 +216,7 @@ $$R_0^{\text{raw}} \;=\; M_0 \oplus M_3 \oplus M_4 \oplus M_5.$$ The transpiler analyzes what those physical measurements actually measure on the two-patch pre-merge state and reports which input observables' values would flip `R0`'s raw value if they were flipped -in the pre-merge frame — that's the `# flipped by:` list, and it's +in the pre-merge frame — that's the `#` comment's XOR list, and it's stored on the compiled binary as `readout_propagation`. At runtime, the framework combines the two to produce the value of `R0` in the input frame: @@ -230,7 +231,7 @@ stabilizer-syndrome bits. The runtime uses exactly this combined expression whenever it substitutes `R0` into a downstream frame formula.) -Two kinds of input bits appear in the "flipped by" list, each with a +Two kinds of input bits appear in the comment's XOR list, each with a distinct physical meaning. **The two logical bits (`IN0.LX0`, `IN1.LX0`).** `R0` measures the @@ -273,7 +274,7 @@ Every qubit index appears an even number of times on the right except $\{2, 5, 8\}$, which appear once each — verifying the shift. The four stabilizers ($S_0, S_2, S_5, S_7$) needed for the shift show up as the four destabilizer references `IN0.DS0`, `IN0.DS2`, `IN0.DS5`, -`IN0.DS7` in the "flipped by" list. (In the framework's PROPAGATE +`IN0.DS7` in the comment's XOR list. (In the framework's PROPAGATE algebra, an input stabilizer's measurement-outcome bit is XOR'd in via the destabilizer column `IN

.DS` of `correction_propagation`.) @@ -595,9 +596,9 @@ $\mathrm{LER} \propto p^2$ scaling requires $r \geq 2$: # the round count. # # MergeBegin measures the six merge stabilizers once (this defines the -# joint-parity readout R0) and lifts the two input SurfaceCode patches +# joint-parity readout R0) and merges the two input SurfaceCode patches # into the merged code. MergedSE performs one SE round on the merged -# code; repeating it gives the decoder round-to-round time edges that +# code; repeating it gives the decoder temporally local edges that # catch measurement errors on the joint stabilizer between the merge # and the split. MergeEnd is just the destructive MX of the seam # column that splits the merged code back into two SurfaceCode patches; @@ -743,13 +744,13 @@ Sweeping the round count $r$ against the single-round baseline ($r = 1$, from `ComposeMZZ` in `00_lattice_surgery_library.deq`) at five noise rates: -| Physical rate $p$ | LER ($r = 1$, single-round) | LER ($r = 3$) | -| ------------------- | --------------------------- | --------------------- | -| $1.0\times 10^{-3}$ | $7.44 \times 10^{-3}$ | $4.59 \times 10^{-4}$ | -| $5.0\times 10^{-4}$ | $3.60 \times 10^{-3}$ | $1.13 \times 10^{-4}$ | -| $3.0\times 10^{-4}$ | $2.07 \times 10^{-3}$ | $4.47 \times 10^{-5}$ | -| $2.0\times 10^{-4}$ | $1.32 \times 10^{-3}$ | $1.75 \times 10^{-5}$ | -| $1.0\times 10^{-4}$ | $6.57 \times 10^{-4}$ | $4.74 \times 10^{-6}$ | +| Physical rate $p$ | LER ($r = 1$, single-round) | LER ($r = 3$) | LER ($r = 5$) | +| ------------------- | --------------------------- | --------------------- | --------------------- | +| $1.0\times 10^{-3}$ | $7.44 \times 10^{-3}$ | $4.59 \times 10^{-4}$ | $4.62 \times 10^{-4}$ | +| $5.0\times 10^{-4}$ | $3.60 \times 10^{-3}$ | $1.13 \times 10^{-4}$ | $1.11 \times 10^{-4}$ | +| $3.0\times 10^{-4}$ | $2.07 \times 10^{-3}$ | $4.47 \times 10^{-5}$ | $4.17 \times 10^{-5}$ | +| $2.0\times 10^{-4}$ | $1.32 \times 10^{-3}$ | $1.75 \times 10^{-5}$ | $1.90 \times 10^{-5}$ | +| $1.0\times 10^{-4}$ | $6.57 \times 10^{-4}$ | $4.74 \times 10^{-6}$ | $4.44 \times 10^{-6}$ | (Target of 1000 logical errors per row with `--errors 1000` and `--seed 42`; per-row shot counts range from $\sim 2 \times 10^5$ at the diff --git a/deq/tests/transpiler/check_optimizer_fixtures/code422/code422.auto.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/code422/code422.auto.ref.deq index 6d7bbaf7..c94015e6 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/code422/code422.auto.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/code422/code422.auto.ref.deq @@ -12,8 +12,12 @@ GADGET PrepareXX { RX 0 1 2 3 MPP Z0*Z1*Z2*Z3 OUTPUT Code422 0 1 2 3 - CHECK rec[-2] - CHECK rec[-1] rec[-3] + CHECK OUT0.S0 + CHECK OUT0.S1 M0 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM + PROPAGATE OUT0.LZ1 FROM + PROPAGATE OUT0.LX1 FROM # --- statistics --- # finished checks: 0 @@ -28,8 +32,12 @@ GADGET PrepareZZ { R 0 1 2 3 MPP X0*X1*X2*X3 OUTPUT Code422 0 1 2 3 - CHECK rec[-2] rec[-3] - CHECK rec[-1] + CHECK OUT0.S0 M0 + CHECK OUT0.S1 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM + PROPAGATE OUT0.LZ1 FROM + PROPAGATE OUT0.LX1 FROM # --- statistics --- # finished checks: 0 @@ -46,10 +54,18 @@ GADGET CX { CX 0 4 1 5 2 6 3 7 OUTPUT Code422 0 1 2 3 OUTPUT Code422 4 5 6 7 - CHECK rec[-4] rec[-6] rec[-8] - CHECK rec[-3] rec[-7] - CHECK rec[-2] rec[-6] - CHECK rec[-1] rec[-5] rec[-7] + CHECK OUT0.S0 IN1.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT1.S0 IN1.S0 + CHECK OUT1.S1 IN1.S1 IN0.S1 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT0.LZ1 FROM IN0.LZ1 IN1.LZ1 + PROPAGATE OUT0.LX1 FROM IN0.LX1 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 + PROPAGATE OUT1.LZ1 FROM IN1.LZ1 + PROPAGATE OUT1.LX1 FROM IN0.LX1 IN1.LX1 # --- statistics --- # finished checks: 0 @@ -63,8 +79,12 @@ GADGET CX { GADGET Permute { INPUT Code422 0 1 2 3 OUTPUT Code422 1 0 3 2 - CHECK rec[-2] rec[-4] - CHECK rec[-1] rec[-3] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 IN0.DS1 + PROPAGATE OUT0.LZ1 FROM IN0.LZ1 IN0.DS0 + PROPAGATE OUT0.LX1 FROM IN0.LX1 # --- statistics --- # finished checks: 0 @@ -78,9 +98,9 @@ GADGET Permute { GADGET MeasureZZ { INPUT Code422 0 1 2 3 M 0 1 2 3 - READOUT rec[-4] rec[-2] # flipped by: LX0 - READOUT rec[-4] rec[-3] # flipped by: LX1 - CHECK rec[-1] rec[-2] rec[-3] rec[-4] rec[-5] + READOUT rec[-4] rec[-2] # IN0.LX0 + READOUT rec[-4] rec[-3] # IN0.LX1 + CHECK M3 M2 M1 M0 IN0.S1 # --- statistics --- # finished checks: 1 @@ -94,9 +114,9 @@ GADGET MeasureZZ { GADGET MeasureXX { INPUT Code422 0 1 2 3 MX 0 1 2 3 - READOUT rec[-4] rec[-3] # flipped by: LZ0 - READOUT rec[-4] rec[-2] # flipped by: LZ1 - CHECK rec[-1] rec[-2] rec[-3] rec[-4] rec[-6] + READOUT rec[-4] rec[-3] # IN0.LZ0 + READOUT rec[-4] rec[-2] # IN0.LZ1 + CHECK M3 M2 M1 M0 IN0.S0 # --- statistics --- # finished checks: 1 diff --git a/deq/tests/transpiler/check_optimizer_fixtures/code422/code422.syndrome-meta.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/code422/code422.syndrome-meta.ref.deq index 00470ae9..1acf99ba 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/code422/code422.syndrome-meta.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/code422/code422.syndrome-meta.ref.deq @@ -12,8 +12,12 @@ GADGET PrepareXX { RX 0 1 2 3 MPP Z0*Z1*Z2*Z3 OUTPUT Code422 0 1 2 3 - CHECK rec[-2] - CHECK rec[-1] rec[-3] + CHECK OUT0.S0 + CHECK OUT0.S1 M0 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM + PROPAGATE OUT0.LZ1 FROM + PROPAGATE OUT0.LX1 FROM # --- statistics --- # finished checks: 0 @@ -28,8 +32,12 @@ GADGET PrepareZZ { R 0 1 2 3 MPP X0*X1*X2*X3 OUTPUT Code422 0 1 2 3 - CHECK rec[-2] rec[-3] - CHECK rec[-1] + CHECK OUT0.S0 M0 + CHECK OUT0.S1 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM + PROPAGATE OUT0.LZ1 FROM + PROPAGATE OUT0.LX1 FROM # --- statistics --- # finished checks: 0 @@ -46,10 +54,18 @@ GADGET CX { CX 0 4 1 5 2 6 3 7 OUTPUT Code422 0 1 2 3 OUTPUT Code422 4 5 6 7 - CHECK rec[-4] rec[-6] rec[-8] - CHECK rec[-3] rec[-7] - CHECK rec[-2] rec[-6] - CHECK rec[-1] rec[-5] rec[-7] + CHECK OUT0.S0 IN1.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT1.S0 IN1.S0 + CHECK OUT1.S1 IN1.S1 IN0.S1 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT0.LZ1 FROM IN0.LZ1 IN1.LZ1 + PROPAGATE OUT0.LX1 FROM IN0.LX1 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 + PROPAGATE OUT1.LZ1 FROM IN1.LZ1 + PROPAGATE OUT1.LX1 FROM IN0.LX1 IN1.LX1 # --- statistics --- # finished checks: 0 @@ -63,8 +79,12 @@ GADGET CX { GADGET Permute { INPUT Code422 0 1 2 3 OUTPUT Code422 1 0 3 2 - CHECK rec[-2] rec[-4] - CHECK rec[-1] rec[-3] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 IN0.DS1 + PROPAGATE OUT0.LZ1 FROM IN0.LZ1 IN0.DS0 + PROPAGATE OUT0.LX1 FROM IN0.LX1 # --- statistics --- # finished checks: 0 @@ -78,8 +98,8 @@ GADGET Permute { GADGET MeasureZZ { INPUT Code422 0 1 2 3 M 0 1 2 3 - READOUT rec[-4] rec[-2] # flipped by: LX0 - READOUT rec[-4] rec[-3] # flipped by: LX1 + READOUT rec[-4] rec[-2] # IN0.LX0 + READOUT rec[-4] rec[-3] # IN0.LX1 # --- statistics --- # finished checks: 0 @@ -92,8 +112,8 @@ GADGET MeasureZZ { GADGET MeasureXX { INPUT Code422 0 1 2 3 MX 0 1 2 3 - READOUT rec[-4] rec[-3] # flipped by: LZ0 - READOUT rec[-4] rec[-2] # flipped by: LZ1 + READOUT rec[-4] rec[-3] # IN0.LZ0 + READOUT rec[-4] rec[-2] # IN0.LZ1 # --- statistics --- # finished checks: 0 diff --git a/deq/tests/transpiler/check_optimizer_fixtures/code422/code422.syndrome.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/code422/code422.syndrome.ref.deq index 00470ae9..1acf99ba 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/code422/code422.syndrome.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/code422/code422.syndrome.ref.deq @@ -12,8 +12,12 @@ GADGET PrepareXX { RX 0 1 2 3 MPP Z0*Z1*Z2*Z3 OUTPUT Code422 0 1 2 3 - CHECK rec[-2] - CHECK rec[-1] rec[-3] + CHECK OUT0.S0 + CHECK OUT0.S1 M0 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM + PROPAGATE OUT0.LZ1 FROM + PROPAGATE OUT0.LX1 FROM # --- statistics --- # finished checks: 0 @@ -28,8 +32,12 @@ GADGET PrepareZZ { R 0 1 2 3 MPP X0*X1*X2*X3 OUTPUT Code422 0 1 2 3 - CHECK rec[-2] rec[-3] - CHECK rec[-1] + CHECK OUT0.S0 M0 + CHECK OUT0.S1 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM + PROPAGATE OUT0.LZ1 FROM + PROPAGATE OUT0.LX1 FROM # --- statistics --- # finished checks: 0 @@ -46,10 +54,18 @@ GADGET CX { CX 0 4 1 5 2 6 3 7 OUTPUT Code422 0 1 2 3 OUTPUT Code422 4 5 6 7 - CHECK rec[-4] rec[-6] rec[-8] - CHECK rec[-3] rec[-7] - CHECK rec[-2] rec[-6] - CHECK rec[-1] rec[-5] rec[-7] + CHECK OUT0.S0 IN1.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT1.S0 IN1.S0 + CHECK OUT1.S1 IN1.S1 IN0.S1 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT0.LZ1 FROM IN0.LZ1 IN1.LZ1 + PROPAGATE OUT0.LX1 FROM IN0.LX1 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 + PROPAGATE OUT1.LZ1 FROM IN1.LZ1 + PROPAGATE OUT1.LX1 FROM IN0.LX1 IN1.LX1 # --- statistics --- # finished checks: 0 @@ -63,8 +79,12 @@ GADGET CX { GADGET Permute { INPUT Code422 0 1 2 3 OUTPUT Code422 1 0 3 2 - CHECK rec[-2] rec[-4] - CHECK rec[-1] rec[-3] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 IN0.DS1 + PROPAGATE OUT0.LZ1 FROM IN0.LZ1 IN0.DS0 + PROPAGATE OUT0.LX1 FROM IN0.LX1 # --- statistics --- # finished checks: 0 @@ -78,8 +98,8 @@ GADGET Permute { GADGET MeasureZZ { INPUT Code422 0 1 2 3 M 0 1 2 3 - READOUT rec[-4] rec[-2] # flipped by: LX0 - READOUT rec[-4] rec[-3] # flipped by: LX1 + READOUT rec[-4] rec[-2] # IN0.LX0 + READOUT rec[-4] rec[-3] # IN0.LX1 # --- statistics --- # finished checks: 0 @@ -92,8 +112,8 @@ GADGET MeasureZZ { GADGET MeasureXX { INPUT Code422 0 1 2 3 MX 0 1 2 3 - READOUT rec[-4] rec[-3] # flipped by: LZ0 - READOUT rec[-4] rec[-2] # flipped by: LZ1 + READOUT rec[-4] rec[-3] # IN0.LZ0 + READOUT rec[-4] rec[-2] # IN0.LZ1 # --- statistics --- # finished checks: 0 diff --git a/deq/tests/transpiler/check_optimizer_fixtures/code422/code422.transversal.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/code422/code422.transversal.ref.deq index 6d7bbaf7..c94015e6 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/code422/code422.transversal.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/code422/code422.transversal.ref.deq @@ -12,8 +12,12 @@ GADGET PrepareXX { RX 0 1 2 3 MPP Z0*Z1*Z2*Z3 OUTPUT Code422 0 1 2 3 - CHECK rec[-2] - CHECK rec[-1] rec[-3] + CHECK OUT0.S0 + CHECK OUT0.S1 M0 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM + PROPAGATE OUT0.LZ1 FROM + PROPAGATE OUT0.LX1 FROM # --- statistics --- # finished checks: 0 @@ -28,8 +32,12 @@ GADGET PrepareZZ { R 0 1 2 3 MPP X0*X1*X2*X3 OUTPUT Code422 0 1 2 3 - CHECK rec[-2] rec[-3] - CHECK rec[-1] + CHECK OUT0.S0 M0 + CHECK OUT0.S1 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM + PROPAGATE OUT0.LZ1 FROM + PROPAGATE OUT0.LX1 FROM # --- statistics --- # finished checks: 0 @@ -46,10 +54,18 @@ GADGET CX { CX 0 4 1 5 2 6 3 7 OUTPUT Code422 0 1 2 3 OUTPUT Code422 4 5 6 7 - CHECK rec[-4] rec[-6] rec[-8] - CHECK rec[-3] rec[-7] - CHECK rec[-2] rec[-6] - CHECK rec[-1] rec[-5] rec[-7] + CHECK OUT0.S0 IN1.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT1.S0 IN1.S0 + CHECK OUT1.S1 IN1.S1 IN0.S1 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT0.LZ1 FROM IN0.LZ1 IN1.LZ1 + PROPAGATE OUT0.LX1 FROM IN0.LX1 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 + PROPAGATE OUT1.LZ1 FROM IN1.LZ1 + PROPAGATE OUT1.LX1 FROM IN0.LX1 IN1.LX1 # --- statistics --- # finished checks: 0 @@ -63,8 +79,12 @@ GADGET CX { GADGET Permute { INPUT Code422 0 1 2 3 OUTPUT Code422 1 0 3 2 - CHECK rec[-2] rec[-4] - CHECK rec[-1] rec[-3] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 IN0.DS1 + PROPAGATE OUT0.LZ1 FROM IN0.LZ1 IN0.DS0 + PROPAGATE OUT0.LX1 FROM IN0.LX1 # --- statistics --- # finished checks: 0 @@ -78,9 +98,9 @@ GADGET Permute { GADGET MeasureZZ { INPUT Code422 0 1 2 3 M 0 1 2 3 - READOUT rec[-4] rec[-2] # flipped by: LX0 - READOUT rec[-4] rec[-3] # flipped by: LX1 - CHECK rec[-1] rec[-2] rec[-3] rec[-4] rec[-5] + READOUT rec[-4] rec[-2] # IN0.LX0 + READOUT rec[-4] rec[-3] # IN0.LX1 + CHECK M3 M2 M1 M0 IN0.S1 # --- statistics --- # finished checks: 1 @@ -94,9 +114,9 @@ GADGET MeasureZZ { GADGET MeasureXX { INPUT Code422 0 1 2 3 MX 0 1 2 3 - READOUT rec[-4] rec[-3] # flipped by: LZ0 - READOUT rec[-4] rec[-2] # flipped by: LZ1 - CHECK rec[-1] rec[-2] rec[-3] rec[-4] rec[-6] + READOUT rec[-4] rec[-3] # IN0.LZ0 + READOUT rec[-4] rec[-2] # IN0.LZ1 + CHECK M3 M2 M1 M0 IN0.S0 # --- statistics --- # finished checks: 1 diff --git a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.auto.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.auto.ref.deq index 6bf3060c..3b269c3e 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.auto.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.auto.ref.deq @@ -11,9 +11,11 @@ CODE RepetitionCode [[3,1,3]] { GADGET PrepareZ { R 0 1 2 OUTPUT RepetitionCode 0 1 2 - CHECK rec[-3] - CHECK rec[-2] - CHECK rec[-1] + CHECK OUT0.S0 + CHECK OUT0.S1 + CHECK OUT0.S2 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM # --- statistics --- # finished checks: 0 @@ -27,10 +29,10 @@ GADGET PrepareZ { GADGET MeasureZ { INPUT RepetitionCode 0 1 2 M 0 1 2 - READOUT rec[-3] # flipped by: LX0 - CHECK rec[-2] rec[-3] rec[-6] - CHECK rec[-1] rec[-2] rec[-5] - CHECK rec[-1] rec[-3] rec[-4] + READOUT rec[-3] # IN0.LX0 + CHECK M1 M0 IN0.S0 + CHECK M2 M1 IN0.S1 + CHECK M2 M0 IN0.S2 # --- statistics --- # finished checks: 3 @@ -44,9 +46,11 @@ GADGET MeasureZ { GADGET AutomorphismIdentity { INPUT RepetitionCode 0 1 2 OUTPUT RepetitionCode 2 1 0 - CHECK rec[-3] rec[-5] - CHECK rec[-2] rec[-6] - CHECK rec[-1] rec[-4] + CHECK OUT0.S0 IN0.S1 + CHECK OUT0.S1 IN0.S0 + CHECK OUT0.S2 IN0.S2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 IN0.DS0 IN0.DS1 # --- statistics --- # finished checks: 0 @@ -63,13 +67,15 @@ GADGET Syndrome { CX 0 1 2 3 4 5 CX 2 1 4 3 M 1 3 5 - CHECK rec[-3] rec[-6] - CHECK rec[-2] rec[-5] - CHECK rec[-2] rec[-3] rec[-4] + CHECK M0 IN0.S0 + CHECK M1 IN0.S1 + CHECK M1 M0 IN0.S2 OUTPUT RepetitionCode 0 2 4 - CHECK rec[-3] rec[-6] - CHECK rec[-2] rec[-5] - CHECK rec[-1] rec[-5] rec[-6] + CHECK OUT0.S0 M0 + CHECK OUT0.S1 M1 + CHECK OUT0.S2 M1 M0 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM M0 M1 M2 # --- statistics --- # finished checks: 3 @@ -95,19 +101,21 @@ GADGET MultiSyndrome { CX 0 1 2 3 4 5 CX 2 1 4 3 M 1 3 5 - CHECK rec[-3] rec[-12] - CHECK rec[-2] rec[-11] - CHECK rec[-2] rec[-3] rec[-10] - CHECK rec[-3] rec[-9] - CHECK rec[-2] rec[-8] - CHECK rec[-1] rec[-7] - CHECK rec[-3] rec[-6] - CHECK rec[-2] rec[-5] - CHECK rec[-1] rec[-4] + CHECK M6 IN0.S0 + CHECK M7 IN0.S1 + CHECK M7 M6 IN0.S2 + CHECK M6 M0 + CHECK M7 M1 + CHECK M8 M2 + CHECK M6 M3 + CHECK M7 M4 + CHECK M8 M5 OUTPUT RepetitionCode 0 2 4 - CHECK rec[-3] rec[-6] - CHECK rec[-2] rec[-5] - CHECK rec[-1] rec[-5] rec[-6] + CHECK OUT0.S0 M6 + CHECK OUT0.S1 M7 + CHECK OUT0.S2 M7 M6 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM M6 M7 M8 # --- statistics --- # finished checks: 9 @@ -122,9 +130,11 @@ GADGET MultiSyndrome { GADGET NOP { INPUT RepetitionCode 0 1 2 OUTPUT RepetitionCode 0 1 2 - CHECK rec[-3] rec[-6] - CHECK rec[-2] rec[-5] - CHECK rec[-1] rec[-4] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- # finished checks: 0 @@ -141,12 +151,16 @@ GADGET TransversalCNOT { CX 0 3 1 4 2 5 OUTPUT RepetitionCode 0 1 2 OUTPUT RepetitionCode 3 4 5 - CHECK rec[-6] rec[-12] - CHECK rec[-5] rec[-11] - CHECK rec[-4] rec[-10] - CHECK rec[-3] rec[-9] rec[-12] - CHECK rec[-2] rec[-8] rec[-11] - CHECK rec[-1] rec[-7] rec[-10] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT1.S0 IN1.S0 IN0.S0 + CHECK OUT1.S1 IN1.S1 IN0.S1 + CHECK OUT1.S2 IN1.S2 IN0.S2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 0 @@ -165,17 +179,21 @@ GADGET TransversalCNOT_SE_before_control { CX 2 1 4 3 M 1 3 5 CX 0 6 2 8 4 10 - CHECK rec[-3] rec[-9] - CHECK rec[-2] rec[-8] - CHECK rec[-2] rec[-3] rec[-7] + CHECK M0 IN0.S0 + CHECK M1 IN0.S1 + CHECK M1 M0 IN0.S2 OUTPUT RepetitionCode 0 2 4 OUTPUT RepetitionCode 6 8 10 - CHECK rec[-6] rec[-9] - CHECK rec[-5] rec[-8] - CHECK rec[-4] rec[-8] rec[-9] - CHECK rec[-3] rec[-9] rec[-12] - CHECK rec[-2] rec[-8] rec[-11] - CHECK rec[-1] rec[-8] rec[-9] rec[-10] + CHECK OUT0.S0 M0 + CHECK OUT0.S1 M1 + CHECK OUT0.S2 M1 M0 + CHECK OUT1.S0 M0 IN1.S0 + CHECK OUT1.S1 M1 IN1.S1 + CHECK OUT1.S2 M1 M0 IN1.S2 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN1.LX0 M0 M1 M2 # --- statistics --- # finished checks: 3 @@ -195,17 +213,21 @@ GADGET TransversalCNOT_SE_before_target { CX 8 7 10 9 M 7 9 11 CX 0 6 2 8 4 10 - CHECK rec[-3] rec[-6] - CHECK rec[-2] rec[-5] - CHECK rec[-2] rec[-3] rec[-4] + CHECK M0 IN1.S0 + CHECK M1 IN1.S1 + CHECK M1 M0 IN1.S2 OUTPUT RepetitionCode 0 2 4 OUTPUT RepetitionCode 6 8 10 - CHECK rec[-6] rec[-15] - CHECK rec[-5] rec[-14] - CHECK rec[-4] rec[-13] - CHECK rec[-3] rec[-9] rec[-15] - CHECK rec[-2] rec[-8] rec[-14] - CHECK rec[-1] rec[-8] rec[-9] rec[-13] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT1.S0 M0 IN0.S0 + CHECK OUT1.S1 M1 IN0.S1 + CHECK OUT1.S2 M1 M0 IN0.S2 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT1.LZ0 FROM + PROPAGATE OUT1.LX0 FROM IN0.LX0 M0 M1 M2 # --- statistics --- # finished checks: 3 @@ -225,17 +247,21 @@ GADGET TransversalCNOT_SE_after_control { CX 0 1 2 3 4 5 CX 2 1 4 3 M 1 3 5 - CHECK rec[-3] rec[-9] - CHECK rec[-2] rec[-8] - CHECK rec[-2] rec[-3] rec[-7] + CHECK M0 IN0.S0 + CHECK M1 IN0.S1 + CHECK M1 M0 IN0.S2 OUTPUT RepetitionCode 0 2 4 OUTPUT RepetitionCode 6 8 10 - CHECK rec[-6] rec[-9] - CHECK rec[-5] rec[-8] - CHECK rec[-4] rec[-8] rec[-9] - CHECK rec[-3] rec[-9] rec[-12] - CHECK rec[-2] rec[-8] rec[-11] - CHECK rec[-1] rec[-8] rec[-9] rec[-10] + CHECK OUT0.S0 M0 + CHECK OUT0.S1 M1 + CHECK OUT0.S2 M1 M0 + CHECK OUT1.S0 M0 IN1.S0 + CHECK OUT1.S1 M1 IN1.S1 + CHECK OUT1.S2 M1 M0 IN1.S2 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN1.LX0 M0 M1 M2 # --- statistics --- # finished checks: 3 @@ -255,17 +281,21 @@ GADGET TransversalCNOT_SE_after_target { CX 6 7 8 9 10 11 CX 8 7 10 9 M 7 9 11 - CHECK rec[-3] rec[-6] rec[-9] - CHECK rec[-2] rec[-5] rec[-8] - CHECK rec[-2] rec[-3] rec[-4] rec[-7] + CHECK M0 IN1.S0 IN0.S0 + CHECK M1 IN1.S1 IN0.S1 + CHECK M1 M0 IN1.S2 IN0.S2 OUTPUT RepetitionCode 0 2 4 OUTPUT RepetitionCode 6 8 10 - CHECK rec[-6] rec[-15] - CHECK rec[-5] rec[-14] - CHECK rec[-4] rec[-13] - CHECK rec[-3] rec[-9] - CHECK rec[-2] rec[-8] - CHECK rec[-1] rec[-8] rec[-9] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT1.S0 M0 + CHECK OUT1.S1 M1 + CHECK OUT1.S2 M1 M0 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT1.LZ0 FROM + PROPAGATE OUT1.LX0 FROM M0 M1 M2 # --- statistics --- # finished checks: 3 diff --git a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome-meta.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome-meta.ref.deq index 8ded670d..d018af24 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome-meta.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome-meta.ref.deq @@ -11,9 +11,11 @@ CODE RepetitionCode [[3,1,3]] { GADGET PrepareZ { R 0 1 2 OUTPUT RepetitionCode 0 1 2 - CHECK rec[-3] - CHECK rec[-2] - CHECK rec[-1] + CHECK OUT0.S0 + CHECK OUT0.S1 + CHECK OUT0.S2 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM # --- statistics --- # finished checks: 0 @@ -27,10 +29,10 @@ GADGET PrepareZ { GADGET MeasureZ { INPUT RepetitionCode 0 1 2 M 0 1 2 - READOUT rec[-3] # flipped by: LX0 - CHECK rec[-2] rec[-3] rec[-6] - CHECK rec[-1] rec[-2] rec[-5] - CHECK rec[-1] rec[-3] rec[-4] + READOUT rec[-3] # IN0.LX0 + CHECK M1 M0 IN0.S0 + CHECK M2 M1 IN0.S1 + CHECK M2 M0 IN0.S2 # --- statistics --- # finished checks: 3 @@ -44,9 +46,11 @@ GADGET MeasureZ { GADGET AutomorphismIdentity { INPUT RepetitionCode 0 1 2 OUTPUT RepetitionCode 2 1 0 - CHECK rec[-3] rec[-5] - CHECK rec[-2] rec[-6] - CHECK rec[-1] rec[-4] + CHECK OUT0.S0 IN0.S1 + CHECK OUT0.S1 IN0.S0 + CHECK OUT0.S2 IN0.S2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 IN0.DS0 IN0.DS1 # --- statistics --- # finished checks: 0 @@ -63,13 +67,15 @@ GADGET Syndrome { CX 0 1 2 3 4 5 CX 2 1 4 3 M 1 3 5 - CHECK rec[-3] rec[-6] - CHECK rec[-2] rec[-5] - CHECK rec[-2] rec[-3] rec[-4] + CHECK M0 IN0.S0 + CHECK M1 IN0.S1 + CHECK M1 M0 IN0.S2 OUTPUT RepetitionCode 0 2 4 - CHECK rec[-3] rec[-6] - CHECK rec[-2] rec[-5] - CHECK rec[-1] rec[-5] rec[-6] + CHECK OUT0.S0 M0 + CHECK OUT0.S1 M1 + CHECK OUT0.S2 M1 M0 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM M0 M1 M2 # --- statistics --- # finished checks: 3 @@ -95,19 +101,21 @@ GADGET MultiSyndrome { CX 0 1 2 3 4 5 CX 2 1 4 3 M 1 3 5 - CHECK rec[-9] rec[-12] - CHECK rec[-8] rec[-11] - CHECK rec[-8] rec[-9] rec[-10] - CHECK rec[-3] rec[-9] - CHECK rec[-2] rec[-8] - CHECK rec[-1] rec[-7] - CHECK rec[-3] rec[-6] - CHECK rec[-2] rec[-5] - CHECK rec[-1] rec[-4] + CHECK M0 IN0.S0 + CHECK M1 IN0.S1 + CHECK M1 M0 IN0.S2 + CHECK M6 M0 + CHECK M7 M1 + CHECK M8 M2 + CHECK M6 M3 + CHECK M7 M4 + CHECK M8 M5 OUTPUT RepetitionCode 0 2 4 - CHECK rec[-3] rec[-12] - CHECK rec[-2] rec[-11] - CHECK rec[-1] rec[-11] rec[-12] + CHECK OUT0.S0 M0 + CHECK OUT0.S1 M1 + CHECK OUT0.S2 M1 M0 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM M6 M7 M8 # --- statistics --- # finished checks: 9 @@ -122,9 +130,11 @@ GADGET MultiSyndrome { GADGET NOP { INPUT RepetitionCode 0 1 2 OUTPUT RepetitionCode 0 1 2 - CHECK rec[-3] rec[-6] - CHECK rec[-2] rec[-5] - CHECK rec[-1] rec[-4] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- # finished checks: 0 @@ -141,12 +151,16 @@ GADGET TransversalCNOT { CX 0 3 1 4 2 5 OUTPUT RepetitionCode 0 1 2 OUTPUT RepetitionCode 3 4 5 - CHECK rec[-6] rec[-12] - CHECK rec[-5] rec[-11] - CHECK rec[-4] rec[-10] - CHECK rec[-3] rec[-9] rec[-12] - CHECK rec[-2] rec[-8] rec[-11] - CHECK rec[-1] rec[-7] rec[-10] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT1.S0 IN1.S0 IN0.S0 + CHECK OUT1.S1 IN1.S1 IN0.S1 + CHECK OUT1.S2 IN1.S2 IN0.S2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 0 @@ -165,17 +179,21 @@ GADGET TransversalCNOT_SE_before_control { CX 2 1 4 3 M 1 3 5 CX 0 6 2 8 4 10 - CHECK rec[-3] rec[-9] - CHECK rec[-2] rec[-8] - CHECK rec[-2] rec[-3] rec[-7] + CHECK M0 IN0.S0 + CHECK M1 IN0.S1 + CHECK M1 M0 IN0.S2 OUTPUT RepetitionCode 0 2 4 OUTPUT RepetitionCode 6 8 10 - CHECK rec[-6] rec[-9] - CHECK rec[-5] rec[-8] - CHECK rec[-4] rec[-8] rec[-9] - CHECK rec[-3] rec[-9] rec[-12] - CHECK rec[-2] rec[-8] rec[-11] - CHECK rec[-1] rec[-8] rec[-10] rec[-15] + CHECK OUT0.S0 M0 + CHECK OUT0.S1 M1 + CHECK OUT0.S2 M1 M0 + CHECK OUT1.S0 M0 IN1.S0 + CHECK OUT1.S1 M1 IN1.S1 + CHECK OUT1.S2 M1 IN1.S2 IN0.S0 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN1.LX0 M0 M1 M2 # --- statistics --- # finished checks: 3 @@ -195,17 +213,21 @@ GADGET TransversalCNOT_SE_before_target { CX 8 7 10 9 M 7 9 11 CX 0 6 2 8 4 10 - CHECK rec[-3] rec[-6] - CHECK rec[-2] rec[-5] - CHECK rec[-2] rec[-3] rec[-4] + CHECK M0 IN1.S0 + CHECK M1 IN1.S1 + CHECK M1 M0 IN1.S2 OUTPUT RepetitionCode 0 2 4 OUTPUT RepetitionCode 6 8 10 - CHECK rec[-6] rec[-15] - CHECK rec[-5] rec[-14] - CHECK rec[-4] rec[-13] - CHECK rec[-3] rec[-9] rec[-15] - CHECK rec[-2] rec[-8] rec[-14] - CHECK rec[-1] rec[-8] rec[-12] rec[-13] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT1.S0 M0 IN0.S0 + CHECK OUT1.S1 M1 IN0.S1 + CHECK OUT1.S2 M1 IN1.S0 IN0.S2 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT1.LZ0 FROM + PROPAGATE OUT1.LX0 FROM IN0.LX0 M0 M1 M2 # --- statistics --- # finished checks: 3 @@ -225,17 +247,21 @@ GADGET TransversalCNOT_SE_after_control { CX 0 1 2 3 4 5 CX 2 1 4 3 M 1 3 5 - CHECK rec[-3] rec[-9] - CHECK rec[-2] rec[-8] - CHECK rec[-2] rec[-3] rec[-7] + CHECK M0 IN0.S0 + CHECK M1 IN0.S1 + CHECK M1 M0 IN0.S2 OUTPUT RepetitionCode 0 2 4 OUTPUT RepetitionCode 6 8 10 - CHECK rec[-6] rec[-9] - CHECK rec[-5] rec[-8] - CHECK rec[-4] rec[-8] rec[-9] - CHECK rec[-3] rec[-9] rec[-12] - CHECK rec[-2] rec[-8] rec[-11] - CHECK rec[-1] rec[-8] rec[-10] rec[-15] + CHECK OUT0.S0 M0 + CHECK OUT0.S1 M1 + CHECK OUT0.S2 M1 M0 + CHECK OUT1.S0 M0 IN1.S0 + CHECK OUT1.S1 M1 IN1.S1 + CHECK OUT1.S2 M1 IN1.S2 IN0.S0 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN1.LX0 M0 M1 M2 # --- statistics --- # finished checks: 3 @@ -257,12 +283,16 @@ GADGET TransversalCNOT_SE_after_target { M 7 9 11 OUTPUT RepetitionCode 0 2 4 OUTPUT RepetitionCode 6 8 10 - CHECK rec[-6] rec[-15] - CHECK rec[-5] rec[-14] - CHECK rec[-4] rec[-13] - CHECK rec[-3] rec[-9] - CHECK rec[-2] rec[-8] - CHECK rec[-1] rec[-8] rec[-9] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT1.S0 M0 + CHECK OUT1.S1 M1 + CHECK OUT1.S2 M1 M0 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT1.LZ0 FROM + PROPAGATE OUT1.LX0 FROM M0 M1 M2 # --- statistics --- # finished checks: 0 diff --git a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome.ref.deq index a44e96e4..84df74c5 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome.ref.deq @@ -11,9 +11,11 @@ CODE RepetitionCode [[3,1,3]] { GADGET PrepareZ { R 0 1 2 OUTPUT RepetitionCode 0 1 2 - CHECK rec[-3] - CHECK rec[-2] - CHECK rec[-1] + CHECK OUT0.S0 + CHECK OUT0.S1 + CHECK OUT0.S2 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM # --- statistics --- # finished checks: 0 @@ -27,10 +29,10 @@ GADGET PrepareZ { GADGET MeasureZ { INPUT RepetitionCode 0 1 2 M 0 1 2 - READOUT rec[-3] # flipped by: LX0 - CHECK rec[-2] rec[-3] rec[-6] - CHECK rec[-1] rec[-2] rec[-5] - CHECK rec[-1] rec[-3] rec[-4] + READOUT rec[-3] # IN0.LX0 + CHECK M1 M0 IN0.S0 + CHECK M2 M1 IN0.S1 + CHECK M2 M0 IN0.S2 # --- statistics --- # finished checks: 3 @@ -44,9 +46,11 @@ GADGET MeasureZ { GADGET AutomorphismIdentity { INPUT RepetitionCode 0 1 2 OUTPUT RepetitionCode 2 1 0 - CHECK rec[-3] rec[-5] - CHECK rec[-2] rec[-6] - CHECK rec[-1] rec[-4] + CHECK OUT0.S0 IN0.S1 + CHECK OUT0.S1 IN0.S0 + CHECK OUT0.S2 IN0.S2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 IN0.DS0 IN0.DS1 # --- statistics --- # finished checks: 0 @@ -63,13 +67,15 @@ GADGET Syndrome { CX 0 1 2 3 4 5 CX 2 1 4 3 M 1 3 5 - CHECK rec[-3] rec[-6] - CHECK rec[-2] rec[-5] - CHECK rec[-2] rec[-3] rec[-4] + CHECK M0 IN0.S0 + CHECK M1 IN0.S1 + CHECK M1 M0 IN0.S2 OUTPUT RepetitionCode 0 2 4 - CHECK rec[-3] rec[-6] - CHECK rec[-2] rec[-5] - CHECK rec[-1] rec[-5] rec[-6] + CHECK OUT0.S0 M0 + CHECK OUT0.S1 M1 + CHECK OUT0.S2 M1 M0 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM M0 M1 M2 # --- statistics --- # finished checks: 3 @@ -95,13 +101,15 @@ GADGET MultiSyndrome { CX 0 1 2 3 4 5 CX 2 1 4 3 M 1 3 5 - CHECK rec[-9] rec[-12] - CHECK rec[-8] rec[-11] - CHECK rec[-8] rec[-9] rec[-10] + CHECK M0 IN0.S0 + CHECK M1 IN0.S1 + CHECK M1 M0 IN0.S2 OUTPUT RepetitionCode 0 2 4 - CHECK rec[-3] rec[-12] - CHECK rec[-2] rec[-11] - CHECK rec[-1] rec[-11] rec[-12] + CHECK OUT0.S0 M0 + CHECK OUT0.S1 M1 + CHECK OUT0.S2 M1 M0 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM M6 M7 M8 # --- statistics --- # finished checks: 3 @@ -116,9 +124,11 @@ GADGET MultiSyndrome { GADGET NOP { INPUT RepetitionCode 0 1 2 OUTPUT RepetitionCode 0 1 2 - CHECK rec[-3] rec[-6] - CHECK rec[-2] rec[-5] - CHECK rec[-1] rec[-4] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- # finished checks: 0 @@ -135,12 +145,16 @@ GADGET TransversalCNOT { CX 0 3 1 4 2 5 OUTPUT RepetitionCode 0 1 2 OUTPUT RepetitionCode 3 4 5 - CHECK rec[-6] rec[-12] - CHECK rec[-5] rec[-11] - CHECK rec[-4] rec[-10] - CHECK rec[-3] rec[-9] rec[-12] - CHECK rec[-2] rec[-8] rec[-11] - CHECK rec[-1] rec[-7] rec[-10] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT1.S0 IN1.S0 IN0.S0 + CHECK OUT1.S1 IN1.S1 IN0.S1 + CHECK OUT1.S2 IN1.S2 IN0.S2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 0 @@ -159,17 +173,21 @@ GADGET TransversalCNOT_SE_before_control { CX 2 1 4 3 M 1 3 5 CX 0 6 2 8 4 10 - CHECK rec[-3] rec[-9] - CHECK rec[-2] rec[-8] - CHECK rec[-2] rec[-3] rec[-7] + CHECK M0 IN0.S0 + CHECK M1 IN0.S1 + CHECK M1 M0 IN0.S2 OUTPUT RepetitionCode 0 2 4 OUTPUT RepetitionCode 6 8 10 - CHECK rec[-6] rec[-9] - CHECK rec[-5] rec[-8] - CHECK rec[-4] rec[-8] rec[-9] - CHECK rec[-3] rec[-9] rec[-12] - CHECK rec[-2] rec[-8] rec[-11] - CHECK rec[-1] rec[-8] rec[-10] rec[-15] + CHECK OUT0.S0 M0 + CHECK OUT0.S1 M1 + CHECK OUT0.S2 M1 M0 + CHECK OUT1.S0 M0 IN1.S0 + CHECK OUT1.S1 M1 IN1.S1 + CHECK OUT1.S2 M1 IN1.S2 IN0.S0 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN1.LX0 M0 M1 M2 # --- statistics --- # finished checks: 3 @@ -189,17 +207,21 @@ GADGET TransversalCNOT_SE_before_target { CX 8 7 10 9 M 7 9 11 CX 0 6 2 8 4 10 - CHECK rec[-3] rec[-6] - CHECK rec[-2] rec[-5] - CHECK rec[-2] rec[-3] rec[-4] + CHECK M0 IN1.S0 + CHECK M1 IN1.S1 + CHECK M1 M0 IN1.S2 OUTPUT RepetitionCode 0 2 4 OUTPUT RepetitionCode 6 8 10 - CHECK rec[-6] rec[-15] - CHECK rec[-5] rec[-14] - CHECK rec[-4] rec[-13] - CHECK rec[-3] rec[-9] rec[-15] - CHECK rec[-2] rec[-8] rec[-14] - CHECK rec[-1] rec[-8] rec[-12] rec[-13] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT1.S0 M0 IN0.S0 + CHECK OUT1.S1 M1 IN0.S1 + CHECK OUT1.S2 M1 IN1.S0 IN0.S2 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT1.LZ0 FROM + PROPAGATE OUT1.LX0 FROM IN0.LX0 M0 M1 M2 # --- statistics --- # finished checks: 3 @@ -219,17 +241,21 @@ GADGET TransversalCNOT_SE_after_control { CX 0 1 2 3 4 5 CX 2 1 4 3 M 1 3 5 - CHECK rec[-3] rec[-9] - CHECK rec[-2] rec[-8] - CHECK rec[-2] rec[-3] rec[-7] + CHECK M0 IN0.S0 + CHECK M1 IN0.S1 + CHECK M1 M0 IN0.S2 OUTPUT RepetitionCode 0 2 4 OUTPUT RepetitionCode 6 8 10 - CHECK rec[-6] rec[-9] - CHECK rec[-5] rec[-8] - CHECK rec[-4] rec[-8] rec[-9] - CHECK rec[-3] rec[-9] rec[-12] - CHECK rec[-2] rec[-8] rec[-11] - CHECK rec[-1] rec[-8] rec[-10] rec[-15] + CHECK OUT0.S0 M0 + CHECK OUT0.S1 M1 + CHECK OUT0.S2 M1 M0 + CHECK OUT1.S0 M0 IN1.S0 + CHECK OUT1.S1 M1 IN1.S1 + CHECK OUT1.S2 M1 IN1.S2 IN0.S0 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN1.LX0 M0 M1 M2 # --- statistics --- # finished checks: 3 @@ -251,12 +277,16 @@ GADGET TransversalCNOT_SE_after_target { M 7 9 11 OUTPUT RepetitionCode 0 2 4 OUTPUT RepetitionCode 6 8 10 - CHECK rec[-6] rec[-15] - CHECK rec[-5] rec[-14] - CHECK rec[-4] rec[-13] - CHECK rec[-3] rec[-9] - CHECK rec[-2] rec[-8] - CHECK rec[-1] rec[-8] rec[-9] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT1.S0 M0 + CHECK OUT1.S1 M1 + CHECK OUT1.S2 M1 M0 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT1.LZ0 FROM + PROPAGATE OUT1.LX0 FROM M0 M1 M2 # --- statistics --- # finished checks: 0 diff --git a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.transversal.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.transversal.ref.deq index f405a2aa..03973672 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.transversal.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.transversal.ref.deq @@ -11,9 +11,11 @@ CODE RepetitionCode [[3,1,3]] { GADGET PrepareZ { R 0 1 2 OUTPUT RepetitionCode 0 1 2 - CHECK rec[-3] - CHECK rec[-2] - CHECK rec[-1] + CHECK OUT0.S0 + CHECK OUT0.S1 + CHECK OUT0.S2 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM # --- statistics --- # finished checks: 0 @@ -27,10 +29,10 @@ GADGET PrepareZ { GADGET MeasureZ { INPUT RepetitionCode 0 1 2 M 0 1 2 - READOUT rec[-3] # flipped by: LX0 - CHECK rec[-2] rec[-3] rec[-6] - CHECK rec[-1] rec[-2] rec[-5] - CHECK rec[-1] rec[-3] rec[-4] + READOUT rec[-3] # IN0.LX0 + CHECK M1 M0 IN0.S0 + CHECK M2 M1 IN0.S1 + CHECK M2 M0 IN0.S2 # --- statistics --- # finished checks: 3 @@ -44,9 +46,11 @@ GADGET MeasureZ { GADGET AutomorphismIdentity { INPUT RepetitionCode 0 1 2 OUTPUT RepetitionCode 2 1 0 - CHECK rec[-3] rec[-5] - CHECK rec[-2] rec[-6] - CHECK rec[-1] rec[-4] + CHECK OUT0.S0 IN0.S1 + CHECK OUT0.S1 IN0.S0 + CHECK OUT0.S2 IN0.S2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 IN0.DS0 IN0.DS1 # --- statistics --- # finished checks: 0 @@ -63,13 +67,15 @@ GADGET Syndrome { CX 0 1 2 3 4 5 CX 2 1 4 3 M 1 3 5 - CHECK rec[-3] rec[-6] - CHECK rec[-2] rec[-5] - CHECK rec[-2] rec[-3] rec[-4] + CHECK M0 IN0.S0 + CHECK M1 IN0.S1 + CHECK M1 M0 IN0.S2 OUTPUT RepetitionCode 0 2 4 - CHECK rec[-3] rec[-9] - CHECK rec[-2] rec[-8] - CHECK rec[-1] rec[-7] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM M0 M1 M2 # --- statistics --- # finished checks: 3 @@ -95,19 +101,21 @@ GADGET MultiSyndrome { CX 0 1 2 3 4 5 CX 2 1 4 3 M 1 3 5 - CHECK rec[-3] rec[-12] - CHECK rec[-2] rec[-11] - CHECK rec[-2] rec[-3] rec[-10] - CHECK rec[-3] rec[-9] - CHECK rec[-2] rec[-8] - CHECK rec[-1] rec[-7] - CHECK rec[-3] rec[-6] - CHECK rec[-2] rec[-5] - CHECK rec[-1] rec[-4] + CHECK M6 IN0.S0 + CHECK M7 IN0.S1 + CHECK M7 M6 IN0.S2 + CHECK M6 M0 + CHECK M7 M1 + CHECK M8 M2 + CHECK M6 M3 + CHECK M7 M4 + CHECK M8 M5 OUTPUT RepetitionCode 0 2 4 - CHECK rec[-3] rec[-15] - CHECK rec[-2] rec[-14] - CHECK rec[-1] rec[-13] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM M6 M7 M8 # --- statistics --- # finished checks: 9 @@ -122,9 +130,11 @@ GADGET MultiSyndrome { GADGET NOP { INPUT RepetitionCode 0 1 2 OUTPUT RepetitionCode 0 1 2 - CHECK rec[-3] rec[-6] - CHECK rec[-2] rec[-5] - CHECK rec[-1] rec[-4] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- # finished checks: 0 @@ -141,12 +151,16 @@ GADGET TransversalCNOT { CX 0 3 1 4 2 5 OUTPUT RepetitionCode 0 1 2 OUTPUT RepetitionCode 3 4 5 - CHECK rec[-6] rec[-12] - CHECK rec[-5] rec[-11] - CHECK rec[-4] rec[-10] - CHECK rec[-3] rec[-9] rec[-12] - CHECK rec[-2] rec[-8] rec[-11] - CHECK rec[-1] rec[-7] rec[-10] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT1.S0 IN1.S0 IN0.S0 + CHECK OUT1.S1 IN1.S1 IN0.S1 + CHECK OUT1.S2 IN1.S2 IN0.S2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 0 @@ -165,17 +179,21 @@ GADGET TransversalCNOT_SE_before_control { CX 2 1 4 3 M 1 3 5 CX 0 6 2 8 4 10 - CHECK rec[-3] rec[-9] - CHECK rec[-2] rec[-8] - CHECK rec[-2] rec[-3] rec[-7] + CHECK M0 IN0.S0 + CHECK M1 IN0.S1 + CHECK M1 M0 IN0.S2 OUTPUT RepetitionCode 0 2 4 OUTPUT RepetitionCode 6 8 10 - CHECK rec[-6] rec[-15] - CHECK rec[-5] rec[-14] - CHECK rec[-4] rec[-13] - CHECK rec[-3] rec[-12] rec[-15] - CHECK rec[-2] rec[-11] rec[-14] - CHECK rec[-1] rec[-10] rec[-13] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT1.S0 IN1.S0 IN0.S0 + CHECK OUT1.S1 IN1.S1 IN0.S1 + CHECK OUT1.S2 IN1.S2 IN0.S2 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN1.LX0 M0 M1 M2 # --- statistics --- # finished checks: 3 @@ -195,17 +213,21 @@ GADGET TransversalCNOT_SE_before_target { CX 8 7 10 9 M 7 9 11 CX 0 6 2 8 4 10 - CHECK rec[-3] rec[-6] - CHECK rec[-2] rec[-5] - CHECK rec[-2] rec[-3] rec[-4] + CHECK M0 IN1.S0 + CHECK M1 IN1.S1 + CHECK M1 M0 IN1.S2 OUTPUT RepetitionCode 0 2 4 OUTPUT RepetitionCode 6 8 10 - CHECK rec[-6] rec[-15] - CHECK rec[-5] rec[-14] - CHECK rec[-4] rec[-13] - CHECK rec[-3] rec[-12] rec[-15] - CHECK rec[-2] rec[-11] rec[-14] - CHECK rec[-1] rec[-10] rec[-13] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT1.S0 IN1.S0 IN0.S0 + CHECK OUT1.S1 IN1.S1 IN0.S1 + CHECK OUT1.S2 IN1.S2 IN0.S2 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT1.LZ0 FROM + PROPAGATE OUT1.LX0 FROM IN0.LX0 M0 M1 M2 # --- statistics --- # finished checks: 3 @@ -225,17 +247,21 @@ GADGET TransversalCNOT_SE_after_control { CX 0 1 2 3 4 5 CX 2 1 4 3 M 1 3 5 - CHECK rec[-3] rec[-9] - CHECK rec[-2] rec[-8] - CHECK rec[-2] rec[-3] rec[-7] + CHECK M0 IN0.S0 + CHECK M1 IN0.S1 + CHECK M1 M0 IN0.S2 OUTPUT RepetitionCode 0 2 4 OUTPUT RepetitionCode 6 8 10 - CHECK rec[-6] rec[-15] - CHECK rec[-5] rec[-14] - CHECK rec[-4] rec[-13] - CHECK rec[-3] rec[-12] rec[-15] - CHECK rec[-2] rec[-11] rec[-14] - CHECK rec[-1] rec[-10] rec[-13] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT1.S0 IN1.S0 IN0.S0 + CHECK OUT1.S1 IN1.S1 IN0.S1 + CHECK OUT1.S2 IN1.S2 IN0.S2 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN1.LX0 M0 M1 M2 # --- statistics --- # finished checks: 3 @@ -255,17 +281,21 @@ GADGET TransversalCNOT_SE_after_target { CX 6 7 8 9 10 11 CX 8 7 10 9 M 7 9 11 - CHECK rec[-3] rec[-6] rec[-9] - CHECK rec[-2] rec[-5] rec[-8] - CHECK rec[-2] rec[-3] rec[-4] rec[-7] + CHECK M0 IN1.S0 IN0.S0 + CHECK M1 IN1.S1 IN0.S1 + CHECK M1 M0 IN1.S2 IN0.S2 OUTPUT RepetitionCode 0 2 4 OUTPUT RepetitionCode 6 8 10 - CHECK rec[-6] rec[-15] - CHECK rec[-5] rec[-14] - CHECK rec[-4] rec[-13] - CHECK rec[-3] rec[-9] - CHECK rec[-2] rec[-8] - CHECK rec[-1] rec[-10] rec[-13] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT1.S0 M0 + CHECK OUT1.S1 M1 + CHECK OUT1.S2 IN1.S2 IN0.S2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT1.LZ0 FROM + PROPAGATE OUT1.LX0 FROM M0 M1 M2 # --- statistics --- # finished checks: 3 diff --git a/deq/tests/transpiler/check_optimizer_fixtures/surface_code/surface_code_d3.auto.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/surface_code/surface_code_d3.auto.ref.deq index c21d069e..013fdb46 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/surface_code/surface_code_d3.auto.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/surface_code/surface_code_d3.auto.ref.deq @@ -16,14 +16,16 @@ CODE SurfaceCode [[9,1,3]] { GADGET AutomorphismIdentity { INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 OUTPUT SurfaceCode 8 7 6 5 4 3 2 1 0 - CHECK rec[-8] rec[-9] - CHECK rec[-7] rec[-10] - CHECK rec[-6] rec[-11] - CHECK rec[-5] rec[-12] - CHECK rec[-4] rec[-13] - CHECK rec[-3] rec[-14] - CHECK rec[-2] rec[-15] - CHECK rec[-1] rec[-16] + CHECK OUT0.S0 IN0.S7 + CHECK OUT0.S1 IN0.S6 + CHECK OUT0.S2 IN0.S5 + CHECK OUT0.S3 IN0.S4 + CHECK OUT0.S4 IN0.S3 + CHECK OUT0.S5 IN0.S2 + CHECK OUT0.S6 IN0.S1 + CHECK OUT0.S7 IN0.S0 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN0.DS1 IN0.DS3 IN0.DS4 IN0.DS6 + PROPAGATE OUT0.LX0 FROM IN0.LX0 IN0.DS0 IN0.DS2 IN0.DS5 IN0.DS7 # --- statistics --- # finished checks: 0 @@ -48,19 +50,21 @@ GADGET PrepareZ { CNOT 10 3 12 5 13 7 MZ 9 11 14 16 MX 10 12 13 15 - CHECK rec[-8] - CHECK rec[-7] - CHECK rec[-6] - CHECK rec[-5] + CHECK M0 + CHECK M1 + CHECK M2 + CHECK M3 OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 - CHECK rec[-8] - CHECK rec[-7] rec[-12] - CHECK rec[-6] - CHECK rec[-5] rec[-11] - CHECK rec[-4] rec[-10] - CHECK rec[-3] - CHECK rec[-2] rec[-9] - CHECK rec[-1] + CHECK OUT0.S0 + CHECK OUT0.S1 M4 + CHECK OUT0.S2 + CHECK OUT0.S3 M5 + CHECK OUT0.S4 M6 + CHECK OUT0.S5 + CHECK OUT0.S6 M7 + CHECK OUT0.S7 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM # --- statistics --- # finished checks: 4 @@ -75,11 +79,11 @@ GADGET PrepareZ { GADGET MeasureZ { INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 MZ 0 1 2 3 4 5 6 7 8 - READOUT rec[-9] rec[-6] rec[-3] # flipped by: LX0 - CHECK rec[-7] rec[-8] rec[-17] - CHECK rec[-5] rec[-6] rec[-8] rec[-9] rec[-15] - CHECK rec[-1] rec[-2] rec[-4] rec[-5] rec[-12] - CHECK rec[-2] rec[-3] rec[-10] + READOUT rec[-9] rec[-6] rec[-3] # IN0.LX0 + CHECK M2 M1 IN0.S0 + CHECK M4 M3 M1 M0 IN0.S2 + CHECK M8 M7 M5 M4 IN0.S5 + CHECK M7 M6 IN0.S7 # --- statistics --- # finished checks: 4 @@ -104,23 +108,25 @@ GADGET Syndrome { CNOT 10 3 12 5 13 7 MZ 9 11 14 16 MX 10 12 13 15 - CHECK rec[-8] rec[-16] - CHECK rec[-4] rec[-15] - CHECK rec[-7] rec[-14] - CHECK rec[-3] rec[-13] - CHECK rec[-2] rec[-12] - CHECK rec[-6] rec[-11] - CHECK rec[-1] rec[-10] - CHECK rec[-5] rec[-9] + CHECK M0 IN0.S0 + CHECK M4 IN0.S1 + CHECK M1 IN0.S2 + CHECK M5 IN0.S3 + CHECK M6 IN0.S4 + CHECK M2 IN0.S5 + CHECK M7 IN0.S6 + CHECK M3 IN0.S7 OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 - CHECK rec[-8] rec[-16] - CHECK rec[-7] rec[-12] - CHECK rec[-6] rec[-15] - CHECK rec[-5] rec[-11] - CHECK rec[-4] rec[-10] - CHECK rec[-3] rec[-14] - CHECK rec[-2] rec[-9] - CHECK rec[-1] rec[-13] + CHECK OUT0.S0 M0 + CHECK OUT0.S1 M4 + CHECK OUT0.S2 M1 + CHECK OUT0.S3 M5 + CHECK OUT0.S4 M6 + CHECK OUT0.S5 M2 + CHECK OUT0.S6 M7 + CHECK OUT0.S7 M3 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- # finished checks: 8 @@ -135,14 +141,16 @@ GADGET Syndrome { GADGET NOP { INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 - CHECK rec[-8] rec[-16] - CHECK rec[-7] rec[-15] - CHECK rec[-6] rec[-14] - CHECK rec[-5] rec[-13] - CHECK rec[-4] rec[-12] - CHECK rec[-3] rec[-11] - CHECK rec[-2] rec[-10] - CHECK rec[-1] rec[-9] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT0.S3 IN0.S3 + CHECK OUT0.S4 IN0.S4 + CHECK OUT0.S5 IN0.S5 + CHECK OUT0.S6 IN0.S6 + CHECK OUT0.S7 IN0.S7 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- # finished checks: 0 @@ -159,22 +167,26 @@ GADGET TransversalCNOT { CX 0 9 1 10 2 11 3 12 4 13 5 14 6 15 7 16 8 17 OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17 - CHECK rec[-16] rec[-32] - CHECK rec[-15] rec[-23] rec[-31] - CHECK rec[-14] rec[-30] - CHECK rec[-13] rec[-21] rec[-29] - CHECK rec[-12] rec[-20] rec[-28] - CHECK rec[-11] rec[-27] - CHECK rec[-10] rec[-18] rec[-26] - CHECK rec[-9] rec[-25] - CHECK rec[-8] rec[-24] rec[-32] - CHECK rec[-7] rec[-23] - CHECK rec[-6] rec[-22] rec[-30] - CHECK rec[-5] rec[-21] - CHECK rec[-4] rec[-20] - CHECK rec[-3] rec[-19] rec[-27] - CHECK rec[-2] rec[-18] - CHECK rec[-1] rec[-17] rec[-25] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN1.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT0.S3 IN1.S3 IN0.S3 + CHECK OUT0.S4 IN1.S4 IN0.S4 + CHECK OUT0.S5 IN0.S5 + CHECK OUT0.S6 IN1.S6 IN0.S6 + CHECK OUT0.S7 IN0.S7 + CHECK OUT1.S0 IN1.S0 IN0.S0 + CHECK OUT1.S1 IN1.S1 + CHECK OUT1.S2 IN1.S2 IN0.S2 + CHECK OUT1.S3 IN1.S3 + CHECK OUT1.S4 IN1.S4 + CHECK OUT1.S5 IN1.S5 IN0.S5 + CHECK OUT1.S6 IN1.S6 + CHECK OUT1.S7 IN1.S7 IN0.S7 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 0 diff --git a/deq/tests/transpiler/check_optimizer_fixtures/surface_code/surface_code_d3.syndrome-meta.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/surface_code/surface_code_d3.syndrome-meta.ref.deq index c287934b..c6e6c2c9 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/surface_code/surface_code_d3.syndrome-meta.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/surface_code/surface_code_d3.syndrome-meta.ref.deq @@ -16,14 +16,16 @@ CODE SurfaceCode [[9,1,3]] { GADGET AutomorphismIdentity { INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 OUTPUT SurfaceCode 8 7 6 5 4 3 2 1 0 - CHECK rec[-8] rec[-9] - CHECK rec[-7] rec[-10] - CHECK rec[-6] rec[-11] - CHECK rec[-5] rec[-12] - CHECK rec[-4] rec[-13] - CHECK rec[-3] rec[-14] - CHECK rec[-2] rec[-15] - CHECK rec[-1] rec[-16] + CHECK OUT0.S0 IN0.S7 + CHECK OUT0.S1 IN0.S6 + CHECK OUT0.S2 IN0.S5 + CHECK OUT0.S3 IN0.S4 + CHECK OUT0.S4 IN0.S3 + CHECK OUT0.S5 IN0.S2 + CHECK OUT0.S6 IN0.S1 + CHECK OUT0.S7 IN0.S0 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN0.DS1 IN0.DS3 IN0.DS4 IN0.DS6 + PROPAGATE OUT0.LX0 FROM IN0.LX0 IN0.DS0 IN0.DS2 IN0.DS5 IN0.DS7 # --- statistics --- # finished checks: 0 @@ -48,19 +50,21 @@ GADGET PrepareZ { CNOT 10 3 12 5 13 7 MZ 9 11 14 16 MX 10 12 13 15 - CHECK rec[-8] - CHECK rec[-7] - CHECK rec[-6] - CHECK rec[-5] + CHECK M0 + CHECK M1 + CHECK M2 + CHECK M3 OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 - CHECK rec[-8] - CHECK rec[-7] rec[-12] - CHECK rec[-6] - CHECK rec[-5] rec[-11] - CHECK rec[-4] rec[-10] - CHECK rec[-3] - CHECK rec[-2] rec[-9] - CHECK rec[-1] + CHECK OUT0.S0 + CHECK OUT0.S1 M4 + CHECK OUT0.S2 + CHECK OUT0.S3 M5 + CHECK OUT0.S4 M6 + CHECK OUT0.S5 + CHECK OUT0.S6 M7 + CHECK OUT0.S7 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM # --- statistics --- # finished checks: 4 @@ -75,9 +79,9 @@ GADGET PrepareZ { GADGET MeasureZ { INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 MZ 0 1 2 3 4 5 6 7 8 - READOUT rec[-9] rec[-6] rec[-3] # flipped by: LX0 - CHECK rec[-7] rec[-8] rec[-17] - CHECK rec[-2] rec[-3] rec[-10] + READOUT rec[-9] rec[-6] rec[-3] # IN0.LX0 + CHECK M2 M1 IN0.S0 + CHECK M7 M6 IN0.S7 # --- statistics --- # finished checks: 2 @@ -102,23 +106,25 @@ GADGET Syndrome { CNOT 10 3 12 5 13 7 MZ 9 11 14 16 MX 10 12 13 15 - CHECK rec[-8] rec[-16] - CHECK rec[-4] rec[-15] - CHECK rec[-7] rec[-14] - CHECK rec[-3] rec[-13] - CHECK rec[-2] rec[-12] - CHECK rec[-6] rec[-11] - CHECK rec[-1] rec[-10] - CHECK rec[-5] rec[-9] + CHECK M0 IN0.S0 + CHECK M4 IN0.S1 + CHECK M1 IN0.S2 + CHECK M5 IN0.S3 + CHECK M6 IN0.S4 + CHECK M2 IN0.S5 + CHECK M7 IN0.S6 + CHECK M3 IN0.S7 OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 - CHECK rec[-8] rec[-16] - CHECK rec[-7] rec[-12] - CHECK rec[-6] rec[-15] - CHECK rec[-5] rec[-11] - CHECK rec[-4] rec[-10] - CHECK rec[-3] rec[-14] - CHECK rec[-2] rec[-9] - CHECK rec[-1] rec[-13] + CHECK OUT0.S0 M0 + CHECK OUT0.S1 M4 + CHECK OUT0.S2 M1 + CHECK OUT0.S3 M5 + CHECK OUT0.S4 M6 + CHECK OUT0.S5 M2 + CHECK OUT0.S6 M7 + CHECK OUT0.S7 M3 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- # finished checks: 8 @@ -133,14 +139,16 @@ GADGET Syndrome { GADGET NOP { INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 - CHECK rec[-8] rec[-16] - CHECK rec[-7] rec[-15] - CHECK rec[-6] rec[-14] - CHECK rec[-5] rec[-13] - CHECK rec[-4] rec[-12] - CHECK rec[-3] rec[-11] - CHECK rec[-2] rec[-10] - CHECK rec[-1] rec[-9] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT0.S3 IN0.S3 + CHECK OUT0.S4 IN0.S4 + CHECK OUT0.S5 IN0.S5 + CHECK OUT0.S6 IN0.S6 + CHECK OUT0.S7 IN0.S7 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- # finished checks: 0 @@ -157,22 +165,26 @@ GADGET TransversalCNOT { CX 0 9 1 10 2 11 3 12 4 13 5 14 6 15 7 16 8 17 OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17 - CHECK rec[-16] rec[-32] - CHECK rec[-15] rec[-23] rec[-31] - CHECK rec[-14] rec[-30] - CHECK rec[-13] rec[-21] rec[-29] - CHECK rec[-12] rec[-20] rec[-28] - CHECK rec[-11] rec[-27] - CHECK rec[-10] rec[-18] rec[-26] - CHECK rec[-9] rec[-25] - CHECK rec[-8] rec[-24] rec[-32] - CHECK rec[-7] rec[-23] - CHECK rec[-6] rec[-22] rec[-30] - CHECK rec[-5] rec[-21] - CHECK rec[-4] rec[-20] - CHECK rec[-3] rec[-19] rec[-27] - CHECK rec[-2] rec[-18] - CHECK rec[-1] rec[-17] rec[-25] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN1.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT0.S3 IN1.S3 IN0.S3 + CHECK OUT0.S4 IN1.S4 IN0.S4 + CHECK OUT0.S5 IN0.S5 + CHECK OUT0.S6 IN1.S6 IN0.S6 + CHECK OUT0.S7 IN0.S7 + CHECK OUT1.S0 IN1.S0 IN0.S0 + CHECK OUT1.S1 IN1.S1 + CHECK OUT1.S2 IN1.S2 IN0.S2 + CHECK OUT1.S3 IN1.S3 + CHECK OUT1.S4 IN1.S4 + CHECK OUT1.S5 IN1.S5 IN0.S5 + CHECK OUT1.S6 IN1.S6 + CHECK OUT1.S7 IN1.S7 IN0.S7 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 0 diff --git a/deq/tests/transpiler/check_optimizer_fixtures/surface_code/surface_code_d3.syndrome.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/surface_code/surface_code_d3.syndrome.ref.deq index 29b8f622..c679be44 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/surface_code/surface_code_d3.syndrome.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/surface_code/surface_code_d3.syndrome.ref.deq @@ -16,14 +16,16 @@ CODE SurfaceCode [[9,1,3]] { GADGET AutomorphismIdentity { INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 OUTPUT SurfaceCode 8 7 6 5 4 3 2 1 0 - CHECK rec[-8] rec[-9] - CHECK rec[-7] rec[-10] - CHECK rec[-6] rec[-11] - CHECK rec[-5] rec[-12] - CHECK rec[-4] rec[-13] - CHECK rec[-3] rec[-14] - CHECK rec[-2] rec[-15] - CHECK rec[-1] rec[-16] + CHECK OUT0.S0 IN0.S7 + CHECK OUT0.S1 IN0.S6 + CHECK OUT0.S2 IN0.S5 + CHECK OUT0.S3 IN0.S4 + CHECK OUT0.S4 IN0.S3 + CHECK OUT0.S5 IN0.S2 + CHECK OUT0.S6 IN0.S1 + CHECK OUT0.S7 IN0.S0 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN0.DS1 IN0.DS3 IN0.DS4 IN0.DS6 + PROPAGATE OUT0.LX0 FROM IN0.LX0 IN0.DS0 IN0.DS2 IN0.DS5 IN0.DS7 # --- statistics --- # finished checks: 0 @@ -49,14 +51,16 @@ GADGET PrepareZ { MZ 9 11 14 16 MX 10 12 13 15 OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 - CHECK rec[-8] - CHECK rec[-7] rec[-12] - CHECK rec[-6] - CHECK rec[-5] rec[-11] - CHECK rec[-4] rec[-10] - CHECK rec[-3] - CHECK rec[-2] rec[-9] - CHECK rec[-1] + CHECK OUT0.S0 + CHECK OUT0.S1 M4 + CHECK OUT0.S2 + CHECK OUT0.S3 M5 + CHECK OUT0.S4 M6 + CHECK OUT0.S5 + CHECK OUT0.S6 M7 + CHECK OUT0.S7 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM # --- statistics --- # finished checks: 0 @@ -70,9 +74,9 @@ GADGET PrepareZ { GADGET MeasureZ { INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 MZ 0 1 2 3 4 5 6 7 8 - READOUT rec[-9] rec[-6] rec[-3] # flipped by: LX0 - CHECK rec[-7] rec[-8] rec[-17] - CHECK rec[-2] rec[-3] rec[-10] + READOUT rec[-9] rec[-6] rec[-3] # IN0.LX0 + CHECK M2 M1 IN0.S0 + CHECK M7 M6 IN0.S7 # --- statistics --- # finished checks: 2 @@ -97,23 +101,25 @@ GADGET Syndrome { CNOT 10 3 12 5 13 7 MZ 9 11 14 16 MX 10 12 13 15 - CHECK rec[-8] rec[-16] - CHECK rec[-4] rec[-15] - CHECK rec[-7] rec[-14] - CHECK rec[-3] rec[-13] - CHECK rec[-2] rec[-12] - CHECK rec[-6] rec[-11] - CHECK rec[-1] rec[-10] - CHECK rec[-5] rec[-9] + CHECK M0 IN0.S0 + CHECK M4 IN0.S1 + CHECK M1 IN0.S2 + CHECK M5 IN0.S3 + CHECK M6 IN0.S4 + CHECK M2 IN0.S5 + CHECK M7 IN0.S6 + CHECK M3 IN0.S7 OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 - CHECK rec[-8] rec[-16] - CHECK rec[-7] rec[-12] - CHECK rec[-6] rec[-15] - CHECK rec[-5] rec[-11] - CHECK rec[-4] rec[-10] - CHECK rec[-3] rec[-14] - CHECK rec[-2] rec[-9] - CHECK rec[-1] rec[-13] + CHECK OUT0.S0 M0 + CHECK OUT0.S1 M4 + CHECK OUT0.S2 M1 + CHECK OUT0.S3 M5 + CHECK OUT0.S4 M6 + CHECK OUT0.S5 M2 + CHECK OUT0.S6 M7 + CHECK OUT0.S7 M3 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- # finished checks: 8 @@ -128,14 +134,16 @@ GADGET Syndrome { GADGET NOP { INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 - CHECK rec[-8] rec[-16] - CHECK rec[-7] rec[-15] - CHECK rec[-6] rec[-14] - CHECK rec[-5] rec[-13] - CHECK rec[-4] rec[-12] - CHECK rec[-3] rec[-11] - CHECK rec[-2] rec[-10] - CHECK rec[-1] rec[-9] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT0.S3 IN0.S3 + CHECK OUT0.S4 IN0.S4 + CHECK OUT0.S5 IN0.S5 + CHECK OUT0.S6 IN0.S6 + CHECK OUT0.S7 IN0.S7 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- # finished checks: 0 @@ -152,22 +160,26 @@ GADGET TransversalCNOT { CX 0 9 1 10 2 11 3 12 4 13 5 14 6 15 7 16 8 17 OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17 - CHECK rec[-16] rec[-32] - CHECK rec[-15] rec[-23] rec[-31] - CHECK rec[-14] rec[-30] - CHECK rec[-13] rec[-21] rec[-29] - CHECK rec[-12] rec[-20] rec[-28] - CHECK rec[-11] rec[-27] - CHECK rec[-10] rec[-18] rec[-26] - CHECK rec[-9] rec[-25] - CHECK rec[-8] rec[-24] rec[-32] - CHECK rec[-7] rec[-23] - CHECK rec[-6] rec[-22] rec[-30] - CHECK rec[-5] rec[-21] - CHECK rec[-4] rec[-20] - CHECK rec[-3] rec[-19] rec[-27] - CHECK rec[-2] rec[-18] - CHECK rec[-1] rec[-17] rec[-25] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN1.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT0.S3 IN1.S3 IN0.S3 + CHECK OUT0.S4 IN1.S4 IN0.S4 + CHECK OUT0.S5 IN0.S5 + CHECK OUT0.S6 IN1.S6 IN0.S6 + CHECK OUT0.S7 IN0.S7 + CHECK OUT1.S0 IN1.S0 IN0.S0 + CHECK OUT1.S1 IN1.S1 + CHECK OUT1.S2 IN1.S2 IN0.S2 + CHECK OUT1.S3 IN1.S3 + CHECK OUT1.S4 IN1.S4 + CHECK OUT1.S5 IN1.S5 IN0.S5 + CHECK OUT1.S6 IN1.S6 + CHECK OUT1.S7 IN1.S7 IN0.S7 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 0 diff --git a/deq/tests/transpiler/check_optimizer_fixtures/surface_code/surface_code_d3.transversal.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/surface_code/surface_code_d3.transversal.ref.deq index 98ba385a..495a6701 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/surface_code/surface_code_d3.transversal.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/surface_code/surface_code_d3.transversal.ref.deq @@ -16,14 +16,16 @@ CODE SurfaceCode [[9,1,3]] { GADGET AutomorphismIdentity { INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 OUTPUT SurfaceCode 8 7 6 5 4 3 2 1 0 - CHECK rec[-8] rec[-9] - CHECK rec[-7] rec[-10] - CHECK rec[-6] rec[-11] - CHECK rec[-5] rec[-12] - CHECK rec[-4] rec[-13] - CHECK rec[-3] rec[-14] - CHECK rec[-2] rec[-15] - CHECK rec[-1] rec[-16] + CHECK OUT0.S0 IN0.S7 + CHECK OUT0.S1 IN0.S6 + CHECK OUT0.S2 IN0.S5 + CHECK OUT0.S3 IN0.S4 + CHECK OUT0.S4 IN0.S3 + CHECK OUT0.S5 IN0.S2 + CHECK OUT0.S6 IN0.S1 + CHECK OUT0.S7 IN0.S0 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN0.DS1 IN0.DS3 IN0.DS4 IN0.DS6 + PROPAGATE OUT0.LX0 FROM IN0.LX0 IN0.DS0 IN0.DS2 IN0.DS5 IN0.DS7 # --- statistics --- # finished checks: 0 @@ -48,19 +50,21 @@ GADGET PrepareZ { CNOT 10 3 12 5 13 7 MZ 9 11 14 16 MX 10 12 13 15 - CHECK rec[-8] - CHECK rec[-7] - CHECK rec[-6] - CHECK rec[-5] + CHECK M0 + CHECK M1 + CHECK M2 + CHECK M3 OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 - CHECK rec[-8] - CHECK rec[-7] rec[-12] - CHECK rec[-6] - CHECK rec[-5] rec[-11] - CHECK rec[-4] rec[-10] - CHECK rec[-3] - CHECK rec[-2] rec[-9] - CHECK rec[-1] + CHECK OUT0.S0 + CHECK OUT0.S1 M4 + CHECK OUT0.S2 + CHECK OUT0.S3 M5 + CHECK OUT0.S4 M6 + CHECK OUT0.S5 + CHECK OUT0.S6 M7 + CHECK OUT0.S7 + PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LX0 FROM # --- statistics --- # finished checks: 4 @@ -75,11 +79,11 @@ GADGET PrepareZ { GADGET MeasureZ { INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 MZ 0 1 2 3 4 5 6 7 8 - READOUT rec[-9] rec[-6] rec[-3] # flipped by: LX0 - CHECK rec[-7] rec[-8] rec[-17] - CHECK rec[-5] rec[-6] rec[-8] rec[-9] rec[-15] - CHECK rec[-1] rec[-2] rec[-4] rec[-5] rec[-12] - CHECK rec[-2] rec[-3] rec[-10] + READOUT rec[-9] rec[-6] rec[-3] # IN0.LX0 + CHECK M2 M1 IN0.S0 + CHECK M4 M3 M1 M0 IN0.S2 + CHECK M8 M7 M5 M4 IN0.S5 + CHECK M7 M6 IN0.S7 # --- statistics --- # finished checks: 4 @@ -104,23 +108,25 @@ GADGET Syndrome { CNOT 10 3 12 5 13 7 MZ 9 11 14 16 MX 10 12 13 15 - CHECK rec[-8] rec[-16] - CHECK rec[-4] rec[-15] - CHECK rec[-7] rec[-14] - CHECK rec[-3] rec[-13] - CHECK rec[-2] rec[-12] - CHECK rec[-6] rec[-11] - CHECK rec[-1] rec[-10] - CHECK rec[-5] rec[-9] + CHECK M0 IN0.S0 + CHECK M4 IN0.S1 + CHECK M1 IN0.S2 + CHECK M5 IN0.S3 + CHECK M6 IN0.S4 + CHECK M2 IN0.S5 + CHECK M7 IN0.S6 + CHECK M3 IN0.S7 OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 - CHECK rec[-8] rec[-24] - CHECK rec[-7] rec[-23] - CHECK rec[-6] rec[-22] - CHECK rec[-5] rec[-21] - CHECK rec[-4] rec[-20] - CHECK rec[-3] rec[-19] - CHECK rec[-2] rec[-18] - CHECK rec[-1] rec[-17] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT0.S3 IN0.S3 + CHECK OUT0.S4 IN0.S4 + CHECK OUT0.S5 IN0.S5 + CHECK OUT0.S6 IN0.S6 + CHECK OUT0.S7 IN0.S7 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- # finished checks: 8 @@ -135,14 +141,16 @@ GADGET Syndrome { GADGET NOP { INPUT SurfaceCode 0 1 2 3 4 5 6 7 8 OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 - CHECK rec[-8] rec[-16] - CHECK rec[-7] rec[-15] - CHECK rec[-6] rec[-14] - CHECK rec[-5] rec[-13] - CHECK rec[-4] rec[-12] - CHECK rec[-3] rec[-11] - CHECK rec[-2] rec[-10] - CHECK rec[-1] rec[-9] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT0.S3 IN0.S3 + CHECK OUT0.S4 IN0.S4 + CHECK OUT0.S5 IN0.S5 + CHECK OUT0.S6 IN0.S6 + CHECK OUT0.S7 IN0.S7 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- # finished checks: 0 @@ -159,22 +167,26 @@ GADGET TransversalCNOT { CX 0 9 1 10 2 11 3 12 4 13 5 14 6 15 7 16 8 17 OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17 - CHECK rec[-16] rec[-32] - CHECK rec[-15] rec[-23] rec[-31] - CHECK rec[-14] rec[-30] - CHECK rec[-13] rec[-21] rec[-29] - CHECK rec[-12] rec[-20] rec[-28] - CHECK rec[-11] rec[-27] - CHECK rec[-10] rec[-18] rec[-26] - CHECK rec[-9] rec[-25] - CHECK rec[-8] rec[-24] rec[-32] - CHECK rec[-7] rec[-23] - CHECK rec[-6] rec[-22] rec[-30] - CHECK rec[-5] rec[-21] - CHECK rec[-4] rec[-20] - CHECK rec[-3] rec[-19] rec[-27] - CHECK rec[-2] rec[-18] - CHECK rec[-1] rec[-17] rec[-25] + CHECK OUT0.S0 IN0.S0 + CHECK OUT0.S1 IN1.S1 IN0.S1 + CHECK OUT0.S2 IN0.S2 + CHECK OUT0.S3 IN1.S3 IN0.S3 + CHECK OUT0.S4 IN1.S4 IN0.S4 + CHECK OUT0.S5 IN0.S5 + CHECK OUT0.S6 IN1.S6 IN0.S6 + CHECK OUT0.S7 IN0.S7 + CHECK OUT1.S0 IN1.S0 IN0.S0 + CHECK OUT1.S1 IN1.S1 + CHECK OUT1.S2 IN1.S2 IN0.S2 + CHECK OUT1.S3 IN1.S3 + CHECK OUT1.S4 IN1.S4 + CHECK OUT1.S5 IN1.S5 IN0.S5 + CHECK OUT1.S6 IN1.S6 + CHECK OUT1.S7 IN1.S7 IN0.S7 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 0 From e186a174899a94ef9b0dfc26b68862f0c07df70c Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Thu, 9 Jul 2026 15:39:39 -0700 Subject: [PATCH 021/157] update tutorial chapters --- deq/documents/tutorial/chapters/compose-gadgets.md | 6 +++--- deq/documents/tutorial/chapters/compose-repropagate.md | 4 ++-- deq/documents/tutorial/chapters/debug-deq-program.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/deq/documents/tutorial/chapters/compose-gadgets.md b/deq/documents/tutorial/chapters/compose-gadgets.md index 0c7e6246..fe932af4 100644 --- a/deq/documents/tutorial/chapters/compose-gadgets.md +++ b/deq/documents/tutorial/chapters/compose-gadgets.md @@ -169,7 +169,7 @@ The circuit is physically identical to running the Idle gadget 3 times. Running ERROR(0.01) C0 R0 ERROR(0.01) C0 C1 R0 ERROR(0.01) C1 R0 - READOUT rec[-3] rec[-2] rec[-1] # flipped by: IN0.LX0 + READOUT rec[-3] rec[-2] rec[-1] # IN0.LX0 CHECK M1 M0 IN0.S0 CHECK M2 M1 IN0.S1 @@ -376,7 +376,7 @@ Running `annotate` on the COMPOSE version produces a flattened `GADGET` block: ERROR(0.01) C0 R0 ERROR(0.01) C0 C1 R0 ERROR(0.01) C1 R0 - READOUT rec[-3] rec[-2] rec[-1] # flipped by: IN0.LX0 + READOUT rec[-3] rec[-2] rec[-1] # IN0.LX0 CHECK M1 M0 IN0.S0 CHECK M2 M1 IN0.S1 @@ -734,7 +734,7 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl ERROR(0.01) C0 R0 ERROR(0.01) C0 C1 R0 ERROR(0.01) C1 R0 - READOUT rec[-3] rec[-2] rec[-1] # flipped by: IN0.LX0 + READOUT rec[-3] rec[-2] rec[-1] # IN0.LX0 CHECK M1 M0 IN0.S0 CHECK M2 M1 IN0.S1 diff --git a/deq/documents/tutorial/chapters/compose-repropagate.md b/deq/documents/tutorial/chapters/compose-repropagate.md index be3ccac2..25efc47d 100644 --- a/deq/documents/tutorial/chapters/compose-repropagate.md +++ b/deq/documents/tutorial/chapters/compose-repropagate.md @@ -181,7 +181,7 @@ the diagnostic: CHECK IN0.S0 OUT0.S0 CHECK IN0.S1 OUT0.S1 CHECK M0 OUT0.S2 - READOUT M1 M3 # flipped by: IN0.LZ0 + READOUT M1 M3 # IN0.LZ0 PROPAGATE OUT0.LZ0 FROM PROPAGATE OUT0.LX0 FROM IN0.LX0 @@ -368,7 +368,7 @@ The annotated COMPOSE renders as a flat `GADGET Teleport` block: MPP X4*X5*X6*X7 CX 0 4 1 5 2 6 3 7 MX 0 1 2 3 - READOUT rec[-4] rec[-2] # flipped by: IN0.LZ0 + READOUT rec[-4] rec[-2] # IN0.LZ0 CHECK M4 M3 M2 M1 M0 IN0.S2 OUTPUT Code 4 5 6 7 CHECK OUT0.S0 IN0.S0 diff --git a/deq/documents/tutorial/chapters/debug-deq-program.md b/deq/documents/tutorial/chapters/debug-deq-program.md index b64e6527..a0ed3bda 100644 --- a/deq/documents/tutorial/chapters/debug-deq-program.md +++ b/deq/documents/tutorial/chapters/debug-deq-program.md @@ -96,7 +96,7 @@ Output: ERROR(0.01) C0 R0 ERROR(0.01) C0 C1 R0 ERROR(0.01) C1 R0 - READOUT rec[-3] rec[-2] rec[-1] # flipped by: IN0.LX0 + READOUT rec[-3] rec[-2] rec[-1] # IN0.LX0 CHECK M1 M0 IN0.S0 CHECK M2 M1 IN0.S1 From 79d03bed7d362ca4cd67c3656d629668118f0c4e Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Thu, 9 Jul 2026 16:21:19 -0700 Subject: [PATCH 022/157] add readout propagation chapter --- .../tutorial/chapters/readout-propagation.md | 294 ++++++++++++++++++ .../gen_compose_repropagate.py | 67 ++-- .../language/gen_language_examples.py | 2 + .../exercise_readout_conditions.deq | 30 ++ deq/tests/circuit/test_deq.py | 56 ++++ deq/tests/transpiler/jit_annotate_test.py | 22 +- deq/tests/transpiler/jit_propagate_test.py | 40 +-- .../transpiler/test_compose_repropagate.py | 260 ---------------- 8 files changed, 433 insertions(+), 338 deletions(-) create mode 100644 deq/documents/tutorial/chapters/readout-propagation.md create mode 100644 deq/tests/circuit/repetition_code/exercise_readout_conditions.deq diff --git a/deq/documents/tutorial/chapters/readout-propagation.md b/deq/documents/tutorial/chapters/readout-propagation.md new file mode 100644 index 00000000..9aa1b688 --- /dev/null +++ b/deq/documents/tutorial/chapters/readout-propagation.md @@ -0,0 +1,294 @@ +# READOUT Propagation: Logical Dependency vs. Physical Flow + +When you inspect an `annotate`-emitted `.deq` file for a chained composition, +you'll sometimes see `READOUT` lines carrying more than just `M` tokens: + +[Annotated `ExerciseReadoutConditions`: `READOUT` line with logical and destabilizer input-frame tokens](../../../tests/circuit/repetition_code/exercise_readout_conditions.annotated.deq#L303) + +

    READOUT M2 M5 IN0.LX0 IN0.DS0 IN0.DS1  # IN1.LX0 IN1.DS0 IN1.DS1
+ + +The bare `M2 M5` looks like the sort of thing you'd hand-write for a +measurement-basis readout — but why the extras `IN0.LX0 IN0.DS0 IN0.DS1`? +And what does the trailing `# IN1.LX0 IN1.DS0 IN1.DS1` comment mean? + +This chapter tells the story of what a `READOUT` statement really declares, +why the transpiler sometimes needs explicit input-frame tokens on that line, +and how to read them when you see them. A single fixture built on the +[[3,1,3]] repetition code — +`tests/circuit/repetition_code/exercise_readout_conditions.deq` — is enough +to see every mechanism at work. + +--- + +## The two ingredients of a compiled readout + +At the binary level, every readout $k$ has three companion pieces of data: + +* `measurement_indices` — the list of internal physical measurements whose XOR + forms the raw readout bit; +* `readout_propagation` (`rp`) — one row per readout, encoding which **input + frame bits** also flip that readout when set on the input; +* an implicit affine `FLIP` bit (last column of `rp`). + +The runtime computes each shot as + +$$\text{readouts}[k] \;=\; \bigoplus_{i \in \text{measurement\_indices}[k]} \text{raw}_i + \;\oplus\; \bigoplus_{c \in \text{rp row}[k]} \text{input}_c + \;\oplus\; \text{decoded}_k .$$ + +The `measurement_indices` come directly from the `M` tokens on the source +`READOUT` line — that's straightforward. The interesting piece is the `rp` +row: which columns of the input frame end up in it, and how do they get +there? + +## The input frame: logical observables and stabilizer generators + +Each input port contributes a fixed set of columns to the frame: + +$$[\underbrace{LX_0, LZ_0, \ldots, LX_{k-1}, LZ_{k-1}}_{\text{logical observables}}, +\ \underbrace{S_0, S_1, \ldots, S_{g-1}}_{\text{stabilizer generators}}]$$ + +concatenated across input ports. Two flavors of column can appear in an `rp` +row: + +* A **logical observable column** (`IN

.LX` / `IN

.LZ`) means the + readout flips when the corresponding logical Pauli is applied on the input + patch. +* A **stabilizer generator column** (`IN

.DS`) means the readout flips + when the corresponding stabilizer's *destabilizer* Pauli is applied on the + input — i.e., when the input frame has drifted off code space in that + stabilizer's direction. + +For a valid codestate arriving at the gadget's input, every stabilizer-generator +bit is $0$: that's what "in code space" means at the frame level, and the +destabilizer contributions to $\text{rp} \cdot \text{input}$ vanish exactly. +The destabilizer entries earn their keep in compositions: when a preceding +sub-gadget's residual (its `cp · input + pc · raw + lc · readouts`) carries +a non-zero stabilizer-generator bit forward, that bit rides in as the current +gadget's `input`. A destabilizer entry on the current gadget's `rp` then +correctly toggles the readout by exactly the frame-drift amount that would +otherwise leak into the observed parity — keeping sub-gadget composition +arithmetic closed at the frame level. + +## Two sources of `rp` columns + +The transpiler builds each `rp` row by XORing two independently derived sets of +columns: + +1. **Explicit tokens** on the source `READOUT` line contribute their columns + directly. Three families of token are accepted: + `IN

.LX` / `IN

.LZ` for logical observable columns, and + `IN

.DS` for stabilizer-generator columns. +2. **Walker-implicit tokens** — the transpiler runs a Heisenberg walker + (`compute_implicit_readout_propagation`) that pushes each input frame + column's Pauli representative *forward through the gadget body* and records + which measurements it anti-commutes with. If the walked Pauli anti-commutes + with an odd number of the readout's `measurement_indices`, that column is + added. The walker walks all input frame columns — both logical observables + and destabilizers. + +The final `rp` row is `walker_cols XOR explicit_cols`. This XOR is the key +mechanism, and it exists precisely so that either source can carry the truth +without the two ever double-counting each other. + +## The common case: walker suffices + +For most gadgets — anything whose physical body doesn't erase input observables +before the readout's measurements — the walker output *is* the `rp` row, and +no explicit tokens are needed on the `READOUT` line. Consider +`MeasureZAlternative` from our fixture: + +[`MeasureZAlternative` gadget](../../../tests/circuit/repetition_code/exercise_readout_conditions.deq#L18-L22) + +

GADGET MeasureZAlternative {
+    INPUT RepetitionCode 0 1 2
+    M 0 1 2
+    READOUT rec[-1] # use a different representative
+}
+ + +The [[3,1,3]] repetition code declares its logical `bar Z` representative as +`Z_0` (see `LOGICAL X0*X1*X2 Z0` in `repetition_code_d3.deq`). This gadget +instead reads `rec[-1] = M2` — the measurement of qubit 2 — which samples the +operator $Z_2$. These are the *same* logical operator up to a product of +stabilizers: $Z_2 = Z_0 \cdot S_0 \cdot S_1$ using $S_0 = Z_0 Z_1$ and +$S_1 = Z_1 Z_2$. + +When the physical operator you measure differs from the declared +representative by a product of stabilizers, the compiled `rp` records that +difference by picking up the corresponding destabilizer columns. The +annotator shows it explicitly: + +[Annotated `MeasureZAlternative`: `READOUT` line with destabilizer entries in the compiled `rp`](../../../tests/circuit/repetition_code/exercise_readout_conditions.annotated.deq#L280) + +
    READOUT rec[-1]  # IN0.LX0 IN0.DS0 IN0.DS1
+ + +The trailing `# IN0.LX0 IN0.DS0 IN0.DS1` comment shows the compiled `rp` row +in human-readable form: three columns — the logical `IN0.LX0` (flip the +readout when the input has a logical X applied, since that flips the $Z_0$ +eigenvalue) and the two destabilizer columns picked up by the representative +shift. No explicit tokens on the source line: the walker on this flat body +correctly reproduces all three columns just by tracing each input-frame +Pauli's forward flow through the `M 0 1 2` gates. Compiled `rp` = walker +output, diff is empty. + +## The compose case: `CONDITIONAL` hides the flow + +Now put two `MeasureZAlternative` calls together with a `CONDITIONAL` in +between: + +[`ExerciseReadoutConditions` compose](../../../tests/circuit/repetition_code/exercise_readout_conditions.deq#L24-L30) + +
COMPOSE ExerciseReadoutConditions {
+    INPUT RepetitionCode 0
+    INPUT RepetitionCode 1
+    MeasureZAlternative 0
+    CONDITIONAL rec[-1] X0 1
+    MeasureZAlternative 1
+}
+ + +The two patches are physically independent: `MeasureZAlternative 0` runs on +qubits 0–2, `MeasureZAlternative 1` runs on qubits 3–5, and no joint +measurement or entangling gate crosses the patches. The only link between +them is a **classical feed-forward**: the `CONDITIONAL rec[-1] X0 1` reads +the first readout's classical value and, if it is 1, applies an X gate to +patch 1's qubit 0 before the second readout runs. + +At compose-flatten time, each sub-gadget's `rp` matrix is folded into the +composed body's `rp`. For the *second* `MeasureZAlternative` the flattener +sees two contributions to its rp row: + +1. The leaf's own row on its input patch: `{IN1.LX0, IN1.DS0, IN1.DS1}`. +2. The classical feed-forward means the second readout's value ends up + depending on whatever the first readout depended on: step 9 of the + canonical compose-flatten pass + (see [`canonical.merge`](../design/design-transpiler-jit.md#canonical-merge)) + folds the `CONDITIONAL` into the running `cp/pc/lc` formula. Effectively, + the first readout's rp row `{IN0.LX0, IN0.DS0, IN0.DS1}` is XOR'd into + the second readout's row too, and the first readout's `M2` measurement + joins the second readout's `measurement_indices`. + +The composed binary `rp` for the second readout is therefore +`{IN0.LX0, IN0.DS0, IN0.DS1, IN1.LX0, IN1.DS0, IN1.DS1}` — all six columns — +with `measurement_indices = {M2, M5}`. + +But when the annotator inlines this compose into a flat body, the walker sees +only what the physical M gates literally sample. Patch 0's data qubits are +measured out by `M 0 1 2` before the walker reaches the second readout's +`M 3 4 5`. Walking `IN0.LX0`, `IN0.DS0`, `IN0.DS1` forward past `M 0 1 2` +gives an empty Pauli — the walker records nothing for those columns on the +second readout. For patch 1 the walker still sees +`{IN1.LX0, IN1.DS0, IN1.DS1}` correctly. + +So walker and binary disagree: walker sees three patch-1 columns, binary +insists on all six. + +## Reconciling the disagreement: what "input dependency" means + +The disagreement is real and it has a clean interpretation: + +* The **binary `rp`** is a *logical* statement about the composed operation. + It is built by folding sub-gadget `rp` matrices together at compose-flatten + time: the `CONDITIONAL`'s classical feed-forward contributes the first + readout's dependencies to the second readout's row. +* The **walker's `rp`** is a *physical* statement about the flat body. It + follows each input's Pauli representative through actual `R`, `CX`, `M`, + and `MPP` operations, tracking exactly which measurement outcomes carry + that Pauli's eigenvalue. When physical measurements erase the + representative before subsequent measurements, the walker records no + dependency — because at the physical measurement level, those later + outcomes truly *are* independent of the erased input. + +Both views are correct at their level. What makes the compose consistent is +not the physical persistence of patch 0's Paulis but the combination of the +first M-measurement's outcome + the `CONDITIONAL`'s frame update + the runtime +formula `residual = cp · input + pc · raw + lc · readouts`. The `rp` matrix +is one input to that runtime formula and it must encode the logical +dependency for the frame math to work out. + +## Explicit tokens: how the annotator bridges the two views + +Given the physical/logical mismatch, the annotator's job is to emit a source +`READOUT` line whose *re-parsed* `rp` row matches the binary. Because the +transpiler XORs walker and explicit contributions: + +$$\text{explicit tokens} \;=\; \text{walker\_cols} \;\oplus\; \text{binary\_cols}$$ + +$$\Rightarrow\; \text{rp on re-parse} \;=\; \text{walker\_cols} \oplus \text{explicit} \;=\; \text{binary\_cols}$$ + +For `ExerciseReadoutConditions`'s second readout, +`walker_cols = {IN1.LX0, IN1.DS0, IN1.DS1}` and `binary_cols` is the full +six-column set, so the annotator emits `IN0.LX0 IN0.DS0 IN0.DS1` as explicit +tokens alongside `M2 M5`: + +[Annotated `ExerciseReadoutConditions` readouts](../../../tests/circuit/repetition_code/exercise_readout_conditions.annotated.deq#L302-L303) + +
    READOUT M2  # IN0.LX0 IN0.DS0 IN0.DS1
+    READOUT M2 M5 IN0.LX0 IN0.DS0 IN0.DS1  # IN1.LX0 IN1.DS0 IN1.DS1
+ + +Read the second line as three groups: + +1. `M2 M5` — physical measurement refs (`measurement_indices`). +2. `IN0.LX0 IN0.DS0 IN0.DS1` — explicit input-frame tokens patching the + walker's blind spot on patch 0. +3. `# IN1.LX0 IN1.DS0 IN1.DS1` — a trailing comment showing the *remaining* + rp bits, i.e. the ones the walker still handles physically on patch 1. + +Semantically, the compiled `rp` row for this readout is the XOR of everything +on the line (explicit tokens) with everything in the comment (walker-implicit +tokens). On re-transpile the walker still sees only the patch-1 bits, the +explicit tokens XOR the patch-0 bits back in, and the compiled `rp` +reconstructs byte-for-byte. Without this fix-up, +`test_annotate_exercise_readout_conditions_destab_readout` in +[tests/circuit/test_annotate.py](../../../tests/circuit/test_annotate.py) +would fail its byte-identity assertion. + +The mechanism is uniform across all three token families: the DEQ grammar +accepts `IN

.LX` / `IN

.LZ` / `IN

.DS` on `READOUT` lines +precisely because the walker/binary XOR-patch identity applies to any input +frame column, not just logical ones. The same fix-up pattern shows up at +much larger scale in surface-code lattice surgery — the `MZZ` merge's +joint-Z parity operator differs from each patch's declared `bar Z` +representative by several patch stabilizers, so its `rp` row picks up +`IN

.DS` entries alongside the two logical columns, and any compose +that carries `MZZ`'s dependencies past the walker's physical horizon (via +qubit reuse, reset, or a `CONDITIONAL`) needs explicit destabilizer tokens +in exactly the same shape as the [[3,1,3]] example above. + +## When should you write explicit input tokens by hand? + +For most hand-written leaf `GADGET` blocks: never. The walker sees whatever +your circuit does with each input observable, and that's what your `rp` +should be. Just list the `rec[-k]` measurement refs on the `READOUT` line. + +You need explicit tokens whenever the walker's *physical* view of your gadget +body diverges from the *logical* dependency you want the readout to have. In +practice this shows up when: + +* You are hand-writing a gadget body that mimics a compiled compose (e.g. + copying the annotator's output as a starting point). Any qubit reuse, + reset, or `CONDITIONAL` absorption that erases an input observable before + a subsequent measurement leaves the walker blind to that observable's + logical role. +* You are declaring a readout that *should* logically track an input + observable even though the physical circuit doesn't explicitly measure it + (e.g. because a subsequent correction cancels the erasure). + +In both cases, the rule is the same: put the missing input-frame label +directly on the `READOUT` line. Use `IN

.LX` / `IN

.LZ` for a logical observable +column. Use `IN

.DS` for a destabilizer generator column. The +transpiler XORs them with the walker's output the same way in both cases. + +## Signal in `annotate` output: extra tokens on `READOUT` lines + +Conversely, when you *read* an annotated file and notice extra tokens on a +`READOUT` line beyond the `M` measurement refs, that's a signal that the +compose's compiled `rp` carries a dependency the walker's physical view has +lost — usually via qubit reuse, reset, or a `CONDITIONAL` absorption. The +trailing `# ...` comment always shows the walker's remaining view; the extra +tokens on the line are the annotator's minimum-diff patch to make the row +survive round-trip. The compiled `rp` row is the XOR of the two. diff --git a/deq/documents/tutorial/examples/compose-repropagate/gen_compose_repropagate.py b/deq/documents/tutorial/examples/compose-repropagate/gen_compose_repropagate.py index ad6df37a..c19a3223 100644 --- a/deq/documents/tutorial/examples/compose-repropagate/gen_compose_repropagate.py +++ b/deq/documents/tutorial/examples/compose-repropagate/gen_compose_repropagate.py @@ -4,17 +4,12 @@ breaking changes are caught by ``make tutorial``: * transpile both .deq files; -* annotate the *passing* file (with @REPROPAGATE) and write the - .annotated.deq output; -* annotate the *failing* file (without @REPROPAGATE), capture the - user-visible error message, and write it to a .txt fixture that the - chapter shows verbatim; +* annotate both files and write the .annotated.deq outputs; * extract the inlined Teleport GADGET block from the annotated output as a snippet for inline display. """ import os -import re import subprocess import sys @@ -57,53 +52,36 @@ def write(path: str, content: str) -> None: ) -# ── Annotate the passing file (@REPROPAGATE) ───────────────────────── - -annotated_02 = os.path.join(this_dir, "02_teleport_repropagate.annotated.deq") +# ── Annotate both files ────────────────────────────────────────────── +# +# Under the current architecture the annotator emits ``PROPAGATE`` rows +# verbatim from the binary matrices for every COMPOSE-derived GADGET, so +# annotation succeeds for both the plain-COMPOSE (01) and the +# @REPROPAGATE-COMPOSE (02) file. The semantic difference lies in how the +# propagation rows are *derived* (matrix composition vs. flat-circuit +# Heisenberg) and in the shape of the emitted ``PROPAGATE`` rows. + +annotated_01 = os.path.join(this_dir, "01_teleport_logical.annotated.deq") run_cli( - "annotate 02_teleport_repropagate.deq", + "annotate 01_teleport_logical.deq", [ "annotate", - os.path.join(this_dir, "02_teleport_repropagate.deq"), + os.path.join(this_dir, "01_teleport_logical.deq"), "--out", - annotated_02, + annotated_01, ], ) - -# ── Annotate the failing file (no @REPROPAGATE) ────────────────────── -# -# `deq annotate` is expected to fail here at the round-trip verification -# step. We capture the trailing user-visible error message (everything -# from the final ``ValueError:`` line to the end of stderr) and pin it -# in a text fixture so the chapter's quoted output stays in sync. - -error_fixture = os.path.join(this_dir, "01_teleport_annotate_error.txt") -returncode, _stdout, stderr = run_cli( - "annotate 01_teleport_logical.deq (expected to fail)", +annotated_02 = os.path.join(this_dir, "02_teleport_repropagate.annotated.deq") +run_cli( + "annotate 02_teleport_repropagate.deq", [ "annotate", - os.path.join(this_dir, "01_teleport_logical.deq"), + os.path.join(this_dir, "02_teleport_repropagate.deq"), "--out", - os.path.join(this_dir, "01_teleport_logical.annotated.deq"), + annotated_02, ], - allow_failure=True, ) -if returncode == 0: - raise RuntimeError( - "expected `deq annotate` on 01_teleport_logical.deq to fail " - "(no @REPROPAGATE), but it succeeded; the chapter's narrative " - "no longer matches actual behaviour" - ) - -match = re.search(r"^ValueError: .*\Z", stderr, flags=re.MULTILINE | re.DOTALL) -if match is None: - sys.stderr.write(stderr) - raise RuntimeError( - "could not find the final ValueError in `deq annotate` stderr" - ) -error_text = match.group(0).rstrip() + "\n" -write(error_fixture, error_text) # ── Extract snippets ───────────────────────────────────────────────── @@ -131,6 +109,13 @@ def write(path: str, content: str) -> None: extract_block(src_02, "COMPOSE", "Teleport"), ) +with open(annotated_01, encoding="utf-8") as f: + annotated_01_text = f.read() +write( + os.path.join(this_dir, "snippet_teleport_plain_annotated.deq"), + extract_block(annotated_01_text, "GADGET", "Teleport"), +) + with open(annotated_02, encoding="utf-8") as f: annotated_text = f.read() write( diff --git a/deq/documents/tutorial/examples/language/gen_language_examples.py b/deq/documents/tutorial/examples/language/gen_language_examples.py index c443ffda..a0b09bf3 100644 --- a/deq/documents/tutorial/examples/language/gen_language_examples.py +++ b/deq/documents/tutorial/examples/language/gen_language_examples.py @@ -54,6 +54,8 @@ # also check that the commands in the tutorial actually runs deq_runtime.cli_run( "server", + "--addr", + "[::]:0", "--decoder", "black-box-relay-bp", "--coordinator", diff --git a/deq/tests/circuit/repetition_code/exercise_readout_conditions.deq b/deq/tests/circuit/repetition_code/exercise_readout_conditions.deq new file mode 100644 index 00000000..397560b4 --- /dev/null +++ b/deq/tests/circuit/repetition_code/exercise_readout_conditions.deq @@ -0,0 +1,30 @@ +# ============================================================================= +# Exercise destabilizer contributions on `READOUT` propagation. +# ============================================================================= +# +# Two independent [[3,1,3]] repetition-code patches linked only by a classical +# feed-forward (`CONDITIONAL rec[-1] X0 1`). Triggers the case where a +# compose's compiled `rp` row has entries in **destabilizer** columns of the +# input frame that the walker cannot see physically, forcing the annotator to +# emit explicit `IN

.DS` tokens on the `READOUT` line. +# +# Full walkthrough: `documents/tutorial/chapters/readout-propagation.md`. +# Verified by `test_annotate_exercise_readout_conditions_destab_readout` in +# `tests/circuit/test_annotate.py`. +# ============================================================================= + +IMPORT "repetition_code_d3.deq" + +GADGET MeasureZAlternative { + INPUT RepetitionCode 0 1 2 + M 0 1 2 + READOUT rec[-1] # use a different representative +} + +COMPOSE ExerciseReadoutConditions { + INPUT RepetitionCode 0 + INPUT RepetitionCode 1 + MeasureZAlternative 0 + CONDITIONAL rec[-1] X0 1 + MeasureZAlternative 1 +} diff --git a/deq/tests/circuit/test_deq.py b/deq/tests/circuit/test_deq.py index 2a4d321e..502d9037 100644 --- a/deq/tests/circuit/test_deq.py +++ b/deq/tests/circuit/test_deq.py @@ -851,6 +851,62 @@ def test_mixed_terms_with_flip(self): ] assert propagate.flip is True + def test_readout_term_parses(self): + source = self._CODE_PREAMBLE + """ + GADGET G { + INPUT C 0 1 2 + MPP Z0*Z1 + OUTPUT C 0 1 2 + READOUT M0 + PROPAGATE LX0 FROM LX0 R0 + } + """ + deq = parse(source) + gadget = deq.definitions[1] + propagate = next(s for s in gadget.body if isinstance(s, PropagateStatement)) + assert propagate.target == LogicalPauliTarget(pauli="X", index=0) + assert propagate.terms == [ + LogicalPauliTarget(pauli="X", index=0), + ReadoutTarget(index=0), + ] + assert propagate.flip is False + + def test_readout_term_mixed_with_physical_and_flip(self): + source = self._CODE_PREAMBLE + """ + GADGET G { + INPUT C 0 1 2 + MPP Z0*Z1 + M 3 + OUTPUT C 0 1 2 + READOUT M0 + PROPAGATE LX0 FROM LZ0 IN0.DS0 M1 R0 FLIP + } + """ + deq = parse(source) + gadget = deq.definitions[1] + propagate = next(s for s in gadget.body if isinstance(s, PropagateStatement)) + assert propagate.target == LogicalPauliTarget(pauli="X", index=0) + assert ReadoutTarget(index=0) in propagate.terms + assert propagate.flip is True + + def test_multiple_readout_terms(self): + source = self._CODE_PREAMBLE + """ + GADGET G { + INPUT C 0 1 2 + MPP Z0*Z1 + MPP Z1*Z2 + OUTPUT C 0 1 2 + READOUT M0 + READOUT M1 + PROPAGATE LX0 FROM LX0 R0 R1 + } + """ + deq = parse(source) + gadget = deq.definitions[1] + propagate = next(s for s in gadget.body if isinstance(s, PropagateStatement)) + readout_terms = [t for t in propagate.terms if isinstance(t, ReadoutTarget)] + assert readout_terms == [ReadoutTarget(index=0), ReadoutTarget(index=1)] + def test_multiple_statements(self): source = self._CODE_PREAMBLE + """ GADGET G { diff --git a/deq/tests/transpiler/jit_annotate_test.py b/deq/tests/transpiler/jit_annotate_test.py index 1c1ad958..44edf24c 100644 --- a/deq/tests/transpiler/jit_annotate_test.py +++ b/deq/tests/transpiler/jit_annotate_test.py @@ -200,7 +200,7 @@ def test_annotate_readout_shows_flips_comment() -> None: annotated = annotate(qfile) readout_lines = [l for l in annotated.splitlines() if "READOUT" in l] assert len(readout_lines) == 1 - assert "# flipped by: IN0.LX0" in readout_lines[0] + assert "# IN0.LX0" in readout_lines[0] def test_annotate_readout_no_inputs_no_flips() -> None: @@ -214,7 +214,7 @@ def test_annotate_readout_no_inputs_no_flips() -> None: annotated = annotate(qfile) readout_lines = [l for l in annotated.splitlines() if "READOUT" in l] assert len(readout_lines) == 1 - assert "# flipped by:" not in readout_lines[0] + assert "#" not in readout_lines[0] def test_annotate_readout_comment_survives_roundtrip() -> None: @@ -232,7 +232,7 @@ def test_annotate_readout_comment_survives_roundtrip() -> None: } """) annotated = annotate(qfile) - assert "# flipped by:" in annotated + assert "# IN0.LX0" in annotated # Must re-parse cleanly. round_trip = parse(annotated) # And produce the same JIT library. @@ -296,8 +296,10 @@ def test_annotate_compose_with_multiple_input_ports() -> None: assert len(nt_anno.unfinished_checks) == len(nt_orig.unfinished_checks) -def test_annotate_virtual_logical_roundtrips() -> None: - """VIRTUAL LX0 must survive annotation and re-transpile identically.""" +def test_annotate_virtual_logical_absorbed_into_propagate_flip() -> None: + """VIRTUAL LX0 is absorbed into the PROPAGATE row's ``FLIP`` keyword + by the annotator; the source-level ``VIRTUAL`` statement is dropped. + """ qfile = parse(""" CODE Trivial [[1,1,1]] { LOGICAL X0 Z0 @@ -309,11 +311,13 @@ def test_annotate_virtual_logical_roundtrips() -> None: } """) annotated = annotate(qfile) - # PROPAGATE should capture the flip. + # PROPAGATE captures the flip as the trailing FLIP keyword. assert "PROPAGATE OUT0.LX0 FROM IN0.LX0 FLIP" in annotated - # VIRTUAL should still appear (live, not dropped). - assert "VIRTUAL LX0" in annotated - # Must re-parse and re-transpile cleanly (no crash). + # VIRTUAL is no longer re-emitted; the FLIP suffix carries the + # same contribution. + assert "VIRTUAL" not in annotated + # Must re-parse and re-transpile cleanly (no crash) and produce + # identical propagation matrices. round_trip = parse(annotated) build_jit_library(round_trip) diff --git a/deq/tests/transpiler/jit_propagate_test.py b/deq/tests/transpiler/jit_propagate_test.py index 15e97245..bc80e03d 100644 --- a/deq/tests/transpiler/jit_propagate_test.py +++ b/deq/tests/transpiler/jit_propagate_test.py @@ -113,29 +113,11 @@ def test_propagate_pinning_with_flip() -> None: assert lib.gadget_types[0].base.gtype == 1 -def test_propagate_out_of_span_rejected() -> None: - """PROPAGATE for a row whose delta to the flow is not in span errors clearly.""" - src = REP_CODE_DECLS + """ -@GTYPE(1) -GADGET Identity { - INPUT Rep 0 1 2 - OUTPUT Rep 0 1 2 - PROPAGATE LZ0 FROM LX0 -} -""" - with pytest.raises(ValueError, match="basis-freedom span"): - build_jit_library(parse(src)) - - -def test_propagate_out_of_span_error_suggests_repropagate() -> None: - """The PROPAGATE-out-of-span error mentions the @REPROPAGATE decorator. - - PROPAGATE statements that do not lie in the canonical flow's - basis-freedom span are typically emitted by ``deq annotate`` when - rendering a COMPOSE whose merge-derived propagation cannot be - expressed as circuit flow on the inlined body (e.g. teleportation). - The user fix is to add ``@REPROPAGATE`` to the COMPOSE source, so - the error message must point at that decorator by name. +def test_propagate_is_authoritative_even_when_diverging_from_flow() -> None: + """A ``PROPAGATE`` row is installed verbatim as the residual formula + for its output observable, replacing whatever the natural-Heisenberg + flow would have produced. There is no basis-freedom check — every + declared ``PROPAGATE`` wins. """ src = REP_CODE_DECLS + """ @GTYPE(1) @@ -145,11 +127,13 @@ def test_propagate_out_of_span_error_suggests_repropagate() -> None: PROPAGATE LZ0 FROM LX0 } """ - with pytest.raises(ValueError) as excinfo: - build_jit_library(parse(src)) - msg = str(excinfo.value) - assert "@REPROPAGATE" in msg - assert "COMPOSE" in msg + library = build_jit_library(parse(src)) + gadget = next(gt for gt in library.gadget_types if gt.base.name == "Identity") + cp = gadget.base.correction_propagation + entries = set(zip(cp.i, cp.j)) + # ``PROPAGATE LZ0 FROM LX0`` targets output row 0 (LZ0 → X column). + # ``LX0`` on the RHS is input column 1 (LX0 → Z column of qubit 0). + assert (0, 1) in entries def test_propagate_duplicate_row_rejected() -> None: diff --git a/deq/tests/transpiler/test_compose_repropagate.py b/deq/tests/transpiler/test_compose_repropagate.py index 357e071d..d0d5e6f1 100644 --- a/deq/tests/transpiler/test_compose_repropagate.py +++ b/deq/tests/transpiler/test_compose_repropagate.py @@ -13,7 +13,6 @@ from deq.cli.strip_tags import strip_jit_library from deq.circuit.parser import parse from deq.transpiler.compose_builder import ( - _translate_compose_conditionals, compose_to_synthetic_gadget, has_repropagate, ) @@ -390,262 +389,3 @@ def test_annotate_then_retranspile_byte_equivalent(self) -> None: == anno_stripped.SerializeToString() ) - -class TestTranslateComposeConditionals: - """Unit tests for ``_translate_compose_conditionals`` — the helper - that turns COMPOSE-body ``ConditionalCorrection`` statements into - GADGET-body ``ConditionalStatement(R)`` entries on the synthetic - flat body. - """ - - def test_repeat_block_unrolls_conditional_per_iteration(self) -> None: - """A ``REPEAT N`` block containing a sub-gadget plus a - ``CONDITIONAL rec[-1] X0 0`` must emit ``N`` separate - ``ConditionalStatement`` entries, each referencing the *correct* - absolute readout index for its iteration (R0, R1, … R(N-1)). - - Regression test for an earlier bug where the walker advanced - ``running_readouts`` past the REPEAT block but only emitted one - copy of the CONDITIONAL (the first iteration's); the remaining - ``count - 1`` iterations were silently dropped. - """ - from deq.circuit.model import ( - ComposeDefinition, - ConditionalCorrection, - GadgetApplication, - GadgetDefinition, - InputPort, - Instruction, - OutputPort, - QubitTarget, - ReadoutStatement, - ReadoutTarget, - RepeatBlock, - MeasurementRecordTarget, - ) - - sub = GadgetDefinition( - name="OneReadoutSub", - body=[ - InputPort( - code_name="RepetitionCode", - qubit_indices=[0, 1, 2], - ), - Instruction( - name="M", - targets=[QubitTarget(0), QubitTarget(1), QubitTarget(2)], - ), - ReadoutStatement( - targets=[MeasurementRecordTarget(offset=3)] - ), - OutputPort( - code_name="RepetitionCode", - qubit_indices=[0, 1, 2], - ), - ], - ) - compose = ComposeDefinition( - name="RepeatedRoundCond", - body=[ - InputPort(code_name="RepetitionCode", qubit_indices=[0]), - RepeatBlock( - count=3, - body=[ - GadgetApplication( - gadget_name="OneReadoutSub", - in_indices=[0], - out_indices=[0], - ), - ConditionalCorrection( - readout_offset=1, - paulis=[("X", 0)], - wire=0, - ), - ], - ), - OutputPort(code_name="RepetitionCode", qubit_indices=[0]), - ], - ) - - stmts = _translate_compose_conditionals( - compose, - gadget_defs={"OneReadoutSub": sub}, - compose_defs={}, - known_names={"OneReadoutSub"}, - ) - - assert len(stmts) == 3, ( - f"REPEAT 3 with a CONDITIONAL inside should emit 3 " - f"ConditionalStatement entries (one per unrolled iteration); " - f"got {len(stmts)}" - ) - # Each iteration's CONDITIONAL references rec[-1] = the readout - # from THAT iteration's sub-gadget, which is R0 / R1 / R2 after - # 1 / 2 / 3 sub-gadgets have produced their readouts. - for iter_idx, stmt in enumerate(stmts): - assert stmt.condition == ReadoutTarget(index=iter_idx), ( - f"iteration {iter_idx}: expected R{iter_idx}, got " - f"{stmt.condition}" - ) - assert len(stmt.targets) == 1 - target = stmt.targets[0] - assert target.pauli == "X" - assert target.index == 0 - assert target.port_kind == "OUT" - assert target.port_index == 0 - - def test_nested_repeat_block_unrolls_correctly(self) -> None: - """``REPEAT 2 { REPEAT 3 { sub; CONDITIONAL rec[-1] X0 0 } }`` - must emit 6 ``ConditionalStatement`` entries (= 2 * 3) with - readout indices R0..R5. - - Verifies that the outer REPEAT also unrolls the inner REPEAT, - and that ``running_readouts`` correctly tracks the cumulative - readout count across nested iterations. - """ - from deq.circuit.model import ( - ComposeDefinition, - ConditionalCorrection, - GadgetApplication, - GadgetDefinition, - InputPort, - Instruction, - OutputPort, - QubitTarget, - ReadoutStatement, - ReadoutTarget, - RepeatBlock, - MeasurementRecordTarget, - ) - - sub = GadgetDefinition( - name="OneReadoutSub", - body=[ - InputPort( - code_name="RepetitionCode", - qubit_indices=[0, 1, 2], - ), - Instruction( - name="M", - targets=[QubitTarget(0), QubitTarget(1), QubitTarget(2)], - ), - ReadoutStatement( - targets=[MeasurementRecordTarget(offset=3)] - ), - OutputPort( - code_name="RepetitionCode", - qubit_indices=[0, 1, 2], - ), - ], - ) - compose = ComposeDefinition( - name="NestedRepeatCond", - body=[ - InputPort(code_name="RepetitionCode", qubit_indices=[0]), - RepeatBlock( - count=2, - body=[ - RepeatBlock( - count=3, - body=[ - GadgetApplication( - gadget_name="OneReadoutSub", - in_indices=[0], - out_indices=[0], - ), - ConditionalCorrection( - readout_offset=1, - paulis=[("X", 0)], - wire=0, - ), - ], - ), - ], - ), - OutputPort(code_name="RepetitionCode", qubit_indices=[0]), - ], - ) - - stmts = _translate_compose_conditionals( - compose, - gadget_defs={"OneReadoutSub": sub}, - compose_defs={}, - known_names={"OneReadoutSub"}, - ) - - assert len(stmts) == 6 - for iter_idx, stmt in enumerate(stmts): - assert stmt.condition == ReadoutTarget(index=iter_idx) - - def test_conditional_after_repeat_uses_post_repeat_indices(self) -> None: - """A ``CONDITIONAL`` that follows a ``REPEAT`` block must see - the post-unroll running readout count, so its ``rec[-k]`` - resolves to a readout produced *during* the REPEAT. - """ - from deq.circuit.model import ( - ComposeDefinition, - ConditionalCorrection, - GadgetApplication, - GadgetDefinition, - InputPort, - Instruction, - OutputPort, - QubitTarget, - ReadoutStatement, - ReadoutTarget, - RepeatBlock, - MeasurementRecordTarget, - ) - - sub = GadgetDefinition( - name="OneReadoutSub", - body=[ - InputPort( - code_name="RepetitionCode", - qubit_indices=[0, 1, 2], - ), - Instruction( - name="M", - targets=[QubitTarget(0), QubitTarget(1), QubitTarget(2)], - ), - ReadoutStatement( - targets=[MeasurementRecordTarget(offset=3)] - ), - OutputPort( - code_name="RepetitionCode", - qubit_indices=[0, 1, 2], - ), - ], - ) - compose = ComposeDefinition( - name="PostRepeatCond", - body=[ - InputPort(code_name="RepetitionCode", qubit_indices=[0]), - RepeatBlock( - count=4, - body=[ - GadgetApplication( - gadget_name="OneReadoutSub", - in_indices=[0], - out_indices=[0], - ), - ], - ), - # rec[-1] is the LAST readout from the 4 unrolled iterations - # = R3. - ConditionalCorrection( - readout_offset=1, paulis=[("X", 0)], wire=0 - ), - OutputPort(code_name="RepetitionCode", qubit_indices=[0]), - ], - ) - - stmts = _translate_compose_conditionals( - compose, - gadget_defs={"OneReadoutSub": sub}, - compose_defs={}, - known_names={"OneReadoutSub"}, - ) - - assert len(stmts) == 1 - assert stmts[0].condition == ReadoutTarget(index=3) From 3a135026a47f2b65c01dfc713424fb3ffdafcbfd Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Thu, 9 Jul 2026 16:22:51 -0700 Subject: [PATCH 023/157] add generator script --- .../conditional-correction/gen_conditional.py | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 deq/documents/tutorial/examples/conditional-correction/gen_conditional.py diff --git a/deq/documents/tutorial/examples/conditional-correction/gen_conditional.py b/deq/documents/tutorial/examples/conditional-correction/gen_conditional.py new file mode 100644 index 00000000..7b7ed7d8 --- /dev/null +++ b/deq/documents/tutorial/examples/conditional-correction/gen_conditional.py @@ -0,0 +1,220 @@ +"""Generate outputs for the conditional-correction tutorial chapter. + +Runs the CLI commands referenced in ``conditional-correction.md`` so +that breaking changes are caught by ``make tutorial``: + +* transpile + annotate every variant ``.deq`` file + (``01_teleport_repropagate.deq``, + ``02_teleport_compose_conditional.deq``, + ``03_teleport_program_conditional.deq``); +* extract per-block snippets (``TeleportRepropagate``, + ``TeleportConditional``, ``MeasureBell``, the PROGRAM bodies) so + the chapter can highlight one block at a time; +* run ``deq sample`` on the COMPOSE-level CONDITIONAL memory program + (``TeleportConditionalMemoryZ``), capturing 20 noiseless shots + so the chapter can quote the sample output verbatim; +* run ``deq simulate ler`` on the same program for 20 shots and + capture the ``Logical errors: 0`` line. + +The Bell-pair teleportation timeline figure (``teleport_timeline.png``) +is rendered by the standalone ``teleport_timeline.py`` script in this +directory and committed to the repository, so it is NOT regenerated on +every ``make tutorial`` invocation. +""" + +import os +import re +import subprocess +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from snippet_utils import extract_block, write_snippet # noqa: E402 + +THIS_DIR = os.path.dirname(os.path.abspath(__file__)) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def run_cli( + description: str, + args: list[str], + *, + allow_failure: bool = False, +) -> tuple[int, str, str]: + """Run ``python -m deq `` and return ``(returncode, stdout, stderr)``.""" + print(f" {description}...") + result = subprocess.run( + [sys.executable, "-m", "deq"] + args, + capture_output=True, + text=True, + check=False, + cwd=THIS_DIR, + ) + if result.returncode != 0 and not allow_failure: + sys.stderr.write(result.stderr) + raise RuntimeError(f"command failed: {' '.join(args)}") + return result.returncode, result.stdout, result.stderr + + +def write(path: str, content: str) -> None: + with open(path, "w", encoding="utf-8") as f: + f.write(content) + print(f" -> {os.path.basename(path)}") + + +# --------------------------------------------------------------------------- +# 1. Transpile + annotate every variant +# --------------------------------------------------------------------------- + +print("Transpiling + annotating variant .deq files...") + +VARIANTS: list[tuple[str, str]] = [ + # (filename, program-or-None) + ("01_teleport_repropagate.deq", "TeleportRepropagateMemoryZ"), + ("02_teleport_compose_conditional.deq", "TeleportConditionalMemoryZ"), + ("03_teleport_program_conditional.deq", "TeleportProgramConditionalMemoryZ"), +] + +for deq_name, program in VARIANTS: + deq_path = os.path.join(THIS_DIR, deq_name) + jit_out = deq_path + ".jit" + transpile_args = ["transpile", deq_name, "--out", jit_out] + if program is not None: + transpile_args += ["--program", program] + run_cli(f"transpile {deq_name}", transpile_args) + + annotate_out = deq_path.replace(".deq", ".annotated.deq") + run_cli( + f"annotate {deq_name}", + ["annotate", deq_name, "--out", annotate_out], + ) + + +# --------------------------------------------------------------------------- +# 2. Extract per-block snippets for inline display in the chapter +# --------------------------------------------------------------------------- + +print("Extracting snippets...") + +with open( + os.path.join(THIS_DIR, "00_teleportation_library.deq"), encoding="utf-8" +) as f: + library_text = f.read() + +write_snippet( + os.path.join(THIS_DIR, "snippet_prepare_bell.deq"), + extract_block(library_text, "COMPOSE", "PrepareBell"), +) +write_snippet( + os.path.join(THIS_DIR, "snippet_measure_bell.deq"), + extract_block(library_text, "COMPOSE", "MeasureBell"), +) + +with open( + os.path.join(THIS_DIR, "01_teleport_repropagate.deq"), encoding="utf-8" +) as f: + repropagate_text = f.read() +with open( + os.path.join(THIS_DIR, "02_teleport_compose_conditional.deq"), encoding="utf-8" +) as f: + compose_cond_text = f.read() +with open( + os.path.join(THIS_DIR, "03_teleport_program_conditional.deq"), encoding="utf-8" +) as f: + program_cond_text = f.read() + +write_snippet( + os.path.join(THIS_DIR, "snippet_teleport_repropagate.deq"), + extract_block(repropagate_text, "COMPOSE", "TeleportRepropagate"), +) +write_snippet( + os.path.join(THIS_DIR, "snippet_teleport_conditional.deq"), + extract_block(compose_cond_text, "COMPOSE", "TeleportConditional"), +) +write_snippet( + os.path.join(THIS_DIR, "snippet_teleport_program_conditional.deq"), + extract_block( + program_cond_text, "PROGRAM", "TeleportProgramConditionalMemoryZ" + ), +) + + +# --------------------------------------------------------------------------- +# 3. Run ``deq sample`` on the COMPOSE-level CONDITIONAL memory program +# --------------------------------------------------------------------------- +# +# 20 noiseless shots with a fixed seed produces a deterministic +# transcript that the chapter can quote verbatim, so the reader sees +# both the random ``MeasureBell`` outcomes and the always-zero final +# ``MeasureZ`` that proves the CONDITIONAL absorbed the frame +# correction. + +print("Sampling 20 noiseless shots of TeleportConditionalMemoryZ...") + +_, sample_stdout, _ = run_cli( + "deq sample (compose CONDITIONAL)", + [ + "sample", + "02_teleport_compose_conditional.deq", + "--program", + "TeleportConditionalMemoryZ", + "--shots", + "20", + "--noiseless", + "--interpret", + "--seed", + "42", + ], +) +write( + os.path.join(THIS_DIR, "teleport_conditional_sample.txt"), + sample_stdout, +) + + +# --------------------------------------------------------------------------- +# 4. Run ``deq simulate ler`` on the same program (20 shots noiseless) +# --------------------------------------------------------------------------- + +print("Running 20-shot noiseless LER simulation...") + +_, simulate_stdout, _ = run_cli( + "deq simulate ler (compose CONDITIONAL)", + [ + "simulate", + "ler", + "02_teleport_compose_conditional.deq", + "--program", + "TeleportConditionalMemoryZ", + "--shots", + "20", + "--errors", + "100", + "--batch-size", + "20", + "--seed", + "42", + "--jobs", + "1", + ], +) +m_shots = re.search(r"Shots:\s+(\d+)", simulate_stdout) +m_errs = re.search(r"Logical errors:\s+(\d+)", simulate_stdout) +if m_shots is None or m_errs is None: + raise RuntimeError( + f"could not parse simulator output:\n{simulate_stdout}" + ) +simulate_summary = ( + "=== Simulation Results ===\n" + f" Shots: {m_shots.group(1)}\n" + f" Logical errors: {m_errs.group(1)}\n" +) +write( + os.path.join(THIS_DIR, "teleport_conditional_simulate.txt"), + simulate_summary, +) + +print("done.") From 545f6040e285e997eacb575009e795e43d83f0dd Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Fri, 10 Jul 2026 13:10:18 -0700 Subject: [PATCH 024/157] add changes --- deq/deq/noise/common.py | 1 + deq/deq/transpiler/jit_annotate.py | 172 +++++-- deq/deq/transpiler/jit_library_builder.py | 268 +++++----- deq/deq/transpiler/jit_noise_builder.py | 472 ++++-------------- deq/tests/circuit/test_annotate.py | 80 +++ deq/tests/cli/jit_test.py | 213 ++++++++ .../transpiler/jit_library_builder_test.py | 166 ++++++ 7 files changed, 824 insertions(+), 548 deletions(-) diff --git a/deq/deq/noise/common.py b/deq/deq/noise/common.py index 0bda752a..c42af6be 100644 --- a/deq/deq/noise/common.py +++ b/deq/deq/noise/common.py @@ -140,6 +140,7 @@ def count_braces(line: str) -> tuple[int, int]: "READOUT", "CHECK", "CONDITIONAL", + "PROPAGATE", "VIRTUAL", "REPEAT", "PRESELECT", diff --git a/deq/deq/transpiler/jit_annotate.py b/deq/deq/transpiler/jit_annotate.py index 1981ca13..9cc001a0 100644 --- a/deq/deq/transpiler/jit_annotate.py +++ b/deq/deq/transpiler/jit_annotate.py @@ -57,18 +57,19 @@ Check, PortColumnLayout, flatten_body, + num_frame_columns, select_stabilizer_generators, ) from deq.transpiler.check_plugins import compute_layout, resolve_gadget_checks from deq.transpiler.code_validation import validate_code from deq.transpiler.compose_builder import ( _check_basis_from_jit_gadget_type, - _translate_compose_conditionals, compose_to_synthetic_gadget, expand_compose_circuit, has_repropagate, ) from deq.transpiler.jit_library_builder import ( + _build_logical_correction, build_jit_library, build_readouts, collect_physical_conditionals, @@ -76,8 +77,10 @@ ) from deq.transpiler.jit_noise_builder import ( compute_correction_propagation, + compute_implicit_readout_propagation, compute_physical_correction, iter_noise_errors_with_origin, + resolve_propagations, ) from deq.spec.common import bitmatrix_of import deq.proto.deq_jit_pb2 as jit_pb @@ -347,6 +350,7 @@ def _annotate_gadget( num_finished, cp_pb, pc_pb, + lc_pb, input_virtual_count, ) = _compute_gadget_runtime_data( gadget, codes, check_override=check_override @@ -376,6 +380,7 @@ def _annotate_gadget( propagate_lines = _format_propagate_statements( cp_pb, pc_pb, + lc_pb, input_layout=input_col_layout, output_layout=output_col_layout, ) @@ -435,7 +440,10 @@ def _annotate_gadget( output_port_stab_counts=output_port_stab_counts, ) ) - lines.extend(propagate_lines) + + # Each PROPAGATE row below is the complete XOR formula the runtime + # evaluates for that output observable. + lines.extend(propagate_lines) # Statistics summary all_errors = [e for errs in noise_errors_at.values() for e in errs] @@ -580,11 +588,16 @@ def _render_body_statement( # flatten_body should have already unrolled these; defensive. return [f" # REPEAT {stmt.count} {{ ... }} (unexpected — not unrolled)"] if isinstance(stmt, ConditionalStatement): - targets = " ".join(str(t) for t in stmt.targets) - return [f" CONDITIONAL {stmt.condition} {targets}"] + # CONDITIONAL R/rec[-k]/M statements are absorbed into + # the PROPAGATE block: readout targets appear as ``R`` terms + # (via ``logical_correction``), measurement targets appear as + # ``M`` terms (via ``physical_correction``). + return [] if isinstance(stmt, VirtualLogicalStatement): - targets = " ".join(str(t) for t in stmt.targets) - return [f" VIRTUAL {targets}"] + # VIRTUAL adds a constant flip to the affine column of + # ``correction_propagation``; it appears in the PROPAGATE + # block as the trailing ``FLIP`` keyword. + return [] if isinstance(stmt, PreselectStatement): return [f" PRESELECT {stmt.condition} {stmt.expected_value}"] raise TypeError(f"unhandled gadget statement: {type(stmt).__name__}") @@ -627,17 +640,18 @@ def _format_stats_comment( def _format_propagate_statements( cp_pb: util_pb.BitMatrix, pc_pb: util_pb.BitMatrix, + lc_pb: util_pb.BitMatrix | None = None, *, input_layout: PortColumnLayout, output_layout: PortColumnLayout, ) -> list[str]: - """Render output-logical-row pc/cp data as ``PROPAGATE`` source lines. + """Render output-logical-row cp/pc/lc data as ``PROPAGATE`` source lines. For every output logical row, emit a line of the form .. code-block:: text - PROPAGATE LZ0 FROM LZ0 IN0.DS2 M3 FLIP + PROPAGATE LZ0 FROM LZ0 IN0.DS2 M3 R0 FLIP The right-hand side is the XOR of: @@ -649,6 +663,9 @@ def _format_propagate_statements( * internal physical measurement outcomes, labelled ``M`` (the i-th internal/physical measurement of the gadget, gadget-scoped, 0-based); + * decoded readouts, labelled ``R`` — the readout-conditioned + frame correction the runtime XORs on top of the natural-Heisenberg + residual (rendered when ``lc_pb`` has entries for this row); * the affine ``FLIP`` constant absorbed by the last column of ``correction_propagation`` (appended as the trailing keyword). @@ -661,6 +678,7 @@ def _format_propagate_statements( cp_mat = bitmatrix_of(cp_pb) pc_mat = bitmatrix_of(pc_pb) + lc_mat = bitmatrix_of(lc_pb) if lc_pb is not None and lc_pb.cols > 0 else None affine_col = cp_pb.cols - 1 lines: list[str] = [] @@ -668,6 +686,9 @@ def _format_propagate_statements( out_label = output_layout.render_logical_labels({out_row})[0] cp_cols = set(cp_mat.rows[out_row].support) pc_cols = set(pc_mat.rows[out_row].support) + lc_cols = ( + set(lc_mat.rows[out_row].support) if lc_mat is not None else set() + ) has_flip = affine_col in cp_cols in_obs_cols = cp_cols & input_layout.logical_columns @@ -686,6 +707,8 @@ def _format_propagate_statements( terms.append(f"IN{port_idx}.DS{stab_idx_in_port}") for j in sorted(pc_cols): terms.append(f"M{j}") + for k in sorted(lc_cols): + terms.append(f"R{k}") suffix = " FLIP" if has_flip else "" body = " " + " ".join(terms) if terms else "" @@ -722,9 +745,11 @@ def _format_propagation_comment( row_index: int, layout: PortColumnLayout, ) -> str: - """Format a ``# flipped by: LX0 ...`` comment for one readout row. + """Format a ``# LX0 ...`` comment for one readout row. - Shows which input correction flips this readout. + Shows which input-frame bits flip this readout in addition to what + is already on the READOUT line — semantically, the readout value is + the XOR of everything on the line and everything in this comment. *layout* provides the column-to-observable mapping and stabilizer generator indices for correct multi-port rendering. @@ -751,7 +776,7 @@ def _format_propagation_comment( parts.append("FLIP") if not parts: return "" - return "# flipped by: " + " ".join(parts) + return "# " + " ".join(parts) def _render_error_statement(stmt: ErrorStatement) -> str: @@ -845,12 +870,13 @@ def _compute_gadget_runtime_data( int, util_pb.BitMatrix, util_pb.BitMatrix, + util_pb.BitMatrix, int, ]: """Return runtime data needed to annotate a gadget's noise and flows. Returns ``(noise_errors_by_position, num_finished_checks, cp_pb, - pc_pb, input_virtual_count)``: + pc_pb, lc_pb, input_virtual_count)``: * ``noise_errors_by_position`` — propagated noise mechanisms keyed by the originating noise instruction's index in the flattened @@ -862,6 +888,11 @@ def _compute_gadget_runtime_data( (output rows × input cols + FLIP). * ``pc_pb`` — ``physical_correction`` matrix (output rows × internal-measurement cols). + * ``lc_pb`` — ``logical_correction`` matrix + (output rows × readout cols). Populated from + ``CONDITIONAL R`` statements and ``PROPAGATE`` R-terms in + the source body. Rendered as ``R`` terms in the annotator's + PROPAGATE lines. * ``input_virtual_count`` — number of input virtual measurements, used to convert pc column indices to global measurement indices. """ @@ -885,6 +916,30 @@ def _compute_gadget_runtime_data( output_ports, layout.internal_count, ) + num_output_observables = sum( + num_frame_columns(codes[p.code_name]) for p in output_ports + ) + lc_pb = _build_logical_correction( + gadget, + num_output_observables, + len(_readouts_pb), + list(output_ports), + codes, + ) + + # Resolve user-supplied PROPAGATE statements so the annotator can + # install each row's declared XOR formula into the rendered cp/pc + # matrices. + input_layout_for_props = PortColumnLayout(input_ports, codes) + propagations = resolve_propagations( + gadget, + codes, + input_ports=input_ports, + output_ports=output_ports, + input_layout=input_layout_for_props, + input_virtual_count=layout.input_virtual_count, + ov_start=layout.ov_start, + ) cp_pb, logical_physical_entries = compute_correction_propagation( gadget, @@ -893,6 +948,8 @@ def _compute_gadget_runtime_data( output_ports=output_ports, unfinished_checks=unfinished, input_virtual_count=layout.input_virtual_count, + ov_start=layout.ov_start, + propagations=propagations, ) physical_conditionals_raw = collect_physical_conditionals( gadget, @@ -932,7 +989,7 @@ def _compute_gadget_runtime_data( ): by_position.setdefault(body_index, []).append(error_row) - return by_position, len(finished), cp_pb, pc_pb, layout.input_virtual_count + return by_position, len(finished), cp_pb, pc_pb, lc_pb, layout.input_virtual_count # --------------------------------------------------------------------------- @@ -1042,10 +1099,58 @@ def _render_composed_gadget( ) # READOUT statements. + # + # Each readout's ``base.readout_propagation`` row encodes which + # input-observable columns flip it (matrix-composed semantics from + # the binary). When the re-parsed annotated body's Heisenberg + # walker (:func:`compute_implicit_readout_propagation`) gives the + # same rp row, no extra tokens are needed: walker output alone + # suffices. When the walker differs (e.g. chained-teleportation + # cumulative readouts whose input-observable parity cancels across + # hops in the inlined body), we emit the *diff* as explicit + # ``IN

.L

`` tokens. ``_build_readout_propagation`` XORs + # walker-implicit columns with explicit-logical columns, so + # walker_cols XOR diff = binary_cols on re-parse. prop = base.readout_propagation input_col_layout = PortColumnLayout(input_ports, codes) + affine_col = prop.cols - 1 if prop.cols > 0 else -1 + binary_rp_cols_by_row: dict[int, set[int]] = {} + for r, c in zip(prop.i, prop.j): + binary_rp_cols_by_row.setdefault(r, set()).add(c) + + synth_for_walker = GadgetDefinition( + name=base.name, + body=[*input_ports, *circuit_stmts, *output_ports], + decorators=[], + ) + walker_implicit = compute_implicit_readout_propagation( + synth_for_walker, + codes, + input_ports=input_ports, + readout_measurement_sets=[ + set(r.measurement_indices) for r in base.readouts + ], + ) + for row_index, readout in enumerate(base.readouts): rec_refs = [f"M{mi}" for mi in readout.measurement_indices] + binary_cols = binary_rp_cols_by_row.get(row_index, set()) + binary_observable_cols = binary_cols - {affine_col} + walker_cols = walker_implicit[row_index] if row_index < len(walker_implicit) else set() + diff_cols = binary_observable_cols ^ walker_cols + if diff_cols: + diff_logical = diff_cols & input_col_layout.logical_columns + diff_destab = sorted( + c for c in diff_cols if c in input_col_layout.generator_map + ) + rec_refs.extend( + input_col_layout.render_logical_labels( + diff_logical, combine_xz_to_y=False + ) + ) + for c in diff_destab: + port_idx, stab_idx = input_col_layout.generator_map[c] + rec_refs.append(f"IN{port_idx}.DS{stab_idx}") if rec_refs: comment = _format_propagation_comment( prop, @@ -1055,41 +1160,22 @@ def _render_composed_gadget( suffix = f" {comment}" if comment else "" lines.append(" READOUT " + " ".join(rec_refs) + suffix) - # PROPAGATE statements pin every output logical row. - # After the ``merge()`` absorption pass (canonical.py step 9), the - # composed gadget's ``correction_propagation`` and - # ``physical_correction`` already contain all the input-frame and - # measurement contributions, including those absorbed from any - # ``CONDITIONAL rec[-k] `` in the COMPOSE body. The - # merged ``logical_correction`` is always empty by design. We can - # therefore render PROPAGATE directly from ``base`` and rely on the - # round-trip property that re-transpiling the rendered GADGET - # reproduces these same matrices byte-for-byte. + # PROPAGATE emission. Emit binary cp/pc/lc verbatim: each row is + # authoritative and describes the complete XOR formula the runtime + # evaluates for that output observable. ``VIRTUAL`` and + # ``CONDITIONAL`` are intentionally dropped from the annotated body + # since their contributions already live in cp/pc/lc (VIRTUAL adds a + # ``FLIP`` bit in ``cp``'s affine column; CONDITIONAL populates ``lc`` + # entries) and the PROPAGATE rows below re-emit them as ``FLIP`` + # keywords and ``R`` terms. output_col_layout = PortColumnLayout(output_ports, codes) - propagate_lines = _format_propagate_statements( + lines.extend(_format_propagate_statements( base.correction_propagation, base.physical_correction, + base.logical_correction, input_layout=input_col_layout, output_layout=output_col_layout, - ) - lines.extend(propagate_lines) - - # CONDITIONAL R emission: each ``ConditionalCorrection`` in the - # COMPOSE body becomes a GADGET-level ``CONDITIONAL R`` here. - # The merged ``logical_correction`` is empty (absorbed by step 9 of - # ``merge()``), but the validator on re-transpilation needs to see - # these statements so it can extend its basis-freedom with each - # CONDITIONAL's absorption pattern — without this round trip the - # rendered gadget would reject COMPOSEs whose flat-circuit - # Heisenberg does not naturally include the conditional logical- - # frame correction (e.g. lattice surgery). - known = set(gadget_defs) | set(compose_defs) - conditional_stmts = _translate_compose_conditionals( - compose, gadget_defs, compose_defs, known - ) - for cstmt in conditional_stmts: - targets_str = " ".join(str(t) for t in cstmt.targets) - lines.append(f" CONDITIONAL {cstmt.condition} {targets_str}") + )) # ERROR statements. When ``keep_noise`` is set, the noise # instructions above are emitted verbatim, so re-transpilation diff --git a/deq/deq/transpiler/jit_library_builder.py b/deq/deq/transpiler/jit_library_builder.py index bd2baf5a..4755a0ff 100644 --- a/deq/deq/transpiler/jit_library_builder.py +++ b/deq/deq/transpiler/jit_library_builder.py @@ -27,6 +27,7 @@ ComposeDefinition, ConditionalStatement, Decorator, + DestabilizerTarget, ErrorStatement, GadgetDefinition, InputPort, @@ -40,6 +41,7 @@ PauliTarget, PhysicalMeasurementTarget, DeqFile, + PropagateStatement, QubitTarget, ReadoutStatement, ReadoutTarget, @@ -63,6 +65,8 @@ warn_unrecognized_decorators, ) from deq.transpiler.jit_noise_builder import ( + _resolve_logical_target_to_columns, + _resolve_ds_to_input_cols, compute_correction_propagation, compute_implicit_readout_propagation, compute_noise_errors, @@ -692,55 +696,13 @@ def _build_check( ov_start=ov_start, ) - # Collect CONDITIONAL R entries from the body so the validator - # can extend its basis-freedom with each statement's absorption - # pattern. Without this, COMPOSEs whose flat-circuit Heisenberg - # does not naturally include the conditional logical-frame - # correction (e.g. lattice surgery) would have to use - # ``@REPROPAGATE`` to round-trip through ``deq annotate``. - # - # We silently skip any CONDITIONAL whose target indices are out of - # range here; ``_build_logical_correction`` (run later) will raise - # the proper ``ValueError`` with full diagnostic context. - conditional_basis_info: list[tuple[frozenset[int], int]] = [] - num_logicals_total = sum(len(codes[p.code_name].logicals) for p in output_ports) - for stmt in flatten_body(list(gadget.body)): - if not isinstance(stmt, ConditionalStatement): - continue - if not isinstance(stmt.condition, ReadoutTarget): - continue - readout_idx = stmt.condition.index - if readout_idx < 0 or readout_idx >= len(readouts_pb): - continue - flipped: set[int] = set() - targets_valid = True - for target in stmt.targets: - if target.port_kind is None: - if target.index < 0 or target.index >= num_logicals_total: - targets_valid = False - break - else: - if target.port_kind != "OUT": - targets_valid = False - break - if ( - target.port_index is None - or target.port_index < 0 - or target.port_index >= len(output_ports) - ): - targets_valid = False - break - port_code = codes[output_ports[target.port_index].code_name] - if target.index < 0 or target.index >= len(port_code.logicals): - targets_valid = False - break - flipped.update(conditional_flipped_rows(target, output_ports, codes)) - if targets_valid and flipped: - conditional_basis_info.append((frozenset(flipped), readout_idx)) - - readout_measurement_indices = [ - list(info.measurement_indices) for info in readouts_info - ] + # CONDITIONAL R statements and PROPAGATE R terms in the body + # are both translated faithfully into the ``logical_correction`` + # matrix (see :func:`_build_logical_correction` below). PROPAGATE + # rows are authoritative: whatever the user declares is installed + # verbatim into ``correction_propagation`` / ``physical_correction`` + # for that output row, with no basis-freedom check against the + # natural-Heisenberg derivation. correction_propagation_pb, logical_physical_entries = ( compute_correction_propagation( @@ -753,24 +715,14 @@ def _build_check( input_virtual_count=input_virtual_count, ov_start=ov_start, propagations=propagations, - conditional_basis_info=conditional_basis_info, - readout_propagation=readout_propagation_pb, - readout_measurement_indices=readout_measurement_indices, ) ) - # Rows that the validator absorbed via user-supplied PROPAGATE - # statements have their CONDITIONAL contributions folded into - # ``correction_propagation`` / ``physical_correction``; keeping the - # corresponding ``logical_correction`` entries would double-count - # the readout's effect at runtime. - propagated_rows = set(propagations.keys()) logical_correction_pb = _build_logical_correction( gadget, num_output_observables, len(readouts_pb), output_ports, codes, - skip_rows=propagated_rows, ) physical_conditionals_raw = collect_physical_conditionals( @@ -940,59 +892,79 @@ def _build_logical_correction( num_readouts: int, output_ports: list[OutputPort], codes: dict[str, CodeDefinition], - skip_rows: set[int] | None = None, ) -> util_pb.BitMatrix: - """Build the ``logical_correction`` matrix from CONDITIONAL statements. - - Each ``CONDITIONAL R L

`` applies a logical Pauli correction - conditioned on readout *j*. The correction flips all anti-commuting - output observables. - - In the **logical** frame (2 observables per logical qubit, interleaved - X then Z), ``LX`` flips ``LZ`` and vice versa. - - In the **physical** frame (2 observables per physical qubit), the - logical operator is expanded into its physical Pauli string and each - physical qubit's anti-commuting observable is flipped individually. - - Multiple CONDITIONAL statements XOR into the matrix. - - When *skip_rows* is provided, CONDITIONAL contributions to rows in - that set are omitted from the matrix. The validator already - folded those rows' CONDITIONAL contributions into - ``correction_propagation`` / ``physical_correction`` via the user- - supplied PROPAGATE statements; keeping the corresponding lc entries - would double-count the readout's effect at runtime. + """Build the ``logical_correction`` matrix from CONDITIONAL and PROPAGATE. + + Two source-level constructs write to ``logical_correction``: + + 1. ``CONDITIONAL R L

`` — a logical Pauli correction + conditioned on readout *j*. The correction flips all + anti-commuting output observables. + + 2. ``PROPAGATE OUT

.LX FROM ... R ...`` — a readout term + inside a ``PROPAGATE`` row. Each ``R`` term XORs + ``logical_correction[target_row, j] = 1`` for every row the + PROPAGATE target flips. Non-readout terms in the same + ``PROPAGATE`` are handled by ``_resolve_propagate_statements`` + and route into ``correction_propagation`` / + ``physical_correction`` instead. + + Frame conventions: + + * In the **logical** frame (2 observables per logical qubit, + interleaved X then Z), ``LX`` flips ``LZ`` and vice versa. + * In the **physical** frame (2 observables per physical qubit), + the logical operator is expanded into its physical Pauli string + and each physical qubit's anti-commuting observable is flipped + individually. + + Contributions from both sources XOR into the matrix. Writing both + a ``CONDITIONAL R OUT.LP`` and a ``PROPAGATE OUT.LP FROM + ... R ...`` in the same gadget therefore cancels. """ entries: set[tuple[int, int]] = set() + num_logicals = sum(len(codes[p.code_name].logicals) for p in output_ports) - for stmt in flatten_body(list(gadget.body)): - if not isinstance(stmt, ConditionalStatement): - continue - if not isinstance(stmt.condition, ReadoutTarget): - continue - readout_col = stmt.condition.index - if readout_col >= num_readouts: - raise ValueError( - f"CONDITIONAL in gadget {gadget.name!r}: readout index " - f"R{readout_col} out of range (only {num_readouts} readouts " - f"declared)" - ) - - num_logicals = sum(len(codes[p.code_name].logicals) for p in output_ports) + def _xor_entry(row: int, readout_col: int) -> None: + entries.symmetric_difference_update({(row, readout_col)}) - for target in stmt.targets: - if target.port_kind is None and target.index >= num_logicals: + for stmt in flatten_body(list(gadget.body)): + if isinstance(stmt, ConditionalStatement): + if not isinstance(stmt.condition, ReadoutTarget): + continue + readout_col = stmt.condition.index + if readout_col >= num_readouts: raise ValueError( - f"CONDITIONAL in gadget {gadget.name!r}: logical index " - f"L{target.pauli}{target.index} out of range (only " - f"{num_logicals} output logical qubits)" + f"CONDITIONAL in gadget {gadget.name!r}: readout index " + f"R{readout_col} out of range (only {num_readouts} readouts " + f"declared)" ) - flipped = conditional_flipped_rows(target, output_ports, codes) - for row in flipped: - if skip_rows is not None and row in skip_rows: - continue - entries.symmetric_difference_update({(row, readout_col)}) + for target in stmt.targets: + if target.port_kind is None and target.index >= num_logicals: + raise ValueError( + f"CONDITIONAL in gadget {gadget.name!r}: logical index " + f"L{target.pauli}{target.index} out of range (only " + f"{num_logicals} output logical qubits)" + ) + for row in conditional_flipped_rows(target, output_ports, codes): + _xor_entry(row, readout_col) + + elif isinstance(stmt, PropagateStatement): + readout_terms = [t for t in stmt.terms if isinstance(t, ReadoutTarget)] + if not readout_terms: + continue + target_rows = _resolve_logical_target_to_columns( + stmt.target, list(output_ports), codes, expected_kind="OUT" + ) + for term in readout_terms: + if term.index >= num_readouts: + raise ValueError( + f"PROPAGATE in gadget {gadget.name!r}: readout index " + f"R{term.index} out of range (only {num_readouts} " + f"readouts declared)" + ) + for row in target_rows: + _xor_entry(row, term.index) sorted_entries = sorted(entries) rows_list = [r for r, _ in sorted_entries] @@ -1131,10 +1103,10 @@ def build_readouts( ``GadgetType.Readout.measurement_indices`` indexes the gadget's physical (real) measurements only — it cannot reference input- - virtual or output-virtual stabilizer measurements. Each target - (``rec[-k]``, ``M``, ``IN

.S``, or ``OUT

.S``) is - resolved to a global measurement index, validated to lie in the - internal/physical region, and translated to a real-only index. + virtual or output-virtual stabilizer measurements. Each + measurement target (``rec[-k]`` or ``M``) is resolved to a + global measurement index, validated to lie in the internal / + physical region, and translated to a real-only index. The ``readout_propagation`` matrix is sized ``|readouts| x (|input_observables| + 1)``. Each row records: @@ -1207,6 +1179,8 @@ def build_readouts( class _ReadoutInfo: measurement_indices: list[int] affine_flip: bool + explicit_logical_cols: set[int] + explicit_destab_cols: set[int] def _parse_readout( @@ -1222,21 +1196,36 @@ def _parse_readout( ) -> _ReadoutInfo: """Translate a ``ReadoutStatement`` into a :class:`_ReadoutInfo`. - Accepts any of the four measurement-reference forms (``rec[-k]``, - ``M``, ``IN

.S``, ``OUT

.S``). Raises ``ValueError`` if - the resolved target references a virtual stabilizer measurement. + Accepts physical measurement references (``rec[-k]``, ``M``), + input-side logical Pauli targets (``IN

.L

`` / bare + ``L

``), and input-side destabilizer targets + (``IN

.DS``) which explicitly encode ``readout_propagation`` + bits. + + Logical and destabilizer targets are XOR-combined with the + implicit walker-derived columns at + :func:`_build_readout_propagation` time so the rendered annotated + form can override walker output for readouts whose + matrix-composed rp differs from the inlined-body Heisenberg walk + (e.g. chained teleportation's cumulative readouts, or nested + composes that physically reset qubits carrying an input + destabilizer's Pauli representative before the readout's + measurements). Raises ``ValueError`` if a ``rec[-k]`` reference + resolves to a virtual stabilizer measurement. """ measurement_indices: list[int] = [] + explicit_logical_cols: set[int] = set() + explicit_destab_cols: set[int] = set() affine_flip = stmt.flip + input_layout: PortColumnLayout | None = None + for target in stmt.targets: if isinstance( target, ( MeasurementRecordTarget, PhysicalMeasurementTarget, - InputVirtualTarget, - OutputVirtualTarget, ), ): measurement_indices.append( @@ -1254,16 +1243,33 @@ def _parse_readout( ) ) continue + if isinstance(target, LogicalPauliTarget): + for col in _resolve_logical_target_to_columns( + target, input_ports, codes, expected_kind="IN" + ): + explicit_logical_cols.symmetric_difference_update([col]) + continue + if isinstance(target, DestabilizerTarget): + if input_layout is None: + input_layout = PortColumnLayout(input_ports, codes) + for col in _resolve_ds_to_input_cols( + target, input_layout, input_ports, codes + ): + explicit_destab_cols.symmetric_difference_update([col]) + continue raise ValueError( f"in GADGET {gadget_name!r}: {_render_readout(stmt)}: " - f"unsupported target {target!r}; only measurement references " - f"(rec[-k], M, IN

.S, OUT

.S) or FLIP are " - f"supported in READOUT statements" + f"unsupported target {target!r}; only physical measurement " + f"references (rec[-k], M), input logical Paulis " + f"(IN

.L

), input destabilizers (IN

.DS), " + f"or FLIP are supported in READOUT statements" ) return _ReadoutInfo( measurement_indices=sorted(_xor_deduplicate(measurement_indices)), affine_flip=affine_flip, + explicit_logical_cols=explicit_logical_cols, + explicit_destab_cols=explicit_destab_cols, ) @@ -1279,10 +1285,15 @@ def _resolve_measurement_target( internal_count: int, gadget_name: str, ) -> int: - """Translate a measurement-reference target to a real-measurement index. - - The target may be any of the four forms (``rec[-k]``, ``M``, - ``IN

.S``, ``OUT

.S``). Virtual targets are rejected. + """Translate a physical measurement-reference target to a + real-measurement index. + + The target is either ``rec[-k]`` (``MeasurementRecordTarget``) or + ``M`` (``PhysicalMeasurementTarget``). ``M`` always names a + physical measurement by construction; ``rec[-k]`` may resolve to + a virtual stabilizer slot depending on where the READOUT sits in + the body, and is rejected in that case — readouts must reference + physical measurements only. """ global_index = resolve_measurement_ref_global( target, @@ -1341,10 +1352,17 @@ def _build_readout_propagation( row_idx: list[int] = [] col_idx: list[int] = [] for index, info in enumerate(readouts_info): - if implicit_columns is not None: - for col in sorted(implicit_columns[index]): - row_idx.append(index) - col_idx.append(col) + implicit_set = ( + implicit_columns[index] if implicit_columns is not None else set() + ) + effective_cols = ( + set(implicit_set) + ^ info.explicit_logical_cols + ^ info.explicit_destab_cols + ) + for col in sorted(effective_cols): + row_idx.append(index) + col_idx.append(col) if info.affine_flip: row_idx.append(index) col_idx.append(num_input_observables) diff --git a/deq/deq/transpiler/jit_noise_builder.py b/deq/deq/transpiler/jit_noise_builder.py index 4b7e98cc..2113f6fd 100644 --- a/deq/deq/transpiler/jit_noise_builder.py +++ b/deq/deq/transpiler/jit_noise_builder.py @@ -72,6 +72,7 @@ OutputPort, PhysicalMeasurementTarget, PropagateStatement, + ReadoutTarget, VirtualLogicalStatement, ) from deq.transpiler.jit_transpiler import ( @@ -629,16 +630,23 @@ def _compute_pc_logical_via_flows( _, input_obs_paulis = _build_port_paulis(list(input_ports), codes, num_qubits) _, output_obs_paulis = _build_port_paulis(list(output_ports), codes, num_qubits) + # Build the flow solver context once for this body. All per-row + # solves reuse the same generators and base matrix. + solver_ctx = _build_flow_solver_context( + body_circuit=body_circuit, + input_obs_paulis=input_obs_paulis, + num_qubits=num_qubits, + ) + pc_entries: list[tuple[int, int]] = [] cp_entries: set[tuple[int, int]] = set() flip_entries: set[int] = set() for out_row in sorted(output_layout.logical_columns): target_out = output_obs_paulis[out_row] solution = _solve_logical_row_via_gf2_flow( - body_circuit=body_circuit, - input_obs_paulis=input_obs_paulis, target_out=target_out, num_qubits=num_qubits, + solver_context=solver_ctx, ) if solution is None: continue @@ -655,10 +663,9 @@ def _compute_pc_logical_via_flows( def _solve_logical_row_via_gf2_flow( *, - body_circuit: stim.Circuit, - input_obs_paulis: Sequence[stim.PauliString], target_out: stim.PauliString, num_qubits: int, + solver_context: "_FlowSolverContext", ) -> tuple[set[int], list[int], bool] | None: """Solve for a logical-row flow via stim's signed flow generators. @@ -684,35 +691,21 @@ def _solve_logical_row_via_gf2_flow( Hermitian flow ``+|in_u| → flip · target_out``, where ``|in_u|`` is the unsigned-letter product of the chosen input observables (which is what the runtime XORs as eigenvalue bits). - """ - num_input_cols = len(input_obs_paulis) - gens = list(body_circuit.flow_generators()) - num_gens = len(gens) - - gen_in_symp = [ - _pauli_string_to_symplectic(g.input_copy(), num_qubits) for g in gens - ] - gen_out_symp = [ - _pauli_string_to_symplectic(g.output_copy(), num_qubits) for g in gens - ] - input_col_symp = [ - _pauli_string_to_symplectic(p, num_qubits) for p in input_obs_paulis - ] - base_matrix: list[list[int]] = [] - for i in range(2 * num_qubits): - row = [gen_in_symp[g][i] for g in range(num_gens)] + [ - input_col_symp[c][i] for c in range(num_input_cols) - ] - base_matrix.append(row) - for i in range(2 * num_qubits): - row = [gen_out_symp[g][i] for g in range(num_gens)] + [0] * num_input_cols - base_matrix.append(row) + ``solver_context`` carries the body's flow generators and base + GF(2) matrix — both of which depend only on the body and the + input columns, not on ``target_out``. Callers must pre-build it + once via :func:`_build_flow_solver_context` and reuse it across + every target row for the same body. + """ + ctx = solver_context + num_gens = ctx.num_gens + gens = ctx.gens target_out_symp = _pauli_string_to_symplectic(target_out, num_qubits) rhs = [0] * (2 * num_qubits) + target_out_symp - solution = solve(BitMatrix(base_matrix), BitVector(rhs)) + solution = solve(ctx.base_matrix, BitVector(rhs)) if solution is None: return None @@ -751,6 +744,57 @@ def _solve_logical_row_via_gf2_flow( return cp_cols, sorted(meas_xor), flip +@dataclass +class _FlowSolverContext: + """Cached per-body data for repeated flow solver calls. + + ``stim.Circuit.flow_generators()`` and the base GF(2) matrix + depend only on the body and input columns, not on the output + target. Compute them once and reuse across multiple targets. + """ + + gens: list[stim.Flow] + num_gens: int + base_matrix: BitMatrix + + +def _build_flow_solver_context( + *, + body_circuit: stim.Circuit, + input_obs_paulis: Sequence[stim.PauliString], + num_qubits: int, +) -> _FlowSolverContext: + gens = list(body_circuit.flow_generators()) + num_gens = len(gens) + num_input_cols = len(input_obs_paulis) + + gen_in_symp = [ + _pauli_string_to_symplectic(g.input_copy(), num_qubits) for g in gens + ] + gen_out_symp = [ + _pauli_string_to_symplectic(g.output_copy(), num_qubits) for g in gens + ] + input_col_symp = [ + _pauli_string_to_symplectic(p, num_qubits) for p in input_obs_paulis + ] + + base_matrix_rows: list[list[int]] = [] + for i in range(2 * num_qubits): + row = [gen_in_symp[g][i] for g in range(num_gens)] + [ + input_col_symp[c][i] for c in range(num_input_cols) + ] + base_matrix_rows.append(row) + for i in range(2 * num_qubits): + row = [gen_out_symp[g][i] for g in range(num_gens)] + [0] * num_input_cols + base_matrix_rows.append(row) + + return _FlowSolverContext( + gens=gens, + num_gens=num_gens, + base_matrix=BitMatrix(base_matrix_rows), + ) + + def _pauli_string_to_symplectic(ps: stim.PauliString, num_qubits: int) -> list[int]: """Encode ``ps`` as a 2*num_qubits-bit symplectic vector ``[X|Z]``. @@ -1410,6 +1454,11 @@ def resolve_propagations( f"reference internal physical measurements" ) pc_cols ^= {global_index - input_virtual_count} + elif isinstance(term, ReadoutTarget): + # ``R`` terms route to ``logical_correction`` via + # ``_build_logical_correction``; they do NOT affect + # ``correction_propagation`` or ``physical_correction``. + continue else: raise ValueError( f"in GADGET {gadget.name!r}: unsupported PROPAGATE " @@ -1447,357 +1496,39 @@ def _measurement_count_of_instruction(inst: Instruction) -> int: return len(_qubit_indices(inst)) -def _build_propagation_basis_freedom( - *, - input_layout: PortColumnLayout, - cp_stab_rows: dict[int, set[int]], - pc_stab_rows: dict[int, set[int]], - flip_stab_rows: set[int], - finished_checks: Sequence[Check], - input_virtual_count: int, - ov_start: int, - n_cp: int, - n_pc: int, - flip_col: int, -) -> tuple[list[BitVector], list[str]]: - """Build the basis-freedom column list used to validate PROPAGATE specs. - - The freedom basis contains: - - 1. Input-stabilizer unit vectors — one per input cp generator - column. Selecting one toggles that input cp bit; the receiver - can re-derive the contribution from the input port observables. - 2. Output-stabilizer joint rows — for each output stab row, its - full ``(cp, pc, flip)`` joint vector. Selecting one effectively - absorbs the output stab into the logical row, since the - receiver's output stab corrections will undo it. - 3. Finished-check body+flip vectors — for each finished check, its - internal-measurement columns in pc plus the flip bit if the - check is naturally flipped. Selecting one is harmless because - finished checks always evaluate to zero structurally. - - Returns the basis as a list of column :class:`BitVector` (each of - length ``n_cp + n_pc``) plus a parallel list of per-column - descriptions for diagnostics. Per-row CONDITIONAL R - contributions are added separately by the caller when validating - each PROPAGATE row, since they are valid only for the rows the - CONDITIONAL flips. - """ - columns: list[BitVector] = [] - descriptions: list[str] = [] - - n_total = n_cp + n_pc - flip_index = flip_col - - input_stab_cols = sorted( - c - for c in range(input_layout.num_columns) - if c not in input_layout.logical_columns - ) - for c in input_stab_cols: - v = BitVector.zeros(n_total) - v[c] = True - columns.append(v) - port_idx, stab_idx = input_layout.generator_map[c] - descriptions.append(f"input-stab P{port_idx}.S{stab_idx}") - - stab_row_indices = sorted( - cp_stab_rows.keys() | pc_stab_rows.keys() | flip_stab_rows - ) - for r in stab_row_indices: - v = BitVector.zeros(n_total) - for c in cp_stab_rows.get(r, set()): - v[c] = True - if r in flip_stab_rows: - v[flip_index] = True - for c in pc_stab_rows.get(r, set()): - v[n_cp + c] = True - columns.append(v) - descriptions.append(f"output-stab row {r}") - - for fc_idx, (members, parity) in enumerate(finished_checks): - v = BitVector.zeros(n_total) - if parity: - v[flip_index] = True - for m in members: - if m < input_virtual_count or m >= ov_start: - continue - v[n_cp + (m - input_virtual_count)] = True - columns.append(v) - descriptions.append(f"finished-check #{fc_idx}") - - return columns, descriptions - - -def _columns_to_basis_matrix( - columns: Sequence[BitVector], - n_total: int, -) -> BitMatrix: - """Stack a list of column :class:`BitVector` into a single - :class:`BitMatrix` of size ``n_total × len(columns)``.""" - if not columns: - return BitMatrix.zeros(rows=n_total, columns=0) - matrix = BitMatrix.zeros(rows=n_total, columns=len(columns)) - for j, v in enumerate(columns): - for i in range(n_total): - if v[i]: - matrix[(i, j)] = True - return matrix - - -def _build_conditional_basis_vector( - *, - readout_index: int, - readout_propagation: util_pb.BitMatrix, - readout_measurement_indices: Sequence[int], - n_cp: int, - n_pc: int, - flip_col: int, -) -> BitVector: - """Build the basis-freedom vector for a ``CONDITIONAL R`` entry. - - Encodes the absorption pattern that step 9 of - :func:`deq.spec.canonical.merge` would apply when expanding - ``logical_correction[r, j] = 1`` into ``correction_propagation`` and - ``physical_correction``: - - * ``cp[r, in_col]`` flips for every input observable column where - ``readout_propagation[j, in_col] = 1`` (i.e. the readout depends - on that input observable), - * the affine ``flip_col`` flips when the readout's affine bit is - set, - * ``pc[r, m_idx]`` flips for every measurement index in the - readout's ``measurement_indices`` (the body measurements XORed - into the raw readout bit). - - Returns a single column vector of length ``n_cp + n_pc`` (rows of - the basis matrix), with the row component (which row ``r`` is - flipped) supplied by the caller as a separate per-row gate. - """ - v = BitVector.zeros(n_cp + n_pc) - affine_col = n_cp - 1 - for entry_index in range(len(readout_propagation.i)): - if readout_propagation.i[entry_index] != readout_index: - continue - col = readout_propagation.j[entry_index] - if col == affine_col: - v[flip_col] = True - elif col < n_cp: - v[col] = True - for m_idx in readout_measurement_indices: - v[n_cp + m_idx] = True - return v - - -def _propagation_row_vector( +def _apply_propagations( *, - cp_cols: set[int], - pc_cols: set[int], - flip: bool, - n_cp: int, - n_pc: int, - flip_col: int, -) -> BitVector: - """Encode (cp_cols, pc_cols, flip) into a joint ``n_cp + n_pc`` BitVector.""" - v = BitVector.zeros(n_cp + n_pc) - for c in cp_cols: - v[c] = True - if flip: - v[flip_col] = True - for c in pc_cols: - v[n_cp + c] = True - return v - - -def _repropagate_hint(gadget_name: str) -> str: - """Suffix appended to PROPAGATE-mismatch errors. - - A PROPAGATE row that disagrees with the canonical flow-derived - value typically means the gadget came from a COMPOSE block whose - matrix-composed propagation cannot be expressed as circuit flow - (e.g. teleportation-style conditional logical correction). The - fix is to add ``@REPROPAGATE`` to the COMPOSE so it is built via - the flat-circuit pipeline. - """ - return ( - f"\n Hint: if {gadget_name!r} was generated by 'deq annotate' " - f"from a COMPOSE block, add the @REPROPAGATE decorator to that " - f"COMPOSE. @REPROPAGATE switches the COMPOSE build to the " - f"flat-circuit pipeline so its propagation matrices come from " - f"actual circuit flow on the inlined body, not from sub-gadget " - f"matrix composition." - ) - - -def _validate_and_apply_propagations( - *, - gadget_name: str, propagations: dict[int, ResolvedPropagation], cp_entries: set[tuple[int, int]], logical_physical: list[tuple[int, int]], flow_cp_entries: set[tuple[int, int]], flow_flip_entries: set[int], - input_layout: PortColumnLayout, - output_layout: PortColumnLayout, - unfinished_checks: Sequence[Check], - finished_checks: Sequence[Check], - input_virtual_count: int, - ov_start: int, - n_cp: int, - n_pc: int, flip_col: int, - conditional_basis_info: Sequence[tuple[frozenset[int], int]] = (), - readout_propagation: util_pb.BitMatrix | None = None, - readout_measurement_indices: Sequence[Sequence[int]] = (), ) -> tuple[set[tuple[int, int]], list[tuple[int, int]]]: - """Validate each PROPAGATE row and substitute it for the flow result. - - Modifies ``cp_entries`` and ``logical_physical`` to use the - user-specified row in place of the flow-derived row, after - confirming the substitution lies in the basis-freedom span. - - ``conditional_basis_info`` lists each ``CONDITIONAL R L

`` - statement in the body as ``(flipped_rows, readout_index)`` pairs. - For PROPAGATE rows that are flipped by such a CONDITIONAL, the - basis-freedom is extended with the absorption pattern (``rp[j, *]`` - in cp + ``R[j]`` in pc + the affine bit) so the user's PROPAGATE - can express the absorbed form even when the flow-derived - propagation does not naturally include it (e.g. lattice-surgery - split-measurement frame corrections). - - Returns the updated ``(cp_entries, logical_physical)``. + """Install each declared ``PROPAGATE`` row verbatim in place of the + flow-derived row. + + ``PROPAGATE`` is authoritative: the user's declared XOR formula is + the ground truth for that output row's cp/pc contributions. For + rows without an explicit ``PROPAGATE``, the flow-derived + (natural-Heisenberg + VIRTUAL + measurement-conditioned CONDITIONAL) + entries are kept. Readout terms (``R``) are handled by + :func:`_build_logical_correction` and never touch cp/pc. """ if not propagations: return cp_entries, logical_physical - cp_stab_rows: dict[int, set[int]] = {} - pc_stab_rows: dict[int, set[int]] = {} - flip_stab_rows: set[int] = set() - for uc_idx, (members, parity) in enumerate(unfinished_checks): - out_row = output_layout.stab_to_column[uc_idx] - if out_row is None: - continue - cp_row: set[int] = set() - pc_row: set[int] = set() - for member in members: - if member < input_virtual_count: - for in_col in input_layout.stab_decomposed_columns[member]: - cp_row ^= {in_col} - elif member < ov_start: - pc_row ^= {member - input_virtual_count} - cp_stab_rows[out_row] = cp_row - pc_stab_rows[out_row] = pc_row - if parity: - flip_stab_rows.add(out_row) - - base_columns, base_descriptions = _build_propagation_basis_freedom( - input_layout=input_layout, - cp_stab_rows=cp_stab_rows, - pc_stab_rows=pc_stab_rows, - flip_stab_rows=flip_stab_rows, - finished_checks=finished_checks, - input_virtual_count=input_virtual_count, - ov_start=ov_start, - n_cp=n_cp, - n_pc=n_pc, - flip_col=flip_col, - ) - - n_total = n_cp + n_pc - cond_vectors_by_readout: dict[int, BitVector] = {} - if conditional_basis_info and readout_propagation is not None: - used_readouts = {j for _, j in conditional_basis_info} - for j in used_readouts: - if j < 0 or j >= len(readout_measurement_indices): - continue - cond_vectors_by_readout[j] = _build_conditional_basis_vector( - readout_index=j, - readout_propagation=readout_propagation, - readout_measurement_indices=readout_measurement_indices[j], - n_cp=n_cp, - n_pc=n_pc, - flip_col=flip_col, - ) - - base_matrix = _columns_to_basis_matrix(base_columns, n_total) - flow_cp_per_row: dict[int, set[int]] = {} for r, c in flow_cp_entries: flow_cp_per_row.setdefault(r, set()).add(c) - flow_pc_per_row: dict[int, set[int]] = {} - for r, c in logical_physical: - flow_pc_per_row.setdefault(r, set()).add(c) for row, resolved in sorted(propagations.items()): flow_cp_cols = flow_cp_per_row.get(row, set()) - flow_pc_cols = flow_pc_per_row.get(row, set()) flow_flip = row in flow_flip_entries - flow_vec = _propagation_row_vector( - cp_cols=flow_cp_cols, - pc_cols=flow_pc_cols, - flip=flow_flip, - n_cp=n_cp, - n_pc=n_pc, - flip_col=flip_col, - ) - user_vec = _propagation_row_vector( - cp_cols=set(resolved.cp_input_cols), - pc_cols=set(resolved.pc_internal_cols), - flip=resolved.flip, - n_cp=n_cp, - n_pc=n_pc, - flip_col=flip_col, - ) - delta = flow_vec ^ user_vec - if delta.weight == 0: - continue - - row_extras: list[BitVector] = [] - row_extra_descs: list[str] = [] - for flipped_rows, j in conditional_basis_info: - if row not in flipped_rows: - continue - v = cond_vectors_by_readout.get(j) - if v is None: - continue - row_extras.append(v) - row_extra_descs.append(f"CONDITIONAL R{j} (flips row {row})") - - if row_extras: - row_columns = list(base_columns) + row_extras - row_matrix = _columns_to_basis_matrix(row_columns, n_total) - else: - row_matrix = base_matrix - - if row_matrix.column_count == 0: - raise ValueError( - f"in GADGET {gadget_name!r}: PROPAGATE for output row {row} " - f"({resolved.statement.target}) does not match the unique " - f"flow-derived value and there is no basis-freedom available " - f"to absorb the difference." - f"{_repropagate_hint(gadget_name)}" - ) - alpha = solve(row_matrix, delta) - if alpha is None: - extra_clause = ( - ", or CONDITIONAL R contributions" - if row_extra_descs - else "" - ) - raise ValueError( - f"in GADGET {gadget_name!r}: PROPAGATE for output row {row} " - f"({resolved.statement.target}) does not lie in the " - f"basis-freedom span of that row; the spec differs from the " - f"canonical flow-derived value by {delta.weight} bit(s) " - f"that cannot be expressed as any XOR of input-stabilizers, " - f"output-stabilizer joint rows, or finished-check parities" - f"{extra_clause}." - f"{_repropagate_hint(gadget_name)}" - ) - cp_entries -= {(row, c) for c in flow_cp_cols} - cp_entries -= {(row, flip_col)} if flow_flip else set() + if flow_flip: + cp_entries -= {(row, flip_col)} cp_entries |= {(row, c) for c in resolved.cp_input_cols} if resolved.flip: cp_entries |= {(row, flip_col)} @@ -1806,7 +1537,6 @@ def _validate_and_apply_propagations( for c in sorted(resolved.pc_internal_cols): logical_physical.append((row, c)) - _ = base_descriptions return cp_entries, logical_physical @@ -1868,9 +1598,6 @@ def compute_correction_propagation( input_virtual_count: int, ov_start: int | None = None, propagations: dict[int, ResolvedPropagation] | None = None, - conditional_basis_info: Sequence[tuple[frozenset[int], int]] = (), - readout_propagation: util_pb.BitMatrix | None = None, - readout_measurement_indices: Sequence[Sequence[int]] = (), ) -> tuple[util_pb.BitMatrix, list[tuple[int, int]]]: """Compute the ``correction_propagation`` matrix. @@ -1957,7 +1684,8 @@ def compute_correction_propagation( entries ^= {(row, constant_col)} # Separate the combined entries (VIRTUAL + flow + unfinished) into - # cp-only entries and flip entries for validation. + # cp-only entries and flip entries so :func:`_apply_propagations` + # can remove flow contributions on rows the user explicitly pins. combined_cp_entries: set[tuple[int, int]] = set() combined_flip_entries: set[int] = set() for r, c in entries: @@ -1967,29 +1695,13 @@ def compute_correction_propagation( combined_cp_entries.add((r, c)) if propagations: - if ov_start is None: - raise ValueError( - "compute_correction_propagation: propagations requires ov_start" - ) - entries, logical_physical = _validate_and_apply_propagations( - gadget_name=gadget.name, + entries, logical_physical = _apply_propagations( propagations=propagations, cp_entries=entries, logical_physical=logical_physical, flow_cp_entries=combined_cp_entries, flow_flip_entries=combined_flip_entries, - input_layout=input_layout, - output_layout=output_layout, - unfinished_checks=unfinished_checks, - finished_checks=finished_checks, - input_virtual_count=input_virtual_count, - ov_start=ov_start, - n_cp=cols, - n_pc=ov_start - input_virtual_count, flip_col=constant_col, - conditional_basis_info=conditional_basis_info, - readout_propagation=readout_propagation, - readout_measurement_indices=readout_measurement_indices, ) sorted_entries = sorted(entries) diff --git a/deq/tests/circuit/test_annotate.py b/deq/tests/circuit/test_annotate.py index 655e7690..85c7b3f9 100644 --- a/deq/tests/circuit/test_annotate.py +++ b/deq/tests/circuit/test_annotate.py @@ -115,6 +115,10 @@ def test_annotate_trivial_gadgets() -> None: _assert_annotate_roundtrip(CIRCUIT_DIR / "fixtures" / "trivial_gadgets.deq") +def test_annotate_trivial_surgery() -> None: + _assert_annotate_roundtrip(CIRCUIT_DIR / "fixtures" / "trivial_surgery.deq") + + def test_annotate_floquet666() -> None: _assert_annotate_roundtrip(CIRCUIT_DIR / "fixtures" / "floquet666.deq") @@ -144,3 +148,79 @@ def test_annotate_lattice_surgery_d3() -> None: _assert_annotate_roundtrip( CIRCUIT_DIR / "surface_code" / "lattice_surgery_d3.deq" ) + + +def test_annotate_chained_conditional_same_row() -> None: + """A COMPOSE that chains sub-composes with ``CONDITIONAL`` frame + corrections on the same output row (e.g. + ``DoubleTeleportConditional``, ``TripleTeleportConditional``) is + emitted by the annotator as plain ``PROPAGATE`` rows with no + ``CONDITIONAL`` lines — the canonicalizer's merge step (step 9) + has already folded every sub-gadget CONDITIONAL contribution into + ``correction_propagation`` / ``physical_correction`` on the merged + gadget, leaving ``logical_correction`` empty, so the annotator has + no readout-conditioned flip to re-emit. ``PROPAGATE`` rows are + authoritative: whatever the annotator declares is installed as the + residual formula for that output row, so byte-equivalence of the + compiled library after annotate → re-transpile confirms the + round-trip is semantics-preserving. + """ + qfile = render_and_parse_file( + str(CIRCUIT_DIR / "surface_code" / "teleportation_d3.deq"), + mako_defs=None, + skip_mako_warning=True, + ) + orig_lib = build_jit_library(qfile) + annotated = annotate_impl(qfile) + anno_lib = build_jit_library(parse_deq(annotated)) + _assert_stripped_bytes_equal( + orig_lib, anno_lib, "DoubleTeleportConditional + TripleTeleportConditional" + ) + # Annotated compose GADGETs never emit ``CONDITIONAL``: step-9 + # absorption clears ``logical_correction`` on every merged gadget, + # so the annotator has no readout-conditioned flip to re-emit. + for name in ( + "DoubleTeleportConditional", + "TripleTeleportConditional", + ): + block = annotated.split(f"GADGET {name} {{", 1)[1].split("\n}", 1)[0] + assert "\n CONDITIONAL " not in block, ( + f"unexpected CONDITIONAL line in {name}: annotator should have " + f"dropped every source CONDITIONAL (they are absorbed into " + f"cp/pc by canonical.merge step 9):\n{block}" + ) + + +def test_annotate_exercise_readout_conditions_destab_readout() -> None: + """``ExerciseReadoutConditions`` from ``exercise_readout_conditions.deq`` + triggers the case where a compose's ``readout_propagation`` row has + entries in **destabilizer** columns of the input frame (not just + logical observable columns). + """ + fixture = ( + CIRCUIT_DIR / "repetition_code" / "exercise_readout_conditions.deq" + ) + qfile = render_and_parse_file( + str(fixture), mako_defs=None, skip_mako_warning=True + ) + orig_lib = build_jit_library(qfile) + annotated = annotate_impl(qfile) + anno_lib = build_jit_library(parse_deq(annotated)) + _assert_stripped_bytes_equal(orig_lib, anno_lib, fixture.name) + + block = ( + annotated.split("GADGET ExerciseReadoutConditions {", 1)[1] + .split("\n}", 1)[0] + ) + readout_lines = [ + line.strip() + for line in block.splitlines() + if line.lstrip().startswith("READOUT ") + ] + has_destab_token = any(".DS" in line.split("#", 1)[0] for line in readout_lines) + assert has_destab_token, ( + "expected at least one READOUT line in ExerciseReadoutConditions " + "to carry an IN

.DS destabilizer token bridging the " + "walker/binary rp mismatch, but found none. READOUT lines:\n" + + "\n".join(readout_lines) + ) diff --git a/deq/tests/cli/jit_test.py b/deq/tests/cli/jit_test.py index 4f83baab..920803a8 100644 --- a/deq/tests/cli/jit_test.py +++ b/deq/tests/cli/jit_test.py @@ -1108,6 +1108,103 @@ def test_stim_export_remaps_mpp_pauli_targets() -> None: assert "X0" not in mpp_line, f"local index leaked through in: {mpp_line}" +# --------------------------------------------------------------------------- +# Merge-time residual helpers — shared across conditional-equivalence +# tests over the teleportation, lattice-surgery, and trivial-surgery +# fixtures. Every test that compares two ``CONDITIONAL`` placements +# canonicalising to runtime-equivalent gadgets uses these helpers. +# --------------------------------------------------------------------------- + + +def _compute_zero_measurement_residual( + jit_library: jit_pb.JitLibrary, + gadget_name: str, + input_obs: list[int], +) -> "np.ndarray": + """Merge-time residual of *gadget_name* for the given + input-observable pattern, with zero raw measurements and zero + decoded correction. + + Isolates the ``cp · input`` and ``lc · (rp · input)`` contributions + — which is where the merge-time propagator's correctness matters — + from the physical-measurement and decoder-correction paths. The + runtime formula being evaluated (see ``pauli_frame_tracker.rs``):: + + readouts = raw + decoded.readouts + rp · input + residual = cp · input + pc · raw + lc · readouts + decoded.residual + """ + import numpy as np + + ptype_by_id = {pt.base.ptype: pt.base for pt in jit_library.port_types} + gt = next(g for g in jit_library.gadget_types if g.base.name == gadget_name) + base = gt.base + n_in = sum(len(ptype_by_id[p.ptype].observables) for p in base.inputs) + n_out = sum(len(ptype_by_id[p.ptype].observables) for p in base.outputs) + assert len(input_obs) == n_in, ( + f"{gadget_name}: expected {n_in} input observables, " + f"got {len(input_obs)}" + ) + + def dense(bm: pb.BitMatrix, rows: int, cols: int) -> "np.ndarray": + m = np.zeros((rows, cols), dtype=np.uint8) + for i, j in zip(bm.i, bm.j): + m[i, j] = 1 + return m + + input_ext = np.array(list(input_obs) + [1], dtype=np.uint8) + cp = dense(base.correction_propagation, n_out, n_in + 1) + pc = dense(base.physical_correction, n_out, len(base.measurements)) + lc = dense(base.logical_correction, n_out, len(base.readouts)) + rp = dense(base.readout_propagation, len(base.readouts), n_in + 1) + raw = np.zeros(len(base.measurements), dtype=np.uint8) + raw_readouts = np.zeros(len(base.readouts), dtype=np.uint8) + for c, r in enumerate(base.readouts): + for m in r.measurement_indices: + raw_readouts[c] ^= raw[m] + decoded_readouts = np.zeros(len(base.readouts), dtype=np.uint8) + decoded_residual = np.zeros(n_out, dtype=np.uint8) + readouts = (raw_readouts + decoded_readouts + rp @ input_ext) % 2 + residual = ( + cp @ input_ext + pc @ raw + lc @ readouts + decoded_residual + ) % 2 + return residual + + +def _assert_gadgets_runtime_equivalent( + jit_library: jit_pb.JitLibrary, + name_a: str, + name_b: str, +) -> None: + """ + Assert two gadgets produce identical merge-time residuals. + """ + import numpy as np + + ptype_by_id = {pt.base.ptype: pt.base for pt in jit_library.port_types} + gt_a = next(g for g in jit_library.gadget_types if g.base.name == name_a) + gt_b = next(g for g in jit_library.gadget_types if g.base.name == name_b) + n_in = sum( + len(ptype_by_id[p.ptype].observables) for p in gt_a.base.inputs + ) + assert n_in == sum( + len(ptype_by_id[p.ptype].observables) for p in gt_b.base.inputs + ), f"{name_a} and {name_b} have different input widths" + + basis_inputs: list[list[int]] = [[0] * n_in] + for bit in range(n_in): + vec = [0] * n_in + vec[bit] = 1 + basis_inputs.append(vec) + + for inp in basis_inputs: + res_a = _compute_zero_measurement_residual(jit_library, name_a, inp) + res_b = _compute_zero_measurement_residual(jit_library, name_b, inp) + assert np.array_equal(res_a, res_b), ( + f"{name_a} vs {name_b} disagree for input {inp}: " + f"a={res_a.tolist()} b={res_b.tolist()}" + ) + + # --------------------------------------------------------------------------- # Surface-code logical teleportation (d=3) — end-to-end PROGRAM # compilation for both @REPROPAGATE and explicit-CONDITIONAL variants. @@ -1246,6 +1343,28 @@ def test_repropagate_and_conditional_emit_same_propagation( ] + @pytest.mark.parametrize( + "cond_name,repro_name", + [ + ("TeleportConditional", "TeleportRepropagate"), + ("DoubleTeleportConditional", "DoubleTeleportRepropagate"), + ("TripleTeleportConditional", "TripleTeleportRepropagate"), + ], + ) + def test_conditional_and_repropagate_runtime_equivalent( + self, + teleportation_d3_setup: tuple[jit_pb.JitLibrary, dict[str, object]], + cond_name: str, + repro_name: str, + ) -> None: + """The ``CONDITIONAL`` and ``@REPROPAGATE`` encodings must + produce the same runtime residual for every input observable + pattern, at every nesting depth. + """ + jit_library, _ = teleportation_d3_setup + _assert_gadgets_runtime_equivalent(jit_library, cond_name, repro_name) + + # --------------------------------------------------------------------------- # Lattice surgery (d=3 surface code) — true spatial merge-and-split test. # --------------------------------------------------------------------------- @@ -1584,6 +1703,100 @@ def test_two_mzz_compose_absorbs_byproduct( ) assert len(merge.base.logical_correction.i) == 0 + @pytest.mark.parametrize( + "leaf_name,composed_name", + [ + ("TwoMZZ", "TwoMZZCompose"), + ("TwoMZZExtraCorrMixed", "TwoMZZExtraCorrOuter"), + ], + ) + def test_conditional_placement_runtime_equivalent( + self, + trivial_surgery_library: jit_pb.JitLibrary, + leaf_name: str, + composed_name: str, + ) -> None: + """CONDITIONAL placement across the leaf/compose boundary must + not change runtime behavior. + + Two placement patterns are covered: + + 1. **Multi-wire cascade at the leaf/compose boundary** + (``TwoMZZ`` vs ``TwoMZZCompose``). ``TwoMZZ`` is a leaf + GADGET carrying a cross-wire ``CONDITIONAL R0 OUT1.LX0`` + whose driving readout (joint parity ``M0 + M1``) depends + on flow through *both* input patches — the CONDITIONAL + lives verbatim in the leaf ``logical_correction``. + ``TwoMZZCompose`` implements the same operation as + ``TwoMerge`` + ``TwoSplit`` + an outer + ``CONDITIONAL rec[-1] X0 1``; ``canonical.merge`` absorbs + that CONDITIONAL into ``correction_propagation``, so the + composed base gadget has an empty ``logical_correction``. + + 2. **Mixed inner/outer CONDITIONALs** + (``TwoMZZExtraCorrMixed`` vs ``TwoMZZExtraCorrOuter``). + ``TwoMZZExtraCorrMixed`` wraps ``TwoMZZ`` (whose inner + GADGET-level CONDITIONAL survives as an + ``logical_correction`` entry on the sub-gadget) and adds an + *outer* COMPOSE-level ``CONDITIONAL rec[-1] Z0 0`` that + references the sub-gadget's readout — so merge must + compose an inner ``lc`` row with an outer CONDITIONAL + targeting the same readout. ``TwoMZZExtraCorrOuter`` + implements the same operation with both CONDITIONALs at + the outer COMPOSE level (no inner CONDITIONAL). + + In both pairs the two encodings must produce identical + merge-time residuals on the ``n_in + 1`` basis inputs — which, + by affine-map linearity, implies agreement on every input + observable pattern. + """ + _assert_gadgets_runtime_equivalent( + trivial_surgery_library, leaf_name, composed_name + ) + + def test_mixed_inner_outer_conditional_matrices_byte_identical( + self, + trivial_surgery_library: jit_pb.JitLibrary, + ) -> None: + """``TwoMZZExtraCorrMixed`` (inner ``TwoMZZ`` CONDITIONAL + + outer COMPOSE CONDITIONAL) and ``TwoMZZExtraCorrOuter`` (both + CONDITIONALs at the outer COMPOSE level) must produce + byte-identical ``correction_propagation`` / ``physical_correction`` + / ``readout_propagation`` matrices after step-9 absorption. + + This is a stronger property than the runtime-residual + equivalence checked in + ``test_conditional_placement_runtime_equivalent``: absorption + canonicalizes both encodings into the same merged form, so + the serialized matrices agree entry-for-entry, not just + modulo the runtime formula. + """ + mixed = next( + gt + for gt in trivial_surgery_library.gadget_types + if gt.base.name == "TwoMZZExtraCorrMixed" + ).base + outer = next( + gt + for gt in trivial_surgery_library.gadget_types + if gt.base.name == "TwoMZZExtraCorrOuter" + ).base + + def entries(bm: pb.BitMatrix) -> set[tuple[int, int]]: + return set(zip(bm.i, bm.j)) + + assert entries(mixed.correction_propagation) == entries( + outer.correction_propagation + ) + assert entries(mixed.physical_correction) == entries( + outer.physical_correction + ) + assert entries(mixed.readout_propagation) == entries( + outer.readout_propagation + ) + assert len(mixed.logical_correction.i) == 0 + assert len(outer.logical_correction.i) == 0 + class TestTrivialTwoMZZPrograms: diff --git a/deq/tests/transpiler/jit_library_builder_test.py b/deq/tests/transpiler/jit_library_builder_test.py index e716a8db..fa8cb570 100644 --- a/deq/tests/transpiler/jit_library_builder_test.py +++ b/deq/tests/transpiler/jit_library_builder_test.py @@ -1109,6 +1109,172 @@ def test_conditional_invalid_logical_index() -> None: build_jit_library(parse(source)) +# --------------------------------------------------------------------------- +# PROPAGATE R-term routing (equivalent to CONDITIONAL R) +# --------------------------------------------------------------------------- + + +def test_propagate_r_term_populates_logical_correction() -> None: + """PROPAGATE with an R term should XOR logical_correction[row, k]. + + A source using ``PROPAGATE OUT.LX0 FROM ... R0`` must produce the + same ``logical_correction`` matrix as one using + ``CONDITIONAL R0 OUT.LX0``. + """ + source = """ + CODE Rep [[3,1,3]] { + LOGICAL X0*X1*X2 Z0*Z1*Z2 + STABILIZER Z0*Z1 Z1*Z2 + } + GADGET G { + INPUT Rep 0 1 2 + M 3 + READOUT rec[-1] + OUTPUT Rep 0 1 2 + PROPAGATE LX0 FROM LX0 R0 + } + """ + library = build_jit_library(parse(source)) + gadget = next(gt for gt in library.gadget_types if gt.base.name == "G") + cc = gadget.base.logical_correction + assert cc.rows == 4 + assert cc.cols == 1 + entries = set(zip(cc.i, cc.j)) + # LX0 maps to the Z-column (row 1) of the unified frame, matching + # ``CONDITIONAL R0 LX0`` semantics. + assert entries == {(1, 0)} + + +def test_propagate_r_term_matches_conditional_equivalent() -> None: + """Two sources — one with CONDITIONAL R0 OUT.LX0, one with + PROPAGATE OUT.LX0 FROM LX0 R0 — must produce identical + ``logical_correction`` matrices. + """ + conditional_source = """ + CODE Rep [[3,1,3]] { + LOGICAL X0*X1*X2 Z0*Z1*Z2 + STABILIZER Z0*Z1 Z1*Z2 + } + GADGET G { + INPUT Rep 0 1 2 + M 3 + READOUT rec[-1] + OUTPUT Rep 0 1 2 + CONDITIONAL R0 LX0 + } + """ + propagate_source = """ + CODE Rep [[3,1,3]] { + LOGICAL X0*X1*X2 Z0*Z1*Z2 + STABILIZER Z0*Z1 Z1*Z2 + } + GADGET G { + INPUT Rep 0 1 2 + M 3 + READOUT rec[-1] + OUTPUT Rep 0 1 2 + PROPAGATE LX0 FROM LX0 R0 + } + """ + lib_a = build_jit_library(parse(conditional_source)) + lib_b = build_jit_library(parse(propagate_source)) + cc_a = next(gt for gt in lib_a.gadget_types if gt.base.name == "G").base.logical_correction + cc_b = next(gt for gt in lib_b.gadget_types if gt.base.name == "G").base.logical_correction + assert set(zip(cc_a.i, cc_a.j)) == set(zip(cc_b.i, cc_b.j)) + + +def test_propagate_r_term_xors_with_conditional() -> None: + """A CONDITIONAL R0 and a PROPAGATE R0 targeting the same row cancel + (XOR semantics), leaving logical_correction empty. + """ + source = """ + CODE Rep [[3,1,3]] { + LOGICAL X0*X1*X2 Z0*Z1*Z2 + STABILIZER Z0*Z1 Z1*Z2 + } + GADGET G { + INPUT Rep 0 1 2 + M 3 + READOUT rec[-1] + OUTPUT Rep 0 1 2 + CONDITIONAL R0 LX0 + PROPAGATE LX0 FROM LX0 R0 + } + """ + library = build_jit_library(parse(source)) + gadget = next(gt for gt in library.gadget_types if gt.base.name == "G") + cc = gadget.base.logical_correction + assert set(zip(cc.i, cc.j)) == set() + + +def test_propagate_r_term_invalid_readout_index() -> None: + """PROPAGATE with an R-term whose index exceeds declared readouts + must raise a clear error. + """ + source = """ + CODE Rep [[3,1,3]] { + LOGICAL X0*X1*X2 Z0*Z1*Z2 + STABILIZER Z0*Z1 Z1*Z2 + } + GADGET G { + INPUT Rep 0 1 2 + M 3 + READOUT rec[-1] + OUTPUT Rep 0 1 2 + PROPAGATE LX0 FROM LX0 R5 + } + """ + with pytest.raises(ValueError, match="R5 out of range"): + build_jit_library(parse(source)) + + +def test_propagate_r_term_does_not_leak_to_cp_pc() -> None: + """An R-term inside PROPAGATE must not affect correction_propagation + or physical_correction — those are cp/pc territory only. + """ + with_r_source = """ + CODE Rep [[3,1,3]] { + LOGICAL X0*X1*X2 Z0*Z1*Z2 + STABILIZER Z0*Z1 Z1*Z2 + } + GADGET G { + INPUT Rep 0 1 2 + M 3 + READOUT rec[-1] + OUTPUT Rep 0 1 2 + PROPAGATE LX0 FROM LX0 R0 + } + """ + without_r_source = """ + CODE Rep [[3,1,3]] { + LOGICAL X0*X1*X2 Z0*Z1*Z2 + STABILIZER Z0*Z1 Z1*Z2 + } + GADGET G { + INPUT Rep 0 1 2 + M 3 + READOUT rec[-1] + OUTPUT Rep 0 1 2 + PROPAGATE LX0 FROM LX0 + } + """ + lib_with = build_jit_library(parse(with_r_source)) + lib_without = build_jit_library(parse(without_r_source)) + g_with = next(gt for gt in lib_with.gadget_types if gt.base.name == "G") + g_without = next(gt for gt in lib_without.gadget_types if gt.base.name == "G") + # cp and pc should be identical; only lc differs. + cp_with = set(zip(g_with.base.correction_propagation.i, g_with.base.correction_propagation.j)) + cp_without = set(zip(g_without.base.correction_propagation.i, g_without.base.correction_propagation.j)) + pc_with = set(zip(g_with.base.physical_correction.i, g_with.base.physical_correction.j)) + pc_without = set(zip(g_without.base.physical_correction.i, g_without.base.physical_correction.j)) + assert cp_with == cp_without + assert pc_with == pc_without + # lc differs by exactly the R0 contribution. + lc_with = set(zip(g_with.base.logical_correction.i, g_with.base.logical_correction.j)) + lc_without = set(zip(g_without.base.logical_correction.i, g_without.base.logical_correction.j)) + assert lc_with ^ lc_without == {(1, 0)} + + # --------------------------------------------------------------------------- # COMPOSE CONDITIONAL — synthesizes identity gadget and folds into # composed logical_correction via merge(). From 6fa15c815218223874f4f142d6ae1f3e91c43f4c Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Fri, 10 Jul 2026 14:26:07 -0700 Subject: [PATCH 025/157] update proto binding --- deq/deq_runtime/src/proto/deq.bin.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/deq/deq_runtime/src/proto/deq.bin.rs b/deq/deq_runtime/src/proto/deq.bin.rs index 457dcd38..80f11d50 100644 --- a/deq/deq_runtime/src/proto/deq.bin.rs +++ b/deq/deq_runtime/src/proto/deq.bin.rs @@ -76,16 +76,12 @@ pub struct GadgetType { /// size = |output_observables| rows x |readouts| columns /// formerly named "conditional_correction" /// - /// NOTE: In the canonical / merged form produced by `canonical.merge()`, - /// this matrix is always empty. Conditional corrections from - /// `logical_correction` and from `GadgetModifier.remote_conditional_correction` - /// are absorbed into `correction_propagation` and `physical_correction` - /// (and into per-error `residual`) during the merge. The field remains - /// useful for: - /// - /// * per-gadget authoring (e.g. `CONDITIONAL R L

` in a GADGET); - /// * runtime feed-forward when the runtime applies a `GadgetModifier` - /// `remote_conditional_correction` to a gadget instance. + /// Populated by per-gadget authoring constructs (`CONDITIONAL R L

` inside a GADGET body, `PROPAGATE ... R` R-terms) and + /// by COMPOSE-level `CONDITIONAL rec\[-k\]` corrections (via + /// `GadgetModifier.remote_conditional_correction`). In the merged + /// form produced by `canonical.merge()` these entries are preserved + /// verbatim — the runtime evaluates the flip via + /// `residual ^= logical_correction · readouts`. #[prost(message, optional, tag = "10")] pub logical_correction: ::core::option::Option, /// mapping from internal measurements to output observable corrections From 748e8efb32f9da352f74ee666d6536414b5e8394 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 29 Jun 2026 10:31:34 -0700 Subject: [PATCH 026/157] fix pipeline failure (#96) Co-authored-by: Yue Wu --- .github/workflows/build.yaml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 8fe2b941..989affc0 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -239,7 +239,14 @@ jobs: cargo build --release -p deq-decoder-reference-plugin cargo test --workspace --exclude deq-runtime --all-features --release cargo test --package deq-runtime --features "cli simulator tesseract python dylib" --release - pytest deq/tests + # Run pytest from inside deq/ so that the implicit '' entry in + # sys.path resolves to a directory that contains the real + # deq/__init__.py. From the repo root, Python would otherwise + # pick up the project subdirectory `qdk-ec/deq/` as a namespace + # package named `deq`, shadowing the installed deq package and + # breaking `python -m deq.runtime` in subprocesses spawned by + # `deq simulate ler`. + (cd deq && pytest tests) - name: Run tests (Windows) if: runner.os == 'Windows' @@ -249,7 +256,8 @@ jobs: cargo build --release -p deq-decoder-reference-plugin cargo test --workspace --exclude deq-runtime --all-features --release cargo test --package deq-runtime --features "cli simulator tesseract python dylib" --release - pytest deq\tests + REM See the Linux/Mac step for why pytest must run from deq/. + pushd deq && pytest tests && popd - name: Check clippy (stable only) if: matrix.toolchain == 'stable' From 5a749ec1248ca4e48a6cc60d32a5c4985bc26c95 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:27:27 -0700 Subject: [PATCH 027/157] Cap grpcio-tools below 1.82 to fix protobuf gencode/runtime drift (#103) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- deq/pyproject.toml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/deq/pyproject.toml b/deq/pyproject.toml index c70ce36b..eec3531d 100644 --- a/deq/pyproject.toml +++ b/deq/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=64", "grpcio-tools>=1.76,<2"] +requires = ["setuptools>=64", "grpcio-tools>=1.76,<1.82"] build-backend = "setuptools.build_meta" [project] @@ -26,12 +26,15 @@ dependencies = [ "arguably>=1.0,<2.0", "tqdm", # protobuf runtime must be >= the gencode version produced by grpcio-tools - # at build time. grpcio-tools 1.76+ emits gencode 6.33.5; pin the runtime - # accordingly so editable installs don't end up with an older protobuf - # alongside newer gencode. See https://protobuf.dev/support/cross-version-runtime-guarantee. + # at build time. grpcio-tools 1.76-1.81 emits gencode 6.33.x; pin the + # runtime accordingly so editable installs don't end up with an older + # protobuf alongside newer gencode. grpcio-tools 1.82 bundles a protoc that + # emits gencode 7.x, which is incompatible with the protobuf<7 runtime, so + # cap grpcio-tools below 1.82 until the runtime is bumped to protobuf 7.x. + # See https://protobuf.dev/support/cross-version-runtime-guarantee. "protobuf>=6.33.5,<7", "grpcio", - "grpcio-tools>=1.76,<2", + "grpcio-tools>=1.76,<1.82", "anywidget", "lark", "mako", From 52513c63028c222ec3d64890bd5bea4f912d5932 Mon Sep 17 00:00:00 2001 From: "Juan M. Bello-Rivas" Date: Tue, 7 Jul 2026 10:26:54 -0700 Subject: [PATCH 028/157] pauliverse/paulimer: expose FramePropagator to Python, add reset_qubit, fix swapped Clifford gates (#100) Co-authored-by: jbellorivas Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- paulimer/bindings/python/paulimer.pyi | 96 +++++ paulimer/bindings/python/src/lib.rs | 3 + .../python/src/py_frame_propagator.rs | 193 ++++++++++ .../python/tests/test_frame_propagator.py | 85 +++++ pauliverse/src/frame_propagator.rs | 356 +++++++++++++++++- pauliverse/src/lib.rs | 1 + 6 files changed, 720 insertions(+), 14 deletions(-) create mode 100644 paulimer/bindings/python/src/py_frame_propagator.rs create mode 100644 paulimer/bindings/python/tests/test_frame_propagator.py diff --git a/paulimer/bindings/python/paulimer.pyi b/paulimer/bindings/python/paulimer.pyi index cf797f81..e78b8f7d 100644 --- a/paulimer/bindings/python/paulimer.pyi +++ b/paulimer/bindings/python/paulimer.pyi @@ -19,6 +19,7 @@ __all__ = [ "CliffordUnitary", "DensePauli", "FaultySimulation", + "FramePropagator", "OutcomeCompleteSimulation", "OutcomeCondition", "OutcomeFreeSimulation", @@ -1652,3 +1653,98 @@ class FaultySimulation: ... def __repr__(self) -> str: ... + +@final +class FramePropagator: + """Heisenberg-picture batched Pauli error frame propagator. + + Tracks Pauli errors injected at arbitrary points in a circuit across many + shots in parallel. Internally maintains ``(qubit_count x shot_count)`` X + and Z bit matrices and an ``(outcome_count x shot_count)`` matrix of + outcome deltas (per-shot XOR against the noiseless trajectory). + + Workflow: + 1. Construct with the qubit, outcome and shot capacities of your + circuit. + 2. Walk the circuit: at the desired locations call + :meth:`inject_pauli` to inject a fault for one shot. + 3. Apply gates via the simulation-style methods and record + measurements via :meth:`measure`. + 4. Read :attr:`outcome_deltas` to get the per-shot outcome flips + relative to the noiseless trajectory. + + Pauli gates are no-ops in frame propagation (they commute through Pauli + errors up to a phase). The ``parity`` argument of + :meth:`apply_conditional_pauli` is accepted for API compatibility but + ignored: only the *delta* of the condition matters when propagating + error frames. + """ + + def __new__(cls, qubit_count: int, outcome_count: int, shot_count: int) -> "FramePropagator": + """Create a new propagator. + + Args: + qubit_count: Number of qubits to track. + outcome_count: Number of measurement outcomes the circuit will produce. + shot_count: Number of independent shots to run in parallel. + """ + ... + + @property + def qubit_count(self) -> int: ... + @property + def outcome_count(self) -> int: ... + @property + def shot_count(self) -> int: ... + + def apply_unitary(self, opcode: UnitaryOpcode, qubits: Sequence[int]) -> None: ... + def apply_clifford( + self, clifford: CliffordUnitary, qubits: Sequence[int] | None = None + ) -> None: ... + def apply_pauli( + self, pauli: SparsePauli, controlled_by: SparsePauli | None = None + ) -> None: ... + def apply_pauli_exp(self, pauli: SparsePauli) -> None: ... + def apply_permutation( + self, permutation: Sequence[int], qubits: Sequence[int] | None = None + ) -> None: ... + def apply_conditional_pauli( + self, pauli: SparsePauli, outcomes: Sequence[int], parity: bool = True + ) -> None: ... + def measure(self, observable: SparsePauli, hint: SparsePauli | None = None) -> int: ... + def allocate_random_bit(self) -> int: ... + + def inject_pauli(self, shot: int, pauli: SparsePauli) -> None: + """Inject a Pauli error into the frame for a specific shot. + + Args: + shot: Index of the shot, ``0 <= shot < shot_count``. + pauli: Sparse Pauli error to XOR into the frame. + + Raises: + IndexError: if ``shot`` is out of range, or ``pauli`` acts on a + qubit beyond ``qubit_count``. + """ + ... + + def reset_qubit(self, qubit: int) -> None: + """Reset a qubit, clearing its accumulated error frame across all shots. + + Args: + qubit: Index of the qubit, ``0 <= qubit < qubit_count``. + + Raises: + IndexError: if ``qubit`` is out of range. + """ + ... + + @property + def outcome_deltas(self) -> BitMatrix: + """Outcome delta matrix of shape ``(outcome_count, shot_count)``. + + Bit ``(o, s)`` is true iff outcome ``o`` in shot ``s`` differs from + the noiseless trajectory. + """ + ... + + def __repr__(self) -> str: ... diff --git a/paulimer/bindings/python/src/lib.rs b/paulimer/bindings/python/src/lib.rs index a92d608f..394a0723 100644 --- a/paulimer/bindings/python/src/lib.rs +++ b/paulimer/bindings/python/src/lib.rs @@ -5,6 +5,7 @@ mod format_spec; mod py_clifford; mod py_dense_pauli; mod py_faulty_simulation; +mod py_frame_propagator; mod py_noise; mod py_pauli_group; mod py_sparse_pauli; @@ -17,6 +18,7 @@ pub use py_clifford::{ }; pub use py_dense_pauli::PyDensePauli; pub use py_faulty_simulation::PyFaultySimulation; +pub use py_frame_propagator::PyFramePropagator; pub use py_noise::{PyFault, PyOutcomeCondition, PyPauliDistribution}; pub use py_pauli_group::{py_centralizer_of, py_symplectic_form_of, PyPauliGroup}; pub use py_sparse_pauli::PySparsePauli; @@ -36,6 +38,7 @@ pub fn paulimer(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/paulimer/bindings/python/src/py_frame_propagator.rs b/paulimer/bindings/python/src/py_frame_propagator.rs new file mode 100644 index 00000000..b91c939b --- /dev/null +++ b/paulimer/bindings/python/src/py_frame_propagator.rs @@ -0,0 +1,193 @@ +#![allow(clippy::must_use_candidate)] +#![allow(clippy::doc_markdown)] +#![allow(clippy::needless_pass_by_value)] + +use crate::{PyCliffordUnitary, PySparsePauli, PyUnitaryOp}; +use binar::BitMatrix; +use paulimer::clifford::Clifford; +use paulimer::pauli::Pauli; +use pauliverse::{FramePropagator, Simulation}; +use pyo3::exceptions::PyIndexError; +use pyo3::prelude::*; + +/// Heisenberg-picture batched Pauli error frame propagator. +/// +/// Tracks Pauli errors injected at arbitrary points in a circuit across many +/// shots in parallel using `(qubit_count × shot_count)` X/Z bit matrices and +/// an `(outcome_count × shot_count)` matrix of outcome deltas (per-shot XOR +/// against the noiseless trajectory). +/// +/// Typical workflow: +/// +/// 1. Construct with the qubit/outcome/shot capacities of your circuit. +/// 2. Walk the circuit; at the desired locations call `inject_pauli` to inject +/// the fault for one shot. +/// 3. Apply gates via `apply_unitary` / `apply_clifford` / etc. and record +/// measurements via `measure`. +/// 4. Read `outcome_deltas` to get the per-shot outcome flips relative to the +/// noiseless trajectory. +/// +/// Pauli gates are no-ops in frame propagation (they commute through Pauli +/// errors up to a phase). The `parity` argument of `apply_conditional_pauli` +/// is accepted for API compatibility but ignored: only the delta of the +/// condition matters when propagating error frames. +#[pyclass(name = "FramePropagator", unsendable)] +pub struct PyFramePropagator { + inner: FramePropagator, +} + +#[pymethods] +impl PyFramePropagator { + /// Create a new propagator. + /// + /// Args: + /// qubit_count: Number of qubits to track. + /// outcome_count: Number of measurement outcomes the circuit will produce. + /// shot_count: Number of independent shots to run in parallel. + #[new] + #[pyo3(signature = (qubit_count, outcome_count, shot_count))] + pub fn new(qubit_count: usize, outcome_count: usize, shot_count: usize) -> Self { + PyFramePropagator { + inner: FramePropagator::new(qubit_count, outcome_count, shot_count), + } + } + + #[getter] + pub fn qubit_count(&self) -> usize { + Simulation::qubit_count(&self.inner) + } + + #[getter] + pub fn outcome_count(&self) -> usize { + Simulation::outcome_count(&self.inner) + } + + #[getter] + pub fn shot_count(&self) -> usize { + self.inner.shot_count() + } + + /// Apply a unitary gate. + pub fn apply_unitary(&mut self, opcode: &PyUnitaryOp, qubits: Vec) { + self.inner.unitary_op(opcode.clone().into(), &qubits); + } + + /// Apply a Clifford unitary. + #[pyo3(signature = (clifford, qubits=None))] + pub fn apply_clifford(&mut self, clifford: &PyCliffordUnitary, qubits: Option>) { + let qubits = qubits.unwrap_or_else(|| (0..clifford.inner.num_qubits()).collect()); + self.inner.clifford(&clifford.inner, &qubits); + } + + /// Apply a Pauli gate (no-op for frame propagation). + #[pyo3(signature = (pauli, controlled_by=None))] + pub fn apply_pauli(&mut self, pauli: &PySparsePauli, controlled_by: Option<&PySparsePauli>) { + if let Some(control) = controlled_by { + self.inner.controlled_pauli(&control.inner, &pauli.inner); + } else { + self.inner.pauli(&pauli.inner); + } + } + + /// Apply a Pauli exponential (e^{-iπ/4 P}). + pub fn apply_pauli_exp(&mut self, pauli: &PySparsePauli) { + self.inner.pauli_exp(&pauli.inner); + } + + /// Apply a permutation. + #[pyo3(signature = (permutation, qubits=None))] + pub fn apply_permutation(&mut self, permutation: Vec, qubits: Option>) { + let qubits = qubits.unwrap_or_else(|| (0..permutation.len()).collect()); + self.inner.permute(&permutation, &qubits); + } + + /// Apply a conditional Pauli based on previous outcome ids. + /// + /// `parity` is accepted for API compatibility but ignored: in frame + /// propagation, only the delta of the condition matters. + #[pyo3(signature = (pauli, outcomes, parity=true))] + pub fn apply_conditional_pauli(&mut self, pauli: &PySparsePauli, outcomes: Vec, parity: bool) { + self.inner.conditional_pauli(&pauli.inner, &outcomes, parity); + } + + /// Record a measurement of `observable` and return the assigned outcome id. + /// + /// `hint` is accepted for API compatibility with other simulation backends + /// and ignored. + #[pyo3(signature = (observable, hint=None))] + pub fn measure(&mut self, observable: &PySparsePauli, hint: Option<&PySparsePauli>) -> usize { + let _ = hint; + Simulation::measure(&mut self.inner, &observable.inner) + } + + /// Allocate a random measurement outcome (no anti-commutation update). + pub fn allocate_random_bit(&mut self) -> usize { + Simulation::allocate_random_bit(&mut self.inner) + } + + /// Inject a Pauli error into a specific shot at the current circuit position. + /// + /// Raises: + /// IndexError: if `shot` is out of range, or `pauli` acts on a qubit + /// beyond `qubit_count`. + /// + /// # Errors + /// + /// Returns a Python `IndexError` if `shot >= shot_count` or `pauli` acts on + /// a qubit index `>= qubit_count`. + pub fn inject_pauli(&mut self, shot: usize, pauli: &PySparsePauli) -> PyResult<()> { + let shot_count = self.inner.shot_count(); + if shot >= shot_count { + return Err(PyIndexError::new_err(format!( + "shot {shot} out of range (shot_count = {shot_count})" + ))); + } + let qubit_count = Simulation::qubit_count(&self.inner); + if let Some(max_qubit) = pauli.inner.max_support() { + if max_qubit >= qubit_count { + return Err(PyIndexError::new_err(format!( + "pauli acts on qubit {max_qubit} out of range (qubit_count = {qubit_count})" + ))); + } + } + self.inner.inject_pauli(shot, &pauli.inner); + Ok(()) + } + + /// Reset a qubit, clearing its accumulated error frame across all shots. + /// + /// Raises: + /// IndexError: if `qubit` is out of range. + /// + /// # Errors + /// + /// Returns a Python `IndexError` if `qubit >= qubit_count`. + pub fn reset_qubit(&mut self, qubit: usize) -> PyResult<()> { + let qubit_count = Simulation::qubit_count(&self.inner); + if qubit >= qubit_count { + return Err(PyIndexError::new_err(format!( + "qubit {qubit} out of range (qubit_count = {qubit_count})" + ))); + } + self.inner.reset_qubit(qubit); + Ok(()) + } + + /// Outcome delta matrix: `(outcome_count × shot_count)` row-major bits. + /// + /// Bit `(o, s)` is true iff outcome `o` in shot `s` differs from the + /// noiseless trajectory. + #[getter] + pub fn outcome_deltas(&self) -> BitMatrix { + self.inner.outcome_deltas().clone().into() + } + + fn __repr__(&self) -> String { + format!( + "FramePropagator(qubit_count={}, outcome_count={}, shot_count={})", + Simulation::qubit_count(&self.inner), + Simulation::outcome_count(&self.inner), + self.inner.shot_count(), + ) + } +} diff --git a/paulimer/bindings/python/tests/test_frame_propagator.py b/paulimer/bindings/python/tests/test_frame_propagator.py new file mode 100644 index 00000000..6c6a62b9 --- /dev/null +++ b/paulimer/bindings/python/tests/test_frame_propagator.py @@ -0,0 +1,85 @@ +"""Tests for the FramePropagator Python binding. + +Covers construction/getters, per-shot injection, reset_qubit semantics, +measure-and-reset ordering, out-of-range error handling, and a regression +guard that the S gate propagates errors correctly through the binding. +""" + +import pytest +from binar import BitMatrix +from paulimer import FramePropagator, SparsePauli, UnitaryOpcode + + +class TestBasics: + def test_getters(self): + fp = FramePropagator(3, 5, 7) + assert fp.qubit_count == 3 + # outcome_count grows as measurements are recorded; starts at 0. + assert fp.outcome_count == 0 + assert fp.shot_count == 7 + + def test_outcome_deltas_is_bitmatrix(self): + fp = FramePropagator(1, 1, 1) + fp.measure(SparsePauli("Z")) + assert isinstance(fp.outcome_deltas, BitMatrix) + + +class TestInjectionAndMeasurement: + def test_per_shot_injection_is_independent(self): + # Shot 0: X on control spreads through CNOT to flip both Z measurements. + # Shot 1: Z on target commutes with both Z measurements -> no flips. + fp = FramePropagator(2, 2, 2) + fp.inject_pauli(0, SparsePauli("XI")) + fp.inject_pauli(1, SparsePauli("IZ")) + fp.apply_unitary(UnitaryOpcode.ControlledX, [0, 1]) + fp.measure(SparsePauli("ZI")) + fp.measure(SparsePauli("IZ")) + d = fp.outcome_deltas + assert d[0, 0] and d[1, 0] + assert not d[0, 1] and not d[1, 1] + + def test_s_gate_maps_x_error_to_y(self): + # Regression for the apply_s/apply_sqrt_x swap: S(X) = Y, so a Z + # measurement (anticommuting with the X part of Y) must flip. + fp = FramePropagator(1, 1, 1) + fp.inject_pauli(0, SparsePauli("X")) + fp.apply_unitary(UnitaryOpcode.SqrtZ, [0]) + fp.measure(SparsePauli("Z")) + assert fp.outcome_deltas[0, 0] + + +class TestReset: + def test_reset_clears_frame(self): + fp = FramePropagator(1, 1, 1) + fp.inject_pauli(0, SparsePauli("Z")) + fp.reset_qubit(0) + fp.apply_unitary(UnitaryOpcode.Hadamard, [0]) + fp.measure(SparsePauli("Z")) + assert not fp.outcome_deltas[0, 0] + + def test_measure_then_reset_records_delta_before_clearing(self): + fp = FramePropagator(1, 2, 1) + fp.inject_pauli(0, SparsePauli("X")) + fp.measure(SparsePauli("Z")) # pre-reset: X flips Z + fp.reset_qubit(0) + fp.apply_unitary(UnitaryOpcode.Hadamard, [0]) + fp.measure(SparsePauli("Z")) # post-reset: clean + d = fp.outcome_deltas + assert d[0, 0] and not d[1, 0] + + +class TestBounds: + def test_reset_qubit_out_of_range(self): + fp = FramePropagator(2, 1, 2) + with pytest.raises(IndexError): + fp.reset_qubit(2) + + def test_inject_shot_out_of_range(self): + fp = FramePropagator(2, 1, 2) + with pytest.raises(IndexError): + fp.inject_pauli(2, SparsePauli("XI")) + + def test_inject_qubit_out_of_range(self): + fp = FramePropagator(2, 1, 2) + with pytest.raises(IndexError): + fp.inject_pauli(0, SparsePauli("IIX")) # X on qubit 2 diff --git a/pauliverse/src/frame_propagator.rs b/pauliverse/src/frame_propagator.rs index 618436b9..7470f3d3 100644 --- a/pauliverse/src/frame_propagator.rs +++ b/pauliverse/src/frame_propagator.rs @@ -20,10 +20,11 @@ use binar::matrix::AlignedBitMatrix; use binar::vec::AlignedBitVec; -use binar::{BitMatrix, Bitwise, BitwisePairMut}; +use binar::{BitMatrix, Bitwise, BitwiseMut, BitwisePairMut}; use paulimer::UnitaryOp; use paulimer::clifford::CliffordUnitary; use paulimer::pauli::Pauli; +use paulimer::pauli::SparsePauli; use rand::rngs::SmallRng; use rand::{RngExt, SeedableRng}; @@ -35,7 +36,7 @@ use crate::sampling::GeometricSampler; /// /// Tracks accumulated Pauli errors across all shots as two bit matrices /// (X and Z components), propagating them through Clifford gates via conjugation. -pub(crate) struct FramePropagator { +pub struct FramePropagator { x_frames: AlignedBitMatrix, z_frames: AlignedBitMatrix, outcome_deltas: AlignedBitMatrix, @@ -66,6 +67,24 @@ impl FramePropagator { self.outcome_deltas } + /// Borrow the current outcome deltas matrix without consuming the propagator. + /// + /// Layout: `(n_outcomes × n_shots)` - each row is the error delta for one outcome. + pub fn outcome_deltas(&self) -> &AlignedBitMatrix { + &self.outcome_deltas + } + + /// Number of shots tracked in parallel. + #[must_use] + pub fn shot_count(&self) -> usize { + self.shot_count + } + + /// Number of qubits tracked in the error frame. + fn qubit_count(&self) -> usize { + self.x_frames.shape().0 + } + // ========== Anti-commutation ========== /// Compute which shots have frames that anti-commute with the given Pauli. @@ -125,8 +144,8 @@ impl FramePropagator { /// /// Note: S† has the same effect on Pauli frames (we track mod phase). pub fn apply_s(&mut self, qubit: QubitId) { - let z_row = self.z_frames.row(qubit); - self.x_frames.row_mut(qubit).bitxor_assign(&z_row); + let x_row = self.x_frames.row(qubit); + self.z_frames.row_mut(qubit).bitxor_assign(&x_row); } /// Apply CNOT(control, target): X_c → X_c X_t, Z_t → Z_c Z_t @@ -143,11 +162,11 @@ impl FramePropagator { /// Apply CZ(a, b): X_a → X_a Z_b, X_b → Z_a X_b, Z unchanged pub fn apply_cz(&mut self, qubit_a: QubitId, qubit_b: QubitId) { - let z_b = self.z_frames.row(qubit_b); - self.x_frames.row_mut(qubit_a).bitxor_assign(&z_b); + let x_a = self.x_frames.row(qubit_a); + self.z_frames.row_mut(qubit_b).bitxor_assign(&x_a); - let z_a = self.z_frames.row(qubit_a); - self.x_frames.row_mut(qubit_b).bitxor_assign(&z_a); + let x_b = self.x_frames.row(qubit_b); + self.z_frames.row_mut(qubit_a).bitxor_assign(&x_b); } /// Apply SWAP(a, b): swap both X and Z rows @@ -159,9 +178,12 @@ impl FramePropagator { /// Apply √X on qubit q: Z → -Y = ZX, X → X /// /// Note: √X† has the same effect on Pauli frames (we track mod phase). + /// Apply √X on qubit q: Z → Y = iXZ, so Z → XZ (mod phase), X → X + /// + /// Note: √X† has the same effect on Pauli frames (we track mod phase). pub fn apply_sqrt_x(&mut self, qubit: QubitId) { - let x_row = self.x_frames.row(qubit); - self.z_frames.row_mut(qubit).bitxor_assign(&x_row); + let z_row = self.z_frames.row(qubit); + self.x_frames.row_mut(qubit).bitxor_assign(&z_row); } /// Apply a `UnitaryOp` to the frames. @@ -313,7 +335,9 @@ impl FramePropagator { /// /// Computes the anti-commutation of the current frame with the observable /// and XORs the result into the outcome delta for this measurement. - pub fn measure(&mut self, observable: &P) + /// + /// Returns the newly assigned outcome id. + pub fn measure(&mut self, observable: &P) -> OutcomeId where P::Bits: Bitwise, { @@ -322,6 +346,7 @@ impl FramePropagator { let anticommutes = self.anticommutation_mask(observable); self.outcome_deltas.row_mut(outcome_id).bitxor_assign(&anticommutes); + outcome_id } /// Apply a conditional Pauli gate based on outcome parity. @@ -355,9 +380,11 @@ impl FramePropagator { /// Advance the outcome counter without computing anti-commutation. /// - /// Used for `AllocateRandomBit` instructions. - pub fn skip_outcome(&mut self) { + /// Used for `AllocateRandomBit` instructions. Returns the assigned outcome id. + pub fn skip_outcome(&mut self) -> OutcomeId { + let outcome_id = self.next_outcome_id; self.next_outcome_id += 1; + outcome_id } // ========== Noise Injection ========== @@ -477,7 +504,7 @@ impl FramePropagator { /// Callers must ensure: /// - `shot < self.shot_count` /// - All qubits in `pauli.support()` are less than `self.qubit_count()` - fn apply_pauli_to_shot(&mut self, shot: usize, pauli: &paulimer::pauli::SparsePauli) { + fn apply_pauli_to_shot(&mut self, shot: usize, pauli: &SparsePauli) { use paulimer::pauli::Pauli as PauliTrait; for qubit in pauli.support() { @@ -490,6 +517,46 @@ impl FramePropagator { } } + /// Deterministically inject a Pauli into one shot's frame. + /// + /// Unlike [`Self::inject_noise`], this applies exactly `pauli` to `shot` + /// with no sampling, so per-shot injection can place a distinct chosen + /// Pauli in each shot. Combined with [`Self::into_outcome_deltas`], this + /// turns a single circuit pass over `n` shots into the effect of `n` + /// independent faults. + /// + /// # Panics + /// + /// Panics if `shot >= self.shot_count` or a qubit in `pauli.support()` is + /// out of range. These bounds are always checked, making this the safe + /// public entry point over the unchecked [`Self::apply_pauli_to_shot`]. + pub fn inject_pauli(&mut self, shot: usize, pauli: &SparsePauli) { + let qubit_count = self.qubit_count(); + assert!(shot < self.shot_count, "shot out of range"); + for qubit in pauli.support() { + assert!(qubit < qubit_count, "fault qubit {qubit} out of range 0..{qubit_count}"); + } + self.apply_pauli_to_shot(shot, pauli); + } + + /// Reset a qubit to a fresh stabilizer state, clearing its error frame. + /// + /// A reset discards the qubit's prior state and reprepares it, so in the + /// error-frame (delta-from-reference) picture the accumulated Pauli error + /// on that qubit becomes identity across all shots. Both the X and Z frame + /// components on `qubit` are zeroed. Any preceding measurement (as in + /// measure-and-reset) must be applied via [`Self::measure`] before this + /// call so its outcome delta is recorded from the pre-reset frame. + /// + /// # Panics + /// + /// Panics if `qubit` is out of range. + pub fn reset_qubit(&mut self, qubit: QubitId) { + debug_assert!(qubit < self.qubit_count(), "reset qubit out of range"); + self.x_frames.row_mut(qubit).clear_bits(); + self.z_frames.row_mut(qubit).clear_bits(); + } + // ========== Instruction Dispatch ========== /// Execute a single instruction, propagating its effect through the frames. @@ -536,6 +603,148 @@ impl FramePropagator { } } } + + // ========== Capacity management (Simulation auto-growth) ========== + + /// Grow the X/Z frame matrices so at least `qubit_count` qubits are tracked. + fn grow_qubits(&mut self, qubit_count: usize) { + if qubit_count > self.x_frames.row_count() { + self.x_frames.resize(qubit_count, self.shot_count); + self.z_frames.resize(qubit_count, self.shot_count); + } + } + + fn ensure_qubit_capacity_for(&mut self, support: &[QubitId]) { + let max_qubit = support.iter().copied().max().map_or(0, |q| q + 1); + self.grow_qubits(max_qubit); + } + + fn ensure_qubit_capacity_for_pauli(&mut self, pauli: &P) + where + P::Bits: Bitwise, + { + let Some(max_index) = pauli.max_support() else { return }; + self.grow_qubits(max_index + 1); + } + + fn ensure_outcome_capacity(&mut self) { + if self.next_outcome_id >= self.outcome_deltas.row_count() { + let new_capacity = (self.outcome_deltas.row_count() * 2) + .max(self.next_outcome_id + 1) + .max(8); + self.outcome_deltas.resize(new_capacity, self.shot_count); + } + } +} + +impl Default for FramePropagator { + fn default() -> Self { + Self::new(0, 0, 1) + } +} + +impl crate::Simulation for FramePropagator { + fn allocate_random_bit(&mut self) -> OutcomeId { + self.ensure_outcome_capacity(); + self.skip_outcome() + } + + fn clifford(&mut self, clifford: &CliffordUnitary, support: &[QubitId]) { + self.ensure_qubit_capacity_for(support); + self.apply_clifford(clifford, support); + } + + fn conditional_pauli(&mut self, observable: &SparsePauli, outcomes: &[OutcomeId], _parity: bool) { + self.ensure_qubit_capacity_for_pauli(observable); + self.apply_conditional_pauli(observable, outcomes); + } + + fn controlled_pauli(&mut self, observable1: &SparsePauli, observable2: &SparsePauli) { + self.ensure_qubit_capacity_for_pauli(observable1); + self.ensure_qubit_capacity_for_pauli(observable2); + self.apply_controlled_pauli(observable1, observable2); + } + + fn pauli(&mut self, _observable: &SparsePauli) { + // Pauli gates commute with all Pauli frames (mod phase): no-op. + } + + fn pauli_exp(&mut self, sparse_pauli: &SparsePauli) { + self.ensure_qubit_capacity_for_pauli(sparse_pauli); + self.apply_pauli_exp(sparse_pauli); + } + + fn permute(&mut self, permutation: &[usize], support: &[QubitId]) { + self.ensure_qubit_capacity_for(support); + self.apply_permutation(permutation, support); + } + + fn unitary_op(&mut self, operation: UnitaryOp, support: &[QubitId]) { + self.ensure_qubit_capacity_for(support); + self.apply_unitary_op(operation, support); + } + + fn measure(&mut self, observable: &SparsePauli) -> OutcomeId { + self.ensure_qubit_capacity_for_pauli(observable); + self.ensure_outcome_capacity(); + FramePropagator::measure(self, observable) + } + + fn measure_with_hint(&mut self, observable: &SparsePauli, _hint: &SparsePauli) -> OutcomeId { + crate::Simulation::measure(self, observable) + } + + // FramePropagator tracks Pauli error frames (deltas from a reference + // trajectory), not the stabilizer group of a state, so it cannot answer + // stabilizer queries. Mirror `CircuitBuilder`'s builder-style stubs; these + // are never exercised on the frame-propagation path (driven via `execute` + // and the Python binding's gate/measure methods). + fn is_stabilizer(&self, _observable: &SparsePauli) -> bool { + true + } + + fn is_stabilizer_up_to_sign(&self, _observable: &SparsePauli) -> bool { + true + } + + fn is_stabilizer_with_conditional_sign(&self, _observable: &SparsePauli, _outcomes: &[OutcomeId]) -> bool { + true + } + + fn qubit_count(&self) -> usize { + self.x_frames.row_count() + } + + fn outcome_count(&self) -> usize { + self.next_outcome_id + } + + fn with_capacity(qubit_count: usize, outcome_count: usize, _random_outcome_count: usize) -> Self { + Self::new(qubit_count, outcome_count, 1) + } + + fn qubit_capacity(&self) -> usize { + self.x_frames.row_count() + } + + fn reserve_qubits(&mut self, new_qubit_capacity: usize) { + self.grow_qubits(new_qubit_capacity); + } + + fn outcome_capacity(&self) -> usize { + self.outcome_deltas.row_count() + } + + fn random_outcome_capacity(&self) -> usize { + self.outcome_deltas.row_count() + } + + fn reserve_outcomes(&mut self, new_outcome_capacity: usize, new_random_outcome_capacity: usize) { + let new_capacity = new_outcome_capacity.max(new_random_outcome_capacity); + if new_capacity > self.outcome_capacity() { + self.outcome_deltas.resize(new_capacity, self.shot_count); + } + } } #[cfg(test)] @@ -551,6 +760,73 @@ mod tests { use smallvec::smallvec; use std::str::FromStr; + /// Every `UnitaryOp` must transform error frames identically to paulimer's + /// independently-implemented `CliffordUnitary` tableau (via `apply_clifford`). + /// + /// For each gate we compare the fast-path `apply_unitary_op` against the + /// tableau on every Pauli generator (`X_i`, `Z_i`) of its support. The + /// generators fully determine a Clifford's action, so this exhaustively + /// guards every gate method and the `UnitaryOp` dispatch against + /// conjugation errors (e.g. the S/√X swap). Comparison is bit-level, i.e. + /// up to the global phase that both paths ignore. + fn assert_unitary_op_matches_clifford(op: UnitaryOp, qubit_count: usize) { + let support: Vec = (0..qubit_count).collect(); + let mut reference = CliffordUnitary::identity(qubit_count); + reference.left_mul(op, &support); + + for generator_qubit in 0..qubit_count { + for &(x, z) in &[(true, false), (false, true)] { + let mut propagator = FramePropagator::new(qubit_count, 0, 1); + propagator.x_frames.set((generator_qubit, 0), x); + propagator.z_frames.set((generator_qubit, 0), z); + propagator.apply_unitary_op(op, &support); + + let mut reference_propagator = FramePropagator::new(qubit_count, 0, 1); + reference_propagator.x_frames.set((generator_qubit, 0), x); + reference_propagator.z_frames.set((generator_qubit, 0), z); + reference_propagator.apply_clifford(&reference, &support); + + for qubit in 0..qubit_count { + assert_eq!( + (propagator.x_frames.get((qubit, 0)), propagator.z_frames.get((qubit, 0))), + ( + reference_propagator.x_frames.get((qubit, 0)), + reference_propagator.z_frames.get((qubit, 0)) + ), + "{op:?}: generator (x={x}, z={z}) on qubit {generator_qubit} gives wrong frame on qubit {qubit}", + ); + } + } + } + } + + #[test] + fn test_all_unitary_ops_match_clifford_tableau() { + for op in [ + UnitaryOp::I, + UnitaryOp::X, + UnitaryOp::Y, + UnitaryOp::Z, + UnitaryOp::SqrtX, + UnitaryOp::SqrtXInv, + UnitaryOp::SqrtY, + UnitaryOp::SqrtYInv, + UnitaryOp::SqrtZ, + UnitaryOp::SqrtZInv, + UnitaryOp::Hadamard, + ] { + assert_unitary_op_matches_clifford(op, 1); + } + for op in [ + UnitaryOp::Swap, + UnitaryOp::ControlledX, + UnitaryOp::ControlledZ, + UnitaryOp::PrepareBell, + ] { + assert_unitary_op_matches_clifford(op, 2); + } + } + #[test] fn test_cnot_propagation() { let mut propagator = FramePropagator::new(2, 0, 64); @@ -562,6 +838,58 @@ mod tests { assert!(propagator.x_frames.get((1, 0)), "X propagated to qubit 1"); } + #[test] + fn test_batched_single_fault_effects_via_one_pass() { + // Circuit: CNOT(0 -> 1), then measure Z0, Z1. Two faults, one per shot: + // shot 0 = X on control before the CNOT; shot 1 = Z on the target. + let mut propagator = FramePropagator::new(2, 2, 2); + + propagator.inject_pauli(0, &SparsePauli::from_str("X0").unwrap()); + propagator.inject_pauli(1, &SparsePauli::from_str("Z1").unwrap()); + + propagator.apply_cnot(0, 1); + propagator.measure(&SparsePauli::from_str("Z0").unwrap()); + propagator.measure(&SparsePauli::from_str("Z1").unwrap()); + + let deltas = propagator.into_outcome_deltas(); + + // Shot 0: X on control copies to target -> both Z measurements flip. + assert!(deltas.get((0, 0)), "X0 flips Z0"); + assert!(deltas.get((1, 0)), "X0 propagates to flip Z1"); + + // Shot 1: Z on target commutes with both Z measurements -> no flip. + assert!(!deltas.get((0, 1)), "Z1 leaves Z0"); + assert!(!deltas.get((1, 1)), "Z1 commutes with Z1"); + } + + #[test] + fn test_reset_clears_frame_so_z_error_does_not_survive() { + let mut propagator = FramePropagator::new(1, 1, 1); + propagator.inject_pauli(0, &SparsePauli::from_str("Z0").unwrap()); + + propagator.reset_qubit(0); + propagator.apply_h(0); + propagator.measure(&SparsePauli::from_str("Z0").unwrap()); + + let deltas = propagator.into_outcome_deltas(); + assert!(!deltas.get((0, 0)), "Z error before reset must not survive"); + } + + #[test] + fn test_measure_reset_records_delta_before_clearing() { + let mut propagator = FramePropagator::new(1, 2, 1); + propagator.inject_pauli(0, &SparsePauli::from_str("X0").unwrap()); + + propagator.measure(&SparsePauli::from_str("Z0").unwrap()); + propagator.reset_qubit(0); + propagator.apply_h(0); + propagator.measure(&SparsePauli::from_str("Z0").unwrap()); + + let deltas = propagator.into_outcome_deltas(); + assert!(deltas.get((0, 0)), "X error flips the pre-reset measurement"); + assert!(!deltas.get((1, 0)), "post-reset measurement is clean"); + } + #[test] fn test_hadamard_x_to_z() { let mut propagator = FramePropagator::new(1, 0, 64); diff --git a/pauliverse/src/lib.rs b/pauliverse/src/lib.rs index b2359ce3..ec4f2274 100644 --- a/pauliverse/src/lib.rs +++ b/pauliverse/src/lib.rs @@ -103,6 +103,7 @@ pub(crate) mod statistical_testing; pub use circuit::{Circuit, CircuitBuilder, OutcomeId, QubitId}; pub use faulty_simulation::FaultySimulation; +pub use frame_propagator::FramePropagator; pub use noise::{OutcomeCondition, PauliDistribution, PauliFault}; pub use outcome_complete_simulation::OutcomeCompleteSimulation; pub use outcome_free_simulation::OutcomeFreeSimulation; From fdbaf044e5cc2ab546cbe035ab91ae348e818e6e Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Wed, 8 Jul 2026 21:06:01 -0700 Subject: [PATCH 029/157] Basic Loss Support (#99) Co-authored-by: Yue Wu Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> --- deq/deq/circuit/transformer.py | 5 +- deq/deq/cli/jit.py | 4 +- deq/deq/cli/simulate.py | 16 +- deq/deq/noise/common.py | 4 +- deq/deq/noise/strip.py | 4 +- deq/deq/transpiler/jit_annotate.py | 17 +- deq/deq/transpiler/jit_library_builder.py | 12 +- deq/deq/transpiler/jit_noise_builder.py | 18 +- deq/deq/transpiler/jit_transpiler.py | 4 +- deq/deq/transpiler/stim_constants.py | 48 +++ .../src/controller/static_controller.rs | 76 +++- deq/deq_runtime/src/coordinator.rs | 134 ++++++ .../src/coordinator/monolithic_coordinator.rs | 40 +- .../src/coordinator/window_coordinator.rs | 38 +- deq/deq_runtime/src/decoder/python_decoder.rs | 125 +++--- .../src/decoder/relay_bp_decoder.py | 15 +- .../src/decoder/tesseract_decoder.py | 15 +- deq/deq_runtime/src/misc/mod.rs | 2 + deq/deq_runtime/src/misc/python.rs | 127 ++++++ .../proto/deq.controller.static_controller.rs | 53 ++- deq/deq_runtime/src/proto/deq.coordinator.rs | 9 + deq/deq_runtime/src/proto/deq.simulator.rs | 11 + deq/deq_runtime/src/simulator.rs | 25 ++ deq/deq_runtime/src/simulator/common.rs | 41 ++ .../src/simulator/jit_static_simulator.rs | 1 + .../src/simulator/preselect_simulator.rs | 36 +- .../src/simulator/python_sampler.rs | 254 +++++++++++ .../src/simulator/python_simulator.rs | 236 ++++++++++ deq/deq_runtime/src/simulator/qdk_sampler.py | 157 +++++++ deq/deq_runtime/src/simulator/rhai_assert.rs | 31 ++ .../src/simulator/static_simulator.rs | 36 +- deq/deq_runtime/src/simulator/stim_delays.rs | 32 ++ .../simulator/tableau_preselect_sampler.rs | 1 + deq/deq_runtime/tests/python_sampler_test.rs | 228 ++++++++++ .../tests/standard_decoder_test.rs | 9 +- deq/deq_runtime/tests/stim_sampler_test.rs | 21 + deq/documents/tutorial/README.md | 1 + .../tutorial/chapters/python-decoder.md | 26 +- .../tutorial/chapters/qdk-loss-simulation.md | 313 ++++++++++++++ .../examples/loss-simulation/.gitignore | 1 + .../examples/loss-simulation/gen_snippets.py | 28 ++ .../loss-simulation/loss_ler_sweep.json | 362 ++++++++++++++++ .../loss-simulation/loss_ler_sweep.png | Bin 0 -> 125432 bytes .../loss-simulation/loss_ler_sweep.py | 404 ++++++++++++++++++ .../loss-simulation/repetition_code.deq | 127 ++++++ deq/proto/coordinator.proto | 8 + deq/proto/simulator.proto | 10 + deq/proto/static_controller.proto | 21 +- deq/tests/circuit/test_annotate_keep_noise.py | 116 +++++ 49 files changed, 3132 insertions(+), 170 deletions(-) create mode 100644 deq/deq_runtime/src/misc/python.rs create mode 100644 deq/deq_runtime/src/simulator/python_sampler.rs create mode 100644 deq/deq_runtime/src/simulator/python_simulator.rs create mode 100644 deq/deq_runtime/src/simulator/qdk_sampler.py create mode 100644 deq/deq_runtime/tests/python_sampler_test.rs create mode 100644 deq/documents/tutorial/chapters/qdk-loss-simulation.md create mode 100644 deq/documents/tutorial/examples/loss-simulation/.gitignore create mode 100644 deq/documents/tutorial/examples/loss-simulation/gen_snippets.py create mode 100644 deq/documents/tutorial/examples/loss-simulation/loss_ler_sweep.json create mode 100644 deq/documents/tutorial/examples/loss-simulation/loss_ler_sweep.png create mode 100644 deq/documents/tutorial/examples/loss-simulation/loss_ler_sweep.py create mode 100644 deq/documents/tutorial/examples/loss-simulation/repetition_code.deq diff --git a/deq/deq/circuit/transformer.py b/deq/deq/circuit/transformer.py index a596b7b6..716418a4 100644 --- a/deq/deq/circuit/transformer.py +++ b/deq/deq/circuit/transformer.py @@ -52,8 +52,7 @@ VirtualCorrection, VirtualLogicalStatement, ) -import stim -from deq.transpiler.stim_constants import ANNOTATION_INSTRUCTIONS +from deq.transpiler.stim_constants import ANNOTATION_INSTRUCTIONS, instruction_num_measurements _REC_RE = re.compile(r"rec\[-(\d+)\]") _SWEEP_RE = re.compile(r"sweep\[(\d+)\]") @@ -193,7 +192,7 @@ def _validate_preselect(body: list[Any], gadget_name: str) -> None: elif kind == "repeat_exit": repeat_depth -= 1 elif kind == "instruction": - cum_measurements += stim.CircuitInstruction(str(item)).num_measurements + cum_measurements += instruction_num_measurements(str(item)) elif kind == "preselect": has_preselect = True if repeat_depth > 0: diff --git a/deq/deq/cli/jit.py b/deq/deq/cli/jit.py index 2685a4cd..d72ed7f5 100644 --- a/deq/deq/cli/jit.py +++ b/deq/deq/cli/jit.py @@ -974,7 +974,7 @@ def export_program_stim( Target, ) from deq.circuit.model import MeasurementRecordTarget - import stim + from deq.transpiler.stim_constants import instruction_num_measurements chunks: list[str] = [] next_physical = 0 @@ -1153,7 +1153,7 @@ def export_program_stim( targets=new_targets, ) body_lines.append(str(remapped)) - next_meas_idx += stim.CircuitInstruction(str(stmt)).num_measurements + next_meas_idx += instruction_num_measurements(str(stmt)) if dying: body_lines.append("R " + " ".join(str(p) for p in dying)) diff --git a/deq/deq/cli/simulate.py b/deq/deq/cli/simulate.py index 38daf498..795ae957 100644 --- a/deq/deq/cli/simulate.py +++ b/deq/deq/cli/simulate.py @@ -98,8 +98,11 @@ def simulate__ler( mako: list[str] | None = None, #: suppress the interactive Mako safety prompt skip_mako_warning: bool = False, - #: simulator type: "static" (resample on preselect failure) or - #: "preselect" (retry from gadget start via TableauSimulator) + #: simulator type: "static" (native Stim bulk sampler), "jit-static" + #: (JIT-controller-driven), "preselect" (retry from gadget start via + #: TableauSimulator), or "qdk" (Python sampler via the compile-time + #: embedded ``@qdk_sampler`` adapter; the only path that supports + #: loss-aware simulation). simulator: str = "static", ) -> None: """ @@ -408,9 +411,16 @@ def _run_batch( simulator_config["jit_library_filepath"] = jit_path controller_name = "jit" controller_config = {"filepath": jit_path} + runtime_simulator = simulator + elif simulator == "qdk": + simulator_config["sampler"] = "@qdk_sampler" + controller_name = "static" + controller_config = {"filepath": bin_path} + runtime_simulator = "python" else: controller_name = "static" controller_config = {"filepath": bin_path} + runtime_simulator = simulator cmd = [ sys.executable, "-m", @@ -427,7 +437,7 @@ def _run_batch( "--controller-config", json.dumps(controller_config), "--simulator", - simulator, + runtime_simulator, "--simulator-config", json.dumps(simulator_config), ] diff --git a/deq/deq/noise/common.py b/deq/deq/noise/common.py index c42af6be..a0d267ca 100644 --- a/deq/deq/noise/common.py +++ b/deq/deq/noise/common.py @@ -8,7 +8,7 @@ from deq.transpiler.stim_constants import ( ANNOTATION_INSTRUCTIONS, - NOISE_INSTRUCTIONS, + NOISE_INSTRUCTIONS_ALL, ONE_QUBIT_GATES, PAIR_MEASURE_GATES, PAULI_PRODUCT_GATES, @@ -159,7 +159,7 @@ def count_braces(line: str) -> tuple[int, int]: | Y_BASIS_RESET | PAIR_MEASURE_GATES | PAULI_PRODUCT_GATES - | NOISE_INSTRUCTIONS + | NOISE_INSTRUCTIONS_ALL | ANNOTATION_INSTRUCTIONS | DEQ_KEYWORDS ) diff --git a/deq/deq/noise/strip.py b/deq/deq/noise/strip.py index bf315c41..38fa6ef5 100644 --- a/deq/deq/noise/strip.py +++ b/deq/deq/noise/strip.py @@ -5,7 +5,7 @@ from .common import INSTRUCTION_RE from deq.transpiler.stim_constants import ( MEASUREMENT_INSTRUCTIONS, - NOISE_INSTRUCTIONS, + NOISE_INSTRUCTIONS_ALL, ) _ALL_MEASURES = MEASUREMENT_INSTRUCTIONS | {"MPP"} @@ -32,7 +32,7 @@ def strip_noise(text: str) -> str: result.append(line) continue name_upper = m.group("name").upper() - if name_upper in NOISE_INSTRUCTIONS: + if name_upper in NOISE_INSTRUCTIONS_ALL: continue if name_upper in _ALL_MEASURES and "(" in m.group("after"): cleaned = _PARENS_RE.sub("", line, count=1) diff --git a/deq/deq/transpiler/jit_annotate.py b/deq/deq/transpiler/jit_annotate.py index 9cc001a0..ac80d12b 100644 --- a/deq/deq/transpiler/jit_annotate.py +++ b/deq/deq/transpiler/jit_annotate.py @@ -87,8 +87,9 @@ import deq.proto.util_pb2 as util_pb from deq.transpiler.stim_constants import ( MEASUREMENT_INSTRUCTIONS, - NOISE_INSTRUCTIONS, + NOISE_INSTRUCTIONS_ALL, NOISY_MEASUREMENT_INSTRUCTIONS, + PASSTHROUGH_NOISE_INSTRUCTIONS, TWO_QUBIT_MEASUREMENT_INSTRUCTIONS, mpp_measurement_count, ) @@ -566,8 +567,12 @@ def _render_body_statement( return [f" # {_render_error_statement(stmt)}"] if isinstance(stmt, Instruction): name = stmt.name.upper() - if name in NOISE_INSTRUCTIONS: - if keep_noise: + if name in NOISE_INSTRUCTIONS_ALL: + # Passthrough noise (e.g. LOSS_ERROR) has no equivalent + # ERROR-row representation that re-transpilation could + # reconstruct, so it must be emitted verbatim even when the + # caller asked to comment out regular noise. + if keep_noise or name in PASSTHROUGH_NOISE_INSTRUCTIONS: return [f" {stmt}"] return [f" # {stmt}"] # Noisy measurement: comment out original, emit clean version. @@ -1048,8 +1053,10 @@ def _render_composed_gadget( for stmt in circuit_stmts: if isinstance(stmt, Instruction): name = stmt.name.upper() - if name in NOISE_INSTRUCTIONS: - if keep_noise: + if name in NOISE_INSTRUCTIONS_ALL: + # Passthrough noise (LOSS_ERROR) has no ERROR-row + # equivalent; keep verbatim regardless of `keep_noise`. + if keep_noise or name in PASSTHROUGH_NOISE_INSTRUCTIONS: lines.append(f" {stmt}") else: lines.append(f" # {stmt}") diff --git a/deq/deq/transpiler/jit_library_builder.py b/deq/deq/transpiler/jit_library_builder.py index 4755a0ff..d00c8c31 100644 --- a/deq/deq/transpiler/jit_library_builder.py +++ b/deq/deq/transpiler/jit_library_builder.py @@ -77,12 +77,20 @@ from deq.transpiler.code_validation import validate_code from deq.transpiler.stim_constants import qubit_indices as _qubit_indices -from deq.transpiler.stim_constants import mpp_measurement_count, split_mpp_targets +from deq.transpiler.stim_constants import ( + PASSTHROUGH_NOISE_INSTRUCTIONS, + mpp_measurement_count, + split_mpp_targets, +) def _measurement_tags_of(inst: Instruction) -> list[str]: """Return one human-readable tag per measurement produced by *inst*.""" name = inst.name.upper() + if name in PASSTHROUGH_NOISE_INSTRUCTIONS: + # ``LOSS_ERROR`` (and other QDK-style passthrough extensions) are + # unknown to upstream Stim; they produce no measurement bits. + return [] gate = stim.gate_data(name) if not gate.produces_measurements: return [] @@ -111,6 +119,8 @@ def _measurement_tags_of(inst: Instruction) -> list[str]: def _measurement_count_of(inst: Instruction) -> int: """Return the number of measurements produced by *inst*.""" name = inst.name.upper() + if name in PASSTHROUGH_NOISE_INSTRUCTIONS: + return 0 gate = stim.gate_data(name) if not gate.produces_measurements: return 0 diff --git a/deq/deq/transpiler/jit_noise_builder.py b/deq/deq/transpiler/jit_noise_builder.py index 2113f6fd..fe6f70fe 100644 --- a/deq/deq/transpiler/jit_noise_builder.py +++ b/deq/deq/transpiler/jit_noise_builder.py @@ -87,6 +87,8 @@ from deq.transpiler.stim_constants import ( ANNOTATION_INSTRUCTIONS, NOISE_INSTRUCTIONS, + NOISE_INSTRUCTIONS_ALL, + PASSTHROUGH_NOISE_INSTRUCTIONS, mpp_measurement_count, ) from deq.transpiler.stim_constants import qubit_indices as _qubit_indices @@ -99,6 +101,8 @@ def _real_measurement_count(instr: Instruction) -> int: """Return the number of real measurements an instruction performs.""" name = instr.name.upper() + if name in PASSTHROUGH_NOISE_INSTRUCTIONS: + return 0 if name in ("HERALDED_ERASE", "HERALDED_PAULI_CHANNEL_1"): raise ValueError( f"Heralded instruction '{name}' is not supported by deq. " @@ -179,6 +183,14 @@ def enumerate_noise_mechanisms( - ``I_ERROR`` / ``II_ERROR`` produce no mechanisms. """ name = instr.name.upper() + if name in PASSTHROUGH_NOISE_INSTRUCTIONS: + # Passthrough noise extensions (e.g. ``LOSS_ERROR``) are emitted + # verbatim in the .stim output but contribute no detector edges + # to the decoding hypergraph — deq's decoder side does not (yet) + # consume them. Surfacing them through the JIT noise builder as + # "no mechanisms" lets users freely sprinkle them into gadget + # bodies without breaking hypergraph construction. + return [] if name not in NOISE_INSTRUCTIONS: raise ValueError(f"{name} is not a recognised noise instruction") @@ -376,7 +388,7 @@ def _build_decomposed_body( if not isinstance(stmt, Instruction): continue name = stmt.name.upper() - if name in NOISE_INSTRUCTIONS or name in ANNOTATION_INSTRUCTIONS: + if name in NOISE_INSTRUCTIONS_ALL or name in ANNOTATION_INSTRUCTIONS: continue if lines: lines.append("TICK") @@ -1002,7 +1014,7 @@ def iter_noise_errors_with_origin( else_chain_remaining = 1.0 # ── Pure noise instructions ────────────────────────────────── - if name in NOISE_INSTRUCTIONS: + if name in NOISE_INSTRUCTIONS_ALL: walk_start = ( orig_to_decomposed[i + 1] if i + 1 < len(orig_to_decomposed) @@ -1486,6 +1498,8 @@ def resolve_propagations( def _measurement_count_of_instruction(inst: Instruction) -> int: """Return the number of measurements produced by *inst*.""" name = inst.name.upper() + if name in PASSTHROUGH_NOISE_INSTRUCTIONS: + return 0 gate = stim.gate_data(name) if not gate.produces_measurements: return 0 diff --git a/deq/deq/transpiler/jit_transpiler.py b/deq/deq/transpiler/jit_transpiler.py index e04ce1c2..fead86d1 100644 --- a/deq/deq/transpiler/jit_transpiler.py +++ b/deq/deq/transpiler/jit_transpiler.py @@ -135,7 +135,7 @@ def observable_of_column(column: ObservableColumn) -> int: ) from deq.transpiler.stim_constants import ( ANNOTATION_INSTRUCTIONS, - NOISE_INSTRUCTIONS, + NOISE_INSTRUCTIONS_ALL, ) # --------------------------------------------------------------------------- @@ -158,7 +158,7 @@ def _body_to_stim_circuit( if not isinstance(stmt, Instruction): continue name = stmt.name.upper() - if name in NOISE_INSTRUCTIONS or name in ANNOTATION_INSTRUCTIONS: + if name in NOISE_INSTRUCTIONS_ALL or name in ANNOTATION_INSTRUCTIONS: continue # Rebuild the instruction without tag inst_copy = Instruction( diff --git a/deq/deq/transpiler/stim_constants.py b/deq/deq/transpiler/stim_constants.py index 2da5c281..53e99cd5 100644 --- a/deq/deq/transpiler/stim_constants.py +++ b/deq/deq/transpiler/stim_constants.py @@ -24,6 +24,54 @@ for alias in g.aliases ) +# Stim-extension noise instructions that upstream ``stim.gate_data()`` +# does **not** know about but that deq should accept verbatim inside +# gadget bodies and pass through to the generated ``.stim`` (with the +# usual qubit-target relabeling). These instructions: +# +# * are treated the same as :data:`NOISE_INSTRUCTIONS` by every deq +# transpiler pass that *skips* noise (gate decomposition, hypergraph +# construction, annotation walks, …), +# * produce **no hyperedges** in the JIT noise builder, +# * are assumed to produce **zero measurement bits** (so any +# measurement-counting pass returns 0 for them). +# +# ``LOSS_ERROR(p) q...`` is QDK's stim extension that marks a qubit as +# lossy just before its next measurement. Adding it here lets users +# write loss-aware circuits directly in ``.deq``; the deq runtime +# itself does not interpret loss, but ``qdk.stim`` (driven via +# ``--simulator python``) does. +PASSTHROUGH_NOISE_INSTRUCTIONS: frozenset[str] = frozenset({"LOSS_ERROR"}) + +# Union of all instruction names that every deq transpiler pass that +# already skips :data:`NOISE_INSTRUCTIONS` should also skip. Prefer +# this set in callers that simply want "anything that looks like a +# noise channel" — including QDK-style passthrough extensions. +NOISE_INSTRUCTIONS_ALL: frozenset[str] = NOISE_INSTRUCTIONS | PASSTHROUGH_NOISE_INSTRUCTIONS + + +def instruction_num_measurements(instruction_text: str) -> int: + """Count measurement bits produced by a single stim instruction. + + Delegates to ``stim.CircuitInstruction(...).num_measurements`` for + instructions upstream Stim recognizes. For + :data:`PASSTHROUGH_NOISE_INSTRUCTIONS` (which upstream Stim rejects + with ``Gate not found``) returns ``0`` — these are noise-channel + extensions and contribute no measurement bits. + + Use this helper anywhere we used to call + ``stim.CircuitInstruction(str(stmt)).num_measurements`` on a + user-authored instruction; otherwise circuits containing + ``LOSS_ERROR`` (and any future passthrough extension) will crash + the transpiler. + """ + head = instruction_text.split(None, 1) + if head: + name = head[0].split("(", 1)[0].upper() + if name in PASSTHROUGH_NOISE_INSTRUCTIONS: + return 0 + return stim.CircuitInstruction(instruction_text).num_measurements + # Single-qubit gates that produce measurement results (M, MR, MX, etc.). # Excludes heralded noise channels (HERALDED_ERASE, etc.) which require # a probability argument (num_parens_arguments_range starts at > 0). diff --git a/deq/deq_runtime/src/controller/static_controller.rs b/deq/deq_runtime/src/controller/static_controller.rs index fac98b88..469f83fb 100644 --- a/deq/deq_runtime/src/controller/static_controller.rs +++ b/deq/deq_runtime/src/controller/static_controller.rs @@ -48,6 +48,8 @@ struct StaticControllerState { gid_vec: Vec, /// accumulated measurement outcomes received outcomes: Vec, + /// Accumulated per-measurement loss flags, kept in lockstep with `outcomes`. + loss_mask: Option>, /// background decode tasks for streaming mode (dispatched but not yet awaited) pending_decodes: JoinSet>, /// readouts collected from completed background decode tasks @@ -93,6 +95,7 @@ impl StaticController { next_index: 0, gid_vec: Vec::with_capacity(info.accumulated_measurements.len()), outcomes: Vec::with_capacity(info.total_measurements), + loss_mask: None, pending_decodes: JoinSet::new(), pending_readouts: Vec::new(), dispatched_count: 0, @@ -183,6 +186,7 @@ impl StaticController { state.next_index = 0; state.gid_vec = gid_vec; state.outcomes.clear(); + state.loss_mask = None; state.pending_decodes.shutdown().await; state.pending_readouts.clear(); state.dispatched_count = 0; @@ -192,15 +196,57 @@ impl StaticController { #[cfg(feature = "cli")] #[tonic::async_trait] impl static_controller_server::StaticController for StaticController { - async fn decode(&self, request: Request) -> std::result::Result, Status> { - let outcomes = request.into_inner(); + async fn decode( + &self, + request: Request, + ) -> std::result::Result, Status> { + let request = request.into_inner(); let coordinator = self.wait_until_library_loaded().await; - let outcomes = crate::misc::bit_vector::unpack_bits(&outcomes.data, outcomes.size); + let outcomes_bv = request + .outcomes + .ok_or_else(|| Status::invalid_argument("Outcomes.outcomes is required"))?; + let outcomes = crate::misc::bit_vector::unpack_bits(&outcomes_bv.data, outcomes_bv.size); + let loss_bits: Option> = request.loss_mask.as_ref().map(|bv| { + let bits = crate::misc::bit_vector::unpack_bits(&bv.data, bv.size); + assert_eq!( + bits.len(), + outcomes.len(), + "loss_mask length ({}) does not match outcomes length ({})", + bits.len(), + outcomes.len(), + ); + bits + }); + let is_complete; { // getting the lock to compute the decode requests and spawn tasks let mut state = self.state.lock().await; + + // The Some/None choice for `loss_mask` is locked in by the first + // decode call of each shot. Later calls must match that choice. + let is_first_call_of_shot = state.outcomes.is_empty(); + if is_first_call_of_shot { + state.loss_mask = loss_bits.clone(); + } else { + match (state.loss_mask.as_mut(), loss_bits.as_ref()) { + (Some(acc), Some(new)) => acc.extend_from_slice(new), + (None, None) => {} + (Some(_), None) => { + return Err(Status::invalid_argument( + "loss_mask was supplied earlier in this shot; subsequent decode \ + calls of the same shot must also supply it", + )); + } + (None, Some(_)) => { + return Err(Status::invalid_argument( + "loss_mask was not supplied in the first decode call of this shot; \ + subsequent calls must not supply it either", + )); + } + } + } state.outcomes.extend_from_slice(&outcomes); assert!(state.outcomes.len() <= self.info.total_measurements, "too many outcomes"); is_complete = state.outcomes.len() == self.info.total_measurements; @@ -216,11 +262,19 @@ impl static_controller_server::StaticController for StaticController { } else { self.info.accumulated_measurements[state.next_index - 1] }; - let slice: Vec = state.outcomes[start..self.info.accumulated_measurements[state.next_index]].into(); + let end = self.info.accumulated_measurements[state.next_index]; + let slice: Vec = state.outcomes[start..end].into(); let bit_vector = BitVector { size: slice.len() as u64, data: crate::misc::bit_vector::pack_bits(&slice), }; + let loss_mask_bv = state.loss_mask.as_ref().map(|m| { + let loss_slice: Vec = m[start..end].into(); + BitVector { + size: loss_slice.len() as u64, + data: crate::misc::bit_vector::pack_bits(&loss_slice), + } + }); let dispatch_idx = state.dispatched_count; state.dispatched_count += 1; state.pending_readouts.push(None); @@ -232,6 +286,7 @@ impl static_controller_server::StaticController for StaticController { gid, outcomes: Some(bit_vector), modifiers: vec![], + loss_mask: loss_mask_bv, }) .await .map(|readouts| (dispatch_idx, readouts)) @@ -248,8 +303,11 @@ impl static_controller_server::StaticController for StaticController { if !is_complete { // Partial measurement batch: return empty readouts (tasks are running in background) - let empty = BitVector { size: 0, data: vec![] }; - return Ok(empty.into()); + return Ok(Response::new(coordinator::Readouts { + gid: 0, + readouts: Some(BitVector { size: 0, data: vec![] }), + probabilities: vec![], + })); } // All measurements received: wait for remaining pending decode tasks to complete @@ -287,7 +345,11 @@ impl static_controller_server::StaticController for StaticController { size: gathered_readouts.len() as u64, data: crate::misc::bit_vector::pack_bits(&gathered_readouts), }; - Ok(gathered_readouts.into()) + Ok(Response::new(coordinator::Readouts { + gid: 0, + readouts: Some(gathered_readouts), + probabilities: vec![], + })) } async fn reset(&self, _request: Request<()>) -> std::result::Result, Status> { diff --git a/deq/deq_runtime/src/coordinator.rs b/deq/deq_runtime/src/coordinator.rs index b4bd247f..aac010f9 100644 --- a/deq/deq_runtime/src/coordinator.rs +++ b/deq/deq_runtime/src/coordinator.rs @@ -19,6 +19,33 @@ include!("proto/deq.coordinator.rs"); #[cfg(feature = "cli")] use coordinator_server::CoordinatorServer; +/// Replace each bit of `outcomes` whose position is set in `loss_mask` +/// with a uniformly random bit drawn from `rng`. This is the default +/// **loss-random-imputation** strategy: lost measurements (`loss_mask` +/// bits set to 1) are filled with random bits before the coordinator +/// computes the parity-check syndrome. +/// +/// Panics if `outcomes.size != loss_mask.size`, since a length mismatch +/// indicates a wire-format bug at the controller boundary. +pub fn apply_loss_random_imputation( + outcomes: &mut crate::util::BitVector, + loss_mask: &crate::util::BitVector, + rng: &mut R, +) { + use crate::misc::bit_vector; + use rand::RngExt; + assert_eq!( + outcomes.size, loss_mask.size, + "loss_mask size {} does not match outcomes size {}", + loss_mask.size, outcomes.size, + ); + for i in 0..outcomes.size { + if bit_vector::get_bit(loss_mask, i) { + bit_vector::set_bit(outcomes, i, rng.random::()); + } + } +} + #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Debug)] #[cfg_attr(feature = "cli", derive(ValueEnum))] pub enum CoordinatorType { @@ -213,3 +240,110 @@ impl CoordinatorClient { .map(|v| v.into_inner()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::misc::bit_vector; + use crate::simulator::DeterministicRng; + use crate::util::BitVector; + use rand::SeedableRng; + + #[test] + fn apply_loss_random_imputation_leaves_non_loss_bits_untouched() { + // outcomes = [1, 0, 1, 0], loss_mask = [0, 0, 0, 0] + let mut outcomes = BitVector { + size: 4, + data: vec![0b1010_0000], + }; + let loss_mask = BitVector { + size: 4, + data: vec![0b0000_0000], + }; + let mut rng = DeterministicRng::seed_from_u64(42); + + let before = outcomes.clone(); + apply_loss_random_imputation(&mut outcomes, &loss_mask, &mut rng); + assert_eq!( + outcomes, before, + "no loss_mask bits set → no imputation, outcomes must stay byte-identical", + ); + } + + #[test] + fn apply_loss_random_imputation_only_replaces_marked_bits() { + // 1000 trials with loss_mask = [0, 1, 0, 1]. bits 0 and 2 must + // stay at their input values (1 and 1); bits 1 and 3 must take + // both 0 and 1 over the trial set (with overwhelming probability). + let mut rng = DeterministicRng::seed_from_u64(1); + let loss_mask = BitVector { + size: 4, + data: vec![0b0101_0000], + }; + let mut bit1_zero_count = 0usize; + let mut bit3_zero_count = 0usize; + let trials = 1000usize; + for _ in 0..trials { + let mut outcomes = BitVector { + size: 4, + data: vec![0b1010_0000], + }; + apply_loss_random_imputation(&mut outcomes, &loss_mask, &mut rng); + assert!(bit_vector::get_bit(&outcomes, 0), "bit 0 not in loss_mask, must be preserved"); + assert!(bit_vector::get_bit(&outcomes, 2), "bit 2 not in loss_mask, must be preserved"); + if !bit_vector::get_bit(&outcomes, 1) { + bit1_zero_count += 1; + } + if !bit_vector::get_bit(&outcomes, 3) { + bit3_zero_count += 1; + } + } + let lo = trials / 4; + let hi = 3 * trials / 4; + assert!( + (lo..hi).contains(&bit1_zero_count), + "bit 1 imputed zero {bit1_zero_count}/{trials} times; expected roughly balanced", + ); + assert!( + (lo..hi).contains(&bit3_zero_count), + "bit 3 imputed zero {bit3_zero_count}/{trials} times; expected roughly balanced", + ); + } + + #[test] + fn apply_loss_random_imputation_is_deterministic_with_same_seed() { + let loss_mask = BitVector { + size: 8, + data: vec![0b1111_1111], + }; + let initial = BitVector { + size: 8, + data: vec![0b0000_0000], + }; + + let mut a = initial.clone(); + let mut rng_a = DeterministicRng::seed_from_u64(123); + apply_loss_random_imputation(&mut a, &loss_mask, &mut rng_a); + + let mut b = initial.clone(); + let mut rng_b = DeterministicRng::seed_from_u64(123); + apply_loss_random_imputation(&mut b, &loss_mask, &mut rng_b); + + assert_eq!(a, b, "same seed → identical imputation"); + } + + #[test] + #[should_panic(expected = "does not match outcomes size")] + fn apply_loss_random_imputation_panics_on_size_mismatch() { + let mut outcomes = BitVector { + size: 4, + data: vec![0b0000_0000], + }; + let loss_mask = BitVector { + size: 5, + data: vec![0b0000_1000], + }; + let mut rng = DeterministicRng::seed_from_u64(0); + apply_loss_random_imputation(&mut outcomes, &loss_mask, &mut rng); + } +} diff --git a/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs index 1d53867b..cd06c5bf 100644 --- a/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs @@ -69,6 +69,15 @@ pub struct MonolithicCoordinatorConfig { /// build the decoder data structure every time, which could be time consuming #[serde(default = "default_true")] pub persistent_decoder: bool, + /// when ``true`` (the default), each bit of ``Outcomes.outcomes`` whose + /// position is set in the accompanying ``Outcomes.loss_mask`` is replaced + /// with a uniformly random bit before the coordinator computes the syndrome. + #[serde(default = "default_true")] + pub loss_random_imputation: bool, + /// optional seed for the loss-random-imputation RNG. When ``None``, the + /// RNG is seeded from the OS entropy pool at coordinator construction. + #[serde(default)] + pub loss_random_imputation_seed: Option, } fn default_true() -> bool { @@ -115,6 +124,12 @@ pub struct MonolithicCoordinator { pub cancellation: RwLock, /// Tracks active spawned tasks; reset() waits for all to finish before clearing state. pub task_counter: Arc, + /// Deterministic RNG used by `apply_loss_random_imputation` when + /// ``config.loss_random_imputation`` is enabled. Seeded once at + /// construction from ``config.loss_random_imputation_seed`` (or from OS + /// entropy when no seed was supplied). ``None`` when imputation is + /// disabled, so the field doesn't even allocate. + pub loss_imputation_rng: Option>, } /// Per-coordinator [`FingerprintSource`] adapter for the monolithic @@ -180,6 +195,13 @@ pub struct ErrorModel { impl MonolithicCoordinator { pub fn new(config: serde_json::Value, black_box_decoder: BlackBoxDecoderClient) -> Self { let config: MonolithicCoordinatorConfig = serde_json::from_value(config).unwrap(); + let loss_imputation_rng = if config.loss_random_imputation { + use rand::{Rng, SeedableRng}; + let seed = config.loss_random_imputation_seed.unwrap_or_else(|| rand::rng().next_u64()); + Some(Mutex::new(crate::simulator::DeterministicRng::seed_from_u64(seed))) + } else { + None + }; Self { config, port_types: Default::default(), @@ -199,6 +221,7 @@ impl MonolithicCoordinator { pauli_frame_tracker: Default::default(), cancellation: RwLock::new(CancellationToken::new()), task_counter: TaskCounter::new(), + loss_imputation_rng, } } @@ -1228,11 +1251,18 @@ impl coordinator::coordinator_server::Coordinator for MonolithicCoordinator { return Err(Status::already_exists(format!("gid={} outcomes loaded", gid))); } // load the outcome - gadget.outcomes.replace( - outcomes - .outcomes - .ok_or_else(|| Status::invalid_argument("missing outcomes"))?, - ); + let mut outcome_data = outcomes + .outcomes + .ok_or_else(|| Status::invalid_argument("missing outcomes"))?; + // Apply loss-random-imputation before storing the outcomes: every + // downstream consumer (syndrome calculation, pauli-frame tracker, + // ...) reads `gadget.outcomes` and benefits from a single + // consistent imputed value per measurement bit. + if let (Some(rng_lock), Some(loss_mask)) = (self.loss_imputation_rng.as_ref(), outcomes.loss_mask.as_ref()) { + let mut rng = rng_lock.lock().await; + coordinator::apply_loss_random_imputation(&mut outcome_data, loss_mask, &mut *rng); + } + gadget.outcomes.replace(outcome_data); let mut pending_subgraphs = self.pending_subgraphs.lock().await; let gid_to_union_index = self.gid_to_union_index.lock().await; let union_index = gid_to_union_index[&gid]; diff --git a/deq/deq_runtime/src/coordinator/window_coordinator.rs b/deq/deq_runtime/src/coordinator/window_coordinator.rs index 57ae6b69..2fb00228 100644 --- a/deq/deq_runtime/src/coordinator/window_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/window_coordinator.rs @@ -152,6 +152,15 @@ pub struct WindowCoordinatorConfig { /// the trace is written on each reset() call #[serde(default)] pub trace_filepath: Option, + /// when ``true`` (the default), each bit of ``Outcomes.outcomes`` whose + /// position is set in the accompanying ``Outcomes.loss_mask`` is replaced + /// with a uniformly random bit before the coordinator computes the syndrome. + #[serde(default = "default_true")] + pub loss_random_imputation: bool, + /// optional seed for the loss-random-imputation RNG. When ``None``, the + /// RNG is seeded from the OS entropy pool at coordinator construction. + #[serde(default)] + pub loss_random_imputation_seed: Option, } impl WindowCoordinatorConfig { @@ -221,6 +230,10 @@ pub struct WindowCoordinator { pub cancellation: RwLock, /// Tracks active spawned tasks; reset() waits for all to finish before clearing state. pub task_counter: Arc, + /// Deterministic RNG used by `apply_loss_random_imputation` when + /// ``config.loss_random_imputation`` is enabled. Seeded once at + /// construction; ``None`` when imputation is disabled. + pub loss_imputation_rng: Option>, /// accumulated trace for the current shot pub trace_shot: Arc>, /// accumulated trace across all shots @@ -380,6 +393,13 @@ pub struct ExploredWindow { impl WindowCoordinator { pub fn new(config: serde_json::Value, black_box_decoder: BlackBoxDecoderClient) -> Self { let config: WindowCoordinatorConfig = serde_json::from_value(config).unwrap(); + let loss_imputation_rng = if config.loss_random_imputation { + use rand::{Rng, SeedableRng}; + let seed = config.loss_random_imputation_seed.unwrap_or_else(|| rand::rng().next_u64()); + Some(Mutex::new(crate::simulator::DeterministicRng::seed_from_u64(seed))) + } else { + None + }; Self { config, port_types: Default::default(), @@ -399,6 +419,7 @@ impl WindowCoordinator { pauli_frame_tracker: Default::default(), cancellation: RwLock::new(CancellationToken::new()), task_counter: TaskCounter::new(), + loss_imputation_rng, trace_shot: Arc::new(Mutex::new(trace::Shot::default())), trace: Mutex::new(trace::WindowCoordinatorTrace::default()), } @@ -2546,11 +2567,18 @@ impl coordinator::coordinator_server::Coordinator for WindowCoordinator { .get_mut(&gid) .ok_or_else(|| Status::not_found(format!("gid={}", gid)))?; is_free_hop = gadget.is_free_hop; - gadget.outcomes.send_replace(Some( - outcomes - .outcomes - .ok_or_else(|| Status::invalid_argument("missing outcomes"))?, - )); + let mut outcome_data = outcomes + .outcomes + .ok_or_else(|| Status::invalid_argument("missing outcomes"))?; + // Apply loss-random-imputation before storing the outcomes so + // every downstream consumer (syndrome calc, pauli-frame tracker, + // window decoder) reads a single consistent imputed value per + // measurement bit. + if let (Some(rng_lock), Some(loss_mask)) = (self.loss_imputation_rng.as_ref(), outcomes.loss_mask.as_ref()) { + let mut rng = rng_lock.lock().await; + coordinator::apply_loss_random_imputation(&mut outcome_data, loss_mask, &mut *rng); + } + gadget.outcomes.send_replace(Some(outcome_data)); let gadget_type = gadget_types.get(&gadget.instance.gtype).unwrap(); let mut readouts = Vec::with_capacity(gadget_type.readouts.len()); let data: BitVector = gadget.outcomes.borrow().as_ref().unwrap().clone(); diff --git a/deq/deq_runtime/src/decoder/python_decoder.rs b/deq/deq_runtime/src/decoder/python_decoder.rs index 1d789c4e..77ac462d 100644 --- a/deq/deq_runtime/src/decoder/python_decoder.rs +++ b/deq/deq_runtime/src/decoder/python_decoder.rs @@ -14,13 +14,42 @@ use crate::decoder::blackbox_decoder::{DecodingHypergraph, ParityFactor}; use crate::decoder::thread_pooling::{DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder}; use crate::misc::bit_vector::to_sparse_indices; +use crate::misc::python::{get_or_load_module, get_or_load_module_from_source, json_value_to_py}; use crate::util::BitVector; +use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -use pyo3::types::{PyDict, PyList}; +use pyo3::types::PyList; use serde::{Deserialize, Serialize}; #[cfg(feature = "cli")] use structdoc::StructDoc; +/// Compile-time-embedded Python decoder adapters. +/// +/// When a [`PythonDecoderConfig::file`] value starts with `@`, the +/// string after the `@` is looked up here instead of being treated as +/// a filesystem path. Ships baked into the ``deq_runtime`` binary so +/// callers never need to know where the reference decoder adapters +/// live on disk. The `@` prefix is reserved: no filesystem path +/// starting with `@` will be opened by the decoder. +mod builtin_decoders { + /// Return `(virtual_filename, source_code)` for a named builtin, or + /// `None` if the name is unknown. ``virtual_filename`` is what + /// Python tracebacks display (typically the `@name` sentinel). + pub fn lookup(name: &str) -> Option<(&'static str, &'static str)> { + match name { + "naive_decoder" => Some(("@naive_decoder", include_str!("naive_decoder.py"))), + "relay_bp_decoder" => Some(("@relay_bp_decoder", include_str!("relay_bp_decoder.py"))), + "tesseract_decoder" => Some(("@tesseract_decoder", include_str!("tesseract_decoder.py"))), + _ => None, + } + } + + /// All known builtin decoder names (without the leading `@`). + pub fn names() -> &'static [&'static str] { + &["naive_decoder", "relay_bp_decoder", "tesseract_decoder"] + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "cli", derive(StructDoc))] #[serde(deny_unknown_fields)] @@ -28,7 +57,13 @@ pub struct PythonDecoderConfig { /// we want to recognize all the thread pooling config fields #[serde(flatten)] pub thread_pooling_config: ThreadPoolingConfig, - /// the entry file of the Python decoder (should be a file *.py) + /// Where to find the Python decoder. + /// + /// * A filesystem path to a ``*.py`` file, or + /// * a ``@name`` sentinel that resolves to a compile-time-embedded + /// adapter in the [`builtin_decoders`] registry above (currently + /// ``@naive_decoder``, ``@relay_bp_decoder``, ``@tesseract_decoder``). + /// The ``@`` prefix is reserved and never opens a real file. pub file: String, /// the name of the decoder class inside the Python file; defaults to "Decoder" #[serde(default = "default_decoder_class_name")] @@ -105,7 +140,19 @@ impl DecoderInstance for PythonDecoderInstance { fn new(hypergraph: &DecodingHypergraph, config: &serde_json::Value) -> Self { let config: PythonDecoderConfig = serde_json::from_value(config.clone()).unwrap(); let decoder = Python::attach(|py| { - let module = get_or_load_module(py, &config.file)?; + let module = if let Some(builtin_name) = config.file.strip_prefix('@') { + let (fname, source) = builtin_decoders::lookup(builtin_name).ok_or_else(|| { + let known = builtin_decoders::names() + .iter() + .map(|n| format!("@{n}")) + .collect::>() + .join(", "); + PyValueError::new_err(format!("unknown builtin decoder '@{builtin_name}'. Known builtins: {known}")) + })?; + get_or_load_module_from_source(py, fname, source)? + } else { + get_or_load_module(py, &config.file)? + }; let py_hypergraph = PyDecodingHypergraph::new(py, hypergraph)?; let py_config = json_value_to_py(py, &config.py_config.unwrap_or_else(|| serde_json::json!({})))?; let decoder_class = module.getattr(config.name.as_str())?; @@ -140,76 +187,4 @@ impl DecoderInstance for PythonDecoderInstance { } } -fn get_or_load_module<'py>(py: Python<'py>, file: &str) -> PyResult> { - // In principle, you should not need to modify sys.path; if you encounter some problem - // with module loading, try `export LD_LIBRARY_PATH="$HOME/miniforge3/lib/:$LD_LIBRARY_PATH"` - // - // Use the file path as part of the module name so different Python decoder files - // loaded by the same process get distinct modules (otherwise the first-loaded file - // would be returned for every subsequent file). - let module_name = format!( - "deq_python_decoder_{}", - file.replace(|c: char| !c.is_ascii_alphanumeric(), "_") - ); - let sys = py.import("sys")?; - let modules = sys.getattr("modules")?; - if let Ok(existing) = modules.get_item(&module_name) { - return Ok(existing); - } - let util = py.import("importlib.util")?; - let spec = util.call_method1("spec_from_file_location", (&module_name, file))?; - let module = util.call_method1("module_from_spec", (spec.clone(),))?; - // Note: register in sys.modules AFTER exec_module finishes. Heavy imports - // (e.g. numpy / scipy) inside the loaded module release the GIL, which - // would otherwise let another rayon worker thread observe a half-loaded - // module and call `getattr` for the decoder class before it is defined. - spec.getattr("loader")?.call_method1("exec_module", (module.clone(),))?; - modules.set_item(&module_name, module.clone())?; - Ok(module) -} - -/// Convert a [`serde_json::Value`] directly into a Python object. -/// -/// We don't use the `pythonize` crate here because this crate enables -/// `serde_json/arbitrary_precision`, under which `serde_json::Number` is -/// serialized as the sentinel map `{"$serde_json::private::Number": "10"}` -/// instead of a plain integer. That sentinel breaks any downstream Python -/// callee that expects a real `int` or `float` (e.g. pybind11-bound C++ -/// constructors with strict type checks). Walking the `Value` tree manually -/// lets us emit native Python scalars. -fn json_value_to_py<'py>(py: Python<'py>, value: &serde_json::Value) -> PyResult> { - match value { - serde_json::Value::Null => Ok(py.None().into_bound(py)), - serde_json::Value::Bool(b) => Ok(pyo3::types::PyBool::new(py, *b).to_owned().into_any()), - serde_json::Value::Number(n) => { - if let Some(i) = n.as_i64() { - Ok(i.into_pyobject(py)?.into_any()) - } else if let Some(u) = n.as_u64() { - Ok(u.into_pyobject(py)?.into_any()) - } else if let Some(f) = n.as_f64() { - Ok(f.into_pyobject(py)?.into_any()) - } else { - // Arbitrary-precision number that doesn't fit any of the above: - // fall back to a Python string so the callee can convert. - Ok(n.to_string().into_pyobject(py)?.into_any()) - } - } - serde_json::Value::String(s) => Ok(s.into_pyobject(py)?.into_any()), - serde_json::Value::Array(items) => { - let list = PyList::empty(py); - for item in items { - list.append(json_value_to_py(py, item)?)?; - } - Ok(list.into_any()) - } - serde_json::Value::Object(map) => { - let dict = PyDict::new(py); - for (key, val) in map { - dict.set_item(key, json_value_to_py(py, val)?)?; - } - Ok(dict.into_any()) - } - } -} - pub type PythonDecoder = ThreadPoolingDecoder; diff --git a/deq/deq_runtime/src/decoder/relay_bp_decoder.py b/deq/deq_runtime/src/decoder/relay_bp_decoder.py index f620b414..c0802d4c 100644 --- a/deq/deq_runtime/src/decoder/relay_bp_decoder.py +++ b/deq/deq_runtime/src/decoder/relay_bp_decoder.py @@ -19,12 +19,15 @@ def reset(self) -> None: ... LD_LIBRARY_PATH="$(python -c 'import sysconfig; print(sysconfig.get_config_var("LIBDIR"))'):$LD_LIBRARY_PATH" \ cargo run --bin deq-runtime-cli --features python -- \ - test python-decoder --file src/decoder/relay_bp_decoder.py - -The ``LD_LIBRARY_PATH`` shim points the embedded interpreter at a -``libpython`` that has ``numpy``, ``scipy`` and ``relay_bp`` installed; omit -it if your system Python already has them. Pass ``--py-config '{"seed": 42}'`` -to override decoder kwargs. + test python-decoder --file @relay_bp_decoder + +The ``@relay_bp_decoder`` sentinel resolves to a compile-time-embedded +copy of this file inside ``python_decoder.rs``; pass a filesystem path +instead when working on a local variant. The ``LD_LIBRARY_PATH`` shim +points the embedded interpreter at a ``libpython`` that has ``numpy``, +``scipy`` and ``relay_bp`` installed; omit it if your system Python +already has them. Pass ``--py-config '{"seed": 42}'`` to override +decoder kwargs. """ from typing import Any, Dict, List diff --git a/deq/deq_runtime/src/decoder/tesseract_decoder.py b/deq/deq_runtime/src/decoder/tesseract_decoder.py index 97de2f5c..49ecb73d 100644 --- a/deq/deq_runtime/src/decoder/tesseract_decoder.py +++ b/deq/deq_runtime/src/decoder/tesseract_decoder.py @@ -19,12 +19,15 @@ def reset(self) -> None: ... LD_LIBRARY_PATH="$(python -c 'import sysconfig; print(sysconfig.get_config_var("LIBDIR"))'):$LD_LIBRARY_PATH" \ cargo run --bin deq-runtime-cli --features python -- \ - test python-decoder --file src/decoder/tesseract_decoder.py - -The ``LD_LIBRARY_PATH`` shim points the embedded interpreter at a -``libpython`` that has ``numpy``, ``stim`` and ``tesseract_decoder`` -installed; omit it if your system Python already has them. Pass -``--py-config '{"det_beam": 10}'`` to override decoder kwargs. + test python-decoder --file @tesseract_decoder + +The ``@tesseract_decoder`` sentinel resolves to a compile-time-embedded +copy of this file inside ``python_decoder.rs``; pass a filesystem path +instead when working on a local variant. The ``LD_LIBRARY_PATH`` shim +points the embedded interpreter at a ``libpython`` that has ``numpy``, +``stim`` and ``tesseract_decoder`` installed; omit it if your system +Python already has them. Pass ``--py-config '{"det_beam": 10}'`` to +override decoder kwargs. """ from typing import Any, Dict, List diff --git a/deq/deq_runtime/src/misc/mod.rs b/deq/deq_runtime/src/misc/mod.rs index b94a4b42..8475a6f6 100644 --- a/deq/deq_runtime/src/misc/mod.rs +++ b/deq/deq_runtime/src/misc/mod.rs @@ -5,6 +5,8 @@ pub mod index; #[cfg(feature = "cli")] pub mod parser; pub mod pauli_frame_tracker; +#[cfg(feature = "python")] +pub mod python; pub mod relative_program; pub mod sync; pub mod union_find; diff --git a/deq/deq_runtime/src/misc/python.rs b/deq/deq_runtime/src/misc/python.rs new file mode 100644 index 00000000..db9df1ff --- /dev/null +++ b/deq/deq_runtime/src/misc/python.rs @@ -0,0 +1,127 @@ +//! Shared Python interop helpers used by Rust-side plugins that load +//! user-supplied Python files (e.g. the Python decoder, the Python +//! sampler). +//! +//! Both helpers were originally private to `decoder::python_decoder`; +//! they moved here when a second consumer (the Python sampler) needed +//! them. Behaviour is unchanged. + +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyList}; + +/// Load (or re-fetch) a Python module from a filesystem path. +/// +/// The module is keyed in ``sys.modules`` by a name derived from the +/// file path so different files loaded by the same process get distinct +/// modules (otherwise the first-loaded file would be returned for every +/// subsequent file). +/// +/// In principle, you should not need to modify ``sys.path``; if you +/// encounter some problem with module loading, try +/// ``export LD_LIBRARY_PATH="$HOME/miniforge3/lib/:$LD_LIBRARY_PATH"`` +/// (only needed for standalone Rust binaries — the Python extension +/// module path does not need it). +pub(crate) fn get_or_load_module<'py>(py: Python<'py>, file: &str) -> PyResult> { + let module_name = format!( + "deq_python_module_{}", + file.replace(|c: char| !c.is_ascii_alphanumeric(), "_") + ); + let sys = py.import("sys")?; + let modules = sys.getattr("modules")?; + if let Ok(existing) = modules.get_item(&module_name) { + return Ok(existing); + } + let util = py.import("importlib.util")?; + let spec = util.call_method1("spec_from_file_location", (&module_name, file))?; + let module = util.call_method1("module_from_spec", (spec.clone(),))?; + // Note: register in sys.modules AFTER exec_module finishes. Heavy imports + // (e.g. numpy / scipy) inside the loaded module release the GIL, which + // would otherwise let another rayon worker thread observe a half-loaded + // module and call `getattr` for the user-defined class before it is defined. + spec.getattr("loader")?.call_method1("exec_module", (module.clone(),))?; + modules.set_item(&module_name, module.clone())?; + Ok(module) +} + +/// Load (or re-fetch) a Python module from an in-memory source string. +/// +/// Companion to [`get_or_load_module`] for callers that carry their own +/// source (e.g. the compile-time-embedded builtin samplers registry). +/// ``virtual_filename`` is used as ``__file__`` on the module and as the +/// filename shown in Python tracebacks, so pick something descriptive +/// like ``"@qdk_sampler"`` rather than ``""``. +/// +/// The module is registered in ``sys.modules`` under a deterministic +/// key derived from ``virtual_filename`` so subsequent lookups reuse +/// the already-loaded module. +pub(crate) fn get_or_load_module_from_source<'py>( + py: Python<'py>, + virtual_filename: &str, + source: &str, +) -> PyResult> { + let module_name = format!( + "deq_python_module_{}", + virtual_filename.replace(|c: char| !c.is_ascii_alphanumeric(), "_") + ); + let sys = py.import("sys")?; + let modules = sys.getattr("modules")?; + if let Ok(existing) = modules.get_item(&module_name) { + return Ok(existing); + } + let types = py.import("types")?; + let module = types.getattr("ModuleType")?.call1((&module_name,))?; + module.setattr("__file__", virtual_filename)?; + let builtins = py.import("builtins")?; + let code = builtins.getattr("compile")?.call1((source, virtual_filename, "exec"))?; + let dict = module.getattr("__dict__")?; + // Same ordering as `get_or_load_module`: exec the module body + // BEFORE registering in sys.modules so half-loaded modules can't + // leak to other worker threads mid-import. + builtins.getattr("exec")?.call1((code, dict))?; + modules.set_item(&module_name, module.clone())?; + Ok(module) +} + +/// Convert a [`serde_json::Value`] directly into a Python object. +/// +/// We don't use the `pythonize` crate here because this crate enables +/// `serde_json/arbitrary_precision`, under which `serde_json::Number` is +/// serialized as the sentinel map `{"$serde_json::private::Number": "10"}` +/// instead of a plain integer. That sentinel breaks any downstream Python +/// callee that expects a real `int` or `float` (e.g. pybind11-bound C++ +/// constructors with strict type checks). Walking the `Value` tree manually +/// lets us emit native Python scalars. +pub(crate) fn json_value_to_py<'py>(py: Python<'py>, value: &serde_json::Value) -> PyResult> { + match value { + serde_json::Value::Null => Ok(py.None().into_bound(py)), + serde_json::Value::Bool(b) => Ok(pyo3::types::PyBool::new(py, *b).to_owned().into_any()), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Ok(i.into_pyobject(py)?.into_any()) + } else if let Some(u) = n.as_u64() { + Ok(u.into_pyobject(py)?.into_any()) + } else if let Some(f) = n.as_f64() { + Ok(f.into_pyobject(py)?.into_any()) + } else { + // Arbitrary-precision number that doesn't fit any of the above: + // fall back to a Python string so the callee can convert. + Ok(n.to_string().into_pyobject(py)?.into_any()) + } + } + serde_json::Value::String(s) => Ok(s.into_pyobject(py)?.into_any()), + serde_json::Value::Array(items) => { + let list = PyList::empty(py); + for item in items { + list.append(json_value_to_py(py, item)?)?; + } + Ok(list.into_any()) + } + serde_json::Value::Object(map) => { + let dict = PyDict::new(py); + for (key, val) in map { + dict.set_item(key, json_value_to_py(py, val)?)?; + } + Ok(dict.into_any()) + } + } +} diff --git a/deq/deq_runtime/src/proto/deq.controller.static_controller.rs b/deq/deq_runtime/src/proto/deq.controller.static_controller.rs index e5f87f40..1d14c769 100644 --- a/deq/deq_runtime/src/proto/deq.controller.static_controller.rs +++ b/deq/deq_runtime/src/proto/deq.controller.static_controller.rs @@ -91,15 +91,24 @@ pub mod static_controller_client { self.inner = self.inner.max_encoding_message_size(limit); self } - /// input the measurement outcomes and return the decoded logical readouts; - /// the number of bits returned is the sum of the gadgets that are filled by - /// the input measurement outcomes. In a special case, if all measurements - /// are provided, then all logical readouts are returned. + /// Stream measurement outcomes (and optionally per-measurement loss flags) into + /// the controller and return any decoded logical readouts that have become + /// available. The bits in `Outcomes.outcomes` are accumulated across calls; + /// when enough bits arrive to complete a gadget, that gadget is dispatched to + /// the coordinator and its readouts are gathered. Callers stream the entire + /// shot across one or more calls; the response carries readouts for every + /// gadget that has finished by the time the final batch is received (the + /// empty-readouts response of intermediate calls is also valid). The + /// `Outcomes.gid` field is ignored by this controller — the static program + /// determines gadget order — and `modifiers` is currently unused. + /// `loss_mask`, when present, must have the same length as `outcomes`; + /// it is forwarded verbatim to the coordinator (and on to the decoder) but + /// not interpreted here. pub async fn decode( &mut self, - request: impl tonic::IntoRequest, + request: impl tonic::IntoRequest, ) -> std::result::Result< - tonic::Response, + tonic::Response, tonic::Status, > { self.inner @@ -166,15 +175,24 @@ pub mod static_controller_server { /// Generated trait containing gRPC methods that should be implemented for use with StaticControllerServer. #[async_trait] pub trait StaticController: std::marker::Send + std::marker::Sync + 'static { - /// input the measurement outcomes and return the decoded logical readouts; - /// the number of bits returned is the sum of the gadgets that are filled by - /// the input measurement outcomes. In a special case, if all measurements - /// are provided, then all logical readouts are returned. + /// Stream measurement outcomes (and optionally per-measurement loss flags) into + /// the controller and return any decoded logical readouts that have become + /// available. The bits in `Outcomes.outcomes` are accumulated across calls; + /// when enough bits arrive to complete a gadget, that gadget is dispatched to + /// the coordinator and its readouts are gathered. Callers stream the entire + /// shot across one or more calls; the response carries readouts for every + /// gadget that has finished by the time the final batch is received (the + /// empty-readouts response of intermediate calls is also valid). The + /// `Outcomes.gid` field is ignored by this controller — the static program + /// determines gadget order — and `modifiers` is currently unused. + /// `loss_mask`, when present, must have the same length as `outcomes`; + /// it is forwarded verbatim to the coordinator (and on to the decoder) but + /// not interpreted here. async fn decode( &self, - request: tonic::Request, + request: tonic::Request, ) -> std::result::Result< - tonic::Response, + tonic::Response, tonic::Status, >; /// reset the system (but keep the loaded library) @@ -264,16 +282,19 @@ pub mod static_controller_server { struct DecodeSvc(pub Arc); impl< T: StaticController, - > tonic::server::UnaryService - for DecodeSvc { - type Response = super::super::super::util::BitVector; + > tonic::server::UnaryService< + super::super::super::coordinator::Outcomes, + > for DecodeSvc { + type Response = super::super::super::coordinator::Readouts; type Future = BoxFuture< tonic::Response, tonic::Status, >; fn call( &mut self, - request: tonic::Request, + request: tonic::Request< + super::super::super::coordinator::Outcomes, + >, ) -> Self::Future { let inner = Arc::clone(&self.0); let fut = async move { diff --git a/deq/deq_runtime/src/proto/deq.coordinator.rs b/deq/deq_runtime/src/proto/deq.coordinator.rs index 2fab6f2e..8855a156 100644 --- a/deq/deq_runtime/src/proto/deq.coordinator.rs +++ b/deq/deq_runtime/src/proto/deq.coordinator.rs @@ -49,6 +49,15 @@ pub struct Outcomes { /// committed part or not?) #[prost(message, repeated, tag = "3")] pub modifiers: ::prost::alloc::vec::Vec, + /// optional per-measurement loss flags, one bit per measurement in `outcomes`. + /// A set bit means the corresponding qubit was lost during that measurement, + /// and the bit in `outcomes` is therefore unreliable (typically a random + /// substitute filled in by the simulator). When absent, no loss information + /// is available for this batch and the decoder should treat every measurement + /// bit as reliable. Decoders are free to ignore this field; no decoder is + /// required to consume it. + #[prost(message, optional, tag = "4")] + pub loss_mask: ::core::option::Option, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct Readouts { diff --git a/deq/deq_runtime/src/proto/deq.simulator.rs b/deq/deq_runtime/src/proto/deq.simulator.rs index c84e038e..d40a35b5 100644 --- a/deq/deq_runtime/src/proto/deq.simulator.rs +++ b/deq/deq_runtime/src/proto/deq.simulator.rs @@ -11,4 +11,15 @@ pub struct ShotSample { #[prost(message, optional, tag = "1")] pub outcomes: ::core::option::Option, + /// Optional per-measurement loss flags, one bit per measurement in + /// `outcomes`. A set bit means the corresponding qubit was lost + /// during that measurement, so the bit in `outcomes` is a randomized + /// substitute filled in by the simulator and should be treated as + /// unreliable. Absent (or zero-length) when the sampler cannot + /// distinguish loss from a regular outcome — which is the case for + /// every Stim-native sampler today; only loss-aware samplers such as + /// `--simulator python` driving `qdk.stim` populate this field. + /// Mirrors the `loss_mask` field on :message:`deq.coordinator.Outcomes`. + #[prost(message, optional, tag = "2")] + pub loss_mask: ::core::option::Option, } diff --git a/deq/deq_runtime/src/simulator.rs b/deq/deq_runtime/src/simulator.rs index fcdaedb3..8d4d590a 100644 --- a/deq/deq_runtime/src/simulator.rs +++ b/deq/deq_runtime/src/simulator.rs @@ -21,6 +21,12 @@ pub enum SimulatorType { JitStatic, /// a simulator that drives TableauSimulator with retry-from-BEGIN semantics Preselect, + /// a simulator that draws each shot's measurements from a user-supplied + /// Python sampler (e.g. wrapping ``qdk.stim.run``); loss outcomes + /// (returned as ``-`` by the Python adapter) are replaced with + /// uniformly random bits so the decoder protocol is unchanged. + #[cfg(all(feature = "simulator", feature = "python"))] + Python, } pub mod common; @@ -30,6 +36,10 @@ pub mod jit_static_simulator; pub mod preselect_directives; #[cfg(feature = "simulator")] pub mod preselect_simulator; +#[cfg(all(feature = "simulator", feature = "python"))] +pub mod python_sampler; +#[cfg(all(feature = "simulator", feature = "python"))] +pub mod python_simulator; #[cfg(feature = "simulator")] pub mod rhai_assert; #[cfg(feature = "simulator")] @@ -42,6 +52,8 @@ pub mod tableau_preselect_sampler; pub use jit_static_simulator::JitStaticSimulator; #[cfg(feature = "simulator")] pub use preselect_simulator::PreselectSimulator; +#[cfg(all(feature = "simulator", feature = "python"))] +pub use python_simulator::PythonSimulator; #[cfg(feature = "simulator")] pub use static_simulator::StaticSimulator; @@ -83,6 +95,8 @@ impl SimulatorType { Self::JitStatic => DynSimulator::JitStatic(Box::new(JitStaticSimulator::new(config))), #[cfg(feature = "simulator")] Self::Preselect => DynSimulator::Preselect(Box::new(PreselectSimulator::new(config))), + #[cfg(all(feature = "simulator", feature = "python"))] + Self::Python => DynSimulator::Python(Box::new(PythonSimulator::new(config))), #[cfg(not(feature = "simulator"))] Self::Static | Self::JitStatic | Self::Preselect => { let _ = config; @@ -95,9 +109,14 @@ impl SimulatorType { pub fn config_help() -> String { #[cfg(feature = "simulator")] { + #[cfg(feature = "python")] + let python_help = help_message::("PythonSimulatorConfig:"); + #[cfg(not(feature = "python"))] + let python_help = String::new(); help_message::("StaticSimulatorConfig:") + &*help_message::("JitStaticSimulatorConfig:") + &*help_message::("PreselectSimulatorConfig:") + + &*python_help } #[cfg(not(feature = "simulator"))] String::new() @@ -117,6 +136,8 @@ pub enum DynSimulator { JitStatic(Box), #[cfg(feature = "simulator")] Preselect(Box), + #[cfg(all(feature = "simulator", feature = "python"))] + Python(Box), } impl DynSimulator { @@ -139,6 +160,10 @@ impl DynSimulator { DynSimulator::Preselect(simulator) => { simulator.start(endpoint, shutdown_signal).await; } + #[cfg(all(feature = "simulator", feature = "python"))] + DynSimulator::Python(simulator) => { + simulator.start(endpoint, shutdown_signal).await; + } } } } diff --git a/deq/deq_runtime/src/simulator/common.rs b/deq/deq_runtime/src/simulator/common.rs index 621d72b2..9e52eb01 100644 --- a/deq/deq_runtime/src/simulator/common.rs +++ b/deq/deq_runtime/src/simulator/common.rs @@ -335,6 +335,12 @@ pub struct ErrorSet { /// (index of the marginal, index of the error in the marginal) pub errors: Vec<(usize, usize)>, pub measurements: BitVector, + /// Optional per-measurement loss mask, one bit per measurement in + /// `measurements`. A set bit means the corresponding measurement bit is + /// a randomized substitute for a lost-qubit outcome. `None` when the + /// sampler cannot distinguish loss from a regular outcome (which is the + /// case for every loss-unaware sampler today). + pub loss_mask: Option, } /// Trait for measurement samplers used by the simulation loop. @@ -656,6 +662,7 @@ impl StimSampler { size: measurements_bool.len() as u64, data: bit_vector::pack_bits(&measurements_bool), }, + loss_mask: None, } } } @@ -691,6 +698,7 @@ impl Sampler for StimSampler { pub fn error_set_to_shot_sample(sample: &ErrorSet) -> crate::simulator::ShotSample { crate::simulator::ShotSample { outcomes: Some(sample.measurements.clone()), + loss_mask: sample.loss_mask.clone(), } } @@ -727,4 +735,37 @@ mod tests { ); println!("Filtered {filtered} samples out of 20 successful shots"); } + + #[test] + fn error_set_to_shot_sample_propagates_loss_mask() { + let measurements = BitVector { + size: 4, + data: vec![0b1010_0000], + }; + let loss_mask = BitVector { + size: 4, + data: vec![0b0100_0000], + }; + + let with_loss = ErrorSet { + errors: vec![], + measurements: measurements.clone(), + loss_mask: Some(loss_mask.clone()), + }; + let shot = error_set_to_shot_sample(&with_loss); + assert_eq!(shot.outcomes.as_ref(), Some(&measurements)); + assert_eq!(shot.loss_mask.as_ref(), Some(&loss_mask)); + + let without_loss = ErrorSet { + errors: vec![], + measurements: measurements.clone(), + loss_mask: None, + }; + let shot = error_set_to_shot_sample(&without_loss); + assert_eq!(shot.outcomes.as_ref(), Some(&measurements)); + assert!( + shot.loss_mask.is_none(), + "loss_mask should stay absent when the sampler did not produce one" + ); + } } diff --git a/deq/deq_runtime/src/simulator/jit_static_simulator.rs b/deq/deq_runtime/src/simulator/jit_static_simulator.rs index a8c2e9a5..3cd4d1e1 100644 --- a/deq/deq_runtime/src/simulator/jit_static_simulator.rs +++ b/deq/deq_runtime/src/simulator/jit_static_simulator.rs @@ -230,6 +230,7 @@ impl DecoderClient for JitDecoderClient { gid, outcomes: Some(gadget_measurements), modifiers: vec![], + loss_mask: None, }; let response = client.decode(outcomes).await.unwrap().into_inner(); (index, response.readouts) diff --git a/deq/deq_runtime/src/simulator/preselect_simulator.rs b/deq/deq_runtime/src/simulator/preselect_simulator.rs index e6616f8f..a8c7fd0e 100644 --- a/deq/deq_runtime/src/simulator/preselect_simulator.rs +++ b/deq/deq_runtime/src/simulator/preselect_simulator.rs @@ -6,6 +6,8 @@ #[cfg(feature = "cli")] use crate::controller::static_controller::static_controller_client::StaticControllerClient; #[cfg(feature = "cli")] +use crate::coordinator; +#[cfg(feature = "cli")] use crate::misc::bit_vector; use crate::simulator::DeterministicRng; use crate::simulator::common::{CommonSimulatorConfig, DelayBatch, Sampler}; @@ -126,12 +128,23 @@ impl DecoderClient for PreselectDecoderClient { if self.delay_schedule.is_empty() { let t0 = std::time::Instant::now(); - let readouts = client.decode(sample.measurements.clone()).await.unwrap().into_inner(); + let response = client + .decode(coordinator::Outcomes { + gid: 0, + outcomes: Some(sample.measurements.clone()), + modifiers: vec![], + loss_mask: sample.loss_mask.clone(), + }) + .await + .unwrap() + .into_inner(); self.last_latency_secs = t0.elapsed().as_secs_f64(); - return Some(readouts); + return Some(response.readouts.unwrap()); } let all_bits = bit_vector::unpack_bits(&sample.measurements.data, sample.measurements.size); + let all_loss_bits: Option> = + sample.loss_mask.as_ref().map(|bv| bit_vector::unpack_bits(&bv.data, bv.size)); let mut accumulated_readouts: Vec = Vec::new(); let mut prev_count = 0usize; let n_batches = self.delay_schedule.len(); @@ -152,13 +165,30 @@ impl DecoderClient for PreselectDecoderClient { size: slice.len() as u64, data: bit_vector::pack_bits(slice), }; + let partial_loss = all_loss_bits.as_ref().map(|lb| { + let loss_slice = &lb[prev_count..end]; + BitVector { + size: loss_slice.len() as u64, + data: bit_vector::pack_bits(loss_slice), + } + }); prev_count = end; let t0 = std::time::Instant::now(); - let readouts = client.decode(partial).await.unwrap().into_inner(); + let response = client + .decode(coordinator::Outcomes { + gid: 0, + outcomes: Some(partial), + modifiers: vec![], + loss_mask: partial_loss, + }) + .await + .unwrap() + .into_inner(); if i == n_batches - 1 { self.last_latency_secs = t0.elapsed().as_secs_f64(); } + let readouts = response.readouts.unwrap(); let bits = bit_vector::unpack_bits(&readouts.data, readouts.size); accumulated_readouts.extend_from_slice(&bits); } diff --git a/deq/deq_runtime/src/simulator/python_sampler.rs b/deq/deq_runtime/src/simulator/python_sampler.rs new file mode 100644 index 00000000..e4fbd0f5 --- /dev/null +++ b/deq/deq_runtime/src/simulator/python_sampler.rs @@ -0,0 +1,254 @@ +//! Python sampler +//! +//! Calls a sampler implemented in Python with the following protocol: +//! +//! ```python +//! class Sampler: +//! def __init__(self, circuit_text: str, config: dict) -> None: ... +//! def sample(self) -> str: +//! """Return one shot as a length-N string of '0', '1', or '-' chars, +//! where N == circuit.num_measurements(). '-' means the qubit was +//! lost during the corresponding measurement; the Rust side packs a +//! placeholder `false` bit at that position and sets the +//! corresponding bit of `ErrorSet.loss_mask` to 1. The coordinator +//! then decides what to do with those flagged bits (random +//! imputation by default; see `apply_loss_random_imputation`). +//! ('-' is used rather than 'L' because 'L' and '1' look nearly +//! identical in a fixed-width string.) +//! """ +//! ``` +//! +//! The class name defaults to ``Sampler`` and can be overridden via the +//! ``name`` field in the sampler JSON config. Constructor ``config`` is +//! the user-supplied ``py_config`` dictionary augmented with the simulator +//! ``seed``, ``skip_shots`` and ``num_measurements`` so that the adapter +//! can seed its own RNG and pre-advance shots if desired. +//! +//! ## Loss handling +//! +//! The Rust side maps each ``'-'`` to a placeholder `false` bit in +//! ``ErrorSet.measurements`` and sets the corresponding bit of +//! ``ErrorSet.loss_mask`` to 1. The actual decision of what to do with +//! lost bits — random imputation, erasure handling, etc. — lives in the +//! coordinator (see ``coordinator::apply_loss_random_imputation``). That +//! way any future loss-aware sampler "just works": it only needs to emit +//! correct ``loss_mask`` bits and a sensible placeholder; the coordinator +//! handles the rest. +use crate::misc::bit_vector; +use crate::misc::python::{get_or_load_module, get_or_load_module_from_source, json_value_to_py}; +use crate::simulator::DeterministicRng; +use crate::simulator::common::{ErrorSet, Sampler}; +use crate::util::BitVector; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use serde::{Deserialize, Serialize}; +use std::sync::Mutex; +#[cfg(feature = "cli")] +use structdoc::StructDoc; + +/// Compile-time-embedded Python sampler adapters. +/// +/// When a [`PythonSamplerConfig::sampler`] value starts with `@`, the +/// string after the `@` is looked up here instead of being treated as +/// a filesystem path. The registry maps the short name to Python +/// source baked into the ``deq_runtime`` binary, so callers never need +/// to know where the adapter lives on disk (or ship it alongside +/// their program). The `@` prefix is reserved for this: no filesystem +/// path starting with `@` will be opened by the sampler; use +/// ``./@name.py`` or an absolute path to load such a file. +mod builtin_samplers { + /// Return `(virtual_filename, source_code)` for a named builtin, or + /// `None` if the name is unknown. ``virtual_filename`` is what + /// Python tracebacks display (typically the `@name` sentinel). + pub fn lookup(name: &str) -> Option<(&'static str, &'static str)> { + match name { + "qdk_sampler" => Some(("@qdk_sampler", include_str!("qdk_sampler.py"))), + _ => None, + } + } + + /// All known builtin sampler names (without the leading `@`). + pub fn names() -> &'static [&'static str] { + &["qdk_sampler"] + } +} + +/// Configuration for a Python-backed sampler. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "cli", derive(StructDoc))] +pub struct PythonSamplerConfig { + /// Where to find the Python sampler. + /// + /// * A filesystem path to a ``*.py`` file. + /// * Or a ``@name`` sentinel that resolves to a compile-time-embedded + /// adapter in the [`builtin_samplers`] registry above (e.g. + /// ``@qdk_sampler``). The ``@`` prefix is reserved and never + /// opens a real file. + pub sampler: String, + /// The name of the sampler class inside the Python file; defaults to ``Sampler``. + #[serde(default = "default_class_name")] + pub name: String, + /// Arbitrary Python sampler parameters; forwarded as the second + /// argument to the Python class constructor. The simulator-level + /// ``seed``, ``skip_shots`` and ``num_measurements`` fields are + /// auto-merged in unless already present. + #[cfg_attr(feature = "cli", structdoc(skip))] + #[serde(default)] + pub py_config: Option, +} + +fn default_class_name() -> String { + "Sampler".to_string() +} + +/// A sampler that forwards each shot request to a Python ``sample()`` method. +/// +/// The Python object is held behind a [`Mutex`] so the sampler can implement +/// [`Sampler`] with `&self`. Each call: +/// +/// 1. Acquires the GIL via [`Python::attach`]. +/// 2. Calls ``instance.sample()`` and extracts a ``str``. +/// 3. Maps each char to a bool: ``'0' -> false``, ``'1' -> true``, +/// ``'-' -> false`` (placeholder; the coordinator decides what to do +/// with the corresponding ``loss_mask`` bit). +/// 4. Packs the bools into a [`BitVector`] and returns an [`ErrorSet`]. +pub struct PythonSampler { + instance: Mutex>, + num_measurements: usize, +} + +impl PythonSampler { + /// Construct a new Python sampler. + /// + /// `circuit_text` is passed verbatim as the first constructor argument. + /// `seed`, `skip_shots`, and `num_measurements` are merged into the + /// `py_config` dictionary (without overwriting user-provided keys) so + /// the Python adapter can seed its own RNG and validate the expected + /// shot length. + pub fn new( + circuit_text: &str, + config: &PythonSamplerConfig, + seed: u64, + skip_shots: usize, + num_measurements: usize, + ) -> Self { + let mut py_cfg_json = config.py_config.clone().unwrap_or_else(|| serde_json::json!({})); + if let serde_json::Value::Object(ref mut map) = py_cfg_json { + map.entry("seed".to_string()).or_insert(serde_json::json!(seed)); + map.entry("skip_shots".to_string()).or_insert(serde_json::json!(skip_shots)); + map.entry("num_measurements".to_string()) + .or_insert(serde_json::json!(num_measurements)); + } else { + panic!("py_config must be a JSON object, got: {py_cfg_json}"); + } + + let instance = Python::attach(|py| -> PyResult> { + let module = if let Some(builtin_name) = config.sampler.strip_prefix('@') { + let (fname, source) = builtin_samplers::lookup(builtin_name).ok_or_else(|| { + let known = builtin_samplers::names() + .iter() + .map(|n| format!("@{n}")) + .collect::>() + .join(", "); + PyValueError::new_err(format!("unknown builtin sampler '@{builtin_name}'. Known builtins: {known}")) + })?; + get_or_load_module_from_source(py, fname, source)? + } else { + get_or_load_module(py, &config.sampler)? + }; + let sampler_class = module.getattr(config.name.as_str())?; + let py_cfg = json_value_to_py(py, &py_cfg_json)?; + let inst = sampler_class.call1((circuit_text, py_cfg))?; + Ok(inst.unbind()) + }) + .expect("failed to construct Python sampler"); + + Self { + instance: Mutex::new(instance), + num_measurements, + } + } + + fn next_shot_string(&self) -> String { + let guard = self.instance.lock().expect("PythonSampler mutex poisoned"); + Python::attach(|py| -> PyResult { + let inst = guard.bind(py); + let py_result = inst.call_method0("sample")?; + py_result.extract::() + }) + .expect("Python sampler.sample() raised an exception") + } +} + +impl Sampler for PythonSampler { + fn sample(&self, _rng: &mut DeterministicRng) -> ErrorSet { + let shot = self.next_shot_string(); + assert_eq!( + shot.chars().count(), + self.num_measurements, + "Python sampler returned {} chars, expected {} measurement chars", + shot.chars().count(), + self.num_measurements + ); + + // Build `measurements` and `loss_flags` side by side. Every `'-'` + // contributes a placeholder `false` to `measurements` and a `true` + // to `loss_flags`; the coordinator is responsible for replacing + // those placeholder bits with random bits via its + // `loss_random_imputation` policy. Doing the imputation at the + // coordinator (rather than here at the sampler) means any future + // loss-aware sampler just emits the `loss_mask` and "just works", + // and loss-aware decoders can opt out of imputation entirely. + let mut bits: Vec = Vec::with_capacity(self.num_measurements); + let mut loss_flags: Vec = Vec::with_capacity(self.num_measurements); + for c in shot.chars() { + match c { + '0' => { + bits.push(false); + loss_flags.push(false); + } + '1' => { + bits.push(true); + loss_flags.push(false); + } + '-' => { + bits.push(false); + loss_flags.push(true); + } + other => panic!("Python sampler returned invalid char {other:?} (expected '0', '1', or '-')"), + } + } + + ErrorSet { + errors: vec![], + measurements: BitVector { + size: bits.len() as u64, + data: bit_vector::pack_bits(&bits), + }, + loss_mask: Some(BitVector { + size: loss_flags.len() as u64, + data: bit_vector::pack_bits(&loss_flags), + }), + } + } + + fn sample_single_error(&self, _index: usize) -> ErrorSet { + panic!("sample_single_error is not supported for PythonSampler") + } + + fn count_single_error(&self) -> usize { + panic!("count_single_error is not supported for PythonSampler") + } + + fn readouts_match(&self, _actual: &BitVector, _expected: &BitVector) -> bool { + // Loss-as-flip discards expected-readout information; treat every + // shot as decode-only (no logical-error comparison) by default. + // Users who want logical-error tracking should supply a Rhai + // ``is_logical_error`` script via ``logical_assert_filepath``. + true + } + + fn error_tag(&self, _marginal_index: usize, _error_index: usize) -> &str { + "" + } +} diff --git a/deq/deq_runtime/src/simulator/python_simulator.rs b/deq/deq_runtime/src/simulator/python_simulator.rs new file mode 100644 index 00000000..e3a90adc --- /dev/null +++ b/deq/deq_runtime/src/simulator/python_simulator.rs @@ -0,0 +1,236 @@ +//! Python Simulator +//! +//! A simulator that draws each shot's measurements from a user-supplied +//! Python sampler (e.g. wrapping `qdk.stim.run`) and feeds the result to +//! the standard static decoder controller — identical wire protocol to +//! [`StaticSimulator`], identical decoder-side handling. +//! +//! Loss-as-flip is applied inside [`PythonSampler`]: any ``'-'`` returned +//! by the Python sampler is replaced with a uniformly random bit before +//! the measurement record is packed. No protocol change reaches the +//! decoder. +//! +//! [`StaticSimulator`]: crate::simulator::static_simulator::StaticSimulator +//! [`PythonSampler`]: crate::simulator::python_sampler::PythonSampler + +#[cfg(feature = "cli")] +use crate::controller::static_controller::static_controller_client::StaticControllerClient; +#[cfg(feature = "cli")] +use crate::coordinator; +#[cfg(feature = "cli")] +use crate::misc::bit_vector; +use crate::simulator::DeterministicRng; +use crate::simulator::common::{CommonSimulatorConfig, DelayBatch, Sampler}; +#[cfg(feature = "cli")] +use crate::simulator::common::{DecoderClient, ErrorSet, run_simulation_loop}; +use crate::simulator::python_sampler::{PythonSampler, PythonSamplerConfig}; +#[cfg(feature = "cli")] +use crate::util::BitVector; +use rand::{Rng, SeedableRng}; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "cli")] +use structdoc::StructDoc; +#[cfg(feature = "cli")] +use tokio::sync::oneshot::Sender; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "cli", derive(StructDoc))] +#[serde(deny_unknown_fields)] +pub struct PythonSimulatorConfig { + /// the filepath to a Stim circuit file + pub filepath: String, + /// the Python sampler configuration (forwarded to the Python class constructor) + #[serde(flatten)] + pub sampler: PythonSamplerConfig, + /// common simulation configuration + #[serde(flatten)] + pub common: CommonSimulatorConfig, +} + +pub struct PythonSimulator { + pub config: PythonSimulatorConfig, + pub rng: DeterministicRng, + pub sampler: Box, + #[cfg_attr(not(feature = "cli"), allow(dead_code))] + delay_schedule: Vec, + #[cfg_attr(not(feature = "cli"), allow(dead_code))] + embedded_rhai_script: Option, +} + +impl PythonSimulator { + pub fn new(config: serde_json::Value) -> Self { + let config: PythonSimulatorConfig = serde_json::from_value(config).unwrap(); + let seed: u64 = config.common.seed.unwrap_or_else(|| rand::rng().next_u64()); + + let circuit_text = std::fs::read_to_string(&config.filepath) + .unwrap_or_else(|e| panic!("Failed to read Stim circuit file '{}': {e}", config.filepath)); + let embedded_rhai_script = crate::simulator::rhai_assert::extract_rhai_script(&circuit_text); + + // Count measurements by scanning the text line-by-line rather than via + // the upstream `stim` crate. The python sampler is the plug-point for + // non-stim backends (e.g. QDK's stabilizer simulator with `LOSS_ERROR`), + // so we must not require that the Stim file be parseable by the upstream + // crate. `count_measurements` only needs to recognize the standard + // measurement-producing instruction names. + let num_measurements = crate::simulator::stim_delays::count_measurements(&circuit_text); + + // Strip `#!rhai` blocks before handing the circuit to the Python + // sampler: the sampler has no business with the logical-error + // assertion script. + let sampler_circuit_text = crate::simulator::rhai_assert::strip_rhai_scripts(&circuit_text); + + let sampler = PythonSampler::new( + &sampler_circuit_text, + &config.sampler, + seed, + config.common.skip_shots, + num_measurements, + ); + + let delay_schedule = crate::simulator::stim_delays::extract_delay_schedule(&circuit_text, num_measurements); + + Self { + config, + rng: DeterministicRng::seed_from_u64(seed), + sampler: Box::new(sampler), + delay_schedule, + embedded_rhai_script, + } + } + + #[cfg(feature = "cli")] + pub async fn start(mut self, endpoint: tonic::transport::Endpoint, shutdown: Sender<()>) { + let rhai_engine = crate::simulator::rhai_assert::RhaiAssertEngine::build( + &self.config.filepath, + self.embedded_rhai_script.as_deref(), + self.config.common.logical_assert_filepath.as_deref(), + ); + + let delay_schedule = if self.config.common.strict_timing { + self.delay_schedule.clone() + } else { + vec![] + }; + let mut client = PythonSimDecoderClient { + client: None, + endpoint, + delay_schedule, + last_latency_secs: 0.0, + }; + run_simulation_loop( + &self.config.common, + self.sampler.as_ref(), + &mut self.rng, + &mut client, + shutdown, + &rhai_engine, + ) + .await; + } +} + +#[cfg(feature = "cli")] +struct PythonSimDecoderClient { + client: Option>, + endpoint: tonic::transport::Endpoint, + delay_schedule: Vec, + last_latency_secs: f64, +} + +#[cfg(feature = "cli")] +impl DecoderClient for PythonSimDecoderClient { + async fn initialize(&mut self) -> Result<(), Box> { + self.client = Some(StaticControllerClient::connect(self.endpoint.clone()).await?); + Ok(()) + } + + async fn decode(&mut self, sample: &ErrorSet) -> Option { + let client = self.client.as_mut().unwrap(); + + if self.delay_schedule.is_empty() { + let t0 = std::time::Instant::now(); + let response = client + .decode(coordinator::Outcomes { + gid: 0, + outcomes: Some(sample.measurements.clone()), + modifiers: vec![], + loss_mask: sample.loss_mask.clone(), + }) + .await + .unwrap() + .into_inner(); + self.last_latency_secs = t0.elapsed().as_secs_f64(); + return Some(response.readouts.unwrap()); + } + + let all_bits = bit_vector::unpack_bits(&sample.measurements.data, sample.measurements.size); + let all_loss_bits: Option> = + sample.loss_mask.as_ref().map(|bv| bit_vector::unpack_bits(&bv.data, bv.size)); + let mut accumulated_readouts: Vec = Vec::new(); + let mut prev_count = 0usize; + let n_batches = self.delay_schedule.len(); + + for (i, batch) in self.delay_schedule.iter().enumerate() { + let sleep_duration = if i == 0 { + batch.delay_seconds + } else { + batch.delay_seconds - self.delay_schedule[i - 1].delay_seconds + }; + if sleep_duration > 0.0 { + tokio::time::sleep(std::time::Duration::from_secs_f64(sleep_duration)).await; + } + + let end = batch.cumulative_count.min(all_bits.len()); + let slice = &all_bits[prev_count..end]; + let partial = BitVector { + size: slice.len() as u64, + data: bit_vector::pack_bits(slice), + }; + let partial_loss = all_loss_bits.as_ref().map(|lb| { + let loss_slice = &lb[prev_count..end]; + BitVector { + size: loss_slice.len() as u64, + data: bit_vector::pack_bits(loss_slice), + } + }); + prev_count = end; + + let t0 = std::time::Instant::now(); + let response = client + .decode(coordinator::Outcomes { + gid: 0, + outcomes: Some(partial), + modifiers: vec![], + loss_mask: partial_loss, + }) + .await + .unwrap() + .into_inner(); + if i == n_batches - 1 { + self.last_latency_secs = t0.elapsed().as_secs_f64(); + } + let readouts = response.readouts.unwrap(); + let bits = bit_vector::unpack_bits(&readouts.data, readouts.size); + accumulated_readouts.extend_from_slice(&bits); + } + + Some(BitVector { + size: accumulated_readouts.len() as u64, + data: bit_vector::pack_bits(&accumulated_readouts), + }) + } + + async fn reset(&mut self) -> Result<(), Box> { + let client = self.client.as_mut().unwrap(); + client.reset(()).await?; + Ok(()) + } + + fn simulator_name(&self) -> &'static str { + "python_simulator" + } + + fn last_decode_latency_secs(&self) -> f64 { + self.last_latency_secs + } +} diff --git a/deq/deq_runtime/src/simulator/qdk_sampler.py b/deq/deq_runtime/src/simulator/qdk_sampler.py new file mode 100644 index 00000000..0354da63 --- /dev/null +++ b/deq/deq_runtime/src/simulator/qdk_sampler.py @@ -0,0 +1,157 @@ +"""Python sampler wrapper around the public ``qdk`` PyPI package. + +Exposes the deq Python sampler protocol: + + class Sampler: + def __init__(self, circuit_text: str, config: dict): ... + def sample(self) -> str: + '''Return one shot as a length-N string of '0', '1', or '-' chars.''' + +The Stim circuit text is compiled to QIR + noise inside ``qdk.stim.compile`` +and executed with the loss-aware Clifford simulator +(``qdk.stim.run(..., type="clifford")``). ``qdk.stim.run`` returns +``List[List[Result]]`` where ``Result`` is a Rust-bound enum with members +``Zero``, ``One``, ``Loss``; this adapter converts each shot to a +length-N string of ``'0'``, ``'1'``, ``'-'`` characters before returning +it. The deq Rust sampler then replaces each ``'-'`` with a uniformly random bit +drawn from its deterministic RNG before feeding the shot to the decoder. + +The ``config`` dictionary may contain: + +* ``seed`` (auto-injected by the Rust sampler): base seed for shot batches. + Each refill uses ``seed + batch_index`` so successive batches draw fresh + shots while remaining reproducible. +* ``skip_shots`` (auto-injected): number of shots to discard at the start. +* ``num_measurements`` (auto-injected): expected shot length (sanity check). +* ``batch_size`` (default: 256): how many shots to draw per ``qdk.stim.run`` + call. Larger values amortize Python call overhead at the cost of memory. +* ``type`` (default: ``"clifford"``): forwarded to ``qdk.stim.run``. + Use ``"cpu"`` for non-Clifford circuits. + +Invocation options +------------------ + +**Recommended: the ``@qdk_sampler`` builtin sentinel.** This module is +compiled into the ``deq_runtime`` binary via a small ``builtin_samplers`` +registry inside ``python_sampler.rs``, and the ``PythonSampler`` config +field ``sampler`` resolves any value beginning with ``@`` from that +registry instead of the filesystem. So the canonical invocation is:: + + python -m deq.runtime server \\ + --simulator python \\ + --simulator-config '{"filepath": "circuit.stim", "sampler": "@qdk_sampler", "py_config": {"batch_size": 1024}}' + +**Loading a local copy from disk.** Any ``sampler`` value that does not +start with ``@`` is opened as a filesystem path — useful when hacking +on a customized version of this adapter:: + + python -m deq.runtime server \\ + --simulator python \\ + --simulator-config '{"filepath": "circuit.stim", "sampler": "src/simulator/qdk_sampler.py", "py_config": {"batch_size": 1024}}' + +**Through a standalone Rust binary (development / tests).** When invoked +via ``cargo run`` or ``cargo test``, the binary is *not* an extension +module: pyo3 must link ``libpython`` itself, and the dynamic loader needs +to find it. Point ``LD_LIBRARY_PATH`` at the conda env's libdir:: + + LD_LIBRARY_PATH="$(python -c 'import sysconfig; print(sysconfig.get_config_var(\"LIBDIR\"))'):$LD_LIBRARY_PATH" \\ + cargo run --bin deq-runtime-cli --features simulator,python -- \\ + server \\ + --simulator python \\ + --simulator-config '{"filepath": "circuit.stim", "sampler": "@qdk_sampler", "py_config": {"batch_size": 1024}}' +""" + +from typing import Any, Dict, List + +import qdk.stim +from qdk._native import Result +from qdk.simulation import run_qir + +# Mapping from qdk Result enum to the single-char alphabet the deq Rust +# sampler expects. `Result` is a PyO3-bound class so hashing/equality +# is fast (just an internal discriminant compare). +_RESULT_CHAR: Dict[Result, str] = { + Result.Zero: "0", + Result.One: "1", + Result.Loss: "-", +} + + +def _shot_to_str(shot: List[Result]) -> str: + try: + return "".join(_RESULT_CHAR[r] for r in shot) + except KeyError as e: + raise RuntimeError( + f"qdk.stim.run returned an unknown Result value: {e.args[0]!r}. " + f"Expected one of {list(_RESULT_CHAR)}." + ) from None + + +class Sampler: + def __init__(self, circuit_text: str, config: Dict[str, Any]): + self._src = circuit_text + seed = config.get("seed") + self._base_seed = int(seed) if seed is not None else None + self._skip_shots = int(config.get("skip_shots", 0)) + self._num_measurements = int(config.get("num_measurements", 0)) + self._batch_size = int(config.get("batch_size", 256)) + self._kind = str(config.get("type", "clifford")) + + if self._batch_size <= 0: + raise ValueError(f"batch_size must be positive, got {self._batch_size}") + + # Compile once so every refill reuses the same QIR + NoiseConfig. + qir, noise = qdk.stim.compile(self._src, None) + self._qir = qir + self._noise = noise + + self._batch_index = 0 + self._buffer: List[str] = [] + + # Discard the first `skip_shots` results so the deq simulator's + # skip_shots semantics match what the user would see with the + # built-in stim sampler. `_refill()` leaves `self._buffer` in + # reverse order (so `.pop()` yields shots in their natural order), + # which means "drop the first N shots" == "pop N times from the + # end of the reversed buffer". + remaining_skip = self._skip_shots + while remaining_skip > 0: + if not self._buffer: + self._refill() + drop = min(remaining_skip, len(self._buffer)) + for _ in range(drop): + self._buffer.pop() + remaining_skip -= drop + + def _refill(self) -> None: + shot_seed = ( + self._base_seed + self._batch_index if self._base_seed is not None else None + ) + self._batch_index += 1 + # Use run_qir (not qdk.stim.run) because we already compiled the Stim + # text to QIR in __init__; qdk.stim.run would recompile every batch. + # The NoiseConfig produced by qdk.stim.compile is passed through verbatim. + # Returned value is List[List[Result]] -- one inner list per shot, + # each holding `num_measurements` Result enum values. + raw_shots = run_qir( + self._qir, + shots=self._batch_size, + noise=self._noise, + seed=shot_seed, + type=self._kind, + ) + shots = [_shot_to_str(shot) for shot in raw_shots] + # Reverse so .pop() yields shots in the order qdk returned them. + shots.reverse() + self._buffer = shots + + def sample(self) -> str: + if not self._buffer: + self._refill() + shot = self._buffer.pop() + if self._num_measurements and len(shot) != self._num_measurements: + raise RuntimeError( + f"qdk.stim.run returned a shot of length {len(shot)} " + f"but the Stim circuit declares {self._num_measurements} measurements" + ) + return shot diff --git a/deq/deq_runtime/src/simulator/rhai_assert.rs b/deq/deq_runtime/src/simulator/rhai_assert.rs index d651be94..647c8dc0 100644 --- a/deq/deq_runtime/src/simulator/rhai_assert.rs +++ b/deq/deq_runtime/src/simulator/rhai_assert.rs @@ -160,3 +160,34 @@ pub fn extract_rhai_script(stim_text: &str) -> Option { if script.is_empty() { None } else { Some(script) } } + +/// Return the Stim circuit text with every `#!rhai` script block removed. +/// +/// The `#!rhai` marker line and every subsequent `#`-prefixed line up to the +/// first non-`#` line (or EOF) are dropped. The result is a Stim circuit safe +/// to hand to a third-party Stim parser (e.g. QDK's) that doesn't recognize +/// deq's `#!rhai` extension — the parser would otherwise treat `#!rhai` as an +/// unknown instruction name. +/// +/// This is the mirror of [`extract_rhai_script`]: what `extract_rhai_script` +/// consumes is what `strip_rhai_scripts` drops. +pub fn strip_rhai_scripts(stim_text: &str) -> String { + let mut out = String::with_capacity(stim_text.len()); + let mut in_rhai_block = false; + for line in stim_text.lines() { + let trimmed = line.trim(); + if trimmed == "#!rhai" { + in_rhai_block = true; + continue; + } + if in_rhai_block { + if trimmed.starts_with('#') { + continue; + } + in_rhai_block = false; + } + out.push_str(line); + out.push('\n'); + } + out +} diff --git a/deq/deq_runtime/src/simulator/static_simulator.rs b/deq/deq_runtime/src/simulator/static_simulator.rs index ba19e05c..e979a0c6 100644 --- a/deq/deq_runtime/src/simulator/static_simulator.rs +++ b/deq/deq_runtime/src/simulator/static_simulator.rs @@ -1,6 +1,8 @@ #[cfg(feature = "cli")] use crate::controller::static_controller::static_controller_client::StaticControllerClient; #[cfg(feature = "cli")] +use crate::coordinator; +#[cfg(feature = "cli")] use crate::misc::bit_vector; use crate::simulator::DeterministicRng; use crate::simulator::common::{CommonSimulatorConfig, DelayBatch, Sampler, load_stim_circuit}; @@ -102,12 +104,23 @@ impl DecoderClient for StaticDecoderClient { if self.delay_schedule.is_empty() { let t0 = std::time::Instant::now(); - let readouts = client.decode(sample.measurements.clone()).await.unwrap().into_inner(); + let response = client + .decode(coordinator::Outcomes { + gid: 0, + outcomes: Some(sample.measurements.clone()), + modifiers: vec![], + loss_mask: sample.loss_mask.clone(), + }) + .await + .unwrap() + .into_inner(); self.last_latency_secs = t0.elapsed().as_secs_f64(); - return Some(readouts); + return Some(response.readouts.unwrap()); } let all_bits = bit_vector::unpack_bits(&sample.measurements.data, sample.measurements.size); + let all_loss_bits: Option> = + sample.loss_mask.as_ref().map(|bv| bit_vector::unpack_bits(&bv.data, bv.size)); let mut accumulated_readouts: Vec = Vec::new(); let mut prev_count = 0usize; let n_batches = self.delay_schedule.len(); @@ -128,13 +141,30 @@ impl DecoderClient for StaticDecoderClient { size: slice.len() as u64, data: bit_vector::pack_bits(slice), }; + let partial_loss = all_loss_bits.as_ref().map(|lb| { + let loss_slice = &lb[prev_count..end]; + BitVector { + size: loss_slice.len() as u64, + data: bit_vector::pack_bits(loss_slice), + } + }); prev_count = end; let t0 = std::time::Instant::now(); - let readouts = client.decode(partial).await.unwrap().into_inner(); + let response = client + .decode(coordinator::Outcomes { + gid: 0, + outcomes: Some(partial), + modifiers: vec![], + loss_mask: partial_loss, + }) + .await + .unwrap() + .into_inner(); if i == n_batches - 1 { self.last_latency_secs = t0.elapsed().as_secs_f64(); } + let readouts = response.readouts.unwrap(); let bits = bit_vector::unpack_bits(&readouts.data, readouts.size); accumulated_readouts.extend_from_slice(&bits); } diff --git a/deq/deq_runtime/src/simulator/stim_delays.rs b/deq/deq_runtime/src/simulator/stim_delays.rs index eef8d4ff..ddd34278 100644 --- a/deq/deq_runtime/src/simulator/stim_delays.rs +++ b/deq/deq_runtime/src/simulator/stim_delays.rs @@ -36,6 +36,38 @@ const MEASUREMENT_INSTRUCTIONS: &[&str] = &[ "M", "MZ", "MX", "MY", "MR", "MRX", "MRY", "MRZ", "MXX", "MYY", "MZZ", "MPP", "MPAD", ]; +/// Count measurement bits in a Stim circuit by scanning the text line by line. +/// +/// Unlike `stim::Circuit::num_measurements()`, this only requires that the +/// measurement-producing instruction *names* be recognized (`M`, `MR`, `MZ`, +/// etc.); arbitrary unrecognized instructions on other lines are silently +/// ignored. This makes the function safe to use on Stim files that contain +/// extensions not understood by the upstream `stim` crate (e.g. QDK's +/// `LOSS_ERROR`). +/// +/// # Panics +/// +/// - If a `REPEAT` instruction is encountered (we do not expand loops; the +/// measurement count would otherwise depend on dynamic state). +pub fn count_measurements(stim_text: &str) -> usize { + let mut measurement_count: usize = 0; + for line in stim_text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let instr_name = trimmed.split(|c: char| c.is_whitespace() || c == '(').next().unwrap_or(""); + if instr_name == "REPEAT" { + panic!("REPEAT blocks are not supported."); + } + let name_upper = instr_name.to_uppercase(); + if MEASUREMENT_INSTRUCTIONS.contains(&name_upper.as_str()) { + measurement_count += count_measurement_targets(trimmed, &name_upper); + } + } + measurement_count +} + /// Parse `#!delay` directives and measurement instructions from a Stim /// circuit's text to build a streaming delay schedule. /// diff --git a/deq/deq_runtime/src/simulator/tableau_preselect_sampler.rs b/deq/deq_runtime/src/simulator/tableau_preselect_sampler.rs index 941a4415..fe3d72a9 100644 --- a/deq/deq_runtime/src/simulator/tableau_preselect_sampler.rs +++ b/deq/deq_runtime/src/simulator/tableau_preselect_sampler.rs @@ -349,6 +349,7 @@ impl Sampler for TableauPreselectSampler { size: measurements_bool.len() as u64, data: bit_vector::pack_bits(&measurements_bool), }, + loss_mask: None, } } diff --git a/deq/deq_runtime/tests/python_sampler_test.rs b/deq/deq_runtime/tests/python_sampler_test.rs new file mode 100644 index 00000000..1042e6dc --- /dev/null +++ b/deq/deq_runtime/tests/python_sampler_test.rs @@ -0,0 +1,228 @@ +#![cfg(all(feature = "simulator", feature = "python"))] + +//! Unit tests for `PythonSampler`. +//! +//! Uses a tiny in-test Python sampler that returns canned shots so the +//! tests do not require any third-party Python package (e.g. ``qdk``) to +//! be installed in the embedded interpreter. + +use deq_runtime::misc::bit_vector; +use deq_runtime::simulator::DeterministicRng; +use deq_runtime::simulator::common::Sampler; +use deq_runtime::simulator::python_sampler::{PythonSampler, PythonSamplerConfig}; +use rand::SeedableRng; +use std::io::Write; + +const TEST_CIRCUIT: &str = "H 0\nCNOT 0 1\nM 0 1\n"; + +/// Write a Python sampler module to a tempfile and return both the +/// tempfile (kept alive for the duration of the test) and its path. +fn write_python_sampler(body: &str) -> (tempfile::NamedTempFile, String) { + let mut f = tempfile::Builder::new().suffix(".py").tempfile().expect("create tempfile"); + f.write_all(body.as_bytes()).expect("write tempfile"); + let path = f.path().to_string_lossy().into_owned(); + (f, path) +} + +fn make_sampler(py_body: &str, num_measurements: usize) -> (tempfile::NamedTempFile, PythonSampler) { + let (file, path) = write_python_sampler(py_body); + let config = PythonSamplerConfig { + sampler: path, + name: "Sampler".to_string(), + py_config: None, + }; + let sampler = PythonSampler::new(TEST_CIRCUIT, &config, 42, 0, num_measurements); + (file, sampler) +} + +const CANNED_NO_LOSS: &str = r#" +class Sampler: + def __init__(self, circuit_text, config): + self.shots = ["01", "10", "11", "00"] + self.i = 0 + + def sample(self): + shot = self.shots[self.i % len(self.shots)] + self.i += 1 + return shot +"#; + +const CANNED_ALL_LOSS: &str = r#" +class Sampler: + def __init__(self, circuit_text, config): + pass + + def sample(self): + return "--" +"#; + +const CANNED_MIXED: &str = r#" +class Sampler: + def __init__(self, circuit_text, config): + pass + + def sample(self): + return "1-" +"#; + +#[test] +fn sample_returns_expected_measurement_count() { + let (_file, sampler) = make_sampler(CANNED_NO_LOSS, 2); + let mut rng = DeterministicRng::seed_from_u64(0); + let s = sampler.sample(&mut rng); + assert_eq!(s.measurements.size, 2); +} + +#[test] +fn zero_and_one_chars_map_to_bits() { + let (_file, sampler) = make_sampler(CANNED_NO_LOSS, 2); + let mut rng = DeterministicRng::seed_from_u64(0); + + let expected = [[false, true], [true, false], [true, true], [false, false]]; + for row in expected { + let s = sampler.sample(&mut rng); + let bits = bit_vector::unpack_bits(&s.measurements.data, s.measurements.size); + assert_eq!(bits, row.to_vec()); + // No '-' chars in this canned shot → loss_mask is all-zero. + let loss_bv = s.loss_mask.as_ref().expect("PythonSampler always reports loss_mask"); + let loss_bits = bit_vector::unpack_bits(&loss_bv.data, loss_bv.size); + assert_eq!(loss_bits, vec![false, false]); + rng.jump(); + } +} + +#[test] +fn loss_mask_marks_dash_positions() { + // Mixed canned shot "1-" → measurements are 1 + random; loss_mask is [0, 1]. + let (_file, sampler) = make_sampler(CANNED_MIXED, 2); + let mut rng = DeterministicRng::seed_from_u64(42); + for _ in 0..10 { + let s = sampler.sample(&mut rng); + let loss_bv = s.loss_mask.as_ref().expect("PythonSampler always reports loss_mask"); + let loss_bits = bit_vector::unpack_bits(&loss_bv.data, loss_bv.size); + assert_eq!(loss_bits, vec![false, true], "loss_mask should mark only '-' positions"); + rng.jump(); + } +} + +#[test] +fn dash_maps_to_placeholder_false() { + // The sampler no longer randomizes loss bits — the coordinator does + // that via `apply_loss_random_imputation`. Each `'-'` here becomes + // a deterministic `false` placeholder in `measurements`, with the + // corresponding `loss_mask` bit set. + let (_file, sampler) = make_sampler(CANNED_ALL_LOSS, 2); + let mut rng = DeterministicRng::seed_from_u64(1); + for _ in 0..10 { + let s = sampler.sample(&mut rng); + let bits = bit_vector::unpack_bits(&s.measurements.data, s.measurements.size); + assert_eq!(bits, vec![false, false], "'-' should produce placeholder false bits"); + let loss_bv = s.loss_mask.as_ref().expect("PythonSampler always reports loss_mask"); + let loss_bits = bit_vector::unpack_bits(&loss_bv.data, loss_bv.size); + assert_eq!(loss_bits, vec![true, true]); + rng.jump(); + } +} + +#[test] +fn sample_is_deterministic_across_samplers() { + // Two independent samplers built from the same canned shot produce + // byte-identical outputs; the sampler no longer depends on the rng. + let (_file, sampler_a) = make_sampler(CANNED_MIXED, 2); + let (_file2, sampler_b) = make_sampler(CANNED_MIXED, 2); + + let mut rng_a = DeterministicRng::seed_from_u64(7); + let mut rng_b = DeterministicRng::seed_from_u64(99); + for _ in 0..20 { + let a = sampler_a.sample(&mut rng_a); + let b = sampler_b.sample(&mut rng_b); + assert_eq!(a.measurements, b.measurements); + assert_eq!(a.loss_mask, b.loss_mask); + rng_a.jump(); + rng_b.jump(); + } +} + +#[test] +#[should_panic(expected = "expected '0', '1', or '-'")] +fn invalid_char_panics() { + let body = r#" +class Sampler: + def __init__(self, circuit_text, config): + pass + + def sample(self): + return "0X" +"#; + let (_file, sampler) = make_sampler(body, 2); + let mut rng = DeterministicRng::seed_from_u64(0); + let _ = sampler.sample(&mut rng); +} + +#[test] +#[should_panic(expected = "expected 2 measurement chars")] +fn wrong_length_panics() { + let body = r#" +class Sampler: + def __init__(self, circuit_text, config): + pass + + def sample(self): + return "0" +"#; + let (_file, sampler) = make_sampler(body, 2); + let mut rng = DeterministicRng::seed_from_u64(0); + let _ = sampler.sample(&mut rng); +} + +#[test] +fn custom_class_name_is_honored() { + let body = r#" +class MySampler: + def __init__(self, circuit_text, config): + self.call = 0 + + def sample(self): + self.call += 1 + return "01" +"#; + let (file, path) = write_python_sampler(body); + let config = PythonSamplerConfig { + sampler: path, + name: "MySampler".to_string(), + py_config: None, + }; + let sampler = PythonSampler::new(TEST_CIRCUIT, &config, 0, 0, 2); + let mut rng = DeterministicRng::seed_from_u64(0); + let s = sampler.sample(&mut rng); + let bits = bit_vector::unpack_bits(&s.measurements.data, s.measurements.size); + assert_eq!(bits, vec![false, true]); + drop(file); +} + +#[test] +fn py_config_is_forwarded_with_injected_keys() { + // The sampler asserts that the auto-injected `seed`, `skip_shots`, and + // `num_measurements` keys are present and equal to what the Rust side + // provided. We assert this from inside Python and let the constructor + // raise on mismatch. + let body = r#" +class Sampler: + def __init__(self, circuit_text, config): + assert config["seed"] == 99, f"seed={config['seed']!r}" + assert config["skip_shots"] == 3, f"skip_shots={config['skip_shots']!r}" + assert config["num_measurements"] == 2, f"num_measurements={config['num_measurements']!r}" + assert config["custom"] == "hello", f"custom={config['custom']!r}" + + def sample(self): + return "00" +"#; + let (file, path) = write_python_sampler(body); + let config = PythonSamplerConfig { + sampler: path, + name: "Sampler".to_string(), + py_config: Some(serde_json::json!({"custom": "hello"})), + }; + let _sampler = PythonSampler::new(TEST_CIRCUIT, &config, 99, 3, 2); + drop(file); +} diff --git a/deq/deq_runtime/tests/standard_decoder_test.rs b/deq/deq_runtime/tests/standard_decoder_test.rs index 3e5171e9..5777d20d 100644 --- a/deq/deq_runtime/tests/standard_decoder_test.rs +++ b/deq/deq_runtime/tests/standard_decoder_test.rs @@ -114,8 +114,7 @@ async fn test_tesseract_decoder() { #[tokio::test] async fn test_python_naive_decoder() { use deq_runtime::decoder::PythonDecoder; - let file = format!("{}/src/decoder/naive_decoder.py", env!("CARGO_MANIFEST_DIR"),); - let config = serde_json::json!({ "file": file }); + let config = serde_json::json!({ "file": "@naive_decoder" }); let decoder = Arc::new(PythonDecoder::new(config)); let mut client = BlackBoxDecoderClient::Local(DynBlackBoxDecoder::BlackBoxPython(decoder)); let report = run_standard_suite(&mut client).await; @@ -155,8 +154,7 @@ async fn test_python_relay_bp_decoder() { if !python_modules_available("test_python_relay_bp_decoder", &["numpy", "scipy.sparse", "relay_bp"]) { return; } - let file = format!("{}/src/decoder/relay_bp_decoder.py", env!("CARGO_MANIFEST_DIR"),); - let config = serde_json::json!({ "file": file }); + let config = serde_json::json!({ "file": "@relay_bp_decoder" }); let decoder = Arc::new(PythonDecoder::new(config)); let mut client = BlackBoxDecoderClient::Local(DynBlackBoxDecoder::BlackBoxPython(decoder)); let report = run_standard_suite(&mut client).await; @@ -171,8 +169,7 @@ async fn test_python_tesseract_decoder() { if !python_modules_available("test_python_tesseract_decoder", &["numpy", "stim", "tesseract_decoder"]) { return; } - let file = format!("{}/src/decoder/tesseract_decoder.py", env!("CARGO_MANIFEST_DIR"),); - let config = serde_json::json!({ "file": file }); + let config = serde_json::json!({ "file": "@tesseract_decoder" }); let decoder = Arc::new(PythonDecoder::new(config)); let mut client = BlackBoxDecoderClient::Local(DynBlackBoxDecoder::BlackBoxPython(decoder)); let report = run_standard_suite(&mut client).await; diff --git a/deq/deq_runtime/tests/stim_sampler_test.rs b/deq/deq_runtime/tests/stim_sampler_test.rs index e2e4e31a..9cf99b48 100644 --- a/deq/deq_runtime/tests/stim_sampler_test.rs +++ b/deq/deq_runtime/tests/stim_sampler_test.rs @@ -144,6 +144,27 @@ fn error_set_fields_are_empty_for_stim() { assert!(sample.errors.is_empty(), "StimSampler should not track errors"); } +#[test] +fn shot_sample_loss_mask_is_absent_for_stim() { + use deq_runtime::simulator::common::error_set_to_shot_sample; + + let sampler = StimSampler::new(BELL_CIRCUIT, 42, 0, false); + let mut rng = DeterministicRng::seed_from_u64(0); + for _ in 0..5 { + let sample = sampler.sample(&mut rng); + assert!( + sample.loss_mask.is_none(), + "StimSampler ErrorSet.loss_mask must be None (no loss awareness)" + ); + let shot = error_set_to_shot_sample(&sample); + assert!( + shot.loss_mask.is_none(), + "ShotSample.loss_mask must be absent when the sampler does not track loss" + ); + assert!(shot.outcomes.is_some(), "ShotSample.outcomes must still be populated"); + } +} + #[test] fn readouts_match_always_returns_true() { use deq_runtime::util::BitVector; diff --git a/deq/documents/tutorial/README.md b/deq/documents/tutorial/README.md index 1193d003..ec92aea1 100644 --- a/deq/documents/tutorial/README.md +++ b/deq/documents/tutorial/README.md @@ -122,6 +122,7 @@ Once you become comfortable with the basics, let's look at some advanced topics: - [Parametrization with Mako](chapters/mako-parametrization.md) - [Plug in your own decoder in Python](chapters/python-decoder.md) - [Driving the runtime from Python](chapters/python-runtime.md) +- [Loss-aware simulation with the QDK backend](chapters/qdk-loss-simulation.md) - [Debugging your .deq program](chapters/debug-deq-program.md) - [Steane-style syndrome extraction](chapters/steane-style-ec.md) - [Speed-accuracy trade-off with .deq program] diff --git a/deq/documents/tutorial/chapters/python-decoder.md b/deq/documents/tutorial/chapters/python-decoder.md index eea8ac5a..6c18049b 100644 --- a/deq/documents/tutorial/chapters/python-decoder.md +++ b/deq/documents/tutorial/chapters/python-decoder.md @@ -158,11 +158,17 @@ hypergraphs, single edges, triangles, line chains, etc.) that any black-box decoder must pass. Run it against your wrapper with `deq test python-decoder`: ```sh -deq test python-decoder --file ../../../../deq_runtime/src/decoder/relay_bp_decoder.py +deq test python-decoder --file @relay_bp_decoder # ... 32 lines of [PASS] # passed: 32/32 ``` +The `@relay_bp_decoder` sentinel resolves to a compile-time-embedded copy +of the reference wrapper baked into `python_decoder.rs`; the other builtins +are `@naive_decoder` and `@tesseract_decoder`. Any `--file` value that does +**not** start with `@` is opened as a filesystem path, so pointing `--file` +at your own `*.py` file still works. + Two things to notice: - This is a thin Python entry point that calls into the installed `deq_runtime` @@ -174,7 +180,7 @@ Two things to notice: ```sh deq test python-decoder \ - --file ../../../../deq_runtime/src/decoder/relay_bp_decoder.py \ + --file @relay_bp_decoder \ --py-config '{"seed": 42}' # NOTE: on Windows (cmd/PowerShell), escape inner double quotes, # e.g. '{\"seed\":42}' instead of '{"seed":42}' @@ -201,7 +207,7 @@ deq transpile small_example_evaluation.deq --out small_example.deq.jit --program deq server \ --decoder black-box-python \ --decoder-config '{ - "file": "../../../../deq_runtime/src/decoder/relay_bp_decoder.py" + "file": "@relay_bp_decoder" }' \ --coordinator window \ --coordinator-config '{"buffer_radius":3}' \ @@ -225,19 +231,19 @@ deq server \ `black-box-python` accepts the following config: -| Field | Type | Meaning | -| ------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------- | -| `file` | string | Absolute or relative path to your `*.py` file. Must expose a class shaped like the [protocol above](#the-python-decoder-protocol). | -| `name` | string | Optional. Name of the decoder class inside the file. Defaults to `"Decoder"`. | -| `py_config` | any JSON value | Forwarded to your `Decoder.__init__` as the `config` argument. Omit for `{}`. | -| `parallel` | int (optional) | Number of decoder worker threads (inherited from the thread-pooling layer). | +| Field | Type | Meaning | +| ------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `file` | string | Path to your `*.py` file, or an `@name` sentinel that resolves a compile-time-embedded reference decoder (`@naive_decoder`, `@relay_bp_decoder`, `@tesseract_decoder`). | +| `name` | string | Optional. Name of the decoder class inside the file. Defaults to `"Decoder"`. | +| `py_config` | any JSON value | Forwarded to your `Decoder.__init__` as the `config` argument. Omit for `{}`. | +| `parallel` | int (optional) | Number of decoder worker threads (inherited from the thread-pooling layer). | Pass `py_config` exactly the way you would pass `--py-config` to `test python-decoder`, just nested one level inside the decoder config: ```sh --decoder-config '{ - "file": "deq_runtime/src/decoder/relay_bp_decoder.py", + "file": "@relay_bp_decoder", "py_config": {"seed": 42, "num_sets": 100} }' ``` diff --git a/deq/documents/tutorial/chapters/qdk-loss-simulation.md b/deq/documents/tutorial/chapters/qdk-loss-simulation.md new file mode 100644 index 00000000..7b701609 --- /dev/null +++ b/deq/documents/tutorial/chapters/qdk-loss-simulation.md @@ -0,0 +1,313 @@ +# Loss-aware simulation with the QDK backend + +Neutral-atom and trapped-ion platforms suffer **qubit loss**: a physical +qubit may leave the trap, leak out of the computational subspace, or +otherwise become unavailable to the rest of the circuit. Loss is **not** +a Pauli error. A lost qubit can't be acted on by ordinary gates and +can't yield a clean measurement, but exactly *how* the surrounding +circuit degrades depends on the platform — there is no single +standard loss model: + +- **Neutral atoms (CZ-native).** A `CZ` involving a lost atom has no + effect on its partner; the gate effectively becomes the identity. +- **Trapped ions (MS-native).** When a + [Mølmer–Sørensen gate](https://en.wikipedia.org/wiki/M%C3%B8lmer%E2%80%93S%C3%B8rensen_gate) + touches a lost ion, the surviving partner picks up a deterministic + $S$ or $S^{\dagger}$ — a $\pi/2$ phase rotation that propagates + through the rest of the circuit. +- Other platforms (Rydberg blockade variants, leakage to higher + levels, atom-array transport, …) come with their own variants. + +This chapter is an **introduction**. It pairs the simplest physical +loss model with the simplest decoding strategy deq currently ships: + +- **Simulation side:** any two-qubit gate acting on a lost qubit becomes + the identity, loss does **not** propagate to the partner, and a later + measurement of a lost qubit reports neither a clean `0` nor a clean + `1`. Because loss can be injected anywhere in the circuit (gates, + idling, transport, readout, …), it must be modeled wherever atoms are + in flight, not only just before measurement. +- **Decoding side:** the coordinator applies **loss-random-imputation** + — every lost measurement bit is replaced with a fresh random bit + before the syndrome is computed. The decoder treats the syndrome + (approximately) from a measurement-flip channel, so any off-the-shelf + loss-unaware decoder works unchanged. Note that this + is an approximation: loss can have other effect than just random + measurement bit, and we will consider these effect in a later version. + +Both choices are deliberately the easiest things that work end-to-end; +they are **not** the best you can do. Richer models — gate-by-gate +propagation rules, heralded leakage, transport-induced loss, custom +per-platform loss channels — and richer decoding — erasure decoding, +edge re-weighting, loss-aware MWPM/BP, plug-in loss handlers — will be +the subject of follow-up chapters. + +Stim doesn't model loss as a first-class outcome. The +[QDK](https://github.com/microsoft/qdk) stabilizer simulator does, via +its experimental `qdk.stim` module and a **Stim extension** that adds a +`LOSS_ERROR(p)` instruction. This chapter walks through that extension +end-to-end through deq's `--simulator python` plug-point. + +This chapter walks through: + +1. **An example simulation**: a repetition-code memory experiment + over `3·d` rounds of syndrome extraction, comparing a baseline that + does nothing about loss against a loss-aware variant that + replenishes data qubits each cycle. The loss-aware variant beats + the baseline by **3+ orders of magnitude** even at modest loss + rates. +2. **What's in the `.deq`**: how `LOSS_ERROR(p)` shows up in a gadget + body and what the one-line "replenish" addition does. +3. **How loss flows through deq today**: from the QDK output, through + the Rust runtime's `PythonSampler` (which forwards the loss + positions as a `loss_mask` bitvector alongside the placeholder + outcomes), into the controller, and finally to the coordinator — + which applies its configurable **loss-random-imputation** policy + before computing the syndrome the decoder sees. +4. **Where loss info lives today** and pointers to follow-up chapters. + +--- + +## An example simulation: loss kills, replenish saves + +The example lives in [loss-simulation/repetition_code.deq](../examples/loss-simulation/repetition_code.deq) +— a Mako-templated repetition-code memory experiment with a single +boolean knob, `replenish`: + +- **Baseline (`replenish=False`)**: at the start of each cycle we sprinkle + `LOSS_ERROR(p_loss)` on every data and ancilla qubit, then run a + standard Z-stabilizer syndrome extraction. Lost data qubits **stay + lost** — every subsequent gate on them is the identity and every + subsequent measurement returns `Loss` (imputed by the coordinator to + a fair coin-flip). After `3·d` rounds, accumulated loss decimates + the syndrome. +- **Loss-aware (`replenish=True`)**: at cycle end, teleport each + data qubit `q` onto a fresh buddy `f`. Textbook teleportation is + `R f; CX q → f; MX q; CZ rec[-1] f` — the last step is a + classically-controlled Pauli QDK's Stim parser doesn't yet accept, + so we drop it. That's safe here because we read out only in Z at + the end and the omitted Pauli flips just a global sign. In this case, + loss becomes a one-cycle random bit-flip the decoder attributes + to `X_ERROR`, and code distance still achieves sub-threshold + scaling. + +[loss_ler_sweep.py](../examples/loss-simulation/loss_ler_sweep.py) +sweeps both variants for each `(d, p_loss)` point, transpiles via +`deq transpile`, then drives `python -m deq.runtime server` +with `--simulator python` (the QDK adapter) and `--decoder +black-box-relay-bp` (a real decoder). It captures `Logical errors: K/N` +from each run, accumulates, and plots: + +```sh +python documents/tutorial/examples/loss-simulation/loss_ler_sweep.py \ + --distances 3 5 7 \ + --loss-rates 0.01 0.02 0.05 0.1 0.2 0.3 \ + --target-errors 20 \ + --max-shots 1000000 \ + --workers 4 +``` + +By default the per-instruction Pauli noise rate is set to +`p_Pauli = p_loss / 10` so the decoder always sees a non-trivial +hypergraph — without any Pauli noise the hyperedge probabilities +collapse to zero and the decoder can't pick a meaningful correction +when loss does show up. Use `--p ` to decouple them. + +![Logical error rate vs per-cycle loss probability](../examples/loss-simulation/loss_ler_sweep.png) + +Two observations: + +- **Baseline is not fault-tolerant** — there is no threshold. The + per-data-qubit loss probability over `3d` cycles grows as + `3d · p_loss`, so raising the code distance also raises the loss + exposure and buys no exponential suppression. +- **Loss-aware is fault-tolerant.** The teleportation step swaps + every data qubit onto a fresh buddy each cycle, so per-qubit loss + exposure is bounded by a single cycle's `p_loss` no matter how many + rounds we run. Below threshold the replenish LER drops roughly an + order of magnitude per two units of code distance (at `p_loss = + 0.01`, `d=3 → 5 → 7` LER is `7.5e-3 → 7.0e-4 → 7.1e-5`) — the + exponential suppression in `(d+1)/2` that a fault-tolerant scheme should + give. + +### Caveats + +The omitted conditional `Z` correction is **safe only for a Z-basis +memory experiment**. For an arbitrary logical state (X-basis prep, +mid-circuit logical rotations, anything where the Pauli frame +matters), the missing classical-feedforward `CZ rec[-1] q` would +leave a real bit-flip the decoder cannot recover from. QDK's Stim +parser currently rejects classical-controlled Paulis, so a full +loss-tolerant scheme over arbitrary input states is **not yet +implementable end-to-end through this pipeline** — it is the most +visible missing feature for any protocol whose observables are not +Z-basis-only. A follow-up chapter will revisit this once +physical-level teleportation lands in the QDK simulator. + +--- + +## What's in the `.deq` + +The full source is [repetition_code.deq](../examples/loss-simulation/repetition_code.deq) +— a few dozen lines of Mako-templated deq. The interesting piece is +the `Syndrome` gadget; the `% if replenish:` block is the only +difference between the two variants. + +[Syndrome gadget (Mako source)](../examples/loss-simulation/snippet_syndrome.deq) + +

GADGET Syndrome {
+    INPUT Rep ${" ".join(str(q) for q in data)}
+
+    # Loss event + per-cycle Pauli noise on data qubits.
+    LOSS_ERROR(${p_loss}) ${" ".join(str(q) for q in data)}
+    X_ERROR(${p}) ${" ".join(str(q) for q in data)}
+
+    # Standard Z-stabilizer syndrome extraction.
+    R ${" ".join(str(q) for q in anc)}
+    CX ${" ".join(f"{data[i]} {anc[i]}" for i in range(d - 1))}
+    CX ${" ".join(f"{data[i+1]} {anc[i]}" for i in range(d - 1))}
+
+    # Loss on syndrome ancillas + measurement bit-flip noise.
+    LOSS_ERROR(${p_loss}) ${" ".join(str(q) for q in anc)}
+    X_ERROR(${p}) ${" ".join(str(q) for q in anc)}
+    M ${" ".join(str(q) for q in anc)}
+
+% if replenish:
+    # ── Teleportation replenish: data[i] ─→ fresh[i] (slot rename) ──
+    # One single-qubit teleportation per data qubit per cycle.
+    # The X-basis measurement (``MX``) clears any accumulated loss on
+    # the original data qubit while ``CX q → f`` transfers its
+    # Z-eigenstate to the buddy.  The data state now lives on
+    # ``fresh``, so we just declare the OUTPUT port on the
+    # ``fresh`` slots — the deq compiler wires those physicals into
+    # the next ``Syndrome``'s INPUT with no extra gates.  The would-be
+    # conditional Z corrections are omitted: see the header comment
+    # for why this is safe in a Z-basis memory experiment.
+% for q, f in zip(data, fresh):
+    R ${f}
+    CX ${q} ${f}
+    MX ${q}
+    # CZ rec[-1] ${f}  # omitted, see header comment
+% endfor
+% endif
+
+    OUTPUT Rep ${" ".join(str(q) for q in (fresh if replenish else data))}
+}
+ + +`LOSS_ERROR(p_loss)` is just an instruction in the gadget body. +deq's transpiler treats it as a **passthrough noise instruction**: +emitted verbatim into the generated `.stim` (with the usual +local→physical qubit relabel), contributing nothing to the detector +graph or to the measurement count. Upstream Stim doesn't recognise +`LOSS_ERROR`, so the resulting `.stim` is gated to `--simulator +python`; `qdk.stim` is what actually simulates the loss. + +### Why `PrepareOne` initializes to physical `|1>` + +[PrepareOne gadget (Mako source)](../examples/loss-simulation/snippet_prepareone.deq) + +
GADGET PrepareOne {
+    R ${" ".join(str(q) for q in data)}
+    # logical X gate to prepare |1> state for testing
+    X ${" ".join(str(q) for q in data)}
+    X_ERROR(${p}) ${" ".join(str(q) for q in data)}
+    OUTPUT Rep ${" ".join(str(q) for q in data)}
+    VIRTUAL LX0  # added so that this is indeed outputing the logical |1> state
+}
+ + +Look back at `PrepareOne`: it applies `R` then `X`, so every data +qubit starts in physical `|1>` rather than the more natural `|0>`. +This is deliberate — we don't want the benchmark to secretly favor +loss. In the replenish step, `R f` prepares the buddy in `|0>` and +`CX q → f` copies `q`'s Z-eigenvalue onto it. When `q` is lost the +CX is identity, so `f` stays in `|0>`. With a `|1>` logical state +that's a deterministic bit-flip; with a `|0>` logical state it would +have been a free pass — loss self-healing on every qubit. Preparing +`|1>` puts every loss event at its worst case and is why the +replenish curve saturates above 50% LER at high `p_loss`. + +--- + +## How the pipeline works + +The stim file that `deq transpile` emits — including the +`LOSS_ERROR(p_loss)` passthrough — are handed to the +[QDK](https://github.com/microsoft/qdk) stabilizer simulator through +deq's `--simulator python` plug-point. Four short hops: + +1. **deq's runtime sets `--simulator python`** and `sampler: "@qdk_sampler"`, + which the runtime resolves to a **compile-time-embedded** copy of + [qdk_sampler.py](../../../deq_runtime/src/simulator/qdk_sampler.py) via a + small registry inside + [python_sampler.rs](../../../deq_runtime/src/simulator/python_sampler.rs). You can + still point `sampler` at your own `*.py` adapter when you want to. +2. **`qdk_sampler.py`** calls `qdk.stim.compile(src, None)` once to turn + the Stim source (with `LOSS_ERROR`) into QIR, then batches + `qdk.simulation.run_qir(shots=N)` (default `batch_size=256`) to + amortize the ~0.5 ms-per-call Python overhead. Each shot is a + length-N string of `'0'`, `'1'`, or `'-'`; `'-'` marks a measurement + whose qubit was lost. +3. **The Rust `PythonSampler`** packs each shot into an + [`ErrorSet`](../../../deq_runtime/src/simulator/python_sampler.rs) + with a `placeholder=0` bit and a `loss_mask` bit set at every `'-'` + position. +4. **The coordinator** receives `Outcomes { outcomes, loss_mask }` + and, by default (`loss_random_imputation=true`), replaces every + `outcomes` bit whose `loss_mask` bit is set with a uniformly random + bit drawn from a seeded RNG, then computes + the syndrome the decoder consumes. + +That's the whole pipeline — there's nothing QDK-specific about it +beyond the choice of `qdk_sampler.py` as the adapter. The `@name` +sentinel only recognises names registered in the +[builtin_samplers module in python_sampler.rs](../../../deq_runtime/src/simulator/python_sampler.rs); +any other value of `sampler` is opened as a filesystem path, so a Python +class implementing +```python +class Sampler: + def __init__(self, circuit_text: str, config: dict) -> None: ... + def sample(self) -> str: ... # length-N string of '0', '1', '-' +``` +plugs in the same way; the same `loss_mask` plumbing carries through. + +Three QDK-specific caveats are worth knowing if you're writing your +own circuits or adapters: + +- The `qdk.stim` module is marked **experimental**; its API may shift. +- The `seed` parameter is currently **ignored** by upstream — successive + calls produce different shots even with the same seed. deq still + passes it through so the contract is right when upstream wires it up. +- QDK's Stim parser does **not** yet accept the compact `M(p) ` + noisy-measurement syntax, nor classical-control Paulis + (`CX rec[-1] `), nor `MPP`. Use `X_ERROR(p) ; M ` for + noisy measurement. + +--- + +## Where the loss information lives today + +| Layer | Carries loss info? | Form | +| ---------------------------------------------- | ------------------ | ----------------------------------------- | +| `qdk.stim.run` output | yes | `Result.Loss` enum value | +| `qdk_sampler.py` shot string | yes | `'-'` character | +| `ErrorSet.loss_mask` (Rust) | yes | `Option` | +| `ShotSample.loss_mask` (proto) | yes | `optional BitVector` proto field | +| gRPC `Outcomes.loss_mask` (controller→coord) | yes | `optional BitVector` proto field | +| Coordinator imputation policy | yes (consumed) | `loss_random_imputation` (default `true`) | +| Decoder (`syndrome: list[int]`, sparse indices of the syndrome bits) | **no** | loss is folded into the imputed syndrome | + +By default, the coordinator applies **loss-random-imputation**: every bit +of `outcomes` whose `loss_mask` bit is set is replaced with a uniformly +random bit before the syndrome +is computed. This is the simplest "loss-as-flip" model — it keeps the +black-box decoder protocol unchanged, at the cost of throwing away the +fact that the bit *was* a loss. Pass +`--coordinator-config '{"loss_random_imputation": false}'` to disable +imputation; the decoder then sees a syndrome built from placeholder `0` +bits. Support for loss-aware decoding will be added in the future. + +To make the imputation reproducible across runs, also pass +`"loss_random_imputation_seed": `. When omitted, the RNG is seeded +from OS RNG. diff --git a/deq/documents/tutorial/examples/loss-simulation/.gitignore b/deq/documents/tutorial/examples/loss-simulation/.gitignore new file mode 100644 index 00000000..607c2791 --- /dev/null +++ b/deq/documents/tutorial/examples/loss-simulation/.gitignore @@ -0,0 +1 @@ +workdir/ diff --git a/deq/documents/tutorial/examples/loss-simulation/gen_snippets.py b/deq/documents/tutorial/examples/loss-simulation/gen_snippets.py new file mode 100644 index 00000000..5549aa6f --- /dev/null +++ b/deq/documents/tutorial/examples/loss-simulation/gen_snippets.py @@ -0,0 +1,28 @@ +"""Generate Mako-source snippets for the QDK loss-simulation chapter. + +Extracts the ``GADGET Syndrome`` block out of ``repetition_code.deq`` so +the tutorial's ``## What's in the .deq`` section can link to it via the +standard ``highlight_deq.py`` pipeline. +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from snippet_utils import extract_block, write_snippet + +this_dir = os.path.dirname(os.path.abspath(__file__)) +source = os.path.join(this_dir, "repetition_code.deq") + +with open(source, encoding="utf-8") as f: + text = f.read() + +write_snippet( + os.path.join(this_dir, "snippet_syndrome.deq"), + extract_block(text, "GADGET", "Syndrome"), +) + +write_snippet( + os.path.join(this_dir, "snippet_prepareone.deq"), + extract_block(text, "GADGET", "PrepareOne"), +) diff --git a/deq/documents/tutorial/examples/loss-simulation/loss_ler_sweep.json b/deq/documents/tutorial/examples/loss-simulation/loss_ler_sweep.json new file mode 100644 index 00000000..de81ec6d --- /dev/null +++ b/deq/documents/tutorial/examples/loss-simulation/loss_ler_sweep.json @@ -0,0 +1,362 @@ +[ + { + "variant": "baseline", + "d": 3, + "p": 0.001, + "p_loss": 0.01, + "rounds": 9, + "shots": 10000, + "logical_errors": 96, + "wall_time": 2.8771376609802246 + }, + { + "variant": "baseline", + "d": 3, + "p": 0.002, + "p_loss": 0.02, + "rounds": 9, + "shots": 10000, + "logical_errors": 413, + "wall_time": 2.972980499267578 + }, + { + "variant": "baseline", + "d": 3, + "p": 0.005, + "p_loss": 0.05, + "rounds": 9, + "shots": 10000, + "logical_errors": 1613, + "wall_time": 3.0898849964141846 + }, + { + "variant": "baseline", + "d": 3, + "p": 0.01, + "p_loss": 0.1, + "rounds": 9, + "shots": 10000, + "logical_errors": 3418, + "wall_time": 3.134202003479004 + }, + { + "variant": "baseline", + "d": 3, + "p": 0.02, + "p_loss": 0.2, + "rounds": 9, + "shots": 10000, + "logical_errors": 4855, + "wall_time": 3.0913820266723633 + }, + { + "variant": "baseline", + "d": 3, + "p": 0.03, + "p_loss": 0.3, + "rounds": 9, + "shots": 10000, + "logical_errors": 4993, + "wall_time": 3.100290060043335 + }, + { + "variant": "replenish", + "d": 3, + "p": 0.001, + "p_loss": 0.01, + "rounds": 9, + "shots": 10000, + "logical_errors": 72, + "wall_time": 4.029250621795654 + }, + { + "variant": "replenish", + "d": 3, + "p": 0.002, + "p_loss": 0.02, + "rounds": 9, + "shots": 10000, + "logical_errors": 264, + "wall_time": 3.9270169734954834 + }, + { + "variant": "replenish", + "d": 3, + "p": 0.005, + "p_loss": 0.05, + "rounds": 9, + "shots": 10000, + "logical_errors": 1315, + "wall_time": 3.86773943901062 + }, + { + "variant": "replenish", + "d": 3, + "p": 0.01, + "p_loss": 0.1, + "rounds": 9, + "shots": 10000, + "logical_errors": 3325, + "wall_time": 4.044471979141235 + }, + { + "variant": "replenish", + "d": 3, + "p": 0.02, + "p_loss": 0.2, + "rounds": 9, + "shots": 10000, + "logical_errors": 5853, + "wall_time": 4.011503219604492 + }, + { + "variant": "replenish", + "d": 3, + "p": 0.03, + "p_loss": 0.3, + "rounds": 9, + "shots": 10000, + "logical_errors": 6460, + "wall_time": 4.3647356033325195 + }, + { + "variant": "baseline", + "d": 5, + "p": 0.001, + "p_loss": 0.01, + "rounds": 15, + "shots": 10000, + "logical_errors": 32, + "wall_time": 4.4409339427948 + }, + { + "variant": "baseline", + "d": 5, + "p": 0.002, + "p_loss": 0.02, + "rounds": 15, + "shots": 10000, + "logical_errors": 218, + "wall_time": 4.633340120315552 + }, + { + "variant": "baseline", + "d": 5, + "p": 0.005, + "p_loss": 0.05, + "rounds": 15, + "shots": 10000, + "logical_errors": 1550, + "wall_time": 5.065903425216675 + }, + { + "variant": "baseline", + "d": 5, + "p": 0.01, + "p_loss": 0.1, + "rounds": 15, + "shots": 10000, + "logical_errors": 3745, + "wall_time": 5.65746808052063 + }, + { + "variant": "baseline", + "d": 5, + "p": 0.02, + "p_loss": 0.2, + "rounds": 15, + "shots": 10000, + "logical_errors": 4993, + "wall_time": 6.497108459472656 + }, + { + "variant": "baseline", + "d": 5, + "p": 0.03, + "p_loss": 0.3, + "rounds": 15, + "shots": 10000, + "logical_errors": 5032, + "wall_time": 6.830840110778809 + }, + { + "variant": "replenish", + "d": 5, + "p": 0.001, + "p_loss": 0.01, + "rounds": 15, + "shots": 30000, + "logical_errors": 22, + "wall_time": 19.964688539505005 + }, + { + "variant": "replenish", + "d": 5, + "p": 0.002, + "p_loss": 0.02, + "rounds": 15, + "shots": 10000, + "logical_errors": 50, + "wall_time": 6.942037343978882 + }, + { + "variant": "replenish", + "d": 5, + "p": 0.005, + "p_loss": 0.05, + "rounds": 15, + "shots": 10000, + "logical_errors": 530, + "wall_time": 7.369048118591309 + }, + { + "variant": "replenish", + "d": 5, + "p": 0.01, + "p_loss": 0.1, + "rounds": 15, + "shots": 10000, + "logical_errors": 2119, + "wall_time": 7.842990398406982 + }, + { + "variant": "replenish", + "d": 5, + "p": 0.02, + "p_loss": 0.2, + "rounds": 15, + "shots": 10000, + "logical_errors": 5065, + "wall_time": 8.69098162651062 + }, + { + "variant": "replenish", + "d": 5, + "p": 0.03, + "p_loss": 0.3, + "rounds": 15, + "shots": 10000, + "logical_errors": 6253, + "wall_time": 9.007533073425293 + }, + { + "variant": "baseline", + "d": 7, + "p": 0.001, + "p_loss": 0.01, + "rounds": 21, + "shots": 10000, + "logical_errors": 26, + "wall_time": 6.427351713180542 + }, + { + "variant": "baseline", + "d": 7, + "p": 0.002, + "p_loss": 0.02, + "rounds": 21, + "shots": 10000, + "logical_errors": 227, + "wall_time": 7.49300217628479 + }, + { + "variant": "baseline", + "d": 7, + "p": 0.005, + "p_loss": 0.05, + "rounds": 21, + "shots": 10000, + "logical_errors": 1878, + "wall_time": 11.739124536514282 + }, + { + "variant": "baseline", + "d": 7, + "p": 0.01, + "p_loss": 0.1, + "rounds": 21, + "shots": 10000, + "logical_errors": 4115, + "wall_time": 16.221975803375244 + }, + { + "variant": "baseline", + "d": 7, + "p": 0.02, + "p_loss": 0.2, + "rounds": 21, + "shots": 10000, + "logical_errors": 4953, + "wall_time": 21.5823175907135 + }, + { + "variant": "baseline", + "d": 7, + "p": 0.03, + "p_loss": 0.3, + "rounds": 21, + "shots": 10000, + "logical_errors": 5027, + "wall_time": 24.786964893341064 + }, + { + "variant": "replenish", + "d": 7, + "p": 0.001, + "p_loss": 0.01, + "rounds": 21, + "shots": 320000, + "logical_errors": 20, + "wall_time": 342.95836877822876 + }, + { + "variant": "replenish", + "d": 7, + "p": 0.002, + "p_loss": 0.02, + "rounds": 21, + "shots": 40000, + "logical_errors": 24, + "wall_time": 46.19500780105591 + }, + { + "variant": "replenish", + "d": 7, + "p": 0.005, + "p_loss": 0.05, + "rounds": 21, + "shots": 10000, + "logical_errors": 143, + "wall_time": 14.182263851165771 + }, + { + "variant": "replenish", + "d": 7, + "p": 0.01, + "p_loss": 0.1, + "rounds": 21, + "shots": 10000, + "logical_errors": 1198, + "wall_time": 19.213183164596558 + }, + { + "variant": "replenish", + "d": 7, + "p": 0.02, + "p_loss": 0.2, + "rounds": 21, + "shots": 10000, + "logical_errors": 4774, + "wall_time": 24.261073350906372 + }, + { + "variant": "replenish", + "d": 7, + "p": 0.03, + "p_loss": 0.3, + "rounds": 21, + "shots": 10000, + "logical_errors": 6232, + "wall_time": 27.596652030944824 + } +] \ No newline at end of file diff --git a/deq/documents/tutorial/examples/loss-simulation/loss_ler_sweep.png b/deq/documents/tutorial/examples/loss-simulation/loss_ler_sweep.png new file mode 100644 index 0000000000000000000000000000000000000000..57b08f8aa3d9140a0349b7b75e5cf252a778f9a9 GIT binary patch literal 125432 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
yq z2usZZJ4CW4!HZk-wp~`XEr?NIjWAtRg;)uk#ARkKevW0X@&AEcBais{a=W^XaV9t( zzH#hE`a;MKlx)EJ0$$nBe?*lp8tmJ&S2uvShQFw9<)iK=1{}S$1e2~=Isg;`wO!nT zhAD5qP0qv9prwnE#s9#?w+8WY5N;e!vgbN}09CEJY)5&2dm~OiVpM~87DH14z;}_6 z(Nfh*2YN-QbhwUDgLvbGy+BE}*^t^FEw>ly1~~HmU8i4rY}y+;@PMxmiZ=CY6q0gX zuuwp+>pgM5$J1Qy(Dl42Lh2n}T8VlC2n6atW)ih7c{39CJgIVU3X<5>{IX; za<+hFqoAauS?uKO{9@86KXYS};L4?``3V{`3x$u%5ZAUwv4jZyZ^?S1&M<|2sWE;# z+j}8`&eR&+irB6Omc$CSFwNaAwyPk}uv#sNwBFEJ`&Mc%7)&fw-wvCffC1kas>$=S zpH_AP@0zOHrCj5&iE?6`=yJoc+@Bk zJKBJZo9JK0J#YodxHax}s!aT=L%xA5p=r%WQ>mwb4m2Nto__+aJfdcTJ8ZygU*j-B z%WZl3d@{sgyPa`ACLXm~XfV`A`J9ak5ci1e+gUkaLY3_3Rv2z zH5SBLT3Q;ehgFxvX=w5K+6D;UaR>oxmCKwIMCH#60^)87j6n4iDQbQwVw@BOMlO(J zfH#qflwH@&4+ijr9%FO_hL6EI&8);bxvJs^v^b^n{J@bjcn|aKs%>s@a_e3D^AoF% zG{1MVM6^qY7%Oje)*CSGCI*a35h+(mnFj;VAS3934OrqwrZd*)Am0b2Y;z zuZO;kL}%AG|Ldbk$^;O`aGfZBOAalqi*FJpnD0cdH|I!3Fb)YdTiOsIXKf1D$7saF zLrvGq0|BHSy(g<3L7~M-5ot+D{hm+fq*@-g-=Roc514=a_%UoxO->NXhu`(E-s5h@ zttGw>FA&E+J+r&kJtE%|+;fkP{de~&Bn^~Nffy_?m(*QP@sjNd;bNMZo5ugNkGV4F zqHG(!t@ayv-SZ>k=T9$|@~ZT!3>5*Yaf=%W*r5!xh0A&v$N^2oDWF=JW3{!-+25Zs z53WLA-ZS_Y6c)m{#(0u#!AbT2LF{}FP&haws=e(Lh^f^9E`Dc_?LH}tApv%eu#m!W z5S$msid%F_3K2lSXg3{#uy&H_2&(EnO*y_d>c?vVgcc&l>dzE(!W|j2W>}0G;>-7>-v;`D_IrPi>2|#Y zyJYjhH`>$;pk7+ z`Ldh`3-C9#&h4pYUH$~JJDfI8C5&u-+xGSw_+!V^#?kdILLJGN;t+u#K71yM@h5x zN2~yt0mc@qgLB3LUTe^Ay}1b?l%czA6B|K8FJfjCQ~{)@wq4YVzrcvtX%r*EG*Dy!sObwk^bWm6sA)`S8*Gogjg zr%_Ha%)OosS=Y@534|JJ>#NPY*}66IH(l`?o)_qO!Ezq%+(<89CWFD%$c*Qgm)h$~ zbrlP1m`8wNx!Lj@e%fOBjptu1F`)y-RQ$X@5+TG`1IB>d`J|>QrF-+f<#xWHYmF_V z|2*8O`&dyI7w%+sb6tSpiJue;tk0nip3w9NQ|mh9OFrwDzJD>Bth!b@ni8XG2Kq3f zL0}3<_X>LHR-UNc4&gc)@pwrA>_5MgO_=}yH?o*9OCMF}wu4D)qg=y()5v~({_%g32e88Gbu?;uuwDToKDayK zJ@04$UN8V~1YR^*Qf5eVJ|?4lEQ?&ZFN$^-^pAJ?d`JS?mB&!NN&UBeJs!Y>GF52bl$^hzF%SzF-5r^VD{shsc2@fTx|z**KJ|%U)}tGRhTf#76&Rj2xrE4xChe*X0#|oWCFhsKu<${t48Z0 zy6vgiLR^7oOSp78zYz=!{@T$;KXsLGVhSK{(r8u>=1KvO2>uYnsA5u?-vgme5ya=Bty_@5a*s&Kt~Bv=%$g ztCVVC?SX>YRgn<>q!8h-ViWB8Ap~+!`tf{VN-AbJq{Y54MsN4sP=T0*C zO0eATi9d_H^uT5+&R{!77K6;`w*BQpUWx*slxG4@q}{3&GN6iW9x+`43ACCY?=t0n zF>P-*(0rD~b=W@KIyQYC1{3$H<~{$z*LxuYS=G!1VA{PTV^b&ifXR};pm@F(!l-14 znZ-{u!nhqL%@L`?nypxbH^b%6W1gy76`5nOuxMwaLK}^Dv`IhFjzUOAn2&S67<2f&kb>F)QKH92zP_L}lwVj1@oU8+9&Y%DNFW@Hv`_rmYH8e3PG^Z5;ouPi z%uX`Jv4A8hxW3<^+^>({Wfg0kFofmti2O3$BRIR0%R4VDkZdFK6*I(CmTxQXo5heD zpLaeuZ#Wy-knNKE8XMA)|B9R6OSAm^E^bEENuQiOVCcJNiP``_R6Er#OgI_w?tQQW zuSHPf2MB`gjuus;ki3>Gwn2CTBI5BZ5yZx(rX}KW><-Yn24?7ibWR8~5IHIo$=aHZ zylbpmLz{67%<=e%jjYcDkdZ zQ`jdbEsvIcnI)KoVV!`1%f3g!cs+k<4sueQpSBa+``)-P zRok+zN9%!3HRTa>twxZZP(Pf}LB$-pa;9l%EM4v8DJKf(HowI!Wq~-^*18~-xapS3 zE!q1UG`nqL*d_j#VP*94PPt%&()pQxd{K0`C&98C=ZKvroIumQp?aV){Po-Aj-7%+ z-3W5>C=JM=Oz@$Gl3;5%b-?wwspkq%a_COVX@z`uUo{*9@YqLDjyW?!{j_vKQ9 zW7#sh+l>_lCZ=kmQ~6s0hM!BBGD|w))tc{%%Oymy)dX5Adr&w%FBa>3AAV&-^PYar^QNy zyuZcFWlUs1QEhxU8!%!eh-1_qPGU0!bKoAowmBz+i`&SYnJw>nzXSEeRmcY#5ohi% z0za!OnnQG(n@gGhRUg1xq4fV+?k{THvyzl72`OOndcFW15>i*@FwWMGAy`USXB`Ek z@J8dlKV6T19j(w5N8kUzzU*(brK+KGR;T@$VN&Gv9f!-~fz!hyo?Uv$jQxDUvZdT)iuO-M1;XpsYpIUQT*n?_5hUP__XA4L>iW76 zkRb5+Tv%P2o}$)%^%QVp!pf-{J?9Mk2CIngR-;0zRfb|dWpY?B!Hq)Q4E4DL}^Ty)Y5mHZ~CC(SP z2UddH8Mfyvdl^#>QHYEM?+8+TArOZ`9WK%FNmE5uXxxePUjTW!k@i=(sh@50Y;k`3Uj^h|s>D)PE#BD4WWVKa>AzDi z9Nb?wHwuab&mY=Ownw_U@)@TEg@j&kf+t1EHy!t0U8ZOXiG(+S-F*J!YQZwyF&)6p z&Uz^#02>adxjG=?-`L(h2Uoyv!x0hyu2pqk6lgjh=Fxx1t-sS=vYU`v1V`%Eg{z~b z0c4KFkH^bRw;*&exg;X|17?+-&yT}QQJ^vSe$6)cvTX#JY`xh`1kCaQ^47wDiRClX z9j&d@H^jFVh;+i;GseIOI&nJ6p+Uv5+)SDsCwc!f4H%xCdH4pR&z0SLo80pkU|aiw z9e}c$_}7g}?eI|Q=7VRCmV&R&gVT{*MW&P@O@>8^TjgH~()1fwf%!m~!GQSe6W*mIPsK7x<}GALMBNcK3$U*LEz9q@>} zZf**BW?!MCG~@BPR2J$wfBe`Ap5BbMr&It6@yW%7KG;*ZF-A^TbB4ge1h!@h*iYFm z_c1i`fUKwwR@-2@K#NNkyq6_lLucvn0Q{>D9C8-WU+L8<;Unm*-rtfRQaIiAxZ_rh zupy9NJYF-#C@E+T7SASkAO{Uo;N)FcILzrbq8a=B*P?N;hyDJE7BnQEYU%mBceN{7BAwWnFcz*am>VARV+}!+d(t!$Pw?BOtMN(usQ;IwF zywMkJHuvWxDr&@@BJ&d#K7OANDqkl+DoRL5Sbt{KD89I`gF_(_kA5em@<(6dvY${n z32c}mk`{j+{uM#cYcczgaXB%ia|d-geXJ)MN$+L3w-%&@3RDM}GwksIA|3i<${*rn zxRd#hWrRk3Ld7vB19U7dbBj`SG4XTxvqwH%60L5=KCVduulK9gQx5I*k~6024YQXS zU=qAPJE2SIQWti&meSW%Ty2?~TOb9cDoc%Zb)wz%cHE$nh6C!yURdD#LSjR7OW7^Y z?)TlPg~m!P-l}B_~!Pu zSUkfvY49@8x(R9^WOkt3N2RB07tIc*G~3kSO`fdYC@pfh3K)wRklYy*$-DZO|JZ>- zePgh>J1<{mi z){DPG94i~(Q~Y!bf>X$*0=tbgq^lh)$N_KzsvcWEa;0XAgy67M*O<+X|I#+c!p24? zA^Ga$#0h3rSeckk6=IWmK3JDTEXj!6xSpJLvO}I8e%EkES(Cj9f7+}+{PTpo*u2=? z*@U~qPSvISx8J`Pf%0*FtK&%0^V%eh03COyFYqdJag@PVW@Vt|Ox*pC;dVw4yyJYH z>D248WJ|$?N*WTMSLd}{Aq^faAseImB&OH1in02&1T^e~*aDBfyXTBn8=%bg>*3*~ zdDEZ~m}56-UOtt+!{5Yz9$D555VN-TgnBL&VP|+t?hoe({}wGn+^6NE_ZtyttN@I~ z)HSSNA@#*34C=3l-hb23;M+hSDFfa4Q>4MSK(vL_rJ-Bik^6`G?d3$S4;8C5u2;iN zH>ax+@KGNj^bUx@gEP=~2dd;YX0$||V>#aOi-g1rFSq!~!Jr}q z+ML{6?ET1B%F4+VR91GOY-(lo(0+UCW|yzA zVW{qHezS}n#5?-Q`4zP>b2=RJF}xJ9wdp5kL(4R6I=iV3LL^ZBJ${?nhR>))<}ziH zBjDskyFK#BOwUKbnHG8-diCfp%0iv(T=V9)faoRZ_O3yZ?U9DJlczNqQh2eiB@Bn( z7_7tft~9S#BIL-apyDGXX9|(CY+sL&xwoKp<(BTZGa-HTOKA^Yp!->RqvB@IZr^a7-jPovqeJ)gw5;(TiDCPf^5Y$_@KY3K15U*)4#fhj9Yt5D|Z&R905%?5n) zf*?C(UWSRBBH5|+=a~s5GaQW zG=>%2`%Z+7jNli>veByCUP6(bc9;&<@xS`>1t030ajTq}MNQq82rX(lIm;E%!TL*a z&l9f&GOYG2L(~{T4ipTro<@#$E|9}8f@ZA+k)YzbI1(HrKcIBbwY-x-T6TiTLKTtmhHoxJC7QOR<_3F*} z;@|-%W((s>^}XC&2VwWdp~$n;nc#D<4jwPOWH+g_o)t4E34ZWmwnC@i3pM4c_UFAA zp^9J}G{l!sENFzuvL?F>yPL+~ZdR!@ zLcgCE9q{LF36ktUrP3nfrNIo}@>iLzdhLb+_va903-0D;LQrIY&+klSHib%>xl#Xu zjV}+E^f;dUVV@G{kHsh!`z6MjEV%57bzmfaw=;S|7|ZyIoEx-|UyQeAA73_G^5o`R_0jgR}Q;G$ho%gj-lYts5CwTUfpi&lDj;P2k)BTK715!Lfq}tf_4i}-NU>_gEFuyVolW<(lMu_~+Q0j1S4>em*YRc@g^D`!rNnX!-=)`Z966vSHd@Fz$zA`n z_>7?H%9MO^%M)k)h>qzqYrYU^bwN$G{iKOEU5wBm(adB#C&w6Ko@4b!o`QM%j%YEw zaVQA2kPRM_P|7z`+*OnS0}`}z7&nfh5fX;{9WmgF2n_rvEG(SnF1fbc1cNIhD|8g$@8#rrEFimI^O33%3%yN5+c zh%@Q%axp>$U#uENkjzP?LW|h39^`%T4+?lysF^&3Lx3V65cl@X3w$T>Q;zRPG9VACgvZmBBmE62e4w2dS6$hX%vx4 zn?l>l8+2;X@cih%9|aMIy%n@8l_zmes?AWPG==xOyEir88%hpTE62#wc7F+8TMB!} z=UAE39rUZ2J>#Q^zobVI>tGoG@D!O-xfkBqf4Az3Up zsusI%@@nax&Y8TV9Mc~vd2Kk7n!oaAI@|_{07kc49H>`A@ z3d)Mg?X%`n@E1ty{_CM)ljd%ggJD-{9`Zaz6Y!b+?$$`y14+F~urLf+!P@$x5)KQD z#o9`D_bfprHcc>AZBlJkp@`xzyMq2GW2m6_;Q4lBy)CXnf!OZh7j`%>!y2Giht$lNI19elo zCoDmFNBk$UJTfoiDW&8D9m7Kc{~%#0usO zXpqeN;(gGGFguch@M)-U_SqHnZC^#eU>T}f1WYRG=pII{a571}E*@OpHY3^Ac6uZ> zLBBC39)6q4=*L+kQj{^z=#D*1^Lo_zoQ(Z5?o&1c&164AYf{flGz4KhO;6V5viqiK z0nyz5&52pPf*2|8zJgzQ9}Rc=#$3z(4L27|KB))wID0xS{^G%xQ*%t*3!@!h8-sct z*hXqR|4;q7l7VVX1SscQ9g{^b^Ov&3991q3+E5(Rj=4y)8A5+3(04r-K14*s1Q9SJ z`6xUS2CCMe;HRUSVut8knv>3k5+DaP5epz-lz;#RuzCU00 z$1kiidI#3$^A5tdN!Jq zg&lWE-_TN@=(yXCL%^clBzV^m@bOV?_QDFY)K9ac+@j~(TlO`xbIqY+r{0(X$?o%Q z@C-<3o*$x%h7R_xsINOvne1%W$v0pg1iU$QZTd)S=+~ar~J2kP~Y~m zOA;oXFi3YX*alqA;g|{8-|P-1YM8$p;*%Yo!R@}>JN=12rnDPuSKQQqBlaW0YVCcB z+Bb=FN@}Y2J@N*SA51e)Fq*N>uemJ85bvXd*V)~TW2((H@H++v#(NJugSpb$?F>4Q zWh8D7Qyl*Nq+)S{FGxrCwLyOkm5GVzBMa2qx7p5hTbxlTk0NX1$&x$!i@0!aV1}r) zL}RX2+T*FSvm>ibIar2MUc@X4Cf6#xPY5MI@aYbVd`+e}^32YkyL+EOXHz;5(usR> z+7{sye;0240gd3B-O$*MZ00LiW9ZJj-RXOgnWMVutpP_ZHzIBMo{jr?3^_CxPZLzl zI^55>a^{$VCXJ(37dx^t`3WKJNAjqwU+-xhHzOlNztA;glS&5a*6uu z{N!|gz2SHa4JGGz^kXijFNGf(#sa-9KMB=Bc0ve@}4U9FfHg_OvS~o);!ed_$p4QruwVf}9*^ zCo)D(|91j1aP;9OS0XvShG>2st7Z8?CI^9>^34nX>e3hLeA+sKv!%pY&vdN%X*{pu zcPGfI|FqxdT-TmGy@s11Nij`caeYDw5^qq#8FSoIixN+a`f-!m@bkAThk~(j3VTop zV*ex4i>*{1p^R?I0K1@SM&w+HRhm^5ETFWllU6XlfIa!O?sJOVy)T6>_didwvXPSf zA>%s#YV^uAQ+YtWzP=tBs=cFw&E2ReXJ|luJ`q1t42FuR0at0q+=at|26I3~&65KP zApo_l*1zQ^#;VPx9DAHRZ&cIq?o{R@G@zh!k zuGw%x!~v})ve7>+-?NAx1RqcIS|h~cs4AFUfAPgRzZaS~lX4ymg^0MmlIwo{VD3WO zoPP<`9O9gTXgzNV*uFCOTp zOK;K)|M;}`N>WJjiy1FEoZurKv}PPLEq2tyFqy%+XzxlBNab_oRPJwDT?$Zz4Geg{ zi&rkC_VOgSyR(B>a4sQe78Y~=i*Sp}qtO!zuAChsh5e$2jpEK=7#Ix!O`HYx0LO?NKJ|_MSRF8 za<6DvAa7dO^nc>lzu&&nzj*_Vj*jkpMfq}KYxBJT)k~&=OtvRwF4xH)eqEm0d5XK@ zlAm8e7RixP9UZrra1hJ^Ug%li*`CtFipp28R`b*TnP0Bv*lTU>gN2VcZ1!*}`6WJdWD}o0E%wLfZftSoIDcAm%jgx| z-Hu#%iPt)MZ@l3<3Xz3YXx$^DVzO#xL}!L2|0N}A%TZ_^qxbTzJN-?gMVs#pN7BN; zh!~v0GVd42#LpOzvxoj0%ZEsh3u>s3+(u~mQU`mllh=F59*&hw>pq8%J!PZElDve1 zge@QTM<&hMh0Qm-y~USK5gW{Q^N|*+eC%$R0-uwS;(s;*F=WD0T2FnHUH=z9gX5m= z+7B+|?TmU6dyym~SECD1WqyjIQ;7Vs_Npr67dVOBc@N2yuL3ENA-kIe6*N2Ef z*%UPshTEJ4S7gO?4RBCsk?1evCFSvgjG;gByEiw!g<{&BqcAJZ9oH~#AJI)@)P7^o z0rytBHle7@cz>FDn+IK#%`{G~`g?Tgv$`x^`EGf_u>k3efW~&`z0z?AjZ|K%idl*& zp?fn!y@SO+Wohgz&iBM;IelJe;n+{>y`{wGcy>n1U&lxq8c=o6AFq1-XG_#&J^G@c zYi>)o9B-%&NGom413aegKfRrd=wq~3waV_kt=_!+CN#AD3CgSZH&ds@qHm|b6BM5R zgcwLC`=!!gjk85_6KTifm6tW!eh?!5{F&I=T^v?iUVE!s>0msh_wT|Z@9I#`1o{T3 zl)(Ygm(%$cGE|0cSMbh!6&<(zo^Z4VdI=RP^CvECZDD#1`2FK%pTmSPGxcv8WKV9F zRCx{3+l}#Eg3KQ0Phra|++RqD!)nbJep(%HlEvH3hnh?Y)=5NvTbufE#C)5@>P(=t zq8YZ%7;d{(lA^97doJ>uZTd+26Xb3XJnN9m{PZq56D*rrmrIk<>Js%Dh>XkWe74ck z2aT{A$wK+kn#aRi!d5!L$;oP--o-AU@|Ww?4xIi-#|f~%moplXDulkSGB?dO-7HNp z-$5*Jux?n5_>CYy_PE@{c!j;NK(S$JN%kQclcPWbf79c}yW$T#`AcVvbJ&W~k@kh0 z8nZ!heP58JjOMG6x{&_MG4fC4jPO|W;Y+oeUQ`r4MX=-6LM=^|ha8BLW3>XRxs8yaR91f@UG7-AmoZ&u%ZVv!S7in=1&+ zHRlCs`yPu0#!x_jhFjPR1r=OCJ#1R8=Nv;Q@B-&~AI>`=O_)MEPlo1XA$sNa<(LvR zJ|$toQwbGB2A)k2-AhvQeId0acz8jF(-GHQo3@5~Tvmd(}{O>@wg(xV-t!{4m zf&7o0i3zRBWNO?l*!Op5b+t@)*9#~XAD_sZd(z(zd4|o8FQH#-xeSVothi>SSaZ>z zXnYibd!H8=fg0(lLPaiji6?y)eDDYA z5*WyC?$mZ>&_1iC=%Jvk8=zgc%=4 zOT!7bm!4iVZkInW`@i`53aBo#t?xf5NP~2@G}7HE0@4a1NGeE!f^;a|NJt|kpduh4 zsC20yNGL5Sp@az1@$Cnld*{x3@3+>hnKd&iPn>6;y?=G8Nxl|-l^CnVGL2`}P{7d> z_9QPTwmuSztTvrl86zUySc@jj7CpMAYjE-E#hs-k?H%?R2CW18qOx_R6$W!ILEfK@ zBYQ5UKlAMu9+&R^%%WiEFlgoT~I z(4u}Uak0DV(Zuo}{PXyB-WEC}hJU%MP}SYcHKFU=_UW?pE%MrPFQn_q$Y7 zbeo5l*J5{4CmN*o_|2Gx)* ziHW+i%iG}$L>(>kWVx<=k?BqoVmI^w-AaY?q%6H4%bq4ZRYaS9k-3n!a1lSFFn{2{ zo_oBwq~gKItfW|r^OVsKfoD$j)Rxnoauh-GfeiKjgGQ?bci_h6ZZDjzzllF*VbMMo zVT9N9rCwc3Aa4Uo;hFmcmJ{{vZ}o0etaFiHF3oSpGcUraw>PE~X_f z1Lg%`Zb@G1+hx7&AHp*-cLJ$T@t3T#QbxphzW?3qGEg6j@+H2DkB>)bXlQJ%O)`Kp zY3L44LG)RykhfTPrxO#$RZTKBu|CGLak%oGj zG&6c148BWoa&`EY0?+og!+w=pgr~~V-MYK#>uRQV_wI0STIplWi1V*@wwTGkn1r`= z$5m>KQIA<(j5-HzeCGEwN_1pES18?NWbvr+n|JDLUYw68iS)*LhH_PB`T7xTSCvS(`^aaG_?&R#$brq z9emB=B2|<2B936!&YRzO)UTDcm>SkcDDZuhC%6!L*H$BkXsT7AA2DNM!V)}4$$yVD zUDRSdM>-6&wD6QjWFBi?R9Nna372yrLQlZzSB+uhG|L|7eCC*8GKSHPVKMz7#FPG* zHtZFxB5#Iq=GJ?148GeJt2FGIc$G^{BDxu!1mvylaiu0B$`?l}b@H!by^Df-itUst zpIs+7*$wTlQFL89e*t3y=g)4KCnJ2__Q%hkichbUp46`C)-c<$i(6wSpvYXd4B0je1pqg4}a9zv06nef9D~ln|b@7vZyxXoI`+0uh z;9oin!@hFNLux6spVlV3$fBa6PN9Z}hm-tYD6`jVc!ymbx;gE-Y$eJ@iY4&k3mj)6 zMoi4a%ZHrhK=eRqcCz?-gKxxH7S$=h*KPaqFgjxCqC)X$TE@qv4JYkBJ;^n$woBL2 zEqZG$2!EjhP0pWbfr{so&Q4<5wV_=xpuXP!<{*UEr|i&pDF4lKImg!lvk>jDbz z#*0@+x=3*P``yIm$yn@iO9B#cU{~#VBX|E&P)2-ixA|-a}NZ*#H5;Z(z|M}UwmuvShaLZk$ z74fYLU&F@LTbF%fn!>2P>!Ep)x|*HKP@&kjL2R8T=7SZdPJ3w%ck*jKw<)3-6>T$r z*<5U6N*LpayZ%hJ4+q}aW@1r%jXg!`oV8X)trJ~pECb;?8;ulVJ%fm=5k~u-yw6ok zXWOJC@3sYxk}#5Kc>S&@%CBLl`dDz0B$`^}$w$A7&HvoF5X$;$|B77U``IBgHktj0 zH|%e%5-zn!TS|}$2!tQ)+OTGeW4C_$H0?6uxw^0tM%d*i9U3%gFGq_d9XKg@fLRjk z6Md^@0OvNBGA_c@avKuW+ljpR@@Bf~VrHhfm^)6lqz6y+yB+ln=O9uBhVAK&Fq+iw zaufnf0uJ5dO|-bu6iu)5UUqD}i;``Wb6**+5;1Gon3>&4mt>n9N{PRRJZ9VddYvI# z%)l^3rJ?7hebx4cml&=%Uo|#v>F*~-F(%kf3bkN-Sj-u^j}QOgiwl>ZStmgSb}#fM zc$s#@Dl|Bj(mBH z%26;WeBPXn2t^2%0a3@{LN>q~ux=faja~nh^OPtpO@aPw5WJ9K&(Xtu{$c}cOK$Gf zBR1`OuC18mdt2AA+CqHxW1ooOEKFvGMb+WXdv)Ir$yIr$9bKHo#c~R8R;I0oX3f4^ zSg{wQ$jdVX=v0NT@^E6ngRJk}vTVJNAOCdDVf@2;e55NE;r7l!28UqM5cm=Z2nbLh z9rL`zkZx;q@TDGhEpqG&v3V`o6YyN_>2iqs1$T7lytnq0^TwN7n^a0m34Qi)NbP9f z*$AH&E3EFe_~ALA@4TA=rQ;qxSL^(=ZP-_$$F4Q5<`7(;wbe5E_SIna1pBwvVgt3G z8%mqn-yemCPgPMFl%VfZ4OsNQI5(l8$Ms(+vvAe-$c7S3nhY0e7H}vMtjOdvUYoGM zg;EYkt#*#v@!Mw#IHq?F+j#`nOO&4Ow)d8W<*cZaG;*agGw2+bkqbr1x8y37Q-5TD zCNE&!NUYV_a{WU)mIn6yx*!Q-@#?9nG0N}9cC>qpD@T6HrA86aopBPb3CUy*?9qJv zYUGLyb8BJ&R`7e&diHAFBC;yzQoMOXit^dx(oUY_qG(KSpH8kK1{}Idy1hEl+7g1B zYrrLvh2BRLk#Du7hDkwDGt3wlJ-3NXBb34`XfRw!17n5a%rYK||H)Mn%`2ZN;#&Ff zL}iA9%aiy~O^C!IapC&PJ=-!EpM!U`TE@^OU8C@bv7d$ED<$6!fCrA^5{%bNc#wQ= zWay^VsN8_KN@y9kZw$$?l!6K-^4#Ky`*(`AW;^3%`|>XUA>9N=rdHfu&>AegMlnA> zU$+N(NE#TyKHrHfApJ^8^MznN=-|*NIEJe0*293ECUPn_w!3#y#VFL%@a898DKH+m z@oF*>nKvx>Ztg#2pydDYdPkfmYJlPwo0F^atU;byK+@~ZIrSmgZ{klMcah{z-W4G#9BBPS0nFE5wg z|6Htl9|zxiYQwU%Ncr<;1dU7ew|Jmar$-PG{yNFV{rk7n6#*J|hi{J_U;5CmkzI_D zuOshqAZ{t@5d5=p*EssyV9;SVRlF#SLmtWu?=HJJ_|=j%${gZzskV2}B-sbv;C0(=WAYuP0U>kp?{~SW>K%@8-r}U zSI31!*@(mWUme-{nr-`phis~y45Dhc;5~7JP>G8%i7BTi`H@b+f*r-EG0QnNbj#On zC{&4Z`hq`YXmc@cz6{bLIk=`Z=T6OLpcyr z@rV}(-$&QMLPI+hPB~?2#2A{hcuzcPc_A+c&+Ea0|7moz(b^gdTzvS5m_=pCYdS(` z@D+CQ!{aV=cf`cg$zb|Q?mh(;CtF6>X+~LXd1XfJbDn^r-?%aTHi4zqYl98w{*O-$`8Hu`0i>(1XpVUkGik=P0ObM5c~fb3KcGHkd8NDGTR95pS(1Eq`{@ zDJ*aqEm&PG&yYCPQEk7GMAv6?9C{e#b+kd0v+jCC2*@svLjw_TQtQ9PJZd9N8oATp zMpzuKU;$h2un`=n-1qvf^BeD9IK0LkGXR>#;{q(8tX?ObD2F&x~7o~Wtjk>k$c%37x9fZHX3`Y(5eDMv{6CoeeW^{#3E ztu^`wrJJOFPMJBucS(T5<9FCCCw##}#e>h&*H_45lM~9UMBfuknj+1C#YrQku)ZSz%ULAk z&ae*U_A+*m@`K*b!K9Oaii#$kl^9%%N6z(AD2a}z40 z&4Z|1w_mpgi_e0dV12Fe*s>`Q4e4Qb_V!vjJI|n|R#xy6)J~5nKA(xtdEk^|6*ft{ zY?ppdNCdy4a=>`lup}mUm@rfJ^QWgwyAP|4?tDI@d$Xi?XOGc39t~B4;jFZfo@L=^ zZOXMv^ef?lGNbo|B{64Pia4y!C5@Mn*f{Z5ih zA9In=hUL8`_64J<&K?^6nFA~sFjNQW&~Bf)ecg>cBwziK3g{h}oEFaWQ&DA#Ld#+< z=17glZ8*azmABTS-y*QT=P(}is*~O(TO;Qc}yx zUIRa&w4HlmbR{S%4OcrYkPEL@^vnx`)R|*RYC;%Ga@luU!vO@*6^nXg;|Z{*Luj zlV)^Lu-|IFk%$?f&n8E+Az?ol^%hzVAx8z4nKaBW>FXJlpuHrjb~`61m)1>&Qm&~f z-^jv(^2Vey%g>`%bMD4D3y=_I`uU}htQl00%zJhRLFA|Q@5UgW7M?|w!w50TWx55U z*x-o?3-$Fz|Co)1UL_9Fyvt9oq-(_`y_iysK|@u3s5DH&I`guLH9;t~`Wx?qc@G8R z!1UhxKZkMqFw@G+SiFBx7nn43N8sY&TgVKa5uJ<+d^;Fiw5)d3h5aYNO|zVsw}Wb; zH@O7bY2zdo^-6Fr?ZAO9fRvqsd9zqI9}{9o(B(kULoI^C#x`Q6`QpV37uv*Br9xI(Z21id$;o*^y>CdIthO2IoAj-3;Df40jE<{J>x4+fi7C{1%qg}O_B*E!` zZ2{(U=#$4nv6Tn3D9uJ0!q-yEs<1kJ3oiTwHILqN6NSwupWb?JgbuK|-U*(lD&hY= zoDf<4^Et;l(@3Iy4zDf#f{Q4}z~A*Dvib0u5uZ7w1HFf&df{0OxO zbHi*GVRvOub@4IsY~omph0qFaEh7ILr!ko9zH${{oOYQ}Bq14&rpNf+e4j~Jcn-Pk zw#S{8*~ii#=gT5lyH?NlSU3&cszTte71%=bGYi${oI_5Pc`2)+VntQ!b$$uU>&+X= z>Zi4@5_{bdZdvGV{_gAz-K8QE&;FJQ(p`GAVFBG&{rcxBPQ&gY4U9RXZ>cjzomx!d z>ieQ0lDY#H~~Hkey`2&615n@p392bbIMH$F^V=?rK5x>+K}wWOSQZG8 z;;Z(z&sJNW6Qq}$Z&SH)r96wD{>r33ZcyscEyiy}|CR6vOdF5>(@Uzk_PxZ;yAr}n znIBTn~~(Z4I)hojd>mFMRVSTDTKET7{?`eVX8hH_y3cN$i= zDfI*JSedb*q44UC|2+j!kWpjx-TwbI+Bfi9`FCbNF&O##Na%enaTxDwJKJS^Udd{| z;}7(tsD6r!I^4MMT^$MDWA6Uj_2js+4fut`C8|HUa4AF3U(9x%t+ZfCT3^o;&6jjH zhtI3vQu+`0zN3hRNGf*bEi(grvPssA>$q=#uF*FTJp-Md6S&`i&eIw zH*cDUdeLuugxQf^=!792|z(LiQ5RuFai_XI`BEP{| zS&o45*yF8#1LJ=rov5<0bSbLIskhc9;{c$g@YxSKb;fc^KAo|g@$YS$W(B*BhNdP@ zd-sd1B^NeB9!^f52#pPCHu{2hlaUva;{yjLJ(%G{DzvYiBl}E5P38I*Qln|HgGza4 z^!0-V2M_Klqly=tb9ub84yZL4cvNiNIA}<4+jbiEwios52!odt+a50*i)n8&Iq&(J z-uds?3GmVgL=~ZIH}cXL7oUlVN&N1rNyW?UA3r$S9X1E{etoa7?PK1mj)}pK8=1Sw z({uRUD}{r5QdCb^^9p%?9K(gt?0R!qZbmg~;lOvn>+uFKTxw!+1~dd9l3?=wVEneVMcnZL&hzpdQT)d%qY8{fKaz6&A8p(AsT8SL zt^TU3rE*OqVbHNcv591#W~tCk*uMCGp1~$nC?XCbacrdcM*RU*T=&u3-u~$?3d5m) z<3?&bmqKloPcZui^rh{!+cS!{Z-Y3W{L10JlDT;k+$PWKe}m+$7>+TH6$S0Yo)TVv zdU~ur)^xlFM08AmIc>gjSKFJ_cDiqb_w?w{LdpZL?aCG8L#zT1$CWSFBlMYy#MeOu zT3)`ZXhd^${Qu7jLHYBM~KKIGvQo)Y0L7{5xwnH@0psnof!w z%&%XWq!f2L|Au?FW~EJ*d%J@a5sgG!8~FiQf5Vs4;<&R0t4pK4@;=PJ=Y{cSsAKY- zDg5Yy=*+h#?t3>2l+hjbAvLc&gM#1=GAG@Iev&Pos8w-pNZ`D z0x8a$$Xi-6r;9p9a_T>B>h8uvnL9W{-~5o?+}lgIw6yeNeO>pv8%?k+33MsNU%SqZ zt_0LY;?0}B&U-&1RKD@6konZ~C9t5dnPvrJh>^QQ;vpWC^<%ms=2OV{k7aSoS6N9- z4GV?DbD^5FNK=F^+z$!Cc;&GY3FTuJ(38c%l0?wtC|iLj2|J8{>E;G`F)@jRV+Vlb z{_}X0LuI}C9>LbH*QYV(_j*+{z42&-#P~bWdog}IU;IncpiF~2MIm(su4MGL^zTq8 zPEJk-1{)h2)Ch^HUT)AL3uxZlzT!aHRQ;SG25PZXnB*lZt%4N5G92QjEcfVF$xPOt&otBVW8sG zY!wCa0tD~fgPxYGA01uXH8%p=K{f8)uc>BuanQ*zjoh2PFR(GH8T?jpI8~V=$eG0q z@0kdZVo*oUo8N_5QW4Z%F!{;zkk9sHNtsh(!cZJ6&)5V`hOI-AL%nT73#2OnQE%P@+l~yU;x>Y9gD&z6qSFzS1TBw zkxX-?$|T6{$|uk66O1T$5gkUm<&~AbT%|KRySvkXumP*v0cjWO;G<|67)V@M02Kjr zk+zaDit2yIMGFdDM>UPB0wONoW3w?FE}krFEDF95%nBpCik;b9d^|jU=P%OT8Dep9cd$6! zZ*8x!*tM%$OZ#_6L_K?EE@dJ8tl^LxU4SpZKiH+fy3l;2^u~_Rrnc6>cN{aThE882 z>Z?B!DNmMh-_^}aVexuNWaOcXBK8RYeUD>Coc??tv)N;lBfR^=?#0;n-to9saU(-E zvr6yJh_aDs2s|C(xl`7kr8@Te5m-(aevD5_3N9#M1tt%^w&;Nr`0U%-lv6V@S~DBs z&(h^;YN?KIt%OfjQbe2d+!ET@3Y{3_b=Ue{Tafa4k$c}(J8J~iJ0vs-1?Qrq%f?=^rdMv+Hx*1H0pRdC>{lntTw~t^s zIO^aOr2JHJA7_8N*{ih|x;Nul_ZfRUNg=F8>D>@s!SK_}(f0fVuccNW|M{9Z1ptMw z_1hPPVL1k@r+K#q9@mX#h}dJ^y?b~3jTZ@g9!g-;5y@g^3VHCqx|Hj7UcqyJ*Fz`t zbeMiHsQ}B%An%1PY{Z5g9N(Y2uGrcdMu>*OrxERbsSO~a>p~BmwD)E>4!-ht#C?PG z@7G^{A#|c(lnLARc$TvxP3j-OiM-1)B3<_$dyk>-gWU@Muz*}pYl z4kjF4G_@)%&~hOS{7^*MNNVm|qC^^P)nn?zB^iz1KTUmh(q&?N9C3hxfePe1_#hNh zyL3qf0wW+}SXfSl5i%gv z3k?Mc2GiWU=<>h;0plCL*_ktrSwRVXO)H2V!b_j+!1szCToi1z^=qV-!aXhqn=w$E zjv?BapB|*!f9?1;u=%XER+g8M5FCh@%7Z8lei4z7v@|MeQHOBIoj|7E@=9)+#BWqo zRLy{Vfp)DOi056cqHewaAp^Lif|-{Ly5EocabGf-@7E8~H+mmOzKdbk|4sG7S=@Xq z)Tky9{X(iqGpO`-)D@ochVfjP*IjHt^= zZOn4Mwn<%H4wj(5ZEj)P{9EeIRKPP^`ti}l_4V~y-(5jw78VOTI~)dv96qOf9S)b3 zQIX)#(3ZBg)7jbC#X!B3d~M^$1HnPUg9rDZ`DmJ)WGK?kIbVI4$fto{8&aIioj^s} z77^v(XaSl?av2{*`F4i9!d2xj&CA~_&!$no>(D&+ICWOwj479UZXM;x;=*#?eD$&X z<_Bt88E=Hwu`={>adX?V%$=SLIBtKWaQ_Evv0A<$=^3uDIujioExxX8nD92vh$qzO zVBKx9?)I5dI0@m(6>`AMAz_iw*?EmQlaBLw_fXl{dT)Ha^JepH7@?u%^xsZptX+_| zwgaXjm{b!axC5=1hJW3mpyYRW0}5Oo`#L`1{*yozOlCfHe+D7a`$} z)_%zy3oIE~b8~ZfO^qseD?w(L%;!V`pWb=XSB&S*VI*^1R)mZ^#C}$3(`yL`@7wq9 z--|p{go?EeNiFExQjVj-JXc=?`%QrH%G%nRQAmiyuDDvL!xj0S>>3Sf zoQX^~yU1B-rB8E^W{Se$G8ST9#H_KgKsvmA81h~bQ5#9e1@v^Zp$l|~(1zt!hbgia zoE1O8{DTARc#-qMr^f4U|M>CaE!ppj2Q_Y<)5I&6yLT^`n=^jP_7v=a9~G~L?O6*Vyrs;~B*K9Ew=N#1FKCN<`iub8>|#YROy9l5D}3n`XA7dVnehAB`tw$boJdKX8p8x9o*@LZIM^F zb;cfx{sA?KS_(faw(hLY_LZ5*4Udk_N0N0EjgfT%%WGC}6X$-O35?m5oW^Wu+?4bjq|UD)P8H6=yrqD6M&m(RHZZo^V+pBllFUWEJtVHo7UkV zMc^~8E6-A&)~8Epro)fJ?a77o zVEvnXeiJ4x^`c>-RHj<+(x-``GqU z43CmhTYGzn3`L%g@UNFY&K>C<86A3x5Le31Tir5`So-z2b>It0$qR!5=HyPiPa4-& z@7MG#e9!wEs9n^{A52Q|+Zl>ms8>>xO!&)N@NFIrHZ~dxb_=22EfAWZp+Jptb=gW> zLc*duRlumu?cC~QBVmGCFxTZL=5S*mdR42~)_B2bWtFhiRrXf=Hm13(R??YVBGrjk z&DJ~oSf!=A8u1Wx5BrtINAiPTt9)AHtv#m(zFoRs%~T(TLILha>~b51d-ubS{Wd9D9;g~W8=M+@{Z3&`6Y(|&cW84n1f#x znY!w`4twLFlSMz0C-~Q**YiXqmUe?!(A$K89T%#wZi(Kx!7?)j^dJ-=gKXRPp|a_z zDHIMnS7K0F7tg2hI)DKV{9vA4 zx6(4PzJW1@J!|o6$5$$ zHhg@vEqD6#X+}Xo;;t0l)@UlB;RJMmu)7}eAx}gHy!S}ShN#7MSn`Mt1Kfbc?mHI? z)ss{f`?bb=dExAzr2 zG07b(K_>T)$q`AymvYLY{*r|XQ?dT>@FatWCsCona~&Nl&4Ca~1H)|lEs_Z3RH?h? zp>-+u-F1V@HeVgD6NfCGZ19r94I%8DT~{e|vqV^@Ywxbn&&>xvcOOJ4p~kYe>s&@o3Xg70t8THZTG?CCQ|i2*BReL9|? z4dq2Qx1SXIa|dQ1*+g;t-@7PC3u7*VV8wAa27rEi*d) zvsp#i)1%vD%tDpEFSY;D1)RHENL*1bvkb}}ua{0lb3$J1OrvZXX(9OAV&>XY^jhN+ z&2(LFQotD*^alpV;*^0;5V9o5P(NU=Vd3P&2IWSkN1A5nNjKd9gfvR0&aLmJshs;a z($CLLGwhE^*fh<}hCn`{kEje=Ad!IId(+nB)gA1I=S?lg;N7`E2!nC+Bdp+@OX=@8 zNXI>_W=;H1j6$rZ5slR^>TO+|zagzWZN3D-wo(7m5bubei8kWsAjA)7iXT;CB-LsD zEsI?|12ekh-PLID^Up%bK-lvurN$wuG1O?N)rr?(uxOd2rDxxQb*7$e{%V1#y;VXJ#I7i$a5 zjO4AwR%fv2=o0*=7*hugv?>b}PNiy65?=iEtuUQ}X_vFKF=RN}=$UU7@m;Gmw;37k zt+rz7d~*&#p1z}BS+9;R^}GLVmH$J02LLj>?|W*jEzsPl`%RuqBgmIHP^4YqG$ENO zs3*Q@{@Yu@(-7;L*=6 zGI9y2R_kR-$|{G8aZib|Hk{iUjrOg1AIx2J=pOw@E%fb!yuQ@R)w+xWtJ_Ne!O)wRha)6Tg~XnXlTNK{!%jr825&>lEuY+tKnQAOH&wPo0-S5$7pu z04@lNpS`IkE>hHTodI33xy43Ex#)}Y$+TVQ?gtqKgqyps7DwxD**Ewuw0EwHxNA0) z@iJlLBA&=(A6;m3P#<&7;umb%Zag80Or?CQm9^*Asf`^{?6Sxh{lq|5TQ9h{?_UUQ z%W!JNFs0DA%@oZ`XItwNH4YXhi6^h{8^3*WkixfSh&iKteebk<`jp&TrN)_%C7!UI ziH8BekJ~geP$?ZiW4X2WlP&GmTs&82cu}{Qba>tKjED3Wv~wG-!~?Ht+$-LEbu-{E z(}|=yK?{iTCV+>8nN%}Nk_?*hdp`$~F;I~6fdr8m)q9I5GoNKxO3-`Yv9BfY@-==m z{pI%~L=RqqM2D>87-$vovdMGxtpyCCzio_I;M0Oq5dQf z4WA`KO*wpvMsVdI(^E$eegl8O7oZxw@!2MT$AXDk{!&k)6pA-pwKh_DMpjmKxZL6t zB3zxB$y*|Ie_xcS@E+M%TeW2M3{G6iDgx|o29%Vf`?Oe`PlV`tT&@?r*Y8_!W7zst zQPXM8EMQ^Q+0nr)zzN$KO`OEmE0p%h*mSX#=29V%b1P`%iogcwmi2M3>S z(6_uuEzI%713&5|9aFmnkLbX8Zi4J0$&hyV#Zqt?i0wX*Kh-dozv(P-;{~f4W+gHeT4nM8w6xXf|`7!OgaBLF2y1a_1 zl)yxAhGD+SCuzH>pIpPj*|O|iQWvSC8Fl2}_rQNt2Sl2?kQojyP_1yJaZBaGg@rNp zq7Nsi5^DxH{5a1W5~RRj?6GW=`?BI-o(34;nD=lQ-R%OkH11-PvN*};MT)f=)#|~- zJEA|^n~YB}k=pvY{Kg%bj5TI+VrfJjuu#Y_l$V$1R((N5WqNs}GP6PX?nrMMe#vW} z+&%S)2@5%!nsg!NV0*Yh405WUGdI76wwe-JxvVt*A!eE8gs6{$$6wsg7<_UW7v?M5 zq33jn1EjWA9LA4nu%{sZ1$rL@W3IC8QyhR#hD})TUKgK~`R>ZgK=B+jVM_s?#zXS+ zjYsIYxw$t zq8|ZEG4Z3NrFGf)VSyF0sgWy7L`{`@Frxxt0jJv-z%N~TZA3Ifz{>n^0_AOT@5jts zS7P0~^$sza!4KSDt>NA*fB*ACFj0t0iC)e>I>&zx+omV88Cqy2A}nM8Lx$W$cJ0ut zEINjgC0a0~3TlJL&R5U2>G+ibCaQ{*ak}}#wpdj4M9j9>$AJnPP8%iJB5n<(Tj{c9 z|I)qNdTIPSlqsw0+D|=O6w1fP=b1?zF}LCKIc|*la{ohb2z3nibA-;(O0-U3X=!|j zimL9qP>!@76Tj$Zwd=ts`E=$>`D4H9i5~fnE4GYT3fsuvUZST#;@X@e zcs#vd^&Q~-h8N~8l!k=H#}j2r-F-eh=Huy^WGNaj zv;|8P+kI^f*f&M9N}iLR%#&DsxBzYdM4(p@NYwIr!?|a+^O~HSssJTSdlh}kn`M0r zZppOVYtP!2;s8PR9^TA>lE!ZOZ?ePY~=4s(Ph0b^X z^m%-4fMjM30imNfrT33_x;+mLHf;`QcKO(^!^HD=D1(5VguSivtK3=ZNa>K*jkTWE z7heoM;O3+}yp|yLH;XZZ*X`jcA>mLF&&Q!;V<)H;r1JV+>b z21*)3=I2ujMRF_`v)ejilvOFOTCu(|TqMY4+-7`!M@4eMJ(QVc$Ub3eR<5bTa#pV= z^P~qMI#dk7q+9q0fz3WOICwf+SeJv77mUzGFEV{o3c*wi*NVIp*Zv?w z!)5pI#>os2mCncILfTs7gkPJ?O-?~^CXil*`DAGqh!=m9$Liz6Yi(MqX1FqbJ;9!| zHK{}YW07|bbBtxYdO@A#t(hv{vETS56Gpz`DJEr`wt{wZ*CtC7zkp;C=lPF#QN}#? z5=%(>#zIj69rifgdBug{IFTfF>>|6cu@IZq!tor47N)aS3YN` z3LtOk239M-Ic4aYa8-(W;mm# zwb&BD!i|QSbCZC?90vyn=+;wHcfM*B%IkMBy$S=P`MwK5MvkLuBy&iNM!Q~gsRVhJ zsiaaole~lK`j^=dl{MWJ`TwF|P?ak&;A-paRLa${ohYjwRpaQs?jEJ;_am;%Cm|sr z2ohuA|F({frhj0OLn2Txuwl*ALIYyem#4C9PeVgPU&&OkV<~7^zO`;V9$D8O@%rKI z&ZJlR=FSeizwdZj2{;W>91K`t4vvnXRBPO6FX7SWN=7y9^Nj@O_`op^P~Sp_hm8Qy zD0uw1rMp|xs`HqZjQURIf6mDyBF{{UN70fDoC>wtp<^!ig$bfxSMyUGnn>8#evq7u5o+PN-wf{(PN?akYA^rR!&UHBuBY7`M2~m2myj|l z6NV71NftS>W7pH;GzxBTMh=?U$(ub4ciP=t3xVGnFUTS&LX7Lo6C5CZm-PO_L<(}x z^N(~-HV@qlo5Vtz&XRN!Pwy1{ z{#_75iUwIkii77tGl=WN(}WmDJ-HXIq^=1tY_V14okq5|K|UAPGdKG`ndw znfZyFykk^qZ3JKxm-%k0JIljoMeGJSKKZe;vQELk3~tMigoLxuLg@px@ znFsk>U=~Z%dut#B9Z)$qbZcL~MzUjfJJi0|`cW!|kAlU^iZ3>Ifk6i8QcnpTEdFAF zeniZDWIsLS(LDAFGGh&N@nE2ksM7C*=M-(Pi+=RB*yuaWgV!_6Z zPI%K74zhsd7qaBXN|6#1r7Vn;uFMd1|}A| zW*;IshXEH*U)qb%P@!Iz`>oTd*%RL(q85;F=DN;yxVXEk7#NTuBxAtQ?io<)cVS+`9O7t#F5IFi79GvI-pkdV*;P>2YEVbgwZ zdu_6HVuJ4O+Lv~ivUQ8J=;$j4;gcXh#PO527EdF~Lj6Nn5}}cc6`GUE_)J@)+b+(A z%zS=k742`4X2G-DFN8aC6X=(1Lyn}-%dK3jv~t$A$5o6JB~3~TF=D+x0s?i~q}Bxu z8yg#8ERAa%N>XNlnt!D6aQvx#ZEfu%B#PqBBAhZxtiA&I>tEX8fGk8I1LsJzHZ!eR zYFS&eA{Y$(ujcV_+SS$7V!&py{is5lV`#*FeEY=Bk*tmYO#tS_tNqM&o`qlSd9Wh@ zBH^j|`B0!5lz5JQ8qU6%x83n2h=js}%D~*+ zc`wdlrM?=#_exh#mUr8U;v&@A?+<2rVxc{BZQEr*@?#2fE6DO=nUq42!z*L_AR3dB z-&{WX5Mq$;ZEi02nlmR9H~G;}Q(FQJVAjpV%Zm>;5mHehUqM#tiU3GBoG!kgC8HsD;)%er0MqCw0sNX?9f~$8-hq+R4u@!zw`B> z+3*o2Cgw2k%+L3)^h@7C1C1EdIpP%BP*537dCWO#y@}DF>E6yHyk>RncPLSKLsvX^ zLWvF<3Qt*)lE$UcnmdWB;3g*;3LVYdiS^xOhn=L#WY_1+O2#SZZs$&+_(^dsSf}Y( zG;$SRN$*oy=Azg_&+SM4I@rAp5x*2^X=%t*frz7ELxirE0K!(gZ)bogi$z34q_?K` zs*%y$Ra-T6^%nSEV4EPKqKbe~V+J-LgfMzmTB>nJ3svqoCaSEeng>}Nz;m|bUx;%7 z83Q602dV+i-6(~_h?bU+3dCjjJQ@?9x~aJt4V08(PUB4xB<#pC0fl|#eij^id++{z zge$1B`-B6J2MZfJsIgHFpzKz1YD-H?WDti+(exWU<`)!ff#vG5y=r;~thNdY3jAVX zd|SIAVPW|IbGqEQ(*`a!*ZnQWXj%zk_{u{I3ky|$ek3=r{pk94CFdUljyCt>Zv`c@ zM881!c(A@j=n&9ANA(_0)akrHClYW7ArkUjL>b0!wpnr(WuDM2%KqQ}Zjm8b9V0{$ z4ZI-IKrTh4sG#0C}kn5@*r zyw(T1hZZ5{;?vS1Dy%wZigNrddk;ZTwLD9L6LK$c7K$&l5{F(qjrPgN8m@GVEiUHJ zOc##ub2K*(MCM6Y`CXL(7GI;)!y_YG;9X2FExoG&4jKm@V>`gIBej4I!mIjNtjmHB zC~Rz)SFc_zHfxmiKUlwz31)iOi*9b8B_{{VB89a70VTw=Ivl`P6CN9T^R^klQwY0* zh?U?BKB3VqxXKn7#w&cboocWWE3@=UtdFJ2jCyw4+uNG~MFspRSTD`glUl-!6-rSU zI3Mys7}5SdA9VL*l%YX-CCbo<1+ASa)Z1-8%ipd7ef!J*98U|LVQJ%be!K8=o&Si5 zef7p#$VGRy=F#H0%g)Ha7XM-qWUE9`?K|t83=(F<_JhSiAW$?1bz)u3Rqz<>f32xC zqy6yVgMdZLX{d6_7cL+=j38)<;5eF!fa4=#Im|IIooPXA!(*+iNKms)!5`_M$(%3+ z{L$h-5upLsCyMQ{5`A_!Iv5SXqyZcpNkE-Hf8NE_)neZiYO`W45n%JfuiUvQL(y8T zDbm~`F<&}Qp5LN40HQu0%2K9L9K8&(T>~|%VC7|ti!?-k5Fr#C*93MebNg#Aa=)FM zxIPxEq#hTkJN;v$)hHl9#^yXG*wnh6L}}=_JqDlFQU1?$O9(NzPz6vUX3`t`m#=Uf z>=YVv5Y49_#7R|T2Ra@zYIHs_-4=(bWva81?_;f=L{B~m`gCWnsG{zwmG55rbw;8& zDKra!Acs z&SVn!5I!+1%U-B1(JRCO<^dDFA|)jyhw<98V=tY2%GI&n1G4p~JIM~Ln3`$2rcMX3qNE2H>f39gdi;rlEb#fIJKurAN7%O>`K|~Sf zr|go<{A6m}Yu)jSiKDM;I>9g_OmTwV2|INgUgq!S-q*!ErMt>!@GFz&hi#Bp;IagD z;4-sP&jDV~l@2V8ot@c>26}AZqtL3bxV+}QQ(G+fWlJr9JtN_P+CtH~76C4hf~*{d#nC=tD_O{US~# zQ`V0fx@TA}ilE_;@YBu|Pq6m^?i&OTRuB%Srmfux{e~;jHLiW3g98GZ`U^GCtaI*~ zn3x1DT&GUF?jKPzm+OQKQlx6_0Dtc?9rzyG^l)HuD{*&krN$7DNiKfkI57$@60k&Y z49!L75Jv@<^B)<}?o_>#7Zw7mQR0@H1WhlhP{ zyl!KiMyI5qL0T^8U=UgqBo>hNY5dYDW}!C+>4D*E)qW1Abpdhi_)cj8nU~)Uy)L+$ zx*$imonNQ1=4(1_-+fE;jA(~2`^lJmr*%OkREb5#d~XEjY$L&~dkH>kU%5nW`$6yF zI@N^Moh?HH$unU<(L%e87$QLx@U`v(K)NFf`%#kbci|`u#8Cv1WQaHc(L+I{-q~Mk zJWET92ccY>--qHVKfFt1UkAYu{0kv8h#``(%FGJ+sxkb)ba{M=0P!~(%7$1Sktf!= zo`UM71kqSbq&T3c4TSRD+}|%a)RtuW>P`?m9OQ=8lerXfb*4bK0}P6Cp*jQmY9^c$ zM!+B&?SHsu0n9iY`x0DOcn)~`_2m&EgaL%MZK3=7BZAQfVXwepL-ZgDYK-|ug_E@r zNqi;r3Zj9c2E1eoNCt;fK|>6hAXplNqM~4a0MZRY!QxF#6`@oh6#_&E^s>HhN1Wm2rWY0- z+AnF_+uMUG>As&SbQ?(P12(oMXjc$_uGTRQ8CTP%Pt`gLdc=R-am%{s67T$eZ?8yX zu2MTeDjY~v%5eghWhLqJFY0vWf)Pk7WuzTbHWE()i9msUYZfF$YHDioG1MX-i?kV# zRSzmNYxWQxd@zow04wOaIxYb^R;Kgk2_S9KcDS4gG=LD+eBWe@_1rlHR^sNqzV)d6 zyB;1LU^DA`Ip-R(wTb%=Ck)>GIjAFMJRQ^f#Br$gRGl>`=-z>(v!NqsaU$Zfa7pnwTMDFU%8`eBPqEA zGYhyMQYCJ)O_mVxs=^@np1)8%;!t6~V!c@v0#HY4s}n>ZPD5!QXT|G}(%hB?RxCI( zb3sDsTclV;3H>~0{&n_-4(VerPtuKW@OhSyzyOKC&BFaBWK=h-FEp4f6tVG1 z$9i=~+}qi~fLun`DUFMXAiXjDLI2L^{UjeZ+Cu`(s3(9Ak-O*g^3u#z%e)3lKxEb5=T1 z!mcqIz8mqBm~}Insza5U8gKl$sz!*tRr$u3r2gqIP-QU!!0ql|IZ-yQz2dIa?ea(| ze!yF+JInm5Y*$+CQW{a-WHH1Y|05C)i9C*oh$u{@I_~6zvFRK^2z|?!hdvVdln(fg z0(Gm_o})gB**q5<+}6KP+&Ei*q59NB*qfyd_rB`e1D}0&d!piI2W%)0^Yuy|>2Lr; z4c2|AvR?0$&OZ#OOH2U|mT!^#QR_lI09OF~fUKyZq5|V+1v*DyX{+Y|{`_Z$syv{W zs95oHUHy%kZ*#=mJFDTY)g8_M7SN0q{VYZG*xzsy{8DqA?q@nZgAv}{Eu3{F*AGN` zFwLsKjyb)&Y+|>62)jlC40JZvvLxMgK(A!ECIt}zAXX{9{FEF%^~idLOBz~Q9Y}W% z0~fSYUujsp@)hsL!s)UhadCvGrm-;^0Oo3HYR-+?TzolHGrPzF`K4Lf9+qRXjzBp$ zjBDA2g=FLQ_1B$?8(AX!pAer?q2B*v>`lP2Y}fVimxM}114RfGp+baA8H!XgMiC-2 z36U~J<|#!eLr6lV45dsFAsLEH4Q8c8DiQHNZ}siH*4q1b{CCHT*;4lYB{Q=> zDYu+!Xr1#53lZy3!n5-R|irKTt#jh9g90daT}_q`2X6h8TLq z&0UuP9{zn`EX50^#+WMX<&ZryFviG9GF}w4zL?&btW&uh#^iDrQo-`bV&0CIr9hW*P>WTh3p3D-*p8> z5F!(v`!589Obg%RRNpKv`}x63ybkLeyPjW2$f0r%QxlU3{4&a%Cof)Px1{d5dhMFS z$Cq2RvyO!&q;g?Bq8?HPM@m*8;N~}FZY-#1Nx6}q?~pN)ynp7z5qzJw3Bg!*z#zYp0>XjR$2dc8S}Z_O zPt=zbO8&#%T-P;0G$?}V3w9tb6h-4D*da$da#!>4q^^nIe}O486w0R3C0KVG_MYQwN|I}qJcb$b#PTC*+2S1r zlHBzBhgJ!XU!oiQPYoc*skYo>cDbM$p0Nk^tpyfGn>MY)fb0F3t(OnE9(r0bD-SI- z<^G71r2DiGq!-V+jvoBhvJ{oL(jl1$ukezN5pD^t*tO#)v&HX-CQ@rHIiay=qBXn8 zzk6+?SJqbgEosg%>+k>u|=5YJ+?^xOBWfZ#9-ye=G z$B4-2rR`oulS{doE^phmb#FQXItYpaTm(D7UEN%w5X?J2ZiqfZs@V>GZ9rRFo5I-p zsw!%cI6+-J8gB6=bRbKFFiR3e$#_UjNLA0J^a@C&>krlt}8UAWL`I{LBcatDD=v6JNTTojIGNT^WxiO3MSo z=p7uqj2eFcV8n-xTu!JBS0I5jQo3*{d2~b^(>B2iw6sEc-fwqsa_PYfB-7;u`^~>c zP~cQx&({?uM7N^%{R7pr_0J{8HXcptKT?5U$jIy+nI|h~dk5-B+>dwvz(}^^t2;xi z^^C&jcV2CZe{m*BdSfw<^4adS3zLdZKX)OM=dl$3=N|nOCsg9>IHM3o87AX4m1m@$ zYf$2Yl542s$b&O)I~A&9j(rfSPiF+`<%7+dfUhvyR6@U>@A}mQLwqrR$!@Desx7X< zGZ->j0cBFW6_ULHPwcUO7mB*DANB$YWyz8yMA1$Zw#YJJVrLIS1CHAARMq8Woe%6e z(_1Eh2jbH6;BKJ?wVgx#6W{+F>J#!8p&Nv$p*66`%`({e3|mh=K$cCi?y)CZbMN9# zPoTWMg$z;@O!#LSgNjgJ2S8Ap3i}Fcy`ny;Dt4?{$8x!3-svlM-%{Rp$RIv^Mmp{f znRod^3Dl|beCtf`bw0yN9v;<7Fwq57xXz+tdnnf65gMDZbbS zQAd^yr>CIQsiQa^2DB4rfB$kO`)p+;<+00H6{O6Yp$5r1wgVwMK>6q)vgpx%c??J|p9FM(-A65dzzNk#jcf&ug2CXSH`VcOsX>1pk-p=w zQo)v5^Zas5yzBc>4`HO5#;T}J#dDI%F|hppMoH?|inL4W5&=*ga2~zYJ*MBai-wxQ z8Nx96bDYb;(PyrjuDF7G?~O$?K;84Bri7qDY*|S2umz^^=A1NsE$en)?vjdjztdIK zw7}3%2!QYAV#bFoaff5Or83iW(l4?%h}iy2)uAG*0dPi(PXW@2TfzTiI7 z%*7bOB_eKN|!J&^A8yfuVcHMBwc=GL>M7p4HiMQ{iMHBi#Dj9U9KOH`sK315!HC`T`Ved`?^U5+4KboZ$sE=XNOS~?_i7WmOZ z%m+ruxFKr0W!HVPoVk}TUtUQ}H0=L2x8@(rV|8MUY_-8c34NsB*?y0#pEZNE*SMnB z%`LPnVje23?uMuD=2?hezVl`#H~zsH`Ob%5OM+M4fZU%i9`j^*4un<$Y482`k&x`R z@5^O6=4d2UMU<=YRS1TOErgB~4B%nVseZ-8VxsK@V^R%^Y)#iN?hr8J1Bl8xWoD*x zcL02J3J!``p;Od&P$oXK^0<@-q0=w;jjtGh<1`f#YqGh)Mng?aJ%JZ|GF_mvp8}s1 zttDoWmH!$Q(9YCew%m`JIFg{wGPbvGo`QsuHJn2xnAkF)jsJJZgGjk>EioZkCivX% zU{(D|b#X9qDe_ChO!VHk3*WeI|4# zH1Jpq+K!Ko24ng6gJF$-s3%wpG`6#YJOrQ#$Z#;C!^D>y{w@xnEVNji*a%~byrMlg4bh$@)B|lv1HrsYAoSV4#&Ji zzDMWBSFy0fXiB{fWa4Qlak3zu3Y=2P3JTPa#1RoZYUFC62xTot|EUqNQ$r)rC6L|8 z_2NYnsP(YB(*drb$HoohXZY7pIf_#<^&JNjLB^b%oQN?KC0Pwrf=&sugaiU^L%<;@ z_5gWYx_Oh0Ozl7o;_z#J+-sp4N0}8^@iV%9CwSb7WiNF56WncBqTOa)XOgZP&itstKD#WOR42A2g7^T59}R=&$Vpb zdi>q}yU+6T^7fIyIAAJ89EWtRM*juYU~SzO$SM==yVRPgBT%lzbs`jM zhp79{gd>DIymkWeFekbB7?&iJ+(+}bMOQd?44_pDn2>UePa#T-sH=y~w(XJY43)X$ zCXu?q0QTsIH726PyB_+eE$%gKS-*a}p{G`f+Xo0n^+{^1GC-VN%^D1HqQ9tW``_5i zlgW6O8DFGGve37ZsZN^I8-p2aJs)m#Tk;dyImoBIYhJLVMtbo4V|c|+ldoO#UFa7V zY|hm;Z;RU=rclwgvtItX^4Y6Dz&4|TN}LkjGnnsWWcZQQdrlek$_g2Gx*{u3@1@By z)=shBzq5Z7==OC<1 zN2Pi>>Vjy>C1spr8^o;Fjkaa@;t48$#kG`J&H$Blaz0G@r#!kVdf`OmuUGd6FRxp@ zp4C;(;cPuidj@1y4TH-xl*qm9)Cd6wv96TEHOJkwpKn)qDE)wNN$j`KvNV=OGEDZI^D+KQ;$0 zC8_8j1x4g(Q(tsXj4wot$1x-0(`fb@p-Y{<90#}h-Ro^==G?wTs0-yIZp z=M}#wyes1N??|7TcmF7xGPsy?d*gnsW4Jdle5H zpiPwXS_Ktr)Gj00_Z#YO8Xl!BK?O?&0PNYbhjb?3=6!)cdpbyYd3il7DM`lZgj5ju zBv}ufrNQ9exOVR@P?8j8Sb|iWb01o$aOnhF%T+!ex|V)wCVObZ#WQ^Ab?Pv{O`Y$K zI2gvO@yF(@q@3W6l<~MA$m@v0?mCY+Gn*oRB)9NxCK4CC{W)mFpYKUc0Na+TT@qo$ zP;ai7dyj1|xQv|UDR}!%Y@r!L_m&~qhx{z4t`Ta}E%p~>Qb-E`A(cPUM$pPecrETN0$mFUPVpq00asxK#bwgBodAXUVD07FCyu%B5LP#B_Ccr+EG@VGWbg=7xR5_vkt>udZGo^}dRUP8h`ecy@B?CJBhOxM@YT4-6|hn{H_-*|;H11Y0Y zy3$-+RInt$*tQWF=QWDv-RWRfT&E|vh<>Nc^PCCJL=uPow7;rdtAAkND`G2%e*z_d zEf`~nae{DnF?6&p|KatWCzt!5l32iqsZA(z5EQq5qv&U~L3a0V&&1)OSJ_+@_=x9h zVUNaNsqKd#q?_dzRIB0KDX8&Bf}dK_vKZdH{r)}&WGq$BofAcM6`7cr^VM{`=fP^g zAb=_?Grow!kqVWElAc~C%j})ow>R_g(Q|@WHZdwO2nWK`2WbbO%%xL0{Ly%vYfy%S zoU_|4A~G@2rwHlASx1o2T&S|Uq!kntU}S$h=c3gweeRNBge6}l8bMG?w?HGo9HE&r zl_=vz`nrb>1GKBVJhaiNNDqMXRY2{)#Kxw7@AozgDcQ0kTS`qHUYO2#cPUS=St(w_ z`g?24+iqSOz1|)fb>%6{?e^Uh99z$EPNx2wiNol+Y<1Lnumz~?fLuD3Y*IM&aZc9S z#xi7Vtk|?IO#^Ao)t3*gl^Da2w6mCqJ>}AK%Eod?Nz8b#w!$F#GFJhbkCj>)Stxd&|_+6wGy&QU|^yrEXrc3^Qp3k<^W}0g5-! z4RCSne*V0=IwKMH!T4^1I-cT< z+){VcnR<`TZI4e#An#QCfj}^yapzI+4GbDYEfU-qi3Btn+X3^m=by~58rfs=cUvZc zFno==&Qo!4t3_Q4YDUrPYqxv+G#xknG0+}(ctP(8^YY8nISuDG=s9m@P@?_ERl8h3 zD=r9C5M`g>x{(`iG?Fzj`SELv350`D;@g|I?{Vi(&n~Th7RiA5gnZgvM%Py=(UuI4 zT@@IrQ#Ys7acmTbR)K;D@-(Qy4(RD^fQbpoq$F9KWMU(v;li0$C^FClFk3G$GcyMU z1#Qo3M!EbI!OsjEgedrL`(*=4iL{QMp57Z=aptk?V4^8Nk3h7C_}MPbi`TAQ6L#oV zqIee-i6IUsGB!4FV&W{=Mg{(8 zVDb)ft>dk)78N-sj!Aw65CeOCRZHHvZFFxy1jBL!e|FNbDcR`VFP}h%pfnS8&dp9r zEgS)3uixqi@H|nLa1a*|7qrT!DYBd7*sjp9^GJpA!FT^diezLYR~@wd>rdvNLtGO8;-b|ZVpV-)1TSu!qYq*s1@Wdk%?0+BJGk5Fb za*;t_?JuJXBD%!Q2ars(gtBeVjll6wp3PQ2-981N?4VGNe7a2=g+hV9i`+%R zGNV7qf6n)%t02H5N?(wmGVR4%t+Ep?;>Hh+1|{{k|IitaAx9 zH93h6-`ukjFbu$&4SQ@ikh$(a_4Becfk~U<35}I-;D)-F@ehy%O49^w2zk;Dgl>59 zWdWcqvTUCMbCuwcMXyZ-{Aqy2M3Tx{v;niG%9fZBa8{j3e?J? zcMD?W{rrz2$7J(D-+`psitle$x9+53qSEWFp!8+**&g1)*hj4C+GQG70#&D0^DQC? zTxrDXMDYd|e}6wMd3Lali8QhhvutlImu)Y$(}%Lr z*Te4AsY~dEh?15#sMy4T3lx~Y0CYx&T41ERc_iyMru}iYwzdlGnU}b+UywN!9dEoR zVjuj}`JSQxAfZj@BlF%b;38fE8rzScteSbB1#qgyRhK_2J3uCbaZRuq%Jo9N>5B7Q z-%|ZNN4L-Nl}P|1mR|WCf7H9jAE>H?Tee@TwvrPRVKiMr5aUuQtnqINum_| z1BRhSKxRq*XH%3G8+MyBfDt1WFu?T`=m|c&E0ePLu7Tzi>N22-(e~lUO9Co%bR@2h zBo$ENgE-m~0*Y@bRP#C}u4stM>T{C(8oXY>nt!V%H5m;6opPYcZwY~WdYfy1{wRM| z_lV~&#T*#UW_VtL_H~1&r~-H6@2;HUBX-k2f6`Itul!DvOs_-PL948#d^z2H(^uAO z9MwBL;?RFaXA2@zFB z7{T)gzhjI%FF^+ZyW;YqiYiq#wWi&PQBgF>>UFX{#Enj=jO0-!Dg?FsR73_K`&q_f z)^Vtz4}^xw;loPc--y2qm|AZD27g0)Mu-YZ*U}9^N}9cz2^ZF%U!HX`wK}+t{ zc4VnTKE8xPno8si7&wW|p_+~N?B2KSTK`Vs)Q{ zUfkVl-sk_6=7!jf!3gv99jkDz-}#qw{e-hmlz&yuUOXMOJ`4VhyX%~sX^Pn^rMjeC z)~KKon`;~8b$1)qFMiwlG!l@ag{I1~Em!F01x)TzN-}#*qt2xT<0g{Fiu6c`FRrT9 z;a?Ad3&ZB0nQOr%?muzjMDmoqx%qYb@CZ)1S|C;}IVYu1kX~=dWRuKNBOr|4{nN`V zPEP||AUJp%;;6=0*x6UESh3>!=dNpER9#NOM9_gwEWQ-1#wimMY9JO+GLiXy6F+|J zM}yz{=@anH$J7*7QA=-}DkeA%E~w2nBXmeaq|;-hXjI8C|EO;JbfHW|amuiUcH!t{ zW=9v71Au){->xKL59~VTJ)kW@fFDuz<&1bt7yi4@aqjV2CpsIBT={bcMA_D2Qna=+ zBds?36^pc+f2#G0z&3id$Wv~Q$AqF|9e>@kFXOnjkPwOp>(ED{{2j~J-4H?^P`r#A zGkm#3lhxbTcZtAvUH6v)N`fR9twqdlNByh2!X?*=cv^{8qjw;Qj;MUs|B9RhcT{NC z!;H4W@7lGsWElavQa}+j(fh=<0zcFTwutQJw5vH7ppzg&%RmEOkZmB+chXalz$38M zpw=f~?%%v+3yrzCxoyR=wcC0TV4#fI3c|p);2fw#1{YLPq;JJ*=G%6OK*U?xg=Gwg3`?o2>>Sm5L(Kl`c<6h)@pn#&~<>j6F0rg8OG{oeKkvcSB6*~zvBJoG0 z2KoNIE%WA=PoJLe9PeMuch}yA_1mOc+x^(ap?_$ZS}93gM{r_$c2-PxzDk#TL9oq# zx?HmN%B!a2WvQ*Jspwm{zu!Mzndj>LhY{Qo$0bN-@i}$9hqyB_`jUKg0fu#VH0SdTMe8cXw$Hh}{JqV7cGlfA#2#M46jo zD9`n6-<58Zo10j2UGVtTtlWK@y-P|iToO3Eu5k2edVX{B?^pjUP1;Rzo|{1I3_S&P zg~qwMJm*9ae|k#`YP7jupK>>#eZc`70Nq)>-H{ujPf((&I%`H_13Rr;of;hs#p6DZO`v(k?EYB(>S96o?~^UDEsg z3*xAC=dir1tHe&j(%4T-<1bO5ML@cB%kAeEXv6vQ?3?oDyCPL9>*|AmLi-ndUkFkW~r&E5ox_Y!$u!a_IVy*|JCs_Lwzde<~Hlz z{1zZZ=YjVwgBeAmTADcTXerWe zQ}^_GMjFR%ec|`8V&|0<*l6=5=Sh7GujJPouf<4=Ev{(r5iPG3w~hZ}i7o+nQXH%>3DAVSZjnTuAqMep`0o^#^lnQ0O4aqAlkn+S51c zpYqQ|-}$oNFnIET*30pTrLCWsRuU`|4^(hbHlBaa>AnEXrn}#x+v_*m?|)y_&1=JS zjj5VsdeJ^!d0>J6H`lSC5D#|x)vNXl@j;y}zA4kgwlAnQ!>|q2ORk>$Jj$0@mH8C$5@p zyps|Y*S_l&M^V+%BQL~$zB#Xo+hk~Ws_l9y?p+pMwNo?rJvoU0auQ3#fMyC z2v;;cvLx5nMQo-twkJQ%y1ce2(ck+(NvVTgH0$j>P02MFp3?fQ z@6Ci$t=gpzC6(c{B39i8gjH%ZOGg$s6i)b8Dl|S2!VfX&96p|E5ma~9HRgcMXqVx@ zrsJ!q#pyB)IWK-<{G2w$`a_kirlp}|J@=ynx2(qQ=v-U;E&SecDb~BHGCej1+);PS ziu3bY`J4oYHVh}k1q3Wz$l>EctZ0{)NNwS!Y7GVe4*0Vz^t>$s-L5}y*Zxjl!TUR> z*luQKXhWMNSi8t|#+SvtkEC z@0ArXv#r@v`M!&DS;X2a$Kx})dGYCTW+Y07elu_B&%DVL9k}?1A%K0Qc~e`=13|&< zProa^a^M5z{cOH^0=grM-#>AbfA8-}EUQ#X+JuDj)KG2W5k{?dm^ zCu*6-i`syMyA8KlZksAstx6aT+;75QD{>{_@0BzU=3%|tU>ow@e~sUn>R0%Ha~um& z+X@ra_a7%Wwe{QR_yv!4LkpI=mYFP`%nl3LPfL~AUa@Vxw*`YMV`2hOSLt+F4p&tv zsHDB*XFq0?uzdM)b!~0zn*r80wY4|C>gqZ^5<4?9!?$y%KdN~ofUh^x`yr8XDYMaU zcD>i>eyfr3B=NJ`7H{p*VFUH~!S(g|Uu^Gp&;J;?8mYTQH(FQ>1;Tz52t+09Ta=;B zM}#O08%1b2CTjh^qa-3%#@cUD%Gq42XXwgBLT zH-Gbs-}(75nUyDZOYVtJk#!_U2?q{!l-A-Jnl*`BvpKr)Iv=ly>D%4VR9t zu(5hEeXK$-BO_yjQ1AN^KD;5m>L7)z^HxQS*7-AoZTPxX{aVl6u0w_Fo&d z<=ehpmnHgzZnFA5MjB!RPEU`S>56;1W|e9rWAS_R&s7}?Da$u=5s@BizJ1s|UpyyxH~-De^nCpM znJ+gG=Fz_5w?EYqT4<}+Q*hM+SIc1n!>=E{3d#FyZSTDsi;0TbEFf@bOakSFqoZTt z^<8{?>Thjrt|cW!;Ih*D+&Y#n!oXf|>lOuKskGvZPh#1hziwQjEE-$V(xMJ>n?ivo z1-e82vf_f|*Zv&GZMLMG+5b_L>$YDfHBV3I<&c#f>DvYd_I|u`sYCwp4R^!FvChL{ zMy4ZXq0{Z_Uv-X5T|g0fB{Gt#w|>nR{|&7Zo6{M4cBYMLTlAzoo_=V*qOIsb<~jG1 zxIzP|5wb*pekF-yblMZaKLj)s3R;As`ppFe1sVnh47cXCj_>DY!Z<2{r~t<(B_RAi z65S`kgA?|FETie!Ssp<_23?E>fmE9)J1CU^ESNYr!cm8CA@vp7HPmbel$2;dakq85 zA)lQ*8gDARILUxJa+g1UwuRqtH9V2B5JD#J2}+qPGi~yN~(eo!m z77l24zn)&~i5V~-1VbbZAUp+-%H`*iY<6g4U?^J#;)DpTI6bGi-nkB}40EqB5k~v3 zd*+_S_I1nA>yw!0nwqr6W;GqP>3Tc>BZGr9sI)0V3z`{ccJA5fT2}w5leMC#%9&P- z{izx)Y+&5{*EZ9;8r&Q^;-u5LFI-3<`$5!(3@$CcU zifO!Jl%w;tM-Lx<3Y6z76zSI4T3`LI5-HSxbmy4+y_&DqTQ-x zY3MwR0Sv>a3atqV%&V%ZI=e9MLE@IohEwbtVO~CkE)9PRAA2R#_`;~UK{)r7{d^}N zhG;Sl+$;F%X(=RD^~{-_hz1Ws`N_k}tL!Yx&-Pq9FZEJpTWI+1v$J3A3{(WXmIhCq z852$&2(_*YCR=-B*Fe>p!Z^2ECroxoI<7br{^n)LyJ$)Ii#wuc-Y?9v5P=Wel>{>* zk!Pfl#RKIHU^CkpB!clNjePybFAwZS)IoJmkN%Htv`(b{1>ff^OKUgvxyu*osR3B7 z10FwqY&{Dg0G-vPqp~wJobD4;gi=aL;fAbgJ)+?%0jop+6tFqhO^k48%AN`2mQwpE zTzc=dz&>6+yOVjBVJJL;De$obQ-#=Sf5kJ^t~9iuTr@8J0=NFvHc#e zwa;e-1EOzV3`kqtcjB(%aals~oE;MR6a_EkLDOMlaRdyN0bd=;XMLc%cugGx*`2)4P#NucdZw~jST1Yoh^-7$^$CW7fi;UqDdplY_`^Kt*8y!qJG-8C{TmqQ%>Jdb4Djo=@+=R0Ap(5>19HA0MHQaN2$q>TC?^sL3ZK8SqL-4r=F7bQkzVrF73kZU{sasBqf3nK~Vh0x29mY9IO1W1gKo8N%1*ZI&P z^*~Q_klFIi)_sc?(tQ7xH6NuHo_yG8IQ>z{M{TWw>Q?U1FK<+GOPIe84Gj3=2J`IQ z%L*`AqvwH5$IfhLesm?9>C@CsWW);!%gI$J*nMi~=wP7;8COz(HSD*EdpX$y`V~=; zcZ%(x`J$)~4@O4^W$ZF@BQ$i+gI&KcJ(5B~T8YvYj}WxgZN^^@Q%SKl#!ns>=slR5 zxS1aQy^gGGZ1qdbr!)&@eh7a}o_CB%uR9%0F&nnQ?%u|I(S^n?!i^ z`SWM8VUlGB7LkquoaSV<8umpT?pSEg!*n$#PdhV~800u`$Vf{QFFx68h>8emUsmxm z4EcJYAj=5mK*U(*&J|{cJ9_$HVaYSn5b_6vAK;5>@%QdaaO9T46`85mFuCaL#`KzK zJLw}m;wGk7l7-^gYbh^BLK*y)#qIJWM5w-Kppm}P^@A-yIHXNeM3V! zTZIJ%kX#^fcArz)I%LDZ5!=}NH8c#($xcZ1fq_%Jk*p6c>fuA}GbS|+uf;n$m74o! zTSX`KTIybF4k=;3nK5khxipli;*kmK>>JtD>z4ajca~!+ZCxO(nZ8Z!`KmW3y-c6i ztjmtjP}$1;-vx8Kdyl?hIG~pr!e4?J1+|4?+067SRw&~1E4F{X3OtD>uZN($NS(zC z=-)tOJb}viEO1UlhTp>0OHdRd{>5f#-KNpRq<)M;6%`fPdjSv@UbjPJ^-hC_-q5?5 z0uqF^&Rd&*KXy7~<#hUuQ zHQQe5>bNtjoG)F~-9Gru)_S|w&a|$nH?oLn^bdclznq(8K1q?J|@u_cZZa^3-jX(nPt~5HPXpRE^d?G4JH!GDjI($RP0Te z+H3X1JM-`}L84JDFE1B!G^7&QR2?Bt-hi7sg%y)DH68-B_KSHXDmpYrx9P~KZnT7? z$_Y$d;nn=f=JUF{SC8G(BMzPoQY-oCq*xdK5*UOeOJF7)X9CHGCVzZSDKA+(^_iG9 zGCW%qS3+1+GczjJ`-WMY=`+O=oud;H>?#{WIRjLT+#_z#8q$qfPhJpyV7j~AJuD$c zxc{4d><#6A*##L)gey3AL>TN8cUk^juC|mJasr>tm{i0gBU9Gc9B!~PK#4o!s`PY_ zm=g+gAI!Ca>z;{|GoleeN$HDM1G|85xTTO2^oIzoprXLjDbF2AYGX~iW5wkeHC!)Q z-lzUAJ;xl|oneHr*VWZgC_p5A!z|SYM?PsA+S=G$dDwoCKblo)=`T)GgB5>5C9uyS zwk2w&DL`{K^& zIcJTrB57W_2N!AX8j8z!GOTf%wQdT&JN(~26KH4G?qzG+_Oqw+?wGh3HdYj^r~5;Qxd)0fAxH@gN?kQl z%Qg3##PNs!y_BrEgMHP!5r;l(w&~f^_Ph7DbwYtWrgkCS#yUcnw44O0hNijj_1Ev; zmCl?wy*)X9@NmFwTlqjv3u0q)SU8NH~o^+FPTeohlYig>67@hP= zgCiqLD7e`$#bjg<1{NViZKL>^KGIweOAFMZ?Jh%`XwkGNpFVxtdQs?$>;fFyz1NP) zZr{!8VcLX)1l)!1iAFMAj62e}?h04tSaVaWI@T1<&W#Y~WQstG_zYcP9^#A#hlf)T zVPsu$IUqon?|Mf6-={)E0MGpVbiSHg#Ov&i*lWoKTerFvo+)d}AB^DqBop@@Z136c zA2f(a86`aQb+sV52>o);phVLq0ozdfe=pHrOqoBhs=_Zg}-QtOS-c4B()_wkj-mBO67z1p8Dpc(DQK1%5vcT zJVU$ZfRc;|0`Ye^DcB3kb-#z1@&B_IHwJU_(uqsC@K@ZTIxVR0&meQ%)pmYFQf1So zb2;e-j}W~a_TgpPibZuj@s&WAP$;Y5fC!#;)peQ~g^g4Dxg%?f!Gf9$)dKG;h?TLb?WQ*4oig8k&YCPM;|_$mL2LeWoSvTZu4Mx zLJS`1D6Gf!D=A?vnf4D@z8NlDxDYGnB}>L-M9BV9vEhggDnY#lnbol$7vGg$fO3)- z2RVa0V_~8`1(4WM3Im7q1{~;1D3Iu2oK1RL>#|?9BI|+wIss9FM1Y!7cUpx0FLsxi zQ*`2m#}U2EnQ0EOmH*?^9(gY$76I();K5}G*E=l|S9|-SV_c(3oK8GO1SmvEBa&SG zFTr`=VwCuYP*WT@crd72R$pIV@3*}l1IYE{H;Ki-H;X#(ERhZhtw0F2&H>bR2%5@k zdPXgM;T$yuDCZJ+#x@fp5P%dC{Pu8e{OdY-M(y_#EX>T*6jUyYnd#o64ws?8vyzcR z*{uv*%{u9R>Eg(OENwtkZq`q2p&%g=UZgr8?c|tEBdRcNTu;Cmick(>a@}&w`z5)u zQ&UscV;@;R9bfL(UmwW%r#@Cw*&g#;;Jarl4(Y}>*ty6i+Wu+e!m{;?C$tkUy+TIn z8nBi{XMRpiy|xPX2c8?PuLj`>B!(mi3VgXho6B*7!bYd$(#V~FdXV0f;fzgRwAe~Y zxxudCrn4RY+HYMK)=}=B8GIhROghf#6v}Cdv%QO1)L1&i_v4Djju`(f&(UjdyT096 z**9(lpe-_vk0=}01Sx$A&|`_twzqZ&{9g0(uGRm5US${jGb8~Efsdz1mt1h4+SZr? zA?m`ncr`oodj@`?L(iR?YA(FoA`!8&wIs3gonda@7z|idNXc^eHhctQnl z=*KpOU0@KExWs`A5xRWab(P@O$vvB3v7Iv-ND`|ZAMcG#puvH}ov>y@IhyGbHSPzX z#9F%h?A){iWgpmE?fj^rG|la|HLcQ2_fw@S|HyK}6QRDpWu*q5K zv65Ak9t3kOFpzx5AyqPJ`5!b1{JB-OR-7a&K@Tw*-5Ad_>F$uMk5JdINckg5M*?wy zhF|G)wwY+B34#H966`2JJA7keVn$<06GUE2<_iIsoJZT95d27P>#?pSsanI^>aapQx+Q{XOv>P7bmB*t2cll6GV#v3Cg@6 z97X&h?Wg@!VUVkWL+0#<7A~M_h8>F97=cI}E2x&Kal+$1;-Ad$wead>{~&VYZC5}tMlV3s%4ZO_whfMZ@@)h2! zlQw1KbV74PLIUs*5K9%J!$`QO4zrd~Zuu{MPb4dm@o3ly>YAGae0_bb9h-4A*hCYz zGcNOYJ%8n(`qZ0~6`m(^e>Jr8XZ|Xw9t~E!y{laLKZ!~}5HsS3UtkUw&I7vM`Xi@~ zH^+T?<8q4Y<*=9EJ-ePlxdW$8QKk19KYYR9JMASPxglhcJn(^N)0=ng#^Ir%WSrcr zJS^YYTPJKwf%I$YimGQKh779_+h1C-LE0WtWW0Fc2gcpnEc8A~N=01t?k{9%V@aYZ z&E(($t>BFxcs;pv=~A#0K*SU?##1LvhDSzHA3odwSvPA$BHz#LPE_95BUA6*rEh9# zI$;X2X=aX<8JfVDW;L^M&gWIiynnltThs5)IzQN$RCS?Bs_xChWf9ywIOvdWU?CPG{ZhGD2n$ZbfU)lV_>;J1gV*F|HSmdW%aH#etS68m$;Uh0~ zkK^%Ez#%6LwTNlD!nRN+QO3pfrr}4n4hSoU!q?myVN?F|pIe=tjO2#Ysy+FP6Hv!8 z$1n3f_5eYnax!t_!1GHD&%*!d8v+>%&=GujIv-sAg+z5+M`}2vJBA=jR z{rj6uE9KIs_9|cZt$cAQvm@Z$UK+Oy59jp#+5>s1Pa4h4%(x(Kr)<7fznPvIZi64z z0#Z^v-fj8!1(fbtY5yA zddZSgR#rhBhHgjJ_q^eIFNJAOBTlKg8+V%AK`bm|?-nq?Q zCu=rZrGtt_rTrb190<8u7#l#>{jh$~hfafvg2J;{bBqM8B6o#U$3zO|_@2|lT{ z_^4_Rr2Qidu&pR!zxJEAy5`PB&X|qpE74%&Ui5(35XIp#R3R@-&M>Z!iNC`(L7rhVvFq(&O z7$LZXCf>InN=!&4CKH%>B_M%1Q3|E>0A6Xox%oDb@oiJTC!gjV`@QIN~Q3>NwD@<1aC5`K^ zZk>h}wCFW;D8yt#g?;pV=t)Z0?>@*{f=`>L)MdM=s*pQ@nvNlk}a;vD~sLR?O55 zMDu9fCW$Yye8mc?C^>um{o5#*Sa}&D4F6T^nTJNrCcl42G8u1Mc}a6!T_u#3E#)3= zP$V9<+ZhP38TAWf8m#cE6DI0}8zCTBjNFqiz6IWo??YBC6t1;UCln29#Kju9e_bm3 z>jO{wCukLWaVN+$`?zOkY;0CgVnwa4c3XxZONARP;3u(H;@2~b|Du7BLBJRHf>&tY zyV09BiwOY@+5kwQuf#Lw-EfrA3mMSpB2Cejo;}-AmU>$h-|*M z$E}p32eSA$S=43C0r2HIypnUDJ{z`y*xI2T1sPqW`R(rE#`KYBN}|D9bV+0;Fw`!Y ztNbixe*H2*>`Sb;JqL+9K|zv?>xp{!QgrlG^byi$|Fb4fa|efnoU*sibvCjRpZAYX zB?TUZ;a4XO1qX+vtpFb%g>pMB4XMT1ZI2opkG^sVj~Y>y6jwNI_i?ROk11~h6B9tO zwqkEy9vwnm!<1+YTh%niS{s|0`4M&tSR`%OzwlZ$hSm|Us`<;SC={iW5idV61qNmF zj>;(e%0g~W@>a4Heq&*?R4cos7BcLS^i{6z*R>&1!_ zpcqE$fPt6Na?NMNwHq6d+{Z@5w!QUy!GL*qz&emAZwRtj$-VSz+oX`N#yzO5xIpY7&n@aoEzj5eq=wo zWz8vsbP^#mhG2$bIJ2k#Wn za%F9~Qx0%sqRS$wub^305q|dGFJHn73q|osD6S%{rbl@8xKRJ8(b`WPc(&1U{B^m4 zS+A-fc*h35uVIV7_&kT=OBqv2Q=kluu~a60_ccSir(c`%m<@FT903^0iU>l8SnG$L4|#%=4ifHP38UzIbsj zrLrc?+RZLY;Qz0vBO_lbF|gPqzptEg2XuKIBNeq%`aMOm2TpMnSKhkgE0j9Gpm(q^ zmtOrOE+h$11`bm7`ZY85P^8QHV8tUUJo{MjeI8bV)q#Yp`ja4PcHIswu%nX`36~Cw z-jW!NQxFy569lW|)*tf4}|mzx$6Wlq@KsTqk;|@RuYEB5aae zBjAO7m^^|>2W2LZ`?Dd00G*?ViY1ybG7Tl{;7tymKDvP+6h$Lx}>+1HMIKfJ}g-(?*WJboyD(d}d zCz`;o8wXB6-Uo0fLv~lKVYDy%$^!&9``lf0$aEG^Rx5Z)p3{qu>**7?u8~k%LS}jU z&K*`M_G`TK)Rci$G3mUa9y<`bj*D&^)u{^D(g%z?2FRFv?XR)!LCS^w?a;;3{%{Cl zVzC(#MNfSECPJ-9ZN0XQ%-UdPF0I+U$MhswYCu=2ySg^63A%m4R4!y(fd_8-eV|su z5@<;k99e%=WeW-61<6PwukN9B_y!slDiUCQpy_g`!wz(`2gA(&p-$qYwH`-`h&9Xz zNJRL3{dz)LnSCWfP8D%M09Igi>nHC8X!{U~9LTWzuxQx6v*;uL7g8y5YT=so!D4n9 z$v98Cc^5A#w}?OM+ZspoO@v7R3+xjRKu4;rTeogGenRcH8dSJEHWgqND~O#7%B$&_ zk))MZV^D{Y99AN6gKv;*T^JvF_SbhS-@>DRw#CzeVi%Q&`J5mU6p{*Z?AWmqXJCO7 zC?a{_@P?KMhsZNn;;tZF;QV_J7AOQjX=`QeMuZB{4|JUY+K3I4nlk<6@i@LkTDLe9 zLIgu2yampJaOH!4-)^t^qE;_y&}gCb zs>R_&jH;!-r+O^A3o|m+AOXbL_uTdYaPmwqxwix}2opte7mCnp;mRt1-{=J1sQSLy zbM1w4oHr#y04JH&uU|?bO$mxc>fZV-94HSYFe{#fXhjR?n#|A5WM0Je%{-TL{>vMA z94Gv5S1oozOp(IvA0%1N{Q$AREiTRiCJIePLG3&YWz-aCX>Okn1+CVH(&K=<{3Yui zY@;Nzis10$E=`LvjORCdnAMGK>UH7-p{b4e#nt>^tC{aYWw>CH^sYwyIk%_P>Dgv zpXcM)!Jf~St_`@46bg2Y6F(o9lu%Kyg9Aa^9Ifgcjx1eal1~ph3EjwQBJ1tzJ9S0u z7_^&MyFWY?@j&5ZMZ`ymYKQv~RaI(Ad+K(jJ9i$*uF&4|zmUuuxCgwZJ{k=>?!fPB zK&Z!Wb(4MN(d|2T(t2x3F($q<)YiVOtL%Ff4=ReP@0pl(>s&T)eJiH5;u4clD6Mxj zQu%|Z=A^h&GsAuF-p~A0u+MDZKh?v4u(?F$Et2MIY@SH6wjuA5nu0^R=!@!7uxEUXG~yYA?QGZ;apncEkoSa{mLJqhl{<_1vlb7Ek^sKJdQt+2Pa6rzM5hOPRe@7-^n#9@Gv%qmLE7 z8m%>%vZA^JMcnQ;kDOPaV5*sdn<`L2fs_=Es=O; zjJmx2;-)`OhthD$fe1@Pu}6GGs}+5G7&+W1!?b&*-nosRf*O|944*p;PRLtmI%!{A z`?G@Y-nrY#!%9?paXxfALbYVR0#Qa2fH)9){5F{^??vEhzP2MF<_29uLq-p#+umNJS&h0Q?EOQaP(uL1di35jt z=fJ^|2(nIS{|w#6#7xtnF;>#fgfy`dFG=i9;0Qw#RxxQr_f1|u?Lrh9y}ib;0_hjo z0b(Sgs{a1|7DTM`m@AXtEk_suw0OjT) zBC4JNzkYaNAUz3OLrIo7MxN4w)MNm@L@9~mD6N=Z38(Rkn0!QUear{QpW8{JWuv*eA*zOI(O|5xwfkywGV1<^5&2O-zoMOZj9?J zwqqmRX3-cSbT3}K7*_%{Qa_-Rmap_s{<7h^Xyfu%;XYx!!$g0`E^g4u=%R;S--H1k zMe-7uP0)>wf40dhehQnf^1qY{B>MD%=x8dG3Oaud9$&HohF~l*Ri4w(J3)h zDR>{a9xBlg{s?yf^G8IJ5Dv-zIp1~U44i%NMb8ljD``6#8dS$Z)}%pKTMgwIpxmA3 z)%@>6Gu=x}&Om^O=N2_#`=?2cvCZ5fyx2c{(TFosqV|4_L{~-GEGug`zJZL4-%L*g zZrIY>Jg3Ir=bnFzhv>UI@F!{tm?o=}T+h3^S<%*0C|kB|8)YBCn-aSKY>0$#qW2m7 zfm8JRnV7)3Hr!>A4bZH;d1FA~b3R}(G=SJ zE}k(NU1w`ciIO#uFM0d!U3vE;*+2M0;4h*?%}gtpo8LKc9RGoxTxn>tsG?-)s!_L5 zNpIXsN}|)tfjh3Es2n_~Aw&DC5V9?l)e2kIBm_P>*Q5(DoW#6aUq3FCw zZ%ZN5+uLuj;7e$bBd`U1DEj2ZihTj994>llq}47#1IKe(Md`F5;0;=_({+p#k|az< zZxwmqTvyQ0VA^}`14fFF528tEJqilQYLu=zJtUU|QjH=J?3{ZdZ^f5Id?3v~DPVPS zy=I13puIK*VwD-Wf}Ei^sM<$=e0j^J9KPui#-3nzpwrdWC26;alkrB#A0Anx+KpSw zFyjJU^n|{L3H*arS$J@;0%QuP%xFO>QY!IE!Vo9x!~r-ar7Db_2+>;dM`Lgnj0cg) zKtxGsSD2qNf4gE=T1j>#G{wKCc;Y+(`Q(?`gbq1SI`%pN&m_ZYY!x_=)KwrqQK z$W4kSOg$hA{({3;+p-IDPDOU8FP|V@g{=FcF%%(H*jJDfd}_iVDA1al?8`(iYZg{Yvfq+-Q46X&bBe;xhxFn*g+xoIh6K13A%XzK7NlMOM!8)uUi)s zc-Md6uW*W7sYUUa?+GYw;X;^rLdRLz)U=A2&k^H`a9dv#1-K8W+Y4!6Fo+c5D^_F z-?RV#D@1S6HVMBO&ds?C8a!k~UhH(U*F36G4rBCk zWaJwu3IQfo6D!nSaB)?fUUQcQ2Tvmx1CXhn&K{6DSfEvaR45ekqR>j5vaoT8wH))riXlTk zD8^{B@kz=v=OEd4=&SnwnmhZjp6@k|e-lX^M`dgki4G$wOj0`LAvu%uT#8uhxTF?~ z5-FusdsG~f&10%bPlR8nlrfqNp*$4nL4>uPTA5VqyzX|+b-p-qr~{_j3OH z`I3nF2A!8I(X_Cz2>t1SKFDLTse_%nza}?LLvFoVjF>)avbnvVqB_V;*Pi;-qO@A< zMf+oow5^S87J9@0*T8*f>b1H4?#ZW5oyu`O08DkS zjb;|t`7QBOgR8$&j2Um+eShVO%fy5G_nR@KTM^WWTuqx9XhgEvRp7D#%3O?JWt06N zXcmFN_iJjjsgx{K*O4ZULviEavE|j@>$BFO^vo9O*0yfWM;ud~)sNfQvZkfPQPgNt zC>eB+UdP!+zH-jJ&VL#J4>R|v;QjI?|3UBR-nAvVN%*7m&}y(P;A{ z(3S<9^BiJit3yT|K#7=o=FF0yugZ;%m@k~UVNOL)HMNf~uuIkWwSIo1yMc~x7ERK1 zal9Z&#C@!w8elrI@CtODk+0Mz`N+VehU5*qC2u3#151!MC89H_lUjysA&ynG(Y;Yn zUW2|IuEb37zidoL4^A|XI!hdP*Q|*(7MPz%s;wWhZCGZ|c?HlkCk{C+GE8U0${-hqwv1MtWR~-f~>3oIHh8BTFb^&so*$zG)dYCINO-P){5ak7UFJj-2zywS2th2q(eu}k=MgP9;!c6hOU}MP zx6Q{EJ`eX;Sb`J>&Q%5GD@m;*8egv0;TWZjSS@&O>);kS^@>h93xL1=+Eg>v7y`E@ zQvqF^6HS(tM0%&4BkW6s9-EYubY})@`QD(;4lNJiGxbv#zP~3W_T}GrucNK4y*q5o z%pBz&!!a64W^^(Y;$# zQG|)ubLY-IujxIKWI27hr^yz6*z={`M0!t~#L@QCctHd~O(hQ6`|8KYk%eYUIHcRi z4o_(IF2FsK9{4D=0fX-HxTJ+n39CVz5{P zbkL&#v}XZh_sW97P;`JD;uno`i`pBG=a+3nyCyGQwqOY?2I0LQhvOoHbBV*?QTlki zZESDeVZV=dBFOfIZS-%Er1b2b@eAHm&JzGVtY0?YoR{WEas(lr-BC@?C|7>nx~am7 zt2Wo{hl*K)miG5h({pQlO-Oin8GSfCSL%i}Qo-A9x?D1qv6;G5uebLk;j^Ql3sn@A ze{_BPo8XWa8@vgWvXiF6bgArkrhbT5sZ{s^m|ptZSO=K^!F9~KzU^-W6zs|@OMFYC zx~7Y1-oSwaBe5?%e*9JV@gvE}$)?w7@;Q`QJFXu0yQuTV8|ektING#b>Wa$6SjOz^ ztof%8ZDdE4m#CKMx(AW!kc>?{Y%H07|VOZA^ZN7{snDHhg&nhHjv z>I(NmpD<>A@*4fbHp3J+&w;!4!bb+Ns4Q?~t`Bgat^jQzwjb*2LK%CBAd?^Z%vGdn zN(}kPgf%(5MCZM6&IezrTn_9dfh2pIV>s&?vTLLQ2z#rrlq<+19Q9f7EzO?HX=Nva z?A3sXjfJePj*de1QN1qQm8%K5$H3IdzliPt3Eh`mObOW>*sLf5ozL2c8a00O(9DD9!oRTB+(9q2Qu0g$sfkR6(Wx#1^0DdPfRn21}@zE+RB~qEvR5 zD1yS!LirIYY)*UcFFSfe6%KcTQYH4++Ra16RgKTLJhFAZ_&bp?1CzElyYBup^P9eD zfqs0Obr6~srMX&_eP>nOLC>NM!P|nV0u5^D^avYZ$zJ8la_v?m*e;Gb)&qJg3Q$o%!r>7x zi@K;E>lhhH$c4X~-Ivs*Uh5^T(=(Yui#n1H@8W_{QE|8hq*6KHJ!birt{Z{3(g%U5DUcL|;u&$;&4{xu z9U+@k#m{ou%Za61y_UZ-mQMs|nOSuot)f^K@%=fqxM=&9?ALtbQrU~!FDDgsXCxLv5tnkVH|G4HIwnR+Lq;W% z0@V8~>Hr|IFxLitbThn`?%+M<@Yk_Iadb(3#ni?!!J65mKF!A`!u>t@M=h;bZ~|Go z0Q42mV{c_*qD~JoWyzYgC7t7xx-TqBWDW7OmydV?%;61c&}f9QOGc!buS$En-lgf! zwQ_%5(3^uY!SMXfy%jv!YP0n~VeG(5dr(k*t28=VaWb-YnU$9s+V=Q8*|?&_sHtmY z7X19M=*_cd&l&_z7!|)UB2Ue?+%wKtQxZ5hn|lpyknE0aX!xMGyX~rhRa-`DTU#uM zjUYPWj{%=GE2`XMC@nGmR>#8Juh7JQgqOW4m~BdBXk~`fJzBri*;%Q(a)diDL(XNNX3Kpnv|y2RGm;<7WjGVjVG3$AMBxEGe_j(n+!kXHTFFbR4n`#iotVb} z`<7Wff}W3_S59EkmC6~=xDt#Vob2EMJNX25k1-iUs!J}HwCkV&KRzm5(YQ1O&cp<_ zZ|cNdgA{~^UiOp_-<8#O?_B4O$`55tU12M_6A7^`R#9{N56Q#%# zjvErG%H5}n@h--`fDS)2a4p1Zf$(GvHj$-&|NfA^MXgH$hM@S0A%N;mSV)NIbt&US zF-q{|-He1}JeS_m!-}avt=P&+2m>*c)f0gZX9^1o0a~*wN5~h(+a9b44$Gvqo^2l7 zPL6)I*);>~U6^HvJtH9AD@cUxiQW&(j=oTpLVXg>pV*p1afzoU2G9GbwPC)52Sv;U z_FvW0t(sx){Q_Jua8lEWozdN`pHwi8sk#+geh{z0qh=;Y+Tqd3eoccWHBG`>)sneR z`fmdq9<%+Wtu0O}C{Z!AQrX5KQ_bz5@b1t1rvxIJ8RkRW>^(MNSuzXl zctGRrBOI>yq@q(Zpc7=RfBoYP!?neu|GH{bc5-iXS`wnSfw@g^By)XA+#!3on0wsE zq^};6f?>?MtEvI=hmv}{0{5SwDIA2kgF$E2QMUys-afY9)U@tPng%bkN1g3XyklWg zW>#5KmO_WgbJ;*;c!8ueM$wto(|&tl*_6t*-RsUS3tANHu*iJL@!1{TQ4#lVetzre zj4+q#DO#~>?h2Vf7&fYs6pWjzwm3cOOIx~w=IRfr1q^&Po`WlxI|aV9G_p5{6F{Dr z10KNsBdjm!{LESrWiokJaFXWYxZ2xd?#h5$mQ!#M zE^Av9#}ExutmiDF-r0erkRSo*4AXm<1v7+Oy=c_WiS+Bm9Wx62^Ke*TWLDY?I%K}mVJAO_PWCVrmGB1n zy`RurjWIJDEPx{+tkSbriTOA55$Lcz5Y+Nxiegcf@w;W^EN1zq^8WH4uma4Liv(3U zg0LP0VWcbZc{Gpdzw8J@I^q`;lg*IgjcH=#y=QMdz#nk(ng=r? zyIaeW4aO`k++2{hb}Z_1PbWxi0*L89Oj$wy^{Gd+|OYjAf39?(MPS zV#m=Az7NPx`6-by|Mr-vSp-KA^#-6V>bGvNM!tqzB zWKl{MYN=`vaLO;xS9|)qI`rhXfSCI_JOtk d3tZd%u78-T float: + return self.logical_errors / self.shots if self.shots else float("nan") + + @property + def ler_se(self) -> float: + # Wilson-style standard error for a binomial proportion. + if self.shots == 0: + return float("nan") + p = self.ler + return max((p * (1 - p) / self.shots) ** 0.5, 0.5 / self.shots) + + +def build_artifacts( + *, + d: int, + p: float, + p_loss: float, + rounds: int, + replenish: bool, + out_dir: str, + program: str = "Memory", +) -> tuple[str, str]: + """Transpile the template with Mako params + static-compile; return (stim, bin) paths.""" + from deq.cli.jit import transpile + from deq.compiler.jit_compiler import static_jit_compiler + import deq.proto.deq_jit_pb2 as jit_pb + + jit_path = os.path.join(out_dir, f"{program}.deq.jit") + stim_path = os.path.join(out_dir, f"{program}.stim") + bin_path = os.path.join(out_dir, f"{program}.deq.bin") + transpile( + TEMPLATE_PATH, + out=jit_path, + program=program, + jobs=1, + mako=[ + f"d={d}", f"p={p}", f"p_loss={p_loss}", f"rounds={rounds}", + f"replenish={'1' if replenish else '0'}", + ], + skip_mako_warning=True, + ) + with open(jit_path, "rb") as f: + lib = jit_pb.JitLibrary.FromString(f.read()) + with open(bin_path, "wb") as f: + f.write(static_jit_compiler(lib).SerializeToString()) + return stim_path, bin_path + + +def run_server_batch( + *, + bin_path: str, + stim_path: str, + shots: int, + seed: int, + decoder: str, +) -> tuple[int, int]: + """Run one deq.runtime server subprocess; return (shots, logical_errors). + + Delegates to :func:`deq.cli.simulate._run_batch` — the same helper + that powers ``deq simulate ler``. We ask for ``simulator="qdk"`` so + the wrapper plumbs the ``@qdk_sampler`` builtin adapter. + """ + from deq.cli.simulate import _run_batch + + result = _run_batch( + bin_path=bin_path, + stim_path=stim_path, + jit_path="", + batch_size=shots, + max_errors=shots, # never stop early on errors — collect the full batch + decoder=decoder, + decoder_config=None, + coordinator="monolithic", + coordinator_config=json.dumps({"loss_random_imputation_seed": seed}), + seed=seed, + debug_dir=None, + simulator="qdk", + ) + return int(result.get("shots", 0)), int(result.get("logical_errors", 0)) + + +@dataclasses.dataclass +class _PointState: + """Mutable per-point scheduling state; wraps a Point + build artifacts.""" + + point: Point + bin_path: str + stim_path: str + target_errors: int + max_shots: int + shots_per_batch: int + decoder: str + base_seed: int + next_batch_idx: int = 0 + + def is_done(self) -> bool: + return ( + self.point.logical_errors >= self.target_errors + or self.point.shots >= self.max_shots + ) + + def label(self) -> str: + p = self.point + return f"{p.variant} d={p.d} p_loss={p.p_loss:g}" + + +def _run_one_batch(state: _PointState, seed: int) -> tuple[int, int, float]: + """Pure worker: run one batch; caller accumulates into state.""" + t0 = time.time() + got_shots, got_errors = run_server_batch( + bin_path=state.bin_path, + stim_path=state.stim_path, + shots=min(state.shots_per_batch, state.max_shots - state.point.shots), + seed=seed, + decoder=state.decoder, + ) + return got_shots, got_errors, time.time() - t0 + + +def sweep( + *, + distances: Sequence[int], + loss_rates: Sequence[float], + max_shots: int, + target_errors: int, + shots_per_batch: int, + p: float | None, + rounds_factor: int, + decoder: str, + seed_base: int, + workdir: str, + workers: int = 1, + out_json: str | None = None, + out_png: str | None = None, +) -> list[Point]: + """Parallel round-robin sweep. Every point gets one batch before any + point starts a second one, so the plot fills in all curves at once. + After each batch we rewrite JSON + PNG so anyone tailing the files + sees gradual refinement. + """ + # Pre-build all points sequentially (transpile is cheap). + seed_step = max(1, max_shots // shots_per_batch) + 1 + states: list[_PointState] = [] + seed = seed_base + for d in distances: + rounds = rounds_factor * d + for variant in ("baseline", "replenish"): + for p_loss in loss_rates: + p_eff = p if p is not None else p_loss / 10.0 + point_dir = os.path.join(workdir, f"{variant}_d{d}_pl{p_loss:g}") + os.makedirs(point_dir, exist_ok=True) + stim_path, bin_path = build_artifacts( + d=d, p=p_eff, p_loss=p_loss, rounds=rounds, + replenish=(variant == "replenish"), out_dir=point_dir, + ) + states.append(_PointState( + point=Point( + variant=variant, d=d, p=p_eff, p_loss=p_loss, + rounds=rounds, shots=0, logical_errors=0, wall_time=0.0, + ), + bin_path=bin_path, stim_path=stim_path, + target_errors=target_errors, max_shots=max_shots, + shots_per_batch=shots_per_batch, decoder=decoder, + base_seed=seed, + )) + seed += seed_step + print(f"built {len(states)} points; running with {workers} parallel worker(s)") + + def _refresh() -> None: + snap = [s.point for s in states] + if out_json is not None: + _write_json(snap, out_json) + if out_png is not None: + plot_sweep(snap, out_png) + + rr_cursor = 0 + + def _pick(busy: set[int]) -> int | None: + """Round-robin: next undone, not-already-in-flight state.""" + nonlocal rr_cursor + n = len(states) + for offset in range(n): + idx = (rr_cursor + offset) % n + if idx not in busy and not states[idx].is_done(): + rr_cursor = (idx + 1) % n + return idx + return None + + with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool: + in_flight: dict[concurrent.futures.Future, int] = {} + + def _submit(idx: int) -> None: + st = states[idx] + fut = pool.submit(_run_one_batch, st, st.base_seed + st.next_batch_idx) + st.next_batch_idx += 1 + in_flight[fut] = idx + + def _refill() -> None: + while len(in_flight) < workers: + idx = _pick(set(in_flight.values())) + if idx is None: + return + _submit(idx) + + _refill() + while in_flight: + done, _pending = concurrent.futures.wait( + in_flight, return_when=concurrent.futures.FIRST_COMPLETED, + ) + for fut in done: + idx = in_flight.pop(fut) + st = states[idx] + try: + got_shots, got_errors, elapsed = fut.result() + except Exception as exc: # pragma: no cover — surface and skip + print(f" [{st.label()}] FAILED: {exc}") + st.point.shots = st.max_shots + st.point.logical_errors = st.target_errors + else: + st.point.shots += got_shots + st.point.logical_errors += got_errors + st.point.wall_time += elapsed + ler = st.point.logical_errors / max(1, st.point.shots) + print( + f" [{st.label()}] batch: {got_errors}/{got_shots} " + f"\u2192 cum {st.point.logical_errors}/{st.point.shots} " + f"(LER \u2248 {ler:.3e}) {elapsed:.1f}s" + ) + _refresh() + _refill() + + return [s.point for s in states] + + +def _write_json(points: list[Point], out_json: str) -> None: + """Atomically rewrite the sweep JSON so partial runs stay valid.""" + tmp = out_json + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump([dataclasses.asdict(pt) for pt in points], f, indent=2) + os.replace(tmp, out_json) + + +def plot_sweep(points: list[Point], out_png: str) -> None: + """LER vs p_loss curves. Zero-error points render as 95 % Wilson upper limits.""" + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + except ImportError: + print("matplotlib not installed; skipping plot", file=sys.stderr) + return + + def wilson_upper_95(k: int, n: int) -> float: + if n == 0: + return 1.0 + z = 1.96 + denom = 1.0 + z * z / n + center = (k + z * z / 2.0) / n + rad = z / n * ((k * (n - k) / n + z * z / 4.0) ** 0.5) + return min(1.0, (center + rad) / denom) + + by_curve: dict[tuple[str, int], list[Point]] = {} + for pt in points: + by_curve.setdefault((pt.variant, pt.d), []).append(pt) + + fig, ax = plt.subplots(figsize=(7, 5)) + colors = {3: "#1f77b4", 5: "#d62728", 7: "#2ca02c", 9: "#9467bd"} + styles = {"baseline": ":", "replenish": "-"} + markers = {"baseline": "x", "replenish": "o"} + y_floor = 1e-5 + + for (variant, d), pts in sorted(by_curve.items()): + pts = sorted(pts, key=lambda x: x.p_loss) + measured = [p for p in pts if p.logical_errors > 0] + upper = [p for p in pts if p.logical_errors == 0] + color = colors.get(d, "k") + label = f"d={d}, {variant}" + if measured: + ax.errorbar( + [p.p_loss for p in measured], [p.ler for p in measured], + yerr=[p.ler_se for p in measured], + color=color, linestyle=styles.get(variant, "-"), + marker=markers.get(variant, "."), label=label, capsize=3, + ) + if upper: + ubs = [wilson_upper_95(0, p.shots) for p in upper] + # Cap the visible down-arrow at one decade / the y-axis floor. + downs = [max(ub - max(ub * 0.1, y_floor), 0.0) for ub in ubs] + ax.errorbar( + [p.p_loss for p in upper], ubs, + yerr=[downs, [0] * len(upper)], + color=color, linestyle="", marker="v", uplims=True, + label=None if measured else label, + ) + + ax.axhline(0.5, color="grey", linestyle="--", linewidth=0.8, alpha=0.7) + ax.text(ax.get_xlim()[1] * 0.95, 0.5, " random guess (50%)", + color="grey", fontsize=8, va="bottom", ha="right") + ax.set_xscale("log") + ax.set_yscale("log") + ax.set_ylim(y_floor, 1.0) + ax.set_xlabel("per-cycle loss probability p_loss") + ax.set_ylabel("logical error rate") + ax.set_title( + "Repetition-code memory experiment over 3d rounds\n" + "baseline (no replenish) vs loss-aware (teleportation replenish)" + ) + ax.legend(loc="lower right", fontsize=9) + ax.grid(True, which="both", alpha=0.3) + fig.tight_layout() + fig.savefig(out_png, dpi=150) + plt.close(fig) + print(f"wrote {out_png}") + + +def main(argv: Sequence[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--distances", type=int, nargs="+", default=[3, 5, 7]) + ap.add_argument("--loss-rates", type=float, nargs="+", + default=[0.01, 0.02, 0.05, 0.1, 0.2, 0.3]) + ap.add_argument("--max-shots", type=int, default=1_000_000, + help="hard upper bound on total shots per (variant, d, p_loss) point.") + ap.add_argument("--target-errors", type=int, default=20, + help="stop a point once this many logical errors have accumulated.") + ap.add_argument("--shots-per-batch", type=int, default=10_000, + help="shots per server subprocess; outer loop runs batches until stopping rule.") + ap.add_argument("--p", type=float, default=None, + help="per-instruction Pauli noise rate; defaults to p_loss/10.") + ap.add_argument("--rounds-factor", type=int, default=3) + ap.add_argument("--workers", type=int, default=4, + help="parallel server subprocesses; batches are dispatched round-robin " + "so the figure refines all curves at the same time.") + ap.add_argument("--decoder", type=str, default="black-box-relay-bp") + ap.add_argument("--seed-base", type=int, default=42) + ap.add_argument("--workdir", type=str, default=os.path.join(THIS_DIR, "workdir"), + help="directory for per-point .deq.jit / .stim / .deq.bin artifacts.") + ap.add_argument("--out-png", type=str, + default=os.path.join(THIS_DIR, "loss_ler_sweep.png")) + ap.add_argument("--out-json", type=str, + default=os.path.join(THIS_DIR, "loss_ler_sweep.json")) + args = ap.parse_args(argv) + + os.makedirs(args.workdir, exist_ok=True) + points = sweep( + distances=args.distances, loss_rates=args.loss_rates, + max_shots=args.max_shots, target_errors=args.target_errors, + shots_per_batch=args.shots_per_batch, p=args.p, + rounds_factor=args.rounds_factor, + decoder=args.decoder, seed_base=args.seed_base, + workdir=args.workdir, workers=args.workers, + out_json=args.out_json, out_png=args.out_png, + ) + _write_json(points, args.out_json) + print(f"wrote {args.out_json}") + plot_sweep(points, args.out_png) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deq/documents/tutorial/examples/loss-simulation/repetition_code.deq b/deq/documents/tutorial/examples/loss-simulation/repetition_code.deq new file mode 100644 index 00000000..20307d9b --- /dev/null +++ b/deq/documents/tutorial/examples/loss-simulation/repetition_code.deq @@ -0,0 +1,127 @@ +<% +# Mako parameters +# - d (int) : code distance (>= 3, odd) +# - p (float) : per-instruction Pauli noise rate +# - p_loss (float) : per-cycle qubit-loss probability per data + ancilla +# - rounds (int) : number of syndrome-extraction cycles +# - replenish (0/1): whether to replenish data qubits each cycle (string '0'/'1') +d = int(context.get("d", 3)) +p = float(context.get("p", 0.001)) +p_loss = float(context.get("p_loss", 0.05)) +rounds = int(context.get("rounds", 3 * d)) +replenish = bool(int(context.get("replenish", "0"))) +# Layout: +# data on even slots 0, 2, .., 2(d-1) +# ancilla on odd slots 1, 3, .., 2d-3 +# fresh on slots 2d-1, 2d, .., 3d-2 (only used when replenish=True) +data = [2 * i for i in range(d)] +anc = [2 * i + 1 for i in range(d - 1)] +fresh = [2 * d - 1 + i for i in range(d)] +%> +# Repetition-code memory experiment for the loss-tolerance demo. +# +# Generated from repetition_code.deq with d=${d}, p=${p}, +# p_loss=${p_loss}, rounds=${rounds}, replenish=${replenish}. +# +# Replenishment strategy (when replenish=True): each data qubit ``q`` is +# replenished by a single single-qubit teleportation onto a fresh +# buddy qubit ``f``: +# +# 1. R f # prepare buddy in |0> +# 2. CX q f # transfer Z-eigenstate from q to f +# 3. MX q # measure q in X basis (clears any loss on q) +# +# Instead of a second teleportation to bring the data back to slot +# ``q``, we simply **relabel** the qubit: the gadget's OUTPUT port +# is declared on the ``fresh`` slots rather than the ``data`` slots, +# and the deq compiler takes care of binding the next ``Syndrome`` +# invocation's INPUT to those new physicals. No back-transfer +# gates, no SWAP, just a slot rename in the port declaration — the +# whole replenish costs three instructions per data qubit. +# +# A textbook teleportation also applies a classically-controlled Z +# correction after the X-basis measurement; we omit it because the +# memory experiment is Z-basis (the logical state lives in the Z +# computational basis and the Z correction only changes an +# unobservable global sign). Implementing the conditional Z would +# require physical-level teleportation support in the QDK simulator, +# which currently rejects classical-control instructions like +# ``CZ rec[-1] q`` — we will add it once the feature becomes available. +# +# When ``q`` is alive at the start of the replenish, the CX transfers +# the Z-eigenvalue (with any accumulated X-error history) onto ``f`` +# and the X-basis measurement on ``q`` returns an uncorrelated random +# bit. When ``q`` is lost, the CX acts as identity, ``MX q`` revives +# ``q`` while injecting a random imputed bit into the measurement +# record (the decoder attributes it to ``LOSS_ERROR``), and ``f`` +# starts the next round in alive ``|0>`` — i.e. persistent loss is +# converted into a one-cycle random-bit-flip syndrome. + +CODE Rep [[${d}, 1, ${d}]] { + LOGICAL ${"*".join(f"X{i}" for i in range(d))} Z0 + STABILIZER ${" ".join(f"Z{i}*Z{i+1}" for i in range(d - 1))} +} + +GADGET PrepareOne { + R ${" ".join(str(q) for q in data)} + # logical X gate to prepare |1> state for testing + X ${" ".join(str(q) for q in data)} + X_ERROR(${p}) ${" ".join(str(q) for q in data)} + OUTPUT Rep ${" ".join(str(q) for q in data)} + VIRTUAL LX0 # added so that this is indeed outputing the logical |1> state +} + +GADGET MeasureZ { + INPUT Rep ${" ".join(str(q) for q in data)} + X_ERROR(${p}) ${" ".join(str(q) for q in data)} + M ${" ".join(str(q) for q in data)} + READOUT rec[-${d}] +} + +GADGET Syndrome { + INPUT Rep ${" ".join(str(q) for q in data)} + + # Loss event + per-cycle Pauli noise on data qubits. + LOSS_ERROR(${p_loss}) ${" ".join(str(q) for q in data)} + X_ERROR(${p}) ${" ".join(str(q) for q in data)} + + # Standard Z-stabilizer syndrome extraction. + R ${" ".join(str(q) for q in anc)} + CX ${" ".join(f"{data[i]} {anc[i]}" for i in range(d - 1))} + CX ${" ".join(f"{data[i+1]} {anc[i]}" for i in range(d - 1))} + + # Loss on syndrome ancillas + measurement bit-flip noise. + LOSS_ERROR(${p_loss}) ${" ".join(str(q) for q in anc)} + X_ERROR(${p}) ${" ".join(str(q) for q in anc)} + M ${" ".join(str(q) for q in anc)} + +% if replenish: + # ── Teleportation replenish: data[i] ─→ fresh[i] (slot rename) ── + # One single-qubit teleportation per data qubit per cycle. + # The X-basis measurement (``MX``) clears any accumulated loss on + # the original data qubit while ``CX q → f`` transfers its + # Z-eigenstate to the buddy. The data state now lives on + # ``fresh``, so we just declare the OUTPUT port on the + # ``fresh`` slots — the deq compiler wires those physicals into + # the next ``Syndrome``'s INPUT with no extra gates. The would-be + # conditional Z corrections are omitted: see the header comment + # for why this is safe in a Z-basis memory experiment. +% for q, f in zip(data, fresh): + R ${f} + CX ${q} ${f} + MX ${q} + # CZ rec[-1] ${f} # omitted, see header comment +% endfor +% endif + + OUTPUT Rep ${" ".join(str(q) for q in (fresh if replenish else data))} +} + +PROGRAM Memory { + PrepareOne 0 + REPEAT ${rounds} { + Syndrome 0 + } + MeasureZ 0 + ASSERT_EQ rec[-1] 1 # logical |1> state was prepared +} diff --git a/deq/proto/coordinator.proto b/deq/proto/coordinator.proto index f25e4bbc..23a165ea 100644 --- a/deq/proto/coordinator.proto +++ b/deq/proto/coordinator.proto @@ -65,6 +65,14 @@ message Outcomes { // complicates the decoding system coordination (e.g., do we recompute the // committed part or not?) repeated deq.bin.ProbabilityModifier modifiers = 3; + // optional per-measurement loss flags, one bit per measurement in `outcomes`. + // A set bit means the corresponding qubit was lost during that measurement, + // and the bit in `outcomes` is therefore unreliable (typically a random + // substitute filled in by the simulator). When absent, no loss information + // is available for this batch and the decoder should treat every measurement + // bit as reliable. Decoders are free to ignore this field; no decoder is + // required to consume it. + deq.util.BitVector loss_mask = 4; } message Readouts { diff --git a/deq/proto/simulator.proto b/deq/proto/simulator.proto index cdfa8c02..fec6b696 100644 --- a/deq/proto/simulator.proto +++ b/deq/proto/simulator.proto @@ -16,4 +16,14 @@ import "util.proto"; // helper and a ``partition`` property for that. message ShotSample { deq.util.BitVector outcomes = 1; + // Optional per-measurement loss flags, one bit per measurement in + // ``outcomes``. A set bit means the corresponding qubit was lost + // during that measurement, so the bit in ``outcomes`` is a randomized + // substitute filled in by the simulator and should be treated as + // unreliable. Absent (or zero-length) when the sampler cannot + // distinguish loss from a regular outcome — which is the case for + // every Stim-native sampler today; only loss-aware samplers such as + // ``--simulator python`` driving ``qdk.stim`` populate this field. + // Mirrors the ``loss_mask`` field on :message:`deq.coordinator.Outcomes`. + deq.util.BitVector loss_mask = 2; } diff --git a/deq/proto/static_controller.proto b/deq/proto/static_controller.proto index d6cac3fa..ef171951 100644 --- a/deq/proto/static_controller.proto +++ b/deq/proto/static_controller.proto @@ -5,7 +5,7 @@ syntax = "proto3"; package deq.controller.static_controller; -import "util.proto"; +import "coordinator.proto"; import "google/protobuf/empty.proto"; /* @@ -14,11 +14,20 @@ import "google/protobuf/empty.proto"; */ service StaticController { - // input the measurement outcomes and return the decoded logical readouts; - // the number of bits returned is the sum of the gadgets that are filled by - // the input measurement outcomes. In a special case, if all measurements - // are provided, then all logical readouts are returned. - rpc Decode(deq.util.BitVector) returns (deq.util.BitVector); + // Stream measurement outcomes (and optionally per-measurement loss flags) into + // the controller and return any decoded logical readouts that have become + // available. The bits in ``Outcomes.outcomes`` are accumulated across calls; + // when enough bits arrive to complete a gadget, that gadget is dispatched to + // the coordinator and its readouts are gathered. Callers stream the entire + // shot across one or more calls; the response carries readouts for every + // gadget that has finished by the time the final batch is received (the + // empty-readouts response of intermediate calls is also valid). The + // ``Outcomes.gid`` field is ignored by this controller — the static program + // determines gadget order — and ``modifiers`` is currently unused. + // ``loss_mask``, when present, must have the same length as ``outcomes``; + // it is forwarded verbatim to the coordinator (and on to the decoder) but + // not interpreted here. + rpc Decode(deq.coordinator.Outcomes) returns (deq.coordinator.Readouts); // reset the system (but keep the loaded library) rpc Reset(google.protobuf.Empty) returns (google.protobuf.Empty); } diff --git a/deq/tests/circuit/test_annotate_keep_noise.py b/deq/tests/circuit/test_annotate_keep_noise.py index 7a5c9a30..59507c99 100644 --- a/deq/tests/circuit/test_annotate_keep_noise.py +++ b/deq/tests/circuit/test_annotate_keep_noise.py @@ -207,3 +207,119 @@ def test_round_trips(self, keep_noise: bool) -> None: orig_stripped.SerializeToString() == anno_stripped.SerializeToString() ) + + +_PASSTHROUGH_LOSS_SRC = """ +CODE C[[1,1,1]] { + LOGICAL X0 Z0 + STABILIZER +} + +GADGET Prep { + R 0 + X_ERROR(0.05) 0 + LOSS_ERROR(0.5) 0 + OUTPUT C 0 +} + +GADGET Meas { + INPUT C 0 + LOSS_ERROR(0.3) 0 + M 0 + READOUT M0 +} +""" + + +_PASSTHROUGH_LOSS_COMPOSE_SRC = """ +CODE C[[1,1,1]] { + LOGICAL X0 Z0 + STABILIZER +} + +GADGET Idle { + INPUT C 0 + DEPOLARIZE1(0.01) 0 + LOSS_ERROR(0.4) 0 + OUTPUT C 0 +} + +COMPOSE Sequence { + INPUT C 0 + Idle 0 + OUTPUT C 0 +} +""" + + +class TestKeepNoisePassthroughLoss: + """Passthrough noise instructions (currently ``LOSS_ERROR``) must + survive ``deq annotate`` verbatim regardless of ``--keep-noise``. + + Regular noise like ``X_ERROR``/``DEPOLARIZE1`` can be commented out + under the default ``keep_noise=False`` mode because the annotator + also emits the equivalent ``ERROR(p) ...`` rows; re-transpilation + re-derives the same hypergraph from those ERROR rows. Passthrough + noise has **no** equivalent ERROR-row representation (it contributes + no detector edges), so commenting it out would silently delete the + loss-simulation behavior — the re-transpiled ``.stim`` would no + longer contain the ``LOSS_ERROR`` line. Hence the annotator must + keep passthrough noise verbatim in both modes. + """ + + @pytest.mark.parametrize("keep_noise", [False, True]) + def test_loss_error_kept_verbatim(self, keep_noise: bool) -> None: + rendered = render_annotated(parse(_PASSTHROUGH_LOSS_SRC), keep_noise=keep_noise) + # Verbatim, uncommented in both modes. + assert "\n LOSS_ERROR(0.5) 0" in rendered + assert "\n LOSS_ERROR(0.3) 0" in rendered + # No accidental commented-out copies anywhere in the output. + for line in rendered.splitlines(): + stripped = line.lstrip() + assert not stripped.startswith("# LOSS_ERROR"), ( + f"LOSS_ERROR must never be commented out (line: {line!r})" + ) + + def test_default_still_comments_regular_noise(self) -> None: + """Sanity: keeping LOSS_ERROR verbatim must not flip the + treatment of regular noise — ``X_ERROR`` is still commented + out under the default mode and an explicit ERROR row replaces + it.""" + rendered = render_annotated(parse(_PASSTHROUGH_LOSS_SRC)) + assert "# X_ERROR(0.05) 0" in rendered + assert "ERROR(0.05)" in rendered + + @pytest.mark.parametrize("keep_noise", [False, True]) + def test_loss_error_round_trips_byte_equivalent(self, keep_noise: bool) -> None: + """Annotate → re-parse → re-build JIT library is byte-equivalent + in both modes for passthrough noise. In ``keep_noise=True`` this + is the standard verbatim round-trip; in the default mode it + works because passthrough noise is kept verbatim *and* regular + noise's ERROR-row swap is also byte-equivalent (no detector-edge + contribution differs).""" + qfile = parse(_PASSTHROUGH_LOSS_SRC) + rendered = render_annotated(qfile, keep_noise=keep_noise) + orig_lib = build_jit_library(qfile) + anno_lib = build_jit_library(parse(rendered)) + orig_stripped, _ = strip_jit_library(orig_lib) + anno_stripped, _ = strip_jit_library(anno_lib) + assert ( + orig_stripped.SerializeToString() + == anno_stripped.SerializeToString() + ) + + @pytest.mark.parametrize("keep_noise", [False, True]) + def test_loss_error_kept_verbatim_inside_compose(self, keep_noise: bool) -> None: + """The COMPOSE body inlines into a synthetic GADGET block; the + same passthrough rule applies there.""" + rendered = render_annotated( + parse(_PASSTHROUGH_LOSS_COMPOSE_SRC), keep_noise=keep_noise + ) + assert "GADGET Sequence {" in rendered + assert "\n LOSS_ERROR(0.4) 0" in rendered + for line in rendered.splitlines(): + stripped = line.lstrip() + assert not stripped.startswith("# LOSS_ERROR"), ( + f"LOSS_ERROR must never be commented out inside a COMPOSE " + f"(line: {line!r})" + ) From ba170fd0ebe22c45764b27ffc2fe0a11b8621d6e Mon Sep 17 00:00:00 2001 From: "Juan M. Bello-Rivas" Date: Fri, 10 Jul 2026 14:50:43 -0700 Subject: [PATCH 030/157] Fix clippy lints introduced by Rust 1.97 stable (#109) Co-authored-by: Juan M. Bello-Rivas Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- binar/src/matrix/aligned_bitmatrix.rs | 6 ++-- binar/src/matrix/bitmatrix.rs | 7 ++--- paulimer/src/clifford/clifford_impl.rs | 2 +- paulimer/src/clifford/generic_algos.rs | 2 +- paulimer/tests/clifford_test.rs | 38 +++++++++++++------------- paulimer/tests/pauli_group_test.rs | 2 +- paulimer/tests/pauli_test.rs | 2 +- paulimer/tests/serde_test.rs | 2 +- 8 files changed, 29 insertions(+), 32 deletions(-) diff --git a/binar/src/matrix/aligned_bitmatrix.rs b/binar/src/matrix/aligned_bitmatrix.rs index a11a12a1..62b7b379 100644 --- a/binar/src/matrix/aligned_bitmatrix.rs +++ b/binar/src/matrix/aligned_bitmatrix.rs @@ -428,7 +428,7 @@ impl AlignedBitMatrix { fn rows_of(blocks: &mut [BitBlock], row_count: usize) -> Vec<*mut BitBlock> { let mut rows = Vec::<*mut BitBlock>::new(); - let rowstride = if row_count == 0 { 0 } else { blocks.len() / row_count }; + let rowstride = blocks.len().checked_div(row_count).unwrap_or(0); if rowstride == 0 { rows = vec![blocks.as_mut_ptr(); row_count]; } else { @@ -811,9 +811,9 @@ impl AlignedBitMatrix { /// /// Will panic if matrix is not invertible pub fn inverted(&self) -> AlignedBitMatrix { - assert!(self.column_count() == self.row_count()); + assert_eq!(self.column_count(), self.row_count()); let echelon_form = EchelonForm::new(self.clone()); - assert!(echelon_form.pivots.len() == self.row_count()); + assert_eq!(echelon_form.pivots.len(), self.row_count()); debug_assert_eq!( self * &echelon_form.transform, AlignedBitMatrix::identity(self.row_count()) diff --git a/binar/src/matrix/bitmatrix.rs b/binar/src/matrix/bitmatrix.rs index 4e4f5d1b..56a96f8c 100644 --- a/binar/src/matrix/bitmatrix.rs +++ b/binar/src/matrix/bitmatrix.rs @@ -110,19 +110,16 @@ impl EchelonForm { } /// Returns the reduced row echelon form matrix. - #[must_use] pub fn matrix(&self) -> BitMatrix { self.aligned.matrix.clone().into() } /// Returns the transformation matrix T such that T * original = RREF. - #[must_use] pub fn transform(&self) -> BitMatrix { self.aligned.transform.clone().into() } /// Returns the inverse transpose of the transformation matrix. - #[must_use] pub fn transform_inv_t(&self) -> BitMatrix { self.aligned.transform_inv_t.clone().into() } @@ -842,7 +839,7 @@ impl BitMatrix { /// /// Panics if `left.len() != self.row_count()`. pub fn right_multiply(&self, left: &BitView) -> BitVec { - assert!(left.len() == self.row_count()); + assert_eq!(left.len(), self.row_count()); BitVec::from_aligned(self.column_count(), self.aligned.right_multiply(&left.bits)) } @@ -988,7 +985,7 @@ impl Mul<&BitView<'_>> for &BitMatrix { type Output = BitVec; fn mul(self, right: &BitView) -> Self::Output { - assert!(right.len() == self.column_count()); + assert_eq!(right.len(), self.column_count()); BitVec::from_aligned(self.row_count(), &self.aligned * &right.bits) } } diff --git a/paulimer/src/clifford/clifford_impl.rs b/paulimer/src/clifford/clifford_impl.rs index 3beebcf1..f7a7a7c5 100644 --- a/paulimer/src/clifford/clifford_impl.rs +++ b/paulimer/src/clifford/clifford_impl.rs @@ -1375,7 +1375,7 @@ impl MutablePreImages #[allow(clippy::similar_names)] fn preimage_xz_views_mut_distinct(&mut self, index: (usize, usize)) -> crate::Tuple2x2> { - debug_assert!(index.0 != index.1); + debug_assert_ne!(index.0, index.1); unsafe { let (xx, xz, zx, zz) = get_quad_mut_unsafe(&mut self.preimages); let (xx0, xx1) = get_tuple_mut_unsafe(xx, index); diff --git a/paulimer/src/clifford/generic_algos.rs b/paulimer/src/clifford/generic_algos.rs index 759bad29..9da5b1de 100644 --- a/paulimer/src/clifford/generic_algos.rs +++ b/paulimer/src/clifford/generic_algos.rs @@ -303,7 +303,7 @@ where for<'life> ::PreImageViewMut<'life>: PauliBinaryOps<::DensePauli>, { - assert!(left.num_qubits() == right.num_qubits()); + assert_eq!(left.num_qubits(), right.num_qubits()); let mut result = CliffordLike::zero(left.num_qubits()); for qubit_index in 0..left.num_qubits() { result diff --git a/paulimer/tests/clifford_test.rs b/paulimer/tests/clifford_test.rs index bc3a01ad..a45117d6 100644 --- a/paulimer/tests/clifford_test.rs +++ b/paulimer/tests/clifford_test.rs @@ -290,8 +290,8 @@ proptest! { let z_image = clifford.image(z); let x_image_preimage = clifford.preimage(&x_image); let z_image_preimage = clifford.preimage(&z_image); - assert!( x == x_image_preimage); - assert!( z == z_image_preimage); + assert_eq!( x, x_image_preimage); + assert_eq!( z, z_image_preimage); } } @@ -857,8 +857,8 @@ fn root_y_inv_images(q: usize) -> ImageTable { fn check_images(c: &CliffordLike, image_table: &ImageTable) { let sparse = sparse::; for (p, im_p) in image_table { - assert!(c.image(&sparse(p)) == im_p.as_slice()); - assert!(c.preimage(&sparse(im_p)) == p.as_slice()); + assert_eq!(c.image(&sparse(p)), im_p.as_slice()); + assert_eq!(c.preimage(&sparse(im_p)), p.as_slice()); } } @@ -907,8 +907,8 @@ fn assert_identity_on(c: &impl Clifford, qubit_index: usize) { fn generic_prepare_bell_states_test() { let c = clifford_to_prepare_bell_states::(1); assert!(c.is_valid()); - assert!(c.image_z(0) == [x(0), x(1)].borrow()); - assert!(c.image_z(1) == [z(0), z(1)].borrow()); + assert_eq!(c.image_z(0), [x(0), x(1)].borrow()); + assert_eq!(c.image_z(1), [z(0), z(1)].borrow()); } #[test] @@ -997,10 +997,10 @@ fn assert_images_consistent(clifford: &CliffordL for qubit_index in clifford.qubits() { let im_x = clifford.image_x(qubit_index); let im_z = clifford.image_z(qubit_index); - assert!(clifford.image_x_bits(&IndexSet::singleton(qubit_index)) == im_x); - assert!(clifford.image_z_bits(&IndexSet::singleton(qubit_index)) == im_z); - assert!(clifford.image(&sparse(&[x(qubit_index)])) == im_x); - assert!(clifford.image(&sparse(&[z(qubit_index)])) == im_z); + assert_eq!(clifford.image_x_bits(&IndexSet::singleton(qubit_index)), im_x); + assert_eq!(clifford.image_z_bits(&IndexSet::singleton(qubit_index)), im_z); + assert_eq!(clifford.image(&sparse(&[x(qubit_index)])), im_x); + assert_eq!(clifford.image(&sparse(&[z(qubit_index)])), im_z); } } @@ -1009,10 +1009,10 @@ fn assert_preimages_consistent(clifford: &Cliffo for qubit_index in clifford.qubits() { let pre_im_x = clifford.preimage_x(qubit_index); let pre_im_z = clifford.preimage_z(qubit_index); - assert!(clifford.preimage_x_bits(&IndexSet::singleton(qubit_index)) == pre_im_x); - assert!(clifford.preimage_z_bits(&IndexSet::singleton(qubit_index)) == pre_im_z); - assert!(clifford.preimage(&sparse(&[x(qubit_index)])) == pre_im_x); - assert!(clifford.preimage(&sparse(&[z(qubit_index)])) == pre_im_z); + assert_eq!(clifford.preimage_x_bits(&IndexSet::singleton(qubit_index)), pre_im_x); + assert_eq!(clifford.preimage_z_bits(&IndexSet::singleton(qubit_index)), pre_im_z); + assert_eq!(clifford.preimage(&sparse(&[x(qubit_index)])), pre_im_x); + assert_eq!(clifford.preimage(&sparse(&[z(qubit_index)])), pre_im_z); } } @@ -1064,7 +1064,7 @@ fn compare_clifford_transformations( apply_transformation2(&mut c2); assert!(c1.is_valid()); assert!(c2.is_valid()); - assert!(c1 == c2); + assert_eq!(c1, c2); } fn sparse( @@ -1170,7 +1170,7 @@ fn generic_random_tensor_test(num_qubits1: usize let id2 = CliffordLike::identity(num_qubits2); let r1 = CliffordLike::random(num_qubits1, &mut rand::rng()); let r2 = CliffordLike::random(num_qubits2, &mut rand::rng()); - assert!((r1.tensor(&id2)).multiply_with(&(id1.tensor(&r2))) == r1.tensor(&r2)); + assert_eq!((r1.tensor(&id2)).multiply_with(&(id1.tensor(&r2))), r1.tensor(&r2)); } fn generic_tensor_test() { @@ -1181,7 +1181,7 @@ fn generic_tensor_test() { let mut c1xc2 = CliffordLike::identity(4); c1xc2.left_mul_cx(0, 1); c1xc2.left_mul_cz(2, 3); - assert!(c1xc2 == c1.tensor(&c2)); + assert_eq!(c1xc2, c1.tensor(&c2)); for _ in 0..10 { generic_random_tensor_test::(5, 10); @@ -1230,12 +1230,12 @@ fn css_clifford_test() { assert!(c.is_valid()); for k in c.qubits() { assert!(c.preimage_z(k).x_bits().is_zero()); - assert!(c.preimage_z(k).z_bits() == &a_inv_t.row(k)); + assert_eq!(c.preimage_z(k).z_bits(), &a_inv_t.row(k)); assert!(c.image_z(k).x_bits().is_zero()); assert!(are_bits_equal_to_col(c.image_z(k).z_bits(), &a, k)); assert!(c.preimage_x(k).z_bits().is_zero()); - assert!(c.preimage_x(k).x_bits() == &a.row(k)); + assert_eq!(c.preimage_x(k).x_bits(), &a.row(k)); assert!(c.image_x(k).z_bits().is_zero()); assert!(are_bits_equal_to_col(c.image_x(k).x_bits(), &a_inv_t, k)); } diff --git a/paulimer/tests/pauli_group_test.rs b/paulimer/tests/pauli_group_test.rs index 1d75b352..acdbf72f 100644 --- a/paulimer/tests/pauli_group_test.rs +++ b/paulimer/tests/pauli_group_test.rs @@ -20,7 +20,7 @@ fn sparse_pauli(max_weight: usize) -> BoxedStrategy { let mut x_bits = IndexSet::new(); let mut z_bits = IndexSet::new(); - for (idx, op) in indices.into_iter().zip(operators.into_iter()) { + for (idx, op) in indices.into_iter().zip(operators) { match op { 'X' => x_bits.assign_index(idx, true), 'Z' => z_bits.assign_index(idx, true), diff --git a/paulimer/tests/pauli_test.rs b/paulimer/tests/pauli_test.rs index f5c952fe..be1c4294 100644 --- a/paulimer/tests/pauli_test.rs +++ b/paulimer/tests/pauli_test.rs @@ -181,7 +181,7 @@ fn pauli_product_test() { preimage_view.mul_assign_right(&target); println!("{preimage_view}"); preimage_view.mul_assign_left(&control); - assert!(preimage_view == preimage_r); + assert_eq!(preimage_view, preimage_r); } fn test_round_trip + Eq + fmt::Debug + fmt::Display>( diff --git a/paulimer/tests/serde_test.rs b/paulimer/tests/serde_test.rs index b7b0adb1..19fdcb2b 100644 --- a/paulimer/tests/serde_test.rs +++ b/paulimer/tests/serde_test.rs @@ -28,7 +28,7 @@ mod serde_tests { let mut x_bits = IndexSet::new(); let mut z_bits = IndexSet::new(); - for (index, operator) in indices.into_iter().zip(operators.into_iter()) { + for (index, operator) in indices.into_iter().zip(operators) { match operator { 'X' => x_bits.assign_index(index, true), 'Z' => z_bits.assign_index(index, true), From 16306d39b88413a24579e0ded421c4376d23a69d Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Fri, 10 Jul 2026 15:15:03 -0700 Subject: [PATCH 031/157] fix tutorial build error --- .../gen_readout_propagation.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 deq/documents/tutorial/examples/readout-propagation/gen_readout_propagation.py diff --git a/deq/documents/tutorial/examples/readout-propagation/gen_readout_propagation.py b/deq/documents/tutorial/examples/readout-propagation/gen_readout_propagation.py new file mode 100644 index 00000000..4cb15145 --- /dev/null +++ b/deq/documents/tutorial/examples/readout-propagation/gen_readout_propagation.py @@ -0,0 +1,39 @@ +"""Generate the annotated fixture referenced by the ``readout-propagation`` +tutorial chapter. + +``documents/tutorial/chapters/readout-propagation.md`` includes highlighted +snippets from +``tests/circuit/repetition_code/exercise_readout_conditions.annotated.deq``. +That ``.annotated.deq`` file is ``.gitignore``d (``*.annotated.deq`` under +``deq/.gitignore``), so it doesn't ship in the repo — CI must regenerate it +before ``highlight_deq.py`` runs. This script is discovered by +``documents/tutorial/scripts/run_generators.py`` (which globs +``gen_*.py`` under ``documents/tutorial/examples/``) and produces the +missing file in place. +""" + +import os + +from deq.cli.annotate import annotate + +THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +# THIS_DIR = deq/documents/tutorial/examples/readout-propagation/, so four +# ``..`` steps land at the ``deq/`` package root. +DEQ_ROOT = os.path.abspath(os.path.join(THIS_DIR, "..", "..", "..", "..")) +SOURCE = os.path.join( + DEQ_ROOT, "tests", "circuit", "repetition_code", + "exercise_readout_conditions.deq", +) +ANNOTATED = os.path.join( + DEQ_ROOT, "tests", "circuit", "repetition_code", + "exercise_readout_conditions.annotated.deq", +) + + +def main() -> None: + print(f"Annotating {os.path.relpath(SOURCE, DEQ_ROOT)}...") + annotate(SOURCE, out=ANNOTATED, skip_mako_warning=True) + + +if __name__ == "__main__": + main() From 863e1e97005fc723bc7bfd8a19bfcf8bf00dbaf9 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Fri, 10 Jul 2026 15:51:38 -0700 Subject: [PATCH 032/157] fix test case --- deq/tests/cli/jit_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/deq/tests/cli/jit_test.py b/deq/tests/cli/jit_test.py index 920803a8..0a680429 100644 --- a/deq/tests/cli/jit_test.py +++ b/deq/tests/cli/jit_test.py @@ -13,6 +13,7 @@ from deq.spec.program_equivalence import are_programs_equivalent import deq.proto.deq_bin_pb2 as pb import deq.proto.deq_jit_pb2 as jit_pb +import deq.proto.util_pb2 as util_pb # Minimal 3-logical-qubit trivial code to provide 6 observables for Pauli tests. _TRIVIAL_CODE_K3_DEQ = """\ @@ -1145,7 +1146,7 @@ def _compute_zero_measurement_residual( f"got {len(input_obs)}" ) - def dense(bm: pb.BitMatrix, rows: int, cols: int) -> "np.ndarray": + def dense(bm: util_pb.BitMatrix, rows: int, cols: int) -> "np.ndarray": m = np.zeros((rows, cols), dtype=np.uint8) for i, j in zip(bm.i, bm.j): m[i, j] = 1 @@ -1782,7 +1783,7 @@ def test_mixed_inner_outer_conditional_matrices_byte_identical( if gt.base.name == "TwoMZZExtraCorrOuter" ).base - def entries(bm: pb.BitMatrix) -> set[tuple[int, int]]: + def entries(bm: util_pb.BitMatrix) -> set[tuple[int, int]]: return set(zip(bm.i, bm.j)) assert entries(mixed.correction_propagation) == entries( From 8e5a2c504218d95a9bfb8f96cc6046058c6b8356 Mon Sep 17 00:00:00 2001 From: "Juan M. Bello-Rivas" Date: Fri, 10 Jul 2026 15:43:34 -0700 Subject: [PATCH 033/157] Add Emacs major mode for .deq files (#107) Co-authored-by: Juan M. Bello-Rivas Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- deq/deq/circuit/emacs-deq/.gitignore | 2 + deq/deq/circuit/emacs-deq/Makefile | 19 ++ deq/deq/circuit/emacs-deq/README.md | 77 ++++++ deq/deq/circuit/emacs-deq/deq-mode-tests.el | 214 +++++++++++++++ deq/deq/circuit/emacs-deq/deq-mode.el | 256 ++++++++++++++++++ .../tutorial/chapters/language-basics.md | 23 +- 6 files changed, 587 insertions(+), 4 deletions(-) create mode 100644 deq/deq/circuit/emacs-deq/.gitignore create mode 100644 deq/deq/circuit/emacs-deq/Makefile create mode 100644 deq/deq/circuit/emacs-deq/README.md create mode 100644 deq/deq/circuit/emacs-deq/deq-mode-tests.el create mode 100644 deq/deq/circuit/emacs-deq/deq-mode.el diff --git a/deq/deq/circuit/emacs-deq/.gitignore b/deq/deq/circuit/emacs-deq/.gitignore new file mode 100644 index 00000000..86f38ed6 --- /dev/null +++ b/deq/deq/circuit/emacs-deq/.gitignore @@ -0,0 +1,2 @@ +# Byte-compiled Emacs Lisp +*.elc diff --git a/deq/deq/circuit/emacs-deq/Makefile b/deq/deq/circuit/emacs-deq/Makefile new file mode 100644 index 00000000..dbe8f07e --- /dev/null +++ b/deq/deq/circuit/emacs-deq/Makefile @@ -0,0 +1,19 @@ +EMACS ?= emacs +EL := deq-mode.el +TEST := deq-mode-tests.el +ELC := $(EL:.el=.elc) + +.PHONY: all compile test clean + +all: compile test + +compile: $(ELC) + +$(ELC): $(EL) + $(EMACS) -Q --batch -L . -f batch-byte-compile $< + +test: + $(EMACS) -Q --batch -L . -l $(TEST) -f ert-run-tests-batch-and-exit + +clean: + rm -f $(ELC) diff --git a/deq/deq/circuit/emacs-deq/README.md b/deq/deq/circuit/emacs-deq/README.md new file mode 100644 index 00000000..98d884a5 --- /dev/null +++ b/deq/deq/circuit/emacs-deq/README.md @@ -0,0 +1,77 @@ +# DEQ Language Support for Emacs + +A major mode for editing `.deq` quantum error correction files in GNU Emacs, +derived from `prog-mode`. Mirrors the feature set of the VS Code extension +in [`../vscode-deq`](../vscode-deq/). + +## Features + +- Syntax highlighting for all DEQ constructs: + - `CODE`, `GADGET`, `COMPOSE`, `PROGRAM` blocks + - `LOGICAL`, `STABILIZER` declarations with Pauli products + - `INPUT`, `OUTPUT`, `CHECK`, `READOUT`, `OBSERVABLE_INCLUDE`, + `DETECTOR`, `ERROR(prob)`, `CONDITIONAL`, `PRESELECT`, `VIRTUAL`, + `PROPAGATE`, `FROM`, `FLIP`, `ASSERT_EQ` + - Gadget applications with `IN(…)` / `OUT(…)` port bindings + - Embedded Stim instructions with all target types + - Decorators (`@Name(…)`) +- Distinct, themable faces for each target class: + - `deq-pauli-face` — `[IXYZ]N` (e.g. `X0`) + - `deq-logical-face` — `L[XYZ]N` (e.g. `LZ3`) + - `deq-check-face` — `CN` (e.g. `C12`) + - `deq-readout-face` — `RN`, `DSN` + - `deq-record-face` — `rec[-N]`, `sweep[N]` + - `deq-decorator-face` +- `{`-aware indentation via `syntax-ppss`; customizable through + `deq-indent-offset` (default `4`). +- Imenu navigation by `CODE` / `GADGET` / `COMPOSE` / `PROGRAM` name. +- Comment toggling (`#`), bracket matching, and `show-paren-mode` support. +- Inherits everything from `prog-mode`: `prettify-symbols-mode`, + `flymake-mode`, `display-line-numbers-mode`, xref context menu, etc. + +## Installation + +### Option 1 — load directly from this directory + +```elisp +(add-to-list 'load-path "/path/to/deq/deq/circuit/emacs-deq") +(require 'deq-mode) +``` + +Files ending in `.deq` will automatically open in `deq-mode` thanks to the +`auto-mode-alist` entry installed by the autoload cookie. + +### Option 2 — `use-package` + +```elisp +(use-package deq-mode + :load-path "/path/to/deq/deq/circuit/emacs-deq" + :mode ("\\.deq\\'" . deq-mode)) +``` + +### Optional — byte-compile + +```bash +emacs -Q --batch -f batch-byte-compile deq-mode.el +``` + +### Optional — run the ERT test suite + +```bash +make test # or: make EMACS=/path/to/emacs test +``` + +24 tests cover mode activation, syntax-table parsing, font-lock face +assignment for every target class, indentation (with custom offset), +imenu indexing, and comment commands. + +## Customization + +| Variable | Default | Purpose | +|----------------------|---------|--------------------------------------| +| `deq-indent-offset` | `4` | Number of spaces per indent level. | + +All faces (`deq-pauli-face`, `deq-logical-face`, `deq-check-face`, +`deq-readout-face`, `deq-record-face`, `deq-decorator-face`) inherit from +standard `font-lock-*` faces and can be remapped per-theme via +`custom-set-faces` or `face-remap-add-relative`. diff --git a/deq/deq/circuit/emacs-deq/deq-mode-tests.el b/deq/deq/circuit/emacs-deq/deq-mode-tests.el new file mode 100644 index 00000000..22dc16b4 --- /dev/null +++ b/deq/deq/circuit/emacs-deq/deq-mode-tests.el @@ -0,0 +1,214 @@ +;;; deq-mode-tests.el --- ERT tests for deq-mode -*- lexical-binding: t -*- + +;; Run with: +;; emacs -Q --batch -L . -l deq-mode-tests.el -f ert-run-tests-batch-and-exit + +;;; Code: + +(require 'ert) +(require 'imenu) +(require 'deq-mode) + +;;; ── Helpers ─────────────────────────────────────────────────────────── + +(defmacro deq-test-with-buffer (text &rest body) + "Insert TEXT into a temp buffer in `deq-mode', fontify, run BODY." + (declare (indent 1) (debug t)) + `(with-temp-buffer + (deq-mode) + (insert ,text) + (font-lock-ensure) + (goto-char (point-min)) + ,@body)) + +(defun deq-test--face-at (regexp) + "Search forward for REGEXP and return the face at its first character." + (goto-char (point-min)) + (re-search-forward regexp) + (get-text-property (match-beginning 0) 'face)) + +(defun deq-test--reindent (text) + "Insert TEXT in a `deq-mode' buffer, strip indentation, then re-indent it. +Return the resulting buffer contents." + (with-temp-buffer + (deq-mode) + (insert text) + (goto-char (point-min)) + (while (re-search-forward "^[ \t]+" nil t) (replace-match "")) + (indent-region (point-min) (point-max)) + (buffer-string))) + +;;; ── Activation / derivation ─────────────────────────────────────────── + +(ert-deftest deq-mode/auto-mode-alist-triggers () + (with-temp-buffer + (let ((buffer-file-name "/tmp/example.deq")) + (set-auto-mode) + (should (eq major-mode 'deq-mode))))) + +(ert-deftest deq-mode/derives-from-prog-mode () + (with-temp-buffer + (deq-mode) + (should (derived-mode-p 'prog-mode)))) + +(ert-deftest deq-mode/sets-comment-syntax-vars () + (with-temp-buffer + (deq-mode) + (should (equal comment-start "# ")) + (should (string-match-p "#" comment-start-skip)))) + +;;; ── Syntax table ────────────────────────────────────────────────────── + +(ert-deftest deq-mode/recognizes-line-comments () + (deq-test-with-buffer "GADGET Foo {\n # a comment\n}\n" + (re-search-forward "comment") + (should (nth 4 (syntax-ppss))))) + +(ert-deftest deq-mode/recognizes-strings () + (deq-test-with-buffer "IMPORT \"abc.deq\"\n" + (re-search-forward "abc") + (should (nth 3 (syntax-ppss))))) + +(ert-deftest deq-mode/braces-form-sexps () + (deq-test-with-buffer "GADGET F { X 0 }\n" + (re-search-forward "{") + (backward-char) + (let ((start (point))) + (forward-sexp) + (should (eq (char-before) ?\})) + (should (> (point) start))))) + +(ert-deftest deq-mode/brackets-form-sexps () + (deq-test-with-buffer "CODE Foo[[2,1,3]] {}\n" + (re-search-forward "\\[\\[") + (backward-char 2) + (forward-sexp) + (should (eq (char-before) ?\])))) ;; first ] of ]] + +;;; ── Font-lock ───────────────────────────────────────────────────────── + +(ert-deftest deq-mode/fontify-definition-keyword-and-name () + (deq-test-with-buffer "GADGET Foo {\n}\n" + (should (eq (deq-test--face-at "GADGET") 'font-lock-keyword-face)) + (should (eq (deq-test--face-at "Foo") 'font-lock-function-name-face)))) + +(ert-deftest deq-mode/fontify-control-keyword-import () + (deq-test-with-buffer "IMPORT \"x.deq\"\n" + (should (eq (deq-test--face-at "IMPORT") 'font-lock-keyword-face)))) + +(ert-deftest deq-mode/fontify-statement-keyword-readout () + (deq-test-with-buffer "GADGET F {\n READOUT R0\n}\n" + (should (eq (deq-test--face-at "READOUT") 'font-lock-builtin-face)))) + +(ert-deftest deq-mode/fontify-pauli-target () + (deq-test-with-buffer "GADGET F {\n ERROR(0.1) X3 Y7 Z2\n}\n" + (should (eq (deq-test--face-at "X3") 'deq-pauli-face)) + (should (eq (deq-test--face-at "Y7") 'deq-pauli-face)) + (should (eq (deq-test--face-at "Z2") 'deq-pauli-face)))) + +(ert-deftest deq-mode/fontify-check-target () + (deq-test-with-buffer "GADGET F {\n CHECK C0 C12\n}\n" + (should (eq (deq-test--face-at "C0") 'deq-check-face)) + (should (eq (deq-test--face-at "C12") 'deq-check-face)))) + +(ert-deftest deq-mode/fontify-readout-target () + (deq-test-with-buffer "GADGET F {\n READOUT R5\n}\n" + (should (eq (deq-test--face-at "R5") 'deq-readout-face)))) + +(ert-deftest deq-mode/fontify-logical-target () + (deq-test-with-buffer "GADGET F {\n READOUT LZ3 LX1 LY2\n}\n" + ;; Logical pattern must win over the bare-Pauli pattern. + (should (eq (deq-test--face-at "LZ3") 'deq-logical-face)) + (should (eq (deq-test--face-at "LX1") 'deq-logical-face)) + (should (eq (deq-test--face-at "LY2") 'deq-logical-face)))) + +(ert-deftest deq-mode/fontify-ds-target () + (deq-test-with-buffer "GADGET F {\n PROPAGATE LX0 FROM DS4\n}\n" + (should (eq (deq-test--face-at "DS4") 'deq-readout-face)))) + +(ert-deftest deq-mode/fontify-record-target () + (deq-test-with-buffer "GADGET F {\n CONDITIONAL rec[-1] LX0\n}\n" + (should (eq (deq-test--face-at "rec\\[-1\\]") 'deq-record-face)))) + +(ert-deftest deq-mode/fontify-decorator () + (deq-test-with-buffer "@GTYPE(2)\nGADGET F {\n}\n" + (should (eq (deq-test--face-at "@GTYPE") 'deq-decorator-face)))) + +(ert-deftest deq-mode/no-fontify-inside-comment () + (deq-test-with-buffer "GADGET F {\n # X0 should not be a Pauli here\n}\n" + (re-search-forward "X0") + (let ((face (get-text-property (match-beginning 0) 'face))) + ;; Inside a comment the face should be the comment face, not Pauli. + (should (or (eq face 'font-lock-comment-face) + (and (listp face) + (memq 'font-lock-comment-face face)))) + (should-not (eq face 'deq-pauli-face))))) + +;;; ── Indentation ─────────────────────────────────────────────────────── + +(ert-deftest deq-mode/indent-flat-gadget () + (let ((expected "GADGET Foo {\n READOUT R0\n CHECK C0\n}\n")) + (should (equal (deq-test--reindent expected) expected)))) + +(ert-deftest deq-mode/indent-nested-repeat () + (let ((expected (concat + "GADGET Foo {\n" + " REPEAT 3 {\n" + " CX 0 1\n" + " }\n" + "}\n"))) + (should (equal (deq-test--reindent expected) expected)))) + +(ert-deftest deq-mode/indent-respects-custom-offset () + (let ((deq-indent-offset 2) + (expected (concat + "GADGET Foo {\n" + " REPEAT 3 {\n" + " CX 0 1\n" + " }\n" + "}\n"))) + (should (equal (deq-test--reindent expected) expected)))) + +(ert-deftest deq-mode/indent-closing-brace-dedents () + ;; A line starting with `}' should sit one level out from its body. + (with-temp-buffer + (deq-mode) + (insert "GADGET Foo {\n READOUT R0\n }\n") + (goto-char (point-min)) + (forward-line 2) + (deq-indent-line) + (back-to-indentation) + (should (= (current-column) 0)))) + +;;; ── Imenu ───────────────────────────────────────────────────────────── + +(ert-deftest deq-mode/imenu-finds-all-definition-kinds () + (deq-test-with-buffer + (concat + "CODE C[[2,1]] {\n LOGICAL X0 Z0\n}\n" + "GADGET G {\n}\n" + "COMPOSE M {\n}\n" + "PROGRAM P {\n}\n") + (let ((idx (imenu--make-index-alist))) + (should (assoc "Codes" idx)) + (should (assoc "Gadgets" idx)) + (should (assoc "Compose" idx)) + (should (assoc "Programs" idx)) + (should (assoc "C" (cdr (assoc "Codes" idx)))) + (should (assoc "G" (cdr (assoc "Gadgets" idx)))) + (should (assoc "M" (cdr (assoc "Compose" idx)))) + (should (assoc "P" (cdr (assoc "Programs" idx))))))) + +;;; ── Comment commands ───────────────────────────────────────────────── + +(ert-deftest deq-mode/comment-region-uses-hash () + (with-temp-buffer + (deq-mode) + (insert "CX 0 1\n") + (comment-region (point-min) (point-max)) + (goto-char (point-min)) + (should (looking-at "#")))) + +(provide 'deq-mode-tests) + +;;; deq-mode-tests.el ends here diff --git a/deq/deq/circuit/emacs-deq/deq-mode.el b/deq/deq/circuit/emacs-deq/deq-mode.el new file mode 100644 index 00000000..e44f8872 --- /dev/null +++ b/deq/deq/circuit/emacs-deq/deq-mode.el @@ -0,0 +1,256 @@ +;;; deq-mode.el --- Major mode for DEQ quantum error correction files -*- lexical-binding: t -*- + +;; Copyright (C) 2026 Microsoft + +;; Keywords: languages, quantum +;; Package-Requires: ((emacs "27.1")) +;; Version: 0.1.0 + +;; This file is not part of GNU Emacs. + +;;; Commentary: + +;; Major mode for editing `.deq' files — the source DSL for the DEQ +;; quantum error correction system. Features: +;; +;; * Syntax highlighting for all DEQ constructs (CODE, GADGET, +;; COMPOSE, PROGRAM blocks; LOGICAL/STABILIZER declarations; +;; INPUT/OUTPUT ports; READOUT/CHECK/ERROR/CONDITIONAL statements; +;; embedded Stim instructions; decorators). +;; * Distinct faces for the four target classes (Pauli, check, +;; readout, logical Pauli shortcut) mirroring the VS Code +;; extension's color scheme. +;; * `{}'-aware indentation built on `syntax-ppss'. +;; * Imenu navigation for CODE / GADGET / COMPOSE / PROGRAM names. +;; * Auto-registers for files ending in `.deq'. +;; +;; Install: +;; +;; (add-to-list 'load-path "/path/to/emacs-deq") +;; (require 'deq-mode) + +;;; Code: + +(require 'prog-mode) + +(defgroup deq nil + "Major mode for DEQ quantum error correction files." + :group 'languages + :prefix "deq-") + +(defcustom deq-indent-offset 4 + "Number of spaces per indentation level in `deq-mode'." + :type 'integer + :safe #'integerp + :group 'deq) + +;;; ── Faces ───────────────────────────────────────────────────────────── + +(defface deq-pauli-face + '((t :inherit font-lock-type-face)) + "Face for Pauli operators (e.g. `X0', `Y3', `Z7', `I2')." + :group 'deq) + +(defface deq-logical-face + '((t :inherit font-lock-function-name-face)) + "Face for logical-Pauli shortcut targets (e.g. `LX0', `LY3', `LZ7')." + :group 'deq) + +(defface deq-check-face + '((t :inherit font-lock-constant-face)) + "Face for check/detector targets (e.g. `C0', `C12')." + :group 'deq) + +(defface deq-readout-face + '((t :inherit font-lock-variable-name-face)) + "Face for readout and detector-state targets (e.g. `R0', `DS3')." + :group 'deq) + +(defface deq-record-face + '((t :inherit font-lock-variable-name-face)) + "Face for measurement-record and sweep-bit targets (e.g. `rec[-1]', `sweep[2]')." + :group 'deq) + +(defface deq-decorator-face + '((t :inherit font-lock-preprocessor-face)) + "Face for decorators (e.g. `@GTYPE(2)')." + :group 'deq) + +;;; ── Keyword sets ────────────────────────────────────────────────────── + +(defconst deq--definition-keywords + '("CODE" "GADGET" "COMPOSE" "PROGRAM") + "Top-level definition keywords that introduce a named block.") + +(defconst deq--control-keywords + '("IMPORT" "REPEAT") + "Control-flow / file-level keywords.") + +(defconst deq--statement-keywords + '("INPUT" "OUTPUT" + "LOGICAL" "STABILIZER" + "READOUT" "OBSERVABLE_INCLUDE" + "CHECK" "DETECTOR" + "ERROR" + "CONDITIONAL" "PRESELECT" + "VIRTUAL" "PROPAGATE" "FROM" "FLIP" + "ASSERT_EQ" + "IN" "OUT") + "Body-statement keywords used inside definitions.") + +;;; ── Font-lock ───────────────────────────────────────────────────────── + +(defconst deq-font-lock-keywords + (let ((ctrl (regexp-opt deq--control-keywords 'symbols)) + (stmts (regexp-opt deq--statement-keywords 'symbols)) + (ident "[A-Za-z][A-Za-z0-9_]*")) + `( + ;; Decorators: @Name (arguments highlighted normally). + ("@[A-Za-z][A-Za-z0-9_]*" 0 'deq-decorator-face) + + ;; Definition introducers and their names: CODE Foo / GADGET Foo / ... + (,(concat "\\b\\(" (regexp-opt deq--definition-keywords) "\\)" + "\\s-+\\(" ident "\\)") + (1 font-lock-keyword-face) + (2 font-lock-function-name-face)) + + ;; Other control / statement keywords. + (,ctrl 1 font-lock-keyword-face) + (,stmts 1 font-lock-builtin-face) + + ;; Inverted-target prefix `!' (used before qubits or Paulis). + ("!" 0 font-lock-negation-char-face) + + ;; QEC-specific targets — order matters: more specific first. + ("\\" 0 'deq-logical-face) + ("\\" 0 'deq-readout-face) + ("\\" 0 'deq-check-face) + ("\\" 0 'deq-readout-face) + ("\\<[IXYZ][0-9]+\\>" 0 'deq-pauli-face) + + ;; Measurement record / sweep bit. + ("rec\\[-[0-9]+\\]" 0 'deq-record-face) + ("sweep\\[[0-9]+\\]" 0 'deq-record-face) + + ;; Numbers (integers and floats, with optional sign / exponent). + ("\\_<-?\\(?:[0-9]+\\.?[0-9]*\\|\\.[0-9]+\\)\\(?:[eE][+-]?[0-9]+\\)?\\_>" + 0 font-lock-constant-face))) + "Font-lock keywords for `deq-mode'.") + +;;; ── Syntax table ────────────────────────────────────────────────────── + +(defvar deq-mode-syntax-table + (let ((st (make-syntax-table))) + ;; `#' begins a line comment that ends at newline. + (modify-syntax-entry ?# "<" st) + (modify-syntax-entry ?\n ">" st) + ;; Identifiers may contain `_'. + (modify-syntax-entry ?_ "w" st) + ;; Double-quoted strings. + (modify-syntax-entry ?\" "\"" st) + ;; Matched brackets — enables `forward-sexp', `show-paren-mode', + ;; and `syntax-ppss' depth tracking used by the indenter. + (modify-syntax-entry ?\( "()" st) + (modify-syntax-entry ?\) ")(" st) + (modify-syntax-entry ?\[ "(]" st) + (modify-syntax-entry ?\] ")[" st) + (modify-syntax-entry ?\{ "(}" st) + (modify-syntax-entry ?\} "){" st) + ;; `!' and `@' are punctuation, not word constituents — keeps + ;; `forward-word' from absorbing them into adjacent identifiers. + (modify-syntax-entry ?! "." st) + (modify-syntax-entry ?@ "." st) + ;; `*' as punctuation (combiner in pauli products and stim targets). + (modify-syntax-entry ?* "." st) + st) + "Syntax table for `deq-mode'.") + +;;; ── Indentation ─────────────────────────────────────────────────────── + +(defun deq--paren-depth-at-bol () + "Return the unbalanced paren nesting depth at the start of the current line." + (save-excursion + (beginning-of-line) + (car (syntax-ppss)))) + +(defun deq--line-starts-with-closer-p () + "Non-nil if the first non-whitespace char of the line is a closing bracket." + (save-excursion + (beginning-of-line) + (skip-chars-forward " \t") + (memq (char-after) '(?\} ?\) ?\])))) + +(defun deq-indent-line () + "Indent the current line of DEQ source. + +Uses `syntax-ppss' to compute the `{'/`('/`[' nesting depth at the +beginning of the line and indents to `depth * deq-indent-offset'. +Lines whose first non-whitespace character is a closer (`}', `)', +or `]') are dedented one level so they line up with their opener." + (interactive) + (let* ((depth (deq--paren-depth-at-bol)) + (closer (deq--line-starts-with-closer-p)) + (target (* (max 0 (if closer (1- depth) depth)) + deq-indent-offset)) + (at-or-before-text + (<= (current-column) (current-indentation)))) + (if at-or-before-text + (indent-line-to target) + (save-excursion (indent-line-to target))))) + +;;; ── Imenu ───────────────────────────────────────────────────────────── + +(defconst deq-imenu-generic-expression + (let ((ident "\\([A-Za-z][A-Za-z0-9_]*\\)")) + `(("Codes" ,(concat "^\\s-*CODE\\s-+" ident) 1) + ("Gadgets" ,(concat "^\\s-*GADGET\\s-+" ident) 1) + ("Compose" ,(concat "^\\s-*COMPOSE\\s-+" ident) 1) + ("Programs" ,(concat "^\\s-*PROGRAM\\s-+" ident) 1))) + "Imenu patterns for navigating DEQ definitions.") + +;;; ── Mode definition ─────────────────────────────────────────────────── + +;;;###autoload +(define-derived-mode deq-mode prog-mode "DEQ" + "Major mode for editing DEQ quantum error correction source files. + +\\{deq-mode-map}" + :syntax-table deq-mode-syntax-table + :group 'deq + (setq-local comment-start "# ") + (setq-local comment-start-skip "#+\\s-*") + (setq-local comment-end "") + (setq-local comment-use-syntax t) + (setq-local font-lock-defaults '(deq-font-lock-keywords)) + (setq-local indent-line-function #'deq-indent-line) + (setq-local indent-tabs-mode nil) + (setq-local imenu-generic-expression deq-imenu-generic-expression) + (setq-local beginning-of-defun-function #'deq-beginning-of-defun) + (setq-local end-of-defun-function #'deq-end-of-defun)) + +(defconst deq--defun-start-regexp + (concat "^\\(?:@[A-Za-z][A-Za-z0-9_]*[^\n]*\n\\s-*\\)*" + "\\(?:CODE\\|GADGET\\|COMPOSE\\|PROGRAM\\)\\_>") + "Regexp matching the start of a top-level DEQ definition (allowing decorators).") + +(defun deq-beginning-of-defun (&optional arg) + "Move backward to the beginning of the enclosing DEQ definition. +With ARG, repeat that many times (or forward if ARG is negative)." + (let ((arg (or arg 1))) + (if (> arg 0) + (re-search-backward deq--defun-start-regexp nil 'move arg) + (re-search-forward deq--defun-start-regexp nil 'move (- arg))))) + +(defun deq-end-of-defun () + "Move to the end of the current DEQ definition. +Assumes point is on or before the opening `{' of the definition." + (when (re-search-forward "{" nil 'move) + (backward-char) + (condition-case nil (forward-sexp) (scan-error nil)))) + +;;;###autoload +(add-to-list 'auto-mode-alist '("\\.deq\\'" . deq-mode)) + +(provide 'deq-mode) + +;;; deq-mode.el ends here diff --git a/deq/documents/tutorial/chapters/language-basics.md b/deq/documents/tutorial/chapters/language-basics.md index b4435c6c..9669ab4f 100644 --- a/deq/documents/tutorial/chapters/language-basics.md +++ b/deq/documents/tutorial/chapters/language-basics.md @@ -40,11 +40,14 @@ This chapter walks through the language from the simplest example to the full fe ## Setting Up: Syntax Highlighting -Before writing `.deq` files, install the VS Code syntax highlighting extension. It makes -`.deq` files much more readable — keywords, Pauli operators, measurement references, and -code parameters are all color-coded. +Before writing `.deq` files, install a syntax-highlighting extension for your editor. +It makes `.deq` files much more readable — keywords, Pauli operators, measurement +references, and code parameters are all color-coded. + +### VS Code + +Install via the top-level Makefile: -**Install via Makefile:** ```sh make install-extension ``` @@ -52,6 +55,18 @@ make install-extension After installation, any `.deq` file opened in VS Code will have syntax highlighting automatically. +### Emacs + +Install by: + +```elisp +(use-package deq-mode + :load-path "/path/to/deq/deq/circuit/emacs-deq" + :mode ("\\.deq\\'" . deq-mode)) +``` + +Any file ending in `.deq` will then open in `deq-mode` automatically. + --- ## Defining a QEC Code From 0fa140b75f27ea641e0ab6daa683961aa012129b Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Fri, 10 Jul 2026 21:20:57 -0700 Subject: [PATCH 034/157] remove unnecessary step in annotate --- deq/deq/cli/annotate.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/deq/deq/cli/annotate.py b/deq/deq/cli/annotate.py index 6812fe71..58c67d15 100644 --- a/deq/deq/cli/annotate.py +++ b/deq/deq/cli/annotate.py @@ -6,7 +6,6 @@ from deq.circuit.parser import render_and_parse_file, parse as parse_deq from deq.cli.strip_tags import strip_jit_library -from deq.spec.canonical import absorb_logical_correction_library from deq.transpiler.jit_annotate import annotate as _annotate_impl from deq.transpiler.jit_library_builder import build_jit_library from deq.circuit.mako_support import parse_mako_vars @@ -93,14 +92,7 @@ def annotate( if no_verify: return - # Verify: transpile the annotated output and compare. We absorb - # ``logical_correction`` into ``correction_propagation`` / - # ``physical_correction`` before comparing so a GADGET that - # authors ``CONDITIONAL R`` statements (non-empty - # logical_correction) is treated as equivalent to one whose - # canonical merge has already absorbed those into the propagation - # matrices (empty logical_correction). See - # :func:`deq.spec.canonical.absorb_logical_correction` for details. + # Verify: transpile the annotated output and compare. print( f"Verifying annotated output is equivalent to original", f"(pass --no-verify to skip)...", @@ -108,8 +100,6 @@ def annotate( ) orig_lib = build_jit_library(qfile) anno_lib = build_jit_library(parse_deq(rendered)) - absorb_logical_correction_library(orig_lib) - absorb_logical_correction_library(anno_lib) orig_stripped, _ = strip_jit_library(orig_lib) anno_stripped, _ = strip_jit_library(anno_lib) if orig_stripped.SerializeToString() == anno_stripped.SerializeToString(): @@ -117,7 +107,7 @@ def annotate( else: print( "ERROR: annotated output is not byte-equivalent to original" - " after tag stripping and canonical absorption.", + " after tag stripping.", file=sys.stderr, ) raise SystemExit(1) From 60b45a2c4ff9801bd468521dc04de79b617e89df Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Fri, 10 Jul 2026 22:11:49 -0700 Subject: [PATCH 035/157] remove unnecessary function --- deq/deq/spec/canonical.py | 83 ------------------- .../tutorial/chapters/compose-repropagate.md | 2 +- 2 files changed, 1 insertion(+), 84 deletions(-) diff --git a/deq/deq/spec/canonical.py b/deq/deq/spec/canonical.py index 8873b912..21d58bb5 100644 --- a/deq/deq/spec/canonical.py +++ b/deq/deq/spec/canonical.py @@ -76,8 +76,6 @@ CheckIndex, ErrorIndex, OutputPortIndex, - bitmatrix_of, - bitmatrix_to_proto, ) @@ -225,87 +223,6 @@ def from_gadget_type( ) -# =================================================================== -# absorb_logical_correction() — single-gadget canonical absorption -# =================================================================== - - -def absorb_logical_correction(gt: jit_pb.JitGadgetType) -> None: - """In-place: absorb ``base.logical_correction`` into - ``correction_propagation`` / ``physical_correction`` / per-error - ``residual`` on a single :class:`JitGadgetType`, then clear it. - - This is the single-gadget version of the absorption pass that - :func:`merge` runs on the composed result (see "step 9" inside - ``merge``). A GADGET that authors ``CONDITIONAL R L

`` - statements has a non-empty ``logical_correction``; the canonical - composed form has it empty. Two gadgets are equivalent up to - runtime semantics iff their absorbed forms are byte-equal — this - helper performs that absorption so equivalence checks (e.g. - ``deq annotate``'s verification) can reduce to a byte-compare. - - The absorption mirrors the runtime formula - ``residual ^= lc · readouts`` decomposed by data flow: - - * ``cp[r, *] ^= rp[j, *]`` for every ``(r, j)`` in ``lc`` - (absorbs the input-observable and affine columns); - * ``pc[r, m] ^= 1`` for every ``m`` in - ``readouts[j].measurement_indices`` for every ``(r, j)`` in ``lc``; - * for every error with non-empty ``readout_flips``, - ``residual ^= {rows flipped by lc · readout_flips}``. - - No-op when ``logical_correction`` is already empty. - """ - base = gt.base - lc = base.logical_correction - if not lc.i: - return - - rp = base.readout_propagation - readouts = list(base.readouts) - - cp = bitmatrix_of(base.correction_propagation) - pc = bitmatrix_of(base.physical_correction) - - rp_cols_by_readout: dict[int, set[int]] = {} - for r, c in zip(rp.i, rp.j): - rp_cols_by_readout.setdefault(r, set()).add(c) - - rows_by_readout: dict[int, set[int]] = {} - - for out_row, readout_idx in zip(lc.i, lc.j): - rows_by_readout.setdefault(readout_idx, set()).add(out_row) - for in_col in rp_cols_by_readout.get(readout_idx, ()): - cp[out_row, in_col] ^= True - for meas in readouts[readout_idx].measurement_indices: - pc[out_row, meas] ^= True - - for err in gt.errors: - if not err.base.readout_flips: - continue - residual: set[int] = set(err.base.residual) - for readout_idx in err.base.readout_flips: - residual.symmetric_difference_update( - rows_by_readout.get(readout_idx, ()) - ) - del err.base.residual[:] - err.base.residual.extend(sorted(residual)) - - base.correction_propagation.CopyFrom(bitmatrix_to_proto(cp)) - base.physical_correction.CopyFrom(bitmatrix_to_proto(pc)) - base.logical_correction.CopyFrom(util_pb.BitMatrix(rows=lc.rows, cols=lc.cols)) - - -def absorb_logical_correction_library(lib: jit_pb.JitLibrary) -> None: - """Apply :func:`absorb_logical_correction` to every gadget type in *lib*. - - Convenience wrapper for round-trip equivalence checks that operate - on whole :class:`JitLibrary` protos. - """ - for gt in lib.gadget_types: - absorb_logical_correction(gt) - - # =================================================================== # merge() — merge a subset of gadgets into a single MergedGadget # =================================================================== diff --git a/deq/documents/tutorial/chapters/compose-repropagate.md b/deq/documents/tutorial/chapters/compose-repropagate.md index 25efc47d..64d67b0d 100644 --- a/deq/documents/tutorial/chapters/compose-repropagate.md +++ b/deq/documents/tutorial/chapters/compose-repropagate.md @@ -252,7 +252,7 @@ Two ways to add the correction back: 1. `@REPROPAGATE` — swap the propagation strategy to circuit-flow analysis on the flat inlined body. The next section shows this in full. 2. Write an explicit `CONDITIONAL rec[-k] ` inside the COMPOSE body. - The canonicalizer's `absorb_logical_correction` step folds that CONDITIONAL into + The canonicalizer's merge pass (step 9) folds that CONDITIONAL into cp/pc, producing the same binary as `@REPROPAGATE`. Concretely, replacing the plain COMPOSE with From a970af964ec2d8e2a6f1b464edb6bc5b66ba4a9f Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 11 Jul 2026 14:50:36 -0700 Subject: [PATCH 036/157] improve spec coverage to 100% --- deq/deq/spec/canonical.py | 104 +++++----- deq/tests/spec/canonical_test.py | 209 ++++++++++++++++++++- deq/tests/spec/library_equivalence_test.py | 25 +++ deq/tests/spec/library_validator_test.py | 24 +++ deq/tests/spec/program_validator_test.py | 74 ++++++++ 5 files changed, 387 insertions(+), 49 deletions(-) diff --git a/deq/deq/spec/canonical.py b/deq/deq/spec/canonical.py index 21d58bb5..13efb954 100644 --- a/deq/deq/spec/canonical.py +++ b/deq/deq/spec/canonical.py @@ -257,7 +257,7 @@ class MergedError: residual: list[int] readout_flips: list[int] finished_checks: list[int] - unfinished_checks: list[int] + output_boundary_checks: list[int] tag: str = "" @@ -299,7 +299,10 @@ class MergedGadget: logical_correction: util_pb.BitMatrix physical_correction: util_pb.BitMatrix finished_checks: list[MergedCheck] - unfinished_checks: list[MergedCheck] + # Checks that reference a measurement on a non-merge gadget sitting + # on the merge's output boundary (see ``output_side_gids`` in + # ``merge()``). + output_boundary_checks: list[MergedCheck] errors: list[MergedError] # Traceability maps (local → global within the merged gadget) measurement_map: "Bijection[MeasurementIndex]" @@ -358,7 +361,9 @@ def _to_jit_check(mc: MergedCheck) -> jit_pb.JitGadgetType.Check: probability=me.probability, ), finished_checks=me.finished_checks, - unfinished_checks=me.unfinished_checks, + # ``output_boundary_checks`` maps to the jit_pb + # proto's ``unfinished_checks`` + unfinished_checks=me.output_boundary_checks, ) ) @@ -377,7 +382,11 @@ def _to_jit_check(mc: MergedCheck) -> jit_pb.JitGadgetType.Check: return jit_pb.JitGadgetType( base=base, finished_checks=[_to_jit_check(c) for c in self.finished_checks], - unfinished_checks=[_to_jit_check(c) for c in self.unfinished_checks], + # ``output_boundary_checks`` maps to the jit_pb proto's + # ``unfinished_checks`` + unfinished_checks=[ + _to_jit_check(c) for c in self.output_boundary_checks + ], errors=jit_errors, ) @@ -385,14 +394,14 @@ def to_canonical_form(self) -> CanonicalForm: """Convert to a ``CanonicalForm``. This is only valid when the merged gadget has no input ports and no - unfinished checks (i.e. all gadgets in the circuit were merged). + output-boundary checks (i.e. all gadgets in the circuit were merged). """ assert ( not self.input_ptypes ), "cannot convert to CanonicalForm: merged gadget has input ports" assert ( - not self.unfinished_checks - ), "cannot convert to CanonicalForm: merged gadget has unfinished checks" + not self.output_boundary_checks + ), "cannot convert to CanonicalForm: merged gadget has output-boundary checks" canonical = CanonicalForm() canonical.observable_map = self.observable_map @@ -625,19 +634,21 @@ def merge( ] matrices = propagator.expanded_matrices[gid] - col_to_global_readout: list[int | None] = [] + col_to_global_readout: list[int] = [] for local_readout in expanded_readouts: - if local_readout in readout_map.atob: - col_to_global_readout.append( - readout_map.atob[local_readout].readout_index + if local_readout not in readout_map.atob: + raise ValueError( + f"remote_conditional_correction on merge-set gid={gid} " + f"references readout {local_readout} on a gadget outside " + f"the merge set; the conditional correction would be " + f"silently lost in the merged form" ) - else: - col_to_global_readout.append(None) + col_to_global_readout.append( + readout_map.atob[local_readout].readout_index + ) for row, col in zip(remote_cc.correction.i, remote_cc.correction.j): global_readout_idx = col_to_global_readout[col] - if global_readout_idx is None: - continue out_local = matrices.output_observables[row] out_global = out_local.to_global(gid) global_obs_set = propagator.out_to_residual.get(out_global, set()) @@ -656,10 +667,11 @@ def merge( pc = gadget_type.physical_correction for row_local, col_local in zip(pc.i, pc.j): - # col_local is a local measurement index → remap to global + # col_local is a local measurement index → remap to global. + # Every merge-set gadget's own measurements were registered in + # step 2, so ``local_m`` is always present here. local_m = MeasurementIndex(gid=gid, measurement_index=col_local) - if local_m not in measurement_map.atob: - continue + assert local_m in measurement_map.atob global_m = measurement_map.atob[local_m].measurement_index # row_local is a local output observable index → trace to global @@ -755,9 +767,6 @@ def merge( ) col_to_remote_meas: list[set[int]] = [] for local_readout in expanded_readouts: - if local_readout not in readout_map.atob: - col_to_remote_meas.append(set()) - continue remote_gid = local_readout.gid remote_readout_idx = local_readout.readout_index remote_gadget = program.gadgets[remote_gid] @@ -791,8 +800,6 @@ def merge( col_to_remote_meas.append(remote_meas_set) for row, col in zip(remote_cc.correction.i, remote_cc.correction.j): - if col >= len(col_to_remote_meas): - continue out_local = matrices.output_observables[row] key = (gid, out_local.port, out_local.observable_index) obs_meas_deps[key] ^= col_to_remote_meas[col] @@ -842,9 +849,10 @@ def merge( # ── 7. Build checks ────────────────────────────────────────────── check_map: Bijection[CheckIndex] = Bijection() finished_checks: list[MergedCheck] = [] - unfinished_checks: list[MergedCheck] = [] - # unfinished checks are keyed by (gid, measurement_index) for output-virtual - unfinished_by_key: dict[tuple[int, int], int] = {} + output_boundary_checks: list[MergedCheck] = [] + # output-boundary checks are keyed by (gid, measurement_index) of the + # output-virtual measurement that made the check output-boundary. + output_boundary_by_key: dict[tuple[int, int], int] = {} # Build output-side gid set: non-merge gadgets connected to merge output ports. output_side_gids: set[int] = set() @@ -867,7 +875,7 @@ def _resolve_measurement_ref( Returns (ref, None) for real/input-virtual measurements, or (None, (out_port_idx, stab_idx)) for output-virtual measurements - that make the check unfinished. + that make the containing check an output-boundary check. """ local_m = MeasurementIndex(gid=gid, measurement_index=measurement_index) if local_m in measurement_map.atob: @@ -890,12 +898,13 @@ def _resolve_measurement_ref( ), None, ) - # Output-side: find which output port connects to this gadget - # This measurement makes the check unfinished + # Output-side: find which output port connects to this gadget. + # This measurement is output-virtual at the merge boundary and + # promotes the containing check to an output-boundary check. if gid in output_side_gids: return None, (gid, measurement_index) # Should not reach here in a well-formed circuit - raise ValueError( + raise ValueError( # pragma: no cover f"measurement (gid={gid}, idx={measurement_index}) is from a " f"non-merge gadget that is neither input-side nor output-side" ) @@ -950,8 +959,9 @@ def _resolve_measurement_ref( refs.append(ref) else: assert ov_key is not None - # Output-virtual: this check becomes unfinished. - # There should be at most one OV measurement per check. + # Output-virtual: this check becomes an output-boundary + # check. There should be at most one OV measurement per + # check. output_virtual_key = ov_key mc = MergedCheck( @@ -959,10 +969,13 @@ def _resolve_measurement_ref( naturally_flipped=check.naturally_flipped, ) if output_virtual_key is not None: - # Unfinished check — keyed for later lookup - idx = len(unfinished_checks) - unfinished_by_key[output_virtual_key] = idx - unfinished_checks.append(mc) + # Output-boundary check — keyed by the OV measurement for + # later lookup; encoded in the check_map with a negative + # index so step 8's error dispatch can distinguish it from + # finished checks. + idx = len(output_boundary_checks) + output_boundary_by_key[output_virtual_key] = idx + output_boundary_checks.append(mc) global_ci = CheckIndex(cid=1, check_index=-(idx + 1)) check_map.add(local_ci, global_ci, unique=False) else: @@ -989,7 +1002,7 @@ def _resolve_measurement_ref( local_ei = ErrorIndex(eid=eid, error_index=error_index) fr: list[int] = [] - ur: list[int] = [] + br: list[int] = [] # output-boundary check refs for c in error.checks: err_remote_cid: int = error_model.cid err_remote_check_index = c.check_index @@ -1001,13 +1014,11 @@ def _resolve_measurement_ref( local_ci = CheckIndex( cid=err_remote_cid, check_index=err_remote_check_index ) - if local_ci not in check_map.atob: - continue global_ci = check_map.atob[local_ci] if global_ci.check_index >= 0: fr.append(global_ci.check_index) else: - ur.append(-(global_ci.check_index + 1)) + br.append(-(global_ci.check_index + 1)) residual: set[int] = set() readout_flips: set[int] = set() @@ -1021,7 +1032,7 @@ def _resolve_measurement_ref( err_global_ri = readout_map.atob[err_local_ri] readout_flips ^= {err_global_ri.readout_index} - if not fr and not ur and not residual and not readout_flips: + if not fr and not br and not residual and not readout_flips: continue global_ei = ErrorIndex(eid=1, error_index=len(error_map)) @@ -1032,7 +1043,7 @@ def _resolve_measurement_ref( residual=sorted(residual), readout_flips=sorted(readout_flips), finished_checks=sorted(fr), - unfinished_checks=sorted(ur), + output_boundary_checks=sorted(br), ) ) @@ -1130,7 +1141,7 @@ def _resolve_measurement_ref( logical_correction=logical_correction, physical_correction=physical_correction, finished_checks=finished_checks, - unfinished_checks=unfinished_checks, + output_boundary_checks=output_boundary_checks, errors=merged_errors, measurement_map=measurement_map, observable_map=observable_map, @@ -1320,11 +1331,8 @@ def _expand( local_obs = ObservableIndex( gid=gid, port=output_index, observable_index=obs_idx ) - if local_obs in observable_map.atob: - global_obs = observable_map.atob[local_obs] - self.out_to_residual[local_obs] = {global_obs.observable_index} - else: - self.out_to_residual[local_obs] = set() + global_obs = observable_map.atob[local_obs] + self.out_to_residual[local_obs] = {global_obs.observable_index} self.out_to_readout[local_obs] = set() diff --git a/deq/tests/spec/canonical_test.py b/deq/tests/spec/canonical_test.py index 1468b1d4..aab9a279 100644 --- a/deq/tests/spec/canonical_test.py +++ b/deq/tests/spec/canonical_test.py @@ -1,8 +1,10 @@ +import pytest + import deq.proto.deq_bin_pb2 as pb import deq.proto.util_pb2 as util_pb from deq.spec.program_validator import is_valid from deq.spec.library_equivalence import are_libraries_equivalent -from deq.spec.canonical import canonicalize, canonical_program +from deq.spec.canonical import canonicalize, canonical_program, merge from tests.spec.library_validator_test import default_library # pylint: disable=no-member @@ -750,3 +752,208 @@ def test_canonical_remote_conditional_correction_multiple_gadgets() -> None: "absorbed from the two readout references in the remote conditional " "correction" ) + + +def test_apply_bitmatrix_modifier_none_returns_original() -> None: + """``apply_bitmatrix_modifier(m, None)`` short-circuits to *m*.""" + from deq.spec.common import apply_bitmatrix_modifier + original = util_pb.BitMatrix(rows=2, cols=3, i=[0], j=[1]) + assert apply_bitmatrix_modifier(original, None) is original + + +def test_merge_absorbs_lc_into_error_residual_via_readout_flips() -> None: + """Step 9(c): when ``cc_set`` and an error's ``readout_flips`` both + reference the same readout, the affected output rows are XORed into + the error's ``residual``.""" + lib = pb.Library( + port_types=[pb.PortType(ptype=1, observables=[pb.PortType.Observable()])], + gadget_types=[ + pb.GadgetType( + gtype=1, + measurements=[pb.GadgetType.Measurement()], + outputs=[pb.GadgetType.Port(ptype=1)], + readouts=[pb.GadgetType.Readout(measurement_indices=[0])], + correction_propagation=util_pb.BitMatrix(rows=1, cols=1), + readout_propagation=util_pb.BitMatrix(rows=1, cols=1), + # R0 flips output observable 0 via logical_correction. + logical_correction=util_pb.BitMatrix(rows=1, cols=1, i=[0], j=[0]), + physical_correction=util_pb.BitMatrix(rows=1, cols=1), + ) + ], + check_model_types=[pb.CheckModelType(ctype=1, gtype=1, checks=[])], + error_model_types=[ + pb.ErrorModelType( + etype=1, + ctype=1, + errors=[pb.ErrorModelType.Error(probability=0.1, readout_flips=[0])], + ) + ], + program=[ + pb.Instruction(gadget=pb.Gadget(gtype=1)), + pb.Instruction(check_model=pb.CheckModel(ctype=1, gid=1)), + pb.Instruction(error_model=pb.ErrorModel(etype=1, cid=1)), + ], + ) + canonical_form = canonicalize(lib) + # Absorption folded row 0 into the error's residual; readout_flips is + # preserved (the runtime still XORs the readout bit into residual, and + # the pre-image row is what got added by step 9(c)). + err = canonical_form.error_model_type.errors[0] + assert list(err.residual) == [0] + assert list(err.readout_flips) == [0] + # And the merged logical_correction is empty by design. + assert canonical_form.gadget_type.logical_correction.rows == 1 + assert not canonical_form.gadget_type.logical_correction.i + + +def test_partial_merge_conditional_readout_out_of_set_raises() -> None: + """Partial merge: gadget in the merge set carries a + ``remote_conditional_correction`` referencing a readout on a gadget + outside the merge set. Silently dropping the correction would + change program semantics, so ``merge()`` raises instead. + """ + lib = pb.Library( + port_types=[pb.PortType(ptype=1, observables=[pb.PortType.Observable()])], + gadget_types=[ + pb.GadgetType( + gtype=1, + measurements=[pb.GadgetType.Measurement()], + outputs=[pb.GadgetType.Port(ptype=1)], + readouts=[pb.GadgetType.Readout(measurement_indices=[0])], + correction_propagation=util_pb.BitMatrix(rows=1, cols=1), + readout_propagation=util_pb.BitMatrix(rows=1, cols=1), + logical_correction=util_pb.BitMatrix(rows=1, cols=1), + physical_correction=util_pb.BitMatrix(rows=1, cols=1), + ), + pb.GadgetType( + gtype=2, + measurements=[pb.GadgetType.Measurement()], + inputs=[pb.GadgetType.Port(ptype=1)], + outputs=[pb.GadgetType.Port(ptype=1)], + correction_propagation=util_pb.BitMatrix(rows=1, cols=2), + logical_correction=util_pb.BitMatrix(rows=1, cols=0), + physical_correction=util_pb.BitMatrix(rows=1, cols=1), + ), + ], + check_model_types=[pb.CheckModelType(ctype=1, gtype=2, checks=[])], + error_model_types=[pb.ErrorModelType(etype=1, ctype=1, errors=[])], + program=[ + pb.Instruction(gadget=pb.Gadget(gtype=1)), + pb.Instruction( + gadget=pb.Gadget( + gtype=2, + connectors=[pb.Gadget.Connector(gid=1, port=0)], + modifier=pb.GadgetModifier( + remote_conditional_correction=pb.RemoteConditionalCorrection( + remote_readouts=[ + pb.RemoteConditionalCorrection.RemoteReadout( + gid=1, readout_index=0 + ) + ], + correction=util_pb.BitMatrix(rows=1, cols=1, i=[0], j=[0]), + ) + ), + ) + ), + pb.Instruction(check_model=pb.CheckModel(ctype=1, gid=2)), + pb.Instruction(error_model=pb.ErrorModel(etype=1, cid=1)), + ], + ) + assert is_valid(lib) + # Merge only gadget 2; gadget 1 (the readout host) is outside the merge. + with pytest.raises(ValueError, match="outside the merge set"): + merge(lib, {2}) + + +def test_partial_merge_error_references_unfinished_check() -> None: + """Step 8: an error inside the merge set references a check + whose measurements span an output-side (non-merge) gadget, so the + check becomes an output-boundary check and the error takes the + ``br.append`` branch of the check-index dispatch. + """ + # Three-gadget chain A → B → C, merge = {A, B}. A's check model + # references a measurement on C (via remote_gadget=output), making + # that check an output-boundary check on the merge boundary. A's + # error model references that check. + lib = pb.Library( + port_types=[pb.PortType(ptype=1, observables=[pb.PortType.Observable()])], + gadget_types=[ + pb.GadgetType( + gtype=1, + measurements=[pb.GadgetType.Measurement()], + inputs=[pb.GadgetType.Port(ptype=1)], + outputs=[pb.GadgetType.Port(ptype=1)], + correction_propagation=util_pb.BitMatrix(rows=1, cols=2), + physical_correction=util_pb.BitMatrix(rows=1, cols=1), + ), + pb.GadgetType( + gtype=2, # boundary gadget with its own measurement + measurements=[pb.GadgetType.Measurement()], + inputs=[pb.GadgetType.Port(ptype=1)], + correction_propagation=util_pb.BitMatrix(rows=0, cols=2), + physical_correction=util_pb.BitMatrix(rows=0, cols=1), + ), + pb.GadgetType( + gtype=3, # source + measurements=[pb.GadgetType.Measurement()], + outputs=[pb.GadgetType.Port(ptype=1)], + correction_propagation=util_pb.BitMatrix(rows=1, cols=1), + physical_correction=util_pb.BitMatrix(rows=1, cols=1), + ), + ], + check_model_types=[ + pb.CheckModelType( + ctype=1, + gtype=1, # bound to gadget A + remote_gadgets=[ + # A's output goes to C (gtype=2). + pb.CheckModelType.RemoteGadget(output=0, expecting_gtype=2), + ], + checks=[ + pb.CheckModelType.Check( + measurements=[ + # A's own measurement and C's measurement. + pb.CheckModelType.RemoteMeasurement(measurement_index=0), + pb.CheckModelType.RemoteMeasurement(remote_gadget=0), + ] + ), + ], + ) + ], + error_model_types=[ + pb.ErrorModelType( + etype=1, + ctype=1, + errors=[ + pb.ErrorModelType.Error( + probability=0.1, + checks=[pb.ErrorModelType.RemoteCheck(check_index=0)], + ) + ], + ) + ], + program=[ + pb.Instruction(gadget=pb.Gadget(gtype=3)), # gid=1 (source) + pb.Instruction( + gadget=pb.Gadget( + gtype=1, connectors=[pb.Gadget.Connector(gid=1, port=0)] + ) + ), # gid=2 (gadget A) + pb.Instruction( + gadget=pb.Gadget( + gtype=2, connectors=[pb.Gadget.Connector(gid=2, port=0)] + ) + ), # gid=3 (gadget C, output side) + pb.Instruction(check_model=pb.CheckModel(ctype=1, gid=2)), + pb.Instruction(error_model=pb.ErrorModel(etype=1, cid=1)), + ], + ) + assert is_valid(lib) + # Merge only gid=1 (source) and gid=2 (A); gid=3 (C) stays outside. + merged = merge(lib, {1, 2}) + # The check touched C's measurement → became an output-boundary check. + # The error took the ``br.append`` branch of the check-index dispatch. + assert len(merged.output_boundary_checks) == 1 + assert len(merged.errors) == 1 + assert merged.errors[0].output_boundary_checks == [0] + assert not merged.errors[0].finished_checks diff --git a/deq/tests/spec/library_equivalence_test.py b/deq/tests/spec/library_equivalence_test.py index 20411eaa..208ff8ab 100644 --- a/deq/tests/spec/library_equivalence_test.py +++ b/deq/tests/spec/library_equivalence_test.py @@ -576,3 +576,28 @@ def test_library_equivalence_5_3_4_absolute_cid() -> None: ], ), ) + + +def test_library_equivalence_3_9() -> None: + """LibEq 3.9: two libs differing only in physical_correction are non-equivalent.""" + assert "(LibEq 3.9) physical correction nonequivalent" in are_libraries_equivalent( + library2, + pb.Library( + port_types=library2.port_types, + gadget_types=[ + pb.GadgetType( + gtype=1, + measurements=gadget_type_2_1.measurements, + inputs=gadget_type_2_1.inputs, + outputs=gadget_type_2_1.outputs, + readouts=gadget_type_2_1.readouts, + correction_propagation=gadget_type_2_1.correction_propagation, + readout_propagation=gadget_type_2_1.readout_propagation, + logical_correction=gadget_type_2_1.logical_correction, + physical_correction=util_pb.BitMatrix( + rows=2, cols=2, i=[0], j=[0] # different from library2 + ), + ), + ], + ), + ) diff --git a/deq/tests/spec/library_validator_test.py b/deq/tests/spec/library_validator_test.py index d8954ce0..2666be28 100644 --- a/deq/tests/spec/library_validator_test.py +++ b/deq/tests/spec/library_validator_test.py @@ -785,3 +785,27 @@ def test_library_validity_4_3_6_absolute_cid() -> None: ], ) ) + + +def test_library_validity_2_11() -> None: + """LibSpec 2.11: physical_correction shape must match |output_obs| x |measurements|.""" + + def physical_correction_tester(matrix: util_pb.BitMatrix) -> Violations | ExpandedProgram: + gadget_type = pb.GadgetType() + gadget_type.CopyFrom(default_library.gadget_types[2]) + gadget_type.outputs.MergeFrom([pb.GadgetType.Port(ptype=2)]) + gadget_type.correction_propagation.CopyFrom(util_pb.BitMatrix(rows=2, cols=3)) + gadget_type.logical_correction.CopyFrom(util_pb.BitMatrix(rows=2, cols=1)) + gadget_type.physical_correction.CopyFrom(matrix) + return is_valid( + pb.Library( + port_types=default_library.port_types, + gadget_types=[gadget_type], + ) + ) + + assert physical_correction_tester(util_pb.BitMatrix(rows=2, cols=3)) + assert ( + "(LibSpec 2.11)", + "matrix dimensions differ", + ) in physical_correction_tester(util_pb.BitMatrix(rows=3, cols=3)) diff --git a/deq/tests/spec/program_validator_test.py b/deq/tests/spec/program_validator_test.py index 58d7eeaf..aca8f78d 100644 --- a/deq/tests/spec/program_validator_test.py +++ b/deq/tests/spec/program_validator_test.py @@ -1491,3 +1491,77 @@ def test_program_validity_2_4_success_remote_reference() -> None: assert result, f"Expected valid program but got: {result}" assert isinstance(result, ExpandedProgram) assert 2 in result.expanded_remote_conditional_corrections + + +def test_program_validity_gadget_modifier_logical_and_physical_correction_valid() -> None: + """``logical_correction_mod`` and ``physical_correction_mod`` apply cleanly + to their base matrices when the modifier dimensions match.""" + lib = pb.Library( + port_types=[pb.PortType(ptype=1, observables=[pb.PortType.Observable()])], + gadget_types=[ + pb.GadgetType( + gtype=1, + measurements=[pb.GadgetType.Measurement()], + outputs=[pb.GadgetType.Port(ptype=1)], + readouts=[pb.GadgetType.Readout(measurement_indices=[0])], + correction_propagation=util_pb.BitMatrix(rows=1, cols=1), + readout_propagation=util_pb.BitMatrix(rows=1, cols=1), + logical_correction=util_pb.BitMatrix(rows=1, cols=1), + physical_correction=util_pb.BitMatrix(rows=1, cols=1), + ), + ], + check_model_types=[pb.CheckModelType(ctype=1, checks=[])], + error_model_types=[pb.ErrorModelType(etype=1, errors=[])], + program=[ + pb.Instruction( + gadget=pb.Gadget( + gtype=1, + modifier=pb.GadgetModifier( + logical_correction_mod=pb.BitMatrixModifier( + toggle=util_pb.BitMatrix(rows=1, cols=1, i=[0], j=[0]), + ), + physical_correction_mod=pb.BitMatrixModifier( + toggle=util_pb.BitMatrix(rows=1, cols=1, i=[0], j=[0]), + ), + ), + ) + ), + pb.Instruction(check_model=pb.CheckModel(ctype=1, gid=1)), + pb.Instruction(error_model=pb.ErrorModel(etype=1, cid=1)), + ], + ) + result = is_valid(lib) + assert isinstance(result, ExpandedProgram) + modified = result.modified_gadget_types[1] + assert list(zip(modified.logical_correction.i, modified.logical_correction.j)) == [(0, 0)] + assert list(zip(modified.physical_correction.i, modified.physical_correction.j)) == [(0, 0)] + + +def test_program_validity_4_3_3_sparse_probabilities_valid() -> None: + """Valid ``sparse_probabilities`` (each in [0,1]) applies without violation.""" + lib_error_type = default_library.error_model_types[0] + valid_index = 0 + assert 0 <= valid_index < len(lib_error_type.errors) + result = is_valid( + pb.Library( + **common, + program=[ + *gadgets, + *check_models, + pb.Instruction( + error_model=pb.ErrorModel( + etype=1, + cid=1, + modifier=pb.ErrorModel.ErrorModelModifier( + probability_modifier=pb.ProbabilityModifier( + sparse_indices=[valid_index], + sparse_probabilities=[0.25], + ) + ), + ) + ), + ], + ) + ) + assert isinstance(result, ExpandedProgram) + assert result.modified_error_model_types[1].errors[valid_index].probability == 0.25 From 0cbb1a9e935c85fab920183fcaca7abd08b4e1c5 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 11 Jul 2026 14:54:38 -0700 Subject: [PATCH 037/157] revert renaming --- deq/deq/spec/canonical.py | 54 ++++++++++++++------------------ deq/tests/spec/canonical_test.py | 16 +++++----- 2 files changed, 32 insertions(+), 38 deletions(-) diff --git a/deq/deq/spec/canonical.py b/deq/deq/spec/canonical.py index 13efb954..0ed202ed 100644 --- a/deq/deq/spec/canonical.py +++ b/deq/deq/spec/canonical.py @@ -257,7 +257,7 @@ class MergedError: residual: list[int] readout_flips: list[int] finished_checks: list[int] - output_boundary_checks: list[int] + unfinished_checks: list[int] tag: str = "" @@ -302,7 +302,7 @@ class MergedGadget: # Checks that reference a measurement on a non-merge gadget sitting # on the merge's output boundary (see ``output_side_gids`` in # ``merge()``). - output_boundary_checks: list[MergedCheck] + unfinished_checks: list[MergedCheck] errors: list[MergedError] # Traceability maps (local → global within the merged gadget) measurement_map: "Bijection[MeasurementIndex]" @@ -361,9 +361,7 @@ def _to_jit_check(mc: MergedCheck) -> jit_pb.JitGadgetType.Check: probability=me.probability, ), finished_checks=me.finished_checks, - # ``output_boundary_checks`` maps to the jit_pb - # proto's ``unfinished_checks`` - unfinished_checks=me.output_boundary_checks, + unfinished_checks=me.unfinished_checks, ) ) @@ -382,11 +380,7 @@ def _to_jit_check(mc: MergedCheck) -> jit_pb.JitGadgetType.Check: return jit_pb.JitGadgetType( base=base, finished_checks=[_to_jit_check(c) for c in self.finished_checks], - # ``output_boundary_checks`` maps to the jit_pb proto's - # ``unfinished_checks`` - unfinished_checks=[ - _to_jit_check(c) for c in self.output_boundary_checks - ], + unfinished_checks=[_to_jit_check(c) for c in self.unfinished_checks], errors=jit_errors, ) @@ -394,14 +388,14 @@ def to_canonical_form(self) -> CanonicalForm: """Convert to a ``CanonicalForm``. This is only valid when the merged gadget has no input ports and no - output-boundary checks (i.e. all gadgets in the circuit were merged). + unfinished checks (i.e. all gadgets in the circuit were merged). """ assert ( not self.input_ptypes ), "cannot convert to CanonicalForm: merged gadget has input ports" assert ( - not self.output_boundary_checks - ), "cannot convert to CanonicalForm: merged gadget has output-boundary checks" + not self.unfinished_checks + ), "cannot convert to CanonicalForm: merged gadget has unfinished checks" canonical = CanonicalForm() canonical.observable_map = self.observable_map @@ -849,10 +843,10 @@ def merge( # ── 7. Build checks ────────────────────────────────────────────── check_map: Bijection[CheckIndex] = Bijection() finished_checks: list[MergedCheck] = [] - output_boundary_checks: list[MergedCheck] = [] - # output-boundary checks are keyed by (gid, measurement_index) of the - # output-virtual measurement that made the check output-boundary. - output_boundary_by_key: dict[tuple[int, int], int] = {} + unfinished_checks: list[MergedCheck] = [] + # unfinished checks are keyed by (gid, measurement_index) of the + # output-virtual measurement that made the check unfinished. + unfinished_by_key: dict[tuple[int, int], int] = {} # Build output-side gid set: non-merge gadgets connected to merge output ports. output_side_gids: set[int] = set() @@ -875,7 +869,7 @@ def _resolve_measurement_ref( Returns (ref, None) for real/input-virtual measurements, or (None, (out_port_idx, stab_idx)) for output-virtual measurements - that make the containing check an output-boundary check. + that make the containing check unfinished. """ local_m = MeasurementIndex(gid=gid, measurement_index=measurement_index) if local_m in measurement_map.atob: @@ -900,7 +894,7 @@ def _resolve_measurement_ref( ) # Output-side: find which output port connects to this gadget. # This measurement is output-virtual at the merge boundary and - # promotes the containing check to an output-boundary check. + # makes the containing check unfinished. if gid in output_side_gids: return None, (gid, measurement_index) # Should not reach here in a well-formed circuit @@ -969,13 +963,13 @@ def _resolve_measurement_ref( naturally_flipped=check.naturally_flipped, ) if output_virtual_key is not None: - # Output-boundary check — keyed by the OV measurement for + # Unfinished check — keyed by the OV measurement for # later lookup; encoded in the check_map with a negative - # index so step 8's error dispatch can distinguish it from - # finished checks. - idx = len(output_boundary_checks) - output_boundary_by_key[output_virtual_key] = idx - output_boundary_checks.append(mc) + # index so step 8's error dispatch can distinguish it + # from finished checks. + idx = len(unfinished_checks) + unfinished_by_key[output_virtual_key] = idx + unfinished_checks.append(mc) global_ci = CheckIndex(cid=1, check_index=-(idx + 1)) check_map.add(local_ci, global_ci, unique=False) else: @@ -1002,7 +996,7 @@ def _resolve_measurement_ref( local_ei = ErrorIndex(eid=eid, error_index=error_index) fr: list[int] = [] - br: list[int] = [] # output-boundary check refs + ur: list[int] = [] for c in error.checks: err_remote_cid: int = error_model.cid err_remote_check_index = c.check_index @@ -1018,7 +1012,7 @@ def _resolve_measurement_ref( if global_ci.check_index >= 0: fr.append(global_ci.check_index) else: - br.append(-(global_ci.check_index + 1)) + ur.append(-(global_ci.check_index + 1)) residual: set[int] = set() readout_flips: set[int] = set() @@ -1032,7 +1026,7 @@ def _resolve_measurement_ref( err_global_ri = readout_map.atob[err_local_ri] readout_flips ^= {err_global_ri.readout_index} - if not fr and not br and not residual and not readout_flips: + if not fr and not ur and not residual and not readout_flips: continue global_ei = ErrorIndex(eid=1, error_index=len(error_map)) @@ -1043,7 +1037,7 @@ def _resolve_measurement_ref( residual=sorted(residual), readout_flips=sorted(readout_flips), finished_checks=sorted(fr), - output_boundary_checks=sorted(br), + unfinished_checks=sorted(ur), ) ) @@ -1141,7 +1135,7 @@ def _resolve_measurement_ref( logical_correction=logical_correction, physical_correction=physical_correction, finished_checks=finished_checks, - output_boundary_checks=output_boundary_checks, + unfinished_checks=unfinished_checks, errors=merged_errors, measurement_map=measurement_map, observable_map=observable_map, diff --git a/deq/tests/spec/canonical_test.py b/deq/tests/spec/canonical_test.py index aab9a279..3904d8c3 100644 --- a/deq/tests/spec/canonical_test.py +++ b/deq/tests/spec/canonical_test.py @@ -868,13 +868,13 @@ def test_partial_merge_conditional_readout_out_of_set_raises() -> None: def test_partial_merge_error_references_unfinished_check() -> None: """Step 8: an error inside the merge set references a check whose measurements span an output-side (non-merge) gadget, so the - check becomes an output-boundary check and the error takes the - ``br.append`` branch of the check-index dispatch. + check resolves to an unfinished check and the error takes the + ``ur.append`` branch of the check-index dispatch. """ # Three-gadget chain A → B → C, merge = {A, B}. A's check model # references a measurement on C (via remote_gadget=output), making - # that check an output-boundary check on the merge boundary. A's - # error model references that check. + # that check unfinished on the merge boundary. A's error model + # references that unfinished check. lib = pb.Library( port_types=[pb.PortType(ptype=1, observables=[pb.PortType.Observable()])], gadget_types=[ @@ -951,9 +951,9 @@ def test_partial_merge_error_references_unfinished_check() -> None: assert is_valid(lib) # Merge only gid=1 (source) and gid=2 (A); gid=3 (C) stays outside. merged = merge(lib, {1, 2}) - # The check touched C's measurement → became an output-boundary check. - # The error took the ``br.append`` branch of the check-index dispatch. - assert len(merged.output_boundary_checks) == 1 + # The check touched C's measurement → became unfinished. The error + # took the ``ur.append`` branch of the check-index dispatch. + assert len(merged.unfinished_checks) == 1 assert len(merged.errors) == 1 - assert merged.errors[0].output_boundary_checks == [0] + assert merged.errors[0].unfinished_checks == [0] assert not merged.errors[0].finished_checks From d7f2cd5fe2276d79937375e1696efea45cee6395 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 11 Jul 2026 16:45:05 -0700 Subject: [PATCH 038/157] simplifiy doc --- deq/deq/transpiler/compose_builder.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/deq/deq/transpiler/compose_builder.py b/deq/deq/transpiler/compose_builder.py index b22186d5..20dbb56e 100644 --- a/deq/deq/transpiler/compose_builder.py +++ b/deq/deq/transpiler/compose_builder.py @@ -1519,19 +1519,6 @@ def emit_conditional_correction_instruction( Mutates ``identity_gtype_of_ptype`` (registering the gtype on first use of each port type). - - The caller is responsible for: - - * resolving ``conditional.wire`` to ``wire_ptype`` / ``wire_source`` - and raising any "wire has no producer" error *before* calling; - * verifying ``wire_ptype`` actually appears in - ``port_types_by_ptype``; - * updating its own wire bookkeeping after the call so subsequent - connectors reference ``(gid, 0)``. - - ``error_context`` is a free-form prefix used in raised - :class:`ValueError` messages (e.g. ``"PROGRAM 'foo'"`` or - ``"COMPOSE 'bar'"``). """ from deq.transpiler.jit_library_builder import pauli_to_observable_flips From 529fde938ed94cd44323bf8a62de44a6042bf86d Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 11 Jul 2026 17:21:02 -0700 Subject: [PATCH 039/157] simplify unnecessary if --- deq/deq/transpiler/jit_annotate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deq/deq/transpiler/jit_annotate.py b/deq/deq/transpiler/jit_annotate.py index ac80d12b..b07dfe74 100644 --- a/deq/deq/transpiler/jit_annotate.py +++ b/deq/deq/transpiler/jit_annotate.py @@ -759,7 +759,7 @@ def _format_propagation_comment( *layout* provides the column-to-observable mapping and stabilizer generator indices for correct multi-port rendering. """ - affine_col = propagation.cols - 1 if propagation.cols > 0 else -1 + affine_col = propagation.cols - 1 row_cols = set(bitmatrix_of(propagation).rows[row_index].support) has_affine = affine_col in row_cols cols_set = row_cols - {affine_col} @@ -1120,7 +1120,7 @@ def _render_composed_gadget( # walker_cols XOR diff = binary_cols on re-parse. prop = base.readout_propagation input_col_layout = PortColumnLayout(input_ports, codes) - affine_col = prop.cols - 1 if prop.cols > 0 else -1 + affine_col = prop.cols - 1 binary_rp_cols_by_row: dict[int, set[int]] = {} for r, c in zip(prop.i, prop.j): binary_rp_cols_by_row.setdefault(r, set()).add(c) From c235c49a5550554e494c0c755e9a280c3c8c9819 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 11 Jul 2026 19:22:17 -0700 Subject: [PATCH 040/157] simplify function --- deq/deq/transpiler/jit_library_builder.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/deq/deq/transpiler/jit_library_builder.py b/deq/deq/transpiler/jit_library_builder.py index d00c8c31..96f2464c 100644 --- a/deq/deq/transpiler/jit_library_builder.py +++ b/deq/deq/transpiler/jit_library_builder.py @@ -1355,18 +1355,15 @@ def _readout_tag(measurement_indices: list[int], flip: bool) -> str: def _build_readout_propagation( readouts_info: list[_ReadoutInfo], num_input_observables: int, - implicit_columns: list[set[int]] | None = None, + implicit_columns: list[set[int]], ) -> util_pb.BitMatrix: rows = len(readouts_info) cols = num_input_observables + 1 row_idx: list[int] = [] col_idx: list[int] = [] for index, info in enumerate(readouts_info): - implicit_set = ( - implicit_columns[index] if implicit_columns is not None else set() - ) effective_cols = ( - set(implicit_set) + implicit_columns[index] ^ info.explicit_logical_cols ^ info.explicit_destab_cols ) From 32731fd0aaa3f76edf0f459c7c219ae546bc20e2 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 11 Jul 2026 19:42:13 -0700 Subject: [PATCH 041/157] add comment --- deq/deq/transpiler/jit_noise_builder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/deq/deq/transpiler/jit_noise_builder.py b/deq/deq/transpiler/jit_noise_builder.py index fe6f70fe..54b42ae9 100644 --- a/deq/deq/transpiler/jit_noise_builder.py +++ b/deq/deq/transpiler/jit_noise_builder.py @@ -1540,6 +1540,7 @@ def _apply_propagations( flow_cp_cols = flow_cp_per_row.get(row, set()) flow_flip = row in flow_flip_entries + # Remove flow-derived entries, then add the user-declared entries. cp_entries -= {(row, c) for c in flow_cp_cols} if flow_flip: cp_entries -= {(row, flip_col)} From 8699bbca7a04d9f1b846a8e3696ff07a2dce799e Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 11 Jul 2026 19:44:08 -0700 Subject: [PATCH 042/157] remove unnecessary doc --- deq/deq_runtime/src/proto/deq.bin.rs | 7 ------- deq/proto/deq_bin.proto | 8 -------- 2 files changed, 15 deletions(-) diff --git a/deq/deq_runtime/src/proto/deq.bin.rs b/deq/deq_runtime/src/proto/deq.bin.rs index 80f11d50..13b28fd7 100644 --- a/deq/deq_runtime/src/proto/deq.bin.rs +++ b/deq/deq_runtime/src/proto/deq.bin.rs @@ -75,13 +75,6 @@ pub struct GadgetType { /// mapping from logical readouts to output observables (feed-forward Pauli) /// size = |output_observables| rows x |readouts| columns /// formerly named "conditional_correction" - /// - /// Populated by per-gadget authoring constructs (`CONDITIONAL R L

` inside a GADGET body, `PROPAGATE ... R` R-terms) and - /// by COMPOSE-level `CONDITIONAL rec\[-k\]` corrections (via - /// `GadgetModifier.remote_conditional_correction`). In the merged - /// form produced by `canonical.merge()` these entries are preserved - /// verbatim — the runtime evaluates the flip via - /// `residual ^= logical_correction · readouts`. #[prost(message, optional, tag = "10")] pub logical_correction: ::core::option::Option, /// mapping from internal measurements to output observable corrections diff --git a/deq/proto/deq_bin.proto b/deq/proto/deq_bin.proto index bd02d546..fe70b4a9 100644 --- a/deq/proto/deq_bin.proto +++ b/deq/proto/deq_bin.proto @@ -108,14 +108,6 @@ message GadgetType { // mapping from logical readouts to output observables (feed-forward Pauli) // size = |output_observables| rows x |readouts| columns // formerly named "conditional_correction" - // - // Populated by per-gadget authoring constructs (``CONDITIONAL R - // L

`` inside a GADGET body, ``PROPAGATE ... R`` R-terms) and - // by COMPOSE-level ``CONDITIONAL rec[-k]`` corrections (via - // ``GadgetModifier.remote_conditional_correction``). In the merged - // form produced by ``canonical.merge()`` these entries are preserved - // verbatim — the runtime evaluates the flip via - // ``residual ^= logical_correction · readouts``. deq.util.BitMatrix logical_correction = 10; // Transparent gadget can be useful to dynamically insert Pauli frame updates From 4909c037fd737803f250bc903b303fd20f259ac1 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 11 Jul 2026 21:06:58 -0700 Subject: [PATCH 043/157] fix minor issues --- .../tutorial/chapters/compose-repropagate.md | 11 +++++------ .../compose-repropagate/01_teleport_logical.deq | 6 +++--- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/deq/documents/tutorial/chapters/compose-repropagate.md b/deq/documents/tutorial/chapters/compose-repropagate.md index 64d67b0d..28980a8e 100644 --- a/deq/documents/tutorial/chapters/compose-repropagate.md +++ b/deq/documents/tutorial/chapters/compose-repropagate.md @@ -27,8 +27,7 @@ or an `@REPROPAGATE` decorator that re-derives propagation from the flat inlined circuit, matrix composition drops that correction and the resulting binary is *not* the intended logical identity. -Crucially, `deq annotate` does not fail on the broken COMPOSE — it accepts the -matrix-composed rows because they lie in the basis-freedom span the verifier accepts. +Crucially, `deq annotate` does not fail on the broken COMPOSE. The bug is only visible if you **read the emitted `PROPAGATE` rows**. An empty right-hand side on an output-logical row that should preserve its input observable is the diagnostic. This chapter walks through that pattern: the plain-COMPOSE @@ -98,10 +97,10 @@ port 1: # composed row for `OUT0.LZ0` comes out empty: no input logical # operator (and no measurement bit) propagates to the output LZ. # Since the LZ operator is what flips the X observable, the input's -# X observable is discarded rather than teleported. The `LX` +# X observable correction is discarded rather than teleported. The `LX` # operator still propagates cleanly (input LX -> output LX, both -# flip the Z observable), so the Z observable does survive — but a -# gadget that only teleports one basis is not the identity. +# flip the Z observable), so the Z observable correction does survive — but +# a gadget that only teleports one basis is not the identity. # # See 02_teleport_repropagate.deq for the @REPROPAGATE fix. COMPOSE Teleport { @@ -206,7 +205,7 @@ But **`PROPAGATE OUT0.LZ0 FROM` has an empty right-hand side**: no input operato (and no XOR with any mid-circuit measurement bit) propagates to the output logical $\bar{Z}$ operator. Because $\bar{Z}$ is the operator that flips the frame's $\bar{X}$ observable, the runtime has no expression for the output $\bar{X}$ -observable in terms of the input — the input's $\bar{X}$ information is discarded +observable in terms of the input — the input's $\bar{X}$ correction is discarded rather than teleported. To confirm, look at the compiled `correction_propagation` (cp) and diff --git a/deq/documents/tutorial/examples/compose-repropagate/01_teleport_logical.deq b/deq/documents/tutorial/examples/compose-repropagate/01_teleport_logical.deq index 2ea37367..838894e4 100644 --- a/deq/documents/tutorial/examples/compose-repropagate/01_teleport_logical.deq +++ b/deq/documents/tutorial/examples/compose-repropagate/01_teleport_logical.deq @@ -49,10 +49,10 @@ GADGET MeasureX { # composed row for `OUT0.LZ0` comes out empty: no input logical # operator (and no measurement bit) propagates to the output LZ. # Since the LZ operator is what flips the X observable, the input's -# X observable is discarded rather than teleported. The `LX` +# X observable correction is discarded rather than teleported. The `LX` # operator still propagates cleanly (input LX -> output LX, both -# flip the Z observable), so the Z observable does survive — but a -# gadget that only teleports one basis is not the identity. +# flip the Z observable), so the Z observable correction does survive — but +# a gadget that only teleports one basis is not the identity. # # See 02_teleport_repropagate.deq for the @REPROPAGATE fix. COMPOSE Teleport { From dc36dc41bbb73494d4093e3690396cb15c10350c Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 11 Jul 2026 21:56:25 -0700 Subject: [PATCH 044/157] clean up chapter --- .../chapters/conditional-correction.md | 35 +++++++++---------- .../conditional-correction/.gitignore | 1 - .../02_teleport_compose_conditional.deq | 2 +- .../03_teleport_program_conditional.deq | 2 +- 4 files changed, 19 insertions(+), 21 deletions(-) delete mode 100644 deq/documents/tutorial/examples/conditional-correction/.gitignore diff --git a/deq/documents/tutorial/chapters/conditional-correction.md b/deq/documents/tutorial/chapters/conditional-correction.md index 5a5fb49e..1cbd175a 100644 --- a/deq/documents/tutorial/chapters/conditional-correction.md +++ b/deq/documents/tutorial/chapters/conditional-correction.md @@ -11,10 +11,10 @@ correction, you **write it down as a logical-level statement** inside the `COMPO (or `PROGRAM`) body: ``` -CONDITIONAL rec[-2] Z0 2 # apply logical Z on wire 2 if measurement record -2 = 1 +CONDITIONAL rec[-2] Z0 2 # apply logical Z on wire 2 if logical readout rec[-2] = 1 ``` -The transpiler injects a synthesized identity-host gadget that carries the +The transpiler injects a synthesized empty identity gadget that carries the correction, and the merge() canonicalizer folds the readout's measurement set into the affected output observable's measurement deps — giving the same final `correction_propagation` / `physical_correction` matrices as the `@REPROPAGATE` @@ -97,8 +97,8 @@ them into the gadget so downstream code sees a clean logical-identity teleport. All three variants below rely on a small but crucial deq feature — **concatenated COMPOSE**: once you have declared a `COMPOSE` block, its name becomes callable from inside any *later* `COMPOSE` (or `PROGRAM`) body just like a `GADGET`, so you can -build layered abstractions without inlining everything by hand. We already used it -above: `PrepareBell` and `MeasureBell` are themselves `COMPOSE` blocks assembled +build layered abstractions without inlining everything by hand. For example, +`PrepareBell` and `MeasureBell` are themselves `COMPOSE` blocks assembled from lower-level gadgets, and the three teleport variants below invoke them by name in the same way you would invoke a hand-written GADGET. This lets each teleport variant express the *whole* logical operation in five lines while the underlying @@ -166,7 +166,7 @@ Reading the body line by line: There is no mention of physical qubits anywhere in the body — the correction is expressed purely at the logical level (which Pauli, which logical qubit, which wire). -The transpiler synthesizes a one-port identity-host gadget carrying a +The transpiler synthesizes a one-port identity gadget carrying a `remote_conditional_correction` modifier for each CONDITIONAL; the canonicalizer then folds each readout's measurement set into the affected output observable's measurement deps. The merged `logical_correction` matrix ends up empty (every @@ -243,7 +243,7 @@ basis: # CONDITIONAL rec[-1] X0 2 # if m_ZZ = 1, apply X to output patch # # No ``@REPROPAGATE`` decorator is needed. The transpiler injects a -# synthesized identity-host gadget carrying a +# synthesized identity gadget carrying a # ``remote_conditional_correction`` modifier for each statement; the # canonicalizer folds the readout's measurement set into the affected # output observable's measurement deps, yielding the same @@ -336,20 +336,19 @@ runtime pipeline against a black-box relay-BP decoder: # and ``02_teleport_compose_conditional.deq`` contain only GADGET / # COMPOSE / PROGRAM invocations, so `deq inject si1000` has nothing # to attach noise to; we just ``cp`` them and rewrite the IMPORT -# chain to redirect to the noisy fixture. All three ``*_noisy.deq`` -# outputs are gitignored per this folder's ``.gitignore`` — -# regenerate them on demand. +# chain to redirect to the noisy fixture. All three ``*.noisy.deq`` +# outputs are gitignored — regenerate them on demand. deq inject si1000 ../../../../tests/circuit/surface_code/surface_code_d3.deq \ - --p 1e-4 --out surface_code_d3_noisy.deq -cp 00_teleportation_library.deq 00_teleportation_library_noisy.deq -cp 02_teleport_compose_conditional.deq 02_teleport_compose_conditional_noisy.deq -sed -i 's|"../../../../tests/circuit/surface_code/surface_code_d3.deq"|"surface_code_d3_noisy.deq"|' \ - 00_teleportation_library_noisy.deq -sed -i 's|"00_teleportation_library.deq"|"00_teleportation_library_noisy.deq"|' \ - 02_teleport_compose_conditional_noisy.deq + --p 1e-4 --out surface_code_d3.noisy.deq +cp 00_teleportation_library.deq 00_teleportation_library.noisy.deq +cp 02_teleport_compose_conditional.deq 02_teleport_compose_conditional.noisy.deq +sed -i 's|"../../../../tests/circuit/surface_code/surface_code_d3.deq"|"surface_code_d3.noisy.deq"|' \ + 00_teleportation_library.noisy.deq +sed -i 's|"00_teleportation_library.deq"|"00_teleportation_library.noisy.deq"|' \ + 02_teleport_compose_conditional.noisy.deq # 2) Run the LER simulator. -deq simulate ler 02_teleport_compose_conditional_noisy.deq \ +deq simulate ler 02_teleport_compose_conditional.noisy.deq \ --program TeleportConditionalMemoryZ \ --shots 3000000 --errors 200 --batch-size 5000 --seed 42 ``` @@ -416,7 +415,7 @@ a matter of *which form reads more clearly in source*. | Property | `@REPROPAGATE` is the better fit | `CONDITIONAL` is the better fit | | -------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------- | | The correction comes from a transversal gate's Heisenberg propagation | ✓ | works, but the user has to spell it out explicitly | -| The correction comes from a measurement that has no transversal Heisenberg path in the inlined body | ✗ (`deq annotate` rejects) | ✓ (synthesizes the identity-host gadget) | +| The correction comes from a measurement that has no transversal Heisenberg path in the inlined body | ✗ (the flow solver silently omits the row, giving a wrong `correction_propagation`; adding a `CONDITIONAL` to compensate is itself rejected) | ✓ (synthesizes the identity gadget) | | You want the COMPOSE body to read like a textbook protocol (Bell prep, measure, correct) | works, but the correction is invisible in source | ✓ (CONDITIONAL spells out the correction) | | You want the absolute minimum number of source lines | ✓ (no explicit correction) | one extra line per CONDITIONAL | | You don't yet know whether the correction is a real classical Pauli or a Heisenberg-flow artifact | ✗ (transpiler decides for you) | ✓ (explicit declaration) | diff --git a/deq/documents/tutorial/examples/conditional-correction/.gitignore b/deq/documents/tutorial/examples/conditional-correction/.gitignore deleted file mode 100644 index 4ed99b77..00000000 --- a/deq/documents/tutorial/examples/conditional-correction/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*_noisy.deq diff --git a/deq/documents/tutorial/examples/conditional-correction/02_teleport_compose_conditional.deq b/deq/documents/tutorial/examples/conditional-correction/02_teleport_compose_conditional.deq index 1fd78f6d..e6758f7f 100644 --- a/deq/documents/tutorial/examples/conditional-correction/02_teleport_compose_conditional.deq +++ b/deq/documents/tutorial/examples/conditional-correction/02_teleport_compose_conditional.deq @@ -8,7 +8,7 @@ # CONDITIONAL rec[-1] X0 2 # if m_ZZ = 1, apply X to output patch # # No ``@REPROPAGATE`` decorator is needed. The transpiler injects a -# synthesized identity-host gadget carrying a +# synthesized identity gadget carrying a # ``remote_conditional_correction`` modifier for each statement; the # canonicalizer folds the readout's measurement set into the affected # output observable's measurement deps, yielding the same diff --git a/deq/documents/tutorial/examples/conditional-correction/03_teleport_program_conditional.deq b/deq/documents/tutorial/examples/conditional-correction/03_teleport_program_conditional.deq index 2dcb3c86..bd74bf06 100644 --- a/deq/documents/tutorial/examples/conditional-correction/03_teleport_program_conditional.deq +++ b/deq/documents/tutorial/examples/conditional-correction/03_teleport_program_conditional.deq @@ -5,7 +5,7 @@ # COMPOSE. Structurally identical to the COMPOSE-level pathway: # the program compiler invokes # :func:`emit_conditional_correction_instruction` (see -# ``deq/cli/jit.py``), which synthesizes the same identity-host +# ``deq/cli/jit.py``), which synthesizes the same identity # gadget the COMPOSE pathway emits. # # This is convenient when the conditional fix-up is a one-off — there From c9d183e97c36fc9b8637f9fa7499b0d5221ca992 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 13 Jul 2026 09:51:03 -0700 Subject: [PATCH 045/157] update tutorial chapter --- .../tutorial/chapters/lattice-surgery.md | 165 ++++++++++-------- 1 file changed, 89 insertions(+), 76 deletions(-) diff --git a/deq/documents/tutorial/chapters/lattice-surgery.md b/deq/documents/tutorial/chapters/lattice-surgery.md index 71d8bed2..e00e1560 100644 --- a/deq/documents/tutorial/chapters/lattice-surgery.md +++ b/deq/documents/tutorial/chapters/lattice-surgery.md @@ -2,8 +2,9 @@ The [`CONDITIONAL` chapter](conditional-correction.md) closed with a warning that the reader can safely ignore for teleportation-style gadgets: **the same -physical circuit realises many inequivalent logical actions**, and deq -deliberately refuses to guess which one you want. For Bell-pair teleportation +physical circuit realises many inequivalent logical actions**, and deq's +auto-derived flow either silently picks a reading you did not intend or +fails to derive one at all. For Bell-pair teleportation the choice is invisible because there is only one natural reading — the flow solver picks it, `@REPROPAGATE` and `CONDITIONAL` agree with it, and the user never has to think about the ambiguity. @@ -30,15 +31,18 @@ several different logical actions. Two ambiguities show up: below). (The naming mirrors Stim's single-qubit `MZ` / `MRZ` distinction — `M*` measures only, `MR*` measures and resets.) 2. **Individual $\bar X$ vs joint $\bar X_A \bar X_B$** — because `MZZ` - outputs two individual `SurfaceCode` ports, deq's per-port flow solver - looks for a $\bar X$-flow on each patch separately. Neither individual - $\bar X_A$ nor $\bar X_B$ has one: both anticommute with the joint-Z - observable, so the merge projection destroys them. What survives - is the *product* $\bar X_A \bar X_B$ (it commutes with $\bar Z_A \bar - Z_B$), and an honest joint-Z measurement must preserve it — so the user - has to declare *how* this joint $\bar X$ contribution is distributed - across the two output ports via a hand-written `PROPAGATE` row, since - neither the per-port solver nor the framework can derive it on its own. + outputs two individual `SurfaceCode` ports, deq's flow-target + enumeration walks each output logical column of each port separately + and asks the flow solver for an $\bar X$-flow on each patch on its + own. Neither individual $\bar X_A$ nor $\bar X_B$ has one: both + anticommute with the joint-Z observable, so the merge projection + destroys them. What survives is the *product* $\bar X_A \bar X_B$ + (it commutes with $\bar Z_A \bar Z_B$), and an honest joint-Z + measurement must preserve it — so the user has to declare *how* this + joint $\bar X$ contribution is distributed across the two output + ports via a hand-written `PROPAGATE` row, since the enumeration only + ever hands the solver single-port targets and never asks about the + joint one. The rest of this chapter solves these two problems in turn — first the hand-written `PROPAGATE` fix for Ambiguity 1 (with `CONDITIONAL` covered as @@ -161,19 +165,19 @@ lattice-surgery library: The body ends with the three *declarative* statements this chapter is about: -[`MZZ` body — READOUT, OUTPUT ports, and the three declarative statements](../examples/lattice-surgery/00_lattice_surgery_library.deq#L50-L59) - +[`MZZ` body — READOUT, OUTPUT ports, and the three declarative statements](../examples/lattice-surgery/00_lattice_surgery_library.deq#L52-L61) +


-    MX 18 19 20             # M6 M7 M8
-
     READOUT M0 M3 M4 M5
 
     OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8
     OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17
 
     PROPAGATE OUT1.LX0 FROM IN1.LX0
-    PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M6
- + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M6 + PROPAGATE OUT1.LZ0 FROM +}
+ Together those three hand-written `PROPAGATE` rows tell deq *which logical action* this merge is supposed to represent. Delete them and the compiler @@ -323,11 +327,17 @@ has *GF(2) freedom* in which pre-merge $\bar Z$ frame to charge the row against: `IN0.LX0 ⊕ (merge bits) ⊕ (destabilizer bits)` and `IN1.LX0` produce the same physical observable on the output. -Second, `_compute_pc_logical_via_flows` walks the input ports in order -and returns the first valid representative it finds. Port 0 (patch A) -gets tried first, so the row lands on `IN0.LX0 + destabilizer bits + -measurement bits` rather than the mirror-image row on port 1. A -different port ordering would have produced the mirror. +Second, `_compute_pc_logical_via_flows` builds a single GF(2) linear +system whose columns are ordered `[flow_generators, port_0's +observables + destabilizers, port_1's observables + destabilizers, +...]` and solves it via reduced row-echelon form. RREF picks pivots +strictly left-to-right, so patch A's `IN0.LX0` column — which precedes +patch B's `IN1.LX0` — becomes a pivot; by the time RREF reaches +`IN1.LX0` it is already GF(2)-dependent on the columns to its left +(exactly the merge identity above) and gets classified as a free +column, which the solver fixes to zero. The result is the +`IN0.LX0 + destabilizer bits + measurement bits` row above, and a +different port ordering would have picked the mirror. **Semantic reading**: deq's naive interpretation is *"patch B's post-merge Z frame equals patch A's pre-merge Z frame ⊕ the joint-parity @@ -420,21 +430,8 @@ patch A, the corrected-frame state is $|\Psi^+\rangle = (|1_L 0_L\rangle + |0_L 1_L\rangle)/\sqrt 2$; the honest joint measurement predicts anti-correlated individual outcomes $(joint, A, B) \in \{(1, 0, 1), (1, 1, 0)\}$. -[`PROGRAM BellPairWithLogicalXJointZZ`](../../../tests/circuit/surface_code/lattice_surgery_d3.deq#L271-L281) - -
PROGRAM BellPairWithLogicalXJointZZ {
-    PrepareX 0
-    PrepareZ 1
-    TransversalCNOT 0 1
-    LogicalX 0
-    ComposeMZZ 0 1
-    MeasureZ 0
-    MeasureZ 1
-    ASSERT_EQ rec[-3] 1   # joint LZ_A·LZ_B parity = −1 → readout 1
-}
-
- -produces exactly that pattern under either fix, and produces the +[`PROGRAM BellPairWithLogicalXJointZZ`](../../../tests/circuit/surface_code/lattice_surgery_d3.deq#L271-L281) produces +exactly that pattern under either fix, and produces the *correlated* pattern $\{(1, 0, 0), (1, 1, 1)\}$ when both are absent (the joint readout is right but the individual outcomes agree instead of disagreeing, exposing the silent patch-B rewrite). @@ -442,49 +439,65 @@ of disagreeing, exposing the silent patch-B rewrite). ## Ambiguity 2: joint $\bar X_A \bar X_B$ preservation -The mirror-image table above reveals a *second* post-merge invariant that -the framework's per-port flow solver refuses to derive automatically: the -joint logical-X observable $\bar X_A \bar X_B$. Just like the joint $\bar Z$ -sits on `OUT1.LX0` with a measurement-driven sign flip, the joint $\bar X$ -sits on `OUT0.LZ0` and absorbs a single MX-seam outcome: - -[the two joint-$\bar X$ hand-written `PROPAGATE` rows in `MZZ`](../examples/lattice-surgery/00_lattice_surgery_library.deq#L56-L59) - -
    OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17
-
-    PROPAGATE OUT1.LX0 FROM IN1.LX0
-    PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M6
- +The four auto-derived `PROPAGATE` rows in Piece 2 above reveal a *second* +post-merge invariant that the framework's per-port target enumeration +fails to derive automatically: the joint logical-X observable +$\bar X_A \bar X_B$. Ambiguity 1 was about the *non-empty* row that +pointed at the wrong port (`OUT1.LX0`); Ambiguity 2 is about the two +*empty* rows in the same Piece-2 listing (`OUT0.LZ0 FROM` and +`OUT1.LZ0 FROM`), which encode the enumeration's inability to feed the +solver a target Pauli that captures the joint $\bar X$ flow. Just like +the joint $\bar Z$ sits on `OUT1.LX0` with a measurement-driven sign +flip, the joint $\bar X$ sits on `OUT0.LZ0` and absorbs a single MX-seam +outcome: + +[the two joint-$\bar X$ hand-written `PROPAGATE` rows in `MZZ`](../examples/lattice-surgery/00_lattice_surgery_library.deq#L59-L60) + +
    PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M6
+    PROPAGATE OUT1.LZ0 FROM
+ The question is: why do these rows need to be hand-written at all? -### Why deq refuses to auto-derive the joint-$\bar X$ flow - -The merge body's per-port Heisenberg analysis (the GF(2) solver in -`_compute_pc_logical_via_flows`) runs **once per output logical column**. -For OUT0's $\bar X$ row (rendered `OUT0.LZ0`) it sets the target Pauli to -`OUT0`'s natural $\bar X$ representative — patch A's top-row X-string -$X_0 X_1 X_2$ — and asks `stim.Circuit.has_flow(...)` whether that specific -Pauli is preserved through the merge. It is not: $X_0 X_1 X_2$ -anti-commutes with the MZZ measurement. The same negative result holds for OUT1's -$\bar X$ candidate $X_9 X_{10} X_{11}$. Neither individual $\bar X$ -survives. +### Why deq's per-port target enumeration fails to auto-derive the joint-$\bar X$ flow + +The GF(2) flow solver in `_compute_pc_logical_via_flows` is fully +general: it uses `stim.Circuit.flow_generators()` to enumerate a basis +for the body's flow space and then calls `binar.solve` on a linear +system whose target column is an arbitrary symplectic Pauli vector on +the full body register. Nothing about the solver itself is per-port — +it would happily find a flow for any target Pauli that lies in the +flow-generator span, including one that spans multiple output ports. + +The limitation is the *enumeration strategy* wrapping the solver: the +outer loop calls the solver **once per output logical column**, and +each call passes a target Pauli that lives on a single output port. +For OUT0's $\bar X$ row (rendered `OUT0.LZ0`) the target is `OUT0`'s +natural $\bar X$ representative — patch A's top-row X-string +$X_0 X_1 X_2$ — and the GF(2) system has no solution: $X_0 X_1 X_2$ +anti-commutes with the MZZ measurement, so its symplectic vector is not +in the flow-generator span and `binar.solve` returns ``None``. The +same negative result holds for OUT1's $\bar X$ candidate +$X_9 X_{10} X_{11}$. Neither individual $\bar X$ survives. What *does* survive is the **product** $\bar X_A \cdot \bar X_B = X_0 X_1 X_2 \cdot X_9 X_{10} X_{11}$ — that's the joint-XX Bell stabilizer, and the -merge preserves it (with one measurement bit absorbing the sign). But this -Pauli *spans both output ports*, and the per-port solver only ever -considers per-port targets, so neither row's solver call finds it. - -The framework deliberately stops here rather than guessing. The user still -*wants* the joint $\bar X_A \bar X_B$ parity preserved even though neither -individual $\bar X$ is determined: it is what turns this gadget into a -genuine joint-$\bar Z$ measurement (which by definition must leave -everything that commutes with joint $\bar Z$ untouched) rather than a -measure-and-then-scramble-X operation. Because the framework's per-port -flow solver cannot express "joint XX on some port" without user input, the -user has to declare that preservation explicitly via a hand-written -`PROPAGATE` row. +merge preserves it (with one measurement bit absorbing the sign). The +GF(2) solver would happily find a flow for this joint target if the +enumeration handed it in as a single Pauli; but the outer loop only +ever enumerates single-port single-column targets, so no solver call +ever sees the joint one. + +Once both per-port candidates fail, the enumeration has no fallback: +it only walks per-port single-column targets, so the row is simply +left empty. But the user still *wants* the joint $\bar X_A \bar X_B$ +parity preserved even though neither individual $\bar X$ is determined: +it is what turns this gadget into a genuine joint-$\bar Z$ measurement +(which by definition must leave everything that commutes with joint +$\bar Z$ untouched) rather than a measure-and-then-scramble-X operation. +Because the enumeration never asks the solver about "joint XX on some +port", the user has to declare that preservation explicitly via a +hand-written `PROPAGATE` row. Once both candidate rows fail, the only remaining decisions are *logical-level conventions* the framework cannot fix from circuit structure @@ -770,10 +783,10 @@ strongest signature that the merge is now genuinely fault-tolerant. | Concept | Purpose | | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | -| Physical/logical ambiguity | A single physical body (six MPPs + MX seam) is consistent with many inequivalent logical actions; deq refuses to guess | +| Physical/logical ambiguity | A single physical body (six MPPs + MX seam) is consistent with many inequivalent logical actions; deq's auto-derivation either picks the wrong reading or fails outright | | `PROPAGATE OUT1.LX0 FROM IN1.LX0` | Pins the branch-dependent frame flip, selecting the "honest joint measurement" reading (individual $\bar Z$ frames left alone). Equivalent to `CONDITIONAL R0 OUT1.LX0`, chosen here because it matches the compiled form directly | | Empirical calibration for the fixed-port choice | Product-state discriminators (`ProductZZ_VirtualXA`, `ProductZZ_VirtualXB`) with deterministic outcomes falsify the wrong port | -| Hand-written `PROPAGATE OUT*.LZ0` | Hand-declares the joint $\bar X_A \bar X_B$ preservation that the per-port flow solver misses because the observable spans two ports | +| Hand-written `PROPAGATE OUT*.LZ0` | Hand-declares the joint $\bar X_A \bar X_B$ preservation that the per-port target enumeration misses because the enumeration only hands the solver single-port targets | | Single-round MZZ | Structurally correct after the byproducts, but $\mathrm{LER} \approx 7 p$ across all noise rates — not fault-tolerant | | `MergeBegin` / `MergedSE` / `MergeEnd` refactor | Repeated merge measurements give the decoder temporally local edges, restoring $\mathrm{LER} \propto p^2$ at $d = 3$ (see [Composing Gadgets with COMPOSE](compose-gadgets.md) for the REPEAT mechanics) | | LER at $d = 3$, $p = 10^{-4}$ | $r = 1$: $\approx 7 \times 10^{-4}$ (above physical); $r = 3$: $\approx 5 \times 10^{-6}$ (more than an order of magnitude below physical) | From 7710b7bc12de638382b67bf1cb8ada9e0b6cfe1e Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 13 Jul 2026 10:08:43 -0700 Subject: [PATCH 046/157] fix repetition code implementation --- .../repetition_code/repetition_code.deq | 6 +- .../repetition_code_d3.auto.ref.deq | 104 +++++++++--------- .../repetition_code_d3.syndrome-meta.ref.deq | 92 ++++++++-------- .../repetition_code_d3.syndrome.ref.deq | 78 ++++++------- .../repetition_code_d3.transversal.ref.deq | 98 ++++++++--------- 5 files changed, 186 insertions(+), 192 deletions(-) diff --git a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code.deq b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code.deq index 2471325d..0458e9c7 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code.deq @@ -29,10 +29,10 @@ GADGET AutomorphismIdentity { } <%def name="syndrome_extraction(bias)"> - R ${" ".join(f"{bias+2*i+1}" for i in range(d))} - CX ${" ".join(f"{bias+2*i} {bias+2*i+1}" for i in range(d))} + R ${" ".join(f"{bias+2*i+1}" for i in range(d-1))} + CX ${" ".join(f"{bias+2*i} {bias+2*i+1}" for i in range(d-1))} CX ${" ".join(f"{bias+((2*i+2) % (2*d))} {bias+2*i+1}" for i in range(d-1))} - M ${" ".join(f"{bias+2*i+1}" for i in range(d))} + M ${" ".join(f"{bias+2*i+1}" for i in range(d-1))} @CHECKS(${finder}) diff --git a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.auto.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.auto.ref.deq index 3b269c3e..efaaffc5 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.auto.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.auto.ref.deq @@ -63,10 +63,10 @@ GADGET AutomorphismIdentity { @CHECKS("manual", verify=0) GADGET Syndrome { INPUT RepetitionCode 0 2 4 - R 1 3 5 - CX 0 1 2 3 4 5 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 + M 1 3 CHECK M0 IN0.S0 CHECK M1 IN0.S1 CHECK M1 M0 IN0.S2 @@ -74,8 +74,8 @@ GADGET Syndrome { CHECK OUT0.S0 M0 CHECK OUT0.S1 M1 CHECK OUT0.S2 M1 M0 - PROPAGATE OUT0.LZ0 FROM - PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- # finished checks: 3 @@ -89,37 +89,35 @@ GADGET Syndrome { @CHECKS("manual", verify=0) GADGET MultiSyndrome { INPUT RepetitionCode 0 2 4 - R 1 3 5 - CX 0 1 2 3 4 5 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 - R 1 3 5 - CX 0 1 2 3 4 5 + M 1 3 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 - R 1 3 5 - CX 0 1 2 3 4 5 + M 1 3 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 - CHECK M6 IN0.S0 - CHECK M7 IN0.S1 - CHECK M7 M6 IN0.S2 - CHECK M6 M0 - CHECK M7 M1 - CHECK M8 M2 - CHECK M6 M3 - CHECK M7 M4 - CHECK M8 M5 + M 1 3 + CHECK M4 IN0.S0 + CHECK M5 IN0.S1 + CHECK M5 M4 IN0.S2 + CHECK M4 M0 + CHECK M5 M1 + CHECK M4 M2 + CHECK M5 M3 OUTPUT RepetitionCode 0 2 4 - CHECK OUT0.S0 M6 - CHECK OUT0.S1 M7 - CHECK OUT0.S2 M7 M6 - PROPAGATE OUT0.LZ0 FROM - PROPAGATE OUT0.LX0 FROM M6 M7 M8 + CHECK OUT0.S0 M4 + CHECK OUT0.S1 M5 + CHECK OUT0.S2 M5 M4 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- - # finished checks: 9 - # weight distribution: { 2:8, 3:1 } + # finished checks: 7 + # weight distribution: { 2:6, 3:1 } # unfinished checks: 3 # weight distribution: { 2:2, 3:1 } # errors: 0 @@ -174,10 +172,10 @@ GADGET TransversalCNOT { GADGET TransversalCNOT_SE_before_control { INPUT RepetitionCode 0 2 4 INPUT RepetitionCode 6 8 10 - R 1 3 5 - CX 0 1 2 3 4 5 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 + M 1 3 CX 0 6 2 8 4 10 CHECK M0 IN0.S0 CHECK M1 IN0.S1 @@ -190,10 +188,10 @@ GADGET TransversalCNOT_SE_before_control { CHECK OUT1.S0 M0 IN1.S0 CHECK OUT1.S1 M1 IN1.S1 CHECK OUT1.S2 M1 M0 IN1.S2 - PROPAGATE OUT0.LZ0 FROM - PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 PROPAGATE OUT1.LZ0 FROM IN1.LZ0 - PROPAGATE OUT1.LX0 FROM IN1.LX0 M0 M1 M2 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 3 @@ -208,10 +206,10 @@ GADGET TransversalCNOT_SE_before_control { GADGET TransversalCNOT_SE_before_target { INPUT RepetitionCode 0 2 4 INPUT RepetitionCode 6 8 10 - R 7 9 11 - CX 6 7 8 9 10 11 + R 7 9 + CX 6 7 8 9 CX 8 7 10 9 - M 7 9 11 + M 7 9 CX 0 6 2 8 4 10 CHECK M0 IN1.S0 CHECK M1 IN1.S1 @@ -224,10 +222,10 @@ GADGET TransversalCNOT_SE_before_target { CHECK OUT1.S0 M0 IN0.S0 CHECK OUT1.S1 M1 IN0.S1 CHECK OUT1.S2 M1 M0 IN0.S2 - PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 - PROPAGATE OUT1.LZ0 FROM - PROPAGATE OUT1.LX0 FROM IN0.LX0 M0 M1 M2 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 3 @@ -243,10 +241,10 @@ GADGET TransversalCNOT_SE_after_control { INPUT RepetitionCode 0 2 4 INPUT RepetitionCode 6 8 10 CX 0 6 2 8 4 10 - R 1 3 5 - CX 0 1 2 3 4 5 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 + M 1 3 CHECK M0 IN0.S0 CHECK M1 IN0.S1 CHECK M1 M0 IN0.S2 @@ -258,10 +256,10 @@ GADGET TransversalCNOT_SE_after_control { CHECK OUT1.S0 M0 IN1.S0 CHECK OUT1.S1 M1 IN1.S1 CHECK OUT1.S2 M1 M0 IN1.S2 - PROPAGATE OUT0.LZ0 FROM - PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 PROPAGATE OUT1.LZ0 FROM IN1.LZ0 - PROPAGATE OUT1.LX0 FROM IN1.LX0 M0 M1 M2 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 3 @@ -277,10 +275,10 @@ GADGET TransversalCNOT_SE_after_target { INPUT RepetitionCode 0 2 4 INPUT RepetitionCode 6 8 10 CX 0 6 2 8 4 10 - R 7 9 11 - CX 6 7 8 9 10 11 + R 7 9 + CX 6 7 8 9 CX 8 7 10 9 - M 7 9 11 + M 7 9 CHECK M0 IN1.S0 IN0.S0 CHECK M1 IN1.S1 IN0.S1 CHECK M1 M0 IN1.S2 IN0.S2 @@ -294,8 +292,8 @@ GADGET TransversalCNOT_SE_after_target { CHECK OUT1.S2 M1 M0 PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 - PROPAGATE OUT1.LZ0 FROM - PROPAGATE OUT1.LX0 FROM M0 M1 M2 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 3 diff --git a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome-meta.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome-meta.ref.deq index d018af24..10dcbed3 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome-meta.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome-meta.ref.deq @@ -63,10 +63,10 @@ GADGET AutomorphismIdentity { @CHECKS("manual", verify=0) GADGET Syndrome { INPUT RepetitionCode 0 2 4 - R 1 3 5 - CX 0 1 2 3 4 5 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 + M 1 3 CHECK M0 IN0.S0 CHECK M1 IN0.S1 CHECK M1 M0 IN0.S2 @@ -74,8 +74,8 @@ GADGET Syndrome { CHECK OUT0.S0 M0 CHECK OUT0.S1 M1 CHECK OUT0.S2 M1 M0 - PROPAGATE OUT0.LZ0 FROM - PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- # finished checks: 3 @@ -89,37 +89,35 @@ GADGET Syndrome { @CHECKS("manual", verify=0) GADGET MultiSyndrome { INPUT RepetitionCode 0 2 4 - R 1 3 5 - CX 0 1 2 3 4 5 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 - R 1 3 5 - CX 0 1 2 3 4 5 + M 1 3 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 - R 1 3 5 - CX 0 1 2 3 4 5 + M 1 3 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 + M 1 3 CHECK M0 IN0.S0 CHECK M1 IN0.S1 CHECK M1 M0 IN0.S2 - CHECK M6 M0 - CHECK M7 M1 - CHECK M8 M2 - CHECK M6 M3 - CHECK M7 M4 - CHECK M8 M5 + CHECK M4 M0 + CHECK M5 M1 + CHECK M4 M2 + CHECK M5 M3 OUTPUT RepetitionCode 0 2 4 CHECK OUT0.S0 M0 CHECK OUT0.S1 M1 CHECK OUT0.S2 M1 M0 - PROPAGATE OUT0.LZ0 FROM - PROPAGATE OUT0.LX0 FROM M6 M7 M8 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- - # finished checks: 9 - # weight distribution: { 2:8, 3:1 } + # finished checks: 7 + # weight distribution: { 2:6, 3:1 } # unfinished checks: 3 # weight distribution: { 2:2, 3:1 } # errors: 0 @@ -174,10 +172,10 @@ GADGET TransversalCNOT { GADGET TransversalCNOT_SE_before_control { INPUT RepetitionCode 0 2 4 INPUT RepetitionCode 6 8 10 - R 1 3 5 - CX 0 1 2 3 4 5 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 + M 1 3 CX 0 6 2 8 4 10 CHECK M0 IN0.S0 CHECK M1 IN0.S1 @@ -190,10 +188,10 @@ GADGET TransversalCNOT_SE_before_control { CHECK OUT1.S0 M0 IN1.S0 CHECK OUT1.S1 M1 IN1.S1 CHECK OUT1.S2 M1 IN1.S2 IN0.S0 - PROPAGATE OUT0.LZ0 FROM - PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 PROPAGATE OUT1.LZ0 FROM IN1.LZ0 - PROPAGATE OUT1.LX0 FROM IN1.LX0 M0 M1 M2 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 3 @@ -208,10 +206,10 @@ GADGET TransversalCNOT_SE_before_control { GADGET TransversalCNOT_SE_before_target { INPUT RepetitionCode 0 2 4 INPUT RepetitionCode 6 8 10 - R 7 9 11 - CX 6 7 8 9 10 11 + R 7 9 + CX 6 7 8 9 CX 8 7 10 9 - M 7 9 11 + M 7 9 CX 0 6 2 8 4 10 CHECK M0 IN1.S0 CHECK M1 IN1.S1 @@ -224,10 +222,10 @@ GADGET TransversalCNOT_SE_before_target { CHECK OUT1.S0 M0 IN0.S0 CHECK OUT1.S1 M1 IN0.S1 CHECK OUT1.S2 M1 IN1.S0 IN0.S2 - PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 - PROPAGATE OUT1.LZ0 FROM - PROPAGATE OUT1.LX0 FROM IN0.LX0 M0 M1 M2 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 3 @@ -243,10 +241,10 @@ GADGET TransversalCNOT_SE_after_control { INPUT RepetitionCode 0 2 4 INPUT RepetitionCode 6 8 10 CX 0 6 2 8 4 10 - R 1 3 5 - CX 0 1 2 3 4 5 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 + M 1 3 CHECK M0 IN0.S0 CHECK M1 IN0.S1 CHECK M1 M0 IN0.S2 @@ -258,10 +256,10 @@ GADGET TransversalCNOT_SE_after_control { CHECK OUT1.S0 M0 IN1.S0 CHECK OUT1.S1 M1 IN1.S1 CHECK OUT1.S2 M1 IN1.S2 IN0.S0 - PROPAGATE OUT0.LZ0 FROM - PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 PROPAGATE OUT1.LZ0 FROM IN1.LZ0 - PROPAGATE OUT1.LX0 FROM IN1.LX0 M0 M1 M2 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 3 @@ -277,10 +275,10 @@ GADGET TransversalCNOT_SE_after_target { INPUT RepetitionCode 0 2 4 INPUT RepetitionCode 6 8 10 CX 0 6 2 8 4 10 - R 7 9 11 - CX 6 7 8 9 10 11 + R 7 9 + CX 6 7 8 9 CX 8 7 10 9 - M 7 9 11 + M 7 9 OUTPUT RepetitionCode 0 2 4 OUTPUT RepetitionCode 6 8 10 CHECK OUT0.S0 IN0.S0 @@ -291,8 +289,8 @@ GADGET TransversalCNOT_SE_after_target { CHECK OUT1.S2 M1 M0 PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 - PROPAGATE OUT1.LZ0 FROM - PROPAGATE OUT1.LX0 FROM M0 M1 M2 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 0 diff --git a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome.ref.deq index 84df74c5..9887df43 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome.ref.deq @@ -63,10 +63,10 @@ GADGET AutomorphismIdentity { @CHECKS("manual", verify=0) GADGET Syndrome { INPUT RepetitionCode 0 2 4 - R 1 3 5 - CX 0 1 2 3 4 5 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 + M 1 3 CHECK M0 IN0.S0 CHECK M1 IN0.S1 CHECK M1 M0 IN0.S2 @@ -74,8 +74,8 @@ GADGET Syndrome { CHECK OUT0.S0 M0 CHECK OUT0.S1 M1 CHECK OUT0.S2 M1 M0 - PROPAGATE OUT0.LZ0 FROM - PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- # finished checks: 3 @@ -89,18 +89,18 @@ GADGET Syndrome { @CHECKS("manual", verify=0) GADGET MultiSyndrome { INPUT RepetitionCode 0 2 4 - R 1 3 5 - CX 0 1 2 3 4 5 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 - R 1 3 5 - CX 0 1 2 3 4 5 + M 1 3 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 - R 1 3 5 - CX 0 1 2 3 4 5 + M 1 3 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 + M 1 3 CHECK M0 IN0.S0 CHECK M1 IN0.S1 CHECK M1 M0 IN0.S2 @@ -108,8 +108,8 @@ GADGET MultiSyndrome { CHECK OUT0.S0 M0 CHECK OUT0.S1 M1 CHECK OUT0.S2 M1 M0 - PROPAGATE OUT0.LZ0 FROM - PROPAGATE OUT0.LX0 FROM M6 M7 M8 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- # finished checks: 3 @@ -168,10 +168,10 @@ GADGET TransversalCNOT { GADGET TransversalCNOT_SE_before_control { INPUT RepetitionCode 0 2 4 INPUT RepetitionCode 6 8 10 - R 1 3 5 - CX 0 1 2 3 4 5 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 + M 1 3 CX 0 6 2 8 4 10 CHECK M0 IN0.S0 CHECK M1 IN0.S1 @@ -184,10 +184,10 @@ GADGET TransversalCNOT_SE_before_control { CHECK OUT1.S0 M0 IN1.S0 CHECK OUT1.S1 M1 IN1.S1 CHECK OUT1.S2 M1 IN1.S2 IN0.S0 - PROPAGATE OUT0.LZ0 FROM - PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 PROPAGATE OUT1.LZ0 FROM IN1.LZ0 - PROPAGATE OUT1.LX0 FROM IN1.LX0 M0 M1 M2 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 3 @@ -202,10 +202,10 @@ GADGET TransversalCNOT_SE_before_control { GADGET TransversalCNOT_SE_before_target { INPUT RepetitionCode 0 2 4 INPUT RepetitionCode 6 8 10 - R 7 9 11 - CX 6 7 8 9 10 11 + R 7 9 + CX 6 7 8 9 CX 8 7 10 9 - M 7 9 11 + M 7 9 CX 0 6 2 8 4 10 CHECK M0 IN1.S0 CHECK M1 IN1.S1 @@ -218,10 +218,10 @@ GADGET TransversalCNOT_SE_before_target { CHECK OUT1.S0 M0 IN0.S0 CHECK OUT1.S1 M1 IN0.S1 CHECK OUT1.S2 M1 IN1.S0 IN0.S2 - PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 - PROPAGATE OUT1.LZ0 FROM - PROPAGATE OUT1.LX0 FROM IN0.LX0 M0 M1 M2 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 3 @@ -237,10 +237,10 @@ GADGET TransversalCNOT_SE_after_control { INPUT RepetitionCode 0 2 4 INPUT RepetitionCode 6 8 10 CX 0 6 2 8 4 10 - R 1 3 5 - CX 0 1 2 3 4 5 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 + M 1 3 CHECK M0 IN0.S0 CHECK M1 IN0.S1 CHECK M1 M0 IN0.S2 @@ -252,10 +252,10 @@ GADGET TransversalCNOT_SE_after_control { CHECK OUT1.S0 M0 IN1.S0 CHECK OUT1.S1 M1 IN1.S1 CHECK OUT1.S2 M1 IN1.S2 IN0.S0 - PROPAGATE OUT0.LZ0 FROM - PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 PROPAGATE OUT1.LZ0 FROM IN1.LZ0 - PROPAGATE OUT1.LX0 FROM IN1.LX0 M0 M1 M2 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 3 @@ -271,10 +271,10 @@ GADGET TransversalCNOT_SE_after_target { INPUT RepetitionCode 0 2 4 INPUT RepetitionCode 6 8 10 CX 0 6 2 8 4 10 - R 7 9 11 - CX 6 7 8 9 10 11 + R 7 9 + CX 6 7 8 9 CX 8 7 10 9 - M 7 9 11 + M 7 9 OUTPUT RepetitionCode 0 2 4 OUTPUT RepetitionCode 6 8 10 CHECK OUT0.S0 IN0.S0 @@ -285,8 +285,8 @@ GADGET TransversalCNOT_SE_after_target { CHECK OUT1.S2 M1 M0 PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 - PROPAGATE OUT1.LZ0 FROM - PROPAGATE OUT1.LX0 FROM M0 M1 M2 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 0 diff --git a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.transversal.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.transversal.ref.deq index 03973672..52b0d5b7 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.transversal.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.transversal.ref.deq @@ -63,10 +63,10 @@ GADGET AutomorphismIdentity { @CHECKS("manual", verify=0) GADGET Syndrome { INPUT RepetitionCode 0 2 4 - R 1 3 5 - CX 0 1 2 3 4 5 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 + M 1 3 CHECK M0 IN0.S0 CHECK M1 IN0.S1 CHECK M1 M0 IN0.S2 @@ -74,8 +74,8 @@ GADGET Syndrome { CHECK OUT0.S0 IN0.S0 CHECK OUT0.S1 IN0.S1 CHECK OUT0.S2 IN0.S2 - PROPAGATE OUT0.LZ0 FROM - PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- # finished checks: 3 @@ -89,37 +89,35 @@ GADGET Syndrome { @CHECKS("manual", verify=0) GADGET MultiSyndrome { INPUT RepetitionCode 0 2 4 - R 1 3 5 - CX 0 1 2 3 4 5 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 - R 1 3 5 - CX 0 1 2 3 4 5 + M 1 3 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 - R 1 3 5 - CX 0 1 2 3 4 5 + M 1 3 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 - CHECK M6 IN0.S0 - CHECK M7 IN0.S1 - CHECK M7 M6 IN0.S2 - CHECK M6 M0 - CHECK M7 M1 - CHECK M8 M2 - CHECK M6 M3 - CHECK M7 M4 - CHECK M8 M5 + M 1 3 + CHECK M4 IN0.S0 + CHECK M5 IN0.S1 + CHECK M5 M4 IN0.S2 + CHECK M4 M0 + CHECK M5 M1 + CHECK M4 M2 + CHECK M5 M3 OUTPUT RepetitionCode 0 2 4 CHECK OUT0.S0 IN0.S0 CHECK OUT0.S1 IN0.S1 CHECK OUT0.S2 IN0.S2 - PROPAGATE OUT0.LZ0 FROM - PROPAGATE OUT0.LX0 FROM M6 M7 M8 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 # --- statistics --- - # finished checks: 9 - # weight distribution: { 2:8, 3:1 } + # finished checks: 7 + # weight distribution: { 2:6, 3:1 } # unfinished checks: 3 # weight distribution: { 2:3 } # errors: 0 @@ -174,10 +172,10 @@ GADGET TransversalCNOT { GADGET TransversalCNOT_SE_before_control { INPUT RepetitionCode 0 2 4 INPUT RepetitionCode 6 8 10 - R 1 3 5 - CX 0 1 2 3 4 5 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 + M 1 3 CX 0 6 2 8 4 10 CHECK M0 IN0.S0 CHECK M1 IN0.S1 @@ -190,10 +188,10 @@ GADGET TransversalCNOT_SE_before_control { CHECK OUT1.S0 IN1.S0 IN0.S0 CHECK OUT1.S1 IN1.S1 IN0.S1 CHECK OUT1.S2 IN1.S2 IN0.S2 - PROPAGATE OUT0.LZ0 FROM - PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 PROPAGATE OUT1.LZ0 FROM IN1.LZ0 - PROPAGATE OUT1.LX0 FROM IN1.LX0 M0 M1 M2 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 3 @@ -208,10 +206,10 @@ GADGET TransversalCNOT_SE_before_control { GADGET TransversalCNOT_SE_before_target { INPUT RepetitionCode 0 2 4 INPUT RepetitionCode 6 8 10 - R 7 9 11 - CX 6 7 8 9 10 11 + R 7 9 + CX 6 7 8 9 CX 8 7 10 9 - M 7 9 11 + M 7 9 CX 0 6 2 8 4 10 CHECK M0 IN1.S0 CHECK M1 IN1.S1 @@ -224,10 +222,10 @@ GADGET TransversalCNOT_SE_before_target { CHECK OUT1.S0 IN1.S0 IN0.S0 CHECK OUT1.S1 IN1.S1 IN0.S1 CHECK OUT1.S2 IN1.S2 IN0.S2 - PROPAGATE OUT0.LZ0 FROM + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 - PROPAGATE OUT1.LZ0 FROM - PROPAGATE OUT1.LX0 FROM IN0.LX0 M0 M1 M2 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 3 @@ -243,10 +241,10 @@ GADGET TransversalCNOT_SE_after_control { INPUT RepetitionCode 0 2 4 INPUT RepetitionCode 6 8 10 CX 0 6 2 8 4 10 - R 1 3 5 - CX 0 1 2 3 4 5 + R 1 3 + CX 0 1 2 3 CX 2 1 4 3 - M 1 3 5 + M 1 3 CHECK M0 IN0.S0 CHECK M1 IN0.S1 CHECK M1 M0 IN0.S2 @@ -258,10 +256,10 @@ GADGET TransversalCNOT_SE_after_control { CHECK OUT1.S0 IN1.S0 IN0.S0 CHECK OUT1.S1 IN1.S1 IN0.S1 CHECK OUT1.S2 IN1.S2 IN0.S2 - PROPAGATE OUT0.LZ0 FROM - PROPAGATE OUT0.LX0 FROM M0 M1 M2 + PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 + PROPAGATE OUT0.LX0 FROM IN0.LX0 PROPAGATE OUT1.LZ0 FROM IN1.LZ0 - PROPAGATE OUT1.LX0 FROM IN1.LX0 M0 M1 M2 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 3 @@ -277,10 +275,10 @@ GADGET TransversalCNOT_SE_after_target { INPUT RepetitionCode 0 2 4 INPUT RepetitionCode 6 8 10 CX 0 6 2 8 4 10 - R 7 9 11 - CX 6 7 8 9 10 11 + R 7 9 + CX 6 7 8 9 CX 8 7 10 9 - M 7 9 11 + M 7 9 CHECK M0 IN1.S0 IN0.S0 CHECK M1 IN1.S1 IN0.S1 CHECK M1 M0 IN1.S2 IN0.S2 @@ -294,8 +292,8 @@ GADGET TransversalCNOT_SE_after_target { CHECK OUT1.S2 IN1.S2 IN0.S2 PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 - PROPAGATE OUT1.LZ0 FROM - PROPAGATE OUT1.LX0 FROM M0 M1 M2 + PROPAGATE OUT1.LZ0 FROM IN1.LZ0 + PROPAGATE OUT1.LX0 FROM IN0.LX0 IN1.LX0 # --- statistics --- # finished checks: 3 From c2d5810bd0fe8c15a58b7418f470c13126abe58b Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 13 Jul 2026 10:53:26 -0700 Subject: [PATCH 047/157] find joint logical observable automatically --- deq/deq/transpiler/jit_noise_builder.py | 343 +++++++++--------- .../tutorial/chapters/lattice-surgery.md | 241 ++++-------- .../00_lattice_surgery_library.deq | 2 - .../02_ls_merge_multi_round.deq | 16 +- .../surface_code/lattice_surgery_d3.deq | 28 -- 5 files changed, 253 insertions(+), 377 deletions(-) diff --git a/deq/deq/transpiler/jit_noise_builder.py b/deq/deq/transpiler/jit_noise_builder.py index 54b42ae9..adb9d03b 100644 --- a/deq/deq/transpiler/jit_noise_builder.py +++ b/deq/deq/transpiler/jit_noise_builder.py @@ -55,7 +55,7 @@ from typing import Iterator, Literal, Sequence import stim -from binar import BitMatrix, BitVector, solve +from binar import BitMatrix, BitVector, null_space, solve import deq.proto.deq_bin_pb2 as pb import deq.proto.deq_jit_pb2 as jit_pb @@ -579,22 +579,27 @@ def walk_pauli_forward( # ``correction_propagation``. # # We solve this equation via a symplectic linear system over -# :py:meth:`stim.Circuit.flow_generators`. For each output logical -# observable, the solver finds an XOR combination of input-column -# observables and body-measurement outcomes that operator-equals -# the output, and determines the sign offset by closing the -# Heisenberg equation against the signs of the chosen flow -# generators. This unifies handling of unitary bodies (where the -# flow space is the body's tableau) and bodies with internal -# measurements (e.g. Floquet honeycomb rounds, where the flow -# space encodes which measurement outcomes are needed to close -# the operator equation). +# :py:meth:`stim.Circuit.flow_generators`. Every element of the +# body's flow space that lies simultaneously in the input basis +# span *and* the output basis span contributes one linear +# constraint on the propagation matrix rows; jointly they pin +# ``cp``, ``pc``, and the ``FLIP`` column up to any residual +# GF(2) null space (which corresponds to output rows that are +# only jointly determined — the linear-algebra solver picks one +# self-consistent anchor). This unifies handling of unitary +# bodies (where the flow space is the body's tableau), bodies +# with internal measurements (e.g. Floquet honeycomb rounds, +# where the flow space encodes which measurement outcomes are +# needed to close the operator equation), and multi-port merges +# with joint-observable invariants (e.g. lattice-surgery +# :math:`\\bar X_A \\bar X_B` preservation). # -# When no flow exists for an output observable (e.g. a freshly -# prepared logical with no deterministic pre-image), the row is -# left empty; the runtime treats the observable's value as the -# default constant, which is correct as long as no downstream -# gadget consumes the observable's specific value. +# When an output observable is not determined by any flow (e.g. a +# freshly prepared logical with no deterministic pre-image), the +# corresponding row is left empty; the runtime treats the +# observable's value as the default constant, which is correct as +# long as no downstream gadget consumes the observable's specific +# value. def _compute_pc_logical_via_flows( @@ -618,8 +623,21 @@ def _compute_pc_logical_via_flows( * ``flip_entries`` — set of ``output_logical_row`` whose ``FLIP`` (affine) column must be set to absorb the flow's sign offset. - Rows with no admissible flow (genuinely undetermined output - observables) are silently omitted. + Algorithm (see module docstring for the math): + + 1. Enumerate ``body_circuit.flow_generators()``. + 2. Compute the null space of the augmented symplectic system + ``[P_in | I | 0 ; P_out | 0 | O]`` — each null vector gives + a triple ``(u, v, w)`` where ``u`` is the input-observable + decomposition, ``v`` the output-observable decomposition, + ``w`` the measurement-bit set, and a sign bit ``sigma`` from + the Pauli-algebra closure. + 3. Solve the GF(2) systems ``V · cp = U``, ``V · pc = W``, + ``V · flip = sigma`` column-by-column via :func:`binar.solve`. + 4. Restrict outputs to rows in ``output_layout.logical_columns``. + + Output rows undetermined by any flow (rank deficiency along that + row) are silently omitted. """ body_flat = flatten_body(list(gadget.body)) num_qubits = max(max_qubit_index(list(gadget.body)) + 1, 0) @@ -641,170 +659,157 @@ def _compute_pc_logical_via_flows( _, input_obs_paulis = _build_port_paulis(list(input_ports), codes, num_qubits) _, output_obs_paulis = _build_port_paulis(list(output_ports), codes, num_qubits) + n_in = len(input_obs_paulis) + n_out = len(output_obs_paulis) + n_meas = body_circuit.num_measurements - # Build the flow solver context once for this body. All per-row - # solves reuse the same generators and base matrix. - solver_ctx = _build_flow_solver_context( - body_circuit=body_circuit, - input_obs_paulis=input_obs_paulis, - num_qubits=num_qubits, - ) + flows = list(body_circuit.flow_generators()) + n_flow = len(flows) - pc_entries: list[tuple[int, int]] = [] - cp_entries: set[tuple[int, int]] = set() - flip_entries: set[int] = set() - for out_row in sorted(output_layout.logical_columns): - target_out = output_obs_paulis[out_row] - solution = _solve_logical_row_via_gf2_flow( - target_out=target_out, - num_qubits=num_qubits, - solver_context=solver_ctx, - ) - if solution is None: - continue - cp_cols, meas_indices, flip = solution - for c in cp_cols: - cp_entries.add((out_row, c)) - for m in meas_indices: - pc_entries.append((out_row, m)) - if flip: - flip_entries.add(out_row) + if n_flow == 0 or n_out == 0: + return [], set(), set() - return pc_entries, cp_entries, flip_entries + input_symp = [ + _pauli_string_to_symplectic(p, num_qubits) for p in input_obs_paulis + ] + output_symp = [ + _pauli_string_to_symplectic(p, num_qubits) for p in output_obs_paulis + ] + flow_in_symp = [ + _pauli_string_to_symplectic(g.input_copy(), num_qubits) for g in flows + ] + flow_out_symp = [ + _pauli_string_to_symplectic(g.output_copy(), num_qubits) for g in flows + ] + # Augmented symplectic system A · (Y, u, v)^T = 0: + # top 2N eqs: P_in · Y + I · u = 0 (input-side symplectic match) + # bottom 2N eqs: P_out · Y + O · v = 0 (output-side symplectic match) + a_rows: list[list[int]] = [] + for b in range(2 * num_qubits): + a_rows.append( + [flow_in_symp[g][b] for g in range(n_flow)] + + [input_symp[j][b] for j in range(n_in)] + + [0] * n_out + ) + for b in range(2 * num_qubits): + a_rows.append( + [flow_out_symp[g][b] for g in range(n_flow)] + + [0] * n_in + + [output_symp[i][b] for i in range(n_out)] + ) + if not a_rows: + return [], set(), set() + a_matrix = BitMatrix(a_rows) + kernel_rows = null_space(a_matrix).rows + + U_rows: list[list[int]] = [] + V_rows: list[list[int]] = [] + W_rows: list[list[int]] = [] + sigma_bits: list[int] = [] + for vec_bv in kernel_rows: + vec = [int(bit) for bit in vec_bv] + y_coeffs = vec[:n_flow] + u_coeffs = vec[n_flow : n_flow + n_in] + v_coeffs = vec[n_flow + n_in :] + + # Flows whose output component vanishes in the output basis + # don't constrain propagation rows; they encode measurement + # relations (handled by readout_propagation) or trivial + # identity flows. Skip them. + if not any(v_coeffs): + continue -def _solve_logical_row_via_gf2_flow( - *, - target_out: stim.PauliString, - num_qubits: int, - solver_context: "_FlowSolverContext", -) -> tuple[set[int], list[int], bool] | None: - """Solve for a logical-row flow via stim's signed flow generators. - - Returns ``(cp_cols, body_meas_indices, flip)`` if a flow exists, - or ``None`` if the output observable cannot be expressed as any - XOR combination of input column observables and body measurements. - - The linear system over GF(2) is - - .. math:: - \\Big(\\bigoplus_g y_g \\cdot g.\\text{in}\\Big) - \\;=\\; \\bigoplus_c x_c \\cdot I_c, \\quad - \\Big(\\bigoplus_g y_g \\cdot g.\\text{out}\\Big) - \\;=\\; O_r, - - where ``g`` ranges over the body's stim flow generators and ``c`` - over input columns. The solution's ``meas`` set contributes to - ``physical_correction``; the ``x_c`` indicators contribute to - ``correction_propagation``. - - The constant ``flip`` is determined by closing the signed Pauli - equation ``out_combo · ∏M = C · in_combo · C†`` for the canonical - Hermitian flow ``+|in_u| → flip · target_out``, where ``|in_u|`` - is the unsigned-letter product of the chosen input observables - (which is what the runtime XORs as eigenvalue bits). - - ``solver_context`` carries the body's flow generators and base - GF(2) matrix — both of which depend only on the body and the - input columns, not on ``target_out``. Callers must pre-build it - once via :func:`_build_flow_solver_context` and reuse it across - every target row for the same body. - """ - ctx = solver_context - num_gens = ctx.num_gens - gens = ctx.gens + w_row = [0] * n_meas + combined_input = stim.PauliString(num_qubits) + combined_output = stim.PauliString(num_qubits) + for g_idx, y_bit in enumerate(y_coeffs): + if not y_bit: + continue + for m in flows[g_idx].measurements_copy(): + w_row[m] ^= 1 + combined_input *= flows[g_idx].input_copy() + combined_output *= flows[g_idx].output_copy() + + reconstructed_input = stim.PauliString(num_qubits) + for j, u_bit in enumerate(u_coeffs): + if u_bit: + reconstructed_input *= input_obs_paulis[j] + reconstructed_output = stim.PauliString(num_qubits) + for i, v_bit in enumerate(v_coeffs): + if v_bit: + reconstructed_output *= output_obs_paulis[i] + + sign_factor = ( + combined_input.sign + * reconstructed_output.sign + / (reconstructed_input.sign * combined_output.sign) + ) + if abs(sign_factor.imag) > 1e-6: + raise RuntimeError( + f"jit_noise_builder: null-space sign closure produced " + f"non-real factor {sign_factor!r}; algebra bug." + ) - target_out_symp = _pauli_string_to_symplectic(target_out, num_qubits) - rhs = [0] * (2 * num_qubits) + target_out_symp + U_rows.append(u_coeffs) + V_rows.append(v_coeffs) + W_rows.append(w_row) + sigma_bits.append(int(sign_factor.real < 0)) - solution = solve(ctx.base_matrix, BitVector(rhs)) - if solution is None: - return None + if not V_rows: + return [], set(), set() - y_vec = solution[:num_gens] - x_vec = solution[num_gens:] + v_matrix = BitMatrix(V_rows) + n_constraints = len(V_rows) + + def _solve_column(rhs_col: list[int]) -> list[int] | None: + sol = solve(v_matrix, BitVector(rhs_col)) + if sol is None: + return None + return [int(sol[i]) for i in range(n_out)] + + cp = [[0] * n_in for _ in range(n_out)] + for j in range(n_in): + col = _solve_column([U_rows[k][j] for k in range(n_constraints)]) + if col is None: + raise RuntimeError( + f"jit_noise_builder: flow constraints are inconsistent " + f"for input column {j}" + ) + for i in range(n_out): + cp[i][j] = col[i] + + pc = [[0] * n_meas for _ in range(n_out)] + for l in range(n_meas): + col = _solve_column([W_rows[k][l] for k in range(n_constraints)]) + if col is None: + raise RuntimeError( + f"jit_noise_builder: flow constraints are inconsistent " + f"for measurement {l}" + ) + for i in range(n_out): + pc[i][l] = col[i] - meas_xor: set[int] = set() - in_combo = stim.PauliString(num_qubits) - out_combo = stim.PauliString(num_qubits) - for g, y in enumerate(y_vec): - if not y: - continue - for m in gens[g].measurements_copy(): - meas_xor ^= {m} - in_combo *= gens[g].input_copy() - out_combo *= gens[g].output_copy() - - cp_cols: set[int] = {c for c, x in enumerate(x_vec) if x} - - # The runtime XORs the *eigenvalue bits* of the chosen input - # observables, which corresponds to the unsigned-letter product - # ``|in_u| = |in_combo|``. This canonical Hermitian operator is - # the input the flow query is really about; the order-dependent - # phase of multiplying the chosen ``input_obs_paulis`` together - # is irrelevant here. Solving the operator equation - # ``out_combo · ∏M = C · in_combo · C†`` for the Hermitian flow - # ``+|in_u| → flip · target_out`` gives: - sign_factor = target_out.sign * in_combo.sign / out_combo.sign - if abs(sign_factor.imag) > 1e-6: + flip_col = _solve_column(sigma_bits) + if flip_col is None: raise RuntimeError( - f"jit_noise_builder: GF(2) flow sign closure produced " - f"non-real factor {sign_factor!r}; algebra bug." + "jit_noise_builder: flow sign closure is inconsistent" ) - flip = sign_factor.real < 0 - return cp_cols, sorted(meas_xor), flip - - -@dataclass -class _FlowSolverContext: - """Cached per-body data for repeated flow solver calls. - - ``stim.Circuit.flow_generators()`` and the base GF(2) matrix - depend only on the body and input columns, not on the output - target. Compute them once and reuse across multiple targets. - """ - - gens: list[stim.Flow] - num_gens: int - base_matrix: BitMatrix - - -def _build_flow_solver_context( - *, - body_circuit: stim.Circuit, - input_obs_paulis: Sequence[stim.PauliString], - num_qubits: int, -) -> _FlowSolverContext: - gens = list(body_circuit.flow_generators()) - num_gens = len(gens) - num_input_cols = len(input_obs_paulis) - - gen_in_symp = [ - _pauli_string_to_symplectic(g.input_copy(), num_qubits) for g in gens - ] - gen_out_symp = [ - _pauli_string_to_symplectic(g.output_copy(), num_qubits) for g in gens - ] - input_col_symp = [ - _pauli_string_to_symplectic(p, num_qubits) for p in input_obs_paulis - ] + pc_entries: list[tuple[int, int]] = [] + cp_entries: set[tuple[int, int]] = set() + flip_entries: set[int] = set() + for i in sorted(output_layout.logical_columns): + for j in range(n_in): + if cp[i][j]: + cp_entries.add((i, j)) + for l in range(n_meas): + if pc[i][l]: + pc_entries.append((i, l)) + if flip_col[i]: + flip_entries.add(i) - base_matrix_rows: list[list[int]] = [] - for i in range(2 * num_qubits): - row = [gen_in_symp[g][i] for g in range(num_gens)] + [ - input_col_symp[c][i] for c in range(num_input_cols) - ] - base_matrix_rows.append(row) - for i in range(2 * num_qubits): - row = [gen_out_symp[g][i] for g in range(num_gens)] + [0] * num_input_cols - base_matrix_rows.append(row) - - return _FlowSolverContext( - gens=gens, - num_gens=num_gens, - base_matrix=BitMatrix(base_matrix_rows), - ) + return pc_entries, cp_entries, flip_entries def _pauli_string_to_symplectic(ps: stim.PauliString, num_qubits: int) -> list[int]: diff --git a/deq/documents/tutorial/chapters/lattice-surgery.md b/deq/documents/tutorial/chapters/lattice-surgery.md index e00e1560..065352cd 100644 --- a/deq/documents/tutorial/chapters/lattice-surgery.md +++ b/deq/documents/tutorial/chapters/lattice-surgery.md @@ -3,54 +3,43 @@ The [`CONDITIONAL` chapter](conditional-correction.md) closed with a warning that the reader can safely ignore for teleportation-style gadgets: **the same physical circuit realises many inequivalent logical actions**, and deq's -auto-derived flow either silently picks a reading you did not intend or -fails to derive one at all. For Bell-pair teleportation -the choice is invisible because there is only one natural reading — the flow -solver picks it, `@REPROPAGATE` and `CONDITIONAL` agree with it, and the user -never has to think about the ambiguity. +auto-derived flow silently picks *one* of them, which may not be the one you +intended. For Bell-pair teleportation the choice is invisible because there +is only one natural reading — the flow solver picks it, `@REPROPAGATE` and +`CONDITIONAL` agree with it, and the user never has to think about the +ambiguity. Lattice surgery is where the ambiguity stops being an abstraction and starts biting. The joint-$\bar Z$ merge $\mathrm{MZZ}$ takes two surface-code patches and reads out the joint parity $\bar Z_A \bar Z_B$ — but its physical body -(six joint-Pauli MPPs plus a destructive MX seam) is *equally consistent* with -several different logical actions. Two ambiguities show up: - -1. **`MZZ` versus `MRZZ`** — the same physical body is consistent with both - a pure joint-Z measurement (`MZZ`: read $\bar Z_A \bar Z_B$, leave the - individual $\bar Z_A$, $\bar Z_B$ frames alone so the post-merge state - stays in whichever $\bar Z_A \bar Z_B = \pm 1$ branch the measurement - projected onto) and a joint-Z measure-and-reset (`MRZZ`: read - $\bar Z_A \bar Z_B$, then classically flip patch B's $\bar Z$ frame - whenever the readout is $1$ so the post-merge state is always in the - $+1$ eigenspace — equivalently, patch B's post-merge $\bar Z$ frame is - always forced to agree with patch A's). Without user guidance, deq's - auto-derived flow silently picks the `MRZZ` reading. A hand-written - `PROPAGATE OUT1.LX0 FROM IN1.LX0` row overrides that and pins the - honest `MZZ` reading (an equivalent `CONDITIONAL R0 OUT1.LX0` byproduct - would do the same job — the two forms are derived to be equivalent - below). (The naming mirrors Stim's single-qubit `MZ` / `MRZ` - distinction — `M*` measures only, `MR*` measures and resets.) -2. **Individual $\bar X$ vs joint $\bar X_A \bar X_B$** — because `MZZ` - outputs two individual `SurfaceCode` ports, deq's flow-target - enumeration walks each output logical column of each port separately - and asks the flow solver for an $\bar X$-flow on each patch on its - own. Neither individual $\bar X_A$ nor $\bar X_B$ has one: both - anticommute with the joint-Z observable, so the merge projection - destroys them. What survives is the *product* $\bar X_A \bar X_B$ - (it commutes with $\bar Z_A \bar Z_B$), and an honest joint-Z - measurement must preserve it — so the user has to declare *how* this - joint $\bar X$ contribution is distributed across the two output - ports via a hand-written `PROPAGATE` row, since the enumeration only - ever hands the solver single-port targets and never asks about the - joint one. - -The rest of this chapter solves these two problems in turn — first the -hand-written `PROPAGATE` fix for Ambiguity 1 (with `CONDITIONAL` covered as -an equivalent alternative), then the hand-written `PROPAGATE` row rewrite -for Ambiguity 2. Declaring both byproducts makes MZZ *semantically* -correct, but a single-round MZZ is not fault-tolerant on its own — a -further refactor into repeated single-SE-round GADGETs at the COMPOSE level -is what restores the $\mathrm{LER} \propto p^{(d+1)/2}$ surface-code scaling. +(six joint-Pauli MPPs plus a destructive MX seam) is *equally consistent* +with two different logical actions: a pure joint-Z measurement (`MZZ`), or a +joint-Z measure-and-reset (`MRZZ`). + +**`MZZ` versus `MRZZ`.** Both readings share the same physical body: + +* `MZZ`: read $\bar Z_A \bar Z_B$; leave the individual $\bar Z_A$, $\bar Z_B$ + frames alone so the post-merge state stays in whichever $\bar Z_A + \bar Z_B = \pm 1$ branch the measurement projected onto. +* `MRZZ`: read $\bar Z_A \bar Z_B$; then classically flip patch B's $\bar Z$ + frame whenever the readout is $1$ so the post-merge state is always in the + $+1$ eigenspace — equivalently, patch B's post-merge $\bar Z$ frame is + always forced to agree with patch A's. + +Without user guidance, deq's auto-derived flow silently picks the `MRZZ` +reading. A hand-written `PROPAGATE OUT1.LX0 FROM IN1.LX0` row overrides +that and pins the honest `MZZ` reading (an equivalent `CONDITIONAL R0 +OUT1.LX0` byproduct would do the same job — the two forms are derived to be +equivalent below). The naming mirrors Stim's single-qubit `MZ` / `MRZ` +distinction — `M*` measures only, `MR*` measures and resets. + +The rest of this chapter walks through spotting the `MRZZ` pick in +`deq annotate`'s output, deriving why the flow solver settles on it, and +fixing it via either `PROPAGATE` or `CONDITIONAL`. Declaring the byproduct +makes MZZ *semantically* correct, but a single-round MZZ is not +fault-tolerant on its own — a further refactor into repeated single-SE-round +GADGETs at the COMPOSE level is what restores the $\mathrm{LER} \propto +p^{(d+1)/2}$ surface-code scaling. **Prerequisites.** Read the [`CONDITIONAL` chapter](conditional-correction.md) first; this chapter assumes familiarity with `@REPROPAGATE`, @@ -139,8 +128,6 @@ lattice-surgery library: OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17 PROPAGATE OUT1.LX0 FROM IN1.LX0 - PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M6 - PROPAGATE OUT1.LZ0 FROM } COMPOSE ComposeMZZ { @@ -163,10 +150,10 @@ lattice-surgery library: }
-The body ends with the three *declarative* statements this chapter is about: +The body ends with the *declarative* statement this chapter is about: -[`MZZ` body — READOUT, OUTPUT ports, and the three declarative statements](../examples/lattice-surgery/00_lattice_surgery_library.deq#L52-L61) - +[`MZZ` body — READOUT, OUTPUT ports, and the declarative statement](../examples/lattice-surgery/00_lattice_surgery_library.deq#L52-L59) +

     READOUT M0 M3 M4 M5
 
@@ -174,25 +161,23 @@ The body ends with the three *declarative* statements this chapter is about:
     OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17
 
     PROPAGATE OUT1.LX0 FROM IN1.LX0
-    PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M6
-    PROPAGATE OUT1.LZ0 FROM
 }
- + -Together those three hand-written `PROPAGATE` rows tell deq *which logical -action* this merge is supposed to represent. Delete them and the compiler -still accepts the gadget — it just installs a different (and physically -wrong, for the joint-Z measurement we wanted) logical map. +That single hand-written `PROPAGATE` row tells deq *which logical action* +this merge is supposed to represent. Delete it and the compiler still +accepts the gadget — it just installs the wrong (`MRZZ`, measure-and-reset) +logical map instead of the honest (`MZZ`, measure-only) one. -The rest of this chapter explains why each of those three lines is there. +The rest of this chapter explains why that line is there. --- -## Ambiguity 1: the branch-dependent frame flip +## The ambiguity: `MRZZ` versus `MZZ` ### Spotting the problem in `deq annotate`'s naive output -Delete all three hand-written `PROPAGATE` rows from `MZZ` and let +Delete the hand-written `PROPAGATE` row from `MZZ` and let `deq annotate` derive everything from the physical body alone. A minimal self-contained copy of that stripped body lives at [`01_mzz_before_conditional.deq`](../examples/lattice-surgery/01_mzz_before_conditional.deq), @@ -287,16 +272,30 @@ file: [the auto-derived `PROPAGATE` rows of the un-fixed `MZZ` gadget](../examples/lattice-surgery/01_mzz_before_conditional.annotated.deq#L48-L52) -
    PROPAGATE OUT0.LZ0 FROM
+
    PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M6
     PROPAGATE OUT0.LX0 FROM IN0.LX0
     PROPAGATE OUT1.LZ0 FROM
     PROPAGATE OUT1.LX0 FROM IN0.LX0 IN0.DS0 IN0.DS2 IN0.DS5 IN0.DS7 M0 M3 M4 M5
 
-Stare at those four rows for a moment. `OUT0.LX0 FROM IN0.LX0` is -clean — patch A's pre-merge Z frame propagates to OUT0's post-merge Z -frame untouched. `OUT1.LX0`, by symmetry, ought to read +Stare at those four rows for a moment. Three of them look sensible: + +* `OUT0.LX0 FROM IN0.LX0` — patch A's pre-merge Z frame propagates to + OUT0's post-merge Z frame untouched. +* `OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M6` — the auto-derived anchor for the + joint logical-X invariant $\bar X_A \bar X_B$ that the merge preserves + (with the seam-MX outcome $M_6$ absorbing the measurement-induced + sign). The flow solver notices that neither individual $\bar X$ + survives, but their product does, and it charges the surviving joint + observable to the `OUT0.LZ0` anchor — mathematically arbitrary, but + consistent. +* `OUT1.LZ0 FROM` — the mirror empty row that goes with the + `OUT0.LZ0` joint-$\bar X$ anchor above (charging it here instead + would be an equivalent choice — they differ by the projected + $\bar Z_A \bar Z_B$ stabilizer). + +But then `OUT1.LX0`, by symmetry with `OUT0.LX0`, ought to read `FROM IN1.LX0` — patch B's pre-merge Z frame propagates to OUT1's post-merge Z frame untouched. Instead the annotator produced: @@ -342,11 +341,11 @@ different port ordering would have picked the mirror. **Semantic reading**: deq's naive interpretation is *"patch B's post-merge Z frame equals patch A's pre-merge Z frame ⊕ the joint-parity readout"* — patch B has been silently rewritten to agree with patch A -up to R0. That's the `MRZZ` measure-and-reset behaviour from -Ambiguity 1's intro. It's a self-consistent logical map, but it's the -wrong one for the joint-measurement gadget we're trying to build; an -honest `MZZ` should leave both individual Z frames alone and expose the -joint parity as a *separate* readout. +up to R0. That's the `MRZZ` measure-and-reset behaviour from the +chapter intro. It's a self-consistent logical map, but it's the wrong +one for the joint-measurement gadget we're trying to build; an honest +`MZZ` should leave both individual Z frames alone and expose the joint +parity as a *separate* readout. ### Two equivalent fixes @@ -436,88 +435,6 @@ exactly that pattern under either fix, and produces the (the joint readout is right but the individual outcomes agree instead of disagreeing, exposing the silent patch-B rewrite). - -## Ambiguity 2: joint $\bar X_A \bar X_B$ preservation - -The four auto-derived `PROPAGATE` rows in Piece 2 above reveal a *second* -post-merge invariant that the framework's per-port target enumeration -fails to derive automatically: the joint logical-X observable -$\bar X_A \bar X_B$. Ambiguity 1 was about the *non-empty* row that -pointed at the wrong port (`OUT1.LX0`); Ambiguity 2 is about the two -*empty* rows in the same Piece-2 listing (`OUT0.LZ0 FROM` and -`OUT1.LZ0 FROM`), which encode the enumeration's inability to feed the -solver a target Pauli that captures the joint $\bar X$ flow. Just like -the joint $\bar Z$ sits on `OUT1.LX0` with a measurement-driven sign -flip, the joint $\bar X$ sits on `OUT0.LZ0` and absorbs a single MX-seam -outcome: - -[the two joint-$\bar X$ hand-written `PROPAGATE` rows in `MZZ`](../examples/lattice-surgery/00_lattice_surgery_library.deq#L59-L60) - -
    PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M6
-    PROPAGATE OUT1.LZ0 FROM
- - -The question is: why do these rows need to be hand-written at all? - -### Why deq's per-port target enumeration fails to auto-derive the joint-$\bar X$ flow - -The GF(2) flow solver in `_compute_pc_logical_via_flows` is fully -general: it uses `stim.Circuit.flow_generators()` to enumerate a basis -for the body's flow space and then calls `binar.solve` on a linear -system whose target column is an arbitrary symplectic Pauli vector on -the full body register. Nothing about the solver itself is per-port — -it would happily find a flow for any target Pauli that lies in the -flow-generator span, including one that spans multiple output ports. - -The limitation is the *enumeration strategy* wrapping the solver: the -outer loop calls the solver **once per output logical column**, and -each call passes a target Pauli that lives on a single output port. -For OUT0's $\bar X$ row (rendered `OUT0.LZ0`) the target is `OUT0`'s -natural $\bar X$ representative — patch A's top-row X-string -$X_0 X_1 X_2$ — and the GF(2) system has no solution: $X_0 X_1 X_2$ -anti-commutes with the MZZ measurement, so its symplectic vector is not -in the flow-generator span and `binar.solve` returns ``None``. The -same negative result holds for OUT1's $\bar X$ candidate -$X_9 X_{10} X_{11}$. Neither individual $\bar X$ survives. - -What *does* survive is the **product** $\bar X_A \cdot \bar X_B = X_0 X_1 -X_2 \cdot X_9 X_{10} X_{11}$ — that's the joint-XX Bell stabilizer, and the -merge preserves it (with one measurement bit absorbing the sign). The -GF(2) solver would happily find a flow for this joint target if the -enumeration handed it in as a single Pauli; but the outer loop only -ever enumerates single-port single-column targets, so no solver call -ever sees the joint one. - -Once both per-port candidates fail, the enumeration has no fallback: -it only walks per-port single-column targets, so the row is simply -left empty. But the user still *wants* the joint $\bar X_A \bar X_B$ -parity preserved even though neither individual $\bar X$ is determined: -it is what turns this gadget into a genuine joint-$\bar Z$ measurement -(which by definition must leave everything that commutes with joint -$\bar Z$ untouched) rather than a measure-and-then-scramble-X operation. -Because the enumeration never asks the solver about "joint XX on some -port", the user has to declare that preservation explicitly via a -hand-written `PROPAGATE` row. - -Once both candidate rows fail, the only remaining decisions are -*logical-level conventions* the framework cannot fix from circuit structure -alone: **Which port carries the joint observable?** -Adding the row to *both* -OUT0 and OUT1 would XOR them to zero in the symplectic algebra and erase -the observable. Placing it on OUT0 alone or OUT1 alone is otherwise -equivalent: the two placements differ by a $\bar Z_A \bar Z_B$ operator -(mirror-swapping which port anchors the joint $\bar X$), and the merge -has just projected the state into a $\pm 1$ eigenstate of $\bar Z_A -\bar Z_B$, so that operator acts as a global sign that any downstream -observable sees identically. - -Both decisions belong to the surgery's logical specification, not the -gadget's circuit. The framework leaves the row empty so the test suite -catches the missing declaration loudly (the `BellPair*SurvivesMerge` -programs in `00_lattice_surgery_library.deq` fail at 50% LER without the -declaration), and the user supplies a hand-written `PROPAGATE` row to pin -the conventional choice. - --- ## Error suppression: making the MZZ fault-tolerant @@ -694,14 +611,6 @@ $\mathrm{LER} \propto p^2$ scaling requires $r \geq 2$: OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17 - - # Ambiguity 2 fix: joint LX_A · LX_B preservation via the seam-MX - # sign (M0 = MX 18). Same shape as the single-round MZZ's rows in - # 00_lattice_surgery_library.deq, but with IN0.LZ0 alone standing - # in for IN0.LZ0 ⊕ IN1.LZ0 because MergeEnd's single MergedSurface - # input already carries the joint LX_A · LX_B tracker. - PROPAGATE OUT0.LZ0 FROM IN0.LZ0 M0 - PROPAGATE OUT1.LZ0 FROM } COMPOSE ComposeMZZR { @@ -714,10 +623,10 @@ $\mathrm{LER} \propto p^2$ scaling requires $r \geq 2$: } % endif MergeEnd IN(0) OUT(0 1) - # Ambiguity 1 fix (MRZZ → MZZ): flip patch B's logical-Z frame - # whenever the joint-parity readout R0 (from MergeBegin, at - # rec[-1] here) is 1. Same role as the CONDITIONAL R0 OUT1.LX0 - # byproduct inside the single-round MZZ. + # MRZZ → MZZ correction: flip patch B's logical-Z frame whenever + # the joint-parity readout R0 (from MergeBegin, at rec[-1] here) + # is 1. Same role as the CONDITIONAL R0 OUT1.LX0 byproduct + # inside the single-round MZZ. CONDITIONAL rec[-1] X0 1 OUTPUT SurfaceCode 0 OUTPUT SurfaceCode 1 @@ -783,11 +692,11 @@ strongest signature that the merge is now genuinely fault-tolerant. | Concept | Purpose | | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | -| Physical/logical ambiguity | A single physical body (six MPPs + MX seam) is consistent with many inequivalent logical actions; deq's auto-derivation either picks the wrong reading or fails outright | -| `PROPAGATE OUT1.LX0 FROM IN1.LX0` | Pins the branch-dependent frame flip, selecting the "honest joint measurement" reading (individual $\bar Z$ frames left alone). Equivalent to `CONDITIONAL R0 OUT1.LX0`, chosen here because it matches the compiled form directly | +| Physical/logical ambiguity | A single physical body (six MPPs + MX seam) is consistent with two inequivalent logical actions (`MZZ` vs `MRZZ`); deq's auto-derivation silently picks the `MRZZ` reading unless the user overrides it | +| `PROPAGATE OUT1.LX0 FROM IN1.LX0` | Pins the branch-dependent frame flip, selecting the honest `MZZ` reading (individual $\bar Z$ frames left alone). Equivalent to `CONDITIONAL R0 OUT1.LX0`, chosen here because it matches the compiled form directly | +| Auto-derived joint $\bar X_A \bar X_B$ preservation | The flow solver finds the joint-XX invariant on its own and anchors it on `OUT0.LZ0` (with the mirror empty `OUT1.LZ0` row) — no user-written `PROPAGATE` needed | | Empirical calibration for the fixed-port choice | Product-state discriminators (`ProductZZ_VirtualXA`, `ProductZZ_VirtualXB`) with deterministic outcomes falsify the wrong port | -| Hand-written `PROPAGATE OUT*.LZ0` | Hand-declares the joint $\bar X_A \bar X_B$ preservation that the per-port target enumeration misses because the enumeration only hands the solver single-port targets | -| Single-round MZZ | Structurally correct after the byproducts, but $\mathrm{LER} \approx 7 p$ across all noise rates — not fault-tolerant | +| Single-round MZZ | Structurally correct after the byproduct, but $\mathrm{LER} \approx 7 p$ across all noise rates — not fault-tolerant | | `MergeBegin` / `MergedSE` / `MergeEnd` refactor | Repeated merge measurements give the decoder temporally local edges, restoring $\mathrm{LER} \propto p^2$ at $d = 3$ (see [Composing Gadgets with COMPOSE](compose-gadgets.md) for the REPEAT mechanics) | | LER at $d = 3$, $p = 10^{-4}$ | $r = 1$: $\approx 7 \times 10^{-4}$ (above physical); $r = 3$: $\approx 5 \times 10^{-6}$ (more than an order of magnitude below physical) | diff --git a/deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq b/deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq index f2084b91..2601c95d 100644 --- a/deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq +++ b/deq/documents/tutorial/examples/lattice-surgery/00_lattice_surgery_library.deq @@ -56,8 +56,6 @@ GADGET MZZ { OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17 PROPAGATE OUT1.LX0 FROM IN1.LX0 - PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M6 - PROPAGATE OUT1.LZ0 FROM } COMPOSE ComposeMZZ { diff --git a/deq/documents/tutorial/examples/lattice-surgery/02_ls_merge_multi_round.deq b/deq/documents/tutorial/examples/lattice-surgery/02_ls_merge_multi_round.deq index 62ccb6f3..d11baf16 100644 --- a/deq/documents/tutorial/examples/lattice-surgery/02_ls_merge_multi_round.deq +++ b/deq/documents/tutorial/examples/lattice-surgery/02_ls_merge_multi_round.deq @@ -93,14 +93,6 @@ GADGET MergeEnd { OUTPUT SurfaceCode 0 1 2 3 4 5 6 7 8 OUTPUT SurfaceCode 9 10 11 12 13 14 15 16 17 - - # Ambiguity 2 fix: joint LX_A · LX_B preservation via the seam-MX - # sign (M0 = MX 18). Same shape as the single-round MZZ's rows in - # 00_lattice_surgery_library.deq, but with IN0.LZ0 alone standing - # in for IN0.LZ0 ⊕ IN1.LZ0 because MergeEnd's single MergedSurface - # input already carries the joint LX_A · LX_B tracker. - PROPAGATE OUT0.LZ0 FROM IN0.LZ0 M0 - PROPAGATE OUT1.LZ0 FROM } COMPOSE ComposeMZZR { @@ -113,10 +105,10 @@ COMPOSE ComposeMZZR { } % endif MergeEnd IN(0) OUT(0 1) - # Ambiguity 1 fix (MRZZ → MZZ): flip patch B's logical-Z frame - # whenever the joint-parity readout R0 (from MergeBegin, at - # rec[-1] here) is 1. Same role as the CONDITIONAL R0 OUT1.LX0 - # byproduct inside the single-round MZZ. + # MRZZ → MZZ correction: flip patch B's logical-Z frame whenever + # the joint-parity readout R0 (from MergeBegin, at rec[-1] here) + # is 1. Same role as the CONDITIONAL R0 OUT1.LX0 byproduct + # inside the single-round MZZ. CONDITIONAL rec[-1] X0 1 OUTPUT SurfaceCode 0 OUTPUT SurfaceCode 1 diff --git a/deq/tests/circuit/surface_code/lattice_surgery_d3.deq b/deq/tests/circuit/surface_code/lattice_surgery_d3.deq index 02163b85..a2802df4 100644 --- a/deq/tests/circuit/surface_code/lattice_surgery_d3.deq +++ b/deq/tests/circuit/surface_code/lattice_surgery_d3.deq @@ -121,34 +121,6 @@ GADGET MZZ { # ``CONDITIONAL R0 OUT0.LX0`` flips both predictions in lockstep # and fails the ``ASSERT_EQ`` checks in those programs. CONDITIONAL R0 OUT1.LX0 - - # Joint logical-X survival byproduct (manual override). - # - # The merge body's per-port Heisenberg flow has no solution for - # ``OUT0.LX0 = X0*X1*X2`` alone — that Pauli anti-commutes with - # the merge plaquette ``M0 = Z2*Z5*Z18*Z19``. - # The honest physical observable that survives is the JOINT - # logical X ``LX_A · LX_B = X0*X1*X2 · X9*X10*X11``, which DOES - # have a flow with the first MX seam outcome (``M6 = MX 18``) - # absorbing the measurement-induced sign correction. - # - # In the framework's symplectic algebra the joint observable - # ``LX_A · LX_B`` decomposes as the XOR of the two ports' - # X-direction trackers (``OUT0.LZ0 ⊕ OUT1.LZ0`` in the rendered - # label convention). What needs to hold is that the XOR of the - # two ports' rows reproduces ``IN0.LZ0 ⊕ IN1.LZ0 ⊕ M6``; one - # clean way to satisfy that is to put the full expression on a - # single port and leave the other empty. Below we put it on - # ``OUT0.LZ0`` and leave ``OUT1.LZ0`` empty. The choice doesn't - # matter here because they differ by a LZ_A · LX_A operator, - # but we are already in the +1 or -1 eigenstate of that operator. - # - # These PROPAGATE rows are authoritative — they install exactly - # the residual formula the runtime evaluates for these output - # observables, replacing the per-port flow solver's (unsolvable) - # answer. - PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M6 - PROPAGATE OUT1.LZ0 FROM } # COMPOSE wrapper exercising the COMPOSE pipeline on the joint merge. From 856de921ce92156205642a9e2958c68846dfb926 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 13 Jul 2026 10:57:23 -0700 Subject: [PATCH 048/157] optimize code --- deq/deq/transpiler/jit_noise_builder.py | 75 +++++++++++++------------ 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/deq/deq/transpiler/jit_noise_builder.py b/deq/deq/transpiler/jit_noise_builder.py index adb9d03b..7e308890 100644 --- a/deq/deq/transpiler/jit_noise_builder.py +++ b/deq/deq/transpiler/jit_noise_builder.py @@ -26,15 +26,18 @@ (input-port-observable readouts). - **Symplectic flow analysis** (:func:`_compute_pc_logical_via_flows`) drives **all** logical-row entries of ``correction_propagation`` and - ``physical_correction``. For each output logical observable we solve a - GF(2) linear system over :py:meth:`stim.Circuit.flow_generators` to - find an XOR combination of input-column observables and body-measurement - outcomes that operator-equals the output observable; the combination's - signed sum determines an affine ``FLIP`` offset. This single path - handles unitary bodies (the flow space is the body's tableau) and - measurement-bearing bodies (e.g. Floquet honeycomb rounds, where a - per-column anticom analysis cannot pick a stabilizer-group - representative consistent with the body's measurement outcomes). + ``physical_correction``. A single GF(2) null-space computation on + the augmented symplectic matrix ``[P_in | I | 0 ; P_out | 0 | O]`` + enumerates every flow-generator combination whose input and output + both lie in the respective observable-plus-stabilizer basis spans; + each yields one linear constraint on the cp / pc / ``FLIP`` rows. + The joint system is then solved column-by-column against a single + cached echelon form. This unifies unitary bodies, bodies with + internal measurements (e.g. Floquet honeycomb rounds), and + multi-port merges with joint-observable invariants (e.g. + lattice-surgery :math:`X_A X_B` preservation) --- the + per-target formulation used previously silently missed the joint + case. This module provides: @@ -55,7 +58,7 @@ from typing import Iterator, Literal, Sequence import stim -from binar import BitMatrix, BitVector, null_space, solve +from binar import BitMatrix, BitVector, EchelonForm, null_space import deq.proto.deq_bin_pb2 as pb import deq.proto.deq_jit_pb2 as jit_pb @@ -633,11 +636,15 @@ def _compute_pc_logical_via_flows( ``w`` the measurement-bit set, and a sign bit ``sigma`` from the Pauli-algebra closure. 3. Solve the GF(2) systems ``V · cp = U``, ``V · pc = W``, - ``V · flip = sigma`` column-by-column via :func:`binar.solve`. + ``V · flip = sigma`` column-by-column against one cached + echelon form of ``V``. 4. Restrict outputs to rows in ``output_layout.logical_columns``. - Output rows undetermined by any flow (rank deficiency along that - row) are silently omitted. + Output rows that are only jointly determined by other rows + (rank deficiency of ``V`` along that row) receive an arbitrary + but self-consistent anchor picked by the RREF pivot order; + mirror rows in the same null-space family stay zero. Users who + care about the specific anchor override via ``PROPAGATE``. """ body_flat = flatten_body(list(gadget.body)) num_qubits = max(max_qubit_index(list(gadget.body)) + 1, 0) @@ -648,12 +655,10 @@ def _compute_pc_logical_via_flows( body_circuit = stim.Circuit() for inst in decomposed.instructions: body_circuit.append(inst) - body_circuit = body_circuit.decomposed() # Pad with an explicit identity touching every body qubit so # ``flow_generators()`` sees the full ``num_qubits``-qubit space # even when the body has no stim instructions (e.g. a pure - # port-relabel gadget like ``Permute``). Padding *after* - # ``decomposed()`` because ``decomposed()`` strips identities. + # port-relabel gadget like ``Permute``). if num_qubits > 0: body_circuit.append("I", range(num_qubits)) @@ -698,14 +703,12 @@ def _compute_pc_logical_via_flows( + [0] * n_in + [output_symp[i][b] for i in range(n_out)] ) - if not a_rows: - return [], set(), set() a_matrix = BitMatrix(a_rows) kernel_rows = null_space(a_matrix).rows - U_rows: list[list[int]] = [] - V_rows: list[list[int]] = [] - W_rows: list[list[int]] = [] + u_rows: list[list[int]] = [] + v_rows: list[list[int]] = [] + w_rows: list[list[int]] = [] sigma_bits: list[int] = [] for vec_bv in kernel_rows: vec = [int(bit) for bit in vec_bv] @@ -751,26 +754,26 @@ def _compute_pc_logical_via_flows( f"non-real factor {sign_factor!r}; algebra bug." ) - U_rows.append(u_coeffs) - V_rows.append(v_coeffs) - W_rows.append(w_row) + u_rows.append(u_coeffs) + v_rows.append(v_coeffs) + w_rows.append(w_row) sigma_bits.append(int(sign_factor.real < 0)) - if not V_rows: + if not v_rows: return [], set(), set() - v_matrix = BitMatrix(V_rows) - n_constraints = len(V_rows) + v_echelon = EchelonForm(BitMatrix(v_rows)) + n_constraints = len(v_rows) def _solve_column(rhs_col: list[int]) -> list[int] | None: - sol = solve(v_matrix, BitVector(rhs_col)) + sol = v_echelon.solve(BitVector(rhs_col)) if sol is None: return None return [int(sol[i]) for i in range(n_out)] cp = [[0] * n_in for _ in range(n_out)] for j in range(n_in): - col = _solve_column([U_rows[k][j] for k in range(n_constraints)]) + col = _solve_column([u_rows[k][j] for k in range(n_constraints)]) if col is None: raise RuntimeError( f"jit_noise_builder: flow constraints are inconsistent " @@ -780,15 +783,15 @@ def _solve_column(rhs_col: list[int]) -> list[int] | None: cp[i][j] = col[i] pc = [[0] * n_meas for _ in range(n_out)] - for l in range(n_meas): - col = _solve_column([W_rows[k][l] for k in range(n_constraints)]) + for meas_idx in range(n_meas): + col = _solve_column([w_rows[k][meas_idx] for k in range(n_constraints)]) if col is None: raise RuntimeError( f"jit_noise_builder: flow constraints are inconsistent " - f"for measurement {l}" + f"for measurement {meas_idx}" ) for i in range(n_out): - pc[i][l] = col[i] + pc[i][meas_idx] = col[i] flip_col = _solve_column(sigma_bits) if flip_col is None: @@ -803,9 +806,9 @@ def _solve_column(rhs_col: list[int]) -> list[int] | None: for j in range(n_in): if cp[i][j]: cp_entries.add((i, j)) - for l in range(n_meas): - if pc[i][l]: - pc_entries.append((i, l)) + for meas_idx in range(n_meas): + if pc[i][meas_idx]: + pc_entries.append((i, meas_idx)) if flip_col[i]: flip_entries.add(i) From f0d5d0a76478f661f0d70d269c05522df3d35737 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 13 Jul 2026 12:38:48 -0700 Subject: [PATCH 049/157] minor fixes --- deq/documents/tutorial/chapters/lattice-surgery.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deq/documents/tutorial/chapters/lattice-surgery.md b/deq/documents/tutorial/chapters/lattice-surgery.md index 065352cd..5e2c244f 100644 --- a/deq/documents/tutorial/chapters/lattice-surgery.md +++ b/deq/documents/tutorial/chapters/lattice-surgery.md @@ -512,7 +512,7 @@ The example file Mako-parametrizes the SE-round count `r` ($r \geq 0$) via a COMPOSE-level `REPEAT ${r} { MergedSE }`. Because `MergeBegin`'s measurements are consumed by the readout, the earliest time edge on the joint stabilizer is between `MergedSE` rounds — recovering the -$\mathrm{LER} \propto p^2$ scaling requires $r \geq 2$: +$\mathrm{LER} \propto p^2$ scaling requires $r \geq 3$: [`MergedSurface` / `MergeBegin` / `MergedSE` / `MergeEnd` / `ComposeMZZR` (Mako-parametric)](../examples/lattice-surgery/02_ls_merge_multi_round.deq) From dd7e863b734fe4ad939992f3f5894494dfab8460 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 13 Jul 2026 13:03:25 -0700 Subject: [PATCH 050/157] fix repetition code n,k,d --- deq/documents/tutorial/README.md | 2 +- .../tutorial/chapters/codes-multi-logical.md | 2 +- .../chapters/codes-redundant-stabilizers.md | 6 +- .../tutorial/chapters/compose-gadgets.md | 12 +-- .../tutorial/chapters/debug-deq-program.md | 2 +- .../tutorial/chapters/language-basics.md | 10 +-- .../tutorial/chapters/mako-parametrization.md | 14 ++-- .../tutorial/chapters/multi-port-gadgets.md | 2 +- .../tutorial/chapters/readout-propagation.md | 6 +- .../examples/compose/01_flat_3idle.deq | 2 +- .../examples/compose/02_compose_3idle.deq | 2 +- .../examples/compose/03_nested_compose.deq | 2 +- .../examples/intro/repetition_code.deq | 2 +- .../tutorial/examples/intro/small_example.deq | 2 +- .../examples/language/01_prepare_measure.deq | 2 +- .../tutorial/examples/language/02_noisy.deq | 2 +- .../examples/language/03_with_idle.deq | 2 +- .../examples/language/04_manual_checks.deq | 2 +- .../tutorial/examples/language/05_library.deq | 2 +- .../loss-simulation/repetition_code.deq | 2 +- .../tutorial/examples/mako/01_fixed_d3.deq | 2 +- .../examples/mako/02_parametrized.deq | 2 +- .../examples/mako/02_parametrized_d5.deq | 2 +- .../tutorial/examples/mako/03_include.deq | 2 +- .../examples/multi-port/01_noiseless.deq | 2 +- .../tutorial/examples/multi-port/02_noisy.deq | 2 +- .../examples/multi-port/03_redundant.deq | 2 +- .../01_non_redundant.deq | 2 +- .../redundant-stabilizers/02_redundant.deq | 2 +- deq/tests/circuit/fixtures/example.deq | 2 +- deq/tests/circuit/fixtures/imports/codes.deq | 2 +- .../exercise_readout_conditions.deq | 2 +- .../repetition_code/repetition_code.deq | 2 +- .../repetition_code/repetition_code_d3.deq | 2 +- .../repetition_code/repetition_code_d5.deq | 2 +- deq/tests/circuit/test_deq.py | 12 +-- deq/tests/circuit/test_noise_injection.py | 2 +- deq/tests/runtime/test_sampler.py | 6 +- .../repetition_code/repetition_code.deq | 2 +- .../repetition_code_d3.auto.ref.deq | 2 +- .../repetition_code_d3.syndrome-meta.ref.deq | 2 +- .../repetition_code_d3.syndrome.ref.deq | 2 +- .../repetition_code_d3.transversal.ref.deq | 2 +- deq/tests/transpiler/jit_annotate_test.py | 16 ++-- .../transpiler/jit_library_builder_test.py | 84 +++++++++---------- deq/tests/transpiler/jit_propagate_test.py | 6 +- deq/tests/transpiler/jit_transpiler_test.py | 2 +- deq/tests/transpiler/metachecks_test.py | 6 +- deq/tests/transpiler/mpp_test.py | 2 +- .../transpiler/test_compose_repropagate.py | 4 +- 50 files changed, 129 insertions(+), 129 deletions(-) diff --git a/deq/documents/tutorial/README.md b/deq/documents/tutorial/README.md index ec92aea1..9f3878d0 100644 --- a/deq/documents/tutorial/README.md +++ b/deq/documents/tutorial/README.md @@ -10,7 +10,7 @@ deq has the following features: [A minimal CODE + GADGET definition](examples/intro/small_example.deq)
# define a QEC code of [[n,k,d]] (d is optional)
-CODE RepetitionCode [[3,1,3]] {
+CODE RepetitionCode [[3,1,1]] {
     LOGICAL X0*X1*X2 Z0*Z1*Z2
     STABILIZER Z0*Z1 Z1*Z2
 }
diff --git a/deq/documents/tutorial/chapters/codes-multi-logical.md b/deq/documents/tutorial/chapters/codes-multi-logical.md
index 5511f1fe..6ccb7e7e 100644
--- a/deq/documents/tutorial/chapters/codes-multi-logical.md
+++ b/deq/documents/tutorial/chapters/codes-multi-logical.md
@@ -1,7 +1,7 @@
 # Codes with Multiple Logical Qubits ($k > 1$)
 
 In the [language basics chapter](language-basics.md), we defined the repetition code
-$[[3,1,3]]$ which encodes a single logical qubit ($k=1$). Many practical QEC codes encode
+$[[3,1,1]]$ which encodes a single logical qubit ($k=1$). Many practical QEC codes encode
 **multiple logical qubits** in a single code block — for instance, the $[[15,7,3]]$ quantum
 Hamming code encodes 7 logical qubits in 15 physical qubits with distance 3.
 
diff --git a/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md b/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md
index 9abe1031..44e81d65 100644
--- a/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md
+++ b/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md
@@ -14,7 +14,7 @@ produces cleaner error models when redundant stabilizers are declared.
 
 ## The Setup: 3 Ancillae, but How Many Stabilizers?
 
-Consider a repetition code $[[3,1,3]]$ where the syndrome extraction circuit uses
+Consider a repetition code $[[3,1,1]]$ where the syndrome extraction circuit uses
 **3 ancilla qubits** to measure:
 - Ancilla 1: $Z_0 Z_1$ (parity of qubits 0 and 1)
 - Ancilla 3: $Z_1 Z_2$ (parity of qubits 1 and 2)
@@ -33,7 +33,7 @@ The question is: should we declare 2 or 3 stabilizers in the `CODE` block?
 
 [Non-redundant code definition](../examples/redundant-stabilizers/snippet_code_non_redundant.deq)
 
-
CODE RepetitionCode [[3,1,3]] {
+
CODE RepetitionCode [[3,1,1]] {
     LOGICAL X0*X1*X2 Z0*Z1*Z2
     STABILIZER Z0*Z1 Z1*Z2
 }
@@ -114,7 +114,7 @@ The annotated output for the Idle gadget reveals the problem: [Redundant code definition](../examples/redundant-stabilizers/snippet_code_redundant.deq) -
CODE RepetitionCode [[3,1,3]] {
+
CODE RepetitionCode [[3,1,1]] {
     LOGICAL X0*X1*X2 Z0*Z1*Z2
     STABILIZER Z0*Z1 Z1*Z2 Z0*Z2
 }
diff --git a/deq/documents/tutorial/chapters/compose-gadgets.md b/deq/documents/tutorial/chapters/compose-gadgets.md index fe932af4..335cc896 100644 --- a/deq/documents/tutorial/chapters/compose-gadgets.md +++ b/deq/documents/tutorial/chapters/compose-gadgets.md @@ -29,7 +29,7 @@ Let's write a single gadget with 3 rounds of syndrome extraction:
# Flat 3-round syndrome extraction: all 3 rounds in a single gadget
 # This demonstrates the problem with auto-derived checks spanning all rounds
 
-CODE RepetitionCode [[3,1,3]] {
+CODE RepetitionCode [[3,1,1]] {
     LOGICAL X0*X1*X2 Z0*Z1*Z2
     STABILIZER Z0*Z1 Z1*Z2
 }
@@ -74,7 +74,7 @@ The circuit is physically identical to running the Idle gadget 3 times. Running
 [Annotated flat 3-round gadget](../examples/compose/01_flat_3idle.annotated.deq)
 
 
@PTYPE(1)
-CODE RepetitionCode [[3,1,3]] {
+CODE RepetitionCode [[3,1,1]] {
     LOGICAL X0*X1*X2 Z0*Z1*Z2
     STABILIZER Z0*Z1  # generator S0, destabilizer DS0=X1*X2
     STABILIZER Z1*Z2  # generator S1, destabilizer DS1=X0*X1
@@ -234,7 +234,7 @@ sub-gadget's error locality:
 
# COMPOSE version: 3 rounds of syndrome extraction via composition
 # Demonstrates well-structured checks by construction
 
-CODE RepetitionCode [[3,1,3]] {
+CODE RepetitionCode [[3,1,1]] {
     LOGICAL X0*X1*X2 Z0*Z1*Z2
     STABILIZER Z0*Z1 Z1*Z2
 }
@@ -307,7 +307,7 @@ Running `annotate` on the COMPOSE version produces a flattened `GADGET` block:
 [Annotated COMPOSE 3-round gadget](../examples/compose/02_compose_3idle.annotated.deq)
 
 
@PTYPE(1)
-CODE RepetitionCode [[3,1,3]] {
+CODE RepetitionCode [[3,1,1]] {
     LOGICAL X0*X1*X2 Z0*Z1*Z2
     STABILIZER Z0*Z1  # generator S0, destabilizer DS0=X1*X2
     STABILIZER Z1*Z2  # generator S1, destabilizer DS1=X0*X1
@@ -585,7 +585,7 @@ enables hierarchical composition:
 
# Nested COMPOSE: Idle4 = Idle3 + Idle
 # Demonstrates composing composed gadgets
 
-CODE RepetitionCode [[3,1,3]] {
+CODE RepetitionCode [[3,1,1]] {
     LOGICAL X0*X1*X2 Z0*Z1*Z2
     STABILIZER Z0*Z1 Z1*Z2
 }
@@ -665,7 +665,7 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl
 [Annotated nested COMPOSE](../examples/compose/03_nested_compose.annotated.deq)
 
 
@PTYPE(1)
-CODE RepetitionCode [[3,1,3]] {
+CODE RepetitionCode [[3,1,1]] {
     LOGICAL X0*X1*X2 Z0*Z1*Z2
     STABILIZER Z0*Z1  # generator S0, destabilizer DS0=X1*X2
     STABILIZER Z1*Z2  # generator S1, destabilizer DS1=X0*X1
diff --git a/deq/documents/tutorial/chapters/debug-deq-program.md b/deq/documents/tutorial/chapters/debug-deq-program.md
index a0ed3bda..17623dac 100644
--- a/deq/documents/tutorial/chapters/debug-deq-program.md
+++ b/deq/documents/tutorial/chapters/debug-deq-program.md
@@ -27,7 +27,7 @@ Output:
 [Annotated 03_with_idle.deq](../examples/debug/03_with_idle.annotated.deq)
 
 
@PTYPE(1)
-CODE RepetitionCode [[3,1,3]] {
+CODE RepetitionCode [[3,1,1]] {
     LOGICAL X0*X1*X2 Z0*Z1*Z2
     STABILIZER Z0*Z1  # generator S0, destabilizer DS0=X1*X2
     STABILIZER Z1*Z2  # generator S1, destabilizer DS1=X0*X1
diff --git a/deq/documents/tutorial/chapters/language-basics.md b/deq/documents/tutorial/chapters/language-basics.md
index 9669ab4f..3f8b54fc 100644
--- a/deq/documents/tutorial/chapters/language-basics.md
+++ b/deq/documents/tutorial/chapters/language-basics.md
@@ -79,7 +79,7 @@ quantum error correction code:
 
# A minimal example: prepare and measure a repetition code
 # No noise, no syndrome extraction — just the simplest possible circuit
 
-CODE RepetitionCode [[3,1,3]] {
+CODE RepetitionCode [[3,1,1]] {
     LOGICAL X0*X1*X2 Z0*Z1*Z2
     STABILIZER Z0*Z1 Z1*Z2
 }
@@ -106,7 +106,7 @@ The `CODE` block has three parts:
 
 | Keyword                         | Purpose                                                                                                                                                                                    |
 | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `CODE RepetitionCode [[3,1,3]]` | Declares a code named `RepetitionCode` with parameters $[[n, k, d]] = [[3, 1, 3]]$. The distance $d$ is optional and not used by the system — it's for documentation                       |
+| `CODE RepetitionCode [[3,1,1]]` | Declares a code named `RepetitionCode` with parameters $[[n, k, d]] = [[3, 1, 1]]$. The distance $d$ is optional and not used by the system — it's for documentation                       |
 | `LOGICAL X0*X1*X2 Z0*Z1*Z2`     | Specifies the logical operators. Each space-separated entry is one logical operator pair (X and Z for each logical qubit). Pauli products use `*` notation: `X0*X1*X2` means $X_0 X_1 X_2$ |
 | `STABILIZER Z0*Z1 Z1*Z2`        | Lists the stabilizer generators, space-separated. `Z0*Z1` means $Z_0 Z_1$                                                                                                                  |
 
@@ -280,7 +280,7 @@ needs to repeat this expensive analysis:
 
 
# Adding noise to see how error effects are analyzed offline by the transpiler
 
-CODE RepetitionCode [[3,1,3]] {
+CODE RepetitionCode [[3,1,1]] {
     LOGICAL X0*X1*X2 Z0*Z1*Z2
     STABILIZER Z0*Z1 Z1*Z2
 }
@@ -368,7 +368,7 @@ Now let's add a syndrome extraction round between preparation and measurement:
 
 
# Full example with syndrome extraction (Idle gadget)
 
-CODE RepetitionCode [[3,1,3]] {
+CODE RepetitionCode [[3,1,1]] {
     LOGICAL X0*X1*X2 Z0*Z1*Z2
     STABILIZER Z0*Z1 Z1*Z2
 }
@@ -724,7 +724,7 @@ The relationship between the `.deq` source and the `.deq.jit.txt` output:
 
 | `.deq` source                                             | `.deq.jit.txt` output                                                                                    |
 | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
-| `CODE RepetitionCode [[3,1,3]] { STABILIZER Z0*Z1 Z1*Z2 }` | `port_types { stabilizers { tag: "Z0*Z1" } stabilizers { tag: "Z1*Z2" } }`                                |
+| `CODE RepetitionCode [[3,1,1]] { STABILIZER Z0*Z1 Z1*Z2 }` | `port_types { stabilizers { tag: "Z0*Z1" } stabilizers { tag: "Z1*Z2" } }`                                |
 | `GADGET Idle { ... M 1 3 ... }`                            | `gadget_types { base { measurements { tag: "M 1" } measurements { tag: "M 3" } } ... }`                   |
 | `INPUT RepetitionCode 0 2 4`                               | `inputs { ptype: 1 }` in the gadget's base                                                                |
 | `OUTPUT RepetitionCode 0 2 4`                              | `outputs { ptype: 1 }` in the gadget's base                                                               |
diff --git a/deq/documents/tutorial/chapters/mako-parametrization.md b/deq/documents/tutorial/chapters/mako-parametrization.md
index 80890d5b..6db524f9 100644
--- a/deq/documents/tutorial/chapters/mako-parametrization.md
+++ b/deq/documents/tutorial/chapters/mako-parametrization.md
@@ -20,7 +20,7 @@ Here is a simple repetition code memory experiment hardcoded at $d = 3$ and $p =
 
 
# A repetition code memory experiment — hardcoded at d=3, p=0.05
 
-CODE RepetitionCode [[3,1,3]] {
+CODE RepetitionCode [[3,1,1]] {
     LOGICAL X0*X1*X2 Z0*Z1*Z2
     STABILIZER Z0*Z1 Z1*Z2
 }
@@ -67,7 +67,7 @@ Here is a simple repetition code memory experiment hardcoded at $d = 3$ and $p =
 
 
 To switch to $d = 5$, you would need to change:
-- The `CODE` block: `[[3,1,3]]` → `[[5,1,5]]`, add `X3*X4` / `Z3*Z4` to the logicals,
+- The `CODE` block: `[[3,1,1]]` → `[[5,1,1]]`, add `X3*X4` / `Z3*Z4` to the logicals,
   add two more stabilizers
 - Every `GADGET`: update qubit indices (`R 0 1 2` → `R 0 1 2 3 4`), add more `CX`
   pairs, more ancillae, more measurements
@@ -109,7 +109,7 @@ used for computed values:
 
 [Parametrized CODE block](../examples/mako/snippet_mako_code.deq)
 
-
CODE RepetitionCode [[${d},1,${d}]] {
+
CODE RepetitionCode [[${d},1,1]] {
     LOGICAL ${"*".join(f"X{i}" for i in range(d))} ${"*".join(f"Z{i}" for i in range(d))}
     STABILIZER ${" ".join(f"Z{i}*Z{i+1}" for i in range(d-1))}
 }
@@ -146,7 +146,7 @@ Here is the same repetition code, fully parametrized with Mako: d = int(context.get('d', 3)) p = float(context.get('p', 0.05)) %> -CODE RepetitionCode [[${d},1,${d}]] { +CODE RepetitionCode [[${d},1,1]] { LOGICAL ${"*".join(f"X{i}" for i in range(d))} ${"*".join(f"Z{i}" for i in range(d))} STABILIZER ${" ".join(f"Z{i}*Z{i+1}" for i in range(d-1))} } @@ -198,7 +198,7 @@ Let's compare key lines between the fixed and parametrized versions: | Element | Fixed ($d = 3$) | Parametrized | | -------------- | -------------------------------- | ------------------------------------------------------------------ | -| CODE header | `[[3,1,3]]` | `[[${d},1,${d}]]` | +| CODE header | `[[3,1,1]]` | `[[${d},1,1]]` | | Logical X | `X0*X1*X2` | `${"*".join(f"X{i}" for i in range(d))}` | | Stabilizers | `Z0*Z1 Z1*Z2` | `${" ".join(f"Z{i}*Z{i+1}" for i in range(d-1))}` | | Data qubit list | `0 1 2` | `${" ".join(str(i) for i in range(d))}` | @@ -241,7 +241,7 @@ Here is the result with $d = 5$: [Rendered output at d=5](../examples/mako/02_parametrized_d5.deq)

-CODE RepetitionCode [[5,1,5]] {
+CODE RepetitionCode [[5,1,1]] {
     LOGICAL X0*X1*X2*X3*X4 Z0*Z1*Z2*Z3*Z4
     STABILIZER Z0*Z1 Z1*Z2 Z2*Z3 Z3*Z4
 }
@@ -353,7 +353,7 @@ copy-pasting:
 
# Demonstrates Mako's include directive to inline an existing stim
 # circuit file into a gadget body, avoiding copy-paste
 
-CODE RepetitionCode [[3,1,3]] {
+CODE RepetitionCode [[3,1,1]] {
     LOGICAL X0*X1*X2 Z0*Z1*Z2
     STABILIZER Z0*Z1 Z1*Z2
 }
diff --git a/deq/documents/tutorial/chapters/multi-port-gadgets.md b/deq/documents/tutorial/chapters/multi-port-gadgets.md
index e77fa653..0afef57e 100644
--- a/deq/documents/tutorial/chapters/multi-port-gadgets.md
+++ b/deq/documents/tutorial/chapters/multi-port-gadgets.md
@@ -46,7 +46,7 @@ The most interesting part is the TransversalCNOT's transpiler output. Even thoug
 
# Transversal CNOT on the repetition code — no noise.
 # Demonstrates a multi-port gadget with 2 INPUT and 2 OUTPUT ports.
 
-CODE RepetitionCode [[3,1,3]] {
+CODE RepetitionCode [[3,1,1]] {
     LOGICAL X0*X1*X2 Z0*Z1*Z2
     STABILIZER Z0*Z1 Z1*Z2
 }
diff --git a/deq/documents/tutorial/chapters/readout-propagation.md b/deq/documents/tutorial/chapters/readout-propagation.md
index 9aa1b688..7d5399b4 100644
--- a/deq/documents/tutorial/chapters/readout-propagation.md
+++ b/deq/documents/tutorial/chapters/readout-propagation.md
@@ -15,7 +15,7 @@ And what does the trailing `# IN1.LX0 IN1.DS0 IN1.DS1` comment mean?
 This chapter tells the story of what a `READOUT` statement really declares,
 why the transpiler sometimes needs explicit input-frame tokens on that line,
 and how to read them when you see them.  A single fixture built on the
-[[3,1,3]] repetition code —
+[[3,1,1]] repetition code —
 `tests/circuit/repetition_code/exercise_readout_conditions.deq` — is enough
 to see every mechanism at work.
 
@@ -108,7 +108,7 @@ no explicit tokens are needed on the `READOUT` line.  Consider
 }
-The [[3,1,3]] repetition code declares its logical `bar Z` representative as +The [[3,1,1]] repetition code declares its logical `bar Z` representative as `Z_0` (see `LOGICAL X0*X1*X2 Z0` in `repetition_code_d3.deq`). This gadget instead reads `rec[-1] = M2` — the measurement of qubit 2 — which samples the operator $Z_2$. These are the *same* logical operator up to a product of @@ -257,7 +257,7 @@ representative by several patch stabilizers, so its `rp` row picks up `IN

.DS` entries alongside the two logical columns, and any compose that carries `MZZ`'s dependencies past the walker's physical horizon (via qubit reuse, reset, or a `CONDITIONAL`) needs explicit destabilizer tokens -in exactly the same shape as the [[3,1,3]] example above. +in exactly the same shape as the [[3,1,1]] example above. ## When should you write explicit input tokens by hand? diff --git a/deq/documents/tutorial/examples/compose/01_flat_3idle.deq b/deq/documents/tutorial/examples/compose/01_flat_3idle.deq index d9b3166a..5a84e62b 100644 --- a/deq/documents/tutorial/examples/compose/01_flat_3idle.deq +++ b/deq/documents/tutorial/examples/compose/01_flat_3idle.deq @@ -1,7 +1,7 @@ # Flat 3-round syndrome extraction: all 3 rounds in a single gadget # This demonstrates the problem with auto-derived checks spanning all rounds -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/documents/tutorial/examples/compose/02_compose_3idle.deq b/deq/documents/tutorial/examples/compose/02_compose_3idle.deq index 07865516..d63924cc 100644 --- a/deq/documents/tutorial/examples/compose/02_compose_3idle.deq +++ b/deq/documents/tutorial/examples/compose/02_compose_3idle.deq @@ -1,7 +1,7 @@ # COMPOSE version: 3 rounds of syndrome extraction via composition # Demonstrates well-structured checks by construction -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/documents/tutorial/examples/compose/03_nested_compose.deq b/deq/documents/tutorial/examples/compose/03_nested_compose.deq index 8104a5d8..7bdb8ec6 100644 --- a/deq/documents/tutorial/examples/compose/03_nested_compose.deq +++ b/deq/documents/tutorial/examples/compose/03_nested_compose.deq @@ -1,7 +1,7 @@ # Nested COMPOSE: Idle4 = Idle3 + Idle # Demonstrates composing composed gadgets -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/documents/tutorial/examples/intro/repetition_code.deq b/deq/documents/tutorial/examples/intro/repetition_code.deq index 945a5222..f2becb4b 100644 --- a/deq/documents/tutorial/examples/intro/repetition_code.deq +++ b/deq/documents/tutorial/examples/intro/repetition_code.deq @@ -4,7 +4,7 @@ # the data qubits are indices from 0 to n-1. # the logical basis must be declared sequentially # the code should be defined by a list of stabilizers -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { # X basis followed by Z basis LOGICAL X0*X1*X2 Z0*Z1*Z2 # each element is a stabilizer, stabilizers can be over-complete and is often diff --git a/deq/documents/tutorial/examples/intro/small_example.deq b/deq/documents/tutorial/examples/intro/small_example.deq index c52f3b49..3a19d7d3 100644 --- a/deq/documents/tutorial/examples/intro/small_example.deq +++ b/deq/documents/tutorial/examples/intro/small_example.deq @@ -1,5 +1,5 @@ # define a QEC code of [[n,k,d]] (d is optional) -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/documents/tutorial/examples/language/01_prepare_measure.deq b/deq/documents/tutorial/examples/language/01_prepare_measure.deq index a0f22228..90000f4d 100644 --- a/deq/documents/tutorial/examples/language/01_prepare_measure.deq +++ b/deq/documents/tutorial/examples/language/01_prepare_measure.deq @@ -1,7 +1,7 @@ # A minimal example: prepare and measure a repetition code # No noise, no syndrome extraction — just the simplest possible circuit -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/documents/tutorial/examples/language/02_noisy.deq b/deq/documents/tutorial/examples/language/02_noisy.deq index edc345e5..f876e4ef 100644 --- a/deq/documents/tutorial/examples/language/02_noisy.deq +++ b/deq/documents/tutorial/examples/language/02_noisy.deq @@ -1,6 +1,6 @@ # Adding noise to see how error effects are analyzed offline by the transpiler -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/documents/tutorial/examples/language/03_with_idle.deq b/deq/documents/tutorial/examples/language/03_with_idle.deq index deb24e0e..4ef30e7e 100644 --- a/deq/documents/tutorial/examples/language/03_with_idle.deq +++ b/deq/documents/tutorial/examples/language/03_with_idle.deq @@ -1,6 +1,6 @@ # Full example with syndrome extraction (Idle gadget) -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/documents/tutorial/examples/language/04_manual_checks.deq b/deq/documents/tutorial/examples/language/04_manual_checks.deq index 4e8bfc5b..8d27e761 100644 --- a/deq/documents/tutorial/examples/language/04_manual_checks.deq +++ b/deq/documents/tutorial/examples/language/04_manual_checks.deq @@ -1,7 +1,7 @@ # Manual check mode: user specifies checks explicitly # Same physical circuit as 03_with_idle.deq, but with manual check annotations -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/documents/tutorial/examples/language/05_library.deq b/deq/documents/tutorial/examples/language/05_library.deq index 2d2c06b9..d0f21231 100644 --- a/deq/documents/tutorial/examples/language/05_library.deq +++ b/deq/documents/tutorial/examples/language/05_library.deq @@ -1,5 +1,5 @@ # Library file: code and gadget definitions -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/documents/tutorial/examples/loss-simulation/repetition_code.deq b/deq/documents/tutorial/examples/loss-simulation/repetition_code.deq index 20307d9b..e1468f4f 100644 --- a/deq/documents/tutorial/examples/loss-simulation/repetition_code.deq +++ b/deq/documents/tutorial/examples/loss-simulation/repetition_code.deq @@ -57,7 +57,7 @@ fresh = [2 * d - 1 + i for i in range(d)] # starts the next round in alive ``|0>`` — i.e. persistent loss is # converted into a one-cycle random-bit-flip syndrome. -CODE Rep [[${d}, 1, ${d}]] { +CODE Rep [[${d}, 1, 1]] { LOGICAL ${"*".join(f"X{i}" for i in range(d))} Z0 STABILIZER ${" ".join(f"Z{i}*Z{i+1}" for i in range(d - 1))} } diff --git a/deq/documents/tutorial/examples/mako/01_fixed_d3.deq b/deq/documents/tutorial/examples/mako/01_fixed_d3.deq index 05469f9d..bf030b8b 100644 --- a/deq/documents/tutorial/examples/mako/01_fixed_d3.deq +++ b/deq/documents/tutorial/examples/mako/01_fixed_d3.deq @@ -1,6 +1,6 @@ # A repetition code memory experiment — hardcoded at d=3, p=0.05 -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/documents/tutorial/examples/mako/02_parametrized.deq b/deq/documents/tutorial/examples/mako/02_parametrized.deq index be4be5d5..18a83376 100644 --- a/deq/documents/tutorial/examples/mako/02_parametrized.deq +++ b/deq/documents/tutorial/examples/mako/02_parametrized.deq @@ -3,7 +3,7 @@ d = int(context.get('d', 3)) p = float(context.get('p', 0.05)) %> -CODE RepetitionCode [[${d},1,${d}]] { +CODE RepetitionCode [[${d},1,1]] { LOGICAL ${"*".join(f"X{i}" for i in range(d))} ${"*".join(f"Z{i}" for i in range(d))} STABILIZER ${" ".join(f"Z{i}*Z{i+1}" for i in range(d-1))} } diff --git a/deq/documents/tutorial/examples/mako/02_parametrized_d5.deq b/deq/documents/tutorial/examples/mako/02_parametrized_d5.deq index 39c2a3e0..96996380 100644 --- a/deq/documents/tutorial/examples/mako/02_parametrized_d5.deq +++ b/deq/documents/tutorial/examples/mako/02_parametrized_d5.deq @@ -1,5 +1,5 @@ -CODE RepetitionCode [[5,1,5]] { +CODE RepetitionCode [[5,1,1]] { LOGICAL X0*X1*X2*X3*X4 Z0*Z1*Z2*Z3*Z4 STABILIZER Z0*Z1 Z1*Z2 Z2*Z3 Z3*Z4 } diff --git a/deq/documents/tutorial/examples/mako/03_include.deq b/deq/documents/tutorial/examples/mako/03_include.deq index 2da5a2c9..8476c801 100644 --- a/deq/documents/tutorial/examples/mako/03_include.deq +++ b/deq/documents/tutorial/examples/mako/03_include.deq @@ -1,7 +1,7 @@ # Demonstrates Mako's include directive to inline an existing stim # circuit file into a gadget body, avoiding copy-paste -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/documents/tutorial/examples/multi-port/01_noiseless.deq b/deq/documents/tutorial/examples/multi-port/01_noiseless.deq index 32467dc1..6f7dbda8 100644 --- a/deq/documents/tutorial/examples/multi-port/01_noiseless.deq +++ b/deq/documents/tutorial/examples/multi-port/01_noiseless.deq @@ -1,7 +1,7 @@ # Transversal CNOT on the repetition code — no noise. # Demonstrates a multi-port gadget with 2 INPUT and 2 OUTPUT ports. -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/documents/tutorial/examples/multi-port/02_noisy.deq b/deq/documents/tutorial/examples/multi-port/02_noisy.deq index a1ecf4cc..1c54bd86 100644 --- a/deq/documents/tutorial/examples/multi-port/02_noisy.deq +++ b/deq/documents/tutorial/examples/multi-port/02_noisy.deq @@ -1,6 +1,6 @@ # Transversal CNOT with noise — shows cross-block error propagation. -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/documents/tutorial/examples/multi-port/03_redundant.deq b/deq/documents/tutorial/examples/multi-port/03_redundant.deq index c37bca63..f1462b3b 100644 --- a/deq/documents/tutorial/examples/multi-port/03_redundant.deq +++ b/deq/documents/tutorial/examples/multi-port/03_redundant.deq @@ -1,6 +1,6 @@ # Transversal CNOT with redundant stabilizers — does redundancy leak into checks? -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 Z0*Z2 # redundant: Z0*Z2 = Z0*Z1 · Z1*Z2 } diff --git a/deq/documents/tutorial/examples/redundant-stabilizers/01_non_redundant.deq b/deq/documents/tutorial/examples/redundant-stabilizers/01_non_redundant.deq index 037a1cc2..9c392540 100644 --- a/deq/documents/tutorial/examples/redundant-stabilizers/01_non_redundant.deq +++ b/deq/documents/tutorial/examples/redundant-stabilizers/01_non_redundant.deq @@ -3,7 +3,7 @@ # Because the CODE only declares 2 stabilizers, the transpiler must express # the redundant measurement as a combination — producing hyperedges. -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/documents/tutorial/examples/redundant-stabilizers/02_redundant.deq b/deq/documents/tutorial/examples/redundant-stabilizers/02_redundant.deq index c17a4f76..638e7843 100644 --- a/deq/documents/tutorial/examples/redundant-stabilizers/02_redundant.deq +++ b/deq/documents/tutorial/examples/redundant-stabilizers/02_redundant.deq @@ -1,7 +1,7 @@ # Same circuit as 01_non_redundant.deq, but with all 3 stabilizers declared. # Now each ancilla maps to exactly one stabilizer — no hyperedges. -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 Z0*Z2 } diff --git a/deq/tests/circuit/fixtures/example.deq b/deq/tests/circuit/fixtures/example.deq index b3be5538..e7b8710d 100644 --- a/deq/tests/circuit/fixtures/example.deq +++ b/deq/tests/circuit/fixtures/example.deq @@ -4,7 +4,7 @@ # the data qubits are indices from 0 to n-1. # the logical basis must be declared sequentially # the code should be defined by a list of stabilizers -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { # X basis followed by Z basis LOGICAL X0*X1*X2 Z0*Z1*Z2 # each element is a stabilizer, stabilizers can be over-complete and is often diff --git a/deq/tests/circuit/fixtures/imports/codes.deq b/deq/tests/circuit/fixtures/imports/codes.deq index fe5aded1..ad5b24f8 100644 --- a/deq/tests/circuit/fixtures/imports/codes.deq +++ b/deq/tests/circuit/fixtures/imports/codes.deq @@ -1,4 +1,4 @@ -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/tests/circuit/repetition_code/exercise_readout_conditions.deq b/deq/tests/circuit/repetition_code/exercise_readout_conditions.deq index 397560b4..589bc03f 100644 --- a/deq/tests/circuit/repetition_code/exercise_readout_conditions.deq +++ b/deq/tests/circuit/repetition_code/exercise_readout_conditions.deq @@ -2,7 +2,7 @@ # Exercise destabilizer contributions on `READOUT` propagation. # ============================================================================= # -# Two independent [[3,1,3]] repetition-code patches linked only by a classical +# Two independent [[3,1,1]] repetition-code patches linked only by a classical # feed-forward (`CONDITIONAL rec[-1] X0 1`). Triggers the case where a # compose's compiled `rp` row has entries in **destabilizer** columns of the # input frame that the walker cannot see physically, forcing the annotator to diff --git a/deq/tests/circuit/repetition_code/repetition_code.deq b/deq/tests/circuit/repetition_code/repetition_code.deq index 9d4d0c17..355d8b46 100644 --- a/deq/tests/circuit/repetition_code/repetition_code.deq +++ b/deq/tests/circuit/repetition_code/repetition_code.deq @@ -3,7 +3,7 @@ d = int(context.get('d', 3)) p = float(context.get('p', 0.05)) %> -CODE RepetitionCode [[${d},1,${d}]] { +CODE RepetitionCode [[${d},1,1]] { LOGICAL ${"*".join(f"X{i}" for i in range(d))} Z0 STABILIZER ${" ".join(f"Z{i}*Z{i+1}" for i in range(d-1))} } diff --git a/deq/tests/circuit/repetition_code/repetition_code_d3.deq b/deq/tests/circuit/repetition_code/repetition_code_d3.deq index c266dc2f..3dd2fca1 100644 --- a/deq/tests/circuit/repetition_code/repetition_code_d3.deq +++ b/deq/tests/circuit/repetition_code/repetition_code_d3.deq @@ -1,5 +1,5 @@ -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/tests/circuit/repetition_code/repetition_code_d5.deq b/deq/tests/circuit/repetition_code/repetition_code_d5.deq index e444311a..63b462e3 100644 --- a/deq/tests/circuit/repetition_code/repetition_code_d5.deq +++ b/deq/tests/circuit/repetition_code/repetition_code_d5.deq @@ -1,5 +1,5 @@ -CODE RepetitionCode [[5,1,5]] { +CODE RepetitionCode [[5,1,1]] { LOGICAL X0*X1*X2*X3*X4 Z0 STABILIZER Z0*Z1 Z1*Z2 Z2*Z3 Z3*Z4 } diff --git a/deq/tests/circuit/test_deq.py b/deq/tests/circuit/test_deq.py index 502d9037..d18bbe9b 100644 --- a/deq/tests/circuit/test_deq.py +++ b/deq/tests/circuit/test_deq.py @@ -95,7 +95,7 @@ def test_name(self, code: CodeDefinition): def test_params(self, code: CodeDefinition): assert code.n == 3 assert code.k == 1 - assert code.d == 3 + assert code.d == 1 def test_logical_count(self, code: CodeDefinition): assert len(code.logicals) == 1 @@ -730,7 +730,7 @@ def test_str_keyword_int(self): class TestConditionalParsing: def test_single_target(self): source = """ - CODE C [[3,1,3]] { LOGICAL X0*X1*X2 Z0*Z1*Z2\n STABILIZER Z0*Z1 Z1*Z2 } + CODE C [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2\n STABILIZER Z0*Z1 Z1*Z2 } GADGET G { INPUT C 0 1 2 M 3 @@ -749,7 +749,7 @@ def test_single_target(self): def test_multiple_targets(self): source = """ - CODE C [[3,1,3]] { LOGICAL X0*X1*X2 Z0*Z1*Z2\n STABILIZER Z0*Z1 Z1*Z2 } + CODE C [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2\n STABILIZER Z0*Z1 Z1*Z2 } GADGET G { INPUT C 0 1 2 M 3 @@ -770,7 +770,7 @@ def test_multiple_targets(self): def test_multiple_statements(self): source = """ - CODE C [[3,1,3]] { LOGICAL X0*X1*X2 Z0*Z1*Z2\n STABILIZER Z0*Z1 Z1*Z2 } + CODE C [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2\n STABILIZER Z0*Z1 Z1*Z2 } GADGET G { INPUT C 0 1 2 M 3 4 @@ -791,7 +791,7 @@ def test_multiple_statements(self): def test_conditional_before_output_rejected(self): source = """ - CODE C [[3,1,3]] { LOGICAL X0*X1*X2 Z0*Z1*Z2\n STABILIZER Z0*Z1 Z1*Z2 } + CODE C [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2\n STABILIZER Z0*Z1 Z1*Z2 } GADGET G { INPUT C 0 1 2 M 3 @@ -811,7 +811,7 @@ def test_conditional_before_output_rejected(self): class TestPropagateParsing: _CODE_PREAMBLE = ( - "CODE C [[3,1,3]] { LOGICAL X0*X1*X2 Z0*Z1*Z2\n" " STABILIZER Z0*Z1 Z1*Z2 }\n" + "CODE C [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2\n" " STABILIZER Z0*Z1 Z1*Z2 }\n" ) def test_logical_only(self): diff --git a/deq/tests/circuit/test_noise_injection.py b/deq/tests/circuit/test_noise_injection.py index 8ed63685..55be6825 100644 --- a/deq/tests/circuit/test_noise_injection.py +++ b/deq/tests/circuit/test_noise_injection.py @@ -445,7 +445,7 @@ def test_program_block_no_noise() -> None: def test_code_block_no_noise() -> None: src = dedent("""\ - CODE RepetitionCode [[3,1,3]] { + CODE RepetitionCode [[3,1,1]] { LOGICAL X0 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/tests/runtime/test_sampler.py b/deq/tests/runtime/test_sampler.py index 8ba4785b..d262850e 100644 --- a/deq/tests/runtime/test_sampler.py +++ b/deq/tests/runtime/test_sampler.py @@ -21,7 +21,7 @@ # A self-contained 3-qubit repetition-code memory experiment we can inline # so the tests don't depend on a specific file on disk. _DEQ_SOURCE = """ -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -143,7 +143,7 @@ def test_sampler_supports_program_with_virtual_pauli_corrections(): shape that ``compile_program_for_jit`` reads to record VIRTUAL toggles, even though the matrix entries stay empty.""" src = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -175,7 +175,7 @@ def test_sampler_supports_program_with_compose(): Sampler accepts ``.deq`` files that build up programs with COMPOSE blocks — the same way the CLI's transpile pipeline does.""" src = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code.deq b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code.deq index 0458e9c7..c0c10145 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code.deq @@ -4,7 +4,7 @@ d = int(context.get('d', 3)) p = float(context.get('p', 0.05)) finder = context.get('finder', 'auto') %> -CODE RepetitionCode [[${d},1,${d}]] { +CODE RepetitionCode [[${d},1,1]] { LOGICAL ${"*".join(f"X{i}" for i in range(d))} Z0 STABILIZER ${" ".join(f"Z{i}*Z{i+1}" for i in range(d-1))} Z${d-1}*Z0 } diff --git a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.auto.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.auto.ref.deq index efaaffc5..7f202b51 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.auto.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.auto.ref.deq @@ -1,5 +1,5 @@ @PTYPE(1) -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0 STABILIZER Z0*Z1 # generator S0, destabilizer DS0=X1*X2 STABILIZER Z1*Z2 # generator S1, destabilizer DS1=X2 diff --git a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome-meta.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome-meta.ref.deq index 10dcbed3..cd022c37 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome-meta.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome-meta.ref.deq @@ -1,5 +1,5 @@ @PTYPE(1) -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0 STABILIZER Z0*Z1 # generator S0, destabilizer DS0=X1*X2 STABILIZER Z1*Z2 # generator S1, destabilizer DS1=X2 diff --git a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome.ref.deq index 9887df43..c7c38c8f 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.syndrome.ref.deq @@ -1,5 +1,5 @@ @PTYPE(1) -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0 STABILIZER Z0*Z1 # generator S0, destabilizer DS0=X1*X2 STABILIZER Z1*Z2 # generator S1, destabilizer DS1=X2 diff --git a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.transversal.ref.deq b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.transversal.ref.deq index 52b0d5b7..35eae30d 100644 --- a/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.transversal.ref.deq +++ b/deq/tests/transpiler/check_optimizer_fixtures/repetition_code/repetition_code_d3.transversal.ref.deq @@ -1,5 +1,5 @@ @PTYPE(1) -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0 STABILIZER Z0*Z1 # generator S0, destabilizer DS0=X1*X2 STABILIZER Z1*Z2 # generator S1, destabilizer DS1=X2 diff --git a/deq/tests/transpiler/jit_annotate_test.py b/deq/tests/transpiler/jit_annotate_test.py index 44edf24c..9bd46e4b 100644 --- a/deq/tests/transpiler/jit_annotate_test.py +++ b/deq/tests/transpiler/jit_annotate_test.py @@ -14,7 +14,7 @@ def test_annotate_preserves_logicals_and_stabilizers() -> None: qfile = parse(""" - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -27,7 +27,7 @@ def test_annotate_preserves_logicals_and_stabilizers() -> None: def test_annotate_comments_out_circuit_replaces_check_mode() -> None: qfile = parse(""" - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -53,7 +53,7 @@ def test_annotate_comments_out_circuit_replaces_check_mode() -> None: def test_annotate_inserts_auto_checks_after_measurement() -> None: qfile = parse(""" - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -76,7 +76,7 @@ def test_annotate_inserts_auto_checks_after_measurement() -> None: def test_annotate_drops_user_check_emits_auto() -> None: qfile = parse(""" - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -128,7 +128,7 @@ def test_annotated_output_is_a_valid_deq_file_with_same_jit_library() -> None: def test_annotate_unrolls_repeat_blocks() -> None: qfile = parse(""" - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -148,7 +148,7 @@ def test_annotate_unrolls_repeat_blocks() -> None: def test_annotate_renders_compose_as_gadget_and_program_verbatim() -> None: qfile = parse(""" - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -186,7 +186,7 @@ def test_annotate_renders_compose_as_gadget_and_program_verbatim() -> None: def test_annotate_readout_shows_flips_comment() -> None: """MeasureZ readout should show which input observables flip it.""" qfile = parse(""" - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -220,7 +220,7 @@ def test_annotate_readout_no_inputs_no_flips() -> None: def test_annotate_readout_comment_survives_roundtrip() -> None: """Propagation comments are stripped by parser — round-trip still works.""" qfile = parse(""" - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/tests/transpiler/jit_library_builder_test.py b/deq/tests/transpiler/jit_library_builder_test.py index fa8cb570..e245bdb7 100644 --- a/deq/tests/transpiler/jit_library_builder_test.py +++ b/deq/tests/transpiler/jit_library_builder_test.py @@ -78,7 +78,7 @@ def test_build_library_on_repetition_code_d3() -> None: def test_build_library_respects_pinned_ids() -> None: source = """ @PTYPE(7) - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -128,7 +128,7 @@ def test_build_library_rejects_invalid_pin() -> None: def test_unfinished_check_drops_output_virtual_member() -> None: source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -156,7 +156,7 @@ def test_compose_fan_out_consumes_all_dangling_outputs() -> None: earlier parallel sub-gadgets dangled and the JIT compiler hung. """ source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0 STABILIZER Z0*Z1 Z1*Z2 } @@ -187,7 +187,7 @@ def test_compose_rejects_duplicate_output_wire() -> None: ``deq_runtime/src/jit/jit_compiler.rs``. """ source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0 STABILIZER Z0*Z1 Z1*Z2 } @@ -222,7 +222,7 @@ def test_compose_rejects_duplicate_input_wire() -> None: raise a structured ``ValueError``. """ source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0 STABILIZER Z0*Z1 Z1*Z2 } @@ -249,7 +249,7 @@ def test_compose_rejects_duplicate_input_wire() -> None: def test_compose_rejects_dangling_outputs() -> None: source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0 STABILIZER Z0*Z1 Z1*Z2 } @@ -275,7 +275,7 @@ def test_compose_rejects_dangling_outputs() -> None: def test_compose_rejects_output_for_consumed_wire() -> None: source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0 STABILIZER Z0*Z1 Z1*Z2 } @@ -316,7 +316,7 @@ def test_compose_rejects_dangling_input_overwritten_by_gadget() -> None: Ctrl+C — a single-line bad input produced an unkillable hang). """ source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0 STABILIZER Z0*Z1 Z1*Z2 } @@ -342,7 +342,7 @@ def test_compose_rejects_dangling_input_overwritten_by_gadget() -> None: def test_compose_rejects_shortcut_with_too_many_targets() -> None: source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0 STABILIZER Z0*Z1 Z1*Z2 } @@ -368,7 +368,7 @@ def test_compose_rejects_shortcut_with_too_many_targets() -> None: def test_compose_rejects_shortcut_with_too_few_targets() -> None: source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0 STABILIZER Z0*Z1 Z1*Z2 } @@ -404,7 +404,7 @@ def test_compose_rejects_shortcut_with_too_few_targets() -> None: def test_compose_rejects_explicit_application_with_wrong_port_count() -> None: source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0 STABILIZER Z0*Z1 Z1*Z2 } @@ -440,7 +440,7 @@ def test_library_is_serialisable() -> None: def test_readouts_use_real_measurement_indices() -> None: source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -470,7 +470,7 @@ def test_readouts_use_real_measurement_indices() -> None: def test_readouts_flip_sets_affine_column() -> None: source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -510,7 +510,7 @@ def test_readouts_xor_duplicate_measurements() -> None: def test_readouts_reject_input_virtual_reference() -> None: source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -532,7 +532,7 @@ def test_readouts_reject_input_virtual_reference() -> None: def test_readouts_reject_output_virtual_reference() -> None: source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -553,7 +553,7 @@ def test_readouts_reject_output_virtual_reference() -> None: def test_logical_correction_shape_matches_observables_and_readouts() -> None: source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -574,7 +574,7 @@ def test_logical_correction_shape_matches_observables_and_readouts() -> None: def test_no_readouts_yields_zero_by_one_propagation() -> None: source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -595,7 +595,7 @@ def test_no_readouts_yields_zero_by_one_propagation() -> None: def test_logical_frame_default_observable_count() -> None: source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -613,7 +613,7 @@ def test_logical_frame_default_observable_count() -> None: def _gadget_with_errors(body: str) -> object: source = f""" - CODE Rep [[3,1,3]] {{ + CODE Rep [[3,1,1]] {{ LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 }} @@ -733,7 +733,7 @@ def test_multiple_error_statements_emit_multiple_rows() -> None: def test_compose_gtype_pinned() -> None: source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -764,7 +764,7 @@ def test_compose_gtype_pinned() -> None: def test_compose_gtype_pin_conflicts_with_gadget_pin() -> None: source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -792,7 +792,7 @@ def test_compose_gtype_pin_conflicts_with_gadget_pin() -> None: def test_compose_rejects_non_gtype_decorator() -> None: source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -823,7 +823,7 @@ def test_compose_rejects_non_gtype_decorator() -> None: def test_compose_auto_gtype_skips_pinned_ids() -> None: source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -861,7 +861,7 @@ def test_compose_auto_gtype_skips_pinned_ids() -> None: def test_error_statement_inside_repeat_block_is_expanded() -> None: source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -927,7 +927,7 @@ def test_unrecognized_compose_decorator_raises() -> None: def test_conditional_lx_flips_lz() -> None: """CONDITIONAL R0 LX0 should set logical_correction[1, 0] = 1.""" source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -983,7 +983,7 @@ def test_compose_multi_port_non_linear_wiring() -> None: def test_conditional_lz_flips_lx() -> None: """CONDITIONAL R0 LZ0 should set logical_correction[0, 0] = 1.""" source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -1006,7 +1006,7 @@ def test_conditional_lz_flips_lx() -> None: def test_conditional_ly_flips_both() -> None: """CONDITIONAL R0 LY0 should flip both LX0 and LZ0.""" source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -1028,7 +1028,7 @@ def test_conditional_ly_flips_both() -> None: def test_conditional_multiple_targets() -> None: """CONDITIONAL R0 LX0 LZ0 should flip both anti-commuting partners.""" source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -1051,7 +1051,7 @@ def test_conditional_multiple_targets() -> None: def test_conditional_no_statement_empty_matrix() -> None: """Without CONDITIONAL, matrix should be empty.""" source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -1074,7 +1074,7 @@ def test_conditional_no_statement_empty_matrix() -> None: def test_conditional_invalid_readout_index() -> None: """Readout index out of range should raise ValueError.""" source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -1093,7 +1093,7 @@ def test_conditional_invalid_readout_index() -> None: def test_conditional_invalid_logical_index() -> None: """Logical index out of range should raise ValueError.""" source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -1122,7 +1122,7 @@ def test_propagate_r_term_populates_logical_correction() -> None: ``CONDITIONAL R0 OUT.LX0``. """ source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -1151,7 +1151,7 @@ def test_propagate_r_term_matches_conditional_equivalent() -> None: ``logical_correction`` matrices. """ conditional_source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -1164,7 +1164,7 @@ def test_propagate_r_term_matches_conditional_equivalent() -> None: } """ propagate_source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -1188,7 +1188,7 @@ def test_propagate_r_term_xors_with_conditional() -> None: (XOR semantics), leaving logical_correction empty. """ source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -1212,7 +1212,7 @@ def test_propagate_r_term_invalid_readout_index() -> None: must raise a clear error. """ source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -1233,7 +1233,7 @@ def test_propagate_r_term_does_not_leak_to_cp_pc() -> None: or physical_correction — those are cp/pc territory only. """ with_r_source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -1246,7 +1246,7 @@ def test_propagate_r_term_does_not_leak_to_cp_pc() -> None: } """ without_r_source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -1281,7 +1281,7 @@ def test_propagate_r_term_does_not_leak_to_cp_pc() -> None: # --------------------------------------------------------------------------- _COND_COMPOSE_DEQ = """ -CODE Rep [[3,1,3]] { +CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -1562,7 +1562,7 @@ def test_build_jit_program_populates_type_metadata_only() -> None: from deq.transpiler.jit_library_builder import build_jit_program source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -1615,7 +1615,7 @@ def test_build_jit_program_inlines_compose_as_synthetic_gadget() -> None: from deq.transpiler.jit_library_builder import build_jit_program source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -1654,7 +1654,7 @@ def test_build_jit_program_drives_compile_program_for_jit() -> None: from deq.transpiler.jit_library_builder import build_jit_program source = """ - CODE Rep [[3,1,3]] { + CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/tests/transpiler/jit_propagate_test.py b/deq/tests/transpiler/jit_propagate_test.py index bc80e03d..4be42457 100644 --- a/deq/tests/transpiler/jit_propagate_test.py +++ b/deq/tests/transpiler/jit_propagate_test.py @@ -14,7 +14,7 @@ REP_CODE_DECLS = """ @PTYPE(1) -CODE Rep [[3,1,3]] { +CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0 STABILIZER Z0*Z1 Z1*Z2 } @@ -65,7 +65,7 @@ def test_propagate_matching_flow_keeps_library_unchanged() -> None: def test_propagate_pins_to_alternate_basis_representative() -> None: """PROPAGATE substitutes a different basis representative for a logical row. - For the [[3,1,3]] identity gadget, output row 0 (LZ0 label) can be + For the [[3,1,1]] identity gadget, output row 0 (LZ0 label) can be expressed as the input ``LZ0`` column, or as ``LZ0 XOR IN0.DS0`` (toggling input stab generator 0 is in the basis-freedom span). """ @@ -210,7 +210,7 @@ def test_propagate_uncovered_rows_fall_back_to_flow() -> None: def test_propagate_with_flat_ds_across_multi_port() -> None: """``IN

.DS`` resolves correctly across multiple input ports. - For two input ports of [[3,1,3]] each, ``IN0.DS`` indexes + For two input ports of [[3,1,1]] each, ``IN0.DS`` indexes port 0's stabs and ``IN1.DS`` indexes port 1's stabs. The Permute gadget swaps ports, so output port 0's logical 0 (output row 0) flows from input port 1's logical 0 (input col 4 diff --git a/deq/tests/transpiler/jit_transpiler_test.py b/deq/tests/transpiler/jit_transpiler_test.py index 4f408067..d66c68f9 100644 --- a/deq/tests/transpiler/jit_transpiler_test.py +++ b/deq/tests/transpiler/jit_transpiler_test.py @@ -296,7 +296,7 @@ def test_regroup_syndrome_mixed_finished_and_unfinished() -> None: def test_regroup_rejects_invalid_gadget() -> None: source = """ - CODE RepetitionCode [[3,1,3]] { + CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/tests/transpiler/metachecks_test.py b/deq/tests/transpiler/metachecks_test.py index b68fdac4..7de5350b 100644 --- a/deq/tests/transpiler/metachecks_test.py +++ b/deq/tests/transpiler/metachecks_test.py @@ -1,7 +1,7 @@ # pylint: disable=no-member """Tests for check plugins on gadgets with redundant stabilizers. -Uses the repetition code [[3,1,3]] with 3 ancillae measuring Z0*Z1, Z1*Z2, +Uses the repetition code [[3,1,1]] with 3 ancillae measuring Z0*Z1, Z1*Z2, and the redundant Z0*Z2. Verifies that the ``auto`` plugin keeps the metacheck (weight-3 finished check) while the ``syndrome`` plugin produces minimal per-stabilizer checks without metachecks. @@ -12,7 +12,7 @@ # Shared circuit: 3 ancillae measuring Z0*Z1, Z1*Z2, Z0*Z2 _REDUNDANT_BASE = """\ -CODE RepetitionCode [[3,1,3]] {{ +CODE RepetitionCode [[3,1,1]] {{ LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 Z0*Z2 }} @@ -182,7 +182,7 @@ class TestNonRedundantUnaffected: def test_non_redundant_same_auto_and_syndrome(self) -> None: deq_auto = """\ -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/tests/transpiler/mpp_test.py b/deq/tests/transpiler/mpp_test.py index 3a140254..86db54eb 100644 --- a/deq/tests/transpiler/mpp_test.py +++ b/deq/tests/transpiler/mpp_test.py @@ -110,7 +110,7 @@ def test_two_products(self) -> None: # Repetition code where stabilizers are measured using MPP instead of # ancilla-based syndrome extraction. _MPP_REPETITION_CODE = """\ -CODE RepetitionCode [[3,1,3]] { +CODE RepetitionCode [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } diff --git a/deq/tests/transpiler/test_compose_repropagate.py b/deq/tests/transpiler/test_compose_repropagate.py index d0d5e6f1..24c1f6ed 100644 --- a/deq/tests/transpiler/test_compose_repropagate.py +++ b/deq/tests/transpiler/test_compose_repropagate.py @@ -275,7 +275,7 @@ def test_teleportation_checks_match(self) -> None: def test_simple_cycle_checks_match(self) -> None: base = """ - CODE C[[3,1,3]] { + CODE C[[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 STABILIZER Z0*Z1 Z1*Z2 } @@ -312,7 +312,7 @@ def test_repeated_syndrome_rounds_preserve_round_to_round_checks(self) -> None: round-to-round comparison checks that decoders rely on. """ base = """ - CODE C[[3,1,3]] { + CODE C[[3,1,1]] { LOGICAL X0*X1*X2 Z0 STABILIZER Z0*Z1 Z1*Z2 } From 37491d2ac7e12e723e7b387e249589b6a99ae2db Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 13 Jul 2026 13:18:50 -0700 Subject: [PATCH 051/157] fix minor tutorial errors --- .../tutorial/chapters/readout-propagation.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/deq/documents/tutorial/chapters/readout-propagation.md b/deq/documents/tutorial/chapters/readout-propagation.md index 7d5399b4..a16450ad 100644 --- a/deq/documents/tutorial/chapters/readout-propagation.md +++ b/deq/documents/tutorial/chapters/readout-propagation.md @@ -52,7 +52,7 @@ $$[\underbrace{LX_0, LZ_0, \ldots, LX_{k-1}, LZ_{k-1}}_{\text{logical observable concatenated across input ports. Two flavors of column can appear in an `rp` row: -* A **logical observable column** (`IN

.LX` / `IN

.LZ`) means the +* A **logical observable column** (`IN

.LZ` / `IN

.LX`) means the readout flips when the corresponding logical Pauli is applied on the input patch. * A **stabilizer generator column** (`IN

.DS`) means the readout flips @@ -78,19 +78,18 @@ columns: 1. **Explicit tokens** on the source `READOUT` line contribute their columns directly. Three families of token are accepted: - `IN

.LX` / `IN

.LZ` for logical observable columns, and - `IN

.DS` for stabilizer-generator columns. + `IN

.LX` / `IN

.LZ` for logical corrections, and + `IN

.DS` for destabilizers. 2. **Walker-implicit tokens** — the transpiler runs a Heisenberg walker (`compute_implicit_readout_propagation`) that pushes each input frame column's Pauli representative *forward through the gadget body* and records which measurements it anti-commutes with. If the walked Pauli anti-commutes with an odd number of the readout's `measurement_indices`, that column is - added. The walker walks all input frame columns — both logical observables + added. The walker walks all input frame columns — both logical corrections and destabilizers. The final `rp` row is `walker_cols XOR explicit_cols`. This XOR is the key -mechanism, and it exists precisely so that either source can carry the truth -without the two ever double-counting each other. +mechanism, and it exists precisely so that in most cases user get the correct input frame contributions but in certain complicated cases, user can still override the bits. ## The common case: walker suffices @@ -252,7 +251,7 @@ accepts `IN

.LX` / `IN

.LZ` / `IN

.DS` on `READOUT` lines precisely because the walker/binary XOR-patch identity applies to any input frame column, not just logical ones. The same fix-up pattern shows up at much larger scale in surface-code lattice surgery — the `MZZ` merge's -joint-Z parity operator differs from each patch's declared `bar Z` +joint-Z parity operator differs from each patch's declared $\bar{Z}$ representative by several patch stabilizers, so its `rp` row picks up `IN

.DS` entries alongside the two logical columns, and any compose that carries `MZZ`'s dependencies past the walker's physical horizon (via @@ -279,8 +278,8 @@ practice this shows up when: (e.g. because a subsequent correction cancels the erasure). In both cases, the rule is the same: put the missing input-frame label -directly on the `READOUT` line. Use `IN

.LX` / `IN

.LZ` for a logical observable -column. Use `IN

.DS` for a destabilizer generator column. The +directly on the `READOUT` line. Use `IN

.LX` / `IN

.LZ` for a logical correction +and `IN

.DS` for a destabilizer. The transpiler XORs them with the walker's output the same way in both cases. ## Signal in `annotate` output: extra tokens on `READOUT` lines From 03894d4e073e08827aa17605cd3a6e58c58c93b0 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 13 Jul 2026 13:41:41 -0700 Subject: [PATCH 052/157] simplify tutorial generator with common run_cli --- .../gen_compose_repropagate.py | 32 ++--------- .../examples/compose/gen_compose_examples.py | 57 +++++-------------- .../conditional-correction/gen_conditional.py | 44 +++----------- .../examples/debug/gen_debug_examples.py | 36 ++++-------- .../lattice-surgery/gen_lattice_surgery.py | 18 +----- .../tutorial/examples/snippet_utils.py | 37 ++++++++++++ 6 files changed, 75 insertions(+), 149 deletions(-) diff --git a/deq/documents/tutorial/examples/compose-repropagate/gen_compose_repropagate.py b/deq/documents/tutorial/examples/compose-repropagate/gen_compose_repropagate.py index c19a3223..247d00c0 100644 --- a/deq/documents/tutorial/examples/compose-repropagate/gen_compose_repropagate.py +++ b/deq/documents/tutorial/examples/compose-repropagate/gen_compose_repropagate.py @@ -10,36 +10,14 @@ """ import os -import subprocess import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from snippet_utils import extract_block # noqa: E402 +from snippet_utils import extract_block, run_cli, write_snippet # noqa: E402 this_dir = os.path.dirname(os.path.abspath(__file__)) -def run_cli(description: str, args: list[str], *, allow_failure: bool = False): - """Run a ``python -m deq ...`` command and return (returncode, stdout, stderr).""" - print(f" {description}...") - result = subprocess.run( - [sys.executable, "-m", "deq"] + args, - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0 and not allow_failure: - sys.stderr.write(result.stderr) - raise RuntimeError(f"command failed: {' '.join(args)}") - return result.returncode, result.stdout, result.stderr - - -def write(path: str, content: str) -> None: - with open(path, "w", encoding="utf-8") as f: - f.write(content) - print(f" -> {os.path.basename(path)}") - - # ── Transpile both files (succeeds in both cases) ──────────────────── for name in ("01_teleport_logical.deq", "02_teleport_repropagate.deq"): @@ -95,7 +73,7 @@ def write(path: str, content: str) -> None: os.path.join(this_dir, "01_teleport_logical.deq"), encoding="utf-8" ) as f: src_01 = f.read() -write( +write_snippet( os.path.join(this_dir, "snippet_teleport_compose.deq"), extract_block(src_01, "COMPOSE", "Teleport"), ) @@ -104,21 +82,21 @@ def write(path: str, content: str) -> None: os.path.join(this_dir, "02_teleport_repropagate.deq"), encoding="utf-8" ) as f: src_02 = f.read() -write( +write_snippet( os.path.join(this_dir, "snippet_teleport_compose_repropagate.deq"), extract_block(src_02, "COMPOSE", "Teleport"), ) with open(annotated_01, encoding="utf-8") as f: annotated_01_text = f.read() -write( +write_snippet( os.path.join(this_dir, "snippet_teleport_plain_annotated.deq"), extract_block(annotated_01_text, "GADGET", "Teleport"), ) with open(annotated_02, encoding="utf-8") as f: annotated_text = f.read() -write( +write_snippet( os.path.join(this_dir, "snippet_teleport_annotated.deq"), extract_block(annotated_text, "GADGET", "Teleport"), ) diff --git a/deq/documents/tutorial/examples/compose/gen_compose_examples.py b/deq/documents/tutorial/examples/compose/gen_compose_examples.py index 1d1f68ed..d070f2d4 100644 --- a/deq/documents/tutorial/examples/compose/gen_compose_examples.py +++ b/deq/documents/tutorial/examples/compose/gen_compose_examples.py @@ -5,11 +5,10 @@ """ import os -import subprocess import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from snippet_utils import extract_block +from snippet_utils import extract_block, run_cli, write_snippet this_dir = os.path.dirname(os.path.abspath(__file__)) @@ -20,25 +19,6 @@ ] -def run_cli(description: str, args: list[str]) -> str: - """Run a CLI command and return stdout.""" - print(f" {description}...") - result = subprocess.run( - [sys.executable, "-m", "deq"] + args, - capture_output=True, - text=True, - check=True, - ) - return result.stdout - - -def write(path: str, content: str) -> None: - """Write content to a file.""" - with open(path, "w", encoding="utf-8") as f: - f.write(content) - print(f" -> {os.path.basename(path)}") - - for example in examples: path = os.path.join(this_dir, example) base = os.path.splitext(example)[0] @@ -46,29 +26,18 @@ def write(path: str, content: str) -> None: # Transpile — may fail on stim export for COMPOSE examples, but .jit is # written before the stim export step, so we allow non-zero exit codes out = os.path.join(this_dir, f"{example}.jit") - print(f" transpile {example}...") - result = subprocess.run( - [ - sys.executable, - "-m", - "deq", - "transpile", - path, - "--out", - out, - "--program", - "Simulation", - ], - capture_output=True, - text=True, + returncode, _, stderr = run_cli( + f"transpile {example}", + ["transpile", path, "--out", out, "--program", "Simulation"], + allow_failure=True, ) - if result.returncode != 0: - if "cannot export stim" in result.stderr: + if returncode != 0: + if "cannot export stim" in stderr: print( - f" (stim export skipped — COMPOSE gadgets have no physical circuit)" + " (stim export skipped — COMPOSE gadgets have no physical circuit)" ) else: - print(result.stderr) + print(stderr) raise RuntimeError(f"transpile failed for {example}") # Annotate @@ -83,7 +52,7 @@ def write(path: str, content: str) -> None: os.path.join(this_dir, "02_compose_3idle.annotated.deq"), encoding="utf-8" ) as f: annotated_02 = f.read() -write( +write_snippet( os.path.join(this_dir, "snippet_idle3_annotated.deq"), extract_block(annotated_02, "GADGET", "Idle3"), ) @@ -93,7 +62,7 @@ def write(path: str, content: str) -> None: os.path.join(this_dir, "03_nested_compose.annotated.deq"), encoding="utf-8" ) as f: annotated_03 = f.read() -write( +write_snippet( os.path.join(this_dir, "snippet_idle4_annotated.deq"), extract_block(annotated_03, "GADGET", "Idle4"), ) @@ -103,7 +72,7 @@ def write(path: str, content: str) -> None: with open(os.path.join(this_dir, "02_compose_3idle.deq"), encoding="utf-8") as f: src_02 = f.read() -write( +write_snippet( os.path.join(this_dir, "snippet_compose_idle3.deq"), extract_block(src_02, "COMPOSE", "Idle3"), ) @@ -111,7 +80,7 @@ def write(path: str, content: str) -> None: with open(os.path.join(this_dir, "03_nested_compose.deq"), encoding="utf-8") as f: src_03 = f.read() # Both COMPOSE blocks together -write( +write_snippet( os.path.join(this_dir, "snippet_nested_compose.deq"), extract_block(src_03, "COMPOSE", "Idle3") + "\n" diff --git a/deq/documents/tutorial/examples/conditional-correction/gen_conditional.py b/deq/documents/tutorial/examples/conditional-correction/gen_conditional.py index 7b7ed7d8..4e6c3479 100644 --- a/deq/documents/tutorial/examples/conditional-correction/gen_conditional.py +++ b/deq/documents/tutorial/examples/conditional-correction/gen_conditional.py @@ -24,47 +24,14 @@ import os import re -import subprocess import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from snippet_utils import extract_block, write_snippet # noqa: E402 +from snippet_utils import extract_block, run_cli, write_snippet # noqa: E402 THIS_DIR = os.path.dirname(os.path.abspath(__file__)) -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def run_cli( - description: str, - args: list[str], - *, - allow_failure: bool = False, -) -> tuple[int, str, str]: - """Run ``python -m deq `` and return ``(returncode, stdout, stderr)``.""" - print(f" {description}...") - result = subprocess.run( - [sys.executable, "-m", "deq"] + args, - capture_output=True, - text=True, - check=False, - cwd=THIS_DIR, - ) - if result.returncode != 0 and not allow_failure: - sys.stderr.write(result.stderr) - raise RuntimeError(f"command failed: {' '.join(args)}") - return result.returncode, result.stdout, result.stderr - - -def write(path: str, content: str) -> None: - with open(path, "w", encoding="utf-8") as f: - f.write(content) - print(f" -> {os.path.basename(path)}") - - # --------------------------------------------------------------------------- # 1. Transpile + annotate every variant # --------------------------------------------------------------------------- @@ -84,12 +51,13 @@ def write(path: str, content: str) -> None: transpile_args = ["transpile", deq_name, "--out", jit_out] if program is not None: transpile_args += ["--program", program] - run_cli(f"transpile {deq_name}", transpile_args) + run_cli(f"transpile {deq_name}", transpile_args, cwd=THIS_DIR) annotate_out = deq_path.replace(".deq", ".annotated.deq") run_cli( f"annotate {deq_name}", ["annotate", deq_name, "--out", annotate_out], + cwd=THIS_DIR, ) @@ -168,8 +136,9 @@ def write(path: str, content: str) -> None: "--seed", "42", ], + cwd=THIS_DIR, ) -write( +write_snippet( os.path.join(THIS_DIR, "teleport_conditional_sample.txt"), sample_stdout, ) @@ -200,6 +169,7 @@ def write(path: str, content: str) -> None: "--jobs", "1", ], + cwd=THIS_DIR, ) m_shots = re.search(r"Shots:\s+(\d+)", simulate_stdout) m_errs = re.search(r"Logical errors:\s+(\d+)", simulate_stdout) @@ -212,7 +182,7 @@ def write(path: str, content: str) -> None: f" Shots: {m_shots.group(1)}\n" f" Logical errors: {m_errs.group(1)}\n" ) -write( +write_snippet( os.path.join(THIS_DIR, "teleport_conditional_simulate.txt"), simulate_summary, ) diff --git a/deq/documents/tutorial/examples/debug/gen_debug_examples.py b/deq/documents/tutorial/examples/debug/gen_debug_examples.py index 6160a791..5437675f 100644 --- a/deq/documents/tutorial/examples/debug/gen_debug_examples.py +++ b/deq/documents/tutorial/examples/debug/gen_debug_examples.py @@ -8,6 +8,9 @@ import subprocess import sys +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from snippet_utils import run_cli, write_snippet # noqa: E402 + this_dir = os.path.dirname(os.path.abspath(__file__)) language_dir = os.path.join(this_dir, "..", "language") @@ -31,32 +34,13 @@ ) -def run(description: str, args: list[str]) -> str: - """Run a CLI command and return stdout.""" - print(f" {description}...") - result = subprocess.run( - [sys.executable, "-m", "deq"] + args, - capture_output=True, - text=True, - check=True, - ) - return result.stdout - - -def write(path: str, content: str) -> None: - """Write content to a file.""" - with open(path, "w", encoding="utf-8") as f: - f.write(content) - print(f" -> {os.path.basename(path)}") - - # ── Level 1: annotate ────────────────────────────────────────────── annotated_path = os.path.join(this_dir, "03_with_idle.annotated.deq") -run("annotate", ["annotate", deq_file, "--out", annotated_path]) +run_cli("annotate", ["annotate", deq_file, "--out", annotated_path]) # ── Level 3: compile + canonicalize ───────────────────────────────────────── bin_file = os.path.join(this_dir, "03_with_idle.deq.bin") -run( +run_cli( "compile", [ "compile", @@ -67,7 +51,7 @@ def write(path: str, content: str) -> None: ) canonical_file = os.path.join(this_dir, "03_with_idle.canonical.deq.bin") -run( +run_cli( "canonicalize", [ "canonicalize", @@ -80,19 +64,19 @@ def write(path: str, content: str) -> None: # ── Level 4: sample + interpret ───────────────────────────────────── stim_file = os.path.join(language_dir, "03_with_idle.stim") -output = run( +_, output, _ = run_cli( "sample", ["sample", stim_file, "--shots", "10", "--seed", "1"], ) -write(os.path.join(this_dir, "stim_sample_output.txt"), output) +write_snippet(os.path.join(this_dir, "stim_sample_output.txt"), output) for hex_val, label in [ ("0x00", "no_error"), ("0x80", "ancilla_error"), ("0x20", "data_error"), ]: - output = run( + _, output, _ = run_cli( f"interpret ({label})", ["interpret", bin_file, "--measurements", hex_val], ) - write(os.path.join(this_dir, f"interpret_{label}.txt"), output) + write_snippet(os.path.join(this_dir, f"interpret_{label}.txt"), output) diff --git a/deq/documents/tutorial/examples/lattice-surgery/gen_lattice_surgery.py b/deq/documents/tutorial/examples/lattice-surgery/gen_lattice_surgery.py index 69d893aa..f2d8b171 100644 --- a/deq/documents/tutorial/examples/lattice-surgery/gen_lattice_surgery.py +++ b/deq/documents/tutorial/examples/lattice-surgery/gen_lattice_surgery.py @@ -14,25 +14,13 @@ """ import os -import subprocess import sys - -this_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from snippet_utils import run_cli # noqa: E402 -def run_cli(description: str, args: list[str]) -> None: - """Run a ``python -m deq ...`` command; propagate failures.""" - print(f" {description}...") - result = subprocess.run( - [sys.executable, "-m", "deq"] + args, - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - sys.stderr.write(result.stderr) - raise RuntimeError(f"command failed: {' '.join(args)}") +this_dir = os.path.dirname(os.path.abspath(__file__)) run_cli( diff --git a/deq/documents/tutorial/examples/snippet_utils.py b/deq/documents/tutorial/examples/snippet_utils.py index ff1286d3..07a612c6 100644 --- a/deq/documents/tutorial/examples/snippet_utils.py +++ b/deq/documents/tutorial/examples/snippet_utils.py @@ -1,6 +1,8 @@ """Shared utilities for tutorial generator scripts.""" import os +import subprocess +import sys def write_snippet(path: str, content: str) -> None: @@ -10,6 +12,41 @@ def write_snippet(path: str, content: str) -> None: print(f" -> {os.path.basename(path)}") +def run_cli( + description: str, + args: list[str], + *, + allow_failure: bool = False, + cwd: str | None = None, +) -> tuple[int, str, str]: + """Run ``python -m deq `` and return ``(returncode, stdout, stderr)``. + + Args: + description: Human-readable label printed before running. + args: The deq subcommand and its options (without ``python -m deq``). + allow_failure: If True, a non-zero exit is returned instead of raising; + the caller inspects ``returncode`` to decide what to do. + cwd: Working directory for the subprocess. If None, inherits the + parent process's cwd. + + Raises: + RuntimeError: If the command exits non-zero and ``allow_failure`` is + False. The captured stderr is written to ``sys.stderr`` first. + """ + print(f" {description}...") + result = subprocess.run( + [sys.executable, "-m", "deq"] + args, + capture_output=True, + text=True, + check=False, + cwd=cwd, + ) + if result.returncode != 0 and not allow_failure: + sys.stderr.write(result.stderr) + raise RuntimeError(f"command failed: {' '.join(args)}") + return result.returncode, result.stdout, result.stderr + + def extract_block(text: str, keyword: str, name: str) -> str: """Extract a top-level block (CODE/GADGET/PROGRAM) by keyword and name. From b07adb1ab0c242be179255914ba2dfb5d9461f18 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 13 Jul 2026 13:49:37 -0700 Subject: [PATCH 053/157] remove unnecessary PROPAGATE specification in trivial gadget --- deq/tests/circuit/fixtures/trivial_surgery.deq | 6 ------ 1 file changed, 6 deletions(-) diff --git a/deq/tests/circuit/fixtures/trivial_surgery.deq b/deq/tests/circuit/fixtures/trivial_surgery.deq index 25efe55e..f66e3709 100644 --- a/deq/tests/circuit/fixtures/trivial_surgery.deq +++ b/deq/tests/circuit/fixtures/trivial_surgery.deq @@ -65,9 +65,6 @@ GADGET TwoMZZ { OUTPUT One 2 CONDITIONAL R0 OUT1.LX0 - - PROPAGATE OUT0.LZ0 FROM IN0.LZ0 IN1.LZ0 M2 - PROPAGATE OUT1.LZ0 FROM } CODE Two [[3,1,1]] { @@ -94,9 +91,6 @@ GADGET TwoSplit { OUTPUT One 0 OUTPUT One 2 - - PROPAGATE OUT0.LZ0 FROM IN0.LZ0 M0 - PROPAGATE OUT1.LZ0 FROM } COMPOSE TwoMZZCompose { From 97ab3cfcceb17431f2d1b513ebdb2d467264ecd5 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 13 Jul 2026 20:09:32 -0700 Subject: [PATCH 054/157] simplify test file --- deq/tests/circuit/test_annotate.py | 54 ++++-------------------------- 1 file changed, 6 insertions(+), 48 deletions(-) diff --git a/deq/tests/circuit/test_annotate.py b/deq/tests/circuit/test_annotate.py index 85c7b3f9..d337a77c 100644 --- a/deq/tests/circuit/test_annotate.py +++ b/deq/tests/circuit/test_annotate.py @@ -32,9 +32,7 @@ def _assert_annotate_roundtrip(deq_path: Path) -> None: """Verify that annotating a .deq file preserves transpilation output.""" - qfile = render_and_parse_file( - str(deq_path), mako_defs=None, skip_mako_warning=True - ) + qfile = render_and_parse_file(str(deq_path), mako_defs=None, skip_mako_warning=True) orig_lib = build_jit_library(qfile) rendered = annotate_impl(qfile) anno_lib = build_jit_library(parse_deq(rendered)) @@ -124,47 +122,14 @@ def test_annotate_floquet666() -> None: def test_annotate_teleportation_d3() -> None: - """Surface-code logical teleportation through a Bell pair. - - Exercises both ``@REPROPAGATE`` (inferred conditional correction) - and explicit ``CONDITIONAL`` statements on a single fixture. - """ - _assert_annotate_roundtrip( - CIRCUIT_DIR / "surface_code" / "teleportation_d3.deq" - ) + _assert_annotate_roundtrip(CIRCUIT_DIR / "surface_code" / "teleportation_d3.deq") def test_annotate_lattice_surgery_d3() -> None: - """True lattice surgery on the d=3 rotated surface code. - - Exercises the COMPOSE / @REPROPAGATE pipeline on an MZZ - merge-and-split gadget that spatially merges two surface-code - patches via an intermediate column of |+⟩ data qubits, measures - the four new bulk plaquettes spanning the seam, and splits the - intermediate column back out via X-basis measurement. The - transpiler must derive the correct Pauli frame correction - (``OUT0.LZ0 = IN0.LZ0 ⊕ m_X19 ⊕ m_X20``) automatically. - """ - _assert_annotate_roundtrip( - CIRCUIT_DIR / "surface_code" / "lattice_surgery_d3.deq" - ) + _assert_annotate_roundtrip(CIRCUIT_DIR / "surface_code" / "lattice_surgery_d3.deq") def test_annotate_chained_conditional_same_row() -> None: - """A COMPOSE that chains sub-composes with ``CONDITIONAL`` frame - corrections on the same output row (e.g. - ``DoubleTeleportConditional``, ``TripleTeleportConditional``) is - emitted by the annotator as plain ``PROPAGATE`` rows with no - ``CONDITIONAL`` lines — the canonicalizer's merge step (step 9) - has already folded every sub-gadget CONDITIONAL contribution into - ``correction_propagation`` / ``physical_correction`` on the merged - gadget, leaving ``logical_correction`` empty, so the annotator has - no readout-conditioned flip to re-emit. ``PROPAGATE`` rows are - authoritative: whatever the annotator declares is installed as the - residual formula for that output row, so byte-equivalence of the - compiled library after annotate → re-transpile confirms the - round-trip is semantics-preserving. - """ qfile = render_and_parse_file( str(CIRCUIT_DIR / "surface_code" / "teleportation_d3.deq"), mako_defs=None, @@ -197,21 +162,14 @@ def test_annotate_exercise_readout_conditions_destab_readout() -> None: entries in **destabilizer** columns of the input frame (not just logical observable columns). """ - fixture = ( - CIRCUIT_DIR / "repetition_code" / "exercise_readout_conditions.deq" - ) - qfile = render_and_parse_file( - str(fixture), mako_defs=None, skip_mako_warning=True - ) + fixture = CIRCUIT_DIR / "repetition_code" / "exercise_readout_conditions.deq" + qfile = render_and_parse_file(str(fixture), mako_defs=None, skip_mako_warning=True) orig_lib = build_jit_library(qfile) annotated = annotate_impl(qfile) anno_lib = build_jit_library(parse_deq(annotated)) _assert_stripped_bytes_equal(orig_lib, anno_lib, fixture.name) - block = ( - annotated.split("GADGET ExerciseReadoutConditions {", 1)[1] - .split("\n}", 1)[0] - ) + block = annotated.split("ExerciseReadoutConditions {", 1)[1].split("\n}", 1)[0] readout_lines = [ line.strip() for line in block.splitlines() From 4c887eede814f096b2cfc4d68e79268560b5d347 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 13 Jul 2026 21:28:21 -0700 Subject: [PATCH 055/157] simplify jit test --- deq/tests/cli/jit_test.py | 476 ++++++++------------------------------ 1 file changed, 102 insertions(+), 374 deletions(-) diff --git a/deq/tests/cli/jit_test.py b/deq/tests/cli/jit_test.py index 0a680429..25f6592d 100644 --- a/deq/tests/cli/jit_test.py +++ b/deq/tests/cli/jit_test.py @@ -1239,57 +1239,20 @@ def teleportation_d3_setup() -> tuple[jit_pb.JitLibrary, dict[str, object]]: class TestTeleportationD3: - """End-to-end compilation of surface-code logical teleportation PROGRAMs. + """Structural + runtime equivalence of the two logical-teleportation + encodings in ``teleportation_d3.deq``: - Exercises both new branch features on the same surface-code fixture: - - * ``@REPROPAGATE`` (``TeleportRepropagate*`` programs) infers the - conditional teleportation correction from the inlined flat circuit. - * Explicit ``CONDITIONAL`` (``TeleportConditional*`` programs) emits - synthesized identity gadgets that the canonicalizer's step-9 + * ``@REPROPAGATE`` (``TeleportRepropagate*``) infers the conditional + teleportation correction from the inlined flat circuit. + * Explicit ``CONDITIONAL`` (``TeleportConditional*``) emits + synthesised identity gadgets that the canonicaliser's step-9 absorption folds back into the propagation/correction matrices. - Both encodings must produce ``static_jit_compile``-able binaries that - pass the physical validator. + End-to-end binary validity + assertion + LER checks on the four + ``TeleportConditional*``/``TeleportProgramConditional*`` PROGRAMs + live in :class:`TestConditionalEndToEnd`. """ - @pytest.mark.parametrize( - "program_name", - [ - "TeleportRepropagateMemoryZ", - "TeleportConditionalMemoryZ", - "TeleportRepropagateMemoryX", - "TeleportConditionalMemoryX", - ], - ) - def test_program_compiles_to_valid_binary( - self, - teleportation_d3_setup: tuple[jit_pb.JitLibrary, dict[str, object]], - program_name: str, - ) -> None: - from deq.cli.jit import compile_program_for_jit - - jit_library, program_defs = teleportation_d3_setup - program_def = program_defs[program_name] - - compiled, assertions = compile_program_for_jit(jit_library, program_def) - - # The program must emit at least one ASSERT_EQ (rec[-1] 0) and - # one JIT instruction per gadget application in the body. - assert len(assertions) == 1 - assert assertions[0][1] is False # expected_value=0 - - # Build a fresh library that includes the program stream and run - # the static JIT compiler. is_valid_and_physical sanity-checks - # the produced deq.bin. - lib = jit_pb.JitLibrary() - lib.CopyFrom(jit_library) - lib.ClearField("program") - for instr, _src in compiled: - lib.program.append(instr) - deq_bin = static_jit_compiler(lib) - assert is_valid_and_physical(deq_bin) - def test_repropagate_and_conditional_emit_same_propagation( self, teleportation_d3_setup: tuple[jit_pb.JitLibrary, dict[str, object]], @@ -1386,183 +1349,62 @@ def lattice_surgery_d3_library() -> jit_pb.JitLibrary: return build_jit_library(parse_file(str(LATTICE_SURGERY_D3_DEQ))) -@pytest.fixture(scope="module") -def lattice_surgery_d3_setup() -> tuple[jit_pb.JitLibrary, dict[str, object]]: - """Parse ``lattice_surgery_d3.deq`` and return library + PROGRAMs. - - Returns ``(jit_library, program_defs_by_name)``. ``program_defs`` - keys: ``ComposeMZZMemoryZ`` (the single joint-Z lattice-surgery - memory program). - """ - from deq.circuit.model import ProgramDefinition - from deq.circuit.parser import parse_file - - merged = parse_file(str(LATTICE_SURGERY_D3_DEQ)) - jit_library = build_jit_library(merged) - program_defs = { - d.name: d for d in merged.definitions if isinstance(d, ProgramDefinition) - } - return jit_library, program_defs - - class TestLatticeSurgeryD3: - """Verify the structural properties of the d=3 lattice-surgery - honest joint-Z measurement gadget. - - Unlike the Bell-pair teleportation in ``teleportation_d3.deq``, - this fixture spatially merges two surface-code patches via an - intermediate column of |+⟩ data qubits. ``MZZ`` measures - four bulk plaquettes and two Z-type boundary 2-bodies spanning the - seam, then destructively measures the intermediate column in the X - basis. The product of the four Z-type measurement outcomes equals - the joint ``LZ_A · LZ_B`` parity, exposed via ``READOUT M0 M3 M4 M5``. - The ``ComposeMZZ`` COMPOSE wrapper exercises the COMPOSE pipeline - on this gadget. + """Structural properties of the d=3 lattice-surgery joint-Z merge + gadget in ``lattice_surgery_d3.deq``. + + The fixture spatially merges two surface-code patches via an + intermediate column of |+⟩ data qubits. ``MZZ`` measures four + bulk plaquettes and two Z-type boundary 2-bodies spanning the seam, + then destructively measures the intermediate column in the X basis. + The product of the four Z-type outcomes equals the joint + ``LZ_A · LZ_B`` parity, exposed via ``READOUT M0 M3 M4 M5``. + ``ComposeMZZ`` wraps ``MZZ`` in a COMPOSE block so the + canonicaliser folds the ``CONDITIONAL R0 OUT1.LX0`` byproduct into + ``correction_propagation``. Compilation validity plus the actual + ASSERT_EQ semantics for ``ComposeMZZMemoryZ`` live in + :class:`TestConditionalEndToEnd`. """ - def test_merge_mzz_has_two_input_two_output_ports( - self, - lattice_surgery_d3_library: jit_pb.JitLibrary, - ) -> None: - """``MZZ`` is a 2-input, 2-output gadget — both - patches survive the merge-and-split (the joint Z measurement - is non-destructive on logical information; only the joint - ``LZ_A · LZ_B`` parity is extracted into the measurement - record).""" - merge = next( - gt - for gt in lattice_surgery_d3_library.gadget_types - if gt.base.name == "MZZ" - ) - assert len(merge.base.inputs) == 2 - assert len(merge.base.outputs) == 2 - assert merge.base.inputs[0].ptype == merge.base.inputs[1].ptype - assert merge.base.outputs[0].ptype == merge.base.outputs[1].ptype - assert merge.base.inputs[0].ptype == merge.base.outputs[0].ptype - - def test_merge_mzz_exposes_parity_readout( - self, - lattice_surgery_d3_library: jit_pb.JitLibrary, - ) -> None: - """``MZZ`` exposes the joint ``LZ_A · LZ_B`` parity - as a single logical readout built from the four Z-type merge - measurements (``M0 M3 M4 M5``).""" - merge = next( - gt - for gt in lattice_surgery_d3_library.gadget_types - if gt.base.name == "MZZ" - ) - assert len(merge.base.readouts) == 1 - # Four measurement records — M0, M3, M4, M5. - assert len(merge.base.readouts[0].measurement_indices) == 4 - - def test_ls_merge_compose_has_two_input_two_output_ports( - self, - lattice_surgery_d3_library: jit_pb.JitLibrary, - ) -> None: - """The ``ComposeMZZ`` COMPOSE wrapper preserves the - 2-in / 2-out signature of the underlying joint-merge gadget.""" - gt = next( - g - for g in lattice_surgery_d3_library.gadget_types - if g.base.name == "ComposeMZZ" - ) - assert len(gt.base.inputs) == 2 - assert len(gt.base.outputs) == 2 - - def test_ls_merge_compose_preserves_readout( - self, - lattice_surgery_d3_library: jit_pb.JitLibrary, - ) -> None: - """``ComposeMZZ`` inherits ``MZZ``'s joint - ``LZ_A · LZ_B`` readout — it is preserved by the COMPOSE - merge() pass because the joint readout IS the operation's - output, not a frame-correction bit that gets absorbed.""" - gt = next( - g - for g in lattice_surgery_d3_library.gadget_types - if g.base.name == "ComposeMZZ" - ) - assert len(gt.base.readouts) == 1 - assert len(gt.base.readouts[0].measurement_indices) == 4 - - def test_merge_mzz_has_byproduct_logical_correction( - self, - lattice_surgery_d3_library: jit_pb.JitLibrary, - ) -> None: - """The base ``MZZ`` GADGET carries exactly one - ``logical_correction`` row — the ``CONDITIONAL R0 OUT1.LX0`` - byproduct that re-aligns the post-merge representatives with - the corrected-frame measurement outcomes when the joint - readout fires (joint ZZ = −1 branch). See the fixture's - header comment and the four ``ProductZZ_*`` calibration - programs that pin down ``OUT1`` (patch B) as the correct - side.""" - merge = next( - gt - for gt in lattice_surgery_d3_library.gadget_types - if gt.base.name == "MZZ" - ) - lc = merge.base.logical_correction - assert len(lc.i) == 1 - assert lc.cols == 1 # one readout (the joint parity) - assert list(lc.j) == [0] # driven by R0 (the joint readout) - - def test_ls_merge_absorbs_byproduct( + @pytest.mark.parametrize( + "gadget_name,expected_lc_rows", + [ + # Base MZZ retains the CONDITIONAL R0 OUT1.LX0 byproduct + # as a single logical_correction row. + ("MZZ", 1), + # ComposeMZZ absorbs it into correction_propagation. + ("ComposeMZZ", 0), + ], + ) + def test_merge_shape( self, lattice_surgery_d3_library: jit_pb.JitLibrary, + gadget_name: str, + expected_lc_rows: int, ) -> None: - """``ComposeMZZ``'s COMPOSE merge() canonicaliser absorbs - the ``MZZ`` byproduct into ``correction_propagation``, - so the final ``logical_correction`` matrix is empty. The - joint readout itself is preserved (it IS the operation's - output, not a frame bit), but the conditional Pauli on - ``OUT1.LX0`` folds cleanly into the COMPOSE-level propagation - of the patch-B logical observables.""" + """Each merge presentation must be a 2-in / 2-out gadget on the + surface-code port type, expose the four-measurement joint-parity + readout, and carry the expected number of ``logical_correction`` + rows.""" gt = next( g for g in lattice_surgery_d3_library.gadget_types - if g.base.name == "ComposeMZZ" - ) - assert len(gt.base.logical_correction.i) == 0 - - -class TestLatticeSurgeryD3Programs: - """End-to-end compilation of the lattice-surgery joint-Z memory - program (``ComposeMZZMemoryZ``). - - Prepares two surface-code patches in ``|0_L⟩``, runs the joint Z - measurement, then measures each patch in the Z basis. Because - ``|0_L⟩|0_L⟩`` is a +1 eigenstate of ``LZ_A · LZ_B``, the joint - readout is deterministically ``0`` and both ``MeasureZ`` outcomes - are ``0`` — encoded as three ``ASSERT_EQ rec[-k] 0`` statements. - """ - - def test_program_compiles_to_valid_binary( - self, - lattice_surgery_d3_setup: tuple[jit_pb.JitLibrary, dict[str, object]], - ) -> None: - from deq.cli.jit import compile_program_for_jit - - jit_library, program_defs = lattice_surgery_d3_setup - program_def = program_defs["ComposeMZZMemoryZ"] - - compiled, assertions = compile_program_for_jit(jit_library, program_def) - - # The memory program asserts three readouts equal 0 (joint - # parity + two ``MeasureZ``). - assert len(assertions) == 3 - for assertion in assertions: - assert assertion[1] is False - - # Verify the produced deq.bin is physically valid. - lib = jit_pb.JitLibrary() - lib.CopyFrom(jit_library) - lib.ClearField("program") - for instr, _src in compiled: - lib.program.append(instr) - deq_bin = static_jit_compiler(lib) - assert is_valid_and_physical(deq_bin) + if g.base.name == gadget_name + ).base + assert len(gt.inputs) == len(gt.outputs) == 2 + assert ( + gt.inputs[0].ptype + == gt.inputs[1].ptype + == gt.outputs[0].ptype + == gt.outputs[1].ptype + ) + assert len(gt.readouts) == 1 + assert len(gt.readouts[0].measurement_indices) == 4 + assert len(gt.logical_correction.i) == expected_lc_rows + if expected_lc_rows == 1: + # Byproduct is driven by R0 (the joint-parity readout). + assert gt.logical_correction.cols == 1 + assert list(gt.logical_correction.j) == [0] # --------------------------------------------------------------------------- @@ -1591,118 +1433,67 @@ def trivial_surgery_library() -> jit_pb.JitLibrary: ) -@pytest.fixture(scope="module") -def trivial_surgery_setup() -> tuple[jit_pb.JitLibrary, dict[str, object]]: - """Parse ``trivial_surgery.deq`` and return ``(library, programs)``.""" - from deq.circuit.model import ProgramDefinition - from deq.circuit.parser import render_and_parse_file - - merged = render_and_parse_file( - str(TRIVIAL_SURGERY_DEQ), mako_defs=None, skip_mako_warning=True - ) - jit_library = build_jit_library(merged) - program_defs = { - d.name: d for d in merged.definitions if isinstance(d, ProgramDefinition) - } - return jit_library, program_defs - - class TestTrivialTwoMZZ: - """Structural properties of the trivial-code joint-Z merge - gadgets — the [[1,1,1]] analogues of the surface-code ``MZZ`` - and ``ComposeMZZ`` merges in ``lattice_surgery_d3.deq``. + """Structural + runtime-equivalence properties of the trivial-code + joint-Z merge gadgets in ``trivial_surgery.deq`` — the [[1,1,1]] + analogues of the surface-code ``MZZ``/``ComposeMZZ`` merges. Two single-qubit patches (qubits 0 and 2) are joined by a ``|+⟩`` ancilla on qubit 1; ``MPP Z0*Z1`` + ``MPP Z1*Z2`` extract the joint ``LZ_A · LZ_B`` parity as ``READOUT M0 M1`` and the - ancilla is split back out with ``MX 1``. The fixture exposes - two equivalent presentations: - - * ``TwoMZZ`` — the raw joint-Z merge with an inline - ``CONDITIONAL R0 OUT1.LX0`` byproduct. Deferring the - CONDITIONAL absorption to the runtime decoder leaves one - ``logical_correction`` row on the base gadget. - * ``TwoMZZCompose`` — ``TwoMerge`` + ``TwoSplit`` + a - post-split ``CONDITIONAL rec[-1] X0 1`` wrapped in a - ``COMPOSE`` block. COMPOSE canonicalisation absorbs the - byproduct into ``readout_propagation``, so the composed base - gadget has an empty ``logical_correction`` matrix. + ancilla is split back out with ``MX 1``. Two equivalent + presentations: + + * ``TwoMZZ`` — raw joint-Z merge with an inline + ``CONDITIONAL R0 OUT1.LX0`` byproduct, deferring absorption to + the runtime decoder (one ``logical_correction`` row on the base). + * ``TwoMZZCompose`` — ``TwoMerge`` + ``TwoSplit`` + post-split + ``CONDITIONAL rec[-1] X0 1`` in a COMPOSE block, where + canonicalisation absorbs the byproduct (empty + ``logical_correction``). + + End-to-end assertion + LER validation of the memory programs + lives in :class:`TestConditionalEndToEnd`. """ - @pytest.mark.parametrize("merge_name", ["TwoMZZ", "TwoMZZCompose"]) - def test_merge_has_two_input_two_output_ports( - self, - trivial_surgery_library: jit_pb.JitLibrary, - merge_name: str, - ) -> None: - """Both merge presentations are 2-input, 2-output gadgets - over the ``One`` port type — both patches survive the - merge-and-split.""" - merge = next( - gt - for gt in trivial_surgery_library.gadget_types - if gt.base.name == merge_name - ) - assert len(merge.base.inputs) == 2 - assert len(merge.base.outputs) == 2 - assert merge.base.inputs[0].ptype == merge.base.inputs[1].ptype - assert merge.base.outputs[0].ptype == merge.base.outputs[1].ptype - assert merge.base.inputs[0].ptype == merge.base.outputs[0].ptype - - @pytest.mark.parametrize("merge_name", ["TwoMZZ", "TwoMZZCompose"]) - def test_merge_exposes_parity_readout( + @pytest.mark.parametrize( + "merge_name,expected_lc_rows", + [ + # Inline byproduct — raw TwoMZZ carries one logical_correction row. + ("TwoMZZ", 1), + # COMPOSE-level CONDITIONAL is absorbed into readout_propagation. + ("TwoMZZCompose", 0), + ], + ) + def test_merge_shape( self, trivial_surgery_library: jit_pb.JitLibrary, merge_name: str, + expected_lc_rows: int, ) -> None: - """Both merge presentations expose the joint - ``LZ_A · LZ_B`` parity as a single logical readout built - from the two ``MPP`` outcomes (``M0 M1``).""" + """Each merge presentation must be a 2-in / 2-out gadget on the + ``One`` port type, expose the two-measurement joint-parity + readout, and carry the expected number of ``logical_correction`` + rows (1 for raw TwoMZZ; 0 after COMPOSE absorption).""" merge = next( gt for gt in trivial_surgery_library.gadget_types if gt.base.name == merge_name - ) - assert len(merge.base.readouts) == 1 - assert len(merge.base.readouts[0].measurement_indices) == 2 - - def test_two_mzz_has_byproduct_logical_correction( - self, - trivial_surgery_library: jit_pb.JitLibrary, - ) -> None: - """The raw ``TwoMZZ`` gadget carries exactly one - ``logical_correction`` row — the ``CONDITIONAL R0 OUT1.LX0`` - byproduct that re-aligns the post-merge representatives with - the corrected-frame measurement outcomes on the joint - ``ZZ = −1`` branch. See the four ``ProductZZ_*`` calibration - programs in ``trivial_surgery.deq`` that pin the byproduct - onto patch B (``OUT1``) rather than patch A (``OUT0``).""" - merge = next( - gt - for gt in trivial_surgery_library.gadget_types - if gt.base.name == "TwoMZZ" - ) - lc = merge.base.logical_correction - assert len(lc.i) == 1 - assert lc.cols == 1 # one readout (the joint parity) - assert list(lc.j) == [0] # driven by R0 (the joint readout) - - def test_two_mzz_compose_absorbs_byproduct( - self, - trivial_surgery_library: jit_pb.JitLibrary, - ) -> None: - """``TwoMZZCompose``'s COMPOSE merge() canonicaliser absorbs - the ``TwoSplit`` byproduct's post-split - ``CONDITIONAL rec[-1] X0 1`` into ``readout_propagation``, - leaving an empty ``logical_correction`` on the composed - base gadget — the same pattern as the surface-code - ``ComposeMZZ`` in ``lattice_surgery_d3.deq``.""" - merge = next( - gt - for gt in trivial_surgery_library.gadget_types - if gt.base.name == "TwoMZZCompose" - ) - assert len(merge.base.logical_correction.i) == 0 + ).base + assert len(merge.inputs) == len(merge.outputs) == 2 + assert ( + merge.inputs[0].ptype + == merge.inputs[1].ptype + == merge.outputs[0].ptype + == merge.outputs[1].ptype + ) + assert len(merge.readouts) == 1 + assert len(merge.readouts[0].measurement_indices) == 2 + assert len(merge.logical_correction.i) == expected_lc_rows + if expected_lc_rows == 1: + # Byproduct is driven by R0 (the joint-parity readout). + assert merge.logical_correction.cols == 1 + assert list(merge.logical_correction.j) == [0] @pytest.mark.parametrize( "leaf_name,composed_name", @@ -1799,69 +1590,6 @@ def entries(bm: util_pb.BitMatrix) -> set[tuple[int, int]]: assert len(outer.logical_correction.i) == 0 - -class TestTrivialTwoMZZPrograms: - """End-to-end compilation of the joint-Z merge PROGRAMs in - ``trivial_surgery.deq`` — the [[1,1,1]] analogues of the - surface-code lattice-surgery calibration and Bell-pair programs. - - Every PROGRAM must compile into a physically valid ``.deq.bin`` - and expose at least one ``ASSERT_EQ`` statement. The Mako - ``%for suffix in suffixes`` loop in ``trivial_surgery.deq`` - emits every base program twice — once against raw ``TwoMZZ`` - and once against ``TwoMZZCompose`` — so the parametrisation - covers both variants. Runtime-decoder correctness on noiseless - samples is covered by the ``TestConditionalEndToEnd`` - parametrisation. - """ - - _BASE_NAMES: tuple[str, ...] = ( - "TwoMZZMemoryZ", - "BellPairJointZZ", - "BellPairWithLogicalXJointZZ", - "ProductZZ_00", - "ProductZZ_VirtualXA", - "ProductZZ_VirtualXB", - "ProductZZ_VirtualXBoth", - "BellPairNoLogicalZSurvivesMerge", - "BellPairLogicalZBeforeMergeSurvives", - "BellPairLogicalZAfterMergeSurvives", - ) - - _PROGRAM_NAMES: tuple[str, ...] = tuple( - f"{name}{suffix}" for name in _BASE_NAMES for suffix in ("", "Compose") - ) - - @pytest.mark.parametrize("program_name", sorted(_PROGRAM_NAMES)) - def test_program_compiles_to_valid_binary( - self, - trivial_surgery_setup: tuple[jit_pb.JitLibrary, dict[str, object]], - program_name: str, - ) -> None: - """Every joint-Z merge PROGRAM (raw ``TwoMZZ`` and - ``TwoMZZCompose`` variant) must compile to a physically - valid ``.deq.bin`` with at least one ``ASSERT_EQ`` - statement preserved through compilation.""" - from deq.cli.jit import compile_program_for_jit - - jit_library, program_defs = trivial_surgery_setup - program_def = program_defs[program_name] - - compiled, assertions = compile_program_for_jit(jit_library, program_def) - assert assertions, ( - f"{program_name}: compilation dropped every ASSERT_EQ statement" - ) - - # Verify the produced deq.bin is physically valid. - lib = jit_pb.JitLibrary() - lib.CopyFrom(jit_library) - lib.ClearField("program") - for instr, _src in compiled: - lib.program.append(instr) - deq_bin = static_jit_compiler(lib) - assert is_valid_and_physical(deq_bin) - - # --------------------------------------------------------------------------- # End-to-end ``deq sample`` + ``deq simulate ler`` smoke tests for both # COMPOSE-level and PROGRAM-level ``CONDITIONAL`` correction pathways. From 05a7c196892f1b07ff0c72aadc68345264aa2978 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 13 Jul 2026 21:51:01 -0700 Subject: [PATCH 056/157] simplify jit test --- deq/tests/cli/jit_test.py | 597 +++++++++++--------------------------- 1 file changed, 174 insertions(+), 423 deletions(-) diff --git a/deq/tests/cli/jit_test.py b/deq/tests/cli/jit_test.py index 25f6592d..bc9d1696 100644 --- a/deq/tests/cli/jit_test.py +++ b/deq/tests/cli/jit_test.py @@ -51,20 +51,6 @@ def trivial_code_k3_jit_library() -> jit_pb.JitLibrary: return build_jit_library(parse(_TRIVIAL_CODE_K3_DEQ)) -@pytest.fixture -def trivial_code_k3_codes() -> dict[str, object]: - """Code definitions for the trivial [[3,3]] code.""" - from deq.circuit.model import CodeDefinition - qfile = parse(_TRIVIAL_CODE_K3_DEQ) - return {d.name: d for d in qfile.definitions if isinstance(d, CodeDefinition)} - - -@pytest.fixture -def named_jit_library() -> jit_pb.JitLibrary: - """JIT library with named gadgets — same as trivial_code_k3_jit_library.""" - return build_jit_library(parse(_TRIVIAL_CODE_K3_DEQ)) - - class TestCompileProgram: """Test compiling full programs using .deq PROGRAM body syntax.""" @@ -98,20 +84,6 @@ def test_simple_program( assert instructions[2].gadget.connectors[0].gid == 2 assert instructions[2].gadget.connectors[0].port == 0 - def test_shortcut_form( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary - ) -> None: - """Test shortcut form: PrepareZ 0 (infers IN/OUT from gadget ports).""" - instructions = parse_jit_program( - trivial_code_k3_jit_library, - "PrepareZ 0\nIdle 0\nMeasureZ 0", - ) - - assert len(instructions) == 3 - assert instructions[0].gadget.gtype == 1 - assert instructions[1].gadget.gtype == 2 - assert instructions[2].gadget.gtype == 3 - def test_chained_idles( self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: @@ -167,16 +139,6 @@ def test_unknown_gadget_error( "UnknownGadget OUT(0)", ) - def test_dangling_output_error( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary - ) -> None: - """Test error when program has unconnected output wires.""" - with pytest.raises(ValueError, match="dangling output wires"): - parse_jit_program( - trivial_code_k3_jit_library, - "PrepareZ 0", - ) - def test_dangling_output_lists_each_producer( self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: @@ -192,26 +154,6 @@ def test_dangling_output_lists_each_producer( assert f"wire {wire}" in msg assert "PrepareZ" in msg - def test_dangling_output_with_idle( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary - ) -> None: - """Test error when Idle leaves dangling output.""" - with pytest.raises(ValueError, match="dangling output wires"): - parse_jit_program( - trivial_code_k3_jit_library, - "PrepareZ 0\nIdle 0", - ) - - def test_no_dangling_output_with_measure( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary - ) -> None: - """Test that MeasureZ properly consumes output (no dangling).""" - instructions = parse_jit_program( - trivial_code_k3_jit_library, - "PrepareZ 0\nMeasureZ 0", - ) - assert len(instructions) == 2 - class TestEndToEndCompilation: """Test full end-to-end compilation with validation.""" @@ -238,28 +180,6 @@ def test_compile_and_validate( # Validate assert is_valid_and_physical(deq_bin) - def test_compile_and_validate_shortcut( - self, named_jit_library: jit_pb.JitLibrary - ) -> None: - """Test that compiled program with shortcut form passes validation.""" - instructions = parse_jit_program( - named_jit_library, - "PrepareZ 0\nIdle 0\nMeasureZ 0", - ) - - # Add instructions to library - jit_library = jit_pb.JitLibrary() - jit_library.CopyFrom(named_jit_library) - jit_library.ClearField("program") - for instr in instructions: - jit_library.program.append(instr) - - # Compile to deq.bin - deq_bin = static_jit_compiler(jit_library) - - # Validate - assert is_valid_and_physical(deq_bin) - def test_compile_matches_direct_program( self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: @@ -307,89 +227,42 @@ def test_compile_matches_direct_program( assert are_programs_equivalent(deq_bin_parsed, deq_bin_direct) -class TestParseJitProgramAPI: - """Test the public parse_jit_program API.""" - - def test_parse_jit_program( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary - ) -> None: - """Test the public API function with explicit IN/OUT.""" - instructions = parse_jit_program( - trivial_code_k3_jit_library, - "PrepareZ 0\nIdle 0\nMeasureZ 0", - ) - assert len(instructions) == 3 - assert instructions[0].gadget.gtype == 1 - assert instructions[1].gadget.gtype == 2 - assert instructions[2].gadget.gtype == 3 - - def test_parse_jit_program_shortcut( - self, named_jit_library: jit_pb.JitLibrary - ) -> None: - """Test the public API function with shortcut notation.""" - instructions = parse_jit_program( - named_jit_library, - "PrepareZ 0\nIdle 0\nMeasureZ 0", - ) - assert len(instructions) == 3 - assert instructions[0].gadget.gtype == 1 - assert instructions[1].gadget.gtype == 2 - assert instructions[2].gadget.gtype == 3 - - class TestPauliCorrections: """Test Pauli correction pseudo-instructions (VIRTUAL X0, Z1, Y2, etc.).""" - def test_x0_toggles_z0( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary + @pytest.mark.parametrize( + "pauli,expected_i", + [ + # Single-qubit Paulis: X flips LZ_i (row 2i+1); + # Z flips LX_i (row 2i); Y flips both. + ("X0", [1]), + ("Z1", [2]), + ("X1", [3]), + ("Y0", [0, 1]), + # Multi-Pauli products accumulate flips. + ("X0*Z1", [1, 2]), + ("X0*Z1*Y2", [1, 2, 4, 5]), + ], + ) + def test_virtual_pauli_toggles( + self, + trivial_code_k3_jit_library: jit_pb.JitLibrary, + pauli: str, + expected_i: list[int], ) -> None: - """VIRTUAL X0 0 should toggle Z0 (row 1) in the constant column.""" + """``VIRTUAL 0`` after ``PrepareZ 0`` toggles the + expected rows at the constant column (cols=1 because PrepareZ + has no inputs — the toggle matrix is a plain constant vector). + """ instructions = parse_jit_program( trivial_code_k3_jit_library, - "PrepareZ 0\nVIRTUAL X0 0\nMeasureZ 0", + f"PrepareZ 0\nVIRTUAL {pauli} 0\nMeasureZ 0", ) - assert len(instructions) == 2 toggle = instructions[0].gadget.modifier.correction_propagation_mod.toggle assert toggle.rows == 6 assert toggle.cols == 1 - assert list(toggle.i) == [1] - assert list(toggle.j) == [0] - - def test_z1_toggles_x1( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary - ) -> None: - """VIRTUAL Z1 0 should toggle X1 (row 2) in the constant column.""" - instructions = parse_jit_program( - trivial_code_k3_jit_library, - "PrepareZ 0\nVIRTUAL Z1 0\nMeasureZ 0", - ) - toggle = instructions[0].gadget.modifier.correction_propagation_mod.toggle - assert list(toggle.i) == [2] - assert list(toggle.j) == [0] - - def test_y0_toggles_both( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary - ) -> None: - """VIRTUAL Y0 0 should toggle both X0 (row 0) and Z0 (row 1).""" - instructions = parse_jit_program( - trivial_code_k3_jit_library, - "PrepareZ 0\nVIRTUAL Y0 0\nMeasureZ 0", - ) - toggle = instructions[0].gadget.modifier.correction_propagation_mod.toggle - assert list(toggle.i) == [0, 1] - assert list(toggle.j) == [0, 0] - - def test_multiple_paulis_accumulate( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary - ) -> None: - """VIRTUAL X0 and VIRTUAL Z1 on the same wire should toggle rows 1 and 2.""" - instructions = parse_jit_program( - trivial_code_k3_jit_library, - "PrepareZ 0\nVIRTUAL X0 0\nVIRTUAL Z1 0\nMeasureZ 0", - ) - toggle = instructions[0].gadget.modifier.correction_propagation_mod.toggle - assert list(toggle.i) == [1, 2] - assert list(toggle.j) == [0, 0] + assert list(toggle.i) == expected_i + assert list(toggle.j) == [0] * len(expected_i) def test_double_pauli_cancels( self, trivial_code_k3_jit_library: jit_pb.JitLibrary @@ -418,12 +291,13 @@ def test_pauli_does_not_consume_wire( def test_pauli_on_idle_output( self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: - """VIRTUAL on the output of Idle (which has 1 input port).""" + """VIRTUAL on the output of a gadget with inputs (Idle) lands in + the *last* column of the toggle matrix (the affine column), not + column 0.""" instructions = parse_jit_program( trivial_code_k3_jit_library, "PrepareZ 0\nIdle 0\nVIRTUAL X2 0\nMeasureZ 0", ) - # X2 should be on Idle (gid=2), which has 1 input and 1 output toggle = instructions[1].gadget.modifier.correction_propagation_mod.toggle # Idle: 6 output observables, 6 input observables -> cols = 6+1 = 7 assert toggle.rows == 6 @@ -452,42 +326,6 @@ def test_pauli_undefined_wire( "PrepareZ 0\nVIRTUAL X0 5\nMeasureZ 0", ) - def test_pauli_named_gadgets(self, named_jit_library: jit_pb.JitLibrary) -> None: - """VIRTUAL corrections should work with named gadgets.""" - instructions = parse_jit_program( - named_jit_library, - "PrepareZ 0\nVIRTUAL X1 0\nMeasureZ 0", - ) - toggle = instructions[0].gadget.modifier.correction_propagation_mod.toggle - # X1 -> toggle Z1 (row 3) at constant column (col 0) - assert list(toggle.i) == [3] - assert list(toggle.j) == [0] - - def test_multi_pauli_product( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary - ) -> None: - """VIRTUAL X0*Z1 0 should toggle both Z0 (row 1) and X1 (row 2).""" - instructions = parse_jit_program( - trivial_code_k3_jit_library, - "PrepareZ 0\nVIRTUAL X0*Z1 0\nMeasureZ 0", - ) - toggle = instructions[0].gadget.modifier.correction_propagation_mod.toggle - assert list(toggle.i) == [1, 2] - assert list(toggle.j) == [0, 0] - - def test_multi_pauli_product_three( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary - ) -> None: - """VIRTUAL X0*Z1*Y2 0 should toggle rows 1, 2, 4, and 5.""" - instructions = parse_jit_program( - trivial_code_k3_jit_library, - "PrepareZ 0\nVIRTUAL X0*Z1*Y2 0\nMeasureZ 0", - ) - toggle = instructions[0].gadget.modifier.correction_propagation_mod.toggle - # X0 -> row 1; Z1 -> row 2; Y2 -> rows 4 and 5 - assert list(toggle.i) == [1, 2, 4, 5] - assert list(toggle.j) == [0, 0, 0, 0] - def test_multi_pauli_equivalent_to_separate( self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: @@ -509,11 +347,21 @@ def test_multi_pauli_equivalent_to_separate( class TestConditionalCorrections: """Test CONDITIONAL rec[-k] pauli wire in PROGRAM bodies.""" - def test_emits_identity_gadget_with_modifier( + def test_synthesizes_identity_gadget( self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: - """CONDITIONAL inserts a synthesized identity gadget instance with - a remote_conditional_correction modifier.""" + """CONDITIONAL inserts a synthesized identity gadget that: + + 1. Bears a ``remote_conditional_correction`` modifier pointing + at the most recent logical readout. + 2. Chains into the wire's producer and hands the wire on to the + next consumer. + 3. Introduces a new ``__identity_...`` gtype into the library. + """ + # Snapshot gadget types so we can check that exactly one identity + # gtype is appended. + before = {gt.base.gtype for gt in trivial_code_k3_jit_library.gadget_types} + instructions = parse_jit_program( trivial_code_k3_jit_library, "PrepareZ OUT(0)\n" @@ -523,63 +371,28 @@ def test_emits_identity_gadget_with_modifier( "MeasureZ IN(0)", ) - # Expected: 5 instructions = 2 PrepareZ + 1 MeasureZ + identity + MeasureZ + # (1) Program shape: 5 instructions = 2 PrepareZ + 1 MeasureZ + + # identity + MeasureZ. The identity carries the modifier. assert len(instructions) == 5 cond_instr = instructions[3] assert cond_instr.gadget.HasField("modifier") - modifier = cond_instr.gadget.modifier - assert modifier.HasField("remote_conditional_correction") - rcc = modifier.remote_conditional_correction - # Reference is the most recent logical readout (MeasureZ on wire 1 - # emits 1 logical readout = XOR of the 3 physical measurements). + rcc = cond_instr.gadget.modifier.remote_conditional_correction + # References the MeasureZ on wire 1 (gid 3, readout 0). assert len(rcc.remote_readouts) == 1 assert rcc.remote_readouts[0].gid == 3 assert rcc.remote_readouts[0].readout_index == 0 - # X on logical qubit 0 flips the LZ_0 column (= z_column(0) = 1). + # X on logical qubit 0 flips the LZ_0 column (row 1). assert list(rcc.correction.i) == [1] assert list(rcc.correction.j) == [0] - def test_identity_gadget_chains_correctly( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary - ) -> None: - """The identity gadget consumes the wire from the previous producer - and the next gadget consumes from the identity gadget.""" - instructions = parse_jit_program( - trivial_code_k3_jit_library, - "PrepareZ OUT(0)\n" - "PrepareZ OUT(1)\n" - "MeasureZ IN(1)\n" - "CONDITIONAL rec[-1] X0 0\n" - "MeasureZ IN(0)", - ) - - # gid 1 = PrepareZ (wire 0); gid 2 = PrepareZ (wire 1); - # gid 3 = MeasureZ on wire 1; gid 4 = identity; gid 5 = MeasureZ on wire 0. - # The identity gadget (gid 4) must connect to gid 1 (wire 0's producer). - assert instructions[3].gadget.gid == 4 - assert len(instructions[3].gadget.connectors) == 1 - assert instructions[3].gadget.connectors[0].gid == 1 - assert instructions[3].gadget.connectors[0].port == 0 - # The final MeasureZ (gid 5) must connect to the identity gadget (gid 4). + # (2) Identity gadget (gid 4) chains through — consumes wire 0's + # producer (gid 1) and the following MeasureZ (gid 5) consumes it. + assert cond_instr.gadget.gid == 4 + assert [c.gid for c in cond_instr.gadget.connectors] == [1] assert instructions[4].gadget.gid == 5 - assert len(instructions[4].gadget.connectors) == 1 - assert instructions[4].gadget.connectors[0].gid == 4 - assert instructions[4].gadget.connectors[0].port == 0 + assert [c.gid for c in instructions[4].gadget.connectors] == [4] - def test_identity_gadget_type_added_to_library( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary - ) -> None: - """The synthesized identity gadget type is appended to the library.""" - # Snapshot the gtypes before compile. - before = {gt.base.gtype for gt in trivial_code_k3_jit_library.gadget_types} - parse_jit_program( - trivial_code_k3_jit_library, - "PrepareZ OUT(0)\n" - "MeasureZ IN(0)\n" # produces readouts so rec[-1] resolves - "PrepareZ OUT(0)\n" - "CONDITIONAL rec[-1] X0 0\n" - "MeasureZ IN(0)", - ) + # (3) A single new __identity_... gtype was appended to the library. after = {gt.base.gtype for gt in trivial_code_k3_jit_library.gadget_types} new_gtypes = after - before assert len(new_gtypes) == 1 @@ -590,8 +403,9 @@ def test_identity_gadget_type_added_to_library( ) assert new_gt.base.name.startswith("__identity_") assert len(new_gt.base.measurements) == 0 - assert len(new_gt.base.inputs) == 1 - assert len(new_gt.base.outputs) == 1 + assert ( + len(new_gt.base.inputs) == len(new_gt.base.outputs) == 1 + ) assert new_gt.base.inputs[0].ptype == new_gt.base.outputs[0].ptype def test_identity_gadget_reused_for_same_ptype( @@ -613,55 +427,36 @@ def test_identity_gadget_reused_for_same_ptype( # the same port type. assert after - before == 1 - def test_multi_pauli_product( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary + @pytest.mark.parametrize( + "pauli,expected_i", + [ + # X0*Z1 flips LZ_0 (col 1) and LX_1 (col 2). + ("X0*Z1", [1, 2]), + # Y2 = X2 * Z2 flips LX_2 (col 4) and LZ_2 (col 5). + ("Y2", [4, 5]), + # X0 * X0 = identity; correction matrix is empty. + ("X0*X0", []), + ], + ) + def test_correction_pauli( + self, + trivial_code_k3_jit_library: jit_pb.JitLibrary, + pauli: str, + expected_i: list[int], ) -> None: - """CONDITIONAL rec[-k] X0*Z1 wire flips both LZ_0 and LX_1.""" + """``CONDITIONAL rec[-k] wire`` builds the correction + matrix by flipping the LZ_i / LX_i columns of every Pauli + factor; identical factors cancel via XOR.""" instructions = parse_jit_program( trivial_code_k3_jit_library, "PrepareZ OUT(0)\n" "PrepareZ OUT(1)\n" "MeasureZ IN(1)\n" - "CONDITIONAL rec[-1] X0*Z1 0\n" - "MeasureZ IN(0)", - ) - rcc = instructions[3].gadget.modifier.remote_conditional_correction - # X0 flips LZ_0 (col 1), Z1 flips LX_1 (col 2). Sorted: [1, 2]. - assert list(rcc.correction.i) == [1, 2] - assert list(rcc.correction.j) == [0, 0] - - def test_y_pauli_flips_both( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary - ) -> None: - """CONDITIONAL rec[-k] Y wire flips both LX_i and LZ_i columns.""" - instructions = parse_jit_program( - trivial_code_k3_jit_library, - "PrepareZ OUT(0)\n" - "MeasureZ IN(0)\n" - "PrepareZ OUT(0)\n" - "CONDITIONAL rec[-1] Y2 0\n" - "MeasureZ IN(0)", - ) - rcc = instructions[3].gadget.modifier.remote_conditional_correction - # Y2 = X2 * Z2; flips LZ_2 (col 5) and LX_2 (col 4). Sorted: [4, 5]. - assert list(rcc.correction.i) == [4, 5] - - def test_pauli_cancellation( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary - ) -> None: - """CONDITIONAL rec[-k] X0*X0 wire has no effect (cancellation).""" - instructions = parse_jit_program( - trivial_code_k3_jit_library, - "PrepareZ OUT(0)\n" - "MeasureZ IN(0)\n" - "PrepareZ OUT(0)\n" - "CONDITIONAL rec[-1] X0*X0 0\n" + f"CONDITIONAL rec[-1] {pauli} 0\n" "MeasureZ IN(0)", ) rcc = instructions[3].gadget.modifier.remote_conditional_correction - # X0 * X0 = identity; correction matrix is empty. - assert list(rcc.correction.i) == [] - assert list(rcc.correction.j) == [] + assert list(rcc.correction.i) == expected_i def test_multiple_conditionals_on_same_wire_chain( self, trivial_code_k3_jit_library: jit_pb.JitLibrary @@ -698,44 +493,44 @@ def test_multiple_conditionals_on_same_wire_chain( assert rcc2.remote_readouts[0].gid == 4 assert rcc2.remote_readouts[0].readout_index == 0 - def test_rec_offset_out_of_range_raises( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary - ) -> None: - """CONDITIONAL rec[-k] with k > number of readouts so far raises.""" - with pytest.raises(ValueError, match="readout"): - parse_jit_program( - trivial_code_k3_jit_library, + @pytest.mark.parametrize( + "program,error_pattern", + [ + # rec[-1] with no readouts emitted yet. + ( "PrepareZ OUT(0)\n" - "CONDITIONAL rec[-1] X0 0\n" # no readouts yet + "CONDITIONAL rec[-1] X0 0\n" "MeasureZ IN(0)", - ) - - def test_unknown_wire_raises( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary - ) -> None: - """CONDITIONAL on a wire that has no producer raises.""" - with pytest.raises(ValueError, match="wire"): - parse_jit_program( - trivial_code_k3_jit_library, + "readout", + ), + # Wire 99 has no producer. + ( "PrepareZ OUT(0)\n" "MeasureZ IN(0)\n" - "CONDITIONAL rec[-1] X0 99\n", # wire 99 has no producer - ) - - def test_logical_qubit_index_out_of_range_raises( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary - ) -> None: - """CONDITIONAL with a logical qubit index >= code.k raises.""" - # ThreeQubitCode has k=3, so logical qubit 99 is out of range. - with pytest.raises(ValueError, match="logical qubit"): - parse_jit_program( - trivial_code_k3_jit_library, + "CONDITIONAL rec[-1] X0 99\n", + "wire", + ), + # Logical qubit 99 is out of range for a k=3 code. + ( "PrepareZ OUT(0)\n" "MeasureZ IN(0)\n" "PrepareZ OUT(0)\n" "CONDITIONAL rec[-1] X99 0\n" "MeasureZ IN(0)", - ) + "logical qubit", + ), + ], + ) + def test_invalid_conditional_raises( + self, + trivial_code_k3_jit_library: jit_pb.JitLibrary, + program: str, + error_pattern: str, + ) -> None: + """Common failure modes: rec offset out of range, unknown wire, + logical-qubit index >= code.k.""" + with pytest.raises(ValueError, match=error_pattern): + parse_jit_program(trivial_code_k3_jit_library, program) def test_end_to_end_static_jit_compile( self, trivial_code_k3_jit_library: jit_pb.JitLibrary @@ -812,62 +607,55 @@ def test_repeat_compiles_and_validates( class TestNestedPrograms: """Test sub-program inlining (calling a PROGRAM from another PROGRAM).""" - def test_sub_program_equivalent_to_inline( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary - ) -> None: - """Sub-program call should produce identical instructions to inlining.""" + @staticmethod + def _parse_sub_programs(source: str) -> dict[str, object]: + """Parse *source* and return every ``PROGRAM`` in it keyed by name. + + Every test in this class needs to build a ``program_defs`` dict + from a small snippet of ``.deq`` source; the boilerplate is + identical (import ProgramDefinition, run the parser, filter by + type) so it lives here. + """ from deq.circuit.model import ProgramDefinition from deq.circuit.parser import parse as parse_deq - sub_deq = parse_deq( - "PROGRAM PrepareAndIdle {\n" - " PrepareZ OUT(0)\n" - " Idle IN(0) OUT(0)\n" - "}\n" - ) - sub_def = [d for d in sub_deq.definitions if isinstance(d, ProgramDefinition)][ - 0 - ] + parsed = parse_deq(source) + return { + d.name: d + for d in parsed.definitions + if isinstance(d, ProgramDefinition) + } - inlined = parse_jit_program( - trivial_code_k3_jit_library, - "PrepareZ 0\nIdle 0\nMeasureZ 0", - ) - nested = parse_jit_program( - trivial_code_k3_jit_library, + @pytest.mark.parametrize( + "call_form", + [ + # Explicit IN/OUT. "PrepareAndIdle OUT(0)\nMeasureZ IN(0)", - program_defs={"PrepareAndIdle": sub_def}, - ) - - assert len(inlined) == len(nested) - for a, b in zip(inlined, nested): - assert a.gadget.gtype == b.gadget.gtype - - def test_sub_program_shortcut_form( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary + # Shortcut form. + "PrepareAndIdle 0\nMeasureZ 0", + ], + ) + def test_sub_program_equivalent_to_inline( + self, + trivial_code_k3_jit_library: jit_pb.JitLibrary, + call_form: str, ) -> None: - """Sub-program called with shortcut form: SubProgram wire.""" - from deq.circuit.model import ProgramDefinition - from deq.circuit.parser import parse as parse_deq - - sub_deq = parse_deq( + """Sub-program call should produce identical instructions to + inlining, for both the explicit ``IN(...) OUT(...)`` and the + wire-shortcut forms.""" + defs = self._parse_sub_programs( "PROGRAM PrepareAndIdle {\n" " PrepareZ OUT(0)\n" " Idle IN(0) OUT(0)\n" "}\n" ) - sub_def = [d for d in sub_deq.definitions if isinstance(d, ProgramDefinition)][ - 0 - ] inlined = parse_jit_program( trivial_code_k3_jit_library, "PrepareZ 0\nIdle 0\nMeasureZ 0", ) nested = parse_jit_program( - trivial_code_k3_jit_library, - "PrepareAndIdle 0\nMeasureZ 0", - program_defs={"PrepareAndIdle": sub_def}, + trivial_code_k3_jit_library, call_form, program_defs=defs ) assert len(inlined) == len(nested) @@ -878,10 +666,7 @@ def test_nested_sub_programs( self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: """A calls B, B calls gadgets — two levels of nesting.""" - from deq.circuit.model import ProgramDefinition - from deq.circuit.parser import parse as parse_deq - - parsed = parse_deq( + defs = self._parse_sub_programs( "PROGRAM IdleAndMeasure {\n" " Idle IN(0) OUT(0)\n" " MeasureZ IN(0)\n" @@ -891,19 +676,13 @@ def test_nested_sub_programs( " IdleAndMeasure IN(0)\n" "}\n" ) - defs = { - d.name: d for d in parsed.definitions if isinstance(d, ProgramDefinition) - } - full_def = defs.pop("Full") inlined = parse_jit_program( trivial_code_k3_jit_library, "PrepareZ 0\nIdle 0\nMeasureZ 0", ) nested = parse_jit_program( - trivial_code_k3_jit_library, - "Full 0", - program_defs={**defs, "Full": full_def}, + trivial_code_k3_jit_library, "Full 0", program_defs=defs ) assert len(inlined) == len(nested) @@ -914,10 +693,7 @@ def test_cycle_detection( self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: """Mutually recursive programs should raise an error.""" - from deq.circuit.model import ProgramDefinition - from deq.circuit.parser import parse as parse_deq - - parsed = parse_deq( + defs = self._parse_sub_programs( "PROGRAM A {\n" " B IN(0) OUT(0)\n" "}\n" @@ -925,10 +701,6 @@ def test_cycle_detection( " A IN(0) OUT(0)\n" "}\n" ) - defs = { - d.name: d for d in parsed.definitions if isinstance(d, ProgramDefinition) - } - with pytest.raises(ValueError, match="cycle"): parse_jit_program( trivial_code_k3_jit_library, @@ -936,65 +708,50 @@ def test_cycle_detection( program_defs=defs, ) - def test_wrong_input_count_for_sub_program( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary - ) -> None: - """Wrong number of input wires for sub-program should error.""" - from deq.circuit.model import ProgramDefinition - from deq.circuit.parser import parse as parse_deq - - sub_deq = parse_deq( - "PROGRAM NeedsInput {\n" - " Idle IN(0) OUT(0)\n" - " MeasureZ IN(0)\n" - "}\n" - ) - sub_def = [d for d in sub_deq.definitions if isinstance(d, ProgramDefinition)][ - 0 - ] - - with pytest.raises(ValueError, match="input wires"): - parse_jit_program( - trivial_code_k3_jit_library, + @pytest.mark.parametrize( + "sub_source,call,error_pattern", + [ + # Sub-program expects one input but no wire is supplied. + ( + "PROGRAM NeedsInput {\n" + " Idle IN(0) OUT(0)\n" + " MeasureZ IN(0)\n" + "}\n", "PrepareZ OUT(0)\nNeedsInput OUT(0)", - program_defs={"NeedsInput": sub_def}, - ) - - def test_wrong_output_count_for_sub_program( - self, trivial_code_k3_jit_library: jit_pb.JitLibrary + "input wires", + ), + # Sub-program emits one output but two are requested. + ( + "PROGRAM HasOutput {\n PrepareZ OUT(0)\n}\n", + "HasOutput OUT(0 1)", + "output wires", + ), + ], + ) + def test_wrong_wire_count_for_sub_program( + self, + trivial_code_k3_jit_library: jit_pb.JitLibrary, + sub_source: str, + call: str, + error_pattern: str, ) -> None: - """Wrong number of output wires for sub-program should error.""" - from deq.circuit.model import ProgramDefinition - from deq.circuit.parser import parse as parse_deq - - sub_deq = parse_deq("PROGRAM HasOutput {\n" " PrepareZ OUT(0)\n" "}\n") - sub_def = [d for d in sub_deq.definitions if isinstance(d, ProgramDefinition)][ - 0 - ] - - with pytest.raises(ValueError, match="output wires"): + """Wrong number of input or output wires for a sub-program raises.""" + defs = self._parse_sub_programs(sub_source) + with pytest.raises(ValueError, match=error_pattern): parse_jit_program( - trivial_code_k3_jit_library, - "HasOutput OUT(0 1)", - program_defs={"HasOutput": sub_def}, + trivial_code_k3_jit_library, call, program_defs=defs ) def test_sub_program_with_virtual_corrections( self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: """VIRTUAL corrections inside a sub-program are remapped correctly.""" - from deq.circuit.model import ProgramDefinition - from deq.circuit.parser import parse as parse_deq - - sub_deq = parse_deq( + defs = self._parse_sub_programs( "PROGRAM PrepareWithX0 {\n" " PrepareZ OUT(0)\n" " VIRTUAL X0 0\n" "}\n" ) - sub_def = [d for d in sub_deq.definitions if isinstance(d, ProgramDefinition)][ - 0 - ] inlined = parse_jit_program( trivial_code_k3_jit_library, @@ -1003,7 +760,7 @@ def test_sub_program_with_virtual_corrections( nested = parse_jit_program( trivial_code_k3_jit_library, "PrepareWithX0 OUT(0)\nMeasureZ IN(0)", - program_defs={"PrepareWithX0": sub_def}, + program_defs=defs, ) assert len(inlined) == len(nested) @@ -1016,23 +773,17 @@ def test_end_to_end_compile_with_sub_program( self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: """Sub-program expansion produces a valid compilable program.""" - from deq.circuit.model import ProgramDefinition - from deq.circuit.parser import parse as parse_deq - - sub_deq = parse_deq( + defs = self._parse_sub_programs( "PROGRAM PrepareAndIdle {\n" " PrepareZ OUT(0)\n" " Idle IN(0) OUT(0)\n" "}\n" ) - sub_def = [d for d in sub_deq.definitions if isinstance(d, ProgramDefinition)][ - 0 - ] instructions = parse_jit_program( trivial_code_k3_jit_library, "PrepareAndIdle OUT(0)\nMeasureZ IN(0)", - program_defs={"PrepareAndIdle": sub_def}, + program_defs=defs, ) jit_library = jit_pb.JitLibrary() From 8d8e60d38db6098917293b60a4ea1cdc3dddf4a2 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 13 Jul 2026 22:16:19 -0700 Subject: [PATCH 057/157] simplify jit test --- deq/tests/cli/jit_test.py | 401 ++++++++++++++++++++++++++++---------- 1 file changed, 303 insertions(+), 98 deletions(-) diff --git a/deq/tests/cli/jit_test.py b/deq/tests/cli/jit_test.py index bc9d1696..96663136 100644 --- a/deq/tests/cli/jit_test.py +++ b/deq/tests/cli/jit_test.py @@ -51,6 +51,20 @@ def trivial_code_k3_jit_library() -> jit_pb.JitLibrary: return build_jit_library(parse(_TRIVIAL_CODE_K3_DEQ)) +@pytest.fixture +def trivial_code_k3_codes() -> dict[str, object]: + """Code definitions for the trivial [[3,3]] code.""" + from deq.circuit.model import CodeDefinition + qfile = parse(_TRIVIAL_CODE_K3_DEQ) + return {d.name: d for d in qfile.definitions if isinstance(d, CodeDefinition)} + + +@pytest.fixture +def named_jit_library() -> jit_pb.JitLibrary: + """JIT library with named gadgets — same as trivial_code_k3_jit_library.""" + return build_jit_library(parse(_TRIVIAL_CODE_K3_DEQ)) + + class TestCompileProgram: """Test compiling full programs using .deq PROGRAM body syntax.""" @@ -84,6 +98,20 @@ def test_simple_program( assert instructions[2].gadget.connectors[0].gid == 2 assert instructions[2].gadget.connectors[0].port == 0 + def test_shortcut_form( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """Test shortcut form: PrepareZ 0 (infers IN/OUT from gadget ports).""" + instructions = parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ 0\nIdle 0\nMeasureZ 0", + ) + + assert len(instructions) == 3 + assert instructions[0].gadget.gtype == 1 + assert instructions[1].gadget.gtype == 2 + assert instructions[2].gadget.gtype == 3 + def test_chained_idles( self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: @@ -139,6 +167,16 @@ def test_unknown_gadget_error( "UnknownGadget OUT(0)", ) + def test_dangling_output_error( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """Test error when program has unconnected output wires.""" + with pytest.raises(ValueError, match="dangling output wires"): + parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ 0", + ) + def test_dangling_output_lists_each_producer( self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: @@ -154,6 +192,26 @@ def test_dangling_output_lists_each_producer( assert f"wire {wire}" in msg assert "PrepareZ" in msg + def test_dangling_output_with_idle( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """Test error when Idle leaves dangling output.""" + with pytest.raises(ValueError, match="dangling output wires"): + parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ 0\nIdle 0", + ) + + def test_no_dangling_output_with_measure( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """Test that MeasureZ properly consumes output (no dangling).""" + instructions = parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ 0\nMeasureZ 0", + ) + assert len(instructions) == 2 + class TestEndToEndCompilation: """Test full end-to-end compilation with validation.""" @@ -180,6 +238,28 @@ def test_compile_and_validate( # Validate assert is_valid_and_physical(deq_bin) + def test_compile_and_validate_shortcut( + self, named_jit_library: jit_pb.JitLibrary + ) -> None: + """Test that compiled program with shortcut form passes validation.""" + instructions = parse_jit_program( + named_jit_library, + "PrepareZ 0\nIdle 0\nMeasureZ 0", + ) + + # Add instructions to library + jit_library = jit_pb.JitLibrary() + jit_library.CopyFrom(named_jit_library) + jit_library.ClearField("program") + for instr in instructions: + jit_library.program.append(instr) + + # Compile to deq.bin + deq_bin = static_jit_compiler(jit_library) + + # Validate + assert is_valid_and_physical(deq_bin) + def test_compile_matches_direct_program( self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: @@ -227,42 +307,89 @@ def test_compile_matches_direct_program( assert are_programs_equivalent(deq_bin_parsed, deq_bin_direct) +class TestParseJitProgramAPI: + """Test the public parse_jit_program API.""" + + def test_parse_jit_program( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """Test the public API function with explicit IN/OUT.""" + instructions = parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ 0\nIdle 0\nMeasureZ 0", + ) + assert len(instructions) == 3 + assert instructions[0].gadget.gtype == 1 + assert instructions[1].gadget.gtype == 2 + assert instructions[2].gadget.gtype == 3 + + def test_parse_jit_program_shortcut( + self, named_jit_library: jit_pb.JitLibrary + ) -> None: + """Test the public API function with shortcut notation.""" + instructions = parse_jit_program( + named_jit_library, + "PrepareZ 0\nIdle 0\nMeasureZ 0", + ) + assert len(instructions) == 3 + assert instructions[0].gadget.gtype == 1 + assert instructions[1].gadget.gtype == 2 + assert instructions[2].gadget.gtype == 3 + + class TestPauliCorrections: """Test Pauli correction pseudo-instructions (VIRTUAL X0, Z1, Y2, etc.).""" - @pytest.mark.parametrize( - "pauli,expected_i", - [ - # Single-qubit Paulis: X flips LZ_i (row 2i+1); - # Z flips LX_i (row 2i); Y flips both. - ("X0", [1]), - ("Z1", [2]), - ("X1", [3]), - ("Y0", [0, 1]), - # Multi-Pauli products accumulate flips. - ("X0*Z1", [1, 2]), - ("X0*Z1*Y2", [1, 2, 4, 5]), - ], - ) - def test_virtual_pauli_toggles( - self, - trivial_code_k3_jit_library: jit_pb.JitLibrary, - pauli: str, - expected_i: list[int], + def test_x0_toggles_z0( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: - """``VIRTUAL 0`` after ``PrepareZ 0`` toggles the - expected rows at the constant column (cols=1 because PrepareZ - has no inputs — the toggle matrix is a plain constant vector). - """ + """VIRTUAL X0 0 should toggle Z0 (row 1) in the constant column.""" instructions = parse_jit_program( trivial_code_k3_jit_library, - f"PrepareZ 0\nVIRTUAL {pauli} 0\nMeasureZ 0", + "PrepareZ 0\nVIRTUAL X0 0\nMeasureZ 0", ) + assert len(instructions) == 2 toggle = instructions[0].gadget.modifier.correction_propagation_mod.toggle assert toggle.rows == 6 assert toggle.cols == 1 - assert list(toggle.i) == expected_i - assert list(toggle.j) == [0] * len(expected_i) + assert list(toggle.i) == [1] + assert list(toggle.j) == [0] + + def test_z1_toggles_x1( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """VIRTUAL Z1 0 should toggle X1 (row 2) in the constant column.""" + instructions = parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ 0\nVIRTUAL Z1 0\nMeasureZ 0", + ) + toggle = instructions[0].gadget.modifier.correction_propagation_mod.toggle + assert list(toggle.i) == [2] + assert list(toggle.j) == [0] + + def test_y0_toggles_both( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """VIRTUAL Y0 0 should toggle both X0 (row 0) and Z0 (row 1).""" + instructions = parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ 0\nVIRTUAL Y0 0\nMeasureZ 0", + ) + toggle = instructions[0].gadget.modifier.correction_propagation_mod.toggle + assert list(toggle.i) == [0, 1] + assert list(toggle.j) == [0, 0] + + def test_multiple_paulis_accumulate( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """VIRTUAL X0 and VIRTUAL Z1 on the same wire should toggle rows 1 and 2.""" + instructions = parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ 0\nVIRTUAL X0 0\nVIRTUAL Z1 0\nMeasureZ 0", + ) + toggle = instructions[0].gadget.modifier.correction_propagation_mod.toggle + assert list(toggle.i) == [1, 2] + assert list(toggle.j) == [0, 0] def test_double_pauli_cancels( self, trivial_code_k3_jit_library: jit_pb.JitLibrary @@ -291,13 +418,12 @@ def test_pauli_does_not_consume_wire( def test_pauli_on_idle_output( self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: - """VIRTUAL on the output of a gadget with inputs (Idle) lands in - the *last* column of the toggle matrix (the affine column), not - column 0.""" + """VIRTUAL on the output of Idle (which has 1 input port).""" instructions = parse_jit_program( trivial_code_k3_jit_library, "PrepareZ 0\nIdle 0\nVIRTUAL X2 0\nMeasureZ 0", ) + # X2 should be on Idle (gid=2), which has 1 input and 1 output toggle = instructions[1].gadget.modifier.correction_propagation_mod.toggle # Idle: 6 output observables, 6 input observables -> cols = 6+1 = 7 assert toggle.rows == 6 @@ -326,6 +452,42 @@ def test_pauli_undefined_wire( "PrepareZ 0\nVIRTUAL X0 5\nMeasureZ 0", ) + def test_pauli_named_gadgets(self, named_jit_library: jit_pb.JitLibrary) -> None: + """VIRTUAL corrections should work with named gadgets.""" + instructions = parse_jit_program( + named_jit_library, + "PrepareZ 0\nVIRTUAL X1 0\nMeasureZ 0", + ) + toggle = instructions[0].gadget.modifier.correction_propagation_mod.toggle + # X1 -> toggle Z1 (row 3) at constant column (col 0) + assert list(toggle.i) == [3] + assert list(toggle.j) == [0] + + def test_multi_pauli_product( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """VIRTUAL X0*Z1 0 should toggle both Z0 (row 1) and X1 (row 2).""" + instructions = parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ 0\nVIRTUAL X0*Z1 0\nMeasureZ 0", + ) + toggle = instructions[0].gadget.modifier.correction_propagation_mod.toggle + assert list(toggle.i) == [1, 2] + assert list(toggle.j) == [0, 0] + + def test_multi_pauli_product_three( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """VIRTUAL X0*Z1*Y2 0 should toggle rows 1, 2, 4, and 5.""" + instructions = parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ 0\nVIRTUAL X0*Z1*Y2 0\nMeasureZ 0", + ) + toggle = instructions[0].gadget.modifier.correction_propagation_mod.toggle + # X0 -> row 1; Z1 -> row 2; Y2 -> rows 4 and 5 + assert list(toggle.i) == [1, 2, 4, 5] + assert list(toggle.j) == [0, 0, 0, 0] + def test_multi_pauli_equivalent_to_separate( self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: @@ -607,55 +769,62 @@ def test_repeat_compiles_and_validates( class TestNestedPrograms: """Test sub-program inlining (calling a PROGRAM from another PROGRAM).""" - @staticmethod - def _parse_sub_programs(source: str) -> dict[str, object]: - """Parse *source* and return every ``PROGRAM`` in it keyed by name. - - Every test in this class needs to build a ``program_defs`` dict - from a small snippet of ``.deq`` source; the boilerplate is - identical (import ProgramDefinition, run the parser, filter by - type) so it lives here. - """ + def test_sub_program_equivalent_to_inline( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """Sub-program call should produce identical instructions to inlining.""" from deq.circuit.model import ProgramDefinition from deq.circuit.parser import parse as parse_deq - parsed = parse_deq(source) - return { - d.name: d - for d in parsed.definitions - if isinstance(d, ProgramDefinition) - } + sub_deq = parse_deq( + "PROGRAM PrepareAndIdle {\n" + " PrepareZ OUT(0)\n" + " Idle IN(0) OUT(0)\n" + "}\n" + ) + sub_def = [d for d in sub_deq.definitions if isinstance(d, ProgramDefinition)][ + 0 + ] - @pytest.mark.parametrize( - "call_form", - [ - # Explicit IN/OUT. + inlined = parse_jit_program( + trivial_code_k3_jit_library, + "PrepareZ 0\nIdle 0\nMeasureZ 0", + ) + nested = parse_jit_program( + trivial_code_k3_jit_library, "PrepareAndIdle OUT(0)\nMeasureZ IN(0)", - # Shortcut form. - "PrepareAndIdle 0\nMeasureZ 0", - ], - ) - def test_sub_program_equivalent_to_inline( - self, - trivial_code_k3_jit_library: jit_pb.JitLibrary, - call_form: str, + program_defs={"PrepareAndIdle": sub_def}, + ) + + assert len(inlined) == len(nested) + for a, b in zip(inlined, nested): + assert a.gadget.gtype == b.gadget.gtype + + def test_sub_program_shortcut_form( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: - """Sub-program call should produce identical instructions to - inlining, for both the explicit ``IN(...) OUT(...)`` and the - wire-shortcut forms.""" - defs = self._parse_sub_programs( + """Sub-program called with shortcut form: SubProgram wire.""" + from deq.circuit.model import ProgramDefinition + from deq.circuit.parser import parse as parse_deq + + sub_deq = parse_deq( "PROGRAM PrepareAndIdle {\n" " PrepareZ OUT(0)\n" " Idle IN(0) OUT(0)\n" "}\n" ) + sub_def = [d for d in sub_deq.definitions if isinstance(d, ProgramDefinition)][ + 0 + ] inlined = parse_jit_program( trivial_code_k3_jit_library, "PrepareZ 0\nIdle 0\nMeasureZ 0", ) nested = parse_jit_program( - trivial_code_k3_jit_library, call_form, program_defs=defs + trivial_code_k3_jit_library, + "PrepareAndIdle 0\nMeasureZ 0", + program_defs={"PrepareAndIdle": sub_def}, ) assert len(inlined) == len(nested) @@ -666,7 +835,10 @@ def test_nested_sub_programs( self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: """A calls B, B calls gadgets — two levels of nesting.""" - defs = self._parse_sub_programs( + from deq.circuit.model import ProgramDefinition + from deq.circuit.parser import parse as parse_deq + + parsed = parse_deq( "PROGRAM IdleAndMeasure {\n" " Idle IN(0) OUT(0)\n" " MeasureZ IN(0)\n" @@ -676,13 +848,19 @@ def test_nested_sub_programs( " IdleAndMeasure IN(0)\n" "}\n" ) + defs = { + d.name: d for d in parsed.definitions if isinstance(d, ProgramDefinition) + } + full_def = defs.pop("Full") inlined = parse_jit_program( trivial_code_k3_jit_library, "PrepareZ 0\nIdle 0\nMeasureZ 0", ) nested = parse_jit_program( - trivial_code_k3_jit_library, "Full 0", program_defs=defs + trivial_code_k3_jit_library, + "Full 0", + program_defs={**defs, "Full": full_def}, ) assert len(inlined) == len(nested) @@ -693,7 +871,10 @@ def test_cycle_detection( self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: """Mutually recursive programs should raise an error.""" - defs = self._parse_sub_programs( + from deq.circuit.model import ProgramDefinition + from deq.circuit.parser import parse as parse_deq + + parsed = parse_deq( "PROGRAM A {\n" " B IN(0) OUT(0)\n" "}\n" @@ -701,6 +882,10 @@ def test_cycle_detection( " A IN(0) OUT(0)\n" "}\n" ) + defs = { + d.name: d for d in parsed.definitions if isinstance(d, ProgramDefinition) + } + with pytest.raises(ValueError, match="cycle"): parse_jit_program( trivial_code_k3_jit_library, @@ -708,50 +893,65 @@ def test_cycle_detection( program_defs=defs, ) - @pytest.mark.parametrize( - "sub_source,call,error_pattern", - [ - # Sub-program expects one input but no wire is supplied. - ( - "PROGRAM NeedsInput {\n" - " Idle IN(0) OUT(0)\n" - " MeasureZ IN(0)\n" - "}\n", + def test_wrong_input_count_for_sub_program( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary + ) -> None: + """Wrong number of input wires for sub-program should error.""" + from deq.circuit.model import ProgramDefinition + from deq.circuit.parser import parse as parse_deq + + sub_deq = parse_deq( + "PROGRAM NeedsInput {\n" + " Idle IN(0) OUT(0)\n" + " MeasureZ IN(0)\n" + "}\n" + ) + sub_def = [d for d in sub_deq.definitions if isinstance(d, ProgramDefinition)][ + 0 + ] + + with pytest.raises(ValueError, match="input wires"): + parse_jit_program( + trivial_code_k3_jit_library, "PrepareZ OUT(0)\nNeedsInput OUT(0)", - "input wires", - ), - # Sub-program emits one output but two are requested. - ( - "PROGRAM HasOutput {\n PrepareZ OUT(0)\n}\n", - "HasOutput OUT(0 1)", - "output wires", - ), - ], - ) - def test_wrong_wire_count_for_sub_program( - self, - trivial_code_k3_jit_library: jit_pb.JitLibrary, - sub_source: str, - call: str, - error_pattern: str, + program_defs={"NeedsInput": sub_def}, + ) + + def test_wrong_output_count_for_sub_program( + self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: - """Wrong number of input or output wires for a sub-program raises.""" - defs = self._parse_sub_programs(sub_source) - with pytest.raises(ValueError, match=error_pattern): + """Wrong number of output wires for sub-program should error.""" + from deq.circuit.model import ProgramDefinition + from deq.circuit.parser import parse as parse_deq + + sub_deq = parse_deq("PROGRAM HasOutput {\n" " PrepareZ OUT(0)\n" "}\n") + sub_def = [d for d in sub_deq.definitions if isinstance(d, ProgramDefinition)][ + 0 + ] + + with pytest.raises(ValueError, match="output wires"): parse_jit_program( - trivial_code_k3_jit_library, call, program_defs=defs + trivial_code_k3_jit_library, + "HasOutput OUT(0 1)", + program_defs={"HasOutput": sub_def}, ) def test_sub_program_with_virtual_corrections( self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: """VIRTUAL corrections inside a sub-program are remapped correctly.""" - defs = self._parse_sub_programs( + from deq.circuit.model import ProgramDefinition + from deq.circuit.parser import parse as parse_deq + + sub_deq = parse_deq( "PROGRAM PrepareWithX0 {\n" " PrepareZ OUT(0)\n" " VIRTUAL X0 0\n" "}\n" ) + sub_def = [d for d in sub_deq.definitions if isinstance(d, ProgramDefinition)][ + 0 + ] inlined = parse_jit_program( trivial_code_k3_jit_library, @@ -760,7 +960,7 @@ def test_sub_program_with_virtual_corrections( nested = parse_jit_program( trivial_code_k3_jit_library, "PrepareWithX0 OUT(0)\nMeasureZ IN(0)", - program_defs=defs, + program_defs={"PrepareWithX0": sub_def}, ) assert len(inlined) == len(nested) @@ -773,17 +973,23 @@ def test_end_to_end_compile_with_sub_program( self, trivial_code_k3_jit_library: jit_pb.JitLibrary ) -> None: """Sub-program expansion produces a valid compilable program.""" - defs = self._parse_sub_programs( + from deq.circuit.model import ProgramDefinition + from deq.circuit.parser import parse as parse_deq + + sub_deq = parse_deq( "PROGRAM PrepareAndIdle {\n" " PrepareZ OUT(0)\n" " Idle IN(0) OUT(0)\n" "}\n" ) + sub_def = [d for d in sub_deq.definitions if isinstance(d, ProgramDefinition)][ + 0 + ] instructions = parse_jit_program( trivial_code_k3_jit_library, "PrepareAndIdle OUT(0)\nMeasureZ IN(0)", - program_defs=defs, + program_defs={"PrepareAndIdle": sub_def}, ) jit_library = jit_pb.JitLibrary() @@ -859,7 +1065,6 @@ def test_stim_export_remaps_mpp_pauli_targets() -> None: assert "X4" in mpp_line, f"expected remapped indices in: {mpp_line}" assert "X0" not in mpp_line, f"local index leaked through in: {mpp_line}" - # --------------------------------------------------------------------------- # Merge-time residual helpers — shared across conditional-equivalence # tests over the teleportation, lattice-surgery, and trivial-surgery From 68629e052def2279aa6c56c1eebc73a925ac635f Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 14 Jul 2026 08:51:56 -0700 Subject: [PATCH 058/157] add MZZZ gadget --- .../circuit/fixtures/trivial_surgery.deq | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/deq/tests/circuit/fixtures/trivial_surgery.deq b/deq/tests/circuit/fixtures/trivial_surgery.deq index f66e3709..fb4fc2b2 100644 --- a/deq/tests/circuit/fixtures/trivial_surgery.deq +++ b/deq/tests/circuit/fixtures/trivial_surgery.deq @@ -105,6 +105,40 @@ COMPOSE TwoMZZCompose { OUTPUT One 1 } +GADGET ThreeMZZZ { + INPUT One 0 + INPUT One 1 + INPUT One 2 + + MPP Z0*Z1*Z2 + READOUT M0 + + OUTPUT One 0 + OUTPUT One 1 + OUTPUT One 2 + + CONDITIONAL R0 OUT2.LX0 +} + +# use 3 ancillas to do MZZZ +GADGET ThreeMZZZAlternative { + INPUT One 0 + INPUT One 1 + INPUT One 2 + + RX 3 4 5 + MPP Z0*Z3 Z1*Z4 Z2*Z5 + MPP Z3*Z4*Z5 + READOUT M0 M1 M2 M3 + MX 3 4 5 + + OUTPUT One 0 + OUTPUT One 1 + OUTPUT One 2 + + CONDITIONAL R0 OUT2.LX0 +} + # ── Mixed inner/outer CONDITIONAL fixtures ──────────────────────── # # Both COMPOSEs below implement the same operation as ``TwoMZZ`` but From 02c2df5b6d3ceefcf17625bad52b759499080920 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Fri, 17 Jul 2026 20:31:29 -0700 Subject: [PATCH 059/157] add language level loss support --- deq/deq/circuit/deq.lark | 25 ++++++++ deq/deq/circuit/model.py | 46 +++++++++++++++ deq/deq/circuit/transformer.py | 45 ++++++++++++++ deq/deq/circuit/vscode-deq/README.md | 1 + .../vscode-deq/syntaxes/deq.tmLanguage.json | 59 +++++++++++++++++++ deq/deq/transpiler/stim_constants.py | 9 ++- 6 files changed, 182 insertions(+), 3 deletions(-) diff --git a/deq/deq/circuit/deq.lark b/deq/deq/circuit/deq.lark index 7179da1b..e279d5a9 100644 --- a/deq/deq/circuit/deq.lark +++ b/deq/deq/circuit/deq.lark @@ -39,6 +39,7 @@ _gadget_body_item: repeat_block_gadget | readout_statement | check_statement | error_statement + | loss_statement | conditional_statement | preselect_statement | virtual_logical_statement @@ -103,6 +104,25 @@ port_binding_out: "OUT" "(" INT+ ")" readout_statement: ("READOUT" | "OBSERVABLE_INCLUDE") _readout_target+ [FLIP_KW] check_statement: ("CHECK" | "DETECTOR") target+ [FLIP_KW] error_statement: "ERROR" "(" NUMBER ")" _error_target+ +// A LOSS statement mirrors one entry of the JIT loss model. The source +// form ``LOSS(p) ...`` carries the declared LOSS_ERROR probability; the +// input form ``LOSS(IN.L) ...`` is the continuation of a loss that +// enters on input physical qubit ``j`` of input port ``i`` and carries no +// probability or source-error targets. Targets: ``SE``/``CE`` index +// the gadget's errors (source-only / continuation generators), ``L`` +// indexes the source losses (within-gadget children), ``OUT.L`` are +// output physical-qubit exits, and ``M`` are herald measurements. +loss_statement: LOSS_KW "(" NUMBER ")" _loss_target* + | LOSS_KW "(" INPUT_PHYS_QUBIT_TARGET ")" _loss_target* +_loss_target: SOURCE_ERROR_TARGET + | CONT_ERROR_TARGET + | CHILD_LOSS_TARGET + | OUTPUT_PHYS_QUBIT_TARGET + | PHYS_MEAS_TARGET +// Match ``LOSS`` only as a standalone keyword, never as the prefix of an +// identifier such as the ``LOSS_ERROR`` instruction (the negative lookahead +// rejects a following identifier character). +LOSS_KW.3: /LOSS(?![A-Za-z0-9_])/ conditional_statement: "CONDITIONAL" readout_target logical_pauli_target+ | "CONDITIONAL" _meas_record_like logical_pauli_target+ preselect_statement: "PRESELECT" _meas_record_like INT @@ -225,6 +245,11 @@ OUTPUT_VIRTUAL_TARGET.2: /OUT[0-9]+\.S[0-9]+/ INPUT_DESTAB_TARGET.2: /IN[0-9]+\.DS[0-9]+/ INPUT_LOGICAL_TARGET.3: /IN[0-9]+\.L[XYZ][0-9]+/ OUTPUT_LOGICAL_TARGET.3: /OUT[0-9]+\.L[XYZ][0-9]+/ +SOURCE_ERROR_TARGET.2: /SE[0-9]+/ +CONT_ERROR_TARGET.2: /CE[0-9]+/ +CHILD_LOSS_TARGET.2: /L[0-9]+/ +OUTPUT_PHYS_QUBIT_TARGET.2: /OUT[0-9]+\.L[0-9]+/ +INPUT_PHYS_QUBIT_TARGET.2: /IN[0-9]+\.L[0-9]+/ SWEEP_BIT_TARGET.2: /sweep\[[0-9]+\]/ COMBINER: "*" DECORATOR_NAME: /@[a-zA-Z][a-zA-Z0-9_]*/ diff --git a/deq/deq/circuit/model.py b/deq/deq/circuit/model.py index 49c9d8ae..8020cb93 100644 --- a/deq/deq/circuit/model.py +++ b/deq/deq/circuit/model.py @@ -453,6 +453,51 @@ class ErrorStatement: decorators: list[Decorator] = field(default_factory=list) +@dataclass +class LossStatement: + """A ``LOSS(...)`` declaration mirroring one JIT loss-model entry. + + A *source* loss carries the declared ``LOSS_ERROR`` probability. An + *input* loss (``input_port``/``input_qubit`` set) is the continuation of + a loss entering on that input physical qubit; it carries no probability + and no source-error generators, and its position is identified by the + ``IN.L`` head rather than by list order. + + ``source_errors`` / ``continuation_errors`` index the + gadget's ``ERROR`` mechanisms; ``child_losses`` index the source + losses (within-gadget children); ``output_qubits`` are ``(port, qubit)`` + physical exits; ``measurement_indices`` are herald measurements. + """ + + probability: float | None = None + input_port: int | None = None + input_qubit: int | None = None + source_errors: list[int] = field(default_factory=list) + continuation_errors: list[int] = field(default_factory=list) + child_losses: list[int] = field(default_factory=list) + output_qubits: list[tuple[int, int]] = field(default_factory=list) + measurement_indices: list[int] = field(default_factory=list) + decorators: list[Decorator] = field(default_factory=list) + + @property + def is_input(self) -> bool: + """Whether this is an input-continuation loss (vs a source loss).""" + return self.input_port is not None + + def __str__(self) -> str: + if self.is_input: + head = f"LOSS(IN{self.input_port}.L{self.input_qubit})" + else: + head = f"LOSS({self.probability})" + parts = [head] + parts += [f"SE{i}" for i in self.source_errors] + parts += [f"CE{i}" for i in self.continuation_errors] + parts += [f"L{i}" for i in self.child_losses] + parts += [f"OUT{port}.L{qubit}" for port, qubit in self.output_qubits] + parts += [f"M{i}" for i in self.measurement_indices] + return " ".join(parts) + + @dataclass class ConditionalStatement: """A ``CONDITIONAL R L

...`` or ``CONDITIONAL rec[-k] L

...`` declaration. @@ -564,6 +609,7 @@ class PreselectStatement: | ReadoutStatement | CheckStatement | ErrorStatement + | LossStatement | ConditionalStatement | VirtualLogicalStatement | PropagateStatement diff --git a/deq/deq/circuit/transformer.py b/deq/deq/circuit/transformer.py index 716418a4..b83403a0 100644 --- a/deq/deq/circuit/transformer.py +++ b/deq/deq/circuit/transformer.py @@ -29,6 +29,7 @@ KeywordArg, LogicalOperator, LogicalPauliTarget, + LossStatement, MeasurementRecordTarget, MeasurementRefTarget, OutputPort, @@ -61,6 +62,8 @@ _PHYS_MEAS_RE = re.compile(r"M(\d+)") _INPUT_VIRTUAL_RE = re.compile(r"IN(\d+)\.S(\d+)") _OUTPUT_VIRTUAL_RE = re.compile(r"OUT(\d+)\.S(\d+)") +_INPUT_PHYS_QUBIT_RE = re.compile(r"IN(\d+)\.L(\d+)") +_OUTPUT_PHYS_QUBIT_RE = re.compile(r"OUT(\d+)\.L(\d+)") # Token types whose lexeme refers to a measurement (relative or absolute). _MEAS_REF_TOKEN_TYPES = frozenset( @@ -552,6 +555,48 @@ def error_statement(self, items: list[Any]) -> ErrorStatement: targets: list[ErrorTarget] = list(items[1:]) return ErrorStatement(probability=probability, targets=targets) + def loss_statement(self, items: list[Any]) -> LossStatement: + targets = [ + it for it in items if not (isinstance(it, Token) and it.type == "LOSS_KW") + ] + head = targets[0] + stmt = LossStatement() + if isinstance(head, Token) and head.type == "INPUT_PHYS_QUBIT_TARGET": + match = _INPUT_PHYS_QUBIT_RE.match(str(head)) + if not match: + raise SyntaxError(f"invalid input loss target: {head!r}") + stmt.input_port = int(match.group(1)) + stmt.input_qubit = int(match.group(2)) + else: + probability = float(head) + if not (0.0 < probability <= 1.0): + raise SyntaxError( + f"LOSS probability must be in (0, 1], got {probability}" + ) + stmt.probability = probability + for item in targets[1:]: + if not isinstance(item, Token): + raise SyntaxError(f"unexpected LOSS target: {item!r}") + text = str(item) + if item.type == "SOURCE_ERROR_TARGET": + stmt.source_errors.append(int(text[2:])) + elif item.type == "CONT_ERROR_TARGET": + stmt.continuation_errors.append(int(text[2:])) + elif item.type == "CHILD_LOSS_TARGET": + stmt.child_losses.append(int(text[1:])) + elif item.type == "OUTPUT_PHYS_QUBIT_TARGET": + match = _OUTPUT_PHYS_QUBIT_RE.match(text) + if not match: + raise SyntaxError(f"invalid output qubit target: {item!r}") + stmt.output_qubits.append((int(match.group(1)), int(match.group(2)))) + elif item.type == "PHYS_MEAS_TARGET": + stmt.measurement_indices.append(int(text[1:])) + else: + raise SyntaxError(f"unexpected LOSS target: {item!r}") + if stmt.is_input and stmt.source_errors: + raise SyntaxError("input LOSS must not carry SE (source-error) targets") + return stmt + def conditional_statement(self, items: list[Any]) -> ConditionalStatement: condition = items[0] if isinstance(condition, Token) and condition.type in _MEAS_REF_TOKEN_TYPES: diff --git a/deq/deq/circuit/vscode-deq/README.md b/deq/deq/circuit/vscode-deq/README.md index 6148570c..b29b7f4a 100644 --- a/deq/deq/circuit/vscode-deq/README.md +++ b/deq/deq/circuit/vscode-deq/README.md @@ -9,6 +9,7 @@ Syntax highlighting for `.deq` quantum error correction files. - `LOGICAL`, `STABILIZER` declarations with Pauli products - `INPUT`, `OUTPUT`, `CHECK`, `READOUT` statements - `ERROR(prob)` and `MEASURE(count)` statements + - `LOSS_ERROR(prob)` instructions and `LOSS(...)` loss-model statements with their `SE`/`CE`/`L`/`OUT.L`/`M` targets - Distinct colors for check (`C0`), Pauli (`X0`/`Y0`/`Z0`), readout (`R0`), and logical Pauli shortcut (`LX0`/`LY0`/`LZ0`) targets - `ASSERT_EQ` assertions - Gadget applications with `IN(...)` / `OUT(...)` port bindings diff --git a/deq/deq/circuit/vscode-deq/syntaxes/deq.tmLanguage.json b/deq/deq/circuit/vscode-deq/syntaxes/deq.tmLanguage.json index 7d369d33..1221006e 100644 --- a/deq/deq/circuit/vscode-deq/syntaxes/deq.tmLanguage.json +++ b/deq/deq/circuit/vscode-deq/syntaxes/deq.tmLanguage.json @@ -299,6 +299,9 @@ { "include": "#error-statement" }, + { + "include": "#loss-statement" + }, { "include": "#conditional-statement" }, @@ -572,6 +575,62 @@ } ] }, + "loss-statement": { + "begin": "\\b(LOSS)(?![A-Za-z0-9_])\\s*(\\()", + "beginCaptures": { + "1": { + "name": "keyword.directive.deq" + }, + "2": { + "name": "punctuation.bracket.deq" + } + }, + "end": "(?=#)|$", + "patterns": [ + { + "include": "#mako-expression" + }, + { + "match": "IN\\d+\\.L\\d+", + "name": "entity.name.tag.logical.deq" + }, + { + "include": "#loss-targets" + }, + { + "match": "-?\\d+(?:\\.\\d*)?(?:[eE][+-]?\\d+)?", + "name": "constant.numeric.deq" + }, + { + "match": "\\)", + "name": "punctuation.bracket.deq" + } + ] + }, + "loss-targets": { + "patterns": [ + { + "match": "SE\\d+", + "name": "support.class.loss.source.deq" + }, + { + "match": "CE\\d+", + "name": "support.class.loss.continuation.deq" + }, + { + "match": "OUT\\d+\\.L\\d+", + "name": "entity.name.tag.logical.deq" + }, + { + "match": "L\\d+", + "name": "support.class.loss.child.deq" + }, + { + "match": "M\\d+", + "name": "variable.other.deq" + } + ] + }, "conditional-statement": { "begin": "\\b(CONDITIONAL)\\b", "beginCaptures": { diff --git a/deq/deq/transpiler/stim_constants.py b/deq/deq/transpiler/stim_constants.py index 53e99cd5..c321192c 100644 --- a/deq/deq/transpiler/stim_constants.py +++ b/deq/deq/transpiler/stim_constants.py @@ -36,8 +36,8 @@ # * are assumed to produce **zero measurement bits** (so any # measurement-counting pass returns 0 for them). # -# ``LOSS_ERROR(p) q...`` is QDK's stim extension that marks a qubit as -# lossy just before its next measurement. Adding it here lets users +# ``LOSS_ERROR(p) q...`` is QDK's stim extension that injects persistent +# loss at that exact circuit location. Adding it here lets users # write loss-aware circuits directly in ``.deq``; the deq runtime # itself does not interpret loss, but ``qdk.stim`` (driven via # ``--simulator python``) does. @@ -47,7 +47,9 @@ # already skips :data:`NOISE_INSTRUCTIONS` should also skip. Prefer # this set in callers that simply want "anything that looks like a # noise channel" — including QDK-style passthrough extensions. -NOISE_INSTRUCTIONS_ALL: frozenset[str] = NOISE_INSTRUCTIONS | PASSTHROUGH_NOISE_INSTRUCTIONS +NOISE_INSTRUCTIONS_ALL: frozenset[str] = ( + NOISE_INSTRUCTIONS | PASSTHROUGH_NOISE_INSTRUCTIONS +) def instruction_num_measurements(instruction_text: str) -> int: @@ -72,6 +74,7 @@ def instruction_num_measurements(instruction_text: str) -> int: return 0 return stim.CircuitInstruction(instruction_text).num_measurements + # Single-qubit gates that produce measurement results (M, MR, MX, etc.). # Excludes heralded noise channels (HERALDED_ERASE, etc.) which require # a probability argument (num_parens_arguments_range starts at > 0). From ad66cf4e760cc436dfc8f011d9b923a253aa94f6 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 21 Jul 2026 09:27:31 -0700 Subject: [PATCH 060/157] update protobuf --- deq/deq_runtime/src/proto/deq.bin.rs | 3 + .../src/proto/deq.decoder.blackbox_decoder.rs | 40 ++++++++++ deq/deq_runtime/src/proto/deq.jit.rs | 77 +++++++++++++++++++ deq/proto/blackbox_decoder.proto | 34 ++++++++ deq/proto/deq_bin.proto | 3 + deq/proto/deq_jit.proto | 68 ++++++++++++++++ 6 files changed, 225 insertions(+) diff --git a/deq/deq_runtime/src/proto/deq.bin.rs b/deq/deq_runtime/src/proto/deq.bin.rs index 13b28fd7..0d58d383 100644 --- a/deq/deq_runtime/src/proto/deq.bin.rs +++ b/deq/deq_runtime/src/proto/deq.bin.rs @@ -151,6 +151,9 @@ pub struct PortType { pub mesh: ::prost::alloc::vec::Vec, #[prost(message, repeated, tag = "6")] pub positions: ::prost::alloc::vec::Vec, + /// number of physical qubits carried by a port of this type (the code's `n`). + #[prost(uint64, tag = "7")] + pub n: u64, } /// Nested message and enum types in `PortType`. pub mod port_type { diff --git a/deq/deq_runtime/src/proto/deq.decoder.blackbox_decoder.rs b/deq/deq_runtime/src/proto/deq.decoder.blackbox_decoder.rs index e04867a1..fe873323 100644 --- a/deq/deq_runtime/src/proto/deq.decoder.blackbox_decoder.rs +++ b/deq/deq_runtime/src/proto/deq.decoder.blackbox_decoder.rs @@ -14,6 +14,10 @@ pub struct DecodingProblem { pub hypergraph: ::core::option::Option, #[prost(message, optional, tag = "2")] pub syndrome: ::core::option::Option, + /// Optional loss-aware extension. Present only when atom losses are observed + /// for this shot; absent otherwise. + #[prost(message, optional, tag = "3")] + pub loss: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct LoadHypergraphResponse { @@ -49,6 +53,42 @@ pub struct Hyperedge { #[prost(double, tag = "2")] pub probability: f64, } +/// The observed atom-loss graph for one shot, projected onto the decoding +/// problem's own indices. Enforcing exclusivity is a decoder policy, expressed +/// through `LossSite.children`. A reweighting strategy can instead flatten the +/// sites into ordinary reweighted hyperedges for an unmodified black-box decoder. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct LossInfo { + /// One entry per possible loss site. The runtime has already filtered these to + /// the sites consistent with the observed loss-resolving readouts (herald + /// folding), so per-site heralds are not exposed to the decoder. That is, we + /// guarantee that at least one of the readout is loss and none of them are non-loss + /// (those readouts outside of the decoding window are not considered) + #[prost(message, repeated, tag = "1")] + pub sites: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct LossSite { + /// Hyperedge indices (into DecodingHypergraph.hyperedges) for the SOURCE + /// generators at the loss location. Along a parent/child chain (one atom) at + /// most one site's source is the true start -- the exclusivity because a qubit + /// cannot lose twice. + #[prost(uint64, repeated, tag = "1")] + pub source_edges: ::prost::alloc::vec::Vec, + /// Hyperedge indices for the CONTINUATION generators (the loss propagated + /// forward). Not mutually exclusive; they accompany whichever start reaches + /// them. + #[prost(uint64, repeated, tag = "2")] + pub continuation_edges: ::prost::alloc::vec::Vec, + /// Forward parent -> child links (same atom, lost later): indices into + /// LossInfo.sites. + #[prost(uint64, repeated, tag = "3")] + pub children: ::prost::alloc::vec::Vec, + /// Declared LOSS_ERROR probability of the loss starting at this site, or 0 for + /// a continuation site fixed by a parent in another gadget. + #[prost(double, tag = "4")] + pub probability: f64, +} /// Generated client implementations. #[cfg(feature = "cli")] pub mod black_box_decoder_client { diff --git a/deq/deq_runtime/src/proto/deq.jit.rs b/deq/deq_runtime/src/proto/deq.jit.rs index 1275eab2..388cd8f7 100644 --- a/deq/deq_runtime/src/proto/deq.jit.rs +++ b/deq/deq_runtime/src/proto/deq.jit.rs @@ -30,6 +30,9 @@ pub struct JitPortType { /// generators (one column per generator listed in `stabilizers`). #[prost(uint64, tag = "4")] pub k: u64, + /// number of physical data qubits carried by this port. + #[prost(uint64, tag = "5")] + pub n: u64, } /// Nested message and enum types in `JitPortType`. pub mod jit_port_type { @@ -60,6 +63,8 @@ pub struct JitGadgetType { pub unfinished_checks: ::prost::alloc::vec::Vec, #[prost(message, repeated, tag = "4")] pub errors: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag = "5")] + pub loss_model: ::core::option::Option, } /// Nested message and enum types in `JitGadgetType`. pub mod jit_gadget_type { @@ -120,6 +125,78 @@ pub mod jit_gadget_type { #[prost(uint64, repeated, tag = "3")] pub unfinished_checks: ::prost::alloc::vec::Vec, } + /// Static loss template for this gadget type: the declared loss sites in its + /// body, their local heralds and Pauli generators, within-gadget continuation + /// links, and the physical qubits on which a loss leaves or enters the gadget. + /// Propagation is captured inline until it reaches another declared loss site. + #[derive(Clone, PartialEq, ::prost::Message)] + pub struct LossModel { + #[prost(message, repeated, tag = "1")] + pub losses: ::prost::alloc::vec::Vec, + /// One entry per input physical qubit (in concatenated input-port order); + /// `input_losses\[j\]` describes a loss entering on input qubit `j`. Qubits + /// with no continuation keep a default, empty slot. + #[prost(message, repeated, tag = "2")] + pub input_losses: ::prost::alloc::vec::Vec, + } + /// Nested message and enum types in `LossModel`. + pub mod loss_model { + /// Each loss activates Pauli-envelope generators by index into + /// `JitGadgetType.errors`; a loss-only generator carries probability 0 in + /// its `errors` entry until a loss activates it. + #[derive(Clone, PartialEq, ::prost::Message)] + pub struct Loss { + /// Probability from the LOSS_ERROR instruction that creates this loss + #[prost(double, tag = "1")] + pub probability: f64, + /// Generators caused by an already-active loss, as indices into + /// `JitGadgetType.errors`, inherited from all descendants. + #[prost(uint64, repeated, tag = "2")] + pub continuation_errors: ::prost::alloc::vec::Vec, + /// Generators that apply only when the loss starts here (never inherited), + /// as indices into `JitGadgetType.errors`. + #[prost(uint64, repeated, tag = "3")] + pub source_errors: ::prost::alloc::vec::Vec, + /// Within-gadget continuation links, as indices into this gadget's + /// `losses`, recorded when propagation reaches another declared loss site. + /// Children have greater indices so a single forward pass builds the DAG. + #[prost(uint64, repeated, tag = "4")] + pub child_losses: ::prost::alloc::vec::Vec, + /// Physical-qubit positions on the concatenated OUTPUT ports on which this + /// loss leaves the gadget, each in `[0, sum of output-port n)`. The + /// cross-gadget analog of `child_losses`, linked to the connected gadget's + /// `input_losses` at runtime. + #[prost(uint64, repeated, tag = "5")] + pub child_output_qubits: ::prost::alloc::vec::Vec, + /// Local physical measurements that directly herald this loss node. The + /// runtime folds herald evidence forward along the child links: a loss + /// site is kept when some herald in its forward reach fired as a loss and + /// none fired as a non-loss. + #[prost(uint64, repeated, tag = "6")] + pub loss_measurements: ::prost::alloc::vec::Vec, + } + /// Continuation template for a loss that ENTERS this gadget on an input + /// physical qubit. It carries no probability (fixed by the parent loss that + /// exits into it) and may fan out to within-gadget nodes and output qubits. + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] + pub struct InputLoss { + /// Generators activated at the entry, before any within-gadget fan-out, as + /// indices into `JitGadgetType.errors`. + #[prost(uint64, repeated, tag = "1")] + pub continuation_errors: ::prost::alloc::vec::Vec, + /// Within-gadget loss nodes this entering loss propagates into, as indices + /// into `losses` (the analog of `Loss.child_losses`). + #[prost(uint64, repeated, tag = "2")] + pub child_losses: ::prost::alloc::vec::Vec, + /// Physical-qubit positions on the concatenated OUTPUT ports on which the + /// entering loss leaves the gadget, each in `[0, sum of output-port n)`. + #[prost(uint64, repeated, tag = "3")] + pub child_output_qubits: ::prost::alloc::vec::Vec, + /// Local physical measurements that directly herald the entering loss. + #[prost(uint64, repeated, tag = "4")] + pub loss_measurements: ::prost::alloc::vec::Vec, + } + } } #[derive(Clone, PartialEq, ::prost::Message)] pub struct JitInstruction { diff --git a/deq/proto/blackbox_decoder.proto b/deq/proto/blackbox_decoder.proto index 10d21264..2bfaedca 100644 --- a/deq/proto/blackbox_decoder.proto +++ b/deq/proto/blackbox_decoder.proto @@ -39,6 +39,9 @@ message ResetRequest { message DecodingProblem { DecodingHypergraph hypergraph = 1; deq.util.BitVector syndrome = 2; + // Optional loss-aware extension. Present only when atom losses are observed + // for this shot; absent otherwise. + LossInfo loss = 3; } message LoadHypergraphResponse { @@ -64,3 +67,34 @@ message Hyperedge { // and easier to manipulate, e.g., updating the soft information double probability = 2; } + +// The observed atom-loss graph for one shot, projected onto the decoding +// problem's own indices. Enforcing exclusivity is a decoder policy, expressed +// through `LossSite.children`. A reweighting strategy can instead flatten the +// sites into ordinary reweighted hyperedges for an unmodified black-box decoder. +message LossInfo { + // One entry per possible loss site. The runtime has already filtered these to + // the sites consistent with the observed loss-resolving readouts (herald + // folding), so per-site heralds are not exposed to the decoder. That is, we + // guarantee that at least one of the readout is loss and none of them are non-loss + // (those readouts outside of the decoding window are not considered) + repeated LossSite sites = 1; +} + +message LossSite { + // Hyperedge indices (into DecodingHypergraph.hyperedges) for the SOURCE + // generators at the loss location. Along a parent/child chain (one atom) at + // most one site's source is the true start -- the exclusivity because a qubit + // cannot lose twice. + repeated uint64 source_edges = 1; + // Hyperedge indices for the CONTINUATION generators (the loss propagated + // forward). Not mutually exclusive; they accompany whichever start reaches + // them. + repeated uint64 continuation_edges = 2; + // Forward parent -> child links (same atom, lost later): indices into + // LossInfo.sites. + repeated uint64 children = 3; + // Declared LOSS_ERROR probability of the loss starting at this site, or 0 for + // a continuation site fixed by a parent in another gadget. + double probability = 4; +} diff --git a/deq/proto/deq_bin.proto b/deq/proto/deq_bin.proto index fe70b4a9..6f19fc0e 100644 --- a/deq/proto/deq_bin.proto +++ b/deq/proto/deq_bin.proto @@ -149,6 +149,9 @@ message PortType { repeated deq.visualizer.Mesh mesh = 5; repeated deq.visualizer.Position positions = 6; + + // number of physical qubits carried by a port of this type (the code's ``n``). + uint64 n = 7; } message CheckModelType { diff --git a/deq/proto/deq_jit.proto b/deq/proto/deq_jit.proto index 2705af36..e2edee4f 100644 --- a/deq/proto/deq_jit.proto +++ b/deq/proto/deq_jit.proto @@ -37,6 +37,9 @@ message JitPortType { // (`LX_0, LZ_0, LX_1, LZ_1, ...`) and the remaining ones are stabilizer // generators (one column per generator listed in `stabilizers`). uint64 k = 4; + + // number of physical data qubits carried by this port. + uint64 n = 5; } message JitGadgetType { @@ -105,6 +108,71 @@ message JitGadgetType { repeated uint64 unfinished_checks = 3; } repeated Error errors = 4; + + // Static loss template for this gadget type: the declared loss sites in its + // body, their local heralds and Pauli generators, within-gadget continuation + // links, and the physical qubits on which a loss leaves or enters the gadget. + // Propagation is captured inline until it reaches another declared loss site. + message LossModel { + // Each loss activates Pauli-envelope generators by index into + // `JitGadgetType.errors`; a loss-only generator carries probability 0 in + // its `errors` entry until a loss activates it. + message Loss { + // Probability from the LOSS_ERROR instruction that creates this loss + double probability = 1; + + // Generators caused by an already-active loss, as indices into + // `JitGadgetType.errors`, inherited from all descendants. + repeated uint64 continuation_errors = 2; + + // Generators that apply only when the loss starts here (never inherited), + // as indices into `JitGadgetType.errors`. + repeated uint64 source_errors = 3; + + // Within-gadget continuation links, as indices into this gadget's + // `losses`, recorded when propagation reaches another declared loss site. + // Children have greater indices so a single forward pass builds the DAG. + repeated uint64 child_losses = 4; + + // Physical-qubit positions on the concatenated OUTPUT ports on which this + // loss leaves the gadget, each in `[0, sum of output-port n)`. The + // cross-gadget analog of `child_losses`, linked to the connected gadget's + // `input_losses` at runtime. + repeated uint64 child_output_qubits = 5; + + // Local physical measurements that directly herald this loss node. The + // runtime folds herald evidence forward along the child links: a loss + // site is kept when some herald in its forward reach fired as a loss and + // none fired as a non-loss. + repeated uint64 loss_measurements = 6; + } + repeated Loss losses = 1; + + // Continuation template for a loss that ENTERS this gadget on an input + // physical qubit. It carries no probability (fixed by the parent loss that + // exits into it) and may fan out to within-gadget nodes and output qubits. + message InputLoss { + // Generators activated at the entry, before any within-gadget fan-out, as + // indices into `JitGadgetType.errors`. + repeated uint64 continuation_errors = 1; + + // Within-gadget loss nodes this entering loss propagates into, as indices + // into `losses` (the analog of `Loss.child_losses`). + repeated uint64 child_losses = 2; + + // Physical-qubit positions on the concatenated OUTPUT ports on which the + // entering loss leaves the gadget, each in `[0, sum of output-port n)`. + repeated uint64 child_output_qubits = 3; + + // Local physical measurements that directly herald the entering loss. + repeated uint64 loss_measurements = 4; + } + // One entry per input physical qubit (in concatenated input-port order); + // `input_losses[j]` describes a loss entering on input qubit `j`. Qubits + // with no continuation keep a default, empty slot. + repeated InputLoss input_losses = 2; + } + LossModel loss_model = 5; } message JitInstruction { From 964deb2d8d6fa1c0360eb5f3a6689eb8061027ed Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Wed, 29 Jul 2026 09:31:52 -0700 Subject: [PATCH 061/157] update decoder interface --- deq/proto/blackbox_decoder.proto | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/deq/proto/blackbox_decoder.proto b/deq/proto/blackbox_decoder.proto index 2bfaedca..c2850628 100644 --- a/deq/proto/blackbox_decoder.proto +++ b/deq/proto/blackbox_decoder.proto @@ -79,6 +79,10 @@ message LossInfo { // guarantee that at least one of the readout is loss and none of them are non-loss // (those readouts outside of the decoding window are not considered) repeated LossSite sites = 1; + // Fraction of the average regular-edge weight to which the envelope-matching + // strategy lowers each activated Pauli-envelope edge (e.g., 0.25 and 0.5 from + // the paper arXiv:2603.04156). + double weight_fraction = 2; } message LossSite { From 52358ed5f415f6c9afde50acfa4bed59e6738be5 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Wed, 29 Jul 2026 16:31:40 -0700 Subject: [PATCH 062/157] update protobuf --- deq/proto/blackbox_decoder.proto | 10 +++-- deq/proto/deq_bin.proto | 77 ++++++++++++++++++++++++++++++++ deq/proto/deq_jit.proto | 66 +-------------------------- 3 files changed, 86 insertions(+), 67 deletions(-) diff --git a/deq/proto/blackbox_decoder.proto b/deq/proto/blackbox_decoder.proto index c2850628..77050d6b 100644 --- a/deq/proto/blackbox_decoder.proto +++ b/deq/proto/blackbox_decoder.proto @@ -79,9 +79,13 @@ message LossInfo { // guarantee that at least one of the readout is loss and none of them are non-loss // (those readouts outside of the decoding window are not considered) repeated LossSite sites = 1; - // Fraction of the average regular-edge weight to which the envelope-matching - // strategy lowers each activated Pauli-envelope edge (e.g., 0.25 and 0.5 from - // the paper arXiv:2603.04156). + // Exponent applied to each activated Pauli-envelope edge: the edge takes + // `(p_e (+) p_site)^weight_fraction`, accumulated over the sites activating it + // and combined with the edge's own prior. In weight space this reads + // `w <- weight_fraction * w_scale`, so an activated edge costs a fixed fraction + // of an ordinary one -- the invariant the envelope-matching analysis relies on + // -- with the scale taken from the edge's own neighbourhood rather than a + // global average (e.g. 0.25 and 0.5 in the paper arXiv:2603.04156). double weight_fraction = 2; } diff --git a/deq/proto/deq_bin.proto b/deq/proto/deq_bin.proto index 6f19fc0e..1ae5f0d5 100644 --- a/deq/proto/deq_bin.proto +++ b/deq/proto/deq_bin.proto @@ -132,6 +132,83 @@ message GadgetType { // has physical measurements (is_free_hop = measurements is empty). // When set explicitly, the user-provided value is used. optional bool is_free_hop = 14; + + // Static atom-loss template for this gadget type: the declared loss sites in + // its body, their local heralds and Pauli-envelope generators, within-gadget + // continuation links, and the physical qubits on which a loss leaves or + // enters the gadget. Propagation is captured inline until it reaches another + // declared loss site. Left unset for loss-free gadget types. + // + // Generators are referenced by index into `ErrorModelType.errors` of the + // FIRST error model attached to this gadget (`attaching_eid_vec[0]`, i.e. the + // first one created against its check model). That is the error model the + // gadget type was compiled with: the JIT compiler emits exactly one bin error + // per `deq.jit.JitGadgetType.errors` entry, in order, so the two index + // domains coincide. + // + // Attaching further error models is allowed and is the normal way to add + // extra noise; the loss model simply never references them, and they take + // part in decoding as ordinary edges. The only requirement is ordering — the + // compiled error model must be attached first, or the generator indices + // address the wrong error list. + message LossModel { + message Loss { + // Probability from the LOSS_ERROR instruction that creates this loss + double probability = 1; + + // Generators caused by an already-active loss, as error indices, + // inherited from all descendants. A loss-only generator carries + // probability 0 in its `errors` entry until a loss activates it. + repeated uint64 continuation_errors = 2; + + // Generators that apply only when the loss starts here (never inherited), + // as error indices. + repeated uint64 source_errors = 3; + + // Within-gadget continuation links, as indices into this gadget's + // `losses`, recorded when propagation reaches another declared loss site. + // Children have greater indices so a single forward pass builds the DAG. + repeated uint64 child_losses = 4; + + // Physical-qubit positions on the concatenated OUTPUT ports on which this + // loss leaves the gadget, each in `[0, sum of output-port n)`. The + // cross-gadget analog of `child_losses`, linked to the connected gadget's + // `input_losses` at runtime. + repeated uint64 child_output_qubits = 5; + + // Local physical measurements that directly herald this loss node. The + // runtime folds herald evidence forward along the child links: a loss + // site is kept when some herald in its forward reach fired as a loss and + // none fired as a non-loss. + repeated uint64 loss_measurements = 6; + } + repeated Loss losses = 1; + + // Continuation template for a loss that ENTERS this gadget on an input + // physical qubit. It carries no probability (fixed by the parent loss that + // exits into it) and may fan out to within-gadget nodes and output qubits. + message InputLoss { + // Generators activated at the entry, before any within-gadget fan-out, as + // error indices. + repeated uint64 continuation_errors = 1; + + // Within-gadget loss nodes this entering loss propagates into, as indices + // into `losses` (the analog of `Loss.child_losses`). + repeated uint64 child_losses = 2; + + // Physical-qubit positions on the concatenated OUTPUT ports on which the + // entering loss leaves the gadget, each in `[0, sum of output-port n)`. + repeated uint64 child_output_qubits = 3; + + // Local physical measurements that directly herald the entering loss. + repeated uint64 loss_measurements = 4; + } + // One entry per input physical qubit (in concatenated input-port order); + // `input_losses[j]` describes a loss entering on input qubit `j`. Qubits + // with no continuation keep a default, empty slot. + repeated InputLoss input_losses = 2; + } + LossModel loss_model = 16; } message PortType { diff --git a/deq/proto/deq_jit.proto b/deq/proto/deq_jit.proto index e2edee4f..465fb794 100644 --- a/deq/proto/deq_jit.proto +++ b/deq/proto/deq_jit.proto @@ -109,70 +109,8 @@ message JitGadgetType { } repeated Error errors = 4; - // Static loss template for this gadget type: the declared loss sites in its - // body, their local heralds and Pauli generators, within-gadget continuation - // links, and the physical qubits on which a loss leaves or enters the gadget. - // Propagation is captured inline until it reaches another declared loss site. - message LossModel { - // Each loss activates Pauli-envelope generators by index into - // `JitGadgetType.errors`; a loss-only generator carries probability 0 in - // its `errors` entry until a loss activates it. - message Loss { - // Probability from the LOSS_ERROR instruction that creates this loss - double probability = 1; - - // Generators caused by an already-active loss, as indices into - // `JitGadgetType.errors`, inherited from all descendants. - repeated uint64 continuation_errors = 2; - - // Generators that apply only when the loss starts here (never inherited), - // as indices into `JitGadgetType.errors`. - repeated uint64 source_errors = 3; - - // Within-gadget continuation links, as indices into this gadget's - // `losses`, recorded when propagation reaches another declared loss site. - // Children have greater indices so a single forward pass builds the DAG. - repeated uint64 child_losses = 4; - - // Physical-qubit positions on the concatenated OUTPUT ports on which this - // loss leaves the gadget, each in `[0, sum of output-port n)`. The - // cross-gadget analog of `child_losses`, linked to the connected gadget's - // `input_losses` at runtime. - repeated uint64 child_output_qubits = 5; - - // Local physical measurements that directly herald this loss node. The - // runtime folds herald evidence forward along the child links: a loss - // site is kept when some herald in its forward reach fired as a loss and - // none fired as a non-loss. - repeated uint64 loss_measurements = 6; - } - repeated Loss losses = 1; - - // Continuation template for a loss that ENTERS this gadget on an input - // physical qubit. It carries no probability (fixed by the parent loss that - // exits into it) and may fan out to within-gadget nodes and output qubits. - message InputLoss { - // Generators activated at the entry, before any within-gadget fan-out, as - // indices into `JitGadgetType.errors`. - repeated uint64 continuation_errors = 1; - - // Within-gadget loss nodes this entering loss propagates into, as indices - // into `losses` (the analog of `Loss.child_losses`). - repeated uint64 child_losses = 2; - - // Physical-qubit positions on the concatenated OUTPUT ports on which the - // entering loss leaves the gadget, each in `[0, sum of output-port n)`. - repeated uint64 child_output_qubits = 3; - - // Local physical measurements that directly herald the entering loss. - repeated uint64 loss_measurements = 4; - } - // One entry per input physical qubit (in concatenated input-port order); - // `input_losses[j]` describes a loss entering on input qubit `j`. Qubits - // with no continuation keep a default, empty slot. - repeated InputLoss input_losses = 2; - } - LossModel loss_model = 5; + // The loss template moved to `base` (`deq.bin.GadgetType.loss_model`) + reserved 5; } message JitInstruction { From a95ed341cafbe697f33b3abef0c63e71b5c15e7e Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 3 Aug 2026 10:28:41 -0700 Subject: [PATCH 063/157] update blackbox decoder proto --- deq/proto/blackbox_decoder.proto | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/deq/proto/blackbox_decoder.proto b/deq/proto/blackbox_decoder.proto index 77050d6b..23d653b6 100644 --- a/deq/proto/blackbox_decoder.proto +++ b/deq/proto/blackbox_decoder.proto @@ -52,6 +52,18 @@ message LoadHypergraphResponse { message LoadedDecodingProblem { uint64 hid = 1; deq.util.BitVector syndrome = 2; + // Optional per-shot prior overrides, applied to this decode only. The loaded + // hypergraph is left unchanged, so a subsequent decode on the same `hid` sees + // the priors it was loaded with. + repeated EdgeReweight reweights = 3; +} + +// Replaces the prior of one hyperedge, addressed by its index in the loaded +// hypergraph. The probability is *assigned*, not combined: it may raise or lower +// the loaded prior. +message EdgeReweight { + uint64 edge = 1; + double probability = 2; } message ParityFactor { repeated uint64 subgraph = 1; } @@ -79,14 +91,6 @@ message LossInfo { // guarantee that at least one of the readout is loss and none of them are non-loss // (those readouts outside of the decoding window are not considered) repeated LossSite sites = 1; - // Exponent applied to each activated Pauli-envelope edge: the edge takes - // `(p_e (+) p_site)^weight_fraction`, accumulated over the sites activating it - // and combined with the edge's own prior. In weight space this reads - // `w <- weight_fraction * w_scale`, so an activated edge costs a fixed fraction - // of an ordinary one -- the invariant the envelope-matching analysis relies on - // -- with the scale taken from the edge's own neighbourhood rather than a - // global average (e.g. 0.25 and 0.5 in the paper arXiv:2603.04156). - double weight_fraction = 2; } message LossSite { From bb3b1fe2f1151f1b1bb35394a8d2a81d60d074ba Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Wed, 5 Aug 2026 15:30:04 -0700 Subject: [PATCH 064/157] add language support --- deq/deq/circuit/deqagram_shim.py | 22 +++++ .../bindings/python/deqagram/deqagram.pyi | 17 ++++ deq/deqagram/bindings/python/src/lib.rs | 6 +- .../bindings/python/src/statements.rs | 31 +++++++ deq/deqagram/src/ast.rs | 81 +++++++++++++++++++ deq/deqagram/src/deq.pest | 19 ++++- deq/proto/blackbox_decoder.proto | 3 + 7 files changed, 175 insertions(+), 4 deletions(-) diff --git a/deq/deq/circuit/deqagram_shim.py b/deq/deq/circuit/deqagram_shim.py index b89f441f..d5e2744c 100644 --- a/deq/deq/circuit/deqagram_shim.py +++ b/deq/deq/circuit/deqagram_shim.py @@ -401,6 +401,28 @@ def _gadget_statement_impl( expected_value=preselect.expected_value, decorators=decorators, ) + case deqagram.AttachedGadget.Statement(deqagram.GadgetStatement.Loss(loss)): + if loss.input_port is not None: + if loss.source_errors: + raise SyntaxError( + "input LOSS(IN.L) must not carry source-error (SE) " + "targets" + ) + elif loss.probability is None or not 0.0 < loss.probability <= 1.0: + raise SyntaxError( + f"LOSS probability must be in (0, 1], got {loss.probability}" + ) + return model.LossStatement( + probability=loss.probability, + input_port=loss.input_port, + input_qubit=loss.input_qubit, + source_errors=list(loss.source_errors), + continuation_errors=list(loss.continuation_errors), + child_losses=list(loss.child_losses), + output_qubits=[(port, qubit) for port, qubit in loss.output_qubits], + measurement_indices=list(loss.measurement_indices), + decorators=decorators, + ) case _: raise TypeError(f"unexpected gadget statement: {decorated.statement!r}") diff --git a/deq/deqagram/bindings/python/deqagram/deqagram.pyi b/deq/deqagram/bindings/python/deqagram/deqagram.pyi index 2a833ff4..68a24ffe 100644 --- a/deq/deqagram/bindings/python/deqagram/deqagram.pyi +++ b/deq/deqagram/bindings/python/deqagram/deqagram.pyi @@ -33,6 +33,7 @@ __all__ = [ 'VirtualLogicalStatement', 'PropagateStatement', 'PreselectStatement', + 'LossStatement', 'AssertStatement', 'VirtualCorrection', 'ConditionalCorrection', @@ -338,6 +339,17 @@ class PreselectStatement: conditions: list[MeasurementRef] expected_value: int +@final +class LossStatement: + probability: float | None + input_port: int | None + input_qubit: int | None + source_errors: list[int] + continuation_errors: list[int] + child_losses: list[int] + output_qubits: list[tuple[int, int]] + measurement_indices: list[int] + @final class AssertStatement: target: Target @@ -412,6 +424,11 @@ class GadgetStatement: __match_args__ = ('preselect',) def __new__(cls, preselect: PreselectStatement) -> GadgetStatement.Preselect: ... @final + class Loss(GadgetStatement): + loss: LossStatement + __match_args__ = ('loss',) + def __new__(cls, loss: LossStatement) -> GadgetStatement.Loss: ... + @final class Decorator(GadgetStatement): decorator: Decorator __match_args__ = ('decorator',) diff --git a/deq/deqagram/bindings/python/src/lib.rs b/deq/deqagram/bindings/python/src/lib.rs index b87d30ce..8c217739 100644 --- a/deq/deqagram/bindings/python/src/lib.rs +++ b/deq/deqagram/bindings/python/src/lib.rs @@ -60,8 +60,8 @@ fn deqagram(m: &Bound<'_, PyModule>) -> PyResult<()> { }; use statements::{ AssertStatement, CheckStatement, ComposeStatement, ConditionalCorrection, ConditionalStatement, ErrorStatement, - GadgetApplication, GadgetStatement, Instruction, PortDeclaration, PreselectStatement, ProgramStatement, - PropagateStatement, ReadoutStatement, VirtualCorrection, VirtualLogicalStatement, + GadgetApplication, GadgetStatement, Instruction, LossStatement, PortDeclaration, PreselectStatement, + ProgramStatement, PropagateStatement, ReadoutStatement, VirtualCorrection, VirtualLogicalStatement, }; use targets::{ Condition, ErrorTarget, LogicalPauliTarget, MeasurementRef, Pauli, PauliProduct, PauliTerm, Port, PortKind, @@ -97,6 +97,7 @@ fn deqagram(m: &Bound<'_, PyModule>) -> PyResult<()> { VirtualLogicalStatement, PropagateStatement, PreselectStatement, + LossStatement, AssertStatement, VirtualCorrection, ConditionalCorrection, @@ -154,6 +155,7 @@ fn deqagram(m: &Bound<'_, PyModule>) -> PyResult<()> { "VirtualLogicalStatement", "PropagateStatement", "PreselectStatement", + "LossStatement", "AssertStatement", "VirtualCorrection", "ConditionalCorrection", diff --git a/deq/deqagram/bindings/python/src/statements.rs b/deq/deqagram/bindings/python/src/statements.rs index c8c958b5..3014c41f 100644 --- a/deq/deqagram/bindings/python/src/statements.rs +++ b/deq/deqagram/bindings/python/src/statements.rs @@ -185,6 +185,35 @@ impl From<&ast::PreselectStatement> for PreselectStatement { } } +/// A `LOSS(...)` statement mirroring one loss-model entry. +#[pyclass(name = "LossStatement", frozen, get_all, eq)] +#[derive(Clone, PartialEq)] +pub struct LossStatement { + pub probability: Option, + pub input_port: Option, + pub input_qubit: Option, + pub source_errors: Vec, + pub continuation_errors: Vec, + pub child_losses: Vec, + pub output_qubits: Vec<(u64, u64)>, + pub measurement_indices: Vec, +} + +impl From<&ast::LossStatement> for LossStatement { + fn from(s: &ast::LossStatement) -> Self { + Self { + probability: s.probability, + input_port: s.input_port, + input_qubit: s.input_qubit, + source_errors: s.source_errors.clone(), + continuation_errors: s.continuation_errors.clone(), + child_losses: s.child_losses.clone(), + output_qubits: s.output_qubits.clone(), + measurement_indices: s.measurement_indices.clone(), + } + } +} + /// An `ASSERT_EQ target value` statement. #[pyclass(name = "AssertStatement", frozen, get_all, eq)] #[derive(Clone, PartialEq)] @@ -267,6 +296,7 @@ pub enum GadgetStatement { VirtualLogical { statement: VirtualLogicalStatement }, Propagate { propagate: PropagateStatement }, Preselect { preselect: PreselectStatement }, + Loss { loss: LossStatement }, Decorator { decorator: Decorator }, } @@ -287,6 +317,7 @@ impl From<&ast::GadgetStatement> for GadgetStatement { ast::GadgetStatement::VirtualLogical(v) => Self::VirtualLogical { statement: v.into() }, ast::GadgetStatement::Propagate(p) => Self::Propagate { propagate: p.into() }, ast::GadgetStatement::Preselect(p) => Self::Preselect { preselect: p.into() }, + ast::GadgetStatement::Loss(l) => Self::Loss { loss: l.into() }, ast::GadgetStatement::Decorator(d) => Self::Decorator { decorator: d.into() }, } } diff --git a/deq/deqagram/src/ast.rs b/deq/deqagram/src/ast.rs index f75a7822..5b80994b 100644 --- a/deq/deqagram/src/ast.rs +++ b/deq/deqagram/src/ast.rs @@ -512,6 +512,52 @@ impl fmt::Display for PreselectStatement { } } +/// A `LOSS(...)` statement mirroring one JIT loss-model entry. +/// +/// A *source* loss (`LOSS(p) ...`) carries the declared `LOSS_ERROR` +/// probability. An *input* loss (`LOSS(IN.L) ...`) is the continuation +/// of a loss entering on input physical qubit `input_qubit` of input port +/// `input_port`; it carries no probability. `source_errors` / `continuation_errors` +/// index the gadget's `ERROR` mechanisms, `child_losses` index the source losses +/// (within-gadget children), `output_qubits` are `(port, qubit)` physical exits, +/// and `measurement_indices` are herald measurements. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct LossStatement { + pub probability: Option, + pub input_port: Option, + pub input_qubit: Option, + pub source_errors: Vec, + pub continuation_errors: Vec, + pub child_losses: Vec, + pub output_qubits: Vec<(u64, u64)>, + pub measurement_indices: Vec, +} + +impl fmt::Display for LossStatement { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match (self.input_port, self.input_qubit) { + (Some(port), Some(qubit)) => write!(f, "LOSS(IN{port}.L{qubit})")?, + _ => write!(f, "LOSS({})", self.probability.unwrap_or(0.0))?, + } + for index in &self.source_errors { + write!(f, " SE{index}")?; + } + for index in &self.continuation_errors { + write!(f, " CE{index}")?; + } + for index in &self.child_losses { + write!(f, " L{index}")?; + } + for (port, qubit) in &self.output_qubits { + write!(f, " OUT{port}.L{qubit}")?; + } + for index in &self.measurement_indices { + write!(f, " M{index}")?; + } + Ok(()) + } +} + // ── PROGRAM-only statements ────────────────────────────────────────── /// An `ASSERT_EQ target value` statement. @@ -587,6 +633,7 @@ pub enum GadgetStatement { VirtualLogical(VirtualLogicalStatement), Propagate(PropagateStatement), Preselect(PreselectStatement), + Loss(LossStatement), Decorator(Decorator), } @@ -1058,6 +1105,38 @@ fn parse_error_statement(pair: Pair) -> Result Ok(ErrorStatement { probability, targets }) } +fn parse_loss_statement(pair: Pair) -> Result { + let mut statement = LossStatement::default(); + for item in pair.into_inner() { + match item.as_rule() { + Rule::NUMBER => statement.probability = Some(sub_f64(&item, item.as_str())?), + Rule::INPUT_PHYS_QUBIT_TARGET => { + let (port, qubit) = port_indexed(&item, "IN", "L")?; + statement.input_port = Some(port); + statement.input_qubit = Some(qubit); + } + Rule::SOURCE_ERROR_TARGET => { + statement.source_errors.push(sub_u64(&item, item.as_str().strip_prefix("SE").unwrap())?); + } + Rule::CONT_ERROR_TARGET => { + statement.continuation_errors.push(sub_u64(&item, item.as_str().strip_prefix("CE").unwrap())?); + } + Rule::CHILD_LOSS_TARGET => { + statement.child_losses.push(sub_u64(&item, item.as_str().strip_prefix('L').unwrap())?); + } + Rule::OUTPUT_PHYS_QUBIT_TARGET => { + let (port, qubit) = port_indexed(&item, "OUT", "L")?; + statement.output_qubits.push((port, qubit)); + } + Rule::PHYS_MEAS_TARGET => { + statement.measurement_indices.push(sub_u64(&item, item.as_str().strip_prefix('M').unwrap())?); + } + rule => unreachable!("unexpected loss-target rule {rule:?}"), + } + } + Ok(statement) +} + fn parse_conditional_statement(pair: Pair) -> Result { let mut inner = pair.into_inner(); let first = inner.next().unwrap(); @@ -1191,6 +1270,7 @@ fn parse_gadget_statement(pair: Pair) -> Result, GadgetStatement::VirtualLogical(VirtualLogicalStatement { targets }) } Rule::propagate_statement => GadgetStatement::Propagate(parse_propagate_statement(pair)?), + Rule::loss_statement => GadgetStatement::Loss(parse_loss_statement(pair)?), Rule::decorator => GadgetStatement::Decorator(parse_decorator(pair)?), Rule::instruction => GadgetStatement::Instruction(parse_instruction(pair)?), rule => unreachable!("unexpected gadget statement rule {rule:?}"), @@ -1402,6 +1482,7 @@ fn write_gadget_body(f: &mut fmt::Formatter<'_>, body: &[Spanned write_line(f, level, v)?, GadgetStatement::Propagate(v) => write_line(f, level, v)?, GadgetStatement::Preselect(v) => write_line(f, level, v)?, + GadgetStatement::Loss(v) => write_line(f, level, v)?, GadgetStatement::Decorator(v) => write_line(f, level, v)?, } } diff --git a/deq/deqagram/src/deq.pest b/deq/deqagram/src/deq.pest index 115fd946..d7bf4e1c 100644 --- a/deq/deqagram/src/deq.pest +++ b/deq/deqagram/src/deq.pest @@ -64,7 +64,8 @@ gadget_body_item = _{ | conditional_statement | preselect_statement | virtual_logical_statement - | propagate_statement ) + | propagate_statement + | loss_statement ) | decorator | !KEYWORD_GADGET ~ instruction } @@ -135,6 +136,10 @@ conditional_statement = { preselect_statement = { "PRESELECT" ~ physical_meas_record_like+ ~ INT? } virtual_logical_statement = { "VIRTUAL" ~ logical_pauli_target+ } propagate_statement = { "PROPAGATE" ~ logical_pauli_target ~ "FROM" ~ propagate_term* ~ flip_flag? } +loss_statement = { + "LOSS" ~ "(" ~ NUMBER ~ ")" ~ loss_target* + | "LOSS" ~ "(" ~ INPUT_PHYS_QUBIT_TARGET ~ ")" ~ loss_target* +} flip_flag = { "FLIP" } @@ -148,6 +153,9 @@ meas_record_like = _{ MEASUREMENT_RECORD_TARGET | PHYS_MEAS_TARGET | INPUT_VIRTU // PRESELECT accepts only concrete physical measurements (`rec[-k]` / `M`); // virtual stabilizer measurements (`IN

.S` / `OUT

.S`) are excluded. physical_meas_record_like = _{ MEASUREMENT_RECORD_TARGET | PHYS_MEAS_TARGET } +// LOSS statement targets: source/continuation error generators, within-gadget +// child losses, output physical-qubit exits, and herald measurements. +loss_target = _{ SOURCE_ERROR_TARGET | CONT_ERROR_TARGET | OUTPUT_PHYS_QUBIT_TARGET | CHILD_LOSS_TARGET | PHYS_MEAS_TARGET } check_target = { CHECK_TARGET } readout_target = { READOUT_TARGET } @@ -223,9 +231,11 @@ KEYWORD_COMMON = @{ } /// GADGET bodies additionally reserve the circuit-level statement keywords. +/// `LOSS` (not `LOSS_ERROR`) is reserved via `~ BOUNDARY`: the trailing `_` +/// of `LOSS_ERROR` fails BOUNDARY, so that instruction stays an instruction. KEYWORD_GADGET = @{ KEYWORD_COMMON - | ("CHECK" | "DETECTOR" | "ERROR" | "OBSERVABLE_INCLUDE" | "PRESELECT" + | ("CHECK" | "DETECTOR" | "ERROR" | "LOSS" | "OBSERVABLE_INCLUDE" | "PRESELECT" | "PROPAGATE" | "READOUT" | "VIRTUAL") ~ BOUNDARY } @@ -258,6 +268,11 @@ OUTPUT_VIRTUAL_TARGET = @{ "OUT" ~ ASCII_DIGIT+ ~ ".S" ~ ASCII_DIGIT+ } INPUT_DESTAB_TARGET = @{ "IN" ~ ASCII_DIGIT+ ~ ".DS" ~ ASCII_DIGIT+ } INPUT_LOGICAL_TARGET = @{ "IN" ~ ASCII_DIGIT+ ~ ".L" ~ ("X" | "Y" | "Z") ~ ASCII_DIGIT+ } OUTPUT_LOGICAL_TARGET = @{ "OUT" ~ ASCII_DIGIT+ ~ ".L" ~ ("X" | "Y" | "Z") ~ ASCII_DIGIT+ } +SOURCE_ERROR_TARGET = @{ "SE" ~ ASCII_DIGIT+ } +CONT_ERROR_TARGET = @{ "CE" ~ ASCII_DIGIT+ } +CHILD_LOSS_TARGET = @{ "L" ~ ASCII_DIGIT+ } +OUTPUT_PHYS_QUBIT_TARGET = @{ "OUT" ~ ASCII_DIGIT+ ~ ".L" ~ ASCII_DIGIT+ } +INPUT_PHYS_QUBIT_TARGET = @{ "IN" ~ ASCII_DIGIT+ ~ ".L" ~ ASCII_DIGIT+ } SWEEP_BIT_TARGET = @{ "sweep[" ~ ASCII_DIGIT+ ~ "]" } DECORATOR_NAME = @{ "@" ~ ASCII_ALPHA ~ (ASCII_ALPHANUMERIC | "_")* } diff --git a/deq/proto/blackbox_decoder.proto b/deq/proto/blackbox_decoder.proto index 23d653b6..049a606f 100644 --- a/deq/proto/blackbox_decoder.proto +++ b/deq/proto/blackbox_decoder.proto @@ -56,6 +56,9 @@ message LoadedDecodingProblem { // hypergraph is left unchanged, so a subsequent decode on the same `hid` sees // the priors it was loaded with. repeated EdgeReweight reweights = 3; + // Optional loss-aware extension. Present only when atom losses are observed + // for this shot; absent otherwise. + LossInfo loss = 4; } // Replaces the prior of one hyperedge, addressed by its index in the loaded From 12f4b3793ffbae0bf04ee3184d7bf7f772dece83 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 9 Aug 2026 18:31:07 -0700 Subject: [PATCH 065/157] add fault propagation common tool --- deq/deq/cli/jit.py | 2 +- deq/deq/transpiler/compose_builder.py | 58 ++- deq/deq/transpiler/fault_propagation.py | 458 ++++++++++++++++++++++++ deq/deq/transpiler/stim_constants.py | 28 +- 4 files changed, 520 insertions(+), 26 deletions(-) create mode 100644 deq/deq/transpiler/fault_propagation.py diff --git a/deq/deq/cli/jit.py b/deq/deq/cli/jit.py index 030e3566..ceab110d 100644 --- a/deq/deq/cli/jit.py +++ b/deq/deq/cli/jit.py @@ -1250,7 +1250,7 @@ def export_program_stim( has_preselect = bool(preselect_indices) last_preselect_index = preselect_indices[-1] if has_preselect else -1 if has_preselect: - body_lines.append("PREPARE {") + body_lines.append("SELECT {") gadget_start_meas = next_meas_idx for stmt_index, stmt in enumerate(flattened): if isinstance(stmt, PreselectStatement): diff --git a/deq/deq/transpiler/compose_builder.py b/deq/deq/transpiler/compose_builder.py index 2ff49499..1367ec14 100644 --- a/deq/deq/transpiler/compose_builder.py +++ b/deq/deq/transpiler/compose_builder.py @@ -11,7 +11,10 @@ # pylint: disable=no-member -from typing import Callable, Mapping, Sequence +from typing import TYPE_CHECKING, Callable, Mapping, Sequence + +if TYPE_CHECKING: + from deq.transpiler.jit_library_builder import CompiledGadget import deq.proto.deq_bin_pb2 as pb import deq.proto.deq_jit_pb2 as jit_pb @@ -972,7 +975,7 @@ def compose_to_synthetic_gadget( pipeline side as needed. Used exclusively by the ``@REPROPAGATE`` build/annotate path (see - :func:`_build_repropagated_compose`), which requires the body to be + :func:`_compile_repropagated_compose`), which requires the body to be free of any CONDITIONAL frame correction. :func:`_reject_conditionals_under_repropagate` runs first at the ``@REPROPAGATE`` dispatch site to enforce that invariant, so no @@ -996,7 +999,7 @@ def compose_to_synthetic_gadget( ) -def _build_repropagated_compose( +def _compile_repropagated_compose( compose: ComposeDefinition, *, gtype: int, @@ -1006,7 +1009,8 @@ def _build_repropagated_compose( codes: Mapping[str, CodeDefinition], ptype_of_code: Mapping[str, int], port_types: list[jit_pb.JitPortType], -) -> jit_pb.JitGadgetType: + library_has_loss: bool = True, +) -> "CompiledGadget": """Build a JitGadgetType for an ``@REPROPAGATE`` COMPOSE. Routes the COMPOSE through *both* pipelines and combines them: @@ -1036,7 +1040,7 @@ def _build_repropagated_compose( between this module and ``jit_library_builder``. """ from deq.transpiler.jit_library_builder import ( # local import: cycle - _build_jit_gadget_type, + _compile_jit_gadget_type, ) _reject_conditionals_under_repropagate( @@ -1059,15 +1063,49 @@ def _build_repropagated_compose( finished, unfinished = _check_basis_from_jit_gadget_type( merge_jt, synthetic, codes ) - return _build_jit_gadget_type( + return _compile_jit_gadget_type( synthetic, gtype, dict(ptype_of_code), dict(codes), + library_has_loss=library_has_loss, check_override=(finished, unfinished), ) +def compile_repropagated_compose( + compose: ComposeDefinition, + *, + gtype: int, + gadget_definitions: Mapping[str, GadgetDefinition], + compose_definitions: Mapping[str, ComposeDefinition], + jit_gadget_types_by_name: Mapping[str, jit_pb.JitGadgetType], + codes: Mapping[str, CodeDefinition], + ptype_of_code: Mapping[str, int], + port_types: list[jit_pb.JitPortType], + library_has_loss: bool = True, +) -> "CompiledGadget": + """Compile an ``@REPROPAGATE`` compose with annotation provenance.""" + validate_compose( + compose, + gadget_definitions=gadget_definitions, + compose_definitions=compose_definitions, + ) + if not has_repropagate(compose): + raise ValueError(f"COMPOSE {compose.name!r} is not @REPROPAGATE") + return _compile_repropagated_compose( + compose, + gtype=gtype, + gadget_definitions=gadget_definitions, + compose_definitions=compose_definitions, + jit_gadget_types_by_name=jit_gadget_types_by_name, + codes=codes, + ptype_of_code=ptype_of_code, + port_types=port_types, + library_has_loss=library_has_loss, + ) + + def _check_basis_from_jit_gadget_type( jt: jit_pb.JitGadgetType, synthetic: GadgetDefinition, @@ -1136,6 +1174,7 @@ def build_compose_jit_gadget_type( codes: Mapping[str, CodeDefinition], ptype_of_code: Mapping[str, int], port_types: list[jit_pb.JitPortType], + library_has_loss: bool = True, ) -> jit_pb.JitGadgetType: """Build a composed JitGadgetType. @@ -1145,7 +1184,7 @@ def build_compose_jit_gadget_type( output (measurements, checks, readouts) still comes from merge(), but the propagation matrices and noise-derived ERRORs are recomputed from circuit flow on the inlined body. See - :func:`_build_repropagated_compose` for details. + :func:`_compile_repropagated_compose` for details. """ validate_compose( compose, @@ -1154,7 +1193,7 @@ def build_compose_jit_gadget_type( ) if has_repropagate(compose): - return _build_repropagated_compose( + return _compile_repropagated_compose( compose, gtype=gtype, gadget_definitions=gadget_definitions, @@ -1163,7 +1202,8 @@ def build_compose_jit_gadget_type( codes=codes, ptype_of_code=ptype_of_code, port_types=port_types, - ) + library_has_loss=library_has_loss, + ).jit_type return _build_merge_compose( compose, diff --git a/deq/deq/transpiler/fault_propagation.py b/deq/deq/transpiler/fault_propagation.py new file mode 100644 index 00000000..025281da --- /dev/null +++ b/deq/deq/transpiler/fault_propagation.py @@ -0,0 +1,458 @@ +"""Shared propagation of circuit-level Pauli faults into decoder error rows.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +import stim +from paulimer import FramePropagator, SparsePauli, UnitaryOpcode + +import deq.proto.deq_bin_pb2 as bin_pb +import deq.proto.deq_jit_pb2 as jit_pb +import deq.proto.util_pb2 as util_pb +from deq.circuit.model import ( + CheckStatement, + CodeDefinition, + ConditionalStatement, + ErrorStatement, + GadgetStatement, + InputPort, + Instruction, + LossStatement, + OutputPort, + PreselectStatement, + PropagateStatement, + ReadoutStatement, + RepeatBlock, + VirtualLogicalStatement, +) +from deq.transpiler.jit_transpiler import ( + PortColumnLayout, + pauli_product_to_stim, + select_stabilizer_generators, +) +from deq.transpiler.stim_constants import ( + ANNOTATION_INSTRUCTIONS, + NOISE_INSTRUCTIONS_ALL, + instruction_num_measurements, +) + + +_FLAT_METADATA_TYPES = ( + InputPort, + OutputPort, + ReadoutStatement, + CheckStatement, + ErrorStatement, + LossStatement, + ConditionalStatement, + VirtualLogicalStatement, + PropagateStatement, + PreselectStatement, +) + + +@dataclass(frozen=True) +class DecomposedBody: + """Primitive instructions and body-index mapping for one gadget body.""" + + instructions: tuple[stim.CircuitInstruction, ...] + measurement_start_at: tuple[int, ...] + total_measurements: int + body_start_at: tuple[int, ...] + + +def build_decomposed_body( + flat_body: Sequence[GadgetStatement], +) -> DecomposedBody: + """Decompose a flattened gadget body without merging source statements. + + Each source instruction is decomposed independently so its start boundary + remains explicit without inserting a circuit instruction as a separator. + Non-gate body entries map to the next gate boundary; entries after the last + gate map to the terminal boundary. + + .. warning:: + ``flat_body`` must already have been processed by :func:`flatten_body`. + Unflattened repeat blocks and unknown statement types are rejected. + """ + instructions: list[stim.CircuitInstruction] = [] + gate_body_indices: list[int] = [] + gate_starts: list[int] = [] + for body_index, statement in enumerate(flat_body): + if isinstance(statement, RepeatBlock): + raise ValueError( + "build_decomposed_body requires a flattened gadget body; " + "call flatten_body before decomposing REPEAT blocks" + ) + if not isinstance(statement, Instruction): + if not isinstance(statement, _FLAT_METADATA_TYPES): + raise TypeError( + "unsupported gadget body statement in fault propagation: " + f"{type(statement).__name__}" + ) + continue + name = statement.name.upper() + if name in NOISE_INSTRUCTIONS_ALL or name in ANNOTATION_INSTRUCTIONS: + continue + gate_body_indices.append(body_index) + gate_starts.append(len(instructions)) + instructions.extend( + stim.Circuit( + str( + Instruction( + name=statement.name, + arguments=statement.arguments, + targets=statement.targets, + ) + ) + ) + .decomposed() + ) + + measurement_starts: list[int] = [] + measurement_count = 0 + for instruction in instructions: + measurement_starts.append(measurement_count) + measurement_count += instruction_num_measurements(str(instruction)) + + body_starts: list[int] = [] + gate_cursor = 0 + for body_index in range(len(flat_body)): + if ( + gate_cursor < len(gate_body_indices) + and body_index == gate_body_indices[gate_cursor] + ): + body_starts.append(gate_starts[gate_cursor]) + gate_cursor += 1 + elif gate_cursor < len(gate_body_indices): + body_starts.append(gate_starts[gate_cursor]) + else: + body_starts.append(len(instructions)) + + return DecomposedBody( + instructions=tuple(instructions), + measurement_start_at=tuple(measurement_starts), + total_measurements=measurement_count, + body_start_at=tuple(body_starts), + ) + + +def build_port_paulis( + ports: Sequence[InputPort | OutputPort], + codes: dict[str, CodeDefinition], + num_qubits: int, +) -> tuple[list[stim.PauliString], list[stim.PauliString]]: + """Build output-stabilizer and frame-column Paulis for concatenated ports.""" + output_stabilizer_paulis: list[stim.PauliString] = [] + frame_column_paulis: list[stim.PauliString] = [] + for port in ports: + code = codes[port.code_name] + local_to_global = { + local_qubit: global_qubit + for local_qubit, global_qubit in enumerate(port.qubit_indices) + } + for stabilizer in code.stabilizers: + output_stabilizer_paulis.append( + pauli_product_to_stim( + stabilizer, num_qubits, local_to_global + ) + ) + for logical in code.logicals: + frame_column_paulis.append( + pauli_product_to_stim( + logical.x_operator, num_qubits, local_to_global + ) + ) + frame_column_paulis.append( + pauli_product_to_stim( + logical.z_operator, num_qubits, local_to_global + ) + ) + selected = select_stabilizer_generators(code) + for generator_index in selected.generator_indices: + frame_column_paulis.append( + pauli_product_to_stim( + code.stabilizers[generator_index], + num_qubits, + local_to_global, + ) + ) + return output_stabilizer_paulis, frame_column_paulis + + +@dataclass(frozen=True) +class MechanismFlips: + """Measurement, output-stabilizer, and frame-column propagation result.""" + + flipped_real: set[int] + output_stabilizer_flips: Sequence[bool] + frame_column_flips: Sequence[bool] + + +@dataclass(frozen=True) +class ErrorProjectionContext: + """Per-gadget invariants used to lower propagated faults to error rows.""" + + input_virtual_count: int + finished_member_lists: Sequence[frozenset[int]] + unfinished_member_lists: Sequence[frozenset[int]] + output_stabilizer_measurement_offset: int + readout_measurement_sets: Sequence[set[int]] + logical_columns: set[int] + unfinished_to_column: Sequence[int | None] + physical_correction_by_logical: dict[int, set[int]] + + def output_stabilizer_measurement_index( + self, stabilizer_index: int + ) -> int: + """Map an output-stabilizer position to the global measurement index.""" + return self.output_stabilizer_measurement_offset + stabilizer_index + + +def build_error_projection_context( + *, + output_ports: Sequence[OutputPort], + codes: dict[str, CodeDefinition], + input_virtual_count: int, + finished_checks: Sequence[tuple[frozenset[int], bool]], + unfinished_checks: Sequence[tuple[frozenset[int], bool]], + output_virtual_start: int, + readouts: Sequence[bin_pb.GadgetType.Readout], + physical_correction: util_pb.BitMatrix, +) -> ErrorProjectionContext: + """Build the shared lowering context for one gadget.""" + output_layout = PortColumnLayout(output_ports, codes) + logical_columns = output_layout.logical_columns + physical_correction_by_logical = { + row: set() for row in logical_columns + } + for row, column in zip(physical_correction.i, physical_correction.j): + if row in physical_correction_by_logical: + physical_correction_by_logical[row].add(column) + return ErrorProjectionContext( + input_virtual_count=input_virtual_count, + finished_member_lists=[members for members, _ in finished_checks], + unfinished_member_lists=[members for members, _ in unfinished_checks], + output_stabilizer_measurement_offset=output_virtual_start, + readout_measurement_sets=[ + set(readout.measurement_indices) for readout in readouts + ], + logical_columns=logical_columns, + unfinished_to_column=output_layout.stab_to_column, + physical_correction_by_logical=physical_correction_by_logical, + ) + + +_FRAME_H = UnitaryOpcode.Hadamard +_FRAME_S = UnitaryOpcode.SqrtZ +_FRAME_CX = UnitaryOpcode.ControlledX +_PAULI_NAMES = ["I", "X", "Y", "Z"] + + +def _to_sparse_pauli(pauli: stim.PauliString) -> SparsePauli: + return SparsePauli( + { + qubit: _PAULI_NAMES[pauli[qubit]] + for qubit in range(len(pauli)) + if pauli[qubit] + } + ) + + +def _apply_instruction( + propagator: FramePropagator, + instruction: stim.CircuitInstruction, + real_measurement_outcomes: list[int], +) -> None: + targets = instruction.targets_copy() + match instruction.name: + case "H": + for target in targets: + propagator.apply_unitary(_FRAME_H, [target.value]) + case "S": + for target in targets: + propagator.apply_unitary(_FRAME_S, [target.value]) + case "CX": + for index in range(0, len(targets), 2): + control, target = targets[index], targets[index + 1] + if control.is_measurement_record_target: + record_index = len(real_measurement_outcomes) + control.value + assert 0 <= record_index < len(real_measurement_outcomes) + propagator.apply_conditional_pauli( + SparsePauli.x(target.value), + [real_measurement_outcomes[record_index]], + ) + else: + propagator.apply_unitary( + _FRAME_CX, [control.value, target.value] + ) + case "M": + for target in targets: + real_measurement_outcomes.append( + propagator.measure(SparsePauli.z(target.value)) + ) + case "R": + for target in targets: + propagator.reset_qubit(target.value) + case "MPAD": + for _target in targets: + real_measurement_outcomes.append( + propagator.measure(SparsePauli.identity()) + ) + case other: + raise ValueError( + f"fault propagation encountered unexpected primitive {other}" + ) + + +def propagate_pauli_mechanisms( + mechanisms: Sequence[tuple[int, stim.PauliString]], + body: DecomposedBody, + num_qubits: int, + output_stabilizer_paulis: Sequence[stim.PauliString], + frame_column_paulis: Sequence[stim.PauliString], +) -> list[MechanismFlips]: + """Propagate all injected Pauli mechanisms in one batched frame walk.""" + shot_count = len(mechanisms) + propagator = FramePropagator( + num_qubits, + body.total_measurements + + len(output_stabilizer_paulis) + + len(frame_column_paulis), + shot_count, + ) + shots_by_start: dict[int, list[int]] = {} + for shot, (start, _pauli) in enumerate(mechanisms): + shots_by_start.setdefault(start, []).append(shot) + + injected = 0 + + def inject_at(boundary: int) -> None: + nonlocal injected + for shot in shots_by_start.get(boundary, ()): + propagator.inject_pauli(shot, _to_sparse_pauli(mechanisms[shot][1])) + injected += 1 + + real_measurement_outcomes: list[int] = [] + for boundary, instruction in enumerate(body.instructions): + inject_at(boundary) + _apply_instruction(propagator, instruction, real_measurement_outcomes) + inject_at(len(body.instructions)) + assert injected == shot_count, "each mechanism must be injected exactly once" + + output_stabilizer_outcomes = [ + propagator.measure(_to_sparse_pauli(pauli)) + for pauli in output_stabilizer_paulis + ] + frame_column_outcomes = [ + propagator.measure(_to_sparse_pauli(pauli)) + for pauli in frame_column_paulis + ] + shots_by_outcome = [row.support for row in propagator.outcome_deltas.rows] + + flipped_real = [set() for _ in range(shot_count)] + for real_index, outcome in enumerate(real_measurement_outcomes): + for shot in shots_by_outcome[outcome]: + flipped_real[shot].add(real_index) + output_stabilizer_flips = [ + [False] * len(output_stabilizer_paulis) for _ in range(shot_count) + ] + for stabilizer_index, outcome in enumerate(output_stabilizer_outcomes): + for shot in shots_by_outcome[outcome]: + output_stabilizer_flips[shot][stabilizer_index] = True + frame_column_flips = [ + [False] * len(frame_column_paulis) for _ in range(shot_count) + ] + for frame_column, outcome in enumerate(frame_column_outcomes): + for shot in shots_by_outcome[outcome]: + frame_column_flips[shot][frame_column] = True + return [ + MechanismFlips( + flipped_real=flipped_real[shot], + output_stabilizer_flips=output_stabilizer_flips[shot], + frame_column_flips=frame_column_flips[shot], + ) + for shot in range(shot_count) + ] + + +def _format_pauli(pauli: stim.PauliString) -> str: + terms = [ + f"{_PAULI_NAMES[pauli[qubit]]}{qubit}" + for qubit in range(len(pauli)) + if pauli[qubit] + ] + if not terms: + return "I" + sign = "-" if pauli.sign == -1 else "" + return sign + "*".join(terms) + + +def build_error_row_from_flips( + *, + site_name: str, + site_pauli: stim.PauliString, + probability: float, + flips: MechanismFlips, + context: ErrorProjectionContext, +) -> jit_pb.JitGadgetType.Error | None: + """Lower one propagated mechanism footprint to a decoder error row.""" + flipped_measurements = { + real + context.input_virtual_count for real in flips.flipped_real + } + for stabilizer_index, flipped in enumerate( + flips.output_stabilizer_flips + ): + if flipped: + flipped_measurements.add( + context.output_stabilizer_measurement_index(stabilizer_index) + ) + + finished_flipped = [ + check_index + for check_index, members in enumerate(context.finished_member_lists) + if len(members & flipped_measurements) % 2 == 1 + ] + unfinished_flipped = [ + check_index + for check_index, members in enumerate(context.unfinished_member_lists) + if len(members & flipped_measurements) % 2 == 1 + ] + + residual: set[int] = { + frame_column + for frame_column, flipped in enumerate(flips.frame_column_flips) + if frame_column in context.logical_columns and flipped + } + for logical_row, columns in context.physical_correction_by_logical.items(): + if len(columns & flips.flipped_real) % 2 == 1: + residual ^= {logical_row} + for check_index in unfinished_flipped: + column = context.unfinished_to_column[check_index] + if column is not None: + residual ^= {column} + + readout_flips = [ + readout_index + for readout_index, measurements in enumerate( + context.readout_measurement_sets + ) + if len(measurements & flips.flipped_real) % 2 == 1 + ] + if not ( + finished_flipped or unfinished_flipped or residual or readout_flips + ): + return None + + return jit_pb.JitGadgetType.Error( + base=bin_pb.ErrorModelType.Error( + tag=f"{site_name} {_format_pauli(site_pauli)}", + residual=sorted(residual), + readout_flips=readout_flips, + probability=probability, + ), + finished_checks=finished_flipped, + unfinished_checks=unfinished_flipped, + ) \ No newline at end of file diff --git a/deq/deq/transpiler/stim_constants.py b/deq/deq/transpiler/stim_constants.py index c321192c..1586a04c 100644 --- a/deq/deq/transpiler/stim_constants.py +++ b/deq/deq/transpiler/stim_constants.py @@ -24,10 +24,9 @@ for alias in g.aliases ) -# Stim-extension noise instructions that upstream ``stim.gate_data()`` -# does **not** know about but that deq should accept verbatim inside -# gadget bodies and pass through to the generated ``.stim`` (with the -# usual qubit-target relabeling). These instructions: +# Non-Stim noise instructions that deq accepts verbatim inside gadget bodies and +# passes through to the generated ``.stim`` with the usual qubit-target +# relabeling. These instructions: # # * are treated the same as :data:`NOISE_INSTRUCTIONS` by every deq # transpiler pass that *skips* noise (gate decomposition, hypergraph @@ -36,11 +35,10 @@ # * are assumed to produce **zero measurement bits** (so any # measurement-counting pass returns 0 for them). # -# ``LOSS_ERROR(p) q...`` is QDK's stim extension that injects persistent -# loss at that exact circuit location. Adding it here lets users -# write loss-aware circuits directly in ``.deq``; the deq runtime -# itself does not interpret loss, but ``qdk.stim`` (driven via -# ``--simulator python``) does. +# ``LOSS_ERROR(p) q...`` is QDK's Stim extension that injects persistent loss at +# that exact circuit location. Adding it here lets users write loss-aware +# circuits directly in ``.deq``; the deq runtime itself does not interpret the +# instruction, but ``qdk.stim`` (driven via ``--simulator python``) does. PASSTHROUGH_NOISE_INSTRUCTIONS: frozenset[str] = frozenset({"LOSS_ERROR"}) # Union of all instruction names that every deq transpiler pass that @@ -56,20 +54,18 @@ def instruction_num_measurements(instruction_text: str) -> int: """Count measurement bits produced by a single stim instruction. Delegates to ``stim.CircuitInstruction(...).num_measurements`` for - instructions upstream Stim recognizes. For - :data:`PASSTHROUGH_NOISE_INSTRUCTIONS` (which upstream Stim rejects - with ``Gate not found``) returns ``0`` — these are noise-channel - extensions and contribute no measurement bits. + instructions upstream Stim recognizes. For ``LOSS_ERROR``, which upstream + Stim rejects with ``Gate not found``, returns ``0`` because it contributes no + measurement bits. Use this helper anywhere we used to call ``stim.CircuitInstruction(str(stmt)).num_measurements`` on a user-authored instruction; otherwise circuits containing - ``LOSS_ERROR`` (and any future passthrough extension) will crash - the transpiler. + ``LOSS_ERROR`` will crash the transpiler. """ head = instruction_text.split(None, 1) if head: - name = head[0].split("(", 1)[0].upper() + name = head[0].split("[", 1)[0].split("(", 1)[0].upper() if name in PASSTHROUGH_NOISE_INSTRUCTIONS: return 0 return stim.CircuitInstruction(instruction_text).num_measurements From b5fe83acf6b61b07e231ed70a4ac246570dee26c Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 9 Aug 2026 18:38:45 -0700 Subject: [PATCH 066/157] clean jit noise builder --- deq/deq/transpiler/jit_noise_builder.py | 593 +++--------------------- 1 file changed, 71 insertions(+), 522 deletions(-) diff --git a/deq/deq/transpiler/jit_noise_builder.py b/deq/deq/transpiler/jit_noise_builder.py index ab0cbeb0..77db736e 100644 --- a/deq/deq/transpiler/jit_noise_builder.py +++ b/deq/deq/transpiler/jit_noise_builder.py @@ -59,7 +59,6 @@ import stim from binar import BitMatrix, BitVector, EchelonForm, null_space -from paulimer import FramePropagator, SparsePauli, UnitaryOpcode import deq.proto.deq_bin_pb2 as pb import deq.proto.deq_jit_pb2 as jit_pb @@ -68,7 +67,6 @@ CodeDefinition, DestabilizerTarget, GadgetDefinition, - GadgetStatement, InputPort, Instruction, LogicalPauliTarget, @@ -79,6 +77,16 @@ ReadoutTarget, VirtualLogicalStatement, ) +from deq.transpiler.fault_propagation import ( + DecomposedBody, + ErrorProjectionContext, + MechanismFlips, + build_decomposed_body, + build_error_projection_context, + build_error_row_from_flips, + build_port_paulis, + propagate_pauli_mechanisms, +) from deq.spec.common import bitmatrix_from_sparse from deq.transpiler.jit_transpiler import ( Check, @@ -90,11 +98,10 @@ select_stabilizer_generators, ) from deq.transpiler.stim_constants import ( - ANNOTATION_INSTRUCTIONS, NOISE_INSTRUCTIONS, NOISE_INSTRUCTIONS_ALL, PASSTHROUGH_NOISE_INSTRUCTIONS, - mpp_measurement_count, + instruction_num_measurements, ) from deq.transpiler.stim_constants import qubit_indices as _qubit_indices @@ -114,12 +121,7 @@ def _real_measurement_count(instr: Instruction) -> int: f"Heralded noise channels produce measurements that require " f"erasure decoding, which is not yet implemented." ) - gate_info = stim.gate_data(name) - if not gate_info.produces_measurements: - return 0 - if gate_info.takes_pauli_targets: - return mpp_measurement_count(list(instr.targets)) - return len(_qubit_indices(instr)) + return instruction_num_measurements(str(instr)) # --------------------------------------------------------------------------- @@ -128,7 +130,6 @@ def _real_measurement_count(instr: Instruction) -> int: _PAULI_TO_INT = {"I": 0, "X": 1, "Y": 2, "Z": 3} -_INT_TO_PAULI = ["I", "X", "Y", "Z"] def _pauli_string_for_single( @@ -351,116 +352,6 @@ def enumerate_noise_mechanisms( _X_TABLEAU = stim.Tableau.from_named_gate("X") -@dataclass -class _DecomposedBody: - """A decomposed instruction list with a measurement-start table. - - ``instructions`` contains only ``{H, S, CX, M, R, MPAD}`` — the - base gates produced by ``stim.Circuit.decomposed()``. - - ``meas_start_at[i]`` is the index of the first real measurement - produced by ``instructions[i]``, counting from 0 (i.e. the number - of real measurements emitted by instructions ``0..i-1``). - """ - - instructions: list[stim.CircuitInstruction] - meas_start_at: list[int] - total_measurements: int - - -def _build_decomposed_body( - body_flat: Sequence[GadgetStatement], -) -> tuple[_DecomposedBody, list[int]]: - """Build a decomposed instruction list from a flattened gadget body. - - Gate instructions (excluding noise and annotations) are converted - to a ``stim.Circuit`` and decomposed into ``{H, S, CX, M, R, MPAD}``. - - To prevent stim from merging adjacent same-type instructions (which - would destroy the 1:1 mapping between body positions and decomposed - positions), a ``TICK`` separator is inserted between each gate - instruction. The TICKs are then stripped from the decomposed output. - - Returns ``(decomposed, orig_to_decomposed)`` where - ``orig_to_decomposed[i]`` is the decomposed instruction index that - body index ``i`` maps to. Non-gate body entries (noise, ports, - etc.) map to the same index as the next gate instruction. - """ - # Build circuit with TICK separators to prevent instruction merging. - lines: list[str] = [] - gate_body_indices: list[int] = [] - for idx, stmt in enumerate(body_flat): - if not isinstance(stmt, Instruction): - continue - name = stmt.name.upper() - if name in NOISE_INSTRUCTIONS_ALL or name in ANNOTATION_INSTRUCTIONS: - continue - if lines: - lines.append("TICK") - inst_copy = Instruction( - name=stmt.name, - arguments=stmt.arguments, - targets=stmt.targets, - ) - lines.append(str(inst_copy)) - gate_body_indices.append(idx) - - if not lines: - return ( - _DecomposedBody(instructions=[], meas_start_at=[], total_measurements=0), - [0] * len(body_flat), - ) - - circuit = stim.Circuit("\n".join(lines)) - decomposed_with_ticks = list(circuit.decomposed()) - - # Strip TICKs and record the decomposed index after each TICK group. - instructions: list[stim.CircuitInstruction] = [] - # gate_decomposed_start[k] = decomposed instruction index where - # the k-th gate instruction's decomposed block starts. - gate_decomposed_start: list[int] = [0] - for inst in decomposed_with_ticks: - if inst.name == "TICK": - gate_decomposed_start.append(len(instructions)) - else: - instructions.append(inst) - - # Build meas_start_at for the TICK-stripped instruction list. - starts: list[int] = [] - running = 0 - for inst in instructions: - starts.append(running) - if inst.name in ("M", "MPAD"): - running += len(inst.targets_copy()) - - # Build per-body-index mapping. - # Each gate instruction at body index gate_body_indices[k] maps to - # gate_decomposed_start[k]. Non-gate body entries map to the - # decomposed index of the next gate instruction. - orig_to_decomposed: list[int] = [] - gate_cursor = 0 - for idx in range(len(body_flat)): - if ( - gate_cursor < len(gate_body_indices) - and idx == gate_body_indices[gate_cursor] - ): - orig_to_decomposed.append(gate_decomposed_start[gate_cursor]) - gate_cursor += 1 - elif gate_cursor < len(gate_body_indices): - orig_to_decomposed.append(gate_decomposed_start[gate_cursor]) - else: - orig_to_decomposed.append(len(instructions)) - - return ( - _DecomposedBody( - instructions=instructions, - meas_start_at=starts, - total_measurements=running, - ), - orig_to_decomposed, - ) - - # --------------------------------------------------------------------------- # Forward Pauli walker (decomposed) # --------------------------------------------------------------------------- @@ -473,7 +364,7 @@ class _WalkResult: def walk_pauli_forward( - decomposed: _DecomposedBody, + decomposed: DecomposedBody, start_index: int, initial: stim.PauliString, num_qubits: int, @@ -489,15 +380,15 @@ def walk_pauli_forward( flipped: set[int] = set() current = stim.PauliString(initial) instructions = decomposed.instructions - meas_start_at = decomposed.meas_start_at + meas_start_at = decomposed.measurement_start_at # Track measurement indices in stim-circuit order to resolve rec[-k]. - stim_meas_outcomes: list[int] = [] - # Count measurements before start_index. - for i in range(0, start_index): - inst = instructions[i] - if inst.name in ("M", "MPAD"): - for _ in inst.targets_copy(): - stim_meas_outcomes.append(-1) # placeholder + assert 0 <= start_index <= len(instructions) + measurements_before_start = ( + meas_start_at[start_index] + if start_index < len(meas_start_at) + else decomposed.total_measurements + ) + stim_meas_outcomes = [-1] * measurements_before_start for i in range(start_index, len(instructions)): inst = instructions[i] @@ -653,7 +544,7 @@ def _compute_pc_logical_via_flows( if num_qubits == 0: return [], set(), set() - decomposed, _ = _build_decomposed_body(body_flat) + decomposed = build_decomposed_body(body_flat) body_circuit = stim.Circuit() for inst in decomposed.instructions: body_circuit.append(inst) @@ -664,10 +555,14 @@ def _compute_pc_logical_via_flows( if num_qubits > 0: body_circuit.append("I", range(num_qubits)) - _, input_obs_paulis = _build_port_paulis(list(input_ports), codes, num_qubits) - _, output_obs_paulis = _build_port_paulis(list(output_ports), codes, num_qubits) - n_in = len(input_obs_paulis) - n_out = len(output_obs_paulis) + _, input_frame_column_paulis = build_port_paulis( + list(input_ports), codes, num_qubits + ) + _, output_frame_column_paulis = build_port_paulis( + list(output_ports), codes, num_qubits + ) + n_in = len(input_frame_column_paulis) + n_out = len(output_frame_column_paulis) n_meas = body_circuit.num_measurements flows = list(body_circuit.flow_generators()) @@ -677,10 +572,12 @@ def _compute_pc_logical_via_flows( return [], set(), set() input_symp = [ - _pauli_string_to_symplectic(p, num_qubits) for p in input_obs_paulis + _pauli_string_to_symplectic(pauli, num_qubits) + for pauli in input_frame_column_paulis ] output_symp = [ - _pauli_string_to_symplectic(p, num_qubits) for p in output_obs_paulis + _pauli_string_to_symplectic(pauli, num_qubits) + for pauli in output_frame_column_paulis ] flow_in_symp = [ _pauli_string_to_symplectic(g.input_copy(), num_qubits) for g in flows @@ -841,65 +738,6 @@ def _build_real_meas_start_table( return starts, running -def _build_port_paulis( - ports: Sequence[InputPort | OutputPort], - codes: dict[str, CodeDefinition], - num_qubits: int, -) -> tuple[list[stim.PauliString], list[stim.PauliString]]: - """Return ``(stabilizer_paulis, observable_paulis)`` covering all - ports concatenated in declaration order. - - Works for both input and output ports — they have identical - ``code_name`` / ``qubit_indices`` shape. - - Observable Paulis follow the unified frame layout: - ``[LX0, LZ0, ..., LX_{k-1}, LZ_{k-1}, S0, S1, ..., S_{|generators|-1}]``. - - For input-port use, only the second return value is meaningful; - the stabilizer list is computed but typically discarded. - """ - - stab_paulis: list[stim.PauliString] = [] - obs_paulis: list[stim.PauliString] = [] - for port in ports: - code = codes[port.code_name] - qubit_map = { - logical: physical for logical, physical in enumerate(port.qubit_indices) - } - for stab in code.stabilizers: - stab_paulis.append(pauli_product_to_stim(stab, num_qubits, qubit_map)) - # Logical observables - for logical in code.logicals: - obs_paulis.append( - pauli_product_to_stim(logical.x_operator, num_qubits, qubit_map) - ) - obs_paulis.append( - pauli_product_to_stim(logical.z_operator, num_qubits, qubit_map) - ) - # Stabilizer columns: one generator per selected generator. - sel = select_stabilizer_generators(code) - for j in range(len(sel.generator_indices)): - gen_pauli = pauli_product_to_stim( - code.stabilizers[sel.generator_indices[j]], num_qubits, qubit_map - ) - obs_paulis.append(gen_pauli) - return stab_paulis, obs_paulis - - -def _format_pauli(ps: stim.PauliString) -> str: - """Render a PauliString as e.g. ``X3*Y5*Z7``; identity → ``I``.""" - parts = [] - for q in range(len(ps)): - v = ps[q] - if v == 0: - continue - parts.append(f"{_INT_TO_PAULI[v]}{q}") - if not parts: - return "I" - sign = "-" if ps.sign == -1 else "" - return sign + "*".join(parts) - - def compute_noise_errors( gadget: GadgetDefinition, codes: dict[str, CodeDefinition], @@ -909,7 +747,7 @@ def compute_noise_errors( finished_checks: Sequence[Check], unfinished_checks: Sequence[Check], ov_start: int, - readouts_info: Sequence[object], + readouts: Sequence[pb.GadgetType.Readout], physical_correction: util_pb.BitMatrix, ) -> list[jit_pb.JitGadgetType.Error]: """Expand every noise instruction in the body into JIT ``Error`` rows. @@ -930,61 +768,13 @@ def compute_noise_errors( finished_checks=finished_checks, unfinished_checks=unfinished_checks, ov_start=ov_start, - readouts_info=readouts_info, + readouts=readouts, physical_correction=physical_correction, ): errors.append(error_row) return errors -def _stim_pauli_to_sparse(ps: stim.PauliString) -> SparsePauli: - """Convert a ``stim.PauliString`` to a ``paulimer.SparsePauli`` (sign - dropped: frame propagation only tracks anticommutation, not phase).""" - return SparsePauli( - {q: _INT_TO_PAULI[ps[q]] for q in range(len(ps)) if ps[q]} - ) - - -# Decomposed-body Clifford gates as paulimer unitary opcodes. -_FP_H = UnitaryOpcode.Hadamard -_FP_S = UnitaryOpcode.SqrtZ -_FP_CX = UnitaryOpcode.ControlledX - - -@dataclass(frozen=True) -class _MechanismFlips: - """One noise mechanism's projected footprint from the batched pass. - - * ``flipped_real`` — real (internal) measurement indices the mechanism - flipped. - * ``stab_flips[i]`` — whether the residual anticommutes with output-port - stabilizer ``i``. - * ``obs_flips[j]`` — whether the residual anticommutes with observable ``j``. - """ - - flipped_real: set[int] - stab_flips: Sequence[bool] - obs_flips: Sequence[bool] - - -@dataclass(frozen=True) -class _GadgetErrorContext: - """Per-gadget invariants shared by every error-row builder. - - Computed once from the gadget's ports, checks, and physical-correction - matrix, then threaded through each mechanism's row construction. - """ - - input_virtual_count: int - finished_member_lists: Sequence[frozenset[int]] - unfinished_member_lists: Sequence[frozenset[int]] - stab_global_indices: Sequence[int] - readout_meas_sets: Sequence[set[int]] - logical_col_set: set[int] - unfinished_to_column: Sequence[int | None] - pc_logical_rows: dict[int, set[int]] - - @dataclass(frozen=True) class _NoiseMechanism: """A pure-noise mechanism enumerated from the gadget body. @@ -1002,142 +792,6 @@ class _NoiseMechanism: site_name: str -def _apply_decomposed_instruction( - frame_propagator: FramePropagator, - inst: stim.CircuitInstruction, - outcome_of_real: list[int], -) -> None: - """Apply one decomposed body instruction to ``frame_propagator``. - - ``M``/``MPAD`` append their outcome id to ``outcome_of_real`` (whose length - is the next real-measurement index); a measurement-record-controlled ``CX`` - reads back through it as a conditional Pauli. - """ - raw = inst.targets_copy() - match inst.name: - case "H": - for t in raw: - frame_propagator.apply_unitary(_FP_H, [t.value]) - case "S": - for t in raw: - frame_propagator.apply_unitary(_FP_S, [t.value]) - case "CX": - for j in range(0, len(raw), 2): - ctrl, tgt = raw[j], raw[j + 1] - if ctrl.is_measurement_record_target: - rec_idx = len(outcome_of_real) + ctrl.value - assert 0 <= rec_idx < len(outcome_of_real), ( - f"rec[{ctrl.value}] out of range for {inst.name}" - ) - frame_propagator.apply_conditional_pauli( - SparsePauli.x(tgt.value), [outcome_of_real[rec_idx]] - ) - else: - frame_propagator.apply_unitary(_FP_CX, [ctrl.value, tgt.value]) - case "M": - for t in raw: - outcome_of_real.append(frame_propagator.measure(SparsePauli.z(t.value))) - case "R": - for t in raw: - frame_propagator.reset_qubit(t.value) - case "MPAD": - for t in raw: - outcome_of_real.append(frame_propagator.measure(SparsePauli.identity())) - case other: - raise ValueError( - f"jit_noise_builder: unexpected instruction in decomposed " - f"circuit: {other}" - ) - - -def _batched_mechanism_flips( - mechanisms: Sequence[tuple[int, stim.PauliString]], - decomposed: _DecomposedBody, - num_qubits: int, - stab_paulis: Sequence[stim.PauliString], - obs_paulis: Sequence[stim.PauliString], -) -> list[_MechanismFlips]: - """Propagate every mechanism through the body in a single batched - :class:`FramePropagator` pass and return one :class:`_MechanismFlips` - per mechanism. - - Each mechanism is one shot; ``mechanisms[k] = (walk_start, pauli)`` injects - ``pauli`` into shot ``k`` at decomposed index ``walk_start`` (the position - just after its noise instruction). Internal ``M``/``MPAD`` are recorded via - :meth:`FramePropagator.measure`, giving ``flipped_real``; the port - stabilizers and observables are measured after the body, giving the - residual's anticommutation (``stab_flips`` / ``obs_flips``) without ever - materialising the residual Pauli. - - Reset uses :meth:`FramePropagator.reset_qubit`, i.e. Stim's - discard-and-prepare semantics that clear the whole frame on the reset qubit. - A ``Z`` killed by a reset stays in the code stabilizer group, so it commutes - with every port stabilizer and logical observable and never reaches an - emitted row. - """ - shot_count = len(mechanisms) - instructions = decomposed.instructions - n_real = decomposed.total_measurements - - frame_propagator = FramePropagator( - num_qubits, n_real + len(stab_paulis) + len(obs_paulis), shot_count - ) - - by_start: dict[int, list[int]] = {} - for shot, (walk_start, _pauli) in enumerate(mechanisms): - by_start.setdefault(walk_start, []).append(shot) - - injected = 0 - - def inject_at(index: int) -> None: - nonlocal injected - for shot in by_start.get(index, ()): - frame_propagator.inject_pauli( - shot, _stim_pauli_to_sparse(mechanisms[shot][1]) - ) - injected += 1 - - # outcome id assigned to each real (internal) measurement, in body order; - # a measurement's real index is just its position here. - outcome_of_real: list[int] = [] - - for index, inst in enumerate(instructions): - inject_at(index) - _apply_decomposed_instruction(frame_propagator, inst, outcome_of_real) - inject_at(len(instructions)) - assert injected == shot_count, "each mechanism must be injected exactly once" - - stab_oids = [ - frame_propagator.measure(_stim_pauli_to_sparse(s)) for s in stab_paulis - ] - obs_oids = [ - frame_propagator.measure(_stim_pauli_to_sparse(o)) for o in obs_paulis - ] - - # ``outcome_deltas`` has one row per outcome id; that row's ``support`` is - # the set of shots whose outcome the injected mechanism flipped. Iterating - # rows + support materialises one Python BitVector per outcome; a single - # ``outcome_deltas.sparse_rows()`` call would avoid that once that binding - # lands on binar's main. - shots_by_outcome = [row.support for row in frame_propagator.outcome_deltas.rows] - flipped_real: list[set[int]] = [set() for _ in range(shot_count)] - for real_idx, oid in enumerate(outcome_of_real): - for shot in shots_by_outcome[oid]: - flipped_real[shot].add(real_idx) - stab_flips = [[False] * len(stab_paulis) for _ in range(shot_count)] - for si, oid in enumerate(stab_oids): - for shot in shots_by_outcome[oid]: - stab_flips[shot][si] = True - obs_flips = [[False] * len(obs_paulis) for _ in range(shot_count)] - for oi, oid in enumerate(obs_oids): - for shot in shots_by_outcome[oid]: - obs_flips[shot][oi] = True - return [ - _MechanismFlips(flipped_real[shot], stab_flips[shot], obs_flips[shot]) - for shot in range(shot_count) - ] - - def _collect_noise_mechanisms( body_flat: Sequence[object], num_qubits: int, @@ -1186,8 +840,8 @@ def _collect_noise_mechanisms( def _build_mechanism_rows( mechanisms: Sequence[_NoiseMechanism], - flips: Sequence[_MechanismFlips], - context: _GadgetErrorContext, + flips: Sequence[MechanismFlips], + context: ErrorProjectionContext, ) -> list[tuple[int, jit_pb.JitGadgetType.Error | None]]: """Build one ``(body_index, error_row)`` per mechanism from its footprint.""" rows: list[tuple[int, jit_pb.JitGadgetType.Error | None]] = [] @@ -1195,7 +849,7 @@ def _build_mechanism_rows( rows.append( ( mechanism.body_index, - _build_error_row_from_flips( + build_error_row_from_flips( site_name=mechanism.site_name, site_pauli=mechanism.pauli, probability=mechanism.probability, @@ -1216,7 +870,7 @@ def iter_noise_errors_with_origin( finished_checks: Sequence[Check], unfinished_checks: Sequence[Check], ov_start: int, - readouts_info: Sequence[object], + readouts: Sequence[pb.GadgetType.Readout], physical_correction: util_pb.BitMatrix, ) -> Iterator[tuple[int, jit_pb.JitGadgetType.Error]]: """Yield ``(body_index, error_row)`` for every propagated noise mechanism. @@ -1238,49 +892,32 @@ def iter_noise_errors_with_origin( body_flat = flatten_body(list(gadget.body)) num_qubits = max(max_qubit_index(list(gadget.body)) + 1, 0) real_starts, _total_real = _build_real_meas_start_table(body_flat) - decomposed, orig_to_decomposed = _build_decomposed_body(body_flat) - - stab_paulis, obs_paulis = _build_port_paulis(output_ports, codes, num_qubits) - stab_global_indices = list(range(ov_start, ov_start + len(stab_paulis))) - - output_layout = PortColumnLayout(output_ports, codes) - logical_col_set = output_layout.logical_columns - - finished_member_lists = [members for members, _ in finished_checks] - unfinished_member_lists = [members for members, _ in unfinished_checks] - readout_meas_sets = [ - set(getattr(r, "measurement_indices", [])) for r in readouts_info - ] - - # For each logical output row r, the set of internal-measurement - # columns m with pc[r, m] == 1. Used to subtract the runtime's - # automatic frame update on flipped body measurements out of each - # error's residual. - pc_logical_rows: dict[int, set[int]] = {r: set() for r in logical_col_set} - for row, col in zip(physical_correction.i, physical_correction.j): - if row in pc_logical_rows: - pc_logical_rows[row].add(col) + decomposed = build_decomposed_body(body_flat) + orig_to_decomposed = decomposed.body_start_at - context = _GadgetErrorContext( + output_stabilizer_paulis, frame_column_paulis = build_port_paulis( + output_ports, codes, num_qubits + ) + context = build_error_projection_context( + output_ports=output_ports, + codes=codes, input_virtual_count=input_virtual_count, - finished_member_lists=finished_member_lists, - unfinished_member_lists=unfinished_member_lists, - stab_global_indices=stab_global_indices, - readout_meas_sets=readout_meas_sets, - logical_col_set=logical_col_set, - unfinished_to_column=output_layout.stab_to_column, - pc_logical_rows=pc_logical_rows, + finished_checks=finished_checks, + unfinished_checks=unfinished_checks, + output_virtual_start=ov_start, + readouts=readouts, + physical_correction=physical_correction, ) mechanisms = _collect_noise_mechanisms( body_flat, num_qubits, orig_to_decomposed, len(decomposed.instructions) ) - flips = _batched_mechanism_flips( + flips = propagate_pauli_mechanisms( [(m.walk_start, m.pauli) for m in mechanisms], decomposed, num_qubits, - stab_paulis, - obs_paulis, + output_stabilizer_paulis, + frame_column_paulis, ) mechanism_rows = _build_mechanism_rows(mechanisms, flips, context) @@ -1325,7 +962,7 @@ def _build_measurement_flip_error( *, real_index: int, probability: float, - context: _GadgetErrorContext, + context: ErrorProjectionContext, ) -> jit_pb.JitGadgetType.Error | None: """Build an error row for a single measurement result flip. @@ -1359,14 +996,14 @@ def _build_measurement_flip_error( unfinished_flipped.append(check_idx) readout_flipped: list[int] = [] - for r_idx, meas_set in enumerate(context.readout_meas_sets): + for r_idx, meas_set in enumerate(context.readout_measurement_sets): if real_index in meas_set: readout_flipped.append(r_idx) residual_indices: set[int] = set() # Logical rows: post-runtime residual = raw P_E[r] (zero for a pure # measurement flip) XOR (pc · {real_index})[r]. - for logical_row, cols in context.pc_logical_rows.items(): + for logical_row, cols in context.physical_correction_by_logical.items(): if real_index in cols: residual_indices ^= {logical_row} # Stabilizer columns: set from triggered unfinished checks. @@ -1394,84 +1031,6 @@ def _build_measurement_flip_error( ) -def _build_error_row_from_flips( - *, - site_name: str, - site_pauli: stim.PauliString, - probability: float, - flips: _MechanismFlips, - context: _GadgetErrorContext, -) -> jit_pb.JitGadgetType.Error | None: - """Build an ``Error`` row from a mechanism's already-projected footprint, - or return ``None`` if it has no observable effect. - - The logical-row residual is the *post-runtime* frame error, i.e. - ``P_E[r] ⊕ (pc · M_e)[r]``: the raw projection onto each output observable, - XORed with the runtime's automatic Pauli-frame update derived from the - flipped body measurements through ``physical_correction``. - """ - flipped_globals: set[int] = { - real + context.input_virtual_count for real in flips.flipped_real - } - - # Output-virtual flips from residual. - for stab_idx, flipped in enumerate(flips.stab_flips): - if flipped: - flipped_globals.add(context.stab_global_indices[stab_idx]) - - finished_flipped: list[int] = [] - for check_idx, members in enumerate(context.finished_member_lists): - if len(members & flipped_globals) % 2 == 1: - finished_flipped.append(check_idx) - - unfinished_flipped: list[int] = [] - for check_idx, members in enumerate(context.unfinished_member_lists): - if len(members & flipped_globals) % 2 == 1: - unfinished_flipped.append(check_idx) - - residual_indices: set[int] = set() - # Logical rows: raw projection P_E[r]. - for obs_idx, flipped in enumerate(flips.obs_flips): - if obs_idx in context.logical_col_set and flipped: - residual_indices.add(obs_idx) - # Logical rows: XOR (pc · M_e)[r] to subtract out the runtime's - # automatic frame update on the flipped body measurements. - for logical_row, cols in context.pc_logical_rows.items(): - if len(cols & flips.flipped_real) % 2 == 1: - residual_indices ^= {logical_row} - - # Stabilizer generator columns: set from unfinished check triggers - # rather than raw anticommutation. - for uc_idx in unfinished_flipped: - col = context.unfinished_to_column[uc_idx] - if col is not None: - residual_indices ^= {col} - sorted_residual = sorted(residual_indices) - - readout_flipped: list[int] = [] - for r_idx, meas_set in enumerate(context.readout_meas_sets): - if len(meas_set & flips.flipped_real) % 2 == 1: - readout_flipped.append(r_idx) - - if not ( - finished_flipped or unfinished_flipped or sorted_residual or readout_flipped - ): - return None - - tag = f"{site_name} {_format_pauli(site_pauli)}" - base = pb.ErrorModelType.Error( - tag=tag, - residual=sorted_residual, - readout_flips=readout_flipped, - probability=probability, - ) - return jit_pb.JitGadgetType.Error( - base=base, - finished_checks=finished_flipped, - unfinished_checks=unfinished_flipped, - ) - - # --------------------------------------------------------------------------- # PROPAGATE statement resolution and validation # --------------------------------------------------------------------------- @@ -1648,7 +1207,7 @@ def resolve_propagations( elif isinstance(stmt, OutputPort): running += len(codes[stmt.code_name].stabilizers) elif isinstance(stmt, Instruction): - running += _measurement_count_of_instruction(stmt) + running += instruction_num_measurements(str(stmt)) elif isinstance(stmt, PropagateStatement): target_rows = _resolve_logical_target_to_columns( stmt.target, list(output_ports), codes, expected_kind="OUT" @@ -1721,21 +1280,6 @@ def resolve_propagations( return result -def _measurement_count_of_instruction(inst: Instruction) -> int: - """Return the number of measurements produced by *inst*.""" - name = inst.name.upper() - if name in PASSTHROUGH_NOISE_INSTRUCTIONS: - return 0 - gate = stim.gate_data(name) - if not gate.produces_measurements: - return 0 - if gate.takes_pauli_targets: - return mpp_measurement_count(list(inst.targets)) - if gate.is_two_qubit_gate: - return len(_qubit_indices(inst)) // 2 - return len(_qubit_indices(inst)) - - def _apply_propagations( *, propagations: dict[int, ResolvedPropagation], @@ -1806,15 +1350,20 @@ def _build_input_port_paulis( paulis: list[stim.PauliString] = [] for port in input_ports: code = codes[port.code_name] - qubit_map = { - logical: physical for logical, physical in enumerate(port.qubit_indices) + local_to_global = { + local_qubit: global_qubit + for local_qubit, global_qubit in enumerate(port.qubit_indices) } for logical in code.logicals: paulis.append( - pauli_product_to_stim(logical.z_operator, num_qubits, qubit_map) + pauli_product_to_stim( + logical.z_operator, num_qubits, local_to_global + ) ) paulis.append( - pauli_product_to_stim(logical.x_operator, num_qubits, qubit_map) + pauli_product_to_stim( + logical.x_operator, num_qubits, local_to_global + ) ) sel = select_stabilizer_generators(code) for j in range(len(sel.generator_indices)): @@ -1823,7 +1372,7 @@ def _build_input_port_paulis( for q in range(code.n): v = destab[q] if v != 0: - ps[qubit_map[q]] = v + ps[local_to_global[q]] = v paulis.append(ps) return paulis @@ -2053,7 +1602,7 @@ def compute_implicit_readout_propagation( body_flat = flatten_body(list(gadget.body)) num_qubits = max(max_qubit_index(list(gadget.body)) + 1, 0) - decomposed, _orig_map = _build_decomposed_body(body_flat) + decomposed = build_decomposed_body(body_flat) input_paulis = _build_input_port_paulis(input_ports, codes, num_qubits) From da33efc5c4049aa312188eb7d49884c23f01ef83 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 9 Aug 2026 19:08:09 -0700 Subject: [PATCH 067/157] clean jit library builder --- deq/deq/transpiler/jit_library_builder.py | 287 +++++++++++++++------- deq/deq/transpiler/jit_noise_builder.py | 2 +- deq/deq/transpiler/jit_transpiler.py | 49 ++-- 3 files changed, 229 insertions(+), 109 deletions(-) diff --git a/deq/deq/transpiler/jit_library_builder.py b/deq/deq/transpiler/jit_library_builder.py index bffa0a3d..1308c685 100644 --- a/deq/deq/transpiler/jit_library_builder.py +++ b/deq/deq/transpiler/jit_library_builder.py @@ -34,6 +34,7 @@ InputVirtualTarget, Instruction, LogicalPauliTarget, + LossStatement, MeasurementRecordTarget, MeasurementRefTarget, OutputPort, @@ -46,7 +47,7 @@ ReadoutTarget, ) from deq.transpiler.compose_builder import ( - build_compose_jit_gadget_type, + transpile_compose_jit_gadget_type, compose_to_synthetic_gadget, ) from deq.transpiler.jit_transpiler import ( @@ -68,10 +69,15 @@ _resolve_ds_to_input_cols, compute_correction_propagation, compute_implicit_readout_propagation, - compute_noise_errors, compute_physical_correction, + iter_noise_errors_with_origin, resolve_propagations, ) +from deq.transpiler.loss.transpiler import ( + LossGeneratorPlacement, + transpile_inferred_loss_model, +) +from deq.transpiler.loss.syntax import transpile_declared_loss_model import stim from deq.spec.common import bitmatrix_from_sparse @@ -79,11 +85,56 @@ from deq.transpiler.stim_constants import qubit_indices as _qubit_indices from deq.transpiler.stim_constants import ( PASSTHROUGH_NOISE_INSTRUCTIONS, - mpp_measurement_count, + instruction_num_measurements, split_mpp_targets, ) +@dataclass(frozen=True) +class NoiseErrorOrigin: + """Source-body position and final error index of one noise-derived row.""" + + body_index: int + error_index: int + + +@dataclass(frozen=True) +class JitGadgetArtifacts: + """Runtime gadget protobuf plus annotation-only transpiler provenance.""" + + jit_type: jit_pb.JitGadgetType + noise_error_origins: tuple[NoiseErrorOrigin, ...] = () + loss_generator_placements: tuple[LossGeneratorPlacement, ...] = () + + def __getstate__( + self, + ) -> tuple[bytes, tuple[NoiseErrorOrigin, ...], tuple[LossGeneratorPlacement, ...]]: + return ( + self.jit_type.SerializeToString(), + self.noise_error_origins, + self.loss_generator_placements, + ) + + def __setstate__( + self, + state: tuple[ + bytes, tuple[NoiseErrorOrigin, ...], tuple[LossGeneratorPlacement, ...] + ], + ) -> None: + jit_type, noise_error_origins, loss_generator_placements = state + object.__setattr__(self, "jit_type", jit_pb.JitGadgetType.FromString(jit_type)) + object.__setattr__(self, "noise_error_origins", noise_error_origins) + object.__setattr__(self, "loss_generator_placements", loss_generator_placements) + + +@dataclass(frozen=True) +class JitLibraryArtifacts: + """Runtime library and per-gadget provenance from one transpilation.""" + + jit_library: jit_pb.JitLibrary + gadget_artifacts_by_name: dict[str, JitGadgetArtifacts] + + def _measurement_tags_of(inst: Instruction) -> list[str]: """Return one human-readable tag per measurement produced by *inst*.""" name = inst.name.upper() @@ -116,21 +167,6 @@ def _measurement_tags_of(inst: Instruction) -> list[str]: return single_tags -def _measurement_count_of(inst: Instruction) -> int: - """Return the number of measurements produced by *inst*.""" - name = inst.name.upper() - if name in PASSTHROUGH_NOISE_INSTRUCTIONS: - return 0 - gate = stim.gate_data(name) - if not gate.produces_measurements: - return 0 - if gate.takes_pauli_targets: - return mpp_measurement_count(list(inst.targets)) - if gate.is_two_qubit_gate: - return len(_qubit_indices(inst)) // 2 - return len(_qubit_indices(inst)) - - def build_jit_library( qfile: DeqFile, *, @@ -148,26 +184,55 @@ def build_jit_library( ``1`` (default) runs sequentially with no subprocess overhead. Values > 1 use :class:`~concurrent.futures.ProcessPoolExecutor`. """ + return transpile_jit_library(qfile, jobs=jobs).jit_library + + +def transpile_jit_library( + qfile: DeqFile, + *, + jobs: int = 1, +) -> JitLibraryArtifacts: + """Build a library and retain annotation provenance from the same pass.""" scaffold = _build_library_scaffold(qfile) + # A gadget with input ports gets ``input_losses`` describing how a loss + # entering it would propagate (needed to carry a loss's envelope and herald + # across gadget boundaries). Only build them when the library actually + # declares loss -- a ``LOSS_ERROR`` instruction or an explicit ``LOSS`` + # statement -- so loss-free libraries stay loss-model-free and gain no dead + # loss-generator error rows. + library_has_loss = any( + (isinstance(statement, Instruction) and statement.name.upper() == "LOSS_ERROR") + or isinstance(statement, LossStatement) + for gadget in scaffold.gadgets + for statement in flatten_body(list(gadget.body)) + ) + if jobs > 1 and len(scaffold.gadgets) > 1: - gadget_types = _build_gadget_types_parallel( + gadget_artifacts = _transpile_gadget_types_parallel( scaffold.gadgets, scaffold.gtype_of_gadget, scaffold.ptype_of_code, scaffold.code_by_name, jobs, + library_has_loss=library_has_loss, ) else: - gadget_types = [ - _build_jit_gadget_type( + gadget_artifacts = [ + _transpile_jit_gadget_type( gadget, scaffold.gtype_of_gadget[gadget.name], scaffold.ptype_of_code, scaffold.code_by_name, + library_has_loss=library_has_loss, ) for gadget in scaffold.gadgets ] + gadget_types = [artifacts.jit_type for artifacts in gadget_artifacts] + gadget_artifacts_by_name = { + gadget.name: artifacts + for gadget, artifacts in zip(scaffold.gadgets, gadget_artifacts) + } # Process COMPOSE definitions in source order. Each one becomes a new # JitGadgetType visible to subsequent COMPOSEs (so nested COMPOSE works @@ -177,7 +242,7 @@ def build_jit_library( } compose_so_far: dict[str, ComposeDefinition] = {} for compose in scaffold.composes: - composed_jit = build_compose_jit_gadget_type( + compose_artifacts = transpile_compose_jit_gadget_type( compose, gtype=scaffold.gtype_of_compose[compose.name], gadget_definitions=scaffold.gadget_by_name, @@ -186,14 +251,20 @@ def build_jit_library( codes=scaffold.code_by_name, ptype_of_code=scaffold.ptype_of_code, port_types=scaffold.port_types, + library_has_loss=library_has_loss, ) + composed_jit = compose_artifacts.jit_type + gadget_artifacts_by_name[compose.name] = compose_artifacts gadget_types.append(composed_jit) jit_by_name[compose.name] = composed_jit compose_so_far[compose.name] = compose - return jit_pb.JitLibrary( - port_types=sorted(scaffold.port_types, key=lambda p: p.base.ptype), - gadget_types=sorted(gadget_types, key=lambda g: g.base.gtype), + return JitLibraryArtifacts( + jit_library=jit_pb.JitLibrary( + port_types=sorted(scaffold.port_types, key=lambda p: p.base.ptype), + gadget_types=sorted(gadget_types, key=lambda g: g.base.gtype), + ), + gadget_artifacts_by_name=gadget_artifacts_by_name, ) @@ -317,9 +388,7 @@ def _build_library_scaffold(qfile: DeqFile) -> _LibraryScaffold: port_types = [ _build_jit_port_type(code, ptype_of_code[code.name]) for code in codes ] - obs_count_of_ptype = { - pt.base.ptype: len(pt.base.observables) for pt in port_types - } + obs_count_of_ptype = {pt.base.ptype: len(pt.base.observables) for pt in port_types} return _LibraryScaffold( codes=codes, @@ -363,7 +432,7 @@ def _build_jit_program_gadget_type( _validate_port_qubit_count(port, codes, gadget.name, "OUTPUT") measurement_count = sum( - _measurement_count_of(statement) + instruction_num_measurements(str(statement)) for statement in flatten_body(list(gadget.body), for_simulate=True) if isinstance(statement, Instruction) ) @@ -374,8 +443,7 @@ def _build_jit_program_gadget_type( ) input_observable_count = sum( - obs_count_of_ptype[ptype_of_code[port.code_name]] - for port in gadget.input_ports + obs_count_of_ptype[ptype_of_code[port.code_name]] for port in gadget.input_ports ) output_observable_count = sum( obs_count_of_ptype[ptype_of_code[port.code_name]] @@ -407,34 +475,34 @@ def _build_jit_program_gadget_type( ) -def _build_gadget_types_parallel( +def _transpile_gadget_types_parallel( gadgets: list[GadgetDefinition], gtype_of_gadget: dict[str, int], ptype_of_code: dict[str, int], code_by_name: dict[str, CodeDefinition], jobs: int, -) -> list[jit_pb.JitGadgetType]: - """Build gadget types in parallel using worker processes. - - Protobuf messages are not picklable, so each worker serializes its - result as bytes and the main process deserializes. - """ + *, + library_has_loss: bool, +) -> list[JitGadgetArtifacts]: + """Transpile gadget types with provenance in parallel workers.""" from concurrent.futures import ProcessPoolExecutor - args = [(g, gtype_of_gadget[g.name], ptype_of_code, code_by_name) for g in gadgets] + args = [ + (g, gtype_of_gadget[g.name], ptype_of_code, code_by_name, library_has_loss) + for g in gadgets + ] with ProcessPoolExecutor(max_workers=jobs) as pool: - result_bytes = list(pool.map(_build_jit_gadget_type_bytes, args)) - - return [jit_pb.JitGadgetType.FromString(b) for b in result_bytes] + return list(pool.map(_transpile_jit_gadget_type_worker, args)) -def _build_jit_gadget_type_bytes( - args: tuple[GadgetDefinition, int, dict[str, int], dict[str, CodeDefinition]], -) -> bytes: - """Worker entry point: build a JitGadgetType and return serialized bytes.""" - g, gtype, ptype_of_code, code_by_name = args - result = _build_jit_gadget_type(g, gtype, ptype_of_code, code_by_name) - return result.SerializeToString() +def _transpile_jit_gadget_type_worker( + args: tuple[GadgetDefinition, int, dict[str, int], dict[str, CodeDefinition], bool], +) -> JitGadgetArtifacts: + """Worker entry point returning picklable JIT gadget artifacts.""" + g, gtype, ptype_of_code, code_by_name, library_has_loss = args + return _transpile_jit_gadget_type( + g, gtype, ptype_of_code, code_by_name, library_has_loss=library_has_loss + ) # --------------------------------------------------------------------------- @@ -545,28 +613,27 @@ def _build_jit_port_type(code: CodeDefinition, ptype: int) -> jit_pb.JitPortType ptype=ptype, name=code.name, observables=observables, + n=code.n, ) - return jit_pb.JitPortType(base=base, k=code.k, stabilizers=stabilizers) - - -# --------------------------------------------------------------------------- -# Gadget types -# --------------------------------------------------------------------------- + return jit_pb.JitPortType(base=base, k=code.k, n=code.n, stabilizers=stabilizers) -def _build_jit_gadget_type( +def _transpile_jit_gadget_type( gadget: GadgetDefinition, gtype: int, ptype_of_code: dict[str, int], codes: dict[str, CodeDefinition], *, - check_override: tuple[ - list[tuple[frozenset[int], bool]], - list[tuple[frozenset[int], bool]], - ] - | None = None, -) -> jit_pb.JitGadgetType: - """Build a ``JitGadgetType`` from a ``GadgetDefinition``. + library_has_loss: bool = True, + check_override: ( + tuple[ + list[tuple[frozenset[int], bool]], + list[tuple[frozenset[int], bool]], + ] + | None + ) = None, +) -> JitGadgetArtifacts: + """Transpile a ``GadgetDefinition`` into JIT gadget artifacts. When *check_override* is provided as ``(finished, unfinished)``, it replaces what :func:`resolve_gadget_checks` would derive from the @@ -606,7 +673,7 @@ def _build_jit_gadget_type( # or @DECODE_ONLY on a measurement instruction without a matching # counterpart. sim_meas_count = sum( - _measurement_count_of(s) + instruction_num_measurements(str(s)) for s in flatten_body(list(gadget.body), for_simulate=True) if isinstance(s, Instruction) ) @@ -689,7 +756,7 @@ def _build_check( ) unfinished_pb.append(_build_check(members, parity, drop_ov=ov_index)) - readouts_pb, readout_propagation_pb, readouts_info = build_readouts( + readouts_pb, readout_propagation_pb = build_readouts( gadget, codes, input_virtual_count, input_ports, output_ports, internal_count ) num_output_observables = sum( @@ -762,19 +829,22 @@ def _build_check( num_unfinished=len(unfinished_pb), num_readouts=len(readouts_pb), ) - errors_pb.extend( - compute_noise_errors( - gadget, - codes, - output_ports=output_ports, - input_virtual_count=input_virtual_count, - finished_checks=finished, - unfinished_checks=unfinished, - ov_start=ov_start, - readouts_info=readouts_info, - physical_correction=physical_correction_pb, + noise_error_origins: list[NoiseErrorOrigin] = [] + for body_index, error_row in iter_noise_errors_with_origin( + gadget, + codes, + output_ports=output_ports, + input_virtual_count=input_virtual_count, + finished_checks=finished, + unfinished_checks=unfinished, + ov_start=ov_start, + readouts=readouts_pb, + physical_correction=physical_correction_pb, + ): + noise_error_origins.append( + NoiseErrorOrigin(body_index=body_index, error_index=len(errors_pb)) ) - ) + errors_pb.append(error_row) base = pb.GadgetType( gtype=gtype, @@ -788,11 +858,43 @@ def _build_check( logical_correction=logical_correction_pb, physical_correction=physical_correction_pb, ) - return jit_pb.JitGadgetType( - base=base, - finished_checks=finished_pb, - unfinished_checks=unfinished_pb, - errors=errors_pb, + loss_model_pb = transpile_declared_loss_model( + gadget, + codes, + num_errors=len(errors_pb), + num_measurements=internal_count, + ) + loss_generator_placements: tuple[LossGeneratorPlacement, ...] = () + if loss_model_pb is None: + loss_artifacts = transpile_inferred_loss_model( + gadget, + codes, + output_ports=output_ports, + input_ports=input_ports, + input_virtual_count=input_virtual_count, + finished_checks=finished, + unfinished_checks=unfinished, + ov_start=ov_start, + readouts=readouts_pb, + physical_correction=physical_correction_pb, + existing_errors=errors_pb, + library_has_loss=library_has_loss, + ) + if loss_artifacts.model is not None: + errors_pb.extend(loss_artifacts.added_errors) + loss_model_pb = loss_artifacts.model + loss_generator_placements = loss_artifacts.generator_placements + if loss_model_pb is not None: + base.loss_model.CopyFrom(loss_model_pb) + return JitGadgetArtifacts( + jit_type=jit_pb.JitGadgetType( + base=base, + finished_checks=finished_pb, + unfinished_checks=unfinished_pb, + errors=errors_pb, + ), + noise_error_origins=tuple(noise_error_origins), + loss_generator_placements=loss_generator_placements, ) @@ -854,7 +956,7 @@ def collect_physical_conditionals( elif isinstance(stmt, OutputPort): running += len(codes[stmt.code_name].stabilizers) elif isinstance(stmt, Instruction): - running += _measurement_count_of(stmt) + running += instruction_num_measurements(str(stmt)) elif isinstance(stmt, ConditionalStatement): cond = stmt.condition if not isinstance( @@ -1102,7 +1204,7 @@ def build_readouts( input_ports: list[InputPort], output_ports: list[OutputPort], internal_count: int, -) -> tuple[list[pb.GadgetType.Readout], util_pb.BitMatrix, list["_ReadoutInfo"]]: +) -> tuple[list[pb.GadgetType.Readout], util_pb.BitMatrix]: """Extract READOUT statements and build the ``readouts`` / propagation. ``GadgetType.Readout.measurement_indices`` indexes the gadget's @@ -1138,7 +1240,7 @@ def build_readouts( output_virtual_indices.add(running + k) running += count elif isinstance(stmt, Instruction): - running += _measurement_count_of(stmt) + running += instruction_num_measurements(str(stmt)) elif isinstance(stmt, ReadoutStatement): readouts_info.append( _parse_readout( @@ -1156,7 +1258,7 @@ def build_readouts( if not readouts_info: propagation = util_pb.BitMatrix(rows=0, cols=num_input_observables + 1) - return [], propagation, [] + return [], propagation readouts_pb: list[pb.GadgetType.Readout] = [] for info in readouts_info: @@ -1176,7 +1278,7 @@ def build_readouts( propagation = _build_readout_propagation( readouts_info, num_input_observables, implicit ) - return readouts_pb, propagation, readouts_info + return readouts_pb, propagation @dataclass @@ -1494,10 +1596,13 @@ def _parse_error( f"observable {target}: logical index out of range " f"(port has {n_logicals} logical observable(s))" ) - global_idx = sum( - len(codes[p.code_name].logicals) - for p in output_ports[: target.port_index] - ) + target.index + global_idx = ( + sum( + len(codes[p.code_name].logicals) + for p in output_ports[: target.port_index] + ) + + target.index + ) else: if target.index >= len(logical_qubit_columns): raise ValueError( diff --git a/deq/deq/transpiler/jit_noise_builder.py b/deq/deq/transpiler/jit_noise_builder.py index 77db736e..740555f9 100644 --- a/deq/deq/transpiler/jit_noise_builder.py +++ b/deq/deq/transpiler/jit_noise_builder.py @@ -753,7 +753,7 @@ def compute_noise_errors( """Expand every noise instruction in the body into JIT ``Error`` rows. Parameters mirror the precomputed state available in - :func:`deq.transpiler.jit_library_builder._build_jit_gadget_type`. + :func:`deq.transpiler.jit_library_builder._transpile_jit_gadget_type`. ``physical_correction`` is the freshly-computed pc matrix and is used to subtract out the runtime's automatic Pauli-frame update on flipped body measurements (see diff --git a/deq/deq/transpiler/jit_transpiler.py b/deq/deq/transpiler/jit_transpiler.py index fead86d1..a6cb6c73 100644 --- a/deq/deq/transpiler/jit_transpiler.py +++ b/deq/deq/transpiler/jit_transpiler.py @@ -187,22 +187,23 @@ class _BuildState: def _pauli_product_to_sparse( - product: PauliProduct, qubit_map: dict[int, int] + product: PauliProduct, local_to_global: dict[int, int] ) -> SparsePauli: """Convert a ``PauliProduct`` (with code-local indices) to a ``SparsePauli``. - ``qubit_map`` translates code-local (logical-position) indices to - absolute physical qubit indices, matching the ``INPUT``/``OUTPUT`` port - declaration. + ``local_to_global`` translates code-local qubit indices to gadget-global + qubit indices, matching the ``INPUT``/``OUTPUT`` port declaration. """ terms: dict[int, str] = {} for term in product.terms: if term.pauli == "I": continue - phys = qubit_map[term.index] - if phys in terms: - raise ValueError(f"qubit {phys} appears more than once in PauliProduct") - terms[phys] = term.pauli + global_qubit = local_to_global[term.index] + if global_qubit in terms: + raise ValueError( + f"qubit {global_qubit} appears more than once in PauliProduct" + ) + terms[global_qubit] = term.pauli return SparsePauli(cast(dict, terms)) @@ -296,7 +297,7 @@ def max_qubit_index(statements: Sequence[GadgetStatement]) -> int: def pauli_product_to_stim( product: PauliProduct, num_qubits: int, - qubit_map: dict[int, int] | None = None, + local_to_global: dict[int, int] | None = None, ) -> stim.PauliString: """Convert a :class:`PauliProduct` to a ``stim.PauliString``. @@ -306,14 +307,18 @@ def pauli_product_to_stim( The Pauli product (code-local qubit indices). num_qubits: Total number of qubits in the target Pauli string. - qubit_map: - Optional mapping from code-local to physical qubit indices. + local_to_global: + Optional mapping from code-local to gadget-global qubit indices. When ``None``, indices are used as-is (identity mapping). """ ps = stim.PauliString(num_qubits) for term in product.terms: - phys = qubit_map[term.index] if qubit_map is not None else term.index - ps[phys] = _PAULI_NAME_TO_INT[term.pauli.upper()] + global_qubit = ( + local_to_global[term.index] + if local_to_global is not None + else term.index + ) + ps[global_qubit] = _PAULI_NAME_TO_INT[term.pauli.upper()] return ps @@ -1082,10 +1087,15 @@ def derive_checks_auto( for stmt in body: if isinstance(stmt, InputPort): code = codes[stmt.code_name] - qubit_map = {i: q for i, q in enumerate(stmt.qubit_indices)} + local_to_global = { + local_qubit: global_qubit + for local_qubit, global_qubit in enumerate(stmt.qubit_indices) + } for stabilizer in code.stabilizers: input_virtual_outcomes.append( - sim.measure(_pauli_product_to_sparse(stabilizer, qubit_map)) + sim.measure( + _pauli_product_to_sparse(stabilizer, local_to_global) + ) ) _apply_decomposed_instructions(state, flatten_body(body)) @@ -1094,10 +1104,15 @@ def derive_checks_auto( for stmt in body: if isinstance(stmt, OutputPort): code = codes[stmt.code_name] - qubit_map = {i: q for i, q in enumerate(stmt.qubit_indices)} + local_to_global = { + local_qubit: global_qubit + for local_qubit, global_qubit in enumerate(stmt.qubit_indices) + } for stabilizer in code.stabilizers: output_virtual_outcomes.append( - sim.measure(_pauli_product_to_sparse(stabilizer, qubit_map)) + sim.measure( + _pauli_product_to_sparse(stabilizer, local_to_global) + ) ) sim_outcomes: list[int] = ( From cadba6ca3d454fa4ced206ff589ac0fd0b52a141 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 9 Aug 2026 19:10:48 -0700 Subject: [PATCH 068/157] clean up compose builder --- deq/deq/transpiler/compose_builder.py | 112 ++++++++++++-------------- 1 file changed, 50 insertions(+), 62 deletions(-) diff --git a/deq/deq/transpiler/compose_builder.py b/deq/deq/transpiler/compose_builder.py index 1367ec14..8d22ca4f 100644 --- a/deq/deq/transpiler/compose_builder.py +++ b/deq/deq/transpiler/compose_builder.py @@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Callable, Mapping, Sequence if TYPE_CHECKING: - from deq.transpiler.jit_library_builder import CompiledGadget + from deq.transpiler.jit_library_builder import JitGadgetArtifacts import deq.proto.deq_bin_pb2 as pb import deq.proto.deq_jit_pb2 as jit_pb @@ -46,6 +46,7 @@ from deq.compiler.jit_compiler import static_jit_compiler from deq.spec.canonical import merge from deq.transpiler.jit_transpiler import flatten_body, num_frame_columns +from deq.transpiler.stim_constants import instruction_num_measurements # --------------------------------------------------------------------------- # COMPOSE validation @@ -563,9 +564,6 @@ def _expand_definition( remain valid when the body is inlined into a larger COMPOSE). For a ``COMPOSE``, recursively expands with qubit remapping. """ - # Local import to avoid the cycle compose_builder ↔ jit_library_builder. - from deq.transpiler.jit_library_builder import _measurement_count_of - if name in gadget_defs: gadget = gadget_defs[name] flat = flatten_body(list(gadget.body)) @@ -576,7 +574,7 @@ def _expand_definition( for s in flat: if isinstance(s, Instruction): circuit.append(s) - running += _measurement_count_of(s) + running += instruction_num_measurements(str(s)) elif isinstance(s, ReadoutStatement): circuit.append(_relativize_readout(s, running)) elif isinstance(s, PreselectStatement): @@ -632,9 +630,6 @@ def expand_compose_circuit( Port data qubits are numbered ``0 .. total_data-1`` (dense). Ancilla qubits follow starting at ``total_data``. """ - # Local import to avoid the cycle compose_builder ↔ jit_library_builder. - from deq.transpiler.jit_library_builder import _measurement_count_of - compose_inputs = compose.input_ports compose_outputs = compose.output_ports @@ -781,7 +776,7 @@ def expand_compose_circuit( if isinstance(stmt, Instruction): remapped = _remap_instruction(stmt, qmap) circuit.append(remapped) - cumulative_meas += _measurement_count_of(remapped) + cumulative_meas += instruction_num_measurements(str(remapped)) elif isinstance(stmt, PreselectStatement): circuit.append(_rebase_preselect(stmt, sub_start_meas)) else: @@ -975,7 +970,7 @@ def compose_to_synthetic_gadget( pipeline side as needed. Used exclusively by the ``@REPROPAGATE`` build/annotate path (see - :func:`_compile_repropagated_compose`), which requires the body to be + :func:`_transpile_repropagated_compose`), which requires the body to be free of any CONDITIONAL frame correction. :func:`_reject_conditionals_under_repropagate` runs first at the ``@REPROPAGATE`` dispatch site to enforce that invariant, so no @@ -999,7 +994,7 @@ def compose_to_synthetic_gadget( ) -def _compile_repropagated_compose( +def _transpile_repropagated_compose( compose: ComposeDefinition, *, gtype: int, @@ -1010,7 +1005,7 @@ def _compile_repropagated_compose( ptype_of_code: Mapping[str, int], port_types: list[jit_pb.JitPortType], library_has_loss: bool = True, -) -> "CompiledGadget": +) -> "JitGadgetArtifacts": """Build a JitGadgetType for an ``@REPROPAGATE`` COMPOSE. Routes the COMPOSE through *both* pipelines and combines them: @@ -1024,14 +1019,14 @@ def _compile_repropagated_compose( define. * The flat-circuit pipeline (inlining the body into a synthetic :class:`GadgetDefinition` and running - :func:`_build_jit_gadget_type`) produces the *propagation* + :func:`_transpile_jit_gadget_type`) produces the *propagation* output: ``correction_propagation``, ``physical_correction``, ``logical_correction``, and the noise-derived ``ERROR`` rows. These come from circuit-flow analysis on the inlined body and cover conditional logical corrections that matrix composition cannot represent (the teleportation case). - The merge-derived check basis is fed into ``_build_jit_gadget_type`` + The merge-derived check basis is fed into ``_transpile_jit_gadget_type`` via its ``check_override`` parameter so the propagation/error derivation references the *same* check indices the merge pipeline produces. This keeps everything self-consistent. @@ -1040,7 +1035,7 @@ def _compile_repropagated_compose( between this module and ``jit_library_builder``. """ from deq.transpiler.jit_library_builder import ( # local import: cycle - _compile_jit_gadget_type, + _transpile_jit_gadget_type, ) _reject_conditionals_under_repropagate( @@ -1063,7 +1058,7 @@ def _compile_repropagated_compose( finished, unfinished = _check_basis_from_jit_gadget_type( merge_jt, synthetic, codes ) - return _compile_jit_gadget_type( + return _transpile_jit_gadget_type( synthetic, gtype, dict(ptype_of_code), @@ -1073,7 +1068,7 @@ def _compile_repropagated_compose( ) -def compile_repropagated_compose( +def transpile_compose_jit_gadget_type( compose: ComposeDefinition, *, gtype: int, @@ -1084,25 +1079,41 @@ def compile_repropagated_compose( ptype_of_code: Mapping[str, int], port_types: list[jit_pb.JitPortType], library_has_loss: bool = True, -) -> "CompiledGadget": - """Compile an ``@REPROPAGATE`` compose with annotation provenance.""" +) -> "JitGadgetArtifacts": + """Transpile a composed gadget and retain annotation provenance.""" validate_compose( compose, gadget_definitions=gadget_definitions, compose_definitions=compose_definitions, ) - if not has_repropagate(compose): - raise ValueError(f"COMPOSE {compose.name!r} is not @REPROPAGATE") - return _compile_repropagated_compose( - compose, - gtype=gtype, - gadget_definitions=gadget_definitions, - compose_definitions=compose_definitions, - jit_gadget_types_by_name=jit_gadget_types_by_name, - codes=codes, - ptype_of_code=ptype_of_code, - port_types=port_types, - library_has_loss=library_has_loss, + if has_repropagate(compose): + return _transpile_repropagated_compose( + compose, + gtype=gtype, + gadget_definitions=gadget_definitions, + compose_definitions=compose_definitions, + jit_gadget_types_by_name=jit_gadget_types_by_name, + codes=codes, + ptype_of_code=ptype_of_code, + port_types=port_types, + library_has_loss=library_has_loss, + ) + + from deq.transpiler.jit_library_builder import ( # local import: cycle + JitGadgetArtifacts, + ) + + return JitGadgetArtifacts( + jit_type=_build_merge_compose( + compose, + gtype=gtype, + gadget_definitions=gadget_definitions, + compose_definitions=compose_definitions, + jit_gadget_types_by_name=jit_gadget_types_by_name, + codes=codes, + ptype_of_code=ptype_of_code, + port_types=port_types, + ) ) @@ -1113,7 +1124,7 @@ def _check_basis_from_jit_gadget_type( ) -> tuple[list[tuple[frozenset[int], bool]], list[tuple[frozenset[int], bool]]]: """Recover the ``(members, parity)`` check basis from a JitGadgetType. - Inverts the encoding done by ``_build_jit_gadget_type._build_check``: + Inverts the encoding done by ``_transpile_jit_gadget_type._build_check``: converts each :class:`JitGadgetType.Check`'s ``PresentMeasurement`` list back into a ``frozenset`` of global measurement indices, and re-adds the implicit output-virtual index for each unfinished check. @@ -1176,36 +1187,12 @@ def build_compose_jit_gadget_type( port_types: list[jit_pb.JitPortType], library_has_loss: bool = True, ) -> jit_pb.JitGadgetType: - """Build a composed JitGadgetType. - - By default uses the merge() / Rust JIT compiler pipeline (see - :func:`_build_merge_compose`). When the COMPOSE has the - ``@REPROPAGATE`` decorator, a hybrid path is taken: the structural - output (measurements, checks, readouts) still comes from - merge(), but the propagation matrices and noise-derived ERRORs are - recomputed from circuit flow on the inlined body. See - :func:`_compile_repropagated_compose` for details. - """ - validate_compose( - compose, - gadget_definitions=gadget_definitions, - compose_definitions=compose_definitions, - ) + """Build a composed JitGadgetType without retaining provenance. - if has_repropagate(compose): - return _compile_repropagated_compose( - compose, - gtype=gtype, - gadget_definitions=gadget_definitions, - compose_definitions=compose_definitions, - jit_gadget_types_by_name=jit_gadget_types_by_name, - codes=codes, - ptype_of_code=ptype_of_code, - port_types=port_types, - library_has_loss=library_has_loss, - ).jit_type - - return _build_merge_compose( + This is the protobuf-only wrapper around + :func:`transpile_compose_jit_gadget_type`. + """ + return transpile_compose_jit_gadget_type( compose, gtype=gtype, gadget_definitions=gadget_definitions, @@ -1214,7 +1201,8 @@ def build_compose_jit_gadget_type( codes=codes, ptype_of_code=ptype_of_code, port_types=port_types, - ) + library_has_loss=library_has_loss, + ).jit_type def _build_merge_compose( From b63df2cb6ccae847fb5731f06de1afdaa8c77ab4 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 9 Aug 2026 19:14:00 -0700 Subject: [PATCH 069/157] reduce rename --- deq/deq/transpiler/compose_builder.py | 16 ++++++++-------- deq/deq/transpiler/jit_library_builder.py | 18 +++++++++--------- deq/deq/transpiler/jit_noise_builder.py | 2 +- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/deq/deq/transpiler/compose_builder.py b/deq/deq/transpiler/compose_builder.py index 8d22ca4f..068ecc18 100644 --- a/deq/deq/transpiler/compose_builder.py +++ b/deq/deq/transpiler/compose_builder.py @@ -970,7 +970,7 @@ def compose_to_synthetic_gadget( pipeline side as needed. Used exclusively by the ``@REPROPAGATE`` build/annotate path (see - :func:`_transpile_repropagated_compose`), which requires the body to be + :func:`_build_repropagated_compose`), which requires the body to be free of any CONDITIONAL frame correction. :func:`_reject_conditionals_under_repropagate` runs first at the ``@REPROPAGATE`` dispatch site to enforce that invariant, so no @@ -994,7 +994,7 @@ def compose_to_synthetic_gadget( ) -def _transpile_repropagated_compose( +def _build_repropagated_compose( compose: ComposeDefinition, *, gtype: int, @@ -1019,14 +1019,14 @@ def _transpile_repropagated_compose( define. * The flat-circuit pipeline (inlining the body into a synthetic :class:`GadgetDefinition` and running - :func:`_transpile_jit_gadget_type`) produces the *propagation* + :func:`_build_jit_gadget_type`) produces the *propagation* output: ``correction_propagation``, ``physical_correction``, ``logical_correction``, and the noise-derived ``ERROR`` rows. These come from circuit-flow analysis on the inlined body and cover conditional logical corrections that matrix composition cannot represent (the teleportation case). - The merge-derived check basis is fed into ``_transpile_jit_gadget_type`` + The merge-derived check basis is fed into ``_build_jit_gadget_type`` via its ``check_override`` parameter so the propagation/error derivation references the *same* check indices the merge pipeline produces. This keeps everything self-consistent. @@ -1035,7 +1035,7 @@ def _transpile_repropagated_compose( between this module and ``jit_library_builder``. """ from deq.transpiler.jit_library_builder import ( # local import: cycle - _transpile_jit_gadget_type, + _build_jit_gadget_type, ) _reject_conditionals_under_repropagate( @@ -1058,7 +1058,7 @@ def _transpile_repropagated_compose( finished, unfinished = _check_basis_from_jit_gadget_type( merge_jt, synthetic, codes ) - return _transpile_jit_gadget_type( + return _build_jit_gadget_type( synthetic, gtype, dict(ptype_of_code), @@ -1087,7 +1087,7 @@ def transpile_compose_jit_gadget_type( compose_definitions=compose_definitions, ) if has_repropagate(compose): - return _transpile_repropagated_compose( + return _build_repropagated_compose( compose, gtype=gtype, gadget_definitions=gadget_definitions, @@ -1124,7 +1124,7 @@ def _check_basis_from_jit_gadget_type( ) -> tuple[list[tuple[frozenset[int], bool]], list[tuple[frozenset[int], bool]]]: """Recover the ``(members, parity)`` check basis from a JitGadgetType. - Inverts the encoding done by ``_transpile_jit_gadget_type._build_check``: + Inverts the encoding done by ``_build_jit_gadget_type._build_check``: converts each :class:`JitGadgetType.Check`'s ``PresentMeasurement`` list back into a ``frozenset`` of global measurement indices, and re-adds the implicit output-virtual index for each unfinished check. diff --git a/deq/deq/transpiler/jit_library_builder.py b/deq/deq/transpiler/jit_library_builder.py index 1308c685..07e7a497 100644 --- a/deq/deq/transpiler/jit_library_builder.py +++ b/deq/deq/transpiler/jit_library_builder.py @@ -209,7 +209,7 @@ def transpile_jit_library( ) if jobs > 1 and len(scaffold.gadgets) > 1: - gadget_artifacts = _transpile_gadget_types_parallel( + gadget_artifacts = _build_gadget_types_parallel( scaffold.gadgets, scaffold.gtype_of_gadget, scaffold.ptype_of_code, @@ -219,7 +219,7 @@ def transpile_jit_library( ) else: gadget_artifacts = [ - _transpile_jit_gadget_type( + _build_jit_gadget_type( gadget, scaffold.gtype_of_gadget[gadget.name], scaffold.ptype_of_code, @@ -475,7 +475,7 @@ def _build_jit_program_gadget_type( ) -def _transpile_gadget_types_parallel( +def _build_gadget_types_parallel( gadgets: list[GadgetDefinition], gtype_of_gadget: dict[str, int], ptype_of_code: dict[str, int], @@ -484,7 +484,7 @@ def _transpile_gadget_types_parallel( *, library_has_loss: bool, ) -> list[JitGadgetArtifacts]: - """Transpile gadget types with provenance in parallel workers.""" + """Build gadget types with provenance in parallel workers.""" from concurrent.futures import ProcessPoolExecutor args = [ @@ -492,15 +492,15 @@ def _transpile_gadget_types_parallel( for g in gadgets ] with ProcessPoolExecutor(max_workers=jobs) as pool: - return list(pool.map(_transpile_jit_gadget_type_worker, args)) + return list(pool.map(_build_jit_gadget_type_worker, args)) -def _transpile_jit_gadget_type_worker( +def _build_jit_gadget_type_worker( args: tuple[GadgetDefinition, int, dict[str, int], dict[str, CodeDefinition], bool], ) -> JitGadgetArtifacts: """Worker entry point returning picklable JIT gadget artifacts.""" g, gtype, ptype_of_code, code_by_name, library_has_loss = args - return _transpile_jit_gadget_type( + return _build_jit_gadget_type( g, gtype, ptype_of_code, code_by_name, library_has_loss=library_has_loss ) @@ -618,7 +618,7 @@ def _build_jit_port_type(code: CodeDefinition, ptype: int) -> jit_pb.JitPortType return jit_pb.JitPortType(base=base, k=code.k, n=code.n, stabilizers=stabilizers) -def _transpile_jit_gadget_type( +def _build_jit_gadget_type( gadget: GadgetDefinition, gtype: int, ptype_of_code: dict[str, int], @@ -633,7 +633,7 @@ def _transpile_jit_gadget_type( | None ) = None, ) -> JitGadgetArtifacts: - """Transpile a ``GadgetDefinition`` into JIT gadget artifacts. + """Build JIT gadget artifacts from a ``GadgetDefinition``. When *check_override* is provided as ``(finished, unfinished)``, it replaces what :func:`resolve_gadget_checks` would derive from the diff --git a/deq/deq/transpiler/jit_noise_builder.py b/deq/deq/transpiler/jit_noise_builder.py index 740555f9..77db736e 100644 --- a/deq/deq/transpiler/jit_noise_builder.py +++ b/deq/deq/transpiler/jit_noise_builder.py @@ -753,7 +753,7 @@ def compute_noise_errors( """Expand every noise instruction in the body into JIT ``Error`` rows. Parameters mirror the precomputed state available in - :func:`deq.transpiler.jit_library_builder._transpile_jit_gadget_type`. + :func:`deq.transpiler.jit_library_builder._build_jit_gadget_type`. ``physical_correction`` is the freshly-computed pc matrix and is used to subtract out the runtime's automatic Pauli-frame update on flipped body measurements (see From e253f6f7b5716d5e8fe9488950d7af1e2d9d019d Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 10 Aug 2026 10:19:18 -0700 Subject: [PATCH 070/157] allow dynamic reweighting in tesseract --- deq/deq/transpiler/jit_library_builder.py | 25 +++----- .../cpp/tesseract/tesseract_bridge.cc | 8 +++ .../cpp/tesseract/tesseract_bridge.h | 4 ++ .../cpp/tesseract/tesseract_core.h | 59 ++++++++++++++++++- deq/deq_runtime/deq_runtime.pyi | 11 ++++ 5 files changed, 90 insertions(+), 17 deletions(-) diff --git a/deq/deq/transpiler/jit_library_builder.py b/deq/deq/transpiler/jit_library_builder.py index 07e7a497..cce67f38 100644 --- a/deq/deq/transpiler/jit_library_builder.py +++ b/deq/deq/transpiler/jit_library_builder.py @@ -129,7 +129,7 @@ def __setstate__( @dataclass(frozen=True) class JitLibraryArtifacts: - """Runtime library and per-gadget provenance from one transpilation.""" + """Runtime library and per-gadget provenance produced by one build.""" jit_library: jit_pb.JitLibrary gadget_artifacts_by_name: dict[str, JitGadgetArtifacts] @@ -167,12 +167,14 @@ def _measurement_tags_of(inst: Instruction) -> list[str]: return single_tags -def build_jit_library( - qfile: DeqFile, - *, - jobs: int = 1, -) -> jit_pb.JitLibrary: - """Build a :class:`JitLibrary` from a parsed deq file. +def build_jit_library(qfile: DeqFile, *, jobs: int = 1) -> jit_pb.JitLibrary: + """Build and return the runtime ``JitLibrary`` protobuf.""" + return build_jit_library_artifacts(qfile, jobs=jobs).jit_library + + +def build_jit_library_artifacts(qfile: DeqFile, *, jobs: int = 1) -> JitLibraryArtifacts: + """ + Build a ``JitLibrary`` and retain per-gadget annotation provenance. Parameters ---------- @@ -184,15 +186,6 @@ def build_jit_library( ``1`` (default) runs sequentially with no subprocess overhead. Values > 1 use :class:`~concurrent.futures.ProcessPoolExecutor`. """ - return transpile_jit_library(qfile, jobs=jobs).jit_library - - -def transpile_jit_library( - qfile: DeqFile, - *, - jobs: int = 1, -) -> JitLibraryArtifacts: - """Build a library and retain annotation provenance from the same pass.""" scaffold = _build_library_scaffold(qfile) # A gadget with input ports gets ``input_losses`` describing how a loss diff --git a/deq/deq_runtime/cpp/tesseract/tesseract_bridge.cc b/deq/deq_runtime/cpp/tesseract/tesseract_bridge.cc index a522eb86..7b483a18 100644 --- a/deq/deq_runtime/cpp/tesseract/tesseract_bridge.cc +++ b/deq/deq_runtime/cpp/tesseract/tesseract_bridge.cc @@ -64,4 +64,12 @@ rust::Vec decode_to_errors( return result; } +void update_error_costs( + TesseractDecoderHandle& handle, + rust::Slice edge_probabilities) +{ + std::vector probabilities(edge_probabilities.begin(), edge_probabilities.end()); + handle.decoder.update_error_costs(probabilities); +} + } // namespace tesseract_bridge diff --git a/deq/deq_runtime/cpp/tesseract/tesseract_bridge.h b/deq/deq_runtime/cpp/tesseract/tesseract_bridge.h index ca3b66ff..97ff4700 100644 --- a/deq/deq_runtime/cpp/tesseract/tesseract_bridge.h +++ b/deq/deq_runtime/cpp/tesseract/tesseract_bridge.h @@ -33,6 +33,10 @@ rust::Vec decode_to_errors( TesseractDecoderHandle& handle, rust::Slice detections); +void update_error_costs( + TesseractDecoderHandle& handle, + rust::Slice edge_probabilities); + } // namespace tesseract_bridge #endif // TESSERACT_BRIDGE_H_ diff --git a/deq/deq_runtime/cpp/tesseract/tesseract_core.h b/deq/deq_runtime/cpp/tesseract/tesseract_core.h index e9b08e8f..a2df383c 100644 --- a/deq/deq_runtime/cpp/tesseract/tesseract_core.h +++ b/deq/deq_runtime/cpp/tesseract/tesseract_core.h @@ -58,6 +58,19 @@ struct ErrorChainNode { int64_t parent_idx = -1; }; +// Smallest probability represented rather than clamped away. +// +// A zero-probability error is a *declared* impossibility, not an absent one: the +// producer emits it deliberately (a Pauli-envelope generator a heralded loss can +// later raise) and its index is part of the decoding interface, so it has to +// stay in the graph while remaining unreachable. +// +// This is the smallest bound that keeps `exp(likelihood_cost)` finite, so +// `get_probability()` stays strictly positive and the cost arithmetic stays free +// of inf/NaN -- `merge_weights` yields NaN when two infinite costs meet, which +// happens as soon as two declared-impossible errors share a syndrome. +inline constexpr double MIN_PROBABILITY = 1e-300; + // Represents an error / weighted hyperedge (same as common::Error). struct Error { double likelihood_cost; @@ -68,7 +81,7 @@ struct Error { // Construct from a probability and sorted detector list. Error(double probability, std::vector detectors) : symptom{std::move(detectors)} { - double p = std::clamp(probability, 1e-15, 1.0 - 1e-15); + double p = std::clamp(probability, MIN_PROBABILITY, 1.0 - 1e-15); likelihood_cost = -std::log(p / (1.0 - p)); } @@ -297,6 +310,50 @@ class TesseractDecoder { return predicted_errors_buffer; } + /// Replace every error cost in place from a fresh probability vector, indexed + /// in the original (pre-merge) numbering used at construction. + /// + /// Only the costs are recomputed. The merged grouping, the detector orders + /// and `eneighbors` all depend on the detector sets alone, which do not + /// change, so the two expensive parts of construction are skipped. This is + /// what lets one loaded decoder serve shots whose priors differ -- notably + /// heralded-loss reweighting, which moves a handful of edges per shot. + /// + /// Reproduces construction exactly: costs are merged in original index order + /// with the same formula, and `d2e` is rebuilt in error order before being + /// re-sorted, so ties resolve as they would in a fresh instance. + /// + /// The caller must pass a vector matching the hypergraph this decoder was + /// built from; detector sets are assumed unchanged. + void update_error_costs(const std::vector& probabilities) { + std::vector merged(num_errors, 0.0); + std::vector merged_seen(num_errors, 0); + for (size_t oi = 0; oi < original_error_map.size(); ++oi) { + size_t mi = original_error_map[oi]; + if (mi == std::numeric_limits::max()) continue; + double p = std::clamp(probabilities[oi], common::MIN_PROBABILITY, 1.0 - 1e-15); + double cost = -std::log(p / (1.0 - p)); + merged[mi] = merged_seen[mi] ? common::merge_weights(cost, merged[mi]) : cost; + merged_seen[mi] = 1; + } + for (size_t i = 0; i < num_errors; ++i) { + errors[i].likelihood_cost = merged[i]; + error_costs[i] = ErrorCost{ + merged[i], + merged[i] / static_cast(errors[i].symptom.detectors.size()) + }; + } + for (size_t d = 0; d < num_detectors; ++d) d2e[d].clear(); + for (size_t ei = 0; ei < num_errors; ++ei) { + for (int d : edets[ei]) d2e[d].push_back(static_cast(ei)); + } + for (size_t d = 0; d < num_detectors; ++d) { + std::sort(d2e[d].begin(), d2e[d].end(), [this](size_t a, size_t b) { + return error_costs[a].min_cost < error_costs[b].min_cost; + }); + } + } + private: std::vector errors; std::vector original_error_map; diff --git a/deq/deq_runtime/deq_runtime.pyi b/deq/deq_runtime/deq_runtime.pyi index 09b039e9..b0e40b19 100644 --- a/deq/deq_runtime/deq_runtime.pyi +++ b/deq/deq_runtime/deq_runtime.pyi @@ -27,6 +27,17 @@ class Hyperedge: probability: float +class LossSite: + source_edges: list[int] + continuation_edges: list[int] + children: list[int] + probability: float + + +class LossInfo: + sites: list["LossSite"] + + class Coordinator: """Coordinator (`deq.bin`) interface for the in-process runtime. From fd82f38345a5d7a24f577c619d0afdbe13613ad3 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 10 Aug 2026 10:52:47 -0700 Subject: [PATCH 071/157] minor update to tutorial chapters --- .../chapters/codes-redundant-stabilizers.md | 24 +++--- .../tutorial/chapters/compose-gadgets.md | 86 +++++++++---------- .../tutorial/chapters/debug-deq-program.md | 22 ++--- .../tutorial/chapters/multi-port-gadgets.md | 12 +-- 4 files changed, 72 insertions(+), 72 deletions(-) diff --git a/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md b/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md index 44e81d65..5bf1d228 100644 --- a/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md +++ b/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md @@ -64,17 +64,17 @@ The annotated output for the Idle gadget reveals the problem: GADGET Idle { INPUT RepetitionCode 0 2 4 # X_ERROR(0.01) 0 2 4 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 + ERROR(0.01) C0 OUT0.LX0 # E0 + ERROR(0.01) C0 C1 OUT0.LX0 # E1 + ERROR(0.01) C1 OUT0.LX0 # E2 R 1 3 5 CX 0 1 2 3 4 5 CX 2 1 4 3 0 5 # M(0.01) 1 3 5 M 1 3 5 - ERROR(0.01) C0 C2 C3 - ERROR(0.01) C1 C2 C4 - ERROR(0.01) C2 + ERROR(0.01) C0 C2 C3 # E3 + ERROR(0.01) C1 C2 C4 # E4 + ERROR(0.01) C2 # E5 CHECK M0 IN0.S0 CHECK M1 IN0.S1 CHECK M2 M1 M0 @@ -132,17 +132,17 @@ The annotated Idle gadget: GADGET Idle { INPUT RepetitionCode 0 2 4 # X_ERROR(0.01) 0 2 4 - ERROR(0.01) C0 C2 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 C2 OUT0.LX0 + ERROR(0.01) C0 C2 OUT0.LX0 # E0 + ERROR(0.01) C0 C1 OUT0.LX0 # E1 + ERROR(0.01) C1 C2 OUT0.LX0 # E2 R 1 3 5 CX 0 1 2 3 4 5 CX 2 1 4 3 0 5 # M(0.01) 1 3 5 M 1 3 5 - ERROR(0.01) C0 C3 - ERROR(0.01) C1 C4 - ERROR(0.01) C2 C5 + ERROR(0.01) C0 C3 # E3 + ERROR(0.01) C1 C4 # E4 + ERROR(0.01) C2 C5 # E5 CHECK M0 IN0.S0 CHECK M1 IN0.S1 CHECK M2 IN0.S2 diff --git a/deq/documents/tutorial/chapters/compose-gadgets.md b/deq/documents/tutorial/chapters/compose-gadgets.md index 335cc896..293adf47 100644 --- a/deq/documents/tutorial/chapters/compose-gadgets.md +++ b/deq/documents/tutorial/chapters/compose-gadgets.md @@ -85,9 +85,9 @@ The circuit is physically identical to running the Idle gadget 3 times. Running GADGET PrepareZ { R 0 1 2 # X_ERROR(0.01) 0 1 2 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 + ERROR(0.01) C0 OUT0.LX0 # E0 + ERROR(0.01) C0 C1 OUT0.LX0 # E1 + ERROR(0.01) C1 OUT0.LX0 # E2 OUTPUT RepetitionCode 0 1 2 CHECK OUT0.S0 CHECK OUT0.S1 @@ -107,37 +107,37 @@ The circuit is physically identical to running the Idle gadget 3 times. Running GADGET Flat3Idle { INPUT RepetitionCode 0 2 4 # X_ERROR(0.01) 0 2 4 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 + ERROR(0.01) C0 OUT0.LX0 # E0 + ERROR(0.01) C0 C1 OUT0.LX0 # E1 + ERROR(0.01) C1 OUT0.LX0 # E2 R 1 3 CX 0 1 2 3 CX 2 1 4 3 # X_ERROR(0.01) 1 3 - ERROR(0.01) C2 - ERROR(0.01) C3 + ERROR(0.01) C2 # E3 + ERROR(0.01) C3 # E4 M 1 3 # X_ERROR(0.01) 0 2 4 - ERROR(0.01) C0 C2 OUT0.LX0 - ERROR(0.01) C0 C1 C2 C3 OUT0.LX0 - ERROR(0.01) C1 C3 OUT0.LX0 + ERROR(0.01) C0 C2 OUT0.LX0 # E5 + ERROR(0.01) C0 C1 C2 C3 OUT0.LX0 # E6 + ERROR(0.01) C1 C3 OUT0.LX0 # E7 R 1 3 CX 0 1 2 3 CX 2 1 4 3 # X_ERROR(0.01) 1 3 - ERROR(0.01) C4 - ERROR(0.01) C5 + ERROR(0.01) C4 # E8 + ERROR(0.01) C5 # E9 M 1 3 # X_ERROR(0.01) 0 2 4 - ERROR(0.01) C0 C2 C4 OUT0.LX0 - ERROR(0.01) C0 C1 C2 C3 C4 C5 OUT0.LX0 - ERROR(0.01) C1 C3 C5 OUT0.LX0 + ERROR(0.01) C0 C2 C4 OUT0.LX0 # E10 + ERROR(0.01) C0 C1 C2 C3 C4 C5 OUT0.LX0 # E11 + ERROR(0.01) C1 C3 C5 OUT0.LX0 # E12 R 1 3 CX 0 1 2 3 CX 2 1 4 3 # X_ERROR(0.01) 1 3 - ERROR(0.01) C0 C2 C4 C6 - ERROR(0.01) C1 C3 C5 C7 + ERROR(0.01) C0 C2 C4 C6 # E13 + ERROR(0.01) C1 C3 C5 C7 # E14 M 1 3 CHECK M4 IN0.S0 CHECK M5 IN0.S1 @@ -166,9 +166,9 @@ The circuit is physically identical to running the Idle gadget 3 times. Running INPUT RepetitionCode 0 1 2 # M(0.01) 0 1 2 M 0 1 2 - ERROR(0.01) C0 R0 - ERROR(0.01) C0 C1 R0 - ERROR(0.01) C1 R0 + ERROR(0.01) C0 R0 # E0 + ERROR(0.01) C0 C1 R0 # E1 + ERROR(0.01) C1 R0 # E2 READOUT rec[-3] rec[-2] rec[-1] # IN0.LX0 CHECK M1 M0 IN0.S0 CHECK M2 M1 IN0.S1 @@ -318,9 +318,9 @@ Running `annotate` on the COMPOSE version produces a flattened `GADGET` block: GADGET PrepareZ { R 0 1 2 # X_ERROR(0.01) 0 1 2 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 + ERROR(0.01) C0 OUT0.LX0 # E0 + ERROR(0.01) C0 C1 OUT0.LX0 # E1 + ERROR(0.01) C1 OUT0.LX0 # E2 OUTPUT RepetitionCode 0 1 2 CHECK OUT0.S0 CHECK OUT0.S1 @@ -340,15 +340,15 @@ Running `annotate` on the COMPOSE version produces a flattened `GADGET` block: GADGET Idle { INPUT RepetitionCode 0 2 4 # X_ERROR(0.01) 0 2 4 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 + ERROR(0.01) C0 OUT0.LX0 # E0 + ERROR(0.01) C0 C1 OUT0.LX0 # E1 + ERROR(0.01) C1 OUT0.LX0 # E2 R 1 3 CX 0 1 2 3 CX 2 1 4 3 # X_ERROR(0.01) 1 3 - ERROR(0.01) C0 C2 - ERROR(0.01) C1 C3 + ERROR(0.01) C0 C2 # E3 + ERROR(0.01) C1 C3 # E4 M 1 3 CHECK M0 IN0.S0 CHECK M1 IN0.S1 @@ -373,9 +373,9 @@ Running `annotate` on the COMPOSE version produces a flattened `GADGET` block: INPUT RepetitionCode 0 1 2 # M(0.01) 0 1 2 M 0 1 2 - ERROR(0.01) C0 R0 - ERROR(0.01) C0 C1 R0 - ERROR(0.01) C1 R0 + ERROR(0.01) C0 R0 # E0 + ERROR(0.01) C0 C1 R0 # E1 + ERROR(0.01) C1 R0 # E2 READOUT rec[-3] rec[-2] rec[-1] # IN0.LX0 CHECK M1 M0 IN0.S0 CHECK M2 M1 IN0.S1 @@ -676,9 +676,9 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl GADGET PrepareZ { R 0 1 2 # X_ERROR(0.01) 0 1 2 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 + ERROR(0.01) C0 OUT0.LX0 # E0 + ERROR(0.01) C0 C1 OUT0.LX0 # E1 + ERROR(0.01) C1 OUT0.LX0 # E2 OUTPUT RepetitionCode 0 1 2 CHECK OUT0.S0 CHECK OUT0.S1 @@ -698,16 +698,16 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl GADGET Idle { INPUT RepetitionCode 0 2 4 # X_ERROR(0.01) 0 2 4 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 + ERROR(0.01) C0 OUT0.LX0 # E0 + ERROR(0.01) C0 C1 OUT0.LX0 # E1 + ERROR(0.01) C1 OUT0.LX0 # E2 R 1 3 CX 0 1 2 3 CX 2 1 4 3 # M(0.01) 1 3 M 1 3 - ERROR(0.01) C0 C2 - ERROR(0.01) C1 C3 + ERROR(0.01) C0 C2 # E3 + ERROR(0.01) C1 C3 # E4 CHECK M0 IN0.S0 CHECK M1 IN0.S1 OUTPUT RepetitionCode 0 2 4 @@ -731,9 +731,9 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl INPUT RepetitionCode 0 1 2 # M(0.01) 0 1 2 M 0 1 2 - ERROR(0.01) C0 R0 - ERROR(0.01) C0 C1 R0 - ERROR(0.01) C1 R0 + ERROR(0.01) C0 R0 # E0 + ERROR(0.01) C0 C1 R0 # E1 + ERROR(0.01) C1 R0 # E2 READOUT rec[-3] rec[-2] rec[-1] # IN0.LX0 CHECK M1 M0 IN0.S0 CHECK M2 M1 IN0.S1 diff --git a/deq/documents/tutorial/chapters/debug-deq-program.md b/deq/documents/tutorial/chapters/debug-deq-program.md index 17623dac..c01a2557 100644 --- a/deq/documents/tutorial/chapters/debug-deq-program.md +++ b/deq/documents/tutorial/chapters/debug-deq-program.md @@ -38,9 +38,9 @@ Output: GADGET PrepareZ { R 0 1 2 # X_ERROR(0.01) 0 1 2 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 + ERROR(0.01) C0 OUT0.LX0 # E0 + ERROR(0.01) C0 C1 OUT0.LX0 # E1 + ERROR(0.01) C1 OUT0.LX0 # E2 OUTPUT RepetitionCode 0 1 2 CHECK OUT0.S0 CHECK OUT0.S1 @@ -60,16 +60,16 @@ Output: GADGET Idle { INPUT RepetitionCode 0 2 4 # X_ERROR(0.01) 0 2 4 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 + ERROR(0.01) C0 OUT0.LX0 # E0 + ERROR(0.01) C0 C1 OUT0.LX0 # E1 + ERROR(0.01) C1 OUT0.LX0 # E2 R 1 3 CX 0 1 2 3 CX 2 1 4 3 # M(0.01) 1 3 M 1 3 - ERROR(0.01) C0 C2 - ERROR(0.01) C1 C3 + ERROR(0.01) C0 C2 # E3 + ERROR(0.01) C1 C3 # E4 CHECK M0 IN0.S0 CHECK M1 IN0.S1 OUTPUT RepetitionCode 0 2 4 @@ -93,9 +93,9 @@ Output: INPUT RepetitionCode 0 1 2 # M(0.01) 0 1 2 M 0 1 2 - ERROR(0.01) C0 R0 - ERROR(0.01) C0 C1 R0 - ERROR(0.01) C1 R0 + ERROR(0.01) C0 R0 # E0 + ERROR(0.01) C0 C1 R0 # E1 + ERROR(0.01) C1 R0 # E2 READOUT rec[-3] rec[-2] rec[-1] # IN0.LX0 CHECK M1 M0 IN0.S0 CHECK M2 M1 IN0.S1 diff --git a/deq/documents/tutorial/chapters/multi-port-gadgets.md b/deq/documents/tutorial/chapters/multi-port-gadgets.md index 0afef57e..39bf19ad 100644 --- a/deq/documents/tutorial/chapters/multi-port-gadgets.md +++ b/deq/documents/tutorial/chapters/multi-port-gadgets.md @@ -158,12 +158,12 @@ With noise, the error structure reveals the CNOT's impact on decoding: INPUT RepetitionCode 0 1 2 INPUT RepetitionCode 3 4 5 # X_ERROR(0.01) 0 1 2 3 4 5 - ERROR(0.01) C0 C2 OUT0.LX0 OUT1.LX0 - ERROR(0.01) C0 C1 C2 C3 OUT0.LX0 OUT1.LX0 - ERROR(0.01) C1 C3 OUT0.LX0 OUT1.LX0 - ERROR(0.01) C2 OUT1.LX0 - ERROR(0.01) C2 C3 OUT1.LX0 - ERROR(0.01) C3 OUT1.LX0 + ERROR(0.01) C0 C2 OUT0.LX0 OUT1.LX0 # E0 + ERROR(0.01) C0 C1 C2 C3 OUT0.LX0 OUT1.LX0 # E1 + ERROR(0.01) C1 C3 OUT0.LX0 OUT1.LX0 # E2 + ERROR(0.01) C2 OUT1.LX0 # E3 + ERROR(0.01) C2 C3 OUT1.LX0 # E4 + ERROR(0.01) C3 OUT1.LX0 # E5 CX 0 3 1 4 2 5 OUTPUT RepetitionCode 0 1 2 OUTPUT RepetitionCode 3 4 5 From 3cf6ca89c76e661d0da5afd8ec502703921253b7 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 10 Aug 2026 11:45:12 -0700 Subject: [PATCH 072/157] preserve order when interleaving ERROR and X_ERROR --- deq/deq/transpiler/jit_library_builder.py | 52 ++++++++++++++++------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/deq/deq/transpiler/jit_library_builder.py b/deq/deq/transpiler/jit_library_builder.py index cce67f38..0cb77328 100644 --- a/deq/deq/transpiler/jit_library_builder.py +++ b/deq/deq/transpiler/jit_library_builder.py @@ -814,7 +814,7 @@ def _build_check( logical_physical_entries=logical_physical_entries, ) - errors_pb = _build_errors( + declared_errors = _build_errors( gadget, codes, output_ports, @@ -822,21 +822,40 @@ def _build_check( num_unfinished=len(unfinished_pb), num_readouts=len(readouts_pb), ) - noise_error_origins: list[NoiseErrorOrigin] = [] - for body_index, error_row in iter_noise_errors_with_origin( - gadget, - codes, - output_ports=output_ports, - input_virtual_count=input_virtual_count, - finished_checks=finished, - unfinished_checks=unfinished, - ov_start=ov_start, - readouts=readouts_pb, - physical_correction=physical_correction_pb, - ): - noise_error_origins.append( - NoiseErrorOrigin(body_index=body_index, error_index=len(errors_pb)) + declared_error_positions = [ + body_index + for body_index, statement in enumerate(flatten_body(list(gadget.body))) + if isinstance(statement, ErrorStatement) + ] + ordered_errors = [ + (body_index, False, error_row) + for body_index, error_row in zip( + declared_error_positions, declared_errors, strict=True + ) + ] + ordered_errors.extend( + (body_index, True, error_row) + for body_index, error_row in iter_noise_errors_with_origin( + gadget, + codes, + output_ports=output_ports, + input_virtual_count=input_virtual_count, + finished_checks=finished, + unfinished_checks=unfinished, + ov_start=ov_start, + readouts=readouts_pb, + physical_correction=physical_correction_pb, ) + ) + ordered_errors.sort(key=lambda item: item[0]) + + errors_pb: list[jit_pb.JitGadgetType.Error] = [] + noise_error_origins: list[NoiseErrorOrigin] = [] + for body_index, is_noise_error, error_row in ordered_errors: + if is_noise_error: + noise_error_origins.append( + NoiseErrorOrigin(body_index=body_index, error_index=len(errors_pb)) + ) errors_pb.append(error_row) base = pb.GadgetType( @@ -1487,7 +1506,8 @@ def _build_errors( flips. We translate names to indices and emit one ``JitGadgetType.Error`` per statement. No Pauli propagation happens here — propagation of physical noise sources into footprints is the - responsibility of the ``annotate`` tool. + handled separately by :func:`iter_noise_errors_with_origin`. The caller + interleaves both kinds of row by flattened source-body position. Indexing conventions for ``C``: ``i`` indexes the concatenated ``[finished_checks, unfinished_checks]`` array of the gadget. We From bdb58f87e0459da5355ce6ecb42a30a778a698d4 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 10 Aug 2026 11:50:33 -0700 Subject: [PATCH 073/157] update jit annotate to consider loss --- deq/deq/transpiler/jit_annotate.py | 423 +++++++++++++---------------- 1 file changed, 191 insertions(+), 232 deletions(-) diff --git a/deq/deq/transpiler/jit_annotate.py b/deq/deq/transpiler/jit_annotate.py index c9c52821..8f64105a 100644 --- a/deq/deq/transpiler/jit_annotate.py +++ b/deq/deq/transpiler/jit_annotate.py @@ -1,6 +1,6 @@ """Annotate a ``.deq`` file with derived check structure and noise errors. -This tool helps users understand how their source code is compiled into +This tool helps users understand how their source code is transpiled into hardware-level information (the binary ``JitLibrary``). Transformations applied @@ -17,14 +17,15 @@ - ``REPEAT`` blocks are unrolled (matching the ``.deq.jit`` view of the gadget); - circuit and measurement instructions are kept verbatim; - - noise instructions and user ``ERROR`` statements are commented out; + - noise instructions are commented out and replaced in place by derived + ``ERROR`` rows; + - user ``ERROR`` statements remain at their source positions; - user-written ``CHECK`` and ``READOUT`` statements are emitted verbatim; - auto-derived ``CHECK`` statements that extend the user-provided ones are inserted right after the latest measurement they depend on; - - computed ``ERROR`` statements (from noise propagation) are - inserted after each noise instruction. + - newly inferred loss-induced ``ERROR`` rows are appended after the body. - ``COMPOSE`` definitions are emitted as comments. - ``PROGRAM`` definitions are emitted verbatim. """ @@ -44,6 +45,7 @@ InputPort, Instruction, KeywordArg, + LossStatement, OutputPort, PauliProduct, PhysicalMeasurementTarget, @@ -58,7 +60,6 @@ Check, PortColumnLayout, flatten_body, - num_frame_columns, select_stabilizer_generators, ) from deq.transpiler.check_plugins import compute_layout, resolve_gadget_checks @@ -70,32 +71,20 @@ has_repropagate, ) from deq.transpiler.jit_library_builder import ( - _build_logical_correction, - build_jit_library, - build_readouts, - collect_physical_conditionals, - conditional_flipped_rows, -) -from deq.transpiler.jit_noise_builder import ( - compute_correction_propagation, - compute_implicit_readout_propagation, - compute_physical_correction, - iter_noise_errors_with_origin, - resolve_propagations, + JitGadgetArtifacts, + build_jit_library_artifacts, ) +from deq.transpiler.jit_noise_builder import compute_implicit_readout_propagation +from deq.transpiler.loss.syntax import loss_model_to_statements from deq.spec.common import bitmatrix_of import deq.proto.deq_jit_pb2 as jit_pb import deq.proto.util_pb2 as util_pb from deq.transpiler.stim_constants import ( - MEASUREMENT_INSTRUCTIONS, NOISE_INSTRUCTIONS_ALL, NOISY_MEASUREMENT_INSTRUCTIONS, PASSTHROUGH_NOISE_INSTRUCTIONS, - TWO_QUBIT_MEASUREMENT_INSTRUCTIONS, instruction_num_measurements, - mpp_measurement_count, ) -from deq.transpiler.stim_constants import qubit_indices as _qubit_indices def annotate(qfile: DeqFile, *, keep_noise: bool = False) -> str: @@ -129,7 +118,8 @@ def annotate(qfile: DeqFile, *, keep_noise: bool = False) -> str: # Always build the JIT library to get stable gtype/ptype assignments # and to render COMPOSE definitions as GADGET blocks. - library = build_jit_library(qfile) + library_artifacts = build_jit_library_artifacts(qfile) + library = library_artifacts.jit_library stab_count_of_ptype: dict[int, int] = { pt.base.ptype: len(pt.stabilizers) for pt in library.port_types } @@ -151,14 +141,14 @@ def annotate(qfile: DeqFile, *, keep_noise: bool = False) -> str: _annotate_code(definition, ptype_by_name.get(definition.name)) ) elif isinstance(definition, GadgetDefinition): - gtype = ( - jit_by_name[definition.name].base.gtype - if definition.name in jit_by_name - else None - ) blocks.append( _annotate_gadget( - definition, codes, gtype=gtype, keep_noise=keep_noise + definition, + codes, + artifacts=library_artifacts.gadget_artifacts_by_name[ + definition.name + ], + keep_noise=keep_noise, ) ) elif isinstance(definition, ComposeDefinition): @@ -175,11 +165,6 @@ def annotate(qfile: DeqFile, *, keep_noise: bool = False) -> str: synthetic = compose_to_synthetic_gadget( definition, gadget_defs, compose_so_far, codes ) - gtype = ( - jit_by_name[definition.name].base.gtype - if definition.name in jit_by_name - else None - ) check_override = _check_basis_from_jit_gadget_type( jit_by_name[definition.name], synthetic, codes ) @@ -187,7 +172,9 @@ def annotate(qfile: DeqFile, *, keep_noise: bool = False) -> str: _annotate_gadget( synthetic, codes, - gtype=gtype, + artifacts=library_artifacts.gadget_artifacts_by_name[ + definition.name + ], keep_noise=keep_noise, check_override=check_override, ) @@ -275,13 +262,15 @@ def _annotate_gadget( gadget: GadgetDefinition, codes: dict[str, CodeDefinition], *, - gtype: int | None = None, + artifacts: JitGadgetArtifacts, keep_noise: bool = False, - check_override: tuple[ - list[tuple[frozenset[int], bool]], - list[tuple[frozenset[int], bool]], - ] - | None = None, + check_override: ( + tuple[ + list[tuple[frozenset[int], bool]], + list[tuple[frozenset[int], bool]], + ] + | None + ) = None, ) -> str: """Render *gadget* as a ``@CHECKS("manual", verify=0)`` GADGET block. @@ -293,6 +282,19 @@ def _annotate_gadget( error derivation). """ flat_body = flatten_body(list(gadget.body)) + jit_gadget = artifacts.jit_type + jit_errors = list(jit_gadget.errors) + loss_model = ( + jit_gadget.base.loss_model if jit_gadget.base.HasField("loss_model") else None + ) + declared_error_positions = [ + body_index + for body_index, statement in enumerate(flat_body) + if isinstance(statement, ErrorStatement) + ] + pre_loss_error_count = len(declared_error_positions) + len( + artifacts.noise_error_origins + ) # Walk the body once to label every body position with the running # measurement count *after* that position. We use these snapshots @@ -345,19 +347,35 @@ def _annotate_gadget( finished_at_position = len(flat_body) - 1 unfinished_at_position = len(flat_body) - 1 - decorators = _gadget_decorators_with_manual_checks(gadget.decorators, gtype=gtype) + decorators = _gadget_decorators_with_manual_checks( + gadget.decorators, gtype=jit_gadget.base.gtype + ) lines: list[str] = [*[str(d) for d in decorators], f"GADGET {gadget.name} {{"] - - ( - noise_errors_at, - num_finished, - cp_pb, - pc_pb, - lc_pb, - input_virtual_count, - ) = _compute_gadget_runtime_data( - gadget, codes, check_override=check_override + noise_errors_by_body_index: dict[ + int, list[tuple[int, jit_pb.JitGadgetType.Error]] + ] = {} + for origin in artifacts.noise_error_origins: + noise_errors_by_body_index.setdefault(origin.body_index, []).append( + (origin.error_index, jit_errors[origin.error_index]) + ) + noise_error_indices = { + origin.error_index for origin in artifacts.noise_error_origins + } + declared_error_indices_by_body_index = dict( + zip( + declared_error_positions, + ( + error_index + for error_index in range(pre_loss_error_count) + if error_index not in noise_error_indices + ), + strict=True, + ) ) + num_finished = len(jit_gadget.finished_checks) + cp_pb = jit_gadget.base.correction_propagation + pc_pb = jit_gadget.base.physical_correction + lc_pb = jit_gadget.base.logical_correction # Compute column layouts for output and input ports. output_ports = gadget.output_ports @@ -367,14 +385,8 @@ def _annotate_gadget( input_ports = gadget.input_ports input_col_layout = PortColumnLayout(input_ports, codes) layout = compute_layout(gadget, codes) - _readouts_pb, propagation, _readouts_info = build_readouts( - gadget, - codes, - layout.input_virtual_count, - input_ports, - output_ports, - layout.internal_count, - ) + input_virtual_count = layout.input_virtual_count + propagation = jit_gadget.base.readout_propagation input_port_stab_counts = [len(codes[p.code_name].stabilizers) for p in input_ports] output_port_stab_counts = [ @@ -390,6 +402,45 @@ def _annotate_gadget( readout_counter = 0 pre_running = 0 + loss_generator_comments: dict[int, list[str]] = {} + source_loss_lines: list[str] = [] + input_loss_lines: list[str] = [] + loss_error_counter = 0 + if not keep_noise and loss_model is not None: + source_losses, input_losses = loss_model_to_statements( + loss_model, + input_ports=input_ports, + output_ports=output_ports, + codes=codes, + gadget_name=gadget.name, + ) + source_loss_lines = [ + f" {statement} # L{loss_index}" + for loss_index, statement in enumerate(source_losses) + ] + input_loss_lines = [f" {statement}" for statement in input_losses] + if not keep_noise and artifacts.loss_generator_placements: + # Group generators at each body position by the Pauli channel they + # inject, so degenerate placements (the same channel spread by different + # losses to the same error) collapse to one line that lists every owning + # loss tag, e.g. ``# L3.CE5 L4.CE5``. + channels_by_body: dict[int, dict[str, list[str]]] = {} + for placement in artifacts.loss_generator_placements: + channel = f"{placement.pauli}_ERROR(0.5) {placement.qubit}" + tag = ( + f"{placement.origin.label}." + f"{placement.role.value}{placement.error_index}" + ) + tags = channels_by_body.setdefault(placement.body_index, {}).setdefault( + channel, [] + ) + if tag not in tags: + tags.append(tag) + for body_index, channels in channels_by_body.items(): + loss_generator_comments[body_index] = [ + f" # {channel} # {' '.join(tags)}" + for channel, tags in channels.items() + ] physical_running = 0 for body_index, stmt in enumerate(flat_body): if isinstance(stmt, ReadoutStatement): @@ -402,15 +453,33 @@ def _annotate_gadget( readout_counter += 1 else: for line in _render_body_statement( - stmt, keep_noise=keep_noise, physical_running=physical_running + stmt, + keep_noise=keep_noise, + error_index=declared_error_indices_by_body_index.get(body_index), + physical_running=physical_running, ): lines.append(line) + # Emit each source loss's LOSS(...) line right after its + # commented-out LOSS_ERROR so the loss model is legible in place; + # the trailing ``# L`` on that line labels the loss for + # ``child_losses`` references elsewhere. + if ( + isinstance(stmt, Instruction) + and stmt.name.upper() == "LOSS_ERROR" + and loss_error_counter < len(source_loss_lines) + ): + lines.append(source_loss_lines[loss_error_counter]) + loss_error_counter += 1 + for comment_line in loss_generator_comments.get(body_index, ()): + lines.append(comment_line) # When ``keep_noise`` is set, the noise instructions are emitted # verbatim, so re-transpilation will re-derive the same ERROR # rows from circuit flow. Emitting them here as well would # duplicate them. if not keep_noise: - for error_row in noise_errors_at.get(body_index, []): + for error_index, error_row in noise_errors_by_body_index.get( + body_index, () + ): lines.append( " " + _render_jit_error_to_source( @@ -418,6 +487,7 @@ def _annotate_gadget( num_finished=num_finished, layout=output_col_layout, ) + + f" # E{error_index}" ) pre_running = running_counts[body_index] if isinstance(stmt, Instruction): @@ -453,8 +523,36 @@ def _annotate_gadget( # evaluates for that output observable. lines.extend(propagate_lines) + # Declared and noise-derived rows are active at their source positions in order. + # Only rows newly introduced by loss inference are appended here. + if not keep_noise and loss_model is not None: + loss_errors = jit_errors[pre_loss_error_count:] + if loss_errors: + lines.append("") + for error_index, error_row in enumerate( + loss_errors, start=pre_loss_error_count + ): + lines.append( + " " + + _render_jit_error_to_source( + error_row, + num_finished=num_finished, + layout=output_col_layout, + ) + + f" # E{error_index}" + ) + trailing_loss_lines = list(source_loss_lines[loss_error_counter:]) + trailing_loss_lines.extend(input_loss_lines) + if trailing_loss_lines: + lines.append("") + lines.extend(trailing_loss_lines) + # Statistics summary - all_errors = [e for errs in noise_errors_at.values() for e in errs] + all_errors = [ + error_row + for errors in noise_errors_by_body_index.values() + for _, error_row in errors + ] lines.append("") lines.extend( _format_stats_comment( @@ -505,13 +603,7 @@ def _advance_running_count( if isinstance(stmt, OutputPort): return running + len(codes[stmt.code_name].stabilizers) if isinstance(stmt, Instruction): - name = stmt.name.upper() - if name in MEASUREMENT_INSTRUCTIONS: - return running + len(_qubit_indices(stmt)) - if name in TWO_QUBIT_MEASUREMENT_INSTRUCTIONS: - return running + len(_qubit_indices(stmt)) // 2 - if name == "MPP": - return running + mpp_measurement_count(list(stmt.targets)) + return running + instruction_num_measurements(str(stmt)) return running @@ -548,6 +640,7 @@ def _render_body_statement( stmt: GadgetStatement, *, keep_noise: bool = False, + error_index: int | None = None, physical_running: int = 0, ) -> list[str]: """Render a single body statement as one or more lines (already indented). @@ -557,6 +650,9 @@ def _render_body_statement( so re-transpilation re-derives the original ERROR rows from circuit flow. + When ``error_index`` is given, an explicit source ``ERROR`` statement is + labeled with its canonical runtime index. + ``physical_running`` is the running count of physical measurements produced by preceding statements; it is used to translate absolute ``M`` PRESELECT targets into relative ``rec[-k]``. @@ -573,20 +669,24 @@ def _render_body_statement( # User-written PROPAGATEs are redundant — the full PROPAGATE # block is regenerated from the cp/pc matrices. Drop silently. return [] + if isinstance(stmt, LossStatement): + # User-written LOSS statements are regenerated from the binary loss + # model as a dedicated block, so drop them here to avoid duplication. + return [] if isinstance(stmt, ReadoutStatement): return [f" {_render_readout_statement(stmt)}"] if isinstance(stmt, ErrorStatement): - return [f" # {_render_error_statement(stmt)}"] + suffix = f" # E{error_index}" if error_index is not None else "" + return [f" {_render_error_statement(stmt)}{suffix}"] if isinstance(stmt, Instruction): name = stmt.name.upper() if name in NOISE_INSTRUCTIONS_ALL: - # Passthrough noise (e.g. LOSS_ERROR) has no equivalent - # ERROR-row representation that re-transpilation could - # reconstruct, so it must be emitted verbatim even when the - # caller asked to comment out regular noise. - if keep_noise or name in PASSTHROUGH_NOISE_INSTRUCTIONS: - return [f" {stmt}"] - return [f" # {stmt}"] + # Noise is commented out unless the caller keeps it verbatim for + # the simulator. Passthrough loss (``LOSS_ERROR``) is decoder-facing + # only through the LOSS block, so in decode mode (``keep_noise`` off) + # it is commented out just like the Pauli noise channels. + prefix = "# " if keep_noise else "" + return [f" {prefix}{stmt}"] # Noisy measurement: comment out original, emit clean version. if ( stmt.arguments @@ -728,9 +828,7 @@ def _format_propagate_statements( out_label = output_layout.render_logical_labels({out_row})[0] cp_cols = set(cp_mat.rows[out_row].support) pc_cols = set(pc_mat.rows[out_row].support) - lc_cols = ( - set(lc_mat.rows[out_row].support) if lc_mat is not None else set() - ) + lc_cols = set(lc_mat.rows[out_row].support) if lc_mat is not None else set() has_flip = affine_col in cp_cols in_obs_cols = cp_cols & input_layout.logical_columns @@ -893,147 +991,6 @@ def _render_auto_check( return f" CHECK {tokens}{suffix}" -# --------------------------------------------------------------------------- -# Noise-instruction propagation (forward Pauli walk) -# --------------------------------------------------------------------------- - - -def _compute_gadget_runtime_data( - gadget: GadgetDefinition, - codes: dict[str, CodeDefinition], - *, - check_override: tuple[ - list[tuple[frozenset[int], bool]], - list[tuple[frozenset[int], bool]], - ] - | None = None, -) -> tuple[ - dict[int, list["jit_pb.JitGadgetType.Error"]], - int, - util_pb.BitMatrix, - util_pb.BitMatrix, - util_pb.BitMatrix, - int, -]: - """Return runtime data needed to annotate a gadget's noise and flows. - - Returns ``(noise_errors_by_position, num_finished_checks, cp_pb, - pc_pb, lc_pb, input_virtual_count)``: - - * ``noise_errors_by_position`` — propagated noise mechanisms keyed - by the originating noise instruction's index in the flattened - gadget body. Empty if the gadget has no noise. - * ``num_finished_checks`` — needed by - :func:`_render_jit_error_to_source` to renumber unfinished - checks and by the output-logical-deps comment. - * ``cp_pb`` — ``correction_propagation`` matrix - (output rows × input cols + FLIP). - * ``pc_pb`` — ``physical_correction`` matrix - (output rows × internal-measurement cols). - * ``lc_pb`` — ``logical_correction`` matrix - (output rows × readout cols). Populated from - ``CONDITIONAL R`` statements and ``PROPAGATE`` R-terms in - the source body. Rendered as ``R`` terms in the annotator's - PROPAGATE lines. - * ``input_virtual_count`` — number of input virtual measurements, - used to convert pc column indices to global measurement indices. - """ - input_ports = gadget.input_ports - output_ports = gadget.output_ports - layout = compute_layout(gadget, codes) - - if check_override is not None: - finished = check_override[0] - unfinished = check_override[1] - else: - check_result = resolve_gadget_checks(gadget, codes) - finished = check_result.finished - unfinished = check_result.unfinished - - _readouts_pb, _propagation, readouts_info = build_readouts( - gadget, - codes, - layout.input_virtual_count, - input_ports, - output_ports, - layout.internal_count, - ) - num_output_observables = sum( - num_frame_columns(codes[p.code_name]) for p in output_ports - ) - lc_pb = _build_logical_correction( - gadget, - num_output_observables, - len(_readouts_pb), - list(output_ports), - codes, - ) - - # Resolve user-supplied PROPAGATE statements so the annotator can - # install each row's declared XOR formula into the rendered cp/pc - # matrices. - input_layout_for_props = PortColumnLayout(input_ports, codes) - propagations = resolve_propagations( - gadget, - codes, - input_ports=input_ports, - output_ports=output_ports, - input_layout=input_layout_for_props, - input_virtual_count=layout.input_virtual_count, - ov_start=layout.ov_start, - ) - - cp_pb, logical_physical_entries = compute_correction_propagation( - gadget, - codes, - input_ports=input_ports, - output_ports=output_ports, - unfinished_checks=unfinished, - input_virtual_count=layout.input_virtual_count, - ov_start=layout.ov_start, - propagations=propagations, - ) - physical_conditionals_raw = collect_physical_conditionals( - gadget, - codes, - layout.input_virtual_count, - input_ports, - output_ports, - layout.internal_count, - ) - resolved_physical_conditionals: list[tuple[int, list[int]]] = [] - for pc in physical_conditionals_raw: - flipped: list[int] = [] - for target in pc.targets: - flipped.extend(conditional_flipped_rows(target, output_ports, codes)) - resolved_physical_conditionals.append((pc.internal_meas_index, flipped)) - pc_pb = compute_physical_correction( - codes, - output_ports=output_ports, - unfinished_checks=unfinished, - input_virtual_count=layout.input_virtual_count, - ov_start=layout.ov_start, - physical_conditionals=resolved_physical_conditionals, - logical_physical_entries=logical_physical_entries, - ) - - by_position: dict[int, list[jit_pb.JitGadgetType.Error]] = {} - for body_index, error_row in iter_noise_errors_with_origin( - gadget, - codes, - output_ports=output_ports, - input_virtual_count=layout.input_virtual_count, - finished_checks=finished, - unfinished_checks=unfinished, - ov_start=layout.ov_start, - readouts_info=readouts_info, - physical_correction=pc_pb, - ): - by_position.setdefault(body_index, []).append(error_row) - - return by_position, len(finished), cp_pb, pc_pb, lc_pb, layout.input_virtual_count - - # --------------------------------------------------------------------------- # COMPOSE → GADGET rendering # --------------------------------------------------------------------------- @@ -1178,16 +1135,16 @@ def _render_composed_gadget( synth_for_walker, codes, input_ports=input_ports, - readout_measurement_sets=[ - set(r.measurement_indices) for r in base.readouts - ], + readout_measurement_sets=[set(r.measurement_indices) for r in base.readouts], ) for row_index, readout in enumerate(base.readouts): rec_refs = [f"M{mi}" for mi in readout.measurement_indices] binary_cols = binary_rp_cols_by_row.get(row_index, set()) binary_observable_cols = binary_cols - {affine_col} - walker_cols = walker_implicit[row_index] if row_index < len(walker_implicit) else set() + walker_cols = ( + walker_implicit[row_index] if row_index < len(walker_implicit) else set() + ) diff_cols = binary_observable_cols ^ walker_cols if diff_cols: diff_logical = diff_cols & input_col_layout.logical_columns @@ -1225,13 +1182,15 @@ def _render_composed_gadget( # entries) and the PROPAGATE rows below re-emit them as ``FLIP`` # keywords and ``R`` terms. output_col_layout = PortColumnLayout(output_ports, codes) - lines.extend(_format_propagate_statements( - base.correction_propagation, - base.physical_correction, - base.logical_correction, - input_layout=input_col_layout, - output_layout=output_col_layout, - )) + lines.extend( + _format_propagate_statements( + base.correction_propagation, + base.physical_correction, + base.logical_correction, + input_layout=input_col_layout, + output_layout=output_col_layout, + ) + ) # ERROR statements. When ``keep_noise`` is set, the noise # instructions above are emitted verbatim, so re-transpilation From 32c765e8451821c3f9aeec4b8d3fd3586ea4ec29 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 10 Aug 2026 13:44:42 -0700 Subject: [PATCH 074/157] add test cases --- deq/deq/cli/annotate.py | 4 +- deq/deq/transpiler/jit_annotate.py | 8 +- .../loss-simulation/loss_ler_sweep.py | 2 +- deq/tests/circuit/test_annotate_keep_noise.py | 111 ++++++++---------- deq/tests/circuit/test_annotate_loss.py | 80 +++++++++++++ 5 files changed, 136 insertions(+), 69 deletions(-) create mode 100644 deq/tests/circuit/test_annotate_loss.py diff --git a/deq/deq/cli/annotate.py b/deq/deq/cli/annotate.py index 58c67d15..e019e704 100644 --- a/deq/deq/cli/annotate.py +++ b/deq/deq/cli/annotate.py @@ -52,8 +52,8 @@ def annotate( in the annotated output and the corresponding ``ERROR(p) ...`` rows are *not* emitted. Re-transpilation of the annotated file re-derives those ERROR rows from the kept noise instructions. - This is the recommended mode for producing a Stim-simulatable - annotated file. + Use this setting when the annotated file must retain its original + noise instructions, including for Stim sampling. Args: deq_file: path to the input .deq file. diff --git a/deq/deq/transpiler/jit_annotate.py b/deq/deq/transpiler/jit_annotate.py index 8f64105a..b49bccd9 100644 --- a/deq/deq/transpiler/jit_annotate.py +++ b/deq/deq/transpiler/jit_annotate.py @@ -681,11 +681,9 @@ def _render_body_statement( if isinstance(stmt, Instruction): name = stmt.name.upper() if name in NOISE_INSTRUCTIONS_ALL: - # Noise is commented out unless the caller keeps it verbatim for - # the simulator. Passthrough loss (``LOSS_ERROR``) is decoder-facing - # only through the LOSS block, so in decode mode (``keep_noise`` off) - # it is commented out just like the Pauli noise channels. - prefix = "# " if keep_noise else "" + # Preserve the original noise instruction when requested; otherwise + # its expanded ERROR or LOSS representation replaces it. + prefix = "" if keep_noise else "# " return [f" {prefix}{stmt}"] # Noisy measurement: comment out original, emit clean version. if ( diff --git a/deq/documents/tutorial/examples/loss-simulation/loss_ler_sweep.py b/deq/documents/tutorial/examples/loss-simulation/loss_ler_sweep.py index d7f9e927..9fdfa7ce 100644 --- a/deq/documents/tutorial/examples/loss-simulation/loss_ler_sweep.py +++ b/deq/documents/tutorial/examples/loss-simulation/loss_ler_sweep.py @@ -115,7 +115,7 @@ def run_server_batch( decoder=decoder, decoder_config=None, coordinator="monolithic", - coordinator_config=json.dumps({"loss_random_imputation_seed": seed}), + coordinator_config=json.dumps({"loss_strategy": "ignore", "loss_random_imputation_seed": seed}), seed=seed, debug_dir=None, simulator="qdk", diff --git a/deq/tests/circuit/test_annotate_keep_noise.py b/deq/tests/circuit/test_annotate_keep_noise.py index 59507c99..5e21d018 100644 --- a/deq/tests/circuit/test_annotate_keep_noise.py +++ b/deq/tests/circuit/test_annotate_keep_noise.py @@ -14,7 +14,6 @@ from deq.transpiler.jit_annotate import annotate as render_annotated from deq.transpiler.jit_library_builder import build_jit_library - _NOISY_GADGET_SRC = """ CODE C[[1,1,1]] { LOGICAL X0 Z0 @@ -44,12 +43,13 @@ class TestKeepNoiseGadget: """``--keep-noise`` keeps noise verbatim and skips noise-origin ERRORs.""" - def test_default_comments_noise_and_emits_errors(self) -> None: + def test_without_keep_noise_comments_noise_and_emits_errors(self) -> None: rendered = render_annotated(parse(_NOISY_GADGET_SRC)) assert "# X_ERROR(0.05) 0" in rendered assert "# DEPOLARIZE1(0.01) 0" in rendered - # Default mode emits explicit ERROR(p) rows. + # Expanding noise emits explicit ERROR(p) rows. assert "ERROR(0.05)" in rendered + assert "# E0" in rendered def test_keep_noise_keeps_verbatim_and_no_explicit_errors(self) -> None: rendered = render_annotated(parse(_NOISY_GADGET_SRC), keep_noise=True) @@ -63,9 +63,9 @@ def test_keep_noise_keeps_verbatim_and_no_explicit_errors(self) -> None: # no line *starts* with ``ERROR(`` after stripping indent). for line in rendered.splitlines(): stripped = line.lstrip() - assert not stripped.startswith("ERROR("), ( - f"unexpected explicit ERROR line under --keep-noise: {line!r}" - ) + assert not stripped.startswith( + "ERROR(" + ), f"unexpected explicit ERROR line under --keep-noise: {line!r}" def test_keep_noise_round_trips_byte_equivalent(self) -> None: qfile = parse(_NOISY_GADGET_SRC) @@ -74,10 +74,7 @@ def test_keep_noise_round_trips_byte_equivalent(self) -> None: anno_lib = build_jit_library(parse(rendered)) orig_stripped, _ = strip_jit_library(orig_lib) anno_stripped, _ = strip_jit_library(anno_lib) - assert ( - orig_stripped.SerializeToString() - == anno_stripped.SerializeToString() - ) + assert orig_stripped.SerializeToString() == anno_stripped.SerializeToString() _REPROPAGATE_TELEPORT_SRC = """ @@ -137,12 +134,9 @@ def test_keep_noise_repropagate_round_trips(self) -> None: anno_lib = build_jit_library(parse(rendered)) orig_stripped, _ = strip_jit_library(orig_lib) anno_stripped, _ = strip_jit_library(anno_lib) - assert ( - orig_stripped.SerializeToString() - == anno_stripped.SerializeToString() - ) + assert orig_stripped.SerializeToString() == anno_stripped.SerializeToString() - def test_default_mode_repropagate_round_trips(self) -> None: + def test_expanded_noise_repropagate_round_trips(self) -> None: """Without ``--keep-noise``, an @REPROPAGATE compose still round-trips: noise is commented and ERROR rows are recomputed from the new propagation matrix.""" @@ -159,10 +153,7 @@ def test_default_mode_repropagate_round_trips(self) -> None: anno_lib = build_jit_library(parse(rendered)) orig_stripped, _ = strip_jit_library(orig_lib) anno_stripped, _ = strip_jit_library(anno_lib) - assert ( - orig_stripped.SerializeToString() - == anno_stripped.SerializeToString() - ) + assert orig_stripped.SerializeToString() == anno_stripped.SerializeToString() _NON_REPROPAGATE_NOISY_COMPOSE_SRC = """ @@ -203,10 +194,7 @@ def test_round_trips(self, keep_noise: bool) -> None: anno_lib = build_jit_library(parse(rendered)) orig_stripped, _ = strip_jit_library(orig_lib) anno_stripped, _ = strip_jit_library(anno_lib) - assert ( - orig_stripped.SerializeToString() - == anno_stripped.SerializeToString() - ) + assert orig_stripped.SerializeToString() == anno_stripped.SerializeToString() _PASSTHROUGH_LOSS_SRC = """ @@ -253,37 +241,40 @@ def test_round_trips(self, keep_noise: bool) -> None: class TestKeepNoisePassthroughLoss: - """Passthrough noise instructions (currently ``LOSS_ERROR``) must - survive ``deq annotate`` verbatim regardless of ``--keep-noise``. - - Regular noise like ``X_ERROR``/``DEPOLARIZE1`` can be commented out - under the default ``keep_noise=False`` mode because the annotator - also emits the equivalent ``ERROR(p) ...`` rows; re-transpilation - re-derives the same hypergraph from those ERROR rows. Passthrough - noise has **no** equivalent ERROR-row representation (it contributes - no detector edges), so commenting it out would silently delete the - loss-simulation behavior — the re-transpiled ``.stim`` would no - longer contain the ``LOSS_ERROR`` line. Hence the annotator must - keep passthrough noise verbatim in both modes. + """``LOSS_ERROR`` follows the noise preservation/expansion setting. + + With ``--keep-noise``, ``LOSS_ERROR`` remains verbatim. Without it, the raw + instruction is commented out and its inferred ``LOSS`` metadata is emitted. + The non-repropagated COMPOSE rendering path currently keeps ``LOSS_ERROR`` + verbatim for either flag value. """ @pytest.mark.parametrize("keep_noise", [False, True]) - def test_loss_error_kept_verbatim(self, keep_noise: bool) -> None: + def test_loss_error_rendering_follows_flag(self, keep_noise: bool) -> None: rendered = render_annotated(parse(_PASSTHROUGH_LOSS_SRC), keep_noise=keep_noise) - # Verbatim, uncommented in both modes. - assert "\n LOSS_ERROR(0.5) 0" in rendered - assert "\n LOSS_ERROR(0.3) 0" in rendered - # No accidental commented-out copies anywhere in the output. - for line in rendered.splitlines(): - stripped = line.lstrip() - assert not stripped.startswith("# LOSS_ERROR"), ( - f"LOSS_ERROR must never be commented out (line: {line!r})" - ) - - def test_default_still_comments_regular_noise(self) -> None: + if keep_noise: + # Preserve the original instructions. + assert "\n LOSS_ERROR(0.5) 0" in rendered + assert "\n LOSS_ERROR(0.3) 0" in rendered + for line in rendered.splitlines(): + assert not line.lstrip().startswith("# LOSS_ERROR"), ( + f"LOSS_ERROR must stay verbatim under --keep-noise " + f"(line: {line!r})" + ) + else: + # Expand the instructions into LOSS metadata. + assert "# LOSS_ERROR(0.5) 0" in rendered + assert "# LOSS_ERROR(0.3) 0" in rendered + for line in rendered.splitlines(): + assert not line.lstrip().startswith("LOSS_ERROR"), ( + f"LOSS_ERROR must be commented out when keep_noise=False " + f"(line: {line!r})" + ) + + def test_expansion_still_comments_regular_noise(self) -> None: """Sanity: keeping LOSS_ERROR verbatim must not flip the treatment of regular noise — ``X_ERROR`` is still commented - out under the default mode and an explicit ERROR row replaces + out when ``keep_noise=False`` and an explicit ERROR row replaces it.""" rendered = render_annotated(parse(_PASSTHROUGH_LOSS_SRC)) assert "# X_ERROR(0.05) 0" in rendered @@ -292,32 +283,30 @@ def test_default_still_comments_regular_noise(self) -> None: @pytest.mark.parametrize("keep_noise", [False, True]) def test_loss_error_round_trips_byte_equivalent(self, keep_noise: bool) -> None: """Annotate → re-parse → re-build JIT library is byte-equivalent - in both modes for passthrough noise. In ``keep_noise=True`` this - is the standard verbatim round-trip; in the default mode it - works because passthrough noise is kept verbatim *and* regular - noise's ERROR-row swap is also byte-equivalent (no detector-edge - contribution differs).""" + whether noise instructions are preserved or expanded.""" qfile = parse(_PASSTHROUGH_LOSS_SRC) rendered = render_annotated(qfile, keep_noise=keep_noise) orig_lib = build_jit_library(qfile) anno_lib = build_jit_library(parse(rendered)) orig_stripped, _ = strip_jit_library(orig_lib) anno_stripped, _ = strip_jit_library(anno_lib) - assert ( - orig_stripped.SerializeToString() - == anno_stripped.SerializeToString() - ) + assert orig_stripped.SerializeToString() == anno_stripped.SerializeToString() @pytest.mark.parametrize("keep_noise", [False, True]) def test_loss_error_kept_verbatim_inside_compose(self, keep_noise: bool) -> None: - """The COMPOSE body inlines into a synthetic GADGET block; the - same passthrough rule applies there.""" + """The COMPOSE body inlines into a synthetic GADGET block; the COMPOSE + path is out of scope for the single-gadget LOSS rewrite and still keeps + passthrough noise verbatim for either ``keep_noise`` value.""" rendered = render_annotated( parse(_PASSTHROUGH_LOSS_COMPOSE_SRC), keep_noise=keep_noise ) assert "GADGET Sequence {" in rendered - assert "\n LOSS_ERROR(0.4) 0" in rendered - for line in rendered.splitlines(): + # Scope the check to the composed Sequence block; the standalone + # GADGET Idle block follows the single-gadget expansion rule and is + # intentionally not covered here. + sequence_block = rendered.split("GADGET Sequence {", 1)[1] + assert "\n LOSS_ERROR(0.4) 0" in sequence_block + for line in sequence_block.splitlines(): stripped = line.lstrip() assert not stripped.startswith("# LOSS_ERROR"), ( f"LOSS_ERROR must never be commented out inside a COMPOSE " diff --git a/deq/tests/circuit/test_annotate_loss.py b/deq/tests/circuit/test_annotate_loss.py new file mode 100644 index 00000000..f7887c07 --- /dev/null +++ b/deq/tests/circuit/test_annotate_loss.py @@ -0,0 +1,80 @@ +"""Annotator tests for the ``LOSS`` block derived from the binary loss model.""" + +from deq.circuit.parser import parse +from deq.cli.strip_tags import strip_jit_library +from deq.transpiler.jit_annotate import annotate as render_annotated +from deq.transpiler.jit_library_builder import build_jit_library + +_FAITHFUL_LOSS_SRC = """ +CODE C[[3,1,1]] { + LOGICAL X0 Z0 + STABILIZER +} + +GADGET G { + INPUT C 0 1 2 + M 0 + M 1 + OUTPUT C 0 1 2 + ERROR(0) LX0 + ERROR(0) LZ0 + LOSS(0.1) SE0 CE1 L1 OUT0.L2 M1 + LOSS(0.2) SE1 M0 + LOSS(IN0.L1) CE0 L0 OUT0.L0 M0 +} +""" + + +def test_loss_block_is_emitted_with_labels() -> None: + rendered = render_annotated(parse(_FAITHFUL_LOSS_SRC)) + # Error index labels for SE/CE references. + assert "# E0" in rendered + assert "# E1" in rendered + # Source-loss labels for L references. + assert "# L0" in rendered + assert "# L1" in rendered + # The LOSS statements themselves. + assert "LOSS(0.1) SE0 CE1 L1 OUT0.L2 M1" in rendered + assert "LOSS(0.2) SE1 M0" in rendered + assert "LOSS(IN0.L1) CE0 L0 OUT0.L0 M0" in rendered + + +def test_faithful_loss_round_trips_byte_equivalent() -> None: + qfile = parse(_FAITHFUL_LOSS_SRC) + rendered = render_annotated(qfile, keep_noise=False) + orig, _ = strip_jit_library(build_jit_library(qfile)) + anno, _ = strip_jit_library(build_jit_library(parse(rendered))) + assert orig.SerializeToString() == anno.SerializeToString() + + +def test_keep_noise_omits_expanded_loss_block() -> None: + # Preserved noise reconstructs the loss model on re-transpilation, so the + # expanded LOSS block is not emitted. + rendered = render_annotated(parse(_FAITHFUL_LOSS_SRC), keep_noise=True) + assert "LOSS(0.1)" not in rendered + assert "# L0" not in rendered + + +def test_original_loss_statements_are_not_duplicated() -> None: + rendered = render_annotated(parse(_FAITHFUL_LOSS_SRC)) + # Exactly the two source losses and one input loss appear once each. + assert rendered.count("LOSS(0.1)") == 1 + assert rendered.count("LOSS(0.2)") == 1 + assert rendered.count("LOSS(IN0.L1)") == 1 + + +def test_loss_free_gadget_emits_no_loss_block() -> None: + rendered = render_annotated( + parse( + """ + CODE C[[1,1,1]] { LOGICAL X0 Z0 STABILIZER } + GADGET G { + INPUT C 0 + M 0 + OUTPUT C 0 + } + """ + ) + ) + assert "LOSS(" not in rendered + assert "# L0" not in rendered From dbb5ddf102ce41ec8e5c8cfbceb54a09e254d9b8 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 10 Aug 2026 13:50:08 -0700 Subject: [PATCH 075/157] runtime updates --- .../src/decoder/dyn_lib_decoder.rs | 20 +++-- deq/deq_runtime/src/decoder/python_decoder.rs | 75 +++++++++++++++- .../src/decoder/relay_bp_decoder.rs | 18 ++-- deq/deq_runtime/src/decoder/tesseract_ffi.rs | 10 +++ deq/deq_runtime/src/jit.rs | 1 + deq/deq_runtime/src/proto/deq.bin.rs | 86 +++++++++++++++++++ .../src/proto/deq.decoder.blackbox_decoder.rs | 21 ++++- deq/deq_runtime/src/proto/deq.jit.rs | 74 ---------------- deq/deq_runtime/tests/dyn_lib_decoder_test.rs | 1 + deq/deq_runtime/tests/mock_decoder_test.rs | 4 + 10 files changed, 221 insertions(+), 89 deletions(-) diff --git a/deq/deq_runtime/src/decoder/dyn_lib_decoder.rs b/deq/deq_runtime/src/decoder/dyn_lib_decoder.rs index d1cb0347..e5b37bad 100644 --- a/deq/deq_runtime/src/decoder/dyn_lib_decoder.rs +++ b/deq/deq_runtime/src/decoder/dyn_lib_decoder.rs @@ -66,6 +66,10 @@ fn get_or_load_library(path: &Path) -> &'static DecoderLibrary { pub struct DynLibInstance { loaded: LoadedDecoder, + /// Plugin edge index -> hypergraph hyperedge index. Zero-prior edges are left + /// out of the plugin (they carry infinite weight), so decoded indices are + /// mapped back through this before being returned. + active_edges: Vec, } impl DecoderInstance for DynLibInstance { @@ -73,12 +77,14 @@ impl DecoderInstance for DynLibInstance { let config: DynLibDecoderConfig = serde_json::from_value(config.clone()).expect("invalid DynLibDecoderConfig"); let library = get_or_load_library(&config.library); - // Flatten the hypergraph into CSR for the ABI. - let mut edge_probs = Vec::with_capacity(hypergraph.hyperedges.len()); - let mut edge_offsets = Vec::with_capacity(hypergraph.hyperedges.len() + 1); + // Flatten the usable (non-zero-prior) hyperedges into CSR for the ABI. + let active_edges = crate::decoder::blackbox_util::active_edge_indices(hypergraph); + let mut edge_probs = Vec::with_capacity(active_edges.len()); + let mut edge_offsets = Vec::with_capacity(active_edges.len() + 1); edge_offsets.push(0u64); let mut edge_vertices = Vec::new(); - for hyperedge in &hypergraph.hyperedges { + for &edge in &active_edges { + let hyperedge = &hypergraph.hyperedges[edge as usize]; edge_probs.push(hyperedge.probability); edge_vertices.extend_from_slice(&hyperedge.vertices); edge_offsets.push(edge_vertices.len() as u64); @@ -95,7 +101,7 @@ impl DecoderInstance for DynLibInstance { ) .unwrap_or_else(|e| panic!("plugin {} failed to build decoder: {e}", config.library.display())); - Self { loaded } + Self { loaded, active_edges } } fn decode(&mut self, syndrome: &BitVector) -> ParityFactor { @@ -103,7 +109,9 @@ impl DecoderInstance for DynLibInstance { // so it passes through with no conversion. let mut subgraph = Vec::new(); match self.loaded.decode(syndrome.size, &syndrome.data, &mut subgraph) { - Ok(()) => ParityFactor { subgraph }, + Ok(()) => ParityFactor { + subgraph: subgraph.into_iter().map(|index| self.active_edges[index as usize]).collect(), + }, // DecoderInstance::decode has no error channel; panic so ThreadPoolingDecoder's // catch_unwind turns it into a gRPC Status::internal, mirroring PythonDecoder. Err(e) => panic!("dylib decode failed: {e}"), diff --git a/deq/deq_runtime/src/decoder/python_decoder.rs b/deq/deq_runtime/src/decoder/python_decoder.rs index 77ac462d..488addd7 100644 --- a/deq/deq_runtime/src/decoder/python_decoder.rs +++ b/deq/deq_runtime/src/decoder/python_decoder.rs @@ -11,7 +11,7 @@ //! top-level `name` field in the decoder JSON config. //! -use crate::decoder::blackbox_decoder::{DecodingHypergraph, ParityFactor}; +use crate::decoder::blackbox_decoder::{DecodingHypergraph, LossInfo, ParityFactor}; use crate::decoder::thread_pooling::{DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder}; use crate::misc::bit_vector::to_sparse_indices; use crate::misc::python::{get_or_load_module, get_or_load_module_from_source, json_value_to_py}; @@ -40,13 +40,14 @@ mod builtin_decoders { "naive_decoder" => Some(("@naive_decoder", include_str!("naive_decoder.py"))), "relay_bp_decoder" => Some(("@relay_bp_decoder", include_str!("relay_bp_decoder.py"))), "tesseract_decoder" => Some(("@tesseract_decoder", include_str!("tesseract_decoder.py"))), + "mle_loss_decoder" => Some(("@mle_loss_decoder", include_str!("mle_loss_decoder.py"))), _ => None, } } /// All known builtin decoder names (without the leading `@`). pub fn names() -> &'static [&'static str] { - &["naive_decoder", "relay_bp_decoder", "tesseract_decoder"] + &["naive_decoder", "relay_bp_decoder", "tesseract_decoder", "mle_loss_decoder"] } } @@ -132,6 +133,45 @@ impl PyHyperedge { } } +/// One observed atom-loss site handed to a loss-aware Python decoder. Mirrors +/// [`blackbox_decoder::LossSite`](crate::decoder::blackbox_decoder::LossSite): +/// `source_edges` / `continuation_edges` index the hypergraph, `children` index +/// the [`PyLossInfo::sites`] list. +#[pyclass(name = "LossSite")] +#[derive(Debug)] +pub struct PyLossSite { + #[pyo3(get, set)] + pub source_edges: Vec, + #[pyo3(get, set)] + pub continuation_edges: Vec, + #[pyo3(get, set)] + pub children: Vec, + #[pyo3(get, set)] + pub probability: f64, +} + +#[pymethods] +impl PyLossSite { + fn __repr__(&self) -> PyResult { + Ok(format!("{:?}", self)) + } +} + +/// The shot's observed atom-loss sites handed to a loss-aware Python decoder. +/// Mirrors [`blackbox_decoder::LossInfo`](crate::decoder::blackbox_decoder::LossInfo). +#[pyclass(name = "LossInfo")] +pub struct PyLossInfo { + #[pyo3(get, set)] + pub sites: Py, // PyLossSite +} + +#[pymethods] +impl PyLossInfo { + fn __repr__(&self) -> PyResult { + Python::attach(|py| Ok(format!("LossInfo(sites=[...{}...])", self.sites.bind(py).len()))) + } +} + pub struct PythonDecoderInstance { decoder: Py, } @@ -177,6 +217,37 @@ impl DecoderInstance for PythonDecoderInstance { ParityFactor { subgraph } } + fn decode_with_loss(&mut self, syndrome: &BitVector, loss: Option<&LossInfo>) -> ParityFactor { + // No observed loss this shot -> use the ordinary single-argument call so + // loss-unaware Python decoders keep working unchanged. + let Some(loss) = loss.filter(|l| !l.sites.is_empty()) else { + return self.decode(syndrome); + }; + let subgraph = Python::attach(|py| { + let decoder = self.decoder.bind(py); + let py_syndrome = PyList::empty(py); + for index in to_sparse_indices(syndrome) { + py_syndrome.append(index)?; + } + let py_sites = PyList::empty(py); + for site in &loss.sites { + py_sites.append(PyLossSite { + source_edges: site.source_edges.clone(), + continuation_edges: site.continuation_edges.clone(), + children: site.children.clone(), + probability: site.probability, + })?; + } + let py_loss = PyLossInfo { + sites: py_sites.unbind(), + }; + let py_result = decoder.call_method1("decode", (py_syndrome, py_loss))?; + py_result.extract::>() + }) + .unwrap(); + ParityFactor { subgraph } + } + fn reset(&mut self) { Python::attach(|py| { let decoder = self.decoder.bind(py); diff --git a/deq/deq_runtime/src/decoder/relay_bp_decoder.rs b/deq/deq_runtime/src/decoder/relay_bp_decoder.rs index 9b5c9913..0f88c439 100644 --- a/deq/deq_runtime/src/decoder/relay_bp_decoder.rs +++ b/deq/deq_runtime/src/decoder/relay_bp_decoder.rs @@ -140,15 +140,21 @@ impl RelayBPDecoderDataType for f32 {} pub struct RelayBPDecoderInstance { solver: RelayDecoder, + /// Solver column -> hypergraph hyperedge index. Zero-prior edges are left out + /// of the solver (an infinite log-likelihood ratio would poison the messages), + /// so decoded columns are mapped back through this before being returned. + active_edges: Vec, } impl DecoderInstance for RelayBPDecoderInstance { fn new(hypergraph: &DecodingHypergraph, config: &serde_json::Value) -> Self { let config: RelayBPDecoderConfig = serde_json::from_value(config.clone()).unwrap(); - // build check matrix - let mut check_matrix = TriMat::new((hypergraph.vertex_num as usize, hypergraph.hyperedges.len())); - let mut error_priors = Vec::with_capacity(hypergraph.hyperedges.len()); - for (j, hyperedge) in hypergraph.hyperedges.iter().enumerate() { + // build check matrix over the usable (non-zero-prior) edges only + let active_edges = crate::decoder::blackbox_util::active_edge_indices(hypergraph); + let mut check_matrix = TriMat::new((hypergraph.vertex_num as usize, active_edges.len())); + let mut error_priors = Vec::with_capacity(active_edges.len()); + for (j, &edge) in active_edges.iter().enumerate() { + let hyperedge = &hypergraph.hyperedges[edge as usize]; for &i in hyperedge.vertices.iter() { check_matrix.add_triplet(i as usize, j, 1); } @@ -201,7 +207,7 @@ impl DecoderInstance for RelayBPDecoderInst Arc::new(min_sum_decoder_config), Arc::new(relay_decoder_config), ); - Self { solver } + Self { solver, active_edges } } fn decode(&mut self, syndrome: &BitVector) -> ParityFactor { @@ -214,7 +220,7 @@ impl DecoderInstance for RelayBPDecoderInst subgraph: decoding .iter() .enumerate() - .filter_map(|(i, &bit)| if bit == 1 { Some(i as u64) } else { None }) + .filter_map(|(i, &bit)| if bit == 1 { Some(self.active_edges[i]) } else { None }) .collect(), } } diff --git a/deq/deq_runtime/src/decoder/tesseract_ffi.rs b/deq/deq_runtime/src/decoder/tesseract_ffi.rs index d97a73f9..d3236184 100644 --- a/deq/deq_runtime/src/decoder/tesseract_ffi.rs +++ b/deq/deq_runtime/src/decoder/tesseract_ffi.rs @@ -24,6 +24,8 @@ mod ffi { ) -> Result>; fn decode_to_errors(handle: Pin<&mut TesseractDecoderHandle>, detections: &[u64]) -> Vec; + + fn update_error_costs(handle: Pin<&mut TesseractDecoderHandle>, edge_probabilities: &[f64]); } } @@ -83,4 +85,12 @@ impl TesseractCxxDecoder { pub fn decode(&mut self, detections: &[u64]) -> Vec { ffi::decode_to_errors(self.inner.pin_mut(), detections) } + + /// Replace every edge cost in place, keeping the loaded structure. + /// + /// `edge_probabilities` must be in the same order and of the same length as + /// the vector this decoder was built from; only the values may differ. + pub fn update_error_costs(&mut self, edge_probabilities: &[f64]) { + ffi::update_error_costs(self.inner.pin_mut(), edge_probabilities); + } } diff --git a/deq/deq_runtime/src/jit.rs b/deq/deq_runtime/src/jit.rs index 7cb9c276..43a4e5aa 100644 --- a/deq/deq_runtime/src/jit.rs +++ b/deq/deq_runtime/src/jit.rs @@ -1,6 +1,7 @@ include!("proto/deq.jit.rs"); pub mod jit_compiler; +pub mod loss_compiler; use crate::bin; use tokio_util::sync::CancellationToken; diff --git a/deq/deq_runtime/src/proto/deq.bin.rs b/deq/deq_runtime/src/proto/deq.bin.rs index 0d58d383..4d204159 100644 --- a/deq/deq_runtime/src/proto/deq.bin.rs +++ b/deq/deq_runtime/src/proto/deq.bin.rs @@ -95,6 +95,8 @@ pub struct GadgetType { /// When set explicitly, the user-provided value is used. #[prost(bool, optional, tag = "14")] pub is_free_hop: ::core::option::Option, + #[prost(message, optional, tag = "16")] + pub loss_model: ::core::option::Option, } /// Nested message and enum types in `GadgetType`. pub mod gadget_type { @@ -135,6 +137,90 @@ pub mod gadget_type { #[prost(message, optional, tag = "3")] pub relative: ::core::option::Option, } + /// Static atom-loss template for this gadget type: the declared loss sites in + /// its body, their local heralds and Pauli-envelope generators, within-gadget + /// continuation links, and the physical qubits on which a loss leaves or + /// enters the gadget. Propagation is captured inline until it reaches another + /// declared loss site. Left unset for loss-free gadget types. + /// + /// Generators are referenced by index into `ErrorModelType.errors` of the + /// FIRST error model attached to this gadget (`attaching_eid_vec\[0\]`, i.e. the + /// first one created against its check model). That is the error model the + /// gadget type was compiled with: the JIT compiler emits exactly one bin error + /// per `deq.jit.JitGadgetType.errors` entry, in order, so the two index + /// domains coincide. + /// + /// Attaching further error models is allowed and is the normal way to add + /// extra noise; the loss model simply never references them, and they take + /// part in decoding as ordinary edges. The only requirement is ordering — the + /// compiled error model must be attached first, or the generator indices + /// address the wrong error list. + #[derive(Clone, PartialEq, ::prost::Message)] + pub struct LossModel { + #[prost(message, repeated, tag = "1")] + pub losses: ::prost::alloc::vec::Vec, + /// One entry per input physical qubit (in concatenated input-port order); + /// `input_losses\[j\]` describes a loss entering on input qubit `j`. Qubits + /// with no continuation keep a default, empty slot. + #[prost(message, repeated, tag = "2")] + pub input_losses: ::prost::alloc::vec::Vec, + } + /// Nested message and enum types in `LossModel`. + pub mod loss_model { + #[derive(Clone, PartialEq, ::prost::Message)] + pub struct Loss { + /// Probability from the LOSS_ERROR instruction that creates this loss + #[prost(double, tag = "1")] + pub probability: f64, + /// Generators caused by an already-active loss, as error indices, + /// inherited from all descendants. A loss-only generator carries + /// probability 0 in its `errors` entry until a loss activates it. + #[prost(uint64, repeated, tag = "2")] + pub continuation_errors: ::prost::alloc::vec::Vec, + /// Generators that apply only when the loss starts here (never inherited), + /// as error indices. + #[prost(uint64, repeated, tag = "3")] + pub source_errors: ::prost::alloc::vec::Vec, + /// Within-gadget continuation links, as indices into this gadget's + /// `losses`, recorded when propagation reaches another declared loss site. + /// Children have greater indices so a single forward pass builds the DAG. + #[prost(uint64, repeated, tag = "4")] + pub child_losses: ::prost::alloc::vec::Vec, + /// Physical-qubit positions on the concatenated OUTPUT ports on which this + /// loss leaves the gadget, each in `[0, sum of output-port n)`. The + /// cross-gadget analog of `child_losses`, linked to the connected gadget's + /// `input_losses` at runtime. + #[prost(uint64, repeated, tag = "5")] + pub child_output_qubits: ::prost::alloc::vec::Vec, + /// Local physical measurements that directly herald this loss node. The + /// runtime folds herald evidence forward along the child links: a loss + /// site is kept when some herald in its forward reach fired as a loss and + /// none fired as a non-loss. + #[prost(uint64, repeated, tag = "6")] + pub loss_measurements: ::prost::alloc::vec::Vec, + } + /// Continuation template for a loss that ENTERS this gadget on an input + /// physical qubit. It carries no probability (fixed by the parent loss that + /// exits into it) and may fan out to within-gadget nodes and output qubits. + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] + pub struct InputLoss { + /// Generators activated at the entry, before any within-gadget fan-out, as + /// error indices. + #[prost(uint64, repeated, tag = "1")] + pub continuation_errors: ::prost::alloc::vec::Vec, + /// Within-gadget loss nodes this entering loss propagates into, as indices + /// into `losses` (the analog of `Loss.child_losses`). + #[prost(uint64, repeated, tag = "2")] + pub child_losses: ::prost::alloc::vec::Vec, + /// Physical-qubit positions on the concatenated OUTPUT ports on which the + /// entering loss leaves the gadget, each in `[0, sum of output-port n)`. + #[prost(uint64, repeated, tag = "3")] + pub child_output_qubits: ::prost::alloc::vec::Vec, + /// Local physical measurements that directly herald the entering loss. + #[prost(uint64, repeated, tag = "4")] + pub loss_measurements: ::prost::alloc::vec::Vec, + } + } } #[derive(Clone, PartialEq, ::prost::Message)] pub struct PortType { diff --git a/deq/deq_runtime/src/proto/deq.decoder.blackbox_decoder.rs b/deq/deq_runtime/src/proto/deq.decoder.blackbox_decoder.rs index fe873323..52adf73a 100644 --- a/deq/deq_runtime/src/proto/deq.decoder.blackbox_decoder.rs +++ b/deq/deq_runtime/src/proto/deq.decoder.blackbox_decoder.rs @@ -25,12 +25,31 @@ pub struct LoadHypergraphResponse { #[prost(uint64, tag = "1")] pub hid: u64, } -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct LoadedDecodingProblem { #[prost(uint64, tag = "1")] pub hid: u64, #[prost(message, optional, tag = "2")] pub syndrome: ::core::option::Option, + /// Optional per-shot prior overrides, applied to this decode only. The loaded + /// hypergraph is left unchanged, so a subsequent decode on the same `hid` sees + /// the priors it was loaded with. + #[prost(message, repeated, tag = "3")] + pub reweights: ::prost::alloc::vec::Vec, + /// Optional loss-aware extension. Present only when atom losses are observed + /// for this shot; absent otherwise. + #[prost(message, optional, tag = "4")] + pub loss: ::core::option::Option, +} +/// Replaces the prior of one hyperedge, addressed by its index in the loaded +/// hypergraph. The probability is *assigned*, not combined: it may raise or lower +/// the loaded prior. +#[derive(Clone, Copy, PartialEq, ::prost::Message)] +pub struct EdgeReweight { + #[prost(uint64, tag = "1")] + pub edge: u64, + #[prost(double, tag = "2")] + pub probability: f64, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct ParityFactor { diff --git a/deq/deq_runtime/src/proto/deq.jit.rs b/deq/deq_runtime/src/proto/deq.jit.rs index 388cd8f7..ee9df856 100644 --- a/deq/deq_runtime/src/proto/deq.jit.rs +++ b/deq/deq_runtime/src/proto/deq.jit.rs @@ -63,8 +63,6 @@ pub struct JitGadgetType { pub unfinished_checks: ::prost::alloc::vec::Vec, #[prost(message, repeated, tag = "4")] pub errors: ::prost::alloc::vec::Vec, - #[prost(message, optional, tag = "5")] - pub loss_model: ::core::option::Option, } /// Nested message and enum types in `JitGadgetType`. pub mod jit_gadget_type { @@ -125,78 +123,6 @@ pub mod jit_gadget_type { #[prost(uint64, repeated, tag = "3")] pub unfinished_checks: ::prost::alloc::vec::Vec, } - /// Static loss template for this gadget type: the declared loss sites in its - /// body, their local heralds and Pauli generators, within-gadget continuation - /// links, and the physical qubits on which a loss leaves or enters the gadget. - /// Propagation is captured inline until it reaches another declared loss site. - #[derive(Clone, PartialEq, ::prost::Message)] - pub struct LossModel { - #[prost(message, repeated, tag = "1")] - pub losses: ::prost::alloc::vec::Vec, - /// One entry per input physical qubit (in concatenated input-port order); - /// `input_losses\[j\]` describes a loss entering on input qubit `j`. Qubits - /// with no continuation keep a default, empty slot. - #[prost(message, repeated, tag = "2")] - pub input_losses: ::prost::alloc::vec::Vec, - } - /// Nested message and enum types in `LossModel`. - pub mod loss_model { - /// Each loss activates Pauli-envelope generators by index into - /// `JitGadgetType.errors`; a loss-only generator carries probability 0 in - /// its `errors` entry until a loss activates it. - #[derive(Clone, PartialEq, ::prost::Message)] - pub struct Loss { - /// Probability from the LOSS_ERROR instruction that creates this loss - #[prost(double, tag = "1")] - pub probability: f64, - /// Generators caused by an already-active loss, as indices into - /// `JitGadgetType.errors`, inherited from all descendants. - #[prost(uint64, repeated, tag = "2")] - pub continuation_errors: ::prost::alloc::vec::Vec, - /// Generators that apply only when the loss starts here (never inherited), - /// as indices into `JitGadgetType.errors`. - #[prost(uint64, repeated, tag = "3")] - pub source_errors: ::prost::alloc::vec::Vec, - /// Within-gadget continuation links, as indices into this gadget's - /// `losses`, recorded when propagation reaches another declared loss site. - /// Children have greater indices so a single forward pass builds the DAG. - #[prost(uint64, repeated, tag = "4")] - pub child_losses: ::prost::alloc::vec::Vec, - /// Physical-qubit positions on the concatenated OUTPUT ports on which this - /// loss leaves the gadget, each in `[0, sum of output-port n)`. The - /// cross-gadget analog of `child_losses`, linked to the connected gadget's - /// `input_losses` at runtime. - #[prost(uint64, repeated, tag = "5")] - pub child_output_qubits: ::prost::alloc::vec::Vec, - /// Local physical measurements that directly herald this loss node. The - /// runtime folds herald evidence forward along the child links: a loss - /// site is kept when some herald in its forward reach fired as a loss and - /// none fired as a non-loss. - #[prost(uint64, repeated, tag = "6")] - pub loss_measurements: ::prost::alloc::vec::Vec, - } - /// Continuation template for a loss that ENTERS this gadget on an input - /// physical qubit. It carries no probability (fixed by the parent loss that - /// exits into it) and may fan out to within-gadget nodes and output qubits. - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] - pub struct InputLoss { - /// Generators activated at the entry, before any within-gadget fan-out, as - /// indices into `JitGadgetType.errors`. - #[prost(uint64, repeated, tag = "1")] - pub continuation_errors: ::prost::alloc::vec::Vec, - /// Within-gadget loss nodes this entering loss propagates into, as indices - /// into `losses` (the analog of `Loss.child_losses`). - #[prost(uint64, repeated, tag = "2")] - pub child_losses: ::prost::alloc::vec::Vec, - /// Physical-qubit positions on the concatenated OUTPUT ports on which the - /// entering loss leaves the gadget, each in `[0, sum of output-port n)`. - #[prost(uint64, repeated, tag = "3")] - pub child_output_qubits: ::prost::alloc::vec::Vec, - /// Local physical measurements that directly herald the entering loss. - #[prost(uint64, repeated, tag = "4")] - pub loss_measurements: ::prost::alloc::vec::Vec, - } - } } #[derive(Clone, PartialEq, ::prost::Message)] pub struct JitInstruction { diff --git a/deq/deq_runtime/tests/dyn_lib_decoder_test.rs b/deq/deq_runtime/tests/dyn_lib_decoder_test.rs index 5b214a0d..0081bc4a 100644 --- a/deq/deq_runtime/tests/dyn_lib_decoder_test.rs +++ b/deq/deq_runtime/tests/dyn_lib_decoder_test.rs @@ -83,6 +83,7 @@ async fn load_and_decode_through_grpc_surface() { Request::new(blackbox_decoder::LoadedDecodingProblem { hid, syndrome: Some(syndrome(3, &set_vertices)), + ..Default::default() }), ) .await diff --git a/deq/deq_runtime/tests/mock_decoder_test.rs b/deq/deq_runtime/tests/mock_decoder_test.rs index 4b6a5ee6..4302033d 100644 --- a/deq/deq_runtime/tests/mock_decoder_test.rs +++ b/deq/deq_runtime/tests/mock_decoder_test.rs @@ -26,6 +26,7 @@ async fn test_mock_decoder_records_decode_calls() { Request::new(blackbox_decoder::DecodingProblem { hypergraph: Some(hypergraph.clone()), syndrome: Some(syndrome.clone()), + ..Default::default() }), ) .await @@ -94,6 +95,7 @@ async fn test_mock_decoder_decode_loaded() { Request::new(blackbox_decoder::LoadedDecodingProblem { hid, syndrome: Some(syndrome), + ..Default::default() }), ) .await @@ -127,6 +129,7 @@ async fn test_mock_decoder_custom_response() { Request::new(blackbox_decoder::DecodingProblem { hypergraph: Some(hypergraph), syndrome: Some(syndrome), + ..Default::default() }), ) .await @@ -179,6 +182,7 @@ async fn test_mock_decoder_decode_loaded_not_found() { Request::new(blackbox_decoder::LoadedDecodingProblem { hid: 999, syndrome: Some(syndrome), + ..Default::default() }), ) .await; From 470fece3b190d0626d4e3bbcfc9080f6c96660f1 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 10 Aug 2026 15:40:33 -0700 Subject: [PATCH 076/157] check in small test updates --- deq/deq/cli/jit.py | 5 +++++ deq/deq_runtime/src/decoder/test_harness.rs | 2 ++ deq/deq_runtime/src/misc/index.rs | 2 +- deq/deq_runtime/tests/common/test_library.rs | 1 + deq/deq_runtime/tests/jit_compiler_test.rs | 3 +++ deq/deq_runtime/tests/jit_controller_test.rs | 1 + 6 files changed, 13 insertions(+), 1 deletion(-) diff --git a/deq/deq/cli/jit.py b/deq/deq/cli/jit.py index ceab110d..8eba191e 100644 --- a/deq/deq/cli/jit.py +++ b/deq/deq/cli/jit.py @@ -766,6 +766,10 @@ def compile_program_for_jit( next_synthetic_gtype = ( max((gt.base.gtype for gt in jit_library.gadget_types), default=0) + 1 ) + library_has_loss = any( + gadget_type.base.HasField("loss_model") + for gadget_type in jit_library.gadget_types + ) # Pre-expand sub-program calls and REPEAT blocks. body: list[object] = list(program_def.body) @@ -906,6 +910,7 @@ def compile_program_for_jit( identity_gtype_of_ptype=identity_gtype_of_ptype, next_synthetic_gtype=next_synthetic_gtype, gid=gid, + include_loss_model=library_has_loss, ) ) if new_identity_gt is not None: diff --git a/deq/deq_runtime/src/decoder/test_harness.rs b/deq/deq_runtime/src/decoder/test_harness.rs index ebba9669..1a22c1f0 100644 --- a/deq/deq_runtime/src/decoder/test_harness.rs +++ b/deq/deq_runtime/src/decoder/test_harness.rs @@ -161,6 +161,7 @@ async fn run_decode_path(client: &mut BlackBoxDecoderClient, problem: &StandardT let problem_payload = blackbox_decoder::DecodingProblem { hypergraph: Some(problem.hypergraph.clone()), syndrome: Some(case.syndrome.clone()), + loss: None, }; let outcome = match client.decode(problem_payload).await { Ok(response) => classify(&problem.hypergraph, &case.syndrome, &response), @@ -183,6 +184,7 @@ async fn run_decode_loaded_path( let problem_payload = blackbox_decoder::LoadedDecodingProblem { hid, syndrome: Some(case.syndrome.clone()), + ..Default::default() }; let outcome = match client.decode_loaded(problem_payload).await { Ok(response) => classify(&problem.hypergraph, &case.syndrome, &response), diff --git a/deq/deq_runtime/src/misc/index.rs b/deq/deq_runtime/src/misc/index.rs index 1ae06ca4..7513d977 100644 --- a/deq/deq_runtime/src/misc/index.rs +++ b/deq/deq_runtime/src/misc/index.rs @@ -1,6 +1,6 @@ pub const WILDCARD: u64 = 0; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ErrorIndex { pub eid: u64, pub error_index: u64, diff --git a/deq/deq_runtime/tests/common/test_library.rs b/deq/deq_runtime/tests/common/test_library.rs index bb7c1c36..1db3709c 100644 --- a/deq/deq_runtime/tests/common/test_library.rs +++ b/deq/deq_runtime/tests/common/test_library.rs @@ -20,6 +20,7 @@ pub fn test_jit_library() -> jit::JitLibrary { }), stabilizers: vec![jit::jit_port_type::Stabilizer::default(); 2], k: 1, + ..Default::default() }], gadget_types: vec![ // gtype 1 (opcode 0): prepare_z — no inputs, 1 output diff --git a/deq/deq_runtime/tests/jit_compiler_test.rs b/deq/deq_runtime/tests/jit_compiler_test.rs index 4c3a9f68..b7bbc28d 100644 --- a/deq/deq_runtime/tests/jit_compiler_test.rs +++ b/deq/deq_runtime/tests/jit_compiler_test.rs @@ -21,6 +21,7 @@ fn basic_jit_library() -> jit::JitLibrary { }), stabilizers: vec![jit::jit_port_type::Stabilizer::default(); 2], k: 1, + ..Default::default() }], gadget_types: vec![ // Gadget type 1: prepare_z @@ -464,6 +465,7 @@ fn check_propagation_jit_library() -> jit::JitLibrary { }), stabilizers: vec![jit::jit_port_type::Stabilizer::default(); 1], k: 1, + ..Default::default() }], gadget_types: vec![ // Gadget type 1: prepare @@ -1009,6 +1011,7 @@ fn rep_code_jit_library() -> jit::JitLibrary { }), stabilizers: vec![jit::jit_port_type::Stabilizer::default(); 2], k: 0, + ..Default::default() }], gadget_types: vec![ // Gadget type 1: prepare_z diff --git a/deq/deq_runtime/tests/jit_controller_test.rs b/deq/deq_runtime/tests/jit_controller_test.rs index f5d05c86..9bd10770 100644 --- a/deq/deq_runtime/tests/jit_controller_test.rs +++ b/deq/deq_runtime/tests/jit_controller_test.rs @@ -26,6 +26,7 @@ fn basic_jit_library() -> jit::JitLibrary { }), stabilizers: vec![jit::jit_port_type::Stabilizer::default(); 2], k: 1, + ..Default::default() }], gadget_types: vec![ // Gadget type 1: prepare_z (no inputs, one output) From 88d517b91a4561f665f636f0b061efe5c6a5b01a Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 10 Aug 2026 19:56:17 -0700 Subject: [PATCH 077/157] update tutorial chapters --- .../chapters/codes-redundant-stabilizers.md | 14 +- .../tutorial/chapters/compose-gadgets.md | 324 +++++++++++------- .../tutorial/chapters/debug-deq-program.md | 27 +- .../tutorial/chapters/floquet-code.md | 3 +- .../tutorial/chapters/multi-port-gadgets.md | 3 +- .../tutorial/chapters/steane-style-ec.md | 12 +- deq/tests/circuit/test_annotate_keep_noise.py | 314 ----------------- 7 files changed, 240 insertions(+), 457 deletions(-) delete mode 100644 deq/tests/circuit/test_annotate_keep_noise.py diff --git a/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md b/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md index 5bf1d228..edc77c5b 100644 --- a/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md +++ b/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md @@ -63,14 +63,17 @@ The annotated output for the Idle gadget reveals the problem: @CHECKS("manual", verify=0) GADGET Idle { INPUT RepetitionCode 0 2 4 - # X_ERROR(0.01) 0 2 4 + @SIMULATE_ONLY + X_ERROR(0.01) 0 2 4 ERROR(0.01) C0 OUT0.LX0 # E0 ERROR(0.01) C0 C1 OUT0.LX0 # E1 ERROR(0.01) C1 OUT0.LX0 # E2 R 1 3 5 CX 0 1 2 3 4 5 CX 2 1 4 3 0 5 - # M(0.01) 1 3 5 + @SIMULATE_ONLY + M(0.01) 1 3 5 + @DECODE_ONLY M 1 3 5 ERROR(0.01) C0 C2 C3 # E3 ERROR(0.01) C1 C2 C4 # E4 @@ -131,14 +134,17 @@ The annotated Idle gadget: @CHECKS("manual", verify=0) GADGET Idle { INPUT RepetitionCode 0 2 4 - # X_ERROR(0.01) 0 2 4 + @SIMULATE_ONLY + X_ERROR(0.01) 0 2 4 ERROR(0.01) C0 C2 OUT0.LX0 # E0 ERROR(0.01) C0 C1 OUT0.LX0 # E1 ERROR(0.01) C1 C2 OUT0.LX0 # E2 R 1 3 5 CX 0 1 2 3 4 5 CX 2 1 4 3 0 5 - # M(0.01) 1 3 5 + @SIMULATE_ONLY + M(0.01) 1 3 5 + @DECODE_ONLY M 1 3 5 ERROR(0.01) C0 C3 # E3 ERROR(0.01) C1 C4 # E4 diff --git a/deq/documents/tutorial/chapters/compose-gadgets.md b/deq/documents/tutorial/chapters/compose-gadgets.md index 293adf47..11b3b9dc 100644 --- a/deq/documents/tutorial/chapters/compose-gadgets.md +++ b/deq/documents/tutorial/chapters/compose-gadgets.md @@ -84,7 +84,8 @@ The circuit is physically identical to running the Idle gadget 3 times. Running @CHECKS("manual", verify=0) GADGET PrepareZ { R 0 1 2 - # X_ERROR(0.01) 0 1 2 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 ERROR(0.01) C0 OUT0.LX0 # E0 ERROR(0.01) C0 C1 OUT0.LX0 # E1 ERROR(0.01) C1 OUT0.LX0 # E2 @@ -106,36 +107,42 @@ The circuit is physically identical to running the Idle gadget 3 times. Running @CHECKS("manual", verify=0) GADGET Flat3Idle { INPUT RepetitionCode 0 2 4 - # X_ERROR(0.01) 0 2 4 + @SIMULATE_ONLY + X_ERROR(0.01) 0 2 4 ERROR(0.01) C0 OUT0.LX0 # E0 ERROR(0.01) C0 C1 OUT0.LX0 # E1 ERROR(0.01) C1 OUT0.LX0 # E2 R 1 3 CX 0 1 2 3 CX 2 1 4 3 - # X_ERROR(0.01) 1 3 + @SIMULATE_ONLY + X_ERROR(0.01) 1 3 ERROR(0.01) C2 # E3 ERROR(0.01) C3 # E4 M 1 3 - # X_ERROR(0.01) 0 2 4 + @SIMULATE_ONLY + X_ERROR(0.01) 0 2 4 ERROR(0.01) C0 C2 OUT0.LX0 # E5 ERROR(0.01) C0 C1 C2 C3 OUT0.LX0 # E6 ERROR(0.01) C1 C3 OUT0.LX0 # E7 R 1 3 CX 0 1 2 3 CX 2 1 4 3 - # X_ERROR(0.01) 1 3 + @SIMULATE_ONLY + X_ERROR(0.01) 1 3 ERROR(0.01) C4 # E8 ERROR(0.01) C5 # E9 M 1 3 - # X_ERROR(0.01) 0 2 4 + @SIMULATE_ONLY + X_ERROR(0.01) 0 2 4 ERROR(0.01) C0 C2 C4 OUT0.LX0 # E10 ERROR(0.01) C0 C1 C2 C3 C4 C5 OUT0.LX0 # E11 ERROR(0.01) C1 C3 C5 OUT0.LX0 # E12 R 1 3 CX 0 1 2 3 CX 2 1 4 3 - # X_ERROR(0.01) 1 3 + @SIMULATE_ONLY + X_ERROR(0.01) 1 3 ERROR(0.01) C0 C2 C4 C6 # E13 ERROR(0.01) C1 C3 C5 C7 # E14 M 1 3 @@ -164,7 +171,9 @@ The circuit is physically identical to running the Idle gadget 3 times. Running @CHECKS("manual", verify=0) GADGET MeasureZ { INPUT RepetitionCode 0 1 2 - # M(0.01) 0 1 2 + @SIMULATE_ONLY + M(0.01) 0 1 2 + @DECODE_ONLY M 0 1 2 ERROR(0.01) C0 R0 # E0 ERROR(0.01) C0 C1 R0 # E1 @@ -317,7 +326,8 @@ Running `annotate` on the COMPOSE version produces a flattened `GADGET` block: @CHECKS("manual", verify=0) GADGET PrepareZ { R 0 1 2 - # X_ERROR(0.01) 0 1 2 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 ERROR(0.01) C0 OUT0.LX0 # E0 ERROR(0.01) C0 C1 OUT0.LX0 # E1 ERROR(0.01) C1 OUT0.LX0 # E2 @@ -339,14 +349,16 @@ Running `annotate` on the COMPOSE version produces a flattened `GADGET` block: @CHECKS("manual", verify=0) GADGET Idle { INPUT RepetitionCode 0 2 4 - # X_ERROR(0.01) 0 2 4 + @SIMULATE_ONLY + X_ERROR(0.01) 0 2 4 ERROR(0.01) C0 OUT0.LX0 # E0 ERROR(0.01) C0 C1 OUT0.LX0 # E1 ERROR(0.01) C1 OUT0.LX0 # E2 R 1 3 CX 0 1 2 3 CX 2 1 4 3 - # X_ERROR(0.01) 1 3 + @SIMULATE_ONLY + X_ERROR(0.01) 1 3 ERROR(0.01) C0 C2 # E3 ERROR(0.01) C1 C3 # E4 M 1 3 @@ -371,7 +383,9 @@ Running `annotate` on the COMPOSE version produces a flattened `GADGET` block: @CHECKS("manual", verify=0) GADGET MeasureZ { INPUT RepetitionCode 0 1 2 - # M(0.01) 0 1 2 + @SIMULATE_ONLY + M(0.01) 0 1 2 + @DECODE_ONLY M 0 1 2 ERROR(0.01) C0 R0 # E0 ERROR(0.01) C0 C1 R0 # E1 @@ -392,23 +406,44 @@ Running `annotate` on the COMPOSE version produces a flattened `GADGET` block: @CHECKS("manual", verify=0) GADGET Idle3 { INPUT RepetitionCode 0 1 2 - # X_ERROR(0.01) 0 1 2 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 + ERROR(0.01) C0 OUT0.LX0 # E0 + ERROR(0.01) C0 C1 OUT0.LX0 # E1 + ERROR(0.01) C1 OUT0.LX0 # E2 R 3 4 CX 0 3 1 4 CX 1 3 2 4 - # X_ERROR(0.01) 3 4 + @SIMULATE_ONLY + X_ERROR(0.01) 3 4 + ERROR(0.01) C0 C2 # E3 + ERROR(0.01) C1 C3 # E4 M 3 4 - # X_ERROR(0.01) 0 1 2 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 + ERROR(0.01) C2 OUT0.LX0 # E5 + ERROR(0.01) C2 C3 OUT0.LX0 # E6 + ERROR(0.01) C3 OUT0.LX0 # E7 R 3 4 CX 0 3 1 4 CX 1 3 2 4 - # X_ERROR(0.01) 3 4 + @SIMULATE_ONLY + X_ERROR(0.01) 3 4 + ERROR(0.01) C2 C4 # E8 + ERROR(0.01) C3 C5 # E9 M 3 4 - # X_ERROR(0.01) 0 1 2 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 + ERROR(0.01) C4 OUT0.LX0 # E10 + ERROR(0.01) C4 C5 OUT0.LX0 # E11 + ERROR(0.01) C5 OUT0.LX0 # E12 R 3 4 CX 0 3 1 4 CX 1 3 2 4 - # X_ERROR(0.01) 3 4 + @SIMULATE_ONLY + X_ERROR(0.01) 3 4 + ERROR(0.01) C4 C6 # E13 + ERROR(0.01) C5 C7 # E14 M 3 4 OUTPUT RepetitionCode 0 1 2 CHECK IN0.S0 M0 @@ -421,21 +456,6 @@ Running `annotate` on the COMPOSE version produces a flattened `GADGET` block: CHECK M5 OUT0.S1 PROPAGATE OUT0.LZ0 FROM IN0.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 - ERROR(0.01) C0 C2 - ERROR(0.01) C1 C3 - ERROR(0.01) C2 OUT0.LX0 - ERROR(0.01) C2 C3 OUT0.LX0 - ERROR(0.01) C3 OUT0.LX0 - ERROR(0.01) C2 C4 - ERROR(0.01) C3 C5 - ERROR(0.01) C4 OUT0.LX0 - ERROR(0.01) C4 C5 OUT0.LX0 - ERROR(0.01) C5 OUT0.LX0 - ERROR(0.01) C4 C6 - ERROR(0.01) C5 C7 # --- statistics --- # finished checks: 6 @@ -462,23 +482,44 @@ The composed `Idle3` gadget appears as: @CHECKS("manual", verify=0) GADGET Idle3 { INPUT RepetitionCode 0 1 2 - # X_ERROR(0.01) 0 1 2 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 + ERROR(0.01) C0 OUT0.LX0 # E0 + ERROR(0.01) C0 C1 OUT0.LX0 # E1 + ERROR(0.01) C1 OUT0.LX0 # E2 R 3 4 CX 0 3 1 4 CX 1 3 2 4 - # X_ERROR(0.01) 3 4 + @SIMULATE_ONLY + X_ERROR(0.01) 3 4 + ERROR(0.01) C0 C2 # E3 + ERROR(0.01) C1 C3 # E4 M 3 4 - # X_ERROR(0.01) 0 1 2 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 + ERROR(0.01) C2 OUT0.LX0 # E5 + ERROR(0.01) C2 C3 OUT0.LX0 # E6 + ERROR(0.01) C3 OUT0.LX0 # E7 R 3 4 CX 0 3 1 4 CX 1 3 2 4 - # X_ERROR(0.01) 3 4 + @SIMULATE_ONLY + X_ERROR(0.01) 3 4 + ERROR(0.01) C2 C4 # E8 + ERROR(0.01) C3 C5 # E9 M 3 4 - # X_ERROR(0.01) 0 1 2 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 + ERROR(0.01) C4 OUT0.LX0 # E10 + ERROR(0.01) C4 C5 OUT0.LX0 # E11 + ERROR(0.01) C5 OUT0.LX0 # E12 R 3 4 CX 0 3 1 4 CX 1 3 2 4 - # X_ERROR(0.01) 3 4 + @SIMULATE_ONLY + X_ERROR(0.01) 3 4 + ERROR(0.01) C4 C6 # E13 + ERROR(0.01) C5 C7 # E14 M 3 4 OUTPUT RepetitionCode 0 1 2 CHECK IN0.S0 M0 @@ -491,21 +532,6 @@ The composed `Idle3` gadget appears as: CHECK M5 OUT0.S1 PROPAGATE OUT0.LZ0 FROM IN0.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 - ERROR(0.01) C0 C2 - ERROR(0.01) C1 C3 - ERROR(0.01) C2 OUT0.LX0 - ERROR(0.01) C2 C3 OUT0.LX0 - ERROR(0.01) C3 OUT0.LX0 - ERROR(0.01) C2 C4 - ERROR(0.01) C3 C5 - ERROR(0.01) C4 OUT0.LX0 - ERROR(0.01) C4 C5 OUT0.LX0 - ERROR(0.01) C5 OUT0.LX0 - ERROR(0.01) C4 C6 - ERROR(0.01) C5 C7 # --- statistics --- # finished checks: 6 @@ -675,7 +701,8 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl @CHECKS("manual", verify=0) GADGET PrepareZ { R 0 1 2 - # X_ERROR(0.01) 0 1 2 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 ERROR(0.01) C0 OUT0.LX0 # E0 ERROR(0.01) C0 C1 OUT0.LX0 # E1 ERROR(0.01) C1 OUT0.LX0 # E2 @@ -697,14 +724,17 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl @CHECKS("manual", verify=0) GADGET Idle { INPUT RepetitionCode 0 2 4 - # X_ERROR(0.01) 0 2 4 + @SIMULATE_ONLY + X_ERROR(0.01) 0 2 4 ERROR(0.01) C0 OUT0.LX0 # E0 ERROR(0.01) C0 C1 OUT0.LX0 # E1 ERROR(0.01) C1 OUT0.LX0 # E2 R 1 3 CX 0 1 2 3 CX 2 1 4 3 - # M(0.01) 1 3 + @SIMULATE_ONLY + M(0.01) 1 3 + @DECODE_ONLY M 1 3 ERROR(0.01) C0 C2 # E3 ERROR(0.01) C1 C3 # E4 @@ -729,7 +759,9 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl @CHECKS("manual", verify=0) GADGET MeasureZ { INPUT RepetitionCode 0 1 2 - # M(0.01) 0 1 2 + @SIMULATE_ONLY + M(0.01) 0 1 2 + @DECODE_ONLY M 0 1 2 ERROR(0.01) C0 R0 # E0 ERROR(0.01) C0 C1 R0 # E1 @@ -750,21 +782,48 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl @CHECKS("manual", verify=0) GADGET Idle3 { INPUT RepetitionCode 0 1 2 - # X_ERROR(0.01) 0 1 2 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 + ERROR(0.01) C0 OUT0.LX0 # E0 + ERROR(0.01) C0 C1 OUT0.LX0 # E1 + ERROR(0.01) C1 OUT0.LX0 # E2 R 3 4 CX 0 3 1 4 CX 1 3 2 4 + @SIMULATE_ONLY + M(0.01) 3 4 + @DECODE_ONLY M 3 4 - # X_ERROR(0.01) 0 1 2 + ERROR(0.01) C0 C2 # E3 + ERROR(0.01) C1 C3 # E4 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 + ERROR(0.01) C2 OUT0.LX0 # E5 + ERROR(0.01) C2 C3 OUT0.LX0 # E6 + ERROR(0.01) C3 OUT0.LX0 # E7 R 3 4 CX 0 3 1 4 CX 1 3 2 4 + @SIMULATE_ONLY + M(0.01) 3 4 + @DECODE_ONLY M 3 4 - # X_ERROR(0.01) 0 1 2 + ERROR(0.01) C2 C4 # E8 + ERROR(0.01) C3 C5 # E9 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 + ERROR(0.01) C4 OUT0.LX0 # E10 + ERROR(0.01) C4 C5 OUT0.LX0 # E11 + ERROR(0.01) C5 OUT0.LX0 # E12 R 3 4 CX 0 3 1 4 CX 1 3 2 4 + @SIMULATE_ONLY + M(0.01) 3 4 + @DECODE_ONLY M 3 4 + ERROR(0.01) C4 C6 # E13 + ERROR(0.01) C5 C7 # E14 OUTPUT RepetitionCode 0 1 2 CHECK IN0.S0 M0 CHECK IN0.S1 M1 @@ -776,21 +835,6 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl CHECK M5 OUT0.S1 PROPAGATE OUT0.LZ0 FROM IN0.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 - ERROR(0.01) C0 C2 - ERROR(0.01) C1 C3 - ERROR(0.01) C2 OUT0.LX0 - ERROR(0.01) C2 C3 OUT0.LX0 - ERROR(0.01) C3 OUT0.LX0 - ERROR(0.01) C2 C4 - ERROR(0.01) C3 C5 - ERROR(0.01) C4 OUT0.LX0 - ERROR(0.01) C4 C5 OUT0.LX0 - ERROR(0.01) C5 OUT0.LX0 - ERROR(0.01) C4 C6 - ERROR(0.01) C5 C7 # --- statistics --- # finished checks: 6 @@ -805,26 +849,62 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl @CHECKS("manual", verify=0) GADGET Idle4 { INPUT RepetitionCode 0 1 2 - # X_ERROR(0.01) 0 1 2 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 + ERROR(0.01) C0 OUT0.LX0 # E0 + ERROR(0.01) C0 C1 OUT0.LX0 # E1 + ERROR(0.01) C1 OUT0.LX0 # E2 R 3 4 CX 0 3 1 4 CX 1 3 2 4 + @SIMULATE_ONLY + M(0.01) 3 4 + @DECODE_ONLY M 3 4 - # X_ERROR(0.01) 0 1 2 + ERROR(0.01) C0 C2 # E3 + ERROR(0.01) C1 C3 # E4 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 + ERROR(0.01) C2 OUT0.LX0 # E5 + ERROR(0.01) C2 C3 OUT0.LX0 # E6 + ERROR(0.01) C3 OUT0.LX0 # E7 R 3 4 CX 0 3 1 4 CX 1 3 2 4 + @SIMULATE_ONLY + M(0.01) 3 4 + @DECODE_ONLY M 3 4 - # X_ERROR(0.01) 0 1 2 + ERROR(0.01) C2 C4 # E8 + ERROR(0.01) C3 C5 # E9 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 + ERROR(0.01) C4 OUT0.LX0 # E10 + ERROR(0.01) C4 C5 OUT0.LX0 # E11 + ERROR(0.01) C5 OUT0.LX0 # E12 R 3 4 CX 0 3 1 4 CX 1 3 2 4 + @SIMULATE_ONLY + M(0.01) 3 4 + @DECODE_ONLY M 3 4 - # X_ERROR(0.01) 0 1 2 + ERROR(0.01) C4 C6 # E13 + ERROR(0.01) C5 C7 # E14 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 + ERROR(0.01) C6 OUT0.LX0 # E15 + ERROR(0.01) C6 C7 OUT0.LX0 # E16 + ERROR(0.01) C7 OUT0.LX0 # E17 R 3 4 CX 0 3 1 4 CX 1 3 2 4 + @SIMULATE_ONLY + M(0.01) 3 4 + @DECODE_ONLY M 3 4 + ERROR(0.01) C6 C8 # E18 + ERROR(0.01) C7 C9 # E19 OUTPUT RepetitionCode 0 1 2 CHECK IN0.S0 M0 CHECK IN0.S1 M1 @@ -838,26 +918,6 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl CHECK M7 OUT0.S1 PROPAGATE OUT0.LZ0 FROM IN0.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 - ERROR(0.01) C0 C2 - ERROR(0.01) C1 C3 - ERROR(0.01) C2 OUT0.LX0 - ERROR(0.01) C2 C3 OUT0.LX0 - ERROR(0.01) C3 OUT0.LX0 - ERROR(0.01) C2 C4 - ERROR(0.01) C3 C5 - ERROR(0.01) C4 OUT0.LX0 - ERROR(0.01) C4 C5 OUT0.LX0 - ERROR(0.01) C5 OUT0.LX0 - ERROR(0.01) C4 C6 - ERROR(0.01) C5 C7 - ERROR(0.01) C6 OUT0.LX0 - ERROR(0.01) C6 C7 OUT0.LX0 - ERROR(0.01) C7 OUT0.LX0 - ERROR(0.01) C6 C8 - ERROR(0.01) C7 C9 # --- statistics --- # finished checks: 8 @@ -882,26 +942,62 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl @CHECKS("manual", verify=0) GADGET Idle4 { INPUT RepetitionCode 0 1 2 - # X_ERROR(0.01) 0 1 2 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 + ERROR(0.01) C0 OUT0.LX0 # E0 + ERROR(0.01) C0 C1 OUT0.LX0 # E1 + ERROR(0.01) C1 OUT0.LX0 # E2 R 3 4 CX 0 3 1 4 CX 1 3 2 4 + @SIMULATE_ONLY + M(0.01) 3 4 + @DECODE_ONLY M 3 4 - # X_ERROR(0.01) 0 1 2 + ERROR(0.01) C0 C2 # E3 + ERROR(0.01) C1 C3 # E4 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 + ERROR(0.01) C2 OUT0.LX0 # E5 + ERROR(0.01) C2 C3 OUT0.LX0 # E6 + ERROR(0.01) C3 OUT0.LX0 # E7 R 3 4 CX 0 3 1 4 CX 1 3 2 4 + @SIMULATE_ONLY + M(0.01) 3 4 + @DECODE_ONLY M 3 4 - # X_ERROR(0.01) 0 1 2 + ERROR(0.01) C2 C4 # E8 + ERROR(0.01) C3 C5 # E9 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 + ERROR(0.01) C4 OUT0.LX0 # E10 + ERROR(0.01) C4 C5 OUT0.LX0 # E11 + ERROR(0.01) C5 OUT0.LX0 # E12 R 3 4 CX 0 3 1 4 CX 1 3 2 4 + @SIMULATE_ONLY + M(0.01) 3 4 + @DECODE_ONLY M 3 4 - # X_ERROR(0.01) 0 1 2 + ERROR(0.01) C4 C6 # E13 + ERROR(0.01) C5 C7 # E14 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 + ERROR(0.01) C6 OUT0.LX0 # E15 + ERROR(0.01) C6 C7 OUT0.LX0 # E16 + ERROR(0.01) C7 OUT0.LX0 # E17 R 3 4 CX 0 3 1 4 CX 1 3 2 4 + @SIMULATE_ONLY + M(0.01) 3 4 + @DECODE_ONLY M 3 4 + ERROR(0.01) C6 C8 # E18 + ERROR(0.01) C7 C9 # E19 OUTPUT RepetitionCode 0 1 2 CHECK IN0.S0 M0 CHECK IN0.S1 M1 @@ -915,26 +1011,6 @@ The annotated output for `Idle4` shows 8 measurements (6 from Idle3 + 2 from Idl CHECK M7 OUT0.S1 PROPAGATE OUT0.LZ0 FROM IN0.LZ0 PROPAGATE OUT0.LX0 FROM IN0.LX0 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 - ERROR(0.01) C0 C2 - ERROR(0.01) C1 C3 - ERROR(0.01) C2 OUT0.LX0 - ERROR(0.01) C2 C3 OUT0.LX0 - ERROR(0.01) C3 OUT0.LX0 - ERROR(0.01) C2 C4 - ERROR(0.01) C3 C5 - ERROR(0.01) C4 OUT0.LX0 - ERROR(0.01) C4 C5 OUT0.LX0 - ERROR(0.01) C5 OUT0.LX0 - ERROR(0.01) C4 C6 - ERROR(0.01) C5 C7 - ERROR(0.01) C6 OUT0.LX0 - ERROR(0.01) C6 C7 OUT0.LX0 - ERROR(0.01) C7 OUT0.LX0 - ERROR(0.01) C6 C8 - ERROR(0.01) C7 C9 # --- statistics --- # finished checks: 8 diff --git a/deq/documents/tutorial/chapters/debug-deq-program.md b/deq/documents/tutorial/chapters/debug-deq-program.md index c01a2557..531020a9 100644 --- a/deq/documents/tutorial/chapters/debug-deq-program.md +++ b/deq/documents/tutorial/chapters/debug-deq-program.md @@ -37,7 +37,8 @@ Output: @CHECKS("manual", verify=0) GADGET PrepareZ { R 0 1 2 - # X_ERROR(0.01) 0 1 2 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 ERROR(0.01) C0 OUT0.LX0 # E0 ERROR(0.01) C0 C1 OUT0.LX0 # E1 ERROR(0.01) C1 OUT0.LX0 # E2 @@ -59,14 +60,17 @@ Output: @CHECKS("manual", verify=0) GADGET Idle { INPUT RepetitionCode 0 2 4 - # X_ERROR(0.01) 0 2 4 + @SIMULATE_ONLY + X_ERROR(0.01) 0 2 4 ERROR(0.01) C0 OUT0.LX0 # E0 ERROR(0.01) C0 C1 OUT0.LX0 # E1 ERROR(0.01) C1 OUT0.LX0 # E2 R 1 3 CX 0 1 2 3 CX 2 1 4 3 - # M(0.01) 1 3 + @SIMULATE_ONLY + M(0.01) 1 3 + @DECODE_ONLY M 1 3 ERROR(0.01) C0 C2 # E3 ERROR(0.01) C1 C3 # E4 @@ -91,7 +95,9 @@ Output: @CHECKS("manual", verify=0) GADGET MeasureZ { INPUT RepetitionCode 0 1 2 - # M(0.01) 0 1 2 + @SIMULATE_ONLY + M(0.01) 0 1 2 + @DECODE_ONLY M 0 1 2 ERROR(0.01) C0 R0 # E0 ERROR(0.01) C0 C1 R0 # E1 @@ -118,11 +124,14 @@ Output: ### What to look for -**Noise instructions are commented out** — because the annotator expands each noise -channel into concrete `ERROR(p) C... LX...` statements with explicit check triggers -and logical residuals. Keeping the original noise instructions would cause duplicate -errors during re-transpilation. The annotated file is guaranteed to produce exactly the -same `.deq.jit` as the original (up to tag differences). +**Physical noise and decoder metadata are separated** — for undecorated noise, the +original instruction is retained under `@SIMULATE_ONLY`, while its canonical +`ERROR(p) C... LX...` row carries the explicit check triggers and logical residuals used +for decoding. A decode-visible noisy measurement keeps a clean `@DECODE_ONLY` +counterpart with its probability removed. Existing `@SIMULATE_ONLY` or `@DECODE_ONLY` +intent is preserved; measurement instructions must be paired so the two views produce +the same number of records. The annotated file therefore remains simulatable and is +guaranteed to produce exactly the same `.deq.jit` as the original (up to tag differences). **Auto-derived checks** — the transpiler-derived `CHECK` statements. In the Idle gadget: - `CHECK M0 IN0.S0` — finished check (before `OUTPUT`): physical measurement 0 diff --git a/deq/documents/tutorial/chapters/floquet-code.md b/deq/documents/tutorial/chapters/floquet-code.md index 360f6c84..7e023011 100644 --- a/deq/documents/tutorial/chapters/floquet-code.md +++ b/deq/documents/tutorial/chapters/floquet-code.md @@ -355,7 +355,8 @@ The result for `RoundRed` is: @CHECKS("manual", verify=0) GADGET RoundRed { INPUT HoneycombB 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 - # DEPOLARIZE1(0) 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 + @SIMULATE_ONLY + DEPOLARIZE1(0) 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 MPP(0) X0*X1 X2*X3 X4*X5 X6*X7 X8*X9 X10*X11 X12*X13 X14*X15 X16*X17 CHECK M6 M3 M0 IN0.S12 IN0.S6 IN0.S3 IN0.S0 FLIP CHECK M7 M4 M1 IN0.S13 IN0.S7 IN0.S4 IN0.S1 FLIP diff --git a/deq/documents/tutorial/chapters/multi-port-gadgets.md b/deq/documents/tutorial/chapters/multi-port-gadgets.md index 39bf19ad..46c7f47b 100644 --- a/deq/documents/tutorial/chapters/multi-port-gadgets.md +++ b/deq/documents/tutorial/chapters/multi-port-gadgets.md @@ -157,7 +157,8 @@ With noise, the error structure reveals the CNOT's impact on decoding: GADGET TransversalCNOT { INPUT RepetitionCode 0 1 2 INPUT RepetitionCode 3 4 5 - # X_ERROR(0.01) 0 1 2 3 4 5 + @SIMULATE_ONLY + X_ERROR(0.01) 0 1 2 3 4 5 ERROR(0.01) C0 C2 OUT0.LX0 OUT1.LX0 # E0 ERROR(0.01) C0 C1 C2 C3 OUT0.LX0 OUT1.LX0 # E1 ERROR(0.01) C1 C3 OUT0.LX0 OUT1.LX0 # E2 diff --git a/deq/documents/tutorial/chapters/steane-style-ec.md b/deq/documents/tutorial/chapters/steane-style-ec.md index f2d74c43..9ecddf67 100644 --- a/deq/documents/tutorial/chapters/steane-style-ec.md +++ b/deq/documents/tutorial/chapters/steane-style-ec.md @@ -102,17 +102,21 @@ Running `deq annotate` on this gadget reveals the check structure: CZ rec[-3] 8 rec[-3] 9 rec[-3] 11 CZ rec[-2] 8 rec[-2] 9 rec[-2] 11 rec[-2] 12 CZ rec[-1] 8 rec[-1] 9 - # DEPOLARIZE1(0) 7 8 9 10 11 12 13 + @SIMULATE_ONLY + DEPOLARIZE1(0) 7 8 9 10 11 12 13 RX 14 15 16 17 18 19 20 MPP Z17*Z18*Z19*Z20 Z15*Z16*Z19*Z20 Z14*Z16*Z18*Z20 CX rec[-3] 17 CX rec[-2] 15 rec[-2] 16 rec[-2] 17 rec[-2] 20 CX rec[-1] 15 rec[-1] 16 - # DEPOLARIZE1(0) 14 15 16 17 18 19 20 + @SIMULATE_ONLY + DEPOLARIZE1(0) 14 15 16 17 18 19 20 CX 0 7 1 8 2 9 3 10 4 11 5 12 6 13 - # DEPOLARIZE2(0) 0 7 1 8 2 9 3 10 4 11 5 12 6 13 + @SIMULATE_ONLY + DEPOLARIZE2(0) 0 7 1 8 2 9 3 10 4 11 5 12 6 13 CX 14 7 15 8 16 9 17 10 18 11 19 12 20 13 - # DEPOLARIZE2(0) 14 7 15 8 16 9 17 10 18 11 19 12 20 13 + @SIMULATE_ONLY + DEPOLARIZE2(0) 14 7 15 8 16 9 17 10 18 11 19 12 20 13 MX(0) 0 1 2 3 4 5 6 MZ(0) 7 8 9 10 11 12 13 CHECK M19 M18 M17 M16 IN0.S0 diff --git a/deq/tests/circuit/test_annotate_keep_noise.py b/deq/tests/circuit/test_annotate_keep_noise.py deleted file mode 100644 index 5e21d018..00000000 --- a/deq/tests/circuit/test_annotate_keep_noise.py +++ /dev/null @@ -1,314 +0,0 @@ -"""Tests for the ``--keep-noise`` flag of ``deq annotate``. - -When the flag is on, noise instructions (``X_ERROR``, ``DEPOLARIZE1/2``, -noisy measurements, etc.) are emitted verbatim in the annotated output -and the corresponding ``ERROR(p) ...`` rows are *not* emitted. -Re-transpilation re-derives the same ERROR rows from the kept noise -instructions, so the JIT library round-trips byte-equivalently. -""" - -import pytest - -from deq.cli.strip_tags import strip_jit_library -from deq.circuit.parser import parse -from deq.transpiler.jit_annotate import annotate as render_annotated -from deq.transpiler.jit_library_builder import build_jit_library - -_NOISY_GADGET_SRC = """ -CODE C[[1,1,1]] { - LOGICAL X0 Z0 - STABILIZER -} - -GADGET Prep { - R 0 - X_ERROR(0.05) 0 - OUTPUT C 0 -} - -GADGET Idle { - INPUT C 0 - DEPOLARIZE1(0.01) 0 - OUTPUT C 0 -} - -GADGET Meas { - INPUT C 0 - M(0.02) 0 - READOUT M0 -} -""" - - -class TestKeepNoiseGadget: - """``--keep-noise`` keeps noise verbatim and skips noise-origin ERRORs.""" - - def test_without_keep_noise_comments_noise_and_emits_errors(self) -> None: - rendered = render_annotated(parse(_NOISY_GADGET_SRC)) - assert "# X_ERROR(0.05) 0" in rendered - assert "# DEPOLARIZE1(0.01) 0" in rendered - # Expanding noise emits explicit ERROR(p) rows. - assert "ERROR(0.05)" in rendered - assert "# E0" in rendered - - def test_keep_noise_keeps_verbatim_and_no_explicit_errors(self) -> None: - rendered = render_annotated(parse(_NOISY_GADGET_SRC), keep_noise=True) - # Noise instructions appear uncommented. - assert "\n X_ERROR(0.05) 0" in rendered - assert "\n DEPOLARIZE1(0.01) 0" in rendered - # The noisy measurement keeps its probability argument. - assert "M(0.02) 0" in rendered - # No standalone ``ERROR(p) ...`` lines are emitted (substring - # matches like ``X_ERROR(0.05)`` are excluded by checking that - # no line *starts* with ``ERROR(`` after stripping indent). - for line in rendered.splitlines(): - stripped = line.lstrip() - assert not stripped.startswith( - "ERROR(" - ), f"unexpected explicit ERROR line under --keep-noise: {line!r}" - - def test_keep_noise_round_trips_byte_equivalent(self) -> None: - qfile = parse(_NOISY_GADGET_SRC) - rendered = render_annotated(qfile, keep_noise=True) - orig_lib = build_jit_library(qfile) - anno_lib = build_jit_library(parse(rendered)) - orig_stripped, _ = strip_jit_library(orig_lib) - anno_stripped, _ = strip_jit_library(anno_lib) - assert orig_stripped.SerializeToString() == anno_stripped.SerializeToString() - - -_REPROPAGATE_TELEPORT_SRC = """ -CODE Code[[4,1,2]] { - LOGICAL X0*X2 Z0*Z1 - STABILIZER Z0*Z2 Z1*Z3 X0*X1*X2*X3 -} - -GADGET PrepareZero { - R 0 1 2 3 - X_ERROR(0.05) 0 1 2 3 - MPP X0*X1*X2*X3 - OUTPUT Code 0 1 2 3 -} - -GADGET CNOT { - INPUT Code 0 1 2 3 - INPUT Code 4 5 6 7 - CX 0 4 1 5 2 6 3 7 - DEPOLARIZE2(0.01) 0 4 1 5 2 6 3 7 - OUTPUT Code 0 1 2 3 - OUTPUT Code 4 5 6 7 -} - -GADGET MeasureX { - INPUT Code 0 1 2 3 - MX(0.02) 0 1 2 3 - READOUT M0 M2 -} - -@REPROPAGATE -COMPOSE Teleport { - INPUT Code 0 - PrepareZero 1 - CNOT 0 1 - MeasureX 0 - OUTPUT Code 1 -} -""" - - -class TestKeepNoiseRepropagateCompose: - """``--keep-noise`` combined with ``@REPROPAGATE`` on the user's - teleportation example.""" - - def test_keep_noise_repropagate_round_trips(self) -> None: - qfile = parse(_REPROPAGATE_TELEPORT_SRC) - rendered = render_annotated(qfile, keep_noise=True) - # The composed gadget is rendered as a flat GADGET block. - assert "GADGET Teleport {" in rendered - # Noise instructions are present verbatim. - assert "X_ERROR(0.05)" in rendered - assert "DEPOLARIZE2(0.01)" in rendered - assert "MX(0.02)" in rendered - # Re-transpile and compare. - orig_lib = build_jit_library(qfile) - anno_lib = build_jit_library(parse(rendered)) - orig_stripped, _ = strip_jit_library(orig_lib) - anno_stripped, _ = strip_jit_library(anno_lib) - assert orig_stripped.SerializeToString() == anno_stripped.SerializeToString() - - def test_expanded_noise_repropagate_round_trips(self) -> None: - """Without ``--keep-noise``, an @REPROPAGATE compose still - round-trips: noise is commented and ERROR rows are recomputed - from the new propagation matrix.""" - qfile = parse(_REPROPAGATE_TELEPORT_SRC) - rendered = render_annotated(qfile, keep_noise=False) - assert "GADGET Teleport {" in rendered - # Noise commented out. - assert "# X_ERROR(0.05)" in rendered - assert "# DEPOLARIZE2(0.01)" in rendered - # ERROR rows are present (recomputed from flat circuit). - assert "ERROR(" in rendered - # Round-trip verification. - orig_lib = build_jit_library(qfile) - anno_lib = build_jit_library(parse(rendered)) - orig_stripped, _ = strip_jit_library(orig_lib) - anno_stripped, _ = strip_jit_library(anno_lib) - assert orig_stripped.SerializeToString() == anno_stripped.SerializeToString() - - -_NON_REPROPAGATE_NOISY_COMPOSE_SRC = """ -CODE C[[1,1,1]] { - LOGICAL X0 Z0 - STABILIZER -} - -GADGET Idle { - INPUT C 0 - DEPOLARIZE1(0.01) 0 - OUTPUT C 0 -} - -COMPOSE Sequence { - INPUT C 0 - Idle 0 - OUTPUT C 0 -} -""" - - -class TestKeepNoiseNonRepropagateCompose: - """``--keep-noise`` is orthogonal to ``@REPROPAGATE``. - - A COMPOSE whose merge-derived propagation is already consistent - with the flat-circuit flow (i.e. no teleportation-style conditional - correction) round-trips byte-equivalently with or without - ``--keep-noise`` — even though the COMPOSE has noise instructions - and lacks ``@REPROPAGATE``. - """ - - @pytest.mark.parametrize("keep_noise", [False, True]) - def test_round_trips(self, keep_noise: bool) -> None: - qfile = parse(_NON_REPROPAGATE_NOISY_COMPOSE_SRC) - rendered = render_annotated(qfile, keep_noise=keep_noise) - orig_lib = build_jit_library(qfile) - anno_lib = build_jit_library(parse(rendered)) - orig_stripped, _ = strip_jit_library(orig_lib) - anno_stripped, _ = strip_jit_library(anno_lib) - assert orig_stripped.SerializeToString() == anno_stripped.SerializeToString() - - -_PASSTHROUGH_LOSS_SRC = """ -CODE C[[1,1,1]] { - LOGICAL X0 Z0 - STABILIZER -} - -GADGET Prep { - R 0 - X_ERROR(0.05) 0 - LOSS_ERROR(0.5) 0 - OUTPUT C 0 -} - -GADGET Meas { - INPUT C 0 - LOSS_ERROR(0.3) 0 - M 0 - READOUT M0 -} -""" - - -_PASSTHROUGH_LOSS_COMPOSE_SRC = """ -CODE C[[1,1,1]] { - LOGICAL X0 Z0 - STABILIZER -} - -GADGET Idle { - INPUT C 0 - DEPOLARIZE1(0.01) 0 - LOSS_ERROR(0.4) 0 - OUTPUT C 0 -} - -COMPOSE Sequence { - INPUT C 0 - Idle 0 - OUTPUT C 0 -} -""" - - -class TestKeepNoisePassthroughLoss: - """``LOSS_ERROR`` follows the noise preservation/expansion setting. - - With ``--keep-noise``, ``LOSS_ERROR`` remains verbatim. Without it, the raw - instruction is commented out and its inferred ``LOSS`` metadata is emitted. - The non-repropagated COMPOSE rendering path currently keeps ``LOSS_ERROR`` - verbatim for either flag value. - """ - - @pytest.mark.parametrize("keep_noise", [False, True]) - def test_loss_error_rendering_follows_flag(self, keep_noise: bool) -> None: - rendered = render_annotated(parse(_PASSTHROUGH_LOSS_SRC), keep_noise=keep_noise) - if keep_noise: - # Preserve the original instructions. - assert "\n LOSS_ERROR(0.5) 0" in rendered - assert "\n LOSS_ERROR(0.3) 0" in rendered - for line in rendered.splitlines(): - assert not line.lstrip().startswith("# LOSS_ERROR"), ( - f"LOSS_ERROR must stay verbatim under --keep-noise " - f"(line: {line!r})" - ) - else: - # Expand the instructions into LOSS metadata. - assert "# LOSS_ERROR(0.5) 0" in rendered - assert "# LOSS_ERROR(0.3) 0" in rendered - for line in rendered.splitlines(): - assert not line.lstrip().startswith("LOSS_ERROR"), ( - f"LOSS_ERROR must be commented out when keep_noise=False " - f"(line: {line!r})" - ) - - def test_expansion_still_comments_regular_noise(self) -> None: - """Sanity: keeping LOSS_ERROR verbatim must not flip the - treatment of regular noise — ``X_ERROR`` is still commented - out when ``keep_noise=False`` and an explicit ERROR row replaces - it.""" - rendered = render_annotated(parse(_PASSTHROUGH_LOSS_SRC)) - assert "# X_ERROR(0.05) 0" in rendered - assert "ERROR(0.05)" in rendered - - @pytest.mark.parametrize("keep_noise", [False, True]) - def test_loss_error_round_trips_byte_equivalent(self, keep_noise: bool) -> None: - """Annotate → re-parse → re-build JIT library is byte-equivalent - whether noise instructions are preserved or expanded.""" - qfile = parse(_PASSTHROUGH_LOSS_SRC) - rendered = render_annotated(qfile, keep_noise=keep_noise) - orig_lib = build_jit_library(qfile) - anno_lib = build_jit_library(parse(rendered)) - orig_stripped, _ = strip_jit_library(orig_lib) - anno_stripped, _ = strip_jit_library(anno_lib) - assert orig_stripped.SerializeToString() == anno_stripped.SerializeToString() - - @pytest.mark.parametrize("keep_noise", [False, True]) - def test_loss_error_kept_verbatim_inside_compose(self, keep_noise: bool) -> None: - """The COMPOSE body inlines into a synthetic GADGET block; the COMPOSE - path is out of scope for the single-gadget LOSS rewrite and still keeps - passthrough noise verbatim for either ``keep_noise`` value.""" - rendered = render_annotated( - parse(_PASSTHROUGH_LOSS_COMPOSE_SRC), keep_noise=keep_noise - ) - assert "GADGET Sequence {" in rendered - # Scope the check to the composed Sequence block; the standalone - # GADGET Idle block follows the single-gadget expansion rule and is - # intentionally not covered here. - sequence_block = rendered.split("GADGET Sequence {", 1)[1] - assert "\n LOSS_ERROR(0.4) 0" in sequence_block - for line in sequence_block.splitlines(): - stripped = line.lstrip() - assert not stripped.startswith("# LOSS_ERROR"), ( - f"LOSS_ERROR must never be commented out inside a COMPOSE " - f"(line: {line!r})" - ) From 899a0a857ef921c7c5cc5320883eab8d92bcf751 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 10 Aug 2026 20:47:32 -0700 Subject: [PATCH 078/157] support DECODE_ONLY and SIMULATE_ONLY in annotate tool --- deq/deq/cli/annotate.py | 25 +- deq/deq/cli/jit.py | 4 +- deq/deq/transpiler/jit_annotate.py | 467 ++++++++++--------- deq/deq/transpiler/jit_library_builder.py | 64 ++- deq/tests/circuit/fixtures/teleportation.deq | 3 +- deq/tests/spec/canonical_test.py | 76 +++ 6 files changed, 381 insertions(+), 258 deletions(-) diff --git a/deq/deq/cli/annotate.py b/deq/deq/cli/annotate.py index e019e704..e76429b6 100644 --- a/deq/deq/cli/annotate.py +++ b/deq/deq/cli/annotate.py @@ -25,16 +25,13 @@ def annotate( skip_mako_warning: bool = False, #: skip verification that annotated output transpiles identically no_verify: bool = False, - #: keep noise instructions verbatim instead of commenting them - #: out and emitting expanded ERROR rows - keep_noise: bool = False, ) -> None: """ Rewrite a .deq file to mirror the structure of its compiled .deq.jit. Inlines imports, replaces stabilizers/logicals with `_` identity - placeholders (originals kept as comments), comments out noise instructions, - forces every gadget to + placeholders (originals kept as comments), separates physical noise from + decoder metadata, and forces every gadget to @CHECKS("manual", verify=0), and inserts auto-derived CHECKs (marked `# auto`). COMPOSE/PROGRAM blocks are emitted commented-out for reference. @@ -47,13 +44,15 @@ def annotate( error probabilities are reproducible. Use ``--no-verify`` to skip this (faster but no correctness guarantee). - With ``--keep-noise``, noise instructions (``X_ERROR``, - ``DEPOLARIZE1/2``, noisy measurements, etc.) are emitted verbatim - in the annotated output and the corresponding ``ERROR(p) ...`` - rows are *not* emitted. Re-transpilation of the annotated file - re-derives those ERROR rows from the kept noise instructions. - Use this setting when the annotated file must retain its original - noise instructions, including for Stim sampling. + Undecorated noise instructions (``X_ERROR``, ``DEPOLARIZE1/2``, + ``LOSS_ERROR``, noisy measurements, etc.) are split by visibility: the + original noisy instruction is retained under ``@SIMULATE_ONLY`` for Stim + sampling, while canonical ``ERROR(p) ...`` rows and loss metadata carry + its decode-side effect. A decode-visible noisy measurement additionally + keeps a clean ``@DECODE_ONLY`` measurement instruction. Existing + ``@SIMULATE_ONLY`` and ``@DECODE_ONLY`` intent is preserved; measurement + instructions must still be paired so both views produce the same number + of records. Args: deq_file: path to the input .deq file. @@ -76,7 +75,7 @@ def annotate( skip_mako_warning=skip_mako_warning, ) - rendered = _annotate_impl(qfile, keep_noise=keep_noise) + rendered = _annotate_impl(qfile) # Determine output path. if out is None: diff --git a/deq/deq/cli/jit.py b/deq/deq/cli/jit.py index 8eba191e..28318320 100644 --- a/deq/deq/cli/jit.py +++ b/deq/deq/cli/jit.py @@ -1062,9 +1062,7 @@ def compile_program_for_jit( toggle_set ^= {pos} if toggle_set: - toggle_matrix = bitmatrix_from_sparse( - toggle_set, rows=n_out, cols=n_in + 1 - ) + toggle_matrix = bitmatrix_from_sparse(toggle_set, rows=n_out, cols=n_in + 1) instr.gadget.modifier.correction_propagation_mod.toggle.CopyFrom( toggle_matrix ) diff --git a/deq/deq/transpiler/jit_annotate.py b/deq/deq/transpiler/jit_annotate.py index b49bccd9..ff345d79 100644 --- a/deq/deq/transpiler/jit_annotate.py +++ b/deq/deq/transpiler/jit_annotate.py @@ -17,15 +17,18 @@ - ``REPEAT`` blocks are unrolled (matching the ``.deq.jit`` view of the gadget); - circuit and measurement instructions are kept verbatim; - - noise instructions are commented out and replaced in place by derived - ``ERROR`` rows; - - user ``ERROR`` statements remain at their source positions; + - physical noise instructions are marked ``@SIMULATE_ONLY``; + - noisy measurements receive clean ``@DECODE_ONLY`` counterparts; + - declared and ordinary noise-derived ``ERROR`` rows are emitted beside + their source statements in canonical index order; + - user ``ERROR`` statement spelling is retained; - user-written ``CHECK`` and ``READOUT`` statements are emitted verbatim; - auto-derived ``CHECK`` statements that extend the user-provided ones are inserted right after the latest measurement they depend on; - - newly inferred loss-induced ``ERROR`` rows are appended after the body. + - inferred loss-only rows are emitted at their constituent boundary and + the authoritative ``LOSS`` graph is emitted explicitly. - ``COMPOSE`` definitions are emitted as comments. - ``PROGRAM`` definitions are emitted verbatim. """ @@ -82,27 +85,23 @@ from deq.transpiler.stim_constants import ( NOISE_INSTRUCTIONS_ALL, NOISY_MEASUREMENT_INSTRUCTIONS, - PASSTHROUGH_NOISE_INSTRUCTIONS, instruction_num_measurements, ) -def annotate(qfile: DeqFile, *, keep_noise: bool = False) -> str: +def annotate(qfile: DeqFile) -> str: """Render ``qfile`` as annotated ``.deq`` source mirroring its JIT form. Parameters ---------- qfile: The parsed ``.deq`` file to annotate. - keep_noise: - When ``True``, noise instructions (and noise on noisy - measurements) are emitted verbatim into the annotated output - and the ``ERROR(p) ...`` rows derived from those noise - instructions are *not* emitted. Re-transpilation of the - annotated file re-derives the same ERRORs from the kept noise - instructions. When ``False`` (default), noise instructions - are commented out and the corresponding ERROR rows are emitted - explicitly. + + Undecorated noise is split into its original ``@SIMULATE_ONLY`` physical + instruction and canonical decode-side metadata. Decode-visible noisy + measurements retain a clean ``@DECODE_ONLY`` structural instruction. + Existing visibility decorators are preserved by intent, and measurement + counts must remain equal between the two views. """ codes: dict[str, CodeDefinition] = { d.name: d for d in qfile.definitions if isinstance(d, CodeDefinition) @@ -148,7 +147,6 @@ def annotate(qfile: DeqFile, *, keep_noise: bool = False) -> str: artifacts=library_artifacts.gadget_artifacts_by_name[ definition.name ], - keep_noise=keep_noise, ) ) elif isinstance(definition, ComposeDefinition): @@ -175,24 +173,25 @@ def annotate(qfile: DeqFile, *, keep_noise: bool = False) -> str: artifacts=library_artifacts.gadget_artifacts_by_name[ definition.name ], - keep_noise=keep_noise, check_override=check_override, ) ) else: blocks.append( _render_composed_gadget( - jit_by_name[definition.name], + library_artifacts.gadget_artifacts_by_name[definition.name], stab_count_of_ptype, definition, gadget_defs, compose_defs, codes, - keep_noise=keep_noise, ) ) compose_so_far[definition.name] = definition - elif isinstance(definition, ProgramDefinition): + else: + assert isinstance( + definition, ProgramDefinition + ), f"unsupported top-level definition: {type(definition).__name__}" blocks.append(_emit_program(definition)) return "\n\n".join(blocks) + "\n" @@ -263,7 +262,6 @@ def _annotate_gadget( codes: dict[str, CodeDefinition], *, artifacts: JitGadgetArtifacts, - keep_noise: bool = False, check_override: ( tuple[ list[tuple[frozenset[int], bool]], @@ -287,15 +285,7 @@ def _annotate_gadget( loss_model = ( jit_gadget.base.loss_model if jit_gadget.base.HasField("loss_model") else None ) - declared_error_positions = [ - body_index - for body_index, statement in enumerate(flat_body) - if isinstance(statement, ErrorStatement) - ] - pre_loss_error_count = len(declared_error_positions) + len( - artifacts.noise_error_origins - ) - + emit_loss_metadata = loss_model is not None # Walk the body once to label every body position with the running # measurement count *after* that position. We use these snapshots # both for placing auto-derived CHECKs. @@ -351,27 +341,16 @@ def _annotate_gadget( gadget.decorators, gtype=jit_gadget.base.gtype ) lines: list[str] = [*[str(d) for d in decorators], f"GADGET {gadget.name} {{"] - noise_errors_by_body_index: dict[ - int, list[tuple[int, jit_pb.JitGadgetType.Error]] - ] = {} + noise_error_indices_by_body: dict[int, list[int]] = {} for origin in artifacts.noise_error_origins: - noise_errors_by_body_index.setdefault(origin.body_index, []).append( - (origin.error_index, jit_errors[origin.error_index]) + noise_error_indices_by_body.setdefault(origin.body_index, []).append( + origin.error_index ) - noise_error_indices = { - origin.error_index for origin in artifacts.noise_error_origins + declared_error_index_by_body = { + origin.body_index: origin.error_index + for origin in artifacts.declared_error_origins + if isinstance(flat_body[origin.body_index], ErrorStatement) } - declared_error_indices_by_body_index = dict( - zip( - declared_error_positions, - ( - error_index - for error_index in range(pre_loss_error_count) - if error_index not in noise_error_indices - ), - strict=True, - ) - ) num_finished = len(jit_gadget.finished_checks) cp_pb = jit_gadget.base.correction_propagation pc_pb = jit_gadget.base.physical_correction @@ -402,11 +381,10 @@ def _annotate_gadget( readout_counter = 0 pre_running = 0 - loss_generator_comments: dict[int, list[str]] = {} source_loss_lines: list[str] = [] input_loss_lines: list[str] = [] loss_error_counter = 0 - if not keep_noise and loss_model is not None: + if emit_loss_metadata: source_losses, input_losses = loss_model_to_statements( loss_model, input_ports=input_ports, @@ -419,30 +397,13 @@ def _annotate_gadget( for loss_index, statement in enumerate(source_losses) ] input_loss_lines = [f" {statement}" for statement in input_losses] - if not keep_noise and artifacts.loss_generator_placements: - # Group generators at each body position by the Pauli channel they - # inject, so degenerate placements (the same channel spread by different - # losses to the same error) collapse to one line that lists every owning - # loss tag, e.g. ``# L3.CE5 L4.CE5``. - channels_by_body: dict[int, dict[str, list[str]]] = {} - for placement in artifacts.loss_generator_placements: - channel = f"{placement.pauli}_ERROR(0.5) {placement.qubit}" - tag = ( - f"{placement.origin.label}." - f"{placement.role.value}{placement.error_index}" - ) - tags = channels_by_body.setdefault(placement.body_index, {}).setdefault( - channel, [] - ) - if tag not in tags: - tags.append(tag) - for body_index, channels in channels_by_body.items(): - loss_generator_comments[body_index] = [ - f" # {channel} # {' '.join(tags)}" - for channel, tags in channels.items() - ] + simulation_only_by_boundary = _simulation_only_instructions_by_decode_boundary( + gadget.body + ) physical_running = 0 for body_index, stmt in enumerate(flat_body): + for simulation_instruction in simulation_only_by_boundary.get(body_index, ()): + lines.extend(_render_decorated_instruction(simulation_instruction)) if isinstance(stmt, ReadoutStatement): comment = _format_propagation_comment( propagation, @@ -451,13 +412,11 @@ def _annotate_gadget( ) lines.append(f" {_render_readout_statement(stmt, comment)}") readout_counter += 1 + elif isinstance(stmt, ErrorStatement): + error_index = declared_error_index_by_body[body_index] + lines.append(f" {_render_error_statement(stmt)} # E{error_index}") else: - for line in _render_body_statement( - stmt, - keep_noise=keep_noise, - error_index=declared_error_indices_by_body_index.get(body_index), - physical_running=physical_running, - ): + for line in _render_body_statement(stmt, physical_running=physical_running): lines.append(line) # Emit each source loss's LOSS(...) line right after its # commented-out LOSS_ERROR so the loss model is legible in place; @@ -470,25 +429,16 @@ def _annotate_gadget( ): lines.append(source_loss_lines[loss_error_counter]) loss_error_counter += 1 - for comment_line in loss_generator_comments.get(body_index, ()): - lines.append(comment_line) - # When ``keep_noise`` is set, the noise instructions are emitted - # verbatim, so re-transpilation will re-derive the same ERROR - # rows from circuit flow. Emitting them here as well would - # duplicate them. - if not keep_noise: - for error_index, error_row in noise_errors_by_body_index.get( - body_index, () - ): - lines.append( - " " - + _render_jit_error_to_source( - error_row, - num_finished=num_finished, - layout=output_col_layout, - ) - + f" # E{error_index}" + for error_index in noise_error_indices_by_body.get(body_index, ()): + lines.append( + " " + + _render_jit_error_to_source( + jit_errors[error_index], + num_finished=num_finished, + layout=output_col_layout, ) + + f" # E{error_index}" + ) pre_running = running_counts[body_index] if isinstance(stmt, Instruction): physical_running += instruction_num_measurements(str(stmt)) @@ -518,29 +468,26 @@ def _annotate_gadget( output_port_stab_counts=output_port_stab_counts, ) ) + for simulation_instruction in simulation_only_by_boundary.get(len(flat_body), ()): + lines.extend(_render_decorated_instruction(simulation_instruction)) # Each PROPAGATE row below is the complete XOR formula the runtime # evaluates for that output observable. lines.extend(propagate_lines) - # Declared and noise-derived rows are active at their source positions in order. - # Only rows newly introduced by loss inference are appended here. - if not keep_noise and loss_model is not None: - loss_errors = jit_errors[pre_loss_error_count:] - if loss_errors: - lines.append("") - for error_index, error_row in enumerate( - loss_errors, start=pre_loss_error_count - ): - lines.append( - " " - + _render_jit_error_to_source( - error_row, - num_finished=num_finished, - layout=output_col_layout, - ) - + f" # E{error_index}" + if artifacts.appended_error_origins: + lines.append("") + for origin in artifacts.appended_error_origins: + lines.append( + " " + + _render_jit_error_to_source( + jit_errors[origin.error_index], + num_finished=num_finished, + layout=output_col_layout, ) + + f" # E{origin.error_index}" + ) + if emit_loss_metadata: trailing_loss_lines = list(source_loss_lines[loss_error_counter:]) trailing_loss_lines.extend(input_loss_lines) if trailing_loss_lines: @@ -548,17 +495,12 @@ def _annotate_gadget( lines.extend(trailing_loss_lines) # Statistics summary - all_errors = [ - error_row - for errors in noise_errors_by_body_index.values() - for _, error_row in errors - ] lines.append("") lines.extend( _format_stats_comment( [len(members) for members, _ in finished], [len(members) for members, _ in unfinished], - [len(e.finished_checks) + len(e.unfinished_checks) for e in all_errors], + [len(e.finished_checks) + len(e.unfinished_checks) for e in jit_errors], ) ) @@ -639,19 +581,14 @@ def _render_jit_error_to_source( def _render_body_statement( stmt: GadgetStatement, *, - keep_noise: bool = False, - error_index: int | None = None, physical_running: int = 0, ) -> list[str]: """Render a single body statement as one or more lines (already indented). - When ``keep_noise`` is ``True``, noise instructions are emitted - verbatim and noisy measurements keep their probability arguments, - so re-transpilation re-derives the original ERROR rows from - circuit flow. - - When ``error_index`` is given, an explicit source ``ERROR`` statement is - labeled with its canonical runtime index. + Noise instructions are emitted under ``@SIMULATE_ONLY``. Noisy + measurements also emit a clean ``@DECODE_ONLY`` twin so simulation and + decoding retain identical measurement structure. Canonical ERROR rows are + rendered separately. ``physical_running`` is the running count of physical measurements produced by preceding statements; it is used to translate @@ -673,35 +610,8 @@ def _render_body_statement( # User-written LOSS statements are regenerated from the binary loss # model as a dedicated block, so drop them here to avoid duplication. return [] - if isinstance(stmt, ReadoutStatement): - return [f" {_render_readout_statement(stmt)}"] - if isinstance(stmt, ErrorStatement): - suffix = f" # E{error_index}" if error_index is not None else "" - return [f" {_render_error_statement(stmt)}{suffix}"] if isinstance(stmt, Instruction): - name = stmt.name.upper() - if name in NOISE_INSTRUCTIONS_ALL: - # Preserve the original noise instruction when requested; otherwise - # its expanded ERROR or LOSS representation replaces it. - prefix = "" if keep_noise else "# " - return [f" {prefix}{stmt}"] - # Noisy measurement: comment out original, emit clean version. - if ( - stmt.arguments - and stmt.arguments[0] != 0 - and name in NOISY_MEASUREMENT_INSTRUCTIONS - ): - if keep_noise: - return [f" {stmt}"] - clean = Instruction( - name=stmt.name, tag=stmt.tag, arguments=[], targets=list(stmt.targets) - ) - return [f" # {stmt}", f" {clean}"] - # Circuit and measurement instructions: keep verbatim. - return [f" {stmt}"] - if isinstance(stmt, RepeatBlock): - # flatten_body should have already unrolled these; defensive. - return [f" # REPEAT {stmt.count} {{ ... }} (unexpected — not unrolled)"] + return _render_instruction(stmt) if isinstance(stmt, ConditionalStatement): # CONDITIONAL R/rec[-k]/M statements are absorbed into # the PROPAGATE block: readout targets appear as ``R`` terms @@ -718,6 +628,80 @@ def _render_body_statement( raise TypeError(f"unhandled gadget statement: {type(stmt).__name__}") +def _render_instruction( + stmt: Instruction, +) -> list[str]: + """Split physical noise from its noiseless decode-side structure.""" + name = stmt.name.upper() + simulate_only = any( + decorator.name == "SIMULATE_ONLY" for decorator in stmt.decorators + ) + decode_only = any(decorator.name == "DECODE_ONLY" for decorator in stmt.decorators) + is_noise_channel = name in NOISE_INSTRUCTIONS_ALL + is_noisy_measurement = ( + stmt.arguments + and stmt.arguments[0] != 0 + and name in NOISY_MEASUREMENT_INSTRUCTIONS + ) + if not is_noise_channel and not is_noisy_measurement: + if stmt.decorators: + return _render_decorated_instruction(stmt) + return [f" {stmt}"] + + simulation_visible = not decode_only + decode_visible = not simulate_only + lines: list[str] = [] + if simulation_visible: + lines.extend((" @SIMULATE_ONLY", f" {stmt}")) + if decode_visible and is_noisy_measurement: + clean = Instruction( + name=stmt.name, + tag=stmt.tag, + arguments=[], + targets=list(stmt.targets), + ) + lines.extend((" @DECODE_ONLY", f" {clean}")) + elif decode_only and is_noise_channel: + lines.extend( + ( + *(f" # {decorator}" for decorator in stmt.decorators), + f" # {stmt}", + ) + ) + return lines + + +def _render_decorated_instruction(stmt: Instruction) -> list[str]: + return [ + *(f" {decorator}" for decorator in stmt.decorators), + f" {stmt}", + ] + + +def _simulation_only_instructions_by_decode_boundary( + statements: list[GadgetStatement], +) -> dict[int, list[Instruction]]: + """Group decode-hidden instructions by the next decode-body position.""" + grouped: dict[int, list[Instruction]] = {} + decode_boundary = 0 + + def visit(items: list[GadgetStatement]) -> None: + nonlocal decode_boundary + for statement in items: + if isinstance(statement, RepeatBlock): + for _ in range(statement.count): + visit(statement.body) + elif isinstance(statement, Instruction) and any( + decorator.name == "SIMULATE_ONLY" for decorator in statement.decorators + ): + grouped.setdefault(decode_boundary, []).append(statement) + else: + decode_boundary += 1 + + visit(statements) + return grouped + + def _render_preselect( stmt: PreselectStatement, physical_running: int, @@ -862,12 +846,6 @@ def _render_input_or_output(port: InputPort | OutputPort, keyword: str) -> str: return f" {keyword} {port.code_name}" -def _render_check_statement(stmt: CheckStatement) -> str: - targets = " ".join(str(t) for t in stmt.targets) - suffix = " FLIP" if stmt.flip else "" - return f"CHECK {targets}{suffix}" - - def _render_readout_statement( stmt: ReadoutStatement, propagation_comment: str = "", @@ -995,30 +973,30 @@ def _render_auto_check( def _render_composed_gadget( - gadget: jit_pb.JitGadgetType, + artifacts: JitGadgetArtifacts, stab_count_of_ptype: dict[int, int], compose: ComposeDefinition, gadget_defs: dict[str, GadgetDefinition], compose_defs: dict[str, ComposeDefinition], codes: dict[str, CodeDefinition], - *, - keep_noise: bool = False, ) -> str: """Render a composed ``JitGadgetType`` as a ``GADGET`` block. - Instead of opaque placeholders, the actual circuit of each - sub-gadget is inlined (noise instructions are commented out). Port - qubits are densely numbered starting at 0; ancilla qubits follow. + Instead of opaque placeholders, the actual circuit of each sub-gadget is + inlined with physical noise separated into the simulation view. Port qubits + are densely numbered starting at 0; ancilla qubits follow. - When ``keep_noise`` is ``True``, the noise instructions are emitted - verbatim and the merge-derived ``ERROR`` rows are skipped, so - re-transpilation re-derives them from the inlined circuit. This - is orthogonal to whether the COMPOSE has ``@REPROPAGATE``: it - only changes the noise rendering, not the propagation matrices. + Physical noise is retained under ``@SIMULATE_ONLY``. Declared and ordinary + noise-derived rows are emitted at mapped source positions; inferred + loss-only rows are emitted at constituent boundaries. Noisy measurements + receive clean ``@DECODE_ONLY`` twins, and the merged loss DAG is emitted + explicitly. """ + gadget = artifacts.jit_type base = gadget.base name = base.name or f"AnonymousGadget{base.gtype}" - + loss_model = base.loss_model if base.HasField("loss_model") else None + emit_loss_metadata = loss_model is not None input_stab_counts = [stab_count_of_ptype[p.ptype] for p in base.inputs] output_stab_counts = [stab_count_of_ptype[p.ptype] for p in base.outputs] iv_count = sum(input_stab_counts) @@ -1036,6 +1014,54 @@ def _render_composed_gadget( input_ports, circuit_stmts, output_ports = expand_compose_circuit( compose, gadget_defs, compose_defs, known, codes ) + output_col_layout = PortColumnLayout(output_ports, codes) + noise_errors_by_position: dict[int, list[int]] = {} + for origin in artifacts.noise_error_origins: + noise_errors_by_position.setdefault(origin.body_index, []).append( + origin.error_index + ) + boundary_errors_by_position: dict[int, list[int]] = {} + for origin in ( + *artifacts.declared_error_origins, + *artifacts.appended_error_origins, + ): + boundary_errors_by_position.setdefault(origin.body_index, []).append( + origin.error_index + ) + for error_indices in noise_errors_by_position.values(): + error_indices.sort() + for error_indices in boundary_errors_by_position.values(): + error_indices.sort() + localized_error_indices = { + origin.error_index + for origin in ( + *artifacts.noise_error_origins, + *artifacts.declared_error_origins, + *artifacts.appended_error_origins, + ) + } + expected_error_indices = set(range(len(gadget.errors))) + if localized_error_indices != expected_error_indices: + raise AssertionError( + f"GADGET {name!r} error provenance is incomplete: got " + f"{sorted(localized_error_indices)}, expected " + f"{sorted(expected_error_indices)}" + ) + source_loss_lines: list[str] = [] + input_loss_lines: list[str] = [] + if emit_loss_metadata: + source_losses, input_losses = loss_model_to_statements( + loss_model, + input_ports=input_ports, + output_ports=output_ports, + codes=codes, + gadget_name=name, + ) + source_loss_lines = [ + f" {statement} # L{loss_index}" + for loss_index, statement in enumerate(source_losses) + ] + input_loss_lines = [f" {statement}" for statement in input_losses] # INPUT lines from sub-gadgets' port declarations. for port in input_ports: @@ -1046,32 +1072,40 @@ def _render_composed_gadget( # PRESELECT statements inherited from sub-gadgets can have their # absolute ``M`` targets translated into relative ``rec[-k]``. physical_running = 0 + num_finished = len(gadget.finished_checks) + + def emit_error(error_index: int) -> None: + lines.append( + " " + + _render_jit_error_to_source( + gadget.errors[error_index], + num_finished=num_finished, + layout=output_col_layout, + ) + + f" # E{error_index}" + ) + + decode_position = 0 + for stmt in circuit_stmts: + simulate_only = isinstance(stmt, Instruction) and any( + decorator.name == "SIMULATE_ONLY" for decorator in stmt.decorators + ) + if not simulate_only: + for error_index in boundary_errors_by_position.get(decode_position, ()): + emit_error(error_index) if isinstance(stmt, Instruction): - name = stmt.name.upper() - if name in NOISE_INSTRUCTIONS_ALL: - # Passthrough noise (LOSS_ERROR) has no ERROR-row - # equivalent; keep verbatim regardless of `keep_noise`. - if keep_noise or name in PASSTHROUGH_NOISE_INSTRUCTIONS: - lines.append(f" {stmt}") - else: - lines.append(f" # {stmt}") - elif stmt.arguments and name in NOISY_MEASUREMENT_INSTRUCTIONS: - if keep_noise: - lines.append(f" {stmt}") - else: - # Strip noise arguments from measurements (e.g. M(0.01) → M) - # so re-transpilation doesn't generate extra noise errors. - clean = Instruction( - name=stmt.name, - targets=stmt.targets, - ) - lines.append(f" {clean}") - else: - lines.append(f" {stmt}") - physical_running += instruction_num_measurements(str(stmt)) + lines.extend(_render_instruction(stmt)) + if not simulate_only: + physical_running += instruction_num_measurements(str(stmt)) elif isinstance(stmt, PreselectStatement): lines.append(_render_preselect(stmt, physical_running)) + if not simulate_only: + for error_index in noise_errors_by_position.get(decode_position, ()): + emit_error(error_index) + decode_position += 1 + for error_index in boundary_errors_by_position.get(decode_position, ()): + emit_error(error_index) # OUTPUT lines from sub-gadgets' port declarations. for port in output_ports: @@ -1124,9 +1158,10 @@ def _render_composed_gadget( for r, c in zip(prop.i, prop.j): binary_rp_cols_by_row.setdefault(r, set()).add(c) + decode_circuit_stmts = flatten_body(circuit_stmts) synth_for_walker = GadgetDefinition( name=base.name, - body=[*input_ports, *circuit_stmts, *output_ports], + body=[*input_ports, *decode_circuit_stmts, *output_ports], decorators=[], ) walker_implicit = compute_implicit_readout_propagation( @@ -1162,14 +1197,14 @@ def _render_composed_gadget( # an explicit ``FLIP`` token to keep the readout row round-tripping. if affine_col in binary_cols: rec_refs.append("FLIP") - if rec_refs: - comment = _format_propagation_comment( - prop, - row_index, - layout=input_col_layout, - ) - suffix = f" {comment}" if comment else "" - lines.append(" READOUT " + " ".join(rec_refs) + suffix) + assert rec_refs, f"GADGET {name!r} readout R{row_index} has no source" + comment = _format_propagation_comment( + prop, + row_index, + layout=input_col_layout, + ) + suffix = f" {comment}" if comment else "" + lines.append(" READOUT " + " ".join(rec_refs) + suffix) # PROPAGATE emission. Emit binary cp/pc/lc verbatim: each row is # authoritative and describes the complete XOR formula the runtime @@ -1179,7 +1214,6 @@ def _render_composed_gadget( # ``FLIP`` bit in ``cp``'s affine column; CONDITIONAL populates ``lc`` # entries) and the PROPAGATE rows below re-emit them as ``FLIP`` # keywords and ``R`` terms. - output_col_layout = PortColumnLayout(output_ports, codes) lines.extend( _format_propagate_statements( base.correction_propagation, @@ -1190,21 +1224,12 @@ def _render_composed_gadget( ) ) - # ERROR statements. When ``keep_noise`` is set, the noise - # instructions above are emitted verbatim, so re-transpilation - # re-derives the same ERROR rows from circuit flow — emitting - # them here as well would duplicate them. - if not keep_noise: - num_finished = len(gadget.finished_checks) - for error_row in gadget.errors: - lines.append( - " " - + _render_jit_error_to_source( - error_row, - num_finished=num_finished, - layout=output_col_layout, - ) - ) + if emit_loss_metadata: + trailing_loss_lines = list(source_loss_lines) + trailing_loss_lines.extend(input_loss_lines) + assert trailing_loss_lines, f"GADGET {name!r} has an empty loss model" + lines.append("") + lines.extend(trailing_loss_lines) # Statistics summary lines.append("") diff --git a/deq/deq/transpiler/jit_library_builder.py b/deq/deq/transpiler/jit_library_builder.py index 0cb77328..93f5aed9 100644 --- a/deq/deq/transpiler/jit_library_builder.py +++ b/deq/deq/transpiler/jit_library_builder.py @@ -73,10 +73,7 @@ iter_noise_errors_with_origin, resolve_propagations, ) -from deq.transpiler.loss.transpiler import ( - LossGeneratorPlacement, - transpile_inferred_loss_model, -) +from deq.transpiler.loss.transpiler import transpile_inferred_loss_model from deq.transpiler.loss.syntax import transpile_declared_loss_model import stim @@ -91,8 +88,8 @@ @dataclass(frozen=True) -class NoiseErrorOrigin: - """Source-body position and final error index of one noise-derived row.""" +class ErrorOrigin: + """Flattened source boundary and final index of a non-noise error row.""" body_index: int error_index: int @@ -103,28 +100,41 @@ class JitGadgetArtifacts: """Runtime gadget protobuf plus annotation-only transpiler provenance.""" jit_type: jit_pb.JitGadgetType - noise_error_origins: tuple[NoiseErrorOrigin, ...] = () - loss_generator_placements: tuple[LossGeneratorPlacement, ...] = () + noise_error_origins: tuple[ErrorOrigin, ...] = () + declared_error_origins: tuple[ErrorOrigin, ...] = () + appended_error_origins: tuple[ErrorOrigin, ...] = () def __getstate__( self, - ) -> tuple[bytes, tuple[NoiseErrorOrigin, ...], tuple[LossGeneratorPlacement, ...]]: + ) -> tuple[ + bytes, tuple[ErrorOrigin, ...], tuple[ErrorOrigin, ...], tuple[ErrorOrigin, ...] + ]: return ( self.jit_type.SerializeToString(), self.noise_error_origins, - self.loss_generator_placements, + self.declared_error_origins, + self.appended_error_origins, ) def __setstate__( self, state: tuple[ - bytes, tuple[NoiseErrorOrigin, ...], tuple[LossGeneratorPlacement, ...] + bytes, + tuple[ErrorOrigin, ...], + tuple[ErrorOrigin, ...], + tuple[ErrorOrigin, ...], ], ) -> None: - jit_type, noise_error_origins, loss_generator_placements = state + ( + jit_type, + noise_error_origins, + declared_error_origins, + appended_error_origins, + ) = state object.__setattr__(self, "jit_type", jit_pb.JitGadgetType.FromString(jit_type)) object.__setattr__(self, "noise_error_origins", noise_error_origins) - object.__setattr__(self, "loss_generator_placements", loss_generator_placements) + object.__setattr__(self, "declared_error_origins", declared_error_origins) + object.__setattr__(self, "appended_error_origins", appended_error_origins) @dataclass(frozen=True) @@ -172,7 +182,9 @@ def build_jit_library(qfile: DeqFile, *, jobs: int = 1) -> jit_pb.JitLibrary: return build_jit_library_artifacts(qfile, jobs=jobs).jit_library -def build_jit_library_artifacts(qfile: DeqFile, *, jobs: int = 1) -> JitLibraryArtifacts: +def build_jit_library_artifacts( + qfile: DeqFile, *, jobs: int = 1 +) -> JitLibraryArtifacts: """ Build a ``JitLibrary`` and retain per-gadget annotation provenance. @@ -241,6 +253,7 @@ def build_jit_library_artifacts(qfile: DeqFile, *, jobs: int = 1) -> JitLibraryA gadget_definitions=scaffold.gadget_by_name, compose_definitions=compose_so_far, jit_gadget_types_by_name=jit_by_name, + jit_gadget_artifacts_by_name=gadget_artifacts_by_name, codes=scaffold.code_by_name, ptype_of_code=scaffold.ptype_of_code, port_types=scaffold.port_types, @@ -850,11 +863,16 @@ def _build_check( ordered_errors.sort(key=lambda item: item[0]) errors_pb: list[jit_pb.JitGadgetType.Error] = [] - noise_error_origins: list[NoiseErrorOrigin] = [] + noise_error_origins: list[ErrorOrigin] = [] + declared_error_origins: list[ErrorOrigin] = [] for body_index, is_noise_error, error_row in ordered_errors: if is_noise_error: noise_error_origins.append( - NoiseErrorOrigin(body_index=body_index, error_index=len(errors_pb)) + ErrorOrigin(body_index=body_index, error_index=len(errors_pb)) + ) + else: + declared_error_origins.append( + ErrorOrigin(body_index=body_index, error_index=len(errors_pb)) ) errors_pb.append(error_row) @@ -876,7 +894,7 @@ def _build_check( num_errors=len(errors_pb), num_measurements=internal_count, ) - loss_generator_placements: tuple[LossGeneratorPlacement, ...] = () + appended_error_origins: list[ErrorOrigin] = [] if loss_model_pb is None: loss_artifacts = transpile_inferred_loss_model( gadget, @@ -893,9 +911,16 @@ def _build_check( library_has_loss=library_has_loss, ) if loss_artifacts.model is not None: + body_end = len(flatten_body(list(gadget.body))) + appended_error_origins.extend( + ErrorOrigin( + body_index=body_end, + error_index=len(errors_pb) + offset, + ) + for offset in range(len(loss_artifacts.added_errors)) + ) errors_pb.extend(loss_artifacts.added_errors) loss_model_pb = loss_artifacts.model - loss_generator_placements = loss_artifacts.generator_placements if loss_model_pb is not None: base.loss_model.CopyFrom(loss_model_pb) return JitGadgetArtifacts( @@ -906,7 +931,8 @@ def _build_check( errors=errors_pb, ), noise_error_origins=tuple(noise_error_origins), - loss_generator_placements=loss_generator_placements, + declared_error_origins=tuple(declared_error_origins), + appended_error_origins=tuple(appended_error_origins), ) diff --git a/deq/tests/circuit/fixtures/teleportation.deq b/deq/tests/circuit/fixtures/teleportation.deq index 2e51ea5e..1d6b5844 100644 --- a/deq/tests/circuit/fixtures/teleportation.deq +++ b/deq/tests/circuit/fixtures/teleportation.deq @@ -3,8 +3,7 @@ # However, the default COMPOSE mode doesn't work here for two reasons: # 1. The composed gadget can infer conditional logical correction because it has the full circuit picture, while the current COMPOSE block only run JIT compiler without knowing those conditional corrections. # There should be a decorator like "@REPROPAGATE" on the COMPOSE block to allow the composed gadget to recalculate the propagation matrices from the circuit instead of using matrix composition. -# 2. Related to the first issue, the errors in the composed gadget can have different correction frames (it depends on the concrete propagation matrices). Plus, sometimes we want to keep the noise instructions -# as is, instead of expanding to ERROR statements that are no longer simulatable. There should be a flag to the annotate tool like "--keep-noise" to prevent the expansion of noise instructions. +# 2. Related to the first issue, the errors in the composed gadget can have different correction frames (it depends on the concrete propagation matrices). The annotator retains physical noise as @SIMULATE_ONLY while emitting canonical ERROR statements for decoding. # code layout # 0 1 diff --git a/deq/tests/spec/canonical_test.py b/deq/tests/spec/canonical_test.py index 13efec84..e2b3594b 100644 --- a/deq/tests/spec/canonical_test.py +++ b/deq/tests/spec/canonical_test.py @@ -74,6 +74,82 @@ ) +def test_canonicalize_preserves_loss_model() -> None: + loss_model = pb.GadgetType.LossModel( + losses=[ + pb.GadgetType.LossModel.Loss( + probability=0.1, + source_errors=[0], + continuation_errors=[0], + child_output_qubits=[0], + loss_measurements=[0], + ) + ] + ) + lib = pb.Library( + port_types=[ + pb.PortType( + ptype=1, + n=1, + observables=[pb.PortType.Observable()], + ) + ], + gadget_types=[ + pb.GadgetType( + gtype=1, + measurements=[pb.GadgetType.Measurement()], + outputs=[pb.GadgetType.Port(ptype=1)], + correction_propagation=util_pb.BitMatrix(rows=1, cols=1), + physical_correction=util_pb.BitMatrix(rows=1, cols=1), + loss_model=loss_model, + ) + ], + check_model_types=[ + pb.CheckModelType( + ctype=1, + gtype=1, + checks=[ + pb.CheckModelType.Check( + measurements=[ + pb.CheckModelType.RemoteMeasurement( + measurement_index=0 + ) + ] + ) + ], + ) + ], + error_model_types=[ + pb.ErrorModelType( + etype=1, + ctype=1, + errors=[ + pb.ErrorModelType.Error( + probability=0.0, + checks=[pb.ErrorModelType.RemoteCheck(check_index=0)], + ) + ], + ) + ], + program=[ + pb.Instruction(gadget=pb.Gadget(gtype=1)), + pb.Instruction(check_model=pb.CheckModel(ctype=1, gid=1)), + pb.Instruction(error_model=pb.ErrorModel(etype=1, cid=1)), + ], + ) + + canonical = canonicalize(lib) + + assert is_valid(canonical.library) + assert canonical.port_type.n == 1 + assert canonical.gadget_type.HasField("loss_model") + (loss,) = canonical.gadget_type.loss_model.losses + assert list(loss.source_errors) == [0] + assert list(loss.continuation_errors) == [0] + assert list(loss.child_output_qubits) == [0] + assert list(loss.loss_measurements) == [0] + + def test_canonical_default() -> None: canonical_form = canonicalize(default_library) From 1c07b11c04da422545d521582d22a962d48788cb Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 10 Aug 2026 21:50:20 -0700 Subject: [PATCH 079/157] minor changes --- deq/deq/transpiler/jit_annotate.py | 19 +++++++++---------- deq/deq/transpiler/jit_transpiler.py | 8 ++++---- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/deq/deq/transpiler/jit_annotate.py b/deq/deq/transpiler/jit_annotate.py index ff345d79..e3d6a147 100644 --- a/deq/deq/transpiler/jit_annotate.py +++ b/deq/deq/transpiler/jit_annotate.py @@ -63,6 +63,8 @@ Check, PortColumnLayout, flatten_body, + is_decode_only, + is_simulation_only, select_stabilizer_generators, ) from deq.transpiler.check_plugins import compute_layout, resolve_gadget_checks @@ -488,6 +490,9 @@ def _annotate_gadget( + f" # E{origin.error_index}" ) if emit_loss_metadata: + assert source_loss_lines or input_loss_lines, ( + f"GADGET {gadget.name!r} has an empty loss model" + ) trailing_loss_lines = list(source_loss_lines[loss_error_counter:]) trailing_loss_lines.extend(input_loss_lines) if trailing_loss_lines: @@ -633,10 +638,8 @@ def _render_instruction( ) -> list[str]: """Split physical noise from its noiseless decode-side structure.""" name = stmt.name.upper() - simulate_only = any( - decorator.name == "SIMULATE_ONLY" for decorator in stmt.decorators - ) - decode_only = any(decorator.name == "DECODE_ONLY" for decorator in stmt.decorators) + simulate_only = is_simulation_only(stmt) + decode_only = is_decode_only(stmt) is_noise_channel = name in NOISE_INSTRUCTIONS_ALL is_noisy_measurement = ( stmt.arguments @@ -691,9 +694,7 @@ def visit(items: list[GadgetStatement]) -> None: if isinstance(statement, RepeatBlock): for _ in range(statement.count): visit(statement.body) - elif isinstance(statement, Instruction) and any( - decorator.name == "SIMULATE_ONLY" for decorator in statement.decorators - ): + elif is_simulation_only(statement): grouped.setdefault(decode_boundary, []).append(statement) else: decode_boundary += 1 @@ -1088,9 +1089,7 @@ def emit_error(error_index: int) -> None: decode_position = 0 for stmt in circuit_stmts: - simulate_only = isinstance(stmt, Instruction) and any( - decorator.name == "SIMULATE_ONLY" for decorator in stmt.decorators - ) + simulate_only = is_simulation_only(stmt) if not simulate_only: for error_index in boundary_errors_by_position.get(decode_position, ()): emit_error(error_index) diff --git a/deq/deq/transpiler/jit_transpiler.py b/deq/deq/transpiler/jit_transpiler.py index a6cb6c73..176abeda 100644 --- a/deq/deq/transpiler/jit_transpiler.py +++ b/deq/deq/transpiler/jit_transpiler.py @@ -210,14 +210,14 @@ def _pauli_product_to_sparse( _KNOWN_INSTRUCTION_DECORATORS = frozenset({"SIMULATE_ONLY", "DECODE_ONLY"}) -def _is_simulate_only(stmt: GadgetStatement) -> bool: +def is_simulation_only(stmt: GadgetStatement) -> bool: """True if the statement carries an ``@SIMULATE_ONLY`` decorator.""" return isinstance(stmt, Instruction) and any( d.name == "SIMULATE_ONLY" for d in stmt.decorators ) -def _is_decode_only(stmt: GadgetStatement) -> bool: +def is_decode_only(stmt: GadgetStatement) -> bool: """True if the statement carries a ``@DECODE_ONLY`` decorator.""" return isinstance(stmt, Instruction) and any( d.name == "DECODE_ONLY" for d in stmt.decorators @@ -265,9 +265,9 @@ def flatten_body( flat.extend(flatten_body(body, for_simulate=for_simulate)) else: _validate_instruction_decorators(stmt) - if not for_simulate and _is_simulate_only(stmt): + if not for_simulate and is_simulation_only(stmt): continue - if for_simulate and _is_decode_only(stmt): + if for_simulate and is_decode_only(stmt): continue flat.append(stmt) return flat From 82b16c5996bab55b8ca1ccbd20321955204ac7ca Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 11 Aug 2026 09:58:16 -0700 Subject: [PATCH 080/157] code review --- deq/deq/cli/jit.py | 2 +- deq/deq/cli/sample.py | 22 +- deq/deq/transpiler/compose_builder.py | 357 +++++++++++++----- deq/deq/transpiler/jit_library_builder.py | 5 - .../tutorial/chapters/python-decoder.md | 25 +- deq/proto/blackbox_decoder.proto | 17 +- 6 files changed, 299 insertions(+), 129 deletions(-) diff --git a/deq/deq/cli/jit.py b/deq/deq/cli/jit.py index 28318320..6814ca5a 100644 --- a/deq/deq/cli/jit.py +++ b/deq/deq/cli/jit.py @@ -1274,7 +1274,7 @@ def export_program_stim( f"G{gid}/{name}: PRESELECT target resolves to " f"rec[-{rec_offset}] — the target measurement must " f"have already been produced inside the enclosing " - f"PREPARE block" + f"SELECT block" ) rec_offsets.append(rec_offset) # QDK REQUIRE succeeds when the XOR of its (possibly negated) diff --git a/deq/deq/cli/sample.py b/deq/deq/cli/sample.py index bace434f..3d65ff5f 100644 --- a/deq/deq/cli/sample.py +++ b/deq/deq/cli/sample.py @@ -102,22 +102,22 @@ def _strip_preselect_directives( clean_lines: list[str] = [] requires: list[tuple[list[tuple[int, bool]], bool]] = [] measurement_count = 0 - in_prepare = False + in_select = False for raw_line in stim_text.splitlines(): line = raw_line.strip() - if line == "PREPARE {": - if in_prepare: - raise ValueError("nested PREPARE blocks are not supported") - in_prepare = True + if line in {"SELECT {", "PREPARE {"}: + if in_select: + raise ValueError("nested SELECT blocks are not supported") + in_select = True continue - if line == "}" and in_prepare: - in_prepare = False + if line == "}" and in_select: + in_select = False continue parts = line.split(maxsplit=1) if parts and parts[0] == "REQUIRE": - if not in_prepare: - raise ValueError("REQUIRE must be inside a PREPARE block") + if not in_select: + raise ValueError("REQUIRE must be inside a SELECT block") relative_requires: list[tuple[int, bool]] = [] constant_parity = False target_count = 0 @@ -149,8 +149,8 @@ def _strip_preselect_directives( if line and not line.startswith("#"): measurement_count += stim.CircuitInstruction(line).num_measurements - if in_prepare: - raise ValueError("unclosed PREPARE block") + if in_select: + raise ValueError("unclosed SELECT block") return "\n".join(clean_lines), requires diff --git a/deq/deq/transpiler/compose_builder.py b/deq/deq/transpiler/compose_builder.py index 068ecc18..cbc28302 100644 --- a/deq/deq/transpiler/compose_builder.py +++ b/deq/deq/transpiler/compose_builder.py @@ -45,7 +45,11 @@ ) from deq.compiler.jit_compiler import static_jit_compiler from deq.spec.canonical import merge -from deq.transpiler.jit_transpiler import flatten_body, num_frame_columns +from deq.transpiler.jit_transpiler import ( + flatten_body, + is_simulation_only, + num_frame_columns, +) from deq.transpiler.stim_constants import instruction_num_measurements # --------------------------------------------------------------------------- @@ -558,15 +562,16 @@ def _expand_definition( ) -> tuple[list[InputPort], list[GadgetStatement], list[OutputPort]]: """Expand a single definition into ``(input_ports, circuit, output_ports)``. - For a ``GADGET``, returns its raw ports, Stim instructions, and - ``READOUT`` statements (with absolute ``M`` measurement - references rewritten to relative ``rec[-k]`` references so they - remain valid when the body is inlined into a larger COMPOSE). - For a ``COMPOSE``, recursively expands with qubit remapping. + For a ``GADGET``, returns its raw ports, all decorated instruction views, + and ``READOUT`` statements (with absolute ``M`` measurement references + rewritten to relative ``rec[-k]`` references so they remain valid when the + body is inlined into a larger COMPOSE). For a ``COMPOSE``, recursively + expands with qubit remapping. Consumers select a concrete view with + :func:`flatten_body`. """ if name in gadget_defs: gadget = gadget_defs[name] - flat = flatten_body(list(gadget.body)) + flat = _flatten_body_all_views(list(gadget.body)) inputs = [s for s in flat if isinstance(s, InputPort)] outputs = [s for s in flat if isinstance(s, OutputPort)] circuit: list[GadgetStatement] = [] @@ -574,7 +579,8 @@ def _expand_definition( for s in flat: if isinstance(s, Instruction): circuit.append(s) - running += instruction_num_measurements(str(s)) + if not is_simulation_only(s): + running += instruction_num_measurements(str(s)) elif isinstance(s, ReadoutStatement): circuit.append(_relativize_readout(s, running)) elif isinstance(s, PreselectStatement): @@ -586,11 +592,29 @@ def _expand_definition( return inputs, circuit, outputs if name in compose_defs: return expand_compose_circuit( - compose_defs[name], gadget_defs, compose_defs, known_names, codes + compose_defs[name], + gadget_defs, + compose_defs, + known_names, + codes, ) return [], [], [] +def _flatten_body_all_views( + statements: Sequence[GadgetStatement], +) -> list[GadgetStatement]: + """Unroll repeats without filtering simulation/decode decorators.""" + flattened: list[GadgetStatement] = [] + for statement in statements: + if isinstance(statement, RepeatBlock): + for _ in range(statement.count): + flattened.extend(_flatten_body_all_views(statement.body)) + else: + flattened.append(statement) + return flattened + + def _relativize_readout(stmt: ReadoutStatement, running: int) -> ReadoutStatement: """Translate ``M`` targets in *stmt* to ``rec[-(running - i)]``. @@ -606,9 +630,7 @@ def _relativize_readout(stmt: ReadoutStatement, running: int) -> ReadoutStatemen new_targets: list[ReadoutTargetItem] = [] for target in stmt.targets: if isinstance(target, PhysicalMeasurementTarget): - new_targets.append( - MeasurementRecordTarget(offset=running - target.index) - ) + new_targets.append(MeasurementRecordTarget(offset=running - target.index)) else: new_targets.append(target) return ReadoutStatement( @@ -625,7 +647,7 @@ def expand_compose_circuit( known_names: set[str], codes: Mapping[str, CodeDefinition], ) -> tuple[list[InputPort], list[GadgetStatement], list[OutputPort]]: - """Recursively expand a compose with dense qubit remapping. + """Recursively expand all instruction views with dense qubit remapping. Port data qubits are numbered ``0 .. total_data-1`` (dense). Ancilla qubits follow starting at ``total_data``. @@ -736,7 +758,11 @@ def expand_compose_circuit( cumulative_meas: int = 0 for app_name, in_wires, out_wires in apps: sub_inputs, sub_stmts, sub_outputs = _expand_definition( - app_name, gadget_defs, compose_defs, known_names, codes + app_name, + gadget_defs, + compose_defs, + known_names, + codes, ) # Build qubit remapping: port qubits → dense data indices. @@ -776,7 +802,8 @@ def expand_compose_circuit( if isinstance(stmt, Instruction): remapped = _remap_instruction(stmt, qmap) circuit.append(remapped) - cumulative_meas += instruction_num_measurements(str(remapped)) + if not is_simulation_only(remapped): + cumulative_meas += instruction_num_measurements(str(remapped)) elif isinstance(stmt, PreselectStatement): circuit.append(_rebase_preselect(stmt, sub_start_meas)) else: @@ -841,6 +868,8 @@ def _remap_instruction(stmt: Instruction, qmap: dict[int, int]) -> Instruction: tag=stmt.tag, arguments=list(stmt.arguments), targets=new_targets, + decorators=list(stmt.decorators), + source_line=stmt.source_line, ) @@ -850,7 +879,7 @@ def _rebase_preselect( ) -> PreselectStatement: """Return a copy of *stmt* with each ``M`` target shifted by *sub_start_meas*. Relative ``rec[-k]`` targets are left untouched: their position - relative to the enclosing PREPARE block is preserved by the + relative to the enclosing SELECT block is preserved by the order-preserving inlining. """ new_conditions: list[MeasurementRefTarget] = [] @@ -928,11 +957,7 @@ def _reject_in_compose(sub: ComposeDefinition, is_root: bool) -> None: visited.add(sub.name) for stmt in flatten_body(list(sub.body)): if isinstance(stmt, ConditionalCorrection): - where = ( - "its own body" - if is_root - else f"sub-COMPOSE {sub.name!r}" - ) + where = "its own body" if is_root else f"sub-COMPOSE {sub.name!r}" raise ValueError( f"COMPOSE {compose.name!r} @REPROPAGATE: {where} " f"contains a CONDITIONAL frame correction, which " @@ -965,9 +990,10 @@ def compose_to_synthetic_gadget( The synthetic gadget has the same name as *compose*; its body is ``input_ports + circuit + output_ports`` produced by - :func:`expand_compose_circuit`. Decorators are dropped — the caller - is responsible for re-attaching ``@GTYPE`` / ``@CHECKS`` on the - pipeline side as needed. + :func:`expand_compose_circuit`. Both simulation and decode instruction + views are retained; consumers select their view with ``flatten_body``. + Definition decorators are dropped because callers re-attach ``@GTYPE`` / + ``@CHECKS`` as needed. Used exclusively by the ``@REPROPAGATE`` build/annotate path (see :func:`_build_repropagated_compose`), which requires the body to be @@ -1000,7 +1026,7 @@ def _build_repropagated_compose( gtype: int, gadget_definitions: Mapping[str, GadgetDefinition], compose_definitions: Mapping[str, ComposeDefinition], - jit_gadget_types_by_name: Mapping[str, jit_pb.JitGadgetType], + jit_gadget_artifacts_by_name: Mapping[str, "JitGadgetArtifacts"], codes: Mapping[str, CodeDefinition], ptype_of_code: Mapping[str, int], port_types: list[jit_pb.JitPortType], @@ -1042,22 +1068,22 @@ def _build_repropagated_compose( compose, gadget_definitions, compose_definitions ) - merge_jt = _build_merge_compose( + merge_artifacts = _build_merge_compose( compose, gtype=gtype, gadget_definitions=gadget_definitions, compose_definitions=compose_definitions, - jit_gadget_types_by_name=jit_gadget_types_by_name, + jit_gadget_artifacts_by_name=jit_gadget_artifacts_by_name, codes=codes, ptype_of_code=ptype_of_code, port_types=port_types, + library_has_loss=library_has_loss, ) + merge_jt = merge_artifacts.jit_type synthetic = compose_to_synthetic_gadget( compose, gadget_definitions, compose_definitions, codes ) - finished, unfinished = _check_basis_from_jit_gadget_type( - merge_jt, synthetic, codes - ) + finished, unfinished = _check_basis_from_jit_gadget_type(merge_jt, synthetic, codes) return _build_jit_gadget_type( synthetic, gtype, @@ -1074,7 +1100,7 @@ def transpile_compose_jit_gadget_type( gtype: int, gadget_definitions: Mapping[str, GadgetDefinition], compose_definitions: Mapping[str, ComposeDefinition], - jit_gadget_types_by_name: Mapping[str, jit_pb.JitGadgetType], + jit_gadget_artifacts_by_name: Mapping[str, "JitGadgetArtifacts"], codes: Mapping[str, CodeDefinition], ptype_of_code: Mapping[str, int], port_types: list[jit_pb.JitPortType], @@ -1092,28 +1118,23 @@ def transpile_compose_jit_gadget_type( gtype=gtype, gadget_definitions=gadget_definitions, compose_definitions=compose_definitions, - jit_gadget_types_by_name=jit_gadget_types_by_name, + jit_gadget_artifacts_by_name=jit_gadget_artifacts_by_name, codes=codes, ptype_of_code=ptype_of_code, port_types=port_types, library_has_loss=library_has_loss, ) - from deq.transpiler.jit_library_builder import ( # local import: cycle - JitGadgetArtifacts, - ) - - return JitGadgetArtifacts( - jit_type=_build_merge_compose( - compose, - gtype=gtype, - gadget_definitions=gadget_definitions, - compose_definitions=compose_definitions, - jit_gadget_types_by_name=jit_gadget_types_by_name, - codes=codes, - ptype_of_code=ptype_of_code, - port_types=port_types, - ) + return _build_merge_compose( + compose, + gtype=gtype, + gadget_definitions=gadget_definitions, + compose_definitions=compose_definitions, + jit_gadget_artifacts_by_name=jit_gadget_artifacts_by_name, + codes=codes, + ptype_of_code=ptype_of_code, + port_types=port_types, + library_has_loss=library_has_loss, ) @@ -1170,60 +1191,26 @@ def members_of(check: jit_pb.JitGadgetType.Check) -> set[int]: return finished, unfinished -# =================================================================== -# Public API — JIT-based compose builder -# =================================================================== - - -def build_compose_jit_gadget_type( - compose: ComposeDefinition, - *, - gtype: int, - gadget_definitions: Mapping[str, GadgetDefinition], - compose_definitions: Mapping[str, ComposeDefinition], - jit_gadget_types_by_name: Mapping[str, jit_pb.JitGadgetType], - codes: Mapping[str, CodeDefinition], - ptype_of_code: Mapping[str, int], - port_types: list[jit_pb.JitPortType], - library_has_loss: bool = True, -) -> jit_pb.JitGadgetType: - """Build a composed JitGadgetType without retaining provenance. - - This is the protobuf-only wrapper around - :func:`transpile_compose_jit_gadget_type`. - """ - return transpile_compose_jit_gadget_type( - compose, - gtype=gtype, - gadget_definitions=gadget_definitions, - compose_definitions=compose_definitions, - jit_gadget_types_by_name=jit_gadget_types_by_name, - codes=codes, - ptype_of_code=ptype_of_code, - port_types=port_types, - library_has_loss=library_has_loss, - ).jit_type - - def _build_merge_compose( compose: ComposeDefinition, *, gtype: int, gadget_definitions: Mapping[str, GadgetDefinition], compose_definitions: Mapping[str, ComposeDefinition], - jit_gadget_types_by_name: Mapping[str, jit_pb.JitGadgetType], + jit_gadget_artifacts_by_name: Mapping[str, "JitGadgetArtifacts"], codes: Mapping[str, CodeDefinition], ptype_of_code: Mapping[str, int], port_types: list[jit_pb.JitPortType], -) -> jit_pb.JitGadgetType: - """Build a composed JitGadgetType using mock gadgets + JIT compiler + merge. + library_has_loss: bool = True, +) -> "JitGadgetArtifacts": + """Build composed JIT artifacts using mock gadgets + JIT compiler + merge. 1. Expand the COMPOSE body and validate port-type compatibility and dangling outputs. 2. Construct mock boundary gadgets to close off dangling ports. 3. Run the Rust JIT compiler to produce a complete Library. 4. Call ``merge()`` on only the real gadgets (excluding mocks). - 5. Convert the ``MergedGadget`` to a ``JitGadgetType``. + 5. Convert the ``MergedGadget`` and remapped source provenance to artifacts. The caller is expected to have already run :func:`validate_compose`. """ @@ -1269,7 +1256,9 @@ def _build_merge_compose( in_stabs = [len(codes[p.code_name].stabilizers) for p in inputs] in_obs = [num_frame_columns(codes[p.code_name]) for p in inputs] - sub_jits = [jit_gadget_types_by_name[app.gadget_name] for app in apps] + sub_jits = [ + jit_gadget_artifacts_by_name[app.gadget_name].jit_type for app in apps + ] mock_base = max((jt.base.gtype for jt in sub_jits), default=0) + 100 out_mock_gt = mock_base + len(inputs) @@ -1309,6 +1298,10 @@ def _build_merge_compose( # ── Build JIT program ──────────────────────────────────────────── prog: list[jit_pb.JitInstruction] = [] real_gids: set[int] = set() + source_artifacts_by_gid: dict[int, "JitGadgetArtifacts"] = {} + source_name_by_gid: dict[int, str] = {} + source_body_offset_by_gid: dict[int, int] = {} + expanded_body_offset = 0 gid = 1 # Track, for each logical wire, which (gid, output_port_index) last @@ -1361,6 +1354,7 @@ def _build_merge_compose( identity_gtype_of_ptype=identity_gtype_of_ptype, next_synthetic_gtype=next_synthetic_gtype, gid=gid, + include_loss_model=library_has_loss, ) ) if new_identity_gt is not None: @@ -1375,7 +1369,8 @@ def _build_merge_compose( # item is a GadgetApplication app = item - sub_jit = jit_gadget_types_by_name[app.gadget_name] + source_artifacts = jit_gadget_artifacts_by_name[app.gadget_name] + sub_jit = source_artifacts.jit_type in_wires = list(app.in_indices or []) out_wires = list(app.out_indices or []) connectors = [] @@ -1392,6 +1387,17 @@ def _build_merge_compose( ) ) real_gids.add(gid) + source_artifacts_by_gid[gid] = source_artifacts + source_name_by_gid[gid] = app.gadget_name + source_body_offset_by_gid[gid] = expanded_body_offset + _, expanded_statements, _ = _expand_definition( + app.gadget_name, + gadget_definitions, + compose_definitions, + set(gadget_definitions) | set(compose_definitions), + codes, + ) + expanded_body_offset += len(flatten_body(expanded_statements)) for local_r in range(len(sub_jit.base.readouts)): readout_history.append((gid, local_r)) for port_idx, wire in enumerate(out_wires): @@ -1418,8 +1424,145 @@ def _build_merge_compose( # ── JIT compile → merge real gadgets ───────────────────────────── deq_bin = static_jit_compiler(jit_lib) - merged = merge(deq_bin, real_gids) - return merged.to_jit_gadget_type(gtype=gtype, name=compose.name) + from deq.spec.common import ErrorIndex + from deq.spec.program_validator import ExpandedProgram + from deq.transpiler.jit_library_builder import ErrorOrigin, JitGadgetArtifacts + + expanded_program = ExpandedProgram.from_library(deq_bin) + merged = merge(deq_bin, real_gids, program=expanded_program) + + primary_eid_by_gid: dict[int, int] = {} + for eid in sorted( + expanded_program.error_models, + key=lambda index: expanded_program.error_model_instantiation_order[index], + ): + error_model = expanded_program.error_models[eid] + owner_gid = expanded_program.check_models[error_model.cid].gid + primary_eid_by_gid.setdefault(owner_gid, eid) + + noise_error_origins: list[ErrorOrigin] = [] + declared_error_origins: list[ErrorOrigin] = [] + appended_error_origins: list[ErrorOrigin] = [] + for source_gid, source_artifacts in source_artifacts_by_gid.items(): + eid = primary_eid_by_gid.get(source_gid) + if eid is None: + continue + source_name = source_name_by_gid[source_gid] + source_body: list[GadgetStatement] | None = None + if source_name in gadget_definitions: + source_body = flatten_body(list(gadget_definitions[source_name].body)) + elif source_name in compose_definitions and has_repropagate( + compose_definitions[source_name] + ): + synthetic = compose_to_synthetic_gadget( + compose_definitions[source_name], + gadget_definitions, + compose_definitions, + codes, + ) + source_body = flatten_body(list(synthetic.body)) + + compact_position_by_body_index: dict[int, int] | None = None + compact_boundary_by_body_index: dict[int, int] | None = None + if source_body is not None: + included_positions = [ + body_index + for body_index, statement in enumerate(source_body) + if isinstance( + statement, + (Instruction, ReadoutStatement, PreselectStatement), + ) + ] + compact_position_by_body_index = { + body_index: compact_position + for compact_position, body_index in enumerate(included_positions) + } + included = set(included_positions) + compact_boundary_by_body_index = {} + compact_boundary = 0 + for body_index in range(len(source_body) + 1): + compact_boundary_by_body_index[body_index] = compact_boundary + if body_index in included: + compact_boundary += 1 + + def merged_error_index(error_index: int) -> int | None: + merged_index = merged.error_map.atob.get( + ErrorIndex(eid=eid, error_index=error_index) + ) + return None if merged_index is None else merged_index.error_index + + def merged_boundary(body_index: int) -> int: + local_boundary = ( + compact_boundary_by_body_index[body_index] + if compact_boundary_by_body_index is not None + else body_index + ) + return source_body_offset_by_gid[source_gid] + local_boundary + + for origin in source_artifacts.noise_error_origins: + mapped_error = merged_error_index(origin.error_index) + if mapped_error is None: + continue + local_body_index = ( + compact_position_by_body_index[origin.body_index] + if compact_position_by_body_index is not None + else origin.body_index + ) + noise_error_origins.append( + ErrorOrigin( + body_index=source_body_offset_by_gid[source_gid] + local_body_index, + error_index=mapped_error, + ) + ) + for origin in source_artifacts.declared_error_origins: + mapped_error = merged_error_index(origin.error_index) + if mapped_error is not None: + declared_error_origins.append( + ErrorOrigin( + body_index=merged_boundary(origin.body_index), + error_index=mapped_error, + ) + ) + for origin in source_artifacts.appended_error_origins: + mapped_error = merged_error_index(origin.error_index) + if mapped_error is not None: + appended_error_origins.append( + ErrorOrigin( + body_index=merged_boundary(origin.body_index), + error_index=mapped_error, + ) + ) + noise_error_origins.sort(key=lambda origin: origin.error_index) + declared_error_origins.sort(key=lambda origin: origin.error_index) + appended_error_origins.sort(key=lambda origin: origin.error_index) + + jit_type = merged.to_jit_gadget_type(gtype=gtype, name=compose.name) + ordered_origin_indices = [ + error_index + for _, _, error_index in sorted( + [ + (origin.body_index, 1, origin.error_index) + for origin in noise_error_origins + ] + + [ + (origin.body_index, 0, origin.error_index) + for origin in (*declared_error_origins, *appended_error_origins) + ] + ) + ] + if ordered_origin_indices != list(range(len(jit_type.errors))): + raise AssertionError( + f"COMPOSE {compose.name!r} error provenance is incomplete or " + f"non-monotonic: got {ordered_origin_indices}, expected " + f"{list(range(len(jit_type.errors)))}" + ) + + return JitGadgetArtifacts( + jit_type=jit_type, + noise_error_origins=tuple(noise_error_origins), + declared_error_origins=tuple(declared_error_origins), + appended_error_origins=tuple(appended_error_origins), + ) # =================================================================== @@ -1500,6 +1643,9 @@ def mk_identity_gadget_type( ptype: int, n_obs: int, stab_count: int, + physical_qubit_count: int, + *, + include_loss_model: bool = False, ) -> jit_pb.JitGadgetType: """Build a ``JitGadgetType`` for a measurement-free identity gadget. @@ -1528,18 +1674,24 @@ def mk_identity_gadget_type( i=list(range(n_obs)), j=list(range(n_obs)), ) + base = pb.GadgetType( + gtype=gtype, + name=f"__identity_pt{ptype}__", + measurements=[], + inputs=[pb.GadgetType.Port(ptype=ptype)], + outputs=[pb.GadgetType.Port(ptype=ptype)], + correction_propagation=identity_cp, + readout_propagation=util_pb.BitMatrix(cols=n_obs + 1), + logical_correction=util_pb.BitMatrix(rows=n_obs), + physical_correction=util_pb.BitMatrix(rows=n_obs), + ) + if include_loss_model: + base.loss_model.input_losses.extend( + pb.GadgetType.LossModel.InputLoss(child_output_qubits=[qubit]) + for qubit in range(physical_qubit_count) + ) return jit_pb.JitGadgetType( - base=pb.GadgetType( - gtype=gtype, - name=f"__identity_pt{ptype}__", - measurements=[], - inputs=[pb.GadgetType.Port(ptype=ptype)], - outputs=[pb.GadgetType.Port(ptype=ptype)], - correction_propagation=identity_cp, - readout_propagation=util_pb.BitMatrix(cols=n_obs + 1), - logical_correction=util_pb.BitMatrix(rows=n_obs), - physical_correction=util_pb.BitMatrix(rows=n_obs), - ), + base=base, unfinished_checks=[ jit_pb.JitGadgetType.Check( base=pb.CheckModelType.Check(), @@ -1584,6 +1736,7 @@ def emit_conditional_correction_instruction( identity_gtype_of_ptype: dict[int, int], next_synthetic_gtype: int, gid: int, + include_loss_model: bool = False, ) -> tuple[jit_pb.JitInstruction, jit_pb.JitGadgetType | None, int]: """Build the JIT instruction realising one ``CONDITIONAL`` statement. @@ -1640,6 +1793,8 @@ def emit_conditional_correction_instruction( ptype=wire_ptype, n_obs=n_obs, stab_count=stab_count, + physical_qubit_count=jit_port_type.n, + include_loss_model=include_loss_model, ) src_gid, src_port = wire_source diff --git a/deq/deq/transpiler/jit_library_builder.py b/deq/deq/transpiler/jit_library_builder.py index 93f5aed9..1727a881 100644 --- a/deq/deq/transpiler/jit_library_builder.py +++ b/deq/deq/transpiler/jit_library_builder.py @@ -242,9 +242,6 @@ def build_jit_library_artifacts( # Process COMPOSE definitions in source order. Each one becomes a new # JitGadgetType visible to subsequent COMPOSEs (so nested COMPOSE works # automatically as long as the inner one is declared first). - jit_by_name: dict[str, jit_pb.JitGadgetType] = { - g.name: jt for g, jt in zip(scaffold.gadgets, gadget_types) - } compose_so_far: dict[str, ComposeDefinition] = {} for compose in scaffold.composes: compose_artifacts = transpile_compose_jit_gadget_type( @@ -252,7 +249,6 @@ def build_jit_library_artifacts( gtype=scaffold.gtype_of_compose[compose.name], gadget_definitions=scaffold.gadget_by_name, compose_definitions=compose_so_far, - jit_gadget_types_by_name=jit_by_name, jit_gadget_artifacts_by_name=gadget_artifacts_by_name, codes=scaffold.code_by_name, ptype_of_code=scaffold.ptype_of_code, @@ -262,7 +258,6 @@ def build_jit_library_artifacts( composed_jit = compose_artifacts.jit_type gadget_artifacts_by_name[compose.name] = compose_artifacts gadget_types.append(composed_jit) - jit_by_name[compose.name] = composed_jit compose_so_far[compose.name] = compose return JitLibraryArtifacts( diff --git a/deq/documents/tutorial/chapters/python-decoder.md b/deq/documents/tutorial/chapters/python-decoder.md index 6c18049b..025e1bb1 100644 --- a/deq/documents/tutorial/chapters/python-decoder.md +++ b/deq/documents/tutorial/chapters/python-decoder.md @@ -165,9 +165,10 @@ deq test python-decoder --file @relay_bp_decoder The `@relay_bp_decoder` sentinel resolves to a compile-time-embedded copy of the reference wrapper baked into `python_decoder.rs`; the other builtins -are `@naive_decoder` and `@tesseract_decoder`. Any `--file` value that does -**not** start with `@` is opened as a filesystem path, so pointing `--file` -at your own `*.py` file still works. +are `@naive_decoder`, `@tesseract_decoder`, and the structured-loss-aware +`@mle_loss_decoder`. Any `--file` value that does **not** start with `@` is +opened as a filesystem path, so pointing `--file` at your own `*.py` file still +works. Two things to notice: @@ -231,12 +232,18 @@ deq server \ `black-box-python` accepts the following config: -| Field | Type | Meaning | -| ------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `file` | string | Path to your `*.py` file, or an `@name` sentinel that resolves a compile-time-embedded reference decoder (`@naive_decoder`, `@relay_bp_decoder`, `@tesseract_decoder`). | -| `name` | string | Optional. Name of the decoder class inside the file. Defaults to `"Decoder"`. | -| `py_config` | any JSON value | Forwarded to your `Decoder.__init__` as the `config` argument. Omit for `{}`. | -| `parallel` | int (optional) | Number of decoder worker threads (inherited from the thread-pooling layer). | +| Field | Type | Meaning | +| --------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `file` | string | Path to your `*.py` file, or an `@name` sentinel that resolves a compile-time-embedded reference decoder (`@naive_decoder`, `@relay_bp_decoder`, `@tesseract_decoder`, `@mle_loss_decoder`). | +| `name` | string | Optional. Name of the decoder class inside the file. Defaults to `"Decoder"`. | +| `py_config` | any JSON value | Forwarded to your `Decoder.__init__` as the `config` argument. Omit for `{}`. | +| `supported_features` | string list | Optional. Any of `"reweights"` and `"loss"`; empty by default. Declaring both promises they can be consumed together. `@mle_loss_decoder` declares `"loss"` automatically. | +| `parallel` | int (optional) | Number of decoder worker threads (inherited from the thread-pooling layer). | + +Optional request fields are keyword arguments. A decoder declaring `reweights` +receives `decode(syndrome, reweights=...)`; one declaring `loss` receives +`decode(syndrome, loss=...)`; and a decoder declaring both may receive both in +the same call. Unsupported fields are rejected before `decode` is called. Pass `py_config` exactly the way you would pass `--py-config` to `test python-decoder`, just nested one level inside the decoder config: diff --git a/deq/proto/blackbox_decoder.proto b/deq/proto/blackbox_decoder.proto index 049a606f..1f7b3113 100644 --- a/deq/proto/blackbox_decoder.proto +++ b/deq/proto/blackbox_decoder.proto @@ -16,6 +16,9 @@ import "google/protobuf/empty.proto"; */ service BlackBoxDecoder { + // Report optional request fields this decoder can consume. Advertising + // multiple features means they may be used together in one decode request. + rpc GetCapabilities(google.protobuf.Empty) returns (DecoderCapabilities); // calling this service is in general inefficient because it has to process // the entire decoding hypergraph during runtime. Consider using preloaded // decoder instances like the rpc interfaces followed @@ -29,6 +32,16 @@ service BlackBoxDecoder { rpc Reset(ResetRequest) returns (google.protobuf.Empty); } +enum DecoderFeature { + DECODER_FEATURE_UNSPECIFIED = 0; + DECODER_FEATURE_REWEIGHTS = 1; + DECODER_FEATURE_LOSS = 2; +} + +message DecoderCapabilities { + repeated DecoderFeature features = 1; +} + message ResetRequest { // by default keep all the hypergraphs intact bool reset_hypergraphs = 1; @@ -63,7 +76,7 @@ message LoadedDecodingProblem { // Replaces the prior of one hyperedge, addressed by its index in the loaded // hypergraph. The probability is *assigned*, not combined: it may raise or lower -// the loaded prior. +// the loaded prior. Each edge may appear at most once in a request. message EdgeReweight { uint64 edge = 1; double probability = 2; @@ -99,7 +112,7 @@ message LossInfo { message LossSite { // Hyperedge indices (into DecodingHypergraph.hyperedges) for the SOURCE // generators at the loss location. Along a parent/child chain (one atom) at - // most one site's source is the true start -- the exclusivity because a qubit + // most one site's source is the true start -- the exclusivity because a qubit // cannot lose twice. repeated uint64 source_edges = 1; // Hyperedge indices for the CONTINUATION generators (the loss propagated From 951fb359cd949a6ed41a1e6b66b66efad2919ee3 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 11 Aug 2026 10:44:01 -0700 Subject: [PATCH 081/157] increase test coverage --- .../tutorial/chapters/qdk-loss-simulation.md | 23 +- deq/tests/cli/sample_test.py | 19 +- deq/tests/transpiler/check_optimizer_test.py | 82 +++- .../transpiler/check_plugins_manual_test.py | 139 ++++++ .../transpiler/jit_library_builder_test.py | 420 +++++++++++++++++- 5 files changed, 661 insertions(+), 22 deletions(-) create mode 100644 deq/tests/transpiler/check_plugins_manual_test.py diff --git a/deq/documents/tutorial/chapters/qdk-loss-simulation.md b/deq/documents/tutorial/chapters/qdk-loss-simulation.md index 7b701609..57a02835 100644 --- a/deq/documents/tutorial/chapters/qdk-loss-simulation.md +++ b/deq/documents/tutorial/chapters/qdk-loss-simulation.md @@ -296,17 +296,28 @@ own circuits or adapters: | `ShotSample.loss_mask` (proto) | yes | `optional BitVector` proto field | | gRPC `Outcomes.loss_mask` (controller→coord) | yes | `optional BitVector` proto field | | Coordinator imputation policy | yes (consumed) | `loss_random_imputation` (default `true`) | -| Decoder (`syndrome: list[int]`, sparse indices of the syndrome bits) | **no** | loss is folded into the imputed syndrome | +| Black-box decoder request | strategy-dependent | edge `reweights`, structured `LossInfo`, or both | By default, the coordinator applies **loss-random-imputation**: every bit of `outcomes` whose `loss_mask` bit is set is replaced with a uniformly -random bit before the syndrome -is computed. This is the simplest "loss-as-flip" model — it keeps the -black-box decoder protocol unchanged, at the cost of throwing away the -fact that the bit *was* a loss. Pass +random bit before the syndrome is computed. Pass `--coordinator-config '{"loss_random_imputation": false}'` to disable imputation; the decoder then sees a syndrome built from placeholder `0` -bits. Support for loss-aware decoding will be added in the future. +bits. + +Imputation is independent of `loss_strategy`. The default `reweight` strategy +turns observed losses into shot-scoped edge probabilities. `handoff` sends +structured `LossInfo` to a decoder that advertises loss support, while `ignore` +drops the loss observation after syndrome construction. Runtime probability +updates from `Outcomes.modifiers` remain independent: under `handoff`, a request +may contain both edge reweights and structured loss. + +The coordinator's `decoder_reweighting` setting controls transport only: +`auto` uses loaded reweights when advertised and otherwise materializes an +equivalent one-shot graph, `enabled` requires loaded support, and `disabled` +always materializes. Because `enabled` explicitly selects the loaded interface, +it also requires `persistent_decoder: true`. The policy never changes the +configured loss strategy. To make the imputation reproducible across runs, also pass `"loss_random_imputation_seed": `. When omitted, the RNG is seeded diff --git a/deq/tests/cli/sample_test.py b/deq/tests/cli/sample_test.py index 955cf1be..40789dd9 100644 --- a/deq/tests/cli/sample_test.py +++ b/deq/tests/cli/sample_test.py @@ -66,7 +66,7 @@ def test_sample_deq_with_preselect(tmp_path) -> None: def test_sample_stops_after_preselect_attempt_limit(monkeypatch) -> None: monkeypatch.setattr(sample_cli, "_max_preselect_attempts", 2) stim_text = """ -PREPARE { +SELECT { R 0 M 0 REQUIRE !rec[-1] @@ -92,7 +92,7 @@ def test_sample_parses_optional_constant_require_targets( targets, candidate, expected ) -> None: stim_text = f""" -PREPARE {{ +SELECT {{ M 0 REQUIRE {targets} }} @@ -123,10 +123,10 @@ def test_sample_expands_repeat_blocks() -> None: ) -def test_nested_repeat_blocks_with_prepare_directive() -> None: +def test_nested_repeat_blocks_with_select_directive() -> None: stim_text = """ REPEAT 2 { - PREPARE { + SELECT { REPEAT 2 { M 0 } @@ -139,11 +139,20 @@ def test_nested_repeat_blocks_with_prepare_directive() -> None: samples = sample_cli._sample_stim_text(stim_text, shots=1, seed=0) assert expanded.count("M 0") == 4 - assert expanded.count("PREPARE {") == 2 + assert expanded.count("SELECT {") == 2 assert expanded.count("REQUIRE rec[-1]") == 2 assert parse_bits(samples[0], 4) == [0] * 4 +def test_sample_accepts_legacy_prepare_alias() -> None: + stim_text = "PREPARE {\nM 0\nREQUIRE rec[-1]\n}\n" + + stripped, requires = sample_cli._strip_preselect_directives(stim_text) + + assert stripped == "M 0" + assert len(requires) == 1 + + @pytest.mark.parametrize( ("stim_text", "message"), [ diff --git a/deq/tests/transpiler/check_optimizer_test.py b/deq/tests/transpiler/check_optimizer_test.py index dc840597..92ffc865 100644 --- a/deq/tests/transpiler/check_optimizer_test.py +++ b/deq/tests/transpiler/check_optimizer_test.py @@ -1,9 +1,14 @@ """Unit tests for :mod:`deq.transpiler.check_optimizer`.""" - import pytest -from deq.transpiler.check_optimizer import optimize_checks +from deq.transpiler.check_optimizer import ( + RowSpaceTester, + checks_to_bitmatrix, + filter_input_virtual_only, + optimize_checks, + rref_reduce_with_priority, +) from deq.transpiler.jit_transpiler import Check @@ -135,3 +140,76 @@ def test_handles_empty_basis(input_virtual_count: int, ov_start: int) -> None: ) assert finished_out == [] assert unfinished_out == [] + + +def test_checks_to_bitmatrix_maps_and_filters_columns() -> None: + columns = {0: 2, 1: None, 2: -1, 3: 4} + matrix = checks_to_bitmatrix( + [C(0, 1, 2, 3, parity=True), C(0)], + 4, + col_of=columns.get, + parity_col=3, + ) + + assert set(matrix.rows[0].support) == {2, 3} + assert set(matrix.rows[1].support) == {2} + + +def test_empty_finished_donor_is_ignored() -> None: + finished = [C(), C(0, 1)] + + finished_out, unfinished_out = optimize_checks( + finished, [C(0, 2)], input_virtual_count=1, ov_start=2 + ) + + assert finished_out == finished + assert unfinished_out == [C(1, 2)] + + +def test_rref_finds_multi_donor_reduction_missed_by_pairwise_descent() -> None: + finished = [C(0, 3), C(1, 3)] + unfinished = [C(0, 1, 2, 4)] + + finished_out, unfinished_out = optimize_checks( + finished, unfinished, input_virtual_count=4, ov_start=4 + ) + + assert finished_out == finished + assert unfinished_out == [C(2, 4)] + + +def test_rref_priority_remaps_columns_and_propagates_parity() -> None: + parity_col = 3 + marker_col = 4 + result = rref_reduce_with_priority( + [0, (1 << 2) | (1 << parity_col)], + (1 << marker_col) | (1 << 2) | (1 << 0), + col_order=[2, 0, 1], + parity_col=parity_col, + marker_col=marker_col, + ) + + assert result == (1 << 0) | (1 << parity_col) + + +def test_filter_input_virtual_only_checks() -> None: + checks = [C(), C(0, 1), C(1, 2), C(3)] + + assert filter_input_virtual_only(checks, input_virtual_count=0) == checks + assert filter_input_virtual_only(checks, input_virtual_count=2) == [ + C(1, 2), + C(3), + ] + + +def test_row_space_tester_handles_dependencies_parity_and_combinations() -> None: + tester = RowSpaceTester( + [C(0, 1), C(0, 1), C(2, parity=True)], + total_measurements=3, + ) + + assert tester.test(C()) + assert tester.test(C(0, 1)) + assert tester.test(C(0, 1, 2, parity=True)) + assert not tester.test(C(2)) + assert not tester.test(C(0, 1, parity=True)) diff --git a/deq/tests/transpiler/check_plugins_manual_test.py b/deq/tests/transpiler/check_plugins_manual_test.py new file mode 100644 index 00000000..065bc553 --- /dev/null +++ b/deq/tests/transpiler/check_plugins_manual_test.py @@ -0,0 +1,139 @@ +"""Unit tests for the manual check plugin.""" + +import logging + +import pytest + +from deq.circuit.model import GadgetDefinition +from deq.transpiler.check_optimizer import RowSpaceTester +from deq.transpiler.check_plugins import CheckPluginInput +from deq.transpiler.check_plugins.manual import ( + _suggest_closest_check, + classify_manual_checks, + resolve_checks, +) +from deq.transpiler.jit_transpiler import Check, MeasurementLayout + + +def check(*indices: int, parity: bool = False) -> Check: + return frozenset(indices), parity + + +def plugin_input( + manual_checks: list[Check], + auto_checks: list[Check], + *, + plugin_kwargs: dict[str, str | int | float] | None = None, + total_measurements: int = 2, +) -> CheckPluginInput: + inp = CheckPluginInput( + gadget=GadgetDefinition("G"), + codes={}, + manual_checks=manual_checks, + total_measurements=total_measurements, + layout=MeasurementLayout(0, total_measurements), + plugin_kwargs=dict(plugin_kwargs or {}), + ) + inp.__dict__["auto_checks"] = auto_checks + return inp + + +def test_verify_zero_does_not_request_auto_checks() -> None: + inp = CheckPluginInput( + gadget=GadgetDefinition("G"), + codes={}, + manual_checks=[check(0)], + total_measurements=1, + layout=MeasurementLayout(0, 1), + plugin_kwargs={"verify": 0}, + ) + + result = resolve_checks(inp) + + assert result.finished == [check(0)] + assert result.unfinished == [] + assert "auto_checks" not in inp.__dict__ + + +def test_resolve_checks_rejects_unexpected_plugin_kwargs() -> None: + inp = plugin_input([], [], plugin_kwargs={"verify": 0, "unknown": 1}) + + with pytest.raises(AssertionError, match="unexpected plugin kwargs"): + resolve_checks(inp) + + +def test_resolve_checks_reports_wrong_parity_and_invalid_rows() -> None: + inp = plugin_input( + [check(0), check(0, 1)], + [check(0, parity=True)], + ) + + with pytest.raises(ValueError) as exc_info: + resolve_checks(inp) + + message = str(exc_info.value) + assert "1 manual CHECK(s) have the wrong parity" in message + assert "CHECK #0: CHECK m0" in message + assert "1 manual CHECK(s) are not in the auto-derived check space" in message + assert "CHECK #1: CHECK m0 m1" in message + assert "closest valid check: CHECK m0 FLIP" in message + assert "- remove m1" in message + assert "- add FLIP" in message + + +def test_resolve_checks_logs_missing_manual_rank( + caplog: pytest.LogCaptureFixture, +) -> None: + inp = plugin_input([check(0)], [check(0), check(1)]) + + with caplog.at_level(logging.INFO, logger="deq.transpiler.check_plugins.manual"): + result = resolve_checks(inp) + + assert result.finished == [check(0)] + assert "manual checks span rank 1 / 2" in caplog.text + assert "1 independent checks are missing" in caplog.text + + +def test_classify_manual_checks_orders_unfinished_by_output_index() -> None: + result = classify_manual_checks( + "G", + [check(4, 6, parity=True), check(1), check(0, 5)], + ov_start=5, + num_ov=2, + ) + + assert result.finished == [check(1)] + assert result.unfinished == [check(0, 5), check(4, 6, parity=True)] + + +def test_classify_manual_checks_rejects_duplicate_output_index() -> None: + with pytest.raises(ValueError, match="already covered by an earlier CHECK"): + classify_manual_checks("G", [check(5), check(1, 5)], ov_start=5, num_ov=1) + + +def test_classify_manual_checks_rejects_multiple_output_indices() -> None: + with pytest.raises(ValueError, match="multiple output-virtual indices"): + classify_manual_checks("G", [check(5, 6)], ov_start=5, num_ov=2) + + +def test_classify_manual_checks_rejects_missing_output_index() -> None: + with pytest.raises(ValueError, match=r"indices have no CHECK: \[1\]"): + classify_manual_checks("G", [check(5)], ov_start=5, num_ov=2) + + +def test_suggest_closest_check_returns_none_for_valid_or_disjoint_check() -> None: + tester = RowSpaceTester([check(0, 1)], total_measurements=3) + + assert _suggest_closest_check(tester, check(0, 1)) is None + assert _suggest_closest_check(tester, check(2)) is None + + +def test_suggest_closest_check_describes_added_and_removed_measurements() -> None: + tester = RowSpaceTester([check(0, 1)], total_measurements=3) + + suggestion = _suggest_closest_check(tester, check(0, 2)) + + assert suggestion is not None + assert "closest valid check: CHECK m0 m1" in suggestion + assert "- remove m2" in suggestion + assert "- add m1" in suggestion diff --git a/deq/tests/transpiler/jit_library_builder_test.py b/deq/tests/transpiler/jit_library_builder_test.py index 4dabe52e..b5f52037 100644 --- a/deq/tests/transpiler/jit_library_builder_test.py +++ b/deq/tests/transpiler/jit_library_builder_test.py @@ -5,9 +5,17 @@ import pytest +from deq.circuit.model import ProgramDefinition from deq.circuit.parser import parse, parse_file +from deq.cli.jit import compile_program_for_jit +from deq.compiler.jit_compiler import static_jit_compiler from deq.proto import deq_jit_pb2 as jit_pb -from deq.transpiler.jit_library_builder import build_jit_library +from deq.spec.canonical import canonicalize +from deq.transpiler.jit_library_builder import ( + build_jit_library, + build_jit_library_artifacts, + build_jit_program, +) REPO_ROOT = Path(__file__).resolve().parents[2] REP_DEQ = ( @@ -75,6 +83,61 @@ def test_build_library_on_repetition_code_d3() -> None: assert m.measurement_index < len(syndrome.base.measurements) +def test_build_jit_library_projects_library_from_artifacts() -> None: + qfile = parse_file(str(REP_DEQ)) + artifacts = build_jit_library_artifacts(qfile) + + assert ( + build_jit_library(qfile).SerializeToString() + == artifacts.jit_library.SerializeToString() + ) + assert set(artifacts.gadget_artifacts_by_name) == { + gadget.base.name for gadget in artifacts.jit_library.gadget_types + } + + +def test_parallel_build_preserves_provenance() -> None: + qfile = parse( + """ + CODE C [[1,1,1]] { LOGICAL X0 Z0 STABILIZER } + GADGET A { + INPUT C 0 + LOSS_ERROR(0.1) 0 + X_ERROR(0.01) 0 + M 0 + OUTPUT C 0 + } + GADGET B { + INPUT C 0 + Z_ERROR(0.02) 0 + M 0 + OUTPUT C 0 + } + """ + ) + + sequential = build_jit_library_artifacts(qfile) + parallel = build_jit_library_artifacts(qfile, jobs=2) + + assert ( + sequential.jit_library.SerializeToString() + == parallel.jit_library.SerializeToString() + ) + assert ( + sequential.gadget_artifacts_by_name.keys() + == parallel.gadget_artifacts_by_name.keys() + ) + for name, expected in sequential.gadget_artifacts_by_name.items(): + actual = parallel.gadget_artifacts_by_name[name] + assert ( + expected.jit_type.SerializeToString() + == actual.jit_type.SerializeToString() + ) + assert expected.noise_error_origins == actual.noise_error_origins + assert expected.declared_error_origins == actual.declared_error_origins + assert expected.appended_error_origins == actual.appended_error_origins + + def test_build_library_respects_pinned_ids() -> None: source = """ @PTYPE(7) @@ -180,6 +243,311 @@ def test_compose_fan_out_consumes_all_dangling_outputs() -> None: assert len(dynamic1.base.outputs) == 3 +def test_compose_merges_loss_models_across_internal_ports() -> None: + source = """ + CODE C [[1,1,1]] { LOGICAL X0 Z0 } + + GADGET A { + INPUT C 0 + LOSS_ERROR(0.1) 0 + H 0 + OUTPUT C 0 + } + + GADGET B { + INPUT C 0 + H 0 + LOSS_ERROR(0.2) 0 + M 0 + OUTPUT C 0 + } + + COMPOSE Chain { + INPUT C 0 + A 0 + B 0 + OUTPUT C 0 + } + """ + + library = build_jit_library(parse(source)) + chain = next(gt for gt in library.gadget_types if gt.base.name == "Chain") + + assert chain.base.HasField("loss_model") + assert len(chain.errors) == 1 + loss_model = chain.base.loss_model + assert [loss.probability for loss in loss_model.losses] == [0.1, 0.2] + assert list(loss_model.losses[0].child_losses) == [1] + assert list(loss_model.losses[1].loss_measurements) == [0] + assert len(loss_model.input_losses) == 1 + assert list(loss_model.input_losses[0].child_losses) == [0] + + for loss in loss_model.losses: + for error_index in [*loss.source_errors, *loss.continuation_errors]: + assert error_index < len(chain.errors) + for input_loss in loss_model.input_losses: + for error_index in input_loss.continuation_errors: + assert error_index < len(chain.errors) + + +def test_compose_folds_internal_input_herald_onto_upstream_loss() -> None: + source = """ + CODE C [[1,1,1]] { LOGICAL X0 Z0 STABILIZER } + + GADGET A { + INPUT C 0 + LOSS_ERROR(0.1) 0 + OUTPUT C 0 + } + + GADGET B { + INPUT C 0 + M 0 + OUTPUT C 0 + } + + COMPOSE Chain { + INPUT C 0 + A 0 + B 0 + OUTPUT C 0 + } + """ + + library = build_jit_library(parse(source)) + chain = next(gt for gt in library.gadget_types if gt.base.name == "Chain") + loss_model = chain.base.loss_model + + assert len(loss_model.losses) == 1 + assert list(loss_model.losses[0].loss_measurements) == [0] + assert not loss_model.losses[0].child_losses + + +def test_nested_compose_preserves_loss_through_external_output() -> None: + source = """ + CODE C [[1,1,1]] { LOGICAL X0 Z0 STABILIZER } + + GADGET A { + INPUT C 0 + LOSS_ERROR(0.1) 0 + H 0 + OUTPUT C 0 + } + + GADGET B { + INPUT C 0 + H 0 + OUTPUT C 0 + } + + COMPOSE Inner { + INPUT C 0 + A 0 + B 0 + OUTPUT C 0 + } + + COMPOSE Outer { + INPUT C 0 + Inner 0 + OUTPUT C 0 + } + """ + + library = build_jit_library(parse(source)) + by_name = {gadget.base.name: gadget for gadget in library.gadget_types} + + for name in ("Inner", "Outer"): + gadget = by_name[name] + assert gadget.base.HasField("loss_model") + loss_model = gadget.base.loss_model + assert len(loss_model.losses) == 1 + assert list(loss_model.losses[0].child_output_qubits) == [0] + assert len(loss_model.input_losses) == 1 + assert list(loss_model.input_losses[0].child_losses) == [0] + for error_index in [ + *loss_model.losses[0].source_errors, + *loss_model.losses[0].continuation_errors, + *loss_model.input_losses[0].continuation_errors, + ]: + assert error_index < len(gadget.errors) + + +def test_compose_merges_declared_loss_models() -> None: + source = """ + CODE C [[1,1,1]] { LOGICAL X0 Z0 } + + GADGET A { + INPUT C 0 + OUTPUT C 0 + ERROR(0) LX0 + LOSS(0.1) SE0 CE0 OUT0.L0 + LOSS(IN0.L0) CE0 L0 + } + + GADGET B { + INPUT C 0 + M 0 + OUTPUT C 0 + ERROR(0) LZ0 + LOSS(0.2) SE0 M0 + LOSS(IN0.L0) CE0 L0 M0 + } + + COMPOSE Chain { + INPUT C 0 + A 0 + B 0 + OUTPUT C 0 + } + """ + + library = build_jit_library(parse(source)) + chain = next(gt for gt in library.gadget_types if gt.base.name == "Chain") + loss_model = chain.base.loss_model + + assert [loss.probability for loss in loss_model.losses] == [0.1, 0.2] + assert list(loss_model.losses[0].child_losses) == [1] + assert list(loss_model.losses[0].loss_measurements) == [0] + assert list(loss_model.losses[1].loss_measurements) == [0] + assert list(loss_model.input_losses[0].child_losses) == [0] + + +def test_compose_loss_model_maps_multi_port_physical_slots() -> None: + source = """ + CODE C [[2,2,1]] { + LOGICAL X0 Z0 + LOGICAL X1 Z1 + } + + GADGET A { + INPUT C 0 1 + INPUT C 2 3 + OUTPUT C 0 1 + OUTPUT C 2 3 + ERROR(0) LX0 + LOSS(IN1.L1) CE0 OUT1.L0 + } + + COMPOSE Chain { + INPUT C 0 + INPUT C 1 + A 0 1 + OUTPUT C 0 + OUTPUT C 1 + } + """ + + library = build_jit_library(parse(source)) + chain = next(gt for gt in library.gadget_types if gt.base.name == "Chain") + loss_model = chain.base.loss_model + + assert len(loss_model.input_losses) == 4 + assert loss_model.input_losses[0].SerializeToString() == b"" + assert loss_model.input_losses[1].SerializeToString() == b"" + assert loss_model.input_losses[2].SerializeToString() == b"" + assert list(loss_model.input_losses[3].continuation_errors) == [0] + assert list(loss_model.input_losses[3].child_output_qubits) == [2] + + +def test_compose_drops_loss_generator_whose_merged_footprint_is_empty() -> None: + source = """ + CODE C [[1,1,1]] { LOGICAL X0 Z0 } + + GADGET A { + INPUT C 0 + OUTPUT C 0 + ERROR(0) LX0 + LOSS(0.1) SE0 CE0 OUT0.L0 + } + + GADGET B { + INPUT C 0 + R 0 + OUTPUT C 0 + } + + COMPOSE Chain { + INPUT C 0 + A 0 + B 0 + OUTPUT C 0 + } + """ + + library = build_jit_library(parse(source)) + chain = next(gt for gt in library.gadget_types if gt.base.name == "Chain") + + assert not chain.errors + (loss,) = chain.base.loss_model.losses + assert not loss.source_errors + assert not loss.continuation_errors + + +def test_composed_loss_model_survives_jit_compile_and_canonicalize() -> None: + source = """ + CODE C [[1,1,1]] { LOGICAL X0 Z0 } + + GADGET A { + R 0 + LOSS_ERROR(0.1) 0 + H 0 + OUTPUT C 0 + } + + GADGET B { + INPUT C 0 + H 0 + LOSS_ERROR(0.2) 0 + M 0 + OUTPUT C 0 + } + + GADGET Measure { + INPUT C 0 + M 0 + } + + COMPOSE Chain { + A 0 + B 0 + OUTPUT C 0 + } + + PROGRAM Run { + Chain 0 + Measure 0 + } + """ + + qfile = parse(source) + jit_library = build_jit_library(qfile) + program = next( + definition + for definition in qfile.definitions + if isinstance(definition, ProgramDefinition) + ) + compiled, _ = compile_program_for_jit(jit_library, program) + jit_library.program.extend(instruction for instruction, _ in compiled) + + deq_bin = static_jit_compiler(jit_library) + chain_gtype = next( + gadget.base.gtype + for gadget in jit_library.gadget_types + if gadget.base.name == "Chain" + ) + compiled_chain = next( + gadget for gadget in deq_bin.gadget_types if gadget.gtype == chain_gtype + ) + assert compiled_chain.HasField("loss_model") + + canonical = canonicalize(deq_bin) + assert canonical.gadget_type.HasField("loss_model") + assert [ + loss.probability for loss in canonical.gadget_type.loss_model.losses + ] == [0.1, 0.2] + + def test_compose_rejects_duplicate_output_wire() -> None: """A COMPOSE that binds the same wire to multiple OUTPUT ports must raise a structured ``ValueError`` rather than panicking inside the @@ -723,6 +1091,16 @@ def test_multiple_error_statements_emit_multiple_rows() -> None: assert list(gadget.errors[1].base.residual) == [0] +def test_declared_and_noise_errors_follow_source_order() -> None: + gadget = _gadget_with_errors( + "Z_ERROR(0.001) 0\n ERROR(0.002) LX0" + ) + + assert [error.base.probability for error in gadget.errors] == pytest.approx( + [0.001, 0.002] + ) + + def test_compose_gtype_pinned() -> None: source = """ CODE Rep [[3,1,1]] { @@ -1291,6 +1669,38 @@ def test_propagate_r_term_does_not_leak_to_cp_pc() -> None: """ +def test_compose_loss_passes_through_conditional_identity() -> None: + source = """ + CODE C [[1,1,1]] { LOGICAL X0 Z0 } + + GADGET Pass { + INPUT C 0 + LOSS_ERROR(0.1) 0 + M 1 + READOUT M0 + OUTPUT C 0 + } + + COMPOSE ConditionalPass { + INPUT C 0 + Pass 0 + CONDITIONAL rec[-1] X0 0 + OUTPUT C 0 + } + """ + + library = build_jit_library(parse(source)) + composed = next( + gadget + for gadget in library.gadget_types + if gadget.base.name == "ConditionalPass" + ) + + assert composed.base.HasField("loss_model") + (loss,) = composed.base.loss_model.losses + assert list(loss.child_output_qubits) == [0] + + def test_compose_conditional_folds_into_logical_correction() -> None: """A COMPOSE block with ``CONDITIONAL rec[-1] X0 `` after a measurement gadget absorbs the conditional correction into the @@ -1551,8 +1961,6 @@ def test_build_jit_program_populates_type_metadata_only() -> None: """``build_jit_program`` produces the type / measurement / readout metadata downstream PROGRAM compilation needs, and *omits* every decoder-side field (checks, errors, propagation matrices).""" - from deq.transpiler.jit_library_builder import build_jit_program - source = """ CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 @@ -1604,8 +2012,6 @@ def test_build_jit_program_inlines_compose_as_synthetic_gadget() -> None: """COMPOSE definitions are inlined into synthetic gadgets so the lite library treats them uniformly with regular GADGETs — same gtype namespace, same shape metadata.""" - from deq.transpiler.jit_library_builder import build_jit_program - source = """ CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 @@ -1641,10 +2047,6 @@ def test_build_jit_program_inlines_compose_as_synthetic_gadget() -> None: def test_build_jit_program_drives_compile_program_for_jit() -> None: """The lite library has just enough metadata for :func:`compile_program_for_jit` to produce a valid program.""" - from deq.cli.jit import compile_program_for_jit - from deq.circuit.model import ProgramDefinition - from deq.transpiler.jit_library_builder import build_jit_program - source = """ CODE Rep [[3,1,1]] { LOGICAL X0*X1*X2 Z0*Z1*Z2 From 849879f4cfafb68e69b2a5ce210216f22d875728 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 11 Aug 2026 13:05:32 -0700 Subject: [PATCH 082/157] update test cases --- deq/tests/transpiler/jit_annotate_test.py | 124 ++++++++++++++++-- .../transpiler/jit_library_builder_test.py | 6 +- deq/tests/transpiler/jit_transpiler_test.py | 36 ++++- deq/tests/transpiler/test_code_validation.py | 3 - .../transpiler/test_compose_repropagate.py | 6 +- deq/tests/transpiler/test_noise_conversion.py | 34 ++++- 6 files changed, 187 insertions(+), 22 deletions(-) diff --git a/deq/tests/transpiler/jit_annotate_test.py b/deq/tests/transpiler/jit_annotate_test.py index 2378cc9b..ff81638e 100644 --- a/deq/tests/transpiler/jit_annotate_test.py +++ b/deq/tests/transpiler/jit_annotate_test.py @@ -2,14 +2,23 @@ from pathlib import Path +import pytest + +from deq.circuit.model import CodeDefinition, Decorator, InputPort, PauliProduct from deq.circuit.parser import parse, parse_file -from deq.transpiler.jit_annotate import annotate +from deq.transpiler.jit_annotate import ( + _annotate_code, + _format_measurement_ref, + _gadget_decorators_with_manual_checks, + _render_body_statement, + _render_input_or_output, + _render_pauli_product, + annotate, +) from deq.transpiler.jit_library_builder import build_jit_library REPO_ROOT = Path(__file__).resolve().parents[2] -REP_DEQ = ( - REPO_ROOT / "tests" / "circuit" / "repetition_code" / "repetition_code_d3.deq" -) +REP_DEQ = REPO_ROOT / "tests" / "circuit" / "repetition_code" / "repetition_code_d3.deq" def test_annotate_preserves_logicals_and_stabilizers() -> None: @@ -41,8 +50,9 @@ def test_annotate_comments_out_circuit_replaces_check_mode() -> None: } """) annotated = annotate(qfile) - # Noise instruction commented out. - assert "# X_ERROR" in annotated + # Physical noise stays available to simulation while ERROR rows decode it. + assert "@SIMULATE_ONLY\n X_ERROR(0.05) 0 1 2" in annotated + assert "ERROR(0.05)" in annotated # Measurement kept in original form. assert "M 0 1 2" in annotated # @CHECKS forced to manual(verify=0) for re-transpilation correctness. @@ -178,6 +188,102 @@ def test_annotate_renders_compose_as_gadget_and_program_verbatim() -> None: parse(annotated) +def test_annotate_compose_with_multiple_readouts() -> None: + qfile = parse(""" + CODE C [[1,1,1]] { LOGICAL X0 Z0 } + GADGET Measure { + INPUT C 0 + M 0 + READOUT M0 + READOUT M0 + } + COMPOSE Twice { + INPUT C 0 + Measure 0 + } + """) + + rendered = annotate(qfile) + compose = rendered.split("GADGET Twice {", 1)[1].split("\n}", 1)[0] + assert ( + sum(line.lstrip().startswith("READOUT") for line in compose.splitlines()) == 2 + ) + build_jit_library(parse(rendered)) + + +def test_annotation_helper_edge_contracts() -> None: + assert _render_pauli_product(PauliProduct(terms=())) == "_" + assert _render_input_or_output(InputPort(code_name="C"), "INPUT") == " INPUT C" + assert _annotate_code(CodeDefinition(name="C", n=1, k=0)) == "CODE C [[1,0]] {\n}" + + decorators = _gadget_decorators_with_manual_checks([Decorator(name="CUSTOM")]) + assert [str(decorator) for decorator in decorators] == [ + "@CUSTOM", + '@CHECKS("manual", verify=0)', + ] + + with pytest.raises(ValueError, match="negative global measurement index"): + _format_measurement_ref( + -1, + iv_count=0, + internal_count=0, + input_port_stab_counts=[], + output_port_stab_counts=[], + ) + with pytest.raises(ValueError, match="does not map to any input port"): + _format_measurement_ref( + 0, + iv_count=1, + internal_count=0, + input_port_stab_counts=[], + output_port_stab_counts=[], + ) + with pytest.raises(ValueError, match="out of range"): + _format_measurement_ref( + 1, + iv_count=0, + internal_count=1, + input_port_stab_counts=[], + output_port_stab_counts=[], + ) + with pytest.raises(TypeError, match="unhandled gadget statement"): + _render_body_statement(object()) # type: ignore[arg-type] + + +def test_annotate_continues_after_program_definition() -> None: + rendered = annotate(parse(""" + PROGRAM Empty {} + GADGET G { M 0 } + """)) + + assert rendered.index("PROGRAM Empty") < rendered.index("GADGET G") + + +def test_annotate_preserves_explicit_errors_without_loss() -> None: + qfile = parse(""" + CODE Q [[1,1,1]] { LOGICAL X0 Z0 } + GADGET G { + INPUT Q 0 + ERROR(0.02) LX0 + M 0 + OUTPUT Q 0 + } + """) + + original_error = build_jit_library(qfile).gadget_types[0].errors[0] + rendered = annotate(qfile) + active_error_lines = [ + line for line in rendered.splitlines() if line.lstrip().startswith("ERROR(") + ] + round_trip_errors = build_jit_library(parse(rendered)).gadget_types[0].errors + + assert active_error_lines == [" ERROR(0.02) LX0 # E0"] + assert len(round_trip_errors) == 1 + assert ( + round_trip_errors[0].SerializeToString() == original_error.SerializeToString() + ) + + # --------------------------------------------------------------------------- # READOUT propagation comments # --------------------------------------------------------------------------- @@ -406,9 +512,7 @@ def test_annotate_compose_preselect_translates_absolute_to_relative() -> None: compose_block = annotated.split("GADGET PrepAB", 1)[1] prepab_body = compose_block.split("}", 1)[0] preselect_lines = [ - line.strip() - for line in prepab_body.splitlines() - if "PRESELECT" in line + line.strip() for line in prepab_body.splitlines() if "PRESELECT" in line ] assert preselect_lines == [ "PRESELECT rec[-1] 0", @@ -453,7 +557,7 @@ def test_annotate_s_gate_on_trivial_code() -> None: assert "PROPAGATE OUT0.LZ0 FROM IN0.LX0 IN0.LZ0 FLIP" in annotated assert "PROPAGATE OUT0.LX0 FROM IN0.LX0" in annotated # The annotated form must transpile back to the same JIT library. - from deq.transpiler.jit_library_builder import build_jit_library + src_lib = build_jit_library(qfile) ann_lib = build_jit_library(parse(annotated)) src_cp = src_lib.gadget_types[0].base.correction_propagation diff --git a/deq/tests/transpiler/jit_library_builder_test.py b/deq/tests/transpiler/jit_library_builder_test.py index b5f52037..1c336074 100644 --- a/deq/tests/transpiler/jit_library_builder_test.py +++ b/deq/tests/transpiler/jit_library_builder_test.py @@ -99,7 +99,7 @@ def test_build_jit_library_projects_library_from_artifacts() -> None: def test_parallel_build_preserves_provenance() -> None: qfile = parse( """ - CODE C [[1,1,1]] { LOGICAL X0 Z0 STABILIZER } + CODE C [[1,1,1]] { LOGICAL X0 Z0 } GADGET A { INPUT C 0 LOSS_ERROR(0.1) 0 @@ -292,7 +292,7 @@ def test_compose_merges_loss_models_across_internal_ports() -> None: def test_compose_folds_internal_input_herald_onto_upstream_loss() -> None: source = """ - CODE C [[1,1,1]] { LOGICAL X0 Z0 STABILIZER } + CODE C [[1,1,1]] { LOGICAL X0 Z0 } GADGET A { INPUT C 0 @@ -325,7 +325,7 @@ def test_compose_folds_internal_input_herald_onto_upstream_loss() -> None: def test_nested_compose_preserves_loss_through_external_output() -> None: source = """ - CODE C [[1,1,1]] { LOGICAL X0 Z0 STABILIZER } + CODE C [[1,1,1]] { LOGICAL X0 Z0 } GADGET A { INPUT C 0 diff --git a/deq/tests/transpiler/jit_transpiler_test.py b/deq/tests/transpiler/jit_transpiler_test.py index d66c68f9..fce5a2ea 100644 --- a/deq/tests/transpiler/jit_transpiler_test.py +++ b/deq/tests/transpiler/jit_transpiler_test.py @@ -4,11 +4,13 @@ import pytest -from deq.circuit.model import CodeDefinition, GadgetDefinition -from deq.circuit.parser import parse_file +from deq.circuit.model import CodeDefinition, GadgetDefinition, InputPort, Instruction +from deq.circuit.parser import parse, parse_file from deq.transpiler.jit_transpiler import ( checks_equivalent, derive_checks_auto, + is_decode_only, + is_simulation_only, ) from deq.transpiler.check_plugins import resolve_gadget_checks @@ -22,6 +24,36 @@ ) +def test_instruction_visibility_helpers() -> None: + qfile = parse( + """ + GADGET G { + @SIMULATE_ONLY + X 0 + @DECODE_ONLY + Z 0 + H 0 + } + """ + ) + gadget = next( + definition + for definition in qfile.definitions + if isinstance(definition, GadgetDefinition) + ) + simulation_only, decode_only, undecorated = gadget.body + + assert is_simulation_only(simulation_only) + assert not is_decode_only(simulation_only) + assert is_decode_only(decode_only) + assert not is_simulation_only(decode_only) + assert isinstance(undecorated, Instruction) + assert not is_simulation_only(undecorated) + assert not is_decode_only(undecorated) + assert not is_simulation_only(InputPort(code_name="C")) + assert not is_decode_only(InputPort(code_name="C")) + + def _load_definitions() -> ( tuple[dict[str, CodeDefinition], dict[str, GadgetDefinition]] ): diff --git a/deq/tests/transpiler/test_code_validation.py b/deq/tests/transpiler/test_code_validation.py index d4911e0e..42ab6919 100644 --- a/deq/tests/transpiler/test_code_validation.py +++ b/deq/tests/transpiler/test_code_validation.py @@ -41,7 +41,6 @@ def test_logical_index_out_of_range_message(self) -> None: source = """ CODE WrongN[[1,1,1]] { LOGICAL X0*X1*X2 Z0 - STABILIZER } """ qfile = parse(source) @@ -93,7 +92,6 @@ def test_unknown_input_port_lists_known_codes(self) -> None: source = """ CODE Real[[1,1,1]] { LOGICAL X0 Z0 - STABILIZER } GADGET G { INPUT Trivial 0 @@ -114,7 +112,6 @@ def test_unknown_output_port_message(self) -> None: source = """ CODE Real[[1,1,1]] { LOGICAL X0 Z0 - STABILIZER } GADGET G { INPUT Real 0 diff --git a/deq/tests/transpiler/test_compose_repropagate.py b/deq/tests/transpiler/test_compose_repropagate.py index 572a474c..d8edf705 100644 --- a/deq/tests/transpiler/test_compose_repropagate.py +++ b/deq/tests/transpiler/test_compose_repropagate.py @@ -432,7 +432,7 @@ class TestPreselectPreservedThroughCompose: } """ - def test_emits_prepare_block_with_both_requires(self, tmp_path): + def test_emits_select_block_with_both_requires(self, tmp_path): from deq.cli.jit import jit_compile_program_to_file # Delegate to the same code path the CLI runs; it writes the @@ -446,8 +446,8 @@ def test_emits_prepare_block_with_both_requires(self, tmp_path): ) text = (tmp_path / "prep.stim").read_text(encoding="utf-8") - # Exactly one flat PREPARE containing both sub-gadgets' REQUIREs. - assert text.count("PREPARE {") == 1 + # Exactly one flat QDK SELECT containing both sub-gadgets' REQUIREs. + assert text.count("SELECT {") == 1 require_lines = [ line.strip() for line in text.splitlines() diff --git a/deq/tests/transpiler/test_noise_conversion.py b/deq/tests/transpiler/test_noise_conversion.py index 50e241b5..555cea75 100644 --- a/deq/tests/transpiler/test_noise_conversion.py +++ b/deq/tests/transpiler/test_noise_conversion.py @@ -9,7 +9,10 @@ import pytest from deq.circuit.model import Instruction, QubitTarget -from deq.transpiler.jit_noise_builder import enumerate_noise_mechanisms +from deq.transpiler.jit_noise_builder import ( + _real_measurement_count, + enumerate_noise_mechanisms, +) def _mechanisms(name, p, qubits): @@ -87,6 +90,35 @@ def test_single_mechanism_channels_unchanged(): assert prob == pytest.approx(0.05) +def test_pair_measurement_produces_one_real_result_per_pair(): + instruction = Instruction( + "MXX", targets=[QubitTarget(0), QubitTarget(1)] + ) + + assert _real_measurement_count(instruction) == 1 + + +def test_noisy_pair_measurement_builds_one_flip_error_per_pair(): + from deq.circuit.parser import parse + from deq.transpiler.jit_library_builder import build_jit_library + + library = build_jit_library( + parse( + """ + GADGET G { + MXX(0.1) 0 1 + READOUT rec[-1] + } + """ + ) + ) + gadget = library.gadget_types[0] + + assert len(gadget.base.measurements) == 1 + assert len(gadget.errors) == 1 + assert list(gadget.errors[0].base.readout_flips) == [0] + + def _correlated_instr(name, p, paulis): from deq.circuit.model import PauliTarget From c6d1036d2c525e7494a9c8fc206b4c12f16e1055 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Fri, 14 Aug 2026 10:44:41 -0700 Subject: [PATCH 083/157] add more tests --- deq/tests/circuit/test_annotate.py | 35 + deq/tests/circuit/test_annotate_loss.py | 45 +- .../circuit/test_annotate_split_views.py | 1025 +++++++++++++++++ .../transpiler/fault_propagation_test.py | 106 ++ 4 files changed, 1202 insertions(+), 9 deletions(-) create mode 100644 deq/tests/circuit/test_annotate_split_views.py create mode 100644 deq/tests/transpiler/fault_propagation_test.py diff --git a/deq/tests/circuit/test_annotate.py b/deq/tests/circuit/test_annotate.py index 7517e49e..acff4ecd 100644 --- a/deq/tests/circuit/test_annotate.py +++ b/deq/tests/circuit/test_annotate.py @@ -19,6 +19,7 @@ from pathlib import Path import deq.proto.deq_jit_pb2 as jit_pb +import pytest from deq.circuit.parser import parse as parse_deq, render_and_parse_file from deq.cli.strip_tags import strip_jit_library from deq.transpiler.jit_annotate import annotate as annotate_impl @@ -167,3 +168,37 @@ def test_annotate_conditional_readout_flip() -> None: failed, so a plain roundtrip assertion is sufficient to guard it. """ _assert_annotate_roundtrip(CIRCUIT_DIR / "fixtures" / "conditional_readout_flip.deq") + + +def test_annotate_readout_compose() -> None: + """Round-trip the fixture where COMPOSE changes a readout's physical basis.""" + _assert_annotate_roundtrip(CIRCUIT_DIR / "fixtures" / "readout_compose.deq") + + +def test_composed_readout_renders_destabilizer_override(monkeypatch) -> None: + import deq.transpiler.jit_annotate as annotate_module + + qfile = render_and_parse_file( + str(CIRCUIT_DIR / "fixtures" / "readout_compose.deq"), + mako_defs=None, + skip_mako_warning=True, + ) + original_build = annotate_module.build_jit_library_artifacts + + def with_destabilizer_override(qfile): + artifacts = original_build(qfile) + propagation = artifacts.gadget_artifacts_by_name[ + "XY" + ].jit_type.base.readout_propagation + propagation.i.append(0) + propagation.j.append(2) + return artifacts + + monkeypatch.setattr( + annotate_module, + "build_jit_library_artifacts", + with_destabilizer_override, + ) + rendered = annotate_module.annotate(qfile) + compose = rendered.split("GADGET XY {", 1)[1].split("\n}", 1)[0] + assert "READOUT M0 M1 IN0.DS0" in compose diff --git a/deq/tests/circuit/test_annotate_loss.py b/deq/tests/circuit/test_annotate_loss.py index f7887c07..ffb2491d 100644 --- a/deq/tests/circuit/test_annotate_loss.py +++ b/deq/tests/circuit/test_annotate_loss.py @@ -1,5 +1,7 @@ """Annotator tests for the ``LOSS`` block derived from the binary loss model.""" +import pytest + from deq.circuit.parser import parse from deq.cli.strip_tags import strip_jit_library from deq.transpiler.jit_annotate import annotate as render_annotated @@ -8,7 +10,6 @@ _FAITHFUL_LOSS_SRC = """ CODE C[[3,1,1]] { LOGICAL X0 Z0 - STABILIZER } GADGET G { @@ -41,18 +42,22 @@ def test_loss_block_is_emitted_with_labels() -> None: def test_faithful_loss_round_trips_byte_equivalent() -> None: qfile = parse(_FAITHFUL_LOSS_SRC) - rendered = render_annotated(qfile, keep_noise=False) + rendered = render_annotated(qfile) orig, _ = strip_jit_library(build_jit_library(qfile)) anno, _ = strip_jit_library(build_jit_library(parse(rendered))) assert orig.SerializeToString() == anno.SerializeToString() -def test_keep_noise_omits_expanded_loss_block() -> None: - # Preserved noise reconstructs the loss model on re-transpilation, so the - # expanded LOSS block is not emitted. - rendered = render_annotated(parse(_FAITHFUL_LOSS_SRC), keep_noise=True) - assert "LOSS(0.1)" not in rendered - assert "# L0" not in rendered +def test_annotation_retains_declared_loss_metadata() -> None: + qfile = parse(_FAITHFUL_LOSS_SRC) + rendered = render_annotated(qfile) + + assert "LOSS(0.1) SE0 CE1 L1 OUT0.L2 M1" in rendered + assert "# L0" in rendered + + original, _ = strip_jit_library(build_jit_library(qfile)) + rebuilt, _ = strip_jit_library(build_jit_library(parse(rendered))) + assert original.SerializeToString() == rebuilt.SerializeToString() def test_original_loss_statements_are_not_duplicated() -> None: @@ -67,7 +72,7 @@ def test_loss_free_gadget_emits_no_loss_block() -> None: rendered = render_annotated( parse( """ - CODE C[[1,1,1]] { LOGICAL X0 Z0 STABILIZER } + CODE C[[1,1,1]] { LOGICAL X0 Z0 } GADGET G { INPUT C 0 M 0 @@ -78,3 +83,25 @@ def test_loss_free_gadget_emits_no_loss_block() -> None: ) assert "LOSS(" not in rendered assert "# L0" not in rendered + + +def test_empty_gadget_loss_model_is_rejected(monkeypatch) -> None: + import deq.transpiler.jit_annotate as annotate_module + + qfile = parse(_FAITHFUL_LOSS_SRC) + original_build = annotate_module.build_jit_library_artifacts + + def with_empty_loss_model(qfile): + artifacts = original_build(qfile) + loss_model = artifacts.gadget_artifacts_by_name["G"].jit_type.base.loss_model + loss_model.Clear() + loss_model.SetInParent() + return artifacts + + monkeypatch.setattr( + annotate_module, + "build_jit_library_artifacts", + with_empty_loss_model, + ) + with pytest.raises(AssertionError, match="has an empty loss model"): + annotate_module.annotate(qfile) diff --git a/deq/tests/circuit/test_annotate_split_views.py b/deq/tests/circuit/test_annotate_split_views.py new file mode 100644 index 00000000..2ef447c6 --- /dev/null +++ b/deq/tests/circuit/test_annotate_split_views.py @@ -0,0 +1,1025 @@ +"""Tests for the simulation/decoder split emitted by ``deq annotate``. + +Physical noise is retained under ``@SIMULATE_ONLY`` and the canonical +``ERROR(p) ...`` rows are emitted explicitly. Noisy measurements receive clean +``@DECODE_ONLY`` twins so both views have the same records. +""" + +import pytest + +from deq.cli.strip_tags import strip_jit_library +from deq.circuit.model import GadgetDefinition, Instruction +from deq.circuit.parser import parse +from deq.transpiler.jit_annotate import annotate as render_annotated +from deq.transpiler.jit_library_builder import build_jit_library +from deq.transpiler.jit_transpiler import flatten_body + +_NOISY_GADGET_SRC = """ +CODE C[[1,1,1]] { + LOGICAL X0 Z0 +} + +GADGET Prep { + R 0 + X_ERROR(0.05) 0 + OUTPUT C 0 +} + +GADGET Idle { + INPUT C 0 + DEPOLARIZE1(0.01) 0 + OUTPUT C 0 +} + +GADGET Meas { + INPUT C 0 + M(0.02) 0 + READOUT M0 +} +""" + + +class TestSplitViewGadget: + """Annotation separates simulation noise from decoder metadata.""" + + def test_splits_simulation_noise_and_decoder_errors(self) -> None: + rendered = render_annotated(parse(_NOISY_GADGET_SRC)) + assert "@SIMULATE_ONLY\n X_ERROR(0.05) 0" in rendered + assert "@SIMULATE_ONLY\n DEPOLARIZE1(0.01) 0" in rendered + assert ( + "@SIMULATE_ONLY\n M(0.02) 0\n" + " @DECODE_ONLY\n M 0" + ) in rendered + assert "\n ERROR(" in rendered + + prep = rendered.split("GADGET Prep {", 1)[1].split("\n}", 1)[0] + assert prep.index("X_ERROR(0.05) 0") < prep.index("ERROR(0.05)") + assert prep.index("ERROR(0.05)") < prep.index("OUTPUT C 0") + + def test_declared_and_noise_errors_stay_at_source_positions(self) -> None: + rendered = render_annotated( + parse( + """ + CODE C[[1,1,1]] { LOGICAL X0 Z0 } + GADGET G { + INPUT C 0 + X_ERROR(0.01) 0 + ERROR(0.02) LX0 + H 0 + OUTPUT C 0 + } + """ + ) + ) + gadget = rendered.split("GADGET G {", 1)[1].split("\n}", 1)[0] + + positions = [ + gadget.index("X_ERROR(0.01) 0"), + gadget.index("ERROR(0.01)"), + gadget.index("ERROR(0.02) LX0 # E1"), + gadget.index("H 0"), + ] + assert positions == sorted(positions) + + def test_round_trips_byte_equivalent(self) -> None: + qfile = parse(_NOISY_GADGET_SRC) + rendered = render_annotated(qfile) + orig_lib = build_jit_library(qfile) + anno_lib = build_jit_library(parse(rendered)) + orig_stripped, _ = strip_jit_library(orig_lib) + anno_stripped, _ = strip_jit_library(anno_lib) + assert orig_stripped.SerializeToString() == anno_stripped.SerializeToString() + + def test_annotation_is_idempotent(self) -> None: + qfile = parse(_NOISY_GADGET_SRC) + rendered_once = render_annotated(qfile) + rendered_twice = render_annotated(parse(rendered_once)) + + assert rendered_twice.count("X_ERROR(0.05) 0") == 1 + assert rendered_twice.count("DEPOLARIZE1(0.01) 0") == 1 + assert rendered_twice.count("M(0.02) 0") == 1 + + original, _ = strip_jit_library(build_jit_library(qfile)) + rebuilt, _ = strip_jit_library(build_jit_library(parse(rendered_twice))) + assert original.SerializeToString() == rebuilt.SerializeToString() + + def test_trailing_simulation_only_instruction_is_preserved(self) -> None: + rendered = render_annotated( + parse( + """ + CODE C[[1,1,1]] { LOGICAL X0 Z0 } + GADGET G { + INPUT C 0 + @SIMULATE_ONLY + X_ERROR(0.01) 0 + } + """ + ) + ) + gadget = rendered.split("GADGET G {", 1)[1].split("\n}", 1)[0] + assert gadget.index("INPUT C 0") < gadget.index("@SIMULATE_ONLY") + assert gadget.index("@SIMULATE_ONLY") < gadget.index("X_ERROR(0.01) 0") + + annotated = parse(rendered) + rendered_twice = render_annotated(annotated) + assert rendered_twice.count("X_ERROR(0.01) 0") == 1 + + def test_error_index_comments_survive_second_annotation(self) -> None: + qfile = parse( + """ + CODE C[[1,1,1]] { LOGICAL X0 Z0 } + GADGET G { + INPUT C 0 + X_ERROR(0.01) 0 + H 0 + ERROR(0.02) LX0 + OUTPUT C 0 + } + """ + ) + + rendered_once = render_annotated(qfile) + rendered_twice = render_annotated(parse(rendered_once)) + once_errors = [ + line.strip() + for line in rendered_once.splitlines() + if line.lstrip().startswith("ERROR(") + ] + twice_errors = [ + line.strip() + for line in rendered_twice.splitlines() + if line.lstrip().startswith("ERROR(") + ] + + assert once_errors == twice_errors + assert once_errors[0].endswith("# E0") + assert once_errors[1].endswith("# E1") + + def test_noisy_measurement_has_one_instruction_in_each_view(self) -> None: + rendered = render_annotated(parse(_NOISY_GADGET_SRC)) + qfile = parse(rendered) + gadget = next( + definition + for definition in qfile.definitions + if isinstance(definition, GadgetDefinition) and definition.name == "Meas" + ) + + decode_measurements = [ + str(statement) + for statement in flatten_body(gadget.body) + if isinstance(statement, Instruction) and statement.name.upper() == "M" + ] + simulation_measurements = [ + str(statement) + for statement in flatten_body(gadget.body, for_simulate=True) + if isinstance(statement, Instruction) and statement.name.upper() == "M" + ] + assert decode_measurements == ["M 0"] + assert simulation_measurements == ["M(0.02) 0"] + + def test_existing_noise_decorators_preserve_view_intent(self) -> None: + qfile = parse( + """ + CODE C[[1,1,1]] { LOGICAL X0 Z0 } + GADGET G { + INPUT C 0 + @SIMULATE_ONLY + X_ERROR(0.01) 0 + @DECODE_ONLY + Z_ERROR(0.02) 0 + OUTPUT C 0 + } + """ + ) + + rendered = render_annotated(qfile) + annotated = parse(rendered) + gadget = next( + definition + for definition in annotated.definitions + if isinstance(definition, GadgetDefinition) + ) + decode_instructions = [ + str(statement) + for statement in flatten_body(gadget.body) + if isinstance(statement, Instruction) + ] + simulation_instructions = [ + str(statement) + for statement in flatten_body(gadget.body, for_simulate=True) + if isinstance(statement, Instruction) + ] + + assert "X_ERROR(0.01) 0" in simulation_instructions + assert "X_ERROR(0.01) 0" not in decode_instructions + assert "Z_ERROR(0.02) 0" not in simulation_instructions + assert "Z_ERROR(0.02) 0" not in decode_instructions + assert "ERROR(0.02) OUT0.LZ0 # E0" in rendered + + original, _ = strip_jit_library(build_jit_library(qfile)) + rebuilt, _ = strip_jit_library(build_jit_library(annotated)) + assert original.SerializeToString() == rebuilt.SerializeToString() + + def test_decorated_noisy_measurements_preserve_each_view(self) -> None: + qfile = parse( + """ + CODE C[[1,1,1]] { LOGICAL X0 Z0 } + GADGET G { + INPUT C 0 + @SIMULATE_ONLY + M(0.03) 0 + @DECODE_ONLY + M(0.04) 0 + READOUT M0 + } + """ + ) + + rendered = render_annotated(qfile) + annotated = parse(rendered) + gadget = next( + definition + for definition in annotated.definitions + if isinstance(definition, GadgetDefinition) + ) + decode_measurements = [ + str(statement) + for statement in flatten_body(gadget.body) + if isinstance(statement, Instruction) and statement.name.upper() == "M" + ] + simulation_measurements = [ + str(statement) + for statement in flatten_body(gadget.body, for_simulate=True) + if isinstance(statement, Instruction) and statement.name.upper() == "M" + ] + + assert decode_measurements == ["M 0"] + assert simulation_measurements == ["M(0.03) 0"] + assert "ERROR(0.04) R0 # E0" in rendered + + original, _ = strip_jit_library(build_jit_library(qfile)) + rebuilt, _ = strip_jit_library(build_jit_library(annotated)) + assert original.SerializeToString() == rebuilt.SerializeToString() + + def test_decode_only_noisy_measurement_stays_decode_only_and_clean(self) -> None: + qfile = parse( + """ + CODE C[[1,1,1]] { LOGICAL X0 Z0 } + GADGET G { + INPUT C 0 + @SIMULATE_ONLY + M 0 + @DECODE_ONLY + M(0.04) 0 + READOUT M0 + } + """ + ) + + rendered = render_annotated(qfile) + annotated = parse(rendered) + gadget = next( + definition + for definition in annotated.definitions + if isinstance(definition, GadgetDefinition) + ) + decode_measurements = [ + str(statement) + for statement in flatten_body(gadget.body) + if isinstance(statement, Instruction) and statement.name.upper() == "M" + ] + simulation_measurements = [ + str(statement) + for statement in flatten_body(gadget.body, for_simulate=True) + if isinstance(statement, Instruction) and statement.name.upper() == "M" + ] + + assert decode_measurements == ["M 0"] + assert simulation_measurements == ["M 0"] + assert "@SIMULATE_ONLY\n M(0.04) 0" not in rendered + assert "@DECODE_ONLY\n M 0" in rendered + assert "ERROR(0.04) R0 # E0" in rendered + assert rendered.index("@DECODE_ONLY\n M 0") < rendered.index("# E0") + assert rendered.index("# E0") < rendered.index("READOUT M0") + + original, _ = strip_jit_library(build_jit_library(qfile)) + rebuilt, _ = strip_jit_library(build_jit_library(annotated)) + assert original.SerializeToString() == rebuilt.SerializeToString() + + def test_unpaired_decode_only_measurement_is_rejected(self) -> None: + qfile = parse( + """ + CODE C[[1,1,1]] { LOGICAL X0 Z0 } + GADGET G { + INPUT C 0 + @DECODE_ONLY + M(0.04) 0 + READOUT M0 + } + """ + ) + + with pytest.raises(ValueError, match="mismatched measurement counts"): + render_annotated(qfile) + + def test_existing_non_noise_decorators_are_preserved(self) -> None: + qfile = parse( + """ + CODE C[[1,1,1]] { LOGICAL X0 Z0 } + GADGET G { + INPUT C 0 + @SIMULATE_ONLY + H 0 + @DECODE_ONLY + S 0 + OUTPUT C 0 + } + """ + ) + + rendered = render_annotated(qfile) + assert "@SIMULATE_ONLY\n H 0" in rendered + assert "@DECODE_ONLY\n S 0" in rendered + + original, _ = strip_jit_library(build_jit_library(qfile)) + rebuilt, _ = strip_jit_library(build_jit_library(parse(rendered))) + assert original.SerializeToString() == rebuilt.SerializeToString() + + def test_program_stim_export_uses_only_simulation_noise(self) -> None: + from deq.cli.jit import compile_program_for_jit, export_program_stim + from deq.circuit.model import ProgramDefinition + + qfile = parse( + """ + CODE C[[1,1,1]] { LOGICAL X0 Z0 } + GADGET Prep { + R 0 + X_ERROR(0.01) 0 + LOSS_ERROR(0.1) 0 + OUTPUT C 0 + } + GADGET Meas { + INPUT C 0 + M(0.02) 0 + READOUT M0 + } + PROGRAM Run { + Prep OUT(0) + Meas IN(0) + } + """ + ) + annotated = parse(render_annotated(qfile)) + library = build_jit_library(annotated) + program = next( + definition + for definition in annotated.definitions + if isinstance(definition, ProgramDefinition) + ) + gadgets = { + definition.name: definition + for definition in annotated.definitions + if isinstance(definition, GadgetDefinition) + } + compiled, assertions = compile_program_for_jit(library, program) + for instruction, _ in compiled: + library.program.append(instruction) + + stim_text = export_program_stim( + library, + gadgets, + {gadget.base.gtype: gadget.base.name for gadget in library.gadget_types}, + flatten_body, + program, + [application for _, application in compiled], + assertions, + ) + + assert stim_text.count("X_ERROR(0.01)") == 1 + assert stim_text.count("LOSS_ERROR(0.1)") == 1 + assert stim_text.count("M(0.02)") == 1 + assert "\nM 0\n" not in stim_text + + def test_annotated_compose_stim_export_preserves_decorated_views( + self, tmp_path + ) -> None: + from deq.cli.jit import jit_compile_program_to_file + + qfile = parse( + """ + CODE C[[1,1,1]] { LOGICAL X0 Z0 } + GADGET Prep { + R 0 + @SIMULATE_ONLY + X_ERROR(0.01) 0 + @DECODE_ONLY + Z_ERROR(0.02) 0 + OUTPUT C 0 + } + COMPOSE Chain { + Prep 0 + OUTPUT C 0 + } + GADGET Sink { + INPUT C 0 + M 0 + READOUT M0 + } + PROGRAM Run { + Chain OUT(0) + Sink IN(0) + } + """ + ) + annotated = parse(render_annotated(qfile)) + output_path = tmp_path / "run.deq.jit" + jit_compile_program_to_file( + build_jit_library(annotated), + annotated, + str(output_path), + program="Run", + ) + stim_text = (tmp_path / "run.stim").read_text(encoding="utf-8") + + assert stim_text.count("X_ERROR(0.01)") == 1 + assert "Z_ERROR(0.02)" not in stim_text + + +_REPROPAGATE_TELEPORT_SRC = """ +CODE Code[[4,1,2]] { + LOGICAL X0*X2 Z0*Z1 + STABILIZER Z0*Z2 Z1*Z3 X0*X1*X2*X3 +} + +GADGET PrepareZero { + R 0 1 2 3 + X_ERROR(0.05) 0 1 2 3 + MPP X0*X1*X2*X3 + OUTPUT Code 0 1 2 3 +} + +GADGET CNOT { + INPUT Code 0 1 2 3 + INPUT Code 4 5 6 7 + CX 0 4 1 5 2 6 3 7 + DEPOLARIZE2(0.01) 0 4 1 5 2 6 3 7 + OUTPUT Code 0 1 2 3 + OUTPUT Code 4 5 6 7 +} + +GADGET MeasureX { + INPUT Code 0 1 2 3 + MX(0.02) 0 1 2 3 + READOUT M0 M2 +} + +@REPROPAGATE +COMPOSE Teleport { + INPUT Code 0 + PrepareZero 1 + CNOT 0 1 + MeasureX 0 + OUTPUT Code 1 +} +""" + + +class TestSplitViewRepropagateCompose: + """Split-view annotation combined with ``@REPROPAGATE``.""" + + def test_repropagate_round_trips(self) -> None: + qfile = parse(_REPROPAGATE_TELEPORT_SRC) + rendered = render_annotated(qfile) + # The composed gadget is rendered as a flat GADGET block. + assert "GADGET Teleport {" in rendered + # Noise instructions are present verbatim. + assert "X_ERROR(0.05)" in rendered + assert "DEPOLARIZE2(0.01)" in rendered + assert "MX(0.02)" in rendered + teleport = rendered.split("GADGET Teleport {", 1)[1].split("\n}", 1)[0] + assert teleport.index("X_ERROR(0.05)") < teleport.index("ERROR(0.05)") + # Re-transpile and compare. + orig_lib = build_jit_library(qfile) + anno_lib = build_jit_library(parse(rendered)) + orig_stripped, _ = strip_jit_library(orig_lib) + anno_stripped, _ = strip_jit_library(anno_lib) + assert orig_stripped.SerializeToString() == anno_stripped.SerializeToString() + + def test_repropagate_preserves_decorated_measurement_views(self) -> None: + qfile = parse( + """ + CODE C[[1,1,1]] { LOGICAL X0 Z0 } + GADGET Measure { + INPUT C 0 + @SIMULATE_ONLY + M(0.03) 0 + @DECODE_ONLY + M(0.04) 0 + READOUT M0 + OUTPUT C 0 + } + @REPROPAGATE + COMPOSE Chain { + INPUT C 0 + Measure 0 + OUTPUT C 0 + } + """ + ) + + rendered = render_annotated(qfile) + chain = rendered.split("GADGET Chain {", 1)[1] + assert "@SIMULATE_ONLY\n M(0.03) 0" in chain + assert "@DECODE_ONLY\n M 0" in chain + assert "M(0.04) 0" not in chain + assert "ERROR(0.04) R0 OUT0.LX0 # E0" in chain + + original, _ = strip_jit_library(build_jit_library(qfile)) + rebuilt, _ = strip_jit_library(build_jit_library(parse(rendered))) + assert original.SerializeToString() == rebuilt.SerializeToString() + +_NON_REPROPAGATE_NOISY_COMPOSE_SRC = """ +CODE C[[1,1,1]] { + LOGICAL X0 Z0 +} + +GADGET Idle { + INPUT C 0 + DEPOLARIZE1(0.01) 0 + OUTPUT C 0 +} + +COMPOSE Sequence { + INPUT C 0 + Idle 0 + OUTPUT C 0 +} +""" + + +class TestSplitViewNonRepropagateCompose: + """A normal COMPOSE retains simulation noise and canonical errors.""" + + def test_round_trips(self) -> None: + qfile = parse(_NON_REPROPAGATE_NOISY_COMPOSE_SRC) + rendered = render_annotated(qfile) + orig_lib = build_jit_library(qfile) + anno_lib = build_jit_library(parse(rendered)) + orig_stripped, _ = strip_jit_library(orig_lib) + anno_stripped, _ = strip_jit_library(anno_lib) + assert orig_stripped.SerializeToString() == anno_stripped.SerializeToString() + + def test_missing_provenance_is_rejected( + self, monkeypatch + ) -> None: + import deq.transpiler.jit_annotate as annotate_module + from deq.transpiler.jit_library_builder import JitGadgetArtifacts + + qfile = parse(_NON_REPROPAGATE_NOISY_COMPOSE_SRC) + original_build = annotate_module.build_jit_library_artifacts + + def without_compose_provenance(qfile): + artifacts = original_build(qfile) + composed = artifacts.gadget_artifacts_by_name["Sequence"] + artifacts.gadget_artifacts_by_name["Sequence"] = JitGadgetArtifacts( + jit_type=composed.jit_type + ) + return artifacts + + monkeypatch.setattr( + annotate_module, + "build_jit_library_artifacts", + without_compose_provenance, + ) + with pytest.raises(AssertionError, match="error provenance is incomplete"): + annotate_module.annotate(qfile) + + def test_preserves_existing_split_view_noise(self) -> None: + qfile = parse( + """ + CODE C[[1,1,1]] { LOGICAL X0 Z0 } + GADGET Split { + INPUT C 0 + @SIMULATE_ONLY + X_ERROR(0.01) 0 + @DECODE_ONLY + Z_ERROR(0.02) 0 + OUTPUT C 0 + } + COMPOSE Chain { + INPUT C 0 + Split 0 + OUTPUT C 0 + } + """ + ) + + rendered = render_annotated(qfile) + chain = rendered.split("GADGET Chain {", 1)[1] + assert "@SIMULATE_ONLY\n X_ERROR(0.01) 0" in chain + assert "@SIMULATE_ONLY\n Z_ERROR(0.02) 0" not in chain + assert "\n ERROR(0.02) OUT0.LZ0 # E0" in chain + + original, _ = strip_jit_library(build_jit_library(qfile)) + rebuilt, _ = strip_jit_library(build_jit_library(parse(rendered))) + assert original.SerializeToString() == rebuilt.SerializeToString() + + def test_simulation_only_child_does_not_shift_later_error(self) -> None: + qfile = parse( + """ + CODE C[[1,1,1]] { LOGICAL X0 Z0 } + GADGET SimulationOnly { + INPUT C 0 + @SIMULATE_ONLY + X_ERROR(0.01) 0 + OUTPUT C 0 + } + GADGET Noisy { + INPUT C 0 + Z_ERROR(0.02) 0 + OUTPUT C 0 + } + COMPOSE Chain { + INPUT C 0 + SimulationOnly 0 + Noisy 0 + OUTPUT C 0 + } + """ + ) + + rendered = render_annotated(qfile) + chain = rendered.split("GADGET Chain {", 1)[1] + assert "@SIMULATE_ONLY\n X_ERROR(0.01) 0" in chain + assert chain.index("Z_ERROR(0.02) 0") < chain.index("# E0") + assert chain.index("# E0") < chain.index("OUTPUT C 0") + + original, _ = strip_jit_library(build_jit_library(qfile)) + rebuilt, _ = strip_jit_library(build_jit_library(parse(rendered))) + assert original.SerializeToString() == rebuilt.SerializeToString() + + def test_nested_compose_preserves_existing_split_view_noise(self) -> None: + qfile = parse( + """ + CODE C[[1,1,1]] { LOGICAL X0 Z0 } + GADGET Split { + INPUT C 0 + @SIMULATE_ONLY + X_ERROR(0.01) 0 + @DECODE_ONLY + Z_ERROR(0.02) 0 + OUTPUT C 0 + } + COMPOSE Inner { + INPUT C 0 + Split 0 + OUTPUT C 0 + } + COMPOSE Outer { + INPUT C 0 + Inner 0 + OUTPUT C 0 + } + """ + ) + + rendered = render_annotated(qfile) + outer = rendered.split("GADGET Outer {", 1)[1] + assert "@SIMULATE_ONLY\n X_ERROR(0.01) 0" in outer + assert "@SIMULATE_ONLY\n Z_ERROR(0.02) 0" not in outer + assert "ERROR(0.02) OUT0.LZ0 # E0" in outer + assert outer.index("Z_ERROR(0.02) 0") < outer.index("# E0") + assert outer.index("# E0") < outer.index("OUTPUT C 0") + + original, _ = strip_jit_library(build_jit_library(qfile)) + rebuilt, _ = strip_jit_library(build_jit_library(parse(rendered))) + assert original.SerializeToString() == rebuilt.SerializeToString() + + +_PASSTHROUGH_LOSS_SRC = """ +CODE C[[1,1,1]] { + LOGICAL X0 Z0 +} + +GADGET Prep { + R 0 + X_ERROR(0.05) 0 + LOSS_ERROR(0.5) 0 + OUTPUT C 0 +} + +GADGET Meas { + INPUT C 0 + LOSS_ERROR(0.3) 0 + M 0 + READOUT M0 +} +""" + + +_PASSTHROUGH_LOSS_COMPOSE_SRC = """ +CODE C[[1,1,1]] { + LOGICAL X0 Z0 +} + +GADGET Idle { + INPUT C 0 + DEPOLARIZE1(0.01) 0 + LOSS_ERROR(0.4) 0 + OUTPUT C 0 +} + +COMPOSE Sequence { + INPUT C 0 + Idle 0 + OUTPUT C 0 +} +""" + + +_DECLARED_LOSS_NOISY_COMPOSE_SRC = """ +CODE C[[1,1,1]] { + LOGICAL X0 Z0 +} + +GADGET Declared { + INPUT C 0 + OUTPUT C 0 + ERROR(0) LX0 + LOSS(0.1) SE0 CE0 OUT0.L0 + LOSS(IN0.L0) CE0 L0 +} + +GADGET Noisy { + INPUT C 0 + X_ERROR(0.01) 0 + OUTPUT C 0 +} + +COMPOSE Chain { + INPUT C 0 + Declared 0 + Noisy 0 + OUTPUT C 0 +} +""" + + +class TestSplitViewPassthroughLoss: + """``LOSS_ERROR`` remains physical while explicit loss metadata decodes.""" + + def test_loss_error_is_split_from_decoder_metadata(self) -> None: + rendered = render_annotated(parse(_PASSTHROUGH_LOSS_SRC)) + assert "@SIMULATE_ONLY\n LOSS_ERROR(0.5) 0" in rendered + assert "@SIMULATE_ONLY\n LOSS_ERROR(0.3) 0" in rendered + assert "\n LOSS(0.5)" in rendered + assert "ERROR(0.05)" in rendered + + def test_empty_composed_loss_model_is_rejected(self, monkeypatch) -> None: + import deq.transpiler.jit_annotate as annotate_module + + qfile = parse(_PASSTHROUGH_LOSS_COMPOSE_SRC) + original_build = annotate_module.build_jit_library_artifacts + + def with_empty_loss_model(qfile): + artifacts = original_build(qfile) + loss_model = artifacts.gadget_artifacts_by_name[ + "Sequence" + ].jit_type.base.loss_model + loss_model.Clear() + loss_model.SetInParent() + return artifacts + + monkeypatch.setattr( + annotate_module, + "build_jit_library_artifacts", + with_empty_loss_model, + ) + with pytest.raises(AssertionError, match="has an empty loss model"): + annotate_module.annotate(qfile) + + def test_loss_error_round_trips_byte_equivalent(self) -> None: + qfile = parse(_PASSTHROUGH_LOSS_SRC) + rendered = render_annotated(qfile) + orig_lib = build_jit_library(qfile) + anno_lib = build_jit_library(parse(rendered)) + orig_stripped, _ = strip_jit_library(orig_lib) + anno_stripped, _ = strip_jit_library(anno_lib) + assert orig_stripped.SerializeToString() == anno_stripped.SerializeToString() + + def test_loss_annotation_is_idempotent(self) -> None: + qfile = parse(_PASSTHROUGH_LOSS_SRC) + rendered_once = render_annotated(qfile) + rendered_twice = render_annotated(parse(rendered_once)) + + assert rendered_twice.count("LOSS_ERROR(0.5) 0") == 1 + assert rendered_twice.count("LOSS_ERROR(0.3) 0") == 1 + + original, _ = strip_jit_library(build_jit_library(qfile)) + rebuilt, _ = strip_jit_library(build_jit_library(parse(rendered_twice))) + assert original.SerializeToString() == rebuilt.SerializeToString() + + def test_decorated_loss_errors_preserve_view_intent(self) -> None: + qfile = parse( + """ + CODE C[[1,1,1]] { LOGICAL X0 Z0 } + GADGET G { + INPUT C 0 + @SIMULATE_ONLY + LOSS_ERROR(0.11) 0 + @DECODE_ONLY + LOSS_ERROR(0.22) 0 + OUTPUT C 0 + } + """ + ) + + rendered = render_annotated(qfile) + annotated = parse(rendered) + gadget = next( + definition + for definition in annotated.definitions + if isinstance(definition, GadgetDefinition) + ) + decode_instructions = [ + str(statement) + for statement in flatten_body(gadget.body) + if isinstance(statement, Instruction) + ] + simulation_instructions = [ + str(statement) + for statement in flatten_body(gadget.body, for_simulate=True) + if isinstance(statement, Instruction) + ] + + assert "LOSS_ERROR(0.11) 0" in simulation_instructions + assert "LOSS_ERROR(0.22) 0" not in simulation_instructions + assert not any(instruction.startswith("LOSS_ERROR") for instruction in decode_instructions) + assert "LOSS(0.22)" in rendered + + original, _ = strip_jit_library(build_jit_library(qfile)) + rebuilt, _ = strip_jit_library(build_jit_library(annotated)) + assert original.SerializeToString() == rebuilt.SerializeToString() + + def test_loss_error_is_split_inside_compose(self) -> None: + qfile = parse(_PASSTHROUGH_LOSS_COMPOSE_SRC) + rendered = render_annotated(qfile) + assert "GADGET Sequence {" in rendered + sequence_block = rendered.split("GADGET Sequence {", 1)[1] + assert "\n LOSS(0.4)" in sequence_block + assert "@SIMULATE_ONLY\n DEPOLARIZE1(0.01) 0" in sequence_block + assert "@SIMULATE_ONLY\n LOSS_ERROR(0.4) 0" in sequence_block + + original = build_jit_library(qfile) + rebuilt = build_jit_library(parse(rendered)) + original_stripped, _ = strip_jit_library(original) + rebuilt_stripped, _ = strip_jit_library(rebuilt) + assert ( + original_stripped.SerializeToString() + == rebuilt_stripped.SerializeToString() + ) + + def test_compose_loss_indices_survive_later_noise(self) -> None: + qfile = parse( + """ + CODE C[[1,1,1]] { LOGICAL X0 Z0 } + + GADGET First { + INPUT C 0 + X_ERROR(0.01) 0 + LOSS_ERROR(0.1) 0 + OUTPUT C 0 + } + + GADGET Second { + INPUT C 0 + Z_ERROR(0.02) 0 + OUTPUT C 0 + } + + COMPOSE Chain { + INPUT C 0 + First 0 + Second 0 + OUTPUT C 0 + } + """ + ) + + rendered = render_annotated(qfile) + chain = rendered.split("GADGET Chain {", 1)[1] + assert "@SIMULATE_ONLY\n X_ERROR(0.01) 0" in chain + assert "@SIMULATE_ONLY\n LOSS_ERROR(0.1) 0" in chain + assert "@SIMULATE_ONLY\n Z_ERROR(0.02) 0" in chain + assert "\n LOSS(0.1)" in chain + positions = [ + chain.index("X_ERROR(0.01) 0"), + chain.index("# E0"), + chain.index("LOSS_ERROR(0.1) 0"), + chain.index("# E1"), + chain.index("Z_ERROR(0.02) 0"), + chain.index("# E2"), + chain.index("# E3"), + chain.index("OUTPUT C 0"), + ] + assert positions == sorted(positions) + + original, _ = strip_jit_library(build_jit_library(qfile)) + rebuilt, _ = strip_jit_library(build_jit_library(parse(rendered))) + assert original.SerializeToString() == rebuilt.SerializeToString() + + def test_compose_loss_indices_do_not_deduplicate_by_footprint(self) -> None: + qfile = parse( + """ + CODE C[[1,1,1]] { LOGICAL X0 Z0 } + + GADGET First { + INPUT C 0 + X_ERROR(0.01) 0 + OUTPUT C 0 + } + + GADGET Second { + INPUT C 0 + LOSS_ERROR(0.1) 0 + OUTPUT C 0 + } + + COMPOSE Chain { + INPUT C 0 + First 0 + Second 0 + OUTPUT C 0 + } + """ + ) + + rendered = render_annotated(qfile) + chain = rendered.split("GADGET Chain {", 1)[1] + assert "@SIMULATE_ONLY\n X_ERROR(0.01) 0" in chain + assert "@SIMULATE_ONLY\n LOSS_ERROR(0.1) 0" in chain + assert all(f"# E{index}" in chain for index in range(4)) + + original, _ = strip_jit_library(build_jit_library(qfile)) + rebuilt, _ = strip_jit_library(build_jit_library(parse(rendered))) + assert original.SerializeToString() == rebuilt.SerializeToString() + + def test_declared_loss_metadata_coexists_with_preserved_compose_noise( + self, + ) -> None: + qfile = parse(_DECLARED_LOSS_NOISY_COMPOSE_SRC) + rendered = render_annotated(qfile) + chain = rendered.split("GADGET Chain {", 1)[1] + + assert "\n ERROR(0.0) OUT0.LX0 # E0" in chain + assert "@SIMULATE_ONLY\n X_ERROR(0.01) 0" in chain + assert "\n ERROR(0.01) OUT0.LX0 # E1" in chain + assert "\n ERROR(0.0) OUT0.LZ0 # E2" in chain + assert "\n LOSS(0.1)" in chain + assert chain.index("# E0") < chain.index("X_ERROR(0.01) 0") + assert chain.index("X_ERROR(0.01) 0") < chain.index("# E1") + assert chain.index("# E1") < chain.index("# E2") + + original, _ = strip_jit_library(build_jit_library(qfile)) + rebuilt, _ = strip_jit_library(build_jit_library(parse(rendered))) + assert original.SerializeToString() == rebuilt.SerializeToString() + + def test_mixed_compose_loss_forms_use_merged_metadata(self) -> None: + qfile = parse( + """ + CODE C[[1,1,1]] { LOGICAL X0 Z0 } + + GADGET Inferred { + INPUT C 0 + LOSS_ERROR(0.1) 0 + H 0 + OUTPUT C 0 + } + + GADGET Declared { + INPUT C 0 + M 0 + OUTPUT C 0 + ERROR(0) LX0 + LOSS(0.2) SE0 M0 + LOSS(IN0.L0) CE0 L0 M0 + } + + COMPOSE Chain { + INPUT C 0 + Inferred 0 + Declared 0 + OUTPUT C 0 + } + """ + ) + + rendered = render_annotated(qfile) + chain = rendered.split("GADGET Chain {", 1)[1] + assert "@SIMULATE_ONLY\n LOSS_ERROR(0.1) 0" in chain + assert "\n LOSS(0.1)" in chain + assert "\n LOSS(0.2)" in chain + + original, _ = strip_jit_library(build_jit_library(qfile)) + rebuilt, _ = strip_jit_library(build_jit_library(parse(rendered))) + assert original.SerializeToString() == rebuilt.SerializeToString() diff --git a/deq/tests/transpiler/fault_propagation_test.py b/deq/tests/transpiler/fault_propagation_test.py new file mode 100644 index 00000000..3d93adb1 --- /dev/null +++ b/deq/tests/transpiler/fault_propagation_test.py @@ -0,0 +1,106 @@ +"""Tests for the shared circuit-fault propagation timeline.""" + +from typing import cast + +import pytest + +from deq.circuit.model import GadgetDefinition, GadgetStatement +from deq.circuit.parser import parse +from deq.transpiler.fault_propagation import ( + ErrorProjectionContext, + build_decomposed_body, +) +from deq.transpiler.jit_transpiler import flatten_body + + +def _raw_body(source: str): + qfile = parse(source) + gadget = next( + definition + for definition in qfile.definitions + if isinstance(definition, GadgetDefinition) + ) + return list(gadget.body) + + +def _body(source: str): + return flatten_body(_raw_body(source)) + + +def test_loss_error_uses_the_following_gate_boundary() -> None: + timeline = build_decomposed_body( + _body("GADGET G { H 0 LOSS_ERROR(0.1) 0 MX 0 }") + ) + + assert timeline.body_start_at == (0, 1, 1) + assert [instruction.name for instruction in timeline.instructions] == [ + "H", + "H", + "M", + "H", + ] + assert timeline.measurement_start_at == (0, 0, 0, 1) + assert timeline.total_measurements == 1 + + +def test_adjacent_source_gates_keep_distinct_boundaries() -> None: + timeline = build_decomposed_body(_body("GADGET G { H 0 H 1 }")) + + assert timeline.body_start_at == (0, 1) + assert [str(instruction) for instruction in timeline.instructions] == [ + "H 0", + "H 1", + ] + + +def test_user_tick_does_not_participate_in_boundary_mapping() -> None: + timeline = build_decomposed_body(_body("GADGET G { H 0 TICK MX 1 }")) + + assert timeline.body_start_at == (0, 1, 1) + assert [str(instruction) for instruction in timeline.instructions] == [ + "H 0", + "H 1", + "M 1", + "H 1", + ] + + +def test_unflattened_repeat_block_is_rejected() -> None: + with pytest.raises(ValueError, match="call flatten_body"): + build_decomposed_body( + _raw_body("GADGET G { REPEAT 2 { M 0 } }") + ) + + +def test_unknown_body_block_is_rejected() -> None: + class UnknownBlock: + body: list[object] = [] + + with pytest.raises(TypeError, match="UnknownBlock"): + build_decomposed_body( + [cast(GadgetStatement, UnknownBlock())] + ) + + +def test_measurement_count_uses_stim_instruction_metadata() -> None: + timeline = build_decomposed_body( + _body("GADGET G { MPAD 0 H 0 }") + ) + + assert timeline.measurement_start_at == (0, 1) + assert timeline.total_measurements == 1 + + +def test_output_stabilizer_measurement_index_uses_offset() -> None: + context = ErrorProjectionContext( + input_virtual_count=0, + finished_member_lists=(), + unfinished_member_lists=(), + output_stabilizer_measurement_offset=7, + readout_measurement_sets=(), + logical_columns=set(), + unfinished_to_column=(), + physical_correction_by_logical={}, + ) + + assert context.output_stabilizer_measurement_index(3) == 10 \ No newline at end of file From 326e235d7dd3b8d70c7d89791286292b7825aab4 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Fri, 14 Aug 2026 13:48:34 -0700 Subject: [PATCH 084/157] add tests --- deq/deq/transpiler/jit_annotate.py | 1 - deq/tests/circuit/test_loss_statement.py | 80 ++++++++++++ deq/tests/runtime/test_mle_loss_decoder.py | 136 +++++++++++++++++++++ 3 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 deq/tests/circuit/test_loss_statement.py create mode 100644 deq/tests/runtime/test_mle_loss_decoder.py diff --git a/deq/deq/transpiler/jit_annotate.py b/deq/deq/transpiler/jit_annotate.py index e3d6a147..b13d09b2 100644 --- a/deq/deq/transpiler/jit_annotate.py +++ b/deq/deq/transpiler/jit_annotate.py @@ -1196,7 +1196,6 @@ def emit_error(error_index: int) -> None: # an explicit ``FLIP`` token to keep the readout row round-tripping. if affine_col in binary_cols: rec_refs.append("FLIP") - assert rec_refs, f"GADGET {name!r} readout R{row_index} has no source" comment = _format_propagation_comment( prop, row_index, diff --git a/deq/tests/circuit/test_loss_statement.py b/deq/tests/circuit/test_loss_statement.py new file mode 100644 index 00000000..239d0857 --- /dev/null +++ b/deq/tests/circuit/test_loss_statement.py @@ -0,0 +1,80 @@ +"""Parsing and rendering tests for the ``LOSS(...)`` statement.""" + +import pytest + +from deq.circuit.model import Instruction, LossStatement +from deq.circuit.parser import parse + + +def _gadget_body(src: str) -> list: + qfile = parse(src) + gadget = next( + d for d in qfile.definitions if d.__class__.__name__ == "GadgetDefinition" + ) + return gadget.body + + +_HEADER = "CODE C[[3,1,1]] { LOGICAL X0 Z0 }\n" + + +def test_source_loss_parses_all_target_kinds() -> None: + body = _gadget_body( + _HEADER + "GADGET G { LOSS(0.1) SE0 SE3 CE4 CE5 L3 L4 OUT0.L2 M4 }\n" + ) + (loss,) = [s for s in body if isinstance(s, LossStatement)] + assert not loss.is_input + assert loss.probability == 0.1 + assert loss.source_errors == [0, 3] + assert loss.continuation_errors == [4, 5] + assert loss.child_losses == [3, 4] + assert loss.output_qubits == [(0, 2)] + assert loss.measurement_indices == [4] + + +def test_input_loss_parses_without_probability_or_source_errors() -> None: + body = _gadget_body( + _HEADER + "GADGET G { LOSS(IN0.L1) CE0 CE1 L1 L2 OUT0.L2 M1 }\n" + ) + (loss,) = [s for s in body if isinstance(s, LossStatement)] + assert loss.is_input + assert loss.probability is None + assert loss.input_port == 0 + assert loss.input_qubit == 1 + assert loss.source_errors == [] + assert loss.continuation_errors == [0, 1] + assert loss.child_losses == [1, 2] + assert loss.output_qubits == [(0, 2)] + assert loss.measurement_indices == [1] + + +@pytest.mark.parametrize( + "line", + [ + "LOSS(0.1) SE0 SE3 CE4 CE5 L3 L4 OUT0.L2 M4", + "LOSS(IN0.L1) CE0 CE1 L1 L2 OUT0.L2 M1", + "LOSS(0.05)", + "LOSS(IN2.L7)", + ], +) +def test_loss_statement_round_trips_through_str(line: str) -> None: + body = _gadget_body(_HEADER + f"GADGET G {{ {line} }}\n") + (loss,) = [s for s in body if isinstance(s, LossStatement)] + assert str(loss) == line + + +def test_loss_error_instruction_is_unaffected_by_loss_keyword() -> None: + body = _gadget_body(_HEADER + "GADGET G { LOSS_ERROR(0.1) 0 }\n") + (instruction,) = [s for s in body if isinstance(s, Instruction)] + assert instruction.name.upper() == "LOSS_ERROR" + + +def test_input_loss_rejects_source_error_target() -> None: + with pytest.raises(SyntaxError): + parse(_HEADER + "GADGET G { LOSS(IN0.L0) SE0 }\n") + + +def test_source_loss_rejects_zero_probability() -> None: + # Every loss node is a declared site; zero-probability nodes are never + # created, so LOSS(0) is rejected at parse time. + with pytest.raises(SyntaxError): + parse(_HEADER + "GADGET G { LOSS(0) M0 }\n") diff --git a/deq/tests/runtime/test_mle_loss_decoder.py b/deq/tests/runtime/test_mle_loss_decoder.py new file mode 100644 index 00000000..6a6e2fe5 --- /dev/null +++ b/deq/tests/runtime/test_mle_loss_decoder.py @@ -0,0 +1,136 @@ +"""Focused tests for the bundled generator-MILP loss decoder.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +def _decoder_module(): + path = ( + Path(__file__).resolve().parents[2] + / "deq_runtime" + / "src" + / "decoder" + / "mle_loss_decoder.py" + ) + spec = importlib.util.spec_from_file_location("test_mle_loss_decoder_impl", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _hypergraph(*edges): + return SimpleNamespace( + vertex_num=1, + hyperedges=[ + SimpleNamespace(vertices=list(vertices), probability=probability) + for vertices, probability in edges + ], + ) + + +def _site(*, source=(), continuation=(), children=()): + return SimpleNamespace( + source_edges=list(source), + continuation_edges=list(continuation), + children=list(children), + probability=0.0, + ) + + +def test_ordinary_positive_prior_edge_satisfies_syndrome() -> None: + decoder = _decoder_module().Decoder(_hypergraph(([0], 0.1))) + + assert decoder.decode([0]) == [0] + assert decoder.decode([]) == [] + + +def test_loss_activates_zero_prior_source_edge() -> None: + decoder = _decoder_module().Decoder(_hypergraph(([0], 0.0))) + loss = SimpleNamespace(sites=[_site(source=[0])]) + + with pytest.raises(RuntimeError, match="produced no solution"): + decoder.decode([0]) + assert decoder.decode([0], loss) == [0] + + +def test_parent_start_enables_child_continuation_edge() -> None: + decoder = _decoder_module().Decoder(_hypergraph(([0], 0.0))) + sites = [ + _site(children=[1]), + _site(continuation=[0]), + ] + + enabling, loss_edges, components = decoder._loss_structure(sites) + + assert enabling == {0: {0, 1}} + assert loss_edges == {0} + assert list(components.values()) == [[0, 1]] + assert decoder.decode([0], SimpleNamespace(sites=sites)) == [0] + + +@pytest.mark.parametrize("field", ["source_edges", "continuation_edges"]) +def test_out_of_range_loss_edge_is_rejected(field: str) -> None: + decoder = _decoder_module().Decoder(_hypergraph(([0], 0.0))) + site = _site() + setattr(site, field, [1]) + + with pytest.raises(ValueError, match=r"edge 1, outside \[0, 1\)"): + decoder.decode([0], SimpleNamespace(sites=[site])) + + +def test_out_of_range_child_site_is_rejected() -> None: + decoder = _decoder_module().Decoder(_hypergraph(([0], 0.0))) + + with pytest.raises(ValueError, match=r"site 1, outside \[0, 1\)"): + decoder.decode( + [0], + SimpleNamespace(sites=[_site(source=[0], children=[1])]), + ) + + +def test_cyclic_loss_sites_are_rejected() -> None: + decoder = _decoder_module().Decoder(_hypergraph(([0], 0.0))) + loss = SimpleNamespace( + sites=[ + _site(source=[0], children=[1]), + _site(continuation=[0], children=[0]), + ] + ) + + with pytest.raises(ValueError, match="children graph contains a cycle"): + decoder.decode([0], loss) + + +def test_solver_without_solution_is_reported(monkeypatch: pytest.MonkeyPatch) -> None: + module = _decoder_module() + decoder = module.Decoder(_hypergraph(([0], 0.1))) + monkeypatch.setattr( + module, + "milp", + lambda **_kwargs: SimpleNamespace( + x=None, + status=1, + message="time limit reached", + ), + ) + + with pytest.raises( + RuntimeError, + match=r"produced no solution \(status=1\): time limit reached", + ): + decoder.decode([0]) + + +def test_empty_hypergraph_still_validates_loss_edges() -> None: + decoder = _decoder_module().Decoder(_hypergraph()) + + with pytest.raises(ValueError, match=r"edge 0, outside \[0, 0\)"): + decoder.decode([], SimpleNamespace(sites=[_site(source=[0])])) \ No newline at end of file From 195db6650648245238ad8ead9339b61f4fc0aabb Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 15 Aug 2026 12:09:55 -0700 Subject: [PATCH 085/157] add more test cases --- deq/deq/runtime/__init__.py | 4 +- deq/deq_runtime/src/misc/sync.rs | 42 +++- .../src/proto/deq.decoder.blackbox_decoder.rs | 117 +++++++++- deq/deq_runtime/src/simulator/common.rs | 8 +- .../src/simulator/preselect_directives.rs | 125 ++++++----- deq/deq_runtime/src/simulator/qdk_sampler.py | 14 +- .../simulator/tableau_preselect_sampler.rs | 18 +- deq/deq_runtime/tests/mock_decoder_test.rs | 160 +++++++++++++- .../tests/monolithic_coordinator_test.rs | 192 ++++++++++++++++- .../tests/standard_decoder_test.rs | 204 +++++++++++++++++- deq/deq_runtime/tests/task_counter_test.rs | 22 ++ deq/deqagram/src/ast.rs | 16 +- 12 files changed, 833 insertions(+), 89 deletions(-) diff --git a/deq/deq/runtime/__init__.py b/deq/deq/runtime/__init__.py index 60d6729b..3a861acb 100644 --- a/deq/deq/runtime/__init__.py +++ b/deq/deq/runtime/__init__.py @@ -480,9 +480,9 @@ class Sampler: :class:`KeyError` if no program by that name exists in the file. simulator: Backend name. ``"stim"`` (default) uses Stim's compiled measurement sampler, auto-wrapping with resample-on-failure - when the circuit has ``PREPARE { ... REQUIRE ... }`` blocks + when the circuit has ``SELECT { ... REQUIRE ... }`` blocks (QDK v1.30+ preselection syntax). ``"preselect"`` uses a - tableau-based sampler that natively runs each PREPARE block + tableau-based sampler that natively runs each SELECT block with retry-from-checkpoint semantics. simulator_config: Optional JSON string or mapping with backend options (currently ``preselect_max_attempts``). diff --git a/deq/deq_runtime/src/misc/sync.rs b/deq/deq_runtime/src/misc/sync.rs index 24fcba64..b1a2f549 100644 --- a/deq/deq_runtime/src/misc/sync.rs +++ b/deq/deq_runtime/src/misc/sync.rs @@ -1,5 +1,5 @@ use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use tokio::sync::{Notify, watch}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; @@ -62,6 +62,7 @@ pub fn check_or_receiver( /// shared state. pub struct TaskCounter { count: AtomicUsize, + accepting: AtomicBool, notify: Notify, } @@ -69,6 +70,7 @@ impl TaskCounter { pub fn new() -> Arc { Arc::new(Self { count: AtomicUsize::new(0), + accepting: AtomicBool::new(true), notify: Notify::new(), }) } @@ -96,6 +98,32 @@ impl TaskCounter { counter: Arc::clone(self), } } + + /// Admit a new top-level operation unless reset has paused admission. + /// A second acceptance check closes the race with [`Self::try_pause`]. + pub fn try_guard(self: &Arc) -> Option { + if !self.accepting.load(Ordering::Acquire) { + return None; + } + let guard = self.guard(); + if self.accepting.load(Ordering::Acquire) { + Some(guard) + } else { + drop(guard); + None + } + } + + /// Stop admitting top-level operations until the returned guard is dropped. + /// Returns `None` when another reset already owns the pause. + pub fn try_pause(self: &Arc) -> Option { + self.accepting + .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire) + .ok() + .map(|_| TaskCounterPause { + counter: Arc::clone(self), + }) + } } /// RAII guard that decrements the [`TaskCounter`] when dropped. @@ -113,3 +141,15 @@ impl Drop for TaskGuard { } } } + +/// RAII admission pause used by reset. Dropping it reopens the coordinator even +/// when reset returns early with an error. +pub struct TaskCounterPause { + counter: Arc, +} + +impl Drop for TaskCounterPause { + fn drop(&mut self) { + self.counter.accepting.store(true, Ordering::Release); + } +} diff --git a/deq/deq_runtime/src/proto/deq.decoder.blackbox_decoder.rs b/deq/deq_runtime/src/proto/deq.decoder.blackbox_decoder.rs index 52adf73a..9aa3bd04 100644 --- a/deq/deq_runtime/src/proto/deq.decoder.blackbox_decoder.rs +++ b/deq/deq_runtime/src/proto/deq.decoder.blackbox_decoder.rs @@ -1,5 +1,10 @@ // This file is @generated by prost-build. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DecoderCapabilities { + #[prost(enumeration = "DecoderFeature", repeated, tag = "1")] + pub features: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct ResetRequest { /// by default keep all the hypergraphs intact #[prost(bool, tag = "1")] @@ -43,7 +48,7 @@ pub struct LoadedDecodingProblem { } /// Replaces the prior of one hyperedge, addressed by its index in the loaded /// hypergraph. The probability is *assigned*, not combined: it may raise or lower -/// the loaded prior. +/// the loaded prior. Each edge may appear at most once in a request. #[derive(Clone, Copy, PartialEq, ::prost::Message)] pub struct EdgeReweight { #[prost(uint64, tag = "1")] @@ -108,6 +113,35 @@ pub struct LossSite { #[prost(double, tag = "4")] pub probability: f64, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum DecoderFeature { + Unspecified = 0, + Reweights = 1, + Loss = 2, +} +impl DecoderFeature { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "DECODER_FEATURE_UNSPECIFIED", + Self::Reweights => "DECODER_FEATURE_REWEIGHTS", + Self::Loss => "DECODER_FEATURE_LOSS", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "DECODER_FEATURE_UNSPECIFIED" => Some(Self::Unspecified), + "DECODER_FEATURE_REWEIGHTS" => Some(Self::Reweights), + "DECODER_FEATURE_LOSS" => Some(Self::Loss), + _ => None, + } + } +} /// Generated client implementations. #[cfg(feature = "cli")] pub mod black_box_decoder_client { @@ -200,6 +234,37 @@ pub mod black_box_decoder_client { self.inner = self.inner.max_encoding_message_size(limit); self } + /// Report optional request fields this decoder can consume. Advertising + /// multiple features means they may be used together in one decode request. + pub async fn get_capabilities( + &mut self, + request: impl tonic::IntoRequest<()>, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/deq.decoder.blackbox_decoder.BlackBoxDecoder/GetCapabilities", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "deq.decoder.blackbox_decoder.BlackBoxDecoder", + "GetCapabilities", + ), + ); + self.inner.unary(req, path, codec).await + } /// calling this service is in general inefficient because it has to process /// the entire decoding hypergraph during runtime. Consider using preloaded /// decoder instances like the rpc interfaces followed @@ -329,6 +394,15 @@ pub mod black_box_decoder_server { /// Generated trait containing gRPC methods that should be implemented for use with BlackBoxDecoderServer. #[async_trait] pub trait BlackBoxDecoder: std::marker::Send + std::marker::Sync + 'static { + /// Report optional request fields this decoder can consume. Advertising + /// multiple features means they may be used together in one decode request. + async fn get_capabilities( + &self, + request: tonic::Request<()>, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; /// calling this service is in general inefficient because it has to process /// the entire decoding hypergraph during runtime. Consider using preloaded /// decoder instances like the rpc interfaces followed @@ -432,6 +506,47 @@ pub mod black_box_decoder_server { } fn call(&mut self, req: http::Request) -> Self::Future { match req.uri().path() { + "/deq.decoder.blackbox_decoder.BlackBoxDecoder/GetCapabilities" => { + #[allow(non_camel_case_types)] + struct GetCapabilitiesSvc(pub Arc); + impl tonic::server::UnaryService<()> + for GetCapabilitiesSvc { + type Response = super::DecoderCapabilities; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call(&mut self, request: tonic::Request<()>) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_capabilities(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetCapabilitiesSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } "/deq.decoder.blackbox_decoder.BlackBoxDecoder/Decode" => { #[allow(non_camel_case_types)] struct DecodeSvc(pub Arc); diff --git a/deq/deq_runtime/src/simulator/common.rs b/deq/deq_runtime/src/simulator/common.rs index a0d3cdbe..818dd31b 100644 --- a/deq/deq_runtime/src/simulator/common.rs +++ b/deq/deq_runtime/src/simulator/common.rs @@ -60,7 +60,7 @@ pub struct CommonSimulatorConfig { #[serde(default)] pub logical_assert_filepath: Option, /// Maximum number of resample attempts when preselect checks fail. - /// Only used when the Stim circuit contains `PREPARE { ... REQUIRE ... }` + /// Only used when the Stim circuit contains `SELECT { ... REQUIRE ... }` /// blocks (QDK v1.30+). #[serde(default = "default_preselect_max_attempts")] pub preselect_max_attempts: u64, @@ -414,7 +414,7 @@ pub fn load_stim_circuit( #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub enum SamplerType { /// Stim's compiled measurement sampler. When the circuit contains - /// `PREPARE { ... REQUIRE ... }` blocks, the result is automatically + /// `SELECT { ... REQUIRE ... }` blocks, the result is automatically /// wrapped with [`ResamplePreselectSampler`], which resamples each shot /// from the beginning until every REQUIRE check passes. Stim, @@ -578,7 +578,7 @@ impl Sampler for ResamplePreselectSampler { #[serde(deny_unknown_fields, default)] pub struct StimSamplerConfig { /// Maximum number of resample attempts when the circuit contains - /// `PREPARE { ... REQUIRE ... }` blocks. Ignored when the circuit has no + /// `SELECT { ... REQUIRE ... }` blocks. Ignored when the circuit has no /// preselect directives. pub preselect_max_attempts: u64, } @@ -718,7 +718,7 @@ mod tests { // The PREPARE/REQUIRE block enforces that measurement 0 == 0 // (REQUIRE rec[-1] succeeds when XOR of listed records is 0). // About half the samples should be rejected. - let circuit_text = "PREPARE {\nH 0\nM 0\nREQUIRE rec[-1]\n}\n"; + let circuit_text = "SELECT {\nH 0\nM 0\nREQUIRE rec[-1]\n}\n"; let stim_text = crate::simulator::preselect_directives::strip_preselect_directives(circuit_text); let inner = StimSampler::new(&stim_text, 42, 0, false); diff --git a/deq/deq_runtime/src/simulator/preselect_directives.rs b/deq/deq_runtime/src/simulator/preselect_directives.rs index 92ca063e..5137306a 100644 --- a/deq/deq_runtime/src/simulator/preselect_directives.rs +++ b/deq/deq_runtime/src/simulator/preselect_directives.rs @@ -1,13 +1,15 @@ -//! Parse `PREPARE { ... REQUIRE ... }` blocks from Stim text (QDK v1.30+). +//! Parse `SELECT { ... REQUIRE ... }` blocks from QDK Stim text. //! //! These blocks are emitted by the Python `export_program_stim` function -//! to encode preselection: a `PREPARE { ... }` region runs as a +//! to encode preselection: a `SELECT { ... }` region runs as a //! repeat-until-success unit, restarting from the opening brace whenever //! any embedded `REQUIRE` check fails. Because the upstream `stim` -//! crate does not understand `PREPARE` / `REQUIRE`, callers that need to +//! crate does not understand `SELECT` / `REQUIRE`, callers that need to //! feed the text to `stim::Circuit::from_str` must first call //! [`strip_preselect_directives`] to drop the block markers and require //! lines while preserving every real instruction. +//! Legacy `PREPARE { ... }` blocks emitted by older deq versions are accepted +//! as an input alias, but new output and diagnostics use QDK's `SELECT` spelling. //! //! Two consumers exist: //! @@ -25,7 +27,7 @@ //! ## Syntax //! //! ```text -//! PREPARE { +//! SELECT { //! H 0 //! M 0 //! REQUIRE rec[-1] // succeeds when the last measurement == 0 @@ -38,11 +40,11 @@ //! contribution. Equivalently, `REQUIRE !rec[-1]` succeeds when the //! record equals 1. //! * `rec[-k]` inside a `REQUIRE` refers to the k-th most recent -//! measurement produced **inside the enclosing PREPARE block**. +//! measurement produced **inside the enclosing SELECT block**. //! Referencing a measurement produced outside the block is a parse //! error. -//! * `PREPARE` blocks do **not** nest: the deq JIT emitter always -//! produces at most one top-level `PREPARE` per gadget, and opening +//! * `SELECT` blocks do **not** nest: the deq JIT emitter always +//! produces at most one top-level `SELECT` per gadget, and opening //! a second block before closing the current one is a parse error. use super::stim_delays::{MEASUREMENT_INSTRUCTIONS, count_measurement_targets}; @@ -94,13 +96,13 @@ impl PreselectSchedule { /// The extractor slices the input into a flat sequence of blocks in /// source order. Two flavours exist: /// -/// * **PREPARE bodies** (`checks` non-empty): the real instructions -/// inside a `PREPARE { ... }` region, together with the `REQUIRE` +/// * **SELECT bodies** (`checks` non-empty): the real instructions +/// inside a `SELECT { ... }` region, together with the `REQUIRE` /// clauses that gate acceptance. The sampler re-executes the whole /// `stim_text` until every check passes. /// * **Plain segments** (`checks` empty): any contiguous run of real -/// instructions outside a `PREPARE` region — text before the first -/// `PREPARE {`, text between one block's closing `}` and the next +/// instructions outside a `SELECT` region — text before the first +/// `SELECT {`, text between one block's closing `}` and the next /// opening `{`, and text after the final `}`. These execute exactly /// once. /// @@ -123,7 +125,7 @@ pub fn extract_preselect_schedule(stim_text: &str) -> PreselectSchedule { PreselectSchedule { checks } } -/// Strip every `PREPARE {` / matching `}` / `REQUIRE ...` line from the +/// Strip every `SELECT {` / matching `}` / `REQUIRE ...` line from the /// stim text so the residual is parseable by the upstream `stim` crate. /// /// The rest of the text (including comments, `#!delay`, and real @@ -139,27 +141,27 @@ pub fn strip_preselect_directives(stim_text: &str) -> String { parts.join("\n") } -/// Parse `PREPARE` / `REQUIRE` directives out of the stim text and +/// Parse `SELECT` / `REQUIRE` directives out of the stim text and /// return a flat sequence of [`PreselectBlock`]s. /// /// The sequence alternates between plain segments (no `checks`) and -/// `PREPARE` bodies (non-empty `checks`), in source order. Empty -/// plain segments are omitted, so between two consecutive `PREPARE` +/// `SELECT` bodies (non-empty `checks`), in source order. Empty +/// plain segments are omitted, so between two consecutive `SELECT` /// bodies with nothing between them the returned vector jumps from one /// check-carrying block to the next. /// /// # Panics /// -/// Panics on malformed input: unclosed `PREPARE`, a nested `PREPARE {` +/// Panics on malformed input: unclosed `SELECT`, a nested `SELECT {` /// opened before the current block is closed, a `REQUIRE` outside any -/// `PREPARE` block, a `rec[-0]` target, or a `rec[-k]` that references +/// `SELECT` block, a `rec[-0]` target, or a `rec[-k]` that references /// a measurement not produced inside the enclosing block. pub fn extract_preselect_blocks(stim_text: &str) -> Vec { let mut blocks: Vec = Vec::new(); - // A single active `PREPARE { ... }` frame. The deq JIT emitter + // A single active `SELECT { ... }` frame. The deq JIT emitter // never nests these, and hand-authored circuits should follow the - // same contract; opening a second `PREPARE {` while one is already + // same contract; opening a second `SELECT {` while one is already // active is a parse error. struct Frame { block_start_global_meas: usize, @@ -171,9 +173,9 @@ pub fn extract_preselect_blocks(stim_text: &str) -> Vec { // Global measurement count across the whole circuit so far. let mut global_meas: usize = 0; - // Buffer for real instructions that live outside any PREPARE + // Buffer for real instructions that live outside any SELECT // block; flushed into a plain (no-check) `PreselectBlock` whenever - // a `PREPARE {` opens or the input ends. + // a `SELECT {` opens or the input ends. let mut plain_lines: Vec = Vec::new(); let flush_plain = |plain_lines: &mut Vec, blocks: &mut Vec| { @@ -189,17 +191,20 @@ pub fn extract_preselect_blocks(stim_text: &str) -> Vec { for raw_line in stim_text.lines() { let trimmed = raw_line.trim(); - // PREPARE { — open a block. - if let Some(rest) = trimmed.strip_prefix("PREPARE") { + // SELECT { — open a block. PREPARE is accepted for old deq output. + if let Some((keyword, rest)) = ["SELECT", "PREPARE"] + .into_iter() + .find_map(|keyword| trimmed.strip_prefix(keyword).map(|rest| (keyword, rest))) + { let rest = rest.trim_start(); let open_brace_ok = rest.starts_with('{') && rest.trim_end_matches(|c: char| c.is_whitespace()) == "{"; assert!( open_brace_ok, - "PREPARE must be immediately followed by '{{' on the same line; got: {raw_line:?}" + "{keyword} must be immediately followed by '{{' on the same line; got: {raw_line:?}" ); assert!( current.is_none(), - "nested PREPARE blocks are not supported; close the current \ + "nested SELECT blocks are not supported; close the current \ block with `}}` before opening another one" ); flush_plain(&mut plain_lines, &mut blocks); @@ -212,7 +217,7 @@ pub fn extract_preselect_blocks(stim_text: &str) -> Vec { } // } — close the active block, but only if there is one. A - // standalone `}` outside a PREPARE block is left in the plain + // standalone `}` outside a SELECT block is left in the plain // buffer (e.g. a REPEAT block's closing brace). if trimmed == "}" && current.is_some() { let frame = current.take().expect("current non-empty (just checked)"); @@ -227,7 +232,7 @@ pub fn extract_preselect_blocks(stim_text: &str) -> Vec { if let Some(rest) = trimmed.strip_prefix("REQUIRE") { let frame = current .as_mut() - .expect("REQUIRE outside a PREPARE block; wrap the REQUIRE in `PREPARE { ... }`"); + .expect("REQUIRE outside a SELECT block; wrap the REQUIRE in `SELECT { ... }`"); let block_meas_count = global_meas - frame.block_start_global_meas; let mut targets: Vec = Vec::new(); for token in rest.split_whitespace() { @@ -242,7 +247,7 @@ pub fn extract_preselect_blocks(stim_text: &str) -> Vec { assert!( k <= block_meas_count, "REQUIRE target rec[-{k}] references a measurement outside the \ - enclosing PREPARE block (block has produced only {block_meas_count} \ + enclosing SELECT block (block has produced only {block_meas_count} \ measurement(s) so far)" ); let abs_meas_idx = global_meas - k; @@ -272,7 +277,7 @@ pub fn extract_preselect_blocks(stim_text: &str) -> Vec { // Route the line to the active block if any; otherwise it is a // plain-segment line that will be flushed when the next - // PREPARE opens or the input ends. + // SELECT opens or the input ends. if let Some(frame) = current.as_mut() { frame.lines.push(raw_line.to_owned()); } else { @@ -282,7 +287,7 @@ pub fn extract_preselect_blocks(stim_text: &str) -> Vec { assert!( current.is_none(), - "unterminated PREPARE block (missing `}}` before end of circuit)" + "unterminated SELECT block (missing `}}` before end of circuit)" ); flush_plain(&mut plain_lines, &mut blocks); @@ -328,7 +333,7 @@ mod tests { #[test] fn single_target_require() { - let text = "PREPARE {\nH 0\nM 0\nREQUIRE rec[-1]\n}\n"; + let text = "SELECT {\nH 0\nM 0\nREQUIRE rec[-1]\n}\n"; let blocks = extract_preselect_blocks(text); assert_eq!(blocks.len(), 1); assert_eq!(blocks[0].stim_text, "H 0\nM 0"); @@ -344,7 +349,7 @@ mod tests { #[test] fn negated_target_require() { - let text = "PREPARE {\nH 0\nM 0\nREQUIRE !rec[-1]\n}\n"; + let text = "SELECT {\nH 0\nM 0\nREQUIRE !rec[-1]\n}\n"; let schedule = extract_preselect_schedule(text); assert_eq!(schedule.checks.len(), 1); assert!(schedule.checks[0].targets[0].negated); @@ -353,7 +358,7 @@ mod tests { #[test] fn multi_target_require() { let text = "\ -PREPARE { +SELECT { H 0 H 1 M 0 @@ -377,7 +382,7 @@ REQUIRE !rec[-1] rec[-2] fn parity_encoded_via_single_negation_is_odd_parity() { // `REQUIRE !rec[-1] rec[-2]` succeeds when the XOR of the two // measurements is 1 (odd parity). - let text = "PREPARE {\nH 0\nH 1\nM 0\nM 1\nREQUIRE !rec[-1] rec[-2]\n}\n"; + let text = "SELECT {\nH 0\nH 1\nM 0\nM 1\nREQUIRE !rec[-1] rec[-2]\n}\n"; let schedule = extract_preselect_schedule(text); let check = &schedule.checks[0]; @@ -395,7 +400,7 @@ REQUIRE !rec[-1] rec[-2] fn prefix_body_and_tail_split_into_three_blocks() { let text = "\ R 0 1 -PREPARE { +SELECT { H 0 M 0 REQUIRE rec[-1] @@ -408,7 +413,7 @@ M 0 // Plain prefix. assert_eq!(blocks[0].stim_text, "R 0 1"); assert!(blocks[0].checks.is_empty()); - // PREPARE body. + // SELECT body. assert_eq!(blocks[1].stim_text, "H 0\nM 0"); assert_eq!(blocks[1].checks.len(), 1); // Plain tail. @@ -421,15 +426,15 @@ M 0 } #[test] - fn multiple_prepare_blocks() { + fn multiple_select_blocks() { let text = "\ -PREPARE { +SELECT { H 0 M 0 REQUIRE rec[-1] } CZ 0 1 -PREPARE { +SELECT { H 1 M 1 REQUIRE !rec[-1] @@ -446,44 +451,44 @@ REQUIRE !rec[-1] } #[test] - fn adjacent_prepare_blocks_skip_empty_plain_segment() { + fn adjacent_select_blocks_skip_empty_plain_segment() { let text = "\ -PREPARE { +SELECT { H 0 M 0 REQUIRE rec[-1] } -PREPARE { +SELECT { H 1 M 1 REQUIRE rec[-1] } "; let blocks = extract_preselect_blocks(text); - assert_eq!(blocks.len(), 2, "no empty plain block between two PREPAREs"); + assert_eq!(blocks.len(), 2, "no empty plain block between two SELECTs"); assert!(!blocks[0].checks.is_empty()); assert!(!blocks[1].checks.is_empty()); } #[test] - #[should_panic(expected = "REQUIRE outside a PREPARE block")] - fn require_outside_prepare_panics() { + #[should_panic(expected = "REQUIRE outside a SELECT block")] + fn require_outside_select_panics() { let _ = extract_preselect_blocks("M 0\nREQUIRE rec[-1]\n"); } #[test] - #[should_panic(expected = "unterminated PREPARE block")] - fn unterminated_prepare_panics() { - let _ = extract_preselect_blocks("PREPARE {\nM 0\nREQUIRE rec[-1]\n"); + #[should_panic(expected = "unterminated SELECT block")] + fn unterminated_select_panics() { + let _ = extract_preselect_blocks("SELECT {\nM 0\nREQUIRE rec[-1]\n"); } #[test] - #[should_panic(expected = "nested PREPARE blocks are not supported")] - fn nested_prepare_panics() { + #[should_panic(expected = "nested SELECT blocks are not supported")] + fn nested_select_panics() { let text = "\ -PREPARE { +SELECT { M 0 -PREPARE { +SELECT { M 1 REQUIRE rec[-1] } @@ -496,15 +501,23 @@ REQUIRE rec[-1] #[test] #[should_panic(expected = "rec[-0] is not allowed")] fn rec_zero_panics() { - let _ = extract_preselect_blocks("PREPARE {\nM 0\nREQUIRE rec[-0]\n}\n"); + let _ = extract_preselect_blocks("SELECT {\nM 0\nREQUIRE rec[-0]\n}\n"); } #[test] - #[should_panic(expected = "references a measurement outside the enclosing PREPARE block")] + #[should_panic(expected = "references a measurement outside the enclosing SELECT block")] fn out_of_block_reference_panics() { // Only one measurement inside the block, but REQUIRE asks for - // rec[-2], which would reference something before PREPARE. - let text = "M 0\nPREPARE {\nM 1\nREQUIRE rec[-2]\n}\n"; + // rec[-2], which would reference something before SELECT. + let text = "M 0\nSELECT {\nM 1\nREQUIRE rec[-2]\n}\n"; let _ = extract_preselect_blocks(text); } + + #[test] + fn legacy_prepare_alias_is_accepted() { + let blocks = extract_preselect_blocks("PREPARE {\nM 0\nREQUIRE rec[-1]\n}\n"); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].stim_text, "M 0"); + assert_eq!(blocks[0].checks.len(), 1); + } } diff --git a/deq/deq_runtime/src/simulator/qdk_sampler.py b/deq/deq_runtime/src/simulator/qdk_sampler.py index 0354da63..20addafb 100644 --- a/deq/deq_runtime/src/simulator/qdk_sampler.py +++ b/deq/deq_runtime/src/simulator/qdk_sampler.py @@ -67,6 +67,14 @@ def sample(self) -> str: from qdk._native import Result from qdk.simulation import run_qir +_QDK_SEED_MASK = (1 << 32) - 1 + + +def _to_qdk_seed(seed: int) -> int: + """Narrow a seed to the unsigned 32-bit range accepted by QDK.""" + return seed & _QDK_SEED_MASK + + # Mapping from qdk Result enum to the single-char alphabet the deq Rust # sampler expects. `Result` is a PyO3-bound class so hashing/equality # is fast (just an internal discriminant compare). @@ -91,7 +99,7 @@ class Sampler: def __init__(self, circuit_text: str, config: Dict[str, Any]): self._src = circuit_text seed = config.get("seed") - self._base_seed = int(seed) if seed is not None else None + self._base_seed = _to_qdk_seed(int(seed)) if seed is not None else None self._skip_shots = int(config.get("skip_shots", 0)) self._num_measurements = int(config.get("num_measurements", 0)) self._batch_size = int(config.get("batch_size", 256)) @@ -125,7 +133,9 @@ def __init__(self, circuit_text: str, config: Dict[str, Any]): def _refill(self) -> None: shot_seed = ( - self._base_seed + self._batch_index if self._base_seed is not None else None + _to_qdk_seed(self._base_seed + self._batch_index) + if self._base_seed is not None + else None ) self._batch_index += 1 # Use run_qir (not qdk.stim.run) because we already compiled the Stim diff --git a/deq/deq_runtime/src/simulator/tableau_preselect_sampler.rs b/deq/deq_runtime/src/simulator/tableau_preselect_sampler.rs index 2e809f57..3ece7727 100644 --- a/deq/deq_runtime/src/simulator/tableau_preselect_sampler.rs +++ b/deq/deq_runtime/src/simulator/tableau_preselect_sampler.rs @@ -1,5 +1,5 @@ //! A sampler that drives `stim::TableauSimulator` directly with -//! retry-from-block semantics for `PREPARE { ... REQUIRE ... }` blocks. +//! retry-from-block semantics for `SELECT { ... REQUIRE ... }` blocks. //! //! Instead of sampling the full circuit and filtering, this sampler //! splits the circuit into [`PreselectBlock`]s and, for each shot, @@ -189,7 +189,7 @@ fn resolve_check(check: &PreselectCheck, base_nominal: usize, block_new_actual: let nominal_offset = t .abs_meas_idx .checked_sub(base_nominal) - .expect("REQUIRE target predates the enclosing PREPARE block"); + .expect("REQUIRE target predates the enclosing SELECT block"); let actual_idx = *block_new_actual .get(nominal_offset) .expect("REQUIRE target beyond the block's most recent measurement"); @@ -316,12 +316,12 @@ mod tests { } #[test] - fn extract_blocks_with_prepare_block() { + fn extract_blocks_with_select_block() { let text = "\ R 0 H 0 M 0 -PREPARE { +SELECT { H 0 M 0 REQUIRE rec[-1] @@ -346,7 +346,7 @@ CZ 0 1 let text = "\ R 0 M 0 -PREPARE { +SELECT { H 0 M 0 REQUIRE rec[-1] @@ -363,7 +363,7 @@ REQUIRE rec[-1] #[test] fn preselect_reports_retries() { let text = "\ -PREPARE { +SELECT { H 0 M 0 REQUIRE rec[-1] @@ -394,7 +394,7 @@ REQUIRE rec[-1] #[test] fn deterministic_measurement_no_retry() { let text = "\ -PREPARE { +SELECT { R 0 M 0 REQUIRE rec[-1] @@ -412,7 +412,7 @@ REQUIRE rec[-1] fn many_shots_consistent() { let text = "\ R 0 -PREPARE { +SELECT { H 0 M 0 REQUIRE rec[-1] @@ -440,7 +440,7 @@ M 0 // average half of attempts have even parity and must be // retried. let text = "\ -PREPARE { +SELECT { R 0 1 MX 0 MX 1 diff --git a/deq/deq_runtime/tests/mock_decoder_test.rs b/deq/deq_runtime/tests/mock_decoder_test.rs index 4302033d..7d428e57 100644 --- a/deq/deq_runtime/tests/mock_decoder_test.rs +++ b/deq/deq_runtime/tests/mock_decoder_test.rs @@ -1,8 +1,12 @@ //! Tests for MockDecoder -use deq_runtime::decoder::MockDecoder; +#[cfg(feature = "cli")] +use deq_runtime::decoder::blackbox_decoder::black_box_decoder_server::BlackBoxDecoderServer; use deq_runtime::decoder::blackbox_decoder::{self, black_box_decoder_server::BlackBoxDecoder}; +use deq_runtime::decoder::thread_pooling::DecoderFeatures; +use deq_runtime::decoder::{BlackBoxDecoderClient, MockDecoder}; use deq_runtime::util::BitVector; +use std::sync::Arc; use tonic::Request; #[tokio::test] @@ -108,6 +112,160 @@ async fn test_mock_decoder_decode_loaded() { assert_eq!(state.decode_loaded_calls[0].hid, hid); } +#[tokio::test] +async fn test_decoder_capabilities_are_composable() { + let decoder = MockDecoder::with_features(DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS); + let capabilities = BlackBoxDecoder::get_capabilities(&decoder, Request::new(())) + .await + .unwrap() + .into_inner(); + + assert_eq!( + capabilities.features, + vec![ + blackbox_decoder::DecoderFeature::Reweights as i32, + blackbox_decoder::DecoderFeature::Loss as i32, + ] + ); +} + +#[cfg(feature = "cli")] +#[tokio::test] +async fn test_remote_client_queries_and_caches_capabilities() { + let decoder = Arc::new(MockDecoder::with_features(DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS)); + let incoming = tonic::transport::server::TcpIncoming::bind("127.0.0.1:0".parse().unwrap()).unwrap(); + let address = incoming.local_addr().unwrap(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let service = BlackBoxDecoderServer::from_arc(decoder.clone()); + let server = tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(service) + .serve_with_incoming_shutdown(incoming, async { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + + let endpoint = tonic::transport::Endpoint::from_shared(format!("http://{address}")).unwrap(); + let mut client = BlackBoxDecoderClient::from_endpoint(endpoint).await; + assert_eq!(client.features(), DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS); + + let hid = client + .load_hypergraph(blackbox_decoder::DecodingHypergraph { + vertex_num: 1, + hyperedges: vec![blackbox_decoder::Hyperedge { + vertices: vec![0], + probability: 0.1, + }], + }) + .await + .unwrap() + .hid; + client + .decode_loaded(blackbox_decoder::LoadedDecodingProblem { + hid, + syndrome: Some(BitVector { + size: 1, + data: vec![0b1000_0000], + }), + reweights: vec![blackbox_decoder::EdgeReweight { + edge: 0, + probability: 0.25, + }], + loss: Some(blackbox_decoder::LossInfo { + sites: vec![blackbox_decoder::LossSite { + source_edges: vec![0], + probability: 0.2, + ..Default::default() + }], + }), + }) + .await + .unwrap(); + let state = decoder.state.read().await; + assert_eq!(state.decode_loaded_calls[0].reweights[0].probability, 0.25); + assert_eq!( + state.decode_loaded_calls[0].loss.as_ref().unwrap().sites[0].source_edges, + vec![0] + ); + drop(state); + + shutdown_tx.send(()).unwrap(); + server.await.unwrap(); +} + +#[tokio::test] +async fn test_mock_decoder_accepts_reweights_and_loss_together() { + let decoder = MockDecoder::new(); + let hid = BlackBoxDecoder::load_hypergraph( + &decoder, + Request::new(blackbox_decoder::DecodingHypergraph { + vertex_num: 1, + hyperedges: vec![blackbox_decoder::Hyperedge { + vertices: vec![0], + probability: 0.1, + }], + }), + ) + .await + .unwrap() + .into_inner() + .hid; + let loss = blackbox_decoder::LossInfo { + sites: vec![blackbox_decoder::LossSite { + source_edges: vec![0], + probability: 0.2, + ..Default::default() + }], + }; + + BlackBoxDecoder::decode_loaded( + &decoder, + Request::new(blackbox_decoder::LoadedDecodingProblem { + hid, + syndrome: Some(BitVector { + size: 1, + data: vec![0b1000_0000], + }), + reweights: vec![blackbox_decoder::EdgeReweight { + edge: 0, + probability: 0.3, + }], + loss: Some(loss.clone()), + }), + ) + .await + .unwrap(); + + let state = decoder.state.read().await; + assert_eq!(state.decode_loaded_calls[0].reweights[0].probability, 0.3); + assert_eq!(state.decode_loaded_calls[0].loss, Some(loss)); +} + +#[tokio::test] +async fn test_client_rejects_unsupported_reweights_without_dispatch() { + let decoder = Arc::new(MockDecoder::with_features(DecoderFeatures::LOSS)); + let mut client = BlackBoxDecoderClient::from_mock(decoder.clone()); + let result = client + .decode_loaded(blackbox_decoder::LoadedDecodingProblem { + hid: 1, + syndrome: Some(BitVector { + size: 1, + data: vec![0b1000_0000], + }), + reweights: vec![blackbox_decoder::EdgeReweight { + edge: 0, + probability: 0.3, + }], + loss: None, + }) + .await; + + assert_eq!(result.unwrap_err().code(), tonic::Code::FailedPrecondition); + assert!(decoder.state.read().await.decode_loaded_calls.is_empty()); +} + #[tokio::test] async fn test_mock_decoder_custom_response() { let decoder = MockDecoder::new(); diff --git a/deq/deq_runtime/tests/monolithic_coordinator_test.rs b/deq/deq_runtime/tests/monolithic_coordinator_test.rs index a7d5dc90..39da57ad 100644 --- a/deq/deq_runtime/tests/monolithic_coordinator_test.rs +++ b/deq/deq_runtime/tests/monolithic_coordinator_test.rs @@ -175,6 +175,60 @@ async fn test_monolithic_coordinator_load_library() { assert!(error_model_types.contains_key(&1)); } +#[tokio::test] +async fn test_decode_rejects_malformed_outcomes() { + let coordinator = make_coordinator(make_mock_decoder()); + Coordinator::load_library(&coordinator, Request::new(make_canonical_library())) + .await + .unwrap(); + let gid = Coordinator::execute( + &coordinator, + Request::new(bin::Instruction { + create: Some(instruction::Create::Gadget(make_gadget(0, 1, vec![]))), + }), + ) + .await + .unwrap() + .into_inner() + .id; + + let wrong_outcome_size = Coordinator::decode( + &coordinator, + Request::new(deq_runtime::coordinator::Outcomes { + gid, + outcomes: Some(BitVector { size: 2, data: vec![0] }), + ..Default::default() + }), + ) + .await + .unwrap_err(); + assert_eq!(wrong_outcome_size.code(), tonic::Code::InvalidArgument); + assert!( + wrong_outcome_size + .message() + .contains("outcomes size 2 does not match gadget measurement count 1") + ); + + let wrong_loss_mask_size = Coordinator::decode( + &coordinator, + Request::new(deq_runtime::coordinator::Outcomes { + gid, + outcomes: Some(BitVector { size: 1, data: vec![0] }), + loss_mask: Some(BitVector { size: 2, data: vec![0] }), + ..Default::default() + }), + ) + .await + .unwrap_err(); + + assert_eq!(wrong_loss_mask_size.code(), tonic::Code::InvalidArgument); + assert!( + wrong_loss_mask_size + .message() + .contains("loss_mask size 2 does not match outcomes size 1") + ); +} + #[tokio::test] async fn test_monolithic_coordinator_reset() { let mock = make_mock_decoder(); @@ -395,6 +449,56 @@ async fn test_monolithic_coordinator_eid_assignment() { assert!(error_models.contains_key(&99)); } +#[tokio::test] +async fn test_invalid_error_model_probability_modifier_is_atomic() { + let coordinator = make_coordinator(make_mock_decoder()); + Coordinator::load_library(&coordinator, Request::new(make_canonical_library())) + .await + .unwrap(); + let gid = Coordinator::execute( + &coordinator, + Request::new(bin::Instruction { + create: Some(instruction::Create::Gadget(make_gadget(0, 1, vec![]))), + }), + ) + .await + .unwrap() + .into_inner() + .id; + let cid = Coordinator::execute( + &coordinator, + Request::new(bin::Instruction { + create: Some(instruction::Create::CheckModel(make_check_model(0, 1, gid))), + }), + ) + .await + .unwrap() + .into_inner() + .id; + let mut error_model = make_error_model(0, 1, cid); + error_model.modifier = Some(bin::error_model::ErrorModelModifier { + probability_modifier: Some(bin::ProbabilityModifier { + probabilities: vec![0.1, 0.2], + ..Default::default() + }), + ..Default::default() + }); + + let error = Coordinator::execute( + &coordinator, + Request::new(bin::Instruction { + create: Some(instruction::Create::ErrorModel(error_model)), + }), + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(coordinator.error_models.read().await.is_empty()); + assert!(coordinator.check_models.read().await[&cid].attaching_eid_vec.is_empty()); + assert_eq!(*coordinator.next_eid.lock().await, 1); +} + /// Creates the default_library from the Python test suite (library_validator_test.py). /// This is a complete library with: /// - 2 port types (rep-code with 1 observable, surface-code with 2 observables) @@ -1628,6 +1732,26 @@ fn make_persistent_coordinator(mock: Arc) -> MonolithicCoordinator MonolithicCoordinator::new(config, make_decoder_client(mock)) } +#[tokio::test] +#[should_panic(expected = "the provided parity factor does not match the syndrome")] +async fn test_first_persistent_decode_checks_the_parity_factor() { + let mock = make_mock_decoder(); + mock.set_response(vec![0], vec![0]).await; + let coordinator = MonolithicCoordinator::new( + serde_json::json!({ + "persistent_decoder": true, + "merge_hyperedges": false, + "assert_parity_factor": true, + }), + make_decoder_client(mock), + ); + Coordinator::load_library(&coordinator, Request::new(make_default_library())) + .await + .unwrap(); + + run_canonical_shot(&coordinator, None, None, None).await; +} + /// Build the canonical three-gadget program (initialize → cnot → measure) and /// attach error models with the supplied probability modifiers, then trigger /// the decode pipeline by submitting outcomes concurrently for all gadgets. @@ -1635,6 +1759,7 @@ async fn run_canonical_shot( coordinator: &MonolithicCoordinator, modifier_for_etype_1: Option, modifier_for_etype_2: Option, + runtime_modifier_for_etype_1: Option, ) { let wrap_modifier = |pm: Option| { pm.map(|p| bin::error_model::ErrorModelModifier { @@ -1762,6 +1887,7 @@ async fn run_canonical_shot( Request::new(deq_runtime::coordinator::Outcomes { gid: 2, outcomes: Some(BitVector { data: vec![0], size: 2 }), + modifiers: runtime_modifier_for_etype_1.into_iter().collect(), ..Default::default() }), ) @@ -1824,6 +1950,7 @@ async fn test_persistent_decoder_distinguishes_probability_modifier_across_shots ..Default::default() }), None, + None, ) .await; @@ -1838,6 +1965,7 @@ async fn test_persistent_decoder_distinguishes_probability_modifier_across_shots ..Default::default() }), None, + None, ) .await; @@ -1877,9 +2005,9 @@ async fn test_persistent_decoder_reuses_cache_when_modifier_unchanged() { ..Default::default() }; - run_canonical_shot(&coordinator, Some(modifier.clone()), None).await; + run_canonical_shot(&coordinator, Some(modifier.clone()), None, None).await; reset_keeping_library_and_decoder(&coordinator).await; - run_canonical_shot(&coordinator, Some(modifier), None).await; + run_canonical_shot(&coordinator, Some(modifier), None, None).await; let mock_state = mock.state.read().await; assert_eq!( @@ -1895,3 +2023,63 @@ async fn test_persistent_decoder_reuses_cache_when_modifier_unchanged() { let loaded_decoders = coordinator.loaded_decoders.read().await; assert_eq!(loaded_decoders.len(), 1, "Expected a single cache entry"); } + +#[tokio::test] +async fn test_library_reset_invalidates_coordinator_decoder_cache() { + let mock = make_mock_decoder(); + let coordinator = make_persistent_coordinator(mock.clone()); + Coordinator::load_library(&coordinator, Request::new(make_default_library())) + .await + .unwrap(); + run_canonical_shot(&coordinator, None, None, None).await; + assert_eq!(coordinator.loaded_decoders.read().await.len(), 1); + + Coordinator::reset( + &coordinator, + Request::new(deq_runtime::coordinator::ResetRequest { + reset_library: true, + reset_decoder_service: false, + ..Default::default() + }), + ) + .await + .unwrap(); + + assert!(coordinator.loaded_decoders.read().await.is_empty()); + assert_eq!( + mock.state.read().await.loaded_hypergraphs.len(), + 1, + "reset_decoder_service=false must leave the remote decoder service intact" + ); +} + +#[tokio::test] +async fn test_outcomes_modifier_is_sent_as_loaded_reweight() { + let mock = make_mock_decoder(); + let coordinator = make_persistent_coordinator(mock.clone()); + Coordinator::load_library(&coordinator, Request::new(make_default_library())) + .await + .unwrap(); + + run_canonical_shot( + &coordinator, + None, + None, + Some(bin::ProbabilityModifier { + probabilities: vec![0.27], + ..Default::default() + }), + ) + .await; + + let state = mock.state.read().await; + assert_eq!(state.loaded_hypergraphs.len(), 1); + assert_eq!(state.decode_loaded_calls.len(), 1); + assert_eq!(state.decode_loaded_calls[0].reweights.len(), 1); + assert!((state.decode_loaded_calls[0].reweights[0].probability - 0.27).abs() < 1e-12); + let loaded_probability = state.loaded_hypergraphs[&1].hyperedges[0].probability; + assert!( + (loaded_probability - 0.27).abs() > 1e-12, + "runtime update must not mutate the loaded graph" + ); +} diff --git a/deq/deq_runtime/tests/standard_decoder_test.rs b/deq/deq_runtime/tests/standard_decoder_test.rs index 5777d20d..12eb6c4e 100644 --- a/deq/deq_runtime/tests/standard_decoder_test.rs +++ b/deq/deq_runtime/tests/standard_decoder_test.rs @@ -7,6 +7,9 @@ use std::sync::Arc; +#[cfg(feature = "python")] +use std::io::Write; + use deq_runtime::decoder::test_harness::{Outcome, Path, SuiteReport, run_standard_suite}; use deq_runtime::decoder::test_problems::standard_test_problems; use deq_runtime::decoder::{BlackBoxDecoderClient, DynBlackBoxDecoder, MockDecoder, NaiveDecoder}; @@ -74,7 +77,9 @@ fn always_pass_policy(_problem: &str, _case: &str, _path: Path) -> bool { #[tokio::test] async fn test_naive_decoder() { let decoder = Arc::new(NaiveDecoder::new(serde_json::json!({}))); - let mut client = BlackBoxDecoderClient::Local(DynBlackBoxDecoder::BlackBoxNaive(decoder)); + let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxNaive(decoder)) + .await + .unwrap(); let report = run_standard_suite(&mut client).await; assert_full_coverage(&report); assert_matches_policy(&report, always_empty_subgraph_policy); @@ -83,7 +88,9 @@ async fn test_naive_decoder() { #[tokio::test] async fn test_mock_decoder() { let decoder = Arc::new(MockDecoder::new()); - let mut client = BlackBoxDecoderClient::Local(DynBlackBoxDecoder::MockDecoder(decoder)); + let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::MockDecoder(decoder)) + .await + .unwrap(); let report = run_standard_suite(&mut client).await; assert_full_coverage(&report); assert_matches_policy(&report, always_empty_subgraph_policy); @@ -93,7 +100,9 @@ async fn test_mock_decoder() { async fn test_relay_bp_decoder() { use deq_runtime::decoder::RelayBPDecoder; let decoder = Arc::new(RelayBPDecoder::new(serde_json::json!({}))); - let mut client = BlackBoxDecoderClient::Local(DynBlackBoxDecoder::BlackBoxRelayBP(decoder)); + let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxRelayBP(decoder)) + .await + .unwrap(); let report = run_standard_suite(&mut client).await; assert_full_coverage(&report); assert_matches_policy(&report, always_pass_policy); @@ -104,7 +113,9 @@ async fn test_relay_bp_decoder() { async fn test_tesseract_decoder() { use deq_runtime::decoder::TesseractDecoder; let decoder = Arc::new(TesseractDecoder::new(serde_json::json!({}))); - let mut client = BlackBoxDecoderClient::Local(DynBlackBoxDecoder::BlackBoxTesseract(decoder)); + let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxTesseract(decoder)) + .await + .unwrap(); let report = run_standard_suite(&mut client).await; assert_full_coverage(&report); assert_matches_policy(&report, always_pass_policy); @@ -116,12 +127,187 @@ async fn test_python_naive_decoder() { use deq_runtime::decoder::PythonDecoder; let config = serde_json::json!({ "file": "@naive_decoder" }); let decoder = Arc::new(PythonDecoder::new(config)); - let mut client = BlackBoxDecoderClient::Local(DynBlackBoxDecoder::BlackBoxPython(decoder)); + let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxPython(decoder)) + .await + .unwrap(); let report = run_standard_suite(&mut client).await; assert_full_coverage(&report); assert_matches_policy(&report, always_empty_subgraph_policy); } +#[cfg(feature = "python")] +#[tokio::test] +async fn test_python_named_decoder_without_supported_features() { + use deq_runtime::decoder::PythonDecoder; + use deq_runtime::decoder::thread_pooling::DecoderFeatures; + + let mut decoder_file = tempfile::Builder::new().suffix(".py").tempfile().unwrap(); + decoder_file + .write_all( + br#" +class LegacyDecoder: + def __init__(self, hypergraph, config): + pass + + def decode(self, syndrome): + return [] + + def reset(self): + pass +"#, + ) + .unwrap(); + + let decoder = Arc::new(PythonDecoder::new(serde_json::json!({ + "file": decoder_file.path(), + "name": "LegacyDecoder", + }))); + let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxPython(decoder)) + .await + .unwrap(); + + assert_eq!(client.features(), DecoderFeatures::empty()); + let report = run_standard_suite(&mut client).await; + assert_full_coverage(&report); + assert_matches_policy(&report, always_empty_subgraph_policy); +} + +#[cfg(feature = "python")] +#[tokio::test] +async fn test_python_decoder_receives_reweights_and_loss_together() { + use deq_runtime::decoder::PythonDecoder; + use deq_runtime::decoder::blackbox_decoder::{ + DecodingHypergraph, EdgeReweight, Hyperedge, LoadedDecodingProblem, LossInfo, LossSite, + }; + use deq_runtime::decoder::thread_pooling::DecoderFeatures; + use deq_runtime::util::BitVector; + + let mut decoder_file = tempfile::Builder::new().suffix(".py").tempfile().unwrap(); + decoder_file + .write_all( + br#" +class CombinedDecoder: + @staticmethod + def supported_features(): + return ["reweights", "loss"] + + def __init__(self, hypergraph, config): + assert hypergraph.vertex_num == 1, hypergraph.vertex_num + assert config == {}, config + + def decode(self, syndrome, *, reweights=None, loss=None): + assert syndrome == [0], syndrome + if reweights is None: + assert loss is not None, loss + assert list(loss.sites) == [], loss.sites + return [] + assert reweights == [(0, 0.25)], reweights + assert loss is not None, loss + assert len(loss.sites) == 1, loss.sites + assert loss.sites[0].source_edges == [0], loss.sites[0].source_edges + assert loss.sites[0].probability == 0.2, loss.sites[0].probability + return [0] + + def reset(self): + pass +"#, + ) + .unwrap(); + + let config = serde_json::json!({ + "file": decoder_file.path(), + "name": "CombinedDecoder", + }); + let decoder = Arc::new(PythonDecoder::new(config)); + let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxPython(decoder)) + .await + .unwrap(); + assert_eq!(client.features(), DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS); + + let hid = client + .load_hypergraph(DecodingHypergraph { + vertex_num: 1, + hyperedges: vec![Hyperedge { + vertices: vec![0], + probability: 0.1, + }], + }) + .await + .unwrap() + .hid; + let parity_factor = client + .decode_loaded(LoadedDecodingProblem { + hid, + syndrome: Some(BitVector { + size: 1, + data: vec![0b1000_0000], + }), + reweights: vec![EdgeReweight { + edge: 0, + probability: 0.25, + }], + loss: Some(LossInfo { + sites: vec![LossSite { + source_edges: vec![0], + probability: 0.2, + ..Default::default() + }], + }), + }) + .await + .unwrap(); + + assert_eq!(parity_factor.subgraph, vec![0]); + + let parity_factor = client + .decode_loaded(LoadedDecodingProblem { + hid, + syndrome: Some(BitVector { + size: 1, + data: vec![0b1000_0000], + }), + loss: Some(LossInfo { sites: vec![] }), + ..Default::default() + }) + .await + .unwrap(); + + assert!(parity_factor.subgraph.is_empty()); +} + +#[cfg(feature = "python")] +#[test] +#[should_panic(expected = "unsupported Python decoder feature")] +fn test_python_decoder_rejects_unknown_supported_feature() { + use deq_runtime::decoder::PythonDecoder; + + let mut decoder_file = tempfile::Builder::new().suffix(".py").tempfile().unwrap(); + decoder_file + .write_all( + br#" +class Decoder: + @staticmethod + def supported_features(): + return ["unknown"] +"#, + ) + .unwrap(); + + let _ = PythonDecoder::new(serde_json::json!({ "file": decoder_file.path() })); +} + +#[cfg(feature = "python")] +#[test] +#[should_panic(expected = "invalid PythonDecoderConfig")] +fn test_python_decoder_rejects_supported_features_in_config() { + use deq_runtime::decoder::PythonDecoder; + + let _ = PythonDecoder::new(serde_json::json!({ + "file": "@naive_decoder", + "supported_features": ["loss"], + })); +} + /// Skip the test (with an explanatory message) when one of the listed Python /// modules is not importable in the embedded interpreter. Returns `true` when /// every module is available. @@ -156,7 +342,9 @@ async fn test_python_relay_bp_decoder() { } let config = serde_json::json!({ "file": "@relay_bp_decoder" }); let decoder = Arc::new(PythonDecoder::new(config)); - let mut client = BlackBoxDecoderClient::Local(DynBlackBoxDecoder::BlackBoxPython(decoder)); + let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxPython(decoder)) + .await + .unwrap(); let report = run_standard_suite(&mut client).await; assert_full_coverage(&report); assert_matches_policy(&report, always_pass_policy); @@ -171,7 +359,9 @@ async fn test_python_tesseract_decoder() { } let config = serde_json::json!({ "file": "@tesseract_decoder" }); let decoder = Arc::new(PythonDecoder::new(config)); - let mut client = BlackBoxDecoderClient::Local(DynBlackBoxDecoder::BlackBoxPython(decoder)); + let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxPython(decoder)) + .await + .unwrap(); let report = run_standard_suite(&mut client).await; assert_full_coverage(&report); assert_matches_policy(&report, always_pass_policy); diff --git a/deq/deq_runtime/tests/task_counter_test.rs b/deq/deq_runtime/tests/task_counter_test.rs index fb903a03..5cf8d16c 100644 --- a/deq/deq_runtime/tests/task_counter_test.rs +++ b/deq/deq_runtime/tests/task_counter_test.rs @@ -86,3 +86,25 @@ async fn test_task_counter_guard_drop_on_panic() { .await .expect("wait_for_zero should complete after panic + guard drop"); } + +#[tokio::test] +async fn test_task_counter_pause_blocks_new_operations_and_reopens_on_drop() { + let counter = TaskCounter::new(); + let active = counter.try_guard().expect("operation should be admitted"); + let pause = counter.try_pause().expect("first reset should pause admission"); + + assert!(counter.try_guard().is_none()); + assert!(counter.try_pause().is_none()); + + let waiting = tokio::spawn({ + let counter = counter.clone(); + async move { counter.wait_for_zero().await } + }); + tokio::task::yield_now().await; + assert!(!waiting.is_finished()); + drop(active); + waiting.await.unwrap(); + + drop(pause); + assert!(counter.try_guard().is_some()); +} diff --git a/deq/deqagram/src/ast.rs b/deq/deqagram/src/ast.rs index 5b80994b..d5b90ce4 100644 --- a/deq/deqagram/src/ast.rs +++ b/deq/deqagram/src/ast.rs @@ -1116,20 +1116,28 @@ fn parse_loss_statement(pair: Pair) -> Result { statement.input_qubit = Some(qubit); } Rule::SOURCE_ERROR_TARGET => { - statement.source_errors.push(sub_u64(&item, item.as_str().strip_prefix("SE").unwrap())?); + statement + .source_errors + .push(sub_u64(&item, item.as_str().strip_prefix("SE").unwrap())?); } Rule::CONT_ERROR_TARGET => { - statement.continuation_errors.push(sub_u64(&item, item.as_str().strip_prefix("CE").unwrap())?); + statement + .continuation_errors + .push(sub_u64(&item, item.as_str().strip_prefix("CE").unwrap())?); } Rule::CHILD_LOSS_TARGET => { - statement.child_losses.push(sub_u64(&item, item.as_str().strip_prefix('L').unwrap())?); + statement + .child_losses + .push(sub_u64(&item, item.as_str().strip_prefix('L').unwrap())?); } Rule::OUTPUT_PHYS_QUBIT_TARGET => { let (port, qubit) = port_indexed(&item, "OUT", "L")?; statement.output_qubits.push((port, qubit)); } Rule::PHYS_MEAS_TARGET => { - statement.measurement_indices.push(sub_u64(&item, item.as_str().strip_prefix('M').unwrap())?); + statement + .measurement_indices + .push(sub_u64(&item, item.as_str().strip_prefix('M').unwrap())?); } rule => unreachable!("unexpected loss-target rule {rule:?}"), } From 740668f2f716fd1d10f94860eadefdc1895d3669 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 15 Aug 2026 13:55:23 -0700 Subject: [PATCH 086/157] relex window coordinator test timing --- .../tests/window_coordinator_test.rs | 211 +++++++++--------- 1 file changed, 103 insertions(+), 108 deletions(-) diff --git a/deq/deq_runtime/tests/window_coordinator_test.rs b/deq/deq_runtime/tests/window_coordinator_test.rs index a1669a62..dc066717 100644 --- a/deq/deq_runtime/tests/window_coordinator_test.rs +++ b/deq/deq_runtime/tests/window_coordinator_test.rs @@ -21,6 +21,8 @@ use tonic::Request; // re-use the trace proto types use window_coordinator::trace; +const DEADLOCK_WATCHDOG: std::time::Duration = std::time::Duration::from_secs(30); + // ─── helpers ─────────────────────────────────────────────────────────────── fn make_mock_decoder() -> Arc { @@ -455,6 +457,62 @@ async fn reset_shot(coord: &WindowCoordinator) { .unwrap(); } +#[tokio::test] +async fn reset_waits_for_in_flight_window_decode() { + let trace_file = NamedTempFile::new().unwrap(); + let mock = make_mock_decoder(); + let decode_blocker = mock.block_next_decode(); + let coord = Arc::new(make_coordinator_with_radii( + mock.clone(), + trace_file.path().to_str().unwrap(), + 0, + 0, + )); + Coordinator::load_library(coord.as_ref(), Request::new(make_test_library())) + .await + .unwrap(); + let gid = exec_gadget(coord.as_ref(), make_gadget(0, 1, vec![])).await; + let cid = exec_check_model(coord.as_ref(), make_check_model(0, 1, gid)).await; + exec_error_model(coord.as_ref(), make_error_model(0, 1, cid)).await; + + let decoding = tokio::spawn({ + let coord = coord.clone(); + async move { decode(coord.as_ref(), gid, 1).await } + }); + decode_blocker.wait_until_started().await; + let resetting = tokio::spawn({ + let coord = coord.clone(); + async move { reset_shot(coord.as_ref()).await } + }); + loop { + if coord.task_counter.try_guard().is_none() { + break; + } + tokio::task::yield_now().await; + } + assert!(!resetting.is_finished()); + + let execute_error = Coordinator::execute(coord.as_ref(), Request::new(bin::Instruction::default())) + .await + .unwrap_err(); + assert_eq!(execute_error.code(), tonic::Code::Unavailable); + let reset_error = Coordinator::reset( + coord.as_ref(), + Request::new(deq_runtime::coordinator::ResetRequest::default()), + ) + .await + .unwrap_err(); + assert_eq!(reset_error.code(), tonic::Code::Unavailable); + + decode_blocker.release(); + decoding.await.unwrap(); + resetting.await.unwrap(); + let reopened_error = Coordinator::execute(coord.as_ref(), Request::new(bin::Instruction::default())) + .await + .unwrap_err(); + assert_eq!(reopened_error.code(), tonic::Code::InvalidArgument); +} + /// Read and parse the trace protobuf from the given file. fn read_trace(path: &str) -> trace::WindowCoordinatorTrace { let data = std::fs::read(path).unwrap(); @@ -2539,6 +2597,7 @@ async fn test_stress_random_large() { /// committed per leader, so wall time should be well under 6×30ms = 180ms /// (which would be the cost of decoding each gadget separately). #[tokio::test(flavor = "multi_thread")] +#[ignore = "timing-sensitive performance test"] async fn test_timing_parallel_decode() { let (gids, coord, mock, trace_file) = build_extended_chain(1).await; let [ @@ -2686,30 +2745,18 @@ async fn build_long_chain( (all_gids, checked_gids, coord, mock, trace_file) } -/// Test timing: batch decode (all at once) with a long chain. -/// -/// With 30 checked gadgets, buffer_radius=5, and 100ms decode delay: -/// - Window radius = 5 hops → leaders ≥10 hops apart don't overlap. -/// - Wave 1: ~3 parallel leaders (gadgets ~0, ~10, ~20). -/// After 100ms, these commit → committed gadgets become terminals. -/// - Subsequent waves fill in the gaps, each taking ~100ms. -/// - Total: a small number of waves (not 30 sequential decodes). +/// Test batch decode (all at once) with a long chain. /// -/// We verify: -/// 1. Wall time << 30 × 100ms = 3000ms (proves parallelism + waves). -/// 2. Number of leaders < 30 (some gadgets get committed by neighbors). +/// Verifies that windows commit multiple gadgets per leader, so batch mode +/// produces fewer leaders than checked gadgets without relying on wall time. #[tokio::test(flavor = "multi_thread")] -async fn test_timing_batch_decode_long_chain() { +async fn test_batch_decode_long_chain_uses_fewer_leaders() { let n_checked = 30; let buffer_radius = 5; - let decode_delay_ms = 100; - let (all_gids, checked_gids, coord, mock, trace_file) = build_long_chain(n_checked, buffer_radius).await; + let (all_gids, checked_gids, coord, _mock, trace_file) = build_long_chain(n_checked, buffer_radius).await; let trace_path = trace_file.path().to_str().unwrap().to_string(); - // Set 100ms decode delay - mock.set_decode_delay(std::time::Duration::from_millis(decode_delay_ms)); - // Decode all concurrently (batch mode) — must use tokio::spawn // because non-leader decode calls block waiting for the leader's pauli_frame. let coord = Arc::new(coord); @@ -2723,9 +2770,7 @@ async fn test_timing_batch_decode_long_chain() { }) .collect(); - let wall_start = std::time::Instant::now(); let results = futures_util::future::join_all(handles).await; - let wall_ms = wall_start.elapsed().as_millis(); for result in &results { result.as_ref().unwrap(); @@ -2740,32 +2785,7 @@ async fn test_timing_batch_decode_long_chain() { assert_window_correctness(shot, &all_gids_set, buffer_radius, &test_free_hop_types()); let n_leaders = count_leaders(shot); - let (total_ms, trace_wall_ms, n_parallel_pairs) = check_decode_parallelism(shot); - - eprintln!( - "batch_decode: n_checked={}, buffer_radius={}, delay={}ms", - n_checked, buffer_radius, decode_delay_ms - ); - eprintln!( - " leaders={}, total_decode={}ms, wall={}ms (measured={}ms), parallel_pairs={}", - n_leaders, total_ms, trace_wall_ms, wall_ms, n_parallel_pairs - ); - // With single-gadget commit, each checked gadget is its own leader. - // Parallelism still works: non-overlapping windows decode concurrently. - // With buffer_radius=5 and the chain pattern (checked → free → checked), - // each checked gadget is 2 hops from adjacent checked gadgets (1 hop-counted - // step through the free-hop), so windows 11+ hops apart can run in parallel. - assert!( - wall_ms < 1500, - "batch wall time {wall_ms}ms too high — expected parallel wave decoding \ - (n_leaders={n_leaders}, total_decode={total_ms}ms)" - ); - - // CI runners with few cores can serialize leader decode execution even in - // batch mode, making timestamp-overlap detection flaky. Keep a robust - // structural check instead: batch mode should produce far fewer leaders - // than checked gadgets because windows commit multiple gadgets per leader. assert!( n_leaders < n_checked, "too many leaders in batch mode: leaders={n_leaders}, n_checked={n_checked}" @@ -2785,6 +2805,7 @@ async fn test_timing_batch_decode_long_chain() { /// 1. Number of leaders is significantly more than in batch mode. /// 2. Wall time ≈ delay + (n-1) × stagger ≈ 100 + 290 ≈ 400ms. #[tokio::test(flavor = "multi_thread")] +#[ignore = "timing-sensitive scheduler experiment"] async fn test_timing_staggered_decode_long_chain() { let n_checked = 30; let buffer_radius = 5; @@ -3978,9 +3999,9 @@ async fn test_checked_chain_no_free_hops_2_hops() { }) .collect(); - let results = tokio::time::timeout(std::time::Duration::from_secs(10), futures_util::future::join_all(handles)) + let results = tokio::time::timeout(DEADLOCK_WATCHDOG, futures_util::future::join_all(handles)) .await - .expect("DEADLOCK: concurrent decode did not complete within 10s"); + .expect("DEADLOCK: concurrent decode did not complete within 30s"); for (i, result) in results.into_iter().enumerate() { let readouts = result.unwrap(); @@ -4008,9 +4029,9 @@ async fn test_checked_chain_no_free_hops_3_hops() { }) .collect(); - let results = tokio::time::timeout(std::time::Duration::from_secs(10), futures_util::future::join_all(handles)) + let results = tokio::time::timeout(DEADLOCK_WATCHDOG, futures_util::future::join_all(handles)) .await - .expect("DEADLOCK: concurrent decode did not complete within 10s"); + .expect("DEADLOCK: concurrent decode did not complete within 30s"); for (i, result) in results.into_iter().enumerate() { let readouts = result.unwrap(); @@ -4039,9 +4060,9 @@ async fn test_checked_chain_no_free_hops_10_gadgets_4_hops() { }) .collect(); - let results = tokio::time::timeout(std::time::Duration::from_secs(10), futures_util::future::join_all(handles)) + let results = tokio::time::timeout(DEADLOCK_WATCHDOG, futures_util::future::join_all(handles)) .await - .expect("DEADLOCK: concurrent decode did not complete within 10s"); + .expect("DEADLOCK: concurrent decode did not complete within 30s"); for (i, result) in results.into_iter().enumerate() { let readouts = result.unwrap(); @@ -4108,9 +4129,9 @@ async fn test_checked_chain_multi_shot_7_gadgets_2_hops() { }) .collect(); - let results = tokio::time::timeout(std::time::Duration::from_secs(10), futures_util::future::join_all(handles)) + let results = tokio::time::timeout(DEADLOCK_WATCHDOG, futures_util::future::join_all(handles)) .await - .unwrap_or_else(|_| panic!("DEADLOCK on shot {shot}: concurrent decode did not complete within 10s")); + .unwrap_or_else(|_| panic!("DEADLOCK on shot {shot}: concurrent decode did not complete within 30s")); for (i, result) in results.into_iter().enumerate() { let readouts = result.unwrap(); @@ -4188,9 +4209,9 @@ async fn test_lookahead_radius_zero_buffer_radius_3() { }) .collect(); - let results = tokio::time::timeout(std::time::Duration::from_secs(10), futures_util::future::join_all(handles)) + let results = tokio::time::timeout(DEADLOCK_WATCHDOG, futures_util::future::join_all(handles)) .await - .expect("DEADLOCK: lookahead_radius=0, buffer_radius=3 did not complete within 10s"); + .expect("DEADLOCK: lookahead_radius=0, buffer_radius=3 did not complete within 30s"); for (i, result) in results.into_iter().enumerate() { let readouts = result.unwrap(); @@ -4218,9 +4239,9 @@ async fn test_lookahead_radius_2_buffer_radius_1() { }) .collect(); - let results = tokio::time::timeout(std::time::Duration::from_secs(10), futures_util::future::join_all(handles)) + let results = tokio::time::timeout(DEADLOCK_WATCHDOG, futures_util::future::join_all(handles)) .await - .expect("DEADLOCK: lookahead_radius=2, buffer_radius=1 did not complete within 10s"); + .expect("DEADLOCK: lookahead_radius=2, buffer_radius=1 did not complete within 30s"); for (i, result) in results.into_iter().enumerate() { let readouts = result.unwrap(); @@ -4250,9 +4271,9 @@ async fn test_asymmetric_radii_long_chain() { }) .collect(); - let results = tokio::time::timeout(std::time::Duration::from_secs(15), futures_util::future::join_all(handles)) + let results = tokio::time::timeout(DEADLOCK_WATCHDOG, futures_util::future::join_all(handles)) .await - .expect("DEADLOCK: buffer_radius=2, lookahead_radius=5 did not complete within 15s"); + .expect("DEADLOCK: buffer_radius=2, lookahead_radius=5 did not complete within 30s"); for (i, result) in results.into_iter().enumerate() { let readouts = result.unwrap(); @@ -4273,29 +4294,19 @@ async fn test_asymmetric_radii_long_chain() { /// effective_window_radius = 3, but in streaming mode the BFS should NOT /// wait for gadgets beyond buffer_radius from the center. /// -/// We execute gadgets one at a time with 100ms gaps between them and set -/// decode_delay to 0ms. Each gadget's decode is submitted immediately -/// after its check/error models. If build_window blocks waiting for -/// future gadgets in the lookahead_radius zone, early decodes will take -/// 300+ms (waiting for 3 more gadgets at 100ms each). After the fix, -/// they should complete within ~200ms (at most waiting for 1 output in -/// the buffer_radius zone). +/// We execute gadgets one at a time and submit each decode immediately. After +/// gadget `i` is available, gadget `i - 1` must finish before gadget `i + 1` +/// is created. If window construction waited into the lookahead-only zone, +/// that decode could not finish and the watchdog would expire. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_streaming_lookahead_radius_no_unnecessary_wait() { let n = 8; let buffer_radius = 1; let lookahead_radius = 2; - let gadget_delay = std::time::Duration::from_millis(100); - // Maximum time a single decode should take. With buffer_radius=1, - // the BFS only needs to wait for at most 1 future gadget (100ms), - // plus some overhead. If it waited for the full window (3 gadgets), - // it would take 300ms+. - let max_decode_ms = 250; let trace_file = NamedTempFile::new().unwrap(); let trace_path = trace_file.path().to_str().unwrap().to_string(); let mock = make_mock_decoder(); - mock.set_decode_delay(std::time::Duration::from_millis(0)); let coord = Arc::new(make_coordinator_with_radii( mock.clone(), &trace_path, @@ -4307,10 +4318,8 @@ async fn test_streaming_lookahead_radius_no_unnecessary_wait() { .await .unwrap(); - // Execute gadgets one at a time, submitting decode after each one's - // check+error models arrive. Collect decode handles. let mut gids = Vec::with_capacity(n); - let mut decode_handles = Vec::new(); + let mut previous_decode: Option<(usize, u64, tokio::task::JoinHandle)> = None; for i in 0..n { // Execute gadget @@ -4344,41 +4353,27 @@ async fn test_streaming_lookahead_radius_no_unnecessary_wait() { }; exec_error_model(coord.as_ref(), make_error_model(0, etype, (i + 1) as u64)).await; - // Immediately submit decode let c = coord.clone(); - let t0 = std::time::Instant::now(); - decode_handles.push(tokio::spawn(async move { - let readouts = decode_arc(c, gid, 1).await; - (readouts, t0.elapsed()) - })); - - // Wait before next gadget (simulating streaming arrival) - if i < n - 1 { - tokio::time::sleep(gadget_delay).await; + let current_decode = tokio::spawn(async move { decode_arc(c, gid, 1).await }); + + if let Some((previous_index, previous_gid, handle)) = previous_decode.take() { + let readouts = tokio::time::timeout(DEADLOCK_WATCHDOG, handle) + .await + .unwrap_or_else(|_| { + panic!("decode for gadget {previous_index} waited beyond buffer radius for a future gadget") + }) + .unwrap(); + assert_eq!(readouts.gid, previous_gid); } + previous_decode = Some((i, gid, current_decode)); } - // All decodes must complete without deadlock - let results = tokio::time::timeout( - std::time::Duration::from_secs(15), - futures_util::future::join_all(decode_handles), - ) - .await - .expect("DEADLOCK: streaming decode did not complete within 15s"); - - // Verify: each decode should complete well within the timeout, not - // waiting for the full lookahead_radius worth of future gadgets. - for (i, result) in results.into_iter().enumerate() { - let (readouts, elapsed) = result.unwrap(); - assert_eq!(readouts.gid, gids[i], "gid mismatch for gadget {i}"); - let elapsed_ms = elapsed.as_millis(); - assert!( - elapsed_ms < max_decode_ms as u128, - "gadget {i} (gid={}) decode took {elapsed_ms}ms, expected < {max_decode_ms}ms. \ - build_window likely blocked waiting for future gadgets in the lookahead_radius zone.", - gids[i], - ); - } + let (last_index, last_gid, last_handle) = previous_decode.unwrap(); + let last_readouts = tokio::time::timeout(DEADLOCK_WATCHDOG, last_handle) + .await + .unwrap_or_else(|_| panic!("terminal gadget {last_index} decode did not finish")) + .unwrap(); + assert_eq!(last_readouts.gid, last_gid); } /// Test that isolated vertices (checks with no incident hyperedges) are @@ -4405,9 +4400,9 @@ async fn test_isolated_vertices_stripped() { }) .collect(); - let results = tokio::time::timeout(std::time::Duration::from_secs(10), futures_util::future::join_all(handles)) + let results = tokio::time::timeout(DEADLOCK_WATCHDOG, futures_util::future::join_all(handles)) .await - .expect("DEADLOCK: isolated vertex test did not complete within 10s"); + .expect("DEADLOCK: isolated vertex test did not complete within 30s"); for (i, result) in results.into_iter().enumerate() { let readouts = result.unwrap(); From c94667b128f4bed10f33f8aef23d7d32ca4221be Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 15 Aug 2026 14:01:18 -0700 Subject: [PATCH 087/157] better assertion on async counter --- .../src/coordinator/mock_coordinator.rs | 49 +++++- deq/deq_runtime/src/decoder/mock_decoder.rs | 77 +++++++++- .../tests/jit_cancellation_test.rs | 10 +- deq/deq_runtime/tests/jit_compiler_test.rs | 54 ++----- deq/deq_runtime/tests/jit_controller_test.rs | 140 ++---------------- deq/deq_runtime/tests/task_counter_test.rs | 53 ++----- 6 files changed, 169 insertions(+), 214 deletions(-) diff --git a/deq/deq_runtime/src/coordinator/mock_coordinator.rs b/deq/deq_runtime/src/coordinator/mock_coordinator.rs index c37686ba..88101659 100644 --- a/deq/deq_runtime/src/coordinator/mock_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/mock_coordinator.rs @@ -7,12 +7,29 @@ use crate::bin::{self, instruction}; use crate::coordinator::{self, coordinator_server}; use hashbrown::HashMap; use std::sync::Arc; -use tokio::sync::RwLock; +use tokio::sync::{Notify, RwLock}; use tonic::{Request, Response, Status}; /// A mock coordinator that records all operations for testing. pub struct MockCoordinator { pub state: RwLock, + state_changed: Notify, + execute_blocker: std::sync::Mutex>>, +} + +pub struct MockCoordinatorExecuteBlocker { + started: Notify, + release: Notify, +} + +impl MockCoordinatorExecuteBlocker { + pub async fn wait_until_started(&self) { + self.started.notified().await; + } + + pub fn release(&self) { + self.release.notify_one(); + } } #[derive(Default)] @@ -212,9 +229,30 @@ impl MockCoordinator { next_eid: 1, ..Default::default() }), + state_changed: Notify::new(), + execute_blocker: std::sync::Mutex::new(None), }) } + pub fn block_next_execute(&self) -> Arc { + let blocker = Arc::new(MockCoordinatorExecuteBlocker { + started: Notify::new(), + release: Notify::new(), + }); + *self.execute_blocker.lock().unwrap() = Some(Arc::clone(&blocker)); + blocker + } + + pub async fn wait_for_error_models(&self, count: usize) { + loop { + let notified = self.state_changed.notified(); + if self.state.read().await.error_models.len() >= count { + return; + } + notified.await; + } + } + /// Compute effective types by applying modifiers from instances to their types. /// /// For each check model instance, applies the modifier's reroute_remote_gadgets @@ -394,6 +432,8 @@ impl Default for MockCoordinator { next_eid: 1, ..Default::default() }), + state_changed: Notify::new(), + execute_blocker: std::sync::Mutex::new(None), } } } @@ -430,6 +470,11 @@ impl coordinator_server::Coordinator for MockCoordinator { } async fn execute(&self, request: Request) -> Result, Status> { + let blocker = self.execute_blocker.lock().unwrap().take(); + if let Some(blocker) = blocker { + blocker.started.notify_one(); + blocker.release.notified().await; + } let instruction = request.into_inner(); let mut state = self.state.write().await; @@ -512,6 +557,8 @@ impl coordinator_server::Coordinator for MockCoordinator { // Record the instruction state.instructions.push(instruction); + drop(state); + self.state_changed.notify_one(); Ok(Response::new(coordinator::ExecuteResponse { id })) } diff --git a/deq/deq_runtime/src/decoder/mock_decoder.rs b/deq/deq_runtime/src/decoder/mock_decoder.rs index bf1533cb..b7167285 100644 --- a/deq/deq_runtime/src/decoder/mock_decoder.rs +++ b/deq/deq_runtime/src/decoder/mock_decoder.rs @@ -4,14 +4,14 @@ //! what coordinators send to the decoder at runtime. use crate::decoder::blackbox_decoder::{self, black_box_decoder_server}; +use crate::decoder::thread_pooling::DecoderFeatures; use crate::util::BitVector; use hashbrown::HashMap; use serde::{Deserialize, Serialize}; -#[cfg(feature = "cli")] use std::sync::Arc; #[cfg(feature = "cli")] use structdoc::StructDoc; -use tokio::sync::RwLock; +use tokio::sync::{Notify, RwLock}; #[cfg(feature = "cli")] use tonic::transport::server::Router; use tonic::{Request, Response, Status}; @@ -31,6 +31,23 @@ pub struct MockDecoder { pub state: RwLock, /// Optional delay to simulate decoder latency (applied per decode call). pub decode_delay: std::sync::Mutex>, + decode_blocker: std::sync::Mutex>>, + features: DecoderFeatures, +} + +pub struct MockDecoderDecodeBlocker { + started: Notify, + release: Notify, +} + +impl MockDecoderDecodeBlocker { + pub async fn wait_until_started(&self) { + self.started.notified().await; + } + + pub fn release(&self) { + self.release.notify_one(); + } } #[derive(Default)] @@ -55,6 +72,7 @@ pub struct MockDecoderState { pub struct DecodeProblem { pub hypergraph: blackbox_decoder::DecodingHypergraph, pub syndrome: BitVector, + pub loss: Option, } /// Captured loaded decode problem @@ -62,19 +80,31 @@ pub struct DecodeProblem { pub struct LoadedDecodeProblem { pub hid: u64, pub syndrome: BitVector, + pub reweights: Vec, + pub loss: Option, } impl MockDecoder { pub fn new() -> Self { + Self::with_features(DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS) + } + + pub fn with_features(features: DecoderFeatures) -> Self { Self { state: RwLock::new(MockDecoderState { next_hid: 1, ..Default::default() }), decode_delay: std::sync::Mutex::new(None), + decode_blocker: std::sync::Mutex::new(None), + features, } } + pub fn supported_features(&self) -> DecoderFeatures { + self.features + } + /// Create a MockDecoder from a JSON config value (for CLI use). pub fn from_config(config: serde_json::Value) -> Self { let config: MockDecoderConfig = serde_json::from_value(config).unwrap(); @@ -89,6 +119,8 @@ impl MockDecoder { ..Default::default() }), decode_delay: std::sync::Mutex::new(delay), + decode_blocker: std::sync::Mutex::new(None), + features: DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS, } } @@ -121,6 +153,16 @@ impl MockDecoder { *self.decode_delay.lock().unwrap() = Some(delay); } + /// Block the next decode call until the returned notifier receives a permit. + pub fn block_next_decode(&self) -> Arc { + let blocker = Arc::new(MockDecoderDecodeBlocker { + started: Notify::new(), + release: Notify::new(), + }); + *self.decode_blocker.lock().unwrap() = Some(Arc::clone(&blocker)); + blocker + } + /// Get the subgraph response for a syndrome, or empty if not set fn get_response(state: &MockDecoderState, syndrome: &BitVector) -> Vec { state.custom_responses.get(&syndrome.data).cloned().unwrap_or_default() @@ -128,6 +170,11 @@ impl MockDecoder { /// Apply decode delay if configured. async fn apply_delay(&self) { + let blocker = self.decode_blocker.lock().unwrap().take(); + if let Some(blocker) = blocker { + blocker.started.notify_one(); + blocker.release.notified().await; + } let delay = *self.decode_delay.lock().unwrap(); if let Some(delay) = delay { tokio::time::sleep(delay).await; @@ -149,11 +196,21 @@ impl std::fmt::Debug for MockDecoder { #[tonic::async_trait] impl black_box_decoder_server::BlackBoxDecoder for MockDecoder { + async fn get_capabilities( + &self, + _request: Request<()>, + ) -> Result, Status> { + Ok(Response::new(self.features.to_proto())) + } + async fn decode( &self, request: Request, ) -> Result, Status> { let problem = request.into_inner(); + if problem.loss.is_some() && !self.features.contains(DecoderFeatures::LOSS) { + return Err(Status::failed_precondition("decoder does not support structured loss")); + } let hypergraph = problem .hypergraph .ok_or_else(|| Status::invalid_argument("missing hypergraph"))?; @@ -163,6 +220,7 @@ impl black_box_decoder_server::BlackBoxDecoder for MockDecoder { state.decode_calls.push(DecodeProblem { hypergraph: hypergraph.clone(), syndrome: syndrome.clone(), + loss: problem.loss, }); let subgraph = Self::get_response(&state, &syndrome); @@ -190,6 +248,19 @@ impl black_box_decoder_server::BlackBoxDecoder for MockDecoder { request: Request, ) -> Result, Status> { let problem = request.into_inner(); + let mut required = DecoderFeatures::empty(); + if !problem.reweights.is_empty() { + required = required | DecoderFeatures::REWEIGHTS; + } + if problem.loss.is_some() { + required = required | DecoderFeatures::LOSS; + } + let unsupported = required.difference(self.features); + if !unsupported.is_empty() { + return Err(Status::failed_precondition(format!( + "unsupported decoder features: {unsupported}" + ))); + } let syndrome = problem.syndrome.ok_or_else(|| Status::invalid_argument("missing syndrome"))?; let mut state = self.state.write().await; @@ -200,6 +271,8 @@ impl black_box_decoder_server::BlackBoxDecoder for MockDecoder { state.decode_loaded_calls.push(LoadedDecodeProblem { hid: problem.hid, syndrome: syndrome.clone(), + reweights: problem.reweights, + loss: problem.loss, }); let subgraph = Self::get_response(&state, &syndrome); diff --git a/deq/deq_runtime/tests/jit_cancellation_test.rs b/deq/deq_runtime/tests/jit_cancellation_test.rs index b178f182..7d815b7a 100644 --- a/deq/deq_runtime/tests/jit_cancellation_test.rs +++ b/deq/deq_runtime/tests/jit_cancellation_test.rs @@ -62,7 +62,8 @@ async fn test_jit_reset_gid_sequence() { #[tokio::test] async fn test_reset_during_batch_execute() { - let (controller, _mock) = setup_jit(test_jit_library()).await; + let (controller, mock) = setup_jit(test_jit_library()).await; + let execute_blocker = mock.block_next_execute(); // Start a batch execute with dependencies let ctrl2 = Arc::clone(&controller); @@ -90,13 +91,14 @@ async fn test_reset_during_batch_execute() { let _ = ctrl2.batch_execute(instructions).await; }); - tokio::time::sleep(Duration::from_millis(10)).await; - tokio::time::timeout(Duration::from_secs(5), controller.reset(reset_flags())) + execute_blocker.wait_until_started().await; + tokio::time::timeout(Duration::from_secs(30), controller.reset(reset_flags())) .await .expect("reset should not hang during batch_execute") .expect("reset should not error"); - tokio::time::timeout(Duration::from_secs(2), exec_handle) + execute_blocker.release(); + tokio::time::timeout(Duration::from_secs(30), exec_handle) .await .expect("batch_execute should finish after reset") .expect("batch_execute should not panic"); diff --git a/deq/deq_runtime/tests/jit_compiler_test.rs b/deq/deq_runtime/tests/jit_compiler_test.rs index b7bbc28d..ceb8a7dd 100644 --- a/deq/deq_runtime/tests/jit_compiler_test.rs +++ b/deq/deq_runtime/tests/jit_compiler_test.rs @@ -1664,6 +1664,7 @@ async fn test_repetition_code_jit_self_two_cnot() { async fn test_error_model_blocking_until_syndrome_extraction() { // cargo test test_error_model_blocking_until_syndrome_extraction -- --nocapture use deq_runtime::jit::jit_compiler::JitCompiler; + use futures_util::FutureExt; use std::pin::pin; use std::time::Duration; @@ -1723,16 +1724,9 @@ async fn test_error_model_blocking_until_syndrome_extraction() { let mut future_2 = pin!(error_model_future_2); let mut future_cnot = pin!(error_model_future_cnot); - // CHECK 1: All three futures should be blocked because CNOT's outputs are not connected - tokio::select! { - biased; - _ = &mut future_1 => panic!("prepare_1 error model should be blocked before CNOT outputs are connected"), - _ = &mut future_2 => panic!("prepare_2 error model should be blocked before CNOT outputs are connected"), - _ = &mut future_cnot => panic!("CNOT error model should be blocked before its outputs are connected"), - _ = tokio::time::sleep(Duration::from_millis(50)) => { - // Good - all futures are blocked as expected - } - } + assert!(future_1.as_mut().now_or_never().is_none()); + assert!(future_2.as_mut().now_or_never().is_none()); + assert!(future_cnot.as_mut().now_or_never().is_none()); // Add idle gates on both logical qubits (gid 4 and 5) let (idle_gadget_1, _, _, error_model_future_idle_1) = compiler @@ -1770,14 +1764,11 @@ async fn test_error_model_blocking_until_syndrome_extraction() { let mut future_idle_1 = pin!(error_model_future_idle_1); let mut future_idle_2 = pin!(error_model_future_idle_2); - // Give the runtime a chance to wake up the blocked futures now that connectors are set - tokio::task::yield_now().await; - // CHECK 2: Now CNOT and prepare futures should be ready (idle gates have syndrome extraction) // But idle futures should still be blocked (their outputs are not connected) // We use join! to run CNOT and prepare futures concurrently (they depend on each other) let ready_futures = futures_util::future::join3(&mut future_1, &mut future_2, &mut future_cnot); - let ready_result = tokio::time::timeout(Duration::from_millis(100), ready_futures).await; + let ready_result = tokio::time::timeout(Duration::from_secs(30), ready_futures).await; assert!( ready_result.is_ok(), "prepare and CNOT error models should be ready after idle gates are added" @@ -1798,15 +1789,8 @@ async fn test_error_model_blocking_until_syndrome_extraction() { let (error_model_type_2, _) = result_2; assert_eq!(error_model_type_2.errors.len(), 2); - // CHECK 3: Idle futures should still be blocked (their outputs are not connected to measurement) - tokio::select! { - biased; - _ = &mut future_idle_1 => panic!("idle_1 error model should be blocked before measurement is connected"), - _ = &mut future_idle_2 => panic!("idle_2 error model should be blocked before measurement is connected"), - _ = tokio::time::sleep(Duration::from_millis(10)) => { - // Good - idle futures are blocked as expected - } - } + assert!(future_idle_1.as_mut().now_or_never().is_none()); + assert!(future_idle_2.as_mut().now_or_never().is_none()); // Add measurement gates to complete the circuit let (_, _, _, error_model_future_measure_1) = compiler @@ -1844,13 +1828,13 @@ async fn test_error_model_blocking_until_syndrome_extraction() { .await; // CHECK 3: Now idle futures should be ready - let result_idle_1 = tokio::time::timeout(Duration::from_millis(50), &mut future_idle_1).await; + let result_idle_1 = tokio::time::timeout(Duration::from_secs(30), &mut future_idle_1).await; assert!( result_idle_1.is_ok(), "idle_1 error model should be ready after measurement is added" ); - let result_idle_2 = tokio::time::timeout(Duration::from_millis(50), &mut future_idle_2).await; + let result_idle_2 = tokio::time::timeout(Duration::from_secs(30), &mut future_idle_2).await; assert!( result_idle_2.is_ok(), "idle_2 error model should be ready after measurement is added" @@ -1858,10 +1842,9 @@ async fn test_error_model_blocking_until_syndrome_extraction() { // CHECK 4: Measurement futures should complete immediately (no output ports) let all_measure_futures = vec![error_model_future_measure_1, error_model_future_measure_2]; - let measure_results = - tokio::time::timeout(Duration::from_millis(50), futures_util::future::join_all(all_measure_futures)) - .await - .expect("Measurement error models should be immediately ready"); + let measure_results = tokio::time::timeout(Duration::from_secs(30), futures_util::future::join_all(all_measure_futures)) + .await + .expect("Measurement error models should be immediately ready"); // Verify measurement error models let (error_model_type_measure_1, _) = &measure_results[0]; @@ -1880,7 +1863,7 @@ async fn test_error_model_blocking_until_syndrome_extraction() { async fn test_error_model_blocked_without_output_connection() { // cargo test test_error_model_blocked_without_output_connection -- --nocapture use deq_runtime::jit::jit_compiler::JitCompiler; - use std::time::Duration; + use futures_util::FutureExt; let jit_library = rep_code_jit_library(); let compiler = JitCompiler::new(); @@ -1900,14 +1883,7 @@ async fn test_error_model_blocked_without_output_connection() { ) .await; - // The prepare gadget's output port is not connected to anything. - // Trying to await its error model should block indefinitely. - let result = tokio::time::timeout(Duration::from_millis(50), error_model_future_1).await; - - assert!( - result.is_err(), - "Error model future should be blocked when output port is not connected" - ); + assert!(error_model_future_1.now_or_never().is_none()); } /// Test that connecting output to measurement gadget unblocks the error model. @@ -1952,7 +1928,7 @@ async fn test_error_model_unblocked_with_measurement() { // Now both error model futures should complete let results = tokio::time::timeout( - Duration::from_millis(100), + Duration::from_secs(30), futures_util::future::join_all(vec![error_model_future_1, error_model_future_measure]), ) .await diff --git a/deq/deq_runtime/tests/jit_controller_test.rs b/deq/deq_runtime/tests/jit_controller_test.rs index 9bd10770..05798c72 100644 --- a/deq/deq_runtime/tests/jit_controller_test.rs +++ b/deq/deq_runtime/tests/jit_controller_test.rs @@ -213,6 +213,12 @@ async fn setup_controller(library: jit::JitLibrary, cache_enabled: bool) -> (Arc (controller, mock) } +async fn wait_for_error_models(mock: &MockCoordinator, count: usize) { + timeout(Duration::from_secs(30), mock.wait_for_error_models(count)) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {count} error models")); +} + #[tokio::test] async fn test_basic_compilation_cache_disabled() { let library = basic_jit_library(); @@ -353,21 +359,7 @@ async fn test_error_model_timing_after_output_connection() { let measure_instruction = make_jit_instruction(2, 2, vec![bin::gadget::Connector { gid: 1, port: 0 }]); controller.execute(measure_instruction).await; - let error_model_created = timeout(Duration::from_millis(200), async { - loop { - tokio::time::sleep(Duration::from_millis(10)).await; - let state = mock.state.read().await; - if state.error_models.len() > 1 { - return true; - } - } - }) - .await; - - assert!( - error_model_created.is_ok(), - "error model should be created after output is connected" - ); + wait_for_error_models(&mock, 2).await; let state = mock.state.read().await; assert!(state.error_models.len() == 2, "both error models should be created"); @@ -384,18 +376,7 @@ async fn test_effective_types_expand_modifiers() { let measure_instruction = make_jit_instruction(2, 2, vec![bin::gadget::Connector { gid: 1, port: 0 }]); controller.execute(measure_instruction).await; - // Wait for error models to be created (they're spawned asynchronously) - timeout(Duration::from_millis(100), async { - loop { - tokio::time::sleep(Duration::from_millis(10)).await; - let state = mock.state.read().await; - if state.error_models.len() >= 2 { - break; - } - } - }) - .await - .expect("error models should be created"); + wait_for_error_models(&mock, 2).await; let effective = mock.get_effective_types().await; @@ -492,22 +473,7 @@ async fn test_error_model_timing_blocked_until_output_connected() { .execute(make_jit_instruction(2, 2, vec![bin::gadget::Connector { gid: 1, port: 0 }])) .await; - // Wait for error models to be created - let error_models_created = timeout(Duration::from_millis(200), async { - loop { - tokio::time::sleep(Duration::from_millis(10)).await; - let state = mock.state.read().await; - if state.error_models.len() >= 2 { - return true; - } - } - }) - .await; - - assert!( - error_models_created.is_ok(), - "both error models should be created after output is connected" - ); + wait_for_error_models(&mock, 2).await; let state = mock.state.read().await; assert_eq!(state.error_models.len(), 2, "prepare and measure error models should exist"); @@ -541,22 +507,7 @@ async fn test_error_model_timing_chain() { .execute(make_jit_instruction(3, 2, vec![bin::gadget::Connector { gid: 1, port: 0 }])) .await; - // Wait for prepare_z error model to be created - let prepare_resolved = timeout(Duration::from_millis(100), async { - loop { - tokio::time::sleep(Duration::from_millis(10)).await; - let state = mock.state.read().await; - if !state.error_models.is_empty() { - return true; - } - } - }) - .await; - - assert!( - prepare_resolved.is_ok(), - "prepare error model should resolve when idle (with syndrome extraction) is connected" - ); + wait_for_error_models(&mock, 1).await; { let state = mock.state.read().await; @@ -574,21 +525,7 @@ async fn test_error_model_timing_chain() { .execute(make_jit_instruction(2, 3, vec![bin::gadget::Connector { gid: 2, port: 0 }])) .await; - let error_models_created = timeout(Duration::from_millis(200), async { - loop { - tokio::time::sleep(Duration::from_millis(10)).await; - let state = mock.state.read().await; - if state.error_models.len() >= 3 { - return true; - } - } - }) - .await; - - assert!( - error_models_created.is_ok(), - "all error models should be created after full chain is connected" - ); + wait_for_error_models(&mock, 3).await; } /// Test that measurement gadgets (no output ports) have error models created immediately. @@ -605,23 +542,7 @@ async fn test_error_model_immediate_for_no_output_gadget() { .execute(make_jit_instruction(2, 2, vec![bin::gadget::Connector { gid: 1, port: 0 }])) .await; - // The measure_z error model should be created very quickly since it has no outputs - let measure_error_created = timeout(Duration::from_millis(100), async { - loop { - tokio::time::sleep(Duration::from_millis(5)).await; - let state = mock.state.read().await; - // Check if measure's error model (eid 2) exists - if state.error_models.len() >= 2 { - return true; - } - } - }) - .await; - - assert!( - measure_error_created.is_ok(), - "measurement error model should be created quickly (no output blocking)" - ); + wait_for_error_models(&mock, 2).await; } // ============================================================================ @@ -668,18 +589,7 @@ async fn test_correctness_simple_prepare_measure() { .execute(make_jit_instruction(2, 2, vec![bin::gadget::Connector { gid: 1, port: 0 }])) .await; - // Wait for all error models to be created - timeout(Duration::from_millis(200), async { - loop { - tokio::time::sleep(Duration::from_millis(10)).await; - let state = mock.state.read().await; - if state.error_models.len() >= 2 { - break; - } - } - }) - .await - .expect("error models should be created"); + wait_for_error_models(&mock, 2).await; // Compare effective types against expected assert_effective_types_equivalent(&mock, &expected).await; @@ -738,17 +648,7 @@ async fn test_correctness_with_cache_enabled() { .execute(make_jit_instruction(2, 4, vec![bin::gadget::Connector { gid: 3, port: 0 }])) .await; - timeout(Duration::from_millis(200), async { - loop { - tokio::time::sleep(Duration::from_millis(10)).await; - let state = mock.state.read().await; - if state.error_models.len() >= 4 { - break; - } - } - }) - .await - .expect("all error models should be created"); + wait_for_error_models(&mock, 4).await; assert_effective_types_equivalent(&mock, &expected).await; } @@ -797,17 +697,7 @@ async fn test_correctness_with_idle_chain() { .execute(make_jit_instruction(2, 3, vec![bin::gadget::Connector { gid: 2, port: 0 }])) .await; - timeout(Duration::from_millis(200), async { - loop { - tokio::time::sleep(Duration::from_millis(10)).await; - let state = mock.state.read().await; - if state.error_models.len() >= 3 { - break; - } - } - }) - .await - .expect("all error models should be created"); + wait_for_error_models(&mock, 3).await; assert_effective_types_equivalent(&mock, &expected).await; } diff --git a/deq/deq_runtime/tests/task_counter_test.rs b/deq/deq_runtime/tests/task_counter_test.rs index 5cf8d16c..7b7b4c30 100644 --- a/deq/deq_runtime/tests/task_counter_test.rs +++ b/deq/deq_runtime/tests/task_counter_test.rs @@ -1,16 +1,13 @@ //! Unit tests for the `TaskCounter`/`TaskGuard` barrier primitive. use deq_runtime::misc::sync::TaskCounter; +use futures_util::FutureExt; use std::sync::Arc; -use std::time::Duration; #[tokio::test] async fn test_task_counter_zero_immediately() { let counter = TaskCounter::new(); - // No guards — should return immediately - tokio::time::timeout(Duration::from_millis(100), counter.wait_for_zero()) - .await - .expect("wait_for_zero should return immediately when count is 0"); + assert!(counter.wait_for_zero().now_or_never().is_some()); } #[tokio::test] @@ -18,22 +15,10 @@ async fn test_task_counter_waits_for_guard() { let counter = TaskCounter::new(); let guard = counter.guard(); - // wait_for_zero should NOT complete while guard is alive - let counter2 = Arc::clone(&counter); - let handle = tokio::spawn(async move { - counter2.wait_for_zero().await; - }); + assert!(counter.wait_for_zero().now_or_never().is_none()); - // Give it a moment to start waiting - tokio::time::sleep(Duration::from_millis(50)).await; - assert!(!handle.is_finished(), "wait_for_zero should still be waiting"); - - // Drop the guard — wait_for_zero should complete drop(guard); - tokio::time::timeout(Duration::from_millis(100), handle) - .await - .expect("should complete after guard drop") - .expect("task should not panic"); + assert!(counter.wait_for_zero().now_or_never().is_some()); } #[tokio::test] @@ -43,24 +28,14 @@ async fn test_task_counter_multiple_guards() { let g2 = counter.guard(); let g3 = counter.guard(); - let counter2 = Arc::clone(&counter); - let handle = tokio::spawn(async move { - counter2.wait_for_zero().await; - }); - drop(g1); - tokio::time::sleep(Duration::from_millis(20)).await; - assert!(!handle.is_finished(), "should still wait with 2 guards"); + assert!(counter.wait_for_zero().now_or_never().is_none()); drop(g2); - tokio::time::sleep(Duration::from_millis(20)).await; - assert!(!handle.is_finished(), "should still wait with 1 guard"); + assert!(counter.wait_for_zero().now_or_never().is_none()); drop(g3); - tokio::time::timeout(Duration::from_millis(100), handle) - .await - .expect("should complete after all guards dropped") - .expect("task should not panic"); + assert!(counter.wait_for_zero().now_or_never().is_some()); } #[tokio::test] @@ -81,10 +56,7 @@ async fn test_task_counter_guard_drop_on_panic() { // Drop the outer guard drop(_guard); - // Now wait_for_zero should complete - tokio::time::timeout(Duration::from_millis(100), counter.wait_for_zero()) - .await - .expect("wait_for_zero should complete after panic + guard drop"); + assert!(counter.wait_for_zero().now_or_never().is_some()); } #[tokio::test] @@ -96,14 +68,9 @@ async fn test_task_counter_pause_blocks_new_operations_and_reopens_on_drop() { assert!(counter.try_guard().is_none()); assert!(counter.try_pause().is_none()); - let waiting = tokio::spawn({ - let counter = counter.clone(); - async move { counter.wait_for_zero().await } - }); - tokio::task::yield_now().await; - assert!(!waiting.is_finished()); + assert!(counter.wait_for_zero().now_or_never().is_none()); drop(active); - waiting.await.unwrap(); + assert!(counter.wait_for_zero().now_or_never().is_some()); drop(pause); assert!(counter.try_guard().is_some()); From d187da5a27ef0991f8da7be335597fdea3ad2149 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 15 Aug 2026 14:11:41 -0700 Subject: [PATCH 088/157] add more tests --- deq/tests/transpiler/loss_proto_test.py | 72 +++++++ deq/tests/transpiler/loss_syntax_test.py | 250 +++++++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 deq/tests/transpiler/loss_proto_test.py create mode 100644 deq/tests/transpiler/loss_syntax_test.py diff --git a/deq/tests/transpiler/loss_proto_test.py b/deq/tests/transpiler/loss_proto_test.py new file mode 100644 index 00000000..29d5c326 --- /dev/null +++ b/deq/tests/transpiler/loss_proto_test.py @@ -0,0 +1,72 @@ +"""Tests for single-gadget loss protobuf metadata.""" + +import deq.proto.deq_bin_pb2 as bin_pb +import deq.proto.deq_jit_pb2 as jit_pb + + +def test_loss_model_round_trip() -> None: + gadget = jit_pb.JitGadgetType( + base=bin_pb.GadgetType( + measurements=[bin_pb.GadgetType.Measurement() for _ in range(2)], + loss_model=bin_pb.GadgetType.LossModel( + losses=[ + bin_pb.GadgetType.LossModel.Loss( + probability=0.1, + continuation_errors=[0], + source_errors=[1], + child_losses=[1], + ), + bin_pb.GadgetType.LossModel.Loss( + probability=0.2, + continuation_errors=[2], + source_errors=[0], + child_output_qubits=[1], + loss_measurements=[1], + ), + ], + input_losses=[ + bin_pb.GadgetType.LossModel.InputLoss(), + bin_pb.GadgetType.LossModel.InputLoss( + continuation_errors=[2], + child_losses=[0], + child_output_qubits=[0], + loss_measurements=[0], + ), + ], + ), + ), + errors=[ + jit_pb.JitGadgetType.Error( + base=bin_pb.ErrorModelType.Error(residual=[0], probability=0.0), + finished_checks=[0], + ), + jit_pb.JitGadgetType.Error( + base=bin_pb.ErrorModelType.Error(residual=[1], probability=0.0), + ), + jit_pb.JitGadgetType.Error( + base=bin_pb.ErrorModelType.Error(readout_flips=[0], probability=0.0), + ), + ], + ) + + decoded = jit_pb.JitGadgetType.FromString(gadget.SerializeToString()) + + assert decoded == gadget + loss_model = decoded.base.loss_model + assert loss_model.losses[0].child_losses == [1] + assert loss_model.losses[0].continuation_errors == [0] + assert loss_model.losses[1].child_output_qubits == [1] + assert loss_model.input_losses[1].continuation_errors == [2] + assert loss_model.input_losses[1].child_losses == [0] + assert loss_model.input_losses[1].child_output_qubits == [0] + assert loss_model.input_losses[1].loss_measurements == [0] + + +def test_port_type_records_physical_qubit_count() -> None: + port = jit_pb.JitPortType(base=bin_pb.PortType(ptype=1), k=1, n=7) + + decoded = jit_pb.JitPortType.FromString(port.SerializeToString()) + + assert decoded == port + assert decoded.k == 1 + assert decoded.n == 7 diff --git a/deq/tests/transpiler/loss_syntax_test.py b/deq/tests/transpiler/loss_syntax_test.py new file mode 100644 index 00000000..e641b524 --- /dev/null +++ b/deq/tests/transpiler/loss_syntax_test.py @@ -0,0 +1,250 @@ +"""Tests for the bidirectional ``LOSS`` syntax/runtime metadata codec.""" + +import pytest + +from deq.circuit.model import CodeDefinition, GadgetDefinition +from deq.circuit.parser import parse +from deq.transpiler.jit_library_builder import build_jit_library +from deq.transpiler.loss.syntax import loss_model_to_statements + +_HEADER = """ +CODE C[[3,1,1]] { + LOGICAL X0 Z0 +} +""" + + +def _loss_model(gadget_src: str): + lib = build_jit_library(parse(_HEADER + gadget_src)) + return lib.gadget_types[0].base.loss_model + + +def test_source_and_input_losses_are_packed_faithfully() -> None: + loss_model = _loss_model( + """ + GADGET G { + INPUT C 0 1 2 + M 0 + M 1 + OUTPUT C 0 1 2 + ERROR(0) LX0 + ERROR(0) LZ0 + ERROR(0) LY0 + LOSS(0.1) SE0 CE1 L1 OUT0.L2 M1 + LOSS(0.2) SE1 CE2 M0 + LOSS(IN0.L1) CE0 L0 OUT0.L0 M0 + } + """ + ) + + assert len(loss_model.losses) == 2 + assert len(loss_model.input_losses) == 3 + + first = loss_model.losses[0] + assert first.probability == pytest.approx(0.1) + assert list(first.source_errors) == [0] + assert list(first.continuation_errors) == [1] + assert list(first.child_losses) == [1] + assert list(first.child_output_qubits) == [2] + assert list(first.loss_measurements) == [1] + + entered = loss_model.input_losses[1] + assert list(entered.continuation_errors) == [0] + assert list(entered.child_losses) == [0] + assert list(entered.child_output_qubits) == [0] + assert list(entered.loss_measurements) == [0] + + assert loss_model.input_losses[0].SerializeToString() == b"" + assert loss_model.input_losses[2].SerializeToString() == b"" + + +def test_input_losses_flatten_across_multiple_ports() -> None: + loss_model = _loss_model( + """ + GADGET G { + INPUT C 0 1 2 + INPUT C 3 4 5 + M 0 + OUTPUT C 0 1 2 + OUTPUT C 3 4 5 + LOSS(IN1.L2) OUT1.L0 M0 + } + """ + ) + # Second port starts at flat offset 3; qubit 2 -> slot 5. + assert len(loss_model.input_losses) == 6 + assert loss_model.input_losses[5].SerializeToString() != b"" + # OUT1.L0 -> output offset 3 + 0 = 3. + assert list(loss_model.input_losses[5].child_output_qubits) == [3] + + +def test_runtime_model_decodes_to_canonical_loss_statements() -> None: + source = ( + _HEADER + + """ + GADGET G { + INPUT C 0 1 2 + INPUT C 3 4 5 + M 0 + OUTPUT C 0 1 2 + OUTPUT C 3 4 5 + ERROR(0) LX0 + LOSS(0.1) SE0 OUT1.L2 M0 + LOSS(IN1.L2) CE0 L0 OUT1.L0 M0 + } + """ + ) + qfile = parse(source) + codes = { + definition.name: definition + for definition in qfile.definitions + if isinstance(definition, CodeDefinition) + } + gadget = next( + definition + for definition in qfile.definitions + if isinstance(definition, GadgetDefinition) + ) + loss_model = build_jit_library(qfile).gadget_types[0].base.loss_model + + source_losses, input_losses = loss_model_to_statements( + loss_model, + input_ports=gadget.input_ports, + output_ports=gadget.output_ports, + codes=codes, + gadget_name=gadget.name, + ) + + assert [str(statement) for statement in source_losses] == [ + "LOSS(0.1) SE0 OUT1.L2 M0" + ] + assert [str(statement) for statement in input_losses] == [ + "LOSS(IN1.L2) CE0 L0 OUT1.L0 M0" + ] + + +def test_loss_free_gadget_has_no_loss_model() -> None: + lib = build_jit_library( + parse( + _HEADER + + """ + GADGET G { + INPUT C 0 1 2 + M 0 + OUTPUT C 0 1 2 + } + """ + ) + ) + assert not lib.gadget_types[0].base.HasField("loss_model") + + +def test_loss_and_loss_error_together_is_rejected() -> None: + with pytest.raises(ValueError, match="mixes"): + build_jit_library( + parse( + _HEADER + + """ + GADGET G { + INPUT C 0 1 2 + LOSS_ERROR(0.1) 0 + M 0 + OUTPUT C 0 1 2 + LOSS(0.1) M0 + } + """ + ) + ) + + +def test_out_of_range_error_index_is_rejected() -> None: + with pytest.raises(ValueError, match="error index"): + build_jit_library( + parse( + _HEADER + + """ + GADGET G { + INPUT C 0 1 2 + M 0 + OUTPUT C 0 1 2 + LOSS(0.1) SE5 M0 + } + """ + ) + ) + + +def test_out_of_range_output_qubit_is_rejected() -> None: + with pytest.raises(ValueError, match="physical qubit"): + build_jit_library( + parse( + _HEADER + + """ + GADGET G { + INPUT C 0 1 2 + M 0 + OUTPUT C 0 1 2 + LOSS(0.1) OUT0.L9 M0 + } + """ + ) + ) + + +@pytest.mark.parametrize( + "losses", + [ + "LOSS(0.1) L0\nLOSS(0.1)", + "LOSS(0.1)\nLOSS(0.1) L0", + ], +) +def test_source_loss_children_must_point_forward(losses: str) -> None: + with pytest.raises(ValueError, match="children must have greater indices"): + build_jit_library( + parse( + _HEADER + + f""" + GADGET G {{ + INPUT C 0 1 2 + M 0 + OUTPUT C 0 1 2 + {losses} + }} + """ + ) + ) + + +@pytest.mark.parametrize( + "loss_statement, duplicate", + [ + ("LOSS(0.1) SE0 SE0", "source-error reference SE0"), + ("LOSS(0.1) CE0 CE0", "continuation-error reference CE0"), + ("LOSS(0.1) L1 L1", "child-loss reference L1"), + ("LOSS(0.1) OUT0.L0 OUT0.L0", "output-qubit reference OUT0.L0"), + ("LOSS(0.1) M0 M0", "measurement reference M0"), + ("LOSS(IN0.L0) CE0 CE0", "continuation-error reference CE0"), + ("LOSS(IN0.L0) L0 L0", "child-loss reference L0"), + ("LOSS(IN0.L0) OUT0.L0 OUT0.L0", "output-qubit reference OUT0.L0"), + ("LOSS(IN0.L0) M0 M0", "measurement reference M0"), + ], +) +def test_explicit_loss_rejects_duplicate_references( + loss_statement: str, duplicate: str +) -> None: + with pytest.raises(ValueError, match=duplicate): + build_jit_library( + parse( + _HEADER + + f""" + GADGET G {{ + INPUT C 0 1 2 + M 0 + OUTPUT C 0 1 2 + ERROR(0.0) OUT0.LX0 + {loss_statement} + LOSS(0.1) + }} + """ + ) + ) From 345e8771b03d306709a5cdf0cf2913854bfd1a5f Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 15 Aug 2026 14:22:54 -0700 Subject: [PATCH 089/157] let naive decoder support all features for testing --- deq/deq/circuit/model.py | 3 +- deq/deq/cli/simulate.py | 1 + deq/deq_runtime/src/decoder/naive_decoder.py | 13 +++- deq/deq_runtime/src/decoder/naive_decoder.rs | 8 +++ .../src/decoder/relay_bp_decoder.py | 6 ++ .../src/decoder/relay_bp_decoder.rs | 27 ++++++--- .../src/decoder/tesseract_decoder.py | 6 ++ .../tests/standard_decoder_test.rs | 59 +++++++++++++++++-- 8 files changed, 107 insertions(+), 16 deletions(-) diff --git a/deq/deq/circuit/model.py b/deq/deq/circuit/model.py index d17a3421..4ba89988 100644 --- a/deq/deq/circuit/model.py +++ b/deq/deq/circuit/model.py @@ -470,7 +470,8 @@ class LossStatement: ``source_errors`` / ``continuation_errors`` index the gadget's ``ERROR`` mechanisms; ``child_losses`` index the source losses (within-gadget children); ``output_qubits`` are ``(port, qubit)`` - physical exits; ``measurement_indices`` are herald measurements. + physical exits; ``measurement_indices`` are herald measurements. These + collections are set-valued, so explicit duplicate references are rejected. """ probability: float | None = None diff --git a/deq/deq/cli/simulate.py b/deq/deq/cli/simulate.py index 795ae957..1919154a 100644 --- a/deq/deq/cli/simulate.py +++ b/deq/deq/cli/simulate.py @@ -414,6 +414,7 @@ def _run_batch( runtime_simulator = simulator elif simulator == "qdk": simulator_config["sampler"] = "@qdk_sampler" + simulator_config["py_config"] = {"batch_size": batch_size + 1} controller_name = "static" controller_config = {"filepath": bin_path} runtime_simulator = "python" diff --git a/deq/deq_runtime/src/decoder/naive_decoder.py b/deq/deq_runtime/src/decoder/naive_decoder.py index 74b31477..fcc54edf 100644 --- a/deq/deq_runtime/src/decoder/naive_decoder.py +++ b/deq/deq_runtime/src/decoder/naive_decoder.py @@ -2,6 +2,10 @@ class Decoder: + @staticmethod + def supported_features() -> list[str]: + return ["reweights", "loss"] + def __init__(self, hypergraph, config: Dict): self.verbose = bool(config.get("verbose", False)) if self.verbose: @@ -9,7 +13,14 @@ def __init__(self, hypergraph, config: Dict): print(" hypergraph:", hypergraph) print(" config:", config) - def decode(self, syndrome: list[int]) -> list[int]: + def decode( + self, + syndrome: list[int], + *, + reweights=None, + loss=None, + ) -> list[int]: + del reweights, loss assert isinstance(syndrome, list) if self.verbose: print("Decoding with Decoder") diff --git a/deq/deq_runtime/src/decoder/naive_decoder.rs b/deq/deq_runtime/src/decoder/naive_decoder.rs index 11924292..29b44750 100644 --- a/deq/deq_runtime/src/decoder/naive_decoder.rs +++ b/deq/deq_runtime/src/decoder/naive_decoder.rs @@ -4,6 +4,7 @@ //! use crate::decoder::blackbox_decoder::{self, black_box_decoder_server}; +use crate::decoder::thread_pooling::DecoderFeatures; use serde::{Deserialize, Serialize}; #[cfg(feature = "cli")] use std::sync::Arc; @@ -38,6 +39,13 @@ impl NaiveDecoder { #[tonic::async_trait] impl black_box_decoder_server::BlackBoxDecoder for NaiveDecoder { + async fn get_capabilities( + &self, + _request: Request<()>, + ) -> Result, Status> { + Ok(Response::new((DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS).to_proto())) + } + async fn decode( &self, _request: Request, diff --git a/deq/deq_runtime/src/decoder/relay_bp_decoder.py b/deq/deq_runtime/src/decoder/relay_bp_decoder.py index c0802d4c..3ac5830c 100644 --- a/deq/deq_runtime/src/decoder/relay_bp_decoder.py +++ b/deq/deq_runtime/src/decoder/relay_bp_decoder.py @@ -3,6 +3,8 @@ Exposes the deq Python decoder protocol: class Decoder: + @staticmethod + def supported_features() -> list[str]: ... def __init__(self, hypergraph, config: dict): ... def decode(self, syndrome: list[int]) -> list[int]: ... def reset(self) -> None: ... @@ -39,6 +41,10 @@ def reset(self) -> None: ... class Decoder: + @staticmethod + def supported_features() -> list[str]: + return [] + def __init__(self, hypergraph: Any, config: Dict[str, Any]): vertex_num = int(hypergraph.vertex_num) hyperedges = list(hypergraph.hyperedges) diff --git a/deq/deq_runtime/src/decoder/relay_bp_decoder.rs b/deq/deq_runtime/src/decoder/relay_bp_decoder.rs index 0f88c439..bfd25c60 100644 --- a/deq/deq_runtime/src/decoder/relay_bp_decoder.rs +++ b/deq/deq_runtime/src/decoder/relay_bp_decoder.rs @@ -2,9 +2,10 @@ //! use crate::decoder::blackbox_decoder::{self, ParityFactor}; -use crate::decoder::thread_pooling::{DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder}; +use crate::decoder::thread_pooling::{ + DecodeError, DecodeRequest, DecoderFeatures, DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder, +}; use crate::misc::bit_vector::to_sparse_indices; -use crate::util::BitVector; use blackbox_decoder::DecodingHypergraph; use core::panic; use ndarray::{Array1, Array2}; @@ -210,19 +211,27 @@ impl DecoderInstance for RelayBPDecoderInst Self { solver, active_edges } } - fn decode(&mut self, syndrome: &BitVector) -> ParityFactor { - let mut detectors = Array1::::zeros(syndrome.size as usize); - for index in to_sparse_indices(syndrome) { + fn decode(&mut self, request: DecodeRequest<'_>) -> Result { + request.require_supported(DecoderFeatures::empty())?; + let mut detectors = Array1::::zeros(request.syndrome.size as usize); + for index in to_sparse_indices(request.syndrome) { detectors[index as usize] = 1; } let decoding = self.solver.decode(detectors.view()); - ParityFactor { + if decoding.len() != self.active_edges.len() { + return Err(DecodeError::Backend(format!( + "Relay-BP returned {} edge decisions, expected {}", + decoding.len(), + self.active_edges.len() + ))); + } + Ok(ParityFactor { subgraph: decoding .iter() - .enumerate() - .filter_map(|(i, &bit)| if bit == 1 { Some(self.active_edges[i]) } else { None }) + .zip(&self.active_edges) + .filter_map(|(&bit, &edge)| (bit == 1).then_some(edge)) .collect(), - } + }) } fn reset(&mut self) {} diff --git a/deq/deq_runtime/src/decoder/tesseract_decoder.py b/deq/deq_runtime/src/decoder/tesseract_decoder.py index 49ecb73d..d3530a97 100644 --- a/deq/deq_runtime/src/decoder/tesseract_decoder.py +++ b/deq/deq_runtime/src/decoder/tesseract_decoder.py @@ -3,6 +3,8 @@ Exposes the deq Python decoder protocol: class Decoder: + @staticmethod + def supported_features() -> list[str]: ... def __init__(self, hypergraph, config: dict): ... def decode(self, syndrome: list[int]) -> list[int]: ... def reset(self) -> None: ... @@ -48,6 +50,10 @@ def _build_dem(vertex_num: int, hyperedges) -> stim.DetectorErrorModel: class Decoder: + @staticmethod + def supported_features() -> list[str]: + return [] + def __init__(self, hypergraph: Any, config: Dict[str, Any]): vertex_num = int(hypergraph.vertex_num) hyperedges = list(hypergraph.hyperedges) diff --git a/deq/deq_runtime/tests/standard_decoder_test.rs b/deq/deq_runtime/tests/standard_decoder_test.rs index 12eb6c4e..77f5f136 100644 --- a/deq/deq_runtime/tests/standard_decoder_test.rs +++ b/deq/deq_runtime/tests/standard_decoder_test.rs @@ -10,9 +10,14 @@ use std::sync::Arc; #[cfg(feature = "python")] use std::io::Write; +use deq_runtime::decoder::blackbox_decoder::{ + DecodingHypergraph, DecodingProblem, EdgeReweight, Hyperedge, LoadedDecodingProblem, LossInfo, LossSite, +}; use deq_runtime::decoder::test_harness::{Outcome, Path, SuiteReport, run_standard_suite}; use deq_runtime::decoder::test_problems::standard_test_problems; +use deq_runtime::decoder::thread_pooling::DecoderFeatures; use deq_runtime::decoder::{BlackBoxDecoderClient, DynBlackBoxDecoder, MockDecoder, NaiveDecoder}; +use deq_runtime::util::BitVector; type ExpectedPassFn = fn(problem: &str, case: &str, path: Path) -> bool; @@ -74,12 +79,60 @@ fn always_pass_policy(_problem: &str, _case: &str, _path: Path) -> bool { true } +async fn assert_accepts_all_features(client: &mut BlackBoxDecoderClient) { + assert_eq!(client.features(), DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS); + let hypergraph = DecodingHypergraph { + vertex_num: 1, + hyperedges: vec![Hyperedge { + vertices: vec![0], + probability: 0.1, + }], + }; + let syndrome = BitVector { + size: 1, + data: vec![0b1000_0000], + }; + let loss = LossInfo { + sites: vec![LossSite { + source_edges: vec![0], + probability: 0.2, + ..Default::default() + }], + }; + + let parity_factor = client + .decode(DecodingProblem { + hypergraph: Some(hypergraph.clone()), + syndrome: Some(syndrome.clone()), + loss: Some(loss.clone()), + }) + .await + .unwrap(); + assert!(parity_factor.subgraph.is_empty()); + + let hid = client.load_hypergraph(hypergraph).await.unwrap().hid; + let parity_factor = client + .decode_loaded(LoadedDecodingProblem { + hid, + syndrome: Some(syndrome), + reweights: vec![EdgeReweight { + edge: 0, + probability: 0.25, + }], + loss: Some(loss), + }) + .await + .unwrap(); + assert!(parity_factor.subgraph.is_empty()); +} + #[tokio::test] async fn test_naive_decoder() { let decoder = Arc::new(NaiveDecoder::new(serde_json::json!({}))); let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxNaive(decoder)) .await .unwrap(); + assert_accepts_all_features(&mut client).await; let report = run_standard_suite(&mut client).await; assert_full_coverage(&report); assert_matches_policy(&report, always_empty_subgraph_policy); @@ -130,6 +183,7 @@ async fn test_python_naive_decoder() { let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxPython(decoder)) .await .unwrap(); + assert_accepts_all_features(&mut client).await; let report = run_standard_suite(&mut client).await; assert_full_coverage(&report); assert_matches_policy(&report, always_empty_subgraph_policy); @@ -176,11 +230,6 @@ class LegacyDecoder: #[tokio::test] async fn test_python_decoder_receives_reweights_and_loss_together() { use deq_runtime::decoder::PythonDecoder; - use deq_runtime::decoder::blackbox_decoder::{ - DecodingHypergraph, EdgeReweight, Hyperedge, LoadedDecodingProblem, LossInfo, LossSite, - }; - use deq_runtime::decoder::thread_pooling::DecoderFeatures; - use deq_runtime::util::BitVector; let mut decoder_file = tempfile::Builder::new().suffix(".py").tempfile().unwrap(); decoder_file From ce5d38412a4600a5323436e8737c475b0ecc56eb Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 15 Aug 2026 14:54:49 -0700 Subject: [PATCH 090/157] add loss model API --- deq/deq/transpiler/loss/api.py | 133 +++++++++++++++++++++++++++++++++ deq/deq_runtime/src/cli.rs | 6 +- 2 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 deq/deq/transpiler/loss/api.py diff --git a/deq/deq/transpiler/loss/api.py b/deq/deq/transpiler/loss/api.py new file mode 100644 index 00000000..b72a00fc --- /dev/null +++ b/deq/deq/transpiler/loss/api.py @@ -0,0 +1,133 @@ +"""Public protocols and immutable inputs for physical loss models.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + + +class UnsupportedLossModelError(ValueError): + """Raised when a circuit is outside a loss model's supported scope.""" + + +@dataclass(frozen=True) +class LossGate: + """One gate occurrence passed to a loss-model handler. + + Multi-target Stim instructions are atomized before dispatch, so ``qubits`` + contains exactly the operands of one gate application. Boundaries are in + the current loss-analysis operation stream. + """ + + name: str + source_name: str + arguments: tuple[float, ...] + qubits: tuple[int, ...] + measurement_indices: tuple[int, ...] + body_index: int + boundary_before: int + boundary_after: int + produces_measurement: bool + resets_qubits: bool + is_native: bool + + +@runtime_checkable +class LossAnalysisState(Protocol): + """Constrained mutation surface available to individual gate handlers.""" + + def active_event_ids(self, qubit: int) -> tuple[int, ...]: + """Return source-event worlds with an active branch on ``qubit``.""" + + ... + + def event_has_active_loss(self, event_id: int, qubit: int) -> bool: + """Whether ``event_id`` currently loses ``qubit``.""" + + ... + + def add_continuation_pauli_insertion( + self, + qubit: int, + boundary: int, + paulis: tuple[str, ...] = ("I", "X", "Y", "Z"), + ) -> None: + """Add an inheritable Pauli set to every active branch on ``qubit``.""" + + ... + + def add_event_continuation_pauli_insertion( + self, + event_id: int, + *, + lost_qubit: int, + error_qubit: int, + boundary: int, + paulis: tuple[str, ...] = ("I", "X", "Y", "Z"), + ) -> None: + """Add an inheritable Pauli set in one source-event world.""" + + ... + + def record_loss_measurement(self, qubit: int, measurement_index: int) -> None: + """Associate a measurement result with every active branch.""" + + ... + + def clear_loss(self, qubit: int) -> None: + """Terminate all active branches occupying ``qubit``.""" + + ... + + def swap_losses( + self, event_id: int, first_qubit: int, second_qubit: int, boundary: int + ) -> None: + """Swap active loss flags by ending and recreating event branches.""" + + ... + +@runtime_checkable +class LossGateHandler(Protocol): + """Stateful per-gadget handler that receives one gate at a time.""" + + native_gate_names: frozenset[str] + + def handle_h(self, gate: LossGate, state: LossAnalysisState) -> None: + """Handle one primitive Hadamard occurrence.""" + + ... + + def handle_s(self, gate: LossGate, state: LossAnalysisState) -> None: + """Handle one primitive square-root-of-Z occurrence.""" + + ... + + def handle_cx(self, gate: LossGate, state: LossAnalysisState) -> None: + """Handle one primitive controlled-X occurrence.""" + + ... + + def handle_m(self, gate: LossGate, state: LossAnalysisState) -> None: + """Handle one primitive Z-measurement occurrence.""" + + ... + + def handle_r(self, gate: LossGate, state: LossAnalysisState) -> None: + """Handle one primitive Z-reset occurrence.""" + + ... + + def handle_native_gate(self, gate: LossGate, state: LossAnalysisState) -> None: + """Handle an opted-in source gate without Stim decomposition.""" + + ... + + +@runtime_checkable +class LossModel(Protocol): + """Configured physical loss model shared across gadget analyses.""" + + def create_handler(self) -> LossGateHandler: + """Create fresh mutable handler state for one gadget traversal.""" + + ... diff --git a/deq/deq_runtime/src/cli.rs b/deq/deq_runtime/src/cli.rs index 7aed99d1..7ee7763b 100644 --- a/deq/deq_runtime/src/cli.rs +++ b/deq/deq_runtime/src/cli.rs @@ -55,7 +55,7 @@ enum Commands { enum TestCommands { /// Run the standard suite against a Python decoder defined in a *.py file PythonDecoder { - /// Path to the Python decoder file (must expose a `Decoder` class with `__init__(hypergraph, config)`, `decode(...)`, `reset()`) + /// Path to the Python decoder file (must expose a `Decoder` class with optional static `supported_features()`, plus `__init__(hypergraph, config)`, `decode(...)`, and `reset()`) #[clap(long)] file: PathBuf, /// Optional Python-decoder configuration as a JSON object @@ -103,7 +103,9 @@ async fn run_python_decoder_test(file: PathBuf, py_config: serde_json::Value) { "py_config": py_config, }); let decoder = Arc::new(PythonDecoder::new(config)); - let mut client = BlackBoxDecoderClient::Local(DynBlackBoxDecoder::BlackBoxPython(decoder)); + let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxPython(decoder)) + .await + .unwrap(); let report = run_standard_suite(&mut client).await; for line in report.summary_lines() { println!("{line}"); From c9ee18027450ab51ba948ba3bd51c11e2eee3bee Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 15 Aug 2026 15:12:01 -0700 Subject: [PATCH 091/157] simplify feature passing --- .../src/decoder/dyn_lib_decoder.rs | 34 ++-- deq/deq_runtime/src/decoder/naive_decoder.rs | 7 +- deq/deq_runtime/src/decoder/python_decoder.rs | 149 ++++++++++++------ deq/deq_runtime/src/decoder/test_harness.rs | 26 +-- deq/deq_runtime/tests/dyn_lib_decoder_test.rs | 8 +- deq/deq_runtime/tests/mock_decoder_test.rs | 31 ++-- .../tests/monolithic_coordinator_test.rs | 12 +- .../tests/standard_decoder_test.rs | 89 ++++------- .../tests/window_coordinator_test.rs | 6 +- 9 files changed, 200 insertions(+), 162 deletions(-) diff --git a/deq/deq_runtime/src/decoder/dyn_lib_decoder.rs b/deq/deq_runtime/src/decoder/dyn_lib_decoder.rs index e5b37bad..ad8cb37a 100644 --- a/deq/deq_runtime/src/decoder/dyn_lib_decoder.rs +++ b/deq/deq_runtime/src/decoder/dyn_lib_decoder.rs @@ -22,8 +22,9 @@ use serde::{Deserialize, Serialize}; use structdoc::StructDoc; use crate::decoder::blackbox_decoder::{DecodingHypergraph, ParityFactor}; -use crate::decoder::thread_pooling::{DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder}; -use crate::util::BitVector; +use crate::decoder::thread_pooling::{ + DecodeError, DecodeRequest, DecoderFeatures, DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder, +}; #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "cli", derive(StructDoc))] @@ -104,17 +105,30 @@ impl DecoderInstance for DynLibInstance { Self { loaded, active_edges } } - fn decode(&mut self, syndrome: &BitVector) -> ParityFactor { + fn decode(&mut self, request: DecodeRequest<'_>) -> Result { + request.require_supported(DecoderFeatures::empty())?; // deq's BitVector is already the dense MSB-first packing the ABI expects, // so it passes through with no conversion. let mut subgraph = Vec::new(); - match self.loaded.decode(syndrome.size, &syndrome.data, &mut subgraph) { - Ok(()) => ParityFactor { - subgraph: subgraph.into_iter().map(|index| self.active_edges[index as usize]).collect(), - }, - // DecoderInstance::decode has no error channel; panic so ThreadPoolingDecoder's - // catch_unwind turns it into a gRPC Status::internal, mirroring PythonDecoder. - Err(e) => panic!("dylib decode failed: {e}"), + match self + .loaded + .decode(request.syndrome.size, &request.syndrome.data, &mut subgraph) + { + Ok(()) => { + let subgraph = subgraph + .into_iter() + .map(|index| { + self.active_edges.get(index as usize).copied().ok_or_else(|| { + DecodeError::Backend(format!( + "decoder plugin returned edge {index}, but only {} edges were loaded", + self.active_edges.len() + )) + }) + }) + .collect::>()?; + Ok(ParityFactor { subgraph }) + } + Err(error) => Err(DecodeError::Backend(error.to_string())), } } diff --git a/deq/deq_runtime/src/decoder/naive_decoder.rs b/deq/deq_runtime/src/decoder/naive_decoder.rs index 29b44750..bbbe49a1 100644 --- a/deq/deq_runtime/src/decoder/naive_decoder.rs +++ b/deq/deq_runtime/src/decoder/naive_decoder.rs @@ -29,6 +29,11 @@ impl NaiveDecoder { Self { config } } + #[must_use] + pub fn supported_features(&self) -> DecoderFeatures { + DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS + } + #[cfg(feature = "cli")] pub fn add_service(self: &Arc, router: Router) -> Router { let service = @@ -43,7 +48,7 @@ impl black_box_decoder_server::BlackBoxDecoder for NaiveDecoder { &self, _request: Request<()>, ) -> Result, Status> { - Ok(Response::new((DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS).to_proto())) + Ok(Response::new(self.supported_features().to_proto())) } async fn decode( diff --git a/deq/deq_runtime/src/decoder/python_decoder.rs b/deq/deq_runtime/src/decoder/python_decoder.rs index 488addd7..f40abb23 100644 --- a/deq/deq_runtime/src/decoder/python_decoder.rs +++ b/deq/deq_runtime/src/decoder/python_decoder.rs @@ -3,22 +3,30 @@ //! Calling another decoder written in Python language with the following APIs: //! //! class Decoder: +//! @staticmethod +//! def supported_features() -> list[str]: ... //! def __init__(self, hypergraph: DecodingHypergraph, config: Dict): ... //! def decode(self, syndrome: list[int]) -> list[int]: ... //! def reset(self) -> None: ... //! //! The class name defaults to `Decoder` and can be overridden by setting the -//! top-level `name` field in the decoder JSON config. +//! top-level `name` field in the decoder JSON config. Optional fields are +//! declared by the Python class's `supported_features()` function. A decoder +//! declaring `reweights` receives +//! `decode(syndrome, reweights=...)`, one declaring `loss` receives +//! `decode(syndrome, loss=...)`, and one declaring both may receive both keyword +//! arguments in the same call. //! -use crate::decoder::blackbox_decoder::{DecodingHypergraph, LossInfo, ParityFactor}; -use crate::decoder::thread_pooling::{DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder}; +use crate::decoder::blackbox_decoder::{DecodingHypergraph, ParityFactor}; +use crate::decoder::thread_pooling::{ + DecodeError, DecodeRequest, DecoderFeatures, DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder, +}; use crate::misc::bit_vector::to_sparse_indices; use crate::misc::python::{get_or_load_module, get_or_load_module_from_source, json_value_to_py}; -use crate::util::BitVector; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -use pyo3::types::PyList; +use pyo3::types::{PyDict, PyList}; use serde::{Deserialize, Serialize}; #[cfg(feature = "cli")] use structdoc::StructDoc; @@ -70,7 +78,7 @@ pub struct PythonDecoderConfig { #[serde(default = "default_decoder_class_name")] pub name: String, /// Python decoder parameters - #[structdoc(skip)] + #[cfg_attr(feature = "cli", structdoc(skip))] pub py_config: Option, } @@ -78,6 +86,47 @@ fn default_decoder_class_name() -> String { "Decoder".to_string() } +fn load_decoder_module<'py>(py: Python<'py>, file: &str) -> PyResult> { + if let Some(builtin_name) = file.strip_prefix('@') { + let (filename, source) = builtin_decoders::lookup(builtin_name).ok_or_else(|| { + let known = builtin_decoders::names() + .iter() + .map(|name| format!("@{name}")) + .collect::>() + .join(", "); + PyValueError::new_err(format!("unknown builtin decoder '@{builtin_name}'. Known builtins: {known}")) + })?; + get_or_load_module_from_source(py, filename, source) + } else { + get_or_load_module(py, file) + } +} + +fn decoder_features(file: &str, class_name: &str) -> PyResult { + Python::attach(|py| { + let module = load_decoder_module(py, file)?; + let decoder_class = module.getattr(class_name)?; + if !decoder_class.hasattr("supported_features")? { + return Ok(DecoderFeatures::empty()); + } + let feature_names = decoder_class.call_method0("supported_features")?.extract::>()?; + let mut features = DecoderFeatures::empty(); + for feature_name in feature_names { + features = features + | match feature_name.as_str() { + "reweights" => DecoderFeatures::REWEIGHTS, + "loss" => DecoderFeatures::LOSS, + _ => { + return Err(PyValueError::new_err(format!( + "unsupported Python decoder feature {feature_name:?}; expected \"reweights\" or \"loss\"" + ))); + } + }; + } + Ok(features) + }) +} + #[pyclass(name = "DecodingHypergraph")] pub struct PyDecodingHypergraph { #[pyo3(get, set)] @@ -177,22 +226,20 @@ pub struct PythonDecoderInstance { } impl DecoderInstance for PythonDecoderInstance { + fn supported_features(config: &serde_json::Value) -> DecoderFeatures { + let config = serde_json::from_value::(config.clone()).expect("invalid PythonDecoderConfig"); + decoder_features(&config.file, &config.name).unwrap_or_else(|error| { + panic!( + "failed to query supported_features() from Python decoder {}.{}: {error}", + config.file, config.name + ) + }) + } + fn new(hypergraph: &DecodingHypergraph, config: &serde_json::Value) -> Self { let config: PythonDecoderConfig = serde_json::from_value(config.clone()).unwrap(); let decoder = Python::attach(|py| { - let module = if let Some(builtin_name) = config.file.strip_prefix('@') { - let (fname, source) = builtin_decoders::lookup(builtin_name).ok_or_else(|| { - let known = builtin_decoders::names() - .iter() - .map(|n| format!("@{n}")) - .collect::>() - .join(", "); - PyValueError::new_err(format!("unknown builtin decoder '@{builtin_name}'. Known builtins: {known}")) - })?; - get_or_load_module_from_source(py, fname, source)? - } else { - get_or_load_module(py, &config.file)? - }; + let module = load_decoder_module(py, &config.file)?; let py_hypergraph = PyDecodingHypergraph::new(py, hypergraph)?; let py_config = json_value_to_py(py, &config.py_config.unwrap_or_else(|| serde_json::json!({})))?; let decoder_class = module.getattr(config.name.as_str())?; @@ -203,49 +250,47 @@ impl DecoderInstance for PythonDecoderInstance { Self { decoder } } - fn decode(&mut self, syndrome: &BitVector) -> ParityFactor { + fn decode(&mut self, request: DecodeRequest<'_>) -> Result { let subgraph = Python::attach(|py| { let decoder = self.decoder.bind(py); let py_syndrome = PyList::empty(py); - for index in to_sparse_indices(syndrome) { + for index in to_sparse_indices(request.syndrome) { py_syndrome.append(index)?; } - let py_result = decoder.call_method1("decode", (py_syndrome,))?; - py_result.extract::>() - }) - .unwrap(); - ParityFactor { subgraph } - } - - fn decode_with_loss(&mut self, syndrome: &BitVector, loss: Option<&LossInfo>) -> ParityFactor { - // No observed loss this shot -> use the ordinary single-argument call so - // loss-unaware Python decoders keep working unchanged. - let Some(loss) = loss.filter(|l| !l.sites.is_empty()) else { - return self.decode(syndrome); - }; - let subgraph = Python::attach(|py| { - let decoder = self.decoder.bind(py); - let py_syndrome = PyList::empty(py); - for index in to_sparse_indices(syndrome) { - py_syndrome.append(index)?; + let py_reweights = (!request.reweights.is_empty()).then(|| request.reweights.to_vec()); + let py_loss = request + .loss + .map(|loss| { + let py_sites = PyList::empty(py); + for site in &loss.sites { + py_sites.append(PyLossSite { + source_edges: site.source_edges.clone(), + continuation_edges: site.continuation_edges.clone(), + children: site.children.clone(), + probability: site.probability, + })?; + } + Ok::(PyLossInfo { + sites: py_sites.unbind(), + }) + }) + .transpose()?; + let kwargs = PyDict::new(py); + if let Some(reweights) = py_reweights { + kwargs.set_item("reweights", reweights)?; } - let py_sites = PyList::empty(py); - for site in &loss.sites { - py_sites.append(PyLossSite { - source_edges: site.source_edges.clone(), - continuation_edges: site.continuation_edges.clone(), - children: site.children.clone(), - probability: site.probability, - })?; + if let Some(loss) = py_loss { + kwargs.set_item("loss", loss)?; } - let py_loss = PyLossInfo { - sites: py_sites.unbind(), + let py_result = if kwargs.is_empty() { + decoder.call_method1("decode", (py_syndrome,))? + } else { + decoder.call_method("decode", (py_syndrome,), Some(&kwargs))? }; - let py_result = decoder.call_method1("decode", (py_syndrome, py_loss))?; py_result.extract::>() }) - .unwrap(); - ParityFactor { subgraph } + .map_err(|error| DecodeError::Backend(error.to_string()))?; + Ok(ParityFactor { subgraph }) } fn reset(&mut self) { diff --git a/deq/deq_runtime/src/decoder/test_harness.rs b/deq/deq_runtime/src/decoder/test_harness.rs index 1a22c1f0..a75b98dc 100644 --- a/deq/deq_runtime/src/decoder/test_harness.rs +++ b/deq/deq_runtime/src/decoder/test_harness.rs @@ -1,12 +1,12 @@ //! Standard decoder test harness //! //! Runs the curated set of [`StandardTestProblem`]s against any -//! [`BlackBoxDecoderClient`] (local or remote), exercising both the one-shot +//! [`DynDecoder`], exercising both the one-shot //! `decode` path and the `load_hypergraph` + `decode_loaded` path for every //! case. Returns a [`SuiteReport`] describing the actual outcome of each call; //! callers decide what is acceptable. -use crate::decoder::BlackBoxDecoderClient; +use crate::decoder::DynDecoder; use crate::decoder::blackbox_decoder::{self, DecodingHypergraph, ParityFactor}; use crate::decoder::blackbox_util::is_parity_factor; use crate::decoder::test_problems::{StandardTestProblem, TestCase, case_id, standard_test_problems}; @@ -111,12 +111,12 @@ fn classify(hypergraph: &DecodingHypergraph, syndrome: &BitVector, response: &Pa } } -async fn run_one_problem(client: &mut BlackBoxDecoderClient, problem: &StandardTestProblem, out: &mut Vec) { +async fn run_one_problem(decoder: &DynDecoder, problem: &StandardTestProblem, out: &mut Vec) { for case in &problem.cases { - out.push(run_decode_path(client, problem, case).await); + out.push(run_decode_path(decoder, problem, case).await); } - let load_outcome = client.load_hypergraph(problem.hypergraph.clone()).await; + let load_outcome = decoder.load_hypergraph(problem.hypergraph.clone()).await; let hid = match load_outcome { Ok(response) => Some(response.hid), Err(status) => { @@ -135,13 +135,13 @@ async fn run_one_problem(client: &mut BlackBoxDecoderClient, problem: &StandardT if let Some(hid) = hid { for case in &problem.cases { - out.push(run_decode_loaded_path(client, problem, case, hid).await); + out.push(run_decode_loaded_path(decoder, problem, case, hid).await); } } // Best-effort reset between problems. Failures are recorded as a synthetic // entry so callers see the issue but do not crash the rest of the suite. - if let Err(status) = client + if let Err(status) = decoder .reset(blackbox_decoder::ResetRequest { reset_hypergraphs: true, ..Default::default() @@ -157,13 +157,13 @@ async fn run_one_problem(client: &mut BlackBoxDecoderClient, problem: &StandardT } } -async fn run_decode_path(client: &mut BlackBoxDecoderClient, problem: &StandardTestProblem, case: &TestCase) -> CaseResult { +async fn run_decode_path(decoder: &DynDecoder, problem: &StandardTestProblem, case: &TestCase) -> CaseResult { let problem_payload = blackbox_decoder::DecodingProblem { hypergraph: Some(problem.hypergraph.clone()), syndrome: Some(case.syndrome.clone()), loss: None, }; - let outcome = match client.decode(problem_payload).await { + let outcome = match decoder.decode(problem_payload).await { Ok(response) => classify(&problem.hypergraph, &case.syndrome, &response), Err(status) => Outcome::RpcError(status.to_string()), }; @@ -176,7 +176,7 @@ async fn run_decode_path(client: &mut BlackBoxDecoderClient, problem: &StandardT } async fn run_decode_loaded_path( - client: &mut BlackBoxDecoderClient, + decoder: &DynDecoder, problem: &StandardTestProblem, case: &TestCase, hid: u64, @@ -186,7 +186,7 @@ async fn run_decode_loaded_path( syndrome: Some(case.syndrome.clone()), ..Default::default() }; - let outcome = match client.decode_loaded(problem_payload).await { + let outcome = match decoder.decode_loaded(problem_payload).await { Ok(response) => classify(&problem.hypergraph, &case.syndrome, &response), Err(status) => Outcome::RpcError(status.to_string()), }; @@ -199,10 +199,10 @@ async fn run_decode_loaded_path( } /// Run the full standard suite against `client` and collect outcomes. -pub async fn run_standard_suite(client: &mut BlackBoxDecoderClient) -> SuiteReport { +pub async fn run_standard_suite(decoder: &DynDecoder) -> SuiteReport { let mut results: Vec = Vec::new(); for problem in standard_test_problems() { - run_one_problem(client, &problem, &mut results).await; + run_one_problem(decoder, &problem, &mut results).await; } SuiteReport { results } } diff --git a/deq/deq_runtime/tests/dyn_lib_decoder_test.rs b/deq/deq_runtime/tests/dyn_lib_decoder_test.rs index 0081bc4a..0b8e5c63 100644 --- a/deq/deq_runtime/tests/dyn_lib_decoder_test.rs +++ b/deq/deq_runtime/tests/dyn_lib_decoder_test.rs @@ -11,9 +11,8 @@ use std::path::PathBuf; use std::sync::Arc; use deq_runtime::controller::ParseByName; -use deq_runtime::decoder::DecoderType; -use deq_runtime::decoder::DynLibDecoder; use deq_runtime::decoder::blackbox_decoder::{self, black_box_decoder_server::BlackBoxDecoder}; +use deq_runtime::decoder::{DecoderType, DynDecoder, DynLibDecoder}; use deq_runtime::util::BitVector; use serde_json::json; use tonic::Request; @@ -108,8 +107,5 @@ fn cli_name_selects_dynlib() { // `create` returns the dynlib variant for this name. let config = json!({ "parallel": 1, "library": plugin_path() }); let decoder = DecoderType::BlackBoxDynLib.create(config); - assert!( - decoder.as_black_box_decoder().is_some(), - "dynlib variant must expose a blackbox decoder" - ); + assert!(matches!(decoder, DynDecoder::BlackBoxDynLib(_))); } diff --git a/deq/deq_runtime/tests/mock_decoder_test.rs b/deq/deq_runtime/tests/mock_decoder_test.rs index 7d428e57..85eb021e 100644 --- a/deq/deq_runtime/tests/mock_decoder_test.rs +++ b/deq/deq_runtime/tests/mock_decoder_test.rs @@ -1,10 +1,12 @@ //! Tests for MockDecoder +#[cfg(feature = "cli")] +use deq_runtime::decoder::blackbox_decoder::black_box_decoder_client::BlackBoxDecoderClient; #[cfg(feature = "cli")] use deq_runtime::decoder::blackbox_decoder::black_box_decoder_server::BlackBoxDecoderServer; use deq_runtime::decoder::blackbox_decoder::{self, black_box_decoder_server::BlackBoxDecoder}; use deq_runtime::decoder::thread_pooling::DecoderFeatures; -use deq_runtime::decoder::{BlackBoxDecoderClient, MockDecoder}; +use deq_runtime::decoder::{DynDecoder, MockDecoder}; use deq_runtime::util::BitVector; use std::sync::Arc; use tonic::Request; @@ -131,7 +133,7 @@ async fn test_decoder_capabilities_are_composable() { #[cfg(feature = "cli")] #[tokio::test] -async fn test_remote_client_queries_and_caches_capabilities() { +async fn test_generated_remote_client_reports_capabilities_and_dispatches() { let decoder = Arc::new(MockDecoder::with_features(DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS)); let incoming = tonic::transport::server::TcpIncoming::bind("127.0.0.1:0".parse().unwrap()).unwrap(); let address = incoming.local_addr().unwrap(); @@ -147,23 +149,30 @@ async fn test_remote_client_queries_and_caches_capabilities() { .unwrap(); }); - let endpoint = tonic::transport::Endpoint::from_shared(format!("http://{address}")).unwrap(); - let mut client = BlackBoxDecoderClient::from_endpoint(endpoint).await; - assert_eq!(client.features(), DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS); + let mut client = BlackBoxDecoderClient::connect(format!("http://{address}")).await.unwrap(); + let capabilities = client.get_capabilities(Request::new(())).await.unwrap().into_inner(); + assert_eq!( + capabilities.features, + vec![ + blackbox_decoder::DecoderFeature::Reweights as i32, + blackbox_decoder::DecoderFeature::Loss as i32, + ] + ); let hid = client - .load_hypergraph(blackbox_decoder::DecodingHypergraph { + .load_hypergraph(Request::new(blackbox_decoder::DecodingHypergraph { vertex_num: 1, hyperedges: vec![blackbox_decoder::Hyperedge { vertices: vec![0], probability: 0.1, }], - }) + })) .await .unwrap() + .into_inner() .hid; client - .decode_loaded(blackbox_decoder::LoadedDecodingProblem { + .decode_loaded(Request::new(blackbox_decoder::LoadedDecodingProblem { hid, syndrome: Some(BitVector { size: 1, @@ -180,7 +189,7 @@ async fn test_remote_client_queries_and_caches_capabilities() { ..Default::default() }], }), - }) + })) .await .unwrap(); let state = decoder.state.read().await; @@ -246,8 +255,8 @@ async fn test_mock_decoder_accepts_reweights_and_loss_together() { #[tokio::test] async fn test_client_rejects_unsupported_reweights_without_dispatch() { let decoder = Arc::new(MockDecoder::with_features(DecoderFeatures::LOSS)); - let mut client = BlackBoxDecoderClient::from_mock(decoder.clone()); - let result = client + let handle = DynDecoder::Mock(decoder.clone()); + let result = handle .decode_loaded(blackbox_decoder::LoadedDecodingProblem { hid: 1, syndrome: Some(BitVector { diff --git a/deq/deq_runtime/tests/monolithic_coordinator_test.rs b/deq/deq_runtime/tests/monolithic_coordinator_test.rs index 39da57ad..a9c4c7b5 100644 --- a/deq/deq_runtime/tests/monolithic_coordinator_test.rs +++ b/deq/deq_runtime/tests/monolithic_coordinator_test.rs @@ -3,7 +3,7 @@ use deq_runtime::bin::{self, instruction}; use deq_runtime::coordinator::coordinator_server::Coordinator; use deq_runtime::coordinator::monolithic_coordinator::MonolithicCoordinator; -use deq_runtime::decoder::{BlackBoxDecoderClient, MockDecoder}; +use deq_runtime::decoder::{DynDecoder, MockDecoder}; use deq_runtime::util::{BitMatrix, BitVector}; use std::sync::Arc; use tonic::Request; @@ -12,16 +12,12 @@ fn make_mock_decoder() -> Arc { Arc::new(MockDecoder::new()) } -fn make_decoder_client(mock: Arc) -> BlackBoxDecoderClient { - BlackBoxDecoderClient::from_mock(mock) -} - fn make_coordinator(mock: Arc) -> MonolithicCoordinator { let config = serde_json::json!({ "persistent_decoder": false, "merge_hyperedges": false }); - MonolithicCoordinator::new(config, make_decoder_client(mock)) + MonolithicCoordinator::new(config, DynDecoder::Mock(mock)) } fn make_gadget(gid: u64, gtype: u64, connectors: Vec<(u64, u64)>) -> bin::Gadget { @@ -1729,7 +1725,7 @@ fn make_persistent_coordinator(mock: Arc) -> MonolithicCoordinator "persistent_decoder": true, "merge_hyperedges": false }); - MonolithicCoordinator::new(config, make_decoder_client(mock)) + MonolithicCoordinator::new(config, DynDecoder::Mock(mock)) } #[tokio::test] @@ -1743,7 +1739,7 @@ async fn test_first_persistent_decode_checks_the_parity_factor() { "merge_hyperedges": false, "assert_parity_factor": true, }), - make_decoder_client(mock), + DynDecoder::Mock(mock), ); Coordinator::load_library(&coordinator, Request::new(make_default_library())) .await diff --git a/deq/deq_runtime/tests/standard_decoder_test.rs b/deq/deq_runtime/tests/standard_decoder_test.rs index 77f5f136..b686be28 100644 --- a/deq/deq_runtime/tests/standard_decoder_test.rs +++ b/deq/deq_runtime/tests/standard_decoder_test.rs @@ -16,7 +16,7 @@ use deq_runtime::decoder::blackbox_decoder::{ use deq_runtime::decoder::test_harness::{Outcome, Path, SuiteReport, run_standard_suite}; use deq_runtime::decoder::test_problems::standard_test_problems; use deq_runtime::decoder::thread_pooling::DecoderFeatures; -use deq_runtime::decoder::{BlackBoxDecoderClient, DynBlackBoxDecoder, MockDecoder, NaiveDecoder}; +use deq_runtime::decoder::{DynDecoder, MockDecoder, NaiveDecoder}; use deq_runtime::util::BitVector; type ExpectedPassFn = fn(problem: &str, case: &str, path: Path) -> bool; @@ -79,8 +79,8 @@ fn always_pass_policy(_problem: &str, _case: &str, _path: Path) -> bool { true } -async fn assert_accepts_all_features(client: &mut BlackBoxDecoderClient) { - assert_eq!(client.features(), DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS); +async fn assert_accepts_all_features(decoder: &DynDecoder) { + assert_eq!(decoder.features(), DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS); let hypergraph = DecodingHypergraph { vertex_num: 1, hyperedges: vec![Hyperedge { @@ -100,7 +100,7 @@ async fn assert_accepts_all_features(client: &mut BlackBoxDecoderClient) { }], }; - let parity_factor = client + let parity_factor = decoder .decode(DecodingProblem { hypergraph: Some(hypergraph.clone()), syndrome: Some(syndrome.clone()), @@ -110,8 +110,8 @@ async fn assert_accepts_all_features(client: &mut BlackBoxDecoderClient) { .unwrap(); assert!(parity_factor.subgraph.is_empty()); - let hid = client.load_hypergraph(hypergraph).await.unwrap().hid; - let parity_factor = client + let hid = decoder.load_hypergraph(hypergraph).await.unwrap().hid; + let parity_factor = decoder .decode_loaded(LoadedDecodingProblem { hid, syndrome: Some(syndrome), @@ -128,23 +128,17 @@ async fn assert_accepts_all_features(client: &mut BlackBoxDecoderClient) { #[tokio::test] async fn test_naive_decoder() { - let decoder = Arc::new(NaiveDecoder::new(serde_json::json!({}))); - let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxNaive(decoder)) - .await - .unwrap(); - assert_accepts_all_features(&mut client).await; - let report = run_standard_suite(&mut client).await; + let decoder = DynDecoder::BlackBoxNaive(Arc::new(NaiveDecoder::new(serde_json::json!({})))); + assert_accepts_all_features(&decoder).await; + let report = run_standard_suite(&decoder).await; assert_full_coverage(&report); assert_matches_policy(&report, always_empty_subgraph_policy); } #[tokio::test] async fn test_mock_decoder() { - let decoder = Arc::new(MockDecoder::new()); - let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::MockDecoder(decoder)) - .await - .unwrap(); - let report = run_standard_suite(&mut client).await; + let decoder = DynDecoder::Mock(Arc::new(MockDecoder::new())); + let report = run_standard_suite(&decoder).await; assert_full_coverage(&report); assert_matches_policy(&report, always_empty_subgraph_policy); } @@ -152,11 +146,8 @@ async fn test_mock_decoder() { #[tokio::test] async fn test_relay_bp_decoder() { use deq_runtime::decoder::RelayBPDecoder; - let decoder = Arc::new(RelayBPDecoder::new(serde_json::json!({}))); - let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxRelayBP(decoder)) - .await - .unwrap(); - let report = run_standard_suite(&mut client).await; + let decoder = DynDecoder::BlackBoxRelayBP(Arc::new(RelayBPDecoder::new(serde_json::json!({})))); + let report = run_standard_suite(&decoder).await; assert_full_coverage(&report); assert_matches_policy(&report, always_pass_policy); } @@ -165,11 +156,8 @@ async fn test_relay_bp_decoder() { #[tokio::test] async fn test_tesseract_decoder() { use deq_runtime::decoder::TesseractDecoder; - let decoder = Arc::new(TesseractDecoder::new(serde_json::json!({}))); - let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxTesseract(decoder)) - .await - .unwrap(); - let report = run_standard_suite(&mut client).await; + let decoder = DynDecoder::BlackBoxTesseract(Arc::new(TesseractDecoder::new(serde_json::json!({})))); + let report = run_standard_suite(&decoder).await; assert_full_coverage(&report); assert_matches_policy(&report, always_pass_policy); } @@ -179,12 +167,9 @@ async fn test_tesseract_decoder() { async fn test_python_naive_decoder() { use deq_runtime::decoder::PythonDecoder; let config = serde_json::json!({ "file": "@naive_decoder" }); - let decoder = Arc::new(PythonDecoder::new(config)); - let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxPython(decoder)) - .await - .unwrap(); - assert_accepts_all_features(&mut client).await; - let report = run_standard_suite(&mut client).await; + let decoder = DynDecoder::BlackBoxPython(Arc::new(PythonDecoder::new(config))); + assert_accepts_all_features(&decoder).await; + let report = run_standard_suite(&decoder).await; assert_full_coverage(&report); assert_matches_policy(&report, always_empty_subgraph_policy); } @@ -212,16 +197,13 @@ class LegacyDecoder: ) .unwrap(); - let decoder = Arc::new(PythonDecoder::new(serde_json::json!({ + let decoder = DynDecoder::BlackBoxPython(Arc::new(PythonDecoder::new(serde_json::json!({ "file": decoder_file.path(), "name": "LegacyDecoder", - }))); - let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxPython(decoder)) - .await - .unwrap(); + })))); - assert_eq!(client.features(), DecoderFeatures::empty()); - let report = run_standard_suite(&mut client).await; + assert_eq!(decoder.features(), DecoderFeatures::empty()); + let report = run_standard_suite(&decoder).await; assert_full_coverage(&report); assert_matches_policy(&report, always_empty_subgraph_policy); } @@ -267,13 +249,10 @@ class CombinedDecoder: "file": decoder_file.path(), "name": "CombinedDecoder", }); - let decoder = Arc::new(PythonDecoder::new(config)); - let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxPython(decoder)) - .await - .unwrap(); - assert_eq!(client.features(), DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS); + let decoder = DynDecoder::BlackBoxPython(Arc::new(PythonDecoder::new(config))); + assert_eq!(decoder.features(), DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS); - let hid = client + let hid = decoder .load_hypergraph(DecodingHypergraph { vertex_num: 1, hyperedges: vec![Hyperedge { @@ -284,7 +263,7 @@ class CombinedDecoder: .await .unwrap() .hid; - let parity_factor = client + let parity_factor = decoder .decode_loaded(LoadedDecodingProblem { hid, syndrome: Some(BitVector { @@ -308,7 +287,7 @@ class CombinedDecoder: assert_eq!(parity_factor.subgraph, vec![0]); - let parity_factor = client + let parity_factor = decoder .decode_loaded(LoadedDecodingProblem { hid, syndrome: Some(BitVector { @@ -390,11 +369,8 @@ async fn test_python_relay_bp_decoder() { return; } let config = serde_json::json!({ "file": "@relay_bp_decoder" }); - let decoder = Arc::new(PythonDecoder::new(config)); - let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxPython(decoder)) - .await - .unwrap(); - let report = run_standard_suite(&mut client).await; + let decoder = DynDecoder::BlackBoxPython(Arc::new(PythonDecoder::new(config))); + let report = run_standard_suite(&decoder).await; assert_full_coverage(&report); assert_matches_policy(&report, always_pass_policy); } @@ -407,11 +383,8 @@ async fn test_python_tesseract_decoder() { return; } let config = serde_json::json!({ "file": "@tesseract_decoder" }); - let decoder = Arc::new(PythonDecoder::new(config)); - let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxPython(decoder)) - .await - .unwrap(); - let report = run_standard_suite(&mut client).await; + let decoder = DynDecoder::BlackBoxPython(Arc::new(PythonDecoder::new(config))); + let report = run_standard_suite(&decoder).await; assert_full_coverage(&report); assert_matches_policy(&report, always_pass_policy); } diff --git a/deq/deq_runtime/tests/window_coordinator_test.rs b/deq/deq_runtime/tests/window_coordinator_test.rs index dc066717..1fbbb511 100644 --- a/deq/deq_runtime/tests/window_coordinator_test.rs +++ b/deq/deq_runtime/tests/window_coordinator_test.rs @@ -9,7 +9,7 @@ mod common; use deq_runtime::bin::{self, instruction}; use deq_runtime::coordinator::coordinator_server::Coordinator; use deq_runtime::coordinator::window_coordinator::{self, WindowCoordinator}; -use deq_runtime::decoder::{BlackBoxDecoderClient, MockDecoder}; +use deq_runtime::decoder::{DynDecoder, MockDecoder}; use deq_runtime::jit::{self, static_jit_compile}; use deq_runtime::util::{BitMatrix, BitVector}; use prost::Message; @@ -50,7 +50,7 @@ fn make_coordinator_with_radii( "buffer_radius": buffer_radius, "lookahead_radius": lookahead_radius, }); - WindowCoordinator::new(config, BlackBoxDecoderClient::from_mock(mock)) + WindowCoordinator::new(config, DynDecoder::Mock(mock)) } fn make_gadget(gid: u64, gtype: u64, connectors: Vec<(u64, u64)>) -> bin::Gadget { @@ -4431,7 +4431,7 @@ fn make_persistent_coordinator(mock: Arc, trace_file: &str) -> Wind "buffer_radius": 1usize, "lookahead_radius": 0usize, }); - WindowCoordinator::new(config, BlackBoxDecoderClient::from_mock(mock)) + WindowCoordinator::new(config, DynDecoder::Mock(mock)) } fn make_error_model_with_modifier(eid: u64, etype: u64, cid: u64, pm: Option) -> bin::ErrorModel { From f342f64e2b94d04f8cf3a50524d66869560143c6 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 15 Aug 2026 15:13:14 -0700 Subject: [PATCH 092/157] basic files --- deq/deq_runtime/src/cli.rs | 9 +++------ deq/deq_runtime/src/lib.rs | 2 ++ deq/deq_runtime/src/python.rs | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/deq/deq_runtime/src/cli.rs b/deq/deq_runtime/src/cli.rs index 7ee7763b..2629fe76 100644 --- a/deq/deq_runtime/src/cli.rs +++ b/deq/deq_runtime/src/cli.rs @@ -95,18 +95,15 @@ impl TestCommands { #[cfg(feature = "python")] async fn run_python_decoder_test(file: PathBuf, py_config: serde_json::Value) { use crate::decoder::test_harness::run_standard_suite; - use crate::decoder::{BlackBoxDecoderClient, DynBlackBoxDecoder, PythonDecoder}; + use crate::decoder::{DynDecoder, PythonDecoder}; use std::sync::Arc; let config = serde_json::json!({ "file": file.to_string_lossy(), "py_config": py_config, }); - let decoder = Arc::new(PythonDecoder::new(config)); - let mut client = BlackBoxDecoderClient::from_local(DynBlackBoxDecoder::BlackBoxPython(decoder)) - .await - .unwrap(); - let report = run_standard_suite(&mut client).await; + let decoder = DynDecoder::BlackBoxPython(Arc::new(PythonDecoder::new(config))); + let report = run_standard_suite(&decoder).await; for line in report.summary_lines() { println!("{line}"); } diff --git a/deq/deq_runtime/src/lib.rs b/deq/deq_runtime/src/lib.rs index 6fb6d7ab..fbc494ee 100644 --- a/deq/deq_runtime/src/lib.rs +++ b/deq/deq_runtime/src/lib.rs @@ -37,6 +37,8 @@ use pyo3::prelude::*; fn deq_runtime(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; #[cfg(feature = "cli")] { m.add_function(wrap_pyfunction!(cli_run, m)?)?; diff --git a/deq/deq_runtime/src/python.rs b/deq/deq_runtime/src/python.rs index c50d3632..0dca25d3 100644 --- a/deq/deq_runtime/src/python.rs +++ b/deq/deq_runtime/src/python.rs @@ -490,9 +490,9 @@ impl PySampler { /// circuit: The Stim circuit source (the contents of a `.stim` file). /// simulator: Backend name. ``"stim"`` (default) uses Stim's compiled /// measurement sampler, auto-wrapping with resample-on-failure when - /// the circuit has ``PREPARE { ... REQUIRE ... }`` blocks (QDK v1.30+). + /// the circuit has ``SELECT { ... REQUIRE ... }`` blocks. /// ``"preselect"`` uses a tableau-based sampler that natively runs - /// each PREPARE block with retry-from-checkpoint semantics. + /// each SELECT block with retry-from-checkpoint semantics. /// simulator_config: Optional JSON-string config for the backend. /// Currently supports ``preselect_max_attempts`` (int). /// seed: Optional deterministic seed. When None, a random seed is From 74a831ec519b0b8f514ce855ea201e953939ba4d Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 15 Aug 2026 15:16:20 -0700 Subject: [PATCH 093/157] update decoders --- .../src/decoder/dyn_lib_decoder.rs | 3 +- .../src/decoder/relay_bp_decoder.rs | 3 +- .../src/decoder/tesseract_decoder.rs | 47 ++++++++++++++++--- .../tutorial/chapters/python-decoder.md | 28 ++++++++--- 4 files changed, 64 insertions(+), 17 deletions(-) diff --git a/deq/deq_runtime/src/decoder/dyn_lib_decoder.rs b/deq/deq_runtime/src/decoder/dyn_lib_decoder.rs index ad8cb37a..be88b940 100644 --- a/deq/deq_runtime/src/decoder/dyn_lib_decoder.rs +++ b/deq/deq_runtime/src/decoder/dyn_lib_decoder.rs @@ -23,7 +23,7 @@ use structdoc::StructDoc; use crate::decoder::blackbox_decoder::{DecodingHypergraph, ParityFactor}; use crate::decoder::thread_pooling::{ - DecodeError, DecodeRequest, DecoderFeatures, DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder, + DecodeError, DecodeRequest, DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -106,7 +106,6 @@ impl DecoderInstance for DynLibInstance { } fn decode(&mut self, request: DecodeRequest<'_>) -> Result { - request.require_supported(DecoderFeatures::empty())?; // deq's BitVector is already the dense MSB-first packing the ABI expects, // so it passes through with no conversion. let mut subgraph = Vec::new(); diff --git a/deq/deq_runtime/src/decoder/relay_bp_decoder.rs b/deq/deq_runtime/src/decoder/relay_bp_decoder.rs index bfd25c60..abe93b90 100644 --- a/deq/deq_runtime/src/decoder/relay_bp_decoder.rs +++ b/deq/deq_runtime/src/decoder/relay_bp_decoder.rs @@ -3,7 +3,7 @@ use crate::decoder::blackbox_decoder::{self, ParityFactor}; use crate::decoder::thread_pooling::{ - DecodeError, DecodeRequest, DecoderFeatures, DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder, + DecodeError, DecodeRequest, DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder, }; use crate::misc::bit_vector::to_sparse_indices; use blackbox_decoder::DecodingHypergraph; @@ -212,7 +212,6 @@ impl DecoderInstance for RelayBPDecoderInst } fn decode(&mut self, request: DecodeRequest<'_>) -> Result { - request.require_supported(DecoderFeatures::empty())?; let mut detectors = Array1::::zeros(request.syndrome.size as usize); for index in to_sparse_indices(request.syndrome) { detectors[index as usize] = 1; diff --git a/deq/deq_runtime/src/decoder/tesseract_decoder.rs b/deq/deq_runtime/src/decoder/tesseract_decoder.rs index d0e7a17a..4ff72a3d 100644 --- a/deq/deq_runtime/src/decoder/tesseract_decoder.rs +++ b/deq/deq_runtime/src/decoder/tesseract_decoder.rs @@ -5,9 +5,10 @@ use crate::decoder::blackbox_decoder::{self, ParityFactor}; use crate::decoder::tesseract_ffi::{TesseractCxxConfig, TesseractCxxDecoder}; -use crate::decoder::thread_pooling::{DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder}; +use crate::decoder::thread_pooling::{ + DecodeError, DecodeRequest, DecoderFeatures, DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder, +}; use crate::misc::bit_vector::to_sparse_indices; -use crate::util::BitVector; use blackbox_decoder::DecodingHypergraph; use serde::{Deserialize, Serialize}; #[cfg(feature = "cli")] @@ -54,11 +55,26 @@ fn default_pqlimit() -> u64 { pub struct TesseractDecoderInstance { decoder: TesseractCxxDecoder, + /// The probabilities the loaded decoder was built from, in Tesseract error + /// order. Kept so [`DecoderInstance::decode`] can restore them + /// after a shot-scoped reweighting. + base_probabilities: Vec, } impl DecoderInstance for TesseractDecoderInstance { + fn supported_features(_config: &serde_json::Value) -> DecoderFeatures { + DecoderFeatures::REWEIGHTS + } + fn new(hypergraph: &DecodingHypergraph, config: &serde_json::Value) -> Self { let config: TesseractDecoderConfig = serde_json::from_value(config.clone()).unwrap(); + // Every hyperedge is loaded, including the zero-probability ones. Such an + // edge is a *declared* impossibility rather than an absent one -- the + // producer emits it deliberately and its index is part of the decoding + // interface, referenced by `LossInfo` and by per-shot prior overrides -- + // so dropping it would both break that indexing and make the edge + // impossible to raise later. Tesseract carries it at a cost far beyond + // any real explanation until something raises it. let (edge_vertices, edge_offsets, edge_probabilities) = flatten_hypergraph(hypergraph); let tess_config = TesseractCxxConfig { det_beam: config.det_beam, @@ -76,13 +92,30 @@ impl DecoderInstance for TesseractDecoderInstance { &edge_probabilities, &tess_config, ), + base_probabilities: edge_probabilities, } } - fn decode(&mut self, syndrome: &BitVector) -> ParityFactor { - let detections: Vec = to_sparse_indices(syndrome); + fn decode(&mut self, request: DecodeRequest<'_>) -> Result { + if !request.reweights.is_empty() { + let mut probabilities = self.base_probabilities.clone(); + for &(edge, probability) in request.reweights { + let position = usize::try_from(edge).map_err(|_| { + DecodeError::InvalidInput(format!("reweighted edge {edge} is outside the loaded hypergraph")) + })?; + let target = probabilities.get_mut(position).ok_or_else(|| { + DecodeError::InvalidInput(format!("reweighted edge {edge} is outside the loaded hypergraph")) + })?; + *target = probability; + } + self.decoder.update_error_costs(&probabilities); + } + let detections: Vec = to_sparse_indices(request.syndrome); let error_indices = self.decoder.decode(&detections); - ParityFactor { subgraph: error_indices } + if !request.reweights.is_empty() { + self.decoder.update_error_costs(&self.base_probabilities); + } + Ok(ParityFactor { subgraph: error_indices }) } fn reset(&mut self) { @@ -90,9 +123,9 @@ impl DecoderInstance for TesseractDecoderInstance { } } -/// Flatten a DecodingHypergraph into CSR arrays for the C++ bridge. +/// Flatten a decoding hypergraph into CSR arrays for the C++ bridge. fn flatten_hypergraph(hypergraph: &DecodingHypergraph) -> (Vec, Vec, Vec) { - let total_vertices: usize = hypergraph.hyperedges.iter().map(|e| e.vertices.len()).sum(); + let total_vertices: usize = hypergraph.hyperedges.iter().map(|edge| edge.vertices.len()).sum(); let mut edge_vertices = Vec::with_capacity(total_vertices); let mut edge_offsets = Vec::with_capacity(hypergraph.hyperedges.len() + 1); diff --git a/deq/documents/tutorial/chapters/python-decoder.md b/deq/documents/tutorial/chapters/python-decoder.md index 025e1bb1..ea695340 100644 --- a/deq/documents/tutorial/chapters/python-decoder.md +++ b/deq/documents/tutorial/chapters/python-decoder.md @@ -9,7 +9,7 @@ machinery without touching any Rust code. We will walk through: -1. The Python decoder protocol — three functions, no inheritance. +1. The Python decoder protocol — one capability declaration and three instance methods, no inheritance. 2. A worked example wrapping the public [`relay-bp`](https://pypi.org/project/relay-bp/) PyPI package in ~60 lines. 3. Running the standard decoder unit-test suite against it. 4. Driving a full logical error rate simulation with the wrapper as the decoder, @@ -43,11 +43,20 @@ A Python decoder file is any `*.py` file that exposes a single class: ```python class Decoder: + # Optional; omit this method when no optional request fields are supported. + @staticmethod + def supported_features() -> list[str]: ... def __init__(self, hypergraph, config: dict): ... def decode(self, syndrome: list[int]) -> list[int]: ... def reset(self) -> None: ... ``` +`supported_features()` is called once on the selected class before any +hypergraph-bound instances are created. It returns any optional request fields +the decoder accepts: `"reweights"`, `"loss"`, both, or an empty list. If the +method is omitted, the runtime assumes no optional features. Unknown names are +rejected when the decoder service is constructed. + The runtime instantiates `Decoder(hypergraph, config)` once per hypergraph and then calls `decode(...)` / `reset()` repeatedly. State that should persist across shots lives on the instance; state that should *not* persist across @@ -57,6 +66,8 @@ The class name defaults to `Decoder`. If your file already uses a different name (or you want to expose several decoder classes from the same file), set the top-level `name` field in the decoder JSON config to override it — see [the config table below](#end-to-end-logical-error-rate-on-a-dynamic-circuit). +When `name` is set, the file does not also need to define a class named +`Decoder`. **Inputs:** @@ -89,6 +100,10 @@ from scipy.sparse import csr_matrix from relay_bp import RelayDecoderF64 class Decoder: + @staticmethod + def supported_features(): + return [] + def __init__(self, hypergraph, config): vertex_num = int(hypergraph.vertex_num) hyperedges = list(hypergraph.hyperedges) @@ -141,8 +156,9 @@ Two things worth pointing out: has no tuple type; without it, every user supplying that key from `--py-config` would hit a type error they couldn't fix from the CLI. -That's the whole wrapper. The same shape applies to any decoder: implement -`__init__`/`decode`/`reset` on a class named `Decoder`. +That's the whole wrapper. The same shape applies to any decoder: declare its +optional features and implement `__init__`/`decode`/`reset` on a class named +`Decoder`. > An analogous wrapper for Google's Tesseract beam-search decoder lives at > [deq_runtime/src/decoder/tesseract_decoder.py](../../../deq_runtime/src/decoder/tesseract_decoder.py). @@ -237,11 +253,11 @@ deq server \ | `file` | string | Path to your `*.py` file, or an `@name` sentinel that resolves a compile-time-embedded reference decoder (`@naive_decoder`, `@relay_bp_decoder`, `@tesseract_decoder`, `@mle_loss_decoder`). | | `name` | string | Optional. Name of the decoder class inside the file. Defaults to `"Decoder"`. | | `py_config` | any JSON value | Forwarded to your `Decoder.__init__` as the `config` argument. Omit for `{}`. | -| `supported_features` | string list | Optional. Any of `"reweights"` and `"loss"`; empty by default. Declaring both promises they can be consumed together. `@mle_loss_decoder` declares `"loss"` automatically. | | `parallel` | int (optional) | Number of decoder worker threads (inherited from the thread-pooling layer). | -Optional request fields are keyword arguments. A decoder declaring `reweights` -receives `decode(syndrome, reweights=...)`; one declaring `loss` receives +Optional request fields are keyword arguments declared by +`Decoder.supported_features()`. A decoder declaring `reweights` receives +`decode(syndrome, reweights=...)`; one declaring `loss` receives `decode(syndrome, loss=...)`; and a decoder declaring both may receive both in the same call. Unsupported fields are rejected before `decode` is called. From b9e31ec2e80f4fd28d45a4835a7e3ed736c738c1 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 15 Aug 2026 15:25:07 -0700 Subject: [PATCH 094/157] add decoder.rs and changelog --- deq/CHANGELOG.md | 15 +++ deq/deq_runtime/src/decoder.rs | 163 +++++++++++---------------------- 2 files changed, 69 insertions(+), 109 deletions(-) diff --git a/deq/CHANGELOG.md b/deq/CHANGELOG.md index 886e22bd..bc0222b3 100644 --- a/deq/CHANGELOG.md +++ b/deq/CHANGELOG.md @@ -8,8 +8,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed +- `deq annotate` now always retains physical noise under `@SIMULATE_ONLY` while + emitting canonical `ERROR` and `LOSS` metadata for decoding. Noisy + measurements receive clean `@DECODE_ONLY` counterparts. +- Black-box decoders now expose capabilities and receive one unified decode + request. Per-shot edge reweights and structured loss may be supplied together; + unsupported fields fail explicitly instead of triggering a decoder-side + fallback. +- Monolithic and window coordinators now apply `Outcomes.modifiers` as + shot-scoped probability overrides. The `decoder_reweighting` policy controls + whether overrides use loaded decoder support or an equivalent one-shot graph. +- Preselection consumers now recognize QDK 1.31's `SELECT { ... REQUIRE ... }` + syntax. Legacy `PREPARE { ... }` input remains accepted for older generated + Stim files. ### Removed +- **Breaking:** remove the `deq annotate --keep-noise` option; its behavior is + now the unconditional default. - **Breaking:** bare physical Pauli targets in `ERROR(p)` statements (e.g. `ERROR(0.05) C0 X0`) are no longer valid syntax (previously already rejected by the transpiler). diff --git a/deq/deq_runtime/src/decoder.rs b/deq/deq_runtime/src/decoder.rs index 38d2a32e..7a195f13 100644 --- a/deq/deq_runtime/src/decoder.rs +++ b/deq/deq_runtime/src/decoder.rs @@ -8,6 +8,8 @@ use std::sync::Arc; use tonic::transport::server::Router; use tonic::{Request, Status}; +use thread_pooling::DecoderFeatures; + #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Debug)] #[cfg_attr(feature = "cli", derive(ValueEnum))] pub enum DecoderType { @@ -153,8 +155,8 @@ impl DecoderType { } } +#[derive(Clone)] pub enum DynDecoder { - None, BlackBoxNaive(Arc), BlackBoxRelayBP(Arc), BlackBoxRelayBpF32(Arc>), @@ -171,7 +173,6 @@ impl DynDecoder { #[cfg(feature = "cli")] pub fn add_service(&self, router: Router) -> Router { match self { - DynDecoder::None => router, DynDecoder::BlackBoxNaive(decoder) => NaiveDecoder::add_service(decoder, router), DynDecoder::BlackBoxRelayBP(decoder) => RelayBPDecoder::add_service(decoder, router), DynDecoder::BlackBoxRelayBpF32(decoder) => RelayBPDecoder::::add_service(decoder, router), @@ -185,143 +186,87 @@ impl DynDecoder { } } - pub fn as_black_box_decoder(&self) -> Option { + fn inner(&self) -> &dyn blackbox_decoder::black_box_decoder_server::BlackBoxDecoder { match self { - DynDecoder::BlackBoxNaive(v) => Some(DynBlackBoxDecoder::BlackBoxNaive(v.clone())), - DynDecoder::BlackBoxRelayBP(v) => Some(DynBlackBoxDecoder::BlackBoxRelayBP(v.clone())), - DynDecoder::BlackBoxRelayBpF32(v) => Some(DynBlackBoxDecoder::BlackBoxRelayBpF32(v.clone())), + DynDecoder::BlackBoxNaive(decoder) => decoder.as_ref(), + DynDecoder::BlackBoxRelayBP(decoder) => decoder.as_ref(), + DynDecoder::BlackBoxRelayBpF32(decoder) => decoder.as_ref(), #[cfg(feature = "python")] - DynDecoder::BlackBoxPython(v) => Some(DynBlackBoxDecoder::BlackBoxPython(v.clone())), + DynDecoder::BlackBoxPython(decoder) => decoder.as_ref(), #[cfg(feature = "tesseract")] - DynDecoder::BlackBoxTesseract(v) => Some(DynBlackBoxDecoder::BlackBoxTesseract(v.clone())), + DynDecoder::BlackBoxTesseract(decoder) => decoder.as_ref(), #[cfg(feature = "dylib")] - DynDecoder::BlackBoxDynLib(v) => Some(DynBlackBoxDecoder::BlackBoxDynLib(v.clone())), - DynDecoder::Mock(v) => Some(DynBlackBoxDecoder::MockDecoder(v.clone())), - _ => None, - } - } - - #[cfg(feature = "cli")] - pub async fn as_black_box_decoder_client( - &self, - endpoint: Option<&tonic::transport::Endpoint>, - ) -> Option { - match self.as_black_box_decoder() { - Some(black_box_decoder) => Some(if let Some(endpoint) = endpoint { - BlackBoxDecoderClient::from_endpoint(endpoint.clone()).await - } else { - BlackBoxDecoderClient::Local(black_box_decoder) - }), - None => None, + DynDecoder::BlackBoxDynLib(decoder) => decoder.as_ref(), + DynDecoder::Mock(decoder) => decoder.as_ref(), } } - #[cfg(not(feature = "cli"))] - pub async fn as_black_box_decoder_client(&self) -> Option { - self.as_black_box_decoder().map(BlackBoxDecoderClient::Local) - } -} - -#[derive(Clone)] -pub enum DynBlackBoxDecoder { - BlackBoxNaive(Arc), - BlackBoxRelayBP(Arc), - BlackBoxRelayBpF32(Arc>), - #[cfg(feature = "python")] - BlackBoxPython(Arc), - #[cfg(feature = "tesseract")] - BlackBoxTesseract(Arc), - #[cfg(feature = "dylib")] - BlackBoxDynLib(Arc), - MockDecoder(Arc), -} - -impl DynBlackBoxDecoder { - pub fn inner(&self) -> Arc { + #[must_use] + pub fn features(&self) -> DecoderFeatures { match self { - DynBlackBoxDecoder::BlackBoxNaive(v) => v.clone(), - DynBlackBoxDecoder::BlackBoxRelayBP(v) => v.clone(), - DynBlackBoxDecoder::BlackBoxRelayBpF32(v) => v.clone(), + DynDecoder::BlackBoxNaive(decoder) => decoder.supported_features(), + DynDecoder::BlackBoxRelayBP(decoder) => decoder.features(), + DynDecoder::BlackBoxRelayBpF32(decoder) => decoder.features(), #[cfg(feature = "python")] - DynBlackBoxDecoder::BlackBoxPython(v) => v.clone(), + DynDecoder::BlackBoxPython(decoder) => decoder.features(), #[cfg(feature = "tesseract")] - DynBlackBoxDecoder::BlackBoxTesseract(v) => v.clone(), + DynDecoder::BlackBoxTesseract(decoder) => decoder.features(), #[cfg(feature = "dylib")] - DynBlackBoxDecoder::BlackBoxDynLib(v) => v.clone(), - DynBlackBoxDecoder::MockDecoder(v) => v.clone(), + DynDecoder::BlackBoxDynLib(decoder) => decoder.features(), + DynDecoder::Mock(decoder) => decoder.supported_features(), } } -} -/// a client wrapper that can either be a remote gRPC client or a local reference -#[derive(Clone)] -pub enum BlackBoxDecoderClient { - #[cfg(feature = "cli")] - Remote(blackbox_decoder::black_box_decoder_client::BlackBoxDecoderClient), - Local(DynBlackBoxDecoder), -} - -impl BlackBoxDecoderClient { - #[cfg(feature = "cli")] - pub async fn from_endpoint(endpoint: tonic::transport::Endpoint) -> Self { - Self::Remote( - crate::decoder::blackbox_decoder::black_box_decoder_client::BlackBoxDecoderClient::connect(endpoint) - .await - .unwrap(), - ) - } - - /// Create a client from a MockDecoder for testing - pub fn from_mock(mock: Arc) -> Self { - Self::Local(DynBlackBoxDecoder::MockDecoder(mock)) + fn require_features(&self, required: DecoderFeatures) -> Result<(), Status> { + let unsupported = required.difference(self.features()); + if unsupported.is_empty() { + Ok(()) + } else { + Err(Status::failed_precondition(format!( + "unsupported decoder features: {unsupported}" + ))) + } } pub async fn decode( - &mut self, + &self, problem: blackbox_decoder::DecodingProblem, ) -> Result { - let request = Request::new(problem); - (match self { - #[cfg(feature = "cli")] - BlackBoxDecoderClient::Remote(client) => client.decode(request).await, - BlackBoxDecoderClient::Local(local) => local.inner().decode(request).await, - }) - .map(|v| v.into_inner()) + if problem.loss.is_some() { + self.require_features(DecoderFeatures::LOSS)?; + } + self.inner().decode(Request::new(problem)).await.map(|v| v.into_inner()) } pub async fn load_hypergraph( - &mut self, + &self, hypergraph: blackbox_decoder::DecodingHypergraph, ) -> Result { - let request = Request::new(hypergraph); - (match self { - #[cfg(feature = "cli")] - BlackBoxDecoderClient::Remote(client) => client.load_hypergraph(request).await, - BlackBoxDecoderClient::Local(local) => local.inner().load_hypergraph(request).await, - }) - .map(|v| v.into_inner()) + self.inner() + .load_hypergraph(Request::new(hypergraph)) + .await + .map(|v| v.into_inner()) } pub async fn decode_loaded( - &mut self, + &self, problem: blackbox_decoder::LoadedDecodingProblem, ) -> Result { - let request = Request::new(problem); - (match self { - #[cfg(feature = "cli")] - BlackBoxDecoderClient::Remote(client) => client.decode_loaded(request).await, - BlackBoxDecoderClient::Local(local) => local.inner().decode_loaded(request).await, - }) - .map(|v| v.into_inner()) + let mut required = DecoderFeatures::empty(); + if !problem.reweights.is_empty() { + required = required | DecoderFeatures::REWEIGHTS; + } + if problem.loss.is_some() { + required = required | DecoderFeatures::LOSS; + } + self.require_features(required)?; + self.inner() + .decode_loaded(Request::new(problem)) + .await + .map(|v| v.into_inner()) } - pub async fn reset(&mut self, flags: blackbox_decoder::ResetRequest) -> Result<(), Status> { - let request = Request::new(flags); - (match self { - #[cfg(feature = "cli")] - BlackBoxDecoderClient::Remote(client) => client.reset(request).await, - BlackBoxDecoderClient::Local(local) => local.inner().reset(request).await, - }) - .map(|_| ()) + pub async fn reset(&self, flags: blackbox_decoder::ResetRequest) -> Result<(), Status> { + self.inner().reset(Request::new(flags)).await.map(|_| ()) } } From 2ab8b85f73d41cb4d8825808fc42563582009141 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 15 Aug 2026 15:45:36 -0700 Subject: [PATCH 095/157] add more loss models --- deq/deq/transpiler/compose_builder.py | 5 ++ deq/deq/transpiler/jit_library_builder.py | 53 ++++++++++-- deq/deq/transpiler/loss/api.py | 28 ++++++ deq/deq/transpiler/loss/model_gate_removal.py | 86 +++++++++++++++++++ deq/deq/transpiler/loss/model_ignore.py | 46 ++++++++++ 5 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 deq/deq/transpiler/loss/model_gate_removal.py create mode 100644 deq/deq/transpiler/loss/model_ignore.py diff --git a/deq/deq/transpiler/compose_builder.py b/deq/deq/transpiler/compose_builder.py index cbc28302..036cbc76 100644 --- a/deq/deq/transpiler/compose_builder.py +++ b/deq/deq/transpiler/compose_builder.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: from deq.transpiler.jit_library_builder import JitGadgetArtifacts + from deq.transpiler.loss.api import LossModel import deq.proto.deq_bin_pb2 as pb import deq.proto.deq_jit_pb2 as jit_pb @@ -1031,6 +1032,7 @@ def _build_repropagated_compose( ptype_of_code: Mapping[str, int], port_types: list[jit_pb.JitPortType], library_has_loss: bool = True, + loss_model: "LossModel | None" = None, ) -> "JitGadgetArtifacts": """Build a JitGadgetType for an ``@REPROPAGATE`` COMPOSE. @@ -1090,6 +1092,7 @@ def _build_repropagated_compose( dict(ptype_of_code), dict(codes), library_has_loss=library_has_loss, + loss_model=loss_model, check_override=(finished, unfinished), ) @@ -1105,6 +1108,7 @@ def transpile_compose_jit_gadget_type( ptype_of_code: Mapping[str, int], port_types: list[jit_pb.JitPortType], library_has_loss: bool = True, + loss_model: "LossModel | None" = None, ) -> "JitGadgetArtifacts": """Transpile a composed gadget and retain annotation provenance.""" validate_compose( @@ -1123,6 +1127,7 @@ def transpile_compose_jit_gadget_type( ptype_of_code=ptype_of_code, port_types=port_types, library_has_loss=library_has_loss, + loss_model=loss_model, ) return _build_merge_compose( diff --git a/deq/deq/transpiler/jit_library_builder.py b/deq/deq/transpiler/jit_library_builder.py index 1727a881..498a9280 100644 --- a/deq/deq/transpiler/jit_library_builder.py +++ b/deq/deq/transpiler/jit_library_builder.py @@ -74,6 +74,8 @@ resolve_propagations, ) from deq.transpiler.loss.transpiler import transpile_inferred_loss_model +from deq.transpiler.loss.api import LossModel +from deq.transpiler.loss.model_gate_removal import GateRemovalLossModel from deq.transpiler.loss.syntax import transpile_declared_loss_model import stim @@ -177,13 +179,23 @@ def _measurement_tags_of(inst: Instruction) -> list[str]: return single_tags -def build_jit_library(qfile: DeqFile, *, jobs: int = 1) -> jit_pb.JitLibrary: +def build_jit_library( + qfile: DeqFile, + *, + jobs: int = 1, + loss_model: LossModel | None = None, +) -> jit_pb.JitLibrary: """Build and return the runtime ``JitLibrary`` protobuf.""" - return build_jit_library_artifacts(qfile, jobs=jobs).jit_library + return build_jit_library_artifacts( + qfile, jobs=jobs, loss_model=loss_model + ).jit_library def build_jit_library_artifacts( - qfile: DeqFile, *, jobs: int = 1 + qfile: DeqFile, + *, + jobs: int = 1, + loss_model: LossModel | None = None, ) -> JitLibraryArtifacts: """ Build a ``JitLibrary`` and retain per-gadget annotation provenance. @@ -198,6 +210,8 @@ def build_jit_library_artifacts( ``1`` (default) runs sequentially with no subprocess overhead. Values > 1 use :class:`~concurrent.futures.ProcessPoolExecutor`. """ + if loss_model is None: + loss_model = GateRemovalLossModel() scaffold = _build_library_scaffold(qfile) # A gadget with input ports gets ``input_losses`` describing how a loss @@ -221,6 +235,7 @@ def build_jit_library_artifacts( scaffold.code_by_name, jobs, library_has_loss=library_has_loss, + loss_model=loss_model, ) else: gadget_artifacts = [ @@ -230,6 +245,7 @@ def build_jit_library_artifacts( scaffold.ptype_of_code, scaffold.code_by_name, library_has_loss=library_has_loss, + loss_model=loss_model, ) for gadget in scaffold.gadgets ] @@ -254,6 +270,7 @@ def build_jit_library_artifacts( ptype_of_code=scaffold.ptype_of_code, port_types=scaffold.port_types, library_has_loss=library_has_loss, + loss_model=loss_model, ) composed_jit = compose_artifacts.jit_type gadget_artifacts_by_name[compose.name] = compose_artifacts @@ -484,12 +501,20 @@ def _build_gadget_types_parallel( jobs: int, *, library_has_loss: bool, + loss_model: LossModel, ) -> list[JitGadgetArtifacts]: """Build gadget types with provenance in parallel workers.""" from concurrent.futures import ProcessPoolExecutor args = [ - (g, gtype_of_gadget[g.name], ptype_of_code, code_by_name, library_has_loss) + ( + g, + gtype_of_gadget[g.name], + ptype_of_code, + code_by_name, + library_has_loss, + loss_model, + ) for g in gadgets ] with ProcessPoolExecutor(max_workers=jobs) as pool: @@ -497,12 +522,24 @@ def _build_gadget_types_parallel( def _build_jit_gadget_type_worker( - args: tuple[GadgetDefinition, int, dict[str, int], dict[str, CodeDefinition], bool], + args: tuple[ + GadgetDefinition, + int, + dict[str, int], + dict[str, CodeDefinition], + bool, + LossModel, + ], ) -> JitGadgetArtifacts: """Worker entry point returning picklable JIT gadget artifacts.""" - g, gtype, ptype_of_code, code_by_name, library_has_loss = args + g, gtype, ptype_of_code, code_by_name, library_has_loss, loss_model = args return _build_jit_gadget_type( - g, gtype, ptype_of_code, code_by_name, library_has_loss=library_has_loss + g, + gtype, + ptype_of_code, + code_by_name, + library_has_loss=library_has_loss, + loss_model=loss_model, ) @@ -626,6 +663,7 @@ def _build_jit_gadget_type( codes: dict[str, CodeDefinition], *, library_has_loss: bool = True, + loss_model: LossModel | None = None, check_override: ( tuple[ list[tuple[frozenset[int], bool]], @@ -904,6 +942,7 @@ def _build_check( physical_correction=physical_correction_pb, existing_errors=errors_pb, library_has_loss=library_has_loss, + loss_model=loss_model, ) if loss_artifacts.model is not None: body_end = len(flatten_body(list(gadget.body))) diff --git a/deq/deq/transpiler/loss/api.py b/deq/deq/transpiler/loss/api.py index b72a00fc..3c7ab501 100644 --- a/deq/deq/transpiler/loss/api.py +++ b/deq/deq/transpiler/loss/api.py @@ -69,6 +69,15 @@ def add_event_continuation_pauli_insertion( ... + def add_source_pauli_insertion( + self, + event_id: int, + paulis: tuple[str, ...] = ("I", "X", "Y", "Z"), + ) -> None: + """Add a Pauli set at one loss event's source boundary.""" + + ... + def record_loss_measurement(self, qubit: int, measurement_index: int) -> None: """Associate a measurement result with every active branch.""" @@ -79,6 +88,18 @@ def clear_loss(self, qubit: int) -> None: ... + def propagate_loss( + self, + event_id: int, + *, + lost_qubit: int, + new_qubit: int, + boundary: int, + ) -> None: + """Extend one active loss-event world onto another qubit.""" + + ... + def swap_losses( self, event_id: int, first_qubit: int, second_qubit: int, boundary: int ) -> None: @@ -92,6 +113,13 @@ class LossGateHandler(Protocol): native_gate_names: frozenset[str] + def handle_loss_source( + self, event_id: int, state: LossAnalysisState + ) -> None: + """Handle a newly created physical loss event.""" + + ... + def handle_h(self, gate: LossGate, state: LossAnalysisState) -> None: """Handle one primitive Hadamard occurrence.""" diff --git a/deq/deq/transpiler/loss/model_gate_removal.py b/deq/deq/transpiler/loss/model_gate_removal.py new file mode 100644 index 00000000..35e28ea6 --- /dev/null +++ b/deq/deq/transpiler/loss/model_gate_removal.py @@ -0,0 +1,86 @@ +"""Built-in persistent gate-removal loss model.""" + +from __future__ import annotations + +from deq.transpiler.loss.api import ( + LossAnalysisState, + LossGate, + LossGateHandler, + UnsupportedLossModelError, +) + + +class GateRemovalGateHandler(LossGateHandler): + """Per-gadget handler for persistent loss and removed gates.""" + + native_gate_names = frozenset({"CZ", "S", "SQRT_X", "SQRT_X_DAG", "SWAP"}) + + def handle_loss_source( + self, event_id: int, state: LossAnalysisState + ) -> None: + state.add_source_pauli_insertion(event_id) + + def handle_h(self, gate: LossGate, state: LossAnalysisState) -> None: + for qubit in gate.qubits: + state.add_continuation_pauli_insertion(qubit, gate.boundary_after) + + def handle_s(self, gate: LossGate, state: LossAnalysisState) -> None: + del gate, state + + def handle_cx(self, gate: LossGate, state: LossAnalysisState) -> None: + control, target = gate.qubits # splitted by ./analysis.py + for event_id in state.active_event_ids(target): + state.add_event_continuation_pauli_insertion( + event_id, + lost_qubit=target, + error_qubit=target, + boundary=gate.boundary_after, + ) + if not state.event_has_active_loss(event_id, control): + state.add_event_continuation_pauli_insertion( + event_id, + lost_qubit=target, + error_qubit=control, + boundary=gate.boundary_after, + paulis=("I", "X"), + ) + + def handle_m(self, gate: LossGate, state: LossAnalysisState) -> None: + if len(gate.qubits) != 1 or len(gate.measurement_indices) != 1: + raise UnsupportedLossModelError( + f"M at body index {gate.body_index} requires one qubit and result" + ) + qubit = gate.qubits[0] + state.add_continuation_pauli_insertion(qubit, gate.boundary_before) + state.record_loss_measurement(qubit, gate.measurement_indices[0]) + + def handle_r(self, gate: LossGate, state: LossAnalysisState) -> None: + for qubit in gate.qubits: + state.clear_loss(qubit) + + def handle_native_gate(self, gate: LossGate, state: LossAnalysisState) -> None: + assert gate.name in self.native_gate_names # guaranteed by LossGateHandler + if gate.name == "SWAP": + # A SWAP is a physical atom relabelling in neutral-atom hardware: it + # carries a lost site (and its Pauli envelope) to the partner site + # without injecting any new error. Move each active loss to the + # partner qubit; no continuation Pauli insertion is added. + first, second = gate.qubits + event_ids = set(state.active_event_ids(first)) | set( + state.active_event_ids(second) + ) + for event_id in event_ids: + state.swap_losses(event_id, first, second, gate.boundary_after) + return + if gate.name in {"SQRT_X", "SQRT_X_DAG"}: + for qubit in gate.qubits: + state.add_continuation_pauli_insertion(qubit, gate.boundary_after) + + +class GateRemovalLossModel: + """Persistent loss with removal of gates touching a lost operand.""" + + def create_handler(self) -> LossGateHandler: + """Create independent state for one gadget traversal.""" + + return GateRemovalGateHandler() \ No newline at end of file diff --git a/deq/deq/transpiler/loss/model_ignore.py b/deq/deq/transpiler/loss/model_ignore.py new file mode 100644 index 00000000..75938d1f --- /dev/null +++ b/deq/deq/transpiler/loss/model_ignore.py @@ -0,0 +1,46 @@ +"""Persistent loss model that emits no Pauli-envelope errors.""" + +from __future__ import annotations + +from deq.transpiler.loss.api import ( + LossAnalysisState, + LossGate, + LossGateHandler, + UnsupportedLossModelError, +) +from deq.transpiler.loss.model_gate_removal import GateRemovalGateHandler + + +class IgnoreLossGateHandler(GateRemovalGateHandler): + """Track physical loss lifetimes and heralds without adding errors.""" + + def handle_loss_source( + self, event_id: int, state: LossAnalysisState + ) -> None: + del event_id, state + + def handle_h(self, gate: LossGate, state: LossAnalysisState) -> None: + del gate, state + + def handle_cx(self, gate: LossGate, state: LossAnalysisState) -> None: + del gate, state + + def handle_m(self, gate: LossGate, state: LossAnalysisState) -> None: + if len(gate.qubits) != 1 or len(gate.measurement_indices) != 1: + raise UnsupportedLossModelError( + f"M at body index {gate.body_index} requires one qubit and result" + ) + state.record_loss_measurement(gate.qubits[0], gate.measurement_indices[0]) + + def handle_native_gate(self, gate: LossGate, state: LossAnalysisState) -> None: + if gate.name == "SWAP": + super().handle_native_gate(gate, state) + + +class IgnoreLossModel: + """Preserve loss metadata while omitting every Pauli-envelope error.""" + + def create_handler(self) -> LossGateHandler: + """Create independent state for one gadget traversal.""" + + return IgnoreLossGateHandler() From 5df6921f01cc739905003bd4c6ac0cee9e4286f4 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 15 Aug 2026 19:39:32 -0700 Subject: [PATCH 096/157] integrate loss model parameter into CLI tools --- deq/deq/cli/annotate.py | 14 ++- deq/deq/cli/interpret.py | 6 +- deq/deq/cli/jit.py | 19 +++- deq/deq/cli/sample.py | 13 +++ deq/deq/cli/simulate.py | 54 ++++++++- deq/deq/transpiler/jit_annotate.py | 18 ++- deq/deq/transpiler/jit_library_builder.py | 7 +- deq/deq/transpiler/loss/model_gate_removal.py | 86 -------------- deq/deq/transpiler/loss/model_ignore.py | 46 -------- deq/deq/transpiler/loss/model_neutral_atom.py | 39 +++++++ deq/deq/transpiler/loss/model_trapped_ion.py | 36 ++++++ .../tutorial/chapters/qdk-loss-simulation.md | 18 +++ deq/proto/deq_bin.proto | 7 +- deq/proto/deq_jit.proto | 2 + deq/tests/runtime/test_mle_loss_decoder.py | 4 + .../runtime/test_qdk_sampler_loss_model.py | 106 ++++++++++++++++++ deq/tests/spec/canonical_test.py | 88 ++++++--------- 17 files changed, 355 insertions(+), 208 deletions(-) delete mode 100644 deq/deq/transpiler/loss/model_gate_removal.py delete mode 100644 deq/deq/transpiler/loss/model_ignore.py create mode 100644 deq/deq/transpiler/loss/model_neutral_atom.py create mode 100644 deq/deq/transpiler/loss/model_trapped_ion.py create mode 100644 deq/tests/runtime/test_qdk_sampler_loss_model.py diff --git a/deq/deq/cli/annotate.py b/deq/deq/cli/annotate.py index e76429b6..64b8c7bc 100644 --- a/deq/deq/cli/annotate.py +++ b/deq/deq/cli/annotate.py @@ -8,6 +8,7 @@ from deq.cli.strip_tags import strip_jit_library from deq.transpiler.jit_annotate import annotate as _annotate_impl from deq.transpiler.jit_library_builder import build_jit_library +from deq.transpiler.loss import create_loss_model from deq.circuit.mako_support import parse_mako_vars @@ -23,6 +24,8 @@ def annotate( mako: list[str] | None = None, #: suppress the interactive Mako safety prompt skip_mako_warning: bool = False, + #: physical loss model: "neutral-atom" or "trapped-ion" + loss_model: str = "neutral-atom", #: skip verification that annotated output transpiles identically no_verify: bool = False, ) -> None: @@ -75,7 +78,8 @@ def annotate( skip_mako_warning=skip_mako_warning, ) - rendered = _annotate_impl(qfile) + selected_loss_model = create_loss_model(loss_model) + rendered = _annotate_impl(qfile, loss_model=selected_loss_model) # Determine output path. if out is None: @@ -93,12 +97,12 @@ def annotate( # Verify: transpile the annotated output and compare. print( - f"Verifying annotated output is equivalent to original", - f"(pass --no-verify to skip)...", + "Verifying annotated output is equivalent to original", + "(pass --no-verify to skip)...", file=sys.stderr, ) - orig_lib = build_jit_library(qfile) - anno_lib = build_jit_library(parse_deq(rendered)) + orig_lib = build_jit_library(qfile, loss_model=selected_loss_model) + anno_lib = build_jit_library(parse_deq(rendered), loss_model=selected_loss_model) orig_stripped, _ = strip_jit_library(orig_lib) anno_stripped, _ = strip_jit_library(anno_lib) if orig_stripped.SerializeToString() == anno_stripped.SerializeToString(): diff --git a/deq/deq/cli/interpret.py b/deq/deq/cli/interpret.py index a9300e20..8b47c9dc 100644 --- a/deq/deq/cli/interpret.py +++ b/deq/deq/cli/interpret.py @@ -241,12 +241,16 @@ def _load_library( import tempfile from deq.circuit.parser import render_and_parse_file from deq.transpiler.jit_library_builder import build_jit_library + from deq.transpiler.loss import NeutralAtomLossModel from deq.cli.jit import jit_compile_program_to_file from deq.compiler.jit_compiler import static_jit_compiler import deq.proto.deq_jit_pb2 as jit_pb qfile = render_and_parse_file(file) - jit_library = build_jit_library(qfile) + jit_library = build_jit_library( + qfile, + loss_model=NeutralAtomLossModel(), + ) with tempfile.TemporaryDirectory() as tmpdir: jit_path = os.path.join(tmpdir, "temp.deq.jit") jit_compile_program_to_file(jit_library, qfile, jit_path, program=program) diff --git a/deq/deq/cli/jit.py b/deq/deq/cli/jit.py index 6814ca5a..ced35bc4 100644 --- a/deq/deq/cli/jit.py +++ b/deq/deq/cli/jit.py @@ -3,7 +3,7 @@ import os import re from collections.abc import Sequence -from typing import NamedTuple +from typing import TYPE_CHECKING, NamedTuple import arguably import deq.proto.deq_jit_pb2 as jit_pb @@ -11,6 +11,9 @@ from deq.compiler.jit_compiler import static_jit_compiler from deq.spec.common import bitmatrix_from_sparse +if TYPE_CHECKING: + from deq.circuit.model import DeqFile + @arguably.command def transpile( @@ -23,6 +26,8 @@ def transpile( #: number of parallel worker processes for GADGET type construction; #: defaults to: (logical CPU count - 2), minimum 1 jobs: int = max((os.cpu_count() or 1) - 2, 1), + #: physical loss model: "neutral-atom" or "trapped-ion" + loss_model: str = "neutral-atom", #: register an external check plugin from a .py file (makes the #: file's stem name available as a @CHECKS("name") value) plugin: list[str] | None = None, @@ -66,11 +71,9 @@ def transpile( teleportation), and exposing those would make Stim reject the circuit as having non-deterministic observables. """ - from deq.circuit.model import ( - DeqFile, - ) from deq.circuit.parser import render_and_parse_files from deq.transpiler.jit_library_builder import build_jit_library + from deq.transpiler.loss import create_loss_model from deq.circuit.mako_support import parse_mako_vars if not deq_files: @@ -92,7 +95,11 @@ def transpile( list(deq_files), mako_defs=mako_vars, skip_mako_warning=skip_mako_warning ) - jit_library = build_jit_library(merged, jobs=jobs) + jit_library = build_jit_library( + merged, + jobs=jobs, + loss_model=create_loss_model(loss_model), + ) if out is None: base = deq_files[0] @@ -108,7 +115,7 @@ def transpile( def jit_compile_program_to_file( jit_library: jit_pb.JitLibrary, - merged: "DeqFile", # noqa: F821 + merged: "DeqFile", out: str, *, program: str | None = None, diff --git a/deq/deq/cli/sample.py b/deq/deq/cli/sample.py index 3d65ff5f..de8e88a2 100644 --- a/deq/deq/cli/sample.py +++ b/deq/deq/cli/sample.py @@ -210,6 +210,7 @@ def _compile_deq_to_stim_and_bin( plugin: list[str] | None, mako: list[str] | None, skip_mako_warning: bool, + loss_model: str | None = None, ) -> tuple[str, str]: """Compile .deq files into a .stim circuit and .deq.bin. @@ -221,6 +222,11 @@ def _compile_deq_to_stim_and_bin( captured = io.StringIO() with redirect_stdout(captured): if jit is not None: + if loss_model is not None: + raise ValueError( + "--loss-model cannot be combined with --jit; " + "the loss model is already compiled into the JIT library" + ) if not deq_files: raise ValueError("at least one .deq file is required") from deq.circuit.mako_support import parse_mako_vars @@ -260,6 +266,7 @@ def _compile_deq_to_stim_and_bin( plugin=plugin, mako=mako, skip_mako_warning=skip_mako_warning, + loss_model=loss_model or "neutral-atom", ) compile_(jit_out, out=bin_out) @@ -298,6 +305,9 @@ def sample( mako: list[str] | None = None, #: suppress the interactive Mako safety prompt skip_mako_warning: bool = False, + #: physical loss model for .deq input: "neutral-atom" or "trapped-ion"; + #: cannot be combined with --jit + loss_model: str | None = None, ) -> list[str] | list[tuple[str, str]]: """Sample measurement outcomes from a .stim circuit or .deq source. @@ -332,6 +342,8 @@ def sample( is_stim = files[0].endswith(".stim") if is_stim: + if loss_model is not None: + raise ValueError("--loss-model is only valid for .deq input") if len(files) > 1: raise ValueError("only one .stim file can be given") stim_file = files[0] @@ -373,6 +385,7 @@ def sample( plugin=plugin, mako=mako, skip_mako_warning=skip_mako_warning, + loss_model=loss_model, ) with open(stim_path, encoding="utf-8") as f: diff --git a/deq/deq/cli/simulate.py b/deq/deq/cli/simulate.py index 1919154a..32126e11 100644 --- a/deq/deq/cli/simulate.py +++ b/deq/deq/cli/simulate.py @@ -22,6 +22,7 @@ from dataclasses import dataclass import arguably +from google.protobuf.json_format import MessageToDict from deq.circuit.model import ( CodeDefinition, @@ -91,6 +92,9 @@ def simulate__ler( debug_dir: str | None = None, jobs: int = max((os.cpu_count() or 1) - 2, 1), jit: str | None = None, + #: physical loss model used when building from source: "neutral-atom" or + #: "trapped-ion"; with --jit, any stored loss config must match + loss_model: str | None = None, #: Override the auto-generated .stim file (for debugging) stim: str | None = None, #: Mako variable definitions, each as key=value @@ -154,6 +158,7 @@ def simulate__ler( from tqdm import tqdm from deq.transpiler.jit_library_builder import build_jit_library + from deq.transpiler.loss import create_loss_model from deq.compiler.jit_compiler import static_jit_compiler from deq.cli.jit import compile_program_for_jit, export_program_stim from deq.transpiler.jit_transpiler import flatten_body @@ -164,6 +169,12 @@ def simulate__ler( if not deq_files: raise ValueError("At least one .deq file is required") + selected_loss_model = ( + create_loss_model(loss_model or "neutral-atom") if jit is None else None + ) + selected_loss_config = ( + selected_loss_model.config if selected_loss_model is not None else None + ) # Use a temp dir unless --save is given. tmpdir_ctx = tempfile.TemporaryDirectory() if save is None else None @@ -198,6 +209,7 @@ def simulate__ler( print(f"Loading pre-compiled JIT library from {jit}...") with open(jit, "rb") as f: jit_library = jit_pb.JitLibrary.FromString(f.read()) + selected_loss_config = _resolve_jit_loss_config(jit_library, loss_model) # Sanity check: every gadget in .deq must exist in .deq.jit jit_names = {gt.base.name for gt in jit_library.gadget_types} @@ -214,7 +226,12 @@ def simulate__ler( ) else: print("Building JIT library...") - jit_library = build_jit_library(merged, jobs=jobs) + assert selected_loss_model is not None + jit_library = build_jit_library( + merged, + jobs=jobs, + loss_model=selected_loss_model, + ) # Compile program into JIT instructions print("Compiling program...") @@ -301,6 +318,7 @@ def simulate__ler( with ProcessPoolExecutor(max_workers=jobs) as pool: futures = {} + assert selected_loss_config is not None def _submit_batch() -> bool: """Submit one batch if budget remains. Returns True if submitted.""" @@ -330,6 +348,7 @@ def _submit_batch() -> bool: seed=next_seed, debug_dir=debug_dir, simulator=simulator, + loss_config=selected_loss_config.to_json_object(), ) futures[fut] = (this_batch,) if next_seed is not None: @@ -385,6 +404,33 @@ def _submit_batch() -> bool: tmpdir_ctx.cleanup() +def _resolve_jit_loss_config(jit_library, requested_name: str | None): + """Load persisted overrides, defaulting legacy JIT artifacts to none.""" + + from deq.transpiler.loss import QdkLossConfig, create_loss_model + + metadata = ( + MessageToDict(jit_library.metadata) + if jit_library.HasField("metadata") + else {} + ) + has_stored_config = "loss_strategy" in metadata + stored_config_object = metadata.get("loss_strategy", {}) + if not isinstance(stored_config_object, dict): + raise ValueError( + "precompiled JIT loss-strategy metadata must be an object" + ) + stored_config = QdkLossConfig.from_json_object(stored_config_object) + if has_stored_config and requested_name is not None and ( + create_loss_model(requested_name).config != stored_config + ): + raise ValueError( + f"--loss-model {requested_name!r} does not match precompiled JIT " + f"config; remove the parameter or change it to {stored_config.name!r}" + ) + return stored_config + + def _run_batch( bin_path: str, stim_path: str, @@ -398,6 +444,7 @@ def _run_batch( seed: int | None, debug_dir: str | None, simulator: str = "static", + loss_config: dict[str, object] | None = None, ) -> dict[str, int | float]: """Spawn one deq_runtime server process for a batch of shots.""" simulator_config: dict[str, object] = { @@ -414,7 +461,10 @@ def _run_batch( runtime_simulator = simulator elif simulator == "qdk": simulator_config["sampler"] = "@qdk_sampler" - simulator_config["py_config"] = {"batch_size": batch_size + 1} + simulator_config["py_config"] = { + "batch_size": batch_size + 1, + "loss_config": loss_config, + } controller_name = "static" controller_config = {"filepath": bin_path} runtime_simulator = "python" diff --git a/deq/deq/transpiler/jit_annotate.py b/deq/deq/transpiler/jit_annotate.py index b13d09b2..a3a9daa8 100644 --- a/deq/deq/transpiler/jit_annotate.py +++ b/deq/deq/transpiler/jit_annotate.py @@ -81,6 +81,7 @@ ) from deq.transpiler.jit_noise_builder import compute_implicit_readout_propagation from deq.transpiler.loss.syntax import loss_model_to_statements +from deq.transpiler.loss.api import LossModel from deq.spec.common import bitmatrix_of import deq.proto.deq_jit_pb2 as jit_pb import deq.proto.util_pb2 as util_pb @@ -91,13 +92,16 @@ ) -def annotate(qfile: DeqFile) -> str: +def annotate(qfile: DeqFile, *, loss_model: LossModel | None = None) -> str: """Render ``qfile`` as annotated ``.deq`` source mirroring its JIT form. Parameters ---------- qfile: The parsed ``.deq`` file to annotate. + loss_model: + Physical loss model used to infer decoder metadata. Defaults to the + neutral-atom platform model when omitted. Undecorated noise is split into its original ``@SIMULATE_ONLY`` physical instruction and canonical decode-side metadata. Decode-visible noisy @@ -119,7 +123,11 @@ def annotate(qfile: DeqFile) -> str: # Always build the JIT library to get stable gtype/ptype assignments # and to render COMPOSE definitions as GADGET blocks. - library_artifacts = build_jit_library_artifacts(qfile) + library_artifacts = ( + build_jit_library_artifacts(qfile) + if loss_model is None + else build_jit_library_artifacts(qfile, loss_model=loss_model) + ) library = library_artifacts.jit_library stab_count_of_ptype: dict[int, int] = { pt.base.ptype: len(pt.stabilizers) for pt in library.port_types @@ -490,9 +498,9 @@ def _annotate_gadget( + f" # E{origin.error_index}" ) if emit_loss_metadata: - assert source_loss_lines or input_loss_lines, ( - f"GADGET {gadget.name!r} has an empty loss model" - ) + assert ( + source_loss_lines or input_loss_lines + ), f"GADGET {gadget.name!r} has an empty loss model" trailing_loss_lines = list(source_loss_lines[loss_error_counter:]) trailing_loss_lines.extend(input_loss_lines) if trailing_loss_lines: diff --git a/deq/deq/transpiler/jit_library_builder.py b/deq/deq/transpiler/jit_library_builder.py index 498a9280..3b14dd43 100644 --- a/deq/deq/transpiler/jit_library_builder.py +++ b/deq/deq/transpiler/jit_library_builder.py @@ -75,7 +75,7 @@ ) from deq.transpiler.loss.transpiler import transpile_inferred_loss_model from deq.transpiler.loss.api import LossModel -from deq.transpiler.loss.model_gate_removal import GateRemovalLossModel +from deq.transpiler.loss.model_neutral_atom import NeutralAtomLossModel from deq.transpiler.loss.syntax import transpile_declared_loss_model import stim @@ -211,7 +211,7 @@ def build_jit_library_artifacts( Values > 1 use :class:`~concurrent.futures.ProcessPoolExecutor`. """ if loss_model is None: - loss_model = GateRemovalLossModel() + loss_model = NeutralAtomLossModel() scaffold = _build_library_scaffold(qfile) # A gadget with input ports gets ``input_losses`` describing how a loss @@ -281,6 +281,9 @@ def build_jit_library_artifacts( jit_library=jit_pb.JitLibrary( port_types=sorted(scaffold.port_types, key=lambda p: p.base.ptype), gadget_types=sorted(gadget_types, key=lambda g: g.base.gtype), + metadata={ + "loss_strategy": loss_model.config.to_json_object(), + }, ), gadget_artifacts_by_name=gadget_artifacts_by_name, ) diff --git a/deq/deq/transpiler/loss/model_gate_removal.py b/deq/deq/transpiler/loss/model_gate_removal.py deleted file mode 100644 index 35e28ea6..00000000 --- a/deq/deq/transpiler/loss/model_gate_removal.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Built-in persistent gate-removal loss model.""" - -from __future__ import annotations - -from deq.transpiler.loss.api import ( - LossAnalysisState, - LossGate, - LossGateHandler, - UnsupportedLossModelError, -) - - -class GateRemovalGateHandler(LossGateHandler): - """Per-gadget handler for persistent loss and removed gates.""" - - native_gate_names = frozenset({"CZ", "S", "SQRT_X", "SQRT_X_DAG", "SWAP"}) - - def handle_loss_source( - self, event_id: int, state: LossAnalysisState - ) -> None: - state.add_source_pauli_insertion(event_id) - - def handle_h(self, gate: LossGate, state: LossAnalysisState) -> None: - for qubit in gate.qubits: - state.add_continuation_pauli_insertion(qubit, gate.boundary_after) - - def handle_s(self, gate: LossGate, state: LossAnalysisState) -> None: - del gate, state - - def handle_cx(self, gate: LossGate, state: LossAnalysisState) -> None: - control, target = gate.qubits # splitted by ./analysis.py - for event_id in state.active_event_ids(target): - state.add_event_continuation_pauli_insertion( - event_id, - lost_qubit=target, - error_qubit=target, - boundary=gate.boundary_after, - ) - if not state.event_has_active_loss(event_id, control): - state.add_event_continuation_pauli_insertion( - event_id, - lost_qubit=target, - error_qubit=control, - boundary=gate.boundary_after, - paulis=("I", "X"), - ) - - def handle_m(self, gate: LossGate, state: LossAnalysisState) -> None: - if len(gate.qubits) != 1 or len(gate.measurement_indices) != 1: - raise UnsupportedLossModelError( - f"M at body index {gate.body_index} requires one qubit and result" - ) - qubit = gate.qubits[0] - state.add_continuation_pauli_insertion(qubit, gate.boundary_before) - state.record_loss_measurement(qubit, gate.measurement_indices[0]) - - def handle_r(self, gate: LossGate, state: LossAnalysisState) -> None: - for qubit in gate.qubits: - state.clear_loss(qubit) - - def handle_native_gate(self, gate: LossGate, state: LossAnalysisState) -> None: - assert gate.name in self.native_gate_names # guaranteed by LossGateHandler - if gate.name == "SWAP": - # A SWAP is a physical atom relabelling in neutral-atom hardware: it - # carries a lost site (and its Pauli envelope) to the partner site - # without injecting any new error. Move each active loss to the - # partner qubit; no continuation Pauli insertion is added. - first, second = gate.qubits - event_ids = set(state.active_event_ids(first)) | set( - state.active_event_ids(second) - ) - for event_id in event_ids: - state.swap_losses(event_id, first, second, gate.boundary_after) - return - if gate.name in {"SQRT_X", "SQRT_X_DAG"}: - for qubit in gate.qubits: - state.add_continuation_pauli_insertion(qubit, gate.boundary_after) - - -class GateRemovalLossModel: - """Persistent loss with removal of gates touching a lost operand.""" - - def create_handler(self) -> LossGateHandler: - """Create independent state for one gadget traversal.""" - - return GateRemovalGateHandler() \ No newline at end of file diff --git a/deq/deq/transpiler/loss/model_ignore.py b/deq/deq/transpiler/loss/model_ignore.py deleted file mode 100644 index 75938d1f..00000000 --- a/deq/deq/transpiler/loss/model_ignore.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Persistent loss model that emits no Pauli-envelope errors.""" - -from __future__ import annotations - -from deq.transpiler.loss.api import ( - LossAnalysisState, - LossGate, - LossGateHandler, - UnsupportedLossModelError, -) -from deq.transpiler.loss.model_gate_removal import GateRemovalGateHandler - - -class IgnoreLossGateHandler(GateRemovalGateHandler): - """Track physical loss lifetimes and heralds without adding errors.""" - - def handle_loss_source( - self, event_id: int, state: LossAnalysisState - ) -> None: - del event_id, state - - def handle_h(self, gate: LossGate, state: LossAnalysisState) -> None: - del gate, state - - def handle_cx(self, gate: LossGate, state: LossAnalysisState) -> None: - del gate, state - - def handle_m(self, gate: LossGate, state: LossAnalysisState) -> None: - if len(gate.qubits) != 1 or len(gate.measurement_indices) != 1: - raise UnsupportedLossModelError( - f"M at body index {gate.body_index} requires one qubit and result" - ) - state.record_loss_measurement(gate.qubits[0], gate.measurement_indices[0]) - - def handle_native_gate(self, gate: LossGate, state: LossAnalysisState) -> None: - if gate.name == "SWAP": - super().handle_native_gate(gate, state) - - -class IgnoreLossModel: - """Preserve loss metadata while omitting every Pauli-envelope error.""" - - def create_handler(self) -> LossGateHandler: - """Create independent state for one gadget traversal.""" - - return IgnoreLossGateHandler() diff --git a/deq/deq/transpiler/loss/model_neutral_atom.py b/deq/deq/transpiler/loss/model_neutral_atom.py new file mode 100644 index 00000000..e4c3ab16 --- /dev/null +++ b/deq/deq/transpiler/loss/model_neutral_atom.py @@ -0,0 +1,39 @@ +"""Neutral-atom loss model matching the QDK simulator configuration.""" + +from __future__ import annotations + +from deq.transpiler.loss.api import ( + GateLossPolicy, + LossGateHandler, + QdkLossConfig, +) +from deq.transpiler.loss.model_configured import ConfiguredLossGateHandler + +_NEUTRAL_ATOM_QDK_CONFIG = QdkLossConfig( + gate_policies=( + ("cx", GateLossPolicy.SKIP), + ("cy", GateLossPolicy.SKIP), + ("cz", GateLossPolicy.SKIP), + ("swap", GateLossPolicy.APPLY_ANYWAY), + ), +) + + +class NeutralAtomLossGateHandler(ConfiguredLossGateHandler): + """Use skipped lost-operand gates with physical SWAP relocation.""" + + def __init__(self) -> None: + super().__init__(_NEUTRAL_ATOM_QDK_CONFIG) + + +class NeutralAtomLossModel: + """Neutral-atom platform model: SKIP gates and relocate atoms on SWAP.""" + + name = "neutral-atom" + source_config = _NEUTRAL_ATOM_QDK_CONFIG + qdk_config = _NEUTRAL_ATOM_QDK_CONFIG + + def create_handler(self) -> LossGateHandler: + """Create independent state for one gadget traversal.""" + + return NeutralAtomLossGateHandler() diff --git a/deq/deq/transpiler/loss/model_trapped_ion.py b/deq/deq/transpiler/loss/model_trapped_ion.py new file mode 100644 index 00000000..73a28f92 --- /dev/null +++ b/deq/deq/transpiler/loss/model_trapped_ion.py @@ -0,0 +1,36 @@ +"""Trapped-ion loss model for Mølmer-Sørensen-native hardware.""" + +from __future__ import annotations + +from deq.transpiler.loss.api import GateLossPolicy, LossGateHandler, QdkLossConfig +from deq.transpiler.loss.model_configured import ConfiguredLossGateHandler + + +_TRAPPED_ION_QDK_CONFIG = QdkLossConfig( + gate_policies=( + ("cx", GateLossPolicy.RESIDUAL_S_DAGGER), + ("cy", GateLossPolicy.RESIDUAL_S_DAGGER), + ("cz", GateLossPolicy.RESIDUAL_S_DAGGER), + ("swap", GateLossPolicy.SKIP), + ), +) + + +class TrappedIonLossGateHandler(ConfiguredLossGateHandler): + """Retain the local S-dagger fixup when loss removes the MS interaction.""" + + def __init__(self) -> None: + super().__init__(_TRAPPED_ION_QDK_CONFIG) + + +class TrappedIonLossModel: + """CX-native trapped ions whose lost-operand interaction leaves S-dagger.""" + + name = "trapped-ion" + source_config = _TRAPPED_ION_QDK_CONFIG + qdk_config = _TRAPPED_ION_QDK_CONFIG + + def create_handler(self) -> LossGateHandler: + """Create independent state for one gadget traversal.""" + + return TrappedIonLossGateHandler() diff --git a/deq/documents/tutorial/chapters/qdk-loss-simulation.md b/deq/documents/tutorial/chapters/qdk-loss-simulation.md index 57a02835..4c446783 100644 --- a/deq/documents/tutorial/chapters/qdk-loss-simulation.md +++ b/deq/documents/tutorial/chapters/qdk-loss-simulation.md @@ -18,6 +18,24 @@ standard loss model: - Other platforms (Rydberg blockade variants, leakage to higher levels, atom-array transport, …) come with their own variants. +deq packages these gate-by-gate rules as platform loss models, selected with +``--loss-model``: + +| Model | Default two-qubit policy | Gate overrides | +| --- | --- | --- | +| ``neutral-atom`` | ``SKIP`` | ``SWAP → APPLY_ANYWAY`` so atom transport relocates the loss flag | +| ``trapped-ion`` | QDK defaults | ``CX/CY/CZ → RESIDUAL_S_DAGGER``; ``SWAP → SKIP`` | + +The same canonical configuration is stored in the compiled ``.deq.jit`` and +``.deq.bin`` artifacts and passed to QDK simulation, so decoder metadata and +physical sampling cannot silently select different models. On the decoding +side, the trapped-ion residual $S^{\dagger}$ is represented by its Pauli +envelope $\{I,Z\}$ on the surviving ion. The circuit-level native gate remains +``CX``: the model assumes hardware implements it with an MS interaction and +local $S^{\dagger}$ fixups. If one ion is absent, the MS interaction disappears +while the survivor's fixup remains. No new circuit gates or sampler rewrites are +required. + This chapter is an **introduction**. It pairs the simplest physical loss model with the simplest decoding strategy deq currently ships: diff --git a/deq/proto/deq_bin.proto b/deq/proto/deq_bin.proto index 1ae5f0d5..25398d6e 100644 --- a/deq/proto/deq_bin.proto +++ b/deq/proto/deq_bin.proto @@ -6,6 +6,7 @@ syntax = "proto3"; package deq.bin; import "visualizer.proto"; +import "google/protobuf/struct.proto"; import "util.proto"; /* @@ -28,7 +29,7 @@ import "util.proto"; * like packed array */ -// a library contains a set of gadget, check-model and error-model types, and an +// a library contains a set of gadget, check-model and error-model types, and a // reference logical program that illustrates how to use them message Library { string description = 1; @@ -43,6 +44,10 @@ message Library { // decoding system needs to be more careful about the correctness repeated Instruction program = 6; deq.visualizer.VisualConfig visual_config = 7; + // JSON-compatible reference metadata; no effect on the decoding system. + // Values may contain nested objects, arrays, strings, numbers, booleans, and + // null. + google.protobuf.Struct metadata = 8; } message Instruction { diff --git a/deq/proto/deq_jit.proto b/deq/proto/deq_jit.proto index 465fb794..63b53528 100644 --- a/deq/proto/deq_jit.proto +++ b/deq/proto/deq_jit.proto @@ -7,12 +7,14 @@ package deq.jit; import "visualizer.proto"; import "deq_bin.proto"; +import "google/protobuf/struct.proto"; message JitLibrary { string description = 1; repeated JitGadgetType gadget_types = 2; repeated JitPortType port_types = 3; repeated JitInstruction program = 5; + google.protobuf.Struct metadata = 6; } message UnloadJitLibrary { diff --git a/deq/tests/runtime/test_mle_loss_decoder.py b/deq/tests/runtime/test_mle_loss_decoder.py index 6a6e2fe5..865249ae 100644 --- a/deq/tests/runtime/test_mle_loss_decoder.py +++ b/deq/tests/runtime/test_mle_loss_decoder.py @@ -45,6 +45,10 @@ def _site(*, source=(), continuation=(), children=()): ) +def test_supported_features_declares_loss() -> None: + assert _decoder_module().Decoder.supported_features() == ["loss"] + + def test_ordinary_positive_prior_edge_satisfies_syndrome() -> None: decoder = _decoder_module().Decoder(_hypergraph(([0], 0.1))) diff --git a/deq/tests/runtime/test_qdk_sampler_loss_model.py b/deq/tests/runtime/test_qdk_sampler_loss_model.py new file mode 100644 index 00000000..86929e25 --- /dev/null +++ b/deq/tests/runtime/test_qdk_sampler_loss_model.py @@ -0,0 +1,106 @@ +"""QDK sampler platform loss-configuration tests.""" + +import importlib.util +from pathlib import Path + +import pytest +from qdk.simulation import LossPolicy, NoiseConfig + +from deq.transpiler.loss import NeutralAtomLossModel, TrappedIonLossModel + + +_SAMPLER_PATH = ( + Path(__file__).resolve().parents[2] + / "deq_runtime" + / "src" + / "simulator" + / "qdk_sampler.py" +) +_SPEC = importlib.util.spec_from_file_location("qdk_sampler_for_test", _SAMPLER_PATH) +assert _SPEC is not None and _SPEC.loader is not None +_SAMPLER = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(_SAMPLER) + + +def test_neutral_atom_config_skips_gates_and_relocates_swap() -> None: + noise = NoiseConfig() + + _SAMPLER._configure_loss(noise, NeutralAtomLossModel.config.to_json_object()) + + for ( + table_name, + policy_name, + ) in NeutralAtomLossModel.config.to_json_object().items(): + expected = getattr(LossPolicy, policy_name) + assert getattr(noise, table_name).on_loss == expected + + +def test_missing_config_leaves_qdk_defaults_unchanged() -> None: + noise = NoiseConfig() + defaults = { + gate: getattr(noise, gate).on_loss + for gate in NeutralAtomLossModel.config.to_json_object() + } + + _SAMPLER._configure_loss(noise, None) + + assert {gate: getattr(noise, gate).on_loss for gate in defaults} == defaults + + +def test_trapped_ion_config_leaves_s_dagger_after_lost_cx() -> None: + noise = NoiseConfig() + + _SAMPLER._configure_loss(noise, TrappedIonLossModel.config.to_json_object()) + + for ( + table_name, + policy_name, + ) in TrappedIonLossModel.config.to_json_object().items(): + assert getattr(noise, table_name).on_loss == getattr(LossPolicy, policy_name) + + +@pytest.mark.parametrize("lost_qubit", [0, 1]) +def test_trapped_ion_qdk_sampler_applies_cx_residual_s_dagger( + lost_qubit: int, +) -> None: + survivor = 1 - lost_qubit + sampler = _SAMPLER.Sampler( + f"H {survivor}\nS {survivor}\nLOSS_ERROR(1) {lost_qubit}\n" + f"CX 0 1\nH {survivor}\nM 0 1\n", + { + "seed": 7, + "batch_size": 1, + "loss_config": TrappedIonLossModel.config.to_json_object(), + }, + ) + + assert sampler.sample() == ("-0" if lost_qubit == 0 else "0-") + + +def test_config_applies_only_explicit_gate_overrides() -> None: + noise = NoiseConfig() + original_cx = noise.cx.on_loss + + _SAMPLER._configure_loss( + noise, + {"cz": "RESIDUAL_S_DAGGER"}, + ) + + assert noise.cz.on_loss == LossPolicy.RESIDUAL_S_DAGGER + assert noise.cx.on_loss == original_cx + + +@pytest.mark.parametrize( + ("config", "error_type", "message"), + [ + ([], ValueError, "loss_config must be a JSON object"), + ({"unknown": "SKIP"}, AttributeError, "unknown"), + ({"cx": "UNKNOWN"}, AttributeError, "UNKNOWN"), + ({"cx": "APPLY_ANYWAY"}, AttributeError, "only supports"), + ], +) +def test_invalid_qdk_sampler_loss_config_is_rejected( + config: object, error_type: type[Exception], message: str +) -> None: + with pytest.raises(error_type, match=message): + _SAMPLER._configure_loss(NoiseConfig(), config) diff --git a/deq/tests/spec/canonical_test.py b/deq/tests/spec/canonical_test.py index e2b3594b..ca20cb54 100644 --- a/deq/tests/spec/canonical_test.py +++ b/deq/tests/spec/canonical_test.py @@ -74,7 +74,7 @@ ) -def test_canonicalize_preserves_loss_model() -> None: +def test_canonicalize_preserves_nested_metadata() -> None: loss_model = pb.GadgetType.LossModel( losses=[ pb.GadgetType.LossModel.Loss( @@ -87,6 +87,19 @@ def test_canonicalize_preserves_loss_model() -> None: ] ) lib = pb.Library( + metadata={ + "loss_strategy": { + "name": "neutral-atom", + "config": { + "cx": "SKIP", + "cy": "SKIP", + "cz": "SKIP", + "swap": "APPLY_ANYWAY", + }, + }, + # Synthetic metadata verifies that unrelated nested values survive. + "mock": {"nested": ["value"]}, + }, port_types=[ pb.PortType( ptype=1, @@ -111,9 +124,7 @@ def test_canonicalize_preserves_loss_model() -> None: checks=[ pb.CheckModelType.Check( measurements=[ - pb.CheckModelType.RemoteMeasurement( - measurement_index=0 - ) + pb.CheckModelType.RemoteMeasurement(measurement_index=0) ] ) ], @@ -148,6 +159,7 @@ def test_canonicalize_preserves_loss_model() -> None: assert list(loss.continuation_errors) == [0] assert list(loss.child_output_qubits) == [0] assert list(loss.loss_measurements) == [0] + assert canonical.library.metadata == lib.metadata def test_canonical_default() -> None: @@ -833,6 +845,7 @@ def test_canonical_remote_conditional_correction_multiple_gadgets() -> None: def test_apply_bitmatrix_modifier_none_returns_original() -> None: """``apply_bitmatrix_modifier(m, None)`` short-circuits to *m*.""" from deq.spec.common import apply_bitmatrix_modifier + original = util_pb.BitMatrix(rows=2, cols=3, i=[0], j=[1]) assert apply_bitmatrix_modifier(original, None) is original @@ -1081,18 +1094,10 @@ def test_partial_merge_input_side_all_matrices_and_to_jit() -> None: inputs=[pb.GadgetType.Port(ptype=1)], outputs=[pb.GadgetType.Port(ptype=1)], readouts=[pb.GadgetType.Readout(measurement_indices=[0])], - correction_propagation=util_pb.BitMatrix( - rows=1, cols=2, i=[0], j=[0] - ), - readout_propagation=util_pb.BitMatrix( - rows=1, cols=2, i=[0], j=[0] - ), - logical_correction=util_pb.BitMatrix( - rows=1, cols=1, i=[0], j=[0] - ), - physical_correction=util_pb.BitMatrix( - rows=1, cols=1, i=[0], j=[0] - ), + correction_propagation=util_pb.BitMatrix(rows=1, cols=2, i=[0], j=[0]), + readout_propagation=util_pb.BitMatrix(rows=1, cols=2, i=[0], j=[0]), + logical_correction=util_pb.BitMatrix(rows=1, cols=1, i=[0], j=[0]), + physical_correction=util_pb.BitMatrix(rows=1, cols=1, i=[0], j=[0]), ), # gtype=3: sink shell -- one measurement (so the check can # reference ``remote_gadget=1, measurement_index=0``) and one @@ -1119,18 +1124,14 @@ def test_partial_merge_input_side_all_matrices_and_to_jit() -> None: # Finished check: input-side measurement + middle's own. pb.CheckModelType.Check( measurements=[ - pb.CheckModelType.RemoteMeasurement( - measurement_index=0 - ), + pb.CheckModelType.RemoteMeasurement(measurement_index=0), pb.CheckModelType.RemoteMeasurement(remote_gadget=0), ] ), # Unfinished check: middle's own + output-side measurement. pb.CheckModelType.Check( measurements=[ - pb.CheckModelType.RemoteMeasurement( - measurement_index=0 - ), + pb.CheckModelType.RemoteMeasurement(measurement_index=0), pb.CheckModelType.RemoteMeasurement(remote_gadget=1), ] ), @@ -1186,9 +1187,7 @@ def test_partial_merge_input_side_all_matrices_and_to_jit() -> None: unfinished = merged.unfinished_checks[0] assert len(finished.measurements) == 2 assert len(unfinished.measurements) == 1 # only middle's own; C's is OV - input_virtuals = [ - m for m in finished.measurements if m.input_port is not None - ] + input_virtuals = [m for m in finished.measurements if m.input_port is not None] reals = [m for m in finished.measurements if m.input_port is None] assert len(input_virtuals) == 1 and len(reals) == 1 @@ -1251,15 +1250,11 @@ def test_merge_local_lc_flows_to_downstream_readout() -> None: inputs=[pb.GadgetType.Port(ptype=1)], outputs=[pb.GadgetType.Port(ptype=1)], readouts=[pb.GadgetType.Readout()], - correction_propagation=util_pb.BitMatrix( - rows=1, cols=2, i=[0], j=[0] - ), + correction_propagation=util_pb.BitMatrix(rows=1, cols=2, i=[0], j=[0]), readout_propagation=util_pb.BitMatrix( rows=1, cols=2, i=[0, 0], j=[0, 1] ), - logical_correction=util_pb.BitMatrix( - rows=1, cols=1, i=[0], j=[0] - ), + logical_correction=util_pb.BitMatrix(rows=1, cols=1, i=[0], j=[0]), physical_correction=util_pb.BitMatrix(rows=1, cols=0), ), # gtype=3: downstream sink merge gadget whose readout is @@ -1269,9 +1264,7 @@ def test_merge_local_lc_flows_to_downstream_readout() -> None: inputs=[pb.GadgetType.Port(ptype=1)], readouts=[pb.GadgetType.Readout()], correction_propagation=util_pb.BitMatrix(rows=0, cols=2), - readout_propagation=util_pb.BitMatrix( - rows=1, cols=2, i=[0], j=[0] - ), + readout_propagation=util_pb.BitMatrix(rows=1, cols=2, i=[0], j=[0]), physical_correction=util_pb.BitMatrix(rows=0, cols=0), ), ], @@ -1301,8 +1294,7 @@ def test_merge_local_lc_flows_to_downstream_readout() -> None: # ``logical_correction`` is always empty in the merged form. assert merged.logical_correction.rows == 0 or ( - not list(merged.logical_correction.i) - and not list(merged.logical_correction.j) + not list(merged.logical_correction.i) and not list(merged.logical_correction.j) ) # After absorption, the downstream readout (r_D) picks up the @@ -1363,9 +1355,7 @@ def test_merge_remote_cc_flows_to_downstream_readout() -> None: pb.GadgetType.Readout(), pb.GadgetType.Readout(), ], - correction_propagation=util_pb.BitMatrix( - rows=1, cols=3, i=[0], j=[0] - ), + correction_propagation=util_pb.BitMatrix(rows=1, cols=3, i=[0], j=[0]), readout_propagation=util_pb.BitMatrix( rows=2, cols=3, i=[0, 1], j=[0, 1] ), @@ -1377,9 +1367,7 @@ def test_merge_remote_cc_flows_to_downstream_readout() -> None: gtype=3, inputs=[pb.GadgetType.Port(ptype=1)], outputs=[pb.GadgetType.Port(ptype=1)], - correction_propagation=util_pb.BitMatrix( - rows=1, cols=2, i=[0], j=[0] - ), + correction_propagation=util_pb.BitMatrix(rows=1, cols=2, i=[0], j=[0]), physical_correction=util_pb.BitMatrix(rows=1, cols=0), ), # gtype=4 (Z): downstream sink whose readout is driven by X's @@ -1390,9 +1378,7 @@ def test_merge_remote_cc_flows_to_downstream_readout() -> None: inputs=[pb.GadgetType.Port(ptype=1)], readouts=[pb.GadgetType.Readout()], correction_propagation=util_pb.BitMatrix(rows=0, cols=2), - readout_propagation=util_pb.BitMatrix( - rows=1, cols=2, i=[0], j=[0] - ), + readout_propagation=util_pb.BitMatrix(rows=1, cols=2, i=[0], j=[0]), physical_correction=util_pb.BitMatrix(rows=0, cols=0), ), ], @@ -1492,9 +1478,7 @@ def test_partial_merge_external_check_model_and_non_merge_error_model() -> None: checks=[ pb.CheckModelType.Check( measurements=[ - pb.CheckModelType.RemoteMeasurement( - measurement_index=0 - ), + pb.CheckModelType.RemoteMeasurement(measurement_index=0), ] ), ], @@ -1506,9 +1490,7 @@ def test_partial_merge_external_check_model_and_non_merge_error_model() -> None: checks=[ pb.CheckModelType.Check( measurements=[ - pb.CheckModelType.RemoteMeasurement( - measurement_index=0 - ), + pb.CheckModelType.RemoteMeasurement(measurement_index=0), ] ), ], @@ -1521,9 +1503,7 @@ def test_partial_merge_external_check_model_and_non_merge_error_model() -> None: etype=1, ctype=1, remote_check_models=[ - pb.ErrorModelType.RemoteCheckModel( - output=0, expecting_ctype=2 - ), + pb.ErrorModelType.RemoteCheckModel(output=0, expecting_ctype=2), ], errors=[ pb.ErrorModelType.Error( From 25ff4bccbbe914d4e5d9d027507387c5644839a8 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 15 Aug 2026 19:39:50 -0700 Subject: [PATCH 097/157] update protobuf --- deq/deq_runtime/src/proto/deq.bin.rs | 7 ++++++- deq/deq_runtime/src/proto/deq.jit.rs | 3 +++ deq/proto/deq_jit.proto | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/deq/deq_runtime/src/proto/deq.bin.rs b/deq/deq_runtime/src/proto/deq.bin.rs index 4d204159..a80ed3ec 100644 --- a/deq/deq_runtime/src/proto/deq.bin.rs +++ b/deq/deq_runtime/src/proto/deq.bin.rs @@ -1,5 +1,5 @@ // This file is @generated by prost-build. -/// a library contains a set of gadget, check-model and error-model types, and an +/// a library contains a set of gadget, check-model and error-model types, and a /// reference logical program that illustrates how to use them #[derive(Clone, PartialEq, ::prost::Message)] pub struct Library { @@ -23,6 +23,11 @@ pub struct Library { pub program: ::prost::alloc::vec::Vec, #[prost(message, optional, tag = "7")] pub visual_config: ::core::option::Option, + /// JSON-compatible reference metadata; no effect on the decoding system. + /// Values may contain nested objects, arrays, strings, numbers, booleans, and + /// null. + #[prost(message, optional, tag = "8")] + pub metadata: ::core::option::Option<::prost_types::Struct>, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct Instruction { diff --git a/deq/deq_runtime/src/proto/deq.jit.rs b/deq/deq_runtime/src/proto/deq.jit.rs index ee9df856..d7f21dbb 100644 --- a/deq/deq_runtime/src/proto/deq.jit.rs +++ b/deq/deq_runtime/src/proto/deq.jit.rs @@ -9,6 +9,9 @@ pub struct JitLibrary { pub port_types: ::prost::alloc::vec::Vec, #[prost(message, repeated, tag = "5")] pub program: ::prost::alloc::vec::Vec, + /// Reference metadata copied unchanged to the compiled deq.bin.Library. + #[prost(message, optional, tag = "6")] + pub metadata: ::core::option::Option<::prost_types::Struct>, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct UnloadJitLibrary { diff --git a/deq/proto/deq_jit.proto b/deq/proto/deq_jit.proto index 63b53528..94710758 100644 --- a/deq/proto/deq_jit.proto +++ b/deq/proto/deq_jit.proto @@ -14,6 +14,7 @@ message JitLibrary { repeated JitGadgetType gadget_types = 2; repeated JitPortType port_types = 3; repeated JitInstruction program = 5; + // Reference metadata copied unchanged to the compiled deq.bin.Library. google.protobuf.Struct metadata = 6; } From 65a5f3bb9ccb3a6717e78a286f3dc79121da9d93 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 15 Aug 2026 19:47:35 -0700 Subject: [PATCH 098/157] update protobuf and tests --- deq/deq/transpiler/loss/api.py | 114 +++++++++++++----- deq/deq/transpiler/loss/model_neutral_atom.py | 12 +- deq/deq/transpiler/loss/model_trapped_ion.py | 14 +-- deq/deq_runtime/src/simulator/qdk_sampler.py | 18 ++- deq/deq_runtime/tests/common/test_library.rs | 1 + deq/deq_runtime/tests/jit_compiler_test.rs | 2 + deq/deq_runtime/tests/jit_controller_test.rs | 1 + 7 files changed, 114 insertions(+), 48 deletions(-) diff --git a/deq/deq/transpiler/loss/api.py b/deq/deq/transpiler/loss/api.py index 3c7ab501..9306b8cd 100644 --- a/deq/deq/transpiler/loss/api.py +++ b/deq/deq/transpiler/loss/api.py @@ -2,7 +2,9 @@ from __future__ import annotations +import json from dataclasses import dataclass +from enum import StrEnum from typing import Protocol, runtime_checkable @@ -10,6 +12,78 @@ class UnsupportedLossModelError(ValueError): """Raised when a circuit is outside a loss model's supported scope.""" +class GateLossPolicy(StrEnum): + """QDK behavior for a gate with at least one lost operand.""" + + SKIP = "SKIP" + PROPAGATE = "PROPAGATE" + DEGRADE = "DEGRADE" + RESIDUAL_S_DAGGER = "RESIDUAL_S_DAGGER" + APPLY_ANYWAY = "APPLY_ANYWAY" + + +@dataclass(frozen=True) +class QdkLossConfig: + """JSON-safe QDK policy overrides keyed by gate name.""" + + gate_policies: tuple[tuple[str, str], ...] + + def __post_init__(self) -> None: + normalized: list[tuple[str, str]] = [] + seen: set[str] = set() + for raw_gate, raw_policy in self.gate_policies: + gate = raw_gate.lower() + policy = str(raw_policy) + if gate in seen: + raise ValueError(f"duplicate QDK loss-policy override for {gate!r}") + seen.add(gate) + normalized.append((gate, policy)) + object.__setattr__(self, "gate_policies", tuple(sorted(normalized))) + + def policy_for(self, gate: str) -> str: + """Return the configured policy for a QDK multi-qubit gate.""" + + normalized = gate.lower() + try: + return dict(self.gate_policies)[normalized] + except KeyError: + raise ValueError( + f"no QDK loss policy configured for gate {normalized!r}" + ) from None + + def to_json_object(self) -> dict[str, str]: + """Return the canonical JSON-compatible representation.""" + + return dict(self.gate_policies) + + def to_json(self) -> str: + """Serialize this configuration deterministically.""" + + return json.dumps(self.to_json_object(), sort_keys=True, separators=(",", ":")) + + @classmethod + def from_json(cls, value: str) -> QdkLossConfig: + """Parse and validate a serialized configuration.""" + + try: + parsed = json.loads(value) + except json.JSONDecodeError as error: + raise ValueError(f"invalid QDK loss config JSON: {error}") from error + return cls.from_json_object(parsed) + + @classmethod + def from_json_object(cls, value: object) -> QdkLossConfig: + """Parse and validate a JSON-compatible representation.""" + + if not isinstance(value, dict): + raise ValueError("QDK loss config must be a JSON object") + return cls( + gate_policies=tuple( + (str(gate), str(policy)) for gate, policy in value.items() + ), + ) + + @dataclass(frozen=True) class LossGate: """One gate occurrence passed to a loss-model handler. @@ -29,7 +103,7 @@ class LossGate: boundary_after: int produces_measurement: bool resets_qubits: bool - is_native: bool + is_source_gate: bool @runtime_checkable @@ -107,46 +181,20 @@ def swap_losses( ... + @runtime_checkable class LossGateHandler(Protocol): """Stateful per-gadget handler that receives one gate at a time.""" - native_gate_names: frozenset[str] + source_gate_names: frozenset[str] - def handle_loss_source( - self, event_id: int, state: LossAnalysisState - ) -> None: + def handle_loss_source(self, event_id: int, state: LossAnalysisState) -> None: """Handle a newly created physical loss event.""" ... - def handle_h(self, gate: LossGate, state: LossAnalysisState) -> None: - """Handle one primitive Hadamard occurrence.""" - - ... - - def handle_s(self, gate: LossGate, state: LossAnalysisState) -> None: - """Handle one primitive square-root-of-Z occurrence.""" - - ... - - def handle_cx(self, gate: LossGate, state: LossAnalysisState) -> None: - """Handle one primitive controlled-X occurrence.""" - - ... - - def handle_m(self, gate: LossGate, state: LossAnalysisState) -> None: - """Handle one primitive Z-measurement occurrence.""" - - ... - - def handle_r(self, gate: LossGate, state: LossAnalysisState) -> None: - """Handle one primitive Z-reset occurrence.""" - - ... - - def handle_native_gate(self, gate: LossGate, state: LossAnalysisState) -> None: - """Handle an opted-in source gate without Stim decomposition.""" + def handle_gate(self, gate: LossGate, state: LossAnalysisState) -> None: + """Handle a source-level or decomposed primitive gate.""" ... @@ -155,6 +203,8 @@ def handle_native_gate(self, gate: LossGate, state: LossAnalysisState) -> None: class LossModel(Protocol): """Configured physical loss model shared across gadget analyses.""" + config: QdkLossConfig + def create_handler(self) -> LossGateHandler: """Create fresh mutable handler state for one gadget traversal.""" diff --git a/deq/deq/transpiler/loss/model_neutral_atom.py b/deq/deq/transpiler/loss/model_neutral_atom.py index e4c3ab16..8ebcd50e 100644 --- a/deq/deq/transpiler/loss/model_neutral_atom.py +++ b/deq/deq/transpiler/loss/model_neutral_atom.py @@ -9,7 +9,7 @@ ) from deq.transpiler.loss.model_configured import ConfiguredLossGateHandler -_NEUTRAL_ATOM_QDK_CONFIG = QdkLossConfig( +_NEUTRAL_ATOM_CONFIG = QdkLossConfig( gate_policies=( ("cx", GateLossPolicy.SKIP), ("cy", GateLossPolicy.SKIP), @@ -22,18 +22,16 @@ class NeutralAtomLossGateHandler(ConfiguredLossGateHandler): """Use skipped lost-operand gates with physical SWAP relocation.""" - def __init__(self) -> None: - super().__init__(_NEUTRAL_ATOM_QDK_CONFIG) + def __init__(self, config: QdkLossConfig = _NEUTRAL_ATOM_CONFIG) -> None: + super().__init__(config) class NeutralAtomLossModel: """Neutral-atom platform model: SKIP gates and relocate atoms on SWAP.""" - name = "neutral-atom" - source_config = _NEUTRAL_ATOM_QDK_CONFIG - qdk_config = _NEUTRAL_ATOM_QDK_CONFIG + config = _NEUTRAL_ATOM_CONFIG def create_handler(self) -> LossGateHandler: """Create independent state for one gadget traversal.""" - return NeutralAtomLossGateHandler() + return NeutralAtomLossGateHandler(self.config) diff --git a/deq/deq/transpiler/loss/model_trapped_ion.py b/deq/deq/transpiler/loss/model_trapped_ion.py index 73a28f92..9ef4438b 100644 --- a/deq/deq/transpiler/loss/model_trapped_ion.py +++ b/deq/deq/transpiler/loss/model_trapped_ion.py @@ -6,7 +6,7 @@ from deq.transpiler.loss.model_configured import ConfiguredLossGateHandler -_TRAPPED_ION_QDK_CONFIG = QdkLossConfig( +_TRAPPED_ION_CONFIG = QdkLossConfig( gate_policies=( ("cx", GateLossPolicy.RESIDUAL_S_DAGGER), ("cy", GateLossPolicy.RESIDUAL_S_DAGGER), @@ -19,18 +19,16 @@ class TrappedIonLossGateHandler(ConfiguredLossGateHandler): """Retain the local S-dagger fixup when loss removes the MS interaction.""" - def __init__(self) -> None: - super().__init__(_TRAPPED_ION_QDK_CONFIG) + def __init__(self, config: QdkLossConfig = _TRAPPED_ION_CONFIG) -> None: + super().__init__(config) class TrappedIonLossModel: - """CX-native trapped ions whose lost-operand interaction leaves S-dagger.""" + """Trapped-ion controlled gates whose lost interaction leaves S-dagger.""" - name = "trapped-ion" - source_config = _TRAPPED_ION_QDK_CONFIG - qdk_config = _TRAPPED_ION_QDK_CONFIG + config = _TRAPPED_ION_CONFIG def create_handler(self) -> LossGateHandler: """Create independent state for one gadget traversal.""" - return TrappedIonLossGateHandler() + return TrappedIonLossGateHandler(self.config) diff --git a/deq/deq_runtime/src/simulator/qdk_sampler.py b/deq/deq_runtime/src/simulator/qdk_sampler.py index 20addafb..4f0c3343 100644 --- a/deq/deq_runtime/src/simulator/qdk_sampler.py +++ b/deq/deq_runtime/src/simulator/qdk_sampler.py @@ -27,6 +27,9 @@ def sample(self) -> str: call. Larger values amortize Python call overhead at the cost of memory. * ``type`` (default: ``"clifford"``): forwarded to ``qdk.stim.run``. Use ``"cpu"`` for non-Clifford circuits. +* ``loss_config``: explicit QDK gate-to-policy overrides from the selected + platform model. Unlisted gates retain QDK's own defaults. ``deq simulate + ler`` supplies this automatically. Invocation options ------------------ @@ -65,11 +68,22 @@ def sample(self) -> str: import qdk.stim from qdk._native import Result -from qdk.simulation import run_qir +from qdk.simulation import LossPolicy, run_qir _QDK_SEED_MASK = (1 << 32) - 1 +def _configure_loss(noise: Any, config: Any) -> None: + if config is None: + return + if not isinstance(config, dict): + raise ValueError("loss_config must be a JSON object") + for gate_name, policy_name in config.items(): + table = getattr(noise, gate_name) + policy = getattr(LossPolicy, policy_name) + table.on_loss = policy + + def _to_qdk_seed(seed: int) -> int: """Narrow a seed to the unsigned 32-bit range accepted by QDK.""" return seed & _QDK_SEED_MASK @@ -109,7 +123,9 @@ def __init__(self, circuit_text: str, config: Dict[str, Any]): raise ValueError(f"batch_size must be positive, got {self._batch_size}") # Compile once so every refill reuses the same QIR + NoiseConfig. + loss_config = config.get("loss_config") qir, noise = qdk.stim.compile(self._src, None) + _configure_loss(noise, loss_config) self._qir = qir self._noise = noise diff --git a/deq/deq_runtime/tests/common/test_library.rs b/deq/deq_runtime/tests/common/test_library.rs index 1db3709c..853d9609 100644 --- a/deq/deq_runtime/tests/common/test_library.rs +++ b/deq/deq_runtime/tests/common/test_library.rs @@ -426,5 +426,6 @@ pub fn test_jit_library() -> jit::JitLibrary { }, ], program: vec![], + metadata: None, } } diff --git a/deq/deq_runtime/tests/jit_compiler_test.rs b/deq/deq_runtime/tests/jit_compiler_test.rs index ceb8a7dd..e9042330 100644 --- a/deq/deq_runtime/tests/jit_compiler_test.rs +++ b/deq/deq_runtime/tests/jit_compiler_test.rs @@ -269,6 +269,7 @@ fn basic_jit_library() -> jit::JitLibrary { }, ], program: vec![], + metadata: None, } } @@ -646,6 +647,7 @@ fn check_propagation_jit_library() -> jit::JitLibrary { }, ], program: vec![], + metadata: None, } } diff --git a/deq/deq_runtime/tests/jit_controller_test.rs b/deq/deq_runtime/tests/jit_controller_test.rs index 05798c72..a9cc3b83 100644 --- a/deq/deq_runtime/tests/jit_controller_test.rs +++ b/deq/deq_runtime/tests/jit_controller_test.rs @@ -188,6 +188,7 @@ fn basic_jit_library() -> jit::JitLibrary { }, ], program: vec![], + metadata: None, } } From cdc704fde243c6b5fde2ce643a63426051457d8c Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 15 Aug 2026 19:51:09 -0700 Subject: [PATCH 099/157] remove decoder gRPC interface --- deq/deq_runtime/Cargo.toml | 1 + deq/deq_runtime/src/jit.rs | 26 ++++++++++++++++++++++++++ deq/deq_runtime/src/server.rs | 22 ++++++---------------- 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/deq/deq_runtime/Cargo.toml b/deq/deq_runtime/Cargo.toml index c668fc85..c407ca0b 100644 --- a/deq/deq_runtime/Cargo.toml +++ b/deq/deq_runtime/Cargo.toml @@ -91,6 +91,7 @@ tonic = { version = "0.14.2", default-features = false, features = [ "server", ] } prost = "0.14.1" +prost-types = "0.14.1" tonic-prost = { version = "0.14.2", default-features = false } tokio = { version = "1.48.0", features = ["rt-multi-thread", "time", "macros"] } tokio-util = "0.7" diff --git a/deq/deq_runtime/src/jit.rs b/deq/deq_runtime/src/jit.rs index 43a4e5aa..ff66a996 100644 --- a/deq/deq_runtime/src/jit.rs +++ b/deq/deq_runtime/src/jit.rs @@ -12,6 +12,7 @@ pub async fn static_jit_compile(mut jit_library: JitLibrary) -> bin::Library { let token = CancellationToken::new(); // copy the port types and gadget types from the JIT library let mut library = bin::Library::default(); + library.metadata = jit_library.metadata.clone(); for port_type in jit_library.port_types.iter() { library.port_types.push(port_type.base.as_ref().unwrap().clone()); } @@ -251,3 +252,28 @@ pub fn py_static_jit_compile(py: pyo3::Python<'_>, jit_library: Vec) -> pyo3 library.encode(&mut buf).unwrap(); Ok(buf) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn static_compile_preserves_metadata() { + let metadata = prost_types::Struct { + fields: [( + "source".to_string(), + prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue("unit-test".to_string())), + }, + )] + .into(), + }; + let library = static_jit_compile(JitLibrary { + metadata: Some(metadata.clone()), + ..Default::default() + }) + .await; + + assert_eq!(library.metadata, Some(metadata)); + } +} diff --git a/deq/deq_runtime/src/server.rs b/deq/deq_runtime/src/server.rs index 8a6c950f..70a20d7a 100644 --- a/deq/deq_runtime/src/server.rs +++ b/deq/deq_runtime/src/server.rs @@ -41,8 +41,6 @@ pub struct ServerConfigs { help = coordinator::CoordinatorType::config_help() )] pub coordinator_config: serde_json::Value, - #[clap(long, default_value_t = false)] - pub coordinator_use_remote_client: bool, /// the type of the controller (optional) #[clap(long, value_enum, default_value_t = controller::ControllerType::None)] pub controller: controller::ControllerType, @@ -108,11 +106,8 @@ impl ServerConfigs { // add the decoder service let decoder = self.decoder.create(self.decoder_config); let router = decoder.add_service(router); - let black_box_decoder = decoder - .as_black_box_decoder_client(self.coordinator_use_remote_client.then_some(&endpoint)) - .await; // add coordinator service - let coordinator = self.coordinator.create(self.coordinator_config.clone(), black_box_decoder); + let coordinator = self.coordinator.create(self.coordinator_config.clone(), decoder.clone()); let router = coordinator.add_service(router); coordinator.start().await; // create the controller @@ -138,14 +133,12 @@ impl ServerConfigs { } /// Build an in-process [`LocalServer`] from this config without binding to - /// a network address. Always uses Local clients between coordinator and - /// decoder (the `*_use_remote_client` flags are ignored — in-process callers - /// have no reason to pay gRPC overhead). Use [`LocalServer::bind_grpc`] to - /// optionally expose a network endpoint on top. + /// a network address. The `controller_use_remote_client` flag is ignored; + /// in-process callers have no reason to pay gRPC overhead. Use + /// [`LocalServer::bind_grpc`] to optionally expose a network endpoint on top. pub async fn build_local(self) -> Arc { let decoder = self.decoder.create(self.decoder_config); - let black_box_decoder = decoder.as_black_box_decoder_client(None).await; - let coordinator = self.coordinator.create(self.coordinator_config, black_box_decoder); + let coordinator = self.coordinator.create(self.coordinator_config, decoder.clone()); coordinator.start().await; let controller = self.controller.create(self.controller_config); let coordinator_client = CoordinatorClient::Local(coordinator.clone()); @@ -196,10 +189,7 @@ impl LocalServer { CoordinatorClient::Local(self.coordinator.clone()) } - /// Access the underlying decoder (used internally; mostly here so we keep - /// the field alive — `DynDecoder` does not currently expose a stand-alone - /// "Local" client wrapper because the coordinator owns it through - /// `BlackBoxDecoderClient`). + /// Access the underlying decoder. pub fn decoder(&self) -> &decoder::DynDecoder { &self.decoder } From 23d8adc1a30e33dad86fe45738ffb0af20efbfee Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 15 Aug 2026 19:58:09 -0700 Subject: [PATCH 100/157] remove need for model_reconfigured --- deq/deq/transpiler/loss/model_neutral_atom.py | 48 +++++++++++++- deq/deq/transpiler/loss/model_trapped_ion.py | 62 ++++++++++++++++--- 2 files changed, 99 insertions(+), 11 deletions(-) diff --git a/deq/deq/transpiler/loss/model_neutral_atom.py b/deq/deq/transpiler/loss/model_neutral_atom.py index 8ebcd50e..5f9003da 100644 --- a/deq/deq/transpiler/loss/model_neutral_atom.py +++ b/deq/deq/transpiler/loss/model_neutral_atom.py @@ -4,10 +4,25 @@ from deq.transpiler.loss.api import ( GateLossPolicy, + LossAnalysisState, + LossGate, LossGateHandler, QdkLossConfig, ) -from deq.transpiler.loss.model_configured import ConfiguredLossGateHandler +from deq.transpiler.loss.policies import ( + handle_gate_policy, + handle_loss_source, + handle_measurement, + handle_reset, + handle_skip, +) + +_QDK_TABLE_BY_SOURCE_GATE = { + "CX": "cx", + "CY": "cy", + "CZ": "cz", + "SWAP": "swap", +} _NEUTRAL_ATOM_CONFIG = QdkLossConfig( gate_policies=( @@ -19,11 +34,38 @@ ) -class NeutralAtomLossGateHandler(ConfiguredLossGateHandler): +class NeutralAtomLossGateHandler(LossGateHandler): """Use skipped lost-operand gates with physical SWAP relocation.""" + source_gate_names = frozenset( + { + *_QDK_TABLE_BY_SOURCE_GATE, + "S", + "SQRT_X", + "SQRT_X_DAG", + } + ) + def __init__(self, config: QdkLossConfig = _NEUTRAL_ATOM_CONFIG) -> None: - super().__init__(config) + self.config = config + + def handle_loss_source(self, event_id: int, state: LossAnalysisState) -> None: + handle_loss_source(event_id, state) + + def handle_gate(self, gate: LossGate, state: LossAnalysisState) -> None: + if gate.name == "M": + handle_measurement(gate, state) + return + if gate.name == "R": + handle_reset(gate, state) + return + qdk_table = _QDK_TABLE_BY_SOURCE_GATE.get(gate.name) + if qdk_table is not None: + handle_gate_policy( + GateLossPolicy(self.config.policy_for(qdk_table)), gate, state + ) + return + handle_skip(gate, state) class NeutralAtomLossModel: diff --git a/deq/deq/transpiler/loss/model_trapped_ion.py b/deq/deq/transpiler/loss/model_trapped_ion.py index 9ef4438b..815e8361 100644 --- a/deq/deq/transpiler/loss/model_trapped_ion.py +++ b/deq/deq/transpiler/loss/model_trapped_ion.py @@ -1,9 +1,28 @@ -"""Trapped-ion loss model for Mølmer-Sørensen-native hardware.""" +"""Residual-phase approximation for MS-native trapped-ion hardware.""" from __future__ import annotations -from deq.transpiler.loss.api import GateLossPolicy, LossGateHandler, QdkLossConfig -from deq.transpiler.loss.model_configured import ConfiguredLossGateHandler +from deq.transpiler.loss.api import ( + GateLossPolicy, + LossAnalysisState, + LossGate, + LossGateHandler, + QdkLossConfig, +) +from deq.transpiler.loss.policies import ( + handle_gate_policy, + handle_loss_source, + handle_measurement, + handle_reset, + handle_skip, +) + +_QDK_TABLE_BY_SOURCE_GATE = { + "CX": "cx", + "CY": "cy", + "CZ": "cz", + "SWAP": "swap", +} _TRAPPED_ION_CONFIG = QdkLossConfig( @@ -11,20 +30,47 @@ ("cx", GateLossPolicy.RESIDUAL_S_DAGGER), ("cy", GateLossPolicy.RESIDUAL_S_DAGGER), ("cz", GateLossPolicy.RESIDUAL_S_DAGGER), - ("swap", GateLossPolicy.SKIP), + ("swap", GateLossPolicy.APPLY_ANYWAY), ), ) -class TrappedIonLossGateHandler(ConfiguredLossGateHandler): - """Retain the local S-dagger fixup when loss removes the MS interaction.""" +class TrappedIonLossGateHandler(LossGateHandler): + """Apply QDK's residual-S-dagger policy to controlled gates.""" + + source_gate_names = frozenset( + { + *_QDK_TABLE_BY_SOURCE_GATE, + "S", + "SQRT_X", + "SQRT_X_DAG", + } + ) def __init__(self, config: QdkLossConfig = _TRAPPED_ION_CONFIG) -> None: - super().__init__(config) + self.config = config + + def handle_loss_source(self, event_id: int, state: LossAnalysisState) -> None: + handle_loss_source(event_id, state) + + def handle_gate(self, gate: LossGate, state: LossAnalysisState) -> None: + if gate.name == "M": + handle_measurement(gate, state) + return + if gate.name == "R": + handle_reset(gate, state) + return + qdk_table = _QDK_TABLE_BY_SOURCE_GATE.get(gate.name) + if qdk_table is not None: + handle_gate_policy( + GateLossPolicy(self.config.policy_for(qdk_table)), gate, state + ) + return + handle_skip(gate, state) class TrappedIonLossModel: - """Trapped-ion controlled gates whose lost interaction leaves S-dagger.""" + """Effective trapped-ion model with residual phase on surviving operands.""" config = _TRAPPED_ION_CONFIG From 42a74d540209301e9e42d80ac80cb494891b7dee Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 15 Aug 2026 20:32:40 -0700 Subject: [PATCH 101/157] support user provided loss model .py and json config --- deq/deq/cli/annotate.py | 2 +- deq/deq/cli/jit.py | 2 +- deq/deq/cli/sample.py | 4 +- deq/deq/cli/simulate.py | 25 ++- deq/deq/transpiler/loss/__init__.py | 147 +++++++++++++++++ deq/deq/transpiler/loss/model_neutral_atom.py | 6 + deq/deq/transpiler/loss/model_trapped_ion.py | 6 + deq/deq/transpiler/loss/policies.py | 153 ++++++++++++++++++ 8 files changed, 336 insertions(+), 9 deletions(-) create mode 100644 deq/deq/transpiler/loss/__init__.py create mode 100644 deq/deq/transpiler/loss/policies.py diff --git a/deq/deq/cli/annotate.py b/deq/deq/cli/annotate.py index 64b8c7bc..d452c6fc 100644 --- a/deq/deq/cli/annotate.py +++ b/deq/deq/cli/annotate.py @@ -24,7 +24,7 @@ def annotate( mako: list[str] | None = None, #: suppress the interactive Mako safety prompt skip_mako_warning: bool = False, - #: physical loss model: "neutral-atom" or "trapped-ion" + #: physical loss model: "neutral-atom", "trapped-ion", or a .py file loss_model: str = "neutral-atom", #: skip verification that annotated output transpiles identically no_verify: bool = False, diff --git a/deq/deq/cli/jit.py b/deq/deq/cli/jit.py index ced35bc4..d7c3355b 100644 --- a/deq/deq/cli/jit.py +++ b/deq/deq/cli/jit.py @@ -26,7 +26,7 @@ def transpile( #: number of parallel worker processes for GADGET type construction; #: defaults to: (logical CPU count - 2), minimum 1 jobs: int = max((os.cpu_count() or 1) - 2, 1), - #: physical loss model: "neutral-atom" or "trapped-ion" + #: physical loss model: "neutral-atom", "trapped-ion", or a .py file loss_model: str = "neutral-atom", #: register an external check plugin from a .py file (makes the #: file's stem name available as a @CHECKS("name") value) diff --git a/deq/deq/cli/sample.py b/deq/deq/cli/sample.py index de8e88a2..e88238b8 100644 --- a/deq/deq/cli/sample.py +++ b/deq/deq/cli/sample.py @@ -305,8 +305,8 @@ def sample( mako: list[str] | None = None, #: suppress the interactive Mako safety prompt skip_mako_warning: bool = False, - #: physical loss model for .deq input: "neutral-atom" or "trapped-ion"; - #: cannot be combined with --jit + #: physical loss model for .deq input: built-in name or .py file; cannot + #: be combined with --jit loss_model: str | None = None, ) -> list[str] | list[tuple[str, str]]: """Sample measurement outcomes from a .stim circuit or .deq source. diff --git a/deq/deq/cli/simulate.py b/deq/deq/cli/simulate.py index 32126e11..67e52937 100644 --- a/deq/deq/cli/simulate.py +++ b/deq/deq/cli/simulate.py @@ -30,6 +30,7 @@ GadgetDefinition, ProgramDefinition, ) +from deq.transpiler.loss.api import QdkLossConfig # --------------------------------------------------------------------------- # Helpers @@ -92,9 +93,12 @@ def simulate__ler( debug_dir: str | None = None, jobs: int = max((os.cpu_count() or 1) - 2, 1), jit: str | None = None, - #: physical loss model used when building from source: "neutral-atom" or - #: "trapped-ion"; with --jit, any stored loss config must match + #: decoder loss model: "neutral-atom", "trapped-ion", or a .py file; + #: with --jit, any stored loss config must match loss_model: str | None = None, + #: JSON object of QDK per-gate loss policies; overrides the decoder model's + #: simulation config when provided + simulation_loss_model: str | None = None, #: Override the auto-generated .stim file (for debugging) stim: str | None = None, #: Mako variable definitions, each as key=value @@ -151,6 +155,9 @@ def simulate__ler( jobs: Number of parallel worker processes. jit: path to a pre-compiled ``.deq.jit`` file to skip transpilation. debug_dir: Directory to dump intermediate files for inspection. + loss_model: Built-in decoder loss-model name or path to a Python model. + simulation_loss_model: Optional QDK-only JSON config override. When + omitted, QDK sampling uses the decoder loss model's configuration. """ import tempfile import shutil @@ -169,6 +176,11 @@ def simulate__ler( if not deq_files: raise ValueError("At least one .deq file is required") + simulation_loss_config = ( + QdkLossConfig.from_json(simulation_loss_model) + if simulation_loss_model is not None + else None + ) selected_loss_model = ( create_loss_model(loss_model or "neutral-atom") if jit is None else None ) @@ -233,6 +245,10 @@ def simulate__ler( loss_model=selected_loss_model, ) + assert selected_loss_config is not None + if simulation_loss_config is None: + simulation_loss_config = selected_loss_config + # Compile program into JIT instructions print("Compiling program...") compiled, assertions = compile_program_for_jit( @@ -318,7 +334,6 @@ def simulate__ler( with ProcessPoolExecutor(max_workers=jobs) as pool: futures = {} - assert selected_loss_config is not None def _submit_batch() -> bool: """Submit one batch if budget remains. Returns True if submitted.""" @@ -348,7 +363,7 @@ def _submit_batch() -> bool: seed=next_seed, debug_dir=debug_dir, simulator=simulator, - loss_config=selected_loss_config.to_json_object(), + loss_config=simulation_loss_config.to_json_object(), ) futures[fut] = (this_batch,) if next_seed is not None: @@ -426,7 +441,7 @@ def _resolve_jit_loss_config(jit_library, requested_name: str | None): ): raise ValueError( f"--loss-model {requested_name!r} does not match precompiled JIT " - f"config; remove the parameter or change it to {stored_config.name!r}" + "config; remove the parameter or rebuild the JIT library with that model" ) return stored_config diff --git a/deq/deq/transpiler/loss/__init__.py b/deq/deq/transpiler/loss/__init__.py new file mode 100644 index 00000000..5884a7f0 --- /dev/null +++ b/deq/deq/transpiler/loss/__init__.py @@ -0,0 +1,147 @@ +"""Physical loss-event analysis and built-in platform models. + +Loss analysis discovers source events and their propagated physical branches. +Projection of the resulting Pauli generators into decoder error rows is owned +by :mod:`deq.transpiler.loss.transpiler`. +""" + +import hashlib +import importlib.util +import sys +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path + +from deq.transpiler.loss.analysis import ( + analyze_loss_events, + analyze_loss_events_with_ports, +) +from deq.transpiler.loss.api import ( + GateLossPolicy, + LossAnalysisState, + LossGate, + LossGateHandler, + LossModel, + QdkLossConfig, + UnsupportedLossModelError, +) +from deq.transpiler.loss.model_neutral_atom import ( + NeutralAtomLossGateHandler, + NeutralAtomLossModel, +) +from deq.transpiler.loss.model_trapped_ion import ( + TrappedIonLossGateHandler, + TrappedIonLossModel, +) +from deq.transpiler.loss.ir import ( + LossBranch, + LossEvent, + LossEventGraph, + PauliInsertion, + build_loss_event_graph, +) + +LOSS_MODEL_NAMES = ("neutral-atom", "trapped-ion") + + +@lru_cache(maxsize=None) +def _load_loss_model_file(path: str) -> LossModel: + """Load and validate the model returned by a Python plugin file.""" + + plugin_path = Path(path) + digest = hashlib.sha256(path.encode()).hexdigest()[:16] + module_name = f"deq_loss_model_{plugin_path.stem}_{digest}" + spec = importlib.util.spec_from_file_location(module_name, plugin_path) + if spec is None or spec.loader is None: + raise ValueError(f"cannot load loss model from {plugin_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + factory = getattr(module, "create_loss_model", None) + if not callable(factory): + raise ValueError( + f"loss model file {plugin_path} does not define a callable " + "create_loss_model()" + ) + model = factory() + if not isinstance(model, LossModel): + raise ValueError( + f"create_loss_model() in {plugin_path} did not return a LossModel" + ) + if not isinstance(model.config, QdkLossConfig): + raise ValueError( + f"loss model from {plugin_path} has config of type " + f"{type(model.config).__name__}, expected QdkLossConfig" + ) + return model + + +@dataclass(frozen=True) +class _FileLossModel: + """Pickle-safe proxy that reloads a user model in worker processes.""" + + path: str + config: QdkLossConfig + + def create_handler(self) -> LossGateHandler: + """Create a handler from the model loaded in this process.""" + + model = _load_loss_model_file(self.path) + if model.config != self.config: + raise ValueError(f"loss model file changed after loading: {self.path}") + handler = model.create_handler() + if not isinstance(handler, LossGateHandler): + raise ValueError( + f"loss model from {self.path} did not create a LossGateHandler" + ) + return handler + + +def create_loss_model(selector: str | Path) -> LossModel: + """Create a built-in model by name or load one from a Python file.""" + + constructors = { + "neutral-atom": NeutralAtomLossModel, + "trapped-ion": TrappedIonLossModel, + } + value = str(selector) + if value in constructors: + return constructors[value]() + + path = Path(value).expanduser() + if path.suffix.lower() == ".py": + if not path.is_file(): + raise ValueError(f"loss model file does not exist: {path}") + resolved = str(path.resolve()) + model = _load_loss_model_file(resolved) + return _FileLossModel(path=resolved, config=model.config) + + supported = ", ".join(LOSS_MODEL_NAMES) + raise ValueError( + f"unknown loss model {value!r}; expected one of: {supported}, " + "or a path to a .py file" + ) + + +__all__ = [ + "NeutralAtomLossModel", + "NeutralAtomLossGateHandler", + "TrappedIonLossModel", + "TrappedIonLossGateHandler", + "GateLossPolicy", + "QdkLossConfig", + "LossBranch", + "LossEvent", + "LossEventGraph", + "LossAnalysisState", + "LossGate", + "LossGateHandler", + "LossModel", + "LOSS_MODEL_NAMES", + "PauliInsertion", + "UnsupportedLossModelError", + "analyze_loss_events", + "analyze_loss_events_with_ports", + "build_loss_event_graph", + "create_loss_model", +] diff --git a/deq/deq/transpiler/loss/model_neutral_atom.py b/deq/deq/transpiler/loss/model_neutral_atom.py index 5f9003da..888a02d6 100644 --- a/deq/deq/transpiler/loss/model_neutral_atom.py +++ b/deq/deq/transpiler/loss/model_neutral_atom.py @@ -77,3 +77,9 @@ def create_handler(self) -> LossGateHandler: """Create independent state for one gadget traversal.""" return NeutralAtomLossGateHandler(self.config) + + +def create_loss_model() -> NeutralAtomLossModel: + """Create this model when the module is loaded as a plugin file.""" + + return NeutralAtomLossModel() diff --git a/deq/deq/transpiler/loss/model_trapped_ion.py b/deq/deq/transpiler/loss/model_trapped_ion.py index 815e8361..7e2636c0 100644 --- a/deq/deq/transpiler/loss/model_trapped_ion.py +++ b/deq/deq/transpiler/loss/model_trapped_ion.py @@ -78,3 +78,9 @@ def create_handler(self) -> LossGateHandler: """Create independent state for one gadget traversal.""" return TrappedIonLossGateHandler(self.config) + + +def create_loss_model() -> TrappedIonLossModel: + """Create this model when the module is loaded as a plugin file.""" + + return TrappedIonLossModel() diff --git a/deq/deq/transpiler/loss/policies.py b/deq/deq/transpiler/loss/policies.py new file mode 100644 index 00000000..ee54f54f --- /dev/null +++ b/deq/deq/transpiler/loss/policies.py @@ -0,0 +1,153 @@ +"""Reusable gate-level mechanics for physical loss models.""" + +from __future__ import annotations + +from deq.transpiler.loss.api import ( + GateLossPolicy, + LossAnalysisState, + LossGate, + UnsupportedLossModelError, +) + + +def _has_lost_operand(gate: LossGate, state: LossAnalysisState) -> bool: + return any(state.active_event_ids(qubit) for qubit in gate.qubits) + + +def handle_loss_source(event_id: int, state: LossAnalysisState) -> None: + """Model the reset accompanying physical loss as a full Pauli envelope.""" + + state.add_source_pauli_insertion(event_id) + + +def handle_measurement(gate: LossGate, state: LossAnalysisState) -> None: + """Record a loss herald and the unresolved value of a lost measurement.""" + + if len(gate.qubits) != 1 or len(gate.measurement_indices) != 1: + raise UnsupportedLossModelError( + f"M at body index {gate.body_index} requires one qubit and result" + ) + qubit = gate.qubits[0] + state.add_continuation_pauli_insertion(qubit, gate.boundary_before) + state.record_loss_measurement(qubit, gate.measurement_indices[0]) + + +def handle_reset(gate: LossGate, state: LossAnalysisState) -> None: + """Reload every reset qubit, terminating its active loss branches.""" + + for qubit in gate.qubits: + state.clear_loss(qubit) + + +def handle_skip(gate: LossGate, state: LossAnalysisState) -> None: + """Apply the exact SKIP envelope rules supported by the current loss IR.""" + + if not _has_lost_operand(gate, state): + return + if gate.name == "H": + for qubit in gate.qubits: + state.add_continuation_pauli_insertion(qubit, gate.boundary_after) + return + if gate.name in {"S", "CZ"}: + return + if gate.name in {"SQRT_X", "SQRT_X_DAG"}: + for qubit in gate.qubits: + state.add_continuation_pauli_insertion(qubit, gate.boundary_after) + return + if gate.name == "CX": + control, target = gate.qubits + for event_id in state.active_event_ids(target): + state.add_event_continuation_pauli_insertion( + event_id, + lost_qubit=target, + error_qubit=target, + boundary=gate.boundary_after, + ) + if not state.event_has_active_loss(event_id, control): + state.add_event_continuation_pauli_insertion( + event_id, + lost_qubit=target, + error_qubit=control, + boundary=gate.boundary_after, + paulis=("I", "X"), + ) + return + raise UnsupportedLossModelError( + f"exact SKIP envelope for gate {gate.source_name} is not implemented" + ) + + +def handle_propagate(gate: LossGate, state: LossAnalysisState) -> None: + """Propagate each event with a lost operand to every gate operand.""" + + active_sources: dict[int, int] = {} + for qubit in gate.qubits: + for event_id in state.active_event_ids(qubit): + active_sources.setdefault(event_id, qubit) + for event_id, lost_qubit in active_sources.items(): + for new_qubit in gate.qubits: + state.propagate_loss( + event_id, + lost_qubit=lost_qubit, + new_qubit=new_qubit, + boundary=gate.boundary_after, + ) + + +def handle_apply_anyway_swap(gate: LossGate, state: LossAnalysisState) -> None: + """Apply a physical SWAP by relocating each event's loss flag.""" + + if gate.name != "SWAP": + raise UnsupportedLossModelError( + f"QDK APPLY_ANYWAY is supported only for SWAP, not {gate.source_name}" + ) + first, second = gate.qubits + event_ids = set(state.active_event_ids(first)) | set(state.active_event_ids(second)) + for event_id in event_ids: + state.swap_losses(event_id, first, second, gate.boundary_after) + + +def handle_degrade(gate: LossGate, state: LossAnalysisState) -> None: + """Reject DEGRADE until conditional survivor unitaries are represented exactly.""" + + if not _has_lost_operand(gate, state): + return + raise UnsupportedLossModelError( + f"exact DEGRADE envelope for gate {gate.source_name} is not implemented" + ) + + +def handle_residual_s_dagger(gate: LossGate, state: LossAnalysisState) -> None: + """Add the ``{I, Z}`` Pauli envelope of S-dagger to each survivor.""" + + if not _has_lost_operand(gate, state): + return + active_sources: dict[int, int] = {} + for qubit in gate.qubits: + for event_id in state.active_event_ids(qubit): + active_sources.setdefault(event_id, qubit) + for event_id, lost_qubit in active_sources.items(): + for error_qubit in gate.qubits: + if not state.event_has_active_loss(event_id, error_qubit): + state.add_event_continuation_pauli_insertion( + event_id, + lost_qubit=lost_qubit, + error_qubit=error_qubit, + boundary=gate.boundary_after, + paulis=("I", "Z"), + ) + + +def handle_gate_policy( + policy: GateLossPolicy, gate: LossGate, state: LossAnalysisState +) -> None: + """Apply one QDK gate policy through the exact loss-analysis helpers.""" + + handlers = { + GateLossPolicy.SKIP: handle_skip, + GateLossPolicy.PROPAGATE: handle_propagate, + GateLossPolicy.DEGRADE: handle_degrade, + GateLossPolicy.RESIDUAL_S_DAGGER: handle_residual_s_dagger, + GateLossPolicy.APPLY_ANYWAY: handle_apply_anyway_swap, + } + handlers[policy](gate, state) From 4ce459f4e2a53e3fe4dd2a90cbafccec47ab6f38 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sat, 15 Aug 2026 20:56:15 -0700 Subject: [PATCH 102/157] update model --- deq/deq/transpiler/loss/model_neutral_atom.py | 7 ++++++- deq/deq/transpiler/loss/model_trapped_ion.py | 17 +++++++++++------ .../runtime/test_qdk_sampler_loss_model.py | 6 +++--- deq/tests/spec/canonical_test.py | 11 ++++------- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/deq/deq/transpiler/loss/model_neutral_atom.py b/deq/deq/transpiler/loss/model_neutral_atom.py index 888a02d6..b0fdb6d9 100644 --- a/deq/deq/transpiler/loss/model_neutral_atom.py +++ b/deq/deq/transpiler/loss/model_neutral_atom.py @@ -1,4 +1,9 @@ -"""Neutral-atom loss model matching the QDK simulator configuration.""" +"""Neutral-atom model for native CZ and its compiled CX/CY aliases. + +QDK lowers CX and CY to local gates around CZ. When an operand is absent, +skipping CZ leaves cancelling local wrappers, so all three source gates have the +same effective loss behavior. +""" from __future__ import annotations diff --git a/deq/deq/transpiler/loss/model_trapped_ion.py b/deq/deq/transpiler/loss/model_trapped_ion.py index 7e2636c0..443f5578 100644 --- a/deq/deq/transpiler/loss/model_trapped_ion.py +++ b/deq/deq/transpiler/loss/model_trapped_ion.py @@ -8,6 +8,7 @@ LossGate, LossGateHandler, QdkLossConfig, + UnsupportedLossModelError, ) from deq.transpiler.loss.policies import ( handle_gate_policy, @@ -18,17 +19,14 @@ ) _QDK_TABLE_BY_SOURCE_GATE = { - "CX": "cx", - "CY": "cy", "CZ": "cz", "SWAP": "swap", } +_UNSUPPORTED_CONTROLLED_GATES = frozenset({"CX", "CY"}) _TRAPPED_ION_CONFIG = QdkLossConfig( gate_policies=( - ("cx", GateLossPolicy.RESIDUAL_S_DAGGER), - ("cy", GateLossPolicy.RESIDUAL_S_DAGGER), ("cz", GateLossPolicy.RESIDUAL_S_DAGGER), ("swap", GateLossPolicy.APPLY_ANYWAY), ), @@ -36,11 +34,12 @@ class TrappedIonLossGateHandler(LossGateHandler): - """Apply QDK's residual-S-dagger policy to controlled gates.""" + """Apply one explicit compiled-CZ residual-phase approximation.""" source_gate_names = frozenset( { *_QDK_TABLE_BY_SOURCE_GATE, + *_UNSUPPORTED_CONTROLLED_GATES, "S", "SQRT_X", "SQRT_X_DAG", @@ -60,6 +59,12 @@ def handle_gate(self, gate: LossGate, state: LossAnalysisState) -> None: if gate.name == "R": handle_reset(gate, state) return + if gate.name in _UNSUPPORTED_CONTROLLED_GATES: + raise UnsupportedLossModelError( + "trapped-ion residual-phase model supports CZ only; " + f"{gate.source_name} requires an explicit device-specific " + "decomposition or custom loss model" + ) qdk_table = _QDK_TABLE_BY_SOURCE_GATE.get(gate.name) if qdk_table is not None: handle_gate_policy( @@ -70,7 +75,7 @@ def handle_gate(self, gate: LossGate, state: LossAnalysisState) -> None: class TrappedIonLossModel: - """Effective trapped-ion model with residual phase on surviving operands.""" + """Effective trapped-ion model for one specified CZ compilation.""" config = _TRAPPED_ION_CONFIG diff --git a/deq/tests/runtime/test_qdk_sampler_loss_model.py b/deq/tests/runtime/test_qdk_sampler_loss_model.py index 86929e25..9aa52250 100644 --- a/deq/tests/runtime/test_qdk_sampler_loss_model.py +++ b/deq/tests/runtime/test_qdk_sampler_loss_model.py @@ -47,7 +47,7 @@ def test_missing_config_leaves_qdk_defaults_unchanged() -> None: assert {gate: getattr(noise, gate).on_loss for gate in defaults} == defaults -def test_trapped_ion_config_leaves_s_dagger_after_lost_cx() -> None: +def test_trapped_ion_config_sets_only_supported_gate_policies() -> None: noise = NoiseConfig() _SAMPLER._configure_loss(noise, TrappedIonLossModel.config.to_json_object()) @@ -60,13 +60,13 @@ def test_trapped_ion_config_leaves_s_dagger_after_lost_cx() -> None: @pytest.mark.parametrize("lost_qubit", [0, 1]) -def test_trapped_ion_qdk_sampler_applies_cx_residual_s_dagger( +def test_trapped_ion_qdk_sampler_applies_cz_residual_s_dagger( lost_qubit: int, ) -> None: survivor = 1 - lost_qubit sampler = _SAMPLER.Sampler( f"H {survivor}\nS {survivor}\nLOSS_ERROR(1) {lost_qubit}\n" - f"CX 0 1\nH {survivor}\nM 0 1\n", + f"CZ 0 1\nH {survivor}\nM 0 1\n", { "seed": 7, "batch_size": 1, diff --git a/deq/tests/spec/canonical_test.py b/deq/tests/spec/canonical_test.py index ca20cb54..2a189334 100644 --- a/deq/tests/spec/canonical_test.py +++ b/deq/tests/spec/canonical_test.py @@ -89,13 +89,10 @@ def test_canonicalize_preserves_nested_metadata() -> None: lib = pb.Library( metadata={ "loss_strategy": { - "name": "neutral-atom", - "config": { - "cx": "SKIP", - "cy": "SKIP", - "cz": "SKIP", - "swap": "APPLY_ANYWAY", - }, + "cx": "SKIP", + "cy": "SKIP", + "cz": "SKIP", + "swap": "APPLY_ANYWAY", }, # Synthetic metadata verifies that unrelated nested values survive. "mock": {"nested": ["value"]}, From 513fc4ede98e04d1fcf861f83f7cca959ced1e7e Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 16 Aug 2026 11:53:05 -0700 Subject: [PATCH 103/157] fix CX,CY policy handling --- deq/deq/transpiler/loss/policies.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/deq/deq/transpiler/loss/policies.py b/deq/deq/transpiler/loss/policies.py index ee54f54f..73bd40c1 100644 --- a/deq/deq/transpiler/loss/policies.py +++ b/deq/deq/transpiler/loss/policies.py @@ -40,7 +40,7 @@ def handle_reset(gate: LossGate, state: LossAnalysisState) -> None: def handle_skip(gate: LossGate, state: LossAnalysisState) -> None: - """Apply the exact SKIP envelope rules supported by the current loss IR.""" + """Apply the exact SKIP envelope rules supported by the loss graph.""" if not _has_lost_operand(gate, state): return @@ -54,8 +54,8 @@ def handle_skip(gate: LossGate, state: LossAnalysisState) -> None: for qubit in gate.qubits: state.add_continuation_pauli_insertion(qubit, gate.boundary_after) return - if gate.name == "CX": - control, target = gate.qubits + if gate.name in {"CX", "CY"}: + _, target = gate.qubits for event_id in state.active_event_ids(target): state.add_event_continuation_pauli_insertion( event_id, @@ -63,14 +63,6 @@ def handle_skip(gate: LossGate, state: LossAnalysisState) -> None: error_qubit=target, boundary=gate.boundary_after, ) - if not state.event_has_active_loss(event_id, control): - state.add_event_continuation_pauli_insertion( - event_id, - lost_qubit=target, - error_qubit=control, - boundary=gate.boundary_after, - paulis=("I", "X"), - ) return raise UnsupportedLossModelError( f"exact SKIP envelope for gate {gate.source_name} is not implemented" From afee8cc958a1cee1aa5ab06de3f2613ddaeb182b Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 16 Aug 2026 11:53:36 -0700 Subject: [PATCH 104/157] rename --- deq/deq/transpiler/loss/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deq/deq/transpiler/loss/__init__.py b/deq/deq/transpiler/loss/__init__.py index 5884a7f0..915897f8 100644 --- a/deq/deq/transpiler/loss/__init__.py +++ b/deq/deq/transpiler/loss/__init__.py @@ -33,7 +33,7 @@ TrappedIonLossGateHandler, TrappedIonLossModel, ) -from deq.transpiler.loss.ir import ( +from deq.transpiler.loss.loss_graph import ( LossBranch, LossEvent, LossEventGraph, From 9b70dbb5e2ac70b83c2416aacd564371e866fddc Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 16 Aug 2026 12:13:36 -0700 Subject: [PATCH 105/157] change loss pauli to generator --- deq/deq/transpiler/loss/api.py | 12 ++++++------ deq/deq/transpiler/loss/policies.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/deq/deq/transpiler/loss/api.py b/deq/deq/transpiler/loss/api.py index 9306b8cd..654a9cb5 100644 --- a/deq/deq/transpiler/loss/api.py +++ b/deq/deq/transpiler/loss/api.py @@ -124,9 +124,9 @@ def add_continuation_pauli_insertion( self, qubit: int, boundary: int, - paulis: tuple[str, ...] = ("I", "X", "Y", "Z"), + generators: tuple[str, ...] = ("X", "Z"), ) -> None: - """Add an inheritable Pauli set to every active branch on ``qubit``.""" + """Add Pauli generators to every active branch on ``qubit``.""" ... @@ -137,18 +137,18 @@ def add_event_continuation_pauli_insertion( lost_qubit: int, error_qubit: int, boundary: int, - paulis: tuple[str, ...] = ("I", "X", "Y", "Z"), + generators: tuple[str, ...] = ("X", "Z"), ) -> None: - """Add an inheritable Pauli set in one source-event world.""" + """Add Pauli generators in one source-event world.""" ... def add_source_pauli_insertion( self, event_id: int, - paulis: tuple[str, ...] = ("I", "X", "Y", "Z"), + generators: tuple[str, ...] = ("X", "Z"), ) -> None: - """Add a Pauli set at one loss event's source boundary.""" + """Add Pauli generators at one loss event's source boundary.""" ... diff --git a/deq/deq/transpiler/loss/policies.py b/deq/deq/transpiler/loss/policies.py index 73bd40c1..8c8fecb2 100644 --- a/deq/deq/transpiler/loss/policies.py +++ b/deq/deq/transpiler/loss/policies.py @@ -126,7 +126,7 @@ def handle_residual_s_dagger(gate: LossGate, state: LossAnalysisState) -> None: lost_qubit=lost_qubit, error_qubit=error_qubit, boundary=gate.boundary_after, - paulis=("I", "Z"), + generators=("Z",), ) From 0fcaced4bf7674f6b2b7323825e35e8e766afeca Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 16 Aug 2026 12:30:41 -0700 Subject: [PATCH 106/157] add loss graph definition --- deq/deq/transpiler/loss/loss_graph.py | 222 ++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 deq/deq/transpiler/loss/loss_graph.py diff --git a/deq/deq/transpiler/loss/loss_graph.py b/deq/deq/transpiler/loss/loss_graph.py new file mode 100644 index 00000000..b8b04042 --- /dev/null +++ b/deq/deq/transpiler/loss/loss_graph.py @@ -0,0 +1,222 @@ +"""Loss event graph types and validation.""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass + + +@dataclass(frozen=True, order=True) +class PauliInsertion: + """One single-qubit Pauli generator basis at a circuit boundary. + + The default ``("X", "Z")`` spans the full single-qubit Pauli group. Any + two distinct generators span that same group and canonicalize to this basis. + """ + + boundary: int + qubit: int + generators: tuple[str, ...] = ("X", "Z") + + def __post_init__(self) -> None: + if self.boundary < 0: + raise ValueError("Pauli insertion boundary must be non-negative") + if self.qubit < 0: + raise ValueError("Pauli insertion qubit must be non-negative") + if any(generator not in {"X", "Y", "Z"} for generator in self.generators): + raise ValueError("Pauli insertion generators support only X, Y, and Z") + generators = tuple(sorted(set(self.generators), key="XYZ".index)) + if not generators: + raise ValueError("Pauli insertion requires at least one generator") + if len(generators) > 1: + generators = ("X", "Z") + object.__setattr__(self, "generators", generators) + + +@dataclass(frozen=True) +class LossBranch: + """One physical loss branch caused directly or through propagation. + + Boundaries are positions in the loss model's ideal-operation stream. A + branch may produce zero or multiple loss measurements. + """ + + qubit: int + loss_boundary: int + loss_measurements: tuple[int, ...] + continuation_pauli_insertions: tuple[PauliInsertion, ...] + successor_event_id: int | None = None + + def __post_init__(self) -> None: + if self.qubit < 0 or self.loss_boundary < 0: + raise ValueError("loss branch source must be non-negative") + measurements = tuple(sorted(set(self.loss_measurements))) + insertions = tuple(sorted(set(self.continuation_pauli_insertions))) + if any(index < 0 for index in measurements): + raise ValueError("loss measurement indices must be non-negative") + if self.successor_event_id is not None and self.successor_event_id < 0: + raise ValueError("successor event ID must be non-negative") + object.__setattr__(self, "loss_measurements", measurements) + object.__setattr__(self, "continuation_pauli_insertions", insertions) + + +@dataclass(frozen=True) +class LossEvent: + """One source loss event with one or more propagated loss branches.""" + + event_id: int + body_index: int + target_index: int + source_qubit: int + loss_probability: float + source_boundary: int + branches: tuple[LossBranch, ...] + source_pauli_insertions: tuple[PauliInsertion, ...] = () + + def __post_init__(self) -> None: + if self.event_id < 0: + raise ValueError("loss event ID must be non-negative") + if self.body_index < 0 or self.target_index < 0: + raise ValueError("loss event source indices must be non-negative") + if self.source_qubit < 0 or self.source_boundary < 0: + raise ValueError("loss event source must be non-negative") + if not 0.0 < self.loss_probability <= 1.0: + raise ValueError("loss event probability must be in (0, 1]") + branches = tuple( + sorted( + set(self.branches), + key=lambda branch: ( + branch.loss_boundary, + branch.qubit, + branch.loss_measurements, + branch.continuation_pauli_insertions, + branch.successor_event_id, + ), + ) + ) + if not branches: + raise ValueError("loss event must contain at least one branch") + if not any( + branch.qubit == self.source_qubit + and branch.loss_boundary == self.source_boundary + for branch in branches + ): + raise ValueError("loss event must contain its source branch") + object.__setattr__(self, "branches", branches) + object.__setattr__( + self, + "source_pauli_insertions", + tuple(sorted(set(self.source_pauli_insertions))), + ) + + @property + def affected_qubits(self) -> tuple[int, ...]: + """Return all qubits lost directly or through propagation.""" + + return tuple(sorted({branch.qubit for branch in self.branches})) + + @property + def loss_measurements(self) -> tuple[int, ...]: + """Return local loss measurements before following successor links.""" + + return tuple( + sorted( + { + measurement_index + for branch in self.branches + for measurement_index in branch.loss_measurements + } + ) + ) + + @property + def continuation_pauli_insertions(self) -> tuple[PauliInsertion, ...]: + """Return inheritable effects after this event's source location.""" + + return tuple( + sorted( + { + insertion + for branch in self.branches + for insertion in branch.continuation_pauli_insertions + } + ) + ) + + @property + def local_pauli_insertions(self) -> tuple[PauliInsertion, ...]: + """Return this event's source-only and local continuation effects.""" + + insertions = set(self.source_pauli_insertions) + insertions.update(self.continuation_pauli_insertions) + return tuple(sorted(insertions)) + + +@dataclass(frozen=True) +class LossEventGraph: + """Physical loss events and their definite forward-suffix links.""" + + measurement_count: int + events: tuple[LossEvent, ...] + successor_event_ids: tuple[tuple[int, ...], ...] + + +def build_loss_event_graph( + events: Iterable[LossEvent], measurement_count: int +) -> LossEventGraph: + """Validate and canonicalize the physical loss-event DAG.""" + + ordered_events = tuple(sorted(events, key=lambda event: event.event_id)) + event_ids = [event.event_id for event in ordered_events] + assert len(set(event_ids)) == len(event_ids), "loss event IDs must be unique" + event_index = {event_id: index for index, event_id in enumerate(event_ids)} + successor_event_ids: list[set[int]] = [set() for _ in ordered_events] + predecessor_event_ids: list[set[int]] = [set() for _ in ordered_events] + for event_index_value, event in enumerate(ordered_events): + for measurement_index in event.loss_measurements: + if measurement_index >= measurement_count: + raise ValueError( + f"loss event {event.event_id} references measurement " + f"{measurement_index}, outside measurement_count={measurement_count}" + ) + for branch in event.branches: + successor_id = branch.successor_event_id + if successor_id is None: + continue + if successor_id not in event_index: + raise ValueError( + f"loss event {event.event_id} references unknown successor " + f"{successor_id}" + ) + if successor_id == event.event_id: + raise ValueError("loss event cannot imply itself") + successor_event_ids[event_index_value].add(successor_id) + predecessor_event_ids[event_index[successor_id]].add(event.event_id) + + remaining_predecessors = [ + len(predecessors) for predecessors in predecessor_event_ids + ] + pending = [ + event_id + for event_id in event_ids + if remaining_predecessors[event_index[event_id]] == 0 + ] + topological_order: list[int] = [] + while pending: + event_id = pending.pop() + topological_order.append(event_id) + for successor_id in successor_event_ids[event_index[event_id]]: + successor_index = event_index[successor_id] + remaining_predecessors[successor_index] -= 1 + if remaining_predecessors[successor_index] == 0: + pending.append(successor_id) + if len(topological_order) != len(event_ids): + raise ValueError("loss-event implication graph contains a cycle") + + return LossEventGraph( + measurement_count=measurement_count, + events=ordered_events, + successor_event_ids=tuple( + tuple(sorted(successors)) for successors in successor_event_ids + ), + ) From fd90b542373d10b45c167cf5cfab55909bf16827 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 16 Aug 2026 14:59:38 -0700 Subject: [PATCH 107/157] add loss analysis tool --- deq/deq/transpiler/jit_library_builder.py | 3 +- deq/deq/transpiler/loss/__init__.py | 7 +- deq/deq/transpiler/loss/analysis.py | 774 ++++++++++++++++++ deq/deq/transpiler/loss/api.py | 27 +- deq/deq/transpiler/loss/loss_graph.py | 19 +- deq/deq/transpiler/loss/model_trapped_ion.py | 2 +- deq/deq/transpiler/loss/policies.py | 40 +- .../loss-simulation/repetition_code.deq | 6 +- 8 files changed, 844 insertions(+), 34 deletions(-) create mode 100644 deq/deq/transpiler/loss/analysis.py diff --git a/deq/deq/transpiler/jit_library_builder.py b/deq/deq/transpiler/jit_library_builder.py index 3b14dd43..f87a33b0 100644 --- a/deq/deq/transpiler/jit_library_builder.py +++ b/deq/deq/transpiler/jit_library_builder.py @@ -931,7 +931,7 @@ def _build_check( num_measurements=internal_count, ) appended_error_origins: list[ErrorOrigin] = [] - if loss_model_pb is None: + if loss_model_pb is None and library_has_loss: loss_artifacts = transpile_inferred_loss_model( gadget, codes, @@ -944,7 +944,6 @@ def _build_check( readouts=readouts_pb, physical_correction=physical_correction_pb, existing_errors=errors_pb, - library_has_loss=library_has_loss, loss_model=loss_model, ) if loss_artifacts.model is not None: diff --git a/deq/deq/transpiler/loss/__init__.py b/deq/deq/transpiler/loss/__init__.py index 915897f8..9909accd 100644 --- a/deq/deq/transpiler/loss/__init__.py +++ b/deq/deq/transpiler/loss/__init__.py @@ -12,10 +12,7 @@ from functools import lru_cache from pathlib import Path -from deq.transpiler.loss.analysis import ( - analyze_loss_events, - analyze_loss_events_with_ports, -) +from deq.transpiler.loss.analysis import LossAnalysisResult, analyze_loss_events from deq.transpiler.loss.api import ( GateLossPolicy, LossAnalysisState, @@ -134,6 +131,7 @@ def create_loss_model(selector: str | Path) -> LossModel: "LossEvent", "LossEventGraph", "LossAnalysisState", + "LossAnalysisResult", "LossGate", "LossGateHandler", "LossModel", @@ -141,7 +139,6 @@ def create_loss_model(selector: str | Path) -> LossModel: "PauliInsertion", "UnsupportedLossModelError", "analyze_loss_events", - "analyze_loss_events_with_ports", "build_loss_event_graph", "create_loss_model", ] diff --git a/deq/deq/transpiler/loss/analysis.py b/deq/deq/transpiler/loss/analysis.py new file mode 100644 index 00000000..7f57ad28 --- /dev/null +++ b/deq/deq/transpiler/loss/analysis.py @@ -0,0 +1,774 @@ +"""Whole-gadget traversal and individual-gate dispatch for loss models.""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Sequence +from dataclasses import dataclass, field + +import stim + +from deq.circuit.model import ( + CombinerTarget, + GadgetDefinition, + Instruction, + MeasurementRecordTarget, + PauliTarget, + QubitTarget, +) +from deq.transpiler.fault_propagation import build_decomposed_body +from deq.transpiler.jit_transpiler import flatten_body +from deq.transpiler.loss.api import ( + LossAnalysisState, + LossGate, + LossGateHandler, + LossModel, + UnsupportedLossModelError, +) +from deq.transpiler.loss.loss_graph import ( + LossBranch, + LossEvent, + LossEventGraph, + PauliInsertion, + build_loss_event_graph, +) +from deq.transpiler.stim_constants import ( + ANNOTATION_INSTRUCTIONS, + NOISE_INSTRUCTIONS_ALL, + instruction_num_measurements, + split_mpp_targets, +) + + +@dataclass(frozen=True) +class LossAnalysisResult: + """Loss graph plus physical-qubit mappings at the gadget boundary.""" + + graph: LossEventGraph + exit_qubits_by_event: dict[int, tuple[int, ...]] + input_event_id_by_qubit: dict[int, int] + + +@dataclass +class _PendingLossBranch: + qubit: int + loss_boundary: int + loss_measurements: set[int] = field(default_factory=set) + continuation_pauli_insertions: set[PauliInsertion] = field(default_factory=set) + active: bool = True + successor_event_id: int | None = None + + def finish(self) -> LossBranch: + return LossBranch( + qubit=self.qubit, + loss_boundary=self.loss_boundary, + loss_measurements=tuple(self.loss_measurements), + continuation_pauli_insertions=tuple(self.continuation_pauli_insertions), + successor_event_id=self.successor_event_id, + ) + + +@dataclass +class _PendingLossEvent: + event_id: int + body_index: int + target_index: int + source_qubit: int + loss_probability: float + source_boundary: int + branches: list[_PendingLossBranch] + source_pauli_insertions: set[PauliInsertion] = field(default_factory=set) + + def finish(self) -> LossEvent: + return LossEvent( + event_id=self.event_id, + body_index=self.body_index, + target_index=self.target_index, + source_qubit=self.source_qubit, + loss_probability=self.loss_probability, + source_boundary=self.source_boundary, + branches=tuple(branch.finish() for branch in self.branches), + source_pauli_insertions=tuple(self.source_pauli_insertions), + ) + + +class _MutableLossAnalysisState(LossAnalysisState): + def __init__(self) -> None: + self.events: dict[int, _PendingLossEvent] = {} + self.pending: dict[int, list[tuple[_PendingLossEvent, _PendingLossBranch]]] = ( + defaultdict(list) + ) + + def add_source_event( + self, + *, + event_id: int, + body_index: int, + target_index: int, + qubit: int, + probability: float, + boundary: int, + ) -> None: + self._link_prior_losses_to_new_source(qubit, event_id) + branch = _PendingLossBranch( + qubit=qubit, + loss_boundary=boundary, + ) + event = _PendingLossEvent( + event_id=event_id, + body_index=body_index, + target_index=target_index, + source_qubit=qubit, + loss_probability=probability, + source_boundary=boundary, + branches=[branch], + ) + self.events[event_id] = event + self.pending[qubit].append((event, branch)) + + def _link_prior_losses_to_new_source(self, qubit: int, successor_event_id: int) -> None: + """Share a new source's suffix with prior single-branch losses.""" + + retained: list[tuple[_PendingLossEvent, _PendingLossBranch]] = [] + for event, branch in self.pending.get(qubit, ()): + active_branch_count = sum( + candidate_branch.active for candidate_branch in event.branches + ) + # Share the later suffix only when this is the event's sole lifetime. + if branch.active and active_branch_count == 1: + branch.active = False + branch.successor_event_id = successor_event_id + else: + retained.append((event, branch)) + # Linked branches continue through the successor instead of pending here. + if retained: + self.pending[qubit] = retained + else: + self.pending.pop(qubit, None) + + def active_event_ids(self, qubit: int) -> tuple[int, ...]: + return tuple( + sorted({event.event_id for event, _ in self.pending.get(qubit, ())}) + ) + + def event_has_active_loss(self, event_id: int, qubit: int) -> bool: + return any( + event.event_id == event_id for event, _ in self.pending.get(qubit, ()) + ) + + def add_continuation_pauli_insertion( + self, + qubit: int, + boundary: int, + generators: tuple[str, ...] = ("X", "Z"), + ) -> None: + for _, branch in self.pending.get(qubit, ()): + branch.continuation_pauli_insertions.add( + PauliInsertion( + boundary=boundary, + qubit=qubit, + generators=generators, + ) + ) + + # This is the event-specific form of the broadcast operation above. + def add_event_continuation_pauli_insertion( + self, + event_id: int, + *, + branch_qubit: int, + qubit: int, + boundary: int, + generators: tuple[str, ...] = ("X", "Z"), + ) -> None: + for event, branch in self.pending.get(branch_qubit, ()): + if event.event_id == event_id: + branch.continuation_pauli_insertions.add( + PauliInsertion( + boundary=boundary, + qubit=qubit, + generators=generators, + ) + ) + return + raise ValueError( + f"event {event_id} has no active loss branch on qubit {branch_qubit}" + ) + + def add_source_pauli_insertion( + self, + event_id: int, + generators: tuple[str, ...] = ("X", "Z"), + ) -> None: + event = self.events[event_id] + event.source_pauli_insertions.add( + PauliInsertion( + boundary=event.source_boundary, + qubit=event.source_qubit, + generators=generators, + ) + ) + + def add_loss_controlled_pauli_insertion( + self, + measurement_index: int, + qubit: int, + boundary: int, + generators: tuple[str, ...], + ) -> None: + for event in self.events.values(): + if self.event_has_active_loss(event.event_id, qubit): + continue + for branch in event.branches: + if measurement_index in branch.loss_measurements: + branch.continuation_pauli_insertions.add( + PauliInsertion(boundary, qubit, generators) + ) + + def record_loss_measurement(self, qubit: int, measurement_index: int) -> None: + for _, branch in self.pending.get(qubit, ()): + branch.loss_measurements.add(measurement_index) + + def clear_loss(self, qubit: int) -> None: + for _, branch in self.pending.pop(qubit, ()): + branch.active = False + + def _add_active_branch( + self, + event: _PendingLossEvent, + qubit: int, + boundary: int, + *, + add_continuation_insertion: bool, + ) -> None: + """Add one active physical branch unless the event already occupies it.""" + + if self.event_has_active_loss(event.event_id, qubit): + return + insertions = ( + {PauliInsertion(boundary=boundary, qubit=qubit)} + if add_continuation_insertion + else set() + ) + branch = _PendingLossBranch( + qubit=qubit, + loss_boundary=boundary, + continuation_pauli_insertions=insertions, + ) + event.branches.append(branch) + self.pending[qubit].append((event, branch)) + + def propagate_loss( + self, + event_id: int, + *, + lost_qubit: int, + new_qubit: int, + boundary: int, + ) -> None: + if not self.event_has_active_loss(event_id, lost_qubit): + raise ValueError( + f"event {event_id} has no active loss branch on qubit {lost_qubit}" + ) + self._add_active_branch( + self.events[event_id], + new_qubit, + boundary, + add_continuation_insertion=True, + ) + + def swap_losses( + self, event_id: int, first_qubit: int, second_qubit: int, boundary: int + ) -> None: + event = self.events[event_id] + first_lost = self.event_has_active_loss(event_id, first_qubit) + second_lost = self.event_has_active_loss(event_id, second_qubit) + if first_lost == second_lost: + return + source = first_qubit if first_lost else second_qubit + destination = second_qubit if first_lost else first_qubit + retained = [] + for candidate_event, branch in self.pending.get(source, ()): + if candidate_event.event_id == event_id: + branch.active = False + else: + retained.append((candidate_event, branch)) + if retained: + self.pending[source] = retained + else: + self.pending.pop(source, None) + self._add_active_branch( + event, destination, boundary, add_continuation_insertion=False + ) + + def finish(self, measurement_count: int) -> LossEventGraph: + return build_loss_event_graph( + (event.finish() for event in self.events.values()), + measurement_count=measurement_count, + ) + + +def _collect_loss_sources( + body: Sequence[object], +) -> list[tuple[int, int, int, float, int]]: + sources: list[tuple[int, int, int, float, int]] = [] + next_event_id = 0 + for body_index, statement in enumerate(body): + if not isinstance(statement, Instruction): + continue + if statement.name.upper() != "LOSS_ERROR": + continue + if len(statement.arguments) != 1: + raise ValueError( + f"LOSS_ERROR at flattened body index {body_index} requires " + "exactly one probability" + ) + probability = float(statement.arguments[0]) + if not 0.0 <= probability <= 1.0: + raise ValueError( + f"LOSS_ERROR probability at flattened body index {body_index} " + f"must be in [0, 1], got {probability}" + ) + targets = [ + (target_index, target) + for target_index, target in enumerate(statement.targets) + if isinstance(target, QubitTarget) + ] + if len(targets) != len(statement.targets): + raise ValueError( + f"LOSS_ERROR at flattened body index {body_index} accepts " + "only qubit targets" + ) + if not targets: + raise ValueError( + f"LOSS_ERROR at flattened body index {body_index} requires at " + "least one qubit target" + ) + qubits = [target.index for _, target in targets] + if len(set(qubits)) != len(qubits): + raise ValueError( + f"LOSS_ERROR at flattened body index {body_index} contains a " + "duplicate qubit target" + ) + for target_index, target in targets: + if target.inverted: + raise ValueError("LOSS_ERROR qubit targets cannot be inverted") + if probability == 0.0: + continue + sources.append( + ( + next_event_id, + body_index, + target_index, + probability, + target.index, + ) + ) + next_event_id += 1 + return sources + + +def _split_source_occurrences(statement: Instruction) -> list[Instruction]: + gate = stim.gate_data(statement.name.upper()) + if gate.name == "MPAD": + return [ + Instruction( + name=statement.name, + arguments=statement.arguments, + targets=[target], + ) + for target in statement.targets + ] + if gate.takes_pauli_targets: + occurrences = [] + for group in split_mpp_targets(list(statement.targets)): + targets = [] + for index, target in enumerate(group): + if index: + targets.append(CombinerTarget()) + targets.append(target) + occurrences.append( + Instruction( + name=statement.name, + arguments=statement.arguments, + targets=targets, + ) + ) + return occurrences + + targets = [ + target + for target in statement.targets + if isinstance(target, (QubitTarget, PauliTarget, MeasurementRecordTarget)) + ] + if len(targets) != len(statement.targets): + raise UnsupportedLossModelError( + f"gate {statement.name} with non-qubit targets is not supported by " + "loss-model analysis" + ) + group_size = 2 if gate.is_two_qubit_gate else 1 + if len(targets) % group_size != 0: + raise ValueError( + f"{statement.name} requires target groups of size {group_size}" + ) + occurrences = [] + for index in range(0, len(targets), group_size): + group = targets[index : index + group_size] + record_targets = [ + target for target in group if isinstance(target, MeasurementRecordTarget) + ] + if record_targets and not ( + group_size == 2 + and isinstance(group[0], MeasurementRecordTarget) + and isinstance(group[1], QubitTarget) + ): + raise UnsupportedLossModelError( + f"gate {statement.name} has an unsupported classical target group" + ) + occurrences.append( + Instruction( + name=statement.name, + arguments=statement.arguments, + targets=group, + ) + ) + return occurrences + + +def _loss_gate_from_source_occurrence( + occurrence: Instruction, + *, + body_index: int, + measurement_index: int | None, + prior_measurement_count: int, + boundary: int, + boundary_after: int, +) -> LossGate: + try: + gate = stim.gate_data(occurrence.name.upper()) + except IndexError: + raise UnsupportedLossModelError( + f"unknown gate {occurrence.name} is not supported by Stim" + ) from None + control_measurement_indices = [ + prior_measurement_count - target.offset + for target in occurrence.targets + if isinstance(target, MeasurementRecordTarget) + ] + if len(control_measurement_indices) > 1: + raise UnsupportedLossModelError( + f"gate {occurrence.name} has multiple measurement-record controls" + ) + if any(index < 0 for index in control_measurement_indices): + raise ValueError( + f"gate {occurrence.name} references a measurement before the gadget" + ) + return LossGate( + name=gate.name, + source_name=occurrence.name.upper(), + arguments=tuple(float(argument) for argument in occurrence.arguments), + qubits=tuple( + target.index + for target in occurrence.targets + if isinstance(target, (QubitTarget, PauliTarget)) + ), + measurement_index=measurement_index, + control_measurement_index=( + control_measurement_indices[0] if control_measurement_indices else None + ), + body_index=body_index, + boundary_before=boundary, + boundary_after=boundary_after, + produces_measurement=gate.produces_measurements, + resets_qubits=gate.is_reset, + is_source_gate=True, + ) + + +def _decompose_instruction_for_loss( + statement: Instruction, + *, + body_index: int, + measurement_indices: tuple[int, ...], + measurement_index: int, + boundary: int, + span: int, +) -> list[LossGate]: + decomposed = stim.Circuit(str(statement)).decomposed() + gates: list[LossGate] = [] + measurement_cursor = 0 + instruction_offset = 0 + for instruction in decomposed: + if not isinstance(instruction, stim.CircuitInstruction): + raise UnsupportedLossModelError( + f"decomposition of {statement.name} contains a repeat block" + ) + name = instruction.name + if name == "TICK": + continue + if name == "I": + continue + boundary_before = boundary + instruction_offset + instruction_offset += 1 + if name == "MPAD": + measurement_cursor += instruction_num_measurements(str(instruction)) + continue + if name not in {"H", "S", "CX", "M", "R"}: + raise UnsupportedLossModelError( + f"Stim decomposition of {statement.name} produced unsupported " + f"primitive {name}" + ) + targets = instruction.targets_copy() + group_size = 2 if name == "CX" else 1 + if len(targets) % group_size != 0: + raise ValueError( + f"decomposed {name} requires target groups of size {group_size}" + ) + for index in range(0, len(targets), group_size): + group = targets[index : index + group_size] + control_measurement_index = None + if group[0].is_measurement_record_target: + if len(group) != 2 or not group[1].is_qubit_target: + raise UnsupportedLossModelError( + f"decomposition of {statement.name} contains an " + "unsupported classical target group" + ) + control_index = measurement_index + measurement_cursor + group[0].value + if control_index < 0: + raise ValueError( + f"gate {statement.name} references a measurement before " + "the gadget" + ) + control_measurement_index = control_index + qubits = (group[1].value,) + else: + if not all(target.is_qubit_target for target in group): + raise UnsupportedLossModelError( + f"decomposition of {statement.name} contains a non-qubit target" + ) + qubits = tuple(target.value for target in group) + primitive_measurement_index = None + if name == "M": + if measurement_cursor >= len(measurement_indices): + raise ValueError( + f"decomposition of {statement.name} produced too many " + "measurements" + ) + primitive_measurement_index = measurement_indices[measurement_cursor] + measurement_cursor += 1 + gates.append( + LossGate( + name=name, + source_name=statement.name.upper(), + arguments=tuple(instruction.gate_args_copy()), + qubits=qubits, + measurement_index=primitive_measurement_index, + control_measurement_index=control_measurement_index, + body_index=body_index, + boundary_before=boundary_before, + boundary_after=boundary_before + 1, + produces_measurement=name == "M", + resets_qubits=name == "R", + is_source_gate=False, + ) + ) + if instruction_offset != span: + raise ValueError( + f"decomposition of {statement.name} occupied {instruction_offset} " + f"instructions, expected timeline span {span}" + ) + if measurement_cursor != len(measurement_indices): + raise ValueError( + f"decomposition of {statement.name} produced {measurement_cursor} " + f"measurements, expected {len(measurement_indices)}" + ) + return gates + + +def _loss_gates_for_instruction( + statement: Instruction, + *, + body_index: int, + measurement_index: int, + boundary: int, + span: int, + source_gate_names: frozenset[str], +) -> tuple[list[LossGate], int]: + source_name = statement.name.upper() + try: + source_gate = stim.gate_data(source_name) + except IndexError: + raise UnsupportedLossModelError( + f"unknown gate {statement.name} is not supported by Stim" + ) from None + statement_measurement_count = instruction_num_measurements(str(statement)) + if source_gate.name == "MPAD": + return [], measurement_index + statement_measurement_count + + if source_gate.name not in source_gate_names: + measurement_indices = tuple( + range( + measurement_index, + measurement_index + statement_measurement_count, + ) + ) + return ( + _decompose_instruction_for_loss( + statement, + body_index=body_index, + measurement_indices=measurement_indices, + measurement_index=measurement_index, + boundary=boundary, + span=span, + ), + measurement_index + statement_measurement_count, + ) + + gates: list[LossGate] = [] + for occurrence in _split_source_occurrences(statement): + occurrence_measurement_count = instruction_num_measurements(str(occurrence)) + if occurrence_measurement_count > 1: + raise ValueError( + f"atomized {occurrence.name} produced " + f"{occurrence_measurement_count} measurements" + ) + prior_measurement_count = measurement_index + occurrence_measurement_index = ( + measurement_index if occurrence_measurement_count else None + ) + measurement_index += occurrence_measurement_count + gates.append( + _loss_gate_from_source_occurrence( + occurrence, + body_index=body_index, + measurement_index=occurrence_measurement_index, + prior_measurement_count=prior_measurement_count, + boundary=boundary, + boundary_after=boundary + span, + ) + ) + return gates, measurement_index + + +def analyze_loss_events( + gadget: GadgetDefinition, + model: LossModel, +) -> LossAnalysisResult: + """Traverse one gadget, returning its graph and physical boundary mappings. + + Each declared input qubit receives a synthetic source at boundary ``0``, + modelling a loss that entered from an upstream gadget. Synthetic event IDs + follow the gadget's own ``LOSS_ERROR`` event IDs. + + ``exit_qubits_by_event`` maps each event ID to the physical qubits that still + carry an active loss branch once the body finishes -- the qubits on which the + loss leaves the gadget. ``input_event_id_by_qubit`` maps each seeded input + physical qubit to its event ID. + """ + + body = flatten_body(list(gadget.body)) + decomposed_body = build_decomposed_body(body) + loss_sources = _collect_loss_sources(body) + handler = model.create_handler() + if not isinstance(handler, LossGateHandler): + raise TypeError( + f"loss model returned {type(handler).__name__}, which does not " + "implement loss-source and gate handling" + ) + source_gate_names = frozenset(name.upper() for name in handler.source_gate_names) + total_measurements = sum( + instruction_num_measurements(str(statement)) + for statement in body + if isinstance(statement, Instruction) + ) + + input_qubits = [ + qubit for port in gadget.input_ports for qubit in port.qubit_indices + ] + input_event_id_by_qubit = { + qubit: len(loss_sources) + offset + for offset, qubit in enumerate(input_qubits) + } + + if not loss_sources and not input_event_id_by_qubit: + return LossAnalysisResult( + graph=build_loss_event_graph((), measurement_count=total_measurements), + exit_qubits_by_event={}, + input_event_id_by_qubit={}, + ) + + sources_by_body_index: dict[int, list[tuple[int, int, int, float, int]]] = ( + defaultdict(list) + ) + for source in loss_sources: + sources_by_body_index[source[1]].append(source) + + state = _MutableLossAnalysisState() + for qubit, event_id in input_event_id_by_qubit.items(): + state.add_source_event( + event_id=event_id, + body_index=0, + target_index=0, + qubit=qubit, + probability=1.0, + boundary=0, + ) + handler.handle_loss_source(event_id, state) + measurement_index = 0 + for body_index, statement in enumerate(body): + boundary = decomposed_body.body_start_at[body_index] + for event_id, _, target_index, probability, qubit in sources_by_body_index.get( + body_index, () + ): + state.add_source_event( + event_id=event_id, + body_index=body_index, + target_index=target_index, + qubit=qubit, + probability=probability, + boundary=boundary, + ) + handler.handle_loss_source(event_id, state) + + if not isinstance(statement, Instruction): + continue + source_name = statement.name.upper() + if ( + source_name in NOISE_INSTRUCTIONS_ALL + or source_name in ANNOTATION_INSTRUCTIONS + ): + continue + boundary_after = ( + decomposed_body.body_start_at[body_index + 1] + if body_index + 1 < len(decomposed_body.body_start_at) + else len(decomposed_body.instructions) + ) + gates, measurement_index = _loss_gates_for_instruction( + statement, + body_index=body_index, + measurement_index=measurement_index, + boundary=boundary, + span=boundary_after - boundary, + source_gate_names=source_gate_names, + ) + for gate in gates: + handler.handle_gate(gate, state) + + assert measurement_index == total_measurements + # Qubits still carrying an active, *unheralded* loss branch when the body + # ends are the qubits on which the loss leaves the gadget. A branch that was + # measured (has loss_measurements) is resolved in-gadget and does not exit. + exit_qubits_by_event: dict[int, set[int]] = defaultdict(set) + for qubit, entries in state.pending.items(): + for event, branch in entries: + if branch.active and not branch.loss_measurements: + exit_qubits_by_event[event.event_id].add(qubit) + exits = { + event_id: tuple(sorted(qubits)) + for event_id, qubits in exit_qubits_by_event.items() + } + return LossAnalysisResult( + graph=state.finish(measurement_count=measurement_index), + exit_qubits_by_event=exits, + input_event_id_by_qubit=input_event_id_by_qubit, + ) diff --git a/deq/deq/transpiler/loss/api.py b/deq/deq/transpiler/loss/api.py index 654a9cb5..76d0241c 100644 --- a/deq/deq/transpiler/loss/api.py +++ b/deq/deq/transpiler/loss/api.py @@ -89,15 +89,19 @@ class LossGate: """One gate occurrence passed to a loss-model handler. Multi-target Stim instructions are atomized before dispatch, so ``qubits`` - contains exactly the operands of one gate application. Boundaries are in - the current loss-analysis operation stream. + contains exactly the physical operands of one gate application. A + measurement-record control is instead resolved to an absolute gadget index + in ``control_measurement_index``. ``measurement_index`` is the absolute + index produced by a measurement gate. Each is ``None`` when absent. + Boundaries are in the current loss-analysis operation stream. """ name: str source_name: str arguments: tuple[float, ...] qubits: tuple[int, ...] - measurement_indices: tuple[int, ...] + measurement_index: int | None + control_measurement_index: int | None body_index: int boundary_before: int boundary_after: int @@ -134,12 +138,12 @@ def add_event_continuation_pauli_insertion( self, event_id: int, *, - lost_qubit: int, - error_qubit: int, + branch_qubit: int, + qubit: int, boundary: int, generators: tuple[str, ...] = ("X", "Z"), ) -> None: - """Add Pauli generators in one source-event world.""" + """Add generators on ``qubit`` in one event's ``branch_qubit`` branch.""" ... @@ -152,6 +156,17 @@ def add_source_pauli_insertion( ... + def add_loss_controlled_pauli_insertion( + self, + measurement_index: int, + qubit: int, + boundary: int, + generators: tuple[str, ...], + ) -> None: + """Add generators when ``measurement_index`` is a loss herald.""" + + ... + def record_loss_measurement(self, qubit: int, measurement_index: int) -> None: """Associate a measurement result with every active branch.""" diff --git a/deq/deq/transpiler/loss/loss_graph.py b/deq/deq/transpiler/loss/loss_graph.py index b8b04042..dd81dcd5 100644 --- a/deq/deq/transpiler/loss/loss_graph.py +++ b/deq/deq/transpiler/loss/loss_graph.py @@ -1,4 +1,18 @@ -"""Loss event graph types and validation.""" +"""Loss event graph types and validation. + +The immutable result of loss analysis has this ownership hierarchy:: + + LossEventGraph + └─ LossEvent one possible source loss + ├─ PauliInsertion generators at the source boundary + └─ LossBranch one physical qubit carrying that loss + ├─ PauliInsertion generators accumulated along the branch + └─ successor_event_id + +A loss propagation creates another ``LossBranch`` within the same event. +A successor link instead connects alternative source events on one continuing +loss lifetime, allowing them to share their common suffix. +""" from __future__ import annotations @@ -168,7 +182,8 @@ def build_loss_event_graph( ordered_events = tuple(sorted(events, key=lambda event: event.event_id)) event_ids = [event.event_id for event in ordered_events] - assert len(set(event_ids)) == len(event_ids), "loss event IDs must be unique" + if len(set(event_ids)) != len(event_ids): + raise ValueError("loss event IDs must be unique") event_index = {event_id: index for index, event_id in enumerate(event_ids)} successor_event_ids: list[set[int]] = [set() for _ in ordered_events] predecessor_event_ids: list[set[int]] = [set() for _ in ordered_events] diff --git a/deq/deq/transpiler/loss/model_trapped_ion.py b/deq/deq/transpiler/loss/model_trapped_ion.py index 443f5578..ce7ed711 100644 --- a/deq/deq/transpiler/loss/model_trapped_ion.py +++ b/deq/deq/transpiler/loss/model_trapped_ion.py @@ -59,7 +59,7 @@ def handle_gate(self, gate: LossGate, state: LossAnalysisState) -> None: if gate.name == "R": handle_reset(gate, state) return - if gate.name in _UNSUPPORTED_CONTROLLED_GATES: + if gate.name in _UNSUPPORTED_CONTROLLED_GATES and gate.control_measurement_index is None: raise UnsupportedLossModelError( "trapped-ion residual-phase model supports CZ only; " f"{gate.source_name} requires an explicit device-specific " diff --git a/deq/deq/transpiler/loss/policies.py b/deq/deq/transpiler/loss/policies.py index 8c8fecb2..7edcb989 100644 --- a/deq/deq/transpiler/loss/policies.py +++ b/deq/deq/transpiler/loss/policies.py @@ -23,13 +23,13 @@ def handle_loss_source(event_id: int, state: LossAnalysisState) -> None: def handle_measurement(gate: LossGate, state: LossAnalysisState) -> None: """Record a loss herald and the unresolved value of a lost measurement.""" - if len(gate.qubits) != 1 or len(gate.measurement_indices) != 1: + if len(gate.qubits) != 1 or gate.measurement_index is None: raise UnsupportedLossModelError( f"M at body index {gate.body_index} requires one qubit and result" ) qubit = gate.qubits[0] state.add_continuation_pauli_insertion(qubit, gate.boundary_before) - state.record_loss_measurement(qubit, gate.measurement_indices[0]) + state.record_loss_measurement(qubit, gate.measurement_index) def handle_reset(gate: LossGate, state: LossAnalysisState) -> None: @@ -42,6 +42,20 @@ def handle_reset(gate: LossGate, state: LossAnalysisState) -> None: def handle_skip(gate: LossGate, state: LossAnalysisState) -> None: """Apply the exact SKIP envelope rules supported by the loss graph.""" + if gate.control_measurement_index is not None: + controlled_pauli = {"CX": "X", "CY": "Y", "CZ": "Z"}.get(gate.name) + if controlled_pauli is None or len(gate.qubits) != 1: + raise UnsupportedLossModelError( + f"classically controlled {gate.source_name} is not supported" + ) + state.add_loss_controlled_pauli_insertion( + gate.control_measurement_index, + gate.qubits[0], + gate.boundary_after, + (controlled_pauli,), + ) + return + if not _has_lost_operand(gate, state): return if gate.name == "H": @@ -56,13 +70,7 @@ def handle_skip(gate: LossGate, state: LossAnalysisState) -> None: return if gate.name in {"CX", "CY"}: _, target = gate.qubits - for event_id in state.active_event_ids(target): - state.add_event_continuation_pauli_insertion( - event_id, - lost_qubit=target, - error_qubit=target, - boundary=gate.boundary_after, - ) + state.add_continuation_pauli_insertion(target, gate.boundary_after) return raise UnsupportedLossModelError( f"exact SKIP envelope for gate {gate.source_name} is not implemented" @@ -118,13 +126,13 @@ def handle_residual_s_dagger(gate: LossGate, state: LossAnalysisState) -> None: for qubit in gate.qubits: for event_id in state.active_event_ids(qubit): active_sources.setdefault(event_id, qubit) - for event_id, lost_qubit in active_sources.items(): - for error_qubit in gate.qubits: - if not state.event_has_active_loss(event_id, error_qubit): + for event_id, branch_qubit in active_sources.items(): + for qubit in gate.qubits: + if not state.event_has_active_loss(event_id, qubit): state.add_event_continuation_pauli_insertion( event_id, - lost_qubit=lost_qubit, - error_qubit=error_qubit, + branch_qubit=branch_qubit, + qubit=qubit, boundary=gate.boundary_after, generators=("Z",), ) @@ -135,6 +143,10 @@ def handle_gate_policy( ) -> None: """Apply one QDK gate policy through the exact loss-analysis helpers.""" + # QDK reads a Loss record as false, so its conditional Pauli is skipped. + if gate.control_measurement_index is not None: + handle_skip(gate, state) + return handlers = { GateLossPolicy.SKIP: handle_skip, GateLossPolicy.PROPAGATE: handle_propagate, diff --git a/deq/documents/tutorial/examples/loss-simulation/repetition_code.deq b/deq/documents/tutorial/examples/loss-simulation/repetition_code.deq index e1468f4f..f37d45fe 100644 --- a/deq/documents/tutorial/examples/loss-simulation/repetition_code.deq +++ b/deq/documents/tutorial/examples/loss-simulation/repetition_code.deq @@ -43,10 +43,8 @@ fresh = [2 * d - 1 + i for i in range(d)] # correction after the X-basis measurement; we omit it because the # memory experiment is Z-basis (the logical state lives in the Z # computational basis and the Z correction only changes an -# unobservable global sign). Implementing the conditional Z would -# require physical-level teleportation support in the QDK simulator, -# which currently rejects classical-control instructions like -# ``CZ rec[-1] q`` — we will add it once the feature becomes available. +# unobservable global sign). QDK accepts ``CZ rec[-1] q``; this example +# omits it only because it cannot change the measured result. # # When ``q`` is alive at the start of the replenish, the CX transfers # the Z-eigenvalue (with any accumulated X-error history) onto ``f`` From 9e97da933be5b197026cea774c8de697b885bc27 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 16 Aug 2026 15:13:47 -0700 Subject: [PATCH 108/157] update tutorial chapter --- .../tutorial/chapters/qdk-loss-simulation.md | 160 ++++++++++++++---- 1 file changed, 128 insertions(+), 32 deletions(-) diff --git a/deq/documents/tutorial/chapters/qdk-loss-simulation.md b/deq/documents/tutorial/chapters/qdk-loss-simulation.md index 4c446783..e01cb367 100644 --- a/deq/documents/tutorial/chapters/qdk-loss-simulation.md +++ b/deq/documents/tutorial/chapters/qdk-loss-simulation.md @@ -10,31 +10,130 @@ standard loss model: - **Neutral atoms (CZ-native).** A `CZ` involving a lost atom has no effect on its partner; the gate effectively becomes the identity. -- **Trapped ions (MS-native).** When a - [Mølmer–Sørensen gate](https://en.wikipedia.org/wiki/M%C3%B8lmer%E2%80%93S%C3%B8rensen_gate) - touches a lost ion, the surviving partner picks up a deterministic - $S$ or $S^{\dagger}$ — a $\pi/2$ phase rotation that propagates - through the rest of the circuit. +- **Trapped ions (MS/XX-native).** Trapped-ion processors can implement + [Mølmer–Sørensen $XX$ interactions](https://doi.org/10.1103/PhysRevLett.82.1971) + and compile logical gates such as `CNOT` and controlled phase from an $XX$ + interaction plus local rotations ($S^{\dagger}$). - Other platforms (Rydberg blockade variants, leakage to higher levels, atom-array transport, …) come with their own variants. deq packages these gate-by-gate rules as platform loss models, selected with ``--loss-model``: -| Model | Default two-qubit policy | Gate overrides | +| Model | Scope | Explicit gate policies | | --- | --- | --- | -| ``neutral-atom`` | ``SKIP`` | ``SWAP → APPLY_ANYWAY`` so atom transport relocates the loss flag | -| ``trapped-ion`` | QDK defaults | ``CX/CY/CZ → RESIDUAL_S_DAGGER``; ``SWAP → SKIP`` | - -The same canonical configuration is stored in the compiled ``.deq.jit`` and -``.deq.bin`` artifacts and passed to QDK simulation, so decoder metadata and -physical sampling cannot silently select different models. On the decoding -side, the trapped-ion residual $S^{\dagger}$ is represented by its Pauli -envelope $\{I,Z\}$ on the surviving ion. The circuit-level native gate remains -``CX``: the model assumes hardware implements it with an MS interaction and -local $S^{\dagger}$ fixups. If one ion is absent, the MS interaction disappears -while the survivor's fixup remains. No new circuit gates or sampler rewrites are -required. +| ``neutral-atom`` | native CZ and its compiled controlled-Pauli aliases | ``CX/CY/CZ → SKIP``; ``SWAP → APPLY_ANYWAY`` | +| ``trapped-ion`` | one explicit compiled-CZ residual-phase approximation | ``CZ → RESIDUAL_S_DAGGER``; ``SWAP → APPLY_ANYWAY`` | + +The neutral-atom row does not claim that all three controlled gates are native. +Neutral-atom processors natively realize CZ and obtain CNOT/CX using local +target rotations around CZ. +QDK's neutral-atom compiler likewise lowers ``CX`` to ``H-CZ-H`` and ``CY`` to +``S†-H-CZ-H-S``. If an atom is absent and the native CZ is skipped, the local +wrappers cancel on a surviving target or act only on the absent atom. The +effective operation is therefore SKIP for all three source-level aliases. This +equivalence assumes the loss is already active at the source-gate boundary; +pulse-resolved loss between the local wrappers requires a more detailed model. + +The trapped-ion row is deliberately narrower. The native MS entangler is an +$XX(\chi)$ interaction, not CX. Logical CX, CY, and CZ use different local +rotations around that interaction, so losing the interaction does not leave the +same residual operation for all three. The built-in preset specifies one CZ +compilation and rejects source-level CX/CY. Circuits using them must first expose +a supported CZ-plus-local-gates decomposition or supply a custom loss model. + +The selector also accepts a Python file. The file must define a zero-argument +``create_loss_model()`` function returning an object that implements +``LossModel`` (a ``QdkLossConfig`` in ``config`` plus ``create_handler()``): + +```python +from deq.transpiler.loss import GateLossPolicy, QdkLossConfig +from deq.transpiler.loss.model_neutral_atom import NeutralAtomLossModel + + +class UserLossModel(NeutralAtomLossModel): + config = QdkLossConfig( + gate_policies=( + ("cx", GateLossPolicy.PROPAGATE), + ("cy", GateLossPolicy.SKIP), + ("cz", GateLossPolicy.SKIP), + ("swap", GateLossPolicy.APPLY_ANYWAY), + ) + ) + + +def create_loss_model(): + return UserLossModel() +``` + +The same selector syntax works for built-ins and files, for example +``--loss-model neutral-atom`` and ``--loss-model ./user_loss.py``. + +The same canonical configuration is stored as nested ``loss_strategy`` metadata +in the compiled ``.deq.jit`` and ``.deq.bin`` artifacts. By default, +``deq simulate ler --simulator qdk`` also passes that configuration to QDK, so +decoder metadata and physical sampling stay synchronized. To compare different +assumptions deliberately, pass a JSON object with ``--simulation-loss-model``. +This replaces the decoder-derived QDK configuration; it does not merge with it, +and it leaves the compiled decoder metadata unchanged: + +```sh +deq simulate ler circuit.deq --program Run --simulator qdk \ + --loss-model trapped-ion \ + --simulation-loss-model '{"cz":"SKIP","swap":"APPLY_ANYWAY"}' +``` + +Use ``--simulation-loss-model '{}'`` to leave every QDK loss policy at its own +default while retaining the selected decoder loss model. + +The stored configuration remains structured rather than becoming a JSON string: + +```json +{ + "loss_strategy": { + "cx": "SKIP", + "cy": "SKIP", + "cz": "SKIP", + "swap": "APPLY_ANYWAY" + } +} +``` + +**Scope of the trapped-ion preset.** This is an effective gate-level model, not +a claim that a physical MS pulse intrinsically applies $S^{\dagger}$ when an ion +is absent. Experiments describe native $XX$ interactions and compile `CNOT` and +controlled-phase gates from $XX$ plus separate single-qubit rotations; see +[Debnath et al., *Nature* 536, 63-66 (2016)](https://doi.org/10.1038/nature18648) +and the explicit, interaction-sign-dependent decompositions in +[Maslov, *New J. Phys.* 19, 023035 (2017)](https://doi.org/10.1088/1367-2630/aa5e47). +Those references support the composite-gate picture, but neither reports a +universal residual operation caused by ion loss. + +The motivation for the preset is the following possible controlled-phase +implementation: + +$$ +CZ = e^{-i\pi/4} + e^{+i\pi Z_1/4} + e^{+i\pi Z_2/4} + e^{-i\pi Z_1Z_2/4}. +$$ + +Up to global phase, each local factor $e^{+i\pi Z/4}$ is $S^{\dagger}$. If a +specific implementation loses the two-body interaction while its local phase +corrections still execute, the survivor does acquire $S^{\dagger}$. Reversing +the interaction or compilation convention can change the residual rotation. +QDK's +[`RESIDUAL_S_DAGGER` policy](https://github.com/microsoft/qdk/pull/3302) +implements exactly that abstract behavior: skip the requested multi-qubit gate +and apply $S^{\dagger}$ to each surviving operand. + +The built-in model consequently applies this policy only to `CZ` and rejects +source-level `CX` and `CY`. A hardware-backed model should instead be derived +from the device's actual gate decomposition, pulse ordering, interaction sign, +and loss detection timing. On the decoding side, deq represents the chosen +$S^{\dagger}$ response by its Pauli envelope $\{I,Z\}$; no native-MS circuit +gate or sampler rewrite is implied. This chapter is an **introduction**. It pairs the simplest physical loss model with the simplest decoding strategy deq currently ships: @@ -101,10 +200,9 @@ boolean knob, `replenish`: the syndrome. - **Loss-aware (`replenish=True`)**: at cycle end, teleport each data qubit `q` onto a fresh buddy `f`. Textbook teleportation is - `R f; CX q → f; MX q; CZ rec[-1] f` — the last step is a - classically-controlled Pauli QDK's Stim parser doesn't yet accept, - so we drop it. That's safe here because we read out only in Z at - the end and the omitted Pauli flips just a global sign. In this case, + `R f; CX q → f; MX q; CZ rec[-1] f`. The example omits the final + correction because it reads out only in Z, where that phase correction + cannot change the result. In this case, loss becomes a one-cycle random bit-flip the decoder attributes to `X_ERROR`, and code distance still achieves sub-threshold scaling. @@ -154,13 +252,11 @@ The omitted conditional `Z` correction is **safe only for a Z-basis memory experiment**. For an arbitrary logical state (X-basis prep, mid-circuit logical rotations, anything where the Pauli frame matters), the missing classical-feedforward `CZ rec[-1] q` would -leave a real bit-flip the decoder cannot recover from. QDK's Stim -parser currently rejects classical-controlled Paulis, so a full -loss-tolerant scheme over arbitrary input states is **not yet -implementable end-to-end through this pipeline** — it is the most -visible missing feature for any protocol whose observables are not -Z-basis-only. A follow-up chapter will revisit this once -physical-level teleportation lands in the QDK simulator. +leave a real phase error. QDK and deq now accept record-controlled Pauli +gates. If the controlling measurement is loss, QDK skips the gate and deq's +loss analysis adds the corresponding event-conditioned Pauli generator to the +target. An arbitrary-state protocol should therefore include the correction; +this Z-basis-only example omits it solely because it is observationally inert. --- @@ -298,9 +394,9 @@ own circuits or adapters: calls produce different shots even with the same seed. deq still passes it through so the contract is right when upstream wires it up. - QDK's Stim parser does **not** yet accept the compact `M(p) ` - noisy-measurement syntax, nor classical-control Paulis - (`CX rec[-1] `), nor `MPP`. Use `X_ERROR(p) ; M ` for - noisy measurement. + noisy-measurement syntax or `MPP`. Use `X_ERROR(p) ; M ` for + noisy measurement. Record-controlled Paulis such as `CX rec[-1] ` + are supported; a loss-valued control skips the Pauli. --- From 5884f9b449c2e7c68e1e613ee849d16dfcc32c1c Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 16 Aug 2026 15:22:56 -0700 Subject: [PATCH 109/157] add loss transpiler --- deq/deq/transpiler/compose_builder.py | 8 +- deq/deq/transpiler/jit_library_builder.py | 4 +- deq/deq/transpiler/jit_noise_builder.py | 20 +- deq/deq/transpiler/jit_transpiler.py | 14 ++ deq/deq/transpiler/loss/transpiler.py | 249 ++++++++++++++++++++++ 5 files changed, 273 insertions(+), 22 deletions(-) create mode 100644 deq/deq/transpiler/loss/transpiler.py diff --git a/deq/deq/transpiler/compose_builder.py b/deq/deq/transpiler/compose_builder.py index 036cbc76..7439e4ce 100644 --- a/deq/deq/transpiler/compose_builder.py +++ b/deq/deq/transpiler/compose_builder.py @@ -1031,8 +1031,8 @@ def _build_repropagated_compose( codes: Mapping[str, CodeDefinition], ptype_of_code: Mapping[str, int], port_types: list[jit_pb.JitPortType], - library_has_loss: bool = True, - loss_model: "LossModel | None" = None, + library_has_loss: bool, + loss_model: "LossModel", ) -> "JitGadgetArtifacts": """Build a JitGadgetType for an ``@REPROPAGATE`` COMPOSE. @@ -1107,8 +1107,8 @@ def transpile_compose_jit_gadget_type( codes: Mapping[str, CodeDefinition], ptype_of_code: Mapping[str, int], port_types: list[jit_pb.JitPortType], - library_has_loss: bool = True, - loss_model: "LossModel | None" = None, + library_has_loss: bool, + loss_model: "LossModel", ) -> "JitGadgetArtifacts": """Transpile a composed gadget and retain annotation provenance.""" validate_compose( diff --git a/deq/deq/transpiler/jit_library_builder.py b/deq/deq/transpiler/jit_library_builder.py index f87a33b0..80baf95b 100644 --- a/deq/deq/transpiler/jit_library_builder.py +++ b/deq/deq/transpiler/jit_library_builder.py @@ -665,8 +665,8 @@ def _build_jit_gadget_type( ptype_of_code: dict[str, int], codes: dict[str, CodeDefinition], *, - library_has_loss: bool = True, - loss_model: LossModel | None = None, + library_has_loss: bool, + loss_model: LossModel, check_override: ( tuple[ list[tuple[frozenset[int], bool]], diff --git a/deq/deq/transpiler/jit_noise_builder.py b/deq/deq/transpiler/jit_noise_builder.py index 77db736e..cab77021 100644 --- a/deq/deq/transpiler/jit_noise_builder.py +++ b/deq/deq/transpiler/jit_noise_builder.py @@ -96,6 +96,7 @@ pauli_product_to_stim, resolve_measurement_ref_global, select_stabilizer_generators, + single_pauli_to_stim, ) from deq.transpiler.stim_constants import ( NOISE_INSTRUCTIONS, @@ -132,19 +133,6 @@ def _real_measurement_count(instr: Instruction) -> int: _PAULI_TO_INT = {"I": 0, "X": 1, "Y": 2, "Z": 3} -def _pauli_string_for_single( - num_qubits: int, qubit: int, pauli: str -) -> stim.PauliString: - if qubit < 0 or qubit >= num_qubits: - raise ValueError( - f"qubit index {qubit} out of range for gadget with {num_qubits} " - f"qubit(s) (valid range: 0..{num_qubits - 1})" - ) - ps = stim.PauliString(num_qubits) - ps[qubit] = _PAULI_TO_INT[pauli] - return ps - - def _pauli_string_for_pair( num_qubits: int, q1: int, p1: str, q2: int, p2: str ) -> stim.PauliString: @@ -212,7 +200,7 @@ def enumerate_noise_mechanisms( prob = float(args[0]) pauli = name[0] return [ - (_pauli_string_for_single(num_qubits, q, pauli), prob) + (single_pauli_to_stim(pauli, q, num_qubits), prob) for q in qubits if prob > 0 ] @@ -232,7 +220,7 @@ def enumerate_noise_mechanisms( out: list[tuple[stim.PauliString, float]] = [] for q in qubits: for pauli in ("X", "Y", "Z"): - out.append((_pauli_string_for_single(num_qubits, q, pauli), prob)) + out.append((single_pauli_to_stim(pauli, q, num_qubits), prob)) return out if name == "DEPOLARIZE2": @@ -271,7 +259,7 @@ def enumerate_noise_mechanisms( for q in qubits: for pauli, prob in probs.items(): if prob > 0: - out.append((_pauli_string_for_single(num_qubits, q, pauli), prob)) + out.append((single_pauli_to_stim(pauli, q, num_qubits), prob)) return out if name == "PAULI_CHANNEL_2": diff --git a/deq/deq/transpiler/jit_transpiler.py b/deq/deq/transpiler/jit_transpiler.py index 176abeda..fa61c69b 100644 --- a/deq/deq/transpiler/jit_transpiler.py +++ b/deq/deq/transpiler/jit_transpiler.py @@ -294,6 +294,20 @@ def max_qubit_index(statements: Sequence[GadgetStatement]) -> int: _PAULI_NAME_TO_INT: dict[str, int] = {"I": 0, "X": 1, "Y": 2, "Z": 3} +def single_pauli_to_stim( + pauli: str, qubit: int, num_qubits: int +) -> stim.PauliString: + """Build a ``stim.PauliString`` containing one non-identity Pauli.""" + if qubit < 0 or qubit >= num_qubits: + raise ValueError( + f"qubit index {qubit} out of range for gadget with {num_qubits} " + f"qubit(s) (valid range: 0..{num_qubits - 1})" + ) + result = stim.PauliString(num_qubits) + result[qubit] = _PAULI_NAME_TO_INT[pauli.upper()] + return result + + def pauli_product_to_stim( product: PauliProduct, num_qubits: int, diff --git a/deq/deq/transpiler/loss/transpiler.py b/deq/deq/transpiler/loss/transpiler.py new file mode 100644 index 00000000..24491931 --- /dev/null +++ b/deq/deq/transpiler/loss/transpiler.py @@ -0,0 +1,249 @@ +"""Transpile ``LOSS_ERROR`` instructions into decoder loss metadata. + +This reuses the noise-error frame propagator: each loss-induced Pauli generator +is projected exactly like a ``DEPOLARIZE1``/``X_ERROR`` fault, so the gadget's +checks, readouts, and output observables are inferred by the existing tools. +The resulting footprints become probability-0 ``errors`` (activated by the loss +herald at runtime) and each declared loss links to the generator indices. +""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from typing import Sequence + +import stim + +import deq.proto.deq_bin_pb2 as bin_pb +import deq.proto.deq_jit_pb2 as jit_pb +import deq.proto.util_pb2 as util_pb +from deq.circuit.model import CodeDefinition, GadgetDefinition, OutputPort +from deq.transpiler.fault_propagation import ( + build_decomposed_body, + build_error_projection_context, + build_error_row_from_flips, + build_port_paulis, + propagate_pauli_mechanisms, +) +from deq.transpiler.jit_transpiler import ( + flatten_body, + max_qubit_index, + single_pauli_to_stim, +) +from deq.transpiler.loss.analysis import analyze_loss_events +from deq.transpiler.loss.api import LossModel +from deq.transpiler.loss.syntax import PhysicalPortLayout + + +@dataclass(frozen=True) +class LossModelArtifacts: + """Runtime loss metadata and newly projected errors for one gadget.""" + + model: bin_pb.GadgetType.LossModel | None + added_errors: tuple[jit_pb.JitGadgetType.Error, ...] = () + + +def _error_footprint(error_row: jit_pb.JitGadgetType.Error) -> tuple: + """Return the deduplication key (footprint) of an error row. + + Two errors with the same footprint flip the same checks, readouts, and + output residual, so a loss generator may reuse either interchangeably. + """ + return ( + tuple(error_row.finished_checks), + tuple(error_row.unfinished_checks), + tuple(error_row.base.residual), + tuple(error_row.base.readout_flips), + ) + + +def transpile_inferred_loss_model( + gadget: GadgetDefinition, + codes: dict[str, CodeDefinition], + *, + output_ports: list[OutputPort], + input_ports, + input_virtual_count: int, + finished_checks, + unfinished_checks, + ov_start: int, + readouts: Sequence[bin_pb.GadgetType.Readout], + physical_correction: util_pb.BitMatrix, + existing_errors: Sequence[jit_pb.JitGadgetType.Error], + loss_model: LossModel, +) -> LossModelArtifacts: + """Analyze a gadget and transpile its inferred loss metadata. + + The library builder calls this only when the library contains loss and this + gadget has no explicit ``LOSS`` model. A gadget without a local + ``LOSS_ERROR`` may still produce ``input_losses`` for loss entering through + its input ports. The result has no model only when neither a local nor an + entering loss event can exist. + + A loss generator whose footprint already matches one of ``existing_errors`` + reuses that error's index instead of appending a duplicate; only genuinely + new footprints are returned (to be appended after ``existing_errors``). + """ + analysis = analyze_loss_events(gadget, loss_model) + table = analysis.graph + exit_qubits_by_event = analysis.exit_qubits_by_event + input_event_id_by_qubit = analysis.input_event_id_by_qubit + if not table.events: + return LossModelArtifacts(model=None) + input_layout = PhysicalPortLayout(input_ports, codes) + output_layout = PhysicalPortLayout(output_ports, codes) + input_event_ids = frozenset(input_event_id_by_qubit.values()) + fresh_events = [ + event for event in table.events if event.event_id not in input_event_ids + ] + fresh_position = {event.event_id: index for index, event in enumerate(fresh_events)} + + body_flat = flatten_body(list(gadget.body)) + num_qubits = max(max_qubit_index(list(gadget.body)) + 1, 0) + decomposed = build_decomposed_body(body_flat) + output_stabilizer_paulis, frame_column_paulis = build_port_paulis( + output_ports, codes, num_qubits + ) + context = build_error_projection_context( + output_ports=output_ports, + codes=codes, + input_virtual_count=input_virtual_count, + finished_checks=finished_checks, + unfinished_checks=unfinished_checks, + output_virtual_start=ov_start, + readouts=readouts, + physical_correction=physical_correction, + ) + + # Collect one projection mechanism per (event, kind, generator). + specs: list[tuple[int, str, str]] = [] + mechanisms: list[tuple[int, stim.PauliString]] = [] + for event in table.events: + for kind, insertions in ( + ("source", event.source_pauli_insertions), + ("continuation", event.continuation_pauli_insertions), + ): + for insertion in insertions: + for generator in insertion.generators: + specs.append((event.event_id, kind, f"loss{event.event_id}")) + mechanisms.append( + ( + insertion.boundary, + single_pauli_to_stim( + generator, insertion.qubit, num_qubits + ), + ) + ) + + flips = propagate_pauli_mechanisms( + mechanisms, + decomposed, + num_qubits, + output_stabilizer_paulis, + frame_column_paulis, + ) + + errors: list[jit_pb.JitGadgetType.Error] = [] + existing_error_count = len(existing_errors) + # Seed the footprint map with the caller's regular errors so a loss + # generator that matches one reuses its index instead of duplicating it. + # First occurrence wins, matching a linear scan over ``existing_errors``. + index_by_footprint: dict[tuple, int] = {} + for existing_index, existing_error in enumerate(existing_errors): + index_by_footprint.setdefault(_error_footprint(existing_error), existing_index) + source_error_indices: dict[int, set[int]] = defaultdict(set) + continuation_error_indices: dict[int, set[int]] = defaultdict(set) + for ( + (event_id, kind, site_name), + ( + _walk_start, + pauli, + ), + flip, + ) in zip(specs, mechanisms, flips): + error_row = build_error_row_from_flips( + site_name=site_name, + site_pauli=pauli, + probability=0.0, + flips=flip, + context=context, + ) + if error_row is None: + continue + footprint = _error_footprint(error_row) + error_index = index_by_footprint.get(footprint) + if error_index is None: + error_index = existing_error_count + len(errors) + index_by_footprint[footprint] = error_index + errors.append(error_row) + # An entering loss (input_losses) has no source in this gadget: all of its + # generators are continuation effects, so route its "source" insertions to + # the continuation set too. + is_continuation = kind != "source" or event_id in input_event_ids + target = continuation_error_indices if is_continuation else source_error_indices + target[event_id].add(error_index) + + # Flat output-port position of each output physical qubit, so a loss that is + # still active on an output qubit at the gadget end can be recorded as + # leaving on that port position (``child_output_qubits``). + output_qubit_to_slot = output_layout.qubit_slots() + + def _exit_slots(event_id: int) -> list[int]: + return sorted( + output_qubit_to_slot[qubit] + for qubit in exit_qubits_by_event.get(event_id, ()) + if qubit in output_qubit_to_slot + ) + + # Fresh (in-gadget) losses become ``losses``; entering losses become + # ``input_losses``. ``child_losses`` index into the fresh ``losses`` list, so + # build the fresh-position map first and remap successors through it. + events_by_id = {event.event_id: event for event in table.events} + successors_by_event: dict[int, tuple[int, ...]] = { + event.event_id: table.successor_event_ids[index] + for index, event in enumerate(table.events) + } + + def _child_losses(event_id: int) -> list[int]: + return sorted( + fresh_position[successor] + for successor in successors_by_event[event_id] + if successor in fresh_position + ) + + losses_pb: list[bin_pb.GadgetType.LossModel.Loss] = [] + for event in fresh_events: + losses_pb.append( + bin_pb.GadgetType.LossModel.Loss( + probability=event.loss_probability, + source_errors=sorted(source_error_indices[event.event_id]), + continuation_errors=sorted(continuation_error_indices[event.event_id]), + child_losses=_child_losses(event.event_id), + child_output_qubits=_exit_slots(event.event_id), + loss_measurements=sorted(event.loss_measurements), + ) + ) + + # Flat input-port slot of each input physical qubit; entering-loss generators + # are all continuation (the loss did not originate in this gadget). + input_qubit_to_slot = input_layout.qubit_slots() + input_losses = [ + bin_pb.GadgetType.LossModel.InputLoss() for _ in range(input_layout.size) + ] + for qubit, event_id in input_event_id_by_qubit.items(): + input_losses[input_qubit_to_slot[qubit]] = ( + bin_pb.GadgetType.LossModel.InputLoss( + continuation_errors=sorted(continuation_error_indices[event_id]), + child_losses=_child_losses(event_id), + child_output_qubits=_exit_slots(event_id), + loss_measurements=sorted(events_by_id[event_id].loss_measurements), + ) + ) + loss_model = bin_pb.GadgetType.LossModel( + losses=losses_pb, input_losses=input_losses + ) + return LossModelArtifacts( + model=loss_model, + added_errors=tuple(errors), + ) From fbe7f43a6a0642588d23d55ff3609cea1e99bd29 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 16 Aug 2026 15:27:38 -0700 Subject: [PATCH 110/157] bump minor version because we are adding a significant feature with several API breaking changes --- deq/deq_runtime/Cargo.toml | 2 +- deq/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deq/deq_runtime/Cargo.toml b/deq/deq_runtime/Cargo.toml index c407ca0b..1f572b9e 100644 --- a/deq/deq_runtime/Cargo.toml +++ b/deq/deq_runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deq-runtime" -version = "0.4.1" +version = "0.5.0-rc1" edition = "2024" authors = ["Microsoft Corporation"] description = "deq: Real-time Quantum Error Correction Decoding System" diff --git a/deq/pyproject.toml b/deq/pyproject.toml index 853401cd..f9f08dfe 100644 --- a/deq/pyproject.toml +++ b/deq/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "deq" dynamic = [] -version = "0.4.1" +version = "0.5.0rc1" description = "deq: quantum error correction decoding system." readme = "README.md" license = { text = "MIT" } From 914cdff406801651891cdfe0e0ec050bdbcf6cfe Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 16 Aug 2026 15:30:10 -0700 Subject: [PATCH 111/157] gather stim conversion into a single place --- deq/deq/transpiler/fault_propagation.py | 37 ++------ deq/deq/transpiler/jit_noise_builder.py | 65 +++++--------- deq/deq/transpiler/jit_transpiler.py | 46 +--------- deq/deq/transpiler/stim_constants.py | 115 +++++++++++++++++++++++- 4 files changed, 144 insertions(+), 119 deletions(-) diff --git a/deq/deq/transpiler/fault_propagation.py b/deq/deq/transpiler/fault_propagation.py index 025281da..4f143339 100644 --- a/deq/deq/transpiler/fault_propagation.py +++ b/deq/deq/transpiler/fault_propagation.py @@ -29,13 +29,15 @@ ) from deq.transpiler.jit_transpiler import ( PortColumnLayout, - pauli_product_to_stim, select_stabilizer_generators, ) from deq.transpiler.stim_constants import ( ANNOTATION_INSTRUCTIONS, NOISE_INSTRUCTIONS_ALL, + format_pauli_string, instruction_num_measurements, + pauli_product_to_stim, + pauli_string_to_sparse, ) @@ -248,17 +250,6 @@ def build_error_projection_context( _FRAME_H = UnitaryOpcode.Hadamard _FRAME_S = UnitaryOpcode.SqrtZ _FRAME_CX = UnitaryOpcode.ControlledX -_PAULI_NAMES = ["I", "X", "Y", "Z"] - - -def _to_sparse_pauli(pauli: stim.PauliString) -> SparsePauli: - return SparsePauli( - { - qubit: _PAULI_NAMES[pauli[qubit]] - for qubit in range(len(pauli)) - if pauli[qubit] - } - ) def _apply_instruction( @@ -332,7 +323,9 @@ def propagate_pauli_mechanisms( def inject_at(boundary: int) -> None: nonlocal injected for shot in shots_by_start.get(boundary, ()): - propagator.inject_pauli(shot, _to_sparse_pauli(mechanisms[shot][1])) + propagator.inject_pauli( + shot, pauli_string_to_sparse(mechanisms[shot][1]) + ) injected += 1 real_measurement_outcomes: list[int] = [] @@ -343,11 +336,11 @@ def inject_at(boundary: int) -> None: assert injected == shot_count, "each mechanism must be injected exactly once" output_stabilizer_outcomes = [ - propagator.measure(_to_sparse_pauli(pauli)) + propagator.measure(pauli_string_to_sparse(pauli)) for pauli in output_stabilizer_paulis ] frame_column_outcomes = [ - propagator.measure(_to_sparse_pauli(pauli)) + propagator.measure(pauli_string_to_sparse(pauli)) for pauli in frame_column_paulis ] shots_by_outcome = [row.support for row in propagator.outcome_deltas.rows] @@ -378,18 +371,6 @@ def inject_at(boundary: int) -> None: ] -def _format_pauli(pauli: stim.PauliString) -> str: - terms = [ - f"{_PAULI_NAMES[pauli[qubit]]}{qubit}" - for qubit in range(len(pauli)) - if pauli[qubit] - ] - if not terms: - return "I" - sign = "-" if pauli.sign == -1 else "" - return sign + "*".join(terms) - - def build_error_row_from_flips( *, site_name: str, @@ -448,7 +429,7 @@ def build_error_row_from_flips( return jit_pb.JitGadgetType.Error( base=bin_pb.ErrorModelType.Error( - tag=f"{site_name} {_format_pauli(site_pauli)}", + tag=f"{site_name} {format_pauli_string(site_pauli)}", residual=sorted(residual), readout_flips=readout_flips, probability=probability, diff --git a/deq/deq/transpiler/jit_noise_builder.py b/deq/deq/transpiler/jit_noise_builder.py index cab77021..bd54109f 100644 --- a/deq/deq/transpiler/jit_noise_builder.py +++ b/deq/deq/transpiler/jit_noise_builder.py @@ -93,16 +93,20 @@ PortColumnLayout, flatten_body, max_qubit_index, - pauli_product_to_stim, resolve_measurement_ref_global, select_stabilizer_generators, - single_pauli_to_stim, ) from deq.transpiler.stim_constants import ( NOISE_INSTRUCTIONS, NOISE_INSTRUCTIONS_ALL, PASSTHROUGH_NOISE_INSTRUCTIONS, instruction_num_measurements, + pauli_name_to_int, + pauli_pair_to_stim, + pauli_product_to_stim, + pauli_string_to_symplectic, + pauli_terms_to_stim, + single_pauli_to_stim, ) from deq.transpiler.stim_constants import qubit_indices as _qubit_indices @@ -130,24 +134,6 @@ def _real_measurement_count(instr: Instruction) -> int: # --------------------------------------------------------------------------- -_PAULI_TO_INT = {"I": 0, "X": 1, "Y": 2, "Z": 3} - - -def _pauli_string_for_pair( - num_qubits: int, q1: int, p1: str, q2: int, p2: str -) -> stim.PauliString: - for q in (q1, q2): - if q < 0 or q >= num_qubits: - raise ValueError( - f"qubit index {q} out of range for gadget with {num_qubits} " - f"qubit(s) (valid range: 0..{num_qubits - 1})" - ) - ps = stim.PauliString(num_qubits) - ps[q1] = _PAULI_TO_INT[p1] - ps[q2] = _PAULI_TO_INT[p2] - return ps - - def enumerate_noise_mechanisms( instr: Instruction, num_qubits: int, @@ -244,7 +230,7 @@ def enumerate_noise_mechanisms( if p1 == "I" and p2 == "I": continue out.append( - (_pauli_string_for_pair(num_qubits, q1, p1, q2, p2), prob) + (pauli_pair_to_stim(p1, q1, p2, q2, num_qubits), prob) ) return out @@ -295,7 +281,9 @@ def enumerate_noise_mechanisms( continue out.append( ( - _pauli_string_for_pair(num_qubits, q1, label[0], q2, label[1]), + pauli_pair_to_stim( + label[0], q1, label[1], q2, num_qubits + ), prob, ) ) @@ -311,7 +299,7 @@ def enumerate_noise_mechanisms( return [] # Targets are Pauli targets like X3 Y4 Z5 (parsed as PauliTarget). # Build a single PauliString from them. - ps = stim.PauliString(num_qubits) + terms: list[tuple[int, str]] = [] for target in instr.targets: # PauliTarget has .pauli ("X"/"Y"/"Z") and .index. pauli = getattr(target, "pauli", None) @@ -323,8 +311,8 @@ def enumerate_noise_mechanisms( f"{name} target {target} references qubit {index} but the " f"gadget body only references qubits 0..{num_qubits - 1}" ) - ps[index] = _PAULI_TO_INT[pauli.upper()] - return [(ps, prob)] + terms.append((index, pauli)) + return [(pauli_terms_to_stim(terms, num_qubits), prob)] raise ValueError(f"Unsupported noise instruction: {name}") @@ -367,6 +355,7 @@ def walk_pauli_forward( """ flipped: set[int] = set() current = stim.PauliString(initial) + z_pauli = pauli_name_to_int("Z") instructions = decomposed.instructions meas_start_at = decomposed.measurement_start_at # Track measurement indices in stim-circuit order to resolve rec[-k]. @@ -410,7 +399,7 @@ def walk_pauli_forward( z_basis = stim.PauliString(num_qubits) for offset, q in enumerate(targets): real_idx = meas_start + offset - z_basis[q] = _PAULI_TO_INT["Z"] + z_basis[q] = z_pauli if not current.commutes(z_basis): flipped.add(real_idx) z_basis[q] = 0 @@ -421,7 +410,7 @@ def walk_pauli_forward( # Reset to |0⟩: any non-Z Pauli on q is absorbed. # Z commutes with the reset so it survives. p = current[q] - if p != 0 and p != _PAULI_TO_INT["Z"]: + if p != 0 and p != z_pauli: current[q] = 0 elif name == "MPAD": @@ -560,18 +549,18 @@ def _compute_pc_logical_via_flows( return [], set(), set() input_symp = [ - _pauli_string_to_symplectic(pauli, num_qubits) + pauli_string_to_symplectic(pauli, num_qubits) for pauli in input_frame_column_paulis ] output_symp = [ - _pauli_string_to_symplectic(pauli, num_qubits) + pauli_string_to_symplectic(pauli, num_qubits) for pauli in output_frame_column_paulis ] flow_in_symp = [ - _pauli_string_to_symplectic(g.input_copy(), num_qubits) for g in flows + pauli_string_to_symplectic(g.input_copy(), num_qubits) for g in flows ] flow_out_symp = [ - _pauli_string_to_symplectic(g.output_copy(), num_qubits) for g in flows + pauli_string_to_symplectic(g.output_copy(), num_qubits) for g in flows ] # Augmented symplectic system A · (Y, u, v)^T = 0: @@ -692,20 +681,6 @@ def _solve_column(rhs_col: list[int]) -> list[int] | None: return pc_entries, cp_entries, flip_entries -def _pauli_string_to_symplectic(ps: stim.PauliString, num_qubits: int) -> list[int]: - """Encode ``ps`` as a 2*num_qubits-bit symplectic vector ``[X|Z]``. - - Sign is ignored — sign is tracked separately via the affine - ``FLIP`` column of ``correction_propagation``. The input Pauli - string may be shorter or longer than ``num_qubits``; missing - positions are treated as identity, extra positions are dropped. - """ - xs, zs = ps.to_numpy(bit_packed=False) - n = min(len(xs), num_qubits) - pad = [0] * (num_qubits - n) - return [int(b) for b in xs[:n]] + pad + [int(b) for b in zs[:n]] + pad - - # --------------------------------------------------------------------------- # Top-level: compute noise errors for a gadget # --------------------------------------------------------------------------- diff --git a/deq/deq/transpiler/jit_transpiler.py b/deq/deq/transpiler/jit_transpiler.py index fa61c69b..a93859fd 100644 --- a/deq/deq/transpiler/jit_transpiler.py +++ b/deq/deq/transpiler/jit_transpiler.py @@ -136,6 +136,7 @@ def observable_of_column(column: ObservableColumn) -> int: from deq.transpiler.stim_constants import ( ANNOTATION_INSTRUCTIONS, NOISE_INSTRUCTIONS_ALL, + pauli_product_to_stim, ) # --------------------------------------------------------------------------- @@ -291,51 +292,6 @@ def max_qubit_index(statements: Sequence[GadgetStatement]) -> int: return max_idx -_PAULI_NAME_TO_INT: dict[str, int] = {"I": 0, "X": 1, "Y": 2, "Z": 3} - - -def single_pauli_to_stim( - pauli: str, qubit: int, num_qubits: int -) -> stim.PauliString: - """Build a ``stim.PauliString`` containing one non-identity Pauli.""" - if qubit < 0 or qubit >= num_qubits: - raise ValueError( - f"qubit index {qubit} out of range for gadget with {num_qubits} " - f"qubit(s) (valid range: 0..{num_qubits - 1})" - ) - result = stim.PauliString(num_qubits) - result[qubit] = _PAULI_NAME_TO_INT[pauli.upper()] - return result - - -def pauli_product_to_stim( - product: PauliProduct, - num_qubits: int, - local_to_global: dict[int, int] | None = None, -) -> stim.PauliString: - """Convert a :class:`PauliProduct` to a ``stim.PauliString``. - - Parameters - ---------- - product: - The Pauli product (code-local qubit indices). - num_qubits: - Total number of qubits in the target Pauli string. - local_to_global: - Optional mapping from code-local to gadget-global qubit indices. - When ``None``, indices are used as-is (identity mapping). - """ - ps = stim.PauliString(num_qubits) - for term in product.terms: - global_qubit = ( - local_to_global[term.index] - if local_to_global is not None - else term.index - ) - ps[global_qubit] = _PAULI_NAME_TO_INT[term.pauli.upper()] - return ps - - # --------------------------------------------------------------------------- # Measurement layout and code metadata helpers # --------------------------------------------------------------------------- diff --git a/deq/deq/transpiler/stim_constants.py b/deq/deq/transpiler/stim_constants.py index 1586a04c..e3b40013 100644 --- a/deq/deq/transpiler/stim_constants.py +++ b/deq/deq/transpiler/stim_constants.py @@ -1,10 +1,13 @@ -"""Shared Stim instruction name constants and helpers for the deq transpiler. +"""Shared Stim constants and conversion helpers for the deq transpiler. Classification sets are derived from ``stim.gate_data()`` at import time so they automatically stay in sync with the installed Stim version. """ +from collections.abc import Iterable + import stim +from paulimer import SparsePauli _GATE_DATA = stim.gate_data() _ALL_STIM_NAMES: frozenset[str] = frozenset( @@ -166,12 +169,122 @@ def instruction_num_measurements(instruction_text: str) -> int: from deq.circuit.model import ( CombinerTarget, Instruction, + PauliProduct, PauliTarget, QubitTarget, Target, ) +# ── Pauli conversion helpers ──────────────────────────────────────── + +_PAULI_TO_INT: dict[str, int] = {"I": 0, "X": 1, "Y": 2, "Z": 3} +_INT_TO_PAULI: tuple[str, ...] = ("I", "X", "Y", "Z") + + +def pauli_name_to_int(pauli: str) -> int: + """Return Stim's integer encoding for a Pauli name.""" + return _PAULI_TO_INT[pauli.upper()] + + +def pauli_terms_to_stim( + terms: Iterable[tuple[int, str]], num_qubits: int +) -> stim.PauliString: + """Build a ``stim.PauliString`` from ``(qubit, Pauli)`` terms.""" + result = stim.PauliString(num_qubits) + for qubit, pauli in terms: + if qubit < 0 or qubit >= num_qubits: + raise ValueError( + f"qubit index {qubit} out of range for gadget with {num_qubits} " + f"qubit(s) (valid range: 0..{num_qubits - 1})" + ) + result[qubit] = pauli_name_to_int(pauli) + return result + + +def single_pauli_to_stim( + pauli: str, qubit: int, num_qubits: int +) -> stim.PauliString: + """Build a ``stim.PauliString`` containing one specified Pauli.""" + return pauli_terms_to_stim(((qubit, pauli),), num_qubits) + + +def pauli_pair_to_stim( + first_pauli: str, + first_qubit: int, + second_pauli: str, + second_qubit: int, + num_qubits: int, +) -> stim.PauliString: + """Build a ``stim.PauliString`` containing two specified Paulis.""" + return pauli_terms_to_stim( + ((first_qubit, first_pauli), (second_qubit, second_pauli)), + num_qubits, + ) + + +def pauli_product_to_stim( + product: PauliProduct, + num_qubits: int, + local_to_global: dict[int, int] | None = None, +) -> stim.PauliString: + """Convert a circuit-model ``PauliProduct`` to a ``stim.PauliString``.""" + return pauli_terms_to_stim( + ( + ( + local_to_global[term.index] + if local_to_global is not None + else term.index, + term.pauli, + ) + for term in product.terms + ), + num_qubits, + ) + + +def pauli_string_to_symplectic( + pauli: stim.PauliString, num_qubits: int +) -> list[int]: + """Encode a Pauli string as a ``[X | Z]`` symplectic bit vector.""" + xs, zs = pauli.to_numpy(bit_packed=False) + included_qubits = min(len(xs), num_qubits) + padding = [0] * (num_qubits - included_qubits) + return ( + [int(bit) for bit in xs[:included_qubits]] + + padding + + [int(bit) for bit in zs[:included_qubits]] + + padding + ) + + +def pauli_string_to_sparse(pauli: stim.PauliString) -> SparsePauli: + """Convert a ``stim.PauliString`` to a paulimer ``SparsePauli``.""" + return SparsePauli( + { + qubit: _INT_TO_PAULI[pauli[qubit]] + for qubit in range(len(pauli)) + if pauli[qubit] + } + ) + + +def format_pauli_string(pauli: stim.PauliString) -> str: + """Format a ``stim.PauliString`` as indexed Pauli terms.""" + terms = [ + f"{_INT_TO_PAULI[pauli[qubit]]}{qubit}" + for qubit in range(len(pauli)) + if pauli[qubit] + ] + if not terms: + return "I" + sign = "-" if pauli.sign == -1 else "" + return sign + "*".join(terms) + + +# ── Target helpers ─────────────────────────────────────────────────── + + def qubit_indices(inst: Instruction) -> list[int]: """Extract qubit index integers from an instruction's targets.""" return [t.index for t in inst.targets if isinstance(t, QubitTarget)] From 8f1111aef5e60f8852bc21b12a31e2ebb8cee27d Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 16 Aug 2026 15:34:06 -0700 Subject: [PATCH 112/157] add loss syntax --- deq/deq/transpiler/loss/syntax.py | 295 ++++++++++++++++++++++++++ deq/deq/transpiler/loss/transpiler.py | 2 +- 2 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 deq/deq/transpiler/loss/syntax.py diff --git a/deq/deq/transpiler/loss/syntax.py b/deq/deq/transpiler/loss/syntax.py new file mode 100644 index 00000000..66ad966f --- /dev/null +++ b/deq/deq/transpiler/loss/syntax.py @@ -0,0 +1,295 @@ +"""Bidirectional codec between ``LOSS(...)`` syntax and runtime loss metadata. + +This pass does **not** analyze ``LOSS_ERROR`` instructions. It only packs the +explicit ``LOSS`` statements a user (or the annotator) writes into +``GadgetType.LossModel``, mirroring how ``ERROR`` statements are packed by +:func:`deq.transpiler.jit_library_builder._build_errors`. Deriving a loss +model from ``LOSS_ERROR`` circuit analysis is a separate transpiler pass. + +Index conventions (all local to the gadget): + +- ``SE`` / ``CE`` index ``JitGadgetType.errors``; +- ``L`` indexes the source ``losses`` list (a ``LOSS(p) ...`` statement's + position among source losses, in body order); +- ``OUT.L`` is output port ``i``, physical qubit ``j``; it is flattened + to a position in ``[0, sum of output-port n)`` using each code's ``n``; +- ``LOSS(IN.L)`` places the input continuation at the flat position + ``offset(input_port_i) + j`` of the ``input_losses`` array, which always has + exactly ``sum of input-port n`` entries. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +import deq.proto.deq_bin_pb2 as bin_pb +from deq.circuit.model import ( + CodeDefinition, + GadgetDefinition, + Instruction, + InputPort, + LossStatement, + OutputPort, +) +from deq.transpiler.jit_transpiler import flatten_body + + +@dataclass(frozen=True) +class PhysicalPortLayout: + """Flatten and unflatten physical-qubit positions across ordered ports.""" + + ports: Sequence[InputPort | OutputPort] + codes: dict[str, CodeDefinition] + + def __post_init__(self) -> None: + offsets: list[int] = [] + running = 0 + for port in self.ports: + offsets.append(running) + running += self.codes[port.code_name].n + object.__setattr__(self, "offsets", tuple(offsets)) + object.__setattr__(self, "size", running) + + def flatten(self, port: int, qubit: int, *, label: str) -> int: + if not 0 <= port < len(self.ports): + raise ValueError( + f"{label} references port {port}, but there are " + f"{len(self.ports)} ports" + ) + count = self.codes[self.ports[port].code_name].n + if not 0 <= qubit < count: + raise ValueError( + f"{label} references physical qubit {qubit} on port {port}, " + f"which has {count}" + ) + return self.offsets[port] + qubit + + def unflatten(self, slot: int, *, label: str) -> tuple[int, int]: + if not 0 <= slot < self.size: + raise ValueError( + f"{label} flat position {slot} is outside [0, {self.size})" + ) + for port, offset in enumerate(self.offsets): + count = self.codes[self.ports[port].code_name].n + if slot < offset + count: + return port, slot - offset + raise AssertionError("validated slot must resolve to a port") + + def qubit_slots(self) -> dict[int, int]: + return { + qubit: self.flatten(port, position, label="port qubit") + for port, item in enumerate(self.ports) + for position, qubit in enumerate(item.qubit_indices) + } + + def qubit_coordinates(self) -> dict[int, tuple[int, int]]: + return { + qubit: (port, position) + for port, item in enumerate(self.ports) + for position, qubit in enumerate(item.qubit_indices) + } + + +def transpile_declared_loss_model( + gadget: GadgetDefinition, + codes: dict[str, CodeDefinition], + *, + num_errors: int, + num_measurements: int, +) -> bin_pb.GadgetType.LossModel | None: + """Build a ``LossModel`` from a gadget's explicit ``LOSS`` statements. + + Returns ``None`` when the gadget declares no ``LOSS`` statements, so the + ``loss_model`` field is left unset for loss-free gadgets. + """ + flat = flatten_body(list(gadget.body)) + loss_statements = [s for s in flat if isinstance(s, LossStatement)] + if not loss_statements: + return None + + if any(isinstance(s, Instruction) and s.name.upper() == "LOSS_ERROR" for s in flat): + raise ValueError( + f"GADGET {gadget.name!r} mixes explicit LOSS statements with a " + f"LOSS_ERROR instruction; comment out LOSS_ERROR (or drop the LOSS " + f"statements) so the loss model has a single source of truth" + ) + + input_layout = PhysicalPortLayout(gadget.input_ports, codes) + output_layout = PhysicalPortLayout(gadget.output_ports, codes) + + source_statements = [s for s in loss_statements if not s.is_input] + input_statements = [s for s in loss_statements if s.is_input] + num_source = len(source_statements) + + def _flat_output(statement: LossStatement, port: int, qubit: int) -> int: + return output_layout.flatten( + port, + qubit, + label=f"GADGET {gadget.name!r}: {statement} output", + ) + + def _validate_refs( + statement: LossStatement, + *, + source_index: int | None = None, + ) -> None: + def _validate_unique(references: Sequence[str], label: str) -> None: + seen: set[str] = set() + for reference in references: + if reference in seen: + raise ValueError( + f"GADGET {gadget.name!r}: {statement} contains duplicate " + f"{label} reference {reference}" + ) + seen.add(reference) + + _validate_unique( + [f"SE{index}" for index in statement.source_errors], + "source-error", + ) + _validate_unique( + [f"CE{index}" for index in statement.continuation_errors], + "continuation-error", + ) + _validate_unique( + [f"L{index}" for index in statement.child_losses], + "child-loss", + ) + _validate_unique( + [f"OUT{port}.L{qubit}" for port, qubit in statement.output_qubits], + "output-qubit", + ) + _validate_unique( + [f"M{index}" for index in statement.measurement_indices], + "measurement", + ) + for index in ( + *statement.source_errors, + *statement.continuation_errors, + ): + if not 0 <= index < num_errors: + raise ValueError( + f"GADGET {gadget.name!r}: {statement} references error index " + f"{index}, but the gadget has {num_errors} errors" + ) + for index in statement.child_losses: + if not 0 <= index < num_source: + raise ValueError( + f"GADGET {gadget.name!r}: {statement} references child loss " + f"L{index}, but the gadget has {num_source} source losses" + ) + if source_index is not None and index <= source_index: + raise ValueError( + f"GADGET {gadget.name!r}: source loss L{source_index} " + f"references child L{index}; source children must have " + "greater indices" + ) + for index in statement.measurement_indices: + if not 0 <= index < num_measurements: + raise ValueError( + f"GADGET {gadget.name!r}: {statement} references measurement " + f"M{index}, but the gadget has {num_measurements} measurements" + ) + + losses_pb: list[bin_pb.GadgetType.LossModel.Loss] = [] + for source_index, statement in enumerate(source_statements): + _validate_refs(statement, source_index=source_index) + losses_pb.append( + bin_pb.GadgetType.LossModel.Loss( + probability=statement.probability, + continuation_errors=sorted(statement.continuation_errors), + source_errors=sorted(statement.source_errors), + child_losses=sorted(statement.child_losses), + child_output_qubits=sorted( + _flat_output(statement, port, qubit) + for port, qubit in statement.output_qubits + ), + loss_measurements=sorted(statement.measurement_indices), + ) + ) + + input_losses_pb = [ + bin_pb.GadgetType.LossModel.InputLoss() for _ in range(input_layout.size) + ] + occupied_slots: set[int] = set() + for statement in input_statements: + _validate_refs(statement) + port = statement.input_port + qubit = statement.input_qubit + assert port is not None and qubit is not None + slot = input_layout.flatten( + port, + qubit, + label=f"GADGET {gadget.name!r}: {statement} input", + ) + if slot in occupied_slots: + raise ValueError( + f"GADGET {gadget.name!r}: duplicate input loss for " + f"IN{port}.L{qubit}" + ) + occupied_slots.add(slot) + input_losses_pb[slot] = bin_pb.GadgetType.LossModel.InputLoss( + continuation_errors=sorted(statement.continuation_errors), + child_losses=sorted(statement.child_losses), + child_output_qubits=sorted( + _flat_output(statement, port, qubit) + for port, qubit in statement.output_qubits + ), + loss_measurements=sorted(statement.measurement_indices), + ) + + return bin_pb.GadgetType.LossModel( + losses=losses_pb, + input_losses=input_losses_pb, + ) + + +def loss_model_to_statements( + loss_model: bin_pb.GadgetType.LossModel, + *, + input_ports: Sequence[InputPort], + output_ports: Sequence[OutputPort], + codes: dict[str, CodeDefinition], + gadget_name: str, +) -> tuple[list[LossStatement], list[LossStatement]]: + """Decode runtime loss metadata into source and input ``LOSS`` statements.""" + input_layout = PhysicalPortLayout(input_ports, codes) + output_layout = PhysicalPortLayout(output_ports, codes) + + def output_qubits(slots) -> list[tuple[int, int]]: + return [ + output_layout.unflatten(slot, label=f"GADGET {gadget_name!r} output loss") + for slot in slots + ] + + source_statements = [ + LossStatement( + probability=loss.probability, + source_errors=list(loss.source_errors), + continuation_errors=list(loss.continuation_errors), + child_losses=list(loss.child_losses), + output_qubits=output_qubits(loss.child_output_qubits), + measurement_indices=list(loss.loss_measurements), + ) + for loss in loss_model.losses + ] + + input_statements: list[LossStatement] = [] + for slot, loss in enumerate(loss_model.input_losses): + if loss.SerializeToString() == b"": + continue + port, qubit = input_layout.unflatten( + slot, label=f"GADGET {gadget_name!r} input loss" + ) + input_statements.append( + LossStatement( + input_port=port, + input_qubit=qubit, + continuation_errors=list(loss.continuation_errors), + child_losses=list(loss.child_losses), + output_qubits=output_qubits(loss.child_output_qubits), + measurement_indices=list(loss.loss_measurements), + ) + ) + return source_statements, input_statements diff --git a/deq/deq/transpiler/loss/transpiler.py b/deq/deq/transpiler/loss/transpiler.py index 24491931..f4c3d0e9 100644 --- a/deq/deq/transpiler/loss/transpiler.py +++ b/deq/deq/transpiler/loss/transpiler.py @@ -29,11 +29,11 @@ from deq.transpiler.jit_transpiler import ( flatten_body, max_qubit_index, - single_pauli_to_stim, ) from deq.transpiler.loss.analysis import analyze_loss_events from deq.transpiler.loss.api import LossModel from deq.transpiler.loss.syntax import PhysicalPortLayout +from deq.transpiler.stim_constants import single_pauli_to_stim @dataclass(frozen=True) From 4f4d5d9a694f8a96399c49884de2d803588e7f88 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 16 Aug 2026 16:12:31 -0700 Subject: [PATCH 113/157] merge loss model --- deq/deq/spec/canonical.py | 302 ++++++++++++++++++++++++++++++++++---- 1 file changed, 277 insertions(+), 25 deletions(-) diff --git a/deq/deq/spec/canonical.py b/deq/deq/spec/canonical.py index 17a84ca8..91cad5a4 100644 --- a/deq/deq/spec/canonical.py +++ b/deq/deq/spec/canonical.py @@ -113,7 +113,10 @@ def canonicalize(lib: pb.Library) -> CanonicalForm: program = ExpandedProgram.from_library(lib) all_gids = set(program.gadgets.keys()) merged = merge(lib, all_gids, program=program) - return merged.to_canonical_form() + canonical = merged.to_canonical_form() + if lib.HasField("metadata"): + canonical.library.metadata.CopyFrom(lib.metadata) + return canonical def _gids_in_instantiation_order(program: ExpandedProgram) -> list[int]: @@ -262,6 +265,26 @@ class MergedError: tag: str = "" +@dataclass +class _MergedLossContinuation: + continuation_errors: set[int] = field(default_factory=set) + child_losses: set[tuple[int, int]] = field(default_factory=set) + child_output_qubits: set[int] = field(default_factory=set) + loss_measurements: set[int] = field(default_factory=set) + + def update(self, other: "_MergedLossContinuation") -> None: + self.continuation_errors.update(other.continuation_errors) + self.child_losses.update(other.child_losses) + self.child_output_qubits.update(other.child_output_qubits) + self.loss_measurements.update(other.loss_measurements) + + +@dataclass +class _MergedLossNode(_MergedLossContinuation): + probability: float = 0.0 + source_errors: set[int] = field(default_factory=set) + + @dataclass(frozen=True) class _MergeInputPort: """A merge-boundary input port.""" @@ -305,6 +328,8 @@ class MergedGadget: # ``merge()``). unfinished_checks: list[MergedCheck] errors: list[MergedError] + loss_model: pb.GadgetType.LossModel | None + output_physical_qubit_count: int # Traceability maps (local → global within the merged gadget) measurement_map: "Bijection[MeasurementIndex]" observable_map: "Bijection[ObservableIndex]" @@ -378,6 +403,8 @@ def _to_jit_check(mc: MergedCheck) -> jit_pb.JitGadgetType.Check: logical_correction=self.logical_correction, physical_correction=self.physical_correction, ) + if self.loss_model is not None: + base.loss_model.CopyFrom(self.loss_model) return jit_pb.JitGadgetType( base=base, finished_checks=[_to_jit_check(c) for c in self.finished_checks], @@ -410,21 +437,23 @@ def to_canonical_form(self) -> CanonicalForm: pb.PortType( ptype=1, observables=[pb.PortType.Observable()] * n_obs, + n=self.output_physical_qubit_count, ) ) - canonical.library.gadget_types.append( - pb.GadgetType( - gtype=1, - measurements=list(self.measurements), - outputs=[pb.GadgetType.Port(ptype=1)], - readouts=list(self.readouts), - correction_propagation=self.correction_propagation, - readout_propagation=self.readout_propagation, - logical_correction=self.logical_correction, - physical_correction=self.physical_correction, - ) + gadget_type = pb.GadgetType( + gtype=1, + measurements=list(self.measurements), + outputs=[pb.GadgetType.Port(ptype=1)], + readouts=list(self.readouts), + correction_propagation=self.correction_propagation, + readout_propagation=self.readout_propagation, + logical_correction=self.logical_correction, + physical_correction=self.physical_correction, ) + if self.loss_model is not None: + gadget_type.loss_model.CopyFrom(self.loss_model) + canonical.library.gadget_types.append(gadget_type) global_checks: list[pb.CheckModelType.Check] = [] for mc in self.finished_checks: @@ -623,7 +652,6 @@ def merge( for readout_idx in global_readout_set: cc_readout_set ^= {(readout_idx, global_ri_val)} - # Remote conditional corrections (from merge-set gadgets only) for gid in ordered_gids: if gid not in program.expanded_remote_conditional_corrections: @@ -642,9 +670,7 @@ def merge( f"the merge set; the conditional correction would be " f"silently lost in the merged form" ) - col_to_global_readout.append( - readout_map.atob[local_readout].readout_index - ) + col_to_global_readout.append(readout_map.atob[local_readout].readout_index) for row, col in zip(remote_cc.correction.i, remote_cc.correction.j): global_readout_idx = col_to_global_readout[col] @@ -776,19 +802,16 @@ def merge( remote_matrices = propagator.expanded_matrices[remote_gid] remote_meas_set: set[int] = set() for local_mi in remote_orig.measurement_indices: - lm = MeasurementIndex( - gid=remote_gid, measurement_index=local_mi - ) + lm = MeasurementIndex(gid=remote_gid, measurement_index=local_mi) if lm in measurement_map.atob: - remote_meas_set ^= { - measurement_map.atob[lm].measurement_index - } + remote_meas_set ^= {measurement_map.atob[lm].measurement_index} # Inherited deps from the remote gadget's input # observables that feed its readout via # ``readout_propagation``. - for input_local, readout_set in ( - remote_matrices.readout_propagation.items() - ): + for ( + input_local, + readout_set, + ) in remote_matrices.readout_propagation.items(): if remote_readout_idx not in readout_set: continue remote_connector = remote_gadget.connectors[input_local.port] @@ -1155,6 +1178,19 @@ def _resolve_measurement_ref( pc_set, rows=num_output_obs, cols=num_measurements ) + loss_model = _merge_loss_models( + program=program, + merge_gids=gid_set, + ordered_gids=ordered_gids, + input_ports=input_ports, + output_ports=output_ports, + measurement_map=measurement_map, + error_map=error_map, + ) + output_physical_qubit_count = sum( + program.port_types[port.ptype].n for port in output_ports + ) + return MergedGadget( input_ptypes=[ip.ptype for ip in input_ports], output_ptypes=[op.ptype for op in output_ports], @@ -1167,6 +1203,8 @@ def _resolve_measurement_ref( finished_checks=finished_checks, unfinished_checks=unfinished_checks, errors=merged_errors, + loss_model=loss_model, + output_physical_qubit_count=output_physical_qubit_count, measurement_map=measurement_map, observable_map=observable_map, readout_map=readout_map, @@ -1175,6 +1213,220 @@ def _resolve_measurement_ref( ) +def _merge_loss_models( + *, + program: ExpandedProgram, + merge_gids: frozenset[int], + ordered_gids: list[int], + input_ports: list[_MergeInputPort], + output_ports: list[_MergeOutputPort], + measurement_map: "Bijection[MeasurementIndex]", + error_map: "Bijection[ErrorIndex]", +) -> pb.GadgetType.LossModel | None: + """Compose static loss DAGs while quotienting internal port boundaries. + + Loss generators address the first error model attached to each gadget, as + required by ``GadgetType.loss_model``. If merge removes one of those errors + because its propagated footprint is empty, the corresponding generator is + removed too: it has no effect in the merged gadget and no replacement row is + created. + + Internal ``input_losses`` are virtual continuation nodes. Their generator + and herald payload is folded into each upstream node, and their real-loss + and output continuations are spliced directly into that node. Only input + losses on the merged gadget's external input boundary remain explicit. + """ + loss_models: dict[int, pb.GadgetType.LossModel] = {} + for gid in ordered_gids: + gadget_type = program.modified_gadget_types[gid] + if gadget_type.HasField("loss_model"): + loss_models[gid] = gadget_type.loss_model + if not loss_models: + return None + + primary_eid_by_gid: dict[int, int] = {} + for eid in _eids_in_instantiation_order(program): + error_model = program.error_models[eid] + check_model = program.check_models[error_model.cid] + if check_model.gid in merge_gids: + primary_eid_by_gid.setdefault(check_model.gid, eid) + + def remap_errors(gid: int, indices: Collection[int]) -> set[int]: + if not indices: + return set() + eid = primary_eid_by_gid.get(gid) + if eid is None: + raise ValueError( + f"gadget instance {gid} has loss generators but no attached error model" + ) + remapped: set[int] = set() + for index in indices: + merged = error_map.atob.get(ErrorIndex(eid=eid, error_index=index)) + if merged is not None: + remapped.add(merged.error_index) + return remapped + + def remap_measurements(gid: int, indices: Collection[int]) -> set[int]: + remapped: set[int] = set() + for index in indices: + local = MeasurementIndex(gid=gid, measurement_index=index) + if local not in measurement_map.atob: + raise ValueError( + f"loss herald measurement {index} of gadget instance {gid} " + "is not present in the merged gadget" + ) + remapped.add(measurement_map.atob[local].measurement_index) + return remapped + + def port_width(ptype: int) -> int: + return program.port_types[ptype].n + + for gid, model in loss_models.items(): + gadget_type = program.modified_gadget_types[gid] + expected_inputs = sum(port_width(port.ptype) for port in gadget_type.inputs) + if len(model.input_losses) != expected_inputs: + raise ValueError( + f"loss model of gadget instance {gid} has " + f"{len(model.input_losses)} input-loss slots, expected " + f"{expected_inputs} from its physical input ports" + ) + + def flat_slot_of_port(ports: Collection[pb.GadgetType.Port], port: int) -> int: + return sum(port_width(spec.ptype) for spec in list(ports)[:port]) + + def split_output_slot(gid: int, slot: int) -> tuple[int, int]: + gadget_type = program.modified_gadget_types[gid] + offset = 0 + for port, spec in enumerate(gadget_type.outputs): + width = port_width(spec.ptype) + if slot < offset + width: + return port, slot - offset + offset += width + raise ValueError( + f"loss output slot {slot} is out of range for gadget instance {gid}" + ) + + external_output_base: dict[tuple[int, int], int] = {} + output_offset = 0 + for port in output_ports: + external_output_base[(port.merge_gid, port.port_index)] = output_offset + output_offset += port_width(port.ptype) + + fresh_index_by_key: dict[tuple[int, int], int] = {} + for gid in ordered_gids: + model = loss_models.get(gid) + if model is None: + continue + for loss_index in range(len(model.losses)): + fresh_index_by_key[(gid, loss_index)] = len(fresh_index_by_key) + + input_memo: dict[tuple[int, int], _MergedLossContinuation] = {} + visiting_inputs: set[tuple[int, int]] = set() + + def resolve_output(gid: int, slot: int) -> _MergedLossContinuation: + port, qubit = split_output_slot(gid, slot) + output = OutputPortIndex(gid=gid, port_index=port) + peer = program.peer_input.get(output) + if peer is not None and peer.gid in merge_gids: + peer_type = program.modified_gadget_types[peer.gid] + input_slot = flat_slot_of_port(peer_type.inputs, peer.port_index) + qubit + return resolve_input(peer.gid, input_slot) + + base = external_output_base.get((gid, port)) + if base is None: + return _MergedLossContinuation() + return _MergedLossContinuation(child_output_qubits={base + qubit}) + + def resolve_input(gid: int, slot: int) -> _MergedLossContinuation: + key = (gid, slot) + if key in input_memo: + return input_memo[key] + if key in visiting_inputs: + raise ValueError("loss continuation graph contains an internal cycle") + visiting_inputs.add(key) + + result = _MergedLossContinuation() + model = loss_models.get(gid) + if model is not None and slot < len(model.input_losses): + input_loss = model.input_losses[slot] + result.continuation_errors.update( + remap_errors(gid, input_loss.continuation_errors) + ) + result.loss_measurements.update( + remap_measurements(gid, input_loss.loss_measurements) + ) + for child in input_loss.child_losses: + child_key = (gid, child) + if child_key not in fresh_index_by_key: + raise ValueError( + f"input loss of gadget instance {gid} references " + f"undefined child loss {child}" + ) + result.child_losses.add(child_key) + for child_output in input_loss.child_output_qubits: + result.update(resolve_output(gid, child_output)) + + visiting_inputs.remove(key) + input_memo[key] = result + return result + + merged_nodes: list[_MergedLossNode] = [] + for gid in ordered_gids: + model = loss_models.get(gid) + if model is None: + continue + for loss in model.losses: + node = _MergedLossNode( + probability=loss.probability, + source_errors=remap_errors(gid, loss.source_errors), + continuation_errors=remap_errors(gid, loss.continuation_errors), + loss_measurements=remap_measurements(gid, loss.loss_measurements), + ) + for child in loss.child_losses: + child_key = (gid, child) + if child_key not in fresh_index_by_key: + raise ValueError( + f"loss of gadget instance {gid} references undefined " + f"child loss {child}" + ) + node.child_losses.add(child_key) + for child_output in loss.child_output_qubits: + node.update(resolve_output(gid, child_output)) + merged_nodes.append(node) + + def child_indices(keys: Collection[tuple[int, int]]) -> list[int]: + return sorted(fresh_index_by_key[key] for key in keys) + + losses = [ + pb.GadgetType.LossModel.Loss( + probability=node.probability, + source_errors=sorted(node.source_errors), + continuation_errors=sorted(node.continuation_errors), + child_losses=child_indices(node.child_losses), + child_output_qubits=sorted(node.child_output_qubits), + loss_measurements=sorted(node.loss_measurements), + ) + for node in merged_nodes + ] + + input_losses: list[pb.GadgetType.LossModel.InputLoss] = [] + for port in input_ports: + gadget_type = program.modified_gadget_types[port.merge_gid] + input_base = flat_slot_of_port(gadget_type.inputs, port.port_index) + for qubit in range(port_width(port.ptype)): + continuation = resolve_input(port.merge_gid, input_base + qubit) + input_losses.append( + pb.GadgetType.LossModel.InputLoss( + continuation_errors=sorted(continuation.continuation_errors), + child_losses=child_indices(continuation.child_losses), + child_output_qubits=sorted(continuation.child_output_qubits), + loss_measurements=sorted(continuation.loss_measurements), + ) + ) + + return pb.GadgetType.LossModel(losses=losses, input_losses=input_losses) + + def _classify_merge_ports( program: ExpandedProgram, merge_gids: frozenset[int], From fe713e47bcb50e7ede3dd03f5252bd380f4547e3 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 16 Aug 2026 16:12:42 -0700 Subject: [PATCH 114/157] add tests --- .../runtime/test_qdk_sampler_loss_model.py | 22 ++ deq/tests/transpiler/loss_event_graph_test.py | 199 ++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 deq/tests/transpiler/loss_event_graph_test.py diff --git a/deq/tests/runtime/test_qdk_sampler_loss_model.py b/deq/tests/runtime/test_qdk_sampler_loss_model.py index 9aa52250..04fc3edd 100644 --- a/deq/tests/runtime/test_qdk_sampler_loss_model.py +++ b/deq/tests/runtime/test_qdk_sampler_loss_model.py @@ -77,6 +77,28 @@ def test_trapped_ion_qdk_sampler_applies_cz_residual_s_dagger( assert sampler.sample() == ("-0" if lost_qubit == 0 else "0-") +@pytest.mark.parametrize( + ("control_setup", "expected"), + [ + ("LOSS_ERROR(1) 1", "-0"), + ("X 1", "11"), + ], +) +def test_qdk_sampler_record_control_skips_loss_and_applies_one( + control_setup: str, expected: str +) -> None: + sampler = _SAMPLER.Sampler( + f"R 0 1\n{control_setup}\nM 1\nCX rec[-1] 0\nM 0\n", + { + "seed": 7, + "batch_size": 1, + "loss_config": NeutralAtomLossModel.config.to_json_object(), + }, + ) + + assert sampler.sample() == expected + + def test_config_applies_only_explicit_gate_overrides() -> None: noise = NoiseConfig() original_cx = noise.cx.on_loss diff --git a/deq/tests/transpiler/loss_event_graph_test.py b/deq/tests/transpiler/loss_event_graph_test.py new file mode 100644 index 00000000..71e747ff --- /dev/null +++ b/deq/tests/transpiler/loss_event_graph_test.py @@ -0,0 +1,199 @@ +"""Validation tests for the physical loss-event compiler IR.""" + +import pytest + +from deq.transpiler.loss.loss_graph import ( + LossBranch, + LossEvent, + PauliInsertion, + build_loss_event_graph, +) + + +def _event( + event_id: int, + *, + successor: int | None = None, + measurements: tuple[int, ...] = (), +) -> LossEvent: + return LossEvent( + event_id=event_id, + body_index=event_id, + target_index=0, + source_qubit=0, + loss_probability=0.1, + source_boundary=event_id, + branches=( + LossBranch( + qubit=0, + loss_boundary=event_id, + loss_measurements=measurements, + continuation_pauli_insertions=(), + successor_event_id=successor, + ), + ), + ) + + +def test_graph_canonicalizes_event_and_successor_order() -> None: + graph = build_loss_event_graph( + (_event(1), _event(0, successor=1)), measurement_count=0 + ) + + assert [event.event_id for event in graph.events] == [0, 1] + assert graph.successor_event_ids == ((1,), ()) + + +def test_graph_rejects_cycles() -> None: + with pytest.raises(ValueError, match="contains a cycle"): + build_loss_event_graph( + (_event(0, successor=1), _event(1, successor=0)), + measurement_count=0, + ) + + +def test_graph_rejects_unknown_successor() -> None: + with pytest.raises(ValueError, match="unknown successor"): + build_loss_event_graph((_event(0, successor=1),), measurement_count=0) + + +def test_graph_rejects_out_of_range_measurement() -> None: + with pytest.raises(ValueError, match="outside measurement_count"): + build_loss_event_graph((_event(0, measurements=(1,)),), measurement_count=1) + + +def test_graph_rejects_duplicate_event_ids() -> None: + with pytest.raises(ValueError, match="must be unique"): + build_loss_event_graph((_event(0), _event(0)), measurement_count=0) + + +@pytest.mark.parametrize( + "kwargs, message", + [ + ({"boundary": -1, "qubit": 0}, "boundary must be non-negative"), + ({"boundary": 0, "qubit": -1}, "qubit must be non-negative"), + ( + {"boundary": 0, "qubit": 0, "generators": ("I",)}, + "generators support only X, Y, and Z", + ), + ( + {"boundary": 0, "qubit": 0, "generators": ()}, + "requires at least one generator", + ), + ], +) +def test_pauli_insertion_rejects_invalid_values( + kwargs: dict[str, object], message: str +) -> None: + with pytest.raises(ValueError, match=message): + PauliInsertion(**kwargs) + + +def test_pauli_insertion_canonicalizes_generators() -> None: + insertion = PauliInsertion(0, 0, ("Y", "X", "Y")) + + assert insertion.generators == ("X", "Z") + + +@pytest.mark.parametrize( + "qubit, boundary", + [ + (-1, 0), + (0, -1), + ], +) +def test_loss_branch_rejects_negative_source(qubit: int, boundary: int) -> None: + with pytest.raises(ValueError, match="source must be non-negative"): + LossBranch(qubit, boundary, (), ()) + + +def test_loss_branch_rejects_negative_measurement() -> None: + with pytest.raises(ValueError, match="measurement indices must be non-negative"): + LossBranch(0, 0, (-1,), ()) + + +def test_loss_branch_rejects_negative_successor() -> None: + with pytest.raises(ValueError, match="successor event ID must be non-negative"): + LossBranch(0, 0, (), (), successor_event_id=-1) + + +@pytest.mark.parametrize( + "overrides, message", + [ + ({"event_id": -1}, "event ID must be non-negative"), + ({"body_index": -1}, "source indices must be non-negative"), + ({"target_index": -1}, "source indices must be non-negative"), + ({"source_qubit": -1}, "source must be non-negative"), + ({"source_boundary": -1}, "source must be non-negative"), + ({"loss_probability": 0.0}, r"probability must be in \(0, 1\]"), + ({"loss_probability": 1.1}, r"probability must be in \(0, 1\]"), + ({"branches": ()}, "must contain at least one branch"), + ( + {"branches": (LossBranch(1, 0, (), ()),)}, + "must contain its source branch", + ), + ], +) +def test_loss_event_rejects_invalid_values( + overrides: dict[str, object], message: str +) -> None: + values: dict[str, object] = { + "event_id": 0, + "body_index": 0, + "target_index": 0, + "source_qubit": 0, + "loss_probability": 0.1, + "source_boundary": 0, + "branches": (LossBranch(0, 0, (), ()),), + } + values.update(overrides) + + with pytest.raises(ValueError, match=message): + LossEvent(**values) + + +def test_loss_event_canonicalizes_and_aggregates_branch_data() -> None: + first_insertion = PauliInsertion(2, 0, ("X",)) + second_insertion = PauliInsertion(3, 1) + source_branch = LossBranch( + qubit=0, + loss_boundary=1, + loss_measurements=(0, 2, 2), + continuation_pauli_insertions=(first_insertion, first_insertion), + ) + child_branch = LossBranch( + qubit=1, + loss_boundary=2, + loss_measurements=(1,), + continuation_pauli_insertions=(second_insertion,), + ) + source_insertion = PauliInsertion(1, 0) + + event = LossEvent( + event_id=0, + body_index=0, + target_index=0, + source_qubit=0, + loss_probability=0.1, + source_boundary=1, + branches=(child_branch, source_branch, source_branch), + source_pauli_insertions=(source_insertion, source_insertion), + ) + + assert event.branches == (source_branch, child_branch) + assert event.affected_qubits == (0, 1) + assert event.loss_measurements == (0, 1, 2) + assert event.continuation_pauli_insertions == ( + first_insertion, + second_insertion, + ) + assert event.local_pauli_insertions == ( + source_insertion, + first_insertion, + second_insertion, + ) + + +def test_graph_rejects_self_successor() -> None: + with pytest.raises(ValueError, match="cannot imply itself"): + build_loss_event_graph((_event(0, successor=0),), measurement_count=0) From 11f65296b810f188aaee3e6204be5c0cce3a9c76 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 16 Aug 2026 18:51:22 -0700 Subject: [PATCH 115/157] add loss analysis test --- deq/tests/transpiler/loss_analysis_test.py | 887 +++++++++++++++++++++ 1 file changed, 887 insertions(+) create mode 100644 deq/tests/transpiler/loss_analysis_test.py diff --git a/deq/tests/transpiler/loss_analysis_test.py b/deq/tests/transpiler/loss_analysis_test.py new file mode 100644 index 00000000..7d296c0b --- /dev/null +++ b/deq/tests/transpiler/loss_analysis_test.py @@ -0,0 +1,887 @@ +"""Tests for physical loss-event analysis.""" + +from __future__ import annotations + +import pytest + +from deq.circuit.model import GadgetDefinition, Instruction, QubitTarget +from deq.circuit.parser import parse +from deq.transpiler.loss import LossEventGraph, PauliInsertion, analyze_loss_events +from deq.transpiler.loss.analysis import _split_source_occurrences +from deq.transpiler.loss.api import ( + GateLossPolicy, + QdkLossConfig, + UnsupportedLossModelError, +) +from deq.transpiler.loss.model_neutral_atom import NeutralAtomLossModel +from deq.transpiler.loss.model_trapped_ion import TrappedIonLossModel + + +def _gadget(source: str) -> GadgetDefinition: + qfile = parse(source) + return next( + definition + for definition in qfile.definitions + if isinstance(definition, GadgetDefinition) + ) + + +def _discover(source: str) -> LossEventGraph: + return analyze_loss_events(_gadget(source), NeutralAtomLossModel()).graph + + +class _PropagatingNeutralAtomLossModel(NeutralAtomLossModel): + config = QdkLossConfig(gate_policies=(("cz", GateLossPolicy.PROPAGATE),)) + + +def _instruction(source: str) -> Instruction: + gadget = _gadget(f"GADGET G {{ {source} }}") + return next( + statement for statement in gadget.body if isinstance(statement, Instruction) + ) + + +def _reachable_event_ids(graph: LossEventGraph, event_id: int) -> tuple[int, ...]: + event_index = {event.event_id: index for index, event in enumerate(graph.events)} + visited: set[int] = set() + pending = [event_id] + while pending: + current = pending.pop() + if current in visited: + continue + visited.add(current) + pending.extend(graph.successor_event_ids[event_index[current]]) + return tuple(sorted(visited)) + + +def _complete_loss_measurements( + graph: LossEventGraph, event_id: int +) -> tuple[int, ...]: + events = {event.event_id: event for event in graph.events} + return tuple( + sorted( + { + measurement + for reachable in _reachable_event_ids(graph, event_id) + for measurement in events[reachable].loss_measurements + } + ) + ) + + +def _complete_pauli_insertions( + graph: LossEventGraph, event_id: int +) -> tuple[PauliInsertion, ...]: + events = {event.event_id: event for event in graph.events} + insertions = set(events[event_id].source_pauli_insertions) + for reachable in _reachable_event_ids(graph, event_id): + insertions.update(events[reachable].continuation_pauli_insertions) + return tuple(sorted(insertions)) + + +class _RecordingHandler: + source_gate_names = frozenset() + + def __init__(self) -> None: + self.gates = [] + + def handle_loss_source(self, event_id, state) -> None: + state.add_source_pauli_insertion(event_id) + + def handle_gate(self, gate, state) -> None: + del state + self.gates.append(gate) + + +def test_paper_data_loss_cnot_circuit_produces_expected_boundaries() -> None: + table = _discover(""" + GADGET G { + LOSS_ERROR(0.1) 0 + CX 1 0 + LOSS_ERROR(0.1) 0 + CX 0 2 + LOSS_ERROR(0.1) 0 + CX 0 3 + LOSS_ERROR(0.1) 0 + CX 4 0 + LOSS_ERROR(0.1) 0 + M 0 + } + """) + events = table.events + source_boundaries = [event.source_boundary for event in events] + + assert table.measurement_count == 1 + assert [_complete_loss_measurements(table, event.event_id) for event in events] == [ + (0,) + ] * 5 + assert [_complete_pauli_insertions(table, event.event_id) for event in events] == [ + ( + PauliInsertion(source_boundaries[0], 0), + PauliInsertion(source_boundaries[1], 0), + PauliInsertion(source_boundaries[4], 0), + ), + ( + PauliInsertion(source_boundaries[1], 0), + PauliInsertion(source_boundaries[4], 0), + ), + ( + PauliInsertion(source_boundaries[2], 0), + PauliInsertion(source_boundaries[4], 0), + ), + ( + PauliInsertion(source_boundaries[3], 0), + PauliInsertion(source_boundaries[4], 0), + ), + (PauliInsertion(source_boundaries[4], 0),), + ] + assert table.successor_event_ids == ((1,), (2,), (3,), (4,), ()) + + +def test_consecutive_cnot_targets_share_the_later_suffix() -> None: + table = _discover(""" + GADGET G { + LOSS_ERROR(0.1) 0 + CX 1 0 + LOSS_ERROR(0.1) 0 + CX 2 0 + M 0 + } + """) + first, second = table.events + + assert _complete_pauli_insertions(table, first.event_id) == ( + PauliInsertion(first.source_boundary, 0), + PauliInsertion(1, 0), + PauliInsertion(2, 0), + ) + assert _complete_pauli_insertions(table, second.event_id) == ( + PauliInsertion(second.source_boundary, 0), + PauliInsertion(2, 0), + ) + + +def test_native_cz_and_s_do_not_insert_but_sx_does() -> None: + (event,) = _discover(""" + GADGET G { + LOSS_ERROR(0.1) 0 + CZ 0 1 # H 1; CX 0 1; H 1 + S 0 + SQRT_X 0 # H 0; S 0 ; H 0 + S 0 + M 0 + } + """).events + + assert event.local_pauli_insertions == ( + PauliInsertion(0, 0), + PauliInsertion(7, 0), + PauliInsertion(8, 0), + ) + + +def test_neutral_atom_model_moves_loss_through_physical_swap() -> None: + (event,) = analyze_loss_events( + _gadget(""" + GADGET G { + LOSS_ERROR(0.1) 0 + SWAP 0 1 + M 0 1 + } + """), + NeutralAtomLossModel(), + ).graph.events + + assert event.loss_measurements == (1,) + assert event.source_pauli_insertions == (PauliInsertion(0, 0),) + assert event.continuation_pauli_insertions == (PauliInsertion(1, 1),) + + +@pytest.mark.parametrize("lost_qubit", [0, 1]) +def test_propagate_policy_branches_loss_to_every_gate_operand( + lost_qubit: int, +) -> None: + survivor = 1 - lost_qubit + (event,) = analyze_loss_events( + _gadget(f""" + GADGET G {{ + LOSS_ERROR(0.1) {lost_qubit} + CZ 0 1 + CZ 0 1 + M 0 1 + }} + """), + _PropagatingNeutralAtomLossModel(), + ).graph.events + + branches = {branch.qubit: branch for branch in event.branches} + assert event.affected_qubits == (0, 1) + assert len(event.branches) == 2 + assert branches[lost_qubit].loss_boundary == 0 + assert branches[lost_qubit].continuation_pauli_insertions == ( + PauliInsertion(6, lost_qubit), + ) + assert branches[survivor].loss_boundary == 3 + assert branches[survivor].continuation_pauli_insertions == ( + PauliInsertion(3, survivor), + PauliInsertion(6, survivor), + ) + assert branches[0].loss_measurements == (0,) + assert branches[1].loss_measurements == (1,) + + +@pytest.mark.parametrize("gate", ["CX", "CY", "CZ"]) +@pytest.mark.parametrize("lost_qubit", [0, 1]) +def test_neutral_atom_supports_compiled_controlled_pauli_aliases( + gate: str, lost_qubit: int +) -> None: + (event,) = _discover(f""" + GADGET G {{ + LOSS_ERROR(0.1) {lost_qubit} + {gate} 0 1 + M 0 1 + }} + """).events + + assert event.affected_qubits == (lost_qubit,) + assert event.loss_measurements == (lost_qubit,) + + +@pytest.mark.parametrize("gate", ["CX", "CY"]) +def test_neutral_atom_lost_target_inserts_only_on_lost_qubit(gate: str) -> None: + (event,) = _discover(f""" + GADGET G {{ + LOSS_ERROR(0.1) 1 + {gate} 0 1 + M 0 1 + }} + """).events + + assert [ + (insertion.qubit, insertion.generators) + for insertion in event.continuation_pauli_insertions + ] == [(1, ("X", "Z"))] + + +@pytest.mark.parametrize("model", [NeutralAtomLossModel(), TrappedIonLossModel()]) +@pytest.mark.parametrize( + ("gate", "generator"), + [("CX", "X"), ("CY", "Y"), ("CZ", "Z")], +) +def test_lost_measurement_skips_classically_controlled_pauli( + model, gate: str, generator: str +) -> None: + (event,) = analyze_loss_events( + _gadget(f""" + GADGET G {{ + LOSS_ERROR(0.1) 1 + M 1 + {gate} rec[-1] 0 + M 0 + }} + """), + model, + ).graph.events + + assert event.loss_measurements == (0,) + assert any( + insertion.qubit == 0 and insertion.generators == (generator,) + for insertion in event.continuation_pauli_insertions + ) + + +def test_nonlost_measurement_control_adds_no_pauli_insertion() -> None: + (event,) = _discover(""" + GADGET G { + LOSS_ERROR(0.1) 2 + M 1 + CX rec[-1] 0 + M 2 + } + """).events + + assert all( + insertion.qubit != 0 for insertion in event.continuation_pauli_insertions + ) + + +def test_classical_control_resolves_older_measurement_record() -> None: + (event,) = _discover(""" + GADGET G { + LOSS_ERROR(0.1) 2 + M 2 + M 1 + CX rec[-2] 0 + } + """).events + + assert any( + insertion.qubit == 0 and insertion.generators == ("X",) + for insertion in event.continuation_pauli_insertions + ) + + +def test_neutral_atom_config_round_trips_canonical_json() -> None: + config = NeutralAtomLossModel.config + + assert config.policy_for("cx") == "SKIP" + assert config.policy_for("cy") == "SKIP" + assert config.policy_for("cz") == "SKIP" + assert config.policy_for("swap") == "APPLY_ANYWAY" + assert QdkLossConfig.from_json_object(config.to_json_object()) == config + assert config.to_json() == ( + '{"cx":"SKIP","cy":"SKIP","cz":"SKIP","swap":"APPLY_ANYWAY"}' + ) + + +def test_qdk_loss_config_normalizes_direct_string_policies() -> None: + config = QdkLossConfig( + gate_policies=tuple( + (gate, policy) + for gate, policy in NeutralAtomLossModel.config.to_json_object().items() + ), + ) + + assert config == NeutralAtomLossModel.config + + +def test_trapped_ion_config_matches_platform_rules() -> None: + config = TrappedIonLossModel.config + + assert config.policy_for("cz") == "RESIDUAL_S_DAGGER" + assert config.policy_for("swap") == "APPLY_ANYWAY" + assert config.to_json() == '{"cz":"RESIDUAL_S_DAGGER","swap":"APPLY_ANYWAY"}' + + +@pytest.mark.parametrize("lost_qubit", [0, 1]) +def test_trapped_ion_cz_adds_s_dagger_envelope_to_partner(lost_qubit: int) -> None: + (event,) = analyze_loss_events( + _gadget(f""" + GADGET G {{ + LOSS_ERROR(0.1) {lost_qubit} + CZ 0 1 + M 0 1 + }} + """), + TrappedIonLossModel(), + ).graph.events + + assert event.affected_qubits == (lost_qubit,) + assert event.loss_measurements == (lost_qubit,) + assert any( + insertion.qubit == 1 - lost_qubit and insertion.generators == ("Z",) + for insertion in event.continuation_pauli_insertions + ) + + +@pytest.mark.parametrize("gate", ["CX", "CY"]) +def test_trapped_ion_rejects_controlled_gate_without_supported_decomposition( + gate: str, +) -> None: + with pytest.raises( + UnsupportedLossModelError, + match=rf"supports CZ only; {gate} requires", + ): + analyze_loss_events( + _gadget(f""" + GADGET G {{ + LOSS_ERROR(0.1) 0 + {gate} 0 1 + M 0 1 + }} + """), + TrappedIonLossModel(), + ) + + +def test_lost_cx_control_adds_no_cx_insertion() -> None: + (event,) = _discover(""" + GADGET G { + LOSS_ERROR(0.1) 0 + CX 0 1 + M 0 1 + } + """).events + + assert all(insertion.qubit == 0 for insertion in event.local_pauli_insertions) + assert all( + insertion.generators == ("X", "Z") for insertion in event.local_pauli_insertions + ) + + +def test_hadamards_add_pauli_boundaries_after_each_gate() -> None: + (event,) = _discover(""" + GADGET G { + LOSS_ERROR(0.1) 0 + H 0 + S 0 + H 0 + M 0 + } + """).events + + assert tuple(insertion.boundary for insertion in event.local_pauli_insertions) == ( + 0, + 1, + 3, + ) + + +def test_measurement_indices_include_padding_measurements() -> None: + table = _discover(""" + GADGET G { + MPAD 0 + LOSS_ERROR(0.1) 0 1 + M 1 0 + } + """) + + assert table.measurement_count == 3 + assert [event.source_qubit for event in table.events] == [0, 1] + assert [event.loss_measurements for event in table.events] == [(2,), (1,)] + + +def test_measurement_does_not_terminate_loss_lifetime() -> None: + table = _discover(""" + GADGET G { + REPEAT 2 { + LOSS_ERROR(0.1) 0 + M 0 + } + } + """) + + assert [ + _complete_loss_measurements(table, event.event_id) for event in table.events + ] == [ + (0, 1), + (1,), + ] + assert table.successor_event_ids == ((1,), ()) + + +def test_nested_repeats_preserve_loss_and_measurement_order() -> None: + table = _discover(""" + GADGET G { + REPEAT 2 { + REPEAT 2 { + LOSS_ERROR(0.1) 0 + M 0 + } + } + } + """) + + assert table.measurement_count == 4 + assert [event.loss_measurements for event in table.events] == [ + (0,), + (1,), + (2,), + (3,), + ] + assert table.successor_event_ids == ((1,), (2,), (3,), ()) + + +def test_suffix_sharing_excludes_later_source_insertion() -> None: + table = _discover(""" + GADGET G { + LOSS_ERROR(0.1) 0 + S 0 + LOSS_ERROR(0.1) 0 + H 0 + M 0 + } + """) + + assert table.successor_event_ids == ((1,), ()) + assert table.events[0].source_pauli_insertions == (PauliInsertion(0, 0),) + assert table.events[0].continuation_pauli_insertions == () + assert table.events[1].source_pauli_insertions == (PauliInsertion(1, 0),) + assert table.events[1].continuation_pauli_insertions == (PauliInsertion(2, 0),) + assert _complete_pauli_insertions(table, 0) == ( + PauliInsertion(0, 0), + PauliInsertion(2, 0), + ) + + +def test_deep_loss_chain_stores_each_suffix_once() -> None: + depth = 100 + body = "\n".join("LOSS_ERROR(0.1) 0\nH 0" for _ in range(depth)) + table = _discover(f"GADGET G {{ {body}\nM 0 }}") + + assert len(table.events) == depth + assert sum(len(event.local_pauli_insertions) for event in table.events) <= 2 * depth + assert table.successor_event_ids[:-1] == tuple( + (event_id + 1,) for event_id in range(depth - 1) + ) + assert _complete_loss_measurements(table, 0) == (0,) + + +def test_measure_reset_resolves_loss_before_starting_fresh_lifetime() -> None: + table = _discover(""" + GADGET G { + LOSS_ERROR(0.1) 0 + MR 0 + LOSS_ERROR(0.1) 0 + M 0 + } + """) + + assert [event.loss_measurements for event in table.events] == [(0,), (1,)] + assert [event.source_boundary for event in table.events] == [0, 2] + + +@pytest.mark.parametrize("reset", ["RX", "RY"]) +def test_basis_reset_terminates_loss_without_heralding(reset: str) -> None: + (event,) = _discover(f"GADGET G {{ LOSS_ERROR(0.1) 0 {reset} 0 M 0 }}").events + + assert event.loss_measurements == () + + +@pytest.mark.parametrize("measure_reset", ["MRX", "MRY", "MRZ"]) +def test_basis_measure_reset_heralds_then_terminates_loss( + measure_reset: str, +) -> None: + table = _discover(f"GADGET G {{ LOSS_ERROR(0.1) 0 {measure_reset} 0 M 0 }}") + (event,) = table.events + + assert table.measurement_count == 2 + assert event.loss_measurements == (0,) + + +@pytest.mark.parametrize( + "measurement, expected_boundaries", + [("M", (0,)), ("MX", (0, 1, 3)), ("MY", (0, 2, 4))], +) +def test_single_qubit_measurement_bases_resolve_loss( + measurement: str, expected_boundaries: tuple[int, ...] +) -> None: + table = _discover(f""" + GADGET G {{ + LOSS_ERROR(0.1) 0 + {measurement} 0 + }} + """) + (event,) = table.events + + assert event.loss_measurements == (0,) + assert ( + tuple(insertion.boundary for insertion in event.local_pauli_insertions) + == expected_boundaries + ) + + +def test_zero_probability_loss_location_is_omitted() -> None: + table = _discover(""" + GADGET G { + LOSS_ERROR(0) 0 + M 0 + } + """) + + assert table.events == () + assert table.measurement_count == 1 + + +@pytest.mark.parametrize("target", ["rec[-1]", "sweep[0]", "X1"]) +def test_loss_error_rejects_non_qubit_targets(target: str) -> None: + with pytest.raises(ValueError, match="accepts only qubit targets"): + _discover(f"GADGET G {{ M 0 LOSS_ERROR(0.1) 0 {target} M 0 }}") + + +def test_no_loss_events_still_count_general_measurements() -> None: + table = _discover(""" + GADGET G { + MXX 0 1 + MPP Z0*Z1 X2 + } + """) + + assert table.events == () + assert table.measurement_count == 3 + + +def test_reset_can_terminate_loss_without_a_loss_measurement() -> None: + table = _discover(""" + GADGET G { + LOSS_ERROR(0.1) 0 + R 0 + M 0 + } + """) + (event,) = table.events + + assert event.loss_measurements == () + + +def test_loss_at_gadget_end_can_be_unheralded() -> None: + table = _discover(""" + GADGET G { + LOSS_ERROR(0.1) 0 + H 0 + } + """) + (event,) = table.events + + assert event.loss_measurements == () + assert event.local_pauli_insertions == ( + PauliInsertion(boundary=0, qubit=0), + PauliInsertion(boundary=1, qubit=0), + ) + + +def test_analysis_result_maps_entering_loss_to_gadget_exit() -> None: + result = analyze_loss_events( + _gadget(""" + CODE C [[1,1,1]] { LOGICAL X0 Z0 } + GADGET G { + INPUT C 0 + H 0 + OUTPUT C 0 + } + """), + NeutralAtomLossModel(), + ) + + assert result.input_event_id_by_qubit == {0: 0} + assert result.exit_qubits_by_event == {0: (0,)} + assert result.graph.events[0].source_qubit == 0 + + +def test_loss_in_gadget_with_ports_is_analyzed() -> None: + gadget = _gadget(""" + CODE C [[1,1,1]] { + LOGICAL X0 Z0 + } + + GADGET G { + INPUT C 0 + LOSS_ERROR(0.1) 0 + M 0 + OUTPUT C 0 + } + """) + + result = analyze_loss_events(gadget, NeutralAtomLossModel()) + assert len(result.graph.events) == 2 + assert result.input_event_id_by_qubit == {0: 1} + assert _complete_loss_measurements(result.graph, 0) == (0,) + assert _complete_loss_measurements(result.graph, 1) == (0,) + + +def test_pair_measurement_uses_stim_decomposition_fallback() -> None: + gadget = _gadget(""" + GADGET G { + LOSS_ERROR(0.1) 0 + MXX 0 1 + } + """) + + table = analyze_loss_events(gadget, NeutralAtomLossModel()).graph + + assert table.measurement_count == 1 + assert table.events[0].loss_measurements == (0,) + + +def test_overlapping_mpp_products_preserve_decomposed_boundaries() -> None: + class RecordingModel: + def __init__(self) -> None: + self.handler = _RecordingHandler() + + def create_handler(self): + return self.handler + + model = RecordingModel() + analyze_loss_events( + _gadget(""" + GADGET G { + LOSS_ERROR(0.1) 1 + MPP X0*X1 Z1*Z2 + } + """), + model, + ) + + assert [ + ( + gate.name, + gate.qubits, + gate.measurement_index, + gate.boundary_before, + gate.boundary_after, + ) + for gate in model.handler.gates + ] == [ + ("H", (0,), None, 0, 1), + ("H", (1,), None, 0, 1), + ("CX", (1, 0), None, 1, 2), + ("M", (0,), 0, 2, 3), + ("CX", (1, 0), None, 3, 4), + ("H", (0,), None, 4, 5), + ("H", (1,), None, 4, 5), + ("CX", (2, 1), None, 5, 6), + ("M", (1,), 1, 6, 7), + ("CX", (2, 1), None, 7, 8), + ] + + +def test_loss_model_handler_receives_individual_gate_occurrences() -> None: + class RecordingModel: + def __init__(self) -> None: + self.handler = _RecordingHandler() + + def create_handler(self): + return self.handler + + model = RecordingModel() + analyze_loss_events( + _gadget(""" + GADGET G { + LOSS_ERROR(0.1) 0 + H 0 1 + CX 0 1 2 3 + M 0 1 + R 0 + } + """), + model, + ) + + assert [(gate.name, gate.qubits) for gate in model.handler.gates] == [ + ("H", (0,)), + ("H", (1,)), + ("CX", (0, 1)), + ("CX", (2, 3)), + ("M", (0,)), + ("M", (1,)), + ("R", (0,)), + ] + assert [ + gate.measurement_index + for gate in model.handler.gates + if gate.produces_measurement + ] == [0, 1] + + +def test_non_native_gate_uses_stim_decomposition() -> None: + class RecordingModel: + def __init__(self) -> None: + self.handler = _RecordingHandler() + + def create_handler(self): + return self.handler + + model = RecordingModel() + analyze_loss_events(_gadget("GADGET G { LOSS_ERROR(0.1) 0 CZ 0 1 M 0 }"), model) + + assert [(gate.name, gate.qubits) for gate in model.handler.gates[:3]] == [ + ("H", (1,)), + ("CX", (0, 1)), + ("H", (1,)), + ] + assert all(gate.source_name == "CZ" for gate in model.handler.gates[:3]) + + +def test_classical_control_uses_stim_decomposition_fallback() -> None: + class RecordingModel: + def __init__(self) -> None: + self.handler = _RecordingHandler() + + def create_handler(self): + return self.handler + + model = RecordingModel() + analyze_loss_events( + _gadget(""" + GADGET G { + LOSS_ERROR(0.1) 1 + M 1 + CX rec[-1] 0 + } + """), + model, + ) + + classical_gate = model.handler.gates[1] + assert classical_gate.name == "CX" + assert classical_gate.qubits == (0,) + assert classical_gate.control_measurement_index == 0 + + +def test_source_gate_override_bypasses_stim_decomposition() -> None: + class RecordingHandler(_RecordingHandler): + source_gate_names = frozenset({"CZ"}) + + class RecordingModel: + def __init__(self) -> None: + self.handler = RecordingHandler() + + def create_handler(self): + return self.handler + + model = RecordingModel() + analyze_loss_events(_gadget("GADGET G { LOSS_ERROR(0.1) 0 CZ 0 1 M 0 }"), model) + + assert (model.handler.gates[0].name, model.handler.gates[0].qubits) == ( + "CZ", + (0, 1), + ) + + +@pytest.mark.parametrize( + "source, expected", + [ + ("MPAD 0 1", ["MPAD 0", "MPAD 1"]), + ("MPP X0*Y1 Z2", ["MPP X0*Y1", "MPP Z2"]), + ("H 0 1", ["H 0", "H 1"]), + ("CX 0 1 2 3", ["CX 0 1", "CX 2 3"]), + ("CX rec[-1] 0 rec[-2] 1", ["CX rec[-1] 0", "CX rec[-2] 1"]), + ], +) +def test_split_source_occurrences_preserves_gate_groups( + source: str, expected: list[str] +) -> None: + occurrences = _split_source_occurrences(_instruction(source)) + + assert [str(occurrence) for occurrence in occurrences] == expected + + +def test_split_source_occurrences_rejects_non_qubit_targets() -> None: + with pytest.raises(UnsupportedLossModelError, match="with non-qubit targets"): + _split_source_occurrences(_instruction("CX sweep[0] 0")) + + +def test_split_source_occurrences_rejects_incomplete_gate_group() -> None: + with pytest.raises(ValueError, match="requires target groups of size 2"): + _split_source_occurrences(Instruction(name="CX", targets=[QubitTarget(0)])) + + +def test_classical_control_rejects_measurement_before_gadget() -> None: + with pytest.raises(ValueError, match="before the gadget"): + _discover("GADGET G { LOSS_ERROR(0.1) 1 CX rec[-1] 0 }") + + +@pytest.mark.parametrize( + "statement, message", + [ + ("LOSS_ERROR 0", "requires exactly one probability"), + ("LOSS_ERROR(0.1, 0.2) 0", "requires exactly one probability"), + ("LOSS_ERROR(-0.1) 0", r"must be in \[0, 1\]"), + ("LOSS_ERROR(1.1) 0", r"must be in \[0, 1\]"), + ("LOSS_ERROR(0.1)", "requires at least one qubit target"), + ("LOSS_ERROR(0.1) 0 0", "contains a duplicate qubit target"), + ("LOSS_ERROR(0.1) !0", "qubit targets cannot be inverted"), + ], +) +def test_loss_error_rejects_invalid_source(statement: str, message: str) -> None: + with pytest.raises(ValueError, match=message): + _discover(f"GADGET G {{ {statement} }}") + + +def test_loss_model_must_return_complete_handler() -> None: + class InvalidModel: + def create_handler(self) -> object: + return object() + + with pytest.raises(TypeError, match="does not implement loss-source and gate"): + analyze_loss_events(_gadget("GADGET G { M 0 }"), InvalidModel()) From 5976138d7b6925cf1c58c20ad2643c9d6293eead Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 16 Aug 2026 18:53:59 -0700 Subject: [PATCH 116/157] rename to native_gates --- deq/deq/transpiler/loss/analysis.py | 8 ++++---- deq/deq/transpiler/loss/api.py | 2 +- deq/deq/transpiler/loss/model_neutral_atom.py | 2 +- deq/deq/transpiler/loss/model_trapped_ion.py | 2 +- deq/tests/transpiler/loss_analysis_test.py | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/deq/deq/transpiler/loss/analysis.py b/deq/deq/transpiler/loss/analysis.py index 7f57ad28..aa2b3ac9 100644 --- a/deq/deq/transpiler/loss/analysis.py +++ b/deq/deq/transpiler/loss/analysis.py @@ -592,7 +592,7 @@ def _loss_gates_for_instruction( measurement_index: int, boundary: int, span: int, - source_gate_names: frozenset[str], + native_gates: frozenset[str], ) -> tuple[list[LossGate], int]: source_name = statement.name.upper() try: @@ -605,7 +605,7 @@ def _loss_gates_for_instruction( if source_gate.name == "MPAD": return [], measurement_index + statement_measurement_count - if source_gate.name not in source_gate_names: + if source_gate.name not in native_gates: measurement_indices = tuple( range( measurement_index, @@ -675,7 +675,7 @@ def analyze_loss_events( f"loss model returned {type(handler).__name__}, which does not " "implement loss-source and gate handling" ) - source_gate_names = frozenset(name.upper() for name in handler.source_gate_names) + native_gates = frozenset(name.upper() for name in handler.native_gates) total_measurements = sum( instruction_num_measurements(str(statement)) for statement in body @@ -749,7 +749,7 @@ def analyze_loss_events( measurement_index=measurement_index, boundary=boundary, span=boundary_after - boundary, - source_gate_names=source_gate_names, + native_gates=native_gates, ) for gate in gates: handler.handle_gate(gate, state) diff --git a/deq/deq/transpiler/loss/api.py b/deq/deq/transpiler/loss/api.py index 76d0241c..e08991dc 100644 --- a/deq/deq/transpiler/loss/api.py +++ b/deq/deq/transpiler/loss/api.py @@ -201,7 +201,7 @@ def swap_losses( class LossGateHandler(Protocol): """Stateful per-gadget handler that receives one gate at a time.""" - source_gate_names: frozenset[str] + native_gates: frozenset[str] def handle_loss_source(self, event_id: int, state: LossAnalysisState) -> None: """Handle a newly created physical loss event.""" diff --git a/deq/deq/transpiler/loss/model_neutral_atom.py b/deq/deq/transpiler/loss/model_neutral_atom.py index b0fdb6d9..0a8598f3 100644 --- a/deq/deq/transpiler/loss/model_neutral_atom.py +++ b/deq/deq/transpiler/loss/model_neutral_atom.py @@ -42,7 +42,7 @@ class NeutralAtomLossGateHandler(LossGateHandler): """Use skipped lost-operand gates with physical SWAP relocation.""" - source_gate_names = frozenset( + native_gates = frozenset( { *_QDK_TABLE_BY_SOURCE_GATE, "S", diff --git a/deq/deq/transpiler/loss/model_trapped_ion.py b/deq/deq/transpiler/loss/model_trapped_ion.py index ce7ed711..b68e7a3d 100644 --- a/deq/deq/transpiler/loss/model_trapped_ion.py +++ b/deq/deq/transpiler/loss/model_trapped_ion.py @@ -36,7 +36,7 @@ class TrappedIonLossGateHandler(LossGateHandler): """Apply one explicit compiled-CZ residual-phase approximation.""" - source_gate_names = frozenset( + native_gates = frozenset( { *_QDK_TABLE_BY_SOURCE_GATE, *_UNSUPPORTED_CONTROLLED_GATES, diff --git a/deq/tests/transpiler/loss_analysis_test.py b/deq/tests/transpiler/loss_analysis_test.py index 7d296c0b..492a9d29 100644 --- a/deq/tests/transpiler/loss_analysis_test.py +++ b/deq/tests/transpiler/loss_analysis_test.py @@ -80,7 +80,7 @@ def _complete_pauli_insertions( class _RecordingHandler: - source_gate_names = frozenset() + native_gates = frozenset() def __init__(self) -> None: self.gates = [] @@ -810,7 +810,7 @@ def create_handler(self): def test_source_gate_override_bypasses_stim_decomposition() -> None: class RecordingHandler(_RecordingHandler): - source_gate_names = frozenset({"CZ"}) + native_gates = frozenset({"CZ"}) class RecordingModel: def __init__(self) -> None: From 85bcf3962c6129948be2e3cc4b84431377e615eb Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 16 Aug 2026 19:08:03 -0700 Subject: [PATCH 117/157] add loss transpiler test --- deq/tests/transpiler/loss_transpiler_test.py | 389 +++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 deq/tests/transpiler/loss_transpiler_test.py diff --git a/deq/tests/transpiler/loss_transpiler_test.py b/deq/tests/transpiler/loss_transpiler_test.py new file mode 100644 index 00000000..8bf7f02d --- /dev/null +++ b/deq/tests/transpiler/loss_transpiler_test.py @@ -0,0 +1,389 @@ +"""Inferred loss compiler tests, driven by the annotated file text. + +These assert on the ``annotate`` output (and its byte-equal round-trip) rather +than on loss-model proto internals, which keeps them compact and close to what +a user inspects. +""" + +from google.protobuf.json_format import MessageToDict + +from deq.circuit.parser import parse +from deq.cli.strip_tags import strip_jit_library +from deq.transpiler.jit_annotate import annotate as render_annotated +from deq.transpiler.jit_library_builder import build_jit_library +from deq.transpiler.loss import NeutralAtomLossModel, TrappedIonLossModel + +# The loss-decoding paper's data-loss CNOT chain: qubit 0 is lost at five points +# along its lifetime, pushed through CNOTs, then measured. +_PAPER = """ +CODE Reg [[5,5,1]] { + LOGICAL X0 Z0 + LOGICAL X1 Z1 + LOGICAL X2 Z2 + LOGICAL X3 Z3 + LOGICAL X4 Z4 +} +GADGET PaperDataLoss { + INPUT Reg 0 1 2 3 4 + LOSS_ERROR(0.1) 0 + CX 1 0 + LOSS_ERROR(0.1) 0 + CX 0 2 + LOSS_ERROR(0.1) 0 + CX 0 3 + LOSS_ERROR(0.1) 0 + CX 4 0 + LOSS_ERROR(0.1) 0 + M 0 + OUTPUT Reg 0 1 2 3 4 +} +""" + +_MINIMAL = """ +CODE Q [[1,1,1]] { LOGICAL X0 Z0 } +GADGET Loss1 { + INPUT Q 0 + LOSS_ERROR(0.2) 0 + M 0 + OUTPUT Q 0 +} +""" + + +def _loss_lines(rendered: str) -> list[str]: + return [ + line.strip() + for line in rendered.splitlines() + if line.lstrip().startswith("LOSS(") + ] + + +def test_forward_loss_annotation_round_trips() -> None: + for source in (_PAPER, _MINIMAL): + qfile = parse(source) + rendered = render_annotated(qfile) + original, _ = strip_jit_library(build_jit_library(qfile)) + annotated, _ = strip_jit_library(build_jit_library(parse(rendered))) + assert original.SerializeToString() == annotated.SerializeToString() + + +def test_jit_library_records_loss_strategy_metadata() -> None: + library = build_jit_library(parse(_MINIMAL)) + + assert ( + MessageToDict(library.metadata)["loss_strategy"] + == NeutralAtomLossModel.config.to_json_object() + ) + + +def test_neutral_atom_model_emits_source_envelope_and_herald_metadata() -> None: + gadget = build_jit_library( + parse(_MINIMAL), loss_model=NeutralAtomLossModel() + ).gadget_types[0] + (loss,) = gadget.base.loss_model.losses + + assert list(loss.loss_measurements) == [0] + assert list(loss.source_errors) + assert list(gadget.errors) + + +def test_neutral_atom_model_relocates_loss_through_swap() -> None: + source = """ + CODE Pair [[2,2,1]] { + LOGICAL X0 Z0 + LOGICAL X1 Z1 + } + GADGET G { + INPUT Pair 0 1 + LOSS_ERROR(0.1) 0 + SWAP 0 1 + M 0 1 + OUTPUT Pair 0 1 + } + """ + gadget = build_jit_library( + parse(source), loss_model=NeutralAtomLossModel() + ).gadget_types[0] + + assert list(gadget.base.loss_model.losses[0].loss_measurements) == [1] + + +def test_trapped_ion_cz_residual_becomes_continuation_error() -> None: + source = """ + CODE Pair [[2,2,1]] { + LOGICAL X0 Z0 + LOGICAL X1 Z1 + } + GADGET G { + INPUT Pair 0 1 + LOSS_ERROR(0.1) 0 + CZ 0 1 + M 0 + OUTPUT Pair 0 1 + } + """ + gadget = build_jit_library( + parse(source), loss_model=TrappedIonLossModel() + ).gadget_types[0] + (loss,) = gadget.base.loss_model.losses + continuation_errors = [gadget.errors[index] for index in loss.continuation_errors] + + assert any(list(error.base.residual) == [2] for error in continuation_errors) + + +def test_paper_loss_chain_has_one_herald_at_the_end() -> None: + rendered = render_annotated(parse(_PAPER)) + # Source losses (one per LOSS_ERROR); the entering-loss ``LOSS(IN...)`` lines + # for the input port are counted separately below. + source_lines = [ + line for line in _loss_lines(rendered) if not line.startswith("LOSS(IN") + ] + # One declared source loss per LOSS_ERROR. + assert len(source_lines) == 5 + # The chain is heralded by the single terminal measurement M0: only the + # last loss detects it directly; the rest inherit it through child links. + assert sum("M0" in line for line in source_lines) == 1 + assert "M0" in source_lines[-1] + + +def test_lost_controlled_pauli_target_dephases_control_in_z() -> None: + for gate in ("CX", "CY"): + source = f""" + CODE Pair [[2,2,1]] {{ + LOGICAL X0 Z0 + LOGICAL X1 Z1 + }} + GADGET G {{ + INPUT Pair 0 1 + LOSS_ERROR(0.1) 1 + {gate} 0 1 + M 1 + OUTPUT Pair 0 1 + }} + """ + gadget = build_jit_library(parse(source)).gadget_types[0] + (loss,) = gadget.base.loss_model.losses + generator_errors = [ + gadget.errors[index] + for index in (*loss.source_errors, *loss.continuation_errors) + ] + + assert all(error.base.tag.endswith("1") for error in generator_errors) + residual_span = {frozenset()} + for error in generator_errors: + residual = frozenset(error.base.residual) + residual_span.update( + candidate ^ residual for candidate in tuple(residual_span) + ) + assert frozenset({0}) in residual_span + assert frozenset({1}) not in residual_span + + +def test_lost_record_control_adds_target_continuation_error() -> None: + source = """ + CODE Pair [[2,2,1]] { + LOGICAL X0 Z0 + LOGICAL X1 Z1 + } + GADGET G { + INPUT Pair 0 1 + LOSS_ERROR(0.1) 1 + M 1 + CX rec[-1] 0 + OUTPUT Pair 0 1 + } + """ + gadget = build_jit_library(parse(source)).gadget_types[0] + (loss,) = gadget.base.loss_model.losses + continuation_errors = [gadget.errors[index] for index in loss.continuation_errors] + + assert list(loss.loss_measurements) == [0] + assert any(error.base.tag.endswith("X0") for error in continuation_errors) + + +def test_loss_error_is_followed_by_authoritative_loss_metadata() -> None: + rendered = render_annotated(parse(_PAPER)) + assert ( + "@SIMULATE_ONLY\n" + " LOSS_ERROR(0.1) 0\n" + " LOSS(0.1) SE0 SE1 CE0 CE2 L1 # L0" + ) in rendered + assert "ERROR(0.0) OUT0.LX2 OUT0.LX3 OUT0.LX4 # E0" in rendered + + +def test_continuation_generators_reference_canonical_error_rows() -> None: + lines = render_annotated(parse(_PAPER)).splitlines() + loss_line = next( + line for line in lines if line.strip().startswith("LOSS(0.1) SE0 SE1") + ) + assert "CE2" in loss_line + assert any(line.strip().endswith("# E2") for line in lines) + + +def test_degenerate_generators_share_canonical_error_indices() -> None: + rendered = render_annotated(parse(_PAPER)) + source_losses = [ + line.strip() + for line in rendered.splitlines() + if line.lstrip().startswith("LOSS(0.1)") + ] + assert "CE4 CE5" in source_losses[3] + assert "CE4 CE5" in source_losses[4] + + +def test_multi_target_gates_and_swap_round_trip() -> None: + # Multi-pair CX (one merged decomposed layer) and a native SWAP (loss + # relabelling) both used to overflow the loss-injection boundary and trip + # the "each mechanism must be injected exactly once" assertion. Verify the + # forward transpiler now annotates and round-trips them byte-for-byte. + qfile = parse( + """ + CODE C [[6,1,1]] { LOGICAL X0*X1*X2*X3*X4*X5 Z0 } + GADGET G { + INPUT C 0 1 2 3 4 5 + RZ 3 4 + CX 0 3 1 4 + SWAP 3 0 4 1 + LOSS_ERROR(0.1) 0 1 + CX 2 0 2 1 + MZ 3 4 + OUTPUT C 0 1 2 3 4 5 + } + """ + ) + rendered = render_annotated(qfile) + orig, _ = strip_jit_library(build_jit_library(qfile)) + anno, _ = strip_jit_library(build_jit_library(parse(rendered))) + assert orig.SerializeToString() == anno.SerializeToString() + + +_PAPER_WITH_NOISE = """ +CODE Reg [[5,5,1]] { + LOGICAL X0 Z0 + LOGICAL X1 Z1 + LOGICAL X2 Z2 + LOGICAL X3 Z3 + LOGICAL X4 Z4 +} +GADGET PaperDataLossNoise { + INPUT Reg 0 1 2 3 4 + LOSS_ERROR(0.1) 0 + Z_ERROR(0.001) 0 + CX 1 0 + LOSS_ERROR(0.1) 0 + CX 0 2 + LOSS_ERROR(0.1) 0 + CX 0 3 + LOSS_ERROR(0.1) 0 + CX 4 0 + LOSS_ERROR(0.1) 0 + M 0 + OUTPUT Reg 0 1 2 3 4 +} +""" + + +def test_regular_noise_errors_are_emitted_beside_source() -> None: + lines = render_annotated(parse(_PAPER_WITH_NOISE)).splitlines() + error_line = next( + i for i, line in enumerate(lines) if "ERROR(0.001)" in line and "# E0" in line + ) + output_line = next( + i for i, line in enumerate(lines) if line.strip().startswith("OUTPUT ") + ) + assert error_line < output_line + + +def test_loss_generators_dedup_against_regular_errors() -> None: + rendered = render_annotated(parse(_PAPER_WITH_NOISE)) + # The Z source generator of the first loss has the same footprint as the + # Z_ERROR(0.001) noise (error E0), so the LOSS line references E0 instead + # of appending a duplicate generator row. + first_loss = next( + line.strip() + for line in rendered.splitlines() + if line.lstrip().startswith("LOSS(0.1)") + ) + assert "SE0" in first_loss + + +def test_paper_with_noise_round_trips_byte_equivalent() -> None: + qfile = parse(_PAPER_WITH_NOISE) + rendered = render_annotated(qfile) + orig, _ = strip_jit_library(build_jit_library(qfile)) + anno, _ = strip_jit_library(build_jit_library(parse(rendered))) + assert orig.SerializeToString() == anno.SerializeToString() + + +def test_user_noise_and_loss_errors_round_trip_in_source_order() -> None: + qfile = parse( + """ + CODE C [[2,2,1]] { + LOGICAL X0 Z0 + LOGICAL X1 Z1 + } + GADGET G { + INPUT C 0 1 + LOSS_ERROR(0.1) 0 + Z_ERROR(0.001) 0 + ERROR(0.002) LX1 + M 0 + OUTPUT C 0 1 + } + """ + ) + + rendered = render_annotated(qfile) + original, _ = strip_jit_library(build_jit_library(qfile)) + annotated, _ = strip_jit_library(build_jit_library(parse(rendered))) + + lines = rendered.splitlines() + declared_error_line = next( + index + for index, line in enumerate(lines) + if line.lstrip().startswith("ERROR(0.002)") + ) + measurement_line = next( + index for index, line in enumerate(lines) if line.strip() == "M 0" + ) + noise_error_line = next( + index + for index, line in enumerate(lines) + if line.lstrip().startswith("ERROR(0.001)") + ) + assert noise_error_line < declared_error_line + assert declared_error_line < measurement_line + assert lines[noise_error_line].endswith("# E0") + assert lines[declared_error_line].endswith("# E1") + assert original.SerializeToString() == annotated.SerializeToString() + + +def test_loss_generators_are_probability_zero() -> None: + rendered = render_annotated(parse(_PAPER)) + # Loss-induced generators are footprint-only: activated by the herald at + # runtime, so their static ERROR rows carry probability 0. + error_lines = [ + line.strip() + for line in rendered.splitlines() + if line.lstrip().startswith("ERROR(") + ] + assert error_lines + assert all(line.startswith("ERROR(0.0)") for line in error_lines) + + +def test_minimal_forward_loss_emits_single_loss() -> None: + rendered = render_annotated(parse(_MINIMAL)) + # One source loss from the single LOSS_ERROR; the input port additionally + # yields one ``LOSS(IN...)`` entering-loss line. + source_lines = [ + line for line in _loss_lines(rendered) if not line.startswith("LOSS(IN") + ] + assert len(source_lines) == 1 + assert source_lines[0].startswith("LOSS(0.2)") + assert "M0" in source_lines[0] + + +def test_entering_loss_generator_uses_input_loss_origin() -> None: + rendered = render_annotated(parse(_MINIMAL)) + assert "ERROR(0.0) OUT0.LZ0 # E0" in rendered + assert "LOSS(IN0.L0) CE0 L0" in rendered From bee3339dc771261dd9490bd5eb3fae6dae62fbae Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Sun, 16 Aug 2026 19:35:23 -0700 Subject: [PATCH 118/157] simplify loss model API --- deq/deq/transpiler/loss/__init__.py | 53 ++- deq/deq/transpiler/loss/analysis.py | 15 +- deq/deq/transpiler/loss/api.py | 25 +- deq/deq/transpiler/loss/model_neutral_atom.py | 21 +- deq/deq/transpiler/loss/model_trapped_ion.py | 21 +- .../tutorial/chapters/qdk-loss-simulation.md | 38 +- deq/pyproject.toml | 1 + deq/tests/cli/loss_model_test.py | 440 ++++++++++++++++++ deq/tests/transpiler/loss_analysis_test.py | 82 +--- 9 files changed, 533 insertions(+), 163 deletions(-) create mode 100644 deq/tests/cli/loss_model_test.py diff --git a/deq/deq/transpiler/loss/__init__.py b/deq/deq/transpiler/loss/__init__.py index 9909accd..9780bfc4 100644 --- a/deq/deq/transpiler/loss/__init__.py +++ b/deq/deq/transpiler/loss/__init__.py @@ -5,11 +5,13 @@ by :mod:`deq.transpiler.loss.transpiler`. """ +from __future__ import annotations + import hashlib import importlib.util import sys from dataclasses import dataclass -from functools import lru_cache +from functools import cached_property, lru_cache from pathlib import Path from deq.transpiler.loss.analysis import LossAnalysisResult, analyze_loss_events @@ -17,19 +19,12 @@ GateLossPolicy, LossAnalysisState, LossGate, - LossGateHandler, LossModel, QdkLossConfig, UnsupportedLossModelError, ) -from deq.transpiler.loss.model_neutral_atom import ( - NeutralAtomLossGateHandler, - NeutralAtomLossModel, -) -from deq.transpiler.loss.model_trapped_ion import ( - TrappedIonLossGateHandler, - TrappedIonLossModel, -) +from deq.transpiler.loss.model_neutral_atom import NeutralAtomLossModel +from deq.transpiler.loss.model_trapped_ion import TrappedIonLossModel from deq.transpiler.loss.loss_graph import ( LossBranch, LossEvent, @@ -79,19 +74,30 @@ class _FileLossModel: path: str config: QdkLossConfig + native_gates: frozenset[str] - def create_handler(self) -> LossGateHandler: - """Create a handler from the model loaded in this process.""" - + @cached_property + def _model(self) -> LossModel: model = _load_loss_model_file(self.path) if model.config != self.config: raise ValueError(f"loss model file changed after loading: {self.path}") - handler = model.create_handler() - if not isinstance(handler, LossGateHandler): - raise ValueError( - f"loss model from {self.path} did not create a LossGateHandler" - ) - return handler + if model.native_gates != self.native_gates: + raise ValueError(f"loss model file changed after loading: {self.path}") + return model + + def handle_loss_source(self, event_id: int, state: LossAnalysisState) -> None: + self._model.handle_loss_source(event_id, state) + + def handle_gate(self, gate: LossGate, state: LossAnalysisState) -> None: + self._model.handle_gate(gate, state) + + def __reduce__( + self, + ) -> tuple[ + type[_FileLossModel], + tuple[str, QdkLossConfig, frozenset[str]], + ]: + return type(self), (self.path, self.config, self.native_gates) def create_loss_model(selector: str | Path) -> LossModel: @@ -111,7 +117,11 @@ def create_loss_model(selector: str | Path) -> LossModel: raise ValueError(f"loss model file does not exist: {path}") resolved = str(path.resolve()) model = _load_loss_model_file(resolved) - return _FileLossModel(path=resolved, config=model.config) + return _FileLossModel( + path=resolved, + config=model.config, + native_gates=model.native_gates, + ) supported = ", ".join(LOSS_MODEL_NAMES) raise ValueError( @@ -122,9 +132,7 @@ def create_loss_model(selector: str | Path) -> LossModel: __all__ = [ "NeutralAtomLossModel", - "NeutralAtomLossGateHandler", "TrappedIonLossModel", - "TrappedIonLossGateHandler", "GateLossPolicy", "QdkLossConfig", "LossBranch", @@ -133,7 +141,6 @@ def create_loss_model(selector: str | Path) -> LossModel: "LossAnalysisState", "LossAnalysisResult", "LossGate", - "LossGateHandler", "LossModel", "LOSS_MODEL_NAMES", "PauliInsertion", diff --git a/deq/deq/transpiler/loss/analysis.py b/deq/deq/transpiler/loss/analysis.py index aa2b3ac9..d686f762 100644 --- a/deq/deq/transpiler/loss/analysis.py +++ b/deq/deq/transpiler/loss/analysis.py @@ -21,7 +21,6 @@ from deq.transpiler.loss.api import ( LossAnalysisState, LossGate, - LossGateHandler, LossModel, UnsupportedLossModelError, ) @@ -669,13 +668,11 @@ def analyze_loss_events( body = flatten_body(list(gadget.body)) decomposed_body = build_decomposed_body(body) loss_sources = _collect_loss_sources(body) - handler = model.create_handler() - if not isinstance(handler, LossGateHandler): + if not isinstance(model, LossModel): raise TypeError( - f"loss model returned {type(handler).__name__}, which does not " - "implement loss-source and gate handling" + f"{type(model).__name__} does not implement the LossModel protocol" ) - native_gates = frozenset(name.upper() for name in handler.native_gates) + native_gates = frozenset(name.upper() for name in model.native_gates) total_measurements = sum( instruction_num_measurements(str(statement)) for statement in body @@ -713,7 +710,7 @@ def analyze_loss_events( probability=1.0, boundary=0, ) - handler.handle_loss_source(event_id, state) + model.handle_loss_source(event_id, state) measurement_index = 0 for body_index, statement in enumerate(body): boundary = decomposed_body.body_start_at[body_index] @@ -728,7 +725,7 @@ def analyze_loss_events( probability=probability, boundary=boundary, ) - handler.handle_loss_source(event_id, state) + model.handle_loss_source(event_id, state) if not isinstance(statement, Instruction): continue @@ -752,7 +749,7 @@ def analyze_loss_events( native_gates=native_gates, ) for gate in gates: - handler.handle_gate(gate, state) + model.handle_gate(gate, state) assert measurement_index == total_measurements # Qubits still carrying an active, *unheralded* loss branch when the body diff --git a/deq/deq/transpiler/loss/api.py b/deq/deq/transpiler/loss/api.py index e08991dc..bffa6f45 100644 --- a/deq/deq/transpiler/loss/api.py +++ b/deq/deq/transpiler/loss/api.py @@ -86,7 +86,7 @@ def from_json_object(cls, value: object) -> QdkLossConfig: @dataclass(frozen=True) class LossGate: - """One gate occurrence passed to a loss-model handler. + """One gate occurrence passed to a loss model. Multi-target Stim instructions are atomized before dispatch, so ``qubits`` contains exactly the physical operands of one gate application. A @@ -112,7 +112,7 @@ class LossGate: @runtime_checkable class LossAnalysisState(Protocol): - """Constrained mutation surface available to individual gate handlers.""" + """Constrained mutation surface available to a loss model.""" def active_event_ids(self, qubit: int) -> tuple[int, ...]: """Return source-event worlds with an active branch on ``qubit``.""" @@ -198,9 +198,14 @@ def swap_losses( @runtime_checkable -class LossGateHandler(Protocol): - """Stateful per-gadget handler that receives one gate at a time.""" +class LossModel(Protocol): + """Stateless physical loss rules shared across gadget analyses. + + Mutable traversal state is supplied explicitly through ``LossAnalysisState``. + Implementations must not retain per-gadget state between method calls. + """ + config: QdkLossConfig native_gates: frozenset[str] def handle_loss_source(self, event_id: int, state: LossAnalysisState) -> None: @@ -212,15 +217,3 @@ def handle_gate(self, gate: LossGate, state: LossAnalysisState) -> None: """Handle a source-level or decomposed primitive gate.""" ... - - -@runtime_checkable -class LossModel(Protocol): - """Configured physical loss model shared across gadget analyses.""" - - config: QdkLossConfig - - def create_handler(self) -> LossGateHandler: - """Create fresh mutable handler state for one gadget traversal.""" - - ... diff --git a/deq/deq/transpiler/loss/model_neutral_atom.py b/deq/deq/transpiler/loss/model_neutral_atom.py index 0a8598f3..91b9a7df 100644 --- a/deq/deq/transpiler/loss/model_neutral_atom.py +++ b/deq/deq/transpiler/loss/model_neutral_atom.py @@ -11,7 +11,6 @@ GateLossPolicy, LossAnalysisState, LossGate, - LossGateHandler, QdkLossConfig, ) from deq.transpiler.loss.policies import ( @@ -39,8 +38,10 @@ ) -class NeutralAtomLossGateHandler(LossGateHandler): - """Use skipped lost-operand gates with physical SWAP relocation.""" +class NeutralAtomLossModel: + """Neutral-atom platform model: SKIP gates and relocate atoms on SWAP.""" + + config = _NEUTRAL_ATOM_CONFIG native_gates = frozenset( { @@ -51,9 +52,6 @@ class NeutralAtomLossGateHandler(LossGateHandler): } ) - def __init__(self, config: QdkLossConfig = _NEUTRAL_ATOM_CONFIG) -> None: - self.config = config - def handle_loss_source(self, event_id: int, state: LossAnalysisState) -> None: handle_loss_source(event_id, state) @@ -73,17 +71,6 @@ def handle_gate(self, gate: LossGate, state: LossAnalysisState) -> None: handle_skip(gate, state) -class NeutralAtomLossModel: - """Neutral-atom platform model: SKIP gates and relocate atoms on SWAP.""" - - config = _NEUTRAL_ATOM_CONFIG - - def create_handler(self) -> LossGateHandler: - """Create independent state for one gadget traversal.""" - - return NeutralAtomLossGateHandler(self.config) - - def create_loss_model() -> NeutralAtomLossModel: """Create this model when the module is loaded as a plugin file.""" diff --git a/deq/deq/transpiler/loss/model_trapped_ion.py b/deq/deq/transpiler/loss/model_trapped_ion.py index b68e7a3d..e0e546c2 100644 --- a/deq/deq/transpiler/loss/model_trapped_ion.py +++ b/deq/deq/transpiler/loss/model_trapped_ion.py @@ -6,7 +6,6 @@ GateLossPolicy, LossAnalysisState, LossGate, - LossGateHandler, QdkLossConfig, UnsupportedLossModelError, ) @@ -33,8 +32,10 @@ ) -class TrappedIonLossGateHandler(LossGateHandler): - """Apply one explicit compiled-CZ residual-phase approximation.""" +class TrappedIonLossModel: + """Effective trapped-ion model for one specified CZ compilation.""" + + config = _TRAPPED_ION_CONFIG native_gates = frozenset( { @@ -46,9 +47,6 @@ class TrappedIonLossGateHandler(LossGateHandler): } ) - def __init__(self, config: QdkLossConfig = _TRAPPED_ION_CONFIG) -> None: - self.config = config - def handle_loss_source(self, event_id: int, state: LossAnalysisState) -> None: handle_loss_source(event_id, state) @@ -74,17 +72,6 @@ def handle_gate(self, gate: LossGate, state: LossAnalysisState) -> None: handle_skip(gate, state) -class TrappedIonLossModel: - """Effective trapped-ion model for one specified CZ compilation.""" - - config = _TRAPPED_ION_CONFIG - - def create_handler(self) -> LossGateHandler: - """Create independent state for one gadget traversal.""" - - return TrappedIonLossGateHandler(self.config) - - def create_loss_model() -> TrappedIonLossModel: """Create this model when the module is loaded as a plugin file.""" diff --git a/deq/documents/tutorial/chapters/qdk-loss-simulation.md b/deq/documents/tutorial/chapters/qdk-loss-simulation.md index e01cb367..59ead373 100644 --- a/deq/documents/tutorial/chapters/qdk-loss-simulation.md +++ b/deq/documents/tutorial/chapters/qdk-loss-simulation.md @@ -44,7 +44,9 @@ a supported CZ-plus-local-gates decomposition or supply a custom loss model. The selector also accepts a Python file. The file must define a zero-argument ``create_loss_model()`` function returning an object that implements -``LossModel`` (a ``QdkLossConfig`` in ``config`` plus ``create_handler()``): +``LossModel``: a ``QdkLossConfig`` in ``config``, a ``native_gates`` set, and +the stateless ``handle_loss_source()`` and ``handle_gate()`` methods. Subclassing +a built-in model is sufficient when only its configuration changes: ```python from deq.transpiler.loss import GateLossPolicy, QdkLossConfig @@ -135,29 +137,17 @@ and loss detection timing. On the decoding side, deq represents the chosen $S^{\dagger}$ response by its Pauli envelope $\{I,Z\}$; no native-MS circuit gate or sampler rewrite is implied. -This chapter is an **introduction**. It pairs the simplest physical -loss model with the simplest decoding strategy deq currently ships: - -- **Simulation side:** any two-qubit gate acting on a lost qubit becomes - the identity, loss does **not** propagate to the partner, and a later - measurement of a lost qubit reports neither a clean `0` nor a clean - `1`. Because loss can be injected anywhere in the circuit (gates, - idling, transport, readout, …), it must be modeled wherever atoms are - in flight, not only just before measurement. -- **Decoding side:** the coordinator applies **loss-random-imputation** - — every lost measurement bit is replaced with a fresh random bit - before the syndrome is computed. The decoder treats the syndrome - (approximately) from a measurement-flip channel, so any off-the-shelf - loss-unaware decoder works unchanged. Note that this - is an approximation: loss can have other effect than just random - measurement bit, and we will consider these effect in a later version. - -Both choices are deliberately the easiest things that work end-to-end; -they are **not** the best you can do. Richer models — gate-by-gate -propagation rules, heralded leakage, transport-induced loss, custom -per-platform loss channels — and richer decoding — erasure decoding, -edge re-weighting, loss-aware MWPM/BP, plug-in loss handlers — will be -the subject of follow-up chapters. +This chapter focuses on **sampling and transport**: how QDK produces a loss +result, how deq carries it as a `loss_mask`, and how random imputation chooses +the bit used to construct the syndrome. Imputation is not the loss decoder. It +is an orthogonal syndrome policy that applies whether loss information is +ignored, converted into edge reweights, or handed to a loss-aware decoder. + +deq now compiles each selected platform model into a Pauli-envelope generator +DAG and supports both ordinary-decoder reweighting and structured loss handoff. +The complete compiler and backend model, including small runnable examples and +the paper's four-CX chain, is covered in +[Pauli-envelope loss decoding](pauli-envelope-loss-decoding.md). Stim doesn't model loss as a first-class outcome. The [QDK](https://github.com/microsoft/qdk) stabilizer simulator does, via diff --git a/deq/pyproject.toml b/deq/pyproject.toml index f9f08dfe..6cf5e9ef 100644 --- a/deq/pyproject.toml +++ b/deq/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "networkx", "binar>=0.1.2,<0.2", "paulimer>=0.2.2,<0.3", + "qdk>=1.31,<1.32", ] [project.urls] diff --git a/deq/tests/cli/loss_model_test.py b/deq/tests/cli/loss_model_test.py new file mode 100644 index 00000000..a30fbfd1 --- /dev/null +++ b/deq/tests/cli/loss_model_test.py @@ -0,0 +1,440 @@ +"""CLI coverage for selecting inferred physical loss models.""" + +import pickle +from pathlib import Path + +import pytest +from google.protobuf.json_format import MessageToDict + +import deq.proto.deq_jit_pb2 as jit_pb +import deq.transpiler.loss.model_neutral_atom as neutral_atom_module +import deq.transpiler.loss.model_trapped_ion as trapped_ion_module +from deq.cli.annotate import annotate as annotate_file +from deq.cli.interpret import _load_library +from deq.cli.jit import transpile +from deq.cli.simulate import _resolve_jit_loss_config, _run_batch, simulate__ler +from deq.transpiler.loss import ( + NeutralAtomLossModel, + TrappedIonLossModel, + create_loss_model, +) + +_SOURCE = """ +CODE Pair [[2,2,1]] { + LOGICAL X0 Z0 + LOGICAL X1 Z1 +} +GADGET G { + INPUT Pair 0 1 + LOSS_ERROR(0.1) 0 + CZ 0 1 + M 0 1 + OUTPUT Pair 0 1 +} +""" + + +def _write_user_loss_model(tmp_path: Path) -> Path: + plugin = tmp_path / "user_loss.py" + plugin.write_text( + """ +from deq.transpiler.loss import GateLossPolicy, QdkLossConfig +from deq.transpiler.loss.model_neutral_atom import NeutralAtomLossModel + +class UserLossModel(NeutralAtomLossModel): + config = QdkLossConfig(gate_policies=( + ("cx", GateLossPolicy.PROPAGATE), + ("cy", GateLossPolicy.SKIP), + ("cz", GateLossPolicy.PROPAGATE), + ("swap", GateLossPolicy.APPLY_ANYWAY), + )) + +def create_loss_model(): + return UserLossModel() +""", + encoding="utf-8", + ) + return plugin + + +def _transpile_with_loss_model(tmp_path: Path, loss_model: str): + source = tmp_path / f"{loss_model}.deq" + output = tmp_path / f"{loss_model}.deq.jit" + source.write_text(_SOURCE, encoding="utf-8") + transpile( + str(source), + out=str(output), + jobs=1, + loss_model=loss_model, + skip_mako_warning=True, + ) + return jit_pb.JitLibrary.FromString(output.read_bytes()).gadget_types[0] + + +def test_transpile_accepts_neutral_atom_loss_model(tmp_path: Path) -> None: + gadget = _transpile_with_loss_model(tmp_path, "neutral-atom") + (loss,) = gadget.base.loss_model.losses + + assert list(loss.loss_measurements) == [0] + assert list(loss.source_errors) + + +def test_create_loss_model_returns_platform_model() -> None: + assert isinstance(create_loss_model("neutral-atom"), NeutralAtomLossModel) + assert isinstance(create_loss_model("trapped-ion"), TrappedIonLossModel) + + +@pytest.mark.parametrize( + ("module", "expected_config"), + [ + (neutral_atom_module, NeutralAtomLossModel.config), + (trapped_ion_module, TrappedIonLossModel.config), + ], +) +def test_builtin_loss_model_file_can_be_loaded_by_path(module, expected_config) -> None: + model = create_loss_model(Path(module.__file__)) + + assert model.config == expected_config + assert model.native_gates == module.create_loss_model().native_gates + + +@pytest.mark.parametrize( + ("module", "expected_config"), + [ + (neutral_atom_module, NeutralAtomLossModel.config), + (trapped_ion_module, TrappedIonLossModel.config), + ], +) +def test_transpile_accepts_builtin_loss_model_file_path( + tmp_path: Path, + module, + expected_config, +) -> None: + source = tmp_path / "input.deq" + output = tmp_path / "output.deq.jit" + source.write_text(_SOURCE, encoding="utf-8") + + transpile( + str(source), + out=str(output), + jobs=1, + loss_model=module.__file__, + skip_mako_warning=True, + ) + library = jit_pb.JitLibrary.FromString(output.read_bytes()) + + assert MessageToDict(library.metadata)["loss_strategy"] == ( + expected_config.to_json_object() + ) + + +def test_create_loss_model_loads_python_file(tmp_path: Path) -> None: + plugin = _write_user_loss_model(tmp_path) + + model = create_loss_model(plugin) + loaded_model = getattr(model, "_model") + assert getattr(model, "_model") is loaded_model + restored = pickle.loads(pickle.dumps(model)) + + assert model.config.policy_for("cx") == "PROPAGATE" + assert restored.config == model.config + assert restored.native_gates == model.native_gates + assert "_model" not in vars(restored) + + +def test_transpile_accepts_python_loss_model_file(tmp_path: Path) -> None: + plugin = _write_user_loss_model(tmp_path) + source = tmp_path / "input.deq" + output = tmp_path / "output.deq.jit" + source.write_text( + _SOURCE + """ +GADGET H { + INPUT Pair 0 1 + LOSS_ERROR(0.2) 1 + CZ 0 1 + M 0 1 + OUTPUT Pair 0 1 +} +""", + encoding="utf-8", + ) + + transpile( + str(source), + out=str(output), + jobs=2, + loss_model=str(plugin), + skip_mako_warning=True, + ) + library = jit_pb.JitLibrary.FromString(output.read_bytes()) + + assert MessageToDict(library.metadata)["loss_strategy"]["cz"] == "PROPAGATE" + assert [ + list(gadget.base.loss_model.losses[0].loss_measurements) + for gadget in library.gadget_types + ] == [[0, 1], [0, 1]] + + +def test_create_loss_model_file_requires_factory(tmp_path: Path) -> None: + plugin = tmp_path / "invalid.py" + plugin.write_text("config = {}\n", encoding="utf-8") + + with pytest.raises(ValueError, match=r"callable create_loss_model\(\)"): + create_loss_model(plugin) + + +def test_unknown_loss_model_lists_supported_names() -> None: + with pytest.raises( + ValueError, + match="expected one of: neutral-atom, trapped-ion", + ): + create_loss_model("unknown") + + +def test_annotate_accepts_neutral_atom_loss_model(tmp_path: Path) -> None: + source = tmp_path / "input.deq" + output = tmp_path / "annotated.deq" + source.write_text(_SOURCE, encoding="utf-8") + + annotate_file( + str(source), + out=str(output), + loss_model="neutral-atom", + skip_mako_warning=True, + ) + + loss_line = next( + line.strip() + for line in output.read_text(encoding="utf-8").splitlines() + if line.lstrip().startswith("LOSS(0.1)") + ) + assert "M0" in loss_line + assert " SE" in loss_line + + +def test_transpile_records_trapped_ion_platform_config(tmp_path: Path) -> None: + source = tmp_path / "trapped.deq" + output = tmp_path / "trapped.deq.jit" + source.write_text(_SOURCE, encoding="utf-8") + + transpile( + str(source), + out=str(output), + jobs=1, + loss_model="trapped-ion", + skip_mako_warning=True, + ) + library = jit_pb.JitLibrary.FromString(output.read_bytes()) + + metadata = MessageToDict(library.metadata) + assert metadata["loss_strategy"] == TrappedIonLossModel.config.to_json_object() + + +class _StopAfterBuild(Exception): + pass + + +def test_simulate_passes_loss_model_to_source_build( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "simulate.deq" + source.write_text(_SOURCE + "\nPROGRAM Run {}\n", encoding="utf-8") + + def stop_build(*args, **kwargs): + assert isinstance(kwargs["loss_model"], NeutralAtomLossModel) + raise _StopAfterBuild + + monkeypatch.setattr( + "deq.transpiler.jit_library_builder.build_jit_library", stop_build + ) + with pytest.raises(_StopAfterBuild): + simulate__ler( + str(source), + program="Run", + jobs=1, + loss_model="neutral-atom", + skip_mako_warning=True, + ) + + +@pytest.mark.parametrize( + ("simulation_loss_model", "expected_config"), + [ + (None, TrappedIonLossModel.config.to_json_object()), + ('{"cx":"SKIP"}', {"cx": "SKIP"}), + ("{}", {}), + ], +) +def test_simulation_loss_json_overrides_decoder_config( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + simulation_loss_model: str | None, + expected_config: dict[str, str], +) -> None: + source = tmp_path / "simulate.deq" + source.write_text(_SOURCE + "\nPROGRAM Run {}\n", encoding="utf-8") + + class StopPool: + def __init__(self, *args, **kwargs) -> None: + pass + + def __enter__(self): + return self + + def __exit__(self, *args) -> bool: + return False + + def submit(self, function, **kwargs): + assert kwargs["loss_config"] == expected_config + raise _StopAfterBuild + + monkeypatch.setattr( + "concurrent.futures.ProcessPoolExecutor", + StopPool, + ) + with pytest.raises(_StopAfterBuild): + simulate__ler( + str(source), + program="Run", + jobs=1, + loss_model="trapped-ion", + simulation_loss_model=simulation_loss_model, + simulator="qdk", + skip_mako_warning=True, + ) + + +def test_interpret_uses_neutral_atom_for_source_build( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "interpret.deq" + source.write_text(_SOURCE + "\nPROGRAM Run {}\n", encoding="utf-8") + + def stop_build(*args, **kwargs): + assert isinstance(kwargs["loss_model"], NeutralAtomLossModel) + raise _StopAfterBuild + + monkeypatch.setattr( + "deq.transpiler.jit_library_builder.build_jit_library", stop_build + ) + with pytest.raises(_StopAfterBuild): + _load_library( + str(source), + program="Run", + ) + + +def test_qdk_batch_passes_loss_config_to_sampler( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def stop_run(command, **kwargs): + config_index = command.index("--simulator-config") + 1 + import json + + simulator_config = json.loads(command[config_index]) + assert simulator_config["py_config"]["loss_config"] == ( + NeutralAtomLossModel.config.to_json_object() + ) + raise _StopAfterBuild + + monkeypatch.setattr("deq.cli.simulate.subprocess.run", stop_run) + with pytest.raises(_StopAfterBuild): + _run_batch( + bin_path="library.deq.bin", + stim_path="circuit.stim", + jit_path="library.deq.jit", + batch_size=10, + max_errors=1, + decoder="black-box-naive", + decoder_config=None, + coordinator="monolithic", + coordinator_config=None, + seed=1, + debug_dir=None, + simulator="qdk", + loss_config=NeutralAtomLossModel.config.to_json_object(), + ) + + +def test_simulation_loss_model_rejects_non_json_selector(tmp_path: Path) -> None: + source = tmp_path / "simulate.deq" + source.write_text(_SOURCE + "\nPROGRAM Run {}\n", encoding="utf-8") + + with pytest.raises(ValueError, match="invalid QDK loss config JSON"): + simulate__ler( + str(source), + program="Run", + simulation_loss_model="neutral-atom", + skip_mako_warning=True, + ) + + +def _jit_with_loss_metadata( + *, config: dict[str, str] | None = None +) -> jit_pb.JitLibrary: + if config is None: + config = NeutralAtomLossModel.config.to_json_object() + return jit_pb.JitLibrary(metadata={"loss_strategy": config}) + + +def test_precompiled_jit_uses_persisted_config() -> None: + config = _resolve_jit_loss_config(_jit_with_loss_metadata(), None) + + assert config == NeutralAtomLossModel.config + + +def test_precompiled_jit_accepts_matching_model_assertion() -> None: + config = _resolve_jit_loss_config(_jit_with_loss_metadata(), "neutral-atom") + + assert config == NeutralAtomLossModel.config + + +def test_precompiled_jit_uses_trapped_ion_config() -> None: + config = _resolve_jit_loss_config( + _jit_with_loss_metadata( + config=TrappedIonLossModel.config.to_json_object(), + ), + "trapped-ion", + ) + + assert config == TrappedIonLossModel.config + + +def test_precompiled_jit_without_metadata_uses_empty_config() -> None: + config = _resolve_jit_loss_config(jit_pb.JitLibrary(), None) + + assert config.to_json_object() == {} + + +def test_precompiled_jit_with_unrelated_metadata_uses_empty_config() -> None: + library = jit_pb.JitLibrary(metadata={"mock": {"nested": ["value"]}}) + + config = _resolve_jit_loss_config(library, None) + + assert config.to_json_object() == {} + + +def test_precompiled_jit_without_metadata_accepts_model_selector() -> None: + config = _resolve_jit_loss_config(jit_pb.JitLibrary(), "trapped-ion") + + assert config.to_json_object() == {} + + +def test_precompiled_jit_accepts_stored_config_without_named_preset() -> None: + config = { + "cx": "PROPAGATE", + "cy": "SKIP", + "cz": "SKIP", + "swap": "APPLY_ANYWAY", + } + + stored_config = _resolve_jit_loss_config( + _jit_with_loss_metadata(config=config), None + ) + + assert stored_config.to_json_object() == config + + +def test_precompiled_jit_rejects_mismatched_model_assertion() -> None: + with pytest.raises(ValueError, match="does not match precompiled JIT"): + _resolve_jit_loss_config(_jit_with_loss_metadata(), "trapped-ion") diff --git a/deq/tests/transpiler/loss_analysis_test.py b/deq/tests/transpiler/loss_analysis_test.py index 492a9d29..728ad105 100644 --- a/deq/tests/transpiler/loss_analysis_test.py +++ b/deq/tests/transpiler/loss_analysis_test.py @@ -10,6 +10,8 @@ from deq.transpiler.loss.analysis import _split_source_occurrences from deq.transpiler.loss.api import ( GateLossPolicy, + LossAnalysisState, + LossGate, QdkLossConfig, UnsupportedLossModelError, ) @@ -79,16 +81,19 @@ def _complete_pauli_insertions( return tuple(sorted(insertions)) -class _RecordingHandler: +class _RecordingLossModel: + config = QdkLossConfig(gate_policies=()) native_gates = frozenset() def __init__(self) -> None: - self.gates = [] + self.gates: list[LossGate] = [] - def handle_loss_source(self, event_id, state) -> None: + def handle_loss_source( + self, event_id: int, state: LossAnalysisState + ) -> None: state.add_source_pauli_insertion(event_id) - def handle_gate(self, gate, state) -> None: + def handle_gate(self, gate: LossGate, state: LossAnalysisState) -> None: del state self.gates.append(gate) @@ -684,14 +689,7 @@ def test_pair_measurement_uses_stim_decomposition_fallback() -> None: def test_overlapping_mpp_products_preserve_decomposed_boundaries() -> None: - class RecordingModel: - def __init__(self) -> None: - self.handler = _RecordingHandler() - - def create_handler(self): - return self.handler - - model = RecordingModel() + model = _RecordingLossModel() analyze_loss_events( _gadget(""" GADGET G { @@ -710,7 +708,7 @@ def create_handler(self): gate.boundary_before, gate.boundary_after, ) - for gate in model.handler.gates + for gate in model.gates ] == [ ("H", (0,), None, 0, 1), ("H", (1,), None, 0, 1), @@ -725,15 +723,8 @@ def create_handler(self): ] -def test_loss_model_handler_receives_individual_gate_occurrences() -> None: - class RecordingModel: - def __init__(self) -> None: - self.handler = _RecordingHandler() - - def create_handler(self): - return self.handler - - model = RecordingModel() +def test_loss_model_receives_individual_gate_occurrences() -> None: + model = _RecordingLossModel() analyze_loss_events( _gadget(""" GADGET G { @@ -747,7 +738,7 @@ def create_handler(self): model, ) - assert [(gate.name, gate.qubits) for gate in model.handler.gates] == [ + assert [(gate.name, gate.qubits) for gate in model.gates] == [ ("H", (0,)), ("H", (1,)), ("CX", (0, 1)), @@ -758,39 +749,25 @@ def create_handler(self): ] assert [ gate.measurement_index - for gate in model.handler.gates + for gate in model.gates if gate.produces_measurement ] == [0, 1] def test_non_native_gate_uses_stim_decomposition() -> None: - class RecordingModel: - def __init__(self) -> None: - self.handler = _RecordingHandler() - - def create_handler(self): - return self.handler - - model = RecordingModel() + model = _RecordingLossModel() analyze_loss_events(_gadget("GADGET G { LOSS_ERROR(0.1) 0 CZ 0 1 M 0 }"), model) - assert [(gate.name, gate.qubits) for gate in model.handler.gates[:3]] == [ + assert [(gate.name, gate.qubits) for gate in model.gates[:3]] == [ ("H", (1,)), ("CX", (0, 1)), ("H", (1,)), ] - assert all(gate.source_name == "CZ" for gate in model.handler.gates[:3]) + assert all(gate.source_name == "CZ" for gate in model.gates[:3]) def test_classical_control_uses_stim_decomposition_fallback() -> None: - class RecordingModel: - def __init__(self) -> None: - self.handler = _RecordingHandler() - - def create_handler(self): - return self.handler - - model = RecordingModel() + model = _RecordingLossModel() analyze_loss_events( _gadget(""" GADGET G { @@ -802,27 +779,20 @@ def create_handler(self): model, ) - classical_gate = model.handler.gates[1] + classical_gate = model.gates[1] assert classical_gate.name == "CX" assert classical_gate.qubits == (0,) assert classical_gate.control_measurement_index == 0 def test_source_gate_override_bypasses_stim_decomposition() -> None: - class RecordingHandler(_RecordingHandler): + class RecordingModel(_RecordingLossModel): native_gates = frozenset({"CZ"}) - class RecordingModel: - def __init__(self) -> None: - self.handler = RecordingHandler() - - def create_handler(self): - return self.handler - model = RecordingModel() analyze_loss_events(_gadget("GADGET G { LOSS_ERROR(0.1) 0 CZ 0 1 M 0 }"), model) - assert (model.handler.gates[0].name, model.handler.gates[0].qubits) == ( + assert (model.gates[0].name, model.gates[0].qubits) == ( "CZ", (0, 1), ) @@ -878,10 +848,8 @@ def test_loss_error_rejects_invalid_source(statement: str, message: str) -> None _discover(f"GADGET G {{ {statement} }}") -def test_loss_model_must_return_complete_handler() -> None: - class InvalidModel: - def create_handler(self) -> object: - return object() +def test_loss_model_must_implement_protocol() -> None: + class InvalidModel: ... - with pytest.raises(TypeError, match="does not implement loss-source and gate"): + with pytest.raises(TypeError, match="does not implement the LossModel protocol"): analyze_loss_events(_gadget("GADGET G { M 0 }"), InvalidModel()) From ddc53a2d1245752b083d212f1bb0a7815a0aee0f Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 17 Aug 2026 09:56:58 -0700 Subject: [PATCH 119/157] default to none loss model when interpret --- deq/deq/cli/annotate.py | 2 +- deq/deq/cli/jit.py | 10 +- deq/deq/cli/simulate.py | 4 +- deq/deq/transpiler/jit_library_builder.py | 20 ++-- deq/deq/transpiler/loss/__init__.py | 5 +- deq/deq/transpiler/loss/model_none.py | 43 +++++++++ deq/tests/cli/loss_model_test.py | 111 +++++++++++++++++++++- 7 files changed, 182 insertions(+), 13 deletions(-) create mode 100644 deq/deq/transpiler/loss/model_none.py diff --git a/deq/deq/cli/annotate.py b/deq/deq/cli/annotate.py index d452c6fc..139a1116 100644 --- a/deq/deq/cli/annotate.py +++ b/deq/deq/cli/annotate.py @@ -24,7 +24,7 @@ def annotate( mako: list[str] | None = None, #: suppress the interactive Mako safety prompt skip_mako_warning: bool = False, - #: physical loss model: "neutral-atom", "trapped-ion", or a .py file + #: physical loss model: "neutral-atom", "trapped-ion", "none", or a .py file loss_model: str = "neutral-atom", #: skip verification that annotated output transpiles identically no_verify: bool = False, diff --git a/deq/deq/cli/jit.py b/deq/deq/cli/jit.py index d7c3355b..e5595e02 100644 --- a/deq/deq/cli/jit.py +++ b/deq/deq/cli/jit.py @@ -26,7 +26,7 @@ def transpile( #: number of parallel worker processes for GADGET type construction; #: defaults to: (logical CPU count - 2), minimum 1 jobs: int = max((os.cpu_count() or 1) - 2, 1), - #: physical loss model: "neutral-atom", "trapped-ion", or a .py file + #: physical loss model: "neutral-atom", "trapped-ion", "none", or a .py file loss_model: str = "neutral-atom", #: register an external check plugin from a .py file (makes the #: file's stem name available as a @CHECKS("name") value) @@ -1126,6 +1126,12 @@ def export_program_stim( chunks: list[str] = [] next_physical = 0 next_meas_idx = 0 + # Sample loss only when the compiled library carries the metadata a decoder + # needs to explain it; the ``none`` loss model leaves every gadget without it. + library_models_loss = any( + gadget_type.base.HasField("loss_model") + for gadget_type in jit_library.gadget_types + ) # (gid, port_index) -> list of physical qubit ids for that output port output_physicals: dict[tuple[int, int], list[int]] = {} @@ -1298,6 +1304,8 @@ def export_program_stim( continue if not isinstance(stmt, Instruction): continue + if not library_models_loss and stmt.name.upper() == "LOSS_ERROR": + continue new_targets: list[Target] = [] for t in stmt.targets: if isinstance(t, QubitTarget): diff --git a/deq/deq/cli/simulate.py b/deq/deq/cli/simulate.py index 67e52937..2bb5681d 100644 --- a/deq/deq/cli/simulate.py +++ b/deq/deq/cli/simulate.py @@ -93,8 +93,8 @@ def simulate__ler( debug_dir: str | None = None, jobs: int = max((os.cpu_count() or 1) - 2, 1), jit: str | None = None, - #: decoder loss model: "neutral-atom", "trapped-ion", or a .py file; - #: with --jit, any stored loss config must match + #: decoder loss model: "neutral-atom", "trapped-ion", "none", or a .py + #: file; with --jit, any stored loss config must match loss_model: str | None = None, #: JSON object of QDK per-gate loss policies; overrides the decoder model's #: simulation config when provided diff --git a/deq/deq/transpiler/jit_library_builder.py b/deq/deq/transpiler/jit_library_builder.py index 80baf95b..4ce46ef5 100644 --- a/deq/deq/transpiler/jit_library_builder.py +++ b/deq/deq/transpiler/jit_library_builder.py @@ -76,6 +76,7 @@ from deq.transpiler.loss.transpiler import transpile_inferred_loss_model from deq.transpiler.loss.api import LossModel from deq.transpiler.loss.model_neutral_atom import NeutralAtomLossModel +from deq.transpiler.loss.model_none import NoLossModel from deq.transpiler.loss.syntax import transpile_declared_loss_model import stim @@ -219,8 +220,9 @@ def build_jit_library_artifacts( # across gadget boundaries). Only build them when the library actually # declares loss -- a ``LOSS_ERROR`` instruction or an explicit ``LOSS`` # statement -- so loss-free libraries stay loss-model-free and gain no dead - # loss-generator error rows. - library_has_loss = any( + # loss-generator error rows. ``NoLossModel`` opts out even when the circuit + # does declare loss. + library_has_loss = not isinstance(loss_model, NoLossModel) and any( (isinstance(statement, Instruction) and statement.name.upper() == "LOSS_ERROR") or isinstance(statement, LossStatement) for gadget in scaffold.gadgets @@ -924,11 +926,15 @@ def _build_check( logical_correction=logical_correction_pb, physical_correction=physical_correction_pb, ) - loss_model_pb = transpile_declared_loss_model( - gadget, - codes, - num_errors=len(errors_pb), - num_measurements=internal_count, + loss_model_pb = ( + transpile_declared_loss_model( + gadget, + codes, + num_errors=len(errors_pb), + num_measurements=internal_count, + ) + if library_has_loss + else None ) appended_error_origins: list[ErrorOrigin] = [] if loss_model_pb is None and library_has_loss: diff --git a/deq/deq/transpiler/loss/__init__.py b/deq/deq/transpiler/loss/__init__.py index 9780bfc4..c3f07623 100644 --- a/deq/deq/transpiler/loss/__init__.py +++ b/deq/deq/transpiler/loss/__init__.py @@ -24,6 +24,7 @@ UnsupportedLossModelError, ) from deq.transpiler.loss.model_neutral_atom import NeutralAtomLossModel +from deq.transpiler.loss.model_none import NoLossModel from deq.transpiler.loss.model_trapped_ion import TrappedIonLossModel from deq.transpiler.loss.loss_graph import ( LossBranch, @@ -33,7 +34,7 @@ build_loss_event_graph, ) -LOSS_MODEL_NAMES = ("neutral-atom", "trapped-ion") +LOSS_MODEL_NAMES = ("neutral-atom", "trapped-ion", "none") @lru_cache(maxsize=None) @@ -106,6 +107,7 @@ def create_loss_model(selector: str | Path) -> LossModel: constructors = { "neutral-atom": NeutralAtomLossModel, "trapped-ion": TrappedIonLossModel, + "none": NoLossModel, } value = str(selector) if value in constructors: @@ -132,6 +134,7 @@ def create_loss_model(selector: str | Path) -> LossModel: __all__ = [ "NeutralAtomLossModel", + "NoLossModel", "TrappedIonLossModel", "GateLossPolicy", "QdkLossConfig", diff --git a/deq/deq/transpiler/loss/model_none.py b/deq/deq/transpiler/loss/model_none.py new file mode 100644 index 00000000..8c83e96c --- /dev/null +++ b/deq/deq/transpiler/loss/model_none.py @@ -0,0 +1,43 @@ +"""Opt-out selector that compiles a circuit as if no qubit could be lost.""" + +from __future__ import annotations + +from deq.transpiler.loss.api import ( + LossAnalysisState, + LossGate, + QdkLossConfig, + UnsupportedLossModelError, +) + +_NO_LOSS_CONFIG = QdkLossConfig(gate_policies=()) + +_DISABLED = ( + "the 'none' loss model never analyzes loss; select a platform model to " + "compile LOSS_ERROR into decoder metadata" +) + + +class NoLossModel: + """Disable loss entirely instead of describing how a lost qubit behaves. + + Selecting this model leaves every gadget without loss metadata and drops + ``LOSS_ERROR`` from the exported Stim circuit, so the decoder and the + simulator agree that loss never happens. Use it when a circuit declares + ``LOSS_ERROR`` for other backends, or when its gates fall outside every + built-in platform model's supported scope. + """ + + config = _NO_LOSS_CONFIG + native_gates: frozenset[str] = frozenset() + + def handle_loss_source(self, event_id: int, state: LossAnalysisState) -> None: + raise UnsupportedLossModelError(_DISABLED) + + def handle_gate(self, gate: LossGate, state: LossAnalysisState) -> None: + raise UnsupportedLossModelError(_DISABLED) + + +def create_loss_model() -> NoLossModel: + """Create this model when the module is loaded as a plugin file.""" + + return NoLossModel() diff --git a/deq/tests/cli/loss_model_test.py b/deq/tests/cli/loss_model_test.py index a30fbfd1..9a574d8e 100644 --- a/deq/tests/cli/loss_model_test.py +++ b/deq/tests/cli/loss_model_test.py @@ -15,6 +15,7 @@ from deq.cli.simulate import _resolve_jit_loss_config, _run_batch, simulate__ler from deq.transpiler.loss import ( NeutralAtomLossModel, + NoLossModel, TrappedIonLossModel, create_loss_model, ) @@ -33,6 +34,24 @@ } """ +_PROGRAM_SOURCE = """ +CODE C [[1,1,1]] { LOGICAL X0 Z0 } +GADGET Prep { + R 0 + LOSS_ERROR(0.1) 0 + OUTPUT C 0 +} +GADGET Meas { + INPUT C 0 + M 0 + READOUT M0 +} +PROGRAM Run { + Prep 0 + Meas 0 +} +""" + def _write_user_loss_model(tmp_path: Path) -> Path: plugin = tmp_path / "user_loss.py" @@ -82,6 +101,7 @@ def test_transpile_accepts_neutral_atom_loss_model(tmp_path: Path) -> None: def test_create_loss_model_returns_platform_model() -> None: assert isinstance(create_loss_model("neutral-atom"), NeutralAtomLossModel) assert isinstance(create_loss_model("trapped-ion"), TrappedIonLossModel) + assert isinstance(create_loss_model("none"), NoLossModel) @pytest.mark.parametrize( @@ -186,11 +206,100 @@ def test_create_loss_model_file_requires_factory(tmp_path: Path) -> None: def test_unknown_loss_model_lists_supported_names() -> None: with pytest.raises( ValueError, - match="expected one of: neutral-atom, trapped-ion", + match="expected one of: neutral-atom, trapped-ion, none", ): create_loss_model("unknown") +def test_none_loss_model_leaves_gadgets_without_loss_metadata( + tmp_path: Path, +) -> None: + gadget = _transpile_with_loss_model(tmp_path, "none") + + assert not gadget.base.HasField("loss_model") + assert not gadget.errors + + +def test_none_loss_model_ignores_declared_loss_statements(tmp_path: Path) -> None: + source = tmp_path / "declared.deq" + output = tmp_path / "declared.deq.jit" + source.write_text( + """ +CODE C [[1,1,1]] { LOGICAL X0 Z0 } +GADGET G { + INPUT C 0 + M 0 + OUTPUT C 0 + ERROR(0) LX0 + LOSS(0.1) SE0 M0 +} +""", + encoding="utf-8", + ) + transpile( + str(source), + out=str(output), + jobs=1, + loss_model="none", + skip_mako_warning=True, + ) + gadget = jit_pb.JitLibrary.FromString(output.read_bytes()).gadget_types[0] + + assert not gadget.base.HasField("loss_model") + + +def test_none_loss_model_drops_loss_error_from_stim(tmp_path: Path) -> None: + source = tmp_path / "program.deq" + output = tmp_path / "program.deq.jit" + source.write_text(_PROGRAM_SOURCE, encoding="utf-8") + + transpile( + str(source), + out=str(output), + program="Run", + jobs=1, + loss_model="none", + skip_mako_warning=True, + ) + stim_text = (tmp_path / "program.stim").read_text(encoding="utf-8") + + assert "LOSS_ERROR" not in stim_text + + +def test_platform_loss_model_keeps_loss_error_in_stim(tmp_path: Path) -> None: + source = tmp_path / "program.deq" + output = tmp_path / "program.deq.jit" + source.write_text(_PROGRAM_SOURCE, encoding="utf-8") + + transpile( + str(source), + out=str(output), + program="Run", + jobs=1, + loss_model="neutral-atom", + skip_mako_warning=True, + ) + stim_text = (tmp_path / "program.stim").read_text(encoding="utf-8") + + assert "LOSS_ERROR(0.1)" in stim_text + + +def test_none_loss_model_annotates_without_loss_statements(tmp_path: Path) -> None: + source = tmp_path / "input.deq" + output = tmp_path / "annotated.deq" + source.write_text(_SOURCE, encoding="utf-8") + + annotate_file( + str(source), + out=str(output), + loss_model="none", + skip_mako_warning=True, + ) + annotated = output.read_text(encoding="utf-8") + + assert "LOSS(" not in annotated + + def test_annotate_accepts_neutral_atom_loss_model(tmp_path: Path) -> None: source = tmp_path / "input.deq" output = tmp_path / "annotated.deq" From f4284ab839cc9f5a82c8f87562e1846ef29dace0 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 17 Aug 2026 14:20:24 -0700 Subject: [PATCH 120/157] add loss compiler --- deq/deq_runtime/src/jit/loss_compiler.rs | 731 +++++++++++++++++++++++ 1 file changed, 731 insertions(+) create mode 100644 deq/deq_runtime/src/jit/loss_compiler.rs diff --git a/deq/deq_runtime/src/jit/loss_compiler.rs b/deq/deq_runtime/src/jit/loss_compiler.rs new file mode 100644 index 00000000..10aed531 --- /dev/null +++ b/deq/deq_runtime/src/jit/loss_compiler.rs @@ -0,0 +1,731 @@ +//! Runtime compiler for loss sites. +//! +//! A gadget type's [`LossModel`] describes where losses can originate or enter, +//! how they propagate, which measurements herald them, and which Pauli-envelope +//! generators they activate. That type-level description is not enough to select +//! sites for a shot: cross-gadget propagation depends on the connectivity of the +//! gadget instances in the current decode window, and feasibility depends on the +//! observed loss masks. [`build_cross_gadget_loss_sites`] combines all three into +//! the loss sites consistent with that runtime context. +//! +//! This mirrors JIT compilation: the static loss models are instantiated over a +//! runtime gadget graph, linked across connected ports, evaluated against the +//! shot's observations, and emitted as a window-local site graph. The result is +//! strategy-neutral. A coordinator may project it onto hyperedges and reweight +//! them, or hand the same structured sites to a loss-aware decoder. +//! +//! # Pauli envelope as a linear span +//! +//! A single atom loss has a *Pauli envelope* `E` — a set of Pauli error +//! configurations that bounds the loss's effect on detectors and observables. +//! The envelope is *linear*: it is the GF(2) span of one generator per relevant +//! space-time location (the loss site, after each Hadamard on the lost atom, and +//! just before its measurement). The forward loss transpiler has already +//! projected those generators into each gadget's shared error list and recorded +//! them on the loss model as `source_errors` (active only if the loss starts +//! there) and `continuation_errors` (active whenever a loss passes through). +//! +//! # Runtime compilation +//! +//! One physical atom lost early and measured (replenished) later spans several +//! gadgets: its generators live in the gadget where each Pauli acts, but its +//! herald — the loss-resolving measurement that reveals it — may only appear in a +//! downstream gadget. The compiler first instantiates every gadget's fresh and +//! input loss nodes, then links `child_losses` within each instance and connects +//! `child_output_qubits` to downstream `input_losses` using the resolved instance +//! connectivity. Finally, it folds the loss-mask evidence backward through that +//! graph. A site is *possible* when some herald in its forward reach was observed +//! as a loss and none was observed as a non-loss, so a downstream herald keeps +//! the upstream generators. +//! +//! Connections leaving the decode window are deliberately unresolved: the chain +//! ends at that boundary and can be compiled again by a later window that contains +//! its continuation. +//! +//! # Downstream strategies +//! +//! Runtime compilation does not decide how losses are decoded. The coordinator's +//! reweight strategy maps each compiled site's generators to hyperedges and uses +//! [`EnvelopeReweightPolicy`] to adjust their weights. The handoff strategy +//! preserves the compiled site graph, including parent-child relationships, for a +//! loss-aware decoder. The policy types later in this module support the first +//! strategy but are not part of site compilation. + +use crate::bin::gadget_type::LossModel; +use crate::misc::bit_vector::get_bit; +use crate::misc::util::{exclusive_probability_of, probability_of_weight, weight_of}; +use crate::util::BitVector; +use hashbrown::HashMap; + +/// One instantiated gadget's input to the runtime loss-site compiler. +/// +/// `loss_model` is the static type-level description. `observed` is this +/// instance's shot loss mask, indexed by local measurement index (empty when the +/// gadget recorded no loss this shot); an index at or beyond its size counts as +/// not lost. `output_links` is the resolved runtime connectivity: it maps each +/// output flat slot on which a loss can leave the instance to the `(downstream +/// gadget index, input flat slot)` where it continues. A slot with no entry +/// leaves the decode region or connects to an instance with no loss model, so the +/// compiled chain ends there. +pub struct GadgetLoss<'a> { + pub loss_model: &'a LossModel, + pub observed: &'a BitVector, + pub output_links: &'a HashMap, +} + +/// One possible loss site compiled for the current decode region and shot. +/// +/// Generators index the error list of the gadget identified by `gadget_index` +/// in the [`build_cross_gadget_loss_sites`] input slice, so a downstream strategy +/// can map them onto that gadget's hyperedges. `children` are positions in the +/// returned site list (forward parent -> child links, possibly crossing gadgets). +/// `probability` is the declared `LOSS_ERROR` probability of the loss starting +/// here, or `0` for a continuation entered from a parent in another gadget. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CrossGadgetLossSite { + pub gadget_index: usize, + pub probability: f64, + pub source_generators: Vec, + pub continuation_generators: Vec, + pub children: Vec, +} + +struct CrossGadgetNode { + gadget_index: usize, + probability: f64, + source: Vec, + continuation: Vec, + heralds: Vec, + children: Vec, +} + +/// Intermediate representation of the instantiated loss graph. The per-gadget +/// lookups wire its forward links: `fresh_ids[gadget][local_index]` and +/// `input_ids[gadget][input_slot]` give the node ID of a fresh or entering loss. +struct LossNodeGraph { + nodes: Vec, + fresh_ids: Vec>, + input_ids: Vec>, +} + +/// Compile the possible loss sites for a connected set of gadget instances. +/// +/// Each input supplies a static gadget loss model, that instance's observed loss +/// mask, and its resolved output links within the decode region. Compilation +/// instantiates the models, follows each `child_output_qubits` entry into the +/// connected instance's `input_losses`, and filters the resulting graph using the +/// observed heralds. A node is possible when some herald in its forward reach was +/// observed as a loss and none was observed as a non-loss, so a downstream herald +/// keeps the upstream generators. +/// +/// Only sites consistent with the observations are returned. They remain +/// ungrouped and retain their child links so either coordinator-side reweighting +/// or decoder handoff can consume the same result. +/// +/// A single gadget with no output links degenerates to a within-gadget analysis: +/// each possible loss site with its `child_losses` links preserved. +/// +/// # Panics +/// +/// Panics if a protobuf loss-model index does not fit in `usize`. +#[must_use] +pub fn build_cross_gadget_loss_sites(gadgets: &[GadgetLoss]) -> Vec { + let LossNodeGraph { + mut nodes, + fresh_ids, + input_ids, + } = build_loss_nodes(gadgets); + + // Link the instantiated graph: within-gadget `child_losses` and cross-gadget + // `child_output_qubits` that reach a connected gadget's input. + for (gadget_index, gadget) in gadgets.iter().enumerate() { + for (local_index, loss) in gadget.loss_model.losses.iter().enumerate() { + let node = fresh_ids[gadget_index][local_index]; + resolve_children( + node, + gadget_index, + proto_indices(&loss.child_losses), + proto_indices(&loss.child_output_qubits), + gadget.output_links, + &fresh_ids, + &input_ids, + &mut nodes, + ); + } + for (&node, input_loss) in input_ids[gadget_index].iter().zip(&gadget.loss_model.input_losses) { + resolve_children( + node, + gadget_index, + proto_indices(&input_loss.child_losses), + proto_indices(&input_loss.child_output_qubits), + gadget.output_links, + &fresh_ids, + &input_ids, + &mut nodes, + ); + } + } + + emit_possible_sites(&nodes, gadgets) +} + +/// Instantiate a node for every fresh loss and input loss, together with the +/// per-gadget lookups that link the runtime graph. +fn build_loss_nodes(gadgets: &[GadgetLoss]) -> LossNodeGraph { + let mut nodes: Vec = Vec::new(); + let mut fresh_ids: Vec> = Vec::with_capacity(gadgets.len()); + let mut input_ids: Vec> = Vec::with_capacity(gadgets.len()); + for (gadget_index, gadget) in gadgets.iter().enumerate() { + let mut fresh = Vec::with_capacity(gadget.loss_model.losses.len()); + for loss in &gadget.loss_model.losses { + fresh.push(nodes.len()); + nodes.push(CrossGadgetNode { + gadget_index, + probability: loss.probability, + source: proto_indices(&loss.source_errors).collect(), + continuation: proto_indices(&loss.continuation_errors).collect(), + heralds: proto_indices(&loss.loss_measurements).collect(), + children: Vec::new(), + }); + } + let mut inputs = Vec::with_capacity(gadget.loss_model.input_losses.len()); + for input_loss in &gadget.loss_model.input_losses { + inputs.push(nodes.len()); + nodes.push(CrossGadgetNode { + gadget_index, + probability: 0.0, + source: Vec::new(), + continuation: proto_indices(&input_loss.continuation_errors).collect(), + heralds: proto_indices(&input_loss.loss_measurements).collect(), + children: Vec::new(), + }); + } + fresh_ids.push(fresh); + input_ids.push(inputs); + } + LossNodeGraph { + nodes, + fresh_ids, + input_ids, + } +} + +#[allow(clippy::too_many_arguments)] +fn resolve_children( + node: usize, + gadget_index: usize, + child_losses: impl IntoIterator, + child_output_qubits: impl IntoIterator, + output_links: &HashMap, + fresh_ids: &[Vec], + input_ids: &[Vec], + nodes: &mut [CrossGadgetNode], +) { + for child in child_losses { + if let Some(&child_node) = fresh_ids[gadget_index].get(child) { + nodes[node].children.push(child_node); + } + } + for slot in child_output_qubits { + let Some(&(downstream, input_slot)) = output_links.get(&slot) else { + continue; + }; + nodes[node].children.push(input_ids[downstream][input_slot]); + } +} + +/// Evaluate the instantiated graph against the shot's loss masks and emit sites +/// supported by an observed herald in their forward reach and contradicted by +/// none, with `children` remapped to positions in the returned list. +fn emit_possible_sites(nodes: &[CrossGadgetNode], gadgets: &[GadgetLoss]) -> Vec { + let direct: Vec<(bool, bool)> = nodes + .iter() + .map(|node| { + let observed = gadgets[node.gadget_index].observed; + let observed_size = usize::try_from(observed.size).expect("loss-mask size must fit in usize"); + let mut supported = false; + let mut contradicted = false; + for herald in &node.heralds { + if *herald < observed_size + && get_bit(observed, u64::try_from(*herald).expect("herald index originated as u64")) + { + supported = true; + } else { + contradicted = true; + } + } + (supported, contradicted) + }) + .collect(); + let mut memo: Vec> = vec![None; nodes.len()]; + let possible: Vec = (0..nodes.len()) + .map(|node| { + let (supported, contradicted) = fold_evidence(node, nodes, &direct, &mut memo); + supported && !contradicted + }) + .collect(); + + let mut emitted_position: Vec> = vec![None; nodes.len()]; + let kept: Vec = (0..nodes.len()).filter(|&node| possible[node]).collect(); + for (position, &node) in kept.iter().enumerate() { + emitted_position[node] = Some(position); + } + kept.iter() + .map(|&node| CrossGadgetLossSite { + gadget_index: nodes[node].gadget_index, + probability: nodes[node].probability, + source_generators: nodes[node].source.clone(), + continuation_generators: nodes[node].continuation.clone(), + children: nodes[node] + .children + .iter() + .filter_map(|&child| emitted_position[child]) + .collect(), + }) + .collect() +} + +fn fold_evidence( + node: usize, + nodes: &[CrossGadgetNode], + direct: &[(bool, bool)], + memo: &mut [Option<(bool, bool)>], +) -> (bool, bool) { + if let Some(value) = memo[node] { + return value; + } + // Record the direct evidence provisionally before recursing so a cycle (which + // a well-formed forward loss graph never contains) cannot loop forever. + memo[node] = Some(direct[node]); + let (mut supported, mut contradicted) = direct[node]; + for &child in &nodes[node].children { + let (child_supported, child_contradicted) = fold_evidence(child, nodes, direct, memo); + supported |= child_supported; + contradicted |= child_contradicted; + } + memo[node] = Some((supported, contradicted)); + (supported, contradicted) +} + +fn proto_indices(indices: &[u64]) -> impl Iterator + '_ { + indices + .iter() + .map(|&index| usize::try_from(index).expect("loss-model index must fit in usize")) +} + +/// The envelope-matching reweighting policy. +/// +/// An edge a loss envelope can flip is lowered to `weight_fraction` of the weight +/// of `p_e (+) p_activation`, where `p_e` is its own prior and `p_activation` the +/// total probability that some observed loss activates it: +/// `w <- weight_fraction * w_scale`, with `w = -ln(p/(1-p))`. Taking the fraction +/// in weight space rather than exponentiating the probability is what keeps every +/// reweighted edge at non-negative weight, and applying it once per edge — over +/// the accumulated activation probability — rather than once per activating site +/// is what keeps the fraction a guarantee about the edge; see +/// [`Self::locally_reweighted_probability`]. +/// +/// Combining with `p_e` first also means a high-prior edge that merely happens to +/// lie in an envelope is not dragged down to the loss scale, and leaves a +/// loss-only edge — prior `0`, i.e. infinite weight until a loss activates it — +/// at `weight_fraction` of the site's own weight. +/// +/// `weight_fraction` is the single knob that trades off the two failure modes of +/// loss reweighting: +/// +/// * near `1.0` the loss edges keep ~full weight, so a heralded loss is barely +/// cheaper to explain than an ordinary error and the envelope hardly helps; +/// * near `0.0` the loss edges become free, so the decoder can chain several +/// edges of *one* loss into a zero-cost logical path — no exclusivity — which +/// was measured to be worse than plain random imputation beyond `d = 3`. +/// +/// The soft per-atom exclusivity lives in between, and is measurably a bowl: on a +/// `d = 3` mid-swap memory at `p_loss` of `1-2%` the logical error rate falls +/// from `f = 0.05` to a broad optimum at `0.5-0.7` and rises again toward `1.0`, +/// a `5-6 sigma` effect. At low loss rates it is flat in `f`, since most edges are +/// then activated by a single site. `0.5` — the default, and the original paper's +/// space-like value — is best or tied-best at every rate measured; the original paper +/// additionally lowers *time-like* edges (a measurement error on the same ancilla +/// across rounds, which cannot advance a logical operator spatially) to `0.25`, +/// since making those cheaper is "safe". This single-fraction policy cannot +/// express that split — hyperedges carry no spatial or temporal geometry — so it +/// applies one fraction throughout, and the optimum for a given code and noise is +/// worth sweeping. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct EnvelopeReweightPolicy { + /// Fraction of the scale weight an activated edge is lowered to. Must lie in + /// `(0, 1]`; `0.5` is the reference's space-like value and this crate's + /// default. + pub weight_fraction: f64, + /// Where the scale weight comes from. [`ReweightScale::Local`] is the default + /// and the rule described above; [`ReweightScale::GlobalMean`] reproduces the + /// reference construction for comparison. + pub scale: ReweightScale, +} + +/// Which scale weight [`EnvelopeReweightPolicy`] lowers an activated edge to. +/// +/// Kept selectable so the two constructions can be measured against each other +/// on the same graphs, decoders and shots. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ReweightScale { + /// `w_scale = w(p_e (+) p_activation)`: the edge's own prior combined with + /// the total probability that an observed loss activates it. + #[default] + Local, + /// `w_scale = mean weight of the graph's regular edges`, the reference + /// construction. Every activated edge is assigned the same weight, whatever + /// its own prior and however likely the loss was to reach it. + GlobalMean, + /// `w_scale = mean weight of the regular edges sharing a vertex with this + /// one`. Keeps the reference's semantics -- a heralded loss costs a fixed + /// fraction of an *ordinary error* -- while reading that scale from the + /// edge's own neighbourhood, so no graph-wide statistic is needed and a + /// heterogeneous graph is tracked locally. Falls back to [`Self::Local`] for + /// an edge with no regular neighbour, which is what makes it total on a + /// graph that has no regular edges at all. + NeighbourhoodMean, +} + +impl Default for EnvelopeReweightPolicy { + fn default() -> Self { + Self { + weight_fraction: 0.5, + scale: ReweightScale::Local, + } + } +} + +impl EnvelopeReweightPolicy { + /// A policy with the given fraction. + #[must_use] + pub fn new(weight_fraction: f64) -> Self { + Self { + weight_fraction, + scale: ReweightScale::Local, + } + } + + /// The reference's assignment for an activated edge, given a scale weight + /// read off the graph's regular edges (globally or in a neighbourhood): + /// `w <- weight_fraction * scale_weight`. + #[must_use] + pub fn scaled_probability(self, scale_weight: f64) -> f64 { + probability_of_weight(self.weight_fraction * scale_weight.max(0.0)) + } + + /// An activated edge's locally reweighted probability: the scale + /// `p_scale = p_e (+) p_activation` lowered to `weight_fraction` of its + /// *weight*, `w <- weight_fraction * w(p_scale)`. This uses the edge's own + /// prior and is selected by [`ReweightScale::Local`], or as the fallback for + /// [`ReweightScale::NeighbourhoodMean`] when no regular neighbour exists. + /// + /// `edge_probability` is the edge's own prior and `activation_probability` + /// the total probability that some observed loss activates it — the union + /// over every site that lists the edge, accumulated by the caller *before* + /// this is applied. Applying the fraction once per edge rather than once per + /// site is what keeps the guarantee at edge granularity; see + /// [`loss_reweights`](crate::decoder::blackbox_util::loss_reweights). + #[must_use] + pub fn locally_reweighted_probability(self, edge_probability: f64, activation_probability: f64) -> f64 { + let scale = exclusive_probability_of(edge_probability, activation_probability); + probability_of_weight(self.weight_fraction * weight_of(scale).max(0.0)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bin::gadget_type::LossModel; + use crate::bin::gadget_type::loss_model::Loss; + + fn loss(loss_measurements: &[u64], child_losses: &[u64], source_errors: &[u64], continuation_errors: &[u64]) -> Loss { + Loss { + probability: 0.1, + loss_measurements: loss_measurements.to_vec(), + child_losses: child_losses.to_vec(), + source_errors: source_errors.to_vec(), + continuation_errors: continuation_errors.to_vec(), + ..Loss::default() + } + } + + fn observed(indices: &[u64]) -> BitVector { + let size = indices.iter().copied().max().map_or(0, |max| max + 1); + crate::misc::bit_vector::from_sparse_indices(size, indices) + } + + #[test] + fn reweight_policy_lowers_edge_weight_to_the_fraction() { + // The rule is an exact fraction in MWPM weight space, + // `w_target = fraction * w_scale`, which is where the "costs a fixed + // fraction of an ordinary edge" reading comes from. A loss-only edge + // (prior weight infinite) is raised to a usable value. + let site = 0.001f64; + let half = EnvelopeReweightPolicy::default().locally_reweighted_probability(0.0, site); + let quarter = EnvelopeReweightPolicy::new(0.25).locally_reweighted_probability(0.0, site); + assert!((weight_of(half) - 0.5 * weight_of(site)).abs() < 1e-12); + assert!((weight_of(quarter) - 0.25 * weight_of(site)).abs() < 1e-12); + assert!(half > 0.0 && quarter > half, "smaller fraction -> cheaper edge"); + assert!( + weight_of(half) < weight_of(site), + "activated edge is cheaper than the loss itself" + ); + } + + /// A probability above `1/2` is a negative weight, which would tell the + /// decoder the mechanism is cheaper than free. Exponentiating the + /// probability crossed that line at `p_scale > (1/2)^(1/fraction)` — only + /// `6.25%` at `fraction = 0.25` — so the fraction is taken in weight space + /// instead, which cannot. + #[test] + fn reweight_policy_never_reaches_negative_weight() { + for &fraction in &[0.05, 0.25, 0.5, 0.9, 1.0] { + let policy = EnvelopeReweightPolicy::new(fraction); + for &prior in &[0.0, 1e-9, 0.01, 0.1, 0.3, 0.5] { + for &site in &[0.0, 1e-9, 0.01, 0.1, 0.3, 0.5] { + let contribution = policy.locally_reweighted_probability(prior, site); + assert!( + contribution <= 0.5 + 1e-12, + "fraction={fraction} prior={prior} site={site} gave {contribution} > 1/2" + ); + assert!(weight_of(contribution) >= -1e-12, "negative weight at fraction={fraction}"); + } + } + } + } + + #[test] + fn reweight_policy_never_lowers_a_high_prior_edge() { + // A high-prior edge that merely lies in an envelope keeps its own scale + // rather than being pinned to the loss rate. + let policy = EnvelopeReweightPolicy::default(); + let prior = 0.2; + assert!(policy.locally_reweighted_probability(prior, 1e-6) > prior); + } + + fn loss_out( + loss_measurements: &[u64], + child_losses: &[u64], + source_errors: &[u64], + continuation_errors: &[u64], + child_output_qubits: &[u64], + ) -> Loss { + Loss { + child_output_qubits: child_output_qubits.to_vec(), + ..loss(loss_measurements, child_losses, source_errors, continuation_errors) + } + } + + fn input_loss( + loss_measurements: &[u64], + child_losses: &[u64], + continuation_errors: &[u64], + child_output_qubits: &[u64], + ) -> crate::bin::gadget_type::loss_model::InputLoss { + crate::bin::gadget_type::loss_model::InputLoss { + loss_measurements: loss_measurements.to_vec(), + child_losses: child_losses.to_vec(), + continuation_errors: continuation_errors.to_vec(), + child_output_qubits: child_output_qubits.to_vec(), + } + } + + fn output_links(pairs: &[(usize, (usize, usize))]) -> HashMap { + pairs.iter().copied().collect() + } + + #[test] + fn single_gadget_chain_keeps_possible_sites_with_children() { + // One gadget, no output links: a heralded parent -> child chain. Both + // sites are possible; the within-gadget forward link is preserved + // (remapped to a position in the returned list). + let model = LossModel { + losses: vec![loss(&[0], &[1], &[0], &[1]), loss(&[1], &[], &[2], &[3])], + ..LossModel::default() + }; + let obs = observed(&[0, 1]); + let links = output_links(&[]); + let sites = build_cross_gadget_loss_sites(&[GadgetLoss { + loss_model: &model, + observed: &obs, + output_links: &links, + }]); + assert_eq!(sites.len(), 2); + assert_eq!(sites[0].source_generators, vec![0]); + assert_eq!(sites[0].continuation_generators, vec![1]); + assert_eq!(sites[0].children, vec![1]); + assert_eq!(sites[1].source_generators, vec![2]); + assert_eq!(sites[1].children, Vec::::new()); + } + + #[test] + fn single_gadget_contradicted_parent_drops_the_parent() { + // Same chain, but only herald 1 is observed: the parent (herald 0 not a + // loss) is contradicted and dropped; only the child survives. + let model = LossModel { + losses: vec![loss(&[0], &[1], &[0], &[1]), loss(&[1], &[], &[2], &[3])], + ..LossModel::default() + }; + let obs = observed(&[1]); + let links = output_links(&[]); + let sites = build_cross_gadget_loss_sites(&[GadgetLoss { + loss_model: &model, + observed: &obs, + output_links: &links, + }]); + assert_eq!(sites.len(), 1); + assert_eq!(sites[0].source_generators, vec![2]); + assert_eq!(sites[0].children, Vec::::new()); + } + + #[test] + fn cross_gadget_downstream_herald_supports_upstream_generators() { + // The loss starts in gadget 0 (generators 0, 1) with no local herald and + // leaves on output slot 0; it enters gadget 1 where it is finally heralded + // by measurement 9. Observing 9 must keep BOTH the upstream (gadget 0) and + // the downstream (gadget 1) generators. + let g0 = LossModel { + losses: vec![loss_out(&[], &[], &[0], &[1], &[0])], + ..LossModel::default() + }; + let g1 = LossModel { + input_losses: vec![input_loss(&[9], &[], &[2], &[])], + ..LossModel::default() + }; + let obs0 = observed(&[]); + let obs1 = observed(&[9]); + let links0 = output_links(&[(0, (1, 0))]); + let links1 = output_links(&[]); + let sites = build_cross_gadget_loss_sites(&[ + GadgetLoss { + loss_model: &g0, + observed: &obs0, + output_links: &links0, + }, + GadgetLoss { + loss_model: &g1, + observed: &obs1, + output_links: &links1, + }, + ]); + assert_eq!(sites.len(), 2); + let upstream = sites.iter().find(|s| s.gadget_index == 0).unwrap(); + assert_eq!(upstream.source_generators, vec![0]); + assert_eq!(upstream.continuation_generators, vec![1]); + let downstream = sites.iter().find(|s| s.gadget_index == 1).unwrap(); + assert_eq!(downstream.continuation_generators, vec![2]); + assert!(downstream.source_generators.is_empty()); + } + + #[test] + fn cross_gadget_unobserved_herald_yields_no_sites() { + // Same chain, but the downstream herald is not observed: the whole chain + // is unsupported, so the runtime compiler emits no sites. + let g0 = LossModel { + losses: vec![loss_out(&[], &[], &[0], &[1], &[0])], + ..LossModel::default() + }; + let g1 = LossModel { + input_losses: vec![input_loss(&[9], &[], &[2], &[])], + ..LossModel::default() + }; + let obs = observed(&[]); + let links0 = output_links(&[(0, (1, 0))]); + let links1 = output_links(&[]); + let sites = build_cross_gadget_loss_sites(&[ + GadgetLoss { + loss_model: &g0, + observed: &obs, + output_links: &links0, + }, + GadgetLoss { + loss_model: &g1, + observed: &obs, + output_links: &links1, + }, + ]); + assert!(sites.is_empty()); + } + + #[test] + fn cross_gadget_empty_input_loss_is_filtered() { + let g0 = LossModel { + losses: vec![loss_out(&[0], &[], &[0], &[], &[0])], + ..LossModel::default() + }; + let g1 = LossModel { + input_losses: vec![input_loss(&[], &[], &[], &[])], + ..LossModel::default() + }; + let obs0 = observed(&[0]); + let obs1 = observed(&[]); + let links0 = output_links(&[(0, (1, 0))]); + let links1 = output_links(&[]); + let sites = build_cross_gadget_loss_sites(&[ + GadgetLoss { + loss_model: &g0, + observed: &obs0, + output_links: &links0, + }, + GadgetLoss { + loss_model: &g1, + observed: &obs1, + output_links: &links1, + }, + ]); + assert_eq!(sites.len(), 1); + assert_eq!(sites[0].source_generators, vec![0]); + assert!(sites[0].children.is_empty()); + } + + #[test] + fn cross_gadget_contradicted_downstream_prunes_upstream() { + // The downstream node carries a second herald (7) that is observed as a + // non-loss, contradicting the chain even though herald 9 is observed. The + // whole chain is then impossible. + let g0 = LossModel { + losses: vec![loss_out(&[], &[], &[0], &[1], &[0])], + ..LossModel::default() + }; + let g1 = LossModel { + input_losses: vec![input_loss(&[7, 9], &[], &[2], &[])], + ..LossModel::default() + }; + let obs0 = observed(&[]); + let obs1 = observed(&[9]); + let links0 = output_links(&[(0, (1, 0))]); + let links1 = output_links(&[]); + let sites = build_cross_gadget_loss_sites(&[ + GadgetLoss { + loss_model: &g0, + observed: &obs0, + output_links: &links0, + }, + GadgetLoss { + loss_model: &g1, + observed: &obs1, + output_links: &links1, + }, + ]); + assert!(sites.is_empty()); + } + + #[test] + fn cross_gadget_dangling_output_link_ends_the_chain() { + // The loss leaves gadget 0 on a slot with no downstream link (the port + // exits the decode region). With no herald anywhere, it is unsupported. + let g0 = LossModel { + losses: vec![loss_out(&[], &[], &[0], &[1], &[0])], + ..LossModel::default() + }; + let obs = observed(&[]); + let links = output_links(&[]); + let sites = build_cross_gadget_loss_sites(&[GadgetLoss { + loss_model: &g0, + observed: &obs, + output_links: &links, + }]); + assert!(sites.is_empty()); + } +} From a43f0bdcb7c5ab3a93798ba4d15657a287fa31de Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 17 Aug 2026 14:35:18 -0700 Subject: [PATCH 121/157] remove reweight strategy from loss compiler --- deq/deq_runtime/src/jit/loss_compiler.rs | 179 +---------------------- 1 file changed, 3 insertions(+), 176 deletions(-) diff --git a/deq/deq_runtime/src/jit/loss_compiler.rs b/deq/deq_runtime/src/jit/loss_compiler.rs index 10aed531..da9e841b 100644 --- a/deq/deq_runtime/src/jit/loss_compiler.rs +++ b/deq/deq_runtime/src/jit/loss_compiler.rs @@ -44,16 +44,12 @@ //! //! # Downstream strategies //! -//! Runtime compilation does not decide how losses are decoded. The coordinator's -//! reweight strategy maps each compiled site's generators to hyperedges and uses -//! [`EnvelopeReweightPolicy`] to adjust their weights. The handoff strategy -//! preserves the compiled site graph, including parent-child relationships, for a -//! loss-aware decoder. The policy types later in this module support the first -//! strategy but are not part of site compilation. +//! Runtime compilation does not decide how losses are decoded. The coordinator +//! either reweights the compiled sites or hands their structure to a loss-aware +//! decoder. use crate::bin::gadget_type::LossModel; use crate::misc::bit_vector::get_bit; -use crate::misc::util::{exclusive_probability_of, probability_of_weight, weight_of}; use crate::util::BitVector; use hashbrown::HashMap; @@ -313,126 +309,6 @@ fn proto_indices(indices: &[u64]) -> impl Iterator + '_ { .map(|&index| usize::try_from(index).expect("loss-model index must fit in usize")) } -/// The envelope-matching reweighting policy. -/// -/// An edge a loss envelope can flip is lowered to `weight_fraction` of the weight -/// of `p_e (+) p_activation`, where `p_e` is its own prior and `p_activation` the -/// total probability that some observed loss activates it: -/// `w <- weight_fraction * w_scale`, with `w = -ln(p/(1-p))`. Taking the fraction -/// in weight space rather than exponentiating the probability is what keeps every -/// reweighted edge at non-negative weight, and applying it once per edge — over -/// the accumulated activation probability — rather than once per activating site -/// is what keeps the fraction a guarantee about the edge; see -/// [`Self::locally_reweighted_probability`]. -/// -/// Combining with `p_e` first also means a high-prior edge that merely happens to -/// lie in an envelope is not dragged down to the loss scale, and leaves a -/// loss-only edge — prior `0`, i.e. infinite weight until a loss activates it — -/// at `weight_fraction` of the site's own weight. -/// -/// `weight_fraction` is the single knob that trades off the two failure modes of -/// loss reweighting: -/// -/// * near `1.0` the loss edges keep ~full weight, so a heralded loss is barely -/// cheaper to explain than an ordinary error and the envelope hardly helps; -/// * near `0.0` the loss edges become free, so the decoder can chain several -/// edges of *one* loss into a zero-cost logical path — no exclusivity — which -/// was measured to be worse than plain random imputation beyond `d = 3`. -/// -/// The soft per-atom exclusivity lives in between, and is measurably a bowl: on a -/// `d = 3` mid-swap memory at `p_loss` of `1-2%` the logical error rate falls -/// from `f = 0.05` to a broad optimum at `0.5-0.7` and rises again toward `1.0`, -/// a `5-6 sigma` effect. At low loss rates it is flat in `f`, since most edges are -/// then activated by a single site. `0.5` — the default, and the original paper's -/// space-like value — is best or tied-best at every rate measured; the original paper -/// additionally lowers *time-like* edges (a measurement error on the same ancilla -/// across rounds, which cannot advance a logical operator spatially) to `0.25`, -/// since making those cheaper is "safe". This single-fraction policy cannot -/// express that split — hyperedges carry no spatial or temporal geometry — so it -/// applies one fraction throughout, and the optimum for a given code and noise is -/// worth sweeping. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct EnvelopeReweightPolicy { - /// Fraction of the scale weight an activated edge is lowered to. Must lie in - /// `(0, 1]`; `0.5` is the reference's space-like value and this crate's - /// default. - pub weight_fraction: f64, - /// Where the scale weight comes from. [`ReweightScale::Local`] is the default - /// and the rule described above; [`ReweightScale::GlobalMean`] reproduces the - /// reference construction for comparison. - pub scale: ReweightScale, -} - -/// Which scale weight [`EnvelopeReweightPolicy`] lowers an activated edge to. -/// -/// Kept selectable so the two constructions can be measured against each other -/// on the same graphs, decoders and shots. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum ReweightScale { - /// `w_scale = w(p_e (+) p_activation)`: the edge's own prior combined with - /// the total probability that an observed loss activates it. - #[default] - Local, - /// `w_scale = mean weight of the graph's regular edges`, the reference - /// construction. Every activated edge is assigned the same weight, whatever - /// its own prior and however likely the loss was to reach it. - GlobalMean, - /// `w_scale = mean weight of the regular edges sharing a vertex with this - /// one`. Keeps the reference's semantics -- a heralded loss costs a fixed - /// fraction of an *ordinary error* -- while reading that scale from the - /// edge's own neighbourhood, so no graph-wide statistic is needed and a - /// heterogeneous graph is tracked locally. Falls back to [`Self::Local`] for - /// an edge with no regular neighbour, which is what makes it total on a - /// graph that has no regular edges at all. - NeighbourhoodMean, -} - -impl Default for EnvelopeReweightPolicy { - fn default() -> Self { - Self { - weight_fraction: 0.5, - scale: ReweightScale::Local, - } - } -} - -impl EnvelopeReweightPolicy { - /// A policy with the given fraction. - #[must_use] - pub fn new(weight_fraction: f64) -> Self { - Self { - weight_fraction, - scale: ReweightScale::Local, - } - } - - /// The reference's assignment for an activated edge, given a scale weight - /// read off the graph's regular edges (globally or in a neighbourhood): - /// `w <- weight_fraction * scale_weight`. - #[must_use] - pub fn scaled_probability(self, scale_weight: f64) -> f64 { - probability_of_weight(self.weight_fraction * scale_weight.max(0.0)) - } - - /// An activated edge's locally reweighted probability: the scale - /// `p_scale = p_e (+) p_activation` lowered to `weight_fraction` of its - /// *weight*, `w <- weight_fraction * w(p_scale)`. This uses the edge's own - /// prior and is selected by [`ReweightScale::Local`], or as the fallback for - /// [`ReweightScale::NeighbourhoodMean`] when no regular neighbour exists. - /// - /// `edge_probability` is the edge's own prior and `activation_probability` - /// the total probability that some observed loss activates it — the union - /// over every site that lists the edge, accumulated by the caller *before* - /// this is applied. Applying the fraction once per edge rather than once per - /// site is what keeps the guarantee at edge granularity; see - /// [`loss_reweights`](crate::decoder::blackbox_util::loss_reweights). - #[must_use] - pub fn locally_reweighted_probability(self, edge_probability: f64, activation_probability: f64) -> f64 { - let scale = exclusive_probability_of(edge_probability, activation_probability); - probability_of_weight(self.weight_fraction * weight_of(scale).max(0.0)) - } -} - #[cfg(test)] mod tests { use super::*; @@ -455,55 +331,6 @@ mod tests { crate::misc::bit_vector::from_sparse_indices(size, indices) } - #[test] - fn reweight_policy_lowers_edge_weight_to_the_fraction() { - // The rule is an exact fraction in MWPM weight space, - // `w_target = fraction * w_scale`, which is where the "costs a fixed - // fraction of an ordinary edge" reading comes from. A loss-only edge - // (prior weight infinite) is raised to a usable value. - let site = 0.001f64; - let half = EnvelopeReweightPolicy::default().locally_reweighted_probability(0.0, site); - let quarter = EnvelopeReweightPolicy::new(0.25).locally_reweighted_probability(0.0, site); - assert!((weight_of(half) - 0.5 * weight_of(site)).abs() < 1e-12); - assert!((weight_of(quarter) - 0.25 * weight_of(site)).abs() < 1e-12); - assert!(half > 0.0 && quarter > half, "smaller fraction -> cheaper edge"); - assert!( - weight_of(half) < weight_of(site), - "activated edge is cheaper than the loss itself" - ); - } - - /// A probability above `1/2` is a negative weight, which would tell the - /// decoder the mechanism is cheaper than free. Exponentiating the - /// probability crossed that line at `p_scale > (1/2)^(1/fraction)` — only - /// `6.25%` at `fraction = 0.25` — so the fraction is taken in weight space - /// instead, which cannot. - #[test] - fn reweight_policy_never_reaches_negative_weight() { - for &fraction in &[0.05, 0.25, 0.5, 0.9, 1.0] { - let policy = EnvelopeReweightPolicy::new(fraction); - for &prior in &[0.0, 1e-9, 0.01, 0.1, 0.3, 0.5] { - for &site in &[0.0, 1e-9, 0.01, 0.1, 0.3, 0.5] { - let contribution = policy.locally_reweighted_probability(prior, site); - assert!( - contribution <= 0.5 + 1e-12, - "fraction={fraction} prior={prior} site={site} gave {contribution} > 1/2" - ); - assert!(weight_of(contribution) >= -1e-12, "negative weight at fraction={fraction}"); - } - } - } - } - - #[test] - fn reweight_policy_never_lowers_a_high_prior_edge() { - // A high-prior edge that merely lies in an envelope keeps its own scale - // rather than being pinned to the loss rate. - let policy = EnvelopeReweightPolicy::default(); - let prior = 0.2; - assert!(policy.locally_reweighted_probability(prior, 1e-6) > prior); - } - fn loss_out( loss_measurements: &[u64], child_losses: &[u64], From 237129e21ef0992d1726e088b96bd372f40c51a8 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 17 Aug 2026 14:35:56 -0700 Subject: [PATCH 122/157] add util functions --- deq/deq_runtime/src/misc/index.rs | 4 ++-- deq/deq_runtime/src/misc/util.rs | 28 +++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/deq/deq_runtime/src/misc/index.rs b/deq/deq_runtime/src/misc/index.rs index 7513d977..61137085 100644 --- a/deq/deq_runtime/src/misc/index.rs +++ b/deq/deq_runtime/src/misc/index.rs @@ -2,8 +2,8 @@ pub const WILDCARD: u64 = 0; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ErrorIndex { - pub eid: u64, - pub error_index: u64, + pub eid: usize, + pub error_index: usize, } // let the coordinator keep the internal loaded library diff --git a/deq/deq_runtime/src/misc/util.rs b/deq/deq_runtime/src/misc/util.rs index 1e3224a4..168a2503 100644 --- a/deq/deq_runtime/src/misc/util.rs +++ b/deq/deq_runtime/src/misc/util.rs @@ -21,11 +21,23 @@ pub fn exclusive_probability_of(probability_a: f64, probability_b: f64) -> f64 { probability_a + probability_b - 2.0 * probability_a * probability_b } +/// Return the log-likelihood weight `-ln(p / (1 - p))`. +/// +/// The endpoint values follow the extended-real limits: probability `0` has +/// weight `+inf`, and probability `1` has weight `-inf`. +#[must_use] pub fn weight_of(probability: f64) -> f64 { - debug_assert!(probability > 0.0 && probability < 1.0); + debug_assert!(probability.is_finite() && (0.0..=1.0).contains(&probability)); -(probability / (1.0 - probability)).ln() } +/// Return the probability corresponding to an log-likelihood weight. +#[must_use] +pub fn probability_of_weight(weight: f64) -> f64 { + debug_assert!(!weight.is_nan()); + 1.0 / (1.0 + weight.exp()) +} + /// Returns nanoseconds elapsed since the first call to this function in the /// current process, using a monotonic clock. /// @@ -41,6 +53,20 @@ pub fn timestamp_ns() -> u64 { #[cfg(test)] mod tests { + use super::{probability_of_weight, weight_of}; + + #[test] + fn probability_and_weight_round_trip() { + for probability in [1e-6, 1e-3, 0.01, 0.2] { + let weight = weight_of(probability); + assert!((probability_of_weight(weight) - probability).abs() < 1e-12); + } + assert_eq!(weight_of(0.0), f64::INFINITY); + assert_eq!(weight_of(1.0), f64::NEG_INFINITY); + assert_eq!(probability_of_weight(f64::INFINITY), 0.0); + assert_eq!(probability_of_weight(f64::NEG_INFINITY), 1.0); + } + #[test] #[cfg(all(feature = "cli", feature = "simulator"))] fn help_message() { From f15e61097c7de526cc8bd46b2503253c70b6b75f Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 17 Aug 2026 15:44:46 -0700 Subject: [PATCH 123/157] add heralds to loss decoder interface --- deq/deq/transpiler/loss/analysis.py | 8 +-- deq/deq_runtime/deq_runtime.pyi | 1 + deq/deq_runtime/src/jit/loss_compiler.rs | 71 +++++++++++++++++++ .../src/proto/deq.decoder.blackbox_decoder.rs | 12 ++-- deq/deq_runtime/tests/mock_decoder_test.rs | 1 + .../tests/standard_decoder_test.rs | 2 + deq/proto/blackbox_decoder.proto | 11 +-- deq/tests/runtime/test_mle_loss_decoder.py | 46 ++++++++++-- deq/tests/transpiler/loss_analysis_test.py | 19 +++++ deq/tests/transpiler/loss_transpiler_test.py | 62 +++++++++++++++- 10 files changed, 212 insertions(+), 21 deletions(-) diff --git a/deq/deq/transpiler/loss/analysis.py b/deq/deq/transpiler/loss/analysis.py index d686f762..19334c7c 100644 --- a/deq/deq/transpiler/loss/analysis.py +++ b/deq/deq/transpiler/loss/analysis.py @@ -126,15 +126,11 @@ def add_source_event( self.pending[qubit].append((event, branch)) def _link_prior_losses_to_new_source(self, qubit: int, successor_event_id: int) -> None: - """Share a new source's suffix with prior single-branch losses.""" + """Share a new source's suffix with every prior loss on this branch.""" retained: list[tuple[_PendingLossEvent, _PendingLossBranch]] = [] for event, branch in self.pending.get(qubit, ()): - active_branch_count = sum( - candidate_branch.active for candidate_branch in event.branches - ) - # Share the later suffix only when this is the event's sole lifetime. - if branch.active and active_branch_count == 1: + if branch.active: branch.active = False branch.successor_event_id = successor_event_id else: diff --git a/deq/deq_runtime/deq_runtime.pyi b/deq/deq_runtime/deq_runtime.pyi index b0e40b19..d310986d 100644 --- a/deq/deq_runtime/deq_runtime.pyi +++ b/deq/deq_runtime/deq_runtime.pyi @@ -32,6 +32,7 @@ class LossSite: continuation_edges: list[int] children: list[int] probability: float + heralds: list[int] class LossInfo: diff --git a/deq/deq_runtime/src/jit/loss_compiler.rs b/deq/deq_runtime/src/jit/loss_compiler.rs index da9e841b..0f0fcf99 100644 --- a/deq/deq_runtime/src/jit/loss_compiler.rs +++ b/deq/deq_runtime/src/jit/loss_compiler.rs @@ -52,6 +52,7 @@ use crate::bin::gadget_type::LossModel; use crate::misc::bit_vector::get_bit; use crate::util::BitVector; use hashbrown::HashMap; +use std::collections::BTreeSet; /// One instantiated gadget's input to the runtime loss-site compiler. /// @@ -77,6 +78,8 @@ pub struct GadgetLoss<'a> { /// returned site list (forward parent -> child links, possibly crossing gadgets). /// `probability` is the declared `LOSS_ERROR` probability of the loss starting /// here, or `0` for a continuation entered from a parent in another gadget. +/// `heralds` are direct window-local herald IDs; equal values identify the same +/// gadget-instance measurement across sites. #[derive(Clone, Debug, Default, PartialEq)] pub struct CrossGadgetLossSite { pub gadget_index: usize, @@ -84,6 +87,7 @@ pub struct CrossGadgetLossSite { pub source_generators: Vec, pub continuation_generators: Vec, pub children: Vec, + pub heralds: Vec, } struct CrossGadgetNode { @@ -266,6 +270,13 @@ fn emit_possible_sites(nodes: &[CrossGadgetNode], gadgets: &[GadgetLoss]) -> Vec for (position, &node) in kept.iter().enumerate() { emitted_position[node] = Some(position); } + let mut herald_id_of: HashMap<(usize, usize), usize> = HashMap::new(); + for &node in &kept { + for &herald in &nodes[node].heralds { + let next_id = herald_id_of.len(); + herald_id_of.entry((nodes[node].gadget_index, herald)).or_insert(next_id); + } + } kept.iter() .map(|&node| CrossGadgetLossSite { gadget_index: nodes[node].gadget_index, @@ -277,6 +288,13 @@ fn emit_possible_sites(nodes: &[CrossGadgetNode], gadgets: &[GadgetLoss]) -> Vec .iter() .filter_map(|&child| emitted_position[child]) .collect(), + heralds: nodes[node] + .heralds + .iter() + .map(|&herald| herald_id_of[&(nodes[node].gadget_index, herald)]) + .collect::>() + .into_iter() + .collect(), }) .collect() } @@ -382,8 +400,61 @@ mod tests { assert_eq!(sites[0].source_generators, vec![0]); assert_eq!(sites[0].continuation_generators, vec![1]); assert_eq!(sites[0].children, vec![1]); + assert_eq!(sites[0].heralds, vec![0]); assert_eq!(sites[1].source_generators, vec![2]); assert_eq!(sites[1].children, Vec::::new()); + assert_eq!(sites[1].heralds, vec![1]); + } + + #[test] + fn herald_ids_preserve_equality_without_cross_gadget_collisions() { + let g0 = LossModel { + losses: vec![loss(&[0], &[], &[0], &[]), loss(&[0], &[], &[1], &[])], + ..LossModel::default() + }; + let g1 = LossModel { + losses: vec![loss(&[0], &[], &[2], &[])], + ..LossModel::default() + }; + let obs0 = observed(&[0]); + let obs1 = observed(&[0]); + let links0 = output_links(&[]); + let links1 = output_links(&[]); + + let sites = build_cross_gadget_loss_sites(&[ + GadgetLoss { + loss_model: &g0, + observed: &obs0, + output_links: &links0, + }, + GadgetLoss { + loss_model: &g1, + observed: &obs1, + output_links: &links1, + }, + ]); + + assert_eq!(sites.len(), 3); + assert_eq!(sites[0].heralds, sites[1].heralds); + assert_ne!(sites[0].heralds, sites[2].heralds); + } + + #[test] + fn herald_ids_are_deduplicated_and_ordered() { + let model = LossModel { + losses: vec![loss(&[2], &[], &[0], &[]), loss(&[1, 2, 1], &[], &[1], &[])], + ..LossModel::default() + }; + let obs = observed(&[1, 2]); + let links = output_links(&[]); + + let sites = build_cross_gadget_loss_sites(&[GadgetLoss { + loss_model: &model, + observed: &obs, + output_links: &links, + }]); + + assert_eq!(sites[1].heralds, vec![0, 1]); } #[test] diff --git a/deq/deq_runtime/src/proto/deq.decoder.blackbox_decoder.rs b/deq/deq_runtime/src/proto/deq.decoder.blackbox_decoder.rs index 9aa3bd04..f7418028 100644 --- a/deq/deq_runtime/src/proto/deq.decoder.blackbox_decoder.rs +++ b/deq/deq_runtime/src/proto/deq.decoder.blackbox_decoder.rs @@ -84,10 +84,9 @@ pub struct Hyperedge { #[derive(Clone, PartialEq, ::prost::Message)] pub struct LossInfo { /// One entry per possible loss site. The runtime has already filtered these to - /// the sites consistent with the observed loss-resolving readouts (herald - /// folding), so per-site heralds are not exposed to the decoder. That is, we - /// guarantee that at least one of the readout is loss and none of them are non-loss - /// (those readouts outside of the decoding window are not considered) + /// sites consistent with the observed loss-resolving readouts. Direct herald + /// identities remain available so a loss-aware decoder can correlate possible + /// sites that are not connected by the child graph. #[prost(message, repeated, tag = "1")] pub sites: ::prost::alloc::vec::Vec, } @@ -112,6 +111,11 @@ pub struct LossSite { /// a continuation site fixed by a parent in another gadget. #[prost(double, tag = "4")] pub probability: f64, + /// Direct loss-resolving measurements for this site. Indices are window-local: + /// equal values identify the same observed herald across all LossInfo.sites. + /// Heralds inherited through propagation are found by following children. + #[prost(uint64, repeated, tag = "5")] + pub heralds: ::prost::alloc::vec::Vec, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] diff --git a/deq/deq_runtime/tests/mock_decoder_test.rs b/deq/deq_runtime/tests/mock_decoder_test.rs index 85eb021e..163b65a7 100644 --- a/deq/deq_runtime/tests/mock_decoder_test.rs +++ b/deq/deq_runtime/tests/mock_decoder_test.rs @@ -225,6 +225,7 @@ async fn test_mock_decoder_accepts_reweights_and_loss_together() { sites: vec![blackbox_decoder::LossSite { source_edges: vec![0], probability: 0.2, + heralds: vec![4, 7], ..Default::default() }], }; diff --git a/deq/deq_runtime/tests/standard_decoder_test.rs b/deq/deq_runtime/tests/standard_decoder_test.rs index b686be28..b5f017ba 100644 --- a/deq/deq_runtime/tests/standard_decoder_test.rs +++ b/deq/deq_runtime/tests/standard_decoder_test.rs @@ -237,6 +237,7 @@ class CombinedDecoder: assert len(loss.sites) == 1, loss.sites assert loss.sites[0].source_edges == [0], loss.sites[0].source_edges assert loss.sites[0].probability == 0.2, loss.sites[0].probability + assert loss.sites[0].heralds == [4, 7], loss.sites[0].heralds return [0] def reset(self): @@ -278,6 +279,7 @@ class CombinedDecoder: sites: vec![LossSite { source_edges: vec![0], probability: 0.2, + heralds: vec![4, 7], ..Default::default() }], }), diff --git a/deq/proto/blackbox_decoder.proto b/deq/proto/blackbox_decoder.proto index 1f7b3113..e2ba807c 100644 --- a/deq/proto/blackbox_decoder.proto +++ b/deq/proto/blackbox_decoder.proto @@ -102,10 +102,9 @@ message Hyperedge { // sites into ordinary reweighted hyperedges for an unmodified black-box decoder. message LossInfo { // One entry per possible loss site. The runtime has already filtered these to - // the sites consistent with the observed loss-resolving readouts (herald - // folding), so per-site heralds are not exposed to the decoder. That is, we - // guarantee that at least one of the readout is loss and none of them are non-loss - // (those readouts outside of the decoding window are not considered) + // sites consistent with the observed loss-resolving readouts. Direct herald + // identities remain available so a loss-aware decoder can correlate possible + // sites that are not connected by the child graph. repeated LossSite sites = 1; } @@ -125,4 +124,8 @@ message LossSite { // Declared LOSS_ERROR probability of the loss starting at this site, or 0 for // a continuation site fixed by a parent in another gadget. double probability = 4; + // Direct loss-resolving measurements for this site. Indices are window-local: + // equal values identify the same observed herald across all LossInfo.sites. + // Heralds inherited through propagation are found by following children. + repeated uint64 heralds = 5; } diff --git a/deq/tests/runtime/test_mle_loss_decoder.py b/deq/tests/runtime/test_mle_loss_decoder.py index 865249ae..19b54a5d 100644 --- a/deq/tests/runtime/test_mle_loss_decoder.py +++ b/deq/tests/runtime/test_mle_loss_decoder.py @@ -36,12 +36,13 @@ def _hypergraph(*edges): ) -def _site(*, source=(), continuation=(), children=()): +def _site(*, source=(), continuation=(), children=(), heralds=(), probability=0.1): return SimpleNamespace( source_edges=list(source), continuation_edges=list(continuation), children=list(children), - probability=0.0, + probability=probability, + heralds=list(heralds), ) @@ -58,7 +59,7 @@ def test_ordinary_positive_prior_edge_satisfies_syndrome() -> None: def test_loss_activates_zero_prior_source_edge() -> None: decoder = _decoder_module().Decoder(_hypergraph(([0], 0.0))) - loss = SimpleNamespace(sites=[_site(source=[0])]) + loss = SimpleNamespace(sites=[_site(source=[0], heralds=[0])]) with pytest.raises(RuntimeError, match="produced no solution"): decoder.decode([0]) @@ -69,17 +70,50 @@ def test_parent_start_enables_child_continuation_edge() -> None: decoder = _decoder_module().Decoder(_hypergraph(([0], 0.0))) sites = [ _site(children=[1]), - _site(continuation=[0]), + _site(continuation=[0], heralds=[0]), ] - enabling, loss_edges, components = decoder._loss_structure(sites) + enabling, loss_edges, herald_starts, conflicts, _, _ = decoder._loss_structure(sites) assert enabling == {0: {0, 1}} assert loss_edges == {0} - assert list(components.values()) == [[0, 1]] + assert herald_starts == {0: {0, 1}} + assert conflicts == [(0, 1)] assert decoder.decode([0], SimpleNamespace(sites=sites)) == [0] +def test_shared_heralds_choose_most_likely_cover() -> None: + decoder = _decoder_module().Decoder( + _hypergraph(([0], 0.0), ([0], 0.0)) + ) + joint = _site(source=[0], heralds=[0, 1], probability=0.1) + separate_a = _site(source=[1], heralds=[0], probability=0.2) + separate_b = _site(heralds=[1], probability=0.2) + + assert decoder.decode( + [0], SimpleNamespace(sites=[joint, separate_a, separate_b]) + ) == [0] + + joint.probability = 0.01 + assert decoder.decode( + [0], SimpleNamespace(sites=[joint, separate_a, separate_b]) + ) == [1] + + +def test_branch_siblings_can_start_together_but_conflict_with_parent() -> None: + decoder = _decoder_module().Decoder(_hypergraph()) + sites = [ + _site(children=[1, 2]), + _site(heralds=[0]), + _site(heralds=[1]), + ] + + _, _, herald_starts, conflicts, _, _ = decoder._loss_structure(sites) + + assert herald_starts == {0: {0, 1}, 1: {0, 2}} + assert conflicts == [(0, 1), (0, 2)] + + @pytest.mark.parametrize("field", ["source_edges", "continuation_edges"]) def test_out_of_range_loss_edge_is_rejected(field: str) -> None: decoder = _decoder_module().Decoder(_hypergraph(([0], 0.0))) diff --git a/deq/tests/transpiler/loss_analysis_test.py b/deq/tests/transpiler/loss_analysis_test.py index 728ad105..f437f058 100644 --- a/deq/tests/transpiler/loss_analysis_test.py +++ b/deq/tests/transpiler/loss_analysis_test.py @@ -235,6 +235,25 @@ def test_propagate_policy_branches_loss_to_every_gate_operand( assert branches[1].loss_measurements == (1,) +def test_propagated_branches_link_to_later_sources_independently() -> None: + graph = analyze_loss_events( + _gadget(""" + GADGET G { + LOSS_ERROR(0.1) 0 + CZ 0 1 + LOSS_ERROR(0.2) 0 + LOSS_ERROR(0.3) 1 + M 0 1 + } + """), + _PropagatingNeutralAtomLossModel(), + ).graph + + assert [event.loss_measurements for event in graph.events] == [(), (0,), (1,)] + assert graph.successor_event_ids == ((1, 2), (), ()) + assert _complete_loss_measurements(graph, 0) == (0, 1) + + @pytest.mark.parametrize("gate", ["CX", "CY", "CZ"]) @pytest.mark.parametrize("lost_qubit", [0, 1]) def test_neutral_atom_supports_compiled_controlled_pauli_aliases( diff --git a/deq/tests/transpiler/loss_transpiler_test.py b/deq/tests/transpiler/loss_transpiler_test.py index 8bf7f02d..f3f45b40 100644 --- a/deq/tests/transpiler/loss_transpiler_test.py +++ b/deq/tests/transpiler/loss_transpiler_test.py @@ -11,7 +11,16 @@ from deq.cli.strip_tags import strip_jit_library from deq.transpiler.jit_annotate import annotate as render_annotated from deq.transpiler.jit_library_builder import build_jit_library -from deq.transpiler.loss import NeutralAtomLossModel, TrappedIonLossModel +from deq.transpiler.loss import ( + GateLossPolicy, + NeutralAtomLossModel, + QdkLossConfig, + TrappedIonLossModel, +) + + +class _PropagatingNeutralAtomLossModel(NeutralAtomLossModel): + config = QdkLossConfig(gate_policies=(("cz", GateLossPolicy.PROPAGATE),)) # The loss-decoding paper's data-loss CNOT chain: qubit 0 is lost at five points # along its lifetime, pushed through CNOTs, then measured. @@ -108,6 +117,57 @@ def test_neutral_atom_model_relocates_loss_through_swap() -> None: assert list(gadget.base.loss_model.losses[0].loss_measurements) == [1] +def test_propagated_branches_keep_causal_links_to_later_sources() -> None: + source = """ + GADGET G { + LOSS_ERROR(0.1) 0 + CZ 0 1 + LOSS_ERROR(0.2) 0 + LOSS_ERROR(0.3) 1 + M 0 1 + } + """ + gadget = build_jit_library( + parse(source), loss_model=_PropagatingNeutralAtomLossModel() + ).gadget_types[0] + parent, first_branch, second_branch = gadget.base.loss_model.losses + + assert list(parent.child_losses) == [1, 2] + assert list(parent.loss_measurements) == [] + assert list(first_branch.loss_measurements) == [0] + assert list(second_branch.loss_measurements) == [1] + + +def test_compose_preserves_propagated_branch_successor() -> None: + source = """ + CODE C [[1,1,1]] { LOGICAL X0 Z0 } + GADGET A { + LOSS_ERROR(0.1) 0 + CZ 0 1 + LOSS_ERROR(0.2) 0 + R 1 + OUTPUT C 0 + } + GADGET B { + INPUT C 0 + M 0 + } + COMPOSE Chain { + A 0 + B 0 + } + """ + library = build_jit_library( + parse(source), loss_model=_PropagatingNeutralAtomLossModel() + ) + chain = next(gadget for gadget in library.gadget_types if gadget.base.name == "Chain") + parent, child = chain.base.loss_model.losses + + assert list(parent.child_losses) == [1] + assert list(parent.loss_measurements) == [] + assert list(child.loss_measurements) == [0] + + def test_trapped_ion_cz_residual_becomes_continuation_error() -> None: source = """ CODE Pair [[2,2,1]] { From 850b9c2b2f651516ae14340a5e505b8e5d4f9928 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 17 Aug 2026 16:15:54 -0700 Subject: [PATCH 124/157] update blackbox decoder util --- deq/deq_runtime/src/decoder/blackbox_util.rs | 23 +++++++++++++++---- deq/deq_runtime/src/decoder/python_decoder.rs | 6 ++++- .../src/simulator/python_sampler.rs | 2 +- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/deq/deq_runtime/src/decoder/blackbox_util.rs b/deq/deq_runtime/src/decoder/blackbox_util.rs index f27a1dc3..f6dc0db1 100644 --- a/deq/deq_runtime/src/decoder/blackbox_util.rs +++ b/deq/deq_runtime/src/decoder/blackbox_util.rs @@ -3,15 +3,30 @@ use crate::misc::bit_vector::to_sparse_indices; use crate::util::BitVector; use hashbrown::HashSet; +/// Hypergraph indices of the hyperedges a core decoder should be built from: +/// those with a usable prior probability. +/// +/// The decoding hypergraph may carry mechanisms with probability zero so their +/// stable edge indices remain available for shot-scoped updates. Core solvers +/// should omit those infinite-weight edges until a reweight makes them usable. +pub fn active_edge_indices(hypergraph: &DecodingHypergraph) -> Vec { + hypergraph + .hyperedges + .iter() + .enumerate() + .filter(|(_, hyperedge)| hyperedge.probability > 0.0) + .map(|(index, _)| index as u64) + .collect() +} + pub fn is_parity_factor( decoding_hypergraph: &DecodingHypergraph, parity_factor: &ParityFactor, syndrome: &BitVector, ) -> bool { - // calculate the error syndromes let mut flips = HashSet::::new(); - for &edge_idx in &parity_factor.subgraph { - let edge = &decoding_hypergraph.hyperedges[edge_idx as usize]; + for &edge_index in &parity_factor.subgraph { + let edge = &decoding_hypergraph.hyperedges[edge_index as usize]; for &vertex in &edge.vertices { if !flips.insert(vertex) { flips.remove(&vertex); @@ -19,11 +34,9 @@ pub fn is_parity_factor( } } - // compare with the syndrome let syndrome = to_sparse_indices(syndrome); let mut flips: Vec = flips.into_iter().collect(); flips.sort_unstable(); - syndrome == flips } diff --git a/deq/deq_runtime/src/decoder/python_decoder.rs b/deq/deq_runtime/src/decoder/python_decoder.rs index f40abb23..43b4d717 100644 --- a/deq/deq_runtime/src/decoder/python_decoder.rs +++ b/deq/deq_runtime/src/decoder/python_decoder.rs @@ -185,7 +185,8 @@ impl PyHyperedge { /// One observed atom-loss site handed to a loss-aware Python decoder. Mirrors /// [`blackbox_decoder::LossSite`](crate::decoder::blackbox_decoder::LossSite): /// `source_edges` / `continuation_edges` index the hypergraph, `children` index -/// the [`PyLossInfo::sites`] list. +/// the [`PyLossInfo::sites`] list, and equal `heralds` values identify the same +/// observed loss-resolving measurement. #[pyclass(name = "LossSite")] #[derive(Debug)] pub struct PyLossSite { @@ -197,6 +198,8 @@ pub struct PyLossSite { pub children: Vec, #[pyo3(get, set)] pub probability: f64, + #[pyo3(get, set)] + pub heralds: Vec, } #[pymethods] @@ -268,6 +271,7 @@ impl DecoderInstance for PythonDecoderInstance { continuation_edges: site.continuation_edges.clone(), children: site.children.clone(), probability: site.probability, + heralds: site.heralds.clone(), })?; } Ok::(PyLossInfo { diff --git a/deq/deq_runtime/src/simulator/python_sampler.rs b/deq/deq_runtime/src/simulator/python_sampler.rs index e4fbd0f5..e8b5994f 100644 --- a/deq/deq_runtime/src/simulator/python_sampler.rs +++ b/deq/deq_runtime/src/simulator/python_sampler.rs @@ -30,7 +30,7 @@ //! ``ErrorSet.measurements`` and sets the corresponding bit of //! ``ErrorSet.loss_mask`` to 1. The actual decision of what to do with //! lost bits — random imputation, erasure handling, etc. — lives in the -//! coordinator (see ``coordinator::apply_loss_random_imputation``). That +//! coordinator (see ``coordinator::loss_handler::apply_loss_random_imputation``). That //! way any future loss-aware sampler "just works": it only needs to emit //! correct ``loss_mask`` bits and a sensible placeholder; the coordinator //! handles the rest. From b82c3213f92372d7744803f34313cce8ae18046a Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 17 Aug 2026 16:26:07 -0700 Subject: [PATCH 125/157] add function to loss compiler --- deq/deq_runtime/src/jit/loss_compiler.rs | 44 ++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/deq/deq_runtime/src/jit/loss_compiler.rs b/deq/deq_runtime/src/jit/loss_compiler.rs index 0f0fcf99..50aeb458 100644 --- a/deq/deq_runtime/src/jit/loss_compiler.rs +++ b/deq/deq_runtime/src/jit/loss_compiler.rs @@ -108,6 +108,50 @@ struct LossNodeGraph { input_ids: Vec>, } +fn port_offsets( + ports: &[crate::bin::gadget_type::Port], + port_types: &HashMap>, +) -> Vec { + let mut offsets = Vec::with_capacity(ports.len()); + let mut running = 0usize; + for port in ports { + offsets.push(running); + running += port_types.get(&port.ptype).map_or(0, |port_type| { + usize::try_from(port_type.n).expect("port width must fit in usize") + }); + } + offsets +} + +/// Resolve each loss-bearing output slot to its downstream gadget and input +/// slot within the current decode region. +pub(crate) fn build_cross_gadget_output_links( + gadget_instances: &[&crate::bin::Gadget], + index_of_gid: &HashMap, + gadget_types: &HashMap>, + port_types: &HashMap>, +) -> Vec> { + let mut output_links = vec![HashMap::new(); gadget_instances.len()]; + for (downstream_index, downstream) in gadget_instances.iter().enumerate() { + let downstream_type = gadget_types.get(&downstream.gtype).unwrap(); + let input_offsets = port_offsets(&downstream_type.inputs, port_types); + for (input_port, connector) in downstream.connectors.iter().enumerate() { + let upstream_index = *index_of_gid.get(&connector.gid).unwrap(); + let upstream_type = gadget_types.get(&gadget_instances[upstream_index].gtype).unwrap(); + let output_port = connector.port as usize; + debug_assert!(output_port < upstream_type.outputs.len()); + let output_offsets = port_offsets(&upstream_type.outputs, port_types); + let upstream_type = port_types.get(&upstream_type.outputs[output_port].ptype).unwrap(); + let output_offset = output_offsets[output_port]; + let input_offset = input_offsets[input_port]; + for position in 0..(upstream_type.n as usize) { + output_links[upstream_index].insert(output_offset + position, (downstream_index, input_offset + position)); + } + } + } + output_links +} + /// Compile the possible loss sites for a connected set of gadget instances. /// /// Each input supplies a static gadget loss model, that instance's observed loss From 4a085ebb3eb62c6bb63e8d77cd56a8a6442f4454 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 17 Aug 2026 16:26:44 -0700 Subject: [PATCH 126/157] update coordinator --- deq/deq_runtime/src/coordinator.rs | 173 +++++------------------------ 1 file changed, 30 insertions(+), 143 deletions(-) diff --git a/deq/deq_runtime/src/coordinator.rs b/deq/deq_runtime/src/coordinator.rs index aac010f9..16a05b35 100644 --- a/deq/deq_runtime/src/coordinator.rs +++ b/deq/deq_runtime/src/coordinator.rs @@ -1,4 +1,4 @@ -use crate::decoder::BlackBoxDecoderClient; +use crate::decoder::DynDecoder; #[cfg(feature = "cli")] use crate::misc::util::help_message; #[cfg(feature = "cli")] @@ -19,31 +19,28 @@ include!("proto/deq.coordinator.rs"); #[cfg(feature = "cli")] use coordinator_server::CoordinatorServer; -/// Replace each bit of `outcomes` whose position is set in `loss_mask` -/// with a uniformly random bit drawn from `rng`. This is the default -/// **loss-random-imputation** strategy: lost measurements (`loss_mask` -/// bits set to 1) are filled with random bits before the coordinator -/// computes the parity-check syndrome. -/// -/// Panics if `outcomes.size != loss_mask.size`, since a length mismatch -/// indicates a wire-format bug at the controller boundary. -pub fn apply_loss_random_imputation( - outcomes: &mut crate::util::BitVector, - loss_mask: &crate::util::BitVector, - rng: &mut R, -) { - use crate::misc::bit_vector; - use rand::RngExt; - assert_eq!( - outcomes.size, loss_mask.size, - "loss_mask size {} does not match outcomes size {}", - loss_mask.size, outcomes.size, - ); - for i in 0..outcomes.size { - if bit_vector::get_bit(loss_mask, i) { - bit_vector::set_bit(outcomes, i, rng.random::()); +pub fn validate_outcomes( + outcomes: &crate::util::BitVector, + loss_mask: Option<&crate::util::BitVector>, + expected_size: u64, +) -> Result<(), String> { + crate::misc::bit_vector::validate_data_len(outcomes, "outcomes")?; + if outcomes.size != expected_size { + return Err(format!( + "outcomes size {} does not match gadget measurement count {expected_size}", + outcomes.size + )); + } + if let Some(loss_mask) = loss_mask { + crate::misc::bit_vector::validate_data_len(loss_mask, "loss_mask")?; + if outcomes.size != loss_mask.size { + return Err(format!( + "loss_mask size {} does not match outcomes size {}", + loss_mask.size, outcomes.size + )); } } + Ok(()) } #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Debug)] @@ -90,21 +87,18 @@ pub use decoder_cache_key::{ DecoderCacheKey, ErrorModelFingerprint, FingerprintSource, ProbabilityModifierBits, build_modifier_fingerprints, }; +pub mod decoder_projection; +pub use decoder_projection::{DecodeProjection, DecoderReweighting, Deduplicated, LoadedDecoder}; + +pub mod loss_handler; +pub use loss_handler::{EnvelopeReweightPolicy, LossHandler, LossStrategy, ReweightScale, apply_loss_random_imputation}; + impl CoordinatorType { - pub fn create(&self, config: serde_json::Value, black_box_decoder: Option) -> DynCoordinator { + pub fn create(&self, config: serde_json::Value, decoder: DynDecoder) -> DynCoordinator { match self { Self::Naive => DynCoordinator::Naive(Arc::new(NaiveCoordinator::new(config))), - Self::Monolithic | Self::Window => { - let black_box_decoder = - black_box_decoder.expect("the provided decoder type does not support black box decoder interface"); - match self { - Self::Monolithic => { - DynCoordinator::Monolithic(Arc::new(MonolithicCoordinator::new(config, black_box_decoder))) - } - Self::Window => DynCoordinator::Window(Arc::new(WindowCoordinator::new(config, black_box_decoder))), - _ => unreachable!(), - } - } + Self::Monolithic => DynCoordinator::Monolithic(Arc::new(MonolithicCoordinator::new(config, decoder))), + Self::Window => DynCoordinator::Window(Arc::new(WindowCoordinator::new(config, decoder))), } } @@ -240,110 +234,3 @@ impl CoordinatorClient { .map(|v| v.into_inner()) } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::misc::bit_vector; - use crate::simulator::DeterministicRng; - use crate::util::BitVector; - use rand::SeedableRng; - - #[test] - fn apply_loss_random_imputation_leaves_non_loss_bits_untouched() { - // outcomes = [1, 0, 1, 0], loss_mask = [0, 0, 0, 0] - let mut outcomes = BitVector { - size: 4, - data: vec![0b1010_0000], - }; - let loss_mask = BitVector { - size: 4, - data: vec![0b0000_0000], - }; - let mut rng = DeterministicRng::seed_from_u64(42); - - let before = outcomes.clone(); - apply_loss_random_imputation(&mut outcomes, &loss_mask, &mut rng); - assert_eq!( - outcomes, before, - "no loss_mask bits set → no imputation, outcomes must stay byte-identical", - ); - } - - #[test] - fn apply_loss_random_imputation_only_replaces_marked_bits() { - // 1000 trials with loss_mask = [0, 1, 0, 1]. bits 0 and 2 must - // stay at their input values (1 and 1); bits 1 and 3 must take - // both 0 and 1 over the trial set (with overwhelming probability). - let mut rng = DeterministicRng::seed_from_u64(1); - let loss_mask = BitVector { - size: 4, - data: vec![0b0101_0000], - }; - let mut bit1_zero_count = 0usize; - let mut bit3_zero_count = 0usize; - let trials = 1000usize; - for _ in 0..trials { - let mut outcomes = BitVector { - size: 4, - data: vec![0b1010_0000], - }; - apply_loss_random_imputation(&mut outcomes, &loss_mask, &mut rng); - assert!(bit_vector::get_bit(&outcomes, 0), "bit 0 not in loss_mask, must be preserved"); - assert!(bit_vector::get_bit(&outcomes, 2), "bit 2 not in loss_mask, must be preserved"); - if !bit_vector::get_bit(&outcomes, 1) { - bit1_zero_count += 1; - } - if !bit_vector::get_bit(&outcomes, 3) { - bit3_zero_count += 1; - } - } - let lo = trials / 4; - let hi = 3 * trials / 4; - assert!( - (lo..hi).contains(&bit1_zero_count), - "bit 1 imputed zero {bit1_zero_count}/{trials} times; expected roughly balanced", - ); - assert!( - (lo..hi).contains(&bit3_zero_count), - "bit 3 imputed zero {bit3_zero_count}/{trials} times; expected roughly balanced", - ); - } - - #[test] - fn apply_loss_random_imputation_is_deterministic_with_same_seed() { - let loss_mask = BitVector { - size: 8, - data: vec![0b1111_1111], - }; - let initial = BitVector { - size: 8, - data: vec![0b0000_0000], - }; - - let mut a = initial.clone(); - let mut rng_a = DeterministicRng::seed_from_u64(123); - apply_loss_random_imputation(&mut a, &loss_mask, &mut rng_a); - - let mut b = initial.clone(); - let mut rng_b = DeterministicRng::seed_from_u64(123); - apply_loss_random_imputation(&mut b, &loss_mask, &mut rng_b); - - assert_eq!(a, b, "same seed → identical imputation"); - } - - #[test] - #[should_panic(expected = "does not match outcomes size")] - fn apply_loss_random_imputation_panics_on_size_mismatch() { - let mut outcomes = BitVector { - size: 4, - data: vec![0b0000_0000], - }; - let loss_mask = BitVector { - size: 5, - data: vec![0b0000_1000], - }; - let mut rng = DeterministicRng::seed_from_u64(0); - apply_loss_random_imputation(&mut outcomes, &loss_mask, &mut rng); - } -} From d14a9217fbc7f104766dd835db94f7629550afb6 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 17 Aug 2026 16:58:06 -0700 Subject: [PATCH 127/157] add monolithic coordinator --- .../src/coordinator/monolithic_coordinator.rs | 515 +++++++++++++----- 1 file changed, 376 insertions(+), 139 deletions(-) diff --git a/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs index cd06c5bf..c20cc735 100644 --- a/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs @@ -23,8 +23,16 @@ use crate::bin; use crate::coordinator; -use crate::coordinator::{DecoderCacheKey, FingerprintSource, build_modifier_fingerprints}; -use crate::decoder::BlackBoxDecoderClient; +use crate::coordinator::decoder_projection::{ + DecodeProjection, Deduplicated, apply_reweights, decode_projected, deduplicate_by_syndrome, probability_reweights, + validate_probability_modifier, +}; +use crate::coordinator::loss_handler::{RawLossSite, apply_loss_random_imputation, has_loss_model}; +use crate::coordinator::{ + DecoderCacheKey, DecoderReweighting, FingerprintSource, LoadedDecoder, LossHandler, LossStrategy, + build_modifier_fingerprints, +}; +use crate::decoder::DynDecoder; use crate::decoder::blackbox_decoder::{self, DecodingHypergraph, Hyperedge}; use crate::decoder::blackbox_util::assert_parity_factor; use crate::misc::bit_vector::{self, get_bit, set_bit}; @@ -33,10 +41,10 @@ use crate::misc::pauli_frame_tracker::PauliFrameTracker; use crate::misc::relative_program::{self, RelativeMapping, RelativeProgram}; use crate::misc::sync::{TaskCounter, check_or_receiver, get_or_receiver, get_value}; use crate::misc::union_find::{UnionFindGeneric, UnionNodeTrait}; -use crate::misc::util::exclusive_probability_of; use crate::util::BitVector; use binar::{BitVec, BitwiseMut}; use hashbrown::{HashMap, HashSet}; + use serde::{Deserialize, Serialize}; use std::sync::Arc; #[cfg(feature = "cli")] @@ -53,10 +61,10 @@ pub struct MonolithicCoordinatorConfig { /// should return a parity factor that exactly produces the observed syndrome #[serde(default)] pub assert_parity_factor: bool, - /// merge hyperedges if they have the same syndrome; note that in the ideal - /// case, this should be the job of offline processing instead of online - /// processing, so we disable this feature by default and only provide the - /// functionality to temporarily optimize the decoding performance + /// Merge hyperedges with the same syndrome (enabled by default). Ignored by + /// the ``handoff`` loss strategy because structured loss addresses distinct + /// error mechanisms by edge index and deduplication would destroy that + /// distinction. #[serde(default = "default_true")] pub merge_hyperedges: bool, /// by default, we expand the remote references prior to loading the outcomes, @@ -69,6 +77,12 @@ pub struct MonolithicCoordinatorConfig { /// build the decoder data structure every time, which could be time consuming #[serde(default = "default_true")] pub persistent_decoder: bool, + /// Transport policy for shot-scoped probability updates: ``auto`` uses + /// loaded reweights when supported and otherwise materializes a one-shot + /// graph; ``enabled`` requires decoder support; ``disabled`` always + /// materializes updates. + #[serde(default)] + pub decoder_reweighting: DecoderReweighting, /// when ``true`` (the default), each bit of ``Outcomes.outcomes`` whose /// position is set in the accompanying ``Outcomes.loss_mask`` is replaced /// with a uniformly random bit before the coordinator computes the syndrome. @@ -78,6 +92,26 @@ pub struct MonolithicCoordinatorConfig { /// RNG is seeded from the OS entropy pool at coordinator construction. #[serde(default)] pub loss_random_imputation_seed: Option, + /// what to do with the atom losses heralded by ``Outcomes.loss_mask``: + /// ``"reweight"`` (the default) raises the observed losses' Pauli-envelope + /// generator edges to a comparable weight and hands the decoder an ordinary + /// problem, ``"ignore"`` drops the information, and ``"handoff"`` passes the + /// assembled loss sites to a loss-aware decoder as a structured + /// [`LossInfo`](crate::decoder::blackbox_decoder::LossInfo). Orthogonal to + /// ``loss_random_imputation``, which applies under every strategy. Under + /// ``reweight`` and ``handoff`` the per-gadget-type loss models come from + /// ``GadgetType.loss_model`` in the loaded library — a program whose gadget + /// types declare none costs nothing. + #[serde(default)] + pub loss_strategy: LossStrategy, + /// options for the chosen ``loss_strategy``, parsed and validated at + /// construction. ``ignore`` and ``handoff`` take none; ``reweight`` takes + /// ``{"weight_fraction": f}`` with ``f`` in ``(0, 1]`` (default ``0.5``), + /// the fraction of its own scale *weight* each loss edge is lowered to. An + /// option that belongs to another strategy is an error, not a silent no-op. + #[serde(default)] + #[cfg_attr(feature = "cli", structdoc(leaf = "JSON object"))] + pub loss_config: serde_json::Value, } fn default_true() -> bool { @@ -117,7 +151,7 @@ pub struct MonolithicCoordinator { /// therefore different modifier state — into the same relative slot. pub loaded_decoders: RwLock>, /// the decoder service - pub black_box_decoder: BlackBoxDecoderClient, + pub decoder: DynDecoder, /// Pauli frame tracker pub pauli_frame_tracker: Mutex, /// Cancelled on reset()/drop to abort all pending decode/expand tasks. @@ -130,6 +164,11 @@ pub struct MonolithicCoordinator { /// entropy when no seed was supplied). ``None`` when imputation is /// disabled, so the field doesn't even allocate. pub loss_imputation_rng: Option>, + /// Validated loss strategy, built from ``config.loss_strategy`` and + /// ``config.loss_config`` at construction. + pub loss_handler: LossHandler, + /// Whether this decoder accepts shot-scoped loaded reweights. + pub use_loaded_reweights: bool, } /// Per-coordinator [`FingerprintSource`] adapter for the monolithic @@ -143,25 +182,11 @@ impl FingerprintSource for ErrorModel { } } -#[derive(Debug, Clone)] -pub struct LoadedDecoder { - /// hypergraph id - pub hid: u64, - /// mapping from hypergraph edge index to error index in the relative program - /// note that one have to use the relative program mapping to map it to the - /// global id - pub errors: Arc>, - /// decoding hypergraph for sanity check only - pub decoding_hypergraph: Option>, - /// maps compact vertex index → original vertex index; used to remap - /// syndromes when reusing a cached decoder that had isolated vertices - /// stripped. `None` means no compaction was needed (identity mapping). - pub vertex_remap: Option>>, -} - pub struct Gadget { pub instance: bin::Gadget, pub outcomes: Option, + pub probability_modifiers: Vec<(u64, bin::ProbabilityModifier)>, + pub loss_mask: Option, /// the check model's cid that is binding to this gadget pub binding_cid: watch::Sender>, /// the peer gadgets' gid connected to each output port @@ -193,8 +218,12 @@ pub struct ErrorModel { } impl MonolithicCoordinator { - pub fn new(config: serde_json::Value, black_box_decoder: BlackBoxDecoderClient) -> Self { + pub fn new(config: serde_json::Value, decoder: DynDecoder) -> Self { let config: MonolithicCoordinatorConfig = serde_json::from_value(config).unwrap(); + let use_loaded_reweights = config + .decoder_reweighting + .use_loaded(config.persistent_decoder, decoder.features()) + .unwrap_or_else(|error| panic!("invalid decoder reweighting configuration: {error}")); let loss_imputation_rng = if config.loss_random_imputation { use rand::{Rng, SeedableRng}; let seed = config.loss_random_imputation_seed.unwrap_or_else(|| rand::rng().next_u64()); @@ -202,6 +231,8 @@ impl MonolithicCoordinator { } else { None }; + let loss_handler = LossHandler::new(config.loss_strategy, config.loss_config.clone()) + .unwrap_or_else(|error| panic!("invalid loss configuration: {error}")); Self { config, port_types: Default::default(), @@ -217,11 +248,13 @@ impl MonolithicCoordinator { pending_subgraphs: Mutex::new(UnionFindGeneric::new(0)), gid_to_union_index: Mutex::new(HashMap::new()), loaded_decoders: Default::default(), - black_box_decoder, + decoder, pauli_frame_tracker: Default::default(), cancellation: RwLock::new(CancellationToken::new()), task_counter: TaskCounter::new(), loss_imputation_rng, + loss_handler, + use_loaded_reweights, } } @@ -481,12 +514,12 @@ impl MonolithicCoordinator { // for each error, apply the effect for &ei in parity_factor.subgraph.iter() { let local_error = &errors[ei as usize]; - let local_eid = local_error.eid as usize; + let local_eid = local_error.eid; let eid = mapping.global_eid_of[local_eid]; let error_index = local_error.error_index; let error_model = error_models.get(&eid).unwrap(); let error_model_type = error_model_types.get(&error_model.instance.etype).unwrap(); - let error = &error_model_type.errors[error_index as usize]; + let error = &error_model_type.errors[error_index]; // update the corresponding gadget's residual and readout flips let local_gid = mapping.local_gid_of_local_eid[local_eid]; let residual = &mut residual_vec[local_gid]; @@ -521,8 +554,25 @@ impl MonolithicCoordinator { // calculate syndrome let syndrome = self.get_syndrome(relative_program, mapping, gadgets, check_models).await; + // Assemble the observed atom losses into loss sites. These are + // shot-dependent, but only in their *content*: the loaded graph stays + // fixed and the shot's loss travels with the decode request, so a loss + // shot is served from the cache like any other. + let loss_sites = self.build_loss_sites(mapping, gadgets, check_models).await; + + // Deduplication collapses edges that share a syndrome, which renumbers + // them and, worse for a loss-aware decoder, merges a loss generator with + // any ordinary Pauli error sharing its syndrome. A coordinator that hands + // losses to the decoder therefore never deduplicates -- not even on a shot + // carrying no loss, since the graph it loads then serves later shots that + // do. + let deduplicate = self.config.merge_hyperedges && !self.loss_handler.hands_off_to_decoder(); + let cache_key = if self.config.persistent_decoder { let error_model_types = self.error_model_types.read().await; + // Construction-time modifiers define the base graph and therefore + // its cache identity. Outcomes.modifiers are shot-scoped assignments + // projected below; including them here would defeat decoder reuse. Some(DecoderCacheKey { relative_program: relative_program.clone(), error_model_fingerprints: build_modifier_fingerprints(mapping, error_models, &error_model_types), @@ -533,19 +583,23 @@ impl MonolithicCoordinator { }; if let Some(ref cache_key) = cache_key { - let loaded_decoders = self.loaded_decoders.read().await; - let loaded = loaded_decoders.get(cache_key); + let loaded = self.loaded_decoders.read().await.get(cache_key).cloned(); if let Some(loaded) = loaded { - // we can use the loaded decoding hypergraph to call the decoding service - let parity_factor = self - .black_box_decoder - .clone() - .decode_loaded(blackbox_decoder::LoadedDecodingProblem { - hid: loaded.hid, - syndrome: Some(syndrome.clone()), - }) - .await - .unwrap(); + let probability_reweights = + Self::shot_probability_reweights(mapping, gadgets, &loaded.projection.base_errors); + let (reweights, loss) = + self.loss_handler + .project_shot(&loaded.projection, &probability_reweights, &loss_sites); + let parity_factor = decode_projected( + &self.decoder, + &loaded, + syndrome.clone(), + reweights, + loss, + self.use_loaded_reweights, + ) + .await + .unwrap(); if self.config.assert_parity_factor { assert_parity_factor(loaded.decoding_hypergraph.as_ref().unwrap(), &parity_factor, &syndrome); } @@ -555,94 +609,149 @@ impl MonolithicCoordinator { // when the decoder is not available, construct a monolithic decoding hypergraph // and instantiate such a decoder - let (mut decoding_hypergraph, mut errors) = self + let (decoding_hypergraph, errors) = self .decoding_hypergraph(relative_program, mapping, check_models, error_models) .await; - // merge the decoding hypergraph edges if their syndromes are the same - if self.config.merge_hyperedges { - let mut original_to_merged = Vec::with_capacity(errors.len()); - let mut merged: HashMap, (usize, f64)> = HashMap::new(); - let mut merged_hyperedges: Vec = Vec::with_capacity(errors.len()); - let mut merged_errors = Vec::with_capacity(errors.len()); - for (hyperedge, error_index) in decoding_hypergraph.hyperedges.iter().zip(errors.iter()) { - let mut syndrome = hyperedge.vertices.clone(); - syndrome.sort(); - debug_assert!({ - let degree = syndrome.len(); - syndrome.dedup(); - syndrome.len() == degree - }); // syndrome should not contain duplicate items - if let Some((ei, best_p_e)) = merged.get_mut(&syndrome) { - let p_all = merged_hyperedges[*ei].probability; - merged_hyperedges[*ei].probability = exclusive_probability_of(p_all, hyperedge.probability); - if hyperedge.probability > *best_p_e { - *best_p_e = hyperedge.probability; - merged_errors[*ei] = error_index.clone(); - } - original_to_merged.push(*ei); - } else { - let ei = merged_errors.len(); - merged_hyperedges.push(Hyperedge { - probability: hyperedge.probability, - vertices: syndrome.clone(), - }); - merged_errors.push(error_index.clone()); - original_to_merged.push(ei); - merged.insert(syndrome, (ei, hyperedge.probability)); - } + // Priors, captured before loss reweighting rewrites them. Merging picks its + // representative from these rather than from the reweighted values: a + // reweighted probability is a decoding weight, not a calibrated posterior, + // and letting it arbitrate corrections lets the loss heuristic decide + // logical outcomes. + let priors: Vec = decoding_hypergraph.hyperedges.iter().map(|h| h.probability).collect(); + let probability_reweights = Self::shot_probability_reweights(mapping, gadgets, &errors); + + let Some(cache_key) = cache_key else { + // Without a persistent decoder every shot ships its whole problem, so + // probability updates are applied before loss derives its live priors. + let mut decoding_hypergraph = decoding_hypergraph; + apply_reweights(&mut decoding_hypergraph, &probability_reweights); + let (mut decoding_hypergraph, loss) = self.loss_handler.apply_sites(decoding_hypergraph, &loss_sites, &errors); + let mut errors = errors; + if deduplicate { + let deduplicated = deduplicate_by_syndrome(&decoding_hypergraph, &errors, &priors); + decoding_hypergraph = deduplicated.hypergraph; + errors = Arc::new(deduplicated.representatives); } - decoding_hypergraph = DecodingHypergraph { - vertex_num: decoding_hypergraph.vertex_num, - hyperedges: merged_hyperedges, - }; - errors = Arc::new(merged_errors); - } - let decoding_hypergraph = Arc::new(decoding_hypergraph); - - let parity_factor = if let Some(cache_key) = cache_key { - let hid = self - .black_box_decoder - .clone() - .load_hypergraph(decoding_hypergraph.as_ref().clone()) - .await - .unwrap() - .hid; - let mut loaded_decoders = self.loaded_decoders.write().await; - loaded_decoders.insert( - cache_key, - LoadedDecoder { - hid, - errors: errors.clone(), - decoding_hypergraph: self.config.assert_parity_factor.then_some(decoding_hypergraph.clone()), - vertex_remap: None, - }, - ); - drop(loaded_decoders); - self.black_box_decoder - .clone() - .decode_loaded(blackbox_decoder::LoadedDecodingProblem { - hid, - syndrome: Some(syndrome.clone()), - }) - .await - .unwrap() - } else { - self.black_box_decoder - .clone() + let parity_factor = self + .decoder .decode(blackbox_decoder::DecodingProblem { - hypergraph: Some(decoding_hypergraph.as_ref().clone()), + hypergraph: Some(decoding_hypergraph.clone()), syndrome: Some(syndrome.clone()), + loss, }) .await - .unwrap() + .unwrap(); + if self.config.assert_parity_factor { + assert_parity_factor(&decoding_hypergraph, &parity_factor, &syndrome); + } + return (parity_factor, errors); }; + // Load the *base* graph -- the one carrying priors, before any shot's loss + // is applied -- so this entry serves every shot regardless of which atoms + // it loses, and send the shot's own loss alongside the syndrome. + let deduplicated = if deduplicate { + deduplicate_by_syndrome(&decoding_hypergraph, &errors, &priors) + } else { + Deduplicated::identity(&decoding_hypergraph, &errors) + }; + let loadable = deduplicated.hypergraph.clone(); + let representatives = Arc::new(deduplicated.representatives.clone()); + let projection = Arc::new(DecodeProjection { + base_hypergraph: decoding_hypergraph, + base_errors: errors, + priors, + deduplicated, + }); + let (reweights, loss) = self + .loss_handler + .project_shot(&projection, &probability_reweights, &loss_sites); + let hid = self.decoder.load_hypergraph(loadable.clone()).await.unwrap().hid; + let loaded = LoadedDecoder { + hid, + errors: representatives.clone(), + decoding_hypergraph: (!self.use_loaded_reweights || self.config.assert_parity_factor) + .then(|| Arc::new(loadable)), + vertex_remap: None, + projection, + }; + let mut loaded_decoders = self.loaded_decoders.write().await; + loaded_decoders.insert(cache_key, loaded.clone()); + drop(loaded_decoders); + let parity_factor = decode_projected( + &self.decoder, + &loaded, + syndrome.clone(), + reweights, + loss, + self.use_loaded_reweights, + ) + .await + .unwrap(); if self.config.assert_parity_factor { - assert_parity_factor(&decoding_hypergraph, &parity_factor, &syndrome); + assert_parity_factor(loaded.decoding_hypergraph.as_ref().unwrap(), &parity_factor, &syndrome); + } + (parity_factor, representatives) + } + + async fn bind_probability_modifiers( + &self, + gid: u64, + modifiers: &[bin::ProbabilityModifier], + ) -> Result, Status> { + if modifiers.is_empty() { + return Ok(vec![]); } + let cid = self + .gadgets + .read() + .await + .get(&gid) + .and_then(|gadget| *gadget.binding_cid.borrow()) + .ok_or_else(|| Status::failed_precondition(format!("gid={gid} has no binding check model")))?; + let eids = self + .check_models + .read() + .await + .get(&cid) + .map(|check_model| check_model.attaching_eid_vec.clone()) + .ok_or_else(|| Status::failed_precondition(format!("cid={cid} is not loaded")))?; + if modifiers.len() > eids.len() { + return Err(Status::invalid_argument(format!( + "gid={gid} supplied {} probability modifiers for {} attached error models", + modifiers.len(), + eids.len() + ))); + } + let error_model_types = self.error_model_types.read().await; + let error_models = self.error_models.read().await; + let mut bound = Vec::with_capacity(modifiers.len()); + for (&eid, modifier) in eids.iter().zip(modifiers) { + let error_model = error_models + .get(&eid) + .ok_or_else(|| Status::failed_precondition(format!("eid={eid} is not loaded")))?; + let error_model_type = error_model_types + .get(&error_model.instance.etype) + .ok_or_else(|| Status::failed_precondition(format!("etype={} is not loaded", error_model.instance.etype)))?; + validate_probability_modifier(modifier, error_model_type.errors.len()).map_err(Status::invalid_argument)?; + bound.push((eid, modifier.clone())); + } + Ok(bound) + } - (parity_factor, errors) + fn shot_probability_reweights( + mapping: &RelativeMapping, + gadgets: &HashMap, + error_reference: &[ErrorIndex], + ) -> Vec<(u64, f64)> { + let mut modifiers: Vec<_> = gadgets + .values() + .flat_map(|gadget| gadget.probability_modifiers.iter()) + .filter_map(|(eid, modifier)| mapping.local_eid_of.get(eid).map(|&local_eid| (local_eid, modifier))) + .collect(); + modifiers.sort_unstable_by_key(|&(local_eid, _)| local_eid); + probability_reweights(error_reference, modifiers) } async fn get_syndrome( @@ -687,6 +796,88 @@ impl MonolithicCoordinator { syndrome } + /// Build the possible loss sites for this decode window from the observed atom losses. + /// + /// Returns an empty list under [`LossStrategy::Ignore`], or when no gadget in + /// this window recorded observed losses. Otherwise it collects each gadget's + /// possible loss sites, kept ungrouped for either coordinator-side reweighting + /// or handoff to a loss-aware decoder. + async fn build_loss_sites( + &self, + mapping: &RelativeMapping, + gadgets: &HashMap, + check_models: &HashMap, + ) -> Vec { + use crate::jit::loss_compiler::{GadgetLoss, build_cross_gadget_loss_sites, build_cross_gadget_output_links}; + let mut loss_sites = Vec::new(); + if !self.loss_handler.tracks_losses() || !gadgets.values().any(|g| g.loss_mask.is_some()) { + return loss_sites; + } + + // Gadgets in this window that carry a loss model, in program order. A + // gadget with no *observed* loss is still included: a loss can pass + // through it unheralded and only be resolved downstream, so it is needed + // to assemble the cross-gadget chain. + let gadget_types = self.gadget_types.read().await; + let mut gid_of_index: Vec = Vec::new(); + let mut index_of_gid: HashMap = HashMap::new(); + for &gid in &mapping.global_gid_of { + let Some(gadget) = gadgets.get(&gid) else { continue }; + if !has_loss_model(&gadget_types, gadget.instance.gtype) { + continue; + } + index_of_gid.insert(gid, gid_of_index.len()); + gid_of_index.push(gid); + } + if gid_of_index.is_empty() { + return loss_sites; + } + + // The error model whose error list a site's generators index: the first + // one attached to the gadget, per the `GadgetType.loss_model` contract. + let local_eid_of_index: Vec> = gid_of_index + .iter() + .map(|&gid| { + let cid = (*gadgets.get(&gid)?.binding_cid.borrow())?; + let eid = *check_models.get(&cid)?.attaching_eid_vec.first()?; + mapping.local_eid_of.get(&eid).copied() + }) + .collect(); + + let loss_masks: Vec = gid_of_index + .iter() + .map(|&gid| gadgets.get(&gid).and_then(|g| g.loss_mask.clone()).unwrap_or_default()) + .collect(); + + // Cross-gadget links: each gadget's output flat slot that a loss can leave + // on, mapped to the (downstream gadget index, input flat slot) it enters. + let port_types = self.port_types.read().await; + let gadget_instances: Vec<&bin::Gadget> = + gid_of_index.iter().map(|&gid| &gadgets.get(&gid).unwrap().instance).collect(); + let output_links_vec = build_cross_gadget_output_links(&gadget_instances, &index_of_gid, &gadget_types, &port_types); + + let gadget_losses: Vec = (0..gid_of_index.len()) + .map(|index| { + let gtype = gadgets.get(&gid_of_index[index]).unwrap().instance.gtype; + GadgetLoss { + loss_model: gadget_types.get(>ype).unwrap().loss_model.as_ref().unwrap(), + observed: &loss_masks[index], + output_links: &output_links_vec[index], + } + }) + .collect(); + + // Assemble the possible loss chains across gadgets. Each site maps 1:1 + // onto a returned `RawLossSite` so its `children` (positions in the site + // list) stay valid for a loss-aware decoder; the envelope-matching + // flatten simply unions the edges and ignores `children`. + for site in build_cross_gadget_loss_sites(&gadget_losses) { + let local_eid = local_eid_of_index[site.gadget_index]; + loss_sites.push(RawLossSite::from_compiled(site, local_eid)); + } + loss_sites + } + async fn decoding_hypergraph( &self, relative_program: &RelativeProgram, @@ -733,9 +924,15 @@ impl MonolithicCoordinator { } let local_start_index = mapping.start_indices[local_cid] as u64; for (error_index, error) in errors.iter().enumerate() { - if error.probability <= 0.0 { - continue; - } + // Zero-probability errors are kept, at their prior probability. + // They exist for a reason -- an atom loss activates its + // Pauli-envelope generators, and a caller may reweight any edge + // -- so the hyperedge set stays independent of what happened in + // a given shot. That keeps edge indices stable across shots, so + // `LossInfo` can reference them and a loaded decoder stays + // reusable; the reweighting raises them from infinite weight to + // a usable value. + let probability = error.probability; let mut vertices: Vec = vec![]; for check in &error.checks { if let Some(ri) = check.remote_check_model { @@ -758,13 +955,10 @@ impl MonolithicCoordinator { continue; // skip the no-effect errors } error_reference.push(ErrorIndex { - eid: local_eid as u64, - error_index: error_index as u64, - }); - hyperedges.push(Hyperedge { - vertices, - probability: error.probability, + eid: local_eid, + error_index, }); + hyperedges.push(Hyperedge { vertices, probability }); } } } @@ -981,7 +1175,13 @@ impl MonolithicCoordinator { #[tonic::async_trait] impl coordinator::coordinator_server::Coordinator for MonolithicCoordinator { async fn load_library(&self, request: Request) -> Result, Status> { + let _task_guard = self + .task_counter + .try_guard() + .ok_or_else(|| Status::unavailable("coordinator reset in progress"))?; let library = request.into_inner(); + self.loss_handler + .validate_capability(&library.gadget_types, self.decoder.features())?; let mut port_types = self.port_types.write().await; for port_type in library.port_types.into_iter() { if port_types.contains_key(&port_type.ptype) { @@ -1022,6 +1222,10 @@ impl coordinator::coordinator_server::Coordinator for MonolithicCoordinator { } async fn execute(&self, request: Request) -> Result, Status> { + let _task_guard = self + .task_counter + .try_guard() + .ok_or_else(|| Status::unavailable("coordinator reset in progress"))?; let instruction = request.into_inner(); let create = instruction .create @@ -1081,6 +1285,8 @@ impl coordinator::coordinator_server::Coordinator for MonolithicCoordinator { Gadget { instance: gadget, outcomes: None, + probability_modifiers: vec![], + loss_mask: None, binding_cid: watch::channel(None).0, // important: we should not use vec![;len] syntax because it will create clones outputs: gadget_type.outputs.iter().map(|_| watch::channel(None).0).collect(), @@ -1162,6 +1368,17 @@ impl coordinator::coordinator_server::Coordinator for MonolithicCoordinator { let error_model_types = self.error_model_types.read().await; let mut check_models = self.check_models.write().await; let mut error_models = self.error_models.write().await; + let error_model_type = error_model_types + .get(&error_model.etype) + .ok_or_else(|| Status::not_found(format!("etype={}", error_model.etype)))?; + if let Some(probability_modifier) = error_model + .modifier + .as_ref() + .and_then(|modifier| modifier.probability_modifier.as_ref()) + { + validate_probability_modifier(probability_modifier, error_model_type.errors.len()) + .map_err(Status::invalid_argument)?; + } let eid = if error_model.eid == 0 { // Auto-assign: find next unused eid let mut next_eid = self.next_eid.lock().await; @@ -1175,9 +1392,6 @@ impl coordinator::coordinator_server::Coordinator for MonolithicCoordinator { // User-provided eid error_model.eid }; - let error_model_type = error_model_types - .get(&error_model.etype) - .ok_or_else(|| Status::not_found(format!("etype={}", error_model.etype)))?; let check_model = check_models.get_mut(&error_model.cid).ok_or_else(|| { Status::invalid_argument(format!("eid={eid} attaching to unknown cid={}", error_model.cid)) })?; @@ -1238,12 +1452,14 @@ impl coordinator::coordinator_server::Coordinator for MonolithicCoordinator { async fn decode(&self, request: Request) -> Result, Status> { let outcomes = request.into_inner(); - // Guard the decode operation so that reset() waits for all in-flight - // decodes to finish before clearing shared state (e.g. pauli_frame_tracker). - let _task_guard = self.task_counter.guard(); + let _task_guard = self + .task_counter + .try_guard() + .ok_or_else(|| Status::unavailable("coordinator reset in progress"))?; + let gid = outcomes.gid; + let probability_modifiers = self.bind_probability_modifiers(gid, &outcomes.modifiers).await?; let gadget_types = self.gadget_types.read().await; let mut gadgets = self.gadgets.write().await; - let gid = outcomes.gid; let gadget = gadgets .get_mut(&gid) .ok_or_else(|| Status::not_found(format!("gid={}", gid)))?; @@ -1254,15 +1470,35 @@ impl coordinator::coordinator_server::Coordinator for MonolithicCoordinator { let mut outcome_data = outcomes .outcomes .ok_or_else(|| Status::invalid_argument("missing outcomes"))?; + let gadget_type = gadget_types + .get(&gadget.instance.gtype) + .ok_or_else(|| Status::failed_precondition(format!("gtype={} is not loaded", gadget.instance.gtype)))?; + coordinator::validate_outcomes( + &outcome_data, + outcomes.loss_mask.as_ref(), + u64::try_from(gadget_type.measurements.len()).expect("measurement count must fit in u64"), + ) + .map_err(Status::invalid_argument)?; // Apply loss-random-imputation before storing the outcomes: every // downstream consumer (syndrome calculation, pauli-frame tracker, // ...) reads `gadget.outcomes` and benefits from a single // consistent imputed value per measurement bit. if let (Some(rng_lock), Some(loss_mask)) = (self.loss_imputation_rng.as_ref(), outcomes.loss_mask.as_ref()) { let mut rng = rng_lock.lock().await; - coordinator::apply_loss_random_imputation(&mut outcome_data, loss_mask, &mut *rng); + apply_loss_random_imputation(&mut outcome_data, loss_mask, &mut *rng); + } + // Record the observed atom losses for the loss pipeline: the set of local + // measurement indices flagged as losses. Only kept when the strategy uses + // them and this gadget type actually carries a loss model. + if self.loss_handler.tracks_losses() + && has_loss_model(&gadget_types, gadget.instance.gtype) + && let Some(loss_mask) = outcomes.loss_mask.as_ref() + && (0..loss_mask.size).any(|index| get_bit(loss_mask, index)) + { + gadget.loss_mask = Some(loss_mask.clone()); } gadget.outcomes.replace(outcome_data); + gadget.probability_modifiers = probability_modifiers; let mut pending_subgraphs = self.pending_subgraphs.lock().await; let gid_to_union_index = self.gid_to_union_index.lock().await; let union_index = gid_to_union_index[&gid]; @@ -1272,7 +1508,6 @@ impl coordinator::coordinator_server::Coordinator for MonolithicCoordinator { let is_final_gadget = node.num_unloaded_gadgets == 0 && node.num_unconnected_outputs == 0; let rx = gadget.rx.take().unwrap(); // calculate the raw readouts (before error correction); - let gadget_type = gadget_types.get(&gadget.instance.gtype).unwrap(); let mut readouts = Vec::with_capacity(gadget_type.readouts.len()); let data: &BitVector = gadget.outcomes.as_ref().unwrap(); for readout in gadget_type.readouts.iter() { @@ -1303,6 +1538,10 @@ impl coordinator::coordinator_server::Coordinator for MonolithicCoordinator { async fn reset(&self, request: Request) -> Result, Status> { let flags = request.into_inner(); + let _pause = self + .task_counter + .try_pause() + .ok_or_else(|| Status::unavailable("coordinator reset already in progress"))?; // Cancel all pending async tasks, wait for them to finish, then // install a fresh token so post-reset operations proceed normally. { @@ -1330,19 +1569,17 @@ impl coordinator::coordinator_server::Coordinator for MonolithicCoordinator { pending_subgraphs.remove_all(); self.gid_to_union_index.lock().await.clear(); self.pauli_frame_tracker.lock().await.reset(); + if flags.reset_library || flags.reset_decoder_service { + self.loaded_decoders.write().await.clear(); + } // since decoders reset asynchronously, wait for all the decoders to finish - self.black_box_decoder - .clone() + self.decoder .reset(blackbox_decoder::ResetRequest { reset_hypergraphs: flags.reset_decoder_service, ..Default::default() }) .await .map_err(|e| Status::internal(format!("reset decoder service error: {}", e)))?; - if flags.reset_decoder_service { - let mut loaded_decoders = self.loaded_decoders.write().await; - loaded_decoders.clear(); - } Ok(().into()) } } From 7f5bde9703ebcf55ee5445fbf60a81d379cda1a7 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 17 Aug 2026 17:04:52 -0700 Subject: [PATCH 128/157] check in window coordinator with loss support --- .../src/coordinator/window_coordinator.rs | 502 +++++++++++++----- 1 file changed, 377 insertions(+), 125 deletions(-) diff --git a/deq/deq_runtime/src/coordinator/window_coordinator.rs b/deq/deq_runtime/src/coordinator/window_coordinator.rs index 2fb00228..7cec6278 100644 --- a/deq/deq_runtime/src/coordinator/window_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/window_coordinator.rs @@ -82,9 +82,16 @@ use crate::bin; use crate::coordinator; -use crate::coordinator::monolithic_coordinator::LoadedDecoder; -use crate::coordinator::{DecoderCacheKey, FingerprintSource, build_modifier_fingerprints}; -use crate::decoder::BlackBoxDecoderClient; +use crate::coordinator::decoder_projection::{ + DecodeProjection, Deduplicated, apply_reweights, decode_projected, deduplicate_by_syndrome, probability_reweights, + validate_probability_modifier, +}; +use crate::coordinator::loss_handler::{RawLossSite, apply_loss_random_imputation, has_loss_model}; +use crate::coordinator::{ + DecoderCacheKey, DecoderReweighting, FingerprintSource, LoadedDecoder, LossHandler, LossStrategy, + build_modifier_fingerprints, +}; +use crate::decoder::DynDecoder; use crate::decoder::blackbox_decoder::{self, DecodingHypergraph, Hyperedge}; use crate::decoder::blackbox_util::assert_parity_factor; use crate::misc::bit_vector::{self, flip_bit, get_bit, set_bit}; @@ -93,7 +100,6 @@ use crate::misc::index::{ErrorIndex, WILDCARD}; use crate::misc::pauli_frame_tracker::PauliFrameTracker; use crate::misc::relative_program::{self, RelativeMapping, RelativeProgram}; use crate::misc::sync::{TaskCounter, check_or_receiver, get_or_receiver}; -use crate::misc::util::exclusive_probability_of; use crate::util::BitVector; use binar::{BitVec, BitwiseMut}; use hashbrown::{HashMap, HashSet}; @@ -120,10 +126,10 @@ pub struct WindowCoordinatorConfig { /// should return a parity factor that exactly produces the observed syndrome #[serde(default)] pub assert_parity_factor: bool, - /// merge hyperedges if they have the same syndrome; note that in the ideal - /// case, this should be the job of offline processing instead of online - /// processing, so we disable this feature by default and only provide the - /// functionality to temporarily optimize the decoding performance + /// Merge hyperedges with the same syndrome (enabled by default). Ignored by + /// the ``handoff`` loss strategy because structured loss addresses distinct + /// error mechanisms by edge index and deduplication would destroy that + /// distinction. #[serde(default = "default_true")] pub merge_hyperedges: bool, /// by default, we load the hypergraph to the decoder service and use it @@ -132,6 +138,12 @@ pub struct WindowCoordinatorConfig { /// build the decoder data structure every time, which could be time consuming #[serde(default = "default_true")] pub persistent_decoder: bool, + /// Transport policy for shot-scoped probability updates: ``auto`` uses + /// loaded reweights when supported and otherwise materializes a one-shot + /// graph; ``enabled`` requires decoder support; ``disabled`` always + /// materializes updates. + #[serde(default)] + pub decoder_reweighting: DecoderReweighting, /// Minimum hop-distance from any window boundary required for a gadget to /// be committed. Ensures each committed gadget has sufficient decoder /// context on all sides. The effective window radius is @@ -161,6 +173,19 @@ pub struct WindowCoordinatorConfig { /// RNG is seeded from the OS entropy pool at coordinator construction. #[serde(default)] pub loss_random_imputation_seed: Option, + /// What to do with atom losses heralded by ``Outcomes.loss_mask``: + /// ``"reweight"`` (the default) raises Pauli-envelope edges, ``"ignore"`` + /// drops the loss information, and ``"handoff"`` passes structured sites + /// to a loss-aware decoder. Independent of random imputation. Loss models + /// come from ``GadgetType.loss_model`` in the loaded library. + #[serde(default)] + pub loss_strategy: LossStrategy, + /// Options for the chosen ``loss_strategy``, parsed and validated at + /// construction. ``ignore`` and ``handoff`` take none; ``reweight`` takes + /// ``{"weight_fraction": f}`` with ``f`` in ``(0, 1]`` (default ``0.5``). + #[serde(default)] + #[cfg_attr(feature = "cli", structdoc(leaf = "JSON object"))] + pub loss_config: serde_json::Value, } impl WindowCoordinatorConfig { @@ -223,7 +248,7 @@ pub struct WindowCoordinator { /// determined by `mapping.global_eid_of` — so it must be part of the key. pub loaded_decoders: RwLock>, /// the decoder service - pub black_box_decoder: BlackBoxDecoderClient, + pub decoder: DynDecoder, /// Pauli frame tracker pub pauli_frame_tracker: Mutex, /// Cancelled on reset()/drop to abort all pending decode/expand tasks. @@ -234,6 +259,11 @@ pub struct WindowCoordinator { /// ``config.loss_random_imputation`` is enabled. Seeded once at /// construction; ``None`` when imputation is disabled. pub loss_imputation_rng: Option>, + /// Validated loss strategy, built from ``config.loss_strategy`` and + /// ``config.loss_config`` at construction. + pub loss_handler: LossHandler, + /// Whether this decoder accepts shot-scoped loaded reweights. + pub use_loaded_reweights: bool, /// accumulated trace for the current shot pub trace_shot: Arc>, /// accumulated trace across all shots @@ -262,6 +292,8 @@ pub enum GadgetState { pub struct Gadget { pub instance: bin::Gadget, pub outcomes: watch::Sender>, + pub probability_modifiers: Vec<(u64, bin::ProbabilityModifier)>, + pub loss_mask: Option, /// the check model's cid that is binding to this gadget pub binding_cid: Option, /// the peer gadgets' gid connected to each output port @@ -391,8 +423,12 @@ pub struct ExploredWindow { } impl WindowCoordinator { - pub fn new(config: serde_json::Value, black_box_decoder: BlackBoxDecoderClient) -> Self { + pub fn new(config: serde_json::Value, decoder: DynDecoder) -> Self { let config: WindowCoordinatorConfig = serde_json::from_value(config).unwrap(); + let use_loaded_reweights = config + .decoder_reweighting + .use_loaded(config.persistent_decoder, decoder.features()) + .unwrap_or_else(|error| panic!("invalid decoder reweighting configuration: {error}")); let loss_imputation_rng = if config.loss_random_imputation { use rand::{Rng, SeedableRng}; let seed = config.loss_random_imputation_seed.unwrap_or_else(|| rand::rng().next_u64()); @@ -400,6 +436,8 @@ impl WindowCoordinator { } else { None }; + let loss_handler = LossHandler::new(config.loss_strategy, config.loss_config.clone()) + .unwrap_or_else(|error| panic!("invalid loss configuration: {error}")); Self { config, port_types: Default::default(), @@ -415,11 +453,13 @@ impl WindowCoordinator { next_cid: Mutex::new(1), next_eid: Mutex::new(1), loaded_decoders: Default::default(), - black_box_decoder, + decoder, pauli_frame_tracker: Default::default(), cancellation: RwLock::new(CancellationToken::new()), task_counter: TaskCounter::new(), loss_imputation_rng, + loss_handler, + use_loaded_reweights, trace_shot: Arc::new(Mutex::new(trace::Shot::default())), trace: Mutex::new(trace::WindowCoordinatorTrace::default()), } @@ -1405,7 +1445,7 @@ impl WindowCoordinator { let mut syndrome_flips: HashMap> = HashMap::new(); for &ei in parity_factor.subgraph.iter() { let local_error = &errors[ei as usize]; - let local_eid = local_error.eid as usize; + let local_eid = local_error.eid; let eid = mapping.global_eid_of[local_eid]; let error_index = local_error.error_index; let error_model = error_models.get(&eid).unwrap(); @@ -1413,7 +1453,7 @@ impl WindowCoordinator { continue; // skip errors outside the commit region (includes error-only gadgets) } let error_model_type = error_model_types.get(&error_model.instance.etype).unwrap(); - let error = &error_model_type.errors[error_index as usize]; + let error = &error_model_type.errors[error_index]; // find the gadget that owns this error via its check model let error_gadget_gid = check_models.get(&error_model.instance.cid).unwrap().instance.gid; @@ -1511,14 +1551,103 @@ impl WindowCoordinator { errors.len(), ); let local_error = &errors[ei as usize]; - let local_eid = local_error.eid as usize; + let local_eid = local_error.eid; let eid = mapping.global_eid_of[local_eid]; let error_index = local_error.error_index; - (eid, error_index) + (eid, error_index as u64) }) .collect() } + /// Build the possible loss sites for this window from the observed atom + /// losses, mirroring [`MonolithicCoordinator::build_loss_sites`]. + /// + /// `config.loss_strategy` is not `ignore` and some gadget currently + /// loaded recorded observed losses. The generators are keyed by + /// `(local_eid, error_index)`, matching the `error_reference` that + /// [`decoding_hypergraph`](Self::decoding_hypergraph) produces, so + /// The loss handler projects the sites onto this window's hyperedges. + /// Loss sites are kept per-gadget and ungrouped; cross-gadget continuation + /// whose herald signature is incomplete in this window is naturally dropped + /// when its edges fall outside the window. + async fn build_loss_sites(&self, mapping: &RelativeMapping) -> Vec { + use crate::jit::loss_compiler::{GadgetLoss, build_cross_gadget_loss_sites, build_cross_gadget_output_links}; + let mut loss_sites = Vec::new(); + if !self.loss_handler.tracks_losses() { + return loss_sites; + } + let port_types = self.port_types.read().await; + let gadget_types = self.gadget_types.read().await; + let gadgets = self.gadgets.read().await; + if !mapping + .global_gid_of + .iter() + .any(|gid| gadgets.get(gid).is_some_and(|gadget| gadget.loss_mask.is_some())) + { + return loss_sites; + } + let check_models = self.check_models.read().await; + + // Gadgets in this window that carry a loss model, in window order. A + // gadget with no *observed* loss is still included: a loss can pass + // through it unheralded and only be resolved by a downstream gadget in the + // same window (which is why `buffer_radius` must cover the envelope span). + let mut gid_of_index: Vec = Vec::new(); + let mut index_of_gid: HashMap = HashMap::new(); + for &gid in &mapping.global_gid_of { + let Some(gadget) = gadgets.get(&gid) else { continue }; + if !has_loss_model(&gadget_types, gadget.instance.gtype) { + continue; + } + index_of_gid.insert(gid, gid_of_index.len()); + gid_of_index.push(gid); + } + if gid_of_index.is_empty() { + return loss_sites; + } + + // The error model whose error list a site's generators index: the first + // one attached to the gadget, per the `GadgetType.loss_model` contract. + let local_eid_of_index: Vec> = gid_of_index + .iter() + .map(|&gid| { + let cid = gadgets.get(&gid)?.binding_cid?; + let eid = *check_models.get(&cid)?.attaching_eid_vec.first()?; + mapping.local_eid_of.get(&eid).copied() + }) + .collect(); + + let loss_masks: Vec = gid_of_index + .iter() + .map(|&gid| gadgets.get(&gid).and_then(|g| g.loss_mask.clone()).unwrap_or_default()) + .collect(); + + // Cross-gadget links: each gadget's output flat slot that a loss can leave + // on, mapped to the (downstream gadget index, input flat slot) it enters. + // Links whose downstream gadget is outside this window are dropped, so the + // chain ends there (a later window covering it reweights it there). + let gadget_instances: Vec<&bin::Gadget> = + gid_of_index.iter().map(|&gid| &gadgets.get(&gid).unwrap().instance).collect(); + let output_links_vec = build_cross_gadget_output_links(&gadget_instances, &index_of_gid, &gadget_types, &port_types); + + let gadget_losses: Vec = (0..gid_of_index.len()) + .map(|index| { + let gtype = gadgets.get(&gid_of_index[index]).unwrap().instance.gtype; + GadgetLoss { + loss_model: gadget_types.get(>ype).unwrap().loss_model.as_ref().unwrap(), + observed: &loss_masks[index], + output_links: &output_links_vec[index], + } + }) + .collect(); + + for site in build_cross_gadget_loss_sites(&gadget_losses) { + let local_eid = local_eid_of_index[site.gadget_index]; + loss_sites.push(RawLossSite::from_compiled(site, local_eid)); + } + loss_sites + } + async fn decode_parity_factor( &self, committing_cids: &HashSet, @@ -1548,9 +1677,26 @@ impl WindowCoordinator { span.add_event(Event::new("syndrome_calculated")); span.add_property(|| ("syndrome", format!("{:?}", syndrome))); + // Assemble the observed atom losses into loss sites within this window. + // These are shot-dependent only in their *content*: the loaded graph stays + // fixed and the shot's loss travels with the decode request, so a loss + // shot is served from the cache like any other. + let loss_sites = self.build_loss_sites(mapping).await; + + // Deduplication collapses edges that share a syndrome, which renumbers + // them and, worse for a loss-aware decoder, merges a loss generator with + // any ordinary Pauli error sharing its syndrome. A coordinator that hands + // losses to the decoder therefore never deduplicates -- not even on a shot + // carrying no loss, since the graph it loads then serves later shots that + // do. + let deduplicate = self.config.merge_hyperedges && !self.loss_handler.hands_off_to_decoder(); + let cache_key = if self.config.persistent_decoder { let error_models = self.error_models.read().await; let error_model_types = self.error_model_types.read().await; + // Construction-time modifiers define the base graph and therefore + // its cache identity. Outcomes.modifiers are shot-scoped assignments + // projected below; including them here would defeat decoder reuse. Some(DecoderCacheKey { relative_program: relative_program.clone(), error_model_fingerprints: build_modifier_fingerprints(mapping, &error_models, &error_model_types), @@ -1561,9 +1707,12 @@ impl WindowCoordinator { }; if let Some(ref cache_key) = cache_key { - let loaded_decoders = self.loaded_decoders.read().await; - let loaded = loaded_decoders.get(cache_key); + let loaded = self.loaded_decoders.read().await.get(cache_key).cloned(); if let Some(loaded) = loaded { + let probability_reweights = self.shot_probability_reweights(mapping, &loaded.projection.base_errors).await; + let (reweights, loss) = + self.loss_handler + .project_shot(&loaded.projection, &probability_reweights, &loss_sites); // we can use the loaded decoding hypergraph to call the decoding service span.add_event(Event::new("decoding").with_property(|| ("type", "loaded"))); // Compact syndrome to match the loaded (compacted) hypergraph @@ -1572,15 +1721,16 @@ impl WindowCoordinator { } else { syndrome.clone() }; - let parity_factor = self - .black_box_decoder - .clone() - .decode_loaded(blackbox_decoder::LoadedDecodingProblem { - hid: loaded.hid, - syndrome: Some(decode_syndrome.clone()), - }) - .await - .unwrap(); + let parity_factor = decode_projected( + &self.decoder, + &loaded, + decode_syndrome.clone(), + reweights, + loss, + self.use_loaded_reweights, + ) + .await + .unwrap(); if self.config.assert_parity_factor { assert_parity_factor(loaded.decoding_hypergraph.as_ref().unwrap(), &parity_factor, &decode_syndrome); } @@ -1590,102 +1740,156 @@ impl WindowCoordinator { // when the decoder is not available, construct the decoding hypergraph for the window // and instantiate such a decoder - let (mut decoding_hypergraph, mut errors) = - self.decoding_hypergraph(committing_cids, relative_program, mapping).await; - - // merge the decoding hypergraph edges if their syndromes are the same - if self.config.merge_hyperedges { - let mut original_to_merged = Vec::with_capacity(errors.len()); - let mut merged: HashMap, (usize, f64)> = HashMap::new(); - let mut merged_hyperedges: Vec = Vec::with_capacity(errors.len()); - let mut merged_errors = Vec::with_capacity(errors.len()); - for (hyperedge, error_index) in decoding_hypergraph.hyperedges.iter().zip(errors.iter()) { - let mut syndrome = hyperedge.vertices.clone(); - syndrome.sort(); - debug_assert!({ - let degree = syndrome.len(); - syndrome.dedup(); - syndrome.len() == degree - }); // syndrome should not contain duplicate items - if let Some((ei, best_p_e)) = merged.get_mut(&syndrome) { - let p_all = merged_hyperedges[*ei].probability; - merged_hyperedges[*ei].probability = exclusive_probability_of(p_all, hyperedge.probability); - if hyperedge.probability > *best_p_e { - *best_p_e = hyperedge.probability; - merged_errors[*ei] = error_index.clone(); - } - original_to_merged.push(*ei); - } else { - let ei = merged_errors.len(); - merged_hyperedges.push(Hyperedge { - probability: hyperedge.probability, - vertices: syndrome.clone(), - }); - merged_errors.push(error_index.clone()); - original_to_merged.push(ei); - merged.insert(syndrome, (ei, hyperedge.probability)); - } - } - decoding_hypergraph = DecodingHypergraph { - vertex_num: decoding_hypergraph.vertex_num, - hyperedges: merged_hyperedges, - }; - errors = Arc::new(merged_errors); - } - - // Strip isolated vertices (checks with no incident hyperedges) and - // remap both the hypergraph and syndrome to a contiguous vertex space. - // This is necessary because some decoders (e.g. MWPF) reject graphs - // with isolated vertices. - let (decoding_hypergraph, syndrome, vertex_remap) = Self::compact_vertices(decoding_hypergraph, &syndrome); - - let decoding_hypergraph = Arc::new(decoding_hypergraph); - - let parity_factor = if let Some(cache_key) = cache_key { - span.add_event(Event::new("decoding").with_property(|| ("type", "loading"))); - let hid = self - .black_box_decoder - .clone() - .load_hypergraph(decoding_hypergraph.as_ref().clone()) - .await - .unwrap() - .hid; - let mut loaded_decoders = self.loaded_decoders.write().await; - loaded_decoders.insert( - cache_key, - LoadedDecoder { - hid, - errors: errors.clone(), - decoding_hypergraph: self.config.assert_parity_factor.then_some(decoding_hypergraph.clone()), - vertex_remap: vertex_remap.clone(), - }, - ); - drop(loaded_decoders); - self.black_box_decoder - .clone() - .decode_loaded(blackbox_decoder::LoadedDecodingProblem { - hid, - syndrome: Some(syndrome.clone()), - }) - .await - .unwrap() - } else { + let (decoding_hypergraph, errors) = self.decoding_hypergraph(committing_cids, relative_program, mapping).await; + + // Priors, captured before loss reweighting rewrites them. Merging picks its + // representative from these rather than from the reweighted values: a + // reweighted probability is a decoding weight, not a calibrated posterior, + // and letting it arbitrate corrections lets the loss heuristic decide + // logical outcomes. + let priors: Vec = decoding_hypergraph.hyperedges.iter().map(|h| h.probability).collect(); + let probability_reweights = self.shot_probability_reweights(mapping, &errors).await; + + let Some(cache_key) = cache_key else { + // Without a persistent decoder every shot ships its whole problem, so + // probability updates are applied before loss derives its live priors. + // Loss edges whose checks fall outside the window are absent from the + // hypergraph and silently skipped -- a future window covering them + // handles them there. + let mut decoding_hypergraph = decoding_hypergraph; + apply_reweights(&mut decoding_hypergraph, &probability_reweights); + let (mut decoding_hypergraph, loss) = self.loss_handler.apply_sites(decoding_hypergraph, &loss_sites, &errors); + let mut errors = errors; + if deduplicate { + let deduplicated = deduplicate_by_syndrome(&decoding_hypergraph, &errors, &priors); + decoding_hypergraph = deduplicated.hypergraph; + errors = Arc::new(deduplicated.representatives); + } + let (decoding_hypergraph, syndrome, _) = Self::compact_vertices(decoding_hypergraph, &syndrome); span.add_event(Event::new("decoding").with_property(|| ("type", "temporary"))); - self.black_box_decoder - .clone() + let parity_factor = self + .decoder .decode(blackbox_decoder::DecodingProblem { - hypergraph: Some(decoding_hypergraph.as_ref().clone()), + hypergraph: Some(decoding_hypergraph.clone()), syndrome: Some(syndrome.clone()), + loss, }) .await - .unwrap() + .unwrap(); + if self.config.assert_parity_factor { + assert_parity_factor(&decoding_hypergraph, &parity_factor, &syndrome); + } + return (parity_factor, errors); }; + // Load the *base* graph -- the one carrying priors, before any shot's loss + // is applied -- so this entry serves every shot regardless of which atoms + // it loses, and send the shot's own loss alongside the syndrome. Vertex + // compaction only renumbers vertices, so the edge numbering the loss is + // expressed in survives it. + let deduplicated = if deduplicate { + deduplicate_by_syndrome(&decoding_hypergraph, &errors, &priors) + } else { + Deduplicated::identity(&decoding_hypergraph, &errors) + }; + let (loadable, decode_syndrome, vertex_remap) = Self::compact_vertices(deduplicated.hypergraph.clone(), &syndrome); + let representatives = Arc::new(deduplicated.representatives.clone()); + let projection = Arc::new(DecodeProjection { + base_hypergraph: decoding_hypergraph, + base_errors: errors, + priors, + deduplicated, + }); + let (reweights, loss) = self + .loss_handler + .project_shot(&projection, &probability_reweights, &loss_sites); + span.add_event(Event::new("decoding").with_property(|| ("type", "loading"))); + let hid = self.decoder.load_hypergraph(loadable.clone()).await.unwrap().hid; + let loaded = LoadedDecoder { + hid, + errors: representatives.clone(), + decoding_hypergraph: (!self.use_loaded_reweights || self.config.assert_parity_factor) + .then(|| Arc::new(loadable)), + vertex_remap, + projection, + }; + let mut loaded_decoders = self.loaded_decoders.write().await; + loaded_decoders.insert(cache_key, loaded.clone()); + drop(loaded_decoders); + let parity_factor = decode_projected( + &self.decoder, + &loaded, + decode_syndrome.clone(), + reweights, + loss, + self.use_loaded_reweights, + ) + .await + .unwrap(); if self.config.assert_parity_factor { - assert_parity_factor(&decoding_hypergraph, &parity_factor, &syndrome); + assert_parity_factor(loaded.decoding_hypergraph.as_ref().unwrap(), &parity_factor, &decode_syndrome); } + (parity_factor, representatives) + } - (parity_factor, errors) + async fn bind_probability_modifiers( + &self, + gid: u64, + modifiers: &[bin::ProbabilityModifier], + ) -> Result, Status> { + if modifiers.is_empty() { + return Ok(vec![]); + } + let cid = self + .gadgets + .read() + .await + .get(&gid) + .and_then(|gadget| gadget.binding_cid) + .ok_or_else(|| Status::failed_precondition(format!("gid={gid} has no binding check model")))?; + let eids = self + .check_models + .read() + .await + .get(&cid) + .map(|check_model| check_model.attaching_eid_vec.clone()) + .ok_or_else(|| Status::failed_precondition(format!("cid={cid} is not loaded")))?; + if modifiers.len() > eids.len() { + return Err(Status::invalid_argument(format!( + "gid={gid} supplied {} probability modifiers for {} attached error models", + modifiers.len(), + eids.len() + ))); + } + let error_model_types = self.error_model_types.read().await; + let error_models = self.error_models.read().await; + let mut bound = Vec::with_capacity(modifiers.len()); + for (&eid, modifier) in eids.iter().zip(modifiers) { + let error_model = error_models + .get(&eid) + .ok_or_else(|| Status::failed_precondition(format!("eid={eid} is not loaded")))?; + let error_model_type = error_model_types + .get(&error_model.instance.etype) + .ok_or_else(|| Status::failed_precondition(format!("etype={} is not loaded", error_model.instance.etype)))?; + validate_probability_modifier(modifier, error_model_type.errors.len()).map_err(Status::invalid_argument)?; + bound.push((eid, modifier.clone())); + } + Ok(bound) + } + + async fn shot_probability_reweights( + &self, + mapping: &RelativeMapping, + error_reference: &[ErrorIndex], + ) -> Vec<(u64, f64)> { + let gadgets = self.gadgets.read().await; + let mut modifiers: Vec<_> = gadgets + .values() + .flat_map(|gadget| gadget.probability_modifiers.iter()) + .filter_map(|(eid, modifier)| mapping.local_eid_of.get(eid).map(|&local_eid| (local_eid, modifier))) + .collect(); + modifiers.sort_unstable_by_key(|&(local_eid, _)| local_eid); + probability_reweights(error_reference, modifiers) } async fn decoding_hypergraph( @@ -1747,9 +1951,11 @@ impl WindowCoordinator { errors = modified_errors.as_ref().unwrap(); } for (error_index, error) in errors.iter().enumerate() { - if error.probability <= 0.0 { - continue; - } + // Zero-probability errors are kept, at their prior probability. + // They exist for a reason -- an atom loss activates its + // Pauli-envelope generators, and a caller may reweight any edge + // -- so the hyperedge set stays independent of what happened in + // a given shot, keeping edge indices stable across shots. let mut vertices: Vec = vec![]; let mut has_external_check = false; for check in &error.checks { @@ -1784,8 +1990,8 @@ impl WindowCoordinator { continue; // skip edges with external checks in commit region, or no-effect errors } error_reference.push(ErrorIndex { - eid: local_eid as u64, - error_index: error_index as u64, + eid: local_eid, + error_index, }); hyperedges.push(Hyperedge { vertices, @@ -2139,7 +2345,13 @@ impl WindowCoordinator { impl coordinator::coordinator_server::Coordinator for WindowCoordinator { #[cfg_attr(feature = "cli", fastrace::trace)] async fn load_library(&self, request: Request) -> Result, Status> { + let _task_guard = self + .task_counter + .try_guard() + .ok_or_else(|| Status::unavailable("coordinator reset in progress"))?; let library = request.into_inner(); + self.loss_handler + .validate_capability(&library.gadget_types, self.decoder.features())?; let mut port_types = self.port_types.write().await; for port_type in library.port_types.into_iter() { if port_types.contains_key(&port_type.ptype) { @@ -2181,6 +2393,10 @@ impl coordinator::coordinator_server::Coordinator for WindowCoordinator { #[cfg_attr(feature = "cli", fastrace::trace)] async fn execute(&self, request: Request) -> Result, Status> { + let _task_guard = self + .task_counter + .try_guard() + .ok_or_else(|| Status::unavailable("coordinator reset in progress"))?; let instruction = request.into_inner(); let create = instruction .create @@ -2225,12 +2441,14 @@ impl coordinator::coordinator_server::Coordinator for WindowCoordinator { Gadget { instance: gadget.clone(), outcomes: watch::channel(None).0, + probability_modifiers: vec![], binding_cid: None, // important: we should not use vec![;len] syntax because it will create clones outputs: gadget_type.outputs.iter().map(|_| watch::channel(None).0).collect(), pauli_frame: watch::channel(None).0, is_free_hop, state: watch::channel(GadgetState::Uncommitted).0, + loss_mask: None, }, ); // Drain pending referrals for newly connected output ports. @@ -2453,6 +2671,14 @@ impl coordinator::coordinator_server::Coordinator for WindowCoordinator { let error_model_type = error_model_types .get(&error_model.etype) .ok_or_else(|| Status::not_found(format!("etype={}", error_model.etype)))?; + if let Some(probability_modifier) = error_model + .modifier + .as_ref() + .and_then(|modifier| modifier.probability_modifier.as_ref()) + { + validate_probability_modifier(probability_modifier, error_model_type.errors.len()) + .map_err(Status::invalid_argument)?; + } let mut modified_remote: Vec<_> = error_model_type.remote_check_models.iter().cloned().map(Some).collect(); if let Some(modifier) = &error_model.modifier { for rereoute in &modifier.reroute_remote_check_models { @@ -2556,7 +2782,12 @@ impl coordinator::coordinator_server::Coordinator for WindowCoordinator { #[cfg_attr(feature = "cli", fastrace::trace)] async fn decode(&self, request: Request) -> Result, Status> { let outcomes = request.into_inner(); + let _task_guard = self + .task_counter + .try_guard() + .ok_or_else(|| Status::unavailable("coordinator reset in progress"))?; let gid = outcomes.gid; + let probability_modifiers = self.bind_probability_modifiers(gid, &outcomes.modifiers).await?; // Load outcomes let is_free_hop; @@ -2570,16 +2801,35 @@ impl coordinator::coordinator_server::Coordinator for WindowCoordinator { let mut outcome_data = outcomes .outcomes .ok_or_else(|| Status::invalid_argument("missing outcomes"))?; + let gadget_type = gadget_types + .get(&gadget.instance.gtype) + .ok_or_else(|| Status::failed_precondition(format!("gtype={} is not loaded", gadget.instance.gtype)))?; + coordinator::validate_outcomes( + &outcome_data, + outcomes.loss_mask.as_ref(), + u64::try_from(gadget_type.measurements.len()).expect("measurement count must fit in u64"), + ) + .map_err(Status::invalid_argument)?; // Apply loss-random-imputation before storing the outcomes so // every downstream consumer (syndrome calc, pauli-frame tracker, // window decoder) reads a single consistent imputed value per // measurement bit. if let (Some(rng_lock), Some(loss_mask)) = (self.loss_imputation_rng.as_ref(), outcomes.loss_mask.as_ref()) { let mut rng = rng_lock.lock().await; - coordinator::apply_loss_random_imputation(&mut outcome_data, loss_mask, &mut *rng); + apply_loss_random_imputation(&mut outcome_data, loss_mask, &mut *rng); + } + // Record the observed atom losses for envelope-matching reweighting: + // the local measurement indices flagged as losses. Only kept when the + // strategy is enabled and this gadget type carries a loss model. + if self.loss_handler.tracks_losses() + && has_loss_model(&gadget_types, gadget.instance.gtype) + && let Some(loss_mask) = outcomes.loss_mask.as_ref() + && (0..loss_mask.size).any(|index| get_bit(loss_mask, index)) + { + gadget.loss_mask = Some(loss_mask.clone()); } gadget.outcomes.send_replace(Some(outcome_data)); - let gadget_type = gadget_types.get(&gadget.instance.gtype).unwrap(); + gadget.probability_modifiers = probability_modifiers; let mut readouts = Vec::with_capacity(gadget_type.readouts.len()); let data: BitVector = gadget.outcomes.borrow().as_ref().unwrap().clone(); for readout in gadget_type.readouts.iter() { @@ -2793,6 +3043,10 @@ impl coordinator::coordinator_server::Coordinator for WindowCoordinator { #[cfg_attr(feature = "cli", fastrace::trace)] async fn reset(&self, request: Request) -> Result, Status> { let flags = request.into_inner(); + let _pause = self + .task_counter + .try_pause() + .ok_or_else(|| Status::unavailable("coordinator reset already in progress"))?; // Cancel all pending async tasks, wait for them to finish, then // install a fresh token so post-reset operations proceed normally. { @@ -2819,19 +3073,17 @@ impl coordinator::coordinator_server::Coordinator for WindowCoordinator { *self.next_cid.lock().await = 1; *self.next_eid.lock().await = 1; self.pauli_frame_tracker.lock().await.reset(); + if flags.reset_library || flags.reset_decoder_service { + self.loaded_decoders.write().await.clear(); + } // since decoders reset asynchronously, wait for all the decoders to finish - self.black_box_decoder - .clone() + self.decoder .reset(blackbox_decoder::ResetRequest { reset_hypergraphs: flags.reset_decoder_service, ..Default::default() }) .await .map_err(|e| Status::internal(format!("reset decoder service error: {}", e)))?; - if flags.reset_decoder_service { - let mut loaded_decoders = self.loaded_decoders.write().await; - loaded_decoders.clear(); - } // flush current shot into the trace and write to file if configured { let shot = std::mem::take(&mut *self.trace_shot.lock().await); From 9badfa29366a9478a505be1ad58a73fe545c4c3c Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 17 Aug 2026 18:49:22 -0700 Subject: [PATCH 129/157] extract decoder features from thread pooling --- deq/deq_runtime/src/decoder.rs | 27 +++++-------------- deq/deq_runtime/src/decoder/mock_decoder.rs | 24 +++++------------ deq/deq_runtime/src/decoder/naive_decoder.rs | 2 +- deq/deq_runtime/src/decoder/python_decoder.rs | 19 ++++++------- .../src/decoder/tesseract_decoder.rs | 3 ++- deq/deq_runtime/tests/mock_decoder_test.rs | 2 +- .../tests/standard_decoder_test.rs | 5 ++-- 7 files changed, 28 insertions(+), 54 deletions(-) diff --git a/deq/deq_runtime/src/decoder.rs b/deq/deq_runtime/src/decoder.rs index 7a195f13..e727d375 100644 --- a/deq/deq_runtime/src/decoder.rs +++ b/deq/deq_runtime/src/decoder.rs @@ -8,8 +8,6 @@ use std::sync::Arc; use tonic::transport::server::Router; use tonic::{Request, Status}; -use thread_pooling::DecoderFeatures; - #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Debug)] #[cfg_attr(feature = "cli", derive(ValueEnum))] pub enum DecoderType { @@ -67,6 +65,8 @@ pub mod blackbox_decoder { } pub mod blackbox_util; +pub mod decoder_features; +pub use decoder_features::DecoderFeatures; pub mod mock_decoder; pub mod test_harness; pub mod test_problems; @@ -218,23 +218,16 @@ impl DynDecoder { } fn require_features(&self, required: DecoderFeatures) -> Result<(), Status> { - let unsupported = required.difference(self.features()); - if unsupported.is_empty() { - Ok(()) - } else { - Err(Status::failed_precondition(format!( - "unsupported decoder features: {unsupported}" - ))) - } + required + .require_supported_by(self.features()) + .map_err(|unsupported| Status::failed_precondition(format!("unsupported decoder features: {unsupported}"))) } pub async fn decode( &self, problem: blackbox_decoder::DecodingProblem, ) -> Result { - if problem.loss.is_some() { - self.require_features(DecoderFeatures::LOSS)?; - } + self.require_features(DecoderFeatures::required(false, problem.loss.is_some()))?; self.inner().decode(Request::new(problem)).await.map(|v| v.into_inner()) } @@ -252,13 +245,7 @@ impl DynDecoder { &self, problem: blackbox_decoder::LoadedDecodingProblem, ) -> Result { - let mut required = DecoderFeatures::empty(); - if !problem.reweights.is_empty() { - required = required | DecoderFeatures::REWEIGHTS; - } - if problem.loss.is_some() { - required = required | DecoderFeatures::LOSS; - } + let required = DecoderFeatures::required(!problem.reweights.is_empty(), problem.loss.is_some()); self.require_features(required)?; self.inner() .decode_loaded(Request::new(problem)) diff --git a/deq/deq_runtime/src/decoder/mock_decoder.rs b/deq/deq_runtime/src/decoder/mock_decoder.rs index b7167285..d03dda7a 100644 --- a/deq/deq_runtime/src/decoder/mock_decoder.rs +++ b/deq/deq_runtime/src/decoder/mock_decoder.rs @@ -4,7 +4,7 @@ //! what coordinators send to the decoder at runtime. use crate::decoder::blackbox_decoder::{self, black_box_decoder_server}; -use crate::decoder::thread_pooling::DecoderFeatures; +use crate::decoder::decoder_features::DecoderFeatures; use crate::util::BitVector; use hashbrown::HashMap; use serde::{Deserialize, Serialize}; @@ -208,9 +208,9 @@ impl black_box_decoder_server::BlackBoxDecoder for MockDecoder { request: Request, ) -> Result, Status> { let problem = request.into_inner(); - if problem.loss.is_some() && !self.features.contains(DecoderFeatures::LOSS) { - return Err(Status::failed_precondition("decoder does not support structured loss")); - } + DecoderFeatures::required(false, problem.loss.is_some()) + .require_supported_by(self.features) + .map_err(|unsupported| Status::failed_precondition(format!("unsupported decoder features: {unsupported}")))?; let hypergraph = problem .hypergraph .ok_or_else(|| Status::invalid_argument("missing hypergraph"))?; @@ -248,19 +248,9 @@ impl black_box_decoder_server::BlackBoxDecoder for MockDecoder { request: Request, ) -> Result, Status> { let problem = request.into_inner(); - let mut required = DecoderFeatures::empty(); - if !problem.reweights.is_empty() { - required = required | DecoderFeatures::REWEIGHTS; - } - if problem.loss.is_some() { - required = required | DecoderFeatures::LOSS; - } - let unsupported = required.difference(self.features); - if !unsupported.is_empty() { - return Err(Status::failed_precondition(format!( - "unsupported decoder features: {unsupported}" - ))); - } + DecoderFeatures::required(!problem.reweights.is_empty(), problem.loss.is_some()) + .require_supported_by(self.features) + .map_err(|unsupported| Status::failed_precondition(format!("unsupported decoder features: {unsupported}")))?; let syndrome = problem.syndrome.ok_or_else(|| Status::invalid_argument("missing syndrome"))?; let mut state = self.state.write().await; diff --git a/deq/deq_runtime/src/decoder/naive_decoder.rs b/deq/deq_runtime/src/decoder/naive_decoder.rs index bbbe49a1..60e123ff 100644 --- a/deq/deq_runtime/src/decoder/naive_decoder.rs +++ b/deq/deq_runtime/src/decoder/naive_decoder.rs @@ -4,7 +4,7 @@ //! use crate::decoder::blackbox_decoder::{self, black_box_decoder_server}; -use crate::decoder::thread_pooling::DecoderFeatures; +use crate::decoder::decoder_features::DecoderFeatures; use serde::{Deserialize, Serialize}; #[cfg(feature = "cli")] use std::sync::Arc; diff --git a/deq/deq_runtime/src/decoder/python_decoder.rs b/deq/deq_runtime/src/decoder/python_decoder.rs index 43b4d717..b570e3a1 100644 --- a/deq/deq_runtime/src/decoder/python_decoder.rs +++ b/deq/deq_runtime/src/decoder/python_decoder.rs @@ -19,8 +19,9 @@ //! use crate::decoder::blackbox_decoder::{DecodingHypergraph, ParityFactor}; +use crate::decoder::decoder_features::DecoderFeatures; use crate::decoder::thread_pooling::{ - DecodeError, DecodeRequest, DecoderFeatures, DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder, + DecodeError, DecodeRequest, DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder, }; use crate::misc::bit_vector::to_sparse_indices; use crate::misc::python::{get_or_load_module, get_or_load_module_from_source, json_value_to_py}; @@ -112,16 +113,12 @@ fn decoder_features(file: &str, class_name: &str) -> PyResult { let feature_names = decoder_class.call_method0("supported_features")?.extract::>()?; let mut features = DecoderFeatures::empty(); for feature_name in feature_names { - features = features - | match feature_name.as_str() { - "reweights" => DecoderFeatures::REWEIGHTS, - "loss" => DecoderFeatures::LOSS, - _ => { - return Err(PyValueError::new_err(format!( - "unsupported Python decoder feature {feature_name:?}; expected \"reweights\" or \"loss\"" - ))); - } - }; + let feature = DecoderFeatures::from_name(&feature_name).ok_or_else(|| { + PyValueError::new_err(format!( + "unsupported Python decoder feature {feature_name:?}; expected \"reweights\" or \"loss\"" + )) + })?; + features = features | feature; } Ok(features) }) diff --git a/deq/deq_runtime/src/decoder/tesseract_decoder.rs b/deq/deq_runtime/src/decoder/tesseract_decoder.rs index 4ff72a3d..8ee38e38 100644 --- a/deq/deq_runtime/src/decoder/tesseract_decoder.rs +++ b/deq/deq_runtime/src/decoder/tesseract_decoder.rs @@ -4,9 +4,10 @@ //! use crate::decoder::blackbox_decoder::{self, ParityFactor}; +use crate::decoder::decoder_features::DecoderFeatures; use crate::decoder::tesseract_ffi::{TesseractCxxConfig, TesseractCxxDecoder}; use crate::decoder::thread_pooling::{ - DecodeError, DecodeRequest, DecoderFeatures, DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder, + DecodeError, DecodeRequest, DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder, }; use crate::misc::bit_vector::to_sparse_indices; use blackbox_decoder::DecodingHypergraph; diff --git a/deq/deq_runtime/tests/mock_decoder_test.rs b/deq/deq_runtime/tests/mock_decoder_test.rs index 163b65a7..9fde35c3 100644 --- a/deq/deq_runtime/tests/mock_decoder_test.rs +++ b/deq/deq_runtime/tests/mock_decoder_test.rs @@ -1,11 +1,11 @@ //! Tests for MockDecoder +use deq_runtime::decoder::DecoderFeatures; #[cfg(feature = "cli")] use deq_runtime::decoder::blackbox_decoder::black_box_decoder_client::BlackBoxDecoderClient; #[cfg(feature = "cli")] use deq_runtime::decoder::blackbox_decoder::black_box_decoder_server::BlackBoxDecoderServer; use deq_runtime::decoder::blackbox_decoder::{self, black_box_decoder_server::BlackBoxDecoder}; -use deq_runtime::decoder::thread_pooling::DecoderFeatures; use deq_runtime::decoder::{DynDecoder, MockDecoder}; use deq_runtime::util::BitVector; use std::sync::Arc; diff --git a/deq/deq_runtime/tests/standard_decoder_test.rs b/deq/deq_runtime/tests/standard_decoder_test.rs index b5f017ba..7f66cb46 100644 --- a/deq/deq_runtime/tests/standard_decoder_test.rs +++ b/deq/deq_runtime/tests/standard_decoder_test.rs @@ -15,8 +15,7 @@ use deq_runtime::decoder::blackbox_decoder::{ }; use deq_runtime::decoder::test_harness::{Outcome, Path, SuiteReport, run_standard_suite}; use deq_runtime::decoder::test_problems::standard_test_problems; -use deq_runtime::decoder::thread_pooling::DecoderFeatures; -use deq_runtime::decoder::{DynDecoder, MockDecoder, NaiveDecoder}; +use deq_runtime::decoder::{DecoderFeatures, DynDecoder, MockDecoder, NaiveDecoder}; use deq_runtime::util::BitVector; type ExpectedPassFn = fn(problem: &str, case: &str, path: Path) -> bool; @@ -177,8 +176,8 @@ async fn test_python_naive_decoder() { #[cfg(feature = "python")] #[tokio::test] async fn test_python_named_decoder_without_supported_features() { + use deq_runtime::decoder::DecoderFeatures; use deq_runtime::decoder::PythonDecoder; - use deq_runtime::decoder::thread_pooling::DecoderFeatures; let mut decoder_file = tempfile::Builder::new().suffix(".py").tempfile().unwrap(); decoder_file From 2db7122a32489f9467de0a396be6cc5062c9dd6f Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 17 Aug 2026 20:15:31 -0700 Subject: [PATCH 130/157] check in thread pooling --- deq/deq_runtime/Cargo.toml | 1 + deq/deq_runtime/src/decoder.rs | 2 + .../src/decoder/decoder_features.rs | 110 ++++++ deq/deq_runtime/src/decoder/python_decoder.rs | 2 +- deq/deq_runtime/src/decoder/thread_pooling.rs | 328 +++++++++++++----- deq/deq_runtime/src/decoder/validation.rs | 149 ++++++++ deq/deq_runtime/src/misc/bit_vector.rs | 33 ++ 7 files changed, 536 insertions(+), 89 deletions(-) create mode 100644 deq/deq_runtime/src/decoder/decoder_features.rs create mode 100644 deq/deq_runtime/src/decoder/validation.rs diff --git a/deq/deq_runtime/Cargo.toml b/deq/deq_runtime/Cargo.toml index 1f572b9e..b77340ee 100644 --- a/deq/deq_runtime/Cargo.toml +++ b/deq/deq_runtime/Cargo.toml @@ -66,6 +66,7 @@ python_all = [ dylib = ["dep:deq-decoder-abi"] [dependencies] +bitflags = "2.13.0" num-traits = "0.2.19" serde = { version = "1.0.217", features = ["derive", "rc"] } serde_json = { version = ">=1.0.138, <1.0.142", features = [ diff --git a/deq/deq_runtime/src/decoder.rs b/deq/deq_runtime/src/decoder.rs index e727d375..84481585 100644 --- a/deq/deq_runtime/src/decoder.rs +++ b/deq/deq_runtime/src/decoder.rs @@ -71,6 +71,8 @@ pub mod mock_decoder; pub mod test_harness; pub mod test_problems; pub mod thread_pooling; +#[cfg(debug_assertions)] +pub(crate) mod validation; pub mod naive_decoder; pub use mock_decoder::MockDecoder; diff --git a/deq/deq_runtime/src/decoder/decoder_features.rs b/deq/deq_runtime/src/decoder/decoder_features.rs new file mode 100644 index 00000000..7ecac30f --- /dev/null +++ b/deq/deq_runtime/src/decoder/decoder_features.rs @@ -0,0 +1,110 @@ +//! Optional capabilities supported or required by a decoder request. + +use crate::decoder::blackbox_decoder; +use std::fmt; + +bitflags::bitflags! { + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] + pub struct DecoderFeatures: u32 { + const REWEIGHTS = 1 << 0; + const LOSS = 1 << 1; + } +} + +impl DecoderFeatures { + #[must_use] + pub const fn required(has_reweights: bool, has_loss: bool) -> Self { + let mut features = Self::empty(); + if has_reweights { + features = features.union(Self::REWEIGHTS); + } + if has_loss { + features = features.union(Self::LOSS); + } + features + } + + #[cfg(any(feature = "python", test))] + pub(crate) fn from_protocol_name(name: &str) -> Option { + match name { + "reweights" => Some(Self::REWEIGHTS), + "loss" => Some(Self::LOSS), + _ => None, + } + } + + pub(crate) fn require_supported_by(self, supported: Self) -> Result<(), Self> { + let unsupported = self.difference(supported); + if unsupported.is_empty() { Ok(()) } else { Err(unsupported) } + } + + pub(crate) fn to_proto(self) -> blackbox_decoder::DecoderCapabilities { + let mut features = Vec::with_capacity(2); + if self.contains(Self::REWEIGHTS) { + features.push(blackbox_decoder::DecoderFeature::Reweights as i32); + } + if self.contains(Self::LOSS) { + features.push(blackbox_decoder::DecoderFeature::Loss as i32); + } + blackbox_decoder::DecoderCapabilities { features } + } +} + +impl fmt::Display for DecoderFeatures { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut separator = ""; + if self.contains(Self::REWEIGHTS) { + write!(formatter, "reweights")?; + separator = ", "; + } + if self.contains(Self::LOSS) { + write!(formatter, "{separator}loss")?; + } + if self.is_empty() { + write!(formatter, "none")?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn required_features_compose_independently() { + assert_eq!(DecoderFeatures::required(false, false), DecoderFeatures::empty()); + assert_eq!(DecoderFeatures::required(true, false), DecoderFeatures::REWEIGHTS); + assert_eq!(DecoderFeatures::required(false, true), DecoderFeatures::LOSS); + assert_eq!( + DecoderFeatures::required(true, true), + DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS + ); + assert!( + DecoderFeatures::LOSS + .require_supported_by(DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS) + .is_ok() + ); + assert_eq!( + DecoderFeatures::required(true, true).require_supported_by(DecoderFeatures::LOSS), + Err(DecoderFeatures::REWEIGHTS) + ); + } + + #[test] + fn names_and_proto_use_the_protocol_vocabulary() { + assert_eq!( + DecoderFeatures::from_protocol_name("reweights"), + Some(DecoderFeatures::REWEIGHTS) + ); + assert_eq!(DecoderFeatures::from_protocol_name("loss"), Some(DecoderFeatures::LOSS)); + assert_eq!(DecoderFeatures::from_protocol_name("unknown"), None); + assert_eq!( + (DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS).to_proto().features, + vec![ + blackbox_decoder::DecoderFeature::Reweights as i32, + blackbox_decoder::DecoderFeature::Loss as i32, + ] + ); + } +} diff --git a/deq/deq_runtime/src/decoder/python_decoder.rs b/deq/deq_runtime/src/decoder/python_decoder.rs index b570e3a1..cfadd0de 100644 --- a/deq/deq_runtime/src/decoder/python_decoder.rs +++ b/deq/deq_runtime/src/decoder/python_decoder.rs @@ -113,7 +113,7 @@ fn decoder_features(file: &str, class_name: &str) -> PyResult { let feature_names = decoder_class.call_method0("supported_features")?.extract::>()?; let mut features = DecoderFeatures::empty(); for feature_name in feature_names { - let feature = DecoderFeatures::from_name(&feature_name).ok_or_else(|| { + let feature = DecoderFeatures::from_protocol_name(&feature_name).ok_or_else(|| { PyValueError::new_err(format!( "unsupported Python decoder feature {feature_name:?}; expected \"reweights\" or \"loss\"" )) diff --git a/deq/deq_runtime/src/decoder/thread_pooling.rs b/deq/deq_runtime/src/decoder/thread_pooling.rs index 3311188a..564ee3e2 100644 --- a/deq/deq_runtime/src/decoder/thread_pooling.rs +++ b/deq/deq_runtime/src/decoder/thread_pooling.rs @@ -2,15 +2,20 @@ //! use crate::decoder::blackbox_decoder::{self, ParityFactor, black_box_decoder_server}; +pub use crate::decoder::decoder_features::DecoderFeatures; +#[cfg(debug_assertions)] +use crate::decoder::validation; +use crate::misc::bit_vector; use crate::util::BitVector; use blackbox_decoder::DecodingHypergraph; use hashbrown::HashMap; use serde::{Deserialize, Serialize}; use std::collections::LinkedList; +use std::fmt; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(feature = "cli")] use structdoc::StructDoc; -use tokio::runtime::Handle; use tokio::sync::{Mutex, oneshot, watch}; #[cfg(feature = "cli")] use tonic::transport::server::Router; @@ -29,7 +34,9 @@ pub struct ThreadPoolingDecoder { pub original_config: Arc, pub thread_pool: Arc, loaded: Arc>>>, + next_hid: AtomicU64, decoding: watch::Sender, + features: DecoderFeatures, } pub struct Loaded { @@ -71,17 +78,74 @@ impl std::fmt::Debug for Loaded { } } +pub struct DecodeRequest<'a> { + pub syndrome: &'a BitVector, + /// Shot-scoped prior assignments. Implementations must not let these values + /// affect a later request served by the same pooled instance. + pub reweights: &'a [(u64, f64)], + /// Structured loss observation for this shot. Independent of `reweights`; + /// a decoder advertising both features must accept both together. + pub loss: Option<&'a blackbox_decoder::LossInfo>, +} + +impl DecodeRequest<'_> { + fn required_features(&self) -> DecoderFeatures { + DecoderFeatures::required(!self.reweights.is_empty(), self.loss.is_some()) + } + + fn require_supported(&self, supported: DecoderFeatures) -> Result<(), DecodeError> { + self.required_features() + .require_supported_by(supported) + .map_err(DecodeError::Unsupported) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DecodeError { + Unsupported(DecoderFeatures), + InvalidInput(String), + Backend(String), +} + +impl fmt::Display for DecodeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unsupported(features) => write!(formatter, "unsupported decoder features: {features}"), + Self::InvalidInput(message) => write!(formatter, "invalid decode request: {message}"), + Self::Backend(message) => write!(formatter, "decoder backend failed: {message}"), + } + } +} + +impl std::error::Error for DecodeError {} + pub trait DecoderInstance { + #[must_use] + fn supported_features(_config: &serde_json::Value) -> DecoderFeatures { + DecoderFeatures::empty() + } + fn new(hypergraph: &DecodingHypergraph, config: &serde_json::Value) -> Self; - fn decode(&mut self, syndrome: &BitVector) -> ParityFactor; + fn decode(&mut self, request: DecodeRequest<'_>) -> Result; fn reset(&mut self); } impl ThreadPoolingDecoder { + #[must_use] + pub fn features(&self) -> DecoderFeatures { + self.features + } + + /// # Panics + /// + /// Panics when `original_config` is invalid for thread pooling or the Rayon + /// pool cannot be created. + #[must_use] pub fn new(original_config: serde_json::Value) -> Self { let config: ThreadPoolingConfig = serde_json::from_value(original_config.clone()).unwrap(); + let features = T::supported_features(&original_config); let mut thread_pool_builder = rayon::ThreadPoolBuilder::new(); if config.parallel != 0 { thread_pool_builder = thread_pool_builder.num_threads(config.parallel); @@ -99,11 +163,14 @@ impl ThreadPoolingDecoder { original_config: Arc::new(original_config), thread_pool, loaded: Default::default(), + next_hid: AtomicU64::new(1), decoding: watch::channel(0).0, + features, } } #[cfg(feature = "cli")] + #[must_use] pub fn add_service(self: &Arc, router: Router) -> Router { let service = black_box_decoder_server::BlackBoxDecoderServer::from_arc(self.clone()).max_decoding_message_size(usize::MAX); @@ -113,16 +180,46 @@ impl ThreadPoolingDecoder { #[tonic::async_trait] impl black_box_decoder_server::BlackBoxDecoder for ThreadPoolingDecoder { + async fn get_capabilities( + &self, + _request: Request<()>, + ) -> Result, Status> { + Ok(Response::new(self.features.to_proto())) + } + async fn decode( &self, request: Request, ) -> Result, Status> { let problem = request.into_inner(); - // Skip decoding entirely when syndrome has no defects - if problem.syndrome.as_ref().is_some_and(|s| s.data.iter().all(|&b| b == 0)) { + let syndrome = problem + .syndrome + .as_ref() + .ok_or_else(|| Status::invalid_argument("missing syndrome"))?; + if problem.hypergraph.is_none() { + return Err(Status::invalid_argument("missing hypergraph")); + } + #[cfg(debug_assertions)] + { + let hypergraph = problem.hypergraph.as_ref().unwrap(); + validation::validate_hypergraph(hypergraph).map_err(Status::invalid_argument)?; + validation::validate_syndrome(syndrome, hypergraph.vertex_num).map_err(Status::invalid_argument)?; + validation::validate_loss(problem.loss.as_ref(), hypergraph.hyperedges.len()) + .map_err(Status::invalid_argument)?; + } + let request = DecodeRequest { + syndrome, + reweights: &[], + loss: problem.loss.as_ref(), + }; + request.require_supported(self.features).map_err(decode_error_status)?; + // A plain zero syndrome needs no correction. Side information can still + // change a loss-aware decoder's logical choice, so those requests must + // reach the backend. + if bit_vector::is_zero(syndrome) && problem.loss.is_none() { return Ok(Response::new(ParityFactor { subgraph: vec![] })); } - let (tx, rx) = oneshot::channel::(); + let (tx, rx) = oneshot::channel::>(); let original_config = self.original_config.clone(); self.decoding.send_modify(|v| { *v += 1; @@ -132,30 +229,48 @@ impl black_box_decoder_server::BlackBoxDeco // Without this, a cancelled decode leaks a +1 in the counter, causing // black_box_decoder.reset() to wait forever. let mut decoding_guard = DecodingGuard::new(self.decoding.clone()); + let decoding_tx = self.decoding.clone(); self.thread_pool.spawn(move || { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let hypergraph = problem.hypergraph.as_ref().unwrap(); let mut instance = T::new(hypergraph, &original_config); - let syndrome = problem.syndrome.as_ref().unwrap(); - instance.decode(syndrome) + instance + .decode(DecodeRequest { + syndrome: problem.syndrome.as_ref().unwrap(), + reweights: &[], + loss: problem.loss.as_ref(), + }) + .and_then(|parity_factor| { + #[cfg(debug_assertions)] + { + return validation::validate_parity_factor(parity_factor, hypergraph.hyperedges.len()) + .map_err(DecodeError::Backend); + } + #[cfg(not(debug_assertions))] + { + Ok(parity_factor) + } + }) })); match result { - Ok(parity_factor) => { - let _ = tx.send(parity_factor); + Ok(decode_result) => { + let _ = tx.send(decode_result); } Err(_) => { eprintln!("decoder panicked during decode"); } } + decoding_tx.send_modify(|v| { + *v -= 1; + }); }); - let parity_factor = rx.await; - // Defuse the guard — we'll decrement manually on the happy path. - // If rx.await was cancelled (future dropped), the guard's Drop fires instead. + // The worker owns the decrement from here. A cancelled RPC drops its + // receiver, but reset must still wait for the backend work to finish. decoding_guard.defuse(); - self.decoding.send_modify(|v| { - *v -= 1; - }); - let parity_factor = parity_factor.map_err(|_| Status::internal("decode panicked or was cancelled".to_string()))?; + let parity_factor = rx + .await + .map_err(|_| Status::internal("decode panicked or was cancelled"))? + .map_err(decode_error_status)?; Ok(parity_factor.into()) } @@ -164,33 +279,31 @@ impl black_box_decoder_server::BlackBoxDeco request: Request, ) -> Result, Status> { let hypergraph = Arc::new(request.into_inner()); - let mut loaded = self.loaded.lock().await; - let hid: u64 = (loaded.len() as u64) + 1; - loaded.insert( - hid, - Loaded { - hypergraph: hypergraph.clone(), - instances: [].into(), - }, - ); - drop(loaded); - let (tx, rx) = oneshot::channel::(); + #[cfg(debug_assertions)] + validation::validate_hypergraph(&hypergraph).map_err(Status::invalid_argument)?; + let hid = self.next_hid.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = oneshot::channel::, DecodingGuard), Status>>(); let original_config = self.original_config.clone(); + self.decoding.send_modify(|count| *count += 1); + let decoding_guard = DecodingGuard::new(self.decoding.clone()); self.thread_pool.spawn(move || { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| T::new(&hypergraph, &original_config))); - match result { - Ok(instance) => { - let _ = tx.send(instance); - } + let result = match result { + Ok(instance) => Ok((instance, hypergraph, decoding_guard)), Err(_) => { - eprintln!("decoder panicked during load_hypergraph (hid={})", hid); + eprintln!("decoder panicked during load_hypergraph (hid={hid})"); + Err(Status::internal(format!("hid={hid} load panicked"))) } - } + }; + let _ = tx.send(result); }); - let instance = rx + let (instance, hypergraph, decoding_guard) = rx .await - .map_err(|_| Status::internal(format!("hid={hid} load panicked or was cancelled")))?; - self.loaded.lock().await.get_mut(&hid).unwrap().instances.push_back(instance); + .map_err(|_| Status::internal(format!("hid={hid} load was cancelled")))??; + let mut instances = LinkedList::new(); + instances.push_back(instance); + self.loaded.lock().await.insert(hid, Loaded { hypergraph, instances }); + drop(decoding_guard); Ok(Response::new(blackbox_decoder::LoadHypergraphResponse { hid })) } @@ -199,76 +312,107 @@ impl black_box_decoder_server::BlackBoxDeco request: Request, ) -> Result, Status> { let problem = request.into_inner(); - // Skip decoding entirely when syndrome has no defects - if problem.syndrome.as_ref().is_some_and(|s| s.data.iter().all(|&b| b == 0)) { - return Ok(Response::new(ParityFactor { subgraph: vec![] })); - } - let (tx, rx) = oneshot::channel::(); + let syndrome = problem + .syndrome + .as_ref() + .ok_or_else(|| Status::invalid_argument("missing syndrome"))?; + let reweights: Vec<(u64, f64)> = problem + .reweights + .iter() + .map(|value| (value.edge, value.probability)) + .collect(); + let request = DecodeRequest { + syndrome, + reweights: &reweights, + loss: problem.loss.as_ref(), + }; + request.require_supported(self.features).map_err(decode_error_status)?; // Increment counter BEFORE accessing the loaded map, so that // reset() always sees counter > 0 while we're processing. self.decoding.send_modify(|v| { *v += 1; }); - let decoding_tx = self.decoding.clone(); - let mut instance = { + let decoding_guard = DecodingGuard::new(self.decoding.clone()); + let (instance, hypergraph) = { let mut guard = self.loaded.lock().await; - match guard.get_mut(&problem.hid) { - Some(loaded) => { - if let Some(instance) = loaded.instances.pop_back() { - instance - } else { - let hypergraph = loaded.hypergraph.clone(); - drop(guard); - DecoderInstance::new(&hypergraph, &self.original_config) - } - } - None => { - self.decoding.send_modify(|v| { - *v -= 1; - }); - return Err(Status::not_found(format!("hid={}", problem.hid))); - } + let Some(loaded) = guard.get_mut(&problem.hid) else { + return Err(Status::not_found(format!("hid={}", problem.hid))); + }; + #[cfg(debug_assertions)] + { + let edge_count = loaded.hypergraph.hyperedges.len(); + validation::validate_syndrome(syndrome, loaded.hypergraph.vertex_num).map_err(Status::invalid_argument)?; + validation::validate_reweights(&reweights, edge_count).map_err(Status::invalid_argument)?; + validation::validate_loss(problem.loss.as_ref(), edge_count).map_err(Status::invalid_argument)?; + } + // Preserve the plain zero-syndrome fast path without discarding + // shot-scoped priors or structured loss. Validate the HID and any + // assignments first so malformed requests cannot bypass the API. + if bit_vector::is_zero(syndrome) && reweights.is_empty() && problem.loss.is_none() { + return Ok(Response::new(ParityFactor { subgraph: vec![] })); } + let instance = loaded.instances.pop_back(); + let hypergraph = (instance.is_none() || cfg!(debug_assertions)).then(|| Arc::clone(&loaded.hypergraph)); + (instance, hypergraph) }; - let loaded_arc = self.loaded.clone(); - let handle = Handle::current(); + let (tx, rx) = oneshot::channel::, DecodingGuard), DecodeError>>(); + let original_config = instance.is_none().then(|| Arc::clone(&self.original_config)); + let hid = problem.hid; self.thread_pool.spawn(move || { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let syndrome = problem.syndrome.as_ref().unwrap(); - instance.decode(syndrome) - })); - match result { - Ok(parity_factor) => { - let _ = tx.send(parity_factor); - // reset and put the instance back - instance.reset(); - let _guard = handle.enter(); - tokio::spawn(async move { - let mut guard = loaded_arc.lock().await; - if let Some(loaded) = guard.get_mut(&problem.hid) { - loaded.instances.push_back(instance); + let mut instance = + instance.unwrap_or_else(|| T::new(hypergraph.as_deref().unwrap(), original_config.as_deref().unwrap())); + let decode_result = instance + .decode(DecodeRequest { + syndrome: problem.syndrome.as_ref().unwrap(), + reweights: &reweights, + loss: problem.loss.as_ref(), + }) + .and_then(|parity_factor| { + #[cfg(debug_assertions)] + { + return validation::validate_parity_factor( + parity_factor, + hypergraph.as_ref().unwrap().hyperedges.len(), + ) + .map_err(DecodeError::Backend); + } + #[cfg(not(debug_assertions))] + { + Ok(parity_factor) } - decoding_tx.send_modify(|v| { - *v -= 1; - }); }); + (instance, decode_result) + })); + match result { + Ok((mut instance, Ok(parity_factor))) => { + let reset_succeeded = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| instance.reset())).is_ok(); + if !reset_succeeded { + eprintln!("decoder panicked during reset after decode_loaded (hid={hid})"); + } + let instance = reset_succeeded.then_some(instance); + let _ = tx.send(Ok((parity_factor, instance, decoding_guard))); + } + Ok((_instance, Err(error))) => { + let _ = tx.send(Err(error)); } Err(_) => { - // tx is dropped, rx.await will return RecvError - eprintln!("decoder panicked during decode_loaded (hid={})", problem.hid); - // Instance may be in a bad state, don't return to pool - let _guard = handle.enter(); - tokio::spawn(async move { - decoding_tx.send_modify(|v| { - *v -= 1; - }); - }); + eprintln!("decoder panicked during decode_loaded (hid={hid})"); } } }); - let parity_factor = rx + let (parity_factor, instance, decoding_guard) = rx .await - .map_err(|_| Status::internal("decode panicked or was cancelled".to_string()))?; + .map_err(|_| Status::internal("decode panicked or was cancelled"))? + .map_err(decode_error_status)?; + if let Some(instance) = instance { + let mut guard = self.loaded.lock().await; + if let Some(loaded) = guard.get_mut(&hid) { + loaded.instances.push_back(instance); + } + } + drop(decoding_guard); Ok(parity_factor.into()) } @@ -298,3 +442,11 @@ impl black_box_decoder_server::BlackBoxDeco Ok(().into()) } } + +fn decode_error_status(error: DecodeError) -> Status { + match error { + DecodeError::Unsupported(_) => Status::failed_precondition(error.to_string()), + DecodeError::InvalidInput(_) => Status::invalid_argument(error.to_string()), + DecodeError::Backend(_) => Status::internal(error.to_string()), + } +} diff --git a/deq/deq_runtime/src/decoder/validation.rs b/deq/deq_runtime/src/decoder/validation.rs new file mode 100644 index 00000000..15ee0c75 --- /dev/null +++ b/deq/deq_runtime/src/decoder/validation.rs @@ -0,0 +1,149 @@ +//! Validation of black-box decoder protocol messages. + +use crate::decoder::blackbox_decoder::{DecodingHypergraph, LossInfo, ParityFactor}; +use crate::util::BitVector; + +pub(crate) fn validate_parity_factor(parity_factor: ParityFactor, edge_count: usize) -> Result { + if let Some(&edge) = parity_factor + .subgraph + .iter() + .find(|&&edge| usize::try_from(edge).map_or(true, |edge| edge >= edge_count)) + { + return Err(format!( + "decoder returned edge {edge}, but the hypergraph has {edge_count} edges" + )); + } + Ok(parity_factor) +} + +pub(crate) fn validate_hypergraph(hypergraph: &DecodingHypergraph) -> Result<(), String> { + for (edge, hyperedge) in hypergraph.hyperedges.iter().enumerate() { + if !hyperedge.probability.is_finite() || !(0.0..=1.0).contains(&hyperedge.probability) { + return Err(format!( + "hyperedge {edge} probability must lie in [0, 1], got {}", + hyperedge.probability + )); + } + let mut vertices = hashbrown::HashSet::with_capacity(hyperedge.vertices.len()); + for &vertex in &hyperedge.vertices { + if vertex >= hypergraph.vertex_num { + return Err(format!( + "hyperedge {edge} contains vertex {vertex}, outside [0, {})", + hypergraph.vertex_num + )); + } + if !vertices.insert(vertex) { + return Err(format!("hyperedge {edge} contains vertex {vertex} more than once")); + } + } + } + Ok(()) +} + +pub(crate) fn validate_syndrome(syndrome: &BitVector, vertex_num: u64) -> Result<(), String> { + crate::misc::bit_vector::validate_data_len(syndrome, "syndrome")?; + if syndrome.size != vertex_num { + return Err(format!( + "syndrome size {} does not match hypergraph vertex count {vertex_num}", + syndrome.size + )); + } + Ok(()) +} + +pub(crate) fn validate_reweights(reweights: &[(u64, f64)], edge_count: usize) -> Result<(), String> { + let mut seen = hashbrown::HashSet::with_capacity(reweights.len()); + for &(edge, probability) in reweights { + if usize::try_from(edge).map_or(true, |edge| edge >= edge_count) { + return Err(format!( + "reweighted edge {edge} is outside loaded hypergraph with {edge_count} edges" + )); + } + if !probability.is_finite() || !(0.0..=1.0).contains(&probability) { + return Err(format!( + "reweighted edge {edge} probability must lie in [0, 1], got {probability}" + )); + } + if !seen.insert(edge) { + return Err(format!("reweighted edge {edge} is assigned more than once")); + } + } + Ok(()) +} + +pub(crate) fn validate_loss(loss: Option<&LossInfo>, edge_count: usize) -> Result<(), String> { + let Some(loss) = loss else { + return Ok(()); + }; + let mut indegree = vec![0usize; loss.sites.len()]; + let mut children = vec![Vec::new(); loss.sites.len()]; + for (site_index, site) in loss.sites.iter().enumerate() { + if !site.probability.is_finite() || !(0.0..=1.0).contains(&site.probability) { + return Err(format!( + "loss site {site_index} probability must lie in [0, 1], got {}", + site.probability + )); + } + for (field, edges) in [ + ("source_edges", &site.source_edges), + ("continuation_edges", &site.continuation_edges), + ] { + let mut seen = hashbrown::HashSet::with_capacity(edges.len()); + for &edge in edges { + if usize::try_from(edge).map_or(true, |edge| edge >= edge_count) { + return Err(format!( + "loss site {site_index} {field} contains edge {edge}, outside [0, {edge_count})" + )); + } + if !seen.insert(edge) { + return Err(format!("loss site {site_index} {field} contains edge {edge} more than once")); + } + } + } + let mut seen_children = hashbrown::HashSet::with_capacity(site.children.len()); + for &child in &site.children { + let Ok(child_index) = usize::try_from(child) else { + return Err(format!( + "loss site {site_index} contains child {child}, outside [0, {})", + loss.sites.len() + )); + }; + let Some(degree) = indegree.get_mut(child_index) else { + return Err(format!( + "loss site {site_index} contains child {child}, outside [0, {})", + loss.sites.len() + )); + }; + if !seen_children.insert(child_index) { + return Err(format!("loss site {site_index} contains child {child} more than once")); + } + *degree += 1; + children[site_index].push(child_index); + } + let mut seen_heralds = hashbrown::HashSet::with_capacity(site.heralds.len()); + for &herald in &site.heralds { + if !seen_heralds.insert(herald) { + return Err(format!("loss site {site_index} contains herald {herald} more than once")); + } + } + } + let mut frontier: Vec = indegree + .iter() + .enumerate() + .filter_map(|(site, °ree)| (degree == 0).then_some(site)) + .collect(); + let mut visited = 0usize; + while let Some(site_index) = frontier.pop() { + visited += 1; + for &child_index in &children[site_index] { + indegree[child_index] -= 1; + if indegree[child_index] == 0 { + frontier.push(child_index); + } + } + } + if visited != loss.sites.len() { + return Err("loss site children graph contains a cycle".to_string()); + } + Ok(()) +} diff --git a/deq/deq_runtime/src/misc/bit_vector.rs b/deq/deq_runtime/src/misc/bit_vector.rs index 4332fa32..5f3b4a2d 100644 --- a/deq/deq_runtime/src/misc/bit_vector.rs +++ b/deq/deq_runtime/src/misc/bit_vector.rs @@ -80,6 +80,18 @@ pub fn to_sparse_indices(bit_vector: &BitVector) -> Vec { indices } +/// Return whether every meaningful bit is zero, ignoring unused padding bits in +/// the final byte. +pub fn is_zero(bit_vector: &BitVector) -> bool { + debug_assert!(validate_data_len(bit_vector, "bit vector").is_ok()); + let full_bytes = (bit_vector.size / 8) as usize; + if bit_vector.data[..full_bytes].iter().any(|&byte| byte != 0) { + return false; + } + let remainder = bit_vector.size % 8; + remainder == 0 || bit_vector.data[full_bytes] >> (8 - remainder) == 0 +} + pub fn extend_num_bits(bit_vector: &mut BitVector, extending_length: u64) { bit_vector.size += extending_length; let new_data_len = bit_vector_len(bit_vector.size); @@ -188,4 +200,25 @@ mod tests { assert!(unpack_bits(&[128u8, 64u8], 10) == bits("1000000001")); assert!(unpack_bits(&[20u8, 192u8], 10) == bits("0001010011")); } + + #[test] + fn zero_check_ignores_padding_bits() { + assert!(is_zero(&BitVector { size: 0, data: vec![] })); + assert!(is_zero(&BitVector { + size: 1, + data: vec![0b0111_1111] + })); + assert!(!is_zero(&BitVector { + size: 1, + data: vec![0b1000_0000] + })); + assert!(is_zero(&BitVector { + size: 9, + data: vec![0, 0b0111_1111], + })); + assert!(!is_zero(&BitVector { + size: 9, + data: vec![0, 0b1000_0000], + })); + } } From ab45e0a2a507494740c2d93d397b29ca2011893e Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Mon, 17 Aug 2026 21:44:01 -0700 Subject: [PATCH 131/157] simplify hypergraph pass by allowing isolated vertices --- deq/deq_runtime/src/coordinator.rs | 4 +- .../src/coordinator/monolithic_coordinator.rs | 46 +++---- .../src/coordinator/window_coordinator.rs | 123 ++++-------------- deq/deq_runtime/tests/dyn_lib_decoder_test.rs | 41 ++++++ .../tests/standard_decoder_test.rs | 56 ++++++++ .../tests/window_coordinator_test.rs | 37 ++++-- 6 files changed, 170 insertions(+), 137 deletions(-) diff --git a/deq/deq_runtime/src/coordinator.rs b/deq/deq_runtime/src/coordinator.rs index 16a05b35..78e858c3 100644 --- a/deq/deq_runtime/src/coordinator.rs +++ b/deq/deq_runtime/src/coordinator.rs @@ -87,8 +87,8 @@ pub use decoder_cache_key::{ DecoderCacheKey, ErrorModelFingerprint, FingerprintSource, ProbabilityModifierBits, build_modifier_fingerprints, }; -pub mod decoder_projection; -pub use decoder_projection::{DecodeProjection, DecoderReweighting, Deduplicated, LoadedDecoder}; +pub mod reweight_handler; +pub use reweight_handler::{DecodeProjection, DecoderReweighting, Deduplicated, LoadedDecoder}; pub mod loss_handler; pub use loss_handler::{EnvelopeReweightPolicy, LossHandler, LossStrategy, ReweightScale, apply_loss_random_imputation}; diff --git a/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs index c20cc735..1706210a 100644 --- a/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs @@ -23,11 +23,11 @@ use crate::bin; use crate::coordinator; -use crate::coordinator::decoder_projection::{ - DecodeProjection, Deduplicated, apply_reweights, decode_projected, deduplicate_by_syndrome, probability_reweights, +use crate::coordinator::loss_handler::{RawLossSite, apply_loss_random_imputation, has_loss_model}; +use crate::coordinator::reweight_handler::{ + apply_reweights, decode_projected, deduplicate_by_syndrome, load_projected_decoder, probability_reweights, validate_probability_modifier, }; -use crate::coordinator::loss_handler::{RawLossSite, apply_loss_random_imputation, has_loss_model}; use crate::coordinator::{ DecoderCacheKey, DecoderReweighting, FingerprintSource, LoadedDecoder, LossHandler, LossStrategy, build_modifier_fingerprints, @@ -648,34 +648,24 @@ impl MonolithicCoordinator { return (parity_factor, errors); }; - // Load the *base* graph -- the one carrying priors, before any shot's loss - // is applied -- so this entry serves every shot regardless of which atoms - // it loses, and send the shot's own loss alongside the syndrome. - let deduplicated = if deduplicate { - deduplicate_by_syndrome(&decoding_hypergraph, &errors, &priors) - } else { - Deduplicated::identity(&decoding_hypergraph, &errors) - }; - let loadable = deduplicated.hypergraph.clone(); - let representatives = Arc::new(deduplicated.representatives.clone()); - let projection = Arc::new(DecodeProjection { - base_hypergraph: decoding_hypergraph, - base_errors: errors, + // Load the stable base graph before any shot's loss is applied, so the + // cache entry can serve every later loss pattern. + let retain_decoding_hypergraph = !self.use_loaded_reweights || self.config.assert_parity_factor; + let loaded = load_projected_decoder( + &self.decoder, + decoding_hypergraph, + errors, priors, - deduplicated, - }); + deduplicate, + retain_decoding_hypergraph, + false, + ) + .await + .unwrap(); + let representatives = Arc::clone(&loaded.errors); let (reweights, loss) = self .loss_handler - .project_shot(&projection, &probability_reweights, &loss_sites); - let hid = self.decoder.load_hypergraph(loadable.clone()).await.unwrap().hid; - let loaded = LoadedDecoder { - hid, - errors: representatives.clone(), - decoding_hypergraph: (!self.use_loaded_reweights || self.config.assert_parity_factor) - .then(|| Arc::new(loadable)), - vertex_remap: None, - projection, - }; + .project_shot(&loaded.projection, &probability_reweights, &loss_sites); let mut loaded_decoders = self.loaded_decoders.write().await; loaded_decoders.insert(cache_key, loaded.clone()); drop(loaded_decoders); diff --git a/deq/deq_runtime/src/coordinator/window_coordinator.rs b/deq/deq_runtime/src/coordinator/window_coordinator.rs index 7cec6278..1cb1e5ff 100644 --- a/deq/deq_runtime/src/coordinator/window_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/window_coordinator.rs @@ -82,11 +82,11 @@ use crate::bin; use crate::coordinator; -use crate::coordinator::decoder_projection::{ - DecodeProjection, Deduplicated, apply_reweights, decode_projected, deduplicate_by_syndrome, probability_reweights, - validate_probability_modifier, -}; use crate::coordinator::loss_handler::{RawLossSite, apply_loss_random_imputation, has_loss_model}; +use crate::coordinator::reweight_handler::{ + apply_reweights, decode_projected, deduplicate_by_syndrome, ignore_edge_isolated_history_vertices, + load_projected_decoder, probability_reweights, validate_probability_modifier, +}; use crate::coordinator::{ DecoderCacheKey, DecoderReweighting, FingerprintSource, LoadedDecoder, LossHandler, LossStrategy, build_modifier_fingerprints, @@ -1715,12 +1715,7 @@ impl WindowCoordinator { .project_shot(&loaded.projection, &probability_reweights, &loss_sites); // we can use the loaded decoding hypergraph to call the decoding service span.add_event(Event::new("decoding").with_property(|| ("type", "loaded"))); - // Compact syndrome to match the loaded (compacted) hypergraph - let decode_syndrome = if let Some(ref remap) = loaded.vertex_remap { - Self::remap_syndrome(&syndrome, remap) - } else { - syndrome.clone() - }; + let decode_syndrome = loaded.project_syndrome(syndrome.clone()); let parity_factor = decode_projected( &self.decoder, &loaded, @@ -1765,7 +1760,8 @@ impl WindowCoordinator { decoding_hypergraph = deduplicated.hypergraph; errors = Arc::new(deduplicated.representatives); } - let (decoding_hypergraph, syndrome, _) = Self::compact_vertices(decoding_hypergraph, &syndrome); + let mut syndrome = syndrome; + ignore_edge_isolated_history_vertices(&decoding_hypergraph, &mut syndrome); span.add_event(Event::new("decoding").with_property(|| ("type", "temporary"))); let parity_factor = self .decoder @@ -1782,37 +1778,26 @@ impl WindowCoordinator { return (parity_factor, errors); }; - // Load the *base* graph -- the one carrying priors, before any shot's loss - // is applied -- so this entry serves every shot regardless of which atoms - // it loses, and send the shot's own loss alongside the syndrome. Vertex - // compaction only renumbers vertices, so the edge numbering the loss is - // expressed in survives it. - let deduplicated = if deduplicate { - deduplicate_by_syndrome(&decoding_hypergraph, &errors, &priors) - } else { - Deduplicated::identity(&decoding_hypergraph, &errors) - }; - let (loadable, decode_syndrome, vertex_remap) = Self::compact_vertices(deduplicated.hypergraph.clone(), &syndrome); - let representatives = Arc::new(deduplicated.representatives.clone()); - let projection = Arc::new(DecodeProjection { - base_hypergraph: decoding_hypergraph, - base_errors: errors, + // Load the stable base graph before any shot's loss is applied. Keep + // vertex numbering stable and ignore edge-isolated history-boundary bits. + span.add_event(Event::new("decoding").with_property(|| ("type", "loading"))); + let retain_decoding_hypergraph = !self.use_loaded_reweights || self.config.assert_parity_factor; + let loaded = load_projected_decoder( + &self.decoder, + decoding_hypergraph, + errors, priors, - deduplicated, - }); + deduplicate, + retain_decoding_hypergraph, + true, + ) + .await + .unwrap(); + let representatives = Arc::clone(&loaded.errors); + let decode_syndrome = loaded.project_syndrome(syndrome); let (reweights, loss) = self .loss_handler - .project_shot(&projection, &probability_reweights, &loss_sites); - span.add_event(Event::new("decoding").with_property(|| ("type", "loading"))); - let hid = self.decoder.load_hypergraph(loadable.clone()).await.unwrap().hid; - let loaded = LoadedDecoder { - hid, - errors: representatives.clone(), - decoding_hypergraph: (!self.use_loaded_reweights || self.config.assert_parity_factor) - .then(|| Arc::new(loadable)), - vertex_remap, - projection, - }; + .project_shot(&loaded.projection, &probability_reweights, &loss_sites); let mut loaded_decoders = self.loaded_decoders.write().await; loaded_decoders.insert(cache_key, loaded.clone()); drop(loaded_decoders); @@ -2007,66 +1992,6 @@ impl WindowCoordinator { (hypergraph, Arc::new(error_reference)) } - /// Remove vertices that have no incident hyperedges and remap the - /// remaining vertex indices to a contiguous range. Returns the - /// compacted hypergraph, compacted syndrome, and a remap vector - /// (compact index → original index). If no vertices were removed - /// the remap is `None` (identity). - fn compact_vertices( - mut hypergraph: DecodingHypergraph, - syndrome: &BitVector, - ) -> (DecodingHypergraph, BitVector, Option>>) { - // Collect used vertex indices - let mut used = vec![false; hypergraph.vertex_num as usize]; - for edge in &hypergraph.hyperedges { - for &v in &edge.vertices { - used[v as usize] = true; - } - } - - let used_count = used.iter().filter(|&&u| u).count(); - if used_count == hypergraph.vertex_num as usize { - // No isolated vertices — return as-is - return (hypergraph, syndrome.clone(), None); - } - - // Build old→new mapping and the inverse remap (new→old) - let mut old_to_new = vec![u64::MAX; hypergraph.vertex_num as usize]; - let mut new_to_old: Vec = Vec::with_capacity(used_count); - for (old_idx, &is_used) in used.iter().enumerate() { - if is_used { - old_to_new[old_idx] = new_to_old.len() as u64; - new_to_old.push(old_idx as u64); - } - } - - // Remap hyperedge vertices - for edge in &mut hypergraph.hyperedges { - for v in &mut edge.vertices { - debug_assert_ne!(old_to_new[*v as usize], u64::MAX); - *v = old_to_new[*v as usize]; - } - } - hypergraph.vertex_num = used_count as u64; - - // Remap syndrome - let compact_syndrome = Self::remap_syndrome(syndrome, &new_to_old); - - (hypergraph, compact_syndrome, Some(Arc::new(new_to_old))) - } - - /// Build a compacted syndrome by selecting only the bits at the given - /// original indices. - fn remap_syndrome(syndrome: &BitVector, new_to_old: &[u64]) -> BitVector { - let mut compact = bit_vector::from_sparse_indices(new_to_old.len() as u64, &[]); - for (new_idx, &old_idx) in new_to_old.iter().enumerate() { - if get_bit(syndrome, old_idx) { - set_bit(&mut compact, new_idx as u64, true); - } - } - compact - } - fn expand_remote_check_models_in_window( gid: u64, error_model: &ErrorModel, diff --git a/deq/deq_runtime/tests/dyn_lib_decoder_test.rs b/deq/deq_runtime/tests/dyn_lib_decoder_test.rs index 0b8e5c63..893f9235 100644 --- a/deq/deq_runtime/tests/dyn_lib_decoder_test.rs +++ b/deq/deq_runtime/tests/dyn_lib_decoder_test.rs @@ -98,6 +98,47 @@ async fn load_and_decode_through_grpc_surface() { assert_eq!(decode(vec![]).await, Vec::::new()); // no defects -> empty } +#[tokio::test] +async fn isolated_zero_vertex_is_supported() { + let path = plugin_path(); + assert!( + path.exists(), + "reference plugin not found at {} (run `cargo build -p deq-decoder-reference-plugin`)", + path.display() + ); + let decoder = DynDecoder::BlackBoxDynLib(Arc::new(DynLibDecoder::new(json!({ + "parallel": 1, + "library": path, + })))); + let hypergraph = blackbox_decoder::DecodingHypergraph { + vertex_num: 2, + hyperedges: vec![blackbox_decoder::Hyperedge { + vertices: vec![0], + probability: 0.1, + }], + }; + let syndrome = syndrome(2, &[0]); + + decoder + .decode(blackbox_decoder::DecodingProblem { + hypergraph: Some(hypergraph.clone()), + syndrome: Some(syndrome.clone()), + loss: None, + }) + .await + .unwrap(); + + let hid = decoder.load_hypergraph(hypergraph).await.unwrap().hid; + decoder + .decode_loaded(blackbox_decoder::LoadedDecodingProblem { + hid, + syndrome: Some(syndrome), + ..Default::default() + }) + .await + .unwrap(); +} + /// The CLI name `black-box-dyn-lib` resolves to the dynlib decoder and builds it. #[test] fn cli_name_selects_dynlib() { diff --git a/deq/deq_runtime/tests/standard_decoder_test.rs b/deq/deq_runtime/tests/standard_decoder_test.rs index 7f66cb46..71849da3 100644 --- a/deq/deq_runtime/tests/standard_decoder_test.rs +++ b/deq/deq_runtime/tests/standard_decoder_test.rs @@ -125,10 +125,44 @@ async fn assert_accepts_all_features(decoder: &DynDecoder) { assert!(parity_factor.subgraph.is_empty()); } +async fn assert_accepts_isolated_zero_vertex(decoder: &DynDecoder) { + let hypergraph = DecodingHypergraph { + vertex_num: 2, + hyperedges: vec![Hyperedge { + vertices: vec![0], + probability: 0.1, + }], + }; + let syndrome = BitVector { + size: 2, + data: vec![0b1000_0000], + }; + + decoder + .decode(DecodingProblem { + hypergraph: Some(hypergraph.clone()), + syndrome: Some(syndrome.clone()), + loss: None, + }) + .await + .unwrap(); + + let hid = decoder.load_hypergraph(hypergraph).await.unwrap().hid; + decoder + .decode_loaded(LoadedDecodingProblem { + hid, + syndrome: Some(syndrome), + ..Default::default() + }) + .await + .unwrap(); +} + #[tokio::test] async fn test_naive_decoder() { let decoder = DynDecoder::BlackBoxNaive(Arc::new(NaiveDecoder::new(serde_json::json!({})))); assert_accepts_all_features(&decoder).await; + assert_accepts_isolated_zero_vertex(&decoder).await; let report = run_standard_suite(&decoder).await; assert_full_coverage(&report); assert_matches_policy(&report, always_empty_subgraph_policy); @@ -137,6 +171,7 @@ async fn test_naive_decoder() { #[tokio::test] async fn test_mock_decoder() { let decoder = DynDecoder::Mock(Arc::new(MockDecoder::new())); + assert_accepts_isolated_zero_vertex(&decoder).await; let report = run_standard_suite(&decoder).await; assert_full_coverage(&report); assert_matches_policy(&report, always_empty_subgraph_policy); @@ -146,6 +181,7 @@ async fn test_mock_decoder() { async fn test_relay_bp_decoder() { use deq_runtime::decoder::RelayBPDecoder; let decoder = DynDecoder::BlackBoxRelayBP(Arc::new(RelayBPDecoder::new(serde_json::json!({})))); + assert_accepts_isolated_zero_vertex(&decoder).await; let report = run_standard_suite(&decoder).await; assert_full_coverage(&report); assert_matches_policy(&report, always_pass_policy); @@ -156,6 +192,7 @@ async fn test_relay_bp_decoder() { async fn test_tesseract_decoder() { use deq_runtime::decoder::TesseractDecoder; let decoder = DynDecoder::BlackBoxTesseract(Arc::new(TesseractDecoder::new(serde_json::json!({})))); + assert_accepts_isolated_zero_vertex(&decoder).await; let report = run_standard_suite(&decoder).await; assert_full_coverage(&report); assert_matches_policy(&report, always_pass_policy); @@ -168,6 +205,7 @@ async fn test_python_naive_decoder() { let config = serde_json::json!({ "file": "@naive_decoder" }); let decoder = DynDecoder::BlackBoxPython(Arc::new(PythonDecoder::new(config))); assert_accepts_all_features(&decoder).await; + assert_accepts_isolated_zero_vertex(&decoder).await; let report = run_standard_suite(&decoder).await; assert_full_coverage(&report); assert_matches_policy(&report, always_empty_subgraph_policy); @@ -202,6 +240,7 @@ class LegacyDecoder: })))); assert_eq!(decoder.features(), DecoderFeatures::empty()); + assert_accepts_isolated_zero_vertex(&decoder).await; let report = run_standard_suite(&decoder).await; assert_full_coverage(&report); assert_matches_policy(&report, always_empty_subgraph_policy); @@ -371,6 +410,7 @@ async fn test_python_relay_bp_decoder() { } let config = serde_json::json!({ "file": "@relay_bp_decoder" }); let decoder = DynDecoder::BlackBoxPython(Arc::new(PythonDecoder::new(config))); + assert_accepts_isolated_zero_vertex(&decoder).await; let report = run_standard_suite(&decoder).await; assert_full_coverage(&report); assert_matches_policy(&report, always_pass_policy); @@ -385,7 +425,23 @@ async fn test_python_tesseract_decoder() { } let config = serde_json::json!({ "file": "@tesseract_decoder" }); let decoder = DynDecoder::BlackBoxPython(Arc::new(PythonDecoder::new(config))); + assert_accepts_isolated_zero_vertex(&decoder).await; let report = run_standard_suite(&decoder).await; assert_full_coverage(&report); assert_matches_policy(&report, always_pass_policy); } + +#[cfg(feature = "python")] +#[tokio::test] +async fn test_python_mle_loss_decoder_accepts_isolated_zero_vertex() { + use deq_runtime::decoder::PythonDecoder; + if !python_modules_available( + "test_python_mle_loss_decoder_accepts_isolated_zero_vertex", + &["numpy", "scipy.optimize", "scipy.sparse"], + ) { + return; + } + let config = serde_json::json!({ "file": "@mle_loss_decoder" }); + let decoder = DynDecoder::BlackBoxPython(Arc::new(PythonDecoder::new(config))); + assert_accepts_isolated_zero_vertex(&decoder).await; +} diff --git a/deq/deq_runtime/tests/window_coordinator_test.rs b/deq/deq_runtime/tests/window_coordinator_test.rs index 1fbbb511..bdca5796 100644 --- a/deq/deq_runtime/tests/window_coordinator_test.rs +++ b/deq/deq_runtime/tests/window_coordinator_test.rs @@ -4376,18 +4376,15 @@ async fn test_streaming_lookahead_radius_no_unnecessary_wait() { assert_eq!(last_readouts.gid, last_gid); } -/// Test that isolated vertices (checks with no incident hyperedges) are -/// properly stripped from the decoding hypergraph. This can happen when -/// check-only gadgets (committed, no error models) are included in the -/// relative program — their check model creates vertex slots but no -/// hyperedge references them. +/// Test that edge-isolated history-boundary vertices keep stable numbering but +/// have their syndrome bits cleared. This can happen when check-only gadgets +/// (committed, no error models) are included in the relative program: their +/// check model creates vertex slots but no hyperedge references them. /// /// Uses a chain with buffer_radius=2, lookahead_radius=0 to create windows /// where previously committed gadgets appear as check-only entries. -/// Before the fix, this would panic with "vertex N do not have any -/// neighbor edges" in MWPF. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_isolated_vertices_stripped() { +async fn test_history_boundary_isolated_syndrome_bits_are_zeroed() { let (gids, coord, mock, trace_file) = build_checked_chain_with_radii(8, 2, 0).await; let trace_path = trace_file.path().to_str().unwrap().to_string(); mock.set_decode_delay(std::time::Duration::from_millis(5)); @@ -4409,6 +4406,30 @@ async fn test_isolated_vertices_stripped() { assert_eq!(readouts.gid, gids[i], "gid mismatch for gadget {i}"); } + let state = mock.state.read().await; + let mut found_isolated_vertex = false; + for call in &state.decode_calls { + assert_eq!(call.syndrome.size, call.hypergraph.vertex_num); + let mut incident = vec![false; call.hypergraph.vertex_num as usize]; + for hyperedge in &call.hypergraph.hyperedges { + for &vertex in &hyperedge.vertices { + incident[vertex as usize] = true; + } + } + for (vertex, incident) in incident.into_iter().enumerate() { + if !incident { + found_isolated_vertex = true; + let mask = 1 << (7 - vertex % 8); + assert_eq!(call.syndrome.data[vertex / 8] & mask, 0); + } + } + } + assert!( + found_isolated_vertex, + "test did not produce a history-boundary isolated vertex" + ); + drop(state); + reset_shot(coord.as_ref()).await; let trace = read_trace(&trace_path); let all_gids: HashSet = gids.into_iter().collect(); From f30b676af633d6f64ef6db6e7ebbbd317d2f8772 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 08:52:26 -0700 Subject: [PATCH 132/157] update API --- deq/deq_runtime/src/coordinator.rs | 2 +- .../src/coordinator/monolithic_coordinator.rs | 10 +++++----- deq/deq_runtime/src/coordinator/window_coordinator.rs | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/deq/deq_runtime/src/coordinator.rs b/deq/deq_runtime/src/coordinator.rs index 78e858c3..4864a341 100644 --- a/deq/deq_runtime/src/coordinator.rs +++ b/deq/deq_runtime/src/coordinator.rs @@ -88,7 +88,7 @@ pub use decoder_cache_key::{ }; pub mod reweight_handler; -pub use reweight_handler::{DecodeProjection, DecoderReweighting, Deduplicated, LoadedDecoder}; +pub use reweight_handler::{DecodeProjection, DecoderReweighting, LoadedDecoder}; pub mod loss_handler; pub use loss_handler::{EnvelopeReweightPolicy, LossHandler, LossStrategy, ReweightScale, apply_loss_random_imputation}; diff --git a/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs index 1706210a..44304cb8 100644 --- a/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs @@ -25,7 +25,7 @@ use crate::bin; use crate::coordinator; use crate::coordinator::loss_handler::{RawLossSite, apply_loss_random_imputation, has_loss_model}; use crate::coordinator::reweight_handler::{ - apply_reweights, decode_projected, deduplicate_by_syndrome, load_projected_decoder, probability_reweights, + apply_reweights, decode_projected, deduplicate_decoder_input, load_projected_decoder, probability_reweights, validate_probability_modifier, }; use crate::coordinator::{ @@ -629,9 +629,9 @@ impl MonolithicCoordinator { let (mut decoding_hypergraph, loss) = self.loss_handler.apply_sites(decoding_hypergraph, &loss_sites, &errors); let mut errors = errors; if deduplicate { - let deduplicated = deduplicate_by_syndrome(&decoding_hypergraph, &errors, &priors); - decoding_hypergraph = deduplicated.hypergraph; - errors = Arc::new(deduplicated.representatives); + let prepared = deduplicate_decoder_input(&decoding_hypergraph, &errors, &priors); + decoding_hypergraph = prepared.hypergraph; + errors = Arc::new(prepared.representatives); } let parity_factor = self .decoder @@ -655,7 +655,7 @@ impl MonolithicCoordinator { &self.decoder, decoding_hypergraph, errors, - priors, + &priors, deduplicate, retain_decoding_hypergraph, false, diff --git a/deq/deq_runtime/src/coordinator/window_coordinator.rs b/deq/deq_runtime/src/coordinator/window_coordinator.rs index 1cb1e5ff..277536e5 100644 --- a/deq/deq_runtime/src/coordinator/window_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/window_coordinator.rs @@ -84,7 +84,7 @@ use crate::bin; use crate::coordinator; use crate::coordinator::loss_handler::{RawLossSite, apply_loss_random_imputation, has_loss_model}; use crate::coordinator::reweight_handler::{ - apply_reweights, decode_projected, deduplicate_by_syndrome, ignore_edge_isolated_history_vertices, + apply_reweights, decode_projected, deduplicate_decoder_input, ignore_edge_isolated_history_vertices, load_projected_decoder, probability_reweights, validate_probability_modifier, }; use crate::coordinator::{ @@ -1756,9 +1756,9 @@ impl WindowCoordinator { let (mut decoding_hypergraph, loss) = self.loss_handler.apply_sites(decoding_hypergraph, &loss_sites, &errors); let mut errors = errors; if deduplicate { - let deduplicated = deduplicate_by_syndrome(&decoding_hypergraph, &errors, &priors); - decoding_hypergraph = deduplicated.hypergraph; - errors = Arc::new(deduplicated.representatives); + let prepared = deduplicate_decoder_input(&decoding_hypergraph, &errors, &priors); + decoding_hypergraph = prepared.hypergraph; + errors = Arc::new(prepared.representatives); } let mut syndrome = syndrome; ignore_edge_isolated_history_vertices(&decoding_hypergraph, &mut syndrome); @@ -1786,7 +1786,7 @@ impl WindowCoordinator { &self.decoder, decoding_hypergraph, errors, - priors, + &priors, deduplicate, retain_decoding_hypergraph, true, From 40e3bd3fada99ccd18c2c5e66647d188c22e1da0 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 10:35:30 -0700 Subject: [PATCH 133/157] add thread pooling tests --- deq/deq_runtime/tests/thread_pooling_test.rs | 690 +++++++++++++++++++ 1 file changed, 690 insertions(+) create mode 100644 deq/deq_runtime/tests/thread_pooling_test.rs diff --git a/deq/deq_runtime/tests/thread_pooling_test.rs b/deq/deq_runtime/tests/thread_pooling_test.rs new file mode 100644 index 00000000..e42ab031 --- /dev/null +++ b/deq/deq_runtime/tests/thread_pooling_test.rs @@ -0,0 +1,690 @@ +//! Integration tests for thread-pooled decoder lifecycle and protocol handling. + +use deq_runtime::decoder::DecoderFeatures; +use deq_runtime::decoder::blackbox_decoder::black_box_decoder_server::BlackBoxDecoder; +use deq_runtime::decoder::blackbox_decoder::{self, DecodingHypergraph, ParityFactor}; +use deq_runtime::decoder::thread_pooling::{DecodeError, DecodeRequest, DecoderInstance, ThreadPoolingDecoder}; +use deq_runtime::util::BitVector; +use std::sync::Arc; +use tonic::Request; + +fn single_edge_hypergraph() -> DecodingHypergraph { + DecodingHypergraph { + vertex_num: 1, + hyperedges: vec![blackbox_decoder::Hyperedge { + vertices: vec![0], + probability: 0.1, + }], + } +} + +async fn assert_hid_not_found(decoder: &ThreadPoolingDecoder, hid: u64) { + let error = BlackBoxDecoder::decode_loaded( + decoder, + Request::new(blackbox_decoder::LoadedDecodingProblem { + hid, + syndrome: Some(BitVector { size: 1, data: vec![0] }), + ..Default::default() + }), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::NotFound); +} + +#[tokio::test] +async fn failed_load_does_not_publish_a_hypergraph() { + struct PanickingDecoderInstance; + + impl DecoderInstance for PanickingDecoderInstance { + fn new(_hypergraph: &DecodingHypergraph, _config: &serde_json::Value) -> Self { + panic!("construction failed") + } + + fn decode(&mut self, _request: DecodeRequest<'_>) -> Result { + unreachable!() + } + + fn reset(&mut self) {} + } + + let decoder = ThreadPoolingDecoder::::new(serde_json::json!({})); + + let error = BlackBoxDecoder::load_hypergraph(&decoder, Request::new(single_edge_hypergraph())) + .await + .unwrap_err(); + + assert_eq!(error.code(), tonic::Code::Internal); + assert_hid_not_found(&decoder, 1).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cancelled_load_does_not_publish_a_hypergraph() { + use std::sync::{Barrier, OnceLock}; + + static STARTED: OnceLock> = OnceLock::new(); + static RELEASED: OnceLock> = OnceLock::new(); + + struct CancelledLoadDecoderInstance; + + impl DecoderInstance for CancelledLoadDecoderInstance { + fn new(_hypergraph: &DecodingHypergraph, _config: &serde_json::Value) -> Self { + STARTED.get().unwrap().wait(); + RELEASED.get().unwrap().wait(); + Self + } + + fn decode(&mut self, _request: DecodeRequest<'_>) -> Result { + Ok(ParityFactor { subgraph: vec![] }) + } + + fn reset(&mut self) {} + } + + let started = Arc::new(Barrier::new(2)); + let released = Arc::new(Barrier::new(2)); + STARTED.set(started.clone()).unwrap(); + RELEASED.set(released.clone()).unwrap(); + let decoder = Arc::new(ThreadPoolingDecoder::::new(serde_json::json!( + {} + ))); + let loading = tokio::spawn({ + let decoder = decoder.clone(); + async move { BlackBoxDecoder::load_hypergraph(decoder.as_ref(), Request::new(single_edge_hypergraph())).await } + }); + started.wait(); + loading.abort(); + assert!(loading.await.unwrap_err().is_cancelled()); + + released.wait(); + BlackBoxDecoder::reset(decoder.as_ref(), Request::new(blackbox_decoder::ResetRequest::default())) + .await + .unwrap(); + assert_hid_not_found(decoder.as_ref(), 1).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reset_waits_for_an_in_flight_load_before_clearing() { + use std::sync::{Barrier, OnceLock}; + + static STARTED: OnceLock> = OnceLock::new(); + static RELEASED: OnceLock> = OnceLock::new(); + + struct BlockingDecoderInstance; + + impl DecoderInstance for BlockingDecoderInstance { + fn new(_hypergraph: &DecodingHypergraph, _config: &serde_json::Value) -> Self { + STARTED.get().unwrap().wait(); + RELEASED.get().unwrap().wait(); + Self + } + + fn decode(&mut self, _request: DecodeRequest<'_>) -> Result { + Ok(ParityFactor { subgraph: vec![] }) + } + + fn reset(&mut self) {} + } + + let started = Arc::new(Barrier::new(2)); + let released = Arc::new(Barrier::new(2)); + STARTED.set(started.clone()).unwrap(); + RELEASED.set(released.clone()).unwrap(); + let decoder = Arc::new(ThreadPoolingDecoder::::new(serde_json::json!({}))); + + let loading = tokio::spawn({ + let decoder = decoder.clone(); + async move { BlackBoxDecoder::load_hypergraph(decoder.as_ref(), Request::new(single_edge_hypergraph())).await } + }); + started.wait(); + let resetting = tokio::spawn({ + let decoder = decoder.clone(); + async move { + BlackBoxDecoder::reset( + decoder.as_ref(), + Request::new(blackbox_decoder::ResetRequest { + reset_hypergraphs: true, + ..Default::default() + }), + ) + .await + } + }); + tokio::task::yield_now().await; + assert!(!resetting.is_finished()); + + released.wait(); + let hid = loading.await.unwrap().unwrap().into_inner().hid; + resetting.await.unwrap().unwrap(); + assert_hid_not_found(decoder.as_ref(), hid).await; +} + +#[tokio::test] +async fn reset_panic_discards_the_instance_and_releases_the_counter() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + static CONSTRUCTIONS: AtomicUsize = AtomicUsize::new(0); + + struct ResetPanickingDecoderInstance; + + impl DecoderInstance for ResetPanickingDecoderInstance { + fn new(_hypergraph: &DecodingHypergraph, _config: &serde_json::Value) -> Self { + CONSTRUCTIONS.fetch_add(1, Ordering::Relaxed); + Self + } + + fn decode(&mut self, _request: DecodeRequest<'_>) -> Result { + Ok(ParityFactor { subgraph: vec![0] }) + } + + fn reset(&mut self) { + panic!("reset failed") + } + } + + let decoder = ThreadPoolingDecoder::::new(serde_json::json!({})); + let hid = BlackBoxDecoder::load_hypergraph(&decoder, Request::new(single_edge_hypergraph())) + .await + .unwrap() + .into_inner() + .hid; + + let decode = || { + BlackBoxDecoder::decode_loaded( + &decoder, + Request::new(blackbox_decoder::LoadedDecodingProblem { + hid, + syndrome: Some(BitVector { + size: 1, + data: vec![0b1000_0000], + }), + ..Default::default() + }), + ) + }; + decode().await.unwrap(); + BlackBoxDecoder::reset(&decoder, Request::new(blackbox_decoder::ResetRequest::default())) + .await + .unwrap(); + decode().await.unwrap(); + BlackBoxDecoder::reset(&decoder, Request::new(blackbox_decoder::ResetRequest::default())) + .await + .unwrap(); + + assert_eq!(CONSTRUCTIONS.load(Ordering::Relaxed), 2); +} + +struct InvalidSubgraphDecoderInstance; + +impl DecoderInstance for InvalidSubgraphDecoderInstance { + fn new(_hypergraph: &DecodingHypergraph, _config: &serde_json::Value) -> Self { + Self + } + + fn decode(&mut self, _request: DecodeRequest<'_>) -> Result { + Ok(ParityFactor { subgraph: vec![1] }) + } + + fn reset(&mut self) {} +} + +#[tokio::test] +#[cfg(debug_assertions)] +async fn invalid_backend_edge_is_reported_without_panicking() { + let decoder = ThreadPoolingDecoder::::new(serde_json::json!({})); + let hid = BlackBoxDecoder::load_hypergraph(&decoder, Request::new(single_edge_hypergraph())) + .await + .unwrap() + .into_inner() + .hid; + + let error = BlackBoxDecoder::decode_loaded( + &decoder, + Request::new(blackbox_decoder::LoadedDecodingProblem { + hid, + syndrome: Some(BitVector { + size: 1, + data: vec![0b1000_0000], + }), + ..Default::default() + }), + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), tonic::Code::Internal); + assert!( + error + .message() + .contains("decoder returned edge 1, but the hypergraph has 1 edges") + ); +} + +struct CombinedDecoderInstance; + +impl DecoderInstance for CombinedDecoderInstance { + fn supported_features(_config: &serde_json::Value) -> DecoderFeatures { + DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS + } + + fn new(_hypergraph: &DecodingHypergraph, _config: &serde_json::Value) -> Self { + Self + } + + fn decode(&mut self, request: DecodeRequest<'_>) -> Result { + assert_eq!(request.reweights, &[(0, 0.25)]); + let loss = request.loss.expect("combined request must carry loss"); + assert_eq!(loss.sites[0].source_edges, vec![0]); + Ok(ParityFactor { subgraph: vec![0] }) + } + + fn reset(&mut self) {} +} + +#[tokio::test] +async fn zero_syndrome_still_requires_a_loaded_hypergraph() { + let decoder = ThreadPoolingDecoder::::new(serde_json::json!({})); + assert_hid_not_found(&decoder, 99).await; +} + +#[tokio::test] +async fn padding_bits_do_not_make_a_syndrome_nonzero() { + let decoder = ThreadPoolingDecoder::::new(serde_json::json!({})); + let hid = BlackBoxDecoder::load_hypergraph(&decoder, Request::new(single_edge_hypergraph())) + .await + .unwrap() + .into_inner() + .hid; + + let response = BlackBoxDecoder::decode_loaded( + &decoder, + Request::new(blackbox_decoder::LoadedDecodingProblem { + hid, + syndrome: Some(BitVector { size: 1, data: vec![1] }), + ..Default::default() + }), + ) + .await + .unwrap(); + + assert!(response.into_inner().subgraph.is_empty()); +} + +#[tokio::test] +#[cfg(debug_assertions)] +async fn malformed_syndrome_and_reweights_are_rejected() { + let decoder = ThreadPoolingDecoder::::new(serde_json::json!({})); + let hid = BlackBoxDecoder::load_hypergraph(&decoder, Request::new(single_edge_hypergraph())) + .await + .unwrap() + .into_inner() + .hid; + + for syndrome in [BitVector { size: 1, data: vec![] }, BitVector { size: 2, data: vec![0] }] { + let error = BlackBoxDecoder::decode_loaded( + &decoder, + Request::new(blackbox_decoder::LoadedDecodingProblem { + hid, + syndrome: Some(syndrome), + ..Default::default() + }), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + } + + for reweight in [ + blackbox_decoder::EdgeReweight { + edge: 1, + probability: 0.2, + }, + blackbox_decoder::EdgeReweight { + edge: 0, + probability: f64::NAN, + }, + ] { + let error = BlackBoxDecoder::decode_loaded( + &decoder, + Request::new(blackbox_decoder::LoadedDecodingProblem { + hid, + syndrome: Some(BitVector { + size: 1, + data: vec![0b1000_0000], + }), + reweights: vec![reweight], + ..Default::default() + }), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + } + + let duplicate_reweight = BlackBoxDecoder::decode_loaded( + &decoder, + Request::new(blackbox_decoder::LoadedDecodingProblem { + hid, + syndrome: Some(BitVector { + size: 1, + data: vec![0b1000_0000], + }), + reweights: vec![ + blackbox_decoder::EdgeReweight { + edge: 0, + probability: 0.2, + }, + blackbox_decoder::EdgeReweight { + edge: 0, + probability: 0.3, + }, + ], + ..Default::default() + }), + ) + .await + .unwrap_err(); + assert_eq!(duplicate_reweight.code(), tonic::Code::InvalidArgument); + + for site in [ + blackbox_decoder::LossSite { + source_edges: vec![1], + probability: 0.2, + ..Default::default() + }, + blackbox_decoder::LossSite { + children: vec![0], + probability: 0.2, + ..Default::default() + }, + blackbox_decoder::LossSite { + source_edges: vec![0, 0], + probability: 0.2, + ..Default::default() + }, + blackbox_decoder::LossSite { + heralds: vec![3, 3], + probability: 0.2, + ..Default::default() + }, + ] { + let error = BlackBoxDecoder::decode_loaded( + &decoder, + Request::new(blackbox_decoder::LoadedDecodingProblem { + hid, + syndrome: Some(BitVector { + size: 1, + data: vec![0b1000_0000], + }), + loss: Some(blackbox_decoder::LossInfo { sites: vec![site] }), + ..Default::default() + }), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + } +} + +#[tokio::test] +#[cfg(debug_assertions)] +async fn invalid_hypergraph_is_rejected_before_construction() { + let decoder = ThreadPoolingDecoder::::new(serde_json::json!({})); + for vertices in [vec![1], vec![0, 0]] { + let error = BlackBoxDecoder::load_hypergraph( + &decoder, + Request::new(DecodingHypergraph { + vertex_num: 1, + hyperedges: vec![blackbox_decoder::Hyperedge { + vertices, + probability: 0.1, + }], + }), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn overflow_construction_panic_is_contained_and_releases_the_counter() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Barrier, OnceLock}; + + static CONSTRUCTIONS: AtomicUsize = AtomicUsize::new(0); + static DECODE_STARTED: OnceLock> = OnceLock::new(); + static DECODE_RELEASED: OnceLock> = OnceLock::new(); + + struct OverflowPanickingDecoderInstance; + + impl DecoderInstance for OverflowPanickingDecoderInstance { + fn new(_hypergraph: &DecodingHypergraph, _config: &serde_json::Value) -> Self { + if CONSTRUCTIONS.fetch_add(1, Ordering::Relaxed) > 0 { + panic!("overflow construction failed") + } + Self + } + + fn decode(&mut self, _request: DecodeRequest<'_>) -> Result { + DECODE_STARTED.get().unwrap().wait(); + DECODE_RELEASED.get().unwrap().wait(); + Ok(ParityFactor { subgraph: vec![] }) + } + + fn reset(&mut self) {} + } + + let started = Arc::new(Barrier::new(2)); + let released = Arc::new(Barrier::new(2)); + DECODE_STARTED.set(started.clone()).unwrap(); + DECODE_RELEASED.set(released.clone()).unwrap(); + let decoder = Arc::new(ThreadPoolingDecoder::::new( + serde_json::json!({ "parallel": 2 }), + )); + let hid = BlackBoxDecoder::load_hypergraph(decoder.as_ref(), Request::new(single_edge_hypergraph())) + .await + .unwrap() + .into_inner() + .hid; + let request = || { + Request::new(blackbox_decoder::LoadedDecodingProblem { + hid, + syndrome: Some(BitVector { + size: 1, + data: vec![0b1000_0000], + }), + ..Default::default() + }) + }; + + let first = tokio::spawn({ + let decoder = decoder.clone(); + let request = request(); + async move { BlackBoxDecoder::decode_loaded(decoder.as_ref(), request).await } + }); + started.wait(); + let second_error = BlackBoxDecoder::decode_loaded(decoder.as_ref(), request()).await.unwrap_err(); + assert_eq!(second_error.code(), tonic::Code::Internal); + + released.wait(); + first.await.unwrap().unwrap(); + BlackBoxDecoder::reset(decoder.as_ref(), Request::new(blackbox_decoder::ResetRequest::default())) + .await + .unwrap(); + assert_eq!(CONSTRUCTIONS.load(Ordering::Relaxed), 2); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reset_waits_for_cancelled_one_shot_backend_work() { + use std::sync::{Barrier, OnceLock}; + + static DECODE_STARTED: OnceLock> = OnceLock::new(); + static DECODE_RELEASED: OnceLock> = OnceLock::new(); + + struct BlockingOneShotDecoderInstance; + + impl DecoderInstance for BlockingOneShotDecoderInstance { + fn new(_hypergraph: &DecodingHypergraph, _config: &serde_json::Value) -> Self { + Self + } + + fn decode(&mut self, _request: DecodeRequest<'_>) -> Result { + DECODE_STARTED.get().unwrap().wait(); + DECODE_RELEASED.get().unwrap().wait(); + Ok(ParityFactor { subgraph: vec![] }) + } + + fn reset(&mut self) {} + } + + let started = Arc::new(Barrier::new(2)); + let released = Arc::new(Barrier::new(2)); + DECODE_STARTED.set(started.clone()).unwrap(); + DECODE_RELEASED.set(released.clone()).unwrap(); + let decoder = Arc::new(ThreadPoolingDecoder::::new( + serde_json::json!({}), + )); + let decoding = tokio::spawn({ + let decoder = decoder.clone(); + async move { + BlackBoxDecoder::decode( + decoder.as_ref(), + Request::new(blackbox_decoder::DecodingProblem { + hypergraph: Some(single_edge_hypergraph()), + syndrome: Some(BitVector { + size: 1, + data: vec![0b1000_0000], + }), + loss: None, + }), + ) + .await + } + }); + started.wait(); + decoding.abort(); + assert!(decoding.await.unwrap_err().is_cancelled()); + + let resetting = tokio::spawn({ + let decoder = decoder.clone(); + async move { BlackBoxDecoder::reset(decoder.as_ref(), Request::new(blackbox_decoder::ResetRequest::default())).await } + }); + tokio::task::yield_now().await; + assert!(!resetting.is_finished()); + + released.wait(); + resetting.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn loaded_decode_passes_reweights_and_loss_together() { + let decoder = ThreadPoolingDecoder::::new(serde_json::json!({})); + let hid = BlackBoxDecoder::load_hypergraph(&decoder, Request::new(single_edge_hypergraph())) + .await + .unwrap() + .into_inner() + .hid; + + let response = BlackBoxDecoder::decode_loaded( + &decoder, + Request::new(blackbox_decoder::LoadedDecodingProblem { + hid, + syndrome: Some(BitVector { + size: 1, + data: vec![0b1000_0000], + }), + reweights: vec![blackbox_decoder::EdgeReweight { + edge: 0, + probability: 0.25, + }], + loss: Some(blackbox_decoder::LossInfo { + sites: vec![blackbox_decoder::LossSite { + source_edges: vec![0], + probability: 0.2, + ..Default::default() + }], + }), + }), + ) + .await + .unwrap(); + + assert_eq!(response.into_inner().subgraph, vec![0]); +} + +#[tokio::test] +async fn unsupported_features_are_rejected_before_backend_dispatch() { + let decoder = ThreadPoolingDecoder::::new(serde_json::json!({})); + let hypergraph = single_edge_hypergraph(); + let syndrome = BitVector { + size: 1, + data: vec![0b1000_0000], + }; + + let loss_error = BlackBoxDecoder::decode( + &decoder, + Request::new(blackbox_decoder::DecodingProblem { + hypergraph: Some(hypergraph.clone()), + syndrome: Some(syndrome.clone()), + loss: Some(blackbox_decoder::LossInfo::default()), + }), + ) + .await + .unwrap_err(); + assert_eq!(loss_error.code(), tonic::Code::FailedPrecondition); + + let hid = BlackBoxDecoder::load_hypergraph(&decoder, Request::new(hypergraph)) + .await + .unwrap() + .into_inner() + .hid; + let reweight_error = BlackBoxDecoder::decode_loaded( + &decoder, + Request::new(blackbox_decoder::LoadedDecodingProblem { + hid, + syndrome: Some(syndrome), + reweights: vec![blackbox_decoder::EdgeReweight { + edge: 0, + probability: 0.25, + }], + loss: None, + }), + ) + .await + .unwrap_err(); + assert_eq!(reweight_error.code(), tonic::Code::FailedPrecondition); +} + +#[tokio::test] +async fn zero_syndrome_with_side_information_reaches_decoder() { + let decoder = ThreadPoolingDecoder::::new(serde_json::json!({})); + let hid = BlackBoxDecoder::load_hypergraph(&decoder, Request::new(single_edge_hypergraph())) + .await + .unwrap() + .into_inner() + .hid; + + let response = BlackBoxDecoder::decode_loaded( + &decoder, + Request::new(blackbox_decoder::LoadedDecodingProblem { + hid, + syndrome: Some(BitVector { size: 1, data: vec![0] }), + reweights: vec![blackbox_decoder::EdgeReweight { + edge: 0, + probability: 0.25, + }], + loss: Some(blackbox_decoder::LossInfo { + sites: vec![blackbox_decoder::LossSite { + source_edges: vec![0], + probability: 0.2, + ..Default::default() + }], + }), + }), + ) + .await + .unwrap(); + + assert_eq!(response.into_inner().subgraph, vec![0]); +} From 05afb3438edd5aa08112b3503128978aefa0dede Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 10:52:33 -0700 Subject: [PATCH 134/157] add reweight handler --- .../src/coordinator/reweight_handler.rs | 485 ++++++++++++++++++ 1 file changed, 485 insertions(+) create mode 100644 deq/deq_runtime/src/coordinator/reweight_handler.rs diff --git a/deq/deq_runtime/src/coordinator/reweight_handler.rs b/deq/deq_runtime/src/coordinator/reweight_handler.rs new file mode 100644 index 00000000..bd06e905 --- /dev/null +++ b/deq/deq_runtime/src/coordinator/reweight_handler.rs @@ -0,0 +1,485 @@ +//! Reweight-handling pass for decoder problems. +//! +//! This module owns the loaded-decoder context, hyperedge deduplication, and +//! translation of shot-scoped probability updates onto decoder hypergraphs. +//! It is the probability-reweight counterpart to `loss_handler`. +//! +//! Reweights begin in the original hypergraph's edge numbering. Before a graph +//! is loaded, same-syndrome edges may be collapsed. Window decoders preserve +//! vertex numbering but ignore history-boundary vertices with no incident edge. +//! [`DecodeProjection`] retains the +//! original graph and edge mappings needed to translate every later shot; +//! [`LoadedDecoder`] retains correction representatives and, only when needed +//! locally, the decoder-facing graph. + +use crate::bin; +use crate::decoder::DynDecoder; +use crate::decoder::blackbox_decoder; +use crate::decoder::decoder_features::DecoderFeatures; +use crate::misc::index::ErrorIndex; +use crate::misc::util::exclusive_probability_of; +use crate::util::BitVector; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tonic::Status; + +/// Build and load the stable decoder graph for a persistent cache entry. +/// +/// Deduplication and original-edge projection are shared by coordinators. +/// Window decoding may additionally ignore edge-isolated history-boundary +/// syndrome vertices without renumbering the graph. +pub(crate) async fn load_projected_decoder( + decoder: &DynDecoder, + base_hypergraph: blackbox_decoder::DecodingHypergraph, + base_errors: Arc>, + representative_priors: &[f64], + deduplicate: bool, + retain_decoding_hypergraph: bool, + ignore_isolated_vertices: bool, +) -> Result { + let (projection, prepared) = prepare_decoder(base_hypergraph, base_errors, representative_priors, deduplicate); + let PreparedDecoderInput { + hypergraph, + representatives, + } = prepared; + let ignored_syndrome_vertices = if ignore_isolated_vertices { + Arc::new(edge_isolated_vertices(&hypergraph)) + } else { + Arc::new(vec![]) + }; + let decoding_hypergraph = retain_decoding_hypergraph.then(|| Arc::new(hypergraph.clone())); + let hid = decoder.load_hypergraph(hypergraph).await?.hid; + Ok(LoadedDecoder { + hid, + errors: Arc::new(representatives), + decoding_hypergraph, + ignored_syndrome_vertices, + projection: Arc::new(projection), + }) +} + +fn prepare_decoder( + base_hypergraph: blackbox_decoder::DecodingHypergraph, + base_errors: Arc>, + representative_priors: &[f64], + deduplicate: bool, +) -> (DecodeProjection, PreparedDecoderInput) { + debug_assert_eq!(base_hypergraph.hyperedges.len(), base_errors.len()); + debug_assert_eq!(base_hypergraph.hyperedges.len(), representative_priors.len()); + let (prepared, edge_projection) = if deduplicate { + deduplicate_by_syndrome(&base_hypergraph, &base_errors, representative_priors) + } else { + ( + PreparedDecoderInput { + hypergraph: base_hypergraph.clone(), + representatives: base_errors.as_ref().clone(), + }, + EdgeProjection::Identity, + ) + }; + ( + DecodeProjection { + base_hypergraph, + base_errors, + edge_projection, + }, + prepared, + ) +} + +fn edge_isolated_vertices(hypergraph: &blackbox_decoder::DecodingHypergraph) -> Vec { + let mut incident = vec![false; hypergraph.vertex_num as usize]; + for hyperedge in &hypergraph.hyperedges { + for &vertex in &hyperedge.vertices { + incident[vertex as usize] = true; + } + } + incident + .into_iter() + .enumerate() + .filter_map(|(vertex, incident)| (!incident).then_some(vertex as u64)) + .collect() +} + +/// Clear syndrome bits for history-boundary vertices that no decoder edge can +/// affect, while preserving the graph's stable vertex numbering. +pub(crate) fn ignore_edge_isolated_history_vertices( + hypergraph: &blackbox_decoder::DecodingHypergraph, + syndrome: &mut BitVector, +) { + for vertex in edge_isolated_vertices(hypergraph) { + crate::misc::bit_vector::set_bit(syndrome, vertex, false); + } +} + +/// Validate one gadget-local probability assignment before it is bound to a +/// concrete hypergraph. +pub(crate) fn validate_probability_modifier(modifier: &bin::ProbabilityModifier, error_count: usize) -> Result<(), String> { + if !modifier.probabilities.is_empty() && modifier.probabilities.len() != error_count { + return Err(format!( + "dense probability modifier has length {}, expected 0 or {error_count}", + modifier.probabilities.len() + )); + } + if modifier.sparse_indices.len() != modifier.sparse_probabilities.len() { + return Err(format!( + "sparse probability modifier has {} indices but {} probabilities", + modifier.sparse_indices.len(), + modifier.sparse_probabilities.len() + )); + } + for &index in &modifier.sparse_indices { + if index >= error_count as u64 { + return Err(format!( + "sparse probability modifier index {index} is outside [0, {error_count})" + )); + } + } + for &probability in modifier.probabilities.iter().chain(modifier.sparse_probabilities.iter()) { + if !probability.is_finite() || !(0.0..=1.0).contains(&probability) { + return Err(format!("probability modifier value must lie in [0, 1], got {probability}")); + } + } + Ok(()) +} + +/// Convert gadget-local `(error model, generator)` assignments into updates in +/// the original decoding hypergraph's edge numbering. +/// +/// Several modifiers may target the same edge; the last assignment wins. A +/// later call to [`DecodeProjection::translate_reweights`] translates these +/// original indices into the edge numbering used by a persistent decoder. +pub(crate) fn probability_reweights<'a>( + error_reference: &[ErrorIndex], + modifiers: impl IntoIterator, +) -> Vec<(u64, f64)> { + let modifiers: Vec<_> = modifiers.into_iter().collect(); + if modifiers.is_empty() { + return vec![]; + } + let mut edge_of = hashbrown::HashMap::with_capacity(error_reference.len()); + for (edge, error) in error_reference.iter().enumerate() { + edge_of.insert( + (error.eid, error.error_index), + u64::try_from(edge).expect("hyperedge index must fit in u64"), + ); + } + let mut overrides = hashbrown::HashMap::new(); + for (local_eid, modifier) in modifiers { + for (error_index, &probability) in modifier.probabilities.iter().enumerate() { + if let Some(&edge) = edge_of.get(&(local_eid, error_index)) { + overrides.insert(edge, probability); + } + } + for (&error_index, &probability) in modifier.sparse_indices.iter().zip(modifier.sparse_probabilities.iter()) { + if let Some(&edge) = edge_of.get(&(local_eid, error_index as usize)) { + overrides.insert(edge, probability); + } + } + } + let mut reweights: Vec<_> = overrides.into_iter().collect(); + reweights.sort_unstable_by_key(|&(edge, _)| edge); + reweights +} + +/// Materialize edge probability updates directly into a hypergraph. +pub(crate) fn apply_reweights(hypergraph: &mut blackbox_decoder::DecodingHypergraph, reweights: &[(u64, f64)]) { + for &(edge, probability) in reweights { + hypergraph.hyperedges[edge as usize].probability = probability; + } +} + +/// Select how shot-scoped edge updates reach a persistent decoder. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "cli", derive(structdoc::StructDoc))] +#[serde(rename_all = "snake_case")] +pub enum DecoderReweighting { + /// Use loaded reweights when the decoder advertises support; otherwise + /// materialize a temporary hypergraph for that shot. + #[default] + Auto, + /// Require the decoder to accept reweights alongside a loaded graph. + Enabled, + /// Always materialize a temporary hypergraph when a shot changes weights. + Disabled, +} + +impl DecoderReweighting { + pub(crate) fn use_loaded(self, persistent_decoder: bool, features: DecoderFeatures) -> Result { + if !persistent_decoder { + return match self { + Self::Enabled => Err( + "decoder_reweighting is enabled but persistent_decoder is disabled; loaded reweights cannot be used" + .to_string(), + ), + Self::Auto | Self::Disabled => Ok(false), + }; + } + match self { + Self::Auto => Ok(features.contains(DecoderFeatures::REWEIGHTS)), + Self::Enabled if features.contains(DecoderFeatures::REWEIGHTS) => Ok(true), + Self::Enabled => Err("decoder_reweighting is enabled but the decoder does not support reweights".to_string()), + Self::Disabled => Ok(false), + } + } +} + +/// A decoder loaded with one stable hypergraph and its shot-projection context. +#[derive(Debug, Clone)] +pub struct LoadedDecoder { + /// Backend handle returned when the stable deduplicated graph was loaded. + pub hid: u64, + /// One correction representative per decoder edge, used to interpret the + /// subgraph returned by the backend. These are post-deduplication errors; + /// [`DecodeProjection::base_errors`] retains the original edge list. + pub errors: Arc>, + /// Decoder-facing graph after edge deduplication. It is retained only when + /// a shot may need a materialized fallback graph or when parity-factor + /// assertions need the graph locally. + pub decoding_hypergraph: Option>, + /// History-boundary vertices with no incident decoder edge. Window + /// coordinators clear these syndrome bits instead of renumbering vertices; + /// monolithic coordinators leave this list empty. + pub ignored_syndrome_vertices: Arc>, + /// Stable original-edge context used on cache hits to translate each shot's + /// probability and loss updates into the loaded decoder's edge numbering. + /// Shared because cached [`LoadedDecoder`] values are cloned per shot. + pub projection: Arc, +} + +impl LoadedDecoder { + /// Apply the stable window-boundary syndrome projection for this graph. + pub(crate) fn project_syndrome(&self, mut syndrome: BitVector) -> BitVector { + for &vertex in self.ignored_syndrome_vertices.iter() { + crate::misc::bit_vector::set_bit(&mut syndrome, vertex, false); + } + syndrome + } +} + +/// Decode one projected shot, either by updating the already-loaded graph or +/// by materializing a one-shot graph when the backend lacks loaded reweights. +/// Structured loss accompanies the request in either path. +pub(crate) async fn decode_projected( + decoder: &DynDecoder, + loaded: &LoadedDecoder, + syndrome: BitVector, + reweights: Vec, + loss: Option, + use_loaded_reweights: bool, +) -> Result { + if reweights.is_empty() || use_loaded_reweights { + return decoder + .decode_loaded(blackbox_decoder::LoadedDecodingProblem { + hid: loaded.hid, + syndrome: Some(syndrome), + reweights, + loss, + }) + .await; + } + + // The backend cannot modify its loaded graph. Recreate that exact + // decoder-facing graph, apply this shot's updates, and use one-shot decode. + let mut hypergraph = (**loaded + .decoding_hypergraph + .as_ref() + .ok_or_else(|| Status::internal(format!("hid={} has no materializable hypergraph", loaded.hid)))?) + .clone(); + for reweight in reweights { + let hyperedge = hypergraph.hyperedges.get_mut(reweight.edge as usize).ok_or_else(|| { + Status::invalid_argument(format!( + "reweighted edge {} is outside loaded hypergraph hid={}", + reweight.edge, loaded.hid + )) + })?; + hyperedge.probability = reweight.probability; + } + decoder + .decode(blackbox_decoder::DecodingProblem { + hypergraph: Some(hypergraph), + syndrome: Some(syndrome), + loss, + }) + .await +} + +/// Stable context for projecting shot-scoped updates onto a loaded hypergraph. +/// +/// The base graph and error reference use original edge numbering. The private +/// edge projection translates that space into decoder edge numbering. The +/// decoder-facing graph and correction representatives are consumed separately +/// through [`PreparedDecoderInput`]. +#[derive(Debug)] +pub struct DecodeProjection { + /// Graph before same-syndrome edge deduplication and before any shot-scoped + /// probability or loss update. + pub base_hypergraph: blackbox_decoder::DecodingHypergraph, + /// Error-model generator represented by each edge of [`Self::base_hypergraph`]. + pub base_errors: Arc>, + /// Translation between original edge indices and decoder edge indices. + edge_projection: EdgeProjection, +} + +/// Transient decoder-space values produced while building a projection. +/// +/// The hypergraph moves into the decoder backend and the representatives move +/// into [`LoadedDecoder::errors`]; neither remains duplicated in +/// [`DecodeProjection`]. +#[derive(Debug)] +pub(crate) struct PreparedDecoderInput { + pub(crate) hypergraph: blackbox_decoder::DecodingHypergraph, + pub(crate) representatives: Vec, +} + +/// Bidirectional relationship between original and decoder edge numbering. +/// Identity projections allocate no mapping arrays. +#[derive(Debug)] +enum EdgeProjection { + Identity, + Merged { + decoder_edge_of_original: Vec, + original_edges_of_decoder: Vec>, + }, +} + +/// Collapse same-syndrome hyperedges while preserving the highest-prior +/// correction representative for each group. The supplied slices must be +/// edge-aligned in original numbering. +fn deduplicate_by_syndrome( + hypergraph: &blackbox_decoder::DecodingHypergraph, + errors: &[ErrorIndex], + priors: &[f64], +) -> (PreparedDecoderInput, EdgeProjection) { + let mut seen: hashbrown::HashMap, (usize, f64)> = hashbrown::HashMap::with_capacity(errors.len()); + let mut hyperedges: Vec = Vec::with_capacity(errors.len()); + let mut representatives = Vec::with_capacity(errors.len()); + let mut decoder_edge_of_original = Vec::with_capacity(errors.len()); + let mut original_edges_of_decoder: Vec> = Vec::with_capacity(errors.len()); + for (position, ((hyperedge, error), &prior)) in + hypergraph.hyperedges.iter().zip(errors.iter()).zip(priors.iter()).enumerate() + { + let mut syndrome = hyperedge.vertices.clone(); + syndrome.sort_unstable(); + debug_assert!({ + let degree = syndrome.len(); + syndrome.dedup(); + syndrome.len() == degree + }); + if let Some((index, best_prior)) = seen.get_mut(&syndrome) { + let combined = hyperedges[*index].probability; + hyperedges[*index].probability = exclusive_probability_of(combined, hyperedge.probability); + if prior > *best_prior { + *best_prior = prior; + representatives[*index] = error.clone(); + } + original_edges_of_decoder[*index].push(position); + decoder_edge_of_original.push(*index); + } else { + let index = representatives.len(); + hyperedges.push(blackbox_decoder::Hyperedge { + probability: hyperedge.probability, + vertices: syndrome.clone(), + }); + representatives.push(error.clone()); + original_edges_of_decoder.push(vec![position]); + decoder_edge_of_original.push(index); + seen.insert(syndrome, (index, prior)); + } + } + ( + PreparedDecoderInput { + hypergraph: blackbox_decoder::DecodingHypergraph { + vertex_num: hypergraph.vertex_num, + hyperedges, + }, + representatives, + }, + EdgeProjection::Merged { + decoder_edge_of_original, + original_edges_of_decoder, + }, + ) +} + +/// Deduplicate a one-shot graph whose edge mapping will not be cached. +pub(crate) fn deduplicate_decoder_input( + hypergraph: &blackbox_decoder::DecodingHypergraph, + errors: &[ErrorIndex], + priors: &[f64], +) -> PreparedDecoderInput { + deduplicate_by_syndrome(hypergraph, errors, priors).0 +} + +impl DecodeProjection { + #[cfg(test)] + pub(crate) fn identity( + base_hypergraph: blackbox_decoder::DecodingHypergraph, + base_errors: Arc>, + ) -> Self { + Self { + base_hypergraph, + base_errors, + edge_projection: EdgeProjection::Identity, + } + } + + /// Translate original-edge assignments into decoder-edge assignments. + pub(crate) fn translate_reweights(&self, reweights: &[(u64, f64)]) -> Vec<(u64, f64)> { + self.edge_projection.translate_reweights(&self.base_hypergraph, reweights) + } +} + +impl EdgeProjection { + fn translate_reweights( + &self, + base_hypergraph: &blackbox_decoder::DecodingHypergraph, + reweights: &[(u64, f64)], + ) -> Vec<(u64, f64)> { + match self { + Self::Identity => { + let mut overrides = hashbrown::HashMap::with_capacity(reweights.len()); + for &(edge, probability) in reweights { + overrides.insert(edge, probability); + } + let mut translated: Vec<_> = overrides.into_iter().collect(); + translated.sort_unstable_by_key(|&(edge, _)| edge); + translated + } + Self::Merged { + decoder_edge_of_original, + original_edges_of_decoder, + } => { + let mut overrides = hashbrown::HashMap::with_capacity(reweights.len()); + let mut affected = Vec::with_capacity(reweights.len()); + for &(edge, probability) in reweights { + let original = usize::try_from(edge).expect("edge index must fit in usize"); + overrides.insert(original, probability); + affected.push(decoder_edge_of_original[original]); + } + affected.sort_unstable(); + affected.dedup(); + affected + .into_iter() + .map(|decoder_edge| { + let combined = + original_edges_of_decoder[decoder_edge] + .iter() + .fold(0.0, |accumulated, &original_edge| { + let probability = overrides + .get(&original_edge) + .copied() + .unwrap_or(base_hypergraph.hyperedges[original_edge].probability); + exclusive_probability_of(accumulated, probability) + }); + (u64::try_from(decoder_edge).expect("edge index must fit in u64"), combined) + }) + .collect() + } + } + } +} + +#[cfg(test)] +#[path = "../../tests/unit/reweight_handler_test.rs"] +mod tests; From 6130a2cf4df60814f99acf9ba93301fd0b2fb473 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 10:53:18 -0700 Subject: [PATCH 135/157] simplify type conversion of indices --- deq/deq_decoder_abi/src/plugin.rs | 4 ++-- .../src/coordinator/monolithic_coordinator.rs | 2 +- .../src/coordinator/reweight_handler.rs | 9 +++------ .../src/coordinator/window_coordinator.rs | 2 +- deq/deq_runtime/src/jit/loss_compiler.rs | 16 ++++++---------- deq/deq_runtime/src/simulator/common.rs | 2 +- .../src/simulator/preselect_simulator.rs | 3 +-- 7 files changed, 15 insertions(+), 23 deletions(-) diff --git a/deq/deq_decoder_abi/src/plugin.rs b/deq/deq_decoder_abi/src/plugin.rs index 6b875460..431e7123 100644 --- a/deq/deq_decoder_abi/src/plugin.rs +++ b/deq/deq_decoder_abi/src/plugin.rs @@ -114,8 +114,8 @@ impl<'a> HypergraphView<'a> { } fn edge(&self, index: usize) -> (f64, &'a [u64]) { - let start = usize::try_from(self.edge_offsets[index]).expect("CSR offset exceeds usize"); - let end = usize::try_from(self.edge_offsets[index + 1]).expect("CSR offset exceeds usize"); + let start = usize::try_from(self.edge_offsets[index]).unwrap(); + let end = usize::try_from(self.edge_offsets[index + 1]).unwrap(); (self.edge_probs[index], &self.edge_vertices[start..end]) } diff --git a/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs index 44304cb8..61d6c136 100644 --- a/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs @@ -1466,7 +1466,7 @@ impl coordinator::coordinator_server::Coordinator for MonolithicCoordinator { coordinator::validate_outcomes( &outcome_data, outcomes.loss_mask.as_ref(), - u64::try_from(gadget_type.measurements.len()).expect("measurement count must fit in u64"), + u64::try_from(gadget_type.measurements.len()).unwrap(), ) .map_err(Status::invalid_argument)?; // Apply loss-random-imputation before storing the outcomes: every diff --git a/deq/deq_runtime/src/coordinator/reweight_handler.rs b/deq/deq_runtime/src/coordinator/reweight_handler.rs index bd06e905..0f3333bb 100644 --- a/deq/deq_runtime/src/coordinator/reweight_handler.rs +++ b/deq/deq_runtime/src/coordinator/reweight_handler.rs @@ -159,10 +159,7 @@ pub(crate) fn probability_reweights<'a>( } let mut edge_of = hashbrown::HashMap::with_capacity(error_reference.len()); for (edge, error) in error_reference.iter().enumerate() { - edge_of.insert( - (error.eid, error.error_index), - u64::try_from(edge).expect("hyperedge index must fit in u64"), - ); + edge_of.insert((error.eid, error.error_index), u64::try_from(edge).unwrap()); } let mut overrides = hashbrown::HashMap::new(); for (local_eid, modifier) in modifiers { @@ -453,7 +450,7 @@ impl EdgeProjection { let mut overrides = hashbrown::HashMap::with_capacity(reweights.len()); let mut affected = Vec::with_capacity(reweights.len()); for &(edge, probability) in reweights { - let original = usize::try_from(edge).expect("edge index must fit in usize"); + let original = usize::try_from(edge).unwrap(); overrides.insert(original, probability); affected.push(decoder_edge_of_original[original]); } @@ -472,7 +469,7 @@ impl EdgeProjection { .unwrap_or(base_hypergraph.hyperedges[original_edge].probability); exclusive_probability_of(accumulated, probability) }); - (u64::try_from(decoder_edge).expect("edge index must fit in u64"), combined) + (u64::try_from(decoder_edge).unwrap(), combined) }) .collect() } diff --git a/deq/deq_runtime/src/coordinator/window_coordinator.rs b/deq/deq_runtime/src/coordinator/window_coordinator.rs index 277536e5..f50fb8ca 100644 --- a/deq/deq_runtime/src/coordinator/window_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/window_coordinator.rs @@ -2732,7 +2732,7 @@ impl coordinator::coordinator_server::Coordinator for WindowCoordinator { coordinator::validate_outcomes( &outcome_data, outcomes.loss_mask.as_ref(), - u64::try_from(gadget_type.measurements.len()).expect("measurement count must fit in u64"), + u64::try_from(gadget_type.measurements.len()).unwrap(), ) .map_err(Status::invalid_argument)?; // Apply loss-random-imputation before storing the outcomes so diff --git a/deq/deq_runtime/src/jit/loss_compiler.rs b/deq/deq_runtime/src/jit/loss_compiler.rs index 50aeb458..b002ed3a 100644 --- a/deq/deq_runtime/src/jit/loss_compiler.rs +++ b/deq/deq_runtime/src/jit/loss_compiler.rs @@ -116,9 +116,9 @@ fn port_offsets( let mut running = 0usize; for port in ports { offsets.push(running); - running += port_types.get(&port.ptype).map_or(0, |port_type| { - usize::try_from(port_type.n).expect("port width must fit in usize") - }); + running += port_types + .get(&port.ptype) + .map_or(0, |port_type| usize::try_from(port_type.n).unwrap()); } offsets } @@ -286,13 +286,11 @@ fn emit_possible_sites(nodes: &[CrossGadgetNode], gadgets: &[GadgetLoss]) -> Vec .iter() .map(|node| { let observed = gadgets[node.gadget_index].observed; - let observed_size = usize::try_from(observed.size).expect("loss-mask size must fit in usize"); + let observed_size = usize::try_from(observed.size).unwrap(); let mut supported = false; let mut contradicted = false; for herald in &node.heralds { - if *herald < observed_size - && get_bit(observed, u64::try_from(*herald).expect("herald index originated as u64")) - { + if *herald < observed_size && get_bit(observed, u64::try_from(*herald).unwrap()) { supported = true; } else { contradicted = true; @@ -366,9 +364,7 @@ fn fold_evidence( } fn proto_indices(indices: &[u64]) -> impl Iterator + '_ { - indices - .iter() - .map(|&index| usize::try_from(index).expect("loss-model index must fit in usize")) + indices.iter().map(|&index| usize::try_from(index).unwrap()) } #[cfg(test)] diff --git a/deq/deq_runtime/src/simulator/common.rs b/deq/deq_runtime/src/simulator/common.rs index 818dd31b..77f824e9 100644 --- a/deq/deq_runtime/src/simulator/common.rs +++ b/deq/deq_runtime/src/simulator/common.rs @@ -389,7 +389,7 @@ pub fn load_stim_circuit( let circuit: stim::Circuit = stim_only_text .parse() .expect("Failed to parse Stim circuit for measurement counting"); - let expected = usize::try_from(circuit.num_measurements()).expect("Stim circuit measurement count exceeds usize"); + let expected = usize::try_from(circuit.num_measurements()).unwrap(); crate::simulator::stim_delays::extract_delay_schedule(&circuit_text, expected) }; let sampler: Box = if preselect_schedule.is_empty() { diff --git a/deq/deq_runtime/src/simulator/preselect_simulator.rs b/deq/deq_runtime/src/simulator/preselect_simulator.rs index fad7f33d..805e6e18 100644 --- a/deq/deq_runtime/src/simulator/preselect_simulator.rs +++ b/deq/deq_runtime/src/simulator/preselect_simulator.rs @@ -66,8 +66,7 @@ impl PreselectSimulator { let circuit: stim::Circuit = stim_only_text .parse() .expect("Failed to parse Stim circuit for measurement counting"); - let expected = - usize::try_from(circuit.num_measurements()).expect("Stim circuit measurement count exceeds usize"); + let expected = usize::try_from(circuit.num_measurements()).unwrap(); crate::simulator::stim_delays::extract_delay_schedule(&circuit_text, expected) }; From aa337e6c1c94113a9b939b5b64dde549b9cf51c0 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 11:29:32 -0700 Subject: [PATCH 136/157] move validation to misc --- deq/deq_runtime/src/coordinator.rs | 24 -- .../src/coordinator/monolithic_coordinator.rs | 4 +- .../src/coordinator/reweight_handler.rs | 31 -- .../src/coordinator/window_coordinator.rs | 5 +- deq/deq_runtime/src/decoder.rs | 2 - deq/deq_runtime/src/decoder/thread_pooling.rs | 4 +- deq/deq_runtime/src/decoder/validation.rs | 149 --------- deq/deq_runtime/src/misc/bit_vector.rs | 18 +- deq/deq_runtime/src/misc/mod.rs | 1 + deq/deq_runtime/src/misc/validation.rs | 299 ++++++++++++++++++ 10 files changed, 308 insertions(+), 229 deletions(-) delete mode 100644 deq/deq_runtime/src/decoder/validation.rs create mode 100644 deq/deq_runtime/src/misc/validation.rs diff --git a/deq/deq_runtime/src/coordinator.rs b/deq/deq_runtime/src/coordinator.rs index 4864a341..c56eaa64 100644 --- a/deq/deq_runtime/src/coordinator.rs +++ b/deq/deq_runtime/src/coordinator.rs @@ -19,30 +19,6 @@ include!("proto/deq.coordinator.rs"); #[cfg(feature = "cli")] use coordinator_server::CoordinatorServer; -pub fn validate_outcomes( - outcomes: &crate::util::BitVector, - loss_mask: Option<&crate::util::BitVector>, - expected_size: u64, -) -> Result<(), String> { - crate::misc::bit_vector::validate_data_len(outcomes, "outcomes")?; - if outcomes.size != expected_size { - return Err(format!( - "outcomes size {} does not match gadget measurement count {expected_size}", - outcomes.size - )); - } - if let Some(loss_mask) = loss_mask { - crate::misc::bit_vector::validate_data_len(loss_mask, "loss_mask")?; - if outcomes.size != loss_mask.size { - return Err(format!( - "loss_mask size {} does not match outcomes size {}", - loss_mask.size, outcomes.size - )); - } - } - Ok(()) -} - #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Debug)] #[cfg_attr(feature = "cli", derive(ValueEnum))] pub enum CoordinatorType { diff --git a/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs index 61d6c136..a7a01a46 100644 --- a/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs @@ -26,7 +26,6 @@ use crate::coordinator; use crate::coordinator::loss_handler::{RawLossSite, apply_loss_random_imputation, has_loss_model}; use crate::coordinator::reweight_handler::{ apply_reweights, decode_projected, deduplicate_decoder_input, load_projected_decoder, probability_reweights, - validate_probability_modifier, }; use crate::coordinator::{ DecoderCacheKey, DecoderReweighting, FingerprintSource, LoadedDecoder, LossHandler, LossStrategy, @@ -41,6 +40,7 @@ use crate::misc::pauli_frame_tracker::PauliFrameTracker; use crate::misc::relative_program::{self, RelativeMapping, RelativeProgram}; use crate::misc::sync::{TaskCounter, check_or_receiver, get_or_receiver, get_value}; use crate::misc::union_find::{UnionFindGeneric, UnionNodeTrait}; +use crate::misc::validation::{validate_outcomes, validate_probability_modifier}; use crate::util::BitVector; use binar::{BitVec, BitwiseMut}; use hashbrown::{HashMap, HashSet}; @@ -1463,7 +1463,7 @@ impl coordinator::coordinator_server::Coordinator for MonolithicCoordinator { let gadget_type = gadget_types .get(&gadget.instance.gtype) .ok_or_else(|| Status::failed_precondition(format!("gtype={} is not loaded", gadget.instance.gtype)))?; - coordinator::validate_outcomes( + validate_outcomes( &outcome_data, outcomes.loss_mask.as_ref(), u64::try_from(gadget_type.measurements.len()).unwrap(), diff --git a/deq/deq_runtime/src/coordinator/reweight_handler.rs b/deq/deq_runtime/src/coordinator/reweight_handler.rs index 0f3333bb..9d5fa098 100644 --- a/deq/deq_runtime/src/coordinator/reweight_handler.rs +++ b/deq/deq_runtime/src/coordinator/reweight_handler.rs @@ -112,37 +112,6 @@ pub(crate) fn ignore_edge_isolated_history_vertices( } } -/// Validate one gadget-local probability assignment before it is bound to a -/// concrete hypergraph. -pub(crate) fn validate_probability_modifier(modifier: &bin::ProbabilityModifier, error_count: usize) -> Result<(), String> { - if !modifier.probabilities.is_empty() && modifier.probabilities.len() != error_count { - return Err(format!( - "dense probability modifier has length {}, expected 0 or {error_count}", - modifier.probabilities.len() - )); - } - if modifier.sparse_indices.len() != modifier.sparse_probabilities.len() { - return Err(format!( - "sparse probability modifier has {} indices but {} probabilities", - modifier.sparse_indices.len(), - modifier.sparse_probabilities.len() - )); - } - for &index in &modifier.sparse_indices { - if index >= error_count as u64 { - return Err(format!( - "sparse probability modifier index {index} is outside [0, {error_count})" - )); - } - } - for &probability in modifier.probabilities.iter().chain(modifier.sparse_probabilities.iter()) { - if !probability.is_finite() || !(0.0..=1.0).contains(&probability) { - return Err(format!("probability modifier value must lie in [0, 1], got {probability}")); - } - } - Ok(()) -} - /// Convert gadget-local `(error model, generator)` assignments into updates in /// the original decoding hypergraph's edge numbering. /// diff --git a/deq/deq_runtime/src/coordinator/window_coordinator.rs b/deq/deq_runtime/src/coordinator/window_coordinator.rs index f50fb8ca..e7827ef7 100644 --- a/deq/deq_runtime/src/coordinator/window_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/window_coordinator.rs @@ -85,7 +85,7 @@ use crate::coordinator; use crate::coordinator::loss_handler::{RawLossSite, apply_loss_random_imputation, has_loss_model}; use crate::coordinator::reweight_handler::{ apply_reweights, decode_projected, deduplicate_decoder_input, ignore_edge_isolated_history_vertices, - load_projected_decoder, probability_reweights, validate_probability_modifier, + load_projected_decoder, probability_reweights, }; use crate::coordinator::{ DecoderCacheKey, DecoderReweighting, FingerprintSource, LoadedDecoder, LossHandler, LossStrategy, @@ -100,6 +100,7 @@ use crate::misc::index::{ErrorIndex, WILDCARD}; use crate::misc::pauli_frame_tracker::PauliFrameTracker; use crate::misc::relative_program::{self, RelativeMapping, RelativeProgram}; use crate::misc::sync::{TaskCounter, check_or_receiver, get_or_receiver}; +use crate::misc::validation::{validate_outcomes, validate_probability_modifier}; use crate::util::BitVector; use binar::{BitVec, BitwiseMut}; use hashbrown::{HashMap, HashSet}; @@ -2729,7 +2730,7 @@ impl coordinator::coordinator_server::Coordinator for WindowCoordinator { let gadget_type = gadget_types .get(&gadget.instance.gtype) .ok_or_else(|| Status::failed_precondition(format!("gtype={} is not loaded", gadget.instance.gtype)))?; - coordinator::validate_outcomes( + validate_outcomes( &outcome_data, outcomes.loss_mask.as_ref(), u64::try_from(gadget_type.measurements.len()).unwrap(), diff --git a/deq/deq_runtime/src/decoder.rs b/deq/deq_runtime/src/decoder.rs index 84481585..e727d375 100644 --- a/deq/deq_runtime/src/decoder.rs +++ b/deq/deq_runtime/src/decoder.rs @@ -71,8 +71,6 @@ pub mod mock_decoder; pub mod test_harness; pub mod test_problems; pub mod thread_pooling; -#[cfg(debug_assertions)] -pub(crate) mod validation; pub mod naive_decoder; pub use mock_decoder::MockDecoder; diff --git a/deq/deq_runtime/src/decoder/thread_pooling.rs b/deq/deq_runtime/src/decoder/thread_pooling.rs index 564ee3e2..361eef5d 100644 --- a/deq/deq_runtime/src/decoder/thread_pooling.rs +++ b/deq/deq_runtime/src/decoder/thread_pooling.rs @@ -3,9 +3,9 @@ use crate::decoder::blackbox_decoder::{self, ParityFactor, black_box_decoder_server}; pub use crate::decoder::decoder_features::DecoderFeatures; -#[cfg(debug_assertions)] -use crate::decoder::validation; use crate::misc::bit_vector; +#[cfg(debug_assertions)] +use crate::misc::validation; use crate::util::BitVector; use blackbox_decoder::DecodingHypergraph; use hashbrown::HashMap; diff --git a/deq/deq_runtime/src/decoder/validation.rs b/deq/deq_runtime/src/decoder/validation.rs deleted file mode 100644 index 15ee0c75..00000000 --- a/deq/deq_runtime/src/decoder/validation.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! Validation of black-box decoder protocol messages. - -use crate::decoder::blackbox_decoder::{DecodingHypergraph, LossInfo, ParityFactor}; -use crate::util::BitVector; - -pub(crate) fn validate_parity_factor(parity_factor: ParityFactor, edge_count: usize) -> Result { - if let Some(&edge) = parity_factor - .subgraph - .iter() - .find(|&&edge| usize::try_from(edge).map_or(true, |edge| edge >= edge_count)) - { - return Err(format!( - "decoder returned edge {edge}, but the hypergraph has {edge_count} edges" - )); - } - Ok(parity_factor) -} - -pub(crate) fn validate_hypergraph(hypergraph: &DecodingHypergraph) -> Result<(), String> { - for (edge, hyperedge) in hypergraph.hyperedges.iter().enumerate() { - if !hyperedge.probability.is_finite() || !(0.0..=1.0).contains(&hyperedge.probability) { - return Err(format!( - "hyperedge {edge} probability must lie in [0, 1], got {}", - hyperedge.probability - )); - } - let mut vertices = hashbrown::HashSet::with_capacity(hyperedge.vertices.len()); - for &vertex in &hyperedge.vertices { - if vertex >= hypergraph.vertex_num { - return Err(format!( - "hyperedge {edge} contains vertex {vertex}, outside [0, {})", - hypergraph.vertex_num - )); - } - if !vertices.insert(vertex) { - return Err(format!("hyperedge {edge} contains vertex {vertex} more than once")); - } - } - } - Ok(()) -} - -pub(crate) fn validate_syndrome(syndrome: &BitVector, vertex_num: u64) -> Result<(), String> { - crate::misc::bit_vector::validate_data_len(syndrome, "syndrome")?; - if syndrome.size != vertex_num { - return Err(format!( - "syndrome size {} does not match hypergraph vertex count {vertex_num}", - syndrome.size - )); - } - Ok(()) -} - -pub(crate) fn validate_reweights(reweights: &[(u64, f64)], edge_count: usize) -> Result<(), String> { - let mut seen = hashbrown::HashSet::with_capacity(reweights.len()); - for &(edge, probability) in reweights { - if usize::try_from(edge).map_or(true, |edge| edge >= edge_count) { - return Err(format!( - "reweighted edge {edge} is outside loaded hypergraph with {edge_count} edges" - )); - } - if !probability.is_finite() || !(0.0..=1.0).contains(&probability) { - return Err(format!( - "reweighted edge {edge} probability must lie in [0, 1], got {probability}" - )); - } - if !seen.insert(edge) { - return Err(format!("reweighted edge {edge} is assigned more than once")); - } - } - Ok(()) -} - -pub(crate) fn validate_loss(loss: Option<&LossInfo>, edge_count: usize) -> Result<(), String> { - let Some(loss) = loss else { - return Ok(()); - }; - let mut indegree = vec![0usize; loss.sites.len()]; - let mut children = vec![Vec::new(); loss.sites.len()]; - for (site_index, site) in loss.sites.iter().enumerate() { - if !site.probability.is_finite() || !(0.0..=1.0).contains(&site.probability) { - return Err(format!( - "loss site {site_index} probability must lie in [0, 1], got {}", - site.probability - )); - } - for (field, edges) in [ - ("source_edges", &site.source_edges), - ("continuation_edges", &site.continuation_edges), - ] { - let mut seen = hashbrown::HashSet::with_capacity(edges.len()); - for &edge in edges { - if usize::try_from(edge).map_or(true, |edge| edge >= edge_count) { - return Err(format!( - "loss site {site_index} {field} contains edge {edge}, outside [0, {edge_count})" - )); - } - if !seen.insert(edge) { - return Err(format!("loss site {site_index} {field} contains edge {edge} more than once")); - } - } - } - let mut seen_children = hashbrown::HashSet::with_capacity(site.children.len()); - for &child in &site.children { - let Ok(child_index) = usize::try_from(child) else { - return Err(format!( - "loss site {site_index} contains child {child}, outside [0, {})", - loss.sites.len() - )); - }; - let Some(degree) = indegree.get_mut(child_index) else { - return Err(format!( - "loss site {site_index} contains child {child}, outside [0, {})", - loss.sites.len() - )); - }; - if !seen_children.insert(child_index) { - return Err(format!("loss site {site_index} contains child {child} more than once")); - } - *degree += 1; - children[site_index].push(child_index); - } - let mut seen_heralds = hashbrown::HashSet::with_capacity(site.heralds.len()); - for &herald in &site.heralds { - if !seen_heralds.insert(herald) { - return Err(format!("loss site {site_index} contains herald {herald} more than once")); - } - } - } - let mut frontier: Vec = indegree - .iter() - .enumerate() - .filter_map(|(site, °ree)| (degree == 0).then_some(site)) - .collect(); - let mut visited = 0usize; - while let Some(site_index) = frontier.pop() { - visited += 1; - for &child_index in &children[site_index] { - indegree[child_index] -= 1; - if indegree[child_index] == 0 { - frontier.push(child_index); - } - } - } - if visited != loss.sites.len() { - return Err("loss site children graph contains a cycle".to_string()); - } - Ok(()) -} diff --git a/deq/deq_runtime/src/misc/bit_vector.rs b/deq/deq_runtime/src/misc/bit_vector.rs index 5f3b4a2d..bd9642f5 100644 --- a/deq/deq_runtime/src/misc/bit_vector.rs +++ b/deq/deq_runtime/src/misc/bit_vector.rs @@ -45,22 +45,6 @@ pub fn bit_vector_len(size: u64) -> usize { size.div_ceil(8) as usize // (size + 7) / 8 } -/// Validate that `bit_vector.data` has exactly the number of bytes required -/// for `bit_vector.size` bits. Returns `Ok(())` on success, or a descriptive -/// error string on failure. -pub fn validate_data_len(bit_vector: &BitVector, name: &str) -> Result<(), String> { - let required = bit_vector_len(bit_vector.size); - if bit_vector.data.len() != required { - Err(format!( - "{name} data length ({}) does not match required length ({required}) for {} bits", - bit_vector.data.len(), - bit_vector.size - )) - } else { - Ok(()) - } -} - pub fn from_sparse_indices(size: u64, indices: &[u64]) -> BitVector { let mut data = vec![0u8; bit_vector_len(size)]; for &idx in indices { @@ -83,7 +67,7 @@ pub fn to_sparse_indices(bit_vector: &BitVector) -> Vec { /// Return whether every meaningful bit is zero, ignoring unused padding bits in /// the final byte. pub fn is_zero(bit_vector: &BitVector) -> bool { - debug_assert!(validate_data_len(bit_vector, "bit vector").is_ok()); + debug_assert!(crate::misc::validation::validate_data_len(bit_vector, "bit vector").is_ok()); let full_bytes = (bit_vector.size / 8) as usize; if bit_vector.data[..full_bytes].iter().any(|&byte| byte != 0) { return false; diff --git a/deq/deq_runtime/src/misc/mod.rs b/deq/deq_runtime/src/misc/mod.rs index 8475a6f6..e672abcc 100644 --- a/deq/deq_runtime/src/misc/mod.rs +++ b/deq/deq_runtime/src/misc/mod.rs @@ -11,3 +11,4 @@ pub mod relative_program; pub mod sync; pub mod union_find; pub mod util; +pub mod validation; diff --git a/deq/deq_runtime/src/misc/validation.rs b/deq/deq_runtime/src/misc/validation.rs new file mode 100644 index 00000000..c2f2c9e3 --- /dev/null +++ b/deq/deq_runtime/src/misc/validation.rs @@ -0,0 +1,299 @@ +//! Validation of runtime protocol and coordinator payloads. + +use crate::bin::{GadgetType, ProbabilityModifier}; +use crate::coordinator::loss_handler::LossHandler; +use crate::decoder::decoder_features::DecoderFeatures; +use crate::util::BitVector; +use tonic::Status; + +/// Validate that a bit vector has exactly the bytes required by its declared +/// number of bits. +pub fn validate_data_len(bit_vector: &BitVector, name: &str) -> Result<(), String> { + let required = crate::misc::bit_vector::bit_vector_len(bit_vector.size); + if bit_vector.data.len() != required { + Err(format!( + "{name} data length ({}) does not match required length ({required}) for {} bits", + bit_vector.data.len(), + bit_vector.size + )) + } else { + Ok(()) + } +} + +pub fn validate_outcomes(outcomes: &BitVector, loss_mask: Option<&BitVector>, expected_size: u64) -> Result<(), String> { + validate_data_len(outcomes, "outcomes")?; + if outcomes.size != expected_size { + return Err(format!( + "outcomes size {} does not match gadget measurement count {expected_size}", + outcomes.size + )); + } + if let Some(loss_mask) = loss_mask { + validate_data_len(loss_mask, "loss_mask")?; + if outcomes.size != loss_mask.size { + return Err(format!( + "loss_mask size {} does not match outcomes size {}", + loss_mask.size, outcomes.size + )); + } + } + Ok(()) +} + +/// Validate the shape, indices, and values of a probability modifier for an +/// error model with `error_count` errors. +pub fn validate_probability_modifier(modifier: &ProbabilityModifier, error_count: usize) -> Result<(), String> { + if !modifier.probabilities.is_empty() && modifier.probabilities.len() != error_count { + return Err(format!( + "dense probability modifier has length {}, expected 0 or {error_count}", + modifier.probabilities.len() + )); + } + if modifier.sparse_indices.len() != modifier.sparse_probabilities.len() { + return Err(format!( + "sparse probability modifier has {} indices but {} probabilities", + modifier.sparse_indices.len(), + modifier.sparse_probabilities.len() + )); + } + for &index in &modifier.sparse_indices { + if index >= error_count as u64 { + return Err(format!( + "sparse probability modifier index {index} is outside [0, {error_count})" + )); + } + } + for &probability in modifier.probabilities.iter().chain(modifier.sparse_probabilities.iter()) { + if !probability.is_finite() || !(0.0..=1.0).contains(&probability) { + return Err(format!("probability modifier value must lie in [0, 1], got {probability}")); + } + } + Ok(()) +} + +impl LossHandler { + /// Validate that the decoder capabilities support this loss strategy for + /// the supplied gadget library. + pub fn validate_capability(self, gadget_types: &[GadgetType], features: DecoderFeatures) -> Result<(), Status> { + if self.hands_off_to_decoder() + && gadget_types.iter().any(|gadget_type| gadget_type.loss_model.is_some()) + && !features.contains(DecoderFeatures::LOSS) + { + return Err(Status::failed_precondition( + "loss_strategy handoff requires a decoder with structured loss support for this library", + )); + } + Ok(()) + } +} + +use crate::decoder::blackbox_decoder::{DecodingHypergraph, LossInfo, ParityFactor}; + +/// Validate that every edge returned by a decoder belongs to its hypergraph. +pub fn validate_parity_factor(parity_factor: ParityFactor, edge_count: usize) -> Result { + if let Some(&edge) = parity_factor + .subgraph + .iter() + .find(|&&edge| usize::try_from(edge).map_or(true, |edge| edge >= edge_count)) + { + return Err(format!( + "decoder returned edge {edge}, but the hypergraph has {edge_count} edges" + )); + } + Ok(parity_factor) +} + +/// Validate hyperedge probabilities and vertex references. +pub fn validate_hypergraph(hypergraph: &DecodingHypergraph) -> Result<(), String> { + for (edge, hyperedge) in hypergraph.hyperedges.iter().enumerate() { + if !hyperedge.probability.is_finite() || !(0.0..=1.0).contains(&hyperedge.probability) { + return Err(format!( + "hyperedge {edge} probability must lie in [0, 1], got {}", + hyperedge.probability + )); + } + let mut vertices = hashbrown::HashSet::with_capacity(hyperedge.vertices.len()); + for &vertex in &hyperedge.vertices { + if vertex >= hypergraph.vertex_num { + return Err(format!( + "hyperedge {edge} contains vertex {vertex}, outside [0, {})", + hypergraph.vertex_num + )); + } + if !vertices.insert(vertex) { + return Err(format!("hyperedge {edge} contains vertex {vertex} more than once")); + } + } + } + Ok(()) +} + +/// Validate a syndrome's storage and size against a hypergraph vertex count. +pub fn validate_syndrome(syndrome: &BitVector, vertex_num: u64) -> Result<(), String> { + validate_data_len(syndrome, "syndrome")?; + if syndrome.size != vertex_num { + return Err(format!( + "syndrome size {} does not match hypergraph vertex count {vertex_num}", + syndrome.size + )); + } + Ok(()) +} + +/// Validate loaded-graph reweight indices, probabilities, and uniqueness. +pub fn validate_reweights(reweights: &[(u64, f64)], edge_count: usize) -> Result<(), String> { + let mut seen = hashbrown::HashSet::with_capacity(reweights.len()); + for &(edge, probability) in reweights { + if usize::try_from(edge).map_or(true, |edge| edge >= edge_count) { + return Err(format!( + "reweighted edge {edge} is outside loaded hypergraph with {edge_count} edges" + )); + } + if !probability.is_finite() || !(0.0..=1.0).contains(&probability) { + return Err(format!( + "reweighted edge {edge} probability must lie in [0, 1], got {probability}" + )); + } + if !seen.insert(edge) { + return Err(format!("reweighted edge {edge} is assigned more than once")); + } + } + Ok(()) +} + +/// Validate structured loss sites, references, uniqueness, and acyclicity. +pub fn validate_loss(loss: Option<&LossInfo>, edge_count: usize) -> Result<(), String> { + let Some(loss) = loss else { + return Ok(()); + }; + let mut indegree = vec![0usize; loss.sites.len()]; + let mut children = vec![Vec::new(); loss.sites.len()]; + for (site_index, site) in loss.sites.iter().enumerate() { + if !site.probability.is_finite() || !(0.0..=1.0).contains(&site.probability) { + return Err(format!( + "loss site {site_index} probability must lie in [0, 1], got {}", + site.probability + )); + } + for (field, edges) in [ + ("source_edges", &site.source_edges), + ("continuation_edges", &site.continuation_edges), + ] { + let mut seen = hashbrown::HashSet::with_capacity(edges.len()); + for &edge in edges { + if usize::try_from(edge).map_or(true, |edge| edge >= edge_count) { + return Err(format!( + "loss site {site_index} {field} contains edge {edge}, outside [0, {edge_count})" + )); + } + if !seen.insert(edge) { + return Err(format!("loss site {site_index} {field} contains edge {edge} more than once")); + } + } + } + let mut seen_children = hashbrown::HashSet::with_capacity(site.children.len()); + for &child in &site.children { + let Ok(child_index) = usize::try_from(child) else { + return Err(format!( + "loss site {site_index} contains child {child}, outside [0, {})", + loss.sites.len() + )); + }; + let Some(degree) = indegree.get_mut(child_index) else { + return Err(format!( + "loss site {site_index} contains child {child}, outside [0, {})", + loss.sites.len() + )); + }; + if !seen_children.insert(child_index) { + return Err(format!("loss site {site_index} contains child {child} more than once")); + } + *degree += 1; + children[site_index].push(child_index); + } + let mut seen_heralds = hashbrown::HashSet::with_capacity(site.heralds.len()); + for &herald in &site.heralds { + if !seen_heralds.insert(herald) { + return Err(format!("loss site {site_index} contains herald {herald} more than once")); + } + } + } + let mut frontier: Vec = indegree + .iter() + .enumerate() + .filter_map(|(site, °ree)| (degree == 0).then_some(site)) + .collect(); + let mut visited = 0usize; + while let Some(site_index) = frontier.pop() { + visited += 1; + for &child_index in &children[site_index] { + indegree[child_index] -= 1; + if indegree[child_index] == 0 { + frontier.push(child_index); + } + } + } + if visited != loss.sites.len() { + return Err("loss site children graph contains a cycle".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn probability_modifier_validation_rejects_malformed_inputs() { + let dense_wrong_length = ProbabilityModifier { + probabilities: vec![0.1], + ..Default::default() + }; + assert!(validate_probability_modifier(&dense_wrong_length, 2).is_err()); + + let sparse_length_mismatch = ProbabilityModifier { + sparse_indices: vec![0, 1], + sparse_probabilities: vec![0.2], + ..Default::default() + }; + assert!(validate_probability_modifier(&sparse_length_mismatch, 2).is_err()); + + let sparse_out_of_range = ProbabilityModifier { + sparse_indices: vec![2], + sparse_probabilities: vec![0.2], + ..Default::default() + }; + assert!(validate_probability_modifier(&sparse_out_of_range, 2).is_err()); + + let invalid_probability = ProbabilityModifier { + probabilities: vec![0.1, f64::NAN], + ..Default::default() + }; + assert!(validate_probability_modifier(&invalid_probability, 2).is_err()); + } + + #[test] + fn handoff_requires_loss_support_only_for_loss_bearing_libraries() { + assert!( + LossHandler::Handoff + .validate_capability(&[GadgetType::default()], DecoderFeatures::empty()) + .is_ok() + ); + let loss_bearing = GadgetType { + loss_model: Some(crate::bin::gadget_type::LossModel::default()), + ..Default::default() + }; + + let error = LossHandler::Handoff + .validate_capability(std::slice::from_ref(&loss_bearing), DecoderFeatures::empty()) + .unwrap_err(); + + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!( + LossHandler::Handoff + .validate_capability(&[loss_bearing], DecoderFeatures::LOSS) + .is_ok() + ); + } +} From 4933fc36b6eeab42af793347056d2330e1ef83be Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 11:37:37 -0700 Subject: [PATCH 137/157] add reweight handler test --- .../tests/unit/reweight_handler_test.rs | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 deq/deq_runtime/tests/unit/reweight_handler_test.rs diff --git a/deq/deq_runtime/tests/unit/reweight_handler_test.rs b/deq/deq_runtime/tests/unit/reweight_handler_test.rs new file mode 100644 index 00000000..7287dc28 --- /dev/null +++ b/deq/deq_runtime/tests/unit/reweight_handler_test.rs @@ -0,0 +1,277 @@ +//! Unit tests for the coordinator reweight-handling pass. + +use super::*; + +#[test] +fn sparse_probability_values_override_dense_values() { + let errors = vec![ErrorIndex { eid: 4, error_index: 0 }, ErrorIndex { eid: 4, error_index: 1 }]; + let modifier = bin::ProbabilityModifier { + probabilities: vec![0.1, 0.2], + sparse_indices: vec![0], + sparse_probabilities: vec![0.3], + }; + + assert_eq!(probability_reweights(&errors, [(4, &modifier)]), vec![(0, 0.3), (1, 0.2)]); +} + +#[test] +fn decoder_reweighting_policy_resolves_only_transport() { + assert!(!DecoderReweighting::Auto.use_loaded(true, DecoderFeatures::empty()).unwrap()); + assert!(DecoderReweighting::Auto.use_loaded(true, DecoderFeatures::REWEIGHTS).unwrap()); + assert!( + !DecoderReweighting::Disabled + .use_loaded(true, DecoderFeatures::REWEIGHTS) + .unwrap() + ); + assert!( + DecoderReweighting::Enabled + .use_loaded(true, DecoderFeatures::empty()) + .is_err() + ); + assert!( + DecoderReweighting::Enabled + .use_loaded(true, DecoderFeatures::REWEIGHTS) + .unwrap() + ); + assert!( + !DecoderReweighting::Auto + .use_loaded(false, DecoderFeatures::REWEIGHTS) + .unwrap() + ); + assert!( + DecoderReweighting::Enabled + .use_loaded(false, DecoderFeatures::REWEIGHTS) + .is_err() + ); +} + +async fn loaded_decoder_for_test(mock: &Arc) -> (DynDecoder, LoadedDecoder) { + let hypergraph = blackbox_decoder::DecodingHypergraph { + vertex_num: 1, + hyperedges: vec![blackbox_decoder::Hyperedge { + vertices: vec![0], + probability: 0.1, + }], + }; + let decoder = DynDecoder::Mock(mock.clone()); + let errors = Arc::new(vec![ErrorIndex { eid: 0, error_index: 0 }]); + let loaded = load_projected_decoder(&decoder, hypergraph, errors, &[0.1], false, true, false) + .await + .unwrap(); + (decoder, loaded) +} + +fn test_loss_info() -> blackbox_decoder::LossInfo { + blackbox_decoder::LossInfo { + sites: vec![blackbox_decoder::LossSite { + source_edges: vec![0], + probability: 0.2, + heralds: vec![5], + ..Default::default() + }], + } +} + +#[tokio::test] +async fn projected_decode_sends_reweights_and_loss_together_when_supported() { + let mock = Arc::new(crate::decoder::MockDecoder::new()); + let (client, loaded) = loaded_decoder_for_test(&mock).await; + let loss = test_loss_info(); + decode_projected( + &client, + &loaded, + BitVector { + size: 1, + data: vec![0b1000_0000], + }, + vec![blackbox_decoder::EdgeReweight { + edge: 0, + probability: 0.3, + }], + Some(loss.clone()), + true, + ) + .await + .unwrap(); + + let state = mock.state.read().await; + assert_eq!(state.decode_loaded_calls[0].reweights[0].probability, 0.3); + assert_eq!(state.decode_loaded_calls[0].loss, Some(loss)); + assert!(state.decode_calls.is_empty()); +} + +#[tokio::test] +async fn projected_decode_materializes_reweights_without_dropping_loss() { + let mock = Arc::new(crate::decoder::MockDecoder::with_features(DecoderFeatures::LOSS)); + let (client, loaded) = loaded_decoder_for_test(&mock).await; + let loss = test_loss_info(); + decode_projected( + &client, + &loaded, + BitVector { + size: 1, + data: vec![0b1000_0000], + }, + vec![blackbox_decoder::EdgeReweight { + edge: 0, + probability: 0.3, + }], + Some(loss.clone()), + false, + ) + .await + .unwrap(); + + let state = mock.state.read().await; + assert!((state.decode_calls[0].hypergraph.hyperedges[0].probability - 0.3).abs() < 1e-12); + assert_eq!(state.decode_calls[0].loss, Some(loss)); + assert!(state.decode_loaded_calls.is_empty()); +} + +#[tokio::test] +async fn loaded_projection_zeros_isolated_vertices_without_renumbering() { + let mock = Arc::new(crate::decoder::MockDecoder::new()); + let decoder = DynDecoder::Mock(Arc::clone(&mock)); + let hypergraph = blackbox_decoder::DecodingHypergraph { + vertex_num: 2, + hyperedges: vec![blackbox_decoder::Hyperedge { + vertices: vec![0], + probability: 0.1, + }], + }; + let errors = Arc::new(vec![ErrorIndex { eid: 0, error_index: 0 }]); + + let loaded = load_projected_decoder(&decoder, hypergraph, errors, &[0.1], false, true, true) + .await + .unwrap(); + + assert_eq!(loaded.decoding_hypergraph.as_ref().unwrap().vertex_num, 2); + assert_eq!(loaded.ignored_syndrome_vertices.as_slice(), &[1]); + let projected = loaded.project_syndrome(BitVector { + size: 2, + data: vec![0b1100_0000], + }); + assert_eq!(projected.size, 2); + assert_eq!(projected.data, vec![0b1000_0000]); + assert_eq!(mock.state.read().await.loaded_hypergraphs[&loaded.hid].vertex_num, 2); +} + +#[test] +fn deduplication_keeps_the_highest_prior_correction_not_the_reweighted_one() { + let hypergraph = blackbox_decoder::DecodingHypergraph { + vertex_num: 3, + hyperedges: vec![ + blackbox_decoder::Hyperedge { + probability: 0.31, + vertices: vec![1, 0], + }, + blackbox_decoder::Hyperedge { + probability: 0.35, + vertices: vec![0, 1], + }, + blackbox_decoder::Hyperedge { + probability: 0.02, + vertices: vec![2], + }, + ], + }; + let errors = vec![ + ErrorIndex { eid: 0, error_index: 7 }, + ErrorIndex { eid: 0, error_index: 99 }, + ErrorIndex { eid: 0, error_index: 5 }, + ]; + + let (deduplicated, _) = deduplicate_by_syndrome(&hypergraph, &errors, &[3.7e-4, 0.0, 0.02]); + + assert_eq!(deduplicated.hypergraph.hyperedges.len(), 2); + assert_eq!(deduplicated.hypergraph.hyperedges[0].vertices, vec![0, 1]); + assert_eq!(deduplicated.representatives[0], ErrorIndex { eid: 0, error_index: 7 }); + let combined = 0.31 + 0.35 - 2.0 * 0.31 * 0.35; + assert!((deduplicated.hypergraph.hyperedges[0].probability - combined).abs() < 1e-12); +} + +#[test] +fn deduplication_is_the_identity_when_every_syndrome_is_distinct() { + let hypergraph = blackbox_decoder::DecodingHypergraph { + vertex_num: 2, + hyperedges: vec![ + blackbox_decoder::Hyperedge { + probability: 0.1, + vertices: vec![0], + }, + blackbox_decoder::Hyperedge { + probability: 0.2, + vertices: vec![1], + }, + ], + }; + let errors = vec![ErrorIndex { eid: 0, error_index: 0 }, ErrorIndex { eid: 0, error_index: 1 }]; + let (deduplicated, _) = deduplicate_by_syndrome(&hypergraph, &errors, &[0.1, 0.2]); + assert_eq!(deduplicated.hypergraph.hyperedges.len(), 2); + assert_eq!(deduplicated.representatives, errors); +} + +#[test] +fn identity_grouping_matches_deduplicating_a_collision_free_graph() { + let hypergraph = blackbox_decoder::DecodingHypergraph { + vertex_num: 3, + hyperedges: vec![ + blackbox_decoder::Hyperedge { + probability: 0.1, + vertices: vec![0], + }, + blackbox_decoder::Hyperedge { + probability: 0.2, + vertices: vec![1], + }, + blackbox_decoder::Hyperedge { + probability: 0.0, + vertices: vec![2], + }, + ], + }; + let errors = vec![ + ErrorIndex { eid: 0, error_index: 0 }, + ErrorIndex { eid: 0, error_index: 1 }, + ErrorIndex { eid: 1, error_index: 0 }, + ]; + let priors = [0.1, 0.2, 0.0]; + let (identity_projection, identity) = prepare_decoder(hypergraph.clone(), Arc::new(errors.clone()), &priors, false); + let (collapsed_projection, collapsed) = prepare_decoder(hypergraph, Arc::new(errors.clone()), &priors, true); + assert_eq!(identity.hypergraph, collapsed.hypergraph); + assert_eq!(identity.representatives, collapsed.representatives); + let reweights = [(0, 0.15), (2, 0.3)]; + assert_eq!( + identity_projection.translate_reweights(&reweights), + collapsed_projection.translate_reweights(&reweights) + ); +} + +#[test] +fn translated_reweights_match_deduplicating_an_already_reweighted_graph() { + let priors = vec![3.7e-4, 3.7e-4, 0.0, 0.02]; + let vertices = [vec![0, 1], vec![1, 0], vec![0, 1], vec![2]]; + let errors: Vec = (0..4).map(|error_index| ErrorIndex { eid: 0, error_index }).collect(); + let base = blackbox_decoder::DecodingHypergraph { + vertex_num: 3, + hyperedges: priors + .iter() + .zip(vertices.iter()) + .map(|(&probability, vertex_set)| blackbox_decoder::Hyperedge { + probability, + vertices: vertex_set.clone(), + }) + .collect(), + }; + let reweights = vec![(2u64, 0.31)]; + let (projection, _) = prepare_decoder(base.clone(), Arc::new(errors.clone()), &priors, true); + let translated = projection.translate_reweights(&reweights); + let mut reweighted = base.clone(); + apply_reweights(&mut reweighted, &reweights); + let (expected, _) = deduplicate_by_syndrome(&reweighted, &errors, &priors); + + assert_eq!(translated.len(), 1); + let (edge, probability) = translated[0]; + assert!((probability - expected.hypergraph.hyperedges[edge as usize].probability).abs() < 1e-12); + assert!(translated.iter().all(|&(index, _)| index != 1)); +} From 82eab4b3aa9bcd688565c0869dac041a5bb35784 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 12:55:03 -0700 Subject: [PATCH 138/157] reweight always respect latest probabilities --- .../src/coordinator/monolithic_coordinator.rs | 32 ++--- .../src/coordinator/reweight_handler.rs | 130 ++++++++++-------- .../src/coordinator/window_coordinator.rs | 32 ++--- .../tests/unit/reweight_handler_test.rs | 111 +++++++++++++-- 4 files changed, 190 insertions(+), 115 deletions(-) diff --git a/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs index a7a01a46..62783cfa 100644 --- a/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs @@ -587,15 +587,15 @@ impl MonolithicCoordinator { if let Some(loaded) = loaded { let probability_reweights = Self::shot_probability_reweights(mapping, gadgets, &loaded.projection.base_errors); - let (reweights, loss) = - self.loss_handler - .project_shot(&loaded.projection, &probability_reweights, &loss_sites); + let projected = self + .loss_handler + .project_shot(&loaded.projection, &probability_reweights, &loss_sites); let parity_factor = decode_projected( &self.decoder, &loaded, syndrome.clone(), - reweights, - loss, + projected.reweights, + projected.loss, self.use_loaded_reweights, ) .await @@ -603,7 +603,7 @@ impl MonolithicCoordinator { if self.config.assert_parity_factor { assert_parity_factor(loaded.decoding_hypergraph.as_ref().unwrap(), &parity_factor, &syndrome); } - return (parity_factor, loaded.errors.clone()); + return (parity_factor, projected.errors); } } @@ -613,12 +613,6 @@ impl MonolithicCoordinator { .decoding_hypergraph(relative_program, mapping, check_models, error_models) .await; - // Priors, captured before loss reweighting rewrites them. Merging picks its - // representative from these rather than from the reweighted values: a - // reweighted probability is a decoding weight, not a calibrated posterior, - // and letting it arbitrate corrections lets the loss heuristic decide - // logical outcomes. - let priors: Vec = decoding_hypergraph.hyperedges.iter().map(|h| h.probability).collect(); let probability_reweights = Self::shot_probability_reweights(mapping, gadgets, &errors); let Some(cache_key) = cache_key else { @@ -629,9 +623,9 @@ impl MonolithicCoordinator { let (mut decoding_hypergraph, loss) = self.loss_handler.apply_sites(decoding_hypergraph, &loss_sites, &errors); let mut errors = errors; if deduplicate { - let prepared = deduplicate_decoder_input(&decoding_hypergraph, &errors, &priors); + let prepared = deduplicate_decoder_input(&decoding_hypergraph, &errors); decoding_hypergraph = prepared.hypergraph; - errors = Arc::new(prepared.representatives); + errors = prepared.representatives; } let parity_factor = self .decoder @@ -655,15 +649,13 @@ impl MonolithicCoordinator { &self.decoder, decoding_hypergraph, errors, - &priors, deduplicate, retain_decoding_hypergraph, false, ) .await .unwrap(); - let representatives = Arc::clone(&loaded.errors); - let (reweights, loss) = self + let projected = self .loss_handler .project_shot(&loaded.projection, &probability_reweights, &loss_sites); let mut loaded_decoders = self.loaded_decoders.write().await; @@ -673,8 +665,8 @@ impl MonolithicCoordinator { &self.decoder, &loaded, syndrome.clone(), - reweights, - loss, + projected.reweights, + projected.loss, self.use_loaded_reweights, ) .await @@ -682,7 +674,7 @@ impl MonolithicCoordinator { if self.config.assert_parity_factor { assert_parity_factor(loaded.decoding_hypergraph.as_ref().unwrap(), &parity_factor, &syndrome); } - (parity_factor, representatives) + (parity_factor, projected.errors) } async fn bind_probability_modifiers( diff --git a/deq/deq_runtime/src/coordinator/reweight_handler.rs b/deq/deq_runtime/src/coordinator/reweight_handler.rs index 9d5fa098..09d97713 100644 --- a/deq/deq_runtime/src/coordinator/reweight_handler.rs +++ b/deq/deq_runtime/src/coordinator/reweight_handler.rs @@ -9,8 +9,8 @@ //! vertex numbering but ignore history-boundary vertices with no incident edge. //! [`DecodeProjection`] retains the //! original graph and edge mappings needed to translate every later shot; -//! [`LoadedDecoder`] retains correction representatives and, only when needed -//! locally, the decoder-facing graph. +//! [`LoadedDecoder`] retains the projection and, only when needed locally, the +//! decoder-facing graph. use crate::bin; use crate::decoder::DynDecoder; @@ -32,16 +32,12 @@ pub(crate) async fn load_projected_decoder( decoder: &DynDecoder, base_hypergraph: blackbox_decoder::DecodingHypergraph, base_errors: Arc>, - representative_priors: &[f64], deduplicate: bool, retain_decoding_hypergraph: bool, ignore_isolated_vertices: bool, ) -> Result { - let (projection, prepared) = prepare_decoder(base_hypergraph, base_errors, representative_priors, deduplicate); - let PreparedDecoderInput { - hypergraph, - representatives, - } = prepared; + let (projection, prepared) = prepare_decoder(base_hypergraph, base_errors, deduplicate); + let hypergraph = prepared.hypergraph; let ignored_syndrome_vertices = if ignore_isolated_vertices { Arc::new(edge_isolated_vertices(&hypergraph)) } else { @@ -51,7 +47,6 @@ pub(crate) async fn load_projected_decoder( let hid = decoder.load_hypergraph(hypergraph).await?.hid; Ok(LoadedDecoder { hid, - errors: Arc::new(representatives), decoding_hypergraph, ignored_syndrome_vertices, projection: Arc::new(projection), @@ -61,18 +56,16 @@ pub(crate) async fn load_projected_decoder( fn prepare_decoder( base_hypergraph: blackbox_decoder::DecodingHypergraph, base_errors: Arc>, - representative_priors: &[f64], deduplicate: bool, ) -> (DecodeProjection, PreparedDecoderInput) { debug_assert_eq!(base_hypergraph.hyperedges.len(), base_errors.len()); - debug_assert_eq!(base_hypergraph.hyperedges.len(), representative_priors.len()); let (prepared, edge_projection) = if deduplicate { - deduplicate_by_syndrome(&base_hypergraph, &base_errors, representative_priors) + deduplicate_by_syndrome(&base_hypergraph, &base_errors) } else { ( PreparedDecoderInput { hypergraph: base_hypergraph.clone(), - representatives: base_errors.as_ref().clone(), + representatives: Arc::clone(&base_errors), }, EdgeProjection::Identity, ) @@ -81,6 +74,7 @@ fn prepare_decoder( DecodeProjection { base_hypergraph, base_errors, + decoder_errors: Arc::clone(&prepared.representatives), edge_projection, }, prepared, @@ -116,8 +110,9 @@ pub(crate) fn ignore_edge_isolated_history_vertices( /// the original decoding hypergraph's edge numbering. /// /// Several modifiers may target the same edge; the last assignment wins. A -/// later call to [`DecodeProjection::translate_reweights`] translates these -/// original indices into the edge numbering used by a persistent decoder. +/// later call to [`DecodeProjection::project_reweights`] translates these +/// original indices into the edge numbering used by a persistent decoder and +/// selects correction representatives from the same effective probabilities. pub(crate) fn probability_reweights<'a>( error_reference: &[ErrorIndex], modifiers: impl IntoIterator, @@ -195,10 +190,6 @@ impl DecoderReweighting { pub struct LoadedDecoder { /// Backend handle returned when the stable deduplicated graph was loaded. pub hid: u64, - /// One correction representative per decoder edge, used to interpret the - /// subgraph returned by the backend. These are post-deduplication errors; - /// [`DecodeProjection::base_errors`] retains the original edge list. - pub errors: Arc>, /// Decoder-facing graph after edge deduplication. It is retained only when /// a shot may need a materialized fallback graph or when parity-factor /// assertions need the graph locally. @@ -273,9 +264,10 @@ pub(crate) async fn decode_projected( /// Stable context for projecting shot-scoped updates onto a loaded hypergraph. /// /// The base graph and error reference use original edge numbering. The private -/// edge projection translates that space into decoder edge numbering. The -/// decoder-facing graph and correction representatives are consumed separately -/// through [`PreparedDecoderInput`]. +/// edge projection translates that space into decoder edge numbering and +/// selects correction representatives from each shot's effective +/// probabilities. The decoder-facing graph is consumed through +/// [`PreparedDecoderInput`]. #[derive(Debug)] pub struct DecodeProjection { /// Graph before same-syndrome edge deduplication and before any shot-scoped @@ -283,19 +275,21 @@ pub struct DecodeProjection { pub base_hypergraph: blackbox_decoder::DecodingHypergraph, /// Error-model generator represented by each edge of [`Self::base_hypergraph`]. pub base_errors: Arc>, + /// Baseline correction representative for each decoder edge. + decoder_errors: Arc>, /// Translation between original edge indices and decoder edge indices. edge_projection: EdgeProjection, } /// Transient decoder-space values produced while building a projection. /// -/// The hypergraph moves into the decoder backend and the representatives move -/// into [`LoadedDecoder::errors`]; neither remains duplicated in -/// [`DecodeProjection`]. +/// The hypergraph moves into the decoder backend. The baseline representatives +/// initialize [`DecodeProjection`] and are reused for shots that do not change +/// their merged groups. #[derive(Debug)] pub(crate) struct PreparedDecoderInput { pub(crate) hypergraph: blackbox_decoder::DecodingHypergraph, - pub(crate) representatives: Vec, + pub(crate) representatives: Arc>, } /// Bidirectional relationship between original and decoder edge numbering. @@ -309,22 +303,19 @@ enum EdgeProjection { }, } -/// Collapse same-syndrome hyperedges while preserving the highest-prior +/// Collapse same-syndrome hyperedges while preserving the highest-probability /// correction representative for each group. The supplied slices must be /// edge-aligned in original numbering. fn deduplicate_by_syndrome( hypergraph: &blackbox_decoder::DecodingHypergraph, errors: &[ErrorIndex], - priors: &[f64], ) -> (PreparedDecoderInput, EdgeProjection) { let mut seen: hashbrown::HashMap, (usize, f64)> = hashbrown::HashMap::with_capacity(errors.len()); let mut hyperedges: Vec = Vec::with_capacity(errors.len()); let mut representatives = Vec::with_capacity(errors.len()); let mut decoder_edge_of_original = Vec::with_capacity(errors.len()); let mut original_edges_of_decoder: Vec> = Vec::with_capacity(errors.len()); - for (position, ((hyperedge, error), &prior)) in - hypergraph.hyperedges.iter().zip(errors.iter()).zip(priors.iter()).enumerate() - { + for (position, (hyperedge, error)) in hypergraph.hyperedges.iter().zip(errors.iter()).enumerate() { let mut syndrome = hyperedge.vertices.clone(); syndrome.sort_unstable(); debug_assert!({ @@ -332,11 +323,11 @@ fn deduplicate_by_syndrome( syndrome.dedup(); syndrome.len() == degree }); - if let Some((index, best_prior)) = seen.get_mut(&syndrome) { + if let Some((index, best_probability)) = seen.get_mut(&syndrome) { let combined = hyperedges[*index].probability; hyperedges[*index].probability = exclusive_probability_of(combined, hyperedge.probability); - if prior > *best_prior { - *best_prior = prior; + if hyperedge.probability > *best_probability { + *best_probability = hyperedge.probability; representatives[*index] = error.clone(); } original_edges_of_decoder[*index].push(position); @@ -350,7 +341,7 @@ fn deduplicate_by_syndrome( representatives.push(error.clone()); original_edges_of_decoder.push(vec![position]); decoder_edge_of_original.push(index); - seen.insert(syndrome, (index, prior)); + seen.insert(syndrome, (index, hyperedge.probability)); } } ( @@ -359,7 +350,7 @@ fn deduplicate_by_syndrome( vertex_num: hypergraph.vertex_num, hyperedges, }, - representatives, + representatives: Arc::new(representatives), }, EdgeProjection::Merged { decoder_edge_of_original, @@ -372,9 +363,8 @@ fn deduplicate_by_syndrome( pub(crate) fn deduplicate_decoder_input( hypergraph: &blackbox_decoder::DecodingHypergraph, errors: &[ErrorIndex], - priors: &[f64], ) -> PreparedDecoderInput { - deduplicate_by_syndrome(hypergraph, errors, priors).0 + deduplicate_by_syndrome(hypergraph, errors).0 } impl DecodeProjection { @@ -385,23 +375,28 @@ impl DecodeProjection { ) -> Self { Self { base_hypergraph, + decoder_errors: Arc::clone(&base_errors), base_errors, edge_projection: EdgeProjection::Identity, } } - /// Translate original-edge assignments into decoder-edge assignments. - pub(crate) fn translate_reweights(&self, reweights: &[(u64, f64)]) -> Vec<(u64, f64)> { - self.edge_projection.translate_reweights(&self.base_hypergraph, reweights) + /// Project original-edge probability assignments and correction + /// representatives into decoder edge numbering for one shot. + pub(crate) fn project_reweights(&self, reweights: &[(u64, f64)]) -> (Vec<(u64, f64)>, Arc>) { + self.edge_projection + .project_reweights(&self.base_hypergraph, &self.base_errors, &self.decoder_errors, reweights) } } impl EdgeProjection { - fn translate_reweights( + fn project_reweights( &self, base_hypergraph: &blackbox_decoder::DecodingHypergraph, + base_errors: &[ErrorIndex], + decoder_errors: &Arc>, reweights: &[(u64, f64)], - ) -> Vec<(u64, f64)> { + ) -> (Vec<(u64, f64)>, Arc>) { match self { Self::Identity => { let mut overrides = hashbrown::HashMap::with_capacity(reweights.len()); @@ -410,37 +405,52 @@ impl EdgeProjection { } let mut translated: Vec<_> = overrides.into_iter().collect(); translated.sort_unstable_by_key(|&(edge, _)| edge); - translated + (translated, Arc::clone(decoder_errors)) } Self::Merged { decoder_edge_of_original, original_edges_of_decoder, } => { let mut overrides = hashbrown::HashMap::with_capacity(reweights.len()); - let mut affected = Vec::with_capacity(reweights.len()); + let mut reweighted_decoder_edges = Vec::with_capacity(reweights.len()); for &(edge, probability) in reweights { let original = usize::try_from(edge).unwrap(); overrides.insert(original, probability); - affected.push(decoder_edge_of_original[original]); + reweighted_decoder_edges.push(decoder_edge_of_original[original]); } - affected.sort_unstable(); - affected.dedup(); - affected + reweighted_decoder_edges.sort_unstable(); + reweighted_decoder_edges.dedup(); + if reweighted_decoder_edges.is_empty() { + return (vec![], Arc::clone(decoder_errors)); + } + let mut projected_errors = Arc::clone(decoder_errors); + let translated = reweighted_decoder_edges .into_iter() .map(|decoder_edge| { - let combined = - original_edges_of_decoder[decoder_edge] - .iter() - .fold(0.0, |accumulated, &original_edge| { - let probability = overrides - .get(&original_edge) - .copied() - .unwrap_or(base_hypergraph.hyperedges[original_edge].probability); - exclusive_probability_of(accumulated, probability) - }); + let mut combined = 0.0; + let mut elected = None; + for &original_edge in &original_edges_of_decoder[decoder_edge] { + let probability = overrides + .get(&original_edge) + .copied() + .unwrap_or(base_hypergraph.hyperedges[original_edge].probability); + combined = exclusive_probability_of(combined, probability); + let should_elect = match elected { + None => true, + Some((_, elected_probability)) => probability > elected_probability, + }; + if should_elect { + elected = Some((original_edge, probability)); + } + } + let (elected_original, _) = elected.expect("decoder edge must contain an original edge"); + if projected_errors[decoder_edge] != base_errors[elected_original] { + Arc::make_mut(&mut projected_errors)[decoder_edge].clone_from(&base_errors[elected_original]); + } (u64::try_from(decoder_edge).unwrap(), combined) }) - .collect() + .collect(); + (translated, projected_errors) } } } diff --git a/deq/deq_runtime/src/coordinator/window_coordinator.rs b/deq/deq_runtime/src/coordinator/window_coordinator.rs index e7827ef7..d8cb26c4 100644 --- a/deq/deq_runtime/src/coordinator/window_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/window_coordinator.rs @@ -1711,9 +1711,9 @@ impl WindowCoordinator { let loaded = self.loaded_decoders.read().await.get(cache_key).cloned(); if let Some(loaded) = loaded { let probability_reweights = self.shot_probability_reweights(mapping, &loaded.projection.base_errors).await; - let (reweights, loss) = - self.loss_handler - .project_shot(&loaded.projection, &probability_reweights, &loss_sites); + let projected = self + .loss_handler + .project_shot(&loaded.projection, &probability_reweights, &loss_sites); // we can use the loaded decoding hypergraph to call the decoding service span.add_event(Event::new("decoding").with_property(|| ("type", "loaded"))); let decode_syndrome = loaded.project_syndrome(syndrome.clone()); @@ -1721,8 +1721,8 @@ impl WindowCoordinator { &self.decoder, &loaded, decode_syndrome.clone(), - reweights, - loss, + projected.reweights, + projected.loss, self.use_loaded_reweights, ) .await @@ -1730,7 +1730,7 @@ impl WindowCoordinator { if self.config.assert_parity_factor { assert_parity_factor(loaded.decoding_hypergraph.as_ref().unwrap(), &parity_factor, &decode_syndrome); } - return (parity_factor, loaded.errors.clone()); + return (parity_factor, projected.errors); } } @@ -1738,12 +1738,6 @@ impl WindowCoordinator { // and instantiate such a decoder let (decoding_hypergraph, errors) = self.decoding_hypergraph(committing_cids, relative_program, mapping).await; - // Priors, captured before loss reweighting rewrites them. Merging picks its - // representative from these rather than from the reweighted values: a - // reweighted probability is a decoding weight, not a calibrated posterior, - // and letting it arbitrate corrections lets the loss heuristic decide - // logical outcomes. - let priors: Vec = decoding_hypergraph.hyperedges.iter().map(|h| h.probability).collect(); let probability_reweights = self.shot_probability_reweights(mapping, &errors).await; let Some(cache_key) = cache_key else { @@ -1757,9 +1751,9 @@ impl WindowCoordinator { let (mut decoding_hypergraph, loss) = self.loss_handler.apply_sites(decoding_hypergraph, &loss_sites, &errors); let mut errors = errors; if deduplicate { - let prepared = deduplicate_decoder_input(&decoding_hypergraph, &errors, &priors); + let prepared = deduplicate_decoder_input(&decoding_hypergraph, &errors); decoding_hypergraph = prepared.hypergraph; - errors = Arc::new(prepared.representatives); + errors = prepared.representatives; } let mut syndrome = syndrome; ignore_edge_isolated_history_vertices(&decoding_hypergraph, &mut syndrome); @@ -1787,16 +1781,14 @@ impl WindowCoordinator { &self.decoder, decoding_hypergraph, errors, - &priors, deduplicate, retain_decoding_hypergraph, true, ) .await .unwrap(); - let representatives = Arc::clone(&loaded.errors); let decode_syndrome = loaded.project_syndrome(syndrome); - let (reweights, loss) = self + let projected = self .loss_handler .project_shot(&loaded.projection, &probability_reweights, &loss_sites); let mut loaded_decoders = self.loaded_decoders.write().await; @@ -1806,8 +1798,8 @@ impl WindowCoordinator { &self.decoder, &loaded, decode_syndrome.clone(), - reweights, - loss, + projected.reweights, + projected.loss, self.use_loaded_reweights, ) .await @@ -1815,7 +1807,7 @@ impl WindowCoordinator { if self.config.assert_parity_factor { assert_parity_factor(loaded.decoding_hypergraph.as_ref().unwrap(), &parity_factor, &decode_syndrome); } - (parity_factor, representatives) + (parity_factor, projected.errors) } async fn bind_probability_modifiers( diff --git a/deq/deq_runtime/tests/unit/reweight_handler_test.rs b/deq/deq_runtime/tests/unit/reweight_handler_test.rs index 7287dc28..352da6bb 100644 --- a/deq/deq_runtime/tests/unit/reweight_handler_test.rs +++ b/deq/deq_runtime/tests/unit/reweight_handler_test.rs @@ -55,7 +55,7 @@ async fn loaded_decoder_for_test(mock: &Arc) -> (Dy }; let decoder = DynDecoder::Mock(mock.clone()); let errors = Arc::new(vec![ErrorIndex { eid: 0, error_index: 0 }]); - let loaded = load_projected_decoder(&decoder, hypergraph, errors, &[0.1], false, true, false) + let loaded = load_projected_decoder(&decoder, hypergraph, errors, false, true, false) .await .unwrap(); (decoder, loaded) @@ -141,7 +141,7 @@ async fn loaded_projection_zeros_isolated_vertices_without_renumbering() { }; let errors = Arc::new(vec![ErrorIndex { eid: 0, error_index: 0 }]); - let loaded = load_projected_decoder(&decoder, hypergraph, errors, &[0.1], false, true, true) + let loaded = load_projected_decoder(&decoder, hypergraph, errors, false, true, true) .await .unwrap(); @@ -157,7 +157,7 @@ async fn loaded_projection_zeros_isolated_vertices_without_renumbering() { } #[test] -fn deduplication_keeps_the_highest_prior_correction_not_the_reweighted_one() { +fn deduplication_keeps_the_highest_probability_correction() { let hypergraph = blackbox_decoder::DecodingHypergraph { vertex_num: 3, hyperedges: vec![ @@ -181,11 +181,11 @@ fn deduplication_keeps_the_highest_prior_correction_not_the_reweighted_one() { ErrorIndex { eid: 0, error_index: 5 }, ]; - let (deduplicated, _) = deduplicate_by_syndrome(&hypergraph, &errors, &[3.7e-4, 0.0, 0.02]); + let (deduplicated, _) = deduplicate_by_syndrome(&hypergraph, &errors); assert_eq!(deduplicated.hypergraph.hyperedges.len(), 2); assert_eq!(deduplicated.hypergraph.hyperedges[0].vertices, vec![0, 1]); - assert_eq!(deduplicated.representatives[0], ErrorIndex { eid: 0, error_index: 7 }); + assert_eq!(deduplicated.representatives[0], ErrorIndex { eid: 0, error_index: 99 }); let combined = 0.31 + 0.35 - 2.0 * 0.31 * 0.35; assert!((deduplicated.hypergraph.hyperedges[0].probability - combined).abs() < 1e-12); } @@ -206,9 +206,9 @@ fn deduplication_is_the_identity_when_every_syndrome_is_distinct() { ], }; let errors = vec![ErrorIndex { eid: 0, error_index: 0 }, ErrorIndex { eid: 0, error_index: 1 }]; - let (deduplicated, _) = deduplicate_by_syndrome(&hypergraph, &errors, &[0.1, 0.2]); + let (deduplicated, _) = deduplicate_by_syndrome(&hypergraph, &errors); assert_eq!(deduplicated.hypergraph.hyperedges.len(), 2); - assert_eq!(deduplicated.representatives, errors); + assert_eq!(deduplicated.representatives.as_ref(), &errors); } #[test] @@ -235,18 +235,99 @@ fn identity_grouping_matches_deduplicating_a_collision_free_graph() { ErrorIndex { eid: 0, error_index: 1 }, ErrorIndex { eid: 1, error_index: 0 }, ]; - let priors = [0.1, 0.2, 0.0]; - let (identity_projection, identity) = prepare_decoder(hypergraph.clone(), Arc::new(errors.clone()), &priors, false); - let (collapsed_projection, collapsed) = prepare_decoder(hypergraph, Arc::new(errors.clone()), &priors, true); + let (identity_projection, identity) = prepare_decoder(hypergraph.clone(), Arc::new(errors.clone()), false); + let (collapsed_projection, collapsed) = prepare_decoder(hypergraph, Arc::new(errors.clone()), true); assert_eq!(identity.hypergraph, collapsed.hypergraph); assert_eq!(identity.representatives, collapsed.representatives); let reweights = [(0, 0.15), (2, 0.3)]; assert_eq!( - identity_projection.translate_reweights(&reweights), - collapsed_projection.translate_reweights(&reweights) + identity_projection.project_reweights(&reweights), + collapsed_projection.project_reweights(&reweights) ); } +#[test] +fn shot_reweight_changes_the_merged_correction_representative() { + let hypergraph = blackbox_decoder::DecodingHypergraph { + vertex_num: 2, + hyperedges: vec![ + blackbox_decoder::Hyperedge { + probability: 0.3, + vertices: vec![0, 1], + }, + blackbox_decoder::Hyperedge { + probability: 0.1, + vertices: vec![1, 0], + }, + ], + }; + let errors = Arc::new(vec![ + ErrorIndex { eid: 0, error_index: 7 }, + ErrorIndex { eid: 0, error_index: 99 }, + ]); + let (projection, prepared) = prepare_decoder(hypergraph, errors, true); + + assert_eq!(prepared.representatives[0], ErrorIndex { eid: 0, error_index: 7 }); + + let (_, unchanged_errors) = projection.project_reweights(&[(0, 0.2)]); + assert!(Arc::ptr_eq(&unchanged_errors, &projection.decoder_errors)); + + let (reweights, projected_errors) = projection.project_reweights(&[(1, 0.4)]); + + assert_eq!(projected_errors[0], ErrorIndex { eid: 0, error_index: 99 }); + assert_eq!(reweights.len(), 1); + assert!((reweights[0].1 - exclusive_probability_of(0.3, 0.4)).abs() < 1e-12); +} + +#[test] +fn shot_reweight_re_elects_only_affected_merged_representatives() { + let hypergraph = blackbox_decoder::DecodingHypergraph { + vertex_num: 3, + hyperedges: vec![ + blackbox_decoder::Hyperedge { + probability: 0.3, + vertices: vec![0, 1], + }, + blackbox_decoder::Hyperedge { + probability: 0.1, + vertices: vec![1, 0], + }, + blackbox_decoder::Hyperedge { + probability: 0.25, + vertices: vec![1, 2], + }, + blackbox_decoder::Hyperedge { + probability: 0.2, + vertices: vec![2, 1], + }, + ], + }; + let errors = Arc::new(vec![ + ErrorIndex { eid: 0, error_index: 7 }, + ErrorIndex { eid: 0, error_index: 99 }, + ErrorIndex { eid: 1, error_index: 12 }, + ErrorIndex { eid: 1, error_index: 13 }, + ]); + let (projection, prepared) = prepare_decoder(hypergraph, errors, true); + + assert_eq!(prepared.representatives[0], ErrorIndex { eid: 0, error_index: 7 }); + assert_eq!(prepared.representatives[1], ErrorIndex { eid: 1, error_index: 12 }); + + let (reweights, projected_errors) = projection.project_reweights(&[(0, 0.05)]); + + assert_eq!(projected_errors[0], ErrorIndex { eid: 0, error_index: 99 }); + assert_eq!(projected_errors[1], ErrorIndex { eid: 1, error_index: 12 }); + assert_eq!(reweights.len(), 1); + assert!((reweights[0].1 - exclusive_probability_of(0.05, 0.1)).abs() < 1e-12); + + let (reweights, projected_errors) = projection.project_reweights(&[(0, 0.05), (1, 0.15)]); + + assert_eq!(projected_errors[0], ErrorIndex { eid: 0, error_index: 99 }); + assert_eq!(projected_errors[1], ErrorIndex { eid: 1, error_index: 12 }); + assert_eq!(reweights.len(), 1); + assert!((reweights[0].1 - exclusive_probability_of(0.05, 0.15)).abs() < 1e-12); +} + #[test] fn translated_reweights_match_deduplicating_an_already_reweighted_graph() { let priors = vec![3.7e-4, 3.7e-4, 0.0, 0.02]; @@ -264,11 +345,11 @@ fn translated_reweights_match_deduplicating_an_already_reweighted_graph() { .collect(), }; let reweights = vec![(2u64, 0.31)]; - let (projection, _) = prepare_decoder(base.clone(), Arc::new(errors.clone()), &priors, true); - let translated = projection.translate_reweights(&reweights); + let (projection, _) = prepare_decoder(base.clone(), Arc::new(errors.clone()), true); + let (translated, _) = projection.project_reweights(&reweights); let mut reweighted = base.clone(); apply_reweights(&mut reweighted, &reweights); - let (expected, _) = deduplicate_by_syndrome(&reweighted, &errors, &priors); + let (expected, _) = deduplicate_by_syndrome(&reweighted, &errors); assert_eq!(translated.len(), 1); let (edge, probability) = translated[0]; From d4c3c2b6000920d75a5db70006c409e3d61e9ecd Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 15:12:15 -0700 Subject: [PATCH 139/157] revise reweight handler --- .../src/coordinator/mock_coordinator.rs | 33 ++-- .../src/coordinator/monolithic_coordinator.rs | 77 +++++----- .../src/coordinator/reweight_handler.rs | 142 ++++++++++++++---- .../src/coordinator/window_coordinator.rs | 80 +++++----- deq/deq_runtime/src/misc/validation.rs | 124 +++++++++++++++ .../tests/mock_coordinator_test.rs | 27 ++++ .../tests/monolithic_coordinator_test.rs | 39 +++++ .../tests/unit/reweight_handler_test.rs | 59 +++++++- deq/deq_visualizer/src/misc/VisualizerData.ts | 37 ++++- 9 files changed, 479 insertions(+), 139 deletions(-) diff --git a/deq/deq_runtime/src/coordinator/mock_coordinator.rs b/deq/deq_runtime/src/coordinator/mock_coordinator.rs index 88101659..d9caa4a6 100644 --- a/deq/deq_runtime/src/coordinator/mock_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/mock_coordinator.rs @@ -5,6 +5,9 @@ use crate::bin::{self, instruction}; use crate::coordinator::{self, coordinator_server}; +use crate::misc::validation::{ + apply_check_model_reroutes, apply_error_model_reroutes, validate_check_model_reroutes, validate_error_model_reroutes, +}; use hashbrown::HashMap; use std::sync::Arc; use tokio::sync::{Notify, RwLock}; @@ -272,18 +275,9 @@ impl MockCoordinator { .expect("check model type not found"); // Build modified remote gadgets list (with reroutes applied) - let mut modified_remote_gadgets: Vec> = - base_type.remote_gadgets.iter().cloned().map(Some).collect(); - - if let Some(modifier) = &check_model.modifier { - for reroute in &modifier.reroute_remote_gadgets { - let idx = reroute.remote_gadget_index as usize; - while idx >= modified_remote_gadgets.len() { - modified_remote_gadgets.push(None); - } - modified_remote_gadgets[idx] = reroute.value.clone(); - } - } + let modified_remote_gadgets = + apply_check_model_reroutes(&base_type.remote_gadgets, check_model.modifier.as_ref()) + .expect("check model reroutes were validated on execute"); // Expand remote gadgets to absolute gids let expanded = state.expand_remote_gadgets(check_model, &modified_remote_gadgets); @@ -332,20 +326,13 @@ impl MockCoordinator { .expect("error model type not found"); // Build modified remote check models list (with reroutes applied) - let mut modified_remote_check_models: Vec> = - base_type.remote_check_models.iter().cloned().map(Some).collect(); + let modified_remote_check_models = + apply_error_model_reroutes(&base_type.remote_check_models, error_model.modifier.as_ref()) + .expect("error model reroutes were validated on execute"); let mut errors = base_type.errors.clone(); if let Some(modifier) = &error_model.modifier { - for reroute in &modifier.reroute_remote_check_models { - let idx = reroute.remote_check_model_index as usize; - while idx >= modified_remote_check_models.len() { - modified_remote_check_models.push(None); - } - modified_remote_check_models[idx] = reroute.value.clone(); - } - // Apply probability modifier if let Some(prob_modifier) = &modifier.probability_modifier { for (idx, &prob) in prob_modifier.probabilities.iter().enumerate() { @@ -505,6 +492,7 @@ impl coordinator_server::Coordinator for MockCoordinator { gid } Some(instruction::Create::CheckModel(check_model)) => { + validate_check_model_reroutes(check_model.modifier.as_ref()).map_err(Status::invalid_argument)?; // Validate that the check model type is registered assert!( state.check_model_types.contains_key(&check_model.ctype), @@ -529,6 +517,7 @@ impl coordinator_server::Coordinator for MockCoordinator { cid } Some(instruction::Create::ErrorModel(error_model)) => { + validate_error_model_reroutes(error_model.modifier.as_ref()).map_err(Status::invalid_argument)?; // Validate that the error model type is registered assert!( state.error_model_types.contains_key(&error_model.etype), diff --git a/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs index 62783cfa..f0892048 100644 --- a/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs @@ -25,7 +25,8 @@ use crate::bin; use crate::coordinator; use crate::coordinator::loss_handler::{RawLossSite, apply_loss_random_imputation, has_loss_model}; use crate::coordinator::reweight_handler::{ - apply_reweights, decode_projected, deduplicate_decoder_input, load_projected_decoder, probability_reweights, + ProjectedErrors, apply_reweights, decode_projected, deduplicate_decoder_input, load_projected_decoder, + probability_reweights, }; use crate::coordinator::{ DecoderCacheKey, DecoderReweighting, FingerprintSource, LoadedDecoder, LossHandler, LossStrategy, @@ -40,7 +41,9 @@ use crate::misc::pauli_frame_tracker::PauliFrameTracker; use crate::misc::relative_program::{self, RelativeMapping, RelativeProgram}; use crate::misc::sync::{TaskCounter, check_or_receiver, get_or_receiver, get_value}; use crate::misc::union_find::{UnionFindGeneric, UnionNodeTrait}; -use crate::misc::validation::{validate_outcomes, validate_probability_modifier}; +use crate::misc::validation::{ + apply_check_model_reroutes, apply_error_model_reroutes, validate_outcomes, validate_probability_modifier, +}; use crate::util::BitVector; use binar::{BitVec, BitwiseMut}; use hashbrown::{HashMap, HashSet}; @@ -491,7 +494,7 @@ impl MonolithicCoordinator { async fn update_pauli_frame( &self, parity_factor: &blackbox_decoder::ParityFactor, - errors: &[ErrorIndex], + errors: &ProjectedErrors, relative_program: &RelativeProgram, mapping: &RelativeMapping, error_models: &HashMap, @@ -550,7 +553,7 @@ impl MonolithicCoordinator { gadgets: &HashMap, check_models: &HashMap, error_models: &HashMap, - ) -> (blackbox_decoder::ParityFactor, Arc>) { + ) -> (blackbox_decoder::ParityFactor, ProjectedErrors) { // calculate syndrome let syndrome = self.get_syndrome(relative_program, mapping, gadgets, check_models).await; @@ -585,8 +588,9 @@ impl MonolithicCoordinator { if let Some(ref cache_key) = cache_key { let loaded = self.loaded_decoders.read().await.get(cache_key).cloned(); if let Some(loaded) = loaded { - let probability_reweights = - Self::shot_probability_reweights(mapping, gadgets, &loaded.projection.base_errors); + let probability_reweights = loaded + .projection + .probability_reweights(Self::shot_probability_modifiers(mapping, gadgets)); let projected = self .loss_handler .project_shot(&loaded.projection, &probability_reweights, &loss_sites); @@ -613,9 +617,8 @@ impl MonolithicCoordinator { .decoding_hypergraph(relative_program, mapping, check_models, error_models) .await; - let probability_reweights = Self::shot_probability_reweights(mapping, gadgets, &errors); - let Some(cache_key) = cache_key else { + let probability_reweights = Self::shot_probability_reweights(mapping, gadgets, &errors); // Without a persistent decoder every shot ships its whole problem, so // probability updates are applied before loss derives its live priors. let mut decoding_hypergraph = decoding_hypergraph; @@ -639,7 +642,7 @@ impl MonolithicCoordinator { if self.config.assert_parity_factor { assert_parity_factor(&decoding_hypergraph, &parity_factor, &syndrome); } - return (parity_factor, errors); + return (parity_factor, errors.into()); }; // Load the stable base graph before any shot's loss is applied, so the @@ -655,6 +658,9 @@ impl MonolithicCoordinator { ) .await .unwrap(); + let probability_reweights = loaded + .projection + .probability_reweights(Self::shot_probability_modifiers(mapping, gadgets)); let projected = self .loss_handler .project_shot(&loaded.projection, &probability_reweights, &loss_sites); @@ -727,13 +733,22 @@ impl MonolithicCoordinator { gadgets: &HashMap, error_reference: &[ErrorIndex], ) -> Vec<(u64, f64)> { - let mut modifiers: Vec<_> = gadgets - .values() + probability_reweights(error_reference, Self::shot_probability_modifiers(mapping, gadgets)) + } + + fn shot_probability_modifiers<'a>( + mapping: &RelativeMapping, + gadgets: &'a HashMap, + ) -> Vec<(usize, &'a bin::ProbabilityModifier)> { + let mut modifiers: Vec<_> = mapping + .global_gid_of + .iter() + .filter_map(|gid| gadgets.get(gid)) .flat_map(|gadget| gadget.probability_modifiers.iter()) .filter_map(|(eid, modifier)| mapping.local_eid_of.get(eid).map(|&local_eid| (local_eid, modifier))) .collect(); modifiers.sort_unstable_by_key(|&(local_eid, _)| local_eid); - probability_reweights(error_reference, modifiers) + modifiers } async fn get_syndrome( @@ -1282,6 +1297,13 @@ impl coordinator::coordinator_server::Coordinator for MonolithicCoordinator { let check_model_types = self.check_model_types.read().await; let mut gadgets = self.gadgets.write().await; let mut check_models = self.check_models.write().await; + let check_model_type = check_model_types + .get(&check_model.ctype) + .ok_or_else(|| Status::not_found(format!("ctype={}", check_model.ctype)))?; + let modified_remote = Arc::new( + apply_check_model_reroutes(&check_model_type.remote_gadgets, check_model.modifier.as_ref()) + .map_err(Status::invalid_argument)?, + ); let cid = if check_model.cid == 0 { // Auto-assign: find next unused cid let mut next_cid = self.next_cid.lock().await; @@ -1295,27 +1317,12 @@ impl coordinator::coordinator_server::Coordinator for MonolithicCoordinator { // User-provided cid check_model.cid }; - let check_model_type = check_model_types - .get(&check_model.ctype) - .ok_or_else(|| Status::not_found(format!("ctype={}", check_model.ctype)))?; let gadget = gadgets.get_mut(&check_model.gid).ok_or_else(|| { Status::invalid_argument(format!("cid={cid} binding to unknown gid={}", check_model.gid)) })?; debug_assert!(check_model_type.gtype == WILDCARD || check_model_type.gtype == gadget.instance.gtype); debug_assert!(gadget.binding_cid.borrow().is_none()); gadget.binding_cid.send_replace(Some(cid)); - // apply the modifier reroutes - let mut modified_remote: Vec<_> = check_model_type.remote_gadgets.iter().cloned().map(Some).collect(); - if let Some(modifier) = &check_model.modifier { - for rereoute in &modifier.reroute_remote_gadgets { - // extend the remote_gadgets vector if necessary - while (rereoute.remote_gadget_index as usize) >= modified_remote.len() { - modified_remote.push(None); - } - modified_remote[rereoute.remote_gadget_index as usize] = rereoute.value.clone(); - } - } - let modified_remote = Arc::new(modified_remote); let mut check_model = check_model; check_model.cid = cid; check_models.insert( @@ -1361,6 +1368,10 @@ impl coordinator::coordinator_server::Coordinator for MonolithicCoordinator { validate_probability_modifier(probability_modifier, error_model_type.errors.len()) .map_err(Status::invalid_argument)?; } + let modified_remote = Arc::new( + apply_error_model_reroutes(&error_model_type.remote_check_models, error_model.modifier.as_ref()) + .map_err(Status::invalid_argument)?, + ); let eid = if error_model.eid == 0 { // Auto-assign: find next unused eid let mut next_eid = self.next_eid.lock().await; @@ -1379,18 +1390,6 @@ impl coordinator::coordinator_server::Coordinator for MonolithicCoordinator { })?; debug_assert!(error_model_type.ctype == WILDCARD || error_model_type.ctype == check_model.instance.ctype); check_model.attaching_eid_vec.push(eid); - // apply the modifier reroutes - let mut modified_remote: Vec<_> = error_model_type.remote_check_models.iter().cloned().map(Some).collect(); - if let Some(modifier) = &error_model.modifier { - for rereoute in &modifier.reroute_remote_check_models { - // extend the remote_check_models vector if necessary - while (rereoute.remote_check_model_index as usize) >= modified_remote.len() { - modified_remote.push(None); - } - modified_remote[rereoute.remote_check_model_index as usize] = rereoute.value.clone(); - } - } - let modified_remote = Arc::new(modified_remote); let mut error_model = error_model; error_model.eid = eid; error_models.insert( diff --git a/deq/deq_runtime/src/coordinator/reweight_handler.rs b/deq/deq_runtime/src/coordinator/reweight_handler.rs index 09d97713..39546930 100644 --- a/deq/deq_runtime/src/coordinator/reweight_handler.rs +++ b/deq/deq_runtime/src/coordinator/reweight_handler.rs @@ -20,6 +20,7 @@ use crate::misc::index::ErrorIndex; use crate::misc::util::exclusive_probability_of; use crate::util::BitVector; use serde::{Deserialize, Serialize}; +use std::ops::Index; use std::sync::Arc; use tonic::Status; @@ -59,6 +60,7 @@ fn prepare_decoder( deduplicate: bool, ) -> (DecodeProjection, PreparedDecoderInput) { debug_assert_eq!(base_hypergraph.hyperedges.len(), base_errors.len()); + let error_edge_lookup = ErrorEdgeLookup::new(&base_errors); let (prepared, edge_projection) = if deduplicate { deduplicate_by_syndrome(&base_hypergraph, &base_errors) } else { @@ -75,6 +77,7 @@ fn prepare_decoder( base_hypergraph, base_errors, decoder_errors: Arc::clone(&prepared.representatives), + error_edge_lookup, edge_projection, }, prepared, @@ -117,30 +120,59 @@ pub(crate) fn probability_reweights<'a>( error_reference: &[ErrorIndex], modifiers: impl IntoIterator, ) -> Vec<(u64, f64)> { - let modifiers: Vec<_> = modifiers.into_iter().collect(); - if modifiers.is_empty() { - return vec![]; - } - let mut edge_of = hashbrown::HashMap::with_capacity(error_reference.len()); - for (edge, error) in error_reference.iter().enumerate() { - edge_of.insert((error.eid, error.error_index), u64::try_from(edge).unwrap()); - } - let mut overrides = hashbrown::HashMap::new(); - for (local_eid, modifier) in modifiers { - for (error_index, &probability) in modifier.probabilities.iter().enumerate() { - if let Some(&edge) = edge_of.get(&(local_eid, error_index)) { - overrides.insert(edge, probability); + ErrorEdgeLookup::new(error_reference).project(modifiers) +} + +#[derive(Debug)] +pub(crate) struct ErrorEdgeLookup { + edges_by_eid: hashbrown::HashMap>, +} + +impl ErrorEdgeLookup { + const MISSING_EDGE: u64 = u64::MAX; + + pub(crate) fn new(error_reference: &[ErrorIndex]) -> Self { + let mut edges_by_eid = hashbrown::HashMap::>::new(); + for (edge, error) in error_reference.iter().enumerate() { + let edges = edges_by_eid.entry(error.eid).or_default(); + if edges.len() <= error.error_index { + edges.resize(error.error_index + 1, Self::MISSING_EDGE); } + edges[error.error_index] = u64::try_from(edge).unwrap(); } - for (&error_index, &probability) in modifier.sparse_indices.iter().zip(modifier.sparse_probabilities.iter()) { - if let Some(&edge) = edge_of.get(&(local_eid, error_index as usize)) { - overrides.insert(edge, probability); + Self { edges_by_eid } + } + + pub(crate) fn project<'a>( + &self, + modifiers: impl IntoIterator, + ) -> Vec<(u64, f64)> { + let mut overrides = hashbrown::HashMap::new(); + for (local_eid, modifier) in modifiers { + let Some(edges) = self.edges_by_eid.get(&local_eid) else { + continue; + }; + for (error_index, &probability) in modifier.probabilities.iter().enumerate() { + if let Some(edge) = Self::edge(edges, error_index) { + overrides.insert(edge, probability); + } + } + for (&error_index, &probability) in modifier.sparse_indices.iter().zip(modifier.sparse_probabilities.iter()) { + if let Ok(error_index) = usize::try_from(error_index) + && let Some(edge) = Self::edge(edges, error_index) + { + overrides.insert(edge, probability); + } } } + let mut reweights: Vec<_> = overrides.into_iter().collect(); + reweights.sort_unstable_by_key(|&(edge, _)| edge); + reweights + } + + fn edge(edges: &[u64], error_index: usize) -> Option { + edges.get(error_index).copied().filter(|&edge| edge != Self::MISSING_EDGE) } - let mut reweights: Vec<_> = overrides.into_iter().collect(); - reweights.sort_unstable_by_key(|&(edge, _)| edge); - reweights } /// Materialize edge probability updates directly into a hypergraph. @@ -277,10 +309,52 @@ pub struct DecodeProjection { pub base_errors: Arc>, /// Baseline correction representative for each decoder edge. decoder_errors: Arc>, + /// Original edge lookup built once and reused by every persistent shot. + error_edge_lookup: ErrorEdgeLookup, /// Translation between original edge indices and decoder edge indices. edge_projection: EdgeProjection, } +/// Baseline decoder corrections plus replacements for reweighted merged edges. +#[derive(Debug, Clone)] +pub(crate) struct ProjectedErrors { + baseline: Arc>, + replacements: Vec<(usize, ErrorIndex)>, +} + +impl ProjectedErrors { + pub(crate) fn shared(baseline: Arc>) -> Self { + Self { + baseline, + replacements: vec![], + } + } + + pub(crate) fn len(&self) -> usize { + self.baseline.len() + } +} + +impl Index for ProjectedErrors { + type Output = ErrorIndex; + + fn index(&self, index: usize) -> &Self::Output { + match self + .replacements + .binary_search_by_key(&index, |&(decoder_edge, _)| decoder_edge) + { + Ok(position) => &self.replacements[position].1, + Err(_) => &self.baseline[index], + } + } +} + +impl From>> for ProjectedErrors { + fn from(baseline: Arc>) -> Self { + Self::shared(baseline) + } +} + /// Transient decoder-space values produced while building a projection. /// /// The hypergraph moves into the decoder backend. The baseline representatives @@ -376,14 +450,22 @@ impl DecodeProjection { Self { base_hypergraph, decoder_errors: Arc::clone(&base_errors), + error_edge_lookup: ErrorEdgeLookup::new(&base_errors), base_errors, edge_projection: EdgeProjection::Identity, } } + pub(crate) fn probability_reweights<'a>( + &self, + modifiers: impl IntoIterator, + ) -> Vec<(u64, f64)> { + self.error_edge_lookup.project(modifiers) + } + /// Project original-edge probability assignments and correction /// representatives into decoder edge numbering for one shot. - pub(crate) fn project_reweights(&self, reweights: &[(u64, f64)]) -> (Vec<(u64, f64)>, Arc>) { + pub(crate) fn project_reweights(&self, reweights: &[(u64, f64)]) -> (Vec<(u64, f64)>, ProjectedErrors) { self.edge_projection .project_reweights(&self.base_hypergraph, &self.base_errors, &self.decoder_errors, reweights) } @@ -396,7 +478,7 @@ impl EdgeProjection { base_errors: &[ErrorIndex], decoder_errors: &Arc>, reweights: &[(u64, f64)], - ) -> (Vec<(u64, f64)>, Arc>) { + ) -> (Vec<(u64, f64)>, ProjectedErrors) { match self { Self::Identity => { let mut overrides = hashbrown::HashMap::with_capacity(reweights.len()); @@ -405,7 +487,7 @@ impl EdgeProjection { } let mut translated: Vec<_> = overrides.into_iter().collect(); translated.sort_unstable_by_key(|&(edge, _)| edge); - (translated, Arc::clone(decoder_errors)) + (translated, ProjectedErrors::shared(Arc::clone(decoder_errors))) } Self::Merged { decoder_edge_of_original, @@ -421,9 +503,9 @@ impl EdgeProjection { reweighted_decoder_edges.sort_unstable(); reweighted_decoder_edges.dedup(); if reweighted_decoder_edges.is_empty() { - return (vec![], Arc::clone(decoder_errors)); + return (vec![], ProjectedErrors::shared(Arc::clone(decoder_errors))); } - let mut projected_errors = Arc::clone(decoder_errors); + let mut replacements = Vec::with_capacity(reweighted_decoder_edges.len()); let translated = reweighted_decoder_edges .into_iter() .map(|decoder_edge| { @@ -444,13 +526,19 @@ impl EdgeProjection { } } let (elected_original, _) = elected.expect("decoder edge must contain an original edge"); - if projected_errors[decoder_edge] != base_errors[elected_original] { - Arc::make_mut(&mut projected_errors)[decoder_edge].clone_from(&base_errors[elected_original]); + if decoder_errors[decoder_edge] != base_errors[elected_original] { + replacements.push((decoder_edge, base_errors[elected_original].clone())); } (u64::try_from(decoder_edge).unwrap(), combined) }) .collect(); - (translated, projected_errors) + ( + translated, + ProjectedErrors { + baseline: Arc::clone(decoder_errors), + replacements, + }, + ) } } } diff --git a/deq/deq_runtime/src/coordinator/window_coordinator.rs b/deq/deq_runtime/src/coordinator/window_coordinator.rs index d8cb26c4..c97af31a 100644 --- a/deq/deq_runtime/src/coordinator/window_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/window_coordinator.rs @@ -84,7 +84,7 @@ use crate::bin; use crate::coordinator; use crate::coordinator::loss_handler::{RawLossSite, apply_loss_random_imputation, has_loss_model}; use crate::coordinator::reweight_handler::{ - apply_reweights, decode_projected, deduplicate_decoder_input, ignore_edge_isolated_history_vertices, + ProjectedErrors, apply_reweights, decode_projected, deduplicate_decoder_input, ignore_edge_isolated_history_vertices, load_projected_decoder, probability_reweights, }; use crate::coordinator::{ @@ -100,7 +100,9 @@ use crate::misc::index::{ErrorIndex, WILDCARD}; use crate::misc::pauli_frame_tracker::PauliFrameTracker; use crate::misc::relative_program::{self, RelativeMapping, RelativeProgram}; use crate::misc::sync::{TaskCounter, check_or_receiver, get_or_receiver}; -use crate::misc::validation::{validate_outcomes, validate_probability_modifier}; +use crate::misc::validation::{ + apply_check_model_reroutes, apply_error_model_reroutes, validate_outcomes, validate_probability_modifier, +}; use crate::util::BitVector; use binar::{BitVec, BitwiseMut}; use hashbrown::{HashMap, HashSet}; @@ -1423,7 +1425,7 @@ impl WindowCoordinator { committing_cids: &HashSet, window: &HashSet, parity_factor: &blackbox_decoder::ParityFactor, - errors: &[ErrorIndex], + errors: &ProjectedErrors, relative_program: &RelativeProgram, mapping: &RelativeMapping, ) { @@ -1541,7 +1543,7 @@ impl WindowCoordinator { } } - fn global_subgraph_of(mapping: &RelativeMapping, errors: &[ErrorIndex], subgraph: &[u64]) -> Vec<(u64, u64)> { + fn global_subgraph_of(mapping: &RelativeMapping, errors: &ProjectedErrors, subgraph: &[u64]) -> Vec<(u64, u64)> { subgraph .iter() .map(|&ei| { @@ -1655,7 +1657,7 @@ impl WindowCoordinator { relative_program: &RelativeProgram, mapping: &RelativeMapping, span: &Span, - ) -> (blackbox_decoder::ParityFactor, Arc>) { + ) -> (blackbox_decoder::ParityFactor, ProjectedErrors) { // calculate syndrome span.add_event(Event::new("calculate_syndrome")); let syndrome: BitVector = { @@ -1710,7 +1712,7 @@ impl WindowCoordinator { if let Some(ref cache_key) = cache_key { let loaded = self.loaded_decoders.read().await.get(cache_key).cloned(); if let Some(loaded) = loaded { - let probability_reweights = self.shot_probability_reweights(mapping, &loaded.projection.base_errors).await; + let probability_reweights = self.projected_shot_probability_reweights(mapping, &loaded.projection).await; let projected = self .loss_handler .project_shot(&loaded.projection, &probability_reweights, &loss_sites); @@ -1738,9 +1740,8 @@ impl WindowCoordinator { // and instantiate such a decoder let (decoding_hypergraph, errors) = self.decoding_hypergraph(committing_cids, relative_program, mapping).await; - let probability_reweights = self.shot_probability_reweights(mapping, &errors).await; - let Some(cache_key) = cache_key else { + let probability_reweights = self.shot_probability_reweights(mapping, &errors).await; // Without a persistent decoder every shot ships its whole problem, so // probability updates are applied before loss derives its live priors. // Loss edges whose checks fall outside the window are absent from the @@ -1770,7 +1771,7 @@ impl WindowCoordinator { if self.config.assert_parity_factor { assert_parity_factor(&decoding_hypergraph, &parity_factor, &syndrome); } - return (parity_factor, errors); + return (parity_factor, errors.into()); }; // Load the stable base graph before any shot's loss is applied. Keep @@ -1787,6 +1788,7 @@ impl WindowCoordinator { ) .await .unwrap(); + let probability_reweights = self.projected_shot_probability_reweights(mapping, &loaded.projection).await; let decode_syndrome = loaded.project_syndrome(syndrome); let projected = self .loss_handler @@ -1861,13 +1863,31 @@ impl WindowCoordinator { error_reference: &[ErrorIndex], ) -> Vec<(u64, f64)> { let gadgets = self.gadgets.read().await; - let mut modifiers: Vec<_> = gadgets - .values() + probability_reweights(error_reference, Self::shot_probability_modifiers(mapping, &gadgets)) + } + + async fn projected_shot_probability_reweights( + &self, + mapping: &RelativeMapping, + projection: &crate::coordinator::reweight_handler::DecodeProjection, + ) -> Vec<(u64, f64)> { + let gadgets = self.gadgets.read().await; + projection.probability_reweights(Self::shot_probability_modifiers(mapping, &gadgets)) + } + + fn shot_probability_modifiers<'a>( + mapping: &RelativeMapping, + gadgets: &'a HashMap, + ) -> Vec<(usize, &'a bin::ProbabilityModifier)> { + let mut modifiers: Vec<_> = mapping + .global_gid_of + .iter() + .filter_map(|gid| gadgets.get(gid)) .flat_map(|gadget| gadget.probability_modifiers.iter()) .filter_map(|(eid, modifier)| mapping.local_eid_of.get(eid).map(|&local_eid| (local_eid, modifier))) .collect(); modifiers.sort_unstable_by_key(|&(local_eid, _)| local_eid); - probability_reweights(error_reference, modifiers) + modifiers } async fn decoding_hypergraph( @@ -2439,6 +2459,13 @@ impl coordinator::coordinator_server::Coordinator for WindowCoordinator { let check_model_types = self.check_model_types.read().await; let mut gadgets = self.gadgets.write().await; let mut check_models = self.check_models.write().await; + let check_model_type = check_model_types + .get(&check_model.ctype) + .ok_or_else(|| Status::not_found(format!("ctype={}", check_model.ctype)))?; + let modified_remote = Arc::new( + apply_check_model_reroutes(&check_model_type.remote_gadgets, check_model.modifier.as_ref()) + .map_err(Status::invalid_argument)?, + ); let cid = if check_model.cid == 0 { // Auto-assign: find next unused cid let mut next_cid = self.next_cid.lock().await; @@ -2452,9 +2479,6 @@ impl coordinator::coordinator_server::Coordinator for WindowCoordinator { // User-provided cid check_model.cid }; - let check_model_type = check_model_types - .get(&check_model.ctype) - .ok_or_else(|| Status::not_found(format!("ctype={}", check_model.ctype)))?; let gadget = gadgets.get_mut(&check_model.gid).ok_or_else(|| { Status::invalid_argument(format!("cid={cid} binding to unknown gid={}", check_model.gid)) })?; @@ -2467,18 +2491,6 @@ impl coordinator::coordinator_server::Coordinator for WindowCoordinator { let mut pending_by_gid = self.pending_referring_by_gid.lock().await; pending_by_gid.remove(&check_model.gid).unwrap_or_default() }; - // apply the modifier reroutes - let mut modified_remote: Vec<_> = check_model_type.remote_gadgets.iter().cloned().map(Some).collect(); - if let Some(modifier) = &check_model.modifier { - for rereoute in &modifier.reroute_remote_gadgets { - // extend the remote_gadgets vector if necessary - while (rereoute.remote_gadget_index as usize) >= modified_remote.len() { - modified_remote.push(None); - } - modified_remote[rereoute.remote_gadget_index as usize] = rereoute.value.clone(); - } - } - let modified_remote = Arc::new(modified_remote); let mut check_model = check_model; check_model.cid = cid; check_models.insert( @@ -2597,16 +2609,10 @@ impl coordinator::coordinator_server::Coordinator for WindowCoordinator { validate_probability_modifier(probability_modifier, error_model_type.errors.len()) .map_err(Status::invalid_argument)?; } - let mut modified_remote: Vec<_> = error_model_type.remote_check_models.iter().cloned().map(Some).collect(); - if let Some(modifier) = &error_model.modifier { - for rereoute in &modifier.reroute_remote_check_models { - while (rereoute.remote_check_model_index as usize) >= modified_remote.len() { - modified_remote.push(None); - } - modified_remote[rereoute.remote_check_model_index as usize] = rereoute.value.clone(); - } - } - let modified_remote = Arc::new(modified_remote); + let modified_remote = Arc::new( + apply_error_model_reroutes(&error_model_type.remote_check_models, error_model.modifier.as_ref()) + .map_err(Status::invalid_argument)?, + ); // Acquire locks in ordering: gadgets(read) → check_models(write) → // error_models(write). All three are held throughout to ensure diff --git a/deq/deq_runtime/src/misc/validation.rs b/deq/deq_runtime/src/misc/validation.rs index c2f2c9e3..13c421df 100644 --- a/deq/deq_runtime/src/misc/validation.rs +++ b/deq/deq_runtime/src/misc/validation.rs @@ -6,6 +6,8 @@ use crate::decoder::decoder_features::DecoderFeatures; use crate::util::BitVector; use tonic::Status; +const REMOTE_REROUTE_INDEX_LIMIT: u64 = 65_536; + /// Validate that a bit vector has exactly the bytes required by its declared /// number of bits. pub fn validate_data_len(bit_vector: &BitVector, name: &str) -> Result<(), String> { @@ -72,6 +74,93 @@ pub fn validate_probability_modifier(modifier: &ProbabilityModifier, error_count Ok(()) } +fn remote_reroute_position(index: u64, kind: &str) -> Result { + if index >= REMOTE_REROUTE_INDEX_LIMIT { + return Err(format!( + "{kind} reroute index {index} must be less than {REMOTE_REROUTE_INDEX_LIMIT}" + )); + } + usize::try_from(index).map_err(|_| format!("{kind} reroute index does not fit usize")) +} + +fn validate_remote_reroutes(indices: impl IntoIterator, kind: &str) -> Result<(), String> { + for index in indices { + remote_reroute_position(index, kind)?; + } + Ok(()) +} + +pub(crate) fn validate_check_model_reroutes( + modifier: Option<&crate::bin::check_model::CheckModelModifier>, +) -> Result<(), String> { + validate_remote_reroutes( + modifier + .into_iter() + .flat_map(|modifier| &modifier.reroute_remote_gadgets) + .map(|reroute| reroute.remote_gadget_index), + "remote gadget", + ) +} + +pub(crate) fn validate_error_model_reroutes( + modifier: Option<&crate::bin::error_model::ErrorModelModifier>, +) -> Result<(), String> { + validate_remote_reroutes( + modifier + .into_iter() + .flat_map(|modifier| &modifier.reroute_remote_check_models) + .map(|reroute| reroute.remote_check_model_index), + "remote check model", + ) +} + +fn apply_remote_reroutes( + base: &[T], + reroutes: impl IntoIterator)>, + kind: &str, +) -> Result>, String> +where + T: Clone, +{ + let mut modified: Vec<_> = base.iter().cloned().map(Some).collect(); + for (index, value) in reroutes { + let index = remote_reroute_position(index, kind)?; + if index >= modified.len() { + modified.resize_with(index + 1, || None); + } + modified[index] = value; + } + Ok(modified) +} + +pub(crate) fn apply_check_model_reroutes( + base: &[crate::bin::check_model_type::RemoteGadget], + modifier: Option<&crate::bin::check_model::CheckModelModifier>, +) -> Result>, String> { + apply_remote_reroutes( + base, + modifier + .into_iter() + .flat_map(|modifier| &modifier.reroute_remote_gadgets) + .map(|reroute| (reroute.remote_gadget_index, reroute.value.clone())), + "remote gadget", + ) +} + +pub(crate) fn apply_error_model_reroutes( + base: &[crate::bin::error_model_type::RemoteCheckModel], + modifier: Option<&crate::bin::error_model::ErrorModelModifier>, +) -> Result>, String> { + apply_remote_reroutes( + base, + modifier + .into_iter() + .flat_map(|modifier| &modifier.reroute_remote_check_models) + .map(|reroute| (reroute.remote_check_model_index, reroute.value.clone())), + "remote check model", + ) +} + impl LossHandler { /// Validate that the decoder capabilities support this loss strategy for /// the supplied gadget library. @@ -273,6 +362,41 @@ mod tests { assert!(validate_probability_modifier(&invalid_probability, 2).is_err()); } + #[test] + fn remote_reroutes_extend_within_the_spec_bound() { + let modifier = crate::bin::check_model::CheckModelModifier { + reroute_remote_gadgets: vec![crate::bin::check_model::check_model_modifier::RerouteRemoteGadget { + remote_gadget_index: 5, + value: Some(crate::bin::check_model_type::RemoteGadget::default()), + }], + }; + + let modified = apply_check_model_reroutes(&[], Some(&modifier)).unwrap(); + + assert_eq!(modified.len(), 6); + assert!(modified[5].is_some()); + } + + #[test] + fn remote_reroutes_reject_indices_at_the_spec_bound() { + let check_modifier = crate::bin::check_model::CheckModelModifier { + reroute_remote_gadgets: vec![crate::bin::check_model::check_model_modifier::RerouteRemoteGadget { + remote_gadget_index: REMOTE_REROUTE_INDEX_LIMIT, + value: None, + }], + }; + let error_modifier = crate::bin::error_model::ErrorModelModifier { + reroute_remote_check_models: vec![crate::bin::error_model::error_model_modifier::RerouteRemoteCheckModel { + remote_check_model_index: REMOTE_REROUTE_INDEX_LIMIT, + value: None, + }], + ..Default::default() + }; + + assert!(apply_check_model_reroutes(&[], Some(&check_modifier)).is_err()); + assert!(apply_error_model_reroutes(&[], Some(&error_modifier)).is_err()); + } + #[test] fn handoff_requires_loss_support_only_for_loss_bearing_libraries() { assert!( diff --git a/deq/deq_runtime/tests/mock_coordinator_test.rs b/deq/deq_runtime/tests/mock_coordinator_test.rs index bc776f0d..a67f3fce 100644 --- a/deq/deq_runtime/tests/mock_coordinator_test.rs +++ b/deq/deq_runtime/tests/mock_coordinator_test.rs @@ -136,6 +136,33 @@ async fn test_execute_creates_check_model() { assert_eq!(state.next_cid, 2); } +#[tokio::test] +async fn test_execute_rejects_oversized_remote_reroute() { + let coordinator = MockCoordinator::new(); + load_test_library(&coordinator).await; + let mut check_model = make_check_model(0, 2, 1); + check_model.modifier = Some(bin::check_model::CheckModelModifier { + reroute_remote_gadgets: vec![bin::check_model::check_model_modifier::RerouteRemoteGadget { + remote_gadget_index: 65_536, + value: None, + }], + }); + + let error = coordinator_server::Coordinator::execute( + &*coordinator, + Request::new(bin::Instruction { + create: Some(instruction::Create::CheckModel(check_model)), + }), + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + let state = coordinator.state.read().await; + assert!(state.check_models.is_empty()); + assert_eq!(state.next_cid, 1); +} + #[tokio::test] async fn test_execute_creates_error_model() { let coordinator = MockCoordinator::new(); diff --git a/deq/deq_runtime/tests/monolithic_coordinator_test.rs b/deq/deq_runtime/tests/monolithic_coordinator_test.rs index a9c4c7b5..061d4c76 100644 --- a/deq/deq_runtime/tests/monolithic_coordinator_test.rs +++ b/deq/deq_runtime/tests/monolithic_coordinator_test.rs @@ -495,6 +495,45 @@ async fn test_invalid_error_model_probability_modifier_is_atomic() { assert_eq!(*coordinator.next_eid.lock().await, 1); } +#[tokio::test] +async fn test_oversized_remote_reroute_is_atomic() { + let coordinator = make_coordinator(make_mock_decoder()); + Coordinator::load_library(&coordinator, Request::new(make_canonical_library())) + .await + .unwrap(); + let gid = Coordinator::execute( + &coordinator, + Request::new(bin::Instruction { + create: Some(instruction::Create::Gadget(make_gadget(0, 1, vec![]))), + }), + ) + .await + .unwrap() + .into_inner() + .id; + let mut check_model = make_check_model(0, 1, gid); + check_model.modifier = Some(bin::check_model::CheckModelModifier { + reroute_remote_gadgets: vec![bin::check_model::check_model_modifier::RerouteRemoteGadget { + remote_gadget_index: 65_536, + value: None, + }], + }); + + let error = Coordinator::execute( + &coordinator, + Request::new(bin::Instruction { + create: Some(instruction::Create::CheckModel(check_model)), + }), + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(coordinator.check_models.read().await.is_empty()); + assert!(coordinator.gadgets.read().await[&gid].binding_cid.borrow().is_none()); + assert_eq!(*coordinator.next_cid.lock().await, 1); +} + /// Creates the default_library from the Python test suite (library_validator_test.py). /// This is a complete library with: /// - 2 port types (rep-code with 1 observable, surface-code with 2 observables) diff --git a/deq/deq_runtime/tests/unit/reweight_handler_test.rs b/deq/deq_runtime/tests/unit/reweight_handler_test.rs index 352da6bb..d7adc489 100644 --- a/deq/deq_runtime/tests/unit/reweight_handler_test.rs +++ b/deq/deq_runtime/tests/unit/reweight_handler_test.rs @@ -11,7 +11,49 @@ fn sparse_probability_values_override_dense_values() { sparse_probabilities: vec![0.3], }; - assert_eq!(probability_reweights(&errors, [(4, &modifier)]), vec![(0, 0.3), (1, 0.2)]); + let expected = vec![(0, 0.3), (1, 0.2)]; + assert_eq!(probability_reweights(&errors, [(4, &modifier)]), expected); + + let lookup = ErrorEdgeLookup::new(&errors); + assert_eq!(lookup.project([(4, &modifier)]), expected); +} + +#[test] +fn cached_lookup_projects_only_sparse_modifier_entries() { + let errors: Vec<_> = (0..1_000).map(|error_index| ErrorIndex { eid: 4, error_index }).collect(); + let lookup = ErrorEdgeLookup::new(&errors); + let modifier = bin::ProbabilityModifier { + probabilities: vec![], + sparse_indices: vec![731], + sparse_probabilities: vec![0.42], + }; + + assert_eq!(lookup.project([(4, &modifier)]), vec![(731, 0.42)]); +} + +#[test] +fn cached_lookup_is_sparse_in_eid_and_preserves_error_index_gaps() { + let errors = vec![ + ErrorIndex { + eid: 1_000_000_000, + error_index: 0, + }, + ErrorIndex { + eid: 1_000_000_000, + error_index: 2, + }, + ErrorIndex { eid: 7, error_index: 3 }, + ]; + let lookup = ErrorEdgeLookup::new(&errors); + let modifier = bin::ProbabilityModifier { + probabilities: vec![], + sparse_indices: vec![1, 2], + sparse_probabilities: vec![0.13, 0.42], + }; + + assert_eq!(lookup.edges_by_eid.len(), 2); + assert_eq!(lookup.edges_by_eid[&1_000_000_000].len(), 3); + assert_eq!(lookup.project([(1_000_000_000, &modifier)]), vec![(1, 0.42)]); } #[test] @@ -240,10 +282,13 @@ fn identity_grouping_matches_deduplicating_a_collision_free_graph() { assert_eq!(identity.hypergraph, collapsed.hypergraph); assert_eq!(identity.representatives, collapsed.representatives); let reweights = [(0, 0.15), (2, 0.3)]; - assert_eq!( - identity_projection.project_reweights(&reweights), - collapsed_projection.project_reweights(&reweights) - ); + let (identity_reweights, identity_errors) = identity_projection.project_reweights(&reweights); + let (collapsed_reweights, collapsed_errors) = collapsed_projection.project_reweights(&reweights); + assert_eq!(identity_reweights, collapsed_reweights); + assert_eq!(identity_errors.len(), collapsed_errors.len()); + for index in 0..identity_errors.len() { + assert_eq!(identity_errors[index], collapsed_errors[index]); + } } #[test] @@ -270,11 +315,13 @@ fn shot_reweight_changes_the_merged_correction_representative() { assert_eq!(prepared.representatives[0], ErrorIndex { eid: 0, error_index: 7 }); let (_, unchanged_errors) = projection.project_reweights(&[(0, 0.2)]); - assert!(Arc::ptr_eq(&unchanged_errors, &projection.decoder_errors)); + assert!(Arc::ptr_eq(&unchanged_errors.baseline, &projection.decoder_errors)); + assert!(unchanged_errors.replacements.is_empty()); let (reweights, projected_errors) = projection.project_reweights(&[(1, 0.4)]); assert_eq!(projected_errors[0], ErrorIndex { eid: 0, error_index: 99 }); + assert_eq!(projected_errors.replacements.len(), 1); assert_eq!(reweights.len(), 1); assert!((reweights[0].1 - exclusive_probability_of(0.3, 0.4)).abs() < 1e-12); } diff --git a/deq/deq_visualizer/src/misc/VisualizerData.ts b/deq/deq_visualizer/src/misc/VisualizerData.ts index aab80fb7..1d030a1d 100644 --- a/deq/deq_visualizer/src/misc/VisualizerData.ts +++ b/deq/deq_visualizer/src/misc/VisualizerData.ts @@ -18,6 +18,21 @@ export interface InfoPaneState { let nextPaneId = 0 +const REMOTE_REROUTE_INDEX_LIMIT = 65_536n + +function extendForReroute(values: T[], rerouteIndex: bigint, createPlaceholder: () => T, kind: string): number { + assert(rerouteIndex < REMOTE_REROUTE_INDEX_LIMIT, `${kind} reroute index ${rerouteIndex} must be less than ${REMOTE_REROUTE_INDEX_LIMIT}`) + const index = Number(rerouteIndex) + const previousLength = values.length + if (index >= previousLength) { + values.length = index + 1 + for (let position = previousLength; position <= index; position++) { + values[position] = createPlaceholder() + } + } + return index +} + export class VisualizerData { readonly library: pb2.Library displayMode: Map> @@ -270,10 +285,13 @@ export class VisualizerData { // apply the modifier const remoteGadgets = [...checkModelType.remoteGadgets] checkModel.modifier?.rerouteRemoteGadgets.forEach((reroute) => { - while (reroute.remoteGadgetIndex >= remoteGadgets.length) { - remoteGadgets.push(pb2.CheckModelType_RemoteGadget.create({ tag: 'placeholder' })) - } - remoteGadgets[Number(reroute.remoteGadgetIndex)] = reroute.value! + const index = extendForReroute( + remoteGadgets, + reroute.remoteGadgetIndex, + () => pb2.CheckModelType_RemoteGadget.create({ tag: 'placeholder' }), + 'remote gadget', + ) + remoteGadgets[index] = reroute.value! }) const remoteGadgetGidVec: bigint[] = [] remoteGadgetGidVec.length = remoteGadgets.length @@ -380,10 +398,13 @@ export class VisualizerData { // apply the modifier const remoteCheckModels = [...errorModelType.remoteCheckModels] errorModel.modifier?.rerouteRemoteCheckModels.forEach((reroute) => { - while (reroute.remoteCheckModelIndex >= remoteCheckModels.length) { - remoteCheckModels.push(pb2.ErrorModelType_RemoteCheckModel.create({ tag: 'placeholder' })) - } - remoteCheckModels[Number(reroute.remoteCheckModelIndex)] = reroute.value! + const index = extendForReroute( + remoteCheckModels, + reroute.remoteCheckModelIndex, + () => pb2.ErrorModelType_RemoteCheckModel.create({ tag: 'placeholder' }), + 'remote check model', + ) + remoteCheckModels[index] = reroute.value! }) const remoteCheckModelCidVec: bigint[] = [] remoteCheckModelCidVec.length = remoteCheckModels.length From f4e299f2441152f77b847fb66d3dc12fe77241c2 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 16:54:53 -0700 Subject: [PATCH 140/157] add loss handler --- .../src/coordinator/loss_handler.rs | 569 ++++++++++++++++++ 1 file changed, 569 insertions(+) create mode 100644 deq/deq_runtime/src/coordinator/loss_handler.rs diff --git a/deq/deq_runtime/src/coordinator/loss_handler.rs b/deq/deq_runtime/src/coordinator/loss_handler.rs new file mode 100644 index 00000000..d8fa6cb9 --- /dev/null +++ b/deq/deq_runtime/src/coordinator/loss_handler.rs @@ -0,0 +1,569 @@ +//! Loss-handling strategy: what a coordinator does with heralded atom losses. +//! +//! The coordinator config exposes two fields — a [`LossStrategy`] and an opaque +//! `loss_config` JSON blob — instead of one flag per mechanism. The blob is +//! parsed and validated *once*, here, into a [`LossHandler`]; a coordinator then +//! only asks the handler what to do. Options that belong to another strategy are +//! rejected at construction rather than silently ignored, which is the failure +//! mode the flat `loss_envelope_matching` / `loss_weight_fraction` / +//! `loss_mle_decoding` triple invited. + +use super::reweight_handler::{DecodeProjection, ProjectedErrors, apply_reweights}; +use crate::decoder::blackbox_decoder; +use crate::jit::loss_compiler::CrossGadgetLossSite; +use crate::misc::index::ErrorIndex; +use crate::misc::util::{exclusive_probability_of, probability_of_weight, weight_of}; +use hashbrown::{HashMap, HashSet}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +#[cfg(feature = "cli")] +use structdoc::StructDoc; + +/// Default envelope-matching exponent. +/// +/// Swept on a `d = 3` mid-swap memory (`eta = 0.7`) over `f` in `[0.05, 1.0]`. +/// At low loss rates the logical error rate is flat in `f` — a 10x range moves it +/// by less than noise — because most edges are activated by a single site. At +/// `p_loss` of `1-2%`, where several sites share an edge, the curve is a clear +/// `5-6 sigma` bowl with a broad optimum at `0.5-0.7`: below it the loss edges +/// get cheap enough for the decoder to chain them, above it (up to the +/// undiscounted `f = 1`) the envelope stops helping. `0.5` is best or tied-best +/// at every rate measured, and coincides with the reference's space-like value. +const DEFAULT_WEIGHT_FRACTION: f64 = 0.5; + +/// One possible loss site projected onto a decode window, before its generator +/// indices are mapped onto decoder hyperedges. +/// +/// `local_eid` identifies the error model whose generator list the site +/// addresses. It is `None` for structural pass-through sites, which remain in +/// the graph to preserve probability, herald, and child relationships. +pub(crate) struct RawLossSite { + pub(crate) local_eid: Option, + pub(crate) probability: f64, + pub(crate) source_generators: Vec, + pub(crate) continuation_generators: Vec, + pub(crate) children: Vec, + pub(crate) heralds: Vec, +} + +impl RawLossSite { + pub(crate) fn from_compiled(site: CrossGadgetLossSite, local_eid: Option) -> Self { + Self { + local_eid, + probability: site.probability, + source_generators: site.source_generators, + continuation_generators: site.continuation_generators, + children: site.children, + heralds: site.heralds, + } + } +} + +/// Map window-local loss generators onto decoder hyperedge indices. +/// +/// Generators absent from `error_reference` have no decoder-visible effect and +/// are omitted. Child and herald indices already address the flat shot-local +/// loss payload and are preserved. +pub(crate) fn build_loss_info(loss_sites: &[RawLossSite], error_reference: &[ErrorIndex]) -> blackbox_decoder::LossInfo { + let mut edge_of: hashbrown::HashMap<(usize, usize), u64> = hashbrown::HashMap::with_capacity(error_reference.len()); + for (index, error) in error_reference.iter().enumerate() { + let index = u64::try_from(index).unwrap(); + edge_of.insert((error.eid, error.error_index), index); + } + let map_edges = |local_eid: Option, generators: &[usize]| -> Vec { + let Some(local_eid) = local_eid else { + return vec![]; + }; + generators + .iter() + .filter_map(|&generator| edge_of.get(&(local_eid, generator)).copied()) + .collect::>() + .into_iter() + .collect() + }; + let sites = loss_sites + .iter() + .map(|site| blackbox_decoder::LossSite { + source_edges: map_edges(site.local_eid, &site.source_generators), + continuation_edges: map_edges(site.local_eid, &site.continuation_generators), + probability: site.probability, + children: site.children.iter().map(|&child| u64::try_from(child).unwrap()).collect(), + heralds: site.heralds.iter().map(|&herald| u64::try_from(herald).unwrap()).collect(), + }) + .collect(); + blackbox_decoder::LossInfo { sites } +} + +/// Replace each lost measurement outcome with an independent random bit before +/// syndrome construction. +pub fn apply_loss_random_imputation( + outcomes: &mut crate::util::BitVector, + loss_mask: &crate::util::BitVector, + rng: &mut R, +) { + use crate::misc::bit_vector; + use rand::RngExt; + assert_eq!( + outcomes.size, loss_mask.size, + "loss_mask size {} does not match outcomes size {}", + loss_mask.size, outcomes.size, + ); + for index in 0..outcomes.size { + if bit_vector::get_bit(loss_mask, index) { + bit_vector::set_bit(outcomes, index, rng.random::()); + } + } +} + +/// Whether `gtype` carries the static model needed by the loss pipeline. +pub(crate) fn has_loss_model( + gadget_types: &hashbrown::HashMap>, + gtype: u64, +) -> bool { + gadget_types.get(>ype).is_some_and(|gadget| gadget.loss_model.is_some()) +} + +/// How a coordinator handles the atom losses heralded by ``Outcomes.loss_mask``. +/// +/// Independent of ``loss_random_imputation``, which decides what the *syndrome* +/// does with a lost measurement bit and applies under every strategy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "cli", derive(StructDoc))] +#[serde(rename_all = "snake_case")] +pub enum LossStrategy { + /// Discard the loss information. The lost measurements still take part in + /// the syndrome (imputed when ``loss_random_imputation`` is on), but the + /// decoder is never told a loss happened. Takes no options. + Ignore, + /// Raise each observed loss's Pauli-envelope generator edges from their + /// (typically zero) prior to a weight comparable to ordinary edges, softly + /// enforcing the per-atom exclusivity, then hand the decoder an ordinary + /// problem. The default: it needs no decoder support, and costs nothing on a + /// program whose gadget types declare no loss model. Options: + /// ``weight_fraction`` and ``scale``. + #[default] + Reweight, + /// Pass the assembled loss sites to the decoder as a structured + /// [`LossInfo`](crate::decoder::blackbox_decoder::LossInfo) and let it do + /// the work — the loss-aware decode path, used by decoders such as the + /// ``mle_loss_decoder``. Takes no options. + Handoff, +} + +impl LossStrategy { + fn name(self) -> &'static str { + match self { + Self::Ignore => "ignore", + Self::Reweight => "reweight", + Self::Handoff => "handoff", + } + } +} + +/// Options accepted by [`LossStrategy::Ignore`] and [`LossStrategy::Handoff`]. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct NoOptions {} + +/// How observed losses reweight their Pauli-envelope edges. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EnvelopeReweightPolicy { + /// Exponent applied to each observed loss's Pauli-envelope edges, + /// equivalently the fraction of the edge's own scale weight it is lowered + /// to. Near ``1`` the loss edges barely help; near ``0`` they become free + /// and the decoder chains them into logical errors. + #[serde(default = "default_weight_fraction")] + pub weight_fraction: f64, + /// Which scale weight the fraction is taken of. + #[serde(default)] + pub scale: ReweightScale, +} + +/// Which scale weight [`EnvelopeReweightPolicy`] lowers an activated edge to. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "cli", derive(StructDoc))] +#[serde(rename_all = "snake_case")] +pub enum ReweightScale { + /// The edge's own prior combined with the probability a loss activates it. + #[default] + Local, + /// The mean weight of the graph's regular edges -- the reference + /// construction, kept for comparison. + GlobalMean, + /// The mean weight of the regular edges adjacent to the activated edge: the + /// reference's semantics without a graph-wide statistic. + NeighbourhoodMean, +} + +impl Default for EnvelopeReweightPolicy { + fn default() -> Self { + Self { + weight_fraction: DEFAULT_WEIGHT_FRACTION, + scale: ReweightScale::Local, + } + } +} + +impl EnvelopeReweightPolicy { + #[must_use] + pub fn new(weight_fraction: f64) -> Self { + Self { + weight_fraction, + scale: ReweightScale::Local, + } + } + + /// Apply the configured fraction to a supplied scale weight. + #[must_use] + pub fn scaled_probability(self, scale_weight: f64) -> f64 { + probability_of_weight(self.weight_fraction * scale_weight.max(0.0)) + } + + /// Reweight against the edge's own prior combined with its activation. + #[must_use] + pub fn locally_reweighted_probability(self, edge_probability: f64, activation_probability: f64) -> f64 { + let scale = exclusive_probability_of(edge_probability, activation_probability); + probability_of_weight(self.weight_fraction * weight_of(scale).max(0.0)) + } +} + +fn loss_reweights( + loss: &blackbox_decoder::LossInfo, + hypergraph: &blackbox_decoder::DecodingHypergraph, + policy: EnvelopeReweightPolicy, +) -> Vec<(u64, f64)> { + let accumulated = accumulated_site_probabilities(&loss.sites); + let prior_of = |edge: u64| -> f64 { + hypergraph + .hyperedges + .get(edge as usize) + .map_or(0.0, |hyperedge| hyperedge.probability) + }; + + let mut order = Vec::new(); + let mut activation_of: HashMap = HashMap::new(); + let mut activate = |edge: u64, site_probability: f64, order: &mut Vec| match activation_of.entry(edge) { + hashbrown::hash_map::Entry::Occupied(mut slot) => { + let combined = exclusive_probability_of(*slot.get(), site_probability); + slot.insert(combined); + } + hashbrown::hash_map::Entry::Vacant(slot) => { + slot.insert(site_probability); + order.push(edge); + } + }; + for (index, site) in loss.sites.iter().enumerate() { + for &edge in &site.source_edges { + activate(edge, site.probability, &mut order); + } + for &edge in &site.continuation_edges { + activate(edge, accumulated[index], &mut order); + } + } + + let neighbourhood = if policy.scale == ReweightScale::NeighbourhoodMean { + Some(NeighbourhoodScale::new(hypergraph)) + } else { + None + }; + let global_mean = if policy.scale == ReweightScale::GlobalMean { + regular_mean_weight(hypergraph) + } else { + 0.0 + }; + + order + .into_iter() + .map(|edge| { + let activation = activation_of[&edge]; + let probability = match policy.scale { + ReweightScale::Local => policy.locally_reweighted_probability(prior_of(edge), activation), + ReweightScale::GlobalMean => policy.scaled_probability(global_mean), + ReweightScale::NeighbourhoodMean => { + match neighbourhood.as_ref().and_then(|scale| scale.mean_weight(edge, hypergraph)) { + Some(mean) => policy.scaled_probability(mean), + None => policy.locally_reweighted_probability(prior_of(edge), activation), + } + } + }; + (edge, probability) + }) + .collect() +} + +/// Adjacency index for the optional neighbourhood-mean reweighting policy. +/// +/// Each syndrome vertex maps to the decoder edges incident on it. To find a +/// scale for one activated edge, [`Self::mean_weight`] gathers every edge that +/// shares at least one of its syndrome vertices. An edge can be found through +/// several vertices, so the collected indices are deduplicated before their +/// weights are averaged. +struct NeighbourhoodScale { + /// Decoder-edge indices keyed by incident syndrome vertex. + edges_of_vertex: HashMap>, +} + +impl NeighbourhoodScale { + fn new(hypergraph: &blackbox_decoder::DecodingHypergraph) -> Self { + let mut edges_of_vertex: HashMap> = HashMap::new(); + for (index, hyperedge) in hypergraph.hyperedges.iter().enumerate() { + let index = u32::try_from(index).expect("hyperedge index must fit in u32"); + for &vertex in &hyperedge.vertices { + edges_of_vertex.entry(vertex).or_default().push(index); + } + } + Self { edges_of_vertex } + } + + fn mean_weight(&self, edge: u64, hypergraph: &blackbox_decoder::DecodingHypergraph) -> Option { + let hyperedge = hypergraph.hyperedges.get(edge as usize)?; + let mut seen = HashSet::new(); + let mut total = 0.0; + for vertex in &hyperedge.vertices { + for &neighbour in self.edges_of_vertex.get(vertex).into_iter().flatten() { + if seen.insert(neighbour) { + total += weight_of(hypergraph.hyperedges[neighbour as usize].probability); + } + } + } + (!seen.is_empty()).then(|| total / seen.len() as f64) + } +} + +fn regular_mean_weight(hypergraph: &blackbox_decoder::DecodingHypergraph) -> f64 { + let mut total = 0.0; + let mut count = 0usize; + for hyperedge in &hypergraph.hyperedges { + total += weight_of(hyperedge.probability); + count += 1; + } + if count == 0 { 0.0 } else { total / count as f64 } +} + +fn accumulated_site_probabilities(sites: &[blackbox_decoder::LossSite]) -> Vec { + let mut parents: Vec> = vec![Vec::new(); sites.len()]; + for (index, site) in sites.iter().enumerate() { + for &child in &site.children { + if let Some(slot) = parents.get_mut(child as usize) { + slot.push(index); + } + } + } + let mut accumulated = vec![None; sites.len()]; + let mut visiting = vec![false; sites.len()]; + for index in 0..sites.len() { + accumulate_site(index, sites, &parents, &mut accumulated, &mut visiting); + } + accumulated.into_iter().map(|value| value.unwrap_or(0.0)).collect() +} + +fn accumulate_site( + index: usize, + sites: &[blackbox_decoder::LossSite], + parents: &[Vec], + accumulated: &mut [Option], + visiting: &mut [bool], +) -> f64 { + if let Some(value) = accumulated[index] { + return value; + } + if visiting[index] { + return 0.0; + } + visiting[index] = true; + let mut total = sites[index].probability; + for &parent in &parents[index] { + total = exclusive_probability_of(total, accumulate_site(parent, sites, parents, accumulated, visiting)); + } + visiting[index] = false; + accumulated[index] = Some(total); + total +} + +fn default_weight_fraction() -> f64 { + DEFAULT_WEIGHT_FRACTION +} + +/// A validated loss strategy together with its options. +/// +/// Build with [`LossHandler::new`] at coordinator construction; every later +/// decision is a total function on this value, so a decode never fails on +/// configuration. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum LossHandler { + Ignore, + Reweight(EnvelopeReweightPolicy), + Handoff, +} + +pub(crate) struct ProjectedShot { + pub(crate) reweights: Vec, + pub(crate) loss: Option, + pub(crate) errors: ProjectedErrors, +} + +/// Matches [`LossStrategy::default`]: reweight at [`DEFAULT_WEIGHT_FRACTION`]. +impl Default for LossHandler { + fn default() -> Self { + Self::Reweight(EnvelopeReweightPolicy::default()) + } +} + +impl LossHandler { + /// Parse and validate `config` against `strategy`. + /// + /// Returns a human-readable error when the blob carries an option the + /// chosen strategy does not accept, or a value outside its domain. + pub fn new(strategy: LossStrategy, config: serde_json::Value) -> Result { + let config = if config.is_null() { + serde_json::Value::Object(serde_json::Map::new()) + } else { + config + }; + let name = strategy.name(); + match strategy { + LossStrategy::Ignore | LossStrategy::Handoff => { + serde_json::from_value::(config) + .map_err(|e| format!("loss_config is not valid for loss_strategy \"{name}\", which takes none: {e}"))?; + Ok(if strategy == LossStrategy::Ignore { + Self::Ignore + } else { + Self::Handoff + }) + } + LossStrategy::Reweight => { + let policy: EnvelopeReweightPolicy = serde_json::from_value(config) + .map_err(|e| format!("loss_config is not valid for loss_strategy \"{name}\": {e}"))?; + if !(policy.weight_fraction > 0.0 && policy.weight_fraction <= 1.0) { + return Err(format!( + "loss_config.weight_fraction must lie in (0, 1], got {}", + policy.weight_fraction + )); + } + Ok(Self::Reweight(policy)) + } + } + } + + /// Whether observed losses have to be recorded per gadget and assembled into + /// loss sites. False only for [`LossStrategy::Ignore`], which lets the + /// coordinator skip the whole loss pipeline. + pub fn tracks_losses(self) -> bool { + !matches!(self, Self::Ignore) + } + + /// Whether the assembled sites go to the decoder as a structured `LossInfo` + /// rather than being flattened into reweighted hyperedges. + pub fn hands_off_to_decoder(self) -> bool { + matches!(self, Self::Handoff) + } + + /// The configured policy when this handler reweights loss envelopes. + pub fn reweight_policy(self) -> Option { + match self { + Self::Reweight(policy) => Some(policy), + Self::Ignore | Self::Handoff => None, + } + } + + /// Apply this strategy to loss sites projected onto a freshly built graph. + pub(crate) fn apply_sites( + self, + mut hypergraph: blackbox_decoder::DecodingHypergraph, + loss_sites: &[RawLossSite], + error_reference: &[ErrorIndex], + ) -> (blackbox_decoder::DecodingHypergraph, Option) { + if !self.tracks_losses() || loss_sites.is_empty() { + return (hypergraph, None); + } + let loss = build_loss_info(loss_sites, error_reference); + if self.hands_off_to_decoder() { + return (hypergraph, Some(loss)); + } + let policy = self.reweight_policy().expect("non-ignore, non-handoff handler must reweight"); + for (edge, probability) in loss_reweights(&loss, &hypergraph, policy) { + if let Some(hyperedge) = hypergraph.hyperedges.get_mut(edge as usize) { + hyperedge.probability = probability; + } + } + (hypergraph, None) + } + + /// Project one shot's probability updates and loss onto a loaded graph. + pub(crate) fn project_shot( + self, + projection: &DecodeProjection, + probability_reweights: &[(u64, f64)], + loss_sites: &[RawLossSite], + ) -> ProjectedShot { + if probability_reweights.is_empty() && loss_sites.is_empty() { + let (_, errors) = projection.project_reweights(&[]); + return ProjectedShot { + reweights: vec![], + loss: None, + errors, + }; + } + let loss = + (self.tracks_losses() && !loss_sites.is_empty()).then(|| build_loss_info(loss_sites, &projection.base_errors)); + if self.hands_off_to_decoder() { + let (reweights, errors) = projection.project_reweights(probability_reweights); + return ProjectedShot { + reweights: reweights + .into_iter() + .map(|(edge, probability)| blackbox_decoder::EdgeReweight { edge, probability }) + .collect(), + loss, + errors, + }; + } + let mut combined = probability_reweights.to_vec(); + if let Some(loss) = loss { + let live_hypergraph; + let hypergraph = if probability_reweights.is_empty() { + &projection.base_hypergraph + } else { + live_hypergraph = { + let mut hypergraph = projection.base_hypergraph.clone(); + apply_reweights(&mut hypergraph, probability_reweights); + hypergraph + }; + &live_hypergraph + }; + let loss_reweights = loss_reweights( + &loss, + hypergraph, + self.reweight_policy().unwrap(), + ); + let mut position_of = hashbrown::HashMap::with_capacity(combined.len() + loss_reweights.len()); + for (position, &(edge, _)) in combined.iter().enumerate() { + position_of.insert(edge, position); + } + for (edge, probability) in loss_reweights { + if let Some(&position) = position_of.get(&edge) { + // This is the final loss transform of the user-updated + // prior, not a competing assignment. Combining both would + // apply the user update twice. + combined[position].1 = probability; + } else { + position_of.insert(edge, combined.len()); + combined.push((edge, probability)); + } + } + } + let (reweights, errors) = projection.project_reweights(&combined); + ProjectedShot { + reweights: reweights + .into_iter() + .map(|(edge, probability)| blackbox_decoder::EdgeReweight { edge, probability }) + .collect(), + loss: None, + errors, + } + } +} + +#[cfg(test)] +#[path = "../../tests/unit/loss_handler_test.rs"] +mod tests; From 5a2c920b490d5d56f16ef14f08d01015805cf587 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 16:55:29 -0700 Subject: [PATCH 141/157] add parameters to simulate --- deq/deq/cli/simulate.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/deq/deq/cli/simulate.py b/deq/deq/cli/simulate.py index 2bb5681d..691d3ddc 100644 --- a/deq/deq/cli/simulate.py +++ b/deq/deq/cli/simulate.py @@ -460,6 +460,7 @@ def _run_batch( debug_dir: str | None, simulator: str = "static", loss_config: dict[str, object] | None = None, + timeout: float = 36000, ) -> dict[str, int | float]: """Spawn one deq_runtime server process for a batch of shots.""" simulator_config: dict[str, object] = { @@ -512,16 +513,15 @@ def _run_batch( if coordinator_config is not None: cmd += ["--coordinator-config", coordinator_config] + runtime_env = os.environ.copy() + runtime_env.setdefault("TOKIO_WORKER_THREADS", "4") + runtime_env.setdefault("RAYON_NUM_THREADS", "2") proc = subprocess.run( cmd, capture_output=True, text=True, - timeout=36000, - env={ - **os.environ, - "TOKIO_WORKER_THREADS": "4", - "RAYON_NUM_THREADS": "2", - }, + timeout=timeout, + env=runtime_env, ) combined = proc.stdout + proc.stderr From 696127b0ecca98c15e2a299a0e21b300cda9aacc Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 17:13:05 -0700 Subject: [PATCH 142/157] add loss handler tests --- .../tests/unit/loss_handler_test.rs | 494 ++++++++++++++++++ 1 file changed, 494 insertions(+) create mode 100644 deq/deq_runtime/tests/unit/loss_handler_test.rs diff --git a/deq/deq_runtime/tests/unit/loss_handler_test.rs b/deq/deq_runtime/tests/unit/loss_handler_test.rs new file mode 100644 index 00000000..a5b7afaf --- /dev/null +++ b/deq/deq_runtime/tests/unit/loss_handler_test.rs @@ -0,0 +1,494 @@ +//! Unit tests for the coordinator loss-handling pass. + +use super::*; +use crate::decoder::blackbox_decoder::{DecodingHypergraph, Hyperedge, LossInfo, LossSite}; +use crate::misc::bit_vector; +use crate::simulator::DeterministicRng; +use crate::util::BitVector; +use rand::SeedableRng; +use serde_json::json; +use std::sync::Arc; + +#[test] +fn missing_config_is_accepted_and_defaults() { + let handler = LossHandler::new(LossStrategy::Reweight, serde_json::Value::Null).unwrap(); + assert_eq!( + handler, + LossHandler::Reweight(EnvelopeReweightPolicy { + weight_fraction: DEFAULT_WEIGHT_FRACTION, + scale: ReweightScale::Local, + }) + ); + assert!(LossHandler::new(LossStrategy::Ignore, serde_json::Value::Null).is_ok()); +} + +#[test] +fn reweight_reads_its_weight_fraction() { + let handler = LossHandler::new(LossStrategy::Reweight, json!({ "weight_fraction": 0.25 })).unwrap(); + assert!((handler.reweight_policy().unwrap().weight_fraction - 0.25).abs() < 1e-12); +} + +#[test] +fn reweight_policy_lowers_edge_weight_to_the_fraction() { + let site = 0.001; + let half = EnvelopeReweightPolicy::default().locally_reweighted_probability(0.0, site); + let quarter = EnvelopeReweightPolicy::new(0.25).locally_reweighted_probability(0.0, site); + assert!((weight_of(half) - 0.5 * weight_of(site)).abs() < 1e-12); + assert!((weight_of(quarter) - 0.25 * weight_of(site)).abs() < 1e-12); + assert!(half > 0.0 && quarter > half); +} + +#[test] +fn reweight_policy_never_reaches_negative_weight() { + for &fraction in &[0.05, 0.25, 0.5, 0.9, 1.0] { + let policy = EnvelopeReweightPolicy::new(fraction); + for &prior in &[0.0, 1e-9, 0.01, 0.1, 0.3, 0.5] { + for &site in &[0.0, 1e-9, 0.01, 0.1, 0.3, 0.5] { + let probability = policy.locally_reweighted_probability(prior, site); + assert!(probability <= 0.5 + 1e-12); + assert!(weight_of(probability) >= -1e-12); + } + } + } +} + +#[test] +fn reweight_policy_never_lowers_a_high_prior_edge() { + let policy = EnvelopeReweightPolicy::default(); + let prior = 0.2; + assert!(policy.locally_reweighted_probability(prior, 1e-6) > prior); +} + +/// The whole point of the hierarchy: an option belonging to another strategy +/// is a construction-time error, not a silently ignored field. +#[test] +fn weight_fraction_is_rejected_by_strategies_that_ignore_it() { + for strategy in [LossStrategy::Ignore, LossStrategy::Handoff] { + let error = LossHandler::new(strategy, json!({ "weight_fraction": 0.25 })).unwrap_err(); + assert!(error.contains("takes none"), "unexpected message: {error}"); + } +} + +#[test] +fn unknown_option_is_rejected() { + let error = LossHandler::new(LossStrategy::Reweight, json!({ "weight_fractoin": 0.25 })).unwrap_err(); + assert!(error.contains("weight_fractoin"), "unexpected message: {error}"); +} + +#[test] +fn weight_fraction_outside_the_unit_interval_is_rejected() { + for bad in [0.0, -0.5, 1.5] { + let error = LossHandler::new(LossStrategy::Reweight, json!({ "weight_fraction": bad })).unwrap_err(); + assert!(error.contains("(0, 1]"), "unexpected message: {error}"); + } +} + +#[test] +fn only_ignore_skips_the_loss_pipeline() { + let reweight = LossHandler::Reweight(EnvelopeReweightPolicy { + weight_fraction: 0.5, + scale: ReweightScale::Local, + }); + assert!(!LossHandler::Ignore.tracks_losses()); + assert!(reweight.tracks_losses()); + assert!(LossHandler::Handoff.tracks_losses()); + assert!(LossHandler::Handoff.hands_off_to_decoder()); + assert!(!reweight.hands_off_to_decoder()); +} + +/// The reference construction stays reachable from config, so the two rules +/// can be measured against each other on identical shots. +#[test] +fn scale_selects_the_reference_construction() { + let handler = LossHandler::new( + LossStrategy::Reweight, + json!({ "weight_fraction": 0.5, "scale": "global_mean" }), + ) + .unwrap(); + assert_eq!(handler.reweight_policy().unwrap().scale, ReweightScale::GlobalMean); + let default = LossHandler::new(LossStrategy::Reweight, serde_json::Value::Null).unwrap(); + assert_eq!(default.reweight_policy().unwrap().scale, ReweightScale::Local); + let error = LossHandler::new(LossStrategy::Reweight, json!({ "scale": "globl_mean" })).unwrap_err(); + assert!(error.contains("globl_mean"), "unexpected message: {error}"); +} + +#[test] +fn strategy_deserializes_from_snake_case() { + let strategy: LossStrategy = serde_json::from_value(json!("handoff")).unwrap(); + assert_eq!(strategy, LossStrategy::Handoff); + assert_eq!(LossStrategy::default(), LossStrategy::Reweight); +} + +#[test] +fn build_loss_info_maps_generators_through_the_site_local_eid() { + let error_reference = vec![ + ErrorIndex { eid: 10, error_index: 0 }, + ErrorIndex { eid: 10, error_index: 1 }, + ErrorIndex { eid: 20, error_index: 0 }, + ]; + let loss_sites = vec![ + RawLossSite { + local_eid: Some(10), + probability: 0.1, + source_generators: vec![0], + continuation_generators: vec![1], + children: vec![1], + heralds: vec![7], + }, + RawLossSite { + local_eid: Some(20), + probability: 0.0, + source_generators: vec![], + continuation_generators: vec![0], + children: vec![], + heralds: vec![8], + }, + ]; + + let loss_info = build_loss_info(&loss_sites, &error_reference); + + assert_eq!(loss_info.sites[0].source_edges, vec![0]); + assert_eq!(loss_info.sites[0].continuation_edges, vec![1]); + assert_eq!(loss_info.sites[0].children, vec![1]); + assert_eq!(loss_info.sites[0].heralds, vec![7]); + assert_eq!(loss_info.sites[1].continuation_edges, vec![2]); +} + +#[test] +fn build_loss_info_preserves_structural_sites_without_error_models() { + let loss_sites = vec![ + RawLossSite { + local_eid: None, + probability: 0.1, + source_generators: vec![0], + continuation_generators: vec![], + children: vec![1], + heralds: vec![], + }, + RawLossSite { + local_eid: Some(20), + probability: 0.0, + source_generators: vec![], + continuation_generators: vec![0], + children: vec![], + heralds: vec![0], + }, + ]; + let loss_info = build_loss_info(&loss_sites, &[ErrorIndex { eid: 20, error_index: 0 }]); + + assert!(loss_info.sites[0].source_edges.is_empty()); + assert_eq!(loss_info.sites[0].children, vec![1]); + assert_eq!(loss_info.sites[1].continuation_edges, vec![0]); +} + +fn single_edge_projection(prior: f64) -> DecodeProjection { + let hypergraph = blackbox_decoder::DecodingHypergraph { + vertex_num: 1, + hyperedges: vec![blackbox_decoder::Hyperedge { + vertices: vec![0], + probability: prior, + }], + }; + let errors = Arc::new(vec![ErrorIndex { eid: 0, error_index: 0 }]); + DecodeProjection::identity(hypergraph, errors) +} + +fn single_edge_loss(probability: f64) -> Vec { + vec![RawLossSite { + local_eid: Some(0), + probability, + source_generators: vec![0], + continuation_generators: vec![], + children: vec![], + heralds: vec![0], + }] +} + +#[test] +fn handoff_preserves_user_reweights_and_structured_loss_together() { + let projection = single_edge_projection(0.1); + let projected = LossHandler::Handoff.project_shot(&projection, &[(0, 0.2)], &single_edge_loss(0.3)); + + assert_eq!(projected.reweights[0].edge, 0); + assert!((projected.reweights[0].probability - 0.2).abs() < 1e-12); + assert_eq!(projected.loss.unwrap().sites[0].source_edges, vec![0]); +} + +#[test] +fn loss_reweighting_uses_the_user_updated_prior() { + let projection = single_edge_projection(0.1); + let policy = EnvelopeReweightPolicy::new(0.5); + let projected = LossHandler::Reweight(policy).project_shot(&projection, &[(0, 0.2)], &single_edge_loss(0.3)); + + assert!(projected.loss.is_none()); + assert!((projected.reweights[0].probability - policy.locally_reweighted_probability(0.2, 0.3)).abs() < 1e-12); +} + +#[test] +fn ignore_discards_loss_but_preserves_user_reweights() { + let projection = single_edge_projection(0.1); + let projected = LossHandler::Ignore.project_shot(&projection, &[(0, 0.2)], &single_edge_loss(0.3)); + + assert!(projected.loss.is_none()); + assert!((projected.reweights[0].probability - 0.2).abs() < 1e-12); +} + +#[test] +fn apply_loss_random_imputation_leaves_non_loss_bits_untouched() { + let mut outcomes = BitVector { + size: 4, + data: vec![0b1010_0000], + }; + let loss_mask = BitVector { size: 4, data: vec![0] }; + let before = outcomes.clone(); + + apply_loss_random_imputation(&mut outcomes, &loss_mask, &mut DeterministicRng::seed_from_u64(42)); + + assert_eq!(outcomes, before); +} + +#[test] +fn apply_loss_random_imputation_only_replaces_marked_bits() { + let mut rng = DeterministicRng::seed_from_u64(1); + let loss_mask = BitVector { + size: 4, + data: vec![0b0101_0000], + }; + let mut bit1_zero_count = 0usize; + let mut bit3_zero_count = 0usize; + let trials = 1000usize; + for _ in 0..trials { + let mut outcomes = BitVector { + size: 4, + data: vec![0b1010_0000], + }; + apply_loss_random_imputation(&mut outcomes, &loss_mask, &mut rng); + assert!(bit_vector::get_bit(&outcomes, 0)); + assert!(bit_vector::get_bit(&outcomes, 2)); + bit1_zero_count += usize::from(!bit_vector::get_bit(&outcomes, 1)); + bit3_zero_count += usize::from(!bit_vector::get_bit(&outcomes, 3)); + } + assert!((trials / 4..3 * trials / 4).contains(&bit1_zero_count)); + assert!((trials / 4..3 * trials / 4).contains(&bit3_zero_count)); +} + +#[test] +fn apply_loss_random_imputation_is_deterministic_with_same_seed() { + let loss_mask = BitVector { + size: 8, + data: vec![0xff], + }; + let mut first = BitVector { size: 8, data: vec![0] }; + let mut second = first.clone(); + + apply_loss_random_imputation(&mut first, &loss_mask, &mut DeterministicRng::seed_from_u64(123)); + apply_loss_random_imputation(&mut second, &loss_mask, &mut DeterministicRng::seed_from_u64(123)); + + assert_eq!(first, second); +} + +#[test] +#[should_panic(expected = "does not match outcomes size")] +fn apply_loss_random_imputation_panics_on_size_mismatch() { + apply_loss_random_imputation( + &mut BitVector { size: 4, data: vec![0] }, + &BitVector { + size: 5, + data: vec![0b0000_1000], + }, + &mut DeterministicRng::seed_from_u64(0), + ); +} + +fn reweight_hypergraph(probabilities: &[f64]) -> DecodingHypergraph { + DecodingHypergraph { + vertex_num: 2, + hyperedges: probabilities + .iter() + .map(|&probability| Hyperedge { + vertices: vec![0, 1], + probability, + }) + .collect(), + } +} + +fn reweight_site(probability: f64, source: Vec, continuation: Vec, children: Vec) -> LossSite { + LossSite { + source_edges: source, + continuation_edges: continuation, + children, + probability, + heralds: vec![], + } +} + +fn local_loss(sites: Vec, weight_fraction: f64) -> (LossInfo, EnvelopeReweightPolicy) { + ( + LossInfo { sites }, + EnvelopeReweightPolicy { + weight_fraction, + scale: ReweightScale::Local, + }, + ) +} + +fn global_mean_loss(sites: Vec, weight_fraction: f64) -> (LossInfo, EnvelopeReweightPolicy) { + ( + LossInfo { sites }, + EnvelopeReweightPolicy { + weight_fraction, + scale: ReweightScale::GlobalMean, + }, + ) +} + +fn neighbourhood_loss(sites: Vec, weight_fraction: f64) -> (LossInfo, EnvelopeReweightPolicy) { + ( + LossInfo { sites }, + EnvelopeReweightPolicy { + weight_fraction, + scale: ReweightScale::NeighbourhoodMean, + }, + ) +} + +fn reweights(built: (LossInfo, EnvelopeReweightPolicy), graph: &DecodingHypergraph) -> Vec<(u64, f64)> { + let (loss, policy) = built; + loss_reweights(&loss, graph, policy) +} + +#[test] +fn continuation_site_inherits_its_ancestors_probability() { + let sites = vec![ + reweight_site(0.01, vec![0], vec![], vec![1]), + reweight_site(0.0, vec![], vec![1], vec![]), + ]; + let accumulated = accumulated_site_probabilities(&sites); + assert!((accumulated[0] - 0.01).abs() < 1e-12); + assert!((accumulated[1] - 0.01).abs() < 1e-12); +} + +#[test] +fn accumulated_probability_merges_multiple_ancestors() { + let sites = vec![ + reweight_site(0.01, vec![], vec![], vec![2]), + reweight_site(0.02, vec![], vec![], vec![2]), + reweight_site(0.0, vec![], vec![0], vec![]), + ]; + let accumulated = accumulated_site_probabilities(&sites); + assert!((accumulated[2] - exclusive_probability_of(0.01, 0.02)).abs() < 1e-12); +} + +#[test] +fn local_rule_lowers_a_loss_only_edge_to_the_fraction_of_its_weight() { + let graph = reweight_hypergraph(&[0.0]); + let (loss, policy) = local_loss(vec![reweight_site(0.01, vec![0], vec![], vec![])], 0.5); + let (edge, probability) = loss_reweights(&loss, &graph, policy)[0]; + assert_eq!(edge, 0); + assert!((weight_of(probability) - 0.5 * weight_of(0.01)).abs() < 1e-12); +} + +#[test] +fn local_rule_never_produces_a_negative_weight() { + for &fraction in &[0.05, 0.25, 0.5, 1.0] { + for &prior in &[0.0, 0.01, 0.2, 0.5] { + let graph = reweight_hypergraph(&[prior, prior]); + let (loss, policy) = local_loss( + vec![ + reweight_site(0.4, vec![0, 1], vec![], vec![1]), + reweight_site(0.4, vec![0, 1], vec![], vec![]), + ], + fraction, + ); + for (edge, probability) in loss_reweights(&loss, &graph, policy) { + assert!( + probability <= 0.5 + 1e-12, + "fraction={fraction} prior={prior} edge={edge} gave {probability} > 1/2" + ); + } + } + } +} + +#[test] +fn local_rule_never_lowers_an_edge() { + let prior = 0.3; + let graph = reweight_hypergraph(&[prior]); + let (loss, policy) = local_loss(vec![reweight_site(0.01, vec![0], vec![], vec![])], 0.5); + let (_, probability) = loss_reweights(&loss, &graph, policy)[0]; + assert!(probability >= prior); +} + +#[test] +fn local_rule_makes_propagated_continuation_edges_usable() { + let graph = reweight_hypergraph(&[0.0, 0.0]); + let (loss, policy) = local_loss( + vec![ + reweight_site(0.01, vec![0], vec![], vec![1]), + reweight_site(0.0, vec![], vec![1], vec![]), + ], + 0.5, + ); + let reweights = loss_reweights(&loss, &graph, policy); + let continuation = reweights.iter().find(|(edge, _)| *edge == 1).expect("edge 1 reweighted"); + assert!(continuation.1 > 0.0); + assert!((weight_of(continuation.1) - 0.5 * weight_of(0.01)).abs() < 1e-12); +} + +#[test] +fn local_rule_applies_the_fraction_once_per_edge_not_once_per_site() { + let fraction = 0.25; + let each = 0.01; + for count in [1usize, 2, 4, 10] { + let graph = reweight_hypergraph(&[0.0]); + let sites = (0..count).map(|_| reweight_site(each, vec![0], vec![], vec![])).collect(); + let (_, probability) = reweights(local_loss(sites, fraction), &graph)[0]; + let total = (0..count).fold(0.0, |accumulated, _| exclusive_probability_of(accumulated, each)); + assert!((weight_of(probability) - fraction * weight_of(total)).abs() < 1e-12); + } +} + +#[test] +fn global_mean_rule_assigns_the_graph_average_to_every_activated_edge() { + let graph = reweight_hypergraph(&[0.001, 0.05, 0.0]); + let mean = (weight_of(0.001) + weight_of(0.05)) / 2.0; + let (loss, policy) = global_mean_loss( + vec![ + reweight_site(0.01, vec![0], vec![], vec![]), + reweight_site(0.2, vec![2], vec![], vec![]), + ], + 0.5, + ); + for (_, probability) in loss_reweights(&loss, &graph, policy) { + assert!((weight_of(probability) - 0.5 * mean).abs() < 1e-12); + } +} + +#[test] +fn the_two_scales_disagree_on_a_heterogeneous_graph() { + let graph = reweight_hypergraph(&[0.001, 0.05, 0.0]); + let sites = vec![reweight_site(0.01, vec![2], vec![], vec![])]; + let local = reweights(local_loss(sites.clone(), 0.5), &graph)[0].1; + let global = reweights(global_mean_loss(sites, 0.5), &graph)[0].1; + assert!((local - global).abs() > 1e-6); +} + +#[test] +fn neighbourhood_scale_matches_the_global_mean_on_a_homogeneous_graph() { + let graph = reweight_hypergraph(&[0.01, 0.01, 0.01, 0.0]); + let sites = vec![reweight_site(0.02, vec![3], vec![], vec![])]; + let global = reweights(global_mean_loss(sites.clone(), 0.5), &graph)[0].1; + let local_mean = reweights(neighbourhood_loss(sites, 0.5), &graph)[0].1; + assert!((global - local_mean).abs() < 1e-12); +} + +#[test] +fn neighbourhood_scale_is_defined_without_regular_edges() { + let graph = reweight_hypergraph(&[0.0, 0.0]); + let sites = vec![reweight_site(0.01, vec![0], vec![], vec![])]; + let fallback = reweights(neighbourhood_loss(sites.clone(), 0.5), &graph)[0].1; + let local = reweights(local_loss(sites, 0.5), &graph)[0].1; + assert!((fallback - local).abs() < 1e-12); + assert!(fallback > 0.0 && fallback <= 0.5); +} From 722c0076dacac23bbdf29820125dc1076c353ba4 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 17:13:36 -0700 Subject: [PATCH 143/157] update visualizer package --- deq/deq_visualizer/package-lock.json | 52 +++++++--------------------- 1 file changed, 12 insertions(+), 40 deletions(-) diff --git a/deq/deq_visualizer/package-lock.json b/deq/deq_visualizer/package-lock.json index 5dc10675..7bc6d2c1 100644 --- a/deq/deq_visualizer/package-lock.json +++ b/deq/deq_visualizer/package-lock.json @@ -1065,9 +1065,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1081,9 +1078,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1097,9 +1091,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1113,9 +1104,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1129,9 +1117,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1145,9 +1130,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1161,9 +1143,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1177,9 +1156,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1193,9 +1169,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1209,9 +1182,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1225,9 +1195,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1241,9 +1208,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1257,9 +1221,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1424,6 +1385,7 @@ "integrity": "sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1504,6 +1466,7 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -2070,6 +2033,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2608,6 +2572,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2668,6 +2633,7 @@ "integrity": "sha512-nA5yUs/B1KmKzvC42fyD0+l9Yd+LtEpVhWRbXuDj0e+ZURcTtyRbMDWUeJmTAh2wC6jC83raS63anNM2YT3NPw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "natural-compare": "^1.4.0", @@ -4114,6 +4080,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -4432,7 +4399,8 @@ "version": "0.182.0", "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tinybench": { "version": "2.9.0", @@ -4515,6 +4483,7 @@ "resolved": "https://registry.npmjs.org/tweakpane/-/tweakpane-4.0.5.tgz", "integrity": "sha512-rxEXdSI+ArlG1RyO6FghC4ZUX8JkEfz8F3v1JuteXSV0pEtHJzyo07fcDG+NsJfN5L39kSbCYbB9cBGHyuI/tQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/cocopon" } @@ -4538,6 +4507,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4599,6 +4569,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -5224,6 +5195,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.25", "@vue/compiler-sfc": "3.5.25", From ca769d871ee46e5ebee7398b49043d6698f21d9c Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 18:28:14 -0700 Subject: [PATCH 144/157] add mle loss decoder --- .../src/decoder/mle_loss_decoder.py | 352 ++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 deq/deq_runtime/src/decoder/mle_loss_decoder.py diff --git a/deq/deq_runtime/src/decoder/mle_loss_decoder.py b/deq/deq_runtime/src/decoder/mle_loss_decoder.py new file mode 100644 index 00000000..872de69d --- /dev/null +++ b/deq/deq_runtime/src/decoder/mle_loss_decoder.py @@ -0,0 +1,352 @@ +"""Herald-aware Pauli-envelope generator MILP loss decoder. + +The decoder jointly selects ordinary Pauli edges and source-loss hypotheses. +Every observed herald must be explained by a selected source that reaches its +directly heralded site. Loss-envelope generator edges are free but gated by the +selected source; ordinary edges and source losses retain their log-likelihood +weights. Detector parity, herald coverage, and causal source conflicts are +enforced as a mixed-integer linear program solved by ``scipy.optimize.milp`` +(HiGHS). + +The coordinator maps the returned hyperedge subgraph back to residual and +readout corrections. The decoder therefore consumes detector footprints and +loss-site structure only; it does not need per-edge logical observables. + +Interface (deq Python decoder, see python_decoder.rs): + + Decoder.supported_features() -> ["loss"] + Decoder(hypergraph, config) + decode(syndrome: list[int], loss=None) -> list[int] # selected hyperedges + reset() -> None + +``hypergraph`` exposes ``vertex_num`` and ``hyperedges`` (each with ``vertices`` +and ``probability``). ``loss`` (present only on loss shots) exposes ``sites``, +each with ``source_edges`` / ``continuation_edges`` (hyperedge indices), +``children`` (indices into ``loss.sites``), ``probability``, and ``heralds`` +(window-local observed-herald IDs shared across sites). + +Model. A selected source covers every direct herald in its forward ``children`` +reach. Each observed herald must be covered at least once. Two source sites are +in causal conflict when their forward reaches overlap: both would claim the same +continuing loss lifetime. Branch siblings with disjoint reaches may both start, +which permits alternatives such as one loss before a fan-out versus independent +losses on several branches. Given the selected sources, a ``source_edges`` edge +of site ``s`` needs ``z_s``, and a ``continuation_edges`` edge of site ``s`` +needs a selected source that reaches ``s`` (itself or an ancestor). + +The runtime has already filtered ``sites`` to those consistent with observed +loss-resolving readouts. ``heralds`` preserves the remaining correlation between +otherwise disconnected sites. Envelope patterns are free; selecting a physical +source carries the log-likelihood-ratio weight of its declared ``probability``. +""" + +from __future__ import annotations + +import math +import os +from collections import defaultdict + +import numpy as np +from scipy.optimize import Bounds, LinearConstraint, milp +from scipy.sparse import csr_matrix + + +def _edge_weight(probability: float) -> float: + """Log-likelihood-ratio weight of a Pauli edge with the given probability.""" + if probability <= 0.0: + return math.inf + if probability >= 1.0: + return -math.inf + return math.log((1.0 - probability) / probability) + + +class Decoder: + @staticmethod + def supported_features() -> list[str]: + return ["loss"] + + def __init__(self, hypergraph, config=None): + self.vertex_num = int(hypergraph.vertex_num) + self.edges: list[list[int]] = [] + self.weights: list[float] = [] + for hyperedge in hypergraph.hyperedges: + self.edges.append([int(v) for v in hyperedge.vertices]) + self.weights.append(_edge_weight(float(hyperedge.probability))) + self.num_edges = len(self.edges) + # Detector -> incident hyperedge indices. + self.vertex_edges: dict[int, list[int]] = defaultdict(list) + for edge_index, vertices in enumerate(self.edges): + for vertex in vertices: + self.vertex_edges[vertex].append(edge_index) + config = config or {} + # Optional HiGHS wall-clock limit (seconds) for a single decode. + self.time_limit = config.get("time_limit", None) + self._debug = bool(int(os.environ.get("MLE_DEBUG", "0"))) + self._debug_left = int(os.environ.get("MLE_DEBUG_N", "15")) + + def decode(self, syndrome, loss=None) -> list[int]: + num_edges = self.num_edges + sites = list(loss.sites) if loss is not None else [] + self._validate_loss_sites(sites) + if num_edges == 0: + return [] + syndrome_set = {int(v) for v in syndrome} + num_sites = len(sites) + + enabling, loss_edges, herald_starts, conflicts, parents, ancestors = ( + self._loss_structure(sites) + ) + + if self._debug and sites and self._debug_left > 0: + import sys + self._debug_left -= 1 + print( + f"[MLE] sites={num_sites} heralds={len(herald_starts)} " + f"conflicts={len(conflicts)} loss_edges={len(loss_edges)} " + f"syn={len(syndrome_set)}", + file=sys.stderr, flush=True, + ) + + # Variable layout: [y_e | z_s | slack_v]. + constraint_vertices = sorted(set(self.vertex_edges.keys()) | syndrome_set) + slack_of = {vertex: index for index, vertex in enumerate(constraint_vertices)} + num_slack = len(constraint_vertices) + num_vars = num_edges + num_sites + num_slack + slack_base = num_edges + num_sites + + objective = np.zeros(num_vars) + lower = np.zeros(num_vars) + upper = np.ones(num_vars) + for edge in range(num_edges): + if edge in loss_edges: + continue # free envelope edge, bounds [0, 1], weight 0 + weight = self.weights[edge] + if math.isinf(weight): + if weight > 0.0: + upper[edge] = 0.0 # p <= 0 non-loss edge: unusable + else: + lower[edge] = 1.0 # p >= 1: certain error + else: + objective[edge] = weight + for site_index, site in enumerate(sites): + variable = num_edges + site_index + probability = float(site.probability) + if probability == 0.0 and not site.source_edges: + if parents[site_index]: + upper[variable] = 0.0 + continue + if probability == 1.0: + continue + weight = _edge_weight(probability) + if math.isinf(weight): + upper[variable] = 0.0 + else: + objective[variable] = weight + for vertex in constraint_vertices: + slack = slack_base + slack_of[vertex] + upper[slack] = max(len(self.vertex_edges.get(vertex, [])), 1) + + rows: list[int] = [] + cols: list[int] = [] + vals: list[float] = [] + con_lower: list[float] = [] + con_upper: list[float] = [] + row = 0 + + # Detector satisfaction (GF(2) via an integer slack): for each detector, + # sum of incident selected edges - 2 * slack == observed bit. + for vertex in constraint_vertices: + for edge in self.vertex_edges.get(vertex, []): + rows.append(row) + cols.append(edge) + vals.append(1.0) + rows.append(row) + cols.append(slack_base + slack_of[vertex]) + vals.append(-2.0) + bit = 1.0 if vertex in syndrome_set else 0.0 + con_lower.append(bit) + con_upper.append(bit) + row += 1 + + # Every observed herald needs at least one selected source whose forward + # loss lifetime reaches that herald. + for starts in herald_starts.values(): + for site in starts: + rows.append(row) + cols.append(num_edges + site) + vals.append(1.0) + con_lower.append(1.0) + con_upper.append(np.inf) + row += 1 + + # A certain local source must start unless the loss has already started + # at an ancestor, in which case this site is only a continuation. + for site, loss_site in enumerate(sites): + if float(loss_site.probability) != 1.0: + continue + for start in ancestors[site]: + rows.append(row) + cols.append(num_edges + start) + vals.append(1.0) + con_lower.append(1.0) + con_upper.append(np.inf) + row += 1 + + # Starts whose forward loss lifetimes overlap cannot both be true. + for left, right in conflicts: + rows.extend((row, row)) + cols.extend((num_edges + left, num_edges + right)) + vals.extend((1.0, 1.0)) + con_lower.append(-np.inf) + con_upper.append(1.0) + row += 1 + + # Envelope gating: a loss edge may be selected only if an enabling start + # is chosen. + for edge in sorted(loss_edges): + rows.append(row) + cols.append(edge) + vals.append(1.0) + for site in enabling[edge]: + rows.append(row) + cols.append(num_edges + site) + vals.append(-1.0) + con_lower.append(-np.inf) + con_upper.append(0.0) + row += 1 + + constraints = [] + if row > 0: + matrix = csr_matrix((vals, (rows, cols)), shape=(row, num_vars)) + constraints.append(LinearConstraint(matrix, np.array(con_lower), np.array(con_upper))) + + options = {} + if self.time_limit is not None: + options["time_limit"] = float(self.time_limit) + + result = milp( + c=objective, + constraints=constraints, + integrality=np.ones(num_vars, dtype=int), + bounds=Bounds(lower, upper), + options=options, + ) + if result.x is None: + raise RuntimeError( + f"loss decoder MILP produced no solution (status={result.status}): " + f"{result.message}" + ) + solution = result.x + return [edge for edge in range(num_edges) if solution[edge] > 0.5] + + def _validate_loss_sites(self, sites) -> None: + num_sites = len(sites) + indegree = [0] * num_sites + has_herald = False + for site_index, site in enumerate(sites): + probability = float(site.probability) + if not math.isfinite(probability) or not 0.0 <= probability <= 1.0: + raise ValueError( + f"loss site {site_index} probability must be finite and in [0, 1]" + ) + for field_name in ("source_edges", "continuation_edges"): + for edge in getattr(site, field_name): + edge_index = int(edge) + if not 0 <= edge_index < self.num_edges: + raise ValueError( + f"loss site {site_index} {field_name} contains edge " + f"{edge_index}, outside [0, {self.num_edges})" + ) + for child in site.children: + child_index = int(child) + if not 0 <= child_index < num_sites: + raise ValueError( + f"loss site {site_index} children contains site " + f"{child_index}, outside [0, {num_sites})" + ) + indegree[child_index] += 1 + for herald in site.heralds: + herald_index = int(herald) + if herald_index < 0: + raise ValueError( + f"loss site {site_index} heralds contains negative index " + f"{herald_index}" + ) + has_herald = True + + frontier = [site for site, degree in enumerate(indegree) if degree == 0] + visited = 0 + while frontier: + site = frontier.pop() + visited += 1 + for child in sites[site].children: + child_index = int(child) + indegree[child_index] -= 1 + if indegree[child_index] == 0: + frontier.append(child_index) + if visited != num_sites: + raise ValueError("loss site children graph contains a cycle") + if sites and not has_herald: + raise ValueError("loss sites contain no direct heralds") + + def _loss_structure(self, sites): + """Build edge enabling, herald coverage, and source conflicts. + + * ``enabling[edge]`` is the set of start sites that make loss ``edge`` + usable (a source edge needs its own site; a continuation edge needs a + start that reaches its site). + * ``loss_edges`` is the set of all loss generator hyperedge indices. + * ``herald_starts[herald]`` contains starts whose forward reach covers + that direct observed herald. + * ``conflicts`` contains source pairs with overlapping forward reaches. + * ``parents`` is the immediate reverse child graph. + * ``ancestors`` contains every source that reaches each site. + """ + num_sites = len(sites) + children = [[int(child) for child in site.children] for site in sites] + parents: list[list[int]] = [[] for _ in range(num_sites)] + for site, child_list in enumerate(children): + for child in child_list: + parents[child].append(site) + + remaining_parents = [len(site_parents) for site_parents in parents] + frontier = [site for site, count in enumerate(remaining_parents) if count == 0] + topological_order: list[int] = [] + ancestors = [{site} for site in range(num_sites)] + while frontier: + site = frontier.pop() + topological_order.append(site) + for child in children[site]: + ancestors[child].update(ancestors[site]) + remaining_parents[child] -= 1 + if remaining_parents[child] == 0: + frontier.append(child) + + reachable = [1 << site for site in range(num_sites)] + for site in reversed(topological_order): + for child in children[site]: + reachable[site] |= reachable[child] + + enabling: dict[int, set[int]] = defaultdict(set) + loss_edges: set[int] = set() + herald_starts: dict[int, set[int]] = defaultdict(set) + for site in range(num_sites): + for edge in sites[site].source_edges: + enabling[int(edge)].add(site) + loss_edges.add(int(edge)) + for edge in sites[site].continuation_edges: + enabling[int(edge)].update(ancestors[site]) + loss_edges.add(int(edge)) + for herald in sites[site].heralds: + herald_starts[int(herald)].update(ancestors[site]) + + conflicts = [ + (left, right) + for left in range(num_sites) + for right in range(left + 1, num_sites) + if reachable[left] & reachable[right] + ] + return enabling, loss_edges, herald_starts, conflicts, parents, ancestors + + def reset(self) -> None: + pass From 4713dc8940c6aa2e46a2f1870cb9c0ef593aff60 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 18:57:03 -0700 Subject: [PATCH 145/157] minor updates to tutorial chapters --- deq/documents/tutorial/chapters/bin-basics.md | 4 ++-- deq/documents/tutorial/chapters/qdk-loss-simulation.md | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/deq/documents/tutorial/chapters/bin-basics.md b/deq/documents/tutorial/chapters/bin-basics.md index 0d6983e5..962f5b13 100644 --- a/deq/documents/tutorial/chapters/bin-basics.md +++ b/deq/documents/tutorial/chapters/bin-basics.md @@ -1098,8 +1098,8 @@ This overrides specific entries in the check model type's `remote_gadgets` list. `RerouteRemoteGadget` specifies an index and a replacement `RemoteGadget` definition. The list is **dynamically extensible** — if the index exceeds the type's original list -length, new entries are added. Only changed entries need to be specified (sparse -representation). +length, new entries are added. Extension indices must be less than `65536`. Only +changed entries need to be specified (sparse representation). **Use case:** A check model type defines a remote gadget with `measurement_bias = 0`, suitable for connecting to port 0 of the predecessor. If at runtime the gadget is actually diff --git a/deq/documents/tutorial/chapters/qdk-loss-simulation.md b/deq/documents/tutorial/chapters/qdk-loss-simulation.md index 59ead373..ce195b67 100644 --- a/deq/documents/tutorial/chapters/qdk-loss-simulation.md +++ b/deq/documents/tutorial/chapters/qdk-loss-simulation.md @@ -24,6 +24,15 @@ deq packages these gate-by-gate rules as platform loss models, selected with | --- | --- | --- | | ``neutral-atom`` | native CZ and its compiled controlled-Pauli aliases | ``CX/CY/CZ → SKIP``; ``SWAP → APPLY_ANYWAY`` | | ``trapped-ion`` | one explicit compiled-CZ residual-phase approximation | ``CZ → RESIDUAL_S_DAGGER``; ``SWAP → APPLY_ANYWAY`` | +| ``none`` | opt out of loss entirely | none — loss is not modelled | + +``none`` is not a physical model. It compiles the circuit as if loss could not +happen: no gadget gets loss metadata, explicit ``LOSS`` statements are ignored, +and ``LOSS_ERROR`` is dropped from the exported Stim circuit so the simulator +never samples loss the decoder cannot explain. Use it when a circuit declares +``LOSS_ERROR`` for another backend, or when its gates fall outside every +built-in platform model's supported scope and you want to study the Pauli noise +alone. The neutral-atom row does not claim that all three controlled gates are native. Neutral-atom processors natively realize CZ and obtain CNOT/CX using local From 0869928bcca5836e290164a54dfac37024117833 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 18:58:58 -0700 Subject: [PATCH 146/157] update loss model test --- deq/tests/cli/loss_model_test.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/deq/tests/cli/loss_model_test.py b/deq/tests/cli/loss_model_test.py index 9a574d8e..d52e17ab 100644 --- a/deq/tests/cli/loss_model_test.py +++ b/deq/tests/cli/loss_model_test.py @@ -436,6 +436,9 @@ def stop_build(*args, **kwargs): def test_qdk_batch_passes_loss_config_to_sampler( monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setenv("RAYON_NUM_THREADS", "1") + monkeypatch.setenv("TOKIO_WORKER_THREADS", "1") + def stop_run(command, **kwargs): config_index = command.index("--simulator-config") + 1 import json @@ -444,6 +447,8 @@ def stop_run(command, **kwargs): assert simulator_config["py_config"]["loss_config"] == ( NeutralAtomLossModel.config.to_json_object() ) + assert kwargs["env"]["RAYON_NUM_THREADS"] == "1" + assert kwargs["env"]["TOKIO_WORKER_THREADS"] == "1" raise _StopAfterBuild monkeypatch.setattr("deq.cli.simulate.subprocess.run", stop_run) From 60a66374d3df53bfd622bd04185fc1dff886beb1 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 19:01:42 -0700 Subject: [PATCH 147/157] update syntax highlight tutorial chapter --- .../tutorial/chapters/language-basics.md | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/deq/documents/tutorial/chapters/language-basics.md b/deq/documents/tutorial/chapters/language-basics.md index 3f8b54fc..982c566e 100644 --- a/deq/documents/tutorial/chapters/language-basics.md +++ b/deq/documents/tutorial/chapters/language-basics.md @@ -46,15 +46,30 @@ references, and code parameters are all color-coded. ### VS Code -Install via the top-level Makefile: +The [Microsoft Quantum Development Kit (QDK)](https://marketplace.visualstudio.com/items?itemName=quantum.qsharp-lang-vscode) +extension includes `.deq` language support. Install it from the VS Code +Extensions view, or from the command line: ```sh -make install-extension +code --install-extension quantum.qsharp-lang-vscode ``` -After installation, any `.deq` file opened in VS Code will have syntax highlighting +After installation, any `.deq` file opened in VS Code has syntax highlighting automatically. +To use the newest syntax highlighting from a local qdk-ec checkout, install the +standalone DEQ extension from source instead. From the repository root: + +```sh +cd deq/deq/circuit/vscode-deq +npx --yes @vscode/vsce package +code --install-extension vscode-deq-0.1.0.vsix --force +``` + +Reload VS Code after installing the generated VSIX. This source-install path is +preferred when testing language changes that have not reached the published QDK +extension yet. + ### Emacs Install by: From 28151b78b46d5929a04ae3cfd14c5a909051a9f9 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 19:17:39 -0700 Subject: [PATCH 148/157] fix linux build error --- .github/workflows/build.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f82a3d7a..93b6d23a 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -74,6 +74,20 @@ jobs: with: python-version: "3.11" cache: 'pip' + + - name: Configure Python embedding linker (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + python_libdir="$(python -c 'import sysconfig; print(sysconfig.get_config_var("LIBDIR"))')" + python_soname="$(python -c 'import sysconfig; print(sysconfig.get_config_var("INSTSONAME") or sysconfig.get_config_var("LDLIBRARY"))')" + python_link_name="$(python -c 'import sys; print(f"libpython{sys.version_info.major}.{sys.version_info.minor}.so")')" + test -f "$python_libdir/$python_soname" + python_link_dir="$RUNNER_TEMP/python-lib" + mkdir -p "$python_link_dir" + ln -sf "$python_libdir/$python_soname" "$python_link_dir/$python_link_name" + echo "LIBRARY_PATH=$python_link_dir${LIBRARY_PATH:+:$LIBRARY_PATH}" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$python_libdir${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" >> "$GITHUB_ENV" - name: Create Python virtual environment and install dependencies (Linux/Mac) if: runner.os != 'Windows' From 9dd381040422861947e1ed8195a0dacdf5375f0e Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 19:46:18 -0700 Subject: [PATCH 149/157] fix test error --- deq/deq_runtime/src/coordinator/loss_handler.rs | 12 +++++++----- deq/deq_runtime/src/decoder/python_decoder.rs | 2 +- deq/deq_runtime/src/decoder/thread_pooling.rs | 11 ++++------- deq/deq_runtime/src/jit.rs | 6 ++++-- deq/deq_runtime/tests/unit/reweight_handler_test.rs | 2 +- 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/deq/deq_runtime/src/coordinator/loss_handler.rs b/deq/deq_runtime/src/coordinator/loss_handler.rs index d8fa6cb9..a1b69780 100644 --- a/deq/deq_runtime/src/coordinator/loss_handler.rs +++ b/deq/deq_runtime/src/coordinator/loss_handler.rs @@ -308,6 +308,9 @@ impl NeighbourhoodScale { fn new(hypergraph: &blackbox_decoder::DecodingHypergraph) -> Self { let mut edges_of_vertex: HashMap> = HashMap::new(); for (index, hyperedge) in hypergraph.hyperedges.iter().enumerate() { + if hyperedge.probability == 0.0 { + continue; + } let index = u32::try_from(index).expect("hyperedge index must fit in u32"); for &vertex in &hyperedge.vertices { edges_of_vertex.entry(vertex).or_default().push(index); @@ -335,6 +338,9 @@ fn regular_mean_weight(hypergraph: &blackbox_decoder::DecodingHypergraph) -> f64 let mut total = 0.0; let mut count = 0usize; for hyperedge in &hypergraph.hyperedges { + if hyperedge.probability == 0.0 { + continue; + } total += weight_of(hyperedge.probability); count += 1; } @@ -531,11 +537,7 @@ impl LossHandler { }; &live_hypergraph }; - let loss_reweights = loss_reweights( - &loss, - hypergraph, - self.reweight_policy().unwrap(), - ); + let loss_reweights = loss_reweights(&loss, hypergraph, self.reweight_policy().unwrap()); let mut position_of = hashbrown::HashMap::with_capacity(combined.len() + loss_reweights.len()); for (position, &(edge, _)) in combined.iter().enumerate() { position_of.insert(edge, position); diff --git a/deq/deq_runtime/src/decoder/python_decoder.rs b/deq/deq_runtime/src/decoder/python_decoder.rs index cfadd0de..215f98f9 100644 --- a/deq/deq_runtime/src/decoder/python_decoder.rs +++ b/deq/deq_runtime/src/decoder/python_decoder.rs @@ -118,7 +118,7 @@ fn decoder_features(file: &str, class_name: &str) -> PyResult { "unsupported Python decoder feature {feature_name:?}; expected \"reweights\" or \"loss\"" )) })?; - features = features | feature; + features |= feature; } Ok(features) }) diff --git a/deq/deq_runtime/src/decoder/thread_pooling.rs b/deq/deq_runtime/src/decoder/thread_pooling.rs index 361eef5d..5e03dce9 100644 --- a/deq/deq_runtime/src/decoder/thread_pooling.rs +++ b/deq/deq_runtime/src/decoder/thread_pooling.rs @@ -243,8 +243,8 @@ impl black_box_decoder_server::BlackBoxDeco .and_then(|parity_factor| { #[cfg(debug_assertions)] { - return validation::validate_parity_factor(parity_factor, hypergraph.hyperedges.len()) - .map_err(DecodeError::Backend); + validation::validate_parity_factor(parity_factor, hypergraph.hyperedges.len()) + .map_err(DecodeError::Backend) } #[cfg(not(debug_assertions))] { @@ -371,11 +371,8 @@ impl black_box_decoder_server::BlackBoxDeco .and_then(|parity_factor| { #[cfg(debug_assertions)] { - return validation::validate_parity_factor( - parity_factor, - hypergraph.as_ref().unwrap().hyperedges.len(), - ) - .map_err(DecodeError::Backend); + validation::validate_parity_factor(parity_factor, hypergraph.as_ref().unwrap().hyperedges.len()) + .map_err(DecodeError::Backend) } #[cfg(not(debug_assertions))] { diff --git a/deq/deq_runtime/src/jit.rs b/deq/deq_runtime/src/jit.rs index ff66a996..4b06f47f 100644 --- a/deq/deq_runtime/src/jit.rs +++ b/deq/deq_runtime/src/jit.rs @@ -11,8 +11,10 @@ pub async fn static_jit_compile(mut jit_library: JitLibrary) -> bin::Library { let program = std::mem::take(&mut jit_library.program); let token = CancellationToken::new(); // copy the port types and gadget types from the JIT library - let mut library = bin::Library::default(); - library.metadata = jit_library.metadata.clone(); + let mut library = bin::Library { + metadata: jit_library.metadata.clone(), + ..Default::default() + }; for port_type in jit_library.port_types.iter() { library.port_types.push(port_type.base.as_ref().unwrap().clone()); } diff --git a/deq/deq_runtime/tests/unit/reweight_handler_test.rs b/deq/deq_runtime/tests/unit/reweight_handler_test.rs index d7adc489..498c8aaa 100644 --- a/deq/deq_runtime/tests/unit/reweight_handler_test.rs +++ b/deq/deq_runtime/tests/unit/reweight_handler_test.rs @@ -377,7 +377,7 @@ fn shot_reweight_re_elects_only_affected_merged_representatives() { #[test] fn translated_reweights_match_deduplicating_an_already_reweighted_graph() { - let priors = vec![3.7e-4, 3.7e-4, 0.0, 0.02]; + let priors = [3.7e-4, 3.7e-4, 0.0, 0.02]; let vertices = [vec![0, 1], vec![1, 0], vec![0, 1], vec![2]]; let errors: Vec = (0..4).map(|error_index| ErrorIndex { eid: 0, error_index }).collect(); let base = blackbox_decoder::DecodingHypergraph { From 3d5bd53e7df006ed28d445fd319bee1b4e9e261f Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 20:30:08 -0700 Subject: [PATCH 150/157] install missing dependency --- .github/workflows/build.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 93b6d23a..e94ae089 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -95,7 +95,7 @@ jobs: python -m venv qdk_env source qdk_env/bin/activate python -m pip install --upgrade pip - pip install maturin pytest pytest-asyncio hypothesis more-itertools numpy + pip install maturin pytest pytest-asyncio hypothesis more-itertools numpy scipy - name: Create Python virtual environment and install dependencies (Windows) if: runner.os == 'Windows' @@ -104,7 +104,7 @@ jobs: python -m venv qdk_env call qdk_env\Scripts\activate.bat python -m pip install --upgrade pip - pip install maturin pytest pytest-asyncio hypothesis more-itertools numpy + pip install maturin pytest pytest-asyncio hypothesis more-itertools numpy scipy - name: Build binar Python bindings (Linux/Mac) if: runner.os != 'Windows' From b64c0581c1999b3d4992f503284613d075d19b80 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 22:00:31 -0700 Subject: [PATCH 151/157] add scipy to ado pipeline install --- .ado/stages/build.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.ado/stages/build.yaml b/.ado/stages/build.yaml index 6fca3535..34c1d376 100644 --- a/.ado/stages/build.yaml +++ b/.ado/stages/build.yaml @@ -116,11 +116,11 @@ stages: # agents ship a much newer glibc (Ubuntu 22.04 = 2.35, Azure # Linux 3 = 2.38) and a native link would raise the wheel's # glibc floor above the tag we claim. - pip install "maturin[zig]" pytest hypothesis more-itertools numpy + pip install "maturin[zig]" pytest hypothesis more-itertools numpy scipy maturin_args+=(--zig --target "$(uname -m)-unknown-linux-gnu" --compatibility $(manylinuxTag)) ;; Darwin) - pip install maturin pytest hypothesis more-itertools numpy + pip install maturin pytest hypothesis more-itertools numpy scipy ;; *) echo "Unsupported Unix platform: $(uname -s)" >&2 @@ -161,7 +161,7 @@ stages: python -m venv .venv .\.venv\Scripts\Activate.ps1 pip install --upgrade pip - pip install maturin pytest hypothesis more-itertools numpy + pip install maturin pytest hypothesis more-itertools numpy scipy $wheelhouse = Join-Path $PWD 'target\wheels' function Build-AndTest([string]$cratePath, [string[]]$pytestArgs = @()) { From 0ca0d4c4130da291346884d821295f09f5b920ac Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 22:03:10 -0700 Subject: [PATCH 152/157] bump deqagram version --- deq/deqagram/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deq/deqagram/Cargo.toml b/deq/deqagram/Cargo.toml index 558af85c..660b2a4b 100644 --- a/deq/deqagram/Cargo.toml +++ b/deq/deqagram/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deqagram" -version = "0.1.0" +version = "0.1.1" edition = "2024" authors = ["Microsoft Corporation"] description = "A pest-based parser and typed AST for the deq (.deq) quantum error correction file format." From f1b3dac72d7dbc1a33d13fb734cf17a4d6b98d8b Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 22:03:29 -0700 Subject: [PATCH 153/157] bump dependency version --- deq/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deq/pyproject.toml b/deq/pyproject.toml index e5eeb21e..95238e37 100644 --- a/deq/pyproject.toml +++ b/deq/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ "grpcio", "grpcio-tools>=1.76,<1.84", "anywidget", - "deqagram>=0.1.0,<0.2", + "deqagram>=0.1.1,<0.2", "mako", "stim", "networkx", From 048f4813618426f9cf18e150d10f0364ecb35656 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Tue, 18 Aug 2026 22:24:11 -0700 Subject: [PATCH 154/157] update deqagram python version as well --- deq/deqagram/bindings/python/Cargo.toml | 2 +- deq/deqagram/bindings/python/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deq/deqagram/bindings/python/Cargo.toml b/deq/deqagram/bindings/python/Cargo.toml index 11e44281..2299cab9 100644 --- a/deq/deqagram/bindings/python/Cargo.toml +++ b/deq/deqagram/bindings/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deqagram-python" -version = "0.1.0" +version = "0.1.1" edition = "2024" publish = false diff --git a/deq/deqagram/bindings/python/pyproject.toml b/deq/deqagram/bindings/python/pyproject.toml index 258a84b0..d12ca56a 100644 --- a/deq/deqagram/bindings/python/pyproject.toml +++ b/deq/deqagram/bindings/python/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "maturin" name = "deqagram" description = "Python bindings for deqagram, a parser for the deq (.deq) quantum error correction file format." license = "MIT" -version = "0.1.0" +version = "0.1.1" requires-python = ">=3.8" authors = [{ name = "Microsoft Corporation" }] classifiers = [ From 886107aa3a26b8c64c95b1af91c1e51054d35327 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Wed, 19 Aug 2026 13:43:15 -0700 Subject: [PATCH 155/157] fix qubit id assignment --- deq/deq/transpiler/compose_builder.py | 16 +++- .../transpiler/test_compose_repropagate.py | 93 +++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/deq/deq/transpiler/compose_builder.py b/deq/deq/transpiler/compose_builder.py index 7439e4ce..b9c71b6a 100644 --- a/deq/deq/transpiler/compose_builder.py +++ b/deq/deq/transpiler/compose_builder.py @@ -749,6 +749,11 @@ def expand_compose_circuit( n = wire_n[w] wire_qubits[w] = list(range(off, off + n)) + # Persistent wire qubits must remain live across subsequent sub-gadgets. + # Scratch ancillas are allocated separately for each application and may + # reuse ids once that application's circuit has finished. + next_wire_qubit = total_data + circuit: list[GadgetStatement] = [] # Running count of physical measurements produced by all inlined # instructions so far. Used to shift each sub-gadget's absolute @@ -768,8 +773,10 @@ def expand_compose_circuit( # Build qubit remapping: port qubits → dense data indices. qmap: dict[int, int] = {} + mapped_input_wires: set[int] = set() for port_idx, wire_idx in enumerate(in_wires): if port_idx < len(sub_inputs): + mapped_input_wires.add(wire_idx) current = wire_qubits[wire_idx] for local_i, phys_q in enumerate(sub_inputs[port_idx].qubit_indices): if local_i < len(current): @@ -785,11 +792,16 @@ def expand_compose_circuit( while len(current) < len(out_phys_qs): current.append(offset + len(current)) for local_i, phys_q in enumerate(out_phys_qs): - qmap.setdefault(phys_q, current[local_i]) + if phys_q not in qmap: + if wire_idx not in mapped_input_wires: + qmap[phys_q] = current[local_i] + else: + qmap[phys_q] = next_wire_qubit + next_wire_qubit += 1 # Non-port qubits → ancilla indices after data qubits. all_qs = _collect_qubit_indices_from_stmts(sub_stmts) - ancilla_cursor = total_data + ancilla_cursor = next_wire_qubit for q in sorted(all_qs): if q not in qmap: qmap[q] = ancilla_cursor diff --git a/deq/tests/transpiler/test_compose_repropagate.py b/deq/tests/transpiler/test_compose_repropagate.py index d8edf705..41fcc830 100644 --- a/deq/tests/transpiler/test_compose_repropagate.py +++ b/deq/tests/transpiler/test_compose_repropagate.py @@ -11,6 +11,13 @@ import pytest from deq.cli.strip_tags import strip_jit_library +from deq.circuit.model import ( + CodeDefinition, + ComposeDefinition, + GadgetDefinition, + Instruction, + QubitTarget, +) from deq.circuit.parser import parse from deq.transpiler.compose_builder import ( compose_to_synthetic_gadget, @@ -460,3 +467,89 @@ def test_emits_select_block_with_both_requires(self, tmp_path): assert require_lines[0] == "REQUIRE rec[-1]" # PrepB has parity 1 -> exactly one negation on the first target. assert require_lines[1] == "REQUIRE !rec[-1]" + +def _synthetic_gadget(source: str, name: str) -> GadgetDefinition: + deq_file = parse(source) + codes = { + definition.name: definition + for definition in deq_file.definitions + if isinstance(definition, CodeDefinition) + } + gadgets = { + definition.name: definition + for definition in deq_file.definitions + if isinstance(definition, GadgetDefinition) + } + composes = { + definition.name: definition + for definition in deq_file.definitions + if isinstance(definition, ComposeDefinition) + } + return compose_to_synthetic_gadget(composes[name], gadgets, composes, codes) + + +class TestComposeQubitAllocation: + def test_repeat_reuses_scratch_ancillas(self) -> None: + synthetic = _synthetic_gadget( + """ + CODE Code[[3,1,1]] { + LOGICAL X0*X1*X2 Z0*Z1*Z2 + STABILIZER Z0*Z1 Z1*Z2 + } + GADGET Idle { + INPUT Code 0 1 2 + R 3 4 + CX 0 3 1 4 + M 3 4 + OUTPUT Code 0 1 2 + } + COMPOSE Idle3 { + INPUT Code 0 + REPEAT 3 { Idle 0 } + OUTPUT Code 0 + } + """, + "Idle3", + ) + reset_targets = [ + [ + target.index + for target in statement.targets + if isinstance(target, QubitTarget) + ] + for statement in synthetic.body + if isinstance(statement, Instruction) and statement.name == "R" + ] + assert reset_targets == [[3, 4], [3, 4], [3, 4]] + + def test_distinct_output_register_gets_fresh_qubits(self) -> None: + synthetic = _synthetic_gadget( + """ + CODE Code[[2,1,1]] { + LOGICAL X0*X1 Z0*Z1 + STABILIZER Z0*Z1 + } + GADGET Move { + INPUT Code 0 1 + R 2 3 + CX 0 2 1 3 + OUTPUT Code 2 3 + } + COMPOSE Moved { + INPUT Code 0 + Move 0 + OUTPUT Code 0 + } + """, + "Moved", + ) + cnot = next( + statement + for statement in synthetic.body + if isinstance(statement, Instruction) and statement.name == "CX" + ) + assert [ + target.index + for target in cnot.targets + if isinstance(target, QubitTarget) + ] == [0, 2, 1, 3] From 387fb68a861a428a6deb7f511f6cb86eb565a360 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Wed, 19 Aug 2026 16:59:36 -0700 Subject: [PATCH 156/157] fix corner case of window loss decoding --- .../src/coordinator/monolithic_coordinator.rs | 2 +- .../src/coordinator/window_coordinator.rs | 2 +- deq/deq_runtime/src/jit/loss_compiler.rs | 42 ++++++++++++++++++- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs index f0892048..450f2fd0 100644 --- a/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs @@ -35,6 +35,7 @@ use crate::coordinator::{ use crate::decoder::DynDecoder; use crate::decoder::blackbox_decoder::{self, DecodingHypergraph, Hyperedge}; use crate::decoder::blackbox_util::assert_parity_factor; +use crate::jit::loss_compiler::{GadgetLoss, build_cross_gadget_loss_sites, build_cross_gadget_output_links}; use crate::misc::bit_vector::{self, get_bit, set_bit}; use crate::misc::index::{ErrorIndex, WILDCARD}; use crate::misc::pauli_frame_tracker::PauliFrameTracker; @@ -805,7 +806,6 @@ impl MonolithicCoordinator { gadgets: &HashMap, check_models: &HashMap, ) -> Vec { - use crate::jit::loss_compiler::{GadgetLoss, build_cross_gadget_loss_sites, build_cross_gadget_output_links}; let mut loss_sites = Vec::new(); if !self.loss_handler.tracks_losses() || !gadgets.values().any(|g| g.loss_mask.is_some()) { return loss_sites; diff --git a/deq/deq_runtime/src/coordinator/window_coordinator.rs b/deq/deq_runtime/src/coordinator/window_coordinator.rs index c97af31a..aec37fea 100644 --- a/deq/deq_runtime/src/coordinator/window_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/window_coordinator.rs @@ -94,6 +94,7 @@ use crate::coordinator::{ use crate::decoder::DynDecoder; use crate::decoder::blackbox_decoder::{self, DecodingHypergraph, Hyperedge}; use crate::decoder::blackbox_util::assert_parity_factor; +use crate::jit::loss_compiler::{GadgetLoss, build_cross_gadget_loss_sites, build_cross_gadget_output_links}; use crate::misc::bit_vector::{self, flip_bit, get_bit, set_bit}; use crate::misc::fastrace::{Event, Span, SpanContext}; use crate::misc::index::{ErrorIndex, WILDCARD}; @@ -1574,7 +1575,6 @@ impl WindowCoordinator { /// whose herald signature is incomplete in this window is naturally dropped /// when its edges fall outside the window. async fn build_loss_sites(&self, mapping: &RelativeMapping) -> Vec { - use crate::jit::loss_compiler::{GadgetLoss, build_cross_gadget_loss_sites, build_cross_gadget_output_links}; let mut loss_sites = Vec::new(); if !self.loss_handler.tracks_losses() { return loss_sites; diff --git a/deq/deq_runtime/src/jit/loss_compiler.rs b/deq/deq_runtime/src/jit/loss_compiler.rs index b002ed3a..907abf8b 100644 --- a/deq/deq_runtime/src/jit/loss_compiler.rs +++ b/deq/deq_runtime/src/jit/loss_compiler.rs @@ -124,7 +124,8 @@ fn port_offsets( } /// Resolve each loss-bearing output slot to its downstream gadget and input -/// slot within the current decode region. +/// slot within the supplied loss-bearing slice. A connector whose upstream +/// instance is absent from the slice is an unresolved boundary link. pub(crate) fn build_cross_gadget_output_links( gadget_instances: &[&crate::bin::Gadget], index_of_gid: &HashMap, @@ -136,7 +137,9 @@ pub(crate) fn build_cross_gadget_output_links( let downstream_type = gadget_types.get(&downstream.gtype).unwrap(); let input_offsets = port_offsets(&downstream_type.inputs, port_types); for (input_port, connector) in downstream.connectors.iter().enumerate() { - let upstream_index = *index_of_gid.get(&connector.gid).unwrap(); + let Some(&upstream_index) = index_of_gid.get(&connector.gid) else { + continue; + }; let upstream_type = gadget_types.get(&gadget_instances[upstream_index].gtype).unwrap(); let output_port = connector.port as usize; debug_assert!(output_port < upstream_type.outputs.len()); @@ -372,6 +375,7 @@ mod tests { use super::*; use crate::bin::gadget_type::LossModel; use crate::bin::gadget_type::loss_model::Loss; + use std::sync::Arc; fn loss(loss_measurements: &[u64], child_losses: &[u64], source_errors: &[u64], continuation_errors: &[u64]) -> Loss { Loss { @@ -420,6 +424,40 @@ mod tests { pairs.iter().copied().collect() } + #[test] + fn output_links_ignore_an_upstream_gadget_outside_the_loss_slice() { + let downstream = crate::bin::Gadget { + gid: 2, + gtype: 2, + connectors: vec![crate::bin::gadget::Connector { gid: 1, port: 0 }], + ..Default::default() + }; + let gadget_types = HashMap::from([( + 2, + Arc::new(crate::bin::GadgetType { + gtype: 2, + inputs: vec![crate::bin::gadget_type::Port { + ptype: 1, + ..Default::default() + }], + ..Default::default() + }), + )]); + let port_types = HashMap::from([( + 1, + Arc::new(crate::bin::PortType { + ptype: 1, + n: 1, + ..Default::default() + }), + )]); + let index_of_gid = HashMap::from([(2, 0)]); + + let links = build_cross_gadget_output_links(&[&downstream], &index_of_gid, &gadget_types, &port_types); + + assert_eq!(links, vec![HashMap::new()]); + } + #[test] fn single_gadget_chain_keeps_possible_sites_with_children() { // One gadget, no output links: a heralded parent -> child chain. Both From 3898c54e71851c67830d3c1501b60bb1f5ddc4a3 Mon Sep 17 00:00:00 2001 From: Yue Wu Date: Wed, 19 Aug 2026 17:00:19 -0700 Subject: [PATCH 157/157] bump deq runtime version --- deq/deq_runtime/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deq/deq_runtime/Cargo.toml b/deq/deq_runtime/Cargo.toml index b77340ee..2cc71cc1 100644 --- a/deq/deq_runtime/Cargo.toml +++ b/deq/deq_runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deq-runtime" -version = "0.5.0-rc1" +version = "0.5.0-rc2" edition = "2024" authors = ["Microsoft Corporation"] description = "deq: Real-time Quantum Error Correction Decoding System"