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 = @()) { diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f82a3d7a..e94ae089 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' @@ -81,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' @@ -90,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' diff --git a/deq/CHANGELOG.md b/deq/CHANGELOG.md index 806f55d3..3d4ed1fb 100644 --- a/deq/CHANGELOG.md +++ b/deq/CHANGELOG.md @@ -8,10 +8,25 @@ 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. ## [0.4.2] - 2026-08-03 ### 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/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/deq/circuit/model.py b/deq/deq/circuit/model.py index 61a855ed..4ba89988 100644 --- a/deq/deq/circuit/model.py +++ b/deq/deq/circuit/model.py @@ -457,6 +457,52 @@ 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. These + collections are set-valued, so explicit duplicate references are rejected. + """ + + 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. @@ -584,6 +630,7 @@ class PreselectStatement: | ReadoutStatement | CheckStatement | ErrorStatement + | LossStatement | ConditionalStatement | VirtualLogicalStatement | PropagateStatement 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 ad5f27ae..9c399c19 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" }, @@ -568,6 +571,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/cli/annotate.py b/deq/deq/cli/annotate.py index 58c67d15..139a1116 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,18 +24,17 @@ 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", "none", or a .py file + loss_model: str = "neutral-atom", #: 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 +47,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. - This is the recommended mode for producing a Stim-simulatable - annotated file. + 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 +78,8 @@ def annotate( skip_mako_warning=skip_mako_warning, ) - rendered = _annotate_impl(qfile, keep_noise=keep_noise) + selected_loss_model = create_loss_model(loss_model) + rendered = _annotate_impl(qfile, loss_model=selected_loss_model) # Determine output path. if out is None: @@ -94,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 030e3566..e5595e02 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", "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) 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, @@ -766,6 +773,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 +917,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: @@ -1057,9 +1069,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 ) @@ -1116,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]] = {} @@ -1250,7 +1266,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): @@ -1271,7 +1287,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) @@ -1288,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/sample.py b/deq/deq/cli/sample.py index bace434f..e88238b8 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 @@ -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: 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. @@ -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 795ae957..691d3ddc 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, @@ -29,6 +30,7 @@ GadgetDefinition, ProgramDefinition, ) +from deq.transpiler.loss.api import QdkLossConfig # --------------------------------------------------------------------------- # Helpers @@ -91,6 +93,12 @@ 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", "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 + 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 @@ -147,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 @@ -154,6 +165,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 +176,17 @@ 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 + ) + 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 +221,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 +238,16 @@ 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, + ) + + 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...") @@ -330,6 +363,7 @@ def _submit_batch() -> bool: seed=next_seed, debug_dir=debug_dir, simulator=simulator, + loss_config=simulation_loss_config.to_json_object(), ) futures[fut] = (this_batch,) if next_seed is not None: @@ -385,6 +419,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 " + "config; remove the parameter or rebuild the JIT library with that model" + ) + return stored_config + + def _run_batch( bin_path: str, stim_path: str, @@ -398,6 +459,8 @@ def _run_batch( seed: int | None, 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] = { @@ -414,6 +477,10 @@ def _run_batch( runtime_simulator = simulator elif simulator == "qdk": simulator_config["sampler"] = "@qdk_sampler" + simulator_config["py_config"] = { + "batch_size": batch_size + 1, + "loss_config": loss_config, + } controller_name = "static" controller_config = {"filepath": bin_path} runtime_simulator = "python" @@ -446,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 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/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], diff --git a/deq/deq/transpiler/compose_builder.py b/deq/deq/transpiler/compose_builder.py index 2ff49499..b9c71b6a 100644 --- a/deq/deq/transpiler/compose_builder.py +++ b/deq/deq/transpiler/compose_builder.py @@ -11,7 +11,11 @@ # 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 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 @@ -42,7 +46,12 @@ ) 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 # --------------------------------------------------------------------------- # COMPOSE validation @@ -554,18 +563,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`. """ - # 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)) + 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] = [] @@ -573,7 +580,8 @@ def _expand_definition( for s in flat: if isinstance(s, Instruction): circuit.append(s) - running += _measurement_count_of(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): @@ -585,11 +593,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)]``. @@ -605,9 +631,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( @@ -624,14 +648,11 @@ 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``. """ - # 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 @@ -728,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 @@ -738,13 +764,19 @@ 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. 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): @@ -760,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 @@ -778,7 +815,8 @@ def expand_compose_circuit( if isinstance(stmt, Instruction): remapped = _remap_instruction(stmt, qmap) circuit.append(remapped) - cumulative_meas += _measurement_count_of(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: @@ -843,6 +881,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, ) @@ -852,7 +892,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] = [] @@ -930,11 +970,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 " @@ -967,9 +1003,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 @@ -1002,11 +1039,13 @@ 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], -) -> jit_pb.JitGadgetType: + library_has_loss: bool, + loss_model: "LossModel", +) -> "JitGadgetArtifacts": """Build a JitGadgetType for an ``@REPROPAGATE`` COMPOSE. Routes the COMPOSE through *both* pipelines and combines them: @@ -1020,7 +1059,7 @@ def _build_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:`_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 @@ -1043,31 +1082,79 @@ 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, dict(ptype_of_code), dict(codes), + library_has_loss=library_has_loss, + loss_model=loss_model, check_override=(finished, unfinished), ) +def transpile_compose_jit_gadget_type( + compose: ComposeDefinition, + *, + gtype: int, + gadget_definitions: Mapping[str, GadgetDefinition], + compose_definitions: Mapping[str, ComposeDefinition], + jit_gadget_artifacts_by_name: Mapping[str, "JitGadgetArtifacts"], + codes: Mapping[str, CodeDefinition], + ptype_of_code: Mapping[str, int], + port_types: list[jit_pb.JitPortType], + library_has_loss: bool, + loss_model: "LossModel", +) -> "JitGadgetArtifacts": + """Transpile a composed gadget and retain annotation provenance.""" + 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_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, + loss_model=loss_model, + ) + + 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, + ) + + def _check_basis_from_jit_gadget_type( jt: jit_pb.JitGadgetType, synthetic: GadgetDefinition, @@ -1121,81 +1208,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], -) -> 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], + 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`. """ @@ -1241,7 +1273,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) @@ -1281,6 +1315,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 @@ -1333,6 +1371,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: @@ -1347,7 +1386,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 = [] @@ -1364,6 +1404,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): @@ -1390,8 +1441,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), + ) # =================================================================== @@ -1472,6 +1660,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. @@ -1500,18 +1691,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(), @@ -1556,6 +1753,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. @@ -1612,6 +1810,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/fault_propagation.py b/deq/deq/transpiler/fault_propagation.py new file mode 100644 index 00000000..4f143339 --- /dev/null +++ b/deq/deq/transpiler/fault_propagation.py @@ -0,0 +1,439 @@ +"""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, + 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, +) + + +_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 + + +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, pauli_string_to_sparse(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(pauli_string_to_sparse(pauli)) + for pauli in output_stabilizer_paulis + ] + frame_column_outcomes = [ + 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] + + 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 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_string(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/jit_annotate.py b/deq/deq/transpiler/jit_annotate.py index c9c52821..a3a9daa8 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,18 @@ - ``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; + - 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; - - computed ``ERROR`` statements (from noise propagation) are - inserted after each noise instruction. + - 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. """ @@ -44,6 +48,7 @@ InputPort, Instruction, KeywordArg, + LossStatement, OutputPort, PauliProduct, PhysicalMeasurementTarget, @@ -58,7 +63,8 @@ Check, PortColumnLayout, flatten_body, - num_frame_columns, + is_decode_only, + is_simulation_only, select_stabilizer_generators, ) from deq.transpiler.check_plugins import compute_layout, resolve_gadget_checks @@ -70,50 +76,38 @@ 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.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 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: +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. - 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. + 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 + 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) @@ -129,7 +123,12 @@ 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) + 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 } @@ -151,14 +150,13 @@ 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 + ], ) ) elif isinstance(definition, ComposeDefinition): @@ -175,11 +173,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,25 +180,28 @@ def annotate(qfile: DeqFile, *, keep_noise: bool = False) -> str: _annotate_gadget( synthetic, codes, - gtype=gtype, - keep_noise=keep_noise, + artifacts=library_artifacts.gadget_artifacts_by_name[ + definition.name + ], 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" @@ -275,13 +271,14 @@ def _annotate_gadget( gadget: GadgetDefinition, 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, + artifacts: JitGadgetArtifacts, + 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,7 +290,12 @@ 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 + ) + 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. @@ -345,19 +347,24 @@ 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) - 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 + 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_error_indices_by_body: dict[int, list[int]] = {} + for origin in artifacts.noise_error_origins: + noise_error_indices_by_body.setdefault(origin.body_index, []).append( + origin.error_index + ) + 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) + } + 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 +374,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,8 +391,29 @@ def _annotate_gadget( readout_counter = 0 pre_running = 0 + source_loss_lines: list[str] = [] + input_loss_lines: list[str] = [] + loss_error_counter = 0 + 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=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] + 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, @@ -400,25 +422,33 @@ 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, physical_running=physical_running - ): + for line in _render_body_statement(stmt, physical_running=physical_running): lines.append(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, []): - lines.append( - " " - + _render_jit_error_to_source( - error_row, - num_finished=num_finished, - layout=output_col_layout, - ) + # 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 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)) @@ -448,19 +478,42 @@ 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) + 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: + 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: + lines.append("") + lines.extend(trailing_loss_lines) + # Statistics summary - all_errors = [e for errs in noise_errors_at.values() for e in errs] 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], ) ) @@ -505,13 +558,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 @@ -547,15 +594,14 @@ def _render_jit_error_to_source( def _render_body_statement( stmt: GadgetStatement, *, - keep_noise: bool = False, 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. + 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 @@ -573,37 +619,12 @@ 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, ReadoutStatement): - return [f" {_render_readout_statement(stmt)}"] - if isinstance(stmt, ErrorStatement): - return [f" # {_render_error_statement(stmt)}"] + 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, 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}"] - # 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 @@ -620,6 +641,76 @@ 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 = is_simulation_only(stmt) + decode_only = is_decode_only(stmt) + 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 is_simulation_only(statement): + grouped.setdefault(decode_boundary, []).append(statement) + else: + decode_boundary += 1 + + visit(statements) + return grouped + + def _render_preselect( stmt: PreselectStatement, physical_running: int, @@ -728,9 +819,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 @@ -766,12 +855,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 = "", @@ -893,177 +976,36 @@ 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 # --------------------------------------------------------------------------- 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) @@ -1081,6 +1023,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: @@ -1091,32 +1081,38 @@ 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 = is_simulation_only(stmt) + 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: @@ -1169,25 +1165,26 @@ 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( 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 @@ -1207,14 +1204,13 @@ 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) + 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 @@ -1224,30 +1220,22 @@ 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, - 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 - # 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, - ) - ) + lines.extend( + _format_propagate_statements( + base.correction_propagation, + base.physical_correction, + base.logical_correction, + input_layout=input_col_layout, + output_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 bffa0a3d..4ce46ef5 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 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 from deq.spec.common import bitmatrix_from_sparse @@ -79,11 +85,69 @@ 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 ErrorOrigin: + """Flattened source boundary and final index of a non-noise error 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[ErrorOrigin, ...] = () + declared_error_origins: tuple[ErrorOrigin, ...] = () + appended_error_origins: tuple[ErrorOrigin, ...] = () + + def __getstate__( + self, + ) -> tuple[ + bytes, tuple[ErrorOrigin, ...], tuple[ErrorOrigin, ...], tuple[ErrorOrigin, ...] + ]: + return ( + self.jit_type.SerializeToString(), + self.noise_error_origins, + self.declared_error_origins, + self.appended_error_origins, + ) + + def __setstate__( + self, + state: tuple[ + bytes, + tuple[ErrorOrigin, ...], + tuple[ErrorOrigin, ...], + tuple[ErrorOrigin, ...], + ], + ) -> None: + ( + 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, "declared_error_origins", declared_error_origins) + object.__setattr__(self, "appended_error_origins", appended_error_origins) + + +@dataclass(frozen=True) +class JitLibraryArtifacts: + """Runtime library and per-gadget provenance produced by one build.""" + + 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,27 +180,26 @@ 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, *, jobs: int = 1, + loss_model: LossModel | None = None, ) -> jit_pb.JitLibrary: - """Build a :class:`JitLibrary` from a parsed deq file. + """Build and return the runtime ``JitLibrary`` protobuf.""" + return build_jit_library_artifacts( + qfile, jobs=jobs, loss_model=loss_model + ).jit_library + + +def build_jit_library_artifacts( + qfile: DeqFile, + *, + jobs: int = 1, + loss_model: LossModel | None = None, +) -> JitLibraryArtifacts: + """ + Build a ``JitLibrary`` and retain per-gadget annotation provenance. Parameters ---------- @@ -148,52 +211,83 @@ def build_jit_library( ``1`` (default) runs sequentially with no subprocess overhead. Values > 1 use :class:`~concurrent.futures.ProcessPoolExecutor`. """ + if loss_model is None: + loss_model = NeutralAtomLossModel() 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. ``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 + for statement in flatten_body(list(gadget.body)) + ) + if jobs > 1 and len(scaffold.gadgets) > 1: - gadget_types = _build_gadget_types_parallel( + gadget_artifacts = _build_gadget_types_parallel( scaffold.gadgets, scaffold.gtype_of_gadget, scaffold.ptype_of_code, scaffold.code_by_name, jobs, + library_has_loss=library_has_loss, + loss_model=loss_model, ) else: - gadget_types = [ + gadget_artifacts = [ _build_jit_gadget_type( gadget, scaffold.gtype_of_gadget[gadget.name], scaffold.ptype_of_code, scaffold.code_by_name, + library_has_loss=library_has_loss, + loss_model=loss_model, ) 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 # 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: - 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, 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, + library_has_loss=library_has_loss, + loss_model=loss_model, ) + 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), + metadata={ + "loss_strategy": loss_model.config.to_json_object(), + }, + ), + gadget_artifacts_by_name=gadget_artifacts_by_name, ) @@ -317,9 +411,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 +455,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 +466,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]] @@ -413,28 +504,48 @@ def _build_gadget_types_parallel( 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, + 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) for g in gadgets] + args = [ + ( + 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: - result_bytes = list(pool.map(_build_jit_gadget_type_bytes, args)) - - return [jit_pb.JitGadgetType.FromString(b) for b in result_bytes] - - -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() + return list(pool.map(_build_jit_gadget_type_worker, args)) + + +def _build_jit_gadget_type_worker( + 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, loss_model = args + return _build_jit_gadget_type( + g, + gtype, + ptype_of_code, + code_by_name, + library_has_loss=library_has_loss, + loss_model=loss_model, + ) # --------------------------------------------------------------------------- @@ -545,13 +656,9 @@ 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( @@ -560,13 +667,17 @@ def _build_jit_gadget_type( 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, + loss_model: LossModel, + check_override: ( + tuple[ + list[tuple[frozenset[int], bool]], + list[tuple[frozenset[int], bool]], + ] + | None + ) = None, +) -> JitGadgetArtifacts: + """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 @@ -606,7 +717,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 +800,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( @@ -754,7 +865,7 @@ def _build_check( logical_physical_entries=logical_physical_entries, ) - errors_pb = _build_errors( + declared_errors = _build_errors( gadget, codes, output_ports, @@ -762,8 +873,20 @@ def _build_check( num_unfinished=len(unfinished_pb), num_readouts=len(readouts_pb), ) - errors_pb.extend( - compute_noise_errors( + 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, @@ -771,10 +894,25 @@ def _build_check( finished_checks=finished, unfinished_checks=unfinished, ov_start=ov_start, - readouts_info=readouts_info, + 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[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( + 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) base = pb.GadgetType( gtype=gtype, @@ -788,11 +926,55 @@ 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, + ) + if library_has_loss + else None + ) + appended_error_origins: list[ErrorOrigin] = [] + if loss_model_pb is None and library_has_loss: + 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, + loss_model=loss_model, + ) + 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 + 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), + declared_error_origins=tuple(declared_error_origins), + appended_error_origins=tuple(appended_error_origins), ) @@ -854,7 +1036,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 +1284,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 +1320,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 +1338,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 +1358,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 @@ -1392,7 +1574,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 @@ -1494,10 +1677,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 ab0cbeb0..bd54109f 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,22 +77,36 @@ 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, PortColumnLayout, flatten_body, max_qubit_index, - pauli_product_to_stim, resolve_measurement_ref_global, 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, + 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 @@ -114,12 +126,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)) # --------------------------------------------------------------------------- @@ -127,38 +134,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( - 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: - 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, @@ -211,7 +186,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 ] @@ -231,7 +206,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": @@ -255,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 @@ -270,7 +245,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": @@ -306,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, ) ) @@ -322,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) @@ -334,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}") @@ -351,116 +328,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 +340,7 @@ class _WalkResult: def walk_pauli_forward( - decomposed: _DecomposedBody, + decomposed: DecomposedBody, start_index: int, initial: stim.PauliString, num_qubits: int, @@ -488,16 +355,17 @@ 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.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] @@ -531,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 @@ -542,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": @@ -653,7 +521,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 +532,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,16 +549,18 @@ 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 + 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: @@ -807,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 # --------------------------------------------------------------------------- @@ -841,65 +701,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 +710,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 +731,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 +755,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 +803,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 +812,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 +833,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 +855,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 +925,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 +959,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 +994,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 +1170,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 +1243,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 +1313,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 +1335,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 +1565,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) diff --git a/deq/deq/transpiler/jit_transpiler.py b/deq/deq/transpiler/jit_transpiler.py index fead86d1..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, ) # --------------------------------------------------------------------------- @@ -187,36 +188,37 @@ 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)) _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 @@ -264,9 +266,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 @@ -290,33 +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 pauli_product_to_stim( - product: PauliProduct, - num_qubits: int, - qubit_map: 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. - qubit_map: - Optional mapping from code-local to physical 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()] - return ps - - # --------------------------------------------------------------------------- # Measurement layout and code metadata helpers # --------------------------------------------------------------------------- @@ -1082,10 +1057,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 +1074,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] = ( diff --git a/deq/deq/transpiler/loss/__init__.py b/deq/deq/transpiler/loss/__init__.py new file mode 100644 index 00000000..c3f07623 --- /dev/null +++ b/deq/deq/transpiler/loss/__init__.py @@ -0,0 +1,154 @@ +"""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`. +""" + +from __future__ import annotations + +import hashlib +import importlib.util +import sys +from dataclasses import dataclass +from functools import cached_property, lru_cache +from pathlib import Path + +from deq.transpiler.loss.analysis import LossAnalysisResult, analyze_loss_events +from deq.transpiler.loss.api import ( + GateLossPolicy, + LossAnalysisState, + LossGate, + LossModel, + QdkLossConfig, + 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, + LossEvent, + LossEventGraph, + PauliInsertion, + build_loss_event_graph, +) + +LOSS_MODEL_NAMES = ("neutral-atom", "trapped-ion", "none") + + +@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 + native_gates: frozenset[str] + + @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}") + 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: + """Create a built-in model by name or load one from a Python file.""" + + constructors = { + "neutral-atom": NeutralAtomLossModel, + "trapped-ion": TrappedIonLossModel, + "none": NoLossModel, + } + 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, + native_gates=model.native_gates, + ) + + 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", + "NoLossModel", + "TrappedIonLossModel", + "GateLossPolicy", + "QdkLossConfig", + "LossBranch", + "LossEvent", + "LossEventGraph", + "LossAnalysisState", + "LossAnalysisResult", + "LossGate", + "LossModel", + "LOSS_MODEL_NAMES", + "PauliInsertion", + "UnsupportedLossModelError", + "analyze_loss_events", + "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..19334c7c --- /dev/null +++ b/deq/deq/transpiler/loss/analysis.py @@ -0,0 +1,767 @@ +"""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, + 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 every prior loss on this branch.""" + + retained: list[tuple[_PendingLossEvent, _PendingLossBranch]] = [] + for event, branch in self.pending.get(qubit, ()): + if branch.active: + 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, + native_gates: 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 native_gates: + 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) + if not isinstance(model, LossModel): + raise TypeError( + f"{type(model).__name__} does not implement the LossModel protocol" + ) + native_gates = frozenset(name.upper() for name in model.native_gates) + 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, + ) + 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] + 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, + ) + model.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, + native_gates=native_gates, + ) + for gate in gates: + model.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 new file mode 100644 index 00000000..bffa6f45 --- /dev/null +++ b/deq/deq/transpiler/loss/api.py @@ -0,0 +1,219 @@ +"""Public protocols and immutable inputs for physical loss models.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from enum import StrEnum +from typing import Protocol, runtime_checkable + + +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. + + Multi-target Stim instructions are atomized before dispatch, so ``qubits`` + 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_index: int | None + control_measurement_index: int | None + body_index: int + boundary_before: int + boundary_after: int + produces_measurement: bool + resets_qubits: bool + is_source_gate: bool + + +@runtime_checkable +class LossAnalysisState(Protocol): + """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``.""" + + ... + + 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, + generators: tuple[str, ...] = ("X", "Z"), + ) -> None: + """Add Pauli generators to every active branch on ``qubit``.""" + + ... + + def add_event_continuation_pauli_insertion( + self, + event_id: int, + *, + branch_qubit: int, + qubit: int, + boundary: int, + generators: tuple[str, ...] = ("X", "Z"), + ) -> None: + """Add generators on ``qubit`` in one event's ``branch_qubit`` branch.""" + + ... + + def add_source_pauli_insertion( + self, + event_id: int, + generators: tuple[str, ...] = ("X", "Z"), + ) -> None: + """Add Pauli generators at one loss event's source boundary.""" + + ... + + 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.""" + + ... + + def clear_loss(self, qubit: int) -> None: + """Terminate all active branches occupying ``qubit``.""" + + ... + + 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: + """Swap active loss flags by ending and recreating event branches.""" + + ... + + +@runtime_checkable +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: + """Handle a newly created physical loss event.""" + + ... + + def handle_gate(self, gate: LossGate, state: LossAnalysisState) -> None: + """Handle a source-level or decomposed primitive gate.""" + + ... diff --git a/deq/deq/transpiler/loss/loss_graph.py b/deq/deq/transpiler/loss/loss_graph.py new file mode 100644 index 00000000..dd81dcd5 --- /dev/null +++ b/deq/deq/transpiler/loss/loss_graph.py @@ -0,0 +1,237 @@ +"""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 + +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] + 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] + 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 + ), + ) 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..91b9a7df --- /dev/null +++ b/deq/deq/transpiler/loss/model_neutral_atom.py @@ -0,0 +1,77 @@ +"""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 + +from deq.transpiler.loss.api import ( + GateLossPolicy, + LossAnalysisState, + LossGate, + 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", +} + +_NEUTRAL_ATOM_CONFIG = QdkLossConfig( + gate_policies=( + ("cx", GateLossPolicy.SKIP), + ("cy", GateLossPolicy.SKIP), + ("cz", GateLossPolicy.SKIP), + ("swap", GateLossPolicy.APPLY_ANYWAY), + ), +) + + +class NeutralAtomLossModel: + """Neutral-atom platform model: SKIP gates and relocate atoms on SWAP.""" + + config = _NEUTRAL_ATOM_CONFIG + + native_gates = frozenset( + { + *_QDK_TABLE_BY_SOURCE_GATE, + "S", + "SQRT_X", + "SQRT_X_DAG", + } + ) + + 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) + + +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_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/deq/transpiler/loss/model_trapped_ion.py b/deq/deq/transpiler/loss/model_trapped_ion.py new file mode 100644 index 00000000..e0e546c2 --- /dev/null +++ b/deq/deq/transpiler/loss/model_trapped_ion.py @@ -0,0 +1,78 @@ +"""Residual-phase approximation for MS-native trapped-ion hardware.""" + +from __future__ import annotations + +from deq.transpiler.loss.api import ( + GateLossPolicy, + LossAnalysisState, + LossGate, + QdkLossConfig, + UnsupportedLossModelError, +) +from deq.transpiler.loss.policies import ( + handle_gate_policy, + handle_loss_source, + handle_measurement, + handle_reset, + handle_skip, +) + +_QDK_TABLE_BY_SOURCE_GATE = { + "CZ": "cz", + "SWAP": "swap", +} +_UNSUPPORTED_CONTROLLED_GATES = frozenset({"CX", "CY"}) + + +_TRAPPED_ION_CONFIG = QdkLossConfig( + gate_policies=( + ("cz", GateLossPolicy.RESIDUAL_S_DAGGER), + ("swap", GateLossPolicy.APPLY_ANYWAY), + ), +) + + +class TrappedIonLossModel: + """Effective trapped-ion model for one specified CZ compilation.""" + + config = _TRAPPED_ION_CONFIG + + native_gates = frozenset( + { + *_QDK_TABLE_BY_SOURCE_GATE, + *_UNSUPPORTED_CONTROLLED_GATES, + "S", + "SQRT_X", + "SQRT_X_DAG", + } + ) + + 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 + 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 " + "decomposition or custom loss model" + ) + 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) + + +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..7edcb989 --- /dev/null +++ b/deq/deq/transpiler/loss/policies.py @@ -0,0 +1,157 @@ +"""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 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_index) + + +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 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": + 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 in {"CX", "CY"}: + _, target = gate.qubits + state.add_continuation_pauli_insertion(target, gate.boundary_after) + 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, 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, + branch_qubit=branch_qubit, + qubit=qubit, + boundary=gate.boundary_after, + generators=("Z",), + ) + + +def handle_gate_policy( + policy: GateLossPolicy, gate: LossGate, state: LossAnalysisState +) -> 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, + GateLossPolicy.DEGRADE: handle_degrade, + GateLossPolicy.RESIDUAL_S_DAGGER: handle_residual_s_dagger, + GateLossPolicy.APPLY_ANYWAY: handle_apply_anyway_swap, + } + handlers[policy](gate, state) 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 new file mode 100644 index 00000000..f4c3d0e9 --- /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, +) +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) +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), + ) diff --git a/deq/deq/transpiler/stim_constants.py b/deq/deq/transpiler/stim_constants.py index 53e99cd5..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( @@ -24,10 +27,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,42 +38,42 @@ # * 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. +# ``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 # 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: """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 + # 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). @@ -167,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)] 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/Cargo.toml b/deq/deq_runtime/Cargo.toml index 0ff24c21..2cc71cc1 100644 --- a/deq/deq_runtime/Cargo.toml +++ b/deq/deq_runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deq-runtime" -version = "0.4.3" +version = "0.5.0-rc2" edition = "2024" authors = ["Microsoft Corporation"] description = "deq: Real-time Quantum Error Correction Decoding System" @@ -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 = [ @@ -91,6 +92,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/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..d310986d 100644 --- a/deq/deq_runtime/deq_runtime.pyi +++ b/deq/deq_runtime/deq_runtime.pyi @@ -27,6 +27,18 @@ class Hyperedge: probability: float +class LossSite: + source_edges: list[int] + continuation_edges: list[int] + children: list[int] + probability: float + heralds: list[int] + + +class LossInfo: + sites: list["LossSite"] + + class Coordinator: """Coordinator (`deq.bin`) interface for the in-process runtime. diff --git a/deq/deq_runtime/src/cli.rs b/deq/deq_runtime/src/cli.rs index 7aed99d1..2629fe76 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 @@ -95,16 +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::Local(DynBlackBoxDecoder::BlackBoxPython(decoder)); - 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/coordinator.rs b/deq/deq_runtime/src/coordinator.rs index aac010f9..c56eaa64 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,33 +19,6 @@ 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 { @@ -90,21 +63,18 @@ pub use decoder_cache_key::{ DecoderCacheKey, ErrorModelFingerprint, FingerprintSource, ProbabilityModifierBits, build_modifier_fingerprints, }; +pub mod reweight_handler; +pub use reweight_handler::{DecodeProjection, DecoderReweighting, 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 +210,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); - } -} 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..a1b69780 --- /dev/null +++ b/deq/deq_runtime/src/coordinator/loss_handler.rs @@ -0,0 +1,571 @@ +//! 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() { + 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); + } + } + 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 { + if hyperedge.probability == 0.0 { + continue; + } + 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; diff --git a/deq/deq_runtime/src/coordinator/mock_coordinator.rs b/deq/deq_runtime/src/coordinator/mock_coordinator.rs index c37686ba..d9caa4a6 100644 --- a/deq/deq_runtime/src/coordinator/mock_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/mock_coordinator.rs @@ -5,14 +5,34 @@ 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::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 +232,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 @@ -234,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); @@ -294,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() { @@ -394,6 +419,8 @@ impl Default for MockCoordinator { next_eid: 1, ..Default::default() }), + state_changed: Notify::new(), + execute_blocker: std::sync::Mutex::new(None), } } } @@ -430,6 +457,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; @@ -460,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), @@ -484,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), @@ -512,6 +546,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/coordinator/monolithic_coordinator.rs b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs index cd06c5bf..450f2fd0 100644 --- a/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/monolithic_coordinator.rs @@ -23,20 +23,32 @@ use crate::bin; use crate::coordinator; -use crate::coordinator::{DecoderCacheKey, FingerprintSource, build_modifier_fingerprints}; -use crate::decoder::BlackBoxDecoderClient; +use crate::coordinator::loss_handler::{RawLossSite, apply_loss_random_imputation, has_loss_model}; +use crate::coordinator::reweight_handler::{ + ProjectedErrors, apply_reweights, decode_projected, deduplicate_decoder_input, load_projected_decoder, + probability_reweights, +}; +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::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; 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::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}; + use serde::{Deserialize, Serialize}; use std::sync::Arc; #[cfg(feature = "cli")] @@ -53,10 +65,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 +81,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 +96,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 +155,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 +168,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 +186,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 +222,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 +235,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 +252,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, } } @@ -458,7 +495,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, @@ -481,12 +518,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]; @@ -517,12 +554,29 @@ 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; + // 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,116 +587,169 @@ 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 = loaded + .projection + .probability_reweights(Self::shot_probability_modifiers(mapping, gadgets)); + let projected = self + .loss_handler + .project_shot(&loaded.projection, &probability_reweights, &loss_sites); + let parity_factor = decode_projected( + &self.decoder, + &loaded, + syndrome.clone(), + projected.reweights, + projected.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); } - return (parity_factor, loaded.errors.clone()); + return (parity_factor, projected.errors); } } // 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)); - } + 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; + 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 prepared = deduplicate_decoder_input(&decoding_hypergraph, &errors); + decoding_hypergraph = prepared.hypergraph; + errors = prepared.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.into()); }; + // 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, + deduplicate, + retain_decoding_hypergraph, + false, + ) + .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); + 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(), + projected.reweights, + projected.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, projected.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.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) + } + + fn shot_probability_reweights( + mapping: &RelativeMapping, + gadgets: &HashMap, + error_reference: &[ErrorIndex], + ) -> Vec<(u64, f64)> { + probability_reweights(error_reference, Self::shot_probability_modifiers(mapping, gadgets)) + } - (parity_factor, errors) + 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); + modifiers } async fn get_syndrome( @@ -687,6 +794,87 @@ 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 { + 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 +921,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 +952,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 +1172,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 +1219,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 +1282,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(), @@ -1094,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; @@ -1107,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( @@ -1162,6 +1357,21 @@ 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 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; @@ -1175,26 +1385,11 @@ 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)) })?; 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( @@ -1238,12 +1433,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 +1451,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)))?; + validate_outcomes( + &outcome_data, + outcomes.loss_mask.as_ref(), + u64::try_from(gadget_type.measurements.len()).unwrap(), + ) + .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 +1489,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 +1519,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 +1550,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()) } } 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..39546930 --- /dev/null +++ b/deq/deq_runtime/src/coordinator/reweight_handler.rs @@ -0,0 +1,549 @@ +//! 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 the projection 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::ops::Index; +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>, + deduplicate: bool, + retain_decoding_hypergraph: bool, + ignore_isolated_vertices: bool, +) -> Result { + 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 { + 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, + decoding_hypergraph, + ignored_syndrome_vertices, + projection: Arc::new(projection), + }) +} + +fn prepare_decoder( + base_hypergraph: blackbox_decoder::DecodingHypergraph, + base_errors: Arc>, + 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 { + ( + PreparedDecoderInput { + hypergraph: base_hypergraph.clone(), + representatives: Arc::clone(&base_errors), + }, + EdgeProjection::Identity, + ) + }; + ( + DecodeProjection { + base_hypergraph, + base_errors, + decoder_errors: Arc::clone(&prepared.representatives), + error_edge_lookup, + 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); + } +} + +/// 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::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, +) -> Vec<(u64, f64)> { + 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(); + } + 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) + } +} + +/// 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, + /// 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 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 + /// 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>, + /// 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 +/// 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: Arc>, +} + +/// 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-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], +) -> (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)) in hypergraph.hyperedges.iter().zip(errors.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_probability)) = seen.get_mut(&syndrome) { + let combined = hyperedges[*index].probability; + hyperedges[*index].probability = exclusive_probability_of(combined, hyperedge.probability); + if hyperedge.probability > *best_probability { + *best_probability = hyperedge.probability; + 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, hyperedge.probability)); + } + } + ( + PreparedDecoderInput { + hypergraph: blackbox_decoder::DecodingHypergraph { + vertex_num: hypergraph.vertex_num, + hyperedges, + }, + representatives: Arc::new(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], +) -> PreparedDecoderInput { + deduplicate_by_syndrome(hypergraph, errors).0 +} + +impl DecodeProjection { + #[cfg(test)] + pub(crate) fn identity( + base_hypergraph: blackbox_decoder::DecodingHypergraph, + base_errors: Arc>, + ) -> Self { + 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)>, ProjectedErrors) { + self.edge_projection + .project_reweights(&self.base_hypergraph, &self.base_errors, &self.decoder_errors, reweights) + } +} + +impl EdgeProjection { + fn project_reweights( + &self, + base_hypergraph: &blackbox_decoder::DecodingHypergraph, + base_errors: &[ErrorIndex], + decoder_errors: &Arc>, + reweights: &[(u64, f64)], + ) -> (Vec<(u64, f64)>, ProjectedErrors) { + 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, ProjectedErrors::shared(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 reweighted_decoder_edges = Vec::with_capacity(reweights.len()); + for &(edge, probability) in reweights { + let original = usize::try_from(edge).unwrap(); + overrides.insert(original, probability); + reweighted_decoder_edges.push(decoder_edge_of_original[original]); + } + reweighted_decoder_edges.sort_unstable(); + reweighted_decoder_edges.dedup(); + if reweighted_decoder_edges.is_empty() { + return (vec![], ProjectedErrors::shared(Arc::clone(decoder_errors))); + } + let mut replacements = Vec::with_capacity(reweighted_decoder_edges.len()); + let translated = reweighted_decoder_edges + .into_iter() + .map(|decoder_edge| { + 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 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, + ProjectedErrors { + baseline: Arc::clone(decoder_errors), + replacements, + }, + ) + } + } + } +} + +#[cfg(test)] +#[path = "../../tests/unit/reweight_handler_test.rs"] +mod tests; diff --git a/deq/deq_runtime/src/coordinator/window_coordinator.rs b/deq/deq_runtime/src/coordinator/window_coordinator.rs index 2fb00228..aec37fea 100644 --- a/deq/deq_runtime/src/coordinator/window_coordinator.rs +++ b/deq/deq_runtime/src/coordinator/window_coordinator.rs @@ -82,18 +82,28 @@ 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::loss_handler::{RawLossSite, apply_loss_random_imputation, has_loss_model}; +use crate::coordinator::reweight_handler::{ + ProjectedErrors, apply_reweights, decode_projected, deduplicate_decoder_input, ignore_edge_isolated_history_vertices, + load_projected_decoder, probability_reweights, +}; +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::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}; 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::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}; @@ -120,10 +130,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 +142,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 +177,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 +252,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 +263,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 +296,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 +427,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 +440,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 +457,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()), } @@ -1382,7 +1426,7 @@ impl WindowCoordinator { committing_cids: &HashSet, window: &HashSet, parity_factor: &blackbox_decoder::ParityFactor, - errors: &[ErrorIndex], + errors: &ProjectedErrors, relative_program: &RelativeProgram, mapping: &RelativeMapping, ) { @@ -1405,7 +1449,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 +1457,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; @@ -1500,7 +1544,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| { @@ -1511,21 +1555,109 @@ 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 { + 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, 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 = { @@ -1548,9 +1680,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,131 +1710,184 @@ 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.projected_shot_probability_reweights(mapping, &loaded.projection).await; + 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"))); - // 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 parity_factor = self - .black_box_decoder - .clone() - .decode_loaded(blackbox_decoder::LoadedDecodingProblem { - hid: loaded.hid, - syndrome: Some(decode_syndrome.clone()), - }) - .await - .unwrap(); + let decode_syndrome = loaded.project_syndrome(syndrome.clone()); + let parity_factor = decode_projected( + &self.decoder, + &loaded, + decode_syndrome.clone(), + projected.reweights, + projected.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); } - return (parity_factor, loaded.errors.clone()); + return (parity_factor, projected.errors); } } // 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; + + 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 + // 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 prepared = deduplicate_decoder_input(&decoding_hypergraph, &errors); + decoding_hypergraph = prepared.hypergraph; + errors = prepared.representatives; + } + let mut syndrome = syndrome; + ignore_edge_isolated_history_vertices(&decoding_hypergraph, &mut 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.into()); }; + // 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, + deduplicate, + retain_decoding_hypergraph, + true, + ) + .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 + .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); + let parity_factor = decode_projected( + &self.decoder, + &loaded, + decode_syndrome.clone(), + projected.reweights, + projected.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, projected.errors) + } - (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; + 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); + modifiers } async fn decoding_hypergraph( @@ -1747,9 +1949,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 +1988,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, @@ -1801,66 +2005,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, @@ -2139,7 +2283,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 +2331,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 +2379,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. @@ -2303,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; @@ -2316,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)) })?; @@ -2331,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( @@ -2453,16 +2601,18 @@ 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)))?; - 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(); - } + 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 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 @@ -2556,7 +2706,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 +2725,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)))?; + validate_outcomes( + &outcome_data, + outcomes.loss_mask.as_ref(), + u64::try_from(gadget_type.measurements.len()).unwrap(), + ) + .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 +2967,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 +2997,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); diff --git a/deq/deq_runtime/src/decoder.rs b/deq/deq_runtime/src/decoder.rs index 38d2a32e..e727d375 100644 --- a/deq/deq_runtime/src/decoder.rs +++ b/deq/deq_runtime/src/decoder.rs @@ -65,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; @@ -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,74 @@ 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, + DynDecoder::BlackBoxDynLib(decoder) => decoder.as_ref(), + DynDecoder::Mock(decoder) => decoder.as_ref(), } } - #[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, - } - } - - #[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> { + required + .require_supported_by(self.features()) + .map_err(|unsupported| 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()) + self.require_features(DecoderFeatures::required(false, problem.loss.is_some()))?; + 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 required = DecoderFeatures::required(!problem.reweights.is_empty(), problem.loss.is_some()); + 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(|_| ()) } } 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/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/dyn_lib_decoder.rs b/deq/deq_runtime/src/decoder/dyn_lib_decoder.rs index d1cb0347..be88b940 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, DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder, +}; #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "cli", derive(StructDoc))] @@ -66,6 +67,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 +78,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,18 +102,32 @@ 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 { + fn decode(&mut self, request: DecodeRequest<'_>) -> Result { // 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 }, - // 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/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 diff --git a/deq/deq_runtime/src/decoder/mock_decoder.rs b/deq/deq_runtime/src/decoder/mock_decoder.rs index bf1533cb..d03dda7a 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::decoder_features::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(); + 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"))?; @@ -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,9 @@ impl black_box_decoder_server::BlackBoxDecoder for MockDecoder { request: Request, ) -> Result, Status> { let problem = request.into_inner(); + 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; @@ -200,6 +261,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/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..60e123ff 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::decoder_features::DecoderFeatures; use serde::{Deserialize, Serialize}; #[cfg(feature = "cli")] use std::sync::Arc; @@ -28,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 = @@ -38,6 +44,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(self.supported_features().to_proto())) + } + async fn decode( &self, _request: Request, diff --git a/deq/deq_runtime/src/decoder/python_decoder.rs b/deq/deq_runtime/src/decoder/python_decoder.rs index 77ac462d..215f98f9 100644 --- a/deq/deq_runtime/src/decoder/python_decoder.rs +++ b/deq/deq_runtime/src/decoder/python_decoder.rs @@ -3,22 +3,31 @@ //! 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, ParityFactor}; -use crate::decoder::thread_pooling::{DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder}; +use crate::decoder::decoder_features::DecoderFeatures; +use crate::decoder::thread_pooling::{ + 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}; -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; @@ -40,13 +49,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"] } } @@ -69,7 +79,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, } @@ -77,6 +87,43 @@ 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 { + 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\"" + )) + })?; + features |= feature; + } + Ok(features) + }) +} + #[pyclass(name = "DecodingHypergraph")] pub struct PyDecodingHypergraph { #[pyo3(get, set)] @@ -132,27 +179,67 @@ 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, and equal `heralds` values identify the same +/// observed loss-resolving measurement. +#[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, + #[pyo3(get, set)] + pub heralds: Vec, +} + +#[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, } 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())?; @@ -163,18 +250,48 @@ 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,))?; + 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, + heralds: site.heralds.clone(), + })?; + } + Ok::(PyLossInfo { + sites: py_sites.unbind(), + }) + }) + .transpose()?; + let kwargs = PyDict::new(py); + if let Some(reweights) = py_reweights { + kwargs.set_item("reweights", reweights)?; + } + if let Some(loss) = py_loss { + kwargs.set_item("loss", loss)?; + } + let py_result = if kwargs.is_empty() { + decoder.call_method1("decode", (py_syndrome,))? + } else { + decoder.call_method("decode", (py_syndrome,), Some(&kwargs))? + }; 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/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 9b5c9913..abe93b90 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, 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}; @@ -140,15 +141,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,22 +208,29 @@ 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 { - let mut detectors = Array1::::zeros(syndrome.size as usize); - for index in to_sparse_indices(syndrome) { + fn decode(&mut self, request: DecodeRequest<'_>) -> Result { + 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(i as u64) } 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/src/decoder/tesseract_decoder.rs b/deq/deq_runtime/src/decoder/tesseract_decoder.rs index d0e7a17a..8ee38e38 100644 --- a/deq/deq_runtime/src/decoder/tesseract_decoder.rs +++ b/deq/deq_runtime/src/decoder/tesseract_decoder.rs @@ -4,10 +4,12 @@ //! 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::{DecoderInstance, ThreadPoolingConfig, ThreadPoolingDecoder}; +use crate::decoder::thread_pooling::{ + DecodeError, DecodeRequest, 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 +56,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 +93,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 +124,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/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/decoder/test_harness.rs b/deq/deq_runtime/src/decoder/test_harness.rs index ebba9669..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,12 +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()), }; @@ -175,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, @@ -183,8 +184,9 @@ 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 { + let outcome = match decoder.decode_loaded(problem_payload).await { Ok(response) => classify(&problem.hypergraph, &case.syndrome, &response), Err(status) => Outcome::RpcError(status.to_string()), }; @@ -197,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/src/decoder/thread_pooling.rs b/deq/deq_runtime/src/decoder/thread_pooling.rs index 3311188a..5e03dce9 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; +use crate::misc::bit_vector; +#[cfg(debug_assertions)] +use crate::misc::validation; 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)] + { + 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,104 @@ 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)] + { + 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 +439,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/jit.rs b/deq/deq_runtime/src/jit.rs index 7cb9c276..4b06f47f 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; @@ -10,7 +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(); + 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()); } @@ -250,3 +254,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/jit/loss_compiler.rs b/deq/deq_runtime/src/jit/loss_compiler.rs new file mode 100644 index 00000000..907abf8b --- /dev/null +++ b/deq/deq_runtime/src/jit/loss_compiler.rs @@ -0,0 +1,707 @@ +//! 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 +//! 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::util::BitVector; +use hashbrown::HashMap; +use std::collections::BTreeSet; + +/// 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. +/// `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, + pub probability: f64, + pub source_generators: Vec, + pub continuation_generators: Vec, + pub children: Vec, + pub heralds: 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>, +} + +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).unwrap()); + } + offsets +} + +/// Resolve each loss-bearing output slot to its downstream gadget and input +/// 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, + 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 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()); + 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 +/// 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).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).unwrap()) { + 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); + } + 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, + 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(), + heralds: nodes[node] + .heralds + .iter() + .map(|&herald| herald_id_of[&(nodes[node].gadget_index, herald)]) + .collect::>() + .into_iter() + .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).unwrap()) +} + +#[cfg(test)] +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 { + 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) + } + + 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 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 + // 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[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] + 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()); + } +} 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/misc/bit_vector.rs b/deq/deq_runtime/src/misc/bit_vector.rs index 4332fa32..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 { @@ -80,6 +64,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!(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; + } + 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 +184,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], + })); + } } diff --git a/deq/deq_runtime/src/misc/index.rs b/deq/deq_runtime/src/misc/index.rs index 1ae06ca4..61137085 100644 --- a/deq/deq_runtime/src/misc/index.rs +++ b/deq/deq_runtime/src/misc/index.rs @@ -1,9 +1,9 @@ pub const WILDCARD: u64 = 0; -#[derive(Debug, Clone)] +#[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/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/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/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() { diff --git a/deq/deq_runtime/src/misc/validation.rs b/deq/deq_runtime/src/misc/validation.rs new file mode 100644 index 00000000..13c421df --- /dev/null +++ b/deq/deq_runtime/src/misc/validation.rs @@ -0,0 +1,423 @@ +//! 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; + +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> { + 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(()) +} + +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. + 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 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!( + 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() + ); + } +} diff --git a/deq/deq_runtime/src/proto/deq.bin.rs b/deq/deq_runtime/src/proto/deq.bin.rs index 13b28fd7..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 { @@ -95,6 +100,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 +142,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 { @@ -151,6 +242,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..f7418028 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")] @@ -14,6 +19,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 { @@ -21,12 +30,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. Each edge may appear at most once in a request. +#[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 { @@ -49,6 +77,75 @@ 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 + /// 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, +} +#[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, + /// 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)] +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 { @@ -141,6 +238,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 @@ -270,6 +398,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 @@ -373,6 +510,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/proto/deq.jit.rs b/deq/deq_runtime/src/proto/deq.jit.rs index 1275eab2..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 { @@ -30,6 +33,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 { 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 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 } diff --git a/deq/deq_runtime/src/simulator/common.rs b/deq/deq_runtime/src/simulator/common.rs index a0d3cdbe..77f824e9 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, @@ -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() { @@ -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/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) }; 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. diff --git a/deq/deq_runtime/src/simulator/qdk_sampler.py b/deq/deq_runtime/src/simulator/qdk_sampler.py index 0354da63..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,7 +68,26 @@ 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 + # Mapping from qdk Result enum to the single-char alphabet the deq Rust # sampler expects. `Result` is a PyO3-bound class so hashing/equality @@ -91,7 +113,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)) @@ -101,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 @@ -125,7 +149,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/common/test_library.rs b/deq/deq_runtime/tests/common/test_library.rs index bb7c1c36..853d9609 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 @@ -425,5 +426,6 @@ pub fn test_jit_library() -> jit::JitLibrary { }, ], program: vec![], + metadata: None, } } diff --git a/deq/deq_runtime/tests/dyn_lib_decoder_test.rs b/deq/deq_runtime/tests/dyn_lib_decoder_test.rs index 5b214a0d..893f9235 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; @@ -83,6 +82,7 @@ async fn load_and_decode_through_grpc_surface() { Request::new(blackbox_decoder::LoadedDecodingProblem { hid, syndrome: Some(syndrome(3, &set_vertices)), + ..Default::default() }), ) .await @@ -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() { @@ -107,8 +148,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/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 4c3a9f68..e9042330 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 @@ -268,6 +269,7 @@ fn basic_jit_library() -> jit::JitLibrary { }, ], program: vec![], + metadata: None, } } @@ -464,6 +466,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 @@ -644,6 +647,7 @@ fn check_propagation_jit_library() -> jit::JitLibrary { }, ], program: vec![], + metadata: None, } } @@ -1009,6 +1013,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 @@ -1661,6 +1666,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; @@ -1720,16 +1726,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 @@ -1767,14 +1766,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" @@ -1795,15 +1791,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 @@ -1841,13 +1830,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" @@ -1855,10 +1844,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]; @@ -1877,7 +1865,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(); @@ -1897,14 +1885,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. @@ -1949,7 +1930,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 f5d05c86..a9cc3b83 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) @@ -187,6 +188,7 @@ fn basic_jit_library() -> jit::JitLibrary { }, ], program: vec![], + metadata: None, } } @@ -212,6 +214,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(); @@ -352,21 +360,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"); @@ -383,18 +377,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; @@ -491,22 +474,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"); @@ -540,22 +508,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; @@ -573,21 +526,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. @@ -604,23 +543,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; } // ============================================================================ @@ -667,18 +590,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; @@ -737,17 +649,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; } @@ -796,17 +698,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/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/mock_decoder_test.rs b/deq/deq_runtime/tests/mock_decoder_test.rs index 4b6a5ee6..9fde35c3 100644 --- a/deq/deq_runtime/tests/mock_decoder_test.rs +++ b/deq/deq_runtime/tests/mock_decoder_test.rs @@ -1,8 +1,14 @@ //! Tests for MockDecoder -use deq_runtime::decoder::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::{DynDecoder, MockDecoder}; use deq_runtime::util::BitVector; +use std::sync::Arc; use tonic::Request; #[tokio::test] @@ -26,6 +32,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 +101,7 @@ async fn test_mock_decoder_decode_loaded() { Request::new(blackbox_decoder::LoadedDecodingProblem { hid, syndrome: Some(syndrome), + ..Default::default() }), ) .await @@ -106,6 +114,168 @@ 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_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(); + 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 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(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(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(); + 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, + heralds: vec![4, 7], + ..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 handle = DynDecoder::Mock(decoder.clone()); + let result = handle + .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(); @@ -127,6 +297,7 @@ async fn test_mock_decoder_custom_response() { Request::new(blackbox_decoder::DecodingProblem { hypergraph: Some(hypergraph), syndrome: Some(syndrome), + ..Default::default() }), ) .await @@ -179,6 +350,7 @@ async fn test_mock_decoder_decode_loaded_not_found() { Request::new(blackbox_decoder::LoadedDecodingProblem { hid: 999, syndrome: Some(syndrome), + ..Default::default() }), ) .await; diff --git a/deq/deq_runtime/tests/monolithic_coordinator_test.rs b/deq/deq_runtime/tests/monolithic_coordinator_test.rs index a7d5dc90..061d4c76 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 { @@ -175,6 +171,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 +445,95 @@ 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); +} + +#[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) @@ -1625,7 +1764,27 @@ 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] +#[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, + }), + DynDecoder::Mock(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 @@ -1635,6 +1794,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 +1922,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 +1985,7 @@ async fn test_persistent_decoder_distinguishes_probability_modifier_across_shots ..Default::default() }), None, + None, ) .await; @@ -1838,6 +2000,7 @@ async fn test_persistent_decoder_distinguishes_probability_modifier_across_shots ..Default::default() }), None, + None, ) .await; @@ -1877,9 +2040,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 +2058,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..71849da3 100644 --- a/deq/deq_runtime/tests/standard_decoder_test.rs +++ b/deq/deq_runtime/tests/standard_decoder_test.rs @@ -7,9 +7,16 @@ 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::{BlackBoxDecoderClient, DynBlackBoxDecoder, 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; @@ -71,20 +78,101 @@ fn always_pass_policy(_problem: &str, _case: &str, _path: Path) -> bool { true } +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 { + 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 = decoder + .decode(DecodingProblem { + hypergraph: Some(hypergraph.clone()), + syndrome: Some(syndrome.clone()), + loss: Some(loss.clone()), + }) + .await + .unwrap(); + assert!(parity_factor.subgraph.is_empty()); + + let hid = decoder.load_hypergraph(hypergraph).await.unwrap().hid; + let parity_factor = decoder + .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()); +} + +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 = Arc::new(NaiveDecoder::new(serde_json::json!({}))); - let mut client = BlackBoxDecoderClient::Local(DynBlackBoxDecoder::BlackBoxNaive(decoder)); - 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; + 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); } #[tokio::test] async fn test_mock_decoder() { - let decoder = Arc::new(MockDecoder::new()); - let mut client = BlackBoxDecoderClient::Local(DynBlackBoxDecoder::MockDecoder(decoder)); - let report = run_standard_suite(&mut client).await; + 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); } @@ -92,9 +180,9 @@ 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::Local(DynBlackBoxDecoder::BlackBoxRelayBP(decoder)); - let report = run_standard_suite(&mut client).await; + 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); } @@ -103,9 +191,9 @@ 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::Local(DynBlackBoxDecoder::BlackBoxTesseract(decoder)); - let report = run_standard_suite(&mut client).await; + 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); } @@ -115,13 +203,179 @@ 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::Local(DynBlackBoxDecoder::BlackBoxPython(decoder)); - let report = run_standard_suite(&mut client).await; + 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); +} + +#[cfg(feature = "python")] +#[tokio::test] +async fn test_python_named_decoder_without_supported_features() { + use deq_runtime::decoder::DecoderFeatures; + use deq_runtime::decoder::PythonDecoder; + + 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 = DynDecoder::BlackBoxPython(Arc::new(PythonDecoder::new(serde_json::json!({ + "file": decoder_file.path(), + "name": "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); } +#[cfg(feature = "python")] +#[tokio::test] +async fn test_python_decoder_receives_reweights_and_loss_together() { + use deq_runtime::decoder::PythonDecoder; + + 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 + assert loss.sites[0].heralds == [4, 7], loss.sites[0].heralds + return [0] + + def reset(self): + pass +"#, + ) + .unwrap(); + + let config = serde_json::json!({ + "file": decoder_file.path(), + "name": "CombinedDecoder", + }); + let decoder = DynDecoder::BlackBoxPython(Arc::new(PythonDecoder::new(config))); + assert_eq!(decoder.features(), DecoderFeatures::REWEIGHTS | DecoderFeatures::LOSS); + + let hid = decoder + .load_hypergraph(DecodingHypergraph { + vertex_num: 1, + hyperedges: vec![Hyperedge { + vertices: vec![0], + probability: 0.1, + }], + }) + .await + .unwrap() + .hid; + let parity_factor = decoder + .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, + heralds: vec![4, 7], + ..Default::default() + }], + }), + }) + .await + .unwrap(); + + assert_eq!(parity_factor.subgraph, vec![0]); + + let parity_factor = decoder + .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. @@ -155,9 +409,9 @@ 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::Local(DynBlackBoxDecoder::BlackBoxPython(decoder)); - let report = run_standard_suite(&mut client).await; + 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); } @@ -170,9 +424,24 @@ 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::Local(DynBlackBoxDecoder::BlackBoxPython(decoder)); - let report = run_standard_suite(&mut client).await; + 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/task_counter_test.rs b/deq/deq_runtime/tests/task_counter_test.rs index fb903a03..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; - }); - - // 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"); + assert!(counter.wait_for_zero().now_or_never().is_none()); - // 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,8 +56,22 @@ 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] +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()); + + assert!(counter.wait_for_zero().now_or_never().is_none()); + drop(active); + assert!(counter.wait_for_zero().now_or_never().is_some()); + + drop(pause); + assert!(counter.try_guard().is_some()); } 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]); +} 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); +} 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..498c8aaa --- /dev/null +++ b/deq/deq_runtime/tests/unit/reweight_handler_test.rs @@ -0,0 +1,405 @@ +//! 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], + }; + + 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] +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, 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, 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_probability_correction() { + 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); + + 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: 99 }); + 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); + assert_eq!(deduplicated.hypergraph.hyperedges.len(), 2); + assert_eq!(deduplicated.representatives.as_ref(), &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 (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)]; + 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] +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.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); +} + +#[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 = [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()), true); + let (translated, _) = projection.project_reweights(&reweights); + let mut reweighted = base.clone(); + apply_reweights(&mut reweighted, &reweights); + let (expected, _) = deduplicate_by_syndrome(&reweighted, &errors); + + 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)); +} diff --git a/deq/deq_runtime/tests/window_coordinator_test.rs b/deq/deq_runtime/tests/window_coordinator_test.rs index a1669a62..bdca5796 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; @@ -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 { @@ -48,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 { @@ -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. +/// Test 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). -/// -/// 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,55 +4353,38 @@ 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 -/// 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)); @@ -4405,15 +4397,39 @@ 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(); 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(); @@ -4436,7 +4452,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 { diff --git a/deq/deq_visualizer/package-lock.json b/deq/deq_visualizer/package-lock.json index 03f8dd6f..e814e5e1 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", 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 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." 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/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/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 = [ 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..d5b90ce4 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,46 @@ 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 +1278,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 +1490,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/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/codes-redundant-stabilizers.md b/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md index 44e81d65..edc77c5b 100644 --- a/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md +++ b/deq/documents/tutorial/chapters/codes-redundant-stabilizers.md @@ -63,18 +63,21 @@ 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 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 + @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 - 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 @@ -131,18 +134,21 @@ The annotated Idle gadget: @CHECKS("manual", verify=0) 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 + @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 - 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..11b3b9dc 100644 --- a/deq/documents/tutorial/chapters/compose-gadgets.md +++ b/deq/documents/tutorial/chapters/compose-gadgets.md @@ -84,10 +84,11 @@ 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 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 + @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 OUTPUT RepetitionCode 0 1 2 CHECK OUT0.S0 CHECK OUT0.S1 @@ -106,38 +107,44 @@ 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 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 + @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 - ERROR(0.01) C2 - ERROR(0.01) C3 + @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 - ERROR(0.01) C0 C2 OUT0.LX0 - ERROR(0.01) C0 C1 C2 C3 OUT0.LX0 - ERROR(0.01) C1 C3 OUT0.LX0 + @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 - ERROR(0.01) C4 - ERROR(0.01) C5 + @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 - 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 + @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 - ERROR(0.01) C0 C2 C4 C6 - ERROR(0.01) C1 C3 C5 C7 + @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 CHECK M4 IN0.S0 CHECK M5 IN0.S1 @@ -164,11 +171,13 @@ 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 - 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 @@ -317,10 +326,11 @@ 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 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 + @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 OUTPUT RepetitionCode 0 1 2 CHECK OUT0.S0 CHECK OUT0.S1 @@ -339,16 +349,18 @@ 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 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 + @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 - ERROR(0.01) C0 C2 - ERROR(0.01) C1 C3 + @SIMULATE_ONLY + X_ERROR(0.01) 1 3 + ERROR(0.01) C0 C2 # E3 + ERROR(0.01) C1 C3 # E4 M 1 3 CHECK M0 IN0.S0 CHECK M1 IN0.S1 @@ -371,11 +383,13 @@ 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 - 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 @@ -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,10 +701,11 @@ 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 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 + @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 OUTPUT RepetitionCode 0 1 2 CHECK OUT0.S0 CHECK OUT0.S1 @@ -697,17 +724,20 @@ 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 - ERROR(0.01) C0 OUT0.LX0 - ERROR(0.01) C0 C1 OUT0.LX0 - ERROR(0.01) C1 OUT0.LX0 + @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 - 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 @@ -729,11 +759,13 @@ 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 - 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 @@ -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 17623dac..531020a9 100644 --- a/deq/documents/tutorial/chapters/debug-deq-program.md +++ b/deq/documents/tutorial/chapters/debug-deq-program.md @@ -37,10 +37,11 @@ Output: @CHECKS("manual", verify=0) 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 + @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 OUTPUT RepetitionCode 0 1 2 CHECK OUT0.S0 CHECK OUT0.S1 @@ -59,17 +60,20 @@ Output: @CHECKS("manual", verify=0) 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 + @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 - 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 @@ -91,11 +95,13 @@ 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 - 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 @@ -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/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: diff --git a/deq/documents/tutorial/chapters/multi-port-gadgets.md b/deq/documents/tutorial/chapters/multi-port-gadgets.md index 0afef57e..46c7f47b 100644 --- a/deq/documents/tutorial/chapters/multi-port-gadgets.md +++ b/deq/documents/tutorial/chapters/multi-port-gadgets.md @@ -157,13 +157,14 @@ 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 - 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 + @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 + 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 diff --git a/deq/documents/tutorial/chapters/python-decoder.md b/deq/documents/tutorial/chapters/python-decoder.md index 6c18049b..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). @@ -165,9 +181,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 +248,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 `{}`. | +| `parallel` | int (optional) | Number of decoder worker threads (inherited from the thread-pooling layer). | + +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. 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/documents/tutorial/chapters/qdk-loss-simulation.md b/deq/documents/tutorial/chapters/qdk-loss-simulation.md index 7b701609..ce195b67 100644 --- a/deq/documents/tutorial/chapters/qdk-loss-simulation.md +++ b/deq/documents/tutorial/chapters/qdk-loss-simulation.md @@ -10,37 +10,153 @@ 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. -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. +deq packages these gate-by-gate rules as platform loss models, selected with +``--loss-model``: + +| Model | Scope | Explicit gate policies | +| --- | --- | --- | +| ``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 +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``, 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 +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 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 @@ -83,10 +199,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. @@ -136,13 +251,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. --- @@ -280,9 +393,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. --- @@ -296,17 +409,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/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/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/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`` diff --git a/deq/proto/blackbox_decoder.proto b/deq/proto/blackbox_decoder.proto index 10d21264..e2ba807c 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; @@ -39,6 +52,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 { @@ -49,6 +65,21 @@ 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; + // 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 +// hypergraph. The probability is *assigned*, not combined: it may raise or lower +// the loaded prior. Each edge may appear at most once in a request. +message EdgeReweight { + uint64 edge = 1; + double probability = 2; } message ParityFactor { repeated uint64 subgraph = 1; } @@ -64,3 +95,37 @@ 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 + // 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; +} + +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; + // 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/proto/deq_bin.proto b/deq/proto/deq_bin.proto index fe70b4a9..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 { @@ -132,6 +137,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 { @@ -149,6 +231,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..94710758 100644 --- a/deq/proto/deq_jit.proto +++ b/deq/proto/deq_jit.proto @@ -7,12 +7,15 @@ 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; + // Reference metadata copied unchanged to the compiled deq.bin.Library. + google.protobuf.Struct metadata = 6; } message UnloadJitLibrary { @@ -37,6 +40,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 +111,9 @@ message JitGadgetType { repeated uint64 unfinished_checks = 3; } repeated Error errors = 4; + + // The loss template moved to `base` (`deq.bin.GadgetType.loss_model`) + reserved 5; } message JitInstruction { diff --git a/deq/pyproject.toml b/deq/pyproject.toml index 9693f3ed..95238e37 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.2" +version = "0.5.0rc1" description = "deq: quantum error correction decoding system." readme = "README.md" license = { text = "MIT" } @@ -36,12 +36,13 @@ dependencies = [ "grpcio", "grpcio-tools>=1.76,<1.84", "anywidget", - "deqagram>=0.1.0,<0.2", + "deqagram>=0.1.1,<0.2", "mako", "stim", "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/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/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_keep_noise.py b/deq/tests/circuit/test_annotate_keep_noise.py deleted file mode 100644 index 59507c99..00000000 --- a/deq/tests/circuit/test_annotate_keep_noise.py +++ /dev/null @@ -1,325 +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_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() - ) - - -_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})" - ) diff --git a/deq/tests/circuit/test_annotate_loss.py b/deq/tests/circuit/test_annotate_loss.py new file mode 100644 index 00000000..ffb2491d --- /dev/null +++ b/deq/tests/circuit/test_annotate_loss.py @@ -0,0 +1,107 @@ +"""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 +from deq.transpiler.jit_library_builder import build_jit_library + +_FAITHFUL_LOSS_SRC = """ +CODE C[[3,1,1]] { + LOGICAL X0 Z0 +} + +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) + orig, _ = strip_jit_library(build_jit_library(qfile)) + anno, _ = strip_jit_library(build_jit_library(parse(rendered))) + assert orig.SerializeToString() == anno.SerializeToString() + + +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: + 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 } + GADGET G { + INPUT C 0 + M 0 + OUTPUT C 0 + } + """ + ) + ) + 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/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/cli/loss_model_test.py b/deq/tests/cli/loss_model_test.py new file mode 100644 index 00000000..d52e17ab --- /dev/null +++ b/deq/tests/cli/loss_model_test.py @@ -0,0 +1,554 @@ +"""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, + NoLossModel, + 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 +} +""" + +_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" + 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) + assert isinstance(create_loss_model("none"), NoLossModel) + + +@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, 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" + 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: + 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 + + simulator_config = json.loads(command[config_index]) + 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) + 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/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/runtime/test_mle_loss_decoder.py b/deq/tests/runtime/test_mle_loss_decoder.py new file mode 100644 index 00000000..19b54a5d --- /dev/null +++ b/deq/tests/runtime/test_mle_loss_decoder.py @@ -0,0 +1,174 @@ +"""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=(), heralds=(), probability=0.1): + return SimpleNamespace( + source_edges=list(source), + continuation_edges=list(continuation), + children=list(children), + probability=probability, + heralds=list(heralds), + ) + + +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))) + + 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], heralds=[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], heralds=[0]), + ] + + enabling, loss_edges, herald_starts, conflicts, _, _ = decoder._loss_structure(sites) + + assert enabling == {0: {0, 1}} + assert loss_edges == {0} + 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))) + 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 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..04fc3edd --- /dev/null +++ b/deq/tests/runtime/test_qdk_sampler_loss_model.py @@ -0,0 +1,128 @@ +"""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_sets_only_supported_gate_policies() -> 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_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"CZ 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-") + + +@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 + + _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 13efec84..2a189334 100644 --- a/deq/tests/spec/canonical_test.py +++ b/deq/tests/spec/canonical_test.py @@ -74,6 +74,91 @@ ) +def test_canonicalize_preserves_nested_metadata() -> 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( + metadata={ + "loss_strategy": { + "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, + 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] + assert canonical.library.metadata == lib.metadata + + def test_canonical_default() -> None: canonical_form = canonicalize(default_library) @@ -757,6 +842,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 @@ -1005,18 +1091,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 @@ -1043,18 +1121,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), ] ), @@ -1110,9 +1184,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 @@ -1175,15 +1247,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 @@ -1193,9 +1261,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), ), ], @@ -1225,8 +1291,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 @@ -1287,9 +1352,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] ), @@ -1301,9 +1364,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 @@ -1314,9 +1375,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), ), ], @@ -1416,9 +1475,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), ] ), ], @@ -1430,9 +1487,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), ] ), ], @@ -1445,9 +1500,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( 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/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 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 4dabe52e..1c336074 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 } + 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 } + + 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 } + + 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 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/loss_analysis_test.py b/deq/tests/transpiler/loss_analysis_test.py new file mode 100644 index 00000000..f437f058 --- /dev/null +++ b/deq/tests/transpiler/loss_analysis_test.py @@ -0,0 +1,874 @@ +"""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, + LossAnalysisState, + LossGate, + 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 _RecordingLossModel: + config = QdkLossConfig(gate_policies=()) + native_gates = frozenset() + + def __init__(self) -> None: + self.gates: list[LossGate] = [] + + def handle_loss_source( + self, event_id: int, state: LossAnalysisState + ) -> None: + state.add_source_pauli_insertion(event_id) + + def handle_gate(self, gate: LossGate, state: LossAnalysisState) -> 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,) + + +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( + 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: + model = _RecordingLossModel() + 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.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_receives_individual_gate_occurrences() -> None: + model = _RecordingLossModel() + 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.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.gates + if gate.produces_measurement + ] == [0, 1] + + +def test_non_native_gate_uses_stim_decomposition() -> None: + 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.gates[:3]] == [ + ("H", (1,)), + ("CX", (0, 1)), + ("H", (1,)), + ] + assert all(gate.source_name == "CZ" for gate in model.gates[:3]) + + +def test_classical_control_uses_stim_decomposition_fallback() -> None: + model = _RecordingLossModel() + analyze_loss_events( + _gadget(""" + GADGET G { + LOSS_ERROR(0.1) 1 + M 1 + CX rec[-1] 0 + } + """), + model, + ) + + 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 RecordingModel(_RecordingLossModel): + native_gates = frozenset({"CZ"}) + + model = RecordingModel() + analyze_loss_events(_gadget("GADGET G { LOSS_ERROR(0.1) 0 CZ 0 1 M 0 }"), model) + + assert (model.gates[0].name, model.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_implement_protocol() -> None: + class InvalidModel: ... + + with pytest.raises(TypeError, match="does not implement the LossModel protocol"): + analyze_loss_events(_gadget("GADGET G { M 0 }"), InvalidModel()) 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) 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) + }} + """ + ) + ) diff --git a/deq/tests/transpiler/loss_transpiler_test.py b/deq/tests/transpiler/loss_transpiler_test.py new file mode 100644 index 00000000..f3f45b40 --- /dev/null +++ b/deq/tests/transpiler/loss_transpiler_test.py @@ -0,0 +1,449 @@ +"""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 ( + 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. +_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_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]] { + 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 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..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, @@ -432,7 +439,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 +453,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() @@ -460,3 +467,89 @@ def test_emits_prepare_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] 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