From 017a64ac8ed74883da99cdec2edc871a896beed1 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 18 May 2026 14:53:37 -0600 Subject: [PATCH 01/36] Add DetectorErrorModel.from_guppy and rework lowered QIS replay to strict global-order MeasId pairing --- ...001-from-guppy-tag-referenced-detectors.md | 106 ++++++++ docs/proposals/README.md | 2 +- .../src/fault_tolerance_bindings.rs | 2 +- .../quantum-pecos/src/pecos/qec/__init__.py | 7 +- python/quantum-pecos/src/pecos/qec/dem.py | 234 ++++++++++++++++++ .../src/pecos/qec/surface/decode.py | 137 ++++++---- ruff.toml | 4 + 7 files changed, 445 insertions(+), 47 deletions(-) create mode 100644 docs/proposals/001-from-guppy-tag-referenced-detectors.md create mode 100644 python/quantum-pecos/src/pecos/qec/dem.py diff --git a/docs/proposals/001-from-guppy-tag-referenced-detectors.md b/docs/proposals/001-from-guppy-tag-referenced-detectors.md new file mode 100644 index 000000000..0ebcb1d6e --- /dev/null +++ b/docs/proposals/001-from-guppy-tag-referenced-detectors.md @@ -0,0 +1,106 @@ +# 001 - Tag-referenced detectors for `DetectorErrorModel.from_guppy` + +**Status:** Draft + +**Author:** (dem-polish working notes) + +## Summary + +`DetectorErrorModel.from_guppy(...)` builds a circuit-level DEM by tracing a +Guppy program through Selene/QIS and replaying the captured gate stream into a +`TickCircuit`. Detectors and observables are supplied by the caller as JSON and +reference measurements **positionally** (`records` = negative offsets, or +`meas_ids` = the sequential MeasIds assigned in trace order). + +Positional references are correct only if the caller's assumed measurement +order matches the order the *post-compilation* trace emits. Guppy/Selene +lowering may reorder measurements, which would silently misalign detectors and +produce a wrong-but-plausible DEM. This proposal captures Guppy `result(tag, +value)` **tag identities** through to the `TickCircuit` so detectors can +reference measurements by stable tag, immune to reordering. + +## Background / current state + +- `measure()` intrinsically allocates the result slot: each measured qubit is + backed by exactly one `Operation::AllocateResult { id }`, strictly + interleaved 1:1 with its `Operation::Quantum(Measure)` in the lowered trace. + The replay (`_replay_lowered_qis_trace_into_tick_circuit`) pairs the k-th + `AllocateResult` with the k-th measurement (global trace order) and stamps + that id as the MeasId. This is deterministic and self-consistent *within a + trace*, but the mapping from a *logical* measurement to its trace position is + not guaranteed stable across compilation. +- `result(tag, value)` carries a stable name. In the **direct QIS FFI path** + this becomes `Operation::RecordOutput { result_id, register_name }` + (`crates/pecos-qis-ffi/src/ffi.rs:604,626`) and is serialized into the + operation trace. +- **However, under `selene_engine()` (the only engine that runs Guppy/HUGR in + PECOS today), `RecordOutput` is never emitted into the operation trace** + (empirically: 0 `RecordOutput` ops for a surface program with many + `result(...)` calls). Selene routes `result(...)` to its per-shot + *named-result stream* (`store_named_bool` -> `get_named_results()`, + `crates/pecos-qis/src/ccengine.rs:1191`), which exposes `name -> values`, + **not** `name -> result_id`. So tag<->measurement linkage is unavailable + from anything `capture_operation_trace()` currently returns under Selene. +- The surface `traced_qis` path does not use tags either; its reorder-safety + comes from validating traced measurement order against an abstract reference + circuit (`decode.py`, `_build_surface_tick_circuit_for_native_model`). A + general `from_guppy` has no such reference. + +## Goal + +Allow `detectors_json`/`observables_json` to reference measurements by a stable +`result(...)` tag (e.g. `{"id": 0, "result_tags": ["syn:r0:s3"]}`), resolved to +MeasIds during replay, so detectors survive Guppy/Selene measurement +reordering. + +## Open question / fork (must be resolved before implementation) + +The feasibility hinges on getting tag<->measurement linkage out of the Selene +path: + +1. **Emit `RecordOutput` (with `result_id`) into the Selene operation trace.** + Requires Selene's `result()` lowering to produce a tag-bearing op carrying + the `result_id` (which links to `AllocateResult`/`Measure`). Selene is an + external Quantinuum component; feasibility/locus of change is unknown and + must be investigated. *Cleanest if feasible.* +2. **Correlate the named-result stream with the op trace.** Use + `get_named_results()` (`tag -> values`) plus the op trace + (`result_id -> measurement`). The named-result entries do not currently + carry `result_id`; matching by order/value reintroduces the very + order-dependence we are trying to remove. *Likely unacceptable.* +3. **Non-Selene QIS-FFI trace backend.** The direct QIS FFI path *does* emit + `RecordOutput`+tag. Investigate whether a Guppy/HUGR program can be traced + without the Selene runtime. If so, the feature becomes mostly Python-side. + *Risk: this path may not run HUGR.* + +## Sketch of the end-to-end change (assuming a tag-bearing op is available) + +1. **Trace capture**: ensure an op carrying `(result_id, tag)` reaches + `capture_operation_trace()` chunks. (Rust: `pecos-qis` / serialization in + `crates/pecos-qis/src/ccengine.rs:851`; Python binding `sim.rs:674`.) +2. **Replay** (`decode.py`): while building `meas_ids_in_order`, also build + `tag -> MeasId` (via `result_id`). Attach as TickCircuit metadata + (e.g. `set_meta("meas_tags", json)`). +3. **DemBuilder detector parsing** (`crates/pecos-qec/.../dem_builder/builder.rs`, + `parse_detectors_json`/`ParsedDetector`/`extract_records`): accept an + optional `result_tags` field and resolve it against the `meas_tags` + metadata to record indices/MeasIds. +4. **`from_guppy` API**: document tag-referenced detectors; keep positional + references working for back-compat. Update `_validate_measurement_contract` + to validate tags against the captured tag set. +5. **Tests**: a Guppy program where compilation reorders measurements; + positional detectors give a wrong DEM, tag-referenced detectors give the + correct one. + +## Out of scope / already done in dem-polish + +- `from_guppy` itself, the strict global-order replay rework (removed the + buggy program-vs-slot qubit-match heuristic + silent fallback), and the + corrected `result()` semantics in docs are landed independently of this + proposal. + +## Decision needed + +Pick fork (1), (2), or (3) after a feasibility spike on Selene's `result()` +lowering and the named-result stream. No multi-crate implementation should +start before that spike concludes. diff --git a/docs/proposals/README.md b/docs/proposals/README.md index b9e1c077c..a728ac98e 100644 --- a/docs/proposals/README.md +++ b/docs/proposals/README.md @@ -15,7 +15,7 @@ This directory contains architectural proposals and design explorations for PECO | Folder/File | Status | Summary | |-------------|--------|---------| -| *None currently* | | | +| [001-from-guppy-tag-referenced-detectors.md](001-from-guppy-tag-referenced-detectors.md) | Draft | Capture Guppy `result()` tags so `DetectorErrorModel.from_guppy` detectors are reorder-proof | ## Contributing diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index a637902e3..7d3f26881 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -726,7 +726,7 @@ impl PyInfluenceBuilder { /// # Output in DEM format /// print(dem.to_string()) /// ``` -#[pyclass(name = "DetectorErrorModel", module = "pecos_rslib.qec")] +#[pyclass(subclass, name = "DetectorErrorModel", module = "pecos_rslib.qec")] pub struct PyDetectorErrorModel { inner: RustDetectorErrorModel, } diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index 362efa148..7aac871c1 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -34,7 +34,6 @@ DemBuilder, DemSampler, DemSamplerBuilder, - DetectorErrorModel, EquivalenceResult, FaultLocation, InfluenceBuilder, @@ -72,6 +71,12 @@ ColorCodeStabilizer, generate_488_layout, ) + +# DetectorErrorModel is re-exported from pecos.qec.dem: a thin Python subclass +# of the Rust class that adds the from_guppy convenience constructor (the +# Guppy/Selene trace pipeline is Python-only, so it cannot live in the Rust +# extension without a dependency cycle). +from pecos.qec.dem import DetectorErrorModel from pecos.qec.generic import ( CheckSchedule, PauliOperator, diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py new file mode 100644 index 000000000..81ffa6284 --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -0,0 +1,234 @@ +"""Python-level ``DetectorErrorModel`` with a Guppy convenience constructor. + +The core ``DetectorErrorModel`` is implemented in Rust +(``pecos_rslib.qec.DetectorErrorModel``). The Guppy -> Selene -> QIS-trace +pipeline, however, lives entirely in Python (``pecos.sim``, ``pecos.guppy``, +``pecos.qec.surface.decode``). To keep the convenient +``DetectorErrorModel.from_guppy(...)`` call site without making the low-level +Rust extension import the high-level Python package (a dependency cycle), this +module defines a thin Python subclass that adds :meth:`from_guppy` and is +re-exported as the public ``pecos.qec.DetectorErrorModel``. + +The subclass is behaviorally identical to the Rust class for every other +operation; all existing methods (``from_circuit``, ``from_pecos_metadata_json``, +``to_string``, ``to_sampler``, ...) are inherited unchanged. +""" + +from __future__ import annotations + +import json +from typing import Any + +from pecos_rslib.qec import DetectorErrorModel as _RustDetectorErrorModel + + +def _collect_measurement_info(tc: Any) -> tuple[int, set[int]]: + """Return (measurement count, set of MeasIds) for the traced circuit. + + Counts measured qubits across all MZ gates and gathers the stable MeasIds + stamped on them. + """ + dag = tc.to_dag_circuit() + count = 0 + meas_ids: set[int] = set() + for node_id in dag.nodes(): + gate = dag.gate(node_id) + if gate is None or gate.gate_type.name != "MZ": + continue + qubits = list(gate.qubits) + ids = list(gate.meas_ids) + count += len(qubits) + if len(ids) != len(qubits): + msg = ( + "Traced Guppy circuit has an MZ gate without a stable MeasId " + f"(qubits={qubits}, meas_ids={ids}) after replay and " + "assign_missing_meas_ids(); this indicates an internal " + "inconsistency in the traced-circuit pipeline, not a problem " + "with the caller's inputs." + ) + raise ValueError(msg) + meas_ids.update(int(i) for i in ids) + return count, meas_ids + + +def _validate_measurement_contract( + tc: Any, + *, + detectors_json: str, + observables_json: str, + num_measurements: int | None, +) -> None: + """Fail loudly if the caller's detector/observable JSON is inconsistent. + + Catches the common ``from_guppy`` misuse where detector ``records``/ + ``meas_ids`` do not line up with the measurements the traced program + actually emits, instead of silently building a wrong DEM. + """ + measured, present_ids = _collect_measurement_info(tc) + + if num_measurements is not None and num_measurements != measured: + msg = ( + f"num_measurements={num_measurements} does not match the " + f"{measured} measurement(s) the traced Guppy program emits. The " + "detector/observable record offsets are defined against the " + "traced measurement order; a mismatch means the DEM would be " + "silently wrong." + ) + raise ValueError(msg) + effective = num_measurements if num_measurements is not None else measured + + def _check(kind: str, entries: list[dict[str, Any]]) -> None: + for entry in entries: + # Tracked Paulis reference qubits via "pauli", not measurements. + if entry.get("kind") == "tracked_pauli": + continue + for rec in entry.get("records", []) or []: + idx = effective + int(rec) + if not 0 <= idx < effective: + msg = ( + f"{kind} {entry.get('id', entry)} references record " + f"{rec}, which is out of range for a circuit with " + f"{effective} measurement(s)." + ) + raise ValueError(msg) + for mid in entry.get("meas_ids", []) or []: + if int(mid) not in present_ids: + msg = ( + f"{kind} {entry.get('id', entry)} references " + f"meas_id {mid}, which is not present in the traced " + "circuit. meas_ids must match the stable MeasIds the " + "traced program assigns (one per measured qubit, in " + "trace order)." + ) + raise ValueError(msg) + + try: + detectors = json.loads(detectors_json) if detectors_json else [] + observables = json.loads(observables_json) if observables_json else [] + except json.JSONDecodeError as exc: + msg = f"detectors_json/observables_json is not valid JSON: {exc}" + raise ValueError(msg) from exc + + _check("Detector", detectors) + _check("Observable", observables) + + +class DetectorErrorModel(_RustDetectorErrorModel): + """Detector error model with a Guppy/QIS-trace convenience constructor. + + Identical to :class:`pecos_rslib.qec.DetectorErrorModel` except for the + added :meth:`from_guppy` classmethod. + """ + + __slots__ = () + + @classmethod + def from_guppy( + cls, + guppy: Any, + *, + num_qubits: int, + detectors_json: str, + observables_json: str = "[]", + num_measurements: int | None = None, + p1: float = 0.001, + p2: float = 0.01, + p_meas: float = 0.001, + p_prep: float = 0.001, + seed: int = 0, + ) -> _RustDetectorErrorModel: + """Build a circuit-level DEM from a Guppy program by tracing it. + + Runs ``guppy`` under the Selene QIS engine with operation tracing, + replays the captured gate stream into a ``TickCircuit``, attaches the + caller-supplied detector/observable definitions, and builds the DEM via + native PECOS fault propagation. + + Args: + guppy: Anything ``pecos.sim`` accepts -- a ``@guppy``-decorated + function, a compiled Guppy program (e.g. the object returned by + ``pecos.guppy.make_surface_code``), or a program wrapper. There + is no Guppy *source-string* form in PECOS; pass a program/ + function, not source text. + num_qubits: Number of qubits to allocate for the trace. QIS/HUGR + programs require an explicit qubit count. + detectors_json: Detector definitions as a JSON list, e.g. + ``[{"id": 0, "records": [-1, -5]}, ...]``. ``records`` are + negative measurement offsets (Stim convention); ``meas_ids`` + may be used instead. Defined against the *traced* program's own + measurement order. + observables_json: Observable / tracked-Pauli definitions as a JSON + list. Plain observables look like ``[{"id": 0, "records": + [-1]}]``. Tracked Paulis are entries in this same list carrying + ``"kind": "tracked_pauli"`` (plus ``"label"`` and ``"pauli"``, + e.g. ``"+X0 Z2"``); the DEM builder splits them out + automatically. There is no separate tracked-Pauli argument -- + this matches the underlying circuit-metadata contract exactly. + num_measurements: Total measurement count, used to resolve negative + ``records`` offsets. If omitted, it is inferred from the traced + circuit. + p1: Single-qubit gate depolarizing rate. + p2: Two-qubit gate depolarizing rate. + p_meas: Measurement flip rate. + p_prep: Preparation (reset) error rate. + seed: Seed for the ideal trace run. + + Returns: + A ``DetectorErrorModel`` built from the traced circuit. + + Raises: + ValueError: If ``num_measurements`` disagrees with the traced + measurement count, if a detector/observable references an + out-of-range ``record`` or an absent ``meas_id``, or if the + traced operation stream is malformed (the strict + ``AllocateResult``/``Measure`` pairing in the replay fails). + + Note: + Every measurement is anchored to a stable MeasId automatically: + ``measure()`` itself allocates the result slot in the trace, so a + ``result(...)`` call is *not* required for MeasId assignment + (``result(...)`` controls the program's output stream, which this + path does not consume). + + The caller owns the measurement-order contract: detector/observable + ``records``/``meas_ids`` must reference measurements in the order + the *traced (post-compilation)* program emits them. Unlike the + surface-code ``traced_qis`` path, there is no reference circuit to + validate that ordering against; inputs are checked for internal + consistency only (see ``Raises``), not against intent. + + Known limitation: because references are positional, they are + sensitive to measurement reordering introduced by Guppy/Selene + compilation. Stable, tag-referenced detectors (immune to + reordering) are a planned enhancement -- see + ``docs/proposals/001-from-guppy-tag-referenced-detectors.md``. + """ + from pecos.qec.surface.decode import trace_guppy_into_tick_circuit + + tc = trace_guppy_into_tick_circuit(guppy, num_qubits, seed=seed) + + # Compilation passes required for traced QIS circuits before fault + # analysis: normalize parameterized Clifford rotations to named gates + # and stamp stable MeasIds onto measurement gates. + tc.lower_clifford_rotations() + tc.assign_missing_meas_ids() + + _validate_measurement_contract( + tc, + detectors_json=detectors_json, + observables_json=observables_json, + num_measurements=num_measurements, + ) + + tc.set_meta("detectors", detectors_json) + tc.set_meta("observables", observables_json) + if num_measurements is not None: + tc.set_meta("num_measurements", str(num_measurements)) + + return _RustDetectorErrorModel.from_circuit( + tc, + p1=p1, + p2=p2, + p_meas=p_meas, + p_prep=p_prep, + ) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 6ffb604b9..1faba1a6a 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -584,25 +584,53 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> tick_circuit = TickCircuit() + # Pass 1: build the global, ordered list of result-slot ids that anchor + # MeasIds. In the lowered Selene/QIS trace every measured qubit is backed + # by exactly one AllocateResult op immediately followed by its Measure op + # (the result slot is intrinsic to measure(); array-valued result() + # expands to one AllocateResult per measured element). The k-th + # measurement therefore maps to the k-th AllocateResult id, independent of + # the program-vs-slot qubit-id spaces. This strict 1:1 interleave is an + # invariant of the lowered trace; any deviation means the operation stream + # is malformed or uses an unsupported construct, so we fail loudly rather + # than guess an association and build a silently-wrong DEM. + meas_ids_in_order: list[int] = [] + pending_alloc: int | None = None for chunk in chunks: - # Extract AllocateResult ID → MZ qubit mapping from the operations stream. - # Each AllocateResult(id=N) is followed by Quantum.Measure([qubit, slot]). - # This gives us the MeasId to stamp on each MZ gate. - meas_id_queue: list[tuple[int, int]] = [] # (qubit, meas_id) pairs - last_alloc_id: int | None = None for op in chunk.get("operations") or []: op_dict = dict(op) if "AllocateResult" in op_dict: - last_alloc_id = int(op_dict["AllocateResult"]["id"]) - elif "Quantum" in op_dict: - q_op = op_dict["Quantum"] - if "Measure" in q_op and last_alloc_id is not None: - qubit = int(q_op["Measure"][0]) - meas_id_queue.append((qubit, last_alloc_id)) - last_alloc_id = None - - meas_id_idx = 0 # next MeasId to assign + if pending_alloc is not None: + msg = ( + "Malformed traced operation stream: two consecutive " + "AllocateResult ops with no Measure between them. The " + "lowered trace must interleave AllocateResult/Measure " + "1:1; cannot anchor MeasIds deterministically." + ) + raise ValueError(msg) + pending_alloc = int(op_dict["AllocateResult"]["id"]) + elif "Quantum" in op_dict and "Measure" in op_dict["Quantum"]: + if pending_alloc is None: + msg = ( + "Malformed traced operation stream: a Measure op with " + "no preceding AllocateResult. The lowered trace must " + "interleave AllocateResult/Measure 1:1; cannot anchor " + "a stable MeasId for this measurement." + ) + raise ValueError(msg) + meas_ids_in_order.append(pending_alloc) + pending_alloc = None + if pending_alloc is not None: + msg = ( + "Malformed traced operation stream: a trailing AllocateResult " + "with no following Measure; cannot anchor MeasIds " + "deterministically." + ) + raise ValueError(msg) + # Pass 2: replay gates, stamping MeasIds on MZ gates in global trace order. + meas_cursor = 0 + for chunk in chunks: for gate in chunk.get("lowered_quantum_ops") or []: gate_type = str(gate["gate_type"]) qubits = [int(q) for q in gate.get("qubits", [])] @@ -628,26 +656,16 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> elif gate_type == "PZ": tick.pz(qubits) elif gate_type == "MZ": - # Stamp MeasIds from the AllocateResult stream - meas_ids = [] - for q in qubits: - if meas_id_idx < len(meas_id_queue): - expected_q, mid = meas_id_queue[meas_id_idx] - if expected_q == q: - meas_ids.append(mid) - meas_id_idx += 1 - else: - # Qubit mismatch — fall back to auto-assign - meas_ids = [] - break - else: - meas_ids = [] - break - - if meas_ids: - tick.mz_with_ids(qubits, meas_ids) - else: - tick.mz(qubits) + end = meas_cursor + len(qubits) + if end > len(meas_ids_in_order): + msg = ( + "More measured qubits than result(...)-anchored " + "MeasIds in the traced program; a measurement is " + "missing its result(...) call." + ) + raise ValueError(msg) + tick.mz_with_ids(qubits, meas_ids_in_order[meas_cursor:end]) + meas_cursor = end elif gate_type == "RX": tick.rx(angles[0], qubits) elif gate_type == "RY": @@ -678,28 +696,47 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> msg = f"Unsupported lowered traced gate {gate_type!r}" raise ValueError(msg) + if meas_cursor != len(meas_ids_in_order): + msg = ( + f"Traced program has {len(meas_ids_in_order)} result(...)-anchored " + f"measurements but only {meas_cursor} measured qubit(s) in the " + "lowered gate stream; result()/measurement mismatch." + ) + raise ValueError(msg) + # Compact: ASAP-schedule gates into minimal ticks tick_circuit.compact_ticks() return tick_circuit -def _generate_traced_surface_tick_circuit( - patch: SurfacePatch, - num_rounds: int, - basis: str, -) -> Any: - """Trace the lowered ideal Selene/QIS op stream and replay it into a TickCircuit.""" +def trace_guppy_into_tick_circuit(program: Any, num_qubits: int, *, seed: int = 0) -> Any: + """Trace a Guppy/QIS program's lowered Selene op stream into a ``TickCircuit``. + + Runs ``program`` under the Selene QIS engine with operation tracing enabled + and replays the captured (lowered) gate stream into a PECOS ``TickCircuit``. + This is the generic core shared by the surface traced-QIS path and the + general ``DetectorErrorModel.from_guppy`` entry point. + + Args: + program: Anything ``pecos.sim`` accepts -- a ``@guppy`` function, a + compiled Guppy program, or a program wrapper. + num_qubits: Number of qubits to allocate. QIS/HUGR programs require an + explicit qubit count for trace capture. + seed: Seed for the (ideal) trace run. + + Returns: + A ``TickCircuit`` with no detector/observable metadata attached; the + caller is responsible for supplying that. + """ import pecos - from pecos.guppy import get_num_qubits, make_surface_code - program = make_surface_code(distance=patch.distance, num_rounds=num_rounds, basis=basis) sim_builder = ( pecos.sim(program) .classical(pecos.selene_engine()) .quantum(pecos.stabilizer()) - .qubits(get_num_qubits(patch.distance)) - .seed(0) + .qubits(num_qubits) + .seed(seed) ) chunks = list(sim_builder.capture_operation_trace()) @@ -712,6 +749,18 @@ def _generate_traced_surface_tick_circuit( return _replay_qis_trace_into_tick_circuit(operations) +def _generate_traced_surface_tick_circuit( + patch: SurfacePatch, + num_rounds: int, + basis: str, +) -> Any: + """Trace the lowered ideal Selene/QIS op stream and replay it into a TickCircuit.""" + from pecos.guppy import get_num_qubits, make_surface_code + + program = make_surface_code(distance=patch.distance, num_rounds=num_rounds, basis=basis) + return trace_guppy_into_tick_circuit(program, get_num_qubits(patch.distance), seed=0) + + def _build_surface_tick_circuit_for_native_model( patch: SurfacePatch, num_rounds: int, diff --git a/ruff.toml b/ruff.toml index e012768bd..721f369aa 100644 --- a/ruff.toml +++ b/ruff.toml @@ -168,6 +168,10 @@ ignore = [ "SLF001", # Private member access - accessing internal decoder APIs "ANN401", # Any types - required for optional dependency types (pymatching, stim) ] +"python/quantum-pecos/src/pecos/qec/dem.py" = [ + "PLC0415", # Import inside function - lazy import breaks the qec/__init__ <-> decode cycle + "ANN401", # Any types - duck-typed Guppy program (whatever pecos.sim accepts) and Rust TickCircuit +] "python/quantum-pecos/src/pecos/qec/surface/plot.py" = [ "PLC0415", # Import inside function - lazy loading of qec.surface ] From 4003dc779a7694d9461300f302310c9f410e1f29 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 18 May 2026 16:16:28 -0600 Subject: [PATCH 02/36] Trace Guppy result() tags to MeasIds for reorder-robust tag-referenced detectors in from_guppy --- .../fault_tolerance/dem_builder/builder.rs | 91 +++++++++++ crates/pecos-qis-ffi/src/ffi.rs | 8 + crates/pecos-qis-ffi/src/lib.rs | 95 +++++++++++ crates/pecos-qis/src/ccengine.rs | 40 +++++ crates/pecos-qis/src/executor.rs | 47 ++++++ crates/pecos-qis/src/qis_interface.rs | 16 ++ ...001-from-guppy-tag-referenced-detectors.md | 151 +++++++++++++++++- python/quantum-pecos/src/pecos/qec/dem.py | 71 +++++--- .../src/pecos/qec/surface/decode.py | 35 +++- 9 files changed, 523 insertions(+), 31 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index ae792f1b1..cd0a828d7 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -1271,6 +1271,79 @@ fn extract_records(json: &str) -> Vec { Vec::new() } +/// Rewrite tag-referenced detector/observable JSON into record offsets. +/// +/// Entries may reference measurements by a stable Guppy `result(...)` tag via +/// a `"result_tags": ["sx0:meas:0", ...]` field instead of positional +/// `records`. Each tag is resolved through the circuit's `meas_tags` map +/// (`tag -> [MeasId]`) to record offsets (`meas_id - num_measurements`), since +/// a MeasId is the measurement's absolute trace-order index. Resolved offsets +/// are merged into the entry's `records` and `result_tags` is removed, so the +/// downstream parser is unchanged. Tag references are immune to Guppy/Selene +/// measurement reordering because the tag identity is fixed in the source. +fn resolve_result_tags_in_json( + json: &str, + meas_tags: &std::collections::BTreeMap>, + num_meas: Option, +) -> Result { + let mut value: serde_json::Value = serde_json::from_str(json) + .map_err(|e| DemBuilderError::ParseError(format!("invalid detector/observable JSON: {e}")))?; + let Some(entries) = value.as_array_mut() else { + return Ok(json.to_string()); + }; + + let mut changed = false; + for entry in entries.iter_mut() { + let Some(obj) = entry.as_object_mut() else { + continue; + }; + let Some(tags) = obj.remove("result_tags") else { + continue; + }; + changed = true; + let Some(num_meas) = num_meas else { + return Err(DemBuilderError::ParseError( + "result_tags used but circuit has no num_measurements".to_string(), + )); + }; + let num_meas = num_meas as i64; + + let mut records: Vec = obj + .get("records") + .and_then(|r| r.as_array()) + .map(|a| a.iter().filter_map(serde_json::Value::as_i64).collect()) + .unwrap_or_default(); + + let tag_list = tags.as_array().ok_or_else(|| { + DemBuilderError::ParseError("result_tags must be a JSON array of strings".to_string()) + })?; + for tag in tag_list { + let tag = tag.as_str().ok_or_else(|| { + DemBuilderError::ParseError("result_tags entries must be strings".to_string()) + })?; + let meas_ids = meas_tags.get(tag).ok_or_else(|| { + DemBuilderError::ParseError(format!( + "result_tag {tag:?} not found in circuit meas_tags" + )) + })?; + for &mid in meas_ids { + records.push(mid - num_meas); + } + } + + obj.insert( + "records".to_string(), + serde_json::Value::Array(records.into_iter().map(serde_json::Value::from).collect()), + ); + } + + if !changed { + return Ok(json.to_string()); + } + serde_json::to_string(&value) + .map_err(|e| DemBuilderError::ParseError(format!("failed to re-serialize JSON: {e}"))) +} + // ============================================================================ // Convenience: build DEM from circuit (free function to handle lifetimes) // ============================================================================ @@ -1319,6 +1392,24 @@ fn build_dem_from_circuit( } }); + // Stable Guppy result()-tag -> [MeasId] linkage, if the circuit was traced + // from a Guppy program. Used to resolve tag-referenced detectors into + // record offsets that survive compilation measurement reordering. + let meas_tags: std::collections::BTreeMap> = circuit + .get_attr("meas_tags") + .and_then(|a| { + if let Attribute::String(s) = a { + serde_json::from_str(s).ok() + } else { + None + } + }) + .unwrap_or_default(); + let det_json = det_json + .map(|s| resolve_result_tags_in_json(&s, &meas_tags, num_meas).unwrap_or(s)); + let obs_json = obs_json + .map(|s| resolve_result_tags_in_json(&s, &meas_tags, num_meas).unwrap_or(s)); + let builder = DemBuilder::new(&influence_map).with_noise(p1, p2, p_meas, p_prep); let builder = if let Some(ref dj) = det_json { diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index a0188cea2..ae31083e8 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -468,6 +468,14 @@ pub unsafe extern "C" fn ___read_future_bool(future_id: i64) -> bool { log::debug!("___read_future_bool called with future_id={future_id}"); let result_id = i64_to_usize(future_id); + // Record the read so the next named-result store can attribute this + // measurement's result_id to its Guppy result(...) tag. This is what + // makes tag -> MeasId source-stable across compilation reordering. + if let Some(ctx) = crate::get_execution_context() { + // SAFETY: context is valid for the duration of execution. + unsafe { &*ctx }.note_read_result_id(result_id); + } + // Check if result is already available in thread-local storage let existing_result = with_interface(|interface| interface.get_result(result_id)); log::debug!("___read_future_bool: existing_result={existing_result:?}"); diff --git a/crates/pecos-qis-ffi/src/lib.rs b/crates/pecos-qis-ffi/src/lib.rs index 5fecf0371..d6c0db689 100644 --- a/crates/pecos-qis-ffi/src/lib.rs +++ b/crates/pecos-qis-ffi/src/lib.rs @@ -66,6 +66,15 @@ pub struct ExecutionContext { pub measurement_results: Mutex>>, /// Storage for named results from `print_bool`/`print_bool_arr` (e.g., "synx", "final") pub named_results: Mutex>>, + /// Result IDs read via `___read_future_bool` since the last named-result + /// store. Drained into `named_result_ids` when a `result(tag, ...)` is + /// recorded, giving a robust `tag -> result_id` linkage. + pub pending_read_result_ids: Mutex>, + /// Maps each Guppy `result(tag, ...)` tag to the QIS result IDs + /// (== MeasIds) whose values it recorded, in read order. This is the + /// source-stable measurement identity that survives compilation + /// reordering. + pub named_result_ids: Mutex>>, } impl ExecutionContext { @@ -80,6 +89,8 @@ impl ExecutionContext { pending_ops: Mutex::new(Vec::new()), measurement_results: Mutex::new(Vec::new()), named_results: Mutex::new(BTreeMap::new()), + pending_read_result_ids: Mutex::new(Vec::new()), + named_result_ids: Mutex::new(BTreeMap::new()), } } @@ -101,11 +112,55 @@ impl ExecutionContext { if let Ok(mut named) = self.named_results.lock() { named.clear(); } + if let Ok(mut pending) = self.pending_read_result_ids.lock() { + pending.clear(); + } + if let Ok(mut named_ids) = self.named_result_ids.lock() { + named_ids.clear(); + } + } + + /// Record that `result_id` was just read via `___read_future_bool`. + /// + /// Buffered until the next named-result store, which attributes these IDs + /// to its tag. This is what links a Guppy `result(tag, ...)` to the + /// specific measurement(s) it recorded. + pub fn note_read_result_id(&self, result_id: usize) { + if let Ok(mut pending) = self.pending_read_result_ids.lock() { + pending.push(result_id); + } + } + + /// Drain the pending read result IDs and attribute them to `name`. + fn attribute_pending_result_ids(&self, name: &str) { + let drained = match self.pending_read_result_ids.lock() { + Ok(mut pending) => std::mem::take(&mut *pending), + Err(_) => return, + }; + if drained.is_empty() { + return; + } + if let Ok(mut named_ids) = self.named_result_ids.lock() { + named_ids + .entry(name.to_string()) + .or_default() + .extend(drained); + } + } + + /// Get the `tag -> [result_id, ...]` linkage (returns a clone). + #[must_use] + pub fn get_named_result_ids(&self) -> BTreeMap> { + self.named_result_ids + .lock() + .map(|guard| guard.clone()) + .unwrap_or_default() } /// Store a named result (single bool value) pub fn store_named_bool(&self, name: &str, value: bool) { let thread_id = std::thread::current().id(); + self.attribute_pending_result_ids(name); if let Ok(mut named) = self.named_results.lock() { let entry = named.entry(name.to_string()).or_default(); entry.push(value); @@ -126,6 +181,7 @@ impl ExecutionContext { /// Store a named result array (multiple bool values) pub fn store_named_array(&self, name: &str, values: &[bool]) { + self.attribute_pending_result_ids(name); if let Ok(mut named) = self.named_results.lock() { let entry = named.entry(name.to_string()).or_default(); entry.extend_from_slice(values); @@ -745,6 +801,45 @@ pub extern "C" fn pecos_get_named_results_json() -> *mut std::ffi::c_char { } } +/// Get the `tag -> [result_id, ...]` linkage as a JSON string. +/// +/// Format: `{"sx0:meas:0": [3], "final": [10, 11, 12], ...}`. Each result_id +/// is the QIS measurement identity (== MeasId in the replayed TickCircuit), so +/// this maps every Guppy `result(tag, ...)` to the measurement(s) it recorded, +/// source-stable across compilation reordering. +/// +/// The caller must free the returned string using +/// `pecos_free_named_results_json`. Returns null if no context is registered +/// or the linkage is empty. +/// +/// # Safety +/// This function is safe to call from any thread. The returned pointer must be freed. +#[unsafe(no_mangle)] +pub extern "C" fn pecos_get_named_result_ids_json() -> *mut std::ffi::c_char { + let Some(ctx) = get_execution_context() else { + return std::ptr::null_mut(); + }; + // SAFETY: context is valid for the duration of execution. + let named_result_ids = unsafe { &*ctx }.get_named_result_ids(); + if named_result_ids.is_empty() { + return std::ptr::null_mut(); + } + let json = match serde_json::to_string(&named_result_ids) { + Ok(s) => s, + Err(e) => { + log::error!("pecos_get_named_result_ids_json: serialization error: {e}"); + return std::ptr::null_mut(); + } + }; + match std::ffi::CString::new(json) { + Ok(cstr) => cstr.into_raw(), + Err(e) => { + log::error!("pecos_get_named_result_ids_json: CString error: {e}"); + std::ptr::null_mut() + } + } +} + /// Free a JSON string allocated by `pecos_get_named_results_json` /// /// # Safety diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index 5c85fe079..6b26462e0 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -58,6 +58,11 @@ pub struct OperationTraceChunk { pub num_operations: usize, pub operations: Vec, pub lowered_quantum_ops: Vec, + /// Guppy `result(tag, ...)` -> QIS result IDs (== MeasIds) it recorded. + /// Snapshot at the time this chunk was emitted; the final chunk of a shot + /// carries the complete linkage. Empty when no named results were tagged. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub named_result_ids: std::collections::BTreeMap>, } /// Shared in-memory store for traced QIS operation batches. @@ -860,6 +865,10 @@ impl QisEngine { num_operations: ops.len(), operations: ops.to_vec(), lowered_quantum_ops: lowered_trace, + // Populated only on the authoritative end-of-shot chunk emitted + // from get_results(); per-op-chunk snapshots would miss tail + // result(...) stores. + named_result_ids: std::collections::BTreeMap::new(), }; if let Some(ref collector) = self.operation_trace_collector { @@ -1235,6 +1244,37 @@ impl ClassicalEngine for QisEngine { self.measurement_results.len(), has_named_results ); + + // Emit a final trace chunk carrying the complete result()-tag -> + // [result_id] linkage. Per-chunk snapshots can miss stores that + // happen after the last operation chunk (e.g. the final result(...) + // before program exit); this end-of-shot capture is authoritative. + if let Some(collector) = &self.operation_trace_collector + && let Some(state) = &self.dynamic_state + && let Some(handle) = &state.sync_handle + && let Ok(named_result_ids) = handle.get_named_result_ids() + && !named_result_ids.is_empty() + { + let chunk = OperationTraceChunk { + format: "pecos_qis_operation_trace_v1", + engine_trace_id: self.trace_engine_id, + shot_index: self.trace_shot_index, + chunk_index: self.trace_chunk_index, + stage: "named_result_ids_final".to_string(), + waiting_for_result_id: None, + current_shot_seed: self.current_shot_seed, + simulated_op_count: self.simulated_op_count, + num_operations: 0, + operations: Vec::new(), + lowered_quantum_ops: Vec::new(), + named_result_ids, + }; + match collector.lock() { + Ok(mut guard) => guard.push(chunk), + Err(err) => warn!("Failed to store final named_result_ids chunk: {err}"), + } + } + Ok(shot) } diff --git a/crates/pecos-qis/src/executor.rs b/crates/pecos-qis/src/executor.rs index d1ba56774..d31d85e46 100644 --- a/crates/pecos-qis/src/executor.rs +++ b/crates/pecos-qis/src/executor.rs @@ -454,6 +454,53 @@ impl DynamicSyncHandle for HeliosSyncHandle { debug!("HeliosSyncHandle: Got {} named results", result.len()); Ok(result) } + + fn get_named_result_ids( + &self, + ) -> Result>, InterfaceError> { + let lib = Self::get_lib()?; + + let get_fn: Symbol = unsafe { + lib.get(b"pecos_get_named_result_ids_json\0").map_err(|e| { + InterfaceError::ExecutionError(format!( + "Failed to find pecos_get_named_result_ids_json: {e}" + )) + })? + }; + + let ptr = unsafe { get_fn() }; + if ptr.is_null() { + return Ok(std::collections::BTreeMap::new()); + } + + let c_str = unsafe { std::ffi::CStr::from_ptr(ptr) }; + let json_str = c_str.to_str().map_err(|e| { + InterfaceError::ExecutionError(format!("Invalid UTF-8 in named result ids JSON: {e}")) + })?; + + let result: std::collections::BTreeMap> = serde_json::from_str(json_str) + .map_err(|e| { + InterfaceError::ExecutionError(format!( + "Failed to parse named result ids JSON: {e}" + )) + })?; + + // Allocated by the same CString path; freed by the shared free symbol. + let free_fn: Symbol = unsafe { + lib.get(b"pecos_free_named_results_json\0").map_err(|e| { + InterfaceError::ExecutionError(format!( + "Failed to find pecos_free_named_results_json: {e}" + )) + })? + }; + unsafe { free_fn(ptr) }; + + debug!( + "HeliosSyncHandle: Got {} named result-id entries", + result.len() + ); + Ok(result) + } } /// Derive the project target directory from the compile-time embedded Helios path. diff --git a/crates/pecos-qis/src/qis_interface.rs b/crates/pecos-qis/src/qis_interface.rs index 305add2e1..42f2c2992 100644 --- a/crates/pecos-qis/src/qis_interface.rs +++ b/crates/pecos-qis/src/qis_interface.rs @@ -273,6 +273,22 @@ pub trait DynamicSyncHandle: Send + Sync { fn get_named_results( &self, ) -> Result>, InterfaceError>; + + /// Get the `tag -> [result_id, ...]` linkage from the execution context. + /// + /// Each result_id is the QIS measurement identity (== MeasId in the + /// replayed TickCircuit). Maps every Guppy `result(tag, ...)` to the + /// measurement(s) it recorded, source-stable across compilation + /// reordering. Default implementation returns an empty map for handles + /// that do not track this. + /// + /// # Errors + /// Returns an error if the FFI call fails or JSON parsing fails. + fn get_named_result_ids( + &self, + ) -> Result>, InterfaceError> { + Ok(std::collections::BTreeMap::new()) + } } /// Box type for interface implementations diff --git a/docs/proposals/001-from-guppy-tag-referenced-detectors.md b/docs/proposals/001-from-guppy-tag-referenced-detectors.md index 0ebcb1d6e..b2014f982 100644 --- a/docs/proposals/001-from-guppy-tag-referenced-detectors.md +++ b/docs/proposals/001-from-guppy-tag-referenced-detectors.md @@ -1,6 +1,6 @@ # 001 - Tag-referenced detectors for `DetectorErrorModel.from_guppy` -**Status:** Draft +**Status:** Implemented **Author:** (dem-polish working notes) @@ -99,8 +99,147 @@ path: corrected `result()` semantics in docs are landed independently of this proposal. -## Decision needed - -Pick fork (1), (2), or (3) after a feasibility spike on Selene's `result()` -lowering and the named-result stream. No multi-crate implementation should -start before that spike concludes. +## Empirical findings (dem-polish spike) + +Confirmed by direct probing of `capture_operation_trace()` under +`selene_engine()`: + +- A Guppy program with `result("UNIQTAG_A", ...)` / `result("UNIQTAG_B", ...)` + produces an operation trace in which the tag string **does not appear + anywhere** (full chunk JSON searched). `RecordOutput` count is **0**. +- Op kinds present: `AllocateQubit`, `AllocateResult`, `Quantum`, + `ReleaseQubit` only. `AllocateResult` **is** present (so the QIS FFI + `__quantum__rt__result_allocate` is invoked), but + `__quantum__rt__result_record_output` (which would queue + `RecordOutput { result_id, register_name }`) is **not** invoked. +- Conclusion: under Selene, Guppy `result()` does **not** lower to the QIS FFI + result-record symbol. The tag reaches only Selene's per-shot named-result + stream (`tag -> value`), with **no `result_id` linkage**, so it cannot be + associated with a measurement from anything `capture_operation_trace()` + currently exposes. + +## Consequence + +Source-stable, reorder-proof MeasIds **cannot** be achieved from the +operation-trace path without a Rust/runtime change in `pecos-qis`/ +`pecos-qis-ffi`. The Python `from_guppy` work (committed) is faithful to the +*post-compilation traced order* only; that is explicitly insufficient for the +reorder-robustness requirement. + +## Required spike (Rust) + +1. Determine the exact QIR/runtime symbol Guppy `result(tag, value)` lowers to + under Selene, and whether it exposes the result pointer/id at a point PECOS + can intercept (FFI shim in `crates/pecos-qis-ffi/src/ffi.rs`, or the Selene + runtime integration in `crates/pecos-qis/src/selene_runtime.rs`). +2. If interceptable: add a trace op (or extend an existing one) carrying + `(result_id, tag)` into the serialized operation trace + (`crates/pecos-qis/src/ccengine.rs` `OperationTraceChunk`). +3. Consume it in `_replay_*_qis_trace_into_tick_circuit` to build + `tag -> MeasId`; attach as TickCircuit metadata. +4. Extend DemBuilder detector parsing to resolve a `result_tags` field + (`crates/pecos-qec/.../dem_builder/builder.rs`). +5. `from_guppy`: accept tag-referenced detectors; keep positional for + back-compat. + +Feasibility hinges on step 1, which involves Selene/Guppy lowering conventions +that may live outside this repo. No multi-crate implementation should start +before step 1 concludes. + +## Spike conclusion (step 1 resolved -- NOT feasible PECOS-side) + +Direct reading of the FFI surface (`crates/pecos-qis-ffi/src/ffi.rs`): + +- `measure()` -> `___lazy_measure(qubit)` (l.435) allocates and returns the + `result_id`; `___read_future_bool(result_id)` (l.467) consumes it and + returns a plain `bool`. +- Guppy `result(tag, bool)` lowers to the Selene-style `print_bool(label_ptr, + label_len, value: bool)` (l.668) / `print_bool_selene` (l.824) / + `print_bool_arr_selene` (l.874). These receive **only the tag string and + the concrete bool value** -- the `result_id` is not a parameter and is + structurally absent at the record site. +- `__quantum__rt__record` (l.336) only logs; `__quantum__rt__result_record_output` + (l.604, the QIR convention that *does* carry `result`+tag) is **not invoked** + by Guppy/Selene-lowered programs. + +Therefore the tag and the measurement identity (`result_id`) are never +co-present at any single interceptable call. Adjacency-pairing +`___read_future_bool(result_id)` with a following `print_bool(tag,...)` is +fragile (intervening classical logic / conditionals) and **fundamentally +impossible for array-valued `result()`**, which records many measurements +under one tag with no per-element identity (surface round syndromes and final +readout use exactly this form). + +**Result-record lowering must change upstream (Guppy/tket2/Selene) to carry +the QIS result pointer/id** (e.g. adopt `__quantum__rt__result_record_output( +result, tag)`), or expose a result-id-bearing measurement-tagging API. This is +out of scope for `pecos-qis`. PECOS-side options that remain: + +- **Reference-order safeguard** (generalize the surface path's traced-vs- + reference measurement-order equality check to `from_guppy`; caller supplies + the expected order). Robust against silent reordering corruption; no tags. +- **Documented positional contract** (status quo of the committed work). + +Recommend raising the lowering gap with the Guppy/Selene owners; track here +until upstream provides a result-id-bearing record. + +## CORRECTION: feasible PECOS-side via ExecutionContext read->name linkage + +The "not feasible" conclusion above was wrong. The connection does not need to +exist at a single FFI call; it can be reconstructed and maintained in QIS code +because `ExecutionContext` (`crates/pecos-qis-ffi/src/lib.rs:54`) already holds +both halves: + +- `measurement_results: Mutex>>` -- values indexed by + `result_id` (QIS measurement identity). +- `store_named_bool`/`store_named_array` -> `get_named_results()` -- the + Selene-returned tagged results. + +`___read_future_bool(result_id)` (ffi.rs:467) is invoked to obtain each +measurement bool immediately before the `print_bool*` -> +`store_named_*` that records it under its tag. So the robust linkage is: + +1. `ExecutionContext`: add `pending_read_result_ids: Mutex>` and + `named_result_ids: Mutex>>`. +2. `___read_future_bool(result_id)`: push `result_id` to the pending buffer + (when an execution context is present). +3. `store_named_bool`/`store_named_array`: drain the pending buffer into + `named_result_ids[name]`. +4. Expose `get_named_result_ids() -> {tag: [result_id, ...]}` and surface it + through the trace-capture plumbing (`python/pecos-rslib/src/sim.rs`, + `crates/pecos-qis/src/ccengine.rs`). +5. Replay builds `tag -> MeasId` (since `result_id == AllocateResult id == + MeasId` under the strict replay) and attaches it as TickCircuit metadata. +6. DemBuilder detector parsing resolves an optional `result_tags` field + against that metadata. +7. `from_guppy` accepts tag-referenced detectors; positional kept for + back-compat. + +This is source-stable and immune to measurement reordering. + +## Implemented (dem-polish) + +Delivered exactly as above: + +- `ExecutionContext` (`crates/pecos-qis-ffi/src/lib.rs`): + `pending_read_result_ids` + `named_result_ids`; `___read_future_bool` + records the read; `store_named_bool`/`store_named_array` attribute pending + reads to the tag; `pecos_get_named_result_ids_json` FFI export. +- `DynamicSyncHandle::get_named_result_ids` (default empty) + + `HeliosSyncHandle` impl; surfaced via an authoritative end-of-shot + `OperationTraceChunk { stage: "named_result_ids_final", named_result_ids }` + emitted from `QisEngine::get_results` (per-op-chunk snapshots dropped -- + they missed tail `result(...)` stores). +- Replay (`decode.py::trace_guppy_into_tick_circuit`) attaches + `tc.set_meta("meas_tags", {tag: [MeasId]})`. +- `build_dem_from_circuit` resolves an optional `result_tags` field on + detectors/observables into record offsets via `meas_tags` (serde_json; + hand-rolled detector parser untouched). +- `DetectorErrorModel.from_guppy` accepts `result_tags`, auto-sets + `num_measurements` when tags are used, and fails loud on unknown tags. + +Validated: `meas_tags` == MZ MeasIds; tag-referenced DEM byte-identical to the +positional equivalent; surface positional path byte-identical (Z+X, no +regression); surface LER workflow with distance suppression; unknown-tag +raises; ruff + cargo + 51 pytest pass. The fingerprint idea was dropped (not a +substitute) per the decision to implement the real linkage. diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 81ffa6284..edb2a374c 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -77,11 +77,24 @@ def _validate_measurement_contract( raise ValueError(msg) effective = num_measurements if num_measurements is not None else measured + meas_tags_meta = tc.get_meta("meas_tags") + meas_tags: dict[str, list[int]] = json.loads(meas_tags_meta) if meas_tags_meta else {} + def _check(kind: str, entries: list[dict[str, Any]]) -> None: for entry in entries: # Tracked Paulis reference qubits via "pauli", not measurements. if entry.get("kind") == "tracked_pauli": continue + for tag in entry.get("result_tags", []) or []: + if tag not in meas_tags: + known = ", ".join(sorted(meas_tags)[:8]) or "" + msg = ( + f"{kind} {entry.get('id', entry)} references " + f"result_tag {tag!r}, which the traced program never " + f"recorded via result(...). Known tags: {known}" + f"{' ...' if len(meas_tags) > 8 else ''}." + ) + raise ValueError(msg) for rec in entry.get("records", []) or []: idx = effective + int(rec) if not 0 <= idx < effective: @@ -113,6 +126,20 @@ def _check(kind: str, entries: list[dict[str, Any]]) -> None: _check("Observable", observables) +def _uses_result_tags(detectors_json: str, observables_json: str) -> bool: + """True if any detector/observable references measurements by result tag.""" + for blob in (detectors_json, observables_json): + if not blob: + continue + try: + entries = json.loads(blob) + except json.JSONDecodeError: + continue + if any(e.get("result_tags") for e in entries if isinstance(e, dict)): + return True + return False + + class DetectorErrorModel(_RustDetectorErrorModel): """Detector error model with a Guppy/QIS-trace convenience constructor. @@ -164,9 +191,18 @@ def from_guppy( e.g. ``"+X0 Z2"``); the DEM builder splits them out automatically. There is no separate tracked-Pauli argument -- this matches the underlying circuit-metadata contract exactly. + Reorder-robust alternative: instead of positional ``records``/ + ``meas_ids``, an entry may carry ``"result_tags": ["sx0:meas:0", + ...]`` to reference measurements by the stable Guppy + ``result(tag, ...)`` tag they were recorded under. Tags are + fixed in the Guppy source, so they survive any measurement + reordering introduced by Guppy/Selene compilation. The DEM + builder resolves tags via the trace's ``meas_tags`` linkage; + ``result_tags`` and ``records`` may be combined on one entry. num_measurements: Total measurement count, used to resolve negative ``records`` offsets. If omitted, it is inferred from the traced - circuit. + circuit (and is always set automatically when ``result_tags`` + are used). p1: Single-qubit gate depolarizing rate. p2: Two-qubit gate depolarizing rate. p_meas: Measurement flip rate. @@ -179,28 +215,23 @@ def from_guppy( Raises: ValueError: If ``num_measurements`` disagrees with the traced measurement count, if a detector/observable references an - out-of-range ``record`` or an absent ``meas_id``, or if the + out-of-range ``record``, an absent ``meas_id``, or a + ``result_tag`` the traced program never recorded, or if the traced operation stream is malformed (the strict ``AllocateResult``/``Measure`` pairing in the replay fails). Note: Every measurement is anchored to a stable MeasId automatically: - ``measure()`` itself allocates the result slot in the trace, so a - ``result(...)`` call is *not* required for MeasId assignment - (``result(...)`` controls the program's output stream, which this - path does not consume). - - The caller owns the measurement-order contract: detector/observable - ``records``/``meas_ids`` must reference measurements in the order - the *traced (post-compilation)* program emits them. Unlike the - surface-code ``traced_qis`` path, there is no reference circuit to - validate that ordering against; inputs are checked for internal - consistency only (see ``Raises``), not against intent. - - Known limitation: because references are positional, they are - sensitive to measurement reordering introduced by Guppy/Selene - compilation. Stable, tag-referenced detectors (immune to - reordering) are a planned enhancement -- see + ``measure()`` itself allocates the result slot in the trace. A + ``result(...)`` call is not required for MeasId assignment, but it + *is* what enables reorder-robust ``result_tags`` references: the + trace records, per tag, exactly which MeasIds it captured + (``meas_tags`` metadata), an identity fixed in the Guppy source. + + Positional ``records``/``meas_ids`` reference measurements by + *traced (post-compilation)* order and are therefore sensitive to + measurement reordering by Guppy/Selene compilation; ``result_tags`` + are not. See ``docs/proposals/001-from-guppy-tag-referenced-detectors.md``. """ from pecos.qec.surface.decode import trace_guppy_into_tick_circuit @@ -222,6 +253,10 @@ def from_guppy( tc.set_meta("detectors", detectors_json) tc.set_meta("observables", observables_json) + if num_measurements is None and _uses_result_tags(detectors_json, observables_json): + # The DEM builder resolves result_tags -> record offsets as + # meas_id - num_measurements, so num_measurements must be present. + num_measurements, _ = _collect_measurement_info(tc) if num_measurements is not None: tc.set_meta("num_measurements", str(num_measurements)) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 1faba1a6a..3af5179d6 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -726,9 +726,14 @@ def trace_guppy_into_tick_circuit(program: Any, num_qubits: int, *, seed: int = seed: Seed for the (ideal) trace run. Returns: - A ``TickCircuit`` with no detector/observable metadata attached; the - caller is responsible for supplying that. + A ``TickCircuit``. No detector/observable metadata is attached (the + caller supplies that), but if the program used Guppy ``result(tag, + ...)`` calls, a ``meas_tags`` metadata entry maps each tag to the + MeasIds it recorded -- a source-stable measurement identity that + survives Guppy/Selene compilation reordering. """ + import json + import pecos sim_builder = ( @@ -741,12 +746,28 @@ def trace_guppy_into_tick_circuit(program: Any, num_qubits: int, *, seed: int = chunks = list(sim_builder.capture_operation_trace()) if any(chunk.get("lowered_quantum_ops") for chunk in chunks): - return _replay_lowered_qis_trace_into_tick_circuit(chunks) - - operations: list[dict[str, Any]] = [] + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + else: + operations: list[dict[str, Any]] = [] + for chunk in chunks: + operations.extend(list(chunk.get("operations", []))) + tc = _replay_qis_trace_into_tick_circuit(operations) + + # Attach the source-stable result()-tag -> MeasId linkage. The QIS + # result_id is the same integer stamped as the MeasId during replay, so + # the tag -> [result_id] map captured by the ExecutionContext is directly + # a tag -> [MeasId] map. ``named_result_ids`` is a cumulative snapshot, so + # the last chunk carrying a tag holds its complete id list. + meas_tags: dict[str, list[int]] = {} for chunk in chunks: - operations.extend(list(chunk.get("operations", []))) - return _replay_qis_trace_into_tick_circuit(operations) + nri = chunk.get("named_result_ids") + if nri: + for tag, ids in nri.items(): + meas_tags[tag] = [int(i) for i in ids] + if meas_tags: + tc.set_meta("meas_tags", json.dumps(meas_tags, separators=(",", ":"))) + + return tc def _generate_traced_surface_tick_circuit( From 4d7299c1bd69f6a48a20a870900b006f2f370a98 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 18 May 2026 17:12:42 -0600 Subject: [PATCH 03/36] Accept D0/L0 id form in from_guppy detectors/observables (normalized to int) --- python/quantum-pecos/src/pecos/qec/dem.py | 48 ++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index edb2a374c..fb54331ec 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -126,6 +126,43 @@ def _check(kind: str, entries: list[dict[str, Any]]) -> None: _check("Observable", observables) +def _normalize_entry_ids(blob: str, prefix: str) -> str: + """Normalize ``"id": "D0"``/``"L0"`` to the integer the pipeline expects. + + ``prefix`` is ``"D"`` for detectors, ``"L"`` for observables. Integer ids + and entries without ``"id"`` (e.g. those using ``detector_id`` / + ``observable_id``) pass through unchanged. A string id with the wrong + prefix or a non-numeric body is a hard error -- silently reinterpreting it + would risk a mislabeled DEM. + """ + if not blob: + return blob + try: + entries = json.loads(blob) + except json.JSONDecodeError: + return blob # validation downstream reports the parse error + if not isinstance(entries, list): + return blob + + changed = False + for entry in entries: + if not isinstance(entry, dict) or not isinstance(entry.get("id"), str): + continue + raw = entry["id"].strip() + body = raw[len(prefix):] if raw.startswith(prefix) else None + if body is None or not body.isdigit(): + msg = ( + f"id {entry['id']!r} is not a valid identifier for this list; " + f"expected an integer or {prefix!r}-prefixed form like " + f"{prefix}0 (detectors use 'D', observables use 'L')." + ) + raise ValueError(msg) + entry["id"] = int(body) + changed = True + + return json.dumps(entries, separators=(",", ":")) if changed else blob + + def _uses_result_tags(detectors_json: str, observables_json: str) -> bool: """True if any detector/observable references measurements by result tag.""" for blob in (detectors_json, observables_json): @@ -180,7 +217,10 @@ def from_guppy( num_qubits: Number of qubits to allocate for the trace. QIS/HUGR programs require an explicit qubit count. detectors_json: Detector definitions as a JSON list, e.g. - ``[{"id": 0, "records": [-1, -5]}, ...]``. ``records`` are + ``[{"id": 0, "records": [-1, -5]}, ...]``. ``id`` may be a bare + integer or, for convenience, the DEM-label form ``"D0"`` + (observables likewise accept ``"L0"``); both normalize to the + same integer. ``records`` are negative measurement offsets (Stim convention); ``meas_ids`` may be used instead. Defined against the *traced* program's own measurement order. @@ -236,6 +276,12 @@ def from_guppy( """ from pecos.qec.surface.decode import trace_guppy_into_tick_circuit + # Convenience: allow "id": "D0" / "L0" (matching DEM labels) in + # addition to bare integers. Normalized to ints here so the schema, + # Rust parser, and surface path are untouched. + detectors_json = _normalize_entry_ids(detectors_json, "D") + observables_json = _normalize_entry_ids(observables_json, "L") + tc = trace_guppy_into_tick_circuit(guppy, num_qubits, seed=seed) # Compilation passes required for traced QIS circuits before fault From 46243b0dfb84c8c4d36016b35759067aac8bf62b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 18 May 2026 17:37:22 -0600 Subject: [PATCH 04/36] Document tracked-Pauli qubit-numbering limitation in from_guppy --- .../001-from-guppy-tag-referenced-detectors.md | 18 ++++++++++++++++++ python/quantum-pecos/src/pecos/qec/dem.py | 11 +++++++++++ 2 files changed, 29 insertions(+) diff --git a/docs/proposals/001-from-guppy-tag-referenced-detectors.md b/docs/proposals/001-from-guppy-tag-referenced-detectors.md index b2014f982..724aefaea 100644 --- a/docs/proposals/001-from-guppy-tag-referenced-detectors.md +++ b/docs/proposals/001-from-guppy-tag-referenced-detectors.md @@ -243,3 +243,21 @@ positional equivalent; surface positional path byte-identical (Z+X, no regression); surface LER workflow with distance suppression; unknown-tag raises; ruff + cargo + 51 pytest pass. The fingerprint idea was dropped (not a substitute) per the decision to implement the real linkage. + +## Scope: tracked Paulis are NOT covered + +`result_tags` anchors detectors/observables, which are *measurement*-anchored. +A tracked Pauli (`"kind": "tracked_pauli"` in `observables_json`) references +**qubits** via its `pauli` string, not measurements -- it is a propagated +Pauli frame, not a measurement outcome -- so `result()` tags do not apply. +Its qubit indices are interpreted in the traced (post-compilation) qubit +numbering and are therefore **not** source-stable the way tag-referenced +detectors/observables now are. Guppy exposes no `result()`-equivalent identity +for a qubit, so there is no analogous anchor. + +Impact: geometry-derived paths (e.g. the surface builder, which validates +traced-vs-abstract measurement order and derives logical-operator support from +geometry) are unaffected. Hand-authored tracked Paulis for a *general* +`from_guppy` program must use traced qubit numbering and are reorder-fragile. +Decision: documented as a known limitation (in `from_guppy`'s docstring and +here); a qubit-identity anchor is possible future work, not in scope now. diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index fb54331ec..724d2116e 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -231,6 +231,17 @@ def from_guppy( e.g. ``"+X0 Z2"``); the DEM builder splits them out automatically. There is no separate tracked-Pauli argument -- this matches the underlying circuit-metadata contract exactly. + + Limitation: a tracked Pauli references **qubits** (via its + ``pauli`` string), not measurements, so the ``result_tags`` + anchor does not apply to it. Its qubit indices are interpreted + in the *traced (post-compilation)* qubit numbering and are + therefore **not** source-stable the way tag-referenced + detectors/observables are -- Guppy exposes no ``result()``-style + identity for a qubit. For a hand-authored general Guppy program + the caller must supply tracked-Pauli qubit indices in the + traced numbering; geometry-derived paths (e.g. the surface + builder) avoid this by construction. Reorder-robust alternative: instead of positional ``records``/ ``meas_ids``, an entry may carry ``"result_tags": ["sx0:meas:0", ...]`` to reference measurements by the stable Guppy From 53301ed5116af954b07924c976ecc3867f570840 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 18 May 2026 20:48:05 -0600 Subject: [PATCH 05/36] Add sound HUGR-based result()-tag to measurement extraction in pecos-hugr-qis --- crates/pecos-hugr-qis/src/lib.rs | 3 + crates/pecos-hugr-qis/src/result_tags.rs | 160 ++++++++++++++++++ .../pecos-hugr-qis/tests/fixtures/looped.hugr | Bin 0 -> 9290 bytes .../tests/fixtures/scrambled.hugr | Bin 0 -> 6022 bytes 4 files changed, 163 insertions(+) create mode 100644 crates/pecos-hugr-qis/src/result_tags.rs create mode 100644 crates/pecos-hugr-qis/tests/fixtures/looped.hugr create mode 100644 crates/pecos-hugr-qis/tests/fixtures/scrambled.hugr diff --git a/crates/pecos-hugr-qis/src/lib.rs b/crates/pecos-hugr-qis/src/lib.rs index b75edf4fa..dc0664e82 100644 --- a/crates/pecos-hugr-qis/src/lib.rs +++ b/crates/pecos-hugr-qis/src/lib.rs @@ -62,6 +62,7 @@ The compiler supports standard LLVM optimization levels: pub mod array; pub mod compiler; pub mod prelude; +pub mod result_tags; mod utils; // Re-export main types and functions @@ -74,6 +75,8 @@ pub use compiler::{ // Re-export read_hugr_envelope from utils pub use utils::read_hugr_envelope; +pub use result_tags::extract_result_tag_measurements; + // Re-export inkwell's OptimizationLevel for convenience pub use tket::hugr::llvm::inkwell::OptimizationLevel; diff --git a/crates/pecos-hugr-qis/src/result_tags.rs b/crates/pecos-hugr-qis/src/result_tags.rs new file mode 100644 index 000000000..56d18559e --- /dev/null +++ b/crates/pecos-hugr-qis/src/result_tags.rs @@ -0,0 +1,160 @@ +//! Extract the Guppy `result(tag, ...)` -> measurement binding from a HUGR. +//! +//! This is the *sound* source of the tag<->measurement association: in the +//! compiled HUGR, a `tket.result` op's dataflow input is wired (transitively) +//! from the measurement op(s) that produced its value. That wiring is fixed at +//! compile time and is immune to any later QIS/Selene measurement reordering, +//! unlike a runtime op-stream heuristic. +//! +//! Measurement identity here is the *ordinal* of the measurement op in HUGR +//! traversal order. Whether that ordinal coincides with the QIS-trace +//! `result_id`/MeasId is a separate, verified property (see the dem-polish +//! foundation tests); this module only recovers the structural binding. +//! +//! Note: a *runtime* loop (e.g. `for _ in range(comptime(n))`, as the surface +//! code uses for rounds) is NOT unrolled in the HUGR -- it has one static +//! measure/result op executed n times. Static extraction therefore yields +//! `tag -> static-measure-op`; expanding that to per-iteration runtime MeasIds +//! requires a separate static-op -> runtime-measurement correspondence. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use tket::hugr::ops::OpType; +use tket::hugr::types::Term; +use tket::hugr::{HugrView, IncomingPort, Node}; + +fn extension_ids<'a>(op: &'a OpType) -> Option<(&'a str, String)> { + let ext = op.as_extension_op()?; + Some(( + ext.extension_id().as_ref(), + ext.unqualified_id().to_string(), + )) +} + +fn is_measurement(op: &OpType) -> bool { + matches!( + extension_ids(op), + Some((ext, ref name)) + if ext == "tket.quantum" && (name == "Measure" || name == "MeasureFree") + ) +} + +/// Map each `result(tag, ...)` to the measurement ordinals whose values it +/// recorded, in measurement-ordinal order. +/// +/// A repeated tag (e.g. `result("synx", ...)` in a loop) accumulates each +/// occurrence's measurement ordinals in the order the `result` ops are +/// traversed; callers can disambiguate occurrences as needed. +#[must_use] +pub fn extract_result_tag_measurements>( + hugr: &H, +) -> BTreeMap> { + // Pass 1: ordinal for every measurement op, in traversal order. + let mut meas_ordinal: HashMap = HashMap::new(); + for node in hugr.nodes() { + if is_measurement(hugr.get_optype(node)) { + let next = meas_ordinal.len(); + meas_ordinal.insert(node, next); + } + } + + // Pass 2: per tket.result op, reverse-DFS over value wires to the + // measurement ancestors feeding it. + let mut out: BTreeMap> = BTreeMap::new(); + for node in hugr.nodes() { + let op = hugr.get_optype(node); + let Some((ext, name)) = extension_ids(op) else { + continue; + }; + if ext != "tket.result" || !name.starts_with("result") { + continue; + } + let Some(ext_op) = op.as_extension_op() else { + continue; + }; + let Some(tag) = ext_op.args().iter().find_map(|a| match a { + Term::String(s) => Some(s.clone()), + _ => None, + }) else { + continue; + }; + + // Seed ONLY from input port 0 -- the recorded value. Port 1 of a + // result op is the linear state/order token threading result ops to + // each other and to measurement side-effects; following it conflates + // every measurement. From the value port, a reverse walk reaches the + // measurement(s) via classical value ops (tket.bool:read, array + // constructors, ...); measurements are leaves (we never descend into + // their qubit inputs), so qubit wires are never traversed. + let mut found: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let mut stack: Vec = Vec::new(); + if let Some((src, _)) = hugr.single_linked_output(node, IncomingPort::from(0)) { + stack.push(src); + } + while let Some(n) = stack.pop() { + if !seen.insert(n) { + continue; + } + if let Some(&ord) = meas_ordinal.get(&n) { + found.push(ord); + continue; // a measurement is a leaf for value provenance + } + for p in 0..hugr.num_inputs(n) { + if let Some((src, _)) = hugr.single_linked_output(n, IncomingPort::from(p)) { + stack.push(src); + } + } + } + found.sort_unstable(); + found.dedup(); + out.entry(tag).or_default().extend(found); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::read_hugr_envelope; + + // Fixtures generated from Guppy via /tmp/gen_hugr_fixtures.py (committed so + // the regression does not depend on a Python toolchain at test time). + const SCRAMBLED: &[u8] = include_bytes!("../tests/fixtures/scrambled.hugr"); + const LOOPED: &[u8] = include_bytes!("../tests/fixtures/looped.hugr"); + + /// Foundation: `result()` declared in scrambled order (c, a, b) over + /// measurements made in order (a, b, c) must still bind each tag to ITS + /// OWN measurement. This is the exact case the prior runtime read/store + /// heuristic got wrong (it produced `{tag_c: [0,1,2]}`); the HUGR + /// structural binding is immune to declaration/measurement-order skew. + #[test] + fn scrambled_binds_each_tag_to_its_measurement() { + let hugr = read_hugr_envelope(SCRAMBLED).unwrap(); + let map = extract_result_tag_measurements(&hugr); + assert_eq!( + map, + BTreeMap::from([ + ("tag_a".to_string(), vec![0]), + ("tag_b".to_string(), vec![1]), + ("tag_c".to_string(), vec![2]), + ]), + "tag must bind to its own measurement regardless of result() order", + ); + } + + /// Documents the known limitation: a runtime `for _ in range(comptime(n))` + /// loop is NOT unrolled in the HUGR, so a tag emitted once per iteration + /// has a single static measure op. Per-iteration expansion needs a + /// separate static-op -> runtime-measurement correspondence. + #[test] + fn looped_tag_is_single_static_measure_op() { + let hugr = read_hugr_envelope(LOOPED).unwrap(); + let map = extract_result_tag_measurements(&hugr); + assert_eq!( + map.get("synx").map(Vec::as_slice), + Some([0].as_slice()), + "runtime loop is not unrolled in HUGR: one static measure op", + ); + } +} diff --git a/crates/pecos-hugr-qis/tests/fixtures/looped.hugr b/crates/pecos-hugr-qis/tests/fixtures/looped.hugr new file mode 100644 index 0000000000000000000000000000000000000000..4d8d74e287b8c988a733b3d16af71eba987eff84 GIT binary patch literal 9290 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 literal 0 HcmV?d00001 diff --git a/crates/pecos-hugr-qis/tests/fixtures/scrambled.hugr b/crates/pecos-hugr-qis/tests/fixtures/scrambled.hugr new file mode 100644 index 0000000000000000000000000000000000000000..7e3ffc149e5488dd823fe76028edc1c3356da4b8 GIT binary patch literal 6022 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 Ar2qf` literal 0 HcmV?d00001 From 85acfd961fc02ac9d8f3229fdf279ea92631c429 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 18 May 2026 21:19:04 -0600 Subject: [PATCH 06/36] Excise proven-unsound runtime result()-tag linkage; keep sound HUGR extractor and positional from_guppy --- crates/pecos-hugr-qis/src/result_tags.rs | 32 +++++++ .../fault_tolerance/dem_builder/builder.rs | 91 ------------------ crates/pecos-qis-ffi/src/ffi.rs | 8 -- crates/pecos-qis-ffi/src/lib.rs | 95 ------------------- crates/pecos-qis/src/ccengine.rs | 39 -------- crates/pecos-qis/src/executor.rs | 47 --------- crates/pecos-qis/src/qis_interface.rs | 16 ---- ...001-from-guppy-tag-referenced-detectors.md | 46 ++++++++- python/quantum-pecos/src/pecos/qec/dem.py | 80 ++++------------ .../src/pecos/qec/surface/decode.py | 35 ++----- 10 files changed, 102 insertions(+), 387 deletions(-) diff --git a/crates/pecos-hugr-qis/src/result_tags.rs b/crates/pecos-hugr-qis/src/result_tags.rs index 56d18559e..79c6e3fc5 100644 --- a/crates/pecos-hugr-qis/src/result_tags.rs +++ b/crates/pecos-hugr-qis/src/result_tags.rs @@ -143,6 +143,38 @@ mod tests { ); } + /// Diagnostic: dump the looped HUGR's control-flow / region structure so + /// the unrolled-order reconstruction can be designed against the real + /// loop representation (TailLoop vs CFG, where the comptime bound lives, + /// which region the measure/result ops sit in). + #[test] + fn dump_looped_control_flow() { + let hugr = read_hugr_envelope(LOOPED).unwrap(); + for node in hugr.nodes() { + let op = hugr.get_optype(node); + let parent = hugr.get_parent(node); + let tag = match extension_ids(op) { + Some((e, n)) => format!("EXT {e}:{n}"), + None => format!("{op:?}") + .split_whitespace() + .next() + .unwrap_or("?") + .to_string(), + }; + let interesting = matches!(tag.as_str(), t if t.contains("CFG") + || t.contains("DataflowBlock") || t.contains("TailLoop") + || t.contains("Conditional") || t.contains("Case") + || t.contains("ExitBlock") || t.contains("Const") + || t.contains("FuncDefn")) + || tag.contains("Measure") + || tag.contains("tket.result") + || tag.contains("LoadConstant"); + if interesting { + eprintln!("{node:?} parent={parent:?} {tag}"); + } + } + } + /// Documents the known limitation: a runtime `for _ in range(comptime(n))` /// loop is NOT unrolled in the HUGR, so a tag emitted once per iteration /// has a single static measure op. Per-iteration expansion needs a diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index cd0a828d7..ae792f1b1 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -1271,79 +1271,6 @@ fn extract_records(json: &str) -> Vec { Vec::new() } -/// Rewrite tag-referenced detector/observable JSON into record offsets. -/// -/// Entries may reference measurements by a stable Guppy `result(...)` tag via -/// a `"result_tags": ["sx0:meas:0", ...]` field instead of positional -/// `records`. Each tag is resolved through the circuit's `meas_tags` map -/// (`tag -> [MeasId]`) to record offsets (`meas_id - num_measurements`), since -/// a MeasId is the measurement's absolute trace-order index. Resolved offsets -/// are merged into the entry's `records` and `result_tags` is removed, so the -/// downstream parser is unchanged. Tag references are immune to Guppy/Selene -/// measurement reordering because the tag identity is fixed in the source. -fn resolve_result_tags_in_json( - json: &str, - meas_tags: &std::collections::BTreeMap>, - num_meas: Option, -) -> Result { - let mut value: serde_json::Value = serde_json::from_str(json) - .map_err(|e| DemBuilderError::ParseError(format!("invalid detector/observable JSON: {e}")))?; - let Some(entries) = value.as_array_mut() else { - return Ok(json.to_string()); - }; - - let mut changed = false; - for entry in entries.iter_mut() { - let Some(obj) = entry.as_object_mut() else { - continue; - }; - let Some(tags) = obj.remove("result_tags") else { - continue; - }; - changed = true; - let Some(num_meas) = num_meas else { - return Err(DemBuilderError::ParseError( - "result_tags used but circuit has no num_measurements".to_string(), - )); - }; - let num_meas = num_meas as i64; - - let mut records: Vec = obj - .get("records") - .and_then(|r| r.as_array()) - .map(|a| a.iter().filter_map(serde_json::Value::as_i64).collect()) - .unwrap_or_default(); - - let tag_list = tags.as_array().ok_or_else(|| { - DemBuilderError::ParseError("result_tags must be a JSON array of strings".to_string()) - })?; - for tag in tag_list { - let tag = tag.as_str().ok_or_else(|| { - DemBuilderError::ParseError("result_tags entries must be strings".to_string()) - })?; - let meas_ids = meas_tags.get(tag).ok_or_else(|| { - DemBuilderError::ParseError(format!( - "result_tag {tag:?} not found in circuit meas_tags" - )) - })?; - for &mid in meas_ids { - records.push(mid - num_meas); - } - } - - obj.insert( - "records".to_string(), - serde_json::Value::Array(records.into_iter().map(serde_json::Value::from).collect()), - ); - } - - if !changed { - return Ok(json.to_string()); - } - serde_json::to_string(&value) - .map_err(|e| DemBuilderError::ParseError(format!("failed to re-serialize JSON: {e}"))) -} - // ============================================================================ // Convenience: build DEM from circuit (free function to handle lifetimes) // ============================================================================ @@ -1392,24 +1319,6 @@ fn build_dem_from_circuit( } }); - // Stable Guppy result()-tag -> [MeasId] linkage, if the circuit was traced - // from a Guppy program. Used to resolve tag-referenced detectors into - // record offsets that survive compilation measurement reordering. - let meas_tags: std::collections::BTreeMap> = circuit - .get_attr("meas_tags") - .and_then(|a| { - if let Attribute::String(s) = a { - serde_json::from_str(s).ok() - } else { - None - } - }) - .unwrap_or_default(); - let det_json = det_json - .map(|s| resolve_result_tags_in_json(&s, &meas_tags, num_meas).unwrap_or(s)); - let obs_json = obs_json - .map(|s| resolve_result_tags_in_json(&s, &meas_tags, num_meas).unwrap_or(s)); - let builder = DemBuilder::new(&influence_map).with_noise(p1, p2, p_meas, p_prep); let builder = if let Some(ref dj) = det_json { diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index ae31083e8..a0188cea2 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -468,14 +468,6 @@ pub unsafe extern "C" fn ___read_future_bool(future_id: i64) -> bool { log::debug!("___read_future_bool called with future_id={future_id}"); let result_id = i64_to_usize(future_id); - // Record the read so the next named-result store can attribute this - // measurement's result_id to its Guppy result(...) tag. This is what - // makes tag -> MeasId source-stable across compilation reordering. - if let Some(ctx) = crate::get_execution_context() { - // SAFETY: context is valid for the duration of execution. - unsafe { &*ctx }.note_read_result_id(result_id); - } - // Check if result is already available in thread-local storage let existing_result = with_interface(|interface| interface.get_result(result_id)); log::debug!("___read_future_bool: existing_result={existing_result:?}"); diff --git a/crates/pecos-qis-ffi/src/lib.rs b/crates/pecos-qis-ffi/src/lib.rs index d6c0db689..5fecf0371 100644 --- a/crates/pecos-qis-ffi/src/lib.rs +++ b/crates/pecos-qis-ffi/src/lib.rs @@ -66,15 +66,6 @@ pub struct ExecutionContext { pub measurement_results: Mutex>>, /// Storage for named results from `print_bool`/`print_bool_arr` (e.g., "synx", "final") pub named_results: Mutex>>, - /// Result IDs read via `___read_future_bool` since the last named-result - /// store. Drained into `named_result_ids` when a `result(tag, ...)` is - /// recorded, giving a robust `tag -> result_id` linkage. - pub pending_read_result_ids: Mutex>, - /// Maps each Guppy `result(tag, ...)` tag to the QIS result IDs - /// (== MeasIds) whose values it recorded, in read order. This is the - /// source-stable measurement identity that survives compilation - /// reordering. - pub named_result_ids: Mutex>>, } impl ExecutionContext { @@ -89,8 +80,6 @@ impl ExecutionContext { pending_ops: Mutex::new(Vec::new()), measurement_results: Mutex::new(Vec::new()), named_results: Mutex::new(BTreeMap::new()), - pending_read_result_ids: Mutex::new(Vec::new()), - named_result_ids: Mutex::new(BTreeMap::new()), } } @@ -112,55 +101,11 @@ impl ExecutionContext { if let Ok(mut named) = self.named_results.lock() { named.clear(); } - if let Ok(mut pending) = self.pending_read_result_ids.lock() { - pending.clear(); - } - if let Ok(mut named_ids) = self.named_result_ids.lock() { - named_ids.clear(); - } - } - - /// Record that `result_id` was just read via `___read_future_bool`. - /// - /// Buffered until the next named-result store, which attributes these IDs - /// to its tag. This is what links a Guppy `result(tag, ...)` to the - /// specific measurement(s) it recorded. - pub fn note_read_result_id(&self, result_id: usize) { - if let Ok(mut pending) = self.pending_read_result_ids.lock() { - pending.push(result_id); - } - } - - /// Drain the pending read result IDs and attribute them to `name`. - fn attribute_pending_result_ids(&self, name: &str) { - let drained = match self.pending_read_result_ids.lock() { - Ok(mut pending) => std::mem::take(&mut *pending), - Err(_) => return, - }; - if drained.is_empty() { - return; - } - if let Ok(mut named_ids) = self.named_result_ids.lock() { - named_ids - .entry(name.to_string()) - .or_default() - .extend(drained); - } - } - - /// Get the `tag -> [result_id, ...]` linkage (returns a clone). - #[must_use] - pub fn get_named_result_ids(&self) -> BTreeMap> { - self.named_result_ids - .lock() - .map(|guard| guard.clone()) - .unwrap_or_default() } /// Store a named result (single bool value) pub fn store_named_bool(&self, name: &str, value: bool) { let thread_id = std::thread::current().id(); - self.attribute_pending_result_ids(name); if let Ok(mut named) = self.named_results.lock() { let entry = named.entry(name.to_string()).or_default(); entry.push(value); @@ -181,7 +126,6 @@ impl ExecutionContext { /// Store a named result array (multiple bool values) pub fn store_named_array(&self, name: &str, values: &[bool]) { - self.attribute_pending_result_ids(name); if let Ok(mut named) = self.named_results.lock() { let entry = named.entry(name.to_string()).or_default(); entry.extend_from_slice(values); @@ -801,45 +745,6 @@ pub extern "C" fn pecos_get_named_results_json() -> *mut std::ffi::c_char { } } -/// Get the `tag -> [result_id, ...]` linkage as a JSON string. -/// -/// Format: `{"sx0:meas:0": [3], "final": [10, 11, 12], ...}`. Each result_id -/// is the QIS measurement identity (== MeasId in the replayed TickCircuit), so -/// this maps every Guppy `result(tag, ...)` to the measurement(s) it recorded, -/// source-stable across compilation reordering. -/// -/// The caller must free the returned string using -/// `pecos_free_named_results_json`. Returns null if no context is registered -/// or the linkage is empty. -/// -/// # Safety -/// This function is safe to call from any thread. The returned pointer must be freed. -#[unsafe(no_mangle)] -pub extern "C" fn pecos_get_named_result_ids_json() -> *mut std::ffi::c_char { - let Some(ctx) = get_execution_context() else { - return std::ptr::null_mut(); - }; - // SAFETY: context is valid for the duration of execution. - let named_result_ids = unsafe { &*ctx }.get_named_result_ids(); - if named_result_ids.is_empty() { - return std::ptr::null_mut(); - } - let json = match serde_json::to_string(&named_result_ids) { - Ok(s) => s, - Err(e) => { - log::error!("pecos_get_named_result_ids_json: serialization error: {e}"); - return std::ptr::null_mut(); - } - }; - match std::ffi::CString::new(json) { - Ok(cstr) => cstr.into_raw(), - Err(e) => { - log::error!("pecos_get_named_result_ids_json: CString error: {e}"); - std::ptr::null_mut() - } - } -} - /// Free a JSON string allocated by `pecos_get_named_results_json` /// /// # Safety diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index 6b26462e0..e244847db 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -58,11 +58,6 @@ pub struct OperationTraceChunk { pub num_operations: usize, pub operations: Vec, pub lowered_quantum_ops: Vec, - /// Guppy `result(tag, ...)` -> QIS result IDs (== MeasIds) it recorded. - /// Snapshot at the time this chunk was emitted; the final chunk of a shot - /// carries the complete linkage. Empty when no named results were tagged. - #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] - pub named_result_ids: std::collections::BTreeMap>, } /// Shared in-memory store for traced QIS operation batches. @@ -865,10 +860,6 @@ impl QisEngine { num_operations: ops.len(), operations: ops.to_vec(), lowered_quantum_ops: lowered_trace, - // Populated only on the authoritative end-of-shot chunk emitted - // from get_results(); per-op-chunk snapshots would miss tail - // result(...) stores. - named_result_ids: std::collections::BTreeMap::new(), }; if let Some(ref collector) = self.operation_trace_collector { @@ -1245,36 +1236,6 @@ impl ClassicalEngine for QisEngine { has_named_results ); - // Emit a final trace chunk carrying the complete result()-tag -> - // [result_id] linkage. Per-chunk snapshots can miss stores that - // happen after the last operation chunk (e.g. the final result(...) - // before program exit); this end-of-shot capture is authoritative. - if let Some(collector) = &self.operation_trace_collector - && let Some(state) = &self.dynamic_state - && let Some(handle) = &state.sync_handle - && let Ok(named_result_ids) = handle.get_named_result_ids() - && !named_result_ids.is_empty() - { - let chunk = OperationTraceChunk { - format: "pecos_qis_operation_trace_v1", - engine_trace_id: self.trace_engine_id, - shot_index: self.trace_shot_index, - chunk_index: self.trace_chunk_index, - stage: "named_result_ids_final".to_string(), - waiting_for_result_id: None, - current_shot_seed: self.current_shot_seed, - simulated_op_count: self.simulated_op_count, - num_operations: 0, - operations: Vec::new(), - lowered_quantum_ops: Vec::new(), - named_result_ids, - }; - match collector.lock() { - Ok(mut guard) => guard.push(chunk), - Err(err) => warn!("Failed to store final named_result_ids chunk: {err}"), - } - } - Ok(shot) } diff --git a/crates/pecos-qis/src/executor.rs b/crates/pecos-qis/src/executor.rs index d31d85e46..d1ba56774 100644 --- a/crates/pecos-qis/src/executor.rs +++ b/crates/pecos-qis/src/executor.rs @@ -454,53 +454,6 @@ impl DynamicSyncHandle for HeliosSyncHandle { debug!("HeliosSyncHandle: Got {} named results", result.len()); Ok(result) } - - fn get_named_result_ids( - &self, - ) -> Result>, InterfaceError> { - let lib = Self::get_lib()?; - - let get_fn: Symbol = unsafe { - lib.get(b"pecos_get_named_result_ids_json\0").map_err(|e| { - InterfaceError::ExecutionError(format!( - "Failed to find pecos_get_named_result_ids_json: {e}" - )) - })? - }; - - let ptr = unsafe { get_fn() }; - if ptr.is_null() { - return Ok(std::collections::BTreeMap::new()); - } - - let c_str = unsafe { std::ffi::CStr::from_ptr(ptr) }; - let json_str = c_str.to_str().map_err(|e| { - InterfaceError::ExecutionError(format!("Invalid UTF-8 in named result ids JSON: {e}")) - })?; - - let result: std::collections::BTreeMap> = serde_json::from_str(json_str) - .map_err(|e| { - InterfaceError::ExecutionError(format!( - "Failed to parse named result ids JSON: {e}" - )) - })?; - - // Allocated by the same CString path; freed by the shared free symbol. - let free_fn: Symbol = unsafe { - lib.get(b"pecos_free_named_results_json\0").map_err(|e| { - InterfaceError::ExecutionError(format!( - "Failed to find pecos_free_named_results_json: {e}" - )) - })? - }; - unsafe { free_fn(ptr) }; - - debug!( - "HeliosSyncHandle: Got {} named result-id entries", - result.len() - ); - Ok(result) - } } /// Derive the project target directory from the compile-time embedded Helios path. diff --git a/crates/pecos-qis/src/qis_interface.rs b/crates/pecos-qis/src/qis_interface.rs index 42f2c2992..305add2e1 100644 --- a/crates/pecos-qis/src/qis_interface.rs +++ b/crates/pecos-qis/src/qis_interface.rs @@ -273,22 +273,6 @@ pub trait DynamicSyncHandle: Send + Sync { fn get_named_results( &self, ) -> Result>, InterfaceError>; - - /// Get the `tag -> [result_id, ...]` linkage from the execution context. - /// - /// Each result_id is the QIS measurement identity (== MeasId in the - /// replayed TickCircuit). Maps every Guppy `result(tag, ...)` to the - /// measurement(s) it recorded, source-stable across compilation - /// reordering. Default implementation returns an empty map for handles - /// that do not track this. - /// - /// # Errors - /// Returns an error if the FFI call fails or JSON parsing fails. - fn get_named_result_ids( - &self, - ) -> Result>, InterfaceError> { - Ok(std::collections::BTreeMap::new()) - } } /// Box type for interface implementations diff --git a/docs/proposals/001-from-guppy-tag-referenced-detectors.md b/docs/proposals/001-from-guppy-tag-referenced-detectors.md index 724aefaea..ec2d8bc88 100644 --- a/docs/proposals/001-from-guppy-tag-referenced-detectors.md +++ b/docs/proposals/001-from-guppy-tag-referenced-detectors.md @@ -1,6 +1,9 @@ # 001 - Tag-referenced detectors for `DetectorErrorModel.from_guppy` -**Status:** Implemented +**Status:** Partially delivered — see "Final outcome (dem-polish)" at the +bottom. The sections between here and there record the investigation history +(including a runtime approach that was implemented then **proven unsound and +removed**); the final section is authoritative. **Author:** (dem-polish working notes) @@ -261,3 +264,44 @@ geometry) are unaffected. Hand-authored tracked Paulis for a *general* `from_guppy` program must use traced qubit numbering and are reorder-fragile. Decision: documented as a known limitation (in `from_guppy`'s docstring and here); a qubit-identity anchor is possible future work, not in scope now. + +## Final outcome (dem-polish) -- AUTHORITATIVE + +This section supersedes the "Implemented" and "CORRECTION" sections above. + +What was tried and what landed: + +1. **Runtime read->store linkage (ExecutionContext) -- REMOVED.** Implemented, + then disproved by a foundation test: a program doing all `measure()`s then + all `result()`s yields `{tag_c: [0,1,2]}` instead of the correct per-tag + binding, because measurement-future reads are batched before the stores. + The mechanism (pending_read_result_ids / named_result_ids / + note_read_result_id / pecos_get_named_result_ids_json / OperationTraceChunk + field + end-of-shot emission / DemBuilder `resolve_result_tags_in_json` / + decode.py `meas_tags` / dem.py `result_tags`) was fully excised as unsound. + +2. **Sound HUGR extraction -- KEPT (committed).** + `pecos_hugr_qis::extract_result_tag_measurements` recovers + `tag -> measurement` from the compiled HUGR by structural wire-tracing + (proper `ext_op.args()` tag read; value-port-0 reverse walk). Proven sound + and reorder-immune for straight-line programs by the `scrambled` fixture + test (the exact case the runtime heuristic failed). It is a self-contained + building block; it is **not** wired into `from_guppy`. + +3. **Loops are the unsolved gap.** A `for _ in range(comptime(n))` loop (the + surface-code round structure) is **not unrolled in the HUGR** -- it is a + CFG with one static measure/result op. Static extraction therefore yields + `tag -> static-measure-op`, not per-round MeasIds. Bridging that to runtime + per-occurrence MeasIds requires one of: + - a HUGR CFG abstract interpreter (~= the excluded `HugrEngine`), or + - `tket-qsystem` lowering carrying measurement provenance (upstream), or + - reconstructing the deterministic unrolling from the comptime-bounded CFG + (still requires CFG interpretation). + +Net delivered in `from_guppy`: **sound positional `records`/`meas_ids` +detectors only.** These are byte-identical to the reference and LER-correct +for the surface code (verified), but are *order-sensitive* to Guppy/Selene +recompilation. Reorder-robust tag-referenced detectors are **deferred**; the +sound HUGR building block (#2) is committed for the eventual straight-line +wiring, and the loop case needs CFG-interpreter-class machinery or upstream +`tket-qsystem` provenance. diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 724d2116e..a26d895bf 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -77,24 +77,11 @@ def _validate_measurement_contract( raise ValueError(msg) effective = num_measurements if num_measurements is not None else measured - meas_tags_meta = tc.get_meta("meas_tags") - meas_tags: dict[str, list[int]] = json.loads(meas_tags_meta) if meas_tags_meta else {} - def _check(kind: str, entries: list[dict[str, Any]]) -> None: for entry in entries: # Tracked Paulis reference qubits via "pauli", not measurements. if entry.get("kind") == "tracked_pauli": continue - for tag in entry.get("result_tags", []) or []: - if tag not in meas_tags: - known = ", ".join(sorted(meas_tags)[:8]) or "" - msg = ( - f"{kind} {entry.get('id', entry)} references " - f"result_tag {tag!r}, which the traced program never " - f"recorded via result(...). Known tags: {known}" - f"{' ...' if len(meas_tags) > 8 else ''}." - ) - raise ValueError(msg) for rec in entry.get("records", []) or []: idx = effective + int(rec) if not 0 <= idx < effective: @@ -163,20 +150,6 @@ def _normalize_entry_ids(blob: str, prefix: str) -> str: return json.dumps(entries, separators=(",", ":")) if changed else blob -def _uses_result_tags(detectors_json: str, observables_json: str) -> bool: - """True if any detector/observable references measurements by result tag.""" - for blob in (detectors_json, observables_json): - if not blob: - continue - try: - entries = json.loads(blob) - except json.JSONDecodeError: - continue - if any(e.get("result_tags") for e in entries if isinstance(e, dict)): - return True - return False - - class DetectorErrorModel(_RustDetectorErrorModel): """Detector error model with a Guppy/QIS-trace convenience constructor. @@ -233,27 +206,15 @@ def from_guppy( this matches the underlying circuit-metadata contract exactly. Limitation: a tracked Pauli references **qubits** (via its - ``pauli`` string), not measurements, so the ``result_tags`` - anchor does not apply to it. Its qubit indices are interpreted - in the *traced (post-compilation)* qubit numbering and are - therefore **not** source-stable the way tag-referenced - detectors/observables are -- Guppy exposes no ``result()``-style - identity for a qubit. For a hand-authored general Guppy program - the caller must supply tracked-Pauli qubit indices in the - traced numbering; geometry-derived paths (e.g. the surface - builder) avoid this by construction. - Reorder-robust alternative: instead of positional ``records``/ - ``meas_ids``, an entry may carry ``"result_tags": ["sx0:meas:0", - ...]`` to reference measurements by the stable Guppy - ``result(tag, ...)`` tag they were recorded under. Tags are - fixed in the Guppy source, so they survive any measurement - reordering introduced by Guppy/Selene compilation. The DEM - builder resolves tags via the trace's ``meas_tags`` linkage; - ``result_tags`` and ``records`` may be combined on one entry. + ``pauli`` string), not measurements. Its qubit indices are + interpreted in the *traced (post-compilation)* qubit numbering + and are not source-stable -- for a hand-authored general Guppy + program the caller must supply them in the traced numbering; + geometry-derived paths (e.g. the surface builder) avoid this by + construction. num_measurements: Total measurement count, used to resolve negative ``records`` offsets. If omitted, it is inferred from the traced - circuit (and is always set automatically when ``result_tags`` - are used). + circuit. p1: Single-qubit gate depolarizing rate. p2: Two-qubit gate depolarizing rate. p_meas: Measurement flip rate. @@ -266,23 +227,22 @@ def from_guppy( Raises: ValueError: If ``num_measurements`` disagrees with the traced measurement count, if a detector/observable references an - out-of-range ``record``, an absent ``meas_id``, or a - ``result_tag`` the traced program never recorded, or if the + out-of-range ``record`` or an absent ``meas_id``, or if the traced operation stream is malformed (the strict ``AllocateResult``/``Measure`` pairing in the replay fails). Note: Every measurement is anchored to a stable MeasId automatically: - ``measure()`` itself allocates the result slot in the trace. A - ``result(...)`` call is not required for MeasId assignment, but it - *is* what enables reorder-robust ``result_tags`` references: the - trace records, per tag, exactly which MeasIds it captured - (``meas_tags`` metadata), an identity fixed in the Guppy source. - - Positional ``records``/``meas_ids`` reference measurements by - *traced (post-compilation)* order and are therefore sensitive to - measurement reordering by Guppy/Selene compilation; ``result_tags`` - are not. See + ``measure()`` itself allocates the result slot in the trace (a + ``result(...)`` call is not required for MeasId assignment). + + Detector/observable ``records``/``meas_ids`` reference measurements + by *traced (post-compilation)* order and are therefore sensitive to + any measurement reordering introduced by Guppy/Selene compilation. + Stable, source-anchored tag-referenced detectors are not yet + available (the runtime linkage was proven unsound; the sound + HUGR-based binding works for straight-line programs but loops are + not unrolled in the HUGR) -- see ``docs/proposals/001-from-guppy-tag-referenced-detectors.md``. """ from pecos.qec.surface.decode import trace_guppy_into_tick_circuit @@ -310,10 +270,6 @@ def from_guppy( tc.set_meta("detectors", detectors_json) tc.set_meta("observables", observables_json) - if num_measurements is None and _uses_result_tags(detectors_json, observables_json): - # The DEM builder resolves result_tags -> record offsets as - # meas_id - num_measurements, so num_measurements must be present. - num_measurements, _ = _collect_measurement_info(tc) if num_measurements is not None: tc.set_meta("num_measurements", str(num_measurements)) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 3af5179d6..4028ef6a1 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -726,14 +726,9 @@ def trace_guppy_into_tick_circuit(program: Any, num_qubits: int, *, seed: int = seed: Seed for the (ideal) trace run. Returns: - A ``TickCircuit``. No detector/observable metadata is attached (the - caller supplies that), but if the program used Guppy ``result(tag, - ...)`` calls, a ``meas_tags`` metadata entry maps each tag to the - MeasIds it recorded -- a source-stable measurement identity that - survives Guppy/Selene compilation reordering. + A ``TickCircuit`` with no detector/observable metadata attached; the + caller supplies that. """ - import json - import pecos sim_builder = ( @@ -746,28 +741,12 @@ def trace_guppy_into_tick_circuit(program: Any, num_qubits: int, *, seed: int = chunks = list(sim_builder.capture_operation_trace()) if any(chunk.get("lowered_quantum_ops") for chunk in chunks): - tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) - else: - operations: list[dict[str, Any]] = [] - for chunk in chunks: - operations.extend(list(chunk.get("operations", []))) - tc = _replay_qis_trace_into_tick_circuit(operations) - - # Attach the source-stable result()-tag -> MeasId linkage. The QIS - # result_id is the same integer stamped as the MeasId during replay, so - # the tag -> [result_id] map captured by the ExecutionContext is directly - # a tag -> [MeasId] map. ``named_result_ids`` is a cumulative snapshot, so - # the last chunk carrying a tag holds its complete id list. - meas_tags: dict[str, list[int]] = {} + return _replay_lowered_qis_trace_into_tick_circuit(chunks) + + operations: list[dict[str, Any]] = [] for chunk in chunks: - nri = chunk.get("named_result_ids") - if nri: - for tag, ids in nri.items(): - meas_tags[tag] = [int(i) for i in ids] - if meas_tags: - tc.set_meta("meas_tags", json.dumps(meas_tags, separators=(",", ":"))) - - return tc + operations.extend(list(chunk.get("operations", []))) + return _replay_qis_trace_into_tick_circuit(operations) def _generate_traced_surface_tick_circuit( From 3802864ebcf9ce525fb9dc92d7379f3fc24ddf6b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 18 May 2026 21:20:10 -0600 Subject: [PATCH 07/36] Remove diagnostic dump scaffolding from result_tags tests --- crates/pecos-hugr-qis/src/result_tags.rs | 32 ------------------------ 1 file changed, 32 deletions(-) diff --git a/crates/pecos-hugr-qis/src/result_tags.rs b/crates/pecos-hugr-qis/src/result_tags.rs index 79c6e3fc5..56d18559e 100644 --- a/crates/pecos-hugr-qis/src/result_tags.rs +++ b/crates/pecos-hugr-qis/src/result_tags.rs @@ -143,38 +143,6 @@ mod tests { ); } - /// Diagnostic: dump the looped HUGR's control-flow / region structure so - /// the unrolled-order reconstruction can be designed against the real - /// loop representation (TailLoop vs CFG, where the comptime bound lives, - /// which region the measure/result ops sit in). - #[test] - fn dump_looped_control_flow() { - let hugr = read_hugr_envelope(LOOPED).unwrap(); - for node in hugr.nodes() { - let op = hugr.get_optype(node); - let parent = hugr.get_parent(node); - let tag = match extension_ids(op) { - Some((e, n)) => format!("EXT {e}:{n}"), - None => format!("{op:?}") - .split_whitespace() - .next() - .unwrap_or("?") - .to_string(), - }; - let interesting = matches!(tag.as_str(), t if t.contains("CFG") - || t.contains("DataflowBlock") || t.contains("TailLoop") - || t.contains("Conditional") || t.contains("Case") - || t.contains("ExitBlock") || t.contains("Const") - || t.contains("FuncDefn")) - || tag.contains("Measure") - || tag.contains("tket.result") - || tag.contains("LoadConstant"); - if interesting { - eprintln!("{node:?} parent={parent:?} {tag}"); - } - } - } - /// Documents the known limitation: a runtime `for _ in range(comptime(n))` /// loop is NOT unrolled in the HUGR, so a tag emitted once per iteration /// has a single static measure op. Per-iteration expansion needs a From 29be0f1257c18561c161828802c92c92c011899d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 18 May 2026 22:54:57 -0600 Subject: [PATCH 08/36] Make result_tags clippy-clean (doc backticks, elide lifetime) --- crates/pecos-hugr-qis/src/result_tags.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/pecos-hugr-qis/src/result_tags.rs b/crates/pecos-hugr-qis/src/result_tags.rs index 56d18559e..1e15a565a 100644 --- a/crates/pecos-hugr-qis/src/result_tags.rs +++ b/crates/pecos-hugr-qis/src/result_tags.rs @@ -8,13 +8,13 @@ //! //! Measurement identity here is the *ordinal* of the measurement op in HUGR //! traversal order. Whether that ordinal coincides with the QIS-trace -//! `result_id`/MeasId is a separate, verified property (see the dem-polish +//! `result_id`/`MeasId` is a separate, verified property (see the dem-polish //! foundation tests); this module only recovers the structural binding. //! //! Note: a *runtime* loop (e.g. `for _ in range(comptime(n))`, as the surface //! code uses for rounds) is NOT unrolled in the HUGR -- it has one static //! measure/result op executed n times. Static extraction therefore yields -//! `tag -> static-measure-op`; expanding that to per-iteration runtime MeasIds +//! `tag -> static-measure-op`; expanding that to per-iteration runtime `MeasIds` //! requires a separate static-op -> runtime-measurement correspondence. use std::collections::{BTreeMap, HashMap, HashSet}; @@ -23,7 +23,7 @@ use tket::hugr::ops::OpType; use tket::hugr::types::Term; use tket::hugr::{HugrView, IncomingPort, Node}; -fn extension_ids<'a>(op: &'a OpType) -> Option<(&'a str, String)> { +fn extension_ids(op: &OpType) -> Option<(&str, String)> { let ext = op.as_extension_op()?; Some(( ext.extension_id().as_ref(), From d139cf35d0904530409352fa3b03d96b0a7abe89 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 18 May 2026 23:23:37 -0600 Subject: [PATCH 09/36] Wire sound HUGR-backed result_tags into from_guppy (Rust-centric, loop-guarded fail-loud) --- crates/pecos-hugr-qis/src/lib.rs | 2 +- crates/pecos-hugr-qis/src/result_tags.rs | 13 +++ .../src/fault_tolerance/dem_builder.rs | 2 +- .../fault_tolerance/dem_builder/builder.rs | 101 ++++++++++++++++++ ...001-from-guppy-tag-referenced-detectors.md | 27 +++++ .../pecos-rslib/src/dag_circuit_bindings.rs | 49 +++++++++ python/quantum-pecos/src/pecos/qec/dem.py | 65 +++++++++-- 7 files changed, 246 insertions(+), 13 deletions(-) diff --git a/crates/pecos-hugr-qis/src/lib.rs b/crates/pecos-hugr-qis/src/lib.rs index dc0664e82..c051153b4 100644 --- a/crates/pecos-hugr-qis/src/lib.rs +++ b/crates/pecos-hugr-qis/src/lib.rs @@ -75,7 +75,7 @@ pub use compiler::{ // Re-export read_hugr_envelope from utils pub use utils::read_hugr_envelope; -pub use result_tags::extract_result_tag_measurements; +pub use result_tags::{extract_result_tag_measurements, measurement_op_count}; // Re-export inkwell's OptimizationLevel for convenience pub use tket::hugr::llvm::inkwell::OptimizationLevel; diff --git a/crates/pecos-hugr-qis/src/result_tags.rs b/crates/pecos-hugr-qis/src/result_tags.rs index 1e15a565a..6dcb259f4 100644 --- a/crates/pecos-hugr-qis/src/result_tags.rs +++ b/crates/pecos-hugr-qis/src/result_tags.rs @@ -39,6 +39,19 @@ fn is_measurement(op: &OpType) -> bool { ) } +/// Number of *static* measurement ops in the HUGR. +/// +/// For a straight-line program this equals the runtime measurement count; for +/// a program with a runtime loop it is strictly smaller (the loop body's +/// measure op is counted once). Callers use the mismatch to detect that +/// per-occurrence tag binding is not statically available. +#[must_use] +pub fn measurement_op_count>(hugr: &H) -> usize { + hugr.nodes() + .filter(|&n| is_measurement(hugr.get_optype(n))) + .count() +} + /// Map each `result(tag, ...)` to the measurement ordinals whose values it /// recorded, in measurement-ordinal order. /// diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs index 59d79fd0a..a6af42108 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs @@ -83,7 +83,7 @@ mod mem_builder; pub(crate) mod sampler; mod types; -pub use builder::{DemBuilder, DemBuilderError}; +pub use builder::{DemBuilder, DemBuilderError, resolve_result_tags}; pub use dem_sampler::{SamplingEngine, SamplingStatistics}; pub use equivalence::{ ComparisonDetails, ComparisonMethod, DemParseError, EffectKey, EquivalenceResult, diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index ae792f1b1..e6f0806b5 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -1385,6 +1385,107 @@ fn observable_records_from_annotations( .collect() } +// ============================================================================ +// Tag-referenced detector resolution +// ============================================================================ + +/// Resolve `result_tags` on detector/observable JSON into record offsets. +/// +/// `tag_to_ords` is the **sound** Guppy `result(tag, ...)` -> measurement +/// ordinal binding recovered structurally from the compiled HUGR +/// (reorder-immune; see `pecos_hugr_qis::result_tags`). Each referenced tag's +/// ordinals are converted to record offsets (`ordinal - traced_meas_count`) +/// and merged into the entry's `records`; `result_tags` is removed so the +/// downstream parser is unchanged. +/// +/// Fail-loud (returns `Err`), never silently misbinds: +/// - **Loop guard**: if `static_meas_count != traced_meas_count` the program +/// has un-unrolled runtime loops, so per-occurrence tag binding is not +/// statically available. +/// - An unknown tag, or malformed `result_tags`, is an error. +/// +/// # Errors +/// Returns `DemBuilderError::ParseError` on the loop guard, an unknown tag, +/// malformed `result_tags`, or invalid JSON. +pub fn resolve_result_tags( + detectors_json: &str, + observables_json: &str, + tag_to_ords: &std::collections::BTreeMap>, + static_meas_count: usize, + traced_meas_count: usize, +) -> Result<(String, String), DemBuilderError> { + if static_meas_count != traced_meas_count { + return Err(DemBuilderError::ParseError(format!( + "result_tags (tag-referenced detectors) is not supported for Guppy \ + programs with runtime loops: the HUGR has {static_meas_count} \ + static measurement op(s) but the traced program emits \ + {traced_meas_count} measurement(s). Per-occurrence tag binding is \ + not statically available; use positional records (see \ + docs/proposals/001-from-guppy-tag-referenced-detectors.md)." + ))); + } + let traced = i64::try_from(traced_meas_count).map_err(|_| { + DemBuilderError::ParseError("traced measurement count too large".to_string()) + })?; + + let rewrite = |json: &str, kind: &str| -> Result { + if json.trim().is_empty() { + return Ok(json.to_string()); + } + let mut value: serde_json::Value = serde_json::from_str(json).map_err(|e| { + DemBuilderError::ParseError(format!("invalid detector/observable JSON: {e}")) + })?; + let Some(entries) = value.as_array_mut() else { + return Ok(json.to_string()); + }; + for entry in entries.iter_mut() { + let Some(obj) = entry.as_object_mut() else { + continue; + }; + let Some(tags) = obj.remove("result_tags") else { + continue; + }; + let mut records: Vec = obj + .get("records") + .and_then(|r| r.as_array()) + .map(|a| a.iter().filter_map(serde_json::Value::as_i64).collect()) + .unwrap_or_default(); + let tag_list = tags.as_array().ok_or_else(|| { + DemBuilderError::ParseError( + "result_tags must be a JSON array of strings".to_string(), + ) + })?; + for tag in tag_list { + let tag = tag.as_str().ok_or_else(|| { + DemBuilderError::ParseError("result_tags entries must be strings".to_string()) + })?; + let ords = tag_to_ords.get(tag).ok_or_else(|| { + DemBuilderError::ParseError(format!( + "{kind} references result_tag {tag:?}, which the Guppy \ + program never records via result(...)" + )) + })?; + for &ord in ords { + records.push(i64::try_from(ord).unwrap_or(i64::MAX) - traced); + } + } + obj.insert( + "records".to_string(), + serde_json::Value::Array( + records.into_iter().map(serde_json::Value::from).collect(), + ), + ); + } + serde_json::to_string(&value) + .map_err(|e| DemBuilderError::ParseError(format!("failed to re-serialize JSON: {e}"))) + }; + + Ok(( + rewrite(detectors_json, "Detector")?, + rewrite(observables_json, "Observable")?, + )) +} + // ============================================================================ // Error Type // ============================================================================ diff --git a/docs/proposals/001-from-guppy-tag-referenced-detectors.md b/docs/proposals/001-from-guppy-tag-referenced-detectors.md index ec2d8bc88..bb94e63b6 100644 --- a/docs/proposals/001-from-guppy-tag-referenced-detectors.md +++ b/docs/proposals/001-from-guppy-tag-referenced-detectors.md @@ -305,3 +305,30 @@ recompilation. Reorder-robust tag-referenced detectors are **deferred**; the sound HUGR building block (#2) is committed for the eventual straight-line wiring, and the loop case needs CFG-interpreter-class machinery or upstream `tket-qsystem` provenance. + +## Update (gap-4): sound result_tags wired into from_guppy (Rust-centric) + +The committed HUGR extractor is now wired into `from_guppy` for the +**straight-line** case, with all logic in Rust and a thin Python pass-through +(per architectural review): + +- `pecos_qec::fault_tolerance::dem_builder::resolve_result_tags` (Rust): runtime- + loop guard (static vs traced measurement count), `result_tags`->record + resolution, unknown-tag validation -- all fail-loud (`Result`/`ValueError`). +- `pecos_rslib.resolve_result_tags_for_guppy` (thin pyo3): HUGR-bytes + + detectors/observables JSON + traced count -> resolved JSON, or raises. + Internally calls `pecos_hugr_qis::extract_result_tag_measurements` + + `measurement_op_count`. +- `from_guppy` (thin Python): if `result_tags` present, ferry + `guppy_to_hugr(guppy)` + the traced measurement count to the Rust call. No + tag logic in Python. + +Verified: straight-line `result_tags` DEM is byte-identical to the positional +equivalent (proves the Rust chain and that the HUGR measurement ordinal equals +the traced MeasId order for the supported case); unknown tags fail loud; +runtime-loop programs (incl. surface) **fail loud** rather than silently +misbind; surface positional path byte-identical + LER unaffected. + +Remaining deferred: per-occurrence tag binding for runtime-loop programs +(needs CFG-interpreter-class machinery or upstream `tket-qsystem` +provenance). `from_guppy` now hard-errors that case instead of being silent. diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index 31bd12985..8e6562c39 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -1800,6 +1800,51 @@ fn py_hugr_to_dag_circuit(hugr_bytes: &Bound<'_, PyBytes>) -> PyResult measurement binding recovered +/// from the compiled HUGR. +/// +/// All logic (HUGR extraction, the runtime-loop guard, tag->record resolution, +/// unknown-tag validation) is performed in Rust; this is a thin entry point. +/// Returns the rewritten `(detectors_json, observables_json)` with +/// `result_tags` replaced by record offsets. +/// +/// Args: +/// `detectors_json` / `observables_json`: detector/observable JSON. +/// `hugr_bytes`: HUGR envelope bytes (e.g. `guppy_to_hugr(program)`). +/// `traced_meas_count`: number of measurements in the traced circuit. +/// +/// Raises: +/// `ValueError`: on the runtime-loop guard, an unknown tag, malformed +/// `result_tags`, or invalid JSON. +#[pyfunction] +#[pyo3(name = "resolve_result_tags_for_guppy")] +fn py_resolve_result_tags_for_guppy( + detectors_json: &str, + observables_json: &str, + hugr_bytes: &Bound<'_, PyBytes>, + traced_meas_count: usize, +) -> PyResult<(String, String)> { + use pecos_hugr_qis::{ + extract_result_tag_measurements, measurement_op_count, read_hugr_envelope, + }; + use pecos_qec::fault_tolerance::dem_builder::resolve_result_tags; + + let hugr = read_hugr_envelope(hugr_bytes.as_bytes()) + .map_err(|e| PyErr::new::(format!("Failed to parse HUGR: {e}")))?; + let tag_to_ords = extract_result_tag_measurements(&hugr); + let static_meas_count = measurement_op_count(&hugr); + + resolve_result_tags( + detectors_json, + observables_json, + &tag_to_ords, + static_meas_count, + traced_meas_count, + ) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) +} + /// Map a HUGR operation name to a `GateType`. /// /// Args: @@ -3770,6 +3815,10 @@ pub fn register_quantum_circuit_types(parent_module: &Bound<'_, PyModule>) -> Py // Add HUGR conversion functions parent_module.add_function(wrap_pyfunction!(py_hugr_to_dag_circuit, parent_module)?)?; + parent_module.add_function(wrap_pyfunction!( + py_resolve_result_tags_for_guppy, + parent_module + )?)?; parent_module.add_function(wrap_pyfunction!(py_hugr_op_to_gate_type, parent_module)?)?; parent_module.add_function(wrap_pyfunction!(py_gate_type_to_hugr_op, parent_module)?)?; parent_module.add_function(wrap_pyfunction!(py_is_quantum_operation, parent_module)?)?; diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index a26d895bf..35a95f0a8 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -150,6 +150,17 @@ def _normalize_entry_ids(blob: str, prefix: str) -> str: return json.dumps(entries, separators=(",", ":")) if changed else blob +def _result_tags_present(detectors_json: str, observables_json: str) -> bool: + """Cheap gate: does any entry use ``result_tags``? (substring check). + + Only decides whether to compile the Guppy program to HUGR; the actual + extraction, loop-guard, resolution and validation are all done in Rust. + """ + return '"result_tags"' in (detectors_json or "") or '"result_tags"' in ( + observables_json or "" + ) + + class DetectorErrorModel(_RustDetectorErrorModel): """Detector error model with a Guppy/QIS-trace convenience constructor. @@ -197,6 +208,17 @@ def from_guppy( negative measurement offsets (Stim convention); ``meas_ids`` may be used instead. Defined against the *traced* program's own measurement order. + + Reorder-robust alternative: an entry may instead carry + ``"result_tags": ["m_a", ...]`` to reference measurements by + the stable Guppy ``result(tag, ...)`` tag. The tag->measurement + binding is recovered structurally from the compiled HUGR (in + Rust), so it is immune to Guppy/Selene measurement reordering. + Supported for straight-line programs; for programs with runtime + loops it **fails loudly** (the loop is not unrolled in the + HUGR, so per-occurrence binding is not statically available -- + use positional records there). ``result_tags`` and ``records`` + may be combined on one entry. observables_json: Observable / tracked-Pauli definitions as a JSON list. Plain observables look like ``[{"id": 0, "records": [-1]}]``. Tracked Paulis are entries in this same list carrying @@ -226,23 +248,25 @@ def from_guppy( Raises: ValueError: If ``num_measurements`` disagrees with the traced - measurement count, if a detector/observable references an - out-of-range ``record`` or an absent ``meas_id``, or if the - traced operation stream is malformed (the strict - ``AllocateResult``/``Measure`` pairing in the replay fails). + measurement count; if a detector/observable references an + out-of-range ``record``, an absent ``meas_id``, or a + ``result_tag`` the Guppy program never records; if + ``result_tags`` are used with a program containing runtime + loops (not statically resolvable); or if the traced operation + stream is malformed (the strict ``AllocateResult``/``Measure`` + pairing in the replay fails). Note: Every measurement is anchored to a stable MeasId automatically: ``measure()`` itself allocates the result slot in the trace (a ``result(...)`` call is not required for MeasId assignment). - Detector/observable ``records``/``meas_ids`` reference measurements - by *traced (post-compilation)* order and are therefore sensitive to - any measurement reordering introduced by Guppy/Selene compilation. - Stable, source-anchored tag-referenced detectors are not yet - available (the runtime linkage was proven unsound; the sound - HUGR-based binding works for straight-line programs but loops are - not unrolled in the HUGR) -- see + Positional ``records``/``meas_ids`` reference measurements by + *traced (post-compilation)* order and are sensitive to any + measurement reordering introduced by Guppy/Selene compilation. + ``result_tags`` (above) avoid this for straight-line programs via + the sound HUGR-derived binding; the runtime-loop case remains + deferred -- see ``docs/proposals/001-from-guppy-tag-referenced-detectors.md``. """ from pecos.qec.surface.decode import trace_guppy_into_tick_circuit @@ -261,6 +285,25 @@ def from_guppy( tc.lower_clifford_rotations() tc.assign_missing_meas_ids() + # Tag-referenced detectors: ferry the compiled HUGR + traced + # measurement count to Rust, which does the sound HUGR extraction, + # runtime-loop guard, and result_tags->records resolution (fail-loud). + # This Python side is a thin pass-through; no tag logic lives here. + if _result_tags_present(detectors_json, observables_json): + from pecos_rslib import resolve_result_tags_for_guppy + + from pecos._compilation.guppy import guppy_to_hugr + + measured, _ = _collect_measurement_info(tc) + detectors_json, observables_json = resolve_result_tags_for_guppy( + detectors_json, + observables_json, + guppy_to_hugr(guppy), + measured, + ) + if num_measurements is None: + num_measurements = measured + _validate_measurement_contract( tc, detectors_json=detectors_json, From f1fa2d6cc80efd5cfd5979ca0b8985afb7458083 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 19 May 2026 08:09:05 -0600 Subject: [PATCH 10/36] Address external review: harden HUGR extractor, fix replay (#5/#6), revert gap-4 wiring, fail-loud schema, correct docs --- crates/pecos-hugr-qis/src/result_tags.rs | 119 ++++++++++++------ crates/pecos-hugr-qis/tests/fixtures/arr.hugr | Bin 0 -> 12468 bytes .../tests/fixtures/computed.hugr | Bin 0 -> 5947 bytes .../src/fault_tolerance/dem_builder.rs | 2 +- .../fault_tolerance/dem_builder/builder.rs | 101 --------------- ...001-from-guppy-tag-referenced-detectors.md | 44 +++++++ .../pecos-rslib/src/dag_circuit_bindings.rs | 49 -------- python/quantum-pecos/src/pecos/qec/dem.py | 118 ++++++++--------- .../src/pecos/qec/surface/decode.py | 61 +++------ 9 files changed, 191 insertions(+), 303 deletions(-) create mode 100644 crates/pecos-hugr-qis/tests/fixtures/arr.hugr create mode 100644 crates/pecos-hugr-qis/tests/fixtures/computed.hugr diff --git a/crates/pecos-hugr-qis/src/result_tags.rs b/crates/pecos-hugr-qis/src/result_tags.rs index 6dcb259f4..7543f7219 100644 --- a/crates/pecos-hugr-qis/src/result_tags.rs +++ b/crates/pecos-hugr-qis/src/result_tags.rs @@ -17,7 +17,7 @@ //! `tag -> static-measure-op`; expanding that to per-iteration runtime `MeasIds` //! requires a separate static-op -> runtime-measurement correspondence. -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap}; use tket::hugr::ops::OpType; use tket::hugr::types::Term; @@ -52,12 +52,25 @@ pub fn measurement_op_count>(hugr: &H) -> usize { .count() } -/// Map each `result(tag, ...)` to the measurement ordinals whose values it -/// recorded, in measurement-ordinal order. +/// Map each `result(tag, )` to the measurement ordinal it records. /// -/// A repeated tag (e.g. `result("synx", ...)` in a loop) accumulates each -/// occurrence's measurement ordinals in the order the `result` ops are -/// traversed; callers can disambiguate occurrences as needed. +/// **Sound by construction, narrow by design.** Only the canonical pattern +/// `result(tag, )` is recognized: a +/// `tket.result:result_bool` op whose value input is *exactly* +/// `tket.bool:read` of a measurement op. The compiled chain is verified to be +/// precisely `result_bool <- tket.bool:read <- Measure/MeasureFree`. +/// +/// Any other shape is **deliberately excluded** (the tag is omitted from the +/// returned map) rather than guessed at -- e.g. computed values +/// (`result("x", m0 == m1)` lowers through `tket.bool:eq`), constants +/// (`result("x", True)` lowers through a `Const`), and array-valued +/// `result(...)` (`result_array_bool` lowers through `collections.borrow_arr` +/// machinery that does not cleanly expose per-element measurement provenance). +/// Resolving those structurally would silently misbind (equality is not +/// parity; an empty record set is not a detector), so they are not returned. +/// +/// A tag repeated across the program accumulates its ordinals in traversal +/// order; callers handle occurrence disambiguation / loop guarding. #[must_use] pub fn extract_result_tag_measurements>( hugr: &H, @@ -71,16 +84,21 @@ pub fn extract_result_tag_measurements>( } } - // Pass 2: per tket.result op, reverse-DFS over value wires to the - // measurement ancestors feeding it. + // single_linked_output source op, if any. + let src_op = |node: Node, port: usize| -> Option { + hugr.single_linked_output(node, IncomingPort::from(port)) + .map(|(s, _)| s) + }; + + // Pass 2: accept only result_bool <- tket.bool:read <- measurement. let mut out: BTreeMap> = BTreeMap::new(); for node in hugr.nodes() { let op = hugr.get_optype(node); let Some((ext, name)) = extension_ids(op) else { continue; }; - if ext != "tket.result" || !name.starts_with("result") { - continue; + if ext != "tket.result" || name != "result_bool" { + continue; // arrays / non-bool result ops: not soundly resolvable } let Some(ext_op) = op.as_extension_op() else { continue; @@ -92,36 +110,22 @@ pub fn extract_result_tag_measurements>( continue; }; - // Seed ONLY from input port 0 -- the recorded value. Port 1 of a - // result op is the linear state/order token threading result ops to - // each other and to measurement side-effects; following it conflates - // every measurement. From the value port, a reverse walk reaches the - // measurement(s) via classical value ops (tket.bool:read, array - // constructors, ...); measurements are leaves (we never descend into - // their qubit inputs), so qubit wires are never traversed. - let mut found: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - let mut stack: Vec = Vec::new(); - if let Some((src, _)) = hugr.single_linked_output(node, IncomingPort::from(0)) { - stack.push(src); - } - while let Some(n) = stack.pop() { - if !seen.insert(n) { - continue; - } - if let Some(&ord) = meas_ordinal.get(&n) { - found.push(ord); - continue; // a measurement is a leaf for value provenance - } - for p in 0..hugr.num_inputs(n) { - if let Some((src, _)) = hugr.single_linked_output(n, IncomingPort::from(p)) { - stack.push(src); - } - } + // result_bool value input (port 0) must be exactly `tket.bool:read`. + let Some(read) = src_op(node, 0) else { + continue; + }; + match extension_ids(hugr.get_optype(read)) { + Some((e, ref n)) if e == "tket.bool" && n == "read" => {} + _ => continue, // e.g. tket.bool:eq (computed) -> exclude } - found.sort_unstable(); - found.dedup(); - out.entry(tag).or_default().extend(found); + // ... whose input (port 0) must be a measurement op. + let Some(meas) = src_op(read, 0) else { + continue; + }; + let Some(&ord) = meas_ordinal.get(&meas) else { + continue; // e.g. a Const -> exclude + }; + out.entry(tag).or_default().push(ord); } out } @@ -131,10 +135,16 @@ mod tests { use super::*; use crate::read_hugr_envelope; - // Fixtures generated from Guppy via /tmp/gen_hugr_fixtures.py (committed so - // the regression does not depend on a Python toolchain at test time). + // Fixtures compiled from Guppy (committed so the regression does not + // depend on a Python toolchain at test time): + // scrambled: result() declared c,a,b over measures a,b,c (raw scalars) + // looped: for _ in range(comptime(3)): result("synx", measure(q)) + // computed: result("eq", m0==m1) ; result("const", True) + // arr: result("pair", measure_array(qs)) (array-valued) const SCRAMBLED: &[u8] = include_bytes!("../tests/fixtures/scrambled.hugr"); const LOOPED: &[u8] = include_bytes!("../tests/fixtures/looped.hugr"); + const COMPUTED: &[u8] = include_bytes!("../tests/fixtures/computed.hugr"); + const ARR: &[u8] = include_bytes!("../tests/fixtures/arr.hugr"); /// Foundation: `result()` declared in scrambled order (c, a, b) over /// measurements made in order (a, b, c) must still bind each tag to ITS @@ -170,4 +180,31 @@ mod tests { "runtime loop is not unrolled in HUGR: one static measure op", ); } + + /// Soundness: a computed `result("eq", m0 == m1)` (lowers through + /// `tket.bool:eq`) and a constant `result("const", True)` (lowers through + /// a `Const`) must NOT be returned -- resolving them would silently + /// misbind (equality is not parity; no measurement at all). + #[test] + fn computed_and_constant_tags_are_excluded() { + let hugr = read_hugr_envelope(COMPUTED).unwrap(); + let map = extract_result_tag_measurements(&hugr); + assert!( + !map.contains_key("eq") && !map.contains_key("const"), + "computed/constant tags must be excluded, got {map:?}", + ); + } + + /// Soundness: an array-valued `result("pair", measure_array(qs))` lowers + /// through `collections.borrow_arr` machinery with no clean per-element + /// measurement provenance, so it must NOT be returned. + #[test] + fn array_valued_tag_is_excluded() { + let hugr = read_hugr_envelope(ARR).unwrap(); + let map = extract_result_tag_measurements(&hugr); + assert!( + !map.contains_key("pair"), + "array-valued result tag must be excluded, got {map:?}", + ); + } } diff --git a/crates/pecos-hugr-qis/tests/fixtures/arr.hugr b/crates/pecos-hugr-qis/tests/fixtures/arr.hugr new file mode 100644 index 0000000000000000000000000000000000000000..5f9252f7f55e1df921789cd9841253a5a69e0369 GIT binary patch literal 12468 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 literal 0 HcmV?d00001 diff --git a/crates/pecos-hugr-qis/tests/fixtures/computed.hugr b/crates/pecos-hugr-qis/tests/fixtures/computed.hugr new file mode 100644 index 0000000000000000000000000000000000000000..2f62840b3b9e3f0da3c994a24bd3780c918dd429 GIT binary patch literal 5947 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 measurement -/// ordinal binding recovered structurally from the compiled HUGR -/// (reorder-immune; see `pecos_hugr_qis::result_tags`). Each referenced tag's -/// ordinals are converted to record offsets (`ordinal - traced_meas_count`) -/// and merged into the entry's `records`; `result_tags` is removed so the -/// downstream parser is unchanged. -/// -/// Fail-loud (returns `Err`), never silently misbinds: -/// - **Loop guard**: if `static_meas_count != traced_meas_count` the program -/// has un-unrolled runtime loops, so per-occurrence tag binding is not -/// statically available. -/// - An unknown tag, or malformed `result_tags`, is an error. -/// -/// # Errors -/// Returns `DemBuilderError::ParseError` on the loop guard, an unknown tag, -/// malformed `result_tags`, or invalid JSON. -pub fn resolve_result_tags( - detectors_json: &str, - observables_json: &str, - tag_to_ords: &std::collections::BTreeMap>, - static_meas_count: usize, - traced_meas_count: usize, -) -> Result<(String, String), DemBuilderError> { - if static_meas_count != traced_meas_count { - return Err(DemBuilderError::ParseError(format!( - "result_tags (tag-referenced detectors) is not supported for Guppy \ - programs with runtime loops: the HUGR has {static_meas_count} \ - static measurement op(s) but the traced program emits \ - {traced_meas_count} measurement(s). Per-occurrence tag binding is \ - not statically available; use positional records (see \ - docs/proposals/001-from-guppy-tag-referenced-detectors.md)." - ))); - } - let traced = i64::try_from(traced_meas_count).map_err(|_| { - DemBuilderError::ParseError("traced measurement count too large".to_string()) - })?; - - let rewrite = |json: &str, kind: &str| -> Result { - if json.trim().is_empty() { - return Ok(json.to_string()); - } - let mut value: serde_json::Value = serde_json::from_str(json).map_err(|e| { - DemBuilderError::ParseError(format!("invalid detector/observable JSON: {e}")) - })?; - let Some(entries) = value.as_array_mut() else { - return Ok(json.to_string()); - }; - for entry in entries.iter_mut() { - let Some(obj) = entry.as_object_mut() else { - continue; - }; - let Some(tags) = obj.remove("result_tags") else { - continue; - }; - let mut records: Vec = obj - .get("records") - .and_then(|r| r.as_array()) - .map(|a| a.iter().filter_map(serde_json::Value::as_i64).collect()) - .unwrap_or_default(); - let tag_list = tags.as_array().ok_or_else(|| { - DemBuilderError::ParseError( - "result_tags must be a JSON array of strings".to_string(), - ) - })?; - for tag in tag_list { - let tag = tag.as_str().ok_or_else(|| { - DemBuilderError::ParseError("result_tags entries must be strings".to_string()) - })?; - let ords = tag_to_ords.get(tag).ok_or_else(|| { - DemBuilderError::ParseError(format!( - "{kind} references result_tag {tag:?}, which the Guppy \ - program never records via result(...)" - )) - })?; - for &ord in ords { - records.push(i64::try_from(ord).unwrap_or(i64::MAX) - traced); - } - } - obj.insert( - "records".to_string(), - serde_json::Value::Array( - records.into_iter().map(serde_json::Value::from).collect(), - ), - ); - } - serde_json::to_string(&value) - .map_err(|e| DemBuilderError::ParseError(format!("failed to re-serialize JSON: {e}"))) - }; - - Ok(( - rewrite(detectors_json, "Detector")?, - rewrite(observables_json, "Observable")?, - )) -} - // ============================================================================ // Error Type // ============================================================================ diff --git a/docs/proposals/001-from-guppy-tag-referenced-detectors.md b/docs/proposals/001-from-guppy-tag-referenced-detectors.md index bb94e63b6..8d9ea7cb2 100644 --- a/docs/proposals/001-from-guppy-tag-referenced-detectors.md +++ b/docs/proposals/001-from-guppy-tag-referenced-detectors.md @@ -332,3 +332,47 @@ misbind; surface positional path byte-identical + LER unaffected. Remaining deferred: per-occurrence tag binding for runtime-loop programs (needs CFG-interpreter-class machinery or upstream `tket-qsystem` provenance). `from_guppy` now hard-errors that case instead of being silent. + +## External review response (AUTHORITATIVE — supersedes "Update (gap-4)") + +An external review found real defects. Resolution: + +- **#1 (critical, fixed):** the HUGR extractor over-collected — `result("x", + m0==m1)` (lowers through `tket.bool:eq`) gave `records:[-2,-1]` and + `result("x", True)` gave `records:[]`, both silently wrong. + `pecos_hugr_qis::extract_result_tag_measurements` is now **sound by + construction**: it accepts ONLY `result_bool <- tket.bool:read <- + Measure/MeasureFree` (canonical scalar raw measurement). Computed values, + constants, and array-valued `result()` (`collections.borrow_arr` machinery) + are deliberately excluded, with regression tests. +- **#2 (fixed):** `from_guppy` now validates detector/observable schema + (integer id + records/meas_ids) and **fails loud**, instead of letting the + DEM builder swallow the parse error and return an empty DEM. +- **#3 (doc fixed):** corrected the docstring — hand-authored JSON tracked + Paulis are **not** supported by the `observables_json` path (the JSON + observable parser ignores `kind`/`label`/`pauli`; tracked Paulis come only + from circuit annotations). +- **#4 (moot):** the gap-4 user-facing `result_tags` wiring is **reverted** + (dem.py thin block, `pecos_rslib.resolve_result_tags_for_guppy`, + `pecos_qec::resolve_result_tags`). With no `guppy_to_hugr` call in + `from_guppy`, the wrapper-input regression no longer exists. +- **#5 (fixed, broader than gap-4):** the lowered-replay no longer assumes a + strict AllocateResult/Measure 1:1 interleave. `Quantum.Measure` carries + `[qubit, result_id]`; the replay now reads that `result_id` directly (== the + MeasId), so batched allocate-allocate-measure-measure is handled and the + overstated invariant is gone. +- **#6 (fixed):** the non-lowered replay now stamps the real `result_id` via + `mz_with_ids` instead of discarding it and relying on + `assign_missing_meas_ids()` to invent sequential ids. +- **#7 / overstatements (corrected):** "proven sound for straight-line" and + "tag DEM == positional-equivalent" claims are withdrawn. The only retained, + tested guarantee is the narrow `extract_result_tag_measurements` contract + above; it is a building block, **not wired into `from_guppy`**. Whether HUGR + traversal order equals trace MeasId order in general remains unproven and is + no longer relied upon by any shipped path. + +Net shipped: sound positional `from_guppy` (records/meas_ids, schema-validated +fail-loud, `D0`/`L0`), the corrected strict/non-lowered replay (#5/#6), and a +sound-but-narrow standalone HUGR extractor with tests. Tag-referenced +detectors are NOT exposed to users; the loop case remains deferred (CFG- +interpreter-class machinery or upstream `tket-qsystem` provenance). diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index 8e6562c39..31bd12985 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -1800,51 +1800,6 @@ fn py_hugr_to_dag_circuit(hugr_bytes: &Bound<'_, PyBytes>) -> PyResult measurement binding recovered -/// from the compiled HUGR. -/// -/// All logic (HUGR extraction, the runtime-loop guard, tag->record resolution, -/// unknown-tag validation) is performed in Rust; this is a thin entry point. -/// Returns the rewritten `(detectors_json, observables_json)` with -/// `result_tags` replaced by record offsets. -/// -/// Args: -/// `detectors_json` / `observables_json`: detector/observable JSON. -/// `hugr_bytes`: HUGR envelope bytes (e.g. `guppy_to_hugr(program)`). -/// `traced_meas_count`: number of measurements in the traced circuit. -/// -/// Raises: -/// `ValueError`: on the runtime-loop guard, an unknown tag, malformed -/// `result_tags`, or invalid JSON. -#[pyfunction] -#[pyo3(name = "resolve_result_tags_for_guppy")] -fn py_resolve_result_tags_for_guppy( - detectors_json: &str, - observables_json: &str, - hugr_bytes: &Bound<'_, PyBytes>, - traced_meas_count: usize, -) -> PyResult<(String, String)> { - use pecos_hugr_qis::{ - extract_result_tag_measurements, measurement_op_count, read_hugr_envelope, - }; - use pecos_qec::fault_tolerance::dem_builder::resolve_result_tags; - - let hugr = read_hugr_envelope(hugr_bytes.as_bytes()) - .map_err(|e| PyErr::new::(format!("Failed to parse HUGR: {e}")))?; - let tag_to_ords = extract_result_tag_measurements(&hugr); - let static_meas_count = measurement_op_count(&hugr); - - resolve_result_tags( - detectors_json, - observables_json, - &tag_to_ords, - static_meas_count, - traced_meas_count, - ) - .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) -} - /// Map a HUGR operation name to a `GateType`. /// /// Args: @@ -3815,10 +3770,6 @@ pub fn register_quantum_circuit_types(parent_module: &Bound<'_, PyModule>) -> Py // Add HUGR conversion functions parent_module.add_function(wrap_pyfunction!(py_hugr_to_dag_circuit, parent_module)?)?; - parent_module.add_function(wrap_pyfunction!( - py_resolve_result_tags_for_guppy, - parent_module - )?)?; parent_module.add_function(wrap_pyfunction!(py_hugr_op_to_gate_type, parent_module)?)?; parent_module.add_function(wrap_pyfunction!(py_gate_type_to_hugr_op, parent_module)?)?; parent_module.add_function(wrap_pyfunction!(py_is_quantum_operation, parent_module)?)?; diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 35a95f0a8..edc43a440 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -78,10 +78,34 @@ def _validate_measurement_contract( effective = num_measurements if num_measurements is not None else measured def _check(kind: str, entries: list[dict[str, Any]]) -> None: + alt_id = "detector_id" if kind == "Detector" else "observable_id" + # NB: malformed input raises ValueError (not TypeError) to keep one + # consistent failure type across from_guppy's documented contract and + # the sibling record/meas_id checks below -- hence the TRY004 noqas. for entry in entries: + if not isinstance(entry, dict): + msg = f"{kind} entry is not a JSON object: {entry!r}" + raise ValueError(msg) # noqa: TRY004 # Tracked Paulis reference qubits via "pauli", not measurements. if entry.get("kind") == "tracked_pauli": continue + # Schema: the Rust DEM-builder JSON parser requires an integer id + # and records; on a parse failure it silently builds an empty DEM. + # Validate here so malformed input fails loud instead. + raw_id = entry.get("id", entry.get(alt_id)) + if not isinstance(raw_id, int) or isinstance(raw_id, bool): + msg = ( + f"{kind} entry {entry!r} is missing a valid integer " + f"'id' (or '{alt_id}'); the DEM builder would silently " + "drop it and produce an empty DEM." + ) + raise ValueError(msg) # noqa: TRY004 + if not (entry.get("records") or entry.get("meas_ids")): + msg = ( + f"{kind} {raw_id} has no 'records' or 'meas_ids'; it " + "would contribute nothing and silently weaken the DEM." + ) + raise ValueError(msg) for rec in entry.get("records", []) or []: idx = effective + int(rec) if not 0 <= idx < effective: @@ -150,17 +174,6 @@ def _normalize_entry_ids(blob: str, prefix: str) -> str: return json.dumps(entries, separators=(",", ":")) if changed else blob -def _result_tags_present(detectors_json: str, observables_json: str) -> bool: - """Cheap gate: does any entry use ``result_tags``? (substring check). - - Only decides whether to compile the Guppy program to HUGR; the actual - extraction, loop-guard, resolution and validation are all done in Rust. - """ - return '"result_tags"' in (detectors_json or "") or '"result_tags"' in ( - observables_json or "" - ) - - class DetectorErrorModel(_RustDetectorErrorModel): """Detector error model with a Guppy/QIS-trace convenience constructor. @@ -208,32 +221,18 @@ def from_guppy( negative measurement offsets (Stim convention); ``meas_ids`` may be used instead. Defined against the *traced* program's own measurement order. - - Reorder-robust alternative: an entry may instead carry - ``"result_tags": ["m_a", ...]`` to reference measurements by - the stable Guppy ``result(tag, ...)`` tag. The tag->measurement - binding is recovered structurally from the compiled HUGR (in - Rust), so it is immune to Guppy/Selene measurement reordering. - Supported for straight-line programs; for programs with runtime - loops it **fails loudly** (the loop is not unrolled in the - HUGR, so per-occurrence binding is not statically available -- - use positional records there). ``result_tags`` and ``records`` - may be combined on one entry. - observables_json: Observable / tracked-Pauli definitions as a JSON - list. Plain observables look like ``[{"id": 0, "records": - [-1]}]``. Tracked Paulis are entries in this same list carrying - ``"kind": "tracked_pauli"`` (plus ``"label"`` and ``"pauli"``, - e.g. ``"+X0 Z2"``); the DEM builder splits them out - automatically. There is no separate tracked-Pauli argument -- - this matches the underlying circuit-metadata contract exactly. - - Limitation: a tracked Pauli references **qubits** (via its - ``pauli`` string), not measurements. Its qubit indices are - interpreted in the *traced (post-compilation)* qubit numbering - and are not source-stable -- for a hand-authored general Guppy - program the caller must supply them in the traced numbering; - geometry-derived paths (e.g. the surface builder) avoid this by - construction. + observables_json: Observable definitions as a JSON list, e.g. + ``[{"id": 0, "records": [-1]}]`` (same id/records rules as + detectors). + + Tracked Paulis: **hand-authored JSON tracked Paulis are NOT + supported** by this path. The DEM builder's JSON observable + parser reads only ``id``/``records``; it ignores ``kind`` / + ``label`` / ``pauli``. Tracked Paulis are only produced from + circuit *annotations* (e.g. the surface builder), not from + ``observables_json``. A ``{"kind": "tracked_pauli", ...}`` + entry here is silently treated as a (malformed) observable -- + do not use it. num_measurements: Total measurement count, used to resolve negative ``records`` offsets. If omitted, it is inferred from the traced circuit. @@ -248,25 +247,25 @@ def from_guppy( Raises: ValueError: If ``num_measurements`` disagrees with the traced - measurement count; if a detector/observable references an - out-of-range ``record``, an absent ``meas_id``, or a - ``result_tag`` the Guppy program never records; if - ``result_tags`` are used with a program containing runtime - loops (not statically resolvable); or if the traced operation - stream is malformed (the strict ``AllocateResult``/``Measure`` - pairing in the replay fails). + measurement count, if a detector/observable is malformed or + references an out-of-range ``record`` or an absent + ``meas_id``, or if the traced operation stream cannot be + replayed. Note: Every measurement is anchored to a stable MeasId automatically: ``measure()`` itself allocates the result slot in the trace (a ``result(...)`` call is not required for MeasId assignment). - Positional ``records``/``meas_ids`` reference measurements by - *traced (post-compilation)* order and are sensitive to any - measurement reordering introduced by Guppy/Selene compilation. - ``result_tags`` (above) avoid this for straight-line programs via - the sound HUGR-derived binding; the runtime-loop case remains - deferred -- see + Detector/observable ``records``/``meas_ids`` reference measurements + by *traced (post-compilation)* order and are therefore sensitive to + any measurement reordering introduced by Guppy/Selene compilation. + Source-anchored tag-referenced detectors are **not exposed here**: + the sound HUGR-based binding + (``pecos_hugr_qis::extract_result_tag_measurements``) only covers + the canonical scalar ``result(tag, measure(q))`` pattern and is not + yet wired into ``from_guppy``; runtime-loop programs remain + unsupported. See ``docs/proposals/001-from-guppy-tag-referenced-detectors.md``. """ from pecos.qec.surface.decode import trace_guppy_into_tick_circuit @@ -285,25 +284,6 @@ def from_guppy( tc.lower_clifford_rotations() tc.assign_missing_meas_ids() - # Tag-referenced detectors: ferry the compiled HUGR + traced - # measurement count to Rust, which does the sound HUGR extraction, - # runtime-loop guard, and result_tags->records resolution (fail-loud). - # This Python side is a thin pass-through; no tag logic lives here. - if _result_tags_present(detectors_json, observables_json): - from pecos_rslib import resolve_result_tags_for_guppy - - from pecos._compilation.guppy import guppy_to_hugr - - measured, _ = _collect_measurement_info(tc) - detectors_json, observables_json = resolve_result_tags_for_guppy( - detectors_json, - observables_json, - guppy_to_hugr(guppy), - measured, - ) - if num_measurements is None: - num_measurements = measured - _validate_measurement_contract( tc, detectors_json=detectors_json, diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 4028ef6a1..ada168a71 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -540,8 +540,14 @@ def tuple_args(payload: Any, op_name: str, arity: int) -> tuple[Any, ...]: [(mapped_slot(int(qubit_a), op_name), mapped_slot(int(qubit_b), op_name))], ) elif op_name == "Measure": - program_id, _result_id = tuple_args(payload, op_name, 2) - tick.mz([mapped_slot(int(program_id), op_name)]) + program_id, result_id = tuple_args(payload, op_name, 2) + # Stamp the QIS-provided result_id as the MeasId rather than + # discarding it and letting assign_missing_meas_ids() invent + # sequential ids (which would be wrong for non-sequential ids). + tick.mz_with_ids( + [mapped_slot(int(program_id), op_name)], + [int(result_id)], + ) elif op_name == "Reset": tick.pz([mapped_slot(scalar_arg(payload, op_name), op_name)]) else: @@ -584,49 +590,20 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> tick_circuit = TickCircuit() - # Pass 1: build the global, ordered list of result-slot ids that anchor - # MeasIds. In the lowered Selene/QIS trace every measured qubit is backed - # by exactly one AllocateResult op immediately followed by its Measure op - # (the result slot is intrinsic to measure(); array-valued result() - # expands to one AllocateResult per measured element). The k-th - # measurement therefore maps to the k-th AllocateResult id, independent of - # the program-vs-slot qubit-id spaces. This strict 1:1 interleave is an - # invariant of the lowered trace; any deviation means the operation stream - # is malformed or uses an unsupported construct, so we fail loudly rather - # than guess an association and build a silently-wrong DEM. + # Pass 1: the ordered MeasIds, read directly from each Measure op. A + # ``Quantum.Measure`` op carries ``[qubit, result_id]`` where ``result_id`` + # is the QIS result slot the runtime allocated for it (== the MeasId we + # stamp). Using it directly needs no AllocateResult/Measure pairing + # heuristic and no interleave assumption -- batched + # allocate-allocate-measure-measure (a valid QIS pattern) works the same + # as interleaved. (The order of Measure ops here matches the order of MZ + # gates in ``lowered_quantum_ops``, consumed in pass 2.) meas_ids_in_order: list[int] = [] - pending_alloc: int | None = None for chunk in chunks: for op in chunk.get("operations") or []: - op_dict = dict(op) - if "AllocateResult" in op_dict: - if pending_alloc is not None: - msg = ( - "Malformed traced operation stream: two consecutive " - "AllocateResult ops with no Measure between them. The " - "lowered trace must interleave AllocateResult/Measure " - "1:1; cannot anchor MeasIds deterministically." - ) - raise ValueError(msg) - pending_alloc = int(op_dict["AllocateResult"]["id"]) - elif "Quantum" in op_dict and "Measure" in op_dict["Quantum"]: - if pending_alloc is None: - msg = ( - "Malformed traced operation stream: a Measure op with " - "no preceding AllocateResult. The lowered trace must " - "interleave AllocateResult/Measure 1:1; cannot anchor " - "a stable MeasId for this measurement." - ) - raise ValueError(msg) - meas_ids_in_order.append(pending_alloc) - pending_alloc = None - if pending_alloc is not None: - msg = ( - "Malformed traced operation stream: a trailing AllocateResult " - "with no following Measure; cannot anchor MeasIds " - "deterministically." - ) - raise ValueError(msg) + quantum = dict(op).get("Quantum") + if isinstance(quantum, dict) and "Measure" in quantum: + meas_ids_in_order.append(int(quantum["Measure"][1])) # Pass 2: replay gates, stamping MeasIds on MZ gates in global trace order. meas_cursor = 0 From aa4fb56f8666facdfa6d689262d5286b92ba0e26 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 19 May 2026 10:34:23 -0600 Subject: [PATCH 11/36] Revert broken dynamic-control guard, fail-loud on out-of-range metadata refs, add regression tests --- .../fault_tolerance/dem_builder/builder.rs | 485 ++++++++++++------ .../fault_tolerance/dem_builder/sampler.rs | 21 +- ...001-from-guppy-tag-referenced-detectors.md | 34 ++ .../src/fault_tolerance_bindings.rs | 12 +- python/quantum-pecos/src/pecos/qec/dem.py | 120 ++++- .../src/pecos/qec/surface/decode.py | 12 +- .../tests/qec/test_from_guppy_dem.py | 198 +++++++ .../tests/qec/test_qec_ux_entrypoints.py | 17 + 8 files changed, 713 insertions(+), 186 deletions(-) create mode 100644 python/quantum-pecos/tests/qec/test_from_guppy_dem.py diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index ae792f1b1..bbf2740d0 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -35,6 +35,7 @@ struct ParsedDetector { id: u32, coords: Option<[f64; 3]>, records: Vec, + meas_ids: Vec, } /// Parsed observable from JSON metadata. @@ -42,6 +43,7 @@ struct ParsedDetector { struct ParsedObservable { id: u32, records: Vec, + meas_ids: Vec, } // ============================================================================ @@ -129,6 +131,11 @@ impl<'a> DemBuilder<'a> { /// /// One-liner for the common case. Reads detector/DEM output definitions /// from circuit metadata. + /// + /// # Panics + /// + /// Panics if the circuit's detector/observable metadata is malformed (use + /// [`Self::try_from_circuit`] to handle that as an error instead). #[must_use] pub fn from_circuit( circuit: &pecos_quantum::DagCircuit, @@ -137,12 +144,36 @@ impl<'a> DemBuilder<'a> { p_meas: f64, p_prep: f64, ) -> DetectorErrorModel { + Self::try_from_circuit(circuit, p1, p2, p_meas, p_prep) + .unwrap_or_else(|err| panic!("invalid DEM metadata on circuit: {err}")) + } + + /// Try to build a `DetectorErrorModel` directly from a `DagCircuit` and noise. + /// + /// Reads detector/DEM output definitions from circuit metadata and returns + /// parser errors instead of dropping malformed metadata. + /// + /// # Errors + /// + /// Returns an error if detector or observable metadata is malformed. + pub fn try_from_circuit( + circuit: &pecos_quantum::DagCircuit, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + ) -> Result { build_dem_from_circuit(circuit, p1, p2, p_meas, p_prep) } /// Build a `DetectorErrorModel` from a `TickCircuit` and noise. /// /// Converts to `DagCircuit` internally. + /// + /// # Panics + /// + /// Panics if the circuit's detector/observable metadata is malformed (use + /// [`Self::try_from_tick_circuit`] to handle that as an error instead). #[must_use] pub fn from_tick_circuit( circuit: &pecos_quantum::TickCircuit, @@ -151,6 +182,25 @@ impl<'a> DemBuilder<'a> { p_meas: f64, p_prep: f64, ) -> DetectorErrorModel { + Self::try_from_tick_circuit(circuit, p1, p2, p_meas, p_prep) + .unwrap_or_else(|err| panic!("invalid DEM metadata on circuit: {err}")) + } + + /// Try to build a `DetectorErrorModel` from a `TickCircuit` and noise. + /// + /// Converts to `DagCircuit` internally and returns parser errors instead + /// of dropping malformed metadata. + /// + /// # Errors + /// + /// Returns an error if detector or observable metadata is malformed. + pub fn try_from_tick_circuit( + circuit: &pecos_quantum::TickCircuit, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + ) -> Result { let dag = pecos_quantum::DagCircuit::from(circuit); build_dem_from_circuit(&dag, p1, p2, p_meas, p_prep) } @@ -379,11 +429,80 @@ impl<'a> DemBuilder<'a> { #[allow(clippy::cast_possible_truncation)] // observable count fits in u32 id: id as u32, records, + meas_ids: Vec::new(), }) .collect(); self } + fn meas_id_to_record_offset(&self, meas_id: usize) -> Option { + if meas_id >= self.num_measurements { + return None; + } + let measurement = i64::try_from(meas_id).ok()?; + let total = i64::try_from(self.num_measurements).ok()?; + i32::try_from(measurement - total).ok() + } + + /// Fail loud if any detector/observable references a measurement that does + /// not exist, instead of silently dropping it and weakening the DEM. + /// + /// Checks only the references that are actually consumed: when `records` + /// is non-empty it is used (and `meas_ids` ignored, so `meas_ids` is not + /// checked here -- consistent with [`Self::effective_record_offsets`]); + /// otherwise `meas_ids` is used. `records`+`meas_ids` may legitimately + /// both be present and redundant (surface metadata does this), so their + /// co-presence is *not* an error. + /// + /// # Errors + /// Returns [`DemBuilderError::ParseError`] if a used record offset is out + /// of range for `num_measurements`, or a used `meas_id` is `>= + /// num_measurements`. + fn validate_metadata_refs(&self) -> Result<(), DemBuilderError> { + let check = |kind: &str, id: u32, records: &[i32], meas_ids: &[usize]| { + if records.is_empty() { + for &mid in meas_ids { + if mid >= self.num_measurements { + return Err(DemBuilderError::ParseError(format!( + "{kind} {id} references meas_id {mid}, but the \ + circuit has only {} measurement(s)", + self.num_measurements + ))); + } + } + } else { + for &rec in records { + if record_offset_to_absolute_index(self.num_measurements, rec).is_none() { + return Err(DemBuilderError::ParseError(format!( + "{kind} {id} references record offset {rec}, which \ + is out of range for a circuit with {} \ + measurement(s)", + self.num_measurements + ))); + } + } + } + Ok(()) + }; + for d in &self.detectors { + check("Detector", d.id, &d.records, &d.meas_ids)?; + } + for o in &self.observables { + check("Observable", o.id, &o.records, &o.meas_ids)?; + } + Ok(()) + } + + fn effective_record_offsets(&self, records: &[i32], meas_ids: &[usize]) -> Vec { + if !records.is_empty() { + return records.to_vec(); + } + meas_ids + .iter() + .filter_map(|&meas_id| self.meas_id_to_record_offset(meas_id)) + .collect() + } + /// Builds the Detector Error Model with source tracking. /// /// This performs fault propagation analysis and tracks error sources (X/Z vs Y) @@ -404,7 +523,8 @@ impl<'a> DemBuilder<'a> { if let Some(coords) = det.coords { def = def.with_coords(coords); } - def = def.with_records(det.records.iter().copied()); + let records = self.effective_record_offsets(&det.records, &det.meas_ids); + def = def.with_records(records.iter().copied()); dem.add_detector(def); } @@ -439,7 +559,8 @@ impl<'a> DemBuilder<'a> { // Add observable definitions in the standard `L` namespace. // Observable IDs are not shifted by tracked Paulis. for obs in &self.observables { - let def = DemOutput::new(obs.id).with_records(obs.records.iter().copied()); + let records = self.effective_record_offsets(&obs.records, &obs.meas_ids); + let def = DemOutput::new(obs.id).with_records(records.iter().copied()); dem.add_observable(def); } @@ -888,15 +1009,26 @@ impl<'a> DemBuilder<'a> { }; for det in &self.detectors { - for &rec in &det.records { - if let Some(tc_meas_idx) = - record_offset_to_absolute_index(self.num_measurements, rec) - && let Some(&influence_idx) = tc_to_influence.get(&tc_meas_idx) - { - meas_to_detectors - .entry(influence_idx) - .or_default() - .push(det.id); + if det.records.is_empty() { + for &meas_id in &det.meas_ids { + if let Some(&influence_idx) = tc_to_influence.get(&meas_id) { + meas_to_detectors + .entry(influence_idx) + .or_default() + .push(det.id); + } + } + } else { + for &rec in &det.records { + if let Some(tc_meas_idx) = + record_offset_to_absolute_index(self.num_measurements, rec) + && let Some(&influence_idx) = tc_to_influence.get(&tc_meas_idx) + { + meas_to_detectors + .entry(influence_idx) + .or_default() + .push(det.id); + } } } } @@ -905,15 +1037,26 @@ impl<'a> DemBuilder<'a> { if influence_observable_ids.contains(&obs.id) { continue; } - for &rec in &obs.records { - if let Some(tc_meas_idx) = - record_offset_to_absolute_index(self.num_measurements, rec) - && let Some(&influence_idx) = tc_to_influence.get(&tc_meas_idx) - { - meas_to_observables - .entry(influence_idx) - .or_default() - .push(obs.id); + if obs.records.is_empty() { + for &meas_id in &obs.meas_ids { + if let Some(&influence_idx) = tc_to_influence.get(&meas_id) { + meas_to_observables + .entry(influence_idx) + .or_default() + .push(obs.id); + } + } + } else { + for &rec in &obs.records { + if let Some(tc_meas_idx) = + record_offset_to_absolute_index(self.num_measurements, rec) + && let Some(&influence_idx) = tc_to_influence.get(&tc_meas_idx) + { + meas_to_observables + .entry(influence_idx) + .or_default() + .push(obs.id); + } } } } @@ -1097,63 +1240,40 @@ fn get_y_decomposition(p1: u8, p2: u8) -> Option<(u8, u8, u8, u8)> { /// Parses detector definitions from JSON. fn parse_detectors_json(json: &str) -> Result, DemBuilderError> { - // Simple JSON parsing without serde dependency - // Expected format: [{"id": 0, "coords": [0.0, 0.0, 0.0], "records": [-1, -5]}, ...] - let json = json.trim(); if json.is_empty() || json == "[]" { return Ok(Vec::new()); } - let mut detectors = Vec::new(); - - // Find all objects in the array - let mut depth = 0; - let mut obj_start = None; - - for (i, c) in json.char_indices() { - match c { - '[' if depth == 0 => depth = 1, - '{' if depth == 1 => { - depth = 2; - obj_start = Some(i); - } - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 1 { - if let Some(start) = obj_start { - // i is the byte index of '}', we want to include it - let obj_str = &json[start..i + c.len_utf8()]; - let det = parse_single_detector(obj_str)?; - detectors.push(det); - } - obj_start = None; - } - } - _ => {} - } - } - - Ok(detectors) + let parsed: serde_json::Value = serde_json::from_str(json).map_err(|err| { + DemBuilderError::ParseError(format!("detectors JSON is malformed: {err}")) + })?; + let array = parsed + .as_array() + .ok_or_else(|| DemBuilderError::ParseError("detectors JSON must be an array".into()))?; + array.iter().map(parse_single_detector).collect() } /// Parses a single detector object. -fn parse_single_detector(json: &str) -> Result { +fn parse_single_detector(value: &serde_json::Value) -> Result { + let object = value + .as_object() + .ok_or_else(|| DemBuilderError::ParseError("detector entry must be an object".into()))?; let id = extract_u32( - json, - &["\"id\"", "\"detector_id\""], + object, + &["id", "detector_id"], "missing detector id", "detector id out of range", )?; - let coords = extract_coords(json); - let records = extract_records(json); + let coords = extract_coords(object)?; + let (records, meas_ids) = extract_measurement_refs(object, "detector")?; Ok(ParsedDetector { id, coords, records, + meas_ids, }) } @@ -1164,111 +1284,120 @@ fn parse_observables_json(json: &str) -> Result, DemBuilde return Ok(Vec::new()); } - let mut observables = Vec::new(); - - let mut depth = 0; - let mut obj_start = None; - - for (i, c) in json.char_indices() { - match c { - '[' if depth == 0 => depth = 1, - '{' if depth == 1 => { - depth = 2; - obj_start = Some(i); - } - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 1 { - if let Some(start) = obj_start { - // i is the byte index of '}', we want to include it - let obj_str = &json[start..i + c.len_utf8()]; - let obs = parse_single_observable(obj_str)?; - observables.push(obs); - } - obj_start = None; - } - } - _ => {} - } - } - - Ok(observables) + let parsed: serde_json::Value = serde_json::from_str(json).map_err(|err| { + DemBuilderError::ParseError(format!("observables JSON is malformed: {err}")) + })?; + let array = parsed + .as_array() + .ok_or_else(|| DemBuilderError::ParseError("observables JSON must be an array".into()))?; + array.iter().map(parse_single_observable).collect() } /// Parses a single observable object. -fn parse_single_observable(json: &str) -> Result { +fn parse_single_observable(value: &serde_json::Value) -> Result { + let object = value + .as_object() + .ok_or_else(|| DemBuilderError::ParseError("observable entry must be an object".into()))?; let id = extract_u32( - json, - &["\"id\"", "\"observable_id\""], + object, + &["id", "observable_id"], "missing observable id", "observable id out of range", )?; - let records = extract_records(json); - - Ok(ParsedObservable { id, records }) -} - -/// Extracts a number after a key. -fn extract_number(json: &str, key: &str) -> Option { - let pos = json.find(key)?; - let rest = &json[pos + key.len()..]; - let rest = rest.trim_start_matches(|c: char| c == ':' || c.is_whitespace()); + let (records, meas_ids) = extract_measurement_refs(object, "observable")?; - let end = rest.find(|c: char| !c.is_ascii_digit() && c != '-' && c != '.')?; - let num_str = &rest[..end]; - num_str.parse().ok() + Ok(ParsedObservable { + id, + records, + meas_ids, + }) } fn extract_u32( - json: &str, + object: &serde_json::Map, keys: &[&str], missing_message: &str, range_message: &str, ) -> Result { let value = keys .iter() - .find_map(|key| extract_number(json, key)) + .find_map(|key| object.get(*key)) .ok_or_else(|| DemBuilderError::ParseError(missing_message.into()))?; - u32::try_from(value).map_err(|_| DemBuilderError::ParseError(range_message.into())) + let raw = value.as_u64().ok_or_else(|| { + DemBuilderError::ParseError(format!("{missing_message}: expected unsigned integer")) + })?; + u32::try_from(raw).map_err(|_| DemBuilderError::ParseError(range_message.into())) } /// Extracts coordinates array [x, y, t]. -fn extract_coords(json: &str) -> Option<[f64; 3]> { - let pos = json.find("\"coords\"")?; - let rest = &json[pos..]; - let bracket_start = rest.find('[')?; - let bracket_end = rest.find(']')?; - let array_str = &rest[bracket_start + 1..bracket_end]; - - let nums: Vec = array_str - .split(',') - .filter_map(|s| s.trim().parse().ok()) - .collect(); - - if nums.len() == 3 { - Some([nums[0], nums[1], nums[2]]) - } else { - None +fn extract_coords( + object: &serde_json::Map, +) -> Result, DemBuilderError> { + let Some(coords) = object.get("coords") else { + return Ok(None); + }; + let array = coords + .as_array() + .ok_or_else(|| DemBuilderError::ParseError("detector coords must be an array".into()))?; + if array.len() != 3 { + return Err(DemBuilderError::ParseError( + "detector coords must contain exactly three numbers".into(), + )); + } + let mut values = [0.0; 3]; + for (idx, coord) in array.iter().enumerate() { + values[idx] = coord + .as_f64() + .ok_or_else(|| DemBuilderError::ParseError("detector coords must be numeric".into()))?; } + Ok(Some(values)) } -/// Extracts records array. -fn extract_records(json: &str) -> Vec { - if let Some(pos) = json.find("\"records\"") { - let rest = &json[pos..]; - if let Some(bracket_start) = rest.find('[') - && let Some(bracket_end) = rest.find(']') - { - let array_str = &rest[bracket_start + 1..bracket_end]; - return array_str - .split(',') - .filter_map(|s| s.trim().parse().ok()) - .collect(); - } - } - Vec::new() +/// Extracts `records`/`meas_ids` arrays. +fn extract_measurement_refs( + object: &serde_json::Map, + kind: &str, +) -> Result<(Vec, Vec), DemBuilderError> { + let records = if let Some(records) = object.get("records") { + let array = records.as_array().ok_or_else(|| { + DemBuilderError::ParseError(format!("{kind} records must be an array")) + })?; + array + .iter() + .map(|record| { + let raw = record.as_i64().ok_or_else(|| { + DemBuilderError::ParseError(format!("{kind} record offsets must be integers")) + })?; + i32::try_from(raw).map_err(|_| { + DemBuilderError::ParseError(format!("{kind} record offset out of range")) + }) + }) + .collect::, _>>()? + } else { + Vec::new() + }; + + let meas_ids = if let Some(meas_ids) = object.get("meas_ids") { + let array = meas_ids.as_array().ok_or_else(|| { + DemBuilderError::ParseError(format!("{kind} meas_ids must be an array")) + })?; + array + .iter() + .map(|meas_id| { + let raw = meas_id.as_i64().ok_or_else(|| { + DemBuilderError::ParseError(format!("{kind} meas_ids must be integers")) + })?; + usize::try_from(raw).map_err(|_| { + DemBuilderError::ParseError(format!("{kind} meas_id out of range")) + }) + }) + .collect::, _>>()? + } else { + Vec::new() + }; + + Ok((records, meas_ids)) } // ============================================================================ @@ -1284,7 +1413,7 @@ fn build_dem_from_circuit( p2: f64, p_meas: f64, p_prep: f64, -) -> DetectorErrorModel { +) -> Result { use crate::fault_tolerance::influence_builder::InfluenceBuilder; use crate::fault_tolerance::propagator::DagFaultAnalyzer; use pecos_num::graph::Attribute; @@ -1322,17 +1451,13 @@ fn build_dem_from_circuit( let builder = DemBuilder::new(&influence_map).with_noise(p1, p2, p_meas, p_prep); let builder = if let Some(ref dj) = det_json { - builder - .with_detectors_json(dj) - .unwrap_or_else(|_| DemBuilder::new(&influence_map).with_noise(p1, p2, p_meas, p_prep)) + builder.with_detectors_json(dj)? } else { builder }; let builder = if let Some(ref oj) = obs_json { - builder - .with_observables_json(oj) - .unwrap_or_else(|_| DemBuilder::new(&influence_map).with_noise(p1, p2, p_meas, p_prep)) + builder.with_observables_json(oj)? } else if !annotated_observable_records.is_empty() { builder.with_observable_records(annotated_observable_records) } else { @@ -1345,7 +1470,8 @@ fn build_dem_from_circuit( builder }; - builder.build() + builder.validate_metadata_refs()?; + Ok(builder.build()) } fn observable_records_from_annotations( @@ -1914,6 +2040,7 @@ mod tests { assert_eq!(detectors[0].id, 0); assert_eq!(detectors[0].coords, Some([0.0, 0.0, 0.0])); assert_eq!(detectors[0].records, vec![-1, -5]); + assert!(detectors[0].meas_ids.is_empty()); assert_eq!(detectors[1].id, 1); assert_eq!(detectors[1].records, vec![-2]); } @@ -1927,6 +2054,19 @@ mod tests { assert_eq!(observables.len(), 1); assert_eq!(observables[0].id, 0); assert_eq!(observables[0].records, vec![-1, -3, -5]); + assert!(observables[0].meas_ids.is_empty()); + } + + #[test] + fn test_parse_json_accepts_meas_ids() { + let detectors = parse_detectors_json(r#"[{"id": 0, "meas_ids": [0, 2]}]"#).unwrap(); + assert_eq!(detectors[0].records, Vec::::new()); + assert_eq!(detectors[0].meas_ids, vec![0, 2]); + + let observables = + parse_observables_json(r#"[{"observable_id": 1, "meas_ids": [3]}]"#).unwrap(); + assert_eq!(observables[0].records, Vec::::new()); + assert_eq!(observables[0].meas_ids, vec![3]); } #[test] @@ -1943,6 +2083,21 @@ mod tests { assert_eq!(dem.dem_outputs()[0].records.as_slice(), &[-1, -3]); } + #[test] + fn test_dem_builder_resolves_meas_ids_when_records_are_absent() { + let influence_map = DagFaultInfluenceMap::with_capacity(0); + let dem = DemBuilder::new(&influence_map) + .with_detectors_json(r#"[{"id": 0, "meas_ids": [0, 2]}]"#) + .unwrap() + .with_observables_json(r#"[{"id": 0, "meas_ids": [1]}]"#) + .unwrap() + .with_num_measurements(3) + .build(); + + assert_eq!(dem.detectors[0].records.as_slice(), &[-3, -1]); + assert_eq!(dem.dem_outputs()[0].records.as_slice(), &[-2]); + } + #[test] fn test_parse_empty_json() { assert!(parse_detectors_json("").unwrap().is_empty()); @@ -1950,6 +2105,42 @@ mod tests { assert!(parse_observables_json("").unwrap().is_empty()); } + #[test] + fn test_parse_detector_json_rejects_malformed_shapes() { + for json in [ + "{}", + r#"[{"id":0,"records":["-1"]}]"#, + r#"[{"id":0,"records":[-1.2]}]"#, + r#"[{"id":0,"meas_ids":["0"]}]"#, + r#"[{"id":0,"meas_ids":[-1]}]"#, + r#"[{"id":0,"meas_ids":[1.2]}]"#, + r#"[{"id":true,"records":[-1]}]"#, + ] { + assert!( + parse_detectors_json(json).is_err(), + "detectors JSON should fail loud: {json}" + ); + } + } + + #[test] + fn test_parse_observable_json_rejects_malformed_shapes() { + for json in [ + "{}", + r#"[{"id":0,"records":["-1"]}]"#, + r#"[{"id":0,"records":[-1.2]}]"#, + r#"[{"id":0,"meas_ids":["0"]}]"#, + r#"[{"id":0,"meas_ids":[-1]}]"#, + r#"[{"id":0,"meas_ids":[1.2]}]"#, + r#"[{"observable_id":false,"records":[-1]}]"#, + ] { + assert!( + parse_observables_json(json).is_err(), + "observables JSON should fail loud: {json}" + ); + } + } + #[test] fn test_xor_toggle() { let mut vec: SmallVec<[u32; 4]> = SmallVec::new(); diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs index d564fbf49..bd92d8b87 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -57,6 +57,8 @@ pub enum DetectorValidationError { /// Raw measurement mode requires all gates to be in the supported Clifford /// subset (`H`, `X`, `Y`, `Z`, `SZ`, `SZdg`, `CX`, `CZ`, `SWAP`, `MZ`, `PZ`, `I`). UnsupportedGateForDeterminismAnalysis { gate_type: String }, + /// Circuit detector/observable metadata is malformed. + InvalidMetadata { message: String }, } impl std::fmt::Display for DetectorValidationError { @@ -91,6 +93,9 @@ impl std::fmt::Display for DetectorValidationError { H, X, Y, Z, SZ, SZdg, CX, CZ, SWAP, MZ, PZ/QAlloc, I/Idle." ) } + Self::InvalidMetadata { message } => { + write!(f, "Invalid detector/observable metadata: {message}") + } } } } @@ -459,17 +464,21 @@ impl DemSampler { let builder = DemBuilder::new(&influence_map).with_noise_config(noise.clone()); let builder = if let Some(ref dj) = det_json { - builder.with_detectors_json(dj).unwrap_or_else(|_| { - DemBuilder::new(&influence_map).with_noise_config(noise.clone()) - }) + builder.with_detectors_json(dj).map_err(|err| { + DetectorValidationError::InvalidMetadata { + message: err.to_string(), + } + })? } else { builder }; let builder = if let Some(ref oj) = observables_json { - builder.with_observables_json(oj).unwrap_or_else(|_| { - DemBuilder::new(&influence_map).with_noise_config(noise.clone()) - }) + builder.with_observables_json(oj).map_err(|err| { + DetectorValidationError::InvalidMetadata { + message: err.to_string(), + } + })? } else { builder }; diff --git a/docs/proposals/001-from-guppy-tag-referenced-detectors.md b/docs/proposals/001-from-guppy-tag-referenced-detectors.md index 8d9ea7cb2..832b13cf2 100644 --- a/docs/proposals/001-from-guppy-tag-referenced-detectors.md +++ b/docs/proposals/001-from-guppy-tag-referenced-detectors.md @@ -376,3 +376,37 @@ fail-loud, `D0`/`L0`), the corrected strict/non-lowered replay (#5/#6), and a sound-but-narrow standalone HUGR extractor with tests. Tag-referenced detectors are NOT exposed to users; the loop case remains deferred (CFG- interpreter-class machinery or upstream `tket-qsystem` provenance). + +## Second external review + outcome (AUTHORITATIVE) + +A second independent review (re-verified by a third) found the prior fix round +shipped a **critical regression**: a `reject_dynamic_control` guard added to +`trace_guppy_into_tick_circuit` false-positived on the *standard surface code* +(it has statically-scheduled gates after ancilla measurements every round, in +`pending_continue` chunks), so `from_guppy(make_surface_code(...))` raised +before DEM construction. The same heuristic also missed genuinely +measurement-dependent programs on some seeds. + +Resolution: + +- **Guard reverted.** A runtime-trace heuristic cannot distinguish + statically-scheduled post-measurement gates from true data-dependent + branching. Measurement-dependent (dynamic) control flow is now documented as + **unsupported / undefined** for `from_guppy` (one sampled branch is not a + static DEM); sound detection needs HUGR conditional-on-measurement analysis + (deferred). With the guard gone, surface `from_guppy` is byte-identical to + the `traced_qis` reference again (independently confirmed) -- so the + ~425-line `builder.rs` serde rewrite did **not** change DEM output. +- Confirmed-fixed by the prior round and retained: `meas_ids` end-to-end (now + normalized to records), malformed-JSON fail-loud in `from_guppy`, JSON + `tracked_pauli` rejection, direct `from_circuit`/`DemSampler` fail-loud on + missing-id / malformed metadata. +- Residual (addressed here): out-of-range / unresolved `records`/`meas_ids` + still silently weakened the DEM in the Rust metadata path; the Rust/Python + schema duplicated and diverged; clippy `-D warnings` failed (missing + `# Panics` docs); the public subclass-identity hazard (#6) is documented + (no internal `isinstance` use; public-API caveat only). + +So the previous "proven sound for straight-line / surface byte-identical" +statement is accurate again *only after the guard revert*; it was false in the +intermediate broken tree. diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 7d3f26881..385472f39 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -965,15 +965,15 @@ impl PyDetectorErrorModel { if let Ok(dag) = circuit.extract::>() { - Ok(Self { - inner: DemBuilder::from_circuit(&dag.inner, p1, p2, p_meas, p_prep), - }) + let inner = DemBuilder::try_from_circuit(&dag.inner, p1, p2, p_meas, p_prep) + .map_err(|err| pyo3::exceptions::PyValueError::new_err(err.to_string()))?; + Ok(Self { inner }) } else if let Ok(tc) = circuit.extract::>() { - Ok(Self { - inner: DemBuilder::from_tick_circuit(&tc.inner, p1, p2, p_meas, p_prep), - }) + let inner = DemBuilder::try_from_tick_circuit(&tc.inner, p1, p2, p_meas, p_prep) + .map_err(|err| pyo3::exceptions::PyValueError::new_err(err.to_string()))?; + Ok(Self { inner }) } else { Err(pyo3::exceptions::PyTypeError::new_err( "from_circuit() expects a DagCircuit or TickCircuit", diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index edc43a440..d86f602bb 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -22,22 +22,21 @@ from pecos_rslib.qec import DetectorErrorModel as _RustDetectorErrorModel -def _collect_measurement_info(tc: Any) -> tuple[int, set[int]]: - """Return (measurement count, set of MeasIds) for the traced circuit. +def _collect_measurement_info(tc: Any) -> tuple[int, dict[int, int]]: + """Return (measurement count, MeasId -> measurement index) for the traced circuit. Counts measured qubits across all MZ gates and gathers the stable MeasIds stamped on them. """ dag = tc.to_dag_circuit() count = 0 - meas_ids: set[int] = set() + meas_id_to_index: dict[int, int] = {} for node_id in dag.nodes(): gate = dag.gate(node_id) if gate is None or gate.gate_type.name != "MZ": continue qubits = list(gate.qubits) ids = list(gate.meas_ids) - count += len(qubits) if len(ids) != len(qubits): msg = ( "Traced Guppy circuit has an MZ gate without a stable MeasId " @@ -47,8 +46,14 @@ def _collect_measurement_info(tc: Any) -> tuple[int, set[int]]: "with the caller's inputs." ) raise ValueError(msg) - meas_ids.update(int(i) for i in ids) - return count, meas_ids + for local_idx, mid in enumerate(ids): + stable_id = int(mid) + if stable_id in meas_id_to_index: + msg = f"Duplicate measurement id {stable_id} in TickCircuit" + raise ValueError(msg) + meas_id_to_index[stable_id] = count + local_idx + count += len(qubits) + return count, meas_id_to_index def _validate_measurement_contract( @@ -57,14 +62,16 @@ def _validate_measurement_contract( detectors_json: str, observables_json: str, num_measurements: int | None, -) -> None: +) -> tuple[str, str]: """Fail loudly if the caller's detector/observable JSON is inconsistent. Catches the common ``from_guppy`` misuse where detector ``records``/ ``meas_ids`` do not line up with the measurements the traced program - actually emits, instead of silently building a wrong DEM. + actually emits, instead of silently building a wrong DEM. ``meas_ids`` are + normalized to negative ``records`` offsets because the Rust DEM metadata + parser consumes positional records. """ - measured, present_ids = _collect_measurement_info(tc) + measured, meas_id_to_index = _collect_measurement_info(tc) if num_measurements is not None and num_measurements != measured: msg = ( @@ -77,8 +84,21 @@ def _validate_measurement_contract( raise ValueError(msg) effective = num_measurements if num_measurements is not None else measured - def _check(kind: str, entries: list[dict[str, Any]]) -> None: + def _require_int(value: Any, label: str) -> int: + if not isinstance(value, int) or isinstance(value, bool): + msg = f"{label} must be an integer" + raise ValueError(msg) # noqa: TRY004 + return value + + def _require_list(value: Any, label: str) -> list[Any]: + if not isinstance(value, list): + msg = f"{label} must be a list" + raise ValueError(msg) # noqa: TRY004 + return value + + def _check(kind: str, entries: list[dict[str, Any]]) -> list[dict[str, Any]]: alt_id = "detector_id" if kind == "Detector" else "observable_id" + normalized_entries: list[dict[str, Any]] = [] # NB: malformed input raises ValueError (not TypeError) to keep one # consistent failure type across from_guppy's documented contract and # the sibling record/meas_id checks below -- hence the TRY004 noqas. @@ -88,43 +108,64 @@ def _check(kind: str, entries: list[dict[str, Any]]) -> None: raise ValueError(msg) # noqa: TRY004 # Tracked Paulis reference qubits via "pauli", not measurements. if entry.get("kind") == "tracked_pauli": - continue + msg = ( + f"{kind} entry {entry!r} uses kind='tracked_pauli', " + "which is not supported by from_guppy JSON metadata." + ) + raise ValueError(msg) # Schema: the Rust DEM-builder JSON parser requires an integer id # and records; on a parse failure it silently builds an empty DEM. # Validate here so malformed input fails loud instead. raw_id = entry.get("id", entry.get(alt_id)) - if not isinstance(raw_id, int) or isinstance(raw_id, bool): + try: + _require_int(raw_id, f"{kind} id") + except ValueError as exc: msg = ( f"{kind} entry {entry!r} is missing a valid integer " f"'id' (or '{alt_id}'); the DEM builder would silently " "drop it and produce an empty DEM." ) - raise ValueError(msg) # noqa: TRY004 - if not (entry.get("records") or entry.get("meas_ids")): + raise ValueError(msg) from exc + + records = _require_list(entry.get("records", []), f"{kind} {raw_id} records") + meas_ids = _require_list(entry.get("meas_ids", []), f"{kind} {raw_id} meas_ids") + if not (records or meas_ids): msg = ( f"{kind} {raw_id} has no 'records' or 'meas_ids'; it " "would contribute nothing and silently weaken the DEM." ) raise ValueError(msg) - for rec in entry.get("records", []) or []: - idx = effective + int(rec) + normalized_records: list[int] = [] + for rec in records: + rec_int = _require_int(rec, f"{kind} {raw_id} record offset") + idx = effective + rec_int if not 0 <= idx < effective: msg = ( f"{kind} {entry.get('id', entry)} references record " - f"{rec}, which is out of range for a circuit with " + f"{rec_int}, which is out of range for a circuit with " f"{effective} measurement(s)." ) raise ValueError(msg) - for mid in entry.get("meas_ids", []) or []: - if int(mid) not in present_ids: + normalized_records.append(rec_int) + for mid in meas_ids: + stable_id = _require_int(mid, f"{kind} {raw_id} meas_id") + if stable_id not in meas_id_to_index: msg = ( f"{kind} {entry.get('id', entry)} references " - f"meas_id {mid}, which is not present in the traced " + f"meas_id {stable_id}, which is not present in the traced " "circuit. meas_ids must match the stable MeasIds the " "traced program assigns (one per measured qubit, in " "trace order)." ) raise ValueError(msg) + normalized_records.append(meas_id_to_index[stable_id] - effective) + + normalized = dict(entry) + normalized.pop("meas_ids", None) + normalized["records"] = normalized_records + normalized_entries.append(normalized) + + return normalized_entries try: detectors = json.loads(detectors_json) if detectors_json else [] @@ -133,8 +174,16 @@ def _check(kind: str, entries: list[dict[str, Any]]) -> None: msg = f"detectors_json/observables_json is not valid JSON: {exc}" raise ValueError(msg) from exc - _check("Detector", detectors) - _check("Observable", observables) + if not isinstance(detectors, list) or not isinstance(observables, list): + msg = "detectors_json and observables_json must each be a JSON list" + raise ValueError(msg) # noqa: TRY004 + + normalized_detectors = _check("Detector", detectors) + normalized_observables = _check("Observable", observables) + return ( + json.dumps(normalized_detectors, separators=(",", ":")), + json.dumps(normalized_observables, separators=(",", ":")), + ) def _normalize_entry_ids(blob: str, prefix: str) -> str: @@ -179,6 +228,16 @@ class DetectorErrorModel(_RustDetectorErrorModel): Identical to :class:`pecos_rslib.qec.DetectorErrorModel` except for the added :meth:`from_guppy` classmethod. + + Identity caveat: the inherited Rust factory classmethods + (``from_circuit``, ``from_pecos_metadata_json``, and ``from_guppy``, which + delegates to ``from_circuit``) construct and return the *Rust base* class + ``pecos_rslib.qec.DetectorErrorModel`` -- they do not return instances of + this Python subclass. Consequently ``isinstance(obj, DetectorErrorModel)`` + is ``False`` for objects produced by those constructors even though every + method works identically. Do not use ``isinstance`` against this public + subclass to recognize DEMs; check the Rust base type instead. (No PECOS + code relies on such an ``isinstance``; this is a public-API caveat only.) """ __slots__ = () @@ -231,8 +290,7 @@ def from_guppy( ``label`` / ``pauli``. Tracked Paulis are only produced from circuit *annotations* (e.g. the surface builder), not from ``observables_json``. A ``{"kind": "tracked_pauli", ...}`` - entry here is silently treated as a (malformed) observable -- - do not use it. + entry here is rejected. num_measurements: Total measurement count, used to resolve negative ``records`` offsets. If omitted, it is inferred from the traced circuit. @@ -253,6 +311,18 @@ def from_guppy( replayed. Note: + **Measurement-dependent (dynamic) control flow is unsupported.** + ``from_guppy`` traces one ideal execution; a Guppy program whose + quantum operations depend on a measurement *outcome* (e.g. + ``if measure(q): x(other)``) would yield a DEM built from a single + sampled branch, silently wrong and seed-dependent. No reliable + runtime-trace heuristic distinguishes that from the + statically-scheduled post-measurement gates a normal QEC circuit + has (the surface code has these every round), so no guard is + attempted -- pass straight-line programs only. Sound detection + would require HUGR conditional-on-measurement analysis (deferred; + see proposal 001). + Every measurement is anchored to a stable MeasId automatically: ``measure()`` itself allocates the result slot in the trace (a ``result(...)`` call is not required for MeasId assignment). @@ -284,7 +354,7 @@ def from_guppy( tc.lower_clifford_rotations() tc.assign_missing_meas_ids() - _validate_measurement_contract( + detectors_json, observables_json = _validate_measurement_contract( tc, detectors_json=detectors_json, observables_json=observables_json, diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index ada168a71..72e88ee99 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -583,8 +583,9 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> tick, then compact (ASAP schedule) so that gates on disjoint qubits share a tick --- matching the parallel structure of the abstract circuit. - MeasIds flow from Guppy result() objects: AllocateResult IDs from the - operations stream are stamped on MZ gates via mz_with_ids(). + MeasIds flow from the QIS measurement result slot: Quantum.Measure carries + ``[qubit, result_id]``, and those IDs are stamped on MZ gates via + mz_with_ids(). """ from pecos_rslib.quantum import TickCircuit @@ -695,6 +696,13 @@ def trace_guppy_into_tick_circuit(program: Any, num_qubits: int, *, seed: int = This is the generic core shared by the surface traced-QIS path and the general ``DetectorErrorModel.from_guppy`` entry point. + Note: this traces ONE ideal execution. Measurement-dependent (dynamic) + control flow is therefore *unsupported / undefined* for DEM construction -- + a single sampled branch is not a static circuit. No reliable runtime-trace + heuristic distinguishes that from statically-scheduled post-measurement + gates (the surface code legitimately has those), so no guard is attempted; + callers must pass straight-line programs (see proposal 001). + Args: program: Anything ``pecos.sim`` accepts -- a ``@guppy`` function, a compiled Guppy program, or a program wrapper. diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py new file mode 100644 index 000000000..9c1b45bb5 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -0,0 +1,198 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Regression tests for the Guppy-to-DEM convenience path.""" + +import pytest +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import h, measure, qubit, x +from pecos.guppy import get_num_qubits, make_surface_code +from pecos.qec import DetectorErrorModel +from pecos.qec.surface import SurfacePatch +from pecos.qec.surface.decode import ( + _build_surface_tick_circuit_for_native_model, + _replay_lowered_qis_trace_into_tick_circuit, + _replay_qis_trace_into_tick_circuit, +) + + +@guppy +def _single_measurement() -> None: + q = qubit() + b = measure(q) + result("m", b) + + +@guppy +def _measurement_feedback() -> None: + q0 = qubit() + q1 = qubit() + h(q0) + b0 = measure(q0) + if b0: + x(q1) + b1 = measure(q1) + result("b0", b0) + result("b1", b1) + + +def _dem_text(*, detectors_json: str = "[]", observables_json: str = "[]") -> str: + dem = DetectorErrorModel.from_guppy( + _single_measurement, + num_qubits=1, + detectors_json=detectors_json, + observables_json=observables_json, + p1=0.0, + p2=0.0, + p_meas=0.1, + p_prep=0.0, + seed=0, + ) + return dem.to_string() + + +def _flat_mz_ids(tc) -> list[int]: + dag = tc.to_dag_circuit() + ids: list[int] = [] + for node_id in dag.nodes(): + gate = dag.gate(node_id) + if gate is not None and gate.gate_type.name == "MZ": + ids.extend(int(mid) for mid in gate.meas_ids) + return ids + + +def test_from_guppy_meas_ids_are_normalized_to_records() -> None: + assert _dem_text(detectors_json='[{"id":0,"meas_ids":[0]}]') == _dem_text( + detectors_json='[{"id":0,"records":[-1]}]', + ) + + assert _dem_text(observables_json='[{"id":0,"meas_ids":[0]}]') == _dem_text( + observables_json='[{"id":0,"records":[-1]}]', + ) + + +@pytest.mark.parametrize( + "detectors_json", + [ + "{}", + '[{"id":0,"records":["-1"]}]', + '[{"id":0,"records":[-1.2]}]', + '[{"id":0,"meas_ids":["0"]}]', + ], +) +def test_from_guppy_rejects_malformed_detector_metadata(detectors_json: str) -> None: + with pytest.raises(ValueError, match=r"JSON list|integer|record offset|meas_id"): + _dem_text(detectors_json=detectors_json) + + +def test_from_guppy_rejects_json_tracked_pauli_observables() -> None: + with pytest.raises(ValueError, match="tracked_pauli"): + _dem_text(observables_json='[{"kind":"tracked_pauli","label":"x","pauli":"X0"}]') + + +def test_from_guppy_dynamic_control_is_unsupported_and_unguarded() -> None: + """Measurement-dependent control flow is unsupported/undefined. + + A prior runtime-trace guard false-positived on the standard surface code + (statically-scheduled post-measurement gates look the same in the trace), + so it was reverted. This test pins that NO guard rejects programs here -- + from_guppy must not raise on either a dynamic program or, by extension, + the surface code. The DEM for a dynamic program is undefined/seed-dependent + and callers must not rely on it (see from_guppy docstring / proposal 001). + """ + for s in (0, 2, 5): + dem = DetectorErrorModel.from_guppy( + _measurement_feedback, + num_qubits=2, + detectors_json='[{"id":0,"records":[-2,-1]}]', + p1=0.0, + p2=0.0, + p_meas=0.1, + p_prep=0.0, + seed=s, + ) + assert dem.num_detectors == 1 # builds (undefined content; do not rely) + + +def test_lowered_replay_uses_measure_result_ids_directly() -> None: + chunks = [ + { + "operations": [ + {"AllocateResult": {"id": 42}}, + {"AllocateResult": {"id": 99}}, + {"Quantum": {"Measure": [0, 99]}}, + {"Quantum": {"Measure": [1, 42]}}, + ], + "lowered_quantum_ops": [ + {"gate_type": "MZ", "qubits": [0], "angles": []}, + {"gate_type": "MZ", "qubits": [1], "angles": []}, + ], + }, + ] + + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + + assert _flat_mz_ids(tc) == [99, 42] + + +def test_lowered_replay_fails_on_measurement_count_mismatch() -> None: + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 7]}}], + "lowered_quantum_ops": [{"gate_type": "MZ", "qubits": [0, 1], "angles": []}], + }, + ] + + with pytest.raises(ValueError, match="More measured qubits"): + _replay_lowered_qis_trace_into_tick_circuit(chunks) + + +def test_non_lowered_replay_preserves_non_sequential_result_ids() -> None: + operations = [ + {"AllocateQubit": {"id": 10}}, + {"AllocateQubit": {"id": 20}}, + {"Quantum": {"Measure": [10, 77]}}, + {"Quantum": {"Measure": [20, 3]}}, + ] + + tc = _replay_qis_trace_into_tick_circuit(operations) + + assert _flat_mz_ids(tc) == [77, 3] + + +def test_from_guppy_surface_code_is_byte_identical_to_reference() -> None: + """Regression: from_guppy(make_surface_code(...)) must work and match the + traced_qis reference DEM. A reverted dynamic-control guard had broken this + exact path (it false-positived on surface's post-measurement gates).""" + p = {"p1": 0.005, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} + for basis in ("Z", "X"): + patch = SurfacePatch.create(distance=3) + ref = _build_surface_tick_circuit_for_native_model( + patch, + 3, + basis, + circuit_source="traced_qis", + ) + ref.lower_clifford_rotations() + ref.assign_missing_meas_ids() + ref_dem = DetectorErrorModel.from_circuit(ref, **p).to_string() + got = DetectorErrorModel.from_guppy( + make_surface_code(distance=3, num_rounds=3, basis=basis), + num_qubits=get_num_qubits(3), + detectors_json=ref.get_meta("detectors"), + observables_json=ref.get_meta("observables"), + num_measurements=int(ref.get_meta("num_measurements")), + **p, + ).to_string() + assert got == ref_dem, f"surface from_guppy not byte-identical ({basis})" + + +def test_from_guppy_out_of_range_record_fails_loud() -> None: + with pytest.raises(ValueError, match=r"out of range|record offset"): + _dem_text(detectors_json='[{"id":0,"records":[-2]}]') # only 1 measurement + + +def test_from_guppy_out_of_range_meas_id_fails_loud() -> None: + with pytest.raises(ValueError, match=r"meas_id|not present"): + _dem_text(detectors_json='[{"id":0,"meas_ids":[999]}]') diff --git a/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py b/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py index aceeb3050..84e03a7a5 100644 --- a/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py +++ b/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py @@ -87,6 +87,23 @@ def test_tick_circuit_metadata_helpers_build_detector_and_observable_json() -> N assert int(tc.get_meta("num_observables")) == 3 +def test_malformed_dem_metadata_fails_loud_from_circuit_entrypoints() -> None: + from pecos.qec import DemSampler, DetectorErrorModel + from pecos.quantum import TickCircuit + + tc = TickCircuit() + tc.tick().mz([0]) + tc.set_meta("num_measurements", "1") + tc.set_meta("detectors", '[{"id":0,"records":["-1"]}]') + tc.set_meta("observables", "[]") + + with pytest.raises(ValueError, match="record offsets must be integers"): + DetectorErrorModel.from_circuit(tc, p1=0.0, p2=0.0, p_meas=0.1, p_prep=0.0) + + with pytest.raises(ValueError, match="Invalid detector/observable metadata"): + DemSampler.from_circuit(tc, p1=0.0, p2=0.0, p_meas=0.1, p_prep=0.0) + + def test_tracked_pauli_public_api_uses_current_names_only() -> None: from pecos.quantum import DagCircuit, GateRegistry, GateType, TickCircuit, X From a545393ba2edc59954a73491a528df6a67488fee Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 19 May 2026 11:26:25 -0600 Subject: [PATCH 12/36] Route all circuit-ingest and public DEM paths through fallible try_build, reject inconsistent num_measurements --- .../fault_tolerance/dem_builder/builder.rs | 69 +++++++++- .../fault_tolerance/dem_builder/sampler.rs | 17 ++- ...001-from-guppy-tag-referenced-detectors.md | 22 +++- .../src/fault_tolerance_bindings.rs | 8 +- python/quantum-pecos/src/pecos/qec/dem.py | 2 +- .../src/pecos/qec/surface/decode.py | 6 +- .../tests/qec/test_dem_metadata_fail_loud.py | 122 ++++++++++++++++++ 7 files changed, 230 insertions(+), 16 deletions(-) create mode 100644 python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index bbf2740d0..428522576 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -503,12 +503,38 @@ impl<'a> DemBuilder<'a> { .collect() } + /// Validates metadata refs, then builds the Detector Error Model. + /// + /// This is the fail-loud entry point. Every path that ingests + /// detector/observable metadata derived from a circuit (the + /// `from_circuit` family, [`DemSampler::from_circuit`], and the public + /// Python `DemBuilder.build`) must go through here so an out-of-range + /// record offset or `meas_id` is rejected rather than silently dropped. + /// + /// [`Self::build`] is the infallible counterpart, kept for the raw, + /// decoupled construction case (e.g. an empty influence map where record + /// offsets are opaque DEM coordinates) and so existing callers do not + /// change behavior. + /// + /// # Errors + /// + /// Returns [`DemBuilderError::ParseError`] if a used record offset is out + /// of range for `num_measurements`, or a used `meas_id` is + /// `>= num_measurements`. + pub fn try_build(&self) -> Result { + self.validate_metadata_refs()?; + Ok(self.build()) + } + /// Builds the Detector Error Model with source tracking. /// /// This performs fault propagation analysis and tracks error sources (X/Z vs Y) /// through the pipeline, enabling accurate direct/decomposed form splitting. /// /// Use `dem.to_string()` or `dem.to_string_decomposed()` for output. + /// + /// This does **not** validate metadata refs; callers ingesting + /// circuit-derived metadata must use [`Self::try_build`] instead. #[must_use] pub fn build(&self) -> DetectorErrorModel { let num_influence_dem_outputs = self @@ -1465,13 +1491,20 @@ fn build_dem_from_circuit( }; let builder = if let Some(n) = num_meas { + let actual = influence_map.measurements.len(); + if n != actual { + return Err(DemBuilderError::ParseError(format!( + "circuit declares num_measurements={n} but the circuit \ + performs {actual} measurement(s); the declared count must \ + match so detector/observable record offsets resolve correctly" + ))); + } builder.with_num_measurements(n) } else { builder }; - builder.validate_metadata_refs()?; - Ok(builder.build()) + builder.try_build() } fn observable_records_from_annotations( @@ -2098,6 +2131,38 @@ mod tests { assert_eq!(dem.dem_outputs()[0].records.as_slice(), &[-2]); } + #[test] + fn test_try_build_rejects_out_of_range_record_and_meas_id() { + let influence_map = DagFaultInfluenceMap::with_capacity(0); + + let bad_record = DemBuilder::new(&influence_map) + .with_detectors_json(r#"[{"id": 0, "records": [-2]}]"#) + .unwrap() + .with_num_measurements(1) + .try_build(); + assert!( + bad_record.is_err(), + "out-of-range record must fail try_build" + ); + + let bad_meas_id = DemBuilder::new(&influence_map) + .with_detectors_json(r#"[{"id": 0, "meas_ids": [999]}]"#) + .unwrap() + .with_num_measurements(1) + .try_build(); + assert!( + bad_meas_id.is_err(), + "out-of-range meas_id must fail try_build" + ); + + // The infallible `build` stays lax for the decoupled/raw case so + // existing pass-through callers are unaffected. + let _ = DemBuilder::new(&influence_map) + .with_observables_json(r#"[{"id": 0, "records": [-1, -3]}]"#) + .unwrap() + .build(); + } + #[test] fn test_parse_empty_json() { assert!(parse_detectors_json("").unwrap().is_empty()); diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs index bd92d8b87..808a5c16f 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -484,12 +484,27 @@ impl DemSampler { }; let builder = if let Some(n) = num_meas { + let actual = influence_map.measurements.len(); + if n != actual { + return Err(DetectorValidationError::InvalidMetadata { + message: format!( + "circuit declares num_measurements={n} but the circuit \ + performs {actual} measurement(s); the declared count \ + must match so detector/observable record offsets \ + resolve correctly" + ), + }); + } builder.with_num_measurements(n) } else { builder }; - let dem = builder.build(); + let dem = builder + .try_build() + .map_err(|err| DetectorValidationError::InvalidMetadata { + message: err.to_string(), + })?; Ok(Self::from_detector_error_model(&dem)) } diff --git a/docs/proposals/001-from-guppy-tag-referenced-detectors.md b/docs/proposals/001-from-guppy-tag-referenced-detectors.md index 832b13cf2..964a41f51 100644 --- a/docs/proposals/001-from-guppy-tag-referenced-detectors.md +++ b/docs/proposals/001-from-guppy-tag-referenced-detectors.md @@ -401,11 +401,23 @@ Resolution: normalized to records), malformed-JSON fail-loud in `from_guppy`, JSON `tracked_pauli` rejection, direct `from_circuit`/`DemSampler` fail-loud on missing-id / malformed metadata. -- Residual (addressed here): out-of-range / unresolved `records`/`meas_ids` - still silently weakened the DEM in the Rust metadata path; the Rust/Python - schema duplicated and diverged; clippy `-D warnings` failed (missing - `# Panics` docs); the public subclass-identity hazard (#6) is documented - (no internal `isinstance` use; public-API caveat only). +- Residual out-of-range / unresolved `records`/`meas_ids` (a third review + found the first fix only covered `DetectorErrorModel.from_circuit`, leaving + `DemSampler.from_circuit` and the public `DemBuilder.build` still + silently-weakening, and a declared `num_measurements` that disagreed with + the circuit bypassed the range check entirely). **Now fully addressed:** a + single fallible `DemBuilder::try_build` runs `validate_metadata_refs` and + every circuit-ingest / public path (`from_circuit`, `DemSampler::from_circuit`, + `PyDemBuilder::build`) routes through it; a metadata `num_measurements` that + does not match the circuit's actual measurement count is rejected at ingest. + The infallible `build` stays lax only for the decoupled/raw construction + case (empty influence map, opaque pass-through record offsets) so existing + callers are unaffected. +- Also addressed: clippy `-D warnings` (missing `# Panics` docs); the public + subclass-identity hazard (#6) is documented (no internal `isinstance` use; + public-API caveat only). The Rust/Python schema duplication/divergence + remains a known follow-up (not a correctness defect now that all ingest + paths fail loud). So the previous "proven sound for straight-line / surface byte-identical" statement is accurate again *only after the guard revert*; it was false in the diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 385472f39..c443778b2 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -1357,7 +1357,9 @@ impl PyDemBuilder { /// A `DetectorErrorModel` that can be converted to string format. /// /// Raises: - /// `ValueError`: If the detector or observable JSON is malformed. + /// `ValueError`: If the detector or observable JSON is malformed, or + /// a used record offset / `meas_id` is out of range for the + /// configured measurement count. fn build(&self) -> PyResult { let mut builder = RustDemBuilder::new(&self.influence_map).with_noise_config(self.noise.clone()); @@ -1382,7 +1384,9 @@ impl PyDemBuilder { .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; } - let inner = builder.build(); + let inner = builder + .try_build() + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; Ok(PyDetectorErrorModel { inner }) } diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index d86f602bb..ebf9c98de 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -209,7 +209,7 @@ def _normalize_entry_ids(blob: str, prefix: str) -> str: if not isinstance(entry, dict) or not isinstance(entry.get("id"), str): continue raw = entry["id"].strip() - body = raw[len(prefix):] if raw.startswith(prefix) else None + body = raw[len(prefix) :] if raw.startswith(prefix) else None if body is None or not body.isdigit(): msg = ( f"id {entry['id']!r} is not a valid identifier for this list; " diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 72e88ee99..25469f3ec 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -717,11 +717,7 @@ def trace_guppy_into_tick_circuit(program: Any, num_qubits: int, *, seed: int = import pecos sim_builder = ( - pecos.sim(program) - .classical(pecos.selene_engine()) - .quantum(pecos.stabilizer()) - .qubits(num_qubits) - .seed(seed) + pecos.sim(program).classical(pecos.selene_engine()).quantum(pecos.stabilizer()).qubits(num_qubits).seed(seed) ) chunks = list(sim_builder.capture_operation_trace()) diff --git a/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py b/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py new file mode 100644 index 000000000..64fc74734 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py @@ -0,0 +1,122 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Fail-loud regression tests for circuit-ingested DEM metadata. + +Out-of-range record offsets / meas_ids, and a declared ``num_measurements`` +that disagrees with the circuit, must be rejected on every circuit-ingest +path -- ``DetectorErrorModel.from_circuit``, ``DemSampler.from_circuit``, +and the public ``DemBuilder.build`` -- not silently dropped. +""" + +import pytest +from pecos_rslib import DagCircuit +from pecos_rslib.qec import ( + DagFaultAnalyzer, + DemBuilder, + DemSampler, + DetectorErrorModel, +) + + +def _one_measurement_dag(*, num_measurements: str = "1") -> DagCircuit: + """A circuit performing exactly one Z measurement.""" + dag = DagCircuit() + dag.pz([0]) + dag.mz([0]) + dag.set_attr("num_measurements", num_measurements) + return dag + + +_NOISE = {"p1": 0.0, "p2": 0.0, "p_meas": 0.1, "p_prep": 0.0} + + +# --- positive controls: valid metadata still builds on every path ---------- + + +def test_valid_metadata_builds_on_all_paths() -> None: + dag = _one_measurement_dag() + dag.set_attr("detectors", '[{"id": 0, "records": [-1]}]') + + assert DetectorErrorModel.from_circuit(dag, **_NOISE).num_detectors == 1 + assert DemSampler.from_circuit(dag, **_NOISE).num_detectors == 1 + + im = DagFaultAnalyzer(_one_measurement_dag()).build_influence_map() + builder = DemBuilder(im) + builder.with_noise(**_NOISE) + builder.with_num_measurements(1) + builder.with_detectors_json('[{"id": 0, "records": [-1]}]') + assert builder.build().num_detectors == 1 + + +# --- out-of-range record offsets ------------------------------------------- + + +def test_from_circuit_out_of_range_record_fails_loud() -> None: + dag = _one_measurement_dag() + dag.set_attr("detectors", '[{"id": 0, "records": [-2]}]') + with pytest.raises(ValueError, match=r"out of range|record offset"): + DetectorErrorModel.from_circuit(dag, **_NOISE) + + +def test_dem_sampler_out_of_range_record_fails_loud() -> None: + dag = _one_measurement_dag() + dag.set_attr("detectors", '[{"id": 0, "records": [-2]}]') + with pytest.raises(ValueError, match=r"out of range|record offset"): + DemSampler.from_circuit(dag, **_NOISE) + + +def test_public_dem_builder_out_of_range_record_fails_loud() -> None: + im = DagFaultAnalyzer(_one_measurement_dag()).build_influence_map() + builder = DemBuilder(im) + builder.with_noise(**_NOISE) + builder.with_num_measurements(1) + builder.with_detectors_json('[{"id": 0, "records": [-2]}]') + with pytest.raises(ValueError, match=r"out of range|record offset"): + builder.build() + + +# --- out-of-range meas_ids ------------------------------------------------- + + +def test_from_circuit_out_of_range_meas_id_fails_loud() -> None: + dag = _one_measurement_dag() + dag.set_attr("detectors", '[{"id": 0, "meas_ids": [999]}]') + with pytest.raises(ValueError, match="meas_id"): + DetectorErrorModel.from_circuit(dag, **_NOISE) + + +def test_dem_sampler_out_of_range_meas_id_fails_loud() -> None: + dag = _one_measurement_dag() + dag.set_attr("detectors", '[{"id": 0, "meas_ids": [999]}]') + with pytest.raises(ValueError, match="meas_id"): + DemSampler.from_circuit(dag, **_NOISE) + + +def test_public_dem_builder_out_of_range_meas_id_fails_loud() -> None: + im = DagFaultAnalyzer(_one_measurement_dag()).build_influence_map() + builder = DemBuilder(im) + builder.with_noise(**_NOISE) + builder.with_num_measurements(1) + builder.with_detectors_json('[{"id": 0, "meas_ids": [999]}]') + with pytest.raises(ValueError, match="meas_id"): + builder.build() + + +# --- bogus declared num_measurements --------------------------------------- + + +def test_from_circuit_inconsistent_num_measurements_fails_loud() -> None: + """Declaring 2 measurements on a 1-measurement circuit must be rejected; + otherwise a record offset of -2 would falsely validate and misbind.""" + dag = _one_measurement_dag(num_measurements="2") + dag.set_attr("detectors", '[{"id": 0, "records": [-2]}]') + with pytest.raises(ValueError, match="num_measurements"): + DetectorErrorModel.from_circuit(dag, **_NOISE) + + +def test_dem_sampler_inconsistent_num_measurements_fails_loud() -> None: + dag = _one_measurement_dag(num_measurements="2") + dag.set_attr("detectors", '[{"id": 0, "records": [-2]}]') + with pytest.raises(ValueError, match="num_measurements"): + DemSampler.from_circuit(dag, **_NOISE) From 8971de1435bf8ca2ee6bc77232ff7f4dddfe938f Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 19 May 2026 11:52:31 -0600 Subject: [PATCH 13/36] Enforce num_measurements/influence-map consistency in try_build, closing the public-builder bypass --- .../fault_tolerance/dem_builder/builder.rs | 53 +++++++++++++++---- .../fault_tolerance/dem_builder/sampler.rs | 13 +---- ...001-from-guppy-tag-referenced-detectors.md | 19 ++++--- .../tests/qec/test_dem_metadata_fail_loud.py | 24 +++++++++ 4 files changed, 80 insertions(+), 29 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 428522576..430f9724c 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -516,12 +516,37 @@ impl<'a> DemBuilder<'a> { /// offsets are opaque DEM coordinates) and so existing callers do not /// change behavior. /// + /// Rejects a `num_measurements` that disagrees with a non-empty influence + /// map. + /// + /// When the builder is fed a real circuit (the influence map has + /// measurements), record offsets and `meas_id`s are defined against that + /// circuit's actual measurement record. A caller-supplied + /// `with_num_measurements` that differs would let out-of-range refs pass + /// [`Self::validate_metadata_refs`] and silently misbind, so it is an + /// error. An empty influence map keeps the escape hatch: the count is then + /// purely declarative and record offsets are opaque pass-through DEM + /// coordinates. + fn validate_measurement_count(&self) -> Result<(), DemBuilderError> { + let actual = self.influence_map.measurements.len(); + if actual != 0 && self.num_measurements != actual { + return Err(DemBuilderError::ParseError(format!( + "num_measurements={} disagrees with the {actual} measurement(s) \ + the circuit performs; the declared count must match so \ + detector/observable record offsets resolve correctly", + self.num_measurements + ))); + } + Ok(()) + } + /// # Errors /// - /// Returns [`DemBuilderError::ParseError`] if a used record offset is out - /// of range for `num_measurements`, or a used `meas_id` is - /// `>= num_measurements`. + /// Returns [`DemBuilderError::ParseError`] if `num_measurements` disagrees + /// with a non-empty influence map, a used record offset is out of range + /// for `num_measurements`, or a used `meas_id` is `>= num_measurements`. pub fn try_build(&self) -> Result { + self.validate_measurement_count()?; self.validate_metadata_refs()?; Ok(self.build()) } @@ -1490,15 +1515,9 @@ fn build_dem_from_circuit( builder }; + // `try_build` enforces num_measurements == influence-map count, so a + // metadata override that disagrees with the circuit is rejected there. let builder = if let Some(n) = num_meas { - let actual = influence_map.measurements.len(); - if n != actual { - return Err(DemBuilderError::ParseError(format!( - "circuit declares num_measurements={n} but the circuit \ - performs {actual} measurement(s); the declared count must \ - match so detector/observable record offsets resolve correctly" - ))); - } builder.with_num_measurements(n) } else { builder @@ -2161,6 +2180,18 @@ mod tests { .with_observables_json(r#"[{"id": 0, "records": [-1, -3]}]"#) .unwrap() .build(); + + // Empty influence map keeps the escape hatch: a declared count with + // no real measurements is allowed (opaque pass-through coordinates). + assert!( + DemBuilder::new(&influence_map) + .with_detectors_json(r#"[{"id": 0, "meas_ids": [0, 2]}]"#) + .unwrap() + .with_num_measurements(3) + .try_build() + .is_ok(), + "empty influence map must keep the declarative-count escape hatch" + ); } #[test] diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs index 808a5c16f..5aad8b64e 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -483,18 +483,9 @@ impl DemSampler { builder }; + // `try_build` enforces num_measurements == influence-map count, so a + // metadata override that disagrees with the circuit is rejected there. let builder = if let Some(n) = num_meas { - let actual = influence_map.measurements.len(); - if n != actual { - return Err(DetectorValidationError::InvalidMetadata { - message: format!( - "circuit declares num_measurements={n} but the circuit \ - performs {actual} measurement(s); the declared count \ - must match so detector/observable record offsets \ - resolve correctly" - ), - }); - } builder.with_num_measurements(n) } else { builder diff --git a/docs/proposals/001-from-guppy-tag-referenced-detectors.md b/docs/proposals/001-from-guppy-tag-referenced-detectors.md index 964a41f51..9deac9953 100644 --- a/docs/proposals/001-from-guppy-tag-referenced-detectors.md +++ b/docs/proposals/001-from-guppy-tag-referenced-detectors.md @@ -406,13 +406,18 @@ Resolution: `DemSampler.from_circuit` and the public `DemBuilder.build` still silently-weakening, and a declared `num_measurements` that disagreed with the circuit bypassed the range check entirely). **Now fully addressed:** a - single fallible `DemBuilder::try_build` runs `validate_metadata_refs` and - every circuit-ingest / public path (`from_circuit`, `DemSampler::from_circuit`, - `PyDemBuilder::build`) routes through it; a metadata `num_measurements` that - does not match the circuit's actual measurement count is rejected at ingest. - The infallible `build` stays lax only for the decoupled/raw construction - case (empty influence map, opaque pass-through record offsets) so existing - callers are unaffected. + single fallible `DemBuilder::try_build` runs both + `validate_measurement_count` and `validate_metadata_refs`, and every + circuit-ingest / public path (`from_circuit`, `DemSampler::from_circuit`, + `PyDemBuilder::build`) routes through it. A fourth review then found the + count check was duplicated only in the two circuit-ingest paths, so the + public `DemBuilder` with a non-empty influence map plus an inconsistent + `with_num_measurements` still bypassed it; the check now lives in + `try_build` itself (single source of truth) and rejects any + `num_measurements` that disagrees with a non-empty influence map. The + infallible `build` stays lax only for the decoupled/raw construction case + (empty influence map, opaque pass-through record offsets — the declarative + escape hatch) so existing callers are unaffected. - Also addressed: clippy `-D warnings` (missing `# Panics` docs); the public subclass-identity hazard (#6) is documented (no internal `isinstance` use; public-API caveat only). The Rust/Python schema duplication/divergence diff --git a/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py b/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py index 64fc74734..84789f454 100644 --- a/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py +++ b/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py @@ -120,3 +120,27 @@ def test_dem_sampler_inconsistent_num_measurements_fails_loud() -> None: dag.set_attr("detectors", '[{"id": 0, "records": [-2]}]') with pytest.raises(ValueError, match="num_measurements"): DemSampler.from_circuit(dag, **_NOISE) + + +def test_public_dem_builder_inconsistent_num_measurements_fails_loud() -> None: + """Public builder with a real (non-empty) influence map must reject a + with_num_measurements() that disagrees with the circuit; otherwise an + out-of-range record (e.g. -2 against 1 measurement) silently misbinds.""" + im = DagFaultAnalyzer(_one_measurement_dag()).build_influence_map() + builder = DemBuilder(im) + builder.with_noise(**_NOISE) + builder.with_num_measurements(2) # circuit performs only 1 measurement + builder.with_detectors_json('[{"id": 0, "records": [-2]}]') + with pytest.raises(ValueError, match="num_measurements"): + builder.build() + + +def test_public_dem_builder_consistent_num_measurements_still_builds() -> None: + """The matching-count case (and the empty-influence-map escape hatch) + must keep working -- the count check only fires on a genuine mismatch.""" + im = DagFaultAnalyzer(_one_measurement_dag()).build_influence_map() + builder = DemBuilder(im) + builder.with_noise(**_NOISE) + builder.with_num_measurements(1) + builder.with_detectors_json('[{"id": 0, "records": [-1]}]') + assert builder.build().num_detectors == 1 From 7b1a04cbe4ef9a9d61392eae0079cfc12c439cca Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 19 May 2026 12:19:45 -0600 Subject: [PATCH 14/36] Replace TRY004 noqas with a private _MetadataError type; drop stray ccengine blank line --- crates/pecos-qis/src/ccengine.rs | 1 - python/quantum-pecos/src/pecos/qec/dem.py | 23 ++++++++++++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index e244847db..5c85fe079 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -1235,7 +1235,6 @@ impl ClassicalEngine for QisEngine { self.measurement_results.len(), has_named_results ); - Ok(shot) } diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index ebf9c98de..1668d2ea2 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -22,6 +22,18 @@ from pecos_rslib.qec import DetectorErrorModel as _RustDetectorErrorModel +class _MetadataError(ValueError): + """Malformed ``from_guppy`` detector/observable metadata. + + A ``ValueError`` subclass deliberately raised for type/shape violations + too (where ``TypeError`` would be the Pythonic default), so that *every* + way the caller's metadata can be wrong -- wrong type, wrong shape, + out-of-range ref -- surfaces as one consistent failure type matching + ``from_guppy``'s documented contract. Existing ``except ValueError`` / + ``pytest.raises(ValueError)`` callers keep working. + """ + + def _collect_measurement_info(tc: Any) -> tuple[int, dict[int, int]]: """Return (measurement count, MeasId -> measurement index) for the traced circuit. @@ -87,25 +99,22 @@ def _validate_measurement_contract( def _require_int(value: Any, label: str) -> int: if not isinstance(value, int) or isinstance(value, bool): msg = f"{label} must be an integer" - raise ValueError(msg) # noqa: TRY004 + raise _MetadataError(msg) return value def _require_list(value: Any, label: str) -> list[Any]: if not isinstance(value, list): msg = f"{label} must be a list" - raise ValueError(msg) # noqa: TRY004 + raise _MetadataError(msg) return value def _check(kind: str, entries: list[dict[str, Any]]) -> list[dict[str, Any]]: alt_id = "detector_id" if kind == "Detector" else "observable_id" normalized_entries: list[dict[str, Any]] = [] - # NB: malformed input raises ValueError (not TypeError) to keep one - # consistent failure type across from_guppy's documented contract and - # the sibling record/meas_id checks below -- hence the TRY004 noqas. for entry in entries: if not isinstance(entry, dict): msg = f"{kind} entry is not a JSON object: {entry!r}" - raise ValueError(msg) # noqa: TRY004 + raise _MetadataError(msg) # Tracked Paulis reference qubits via "pauli", not measurements. if entry.get("kind") == "tracked_pauli": msg = ( @@ -176,7 +185,7 @@ def _check(kind: str, entries: list[dict[str, Any]]) -> list[dict[str, Any]]: if not isinstance(detectors, list) or not isinstance(observables, list): msg = "detectors_json and observables_json must each be a JSON list" - raise ValueError(msg) # noqa: TRY004 + raise _MetadataError(msg) normalized_detectors = _check("Detector", detectors) normalized_observables = _check("Observable", observables) From 52255938187a8dc6616bb83a6acb5caa4a5b2fb9 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 19 May 2026 13:23:03 -0600 Subject: [PATCH 15/36] Consolidate detector/observable schema validation into the Rust DEM builder; thin from_guppy wrapper --- .../fault_tolerance/dem_builder/builder.rs | 165 +++++++++-- python/quantum-pecos/src/pecos/qec/dem.py | 269 ++---------------- .../tests/qec/test_from_guppy_dem.py | 26 ++ 3 files changed, 201 insertions(+), 259 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 430f9724c..1c3961d87 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -435,11 +435,29 @@ impl<'a> DemBuilder<'a> { self } - fn meas_id_to_record_offset(&self, meas_id: usize) -> Option { - if meas_id >= self.num_measurements { - return None; + /// Resolves a JSON `meas_id` to a circuit measurement-record index. + /// + /// When the circuit carries stable `MeasId`s (the traced + /// `from_guppy`/`from_circuit` path), `meas_id` is interpreted as that + /// **stable stamped id** and looked up in `influence_map.meas_ids` -- so a + /// non-sequential traced id (e.g. the QIS result slot) resolves correctly + /// regardless of compilation reordering. When no stable ids are present + /// (the decoupled/raw builder with an empty influence map), `meas_id` is a + /// positional measurement index (the legacy escape hatch). Returns the + /// `0..num_measurements` record index, or `None` if the id is absent. + fn resolve_meas_id_to_tc_index(&self, meas_id: usize) -> Option { + if self.influence_map.meas_ids.is_empty() { + return (meas_id < self.num_measurements).then_some(meas_id); } - let measurement = i64::try_from(meas_id).ok()?; + self.influence_map + .meas_ids + .iter() + .position(|mid| mid.0 == meas_id) + } + + fn meas_id_to_record_offset(&self, meas_id: usize) -> Option { + let index = self.resolve_meas_id_to_tc_index(meas_id)?; + let measurement = i64::try_from(index).ok()?; let total = i64::try_from(self.num_measurements).ok()?; i32::try_from(measurement - total).ok() } @@ -462,10 +480,10 @@ impl<'a> DemBuilder<'a> { let check = |kind: &str, id: u32, records: &[i32], meas_ids: &[usize]| { if records.is_empty() { for &mid in meas_ids { - if mid >= self.num_measurements { + if self.resolve_meas_id_to_tc_index(mid).is_none() { return Err(DemBuilderError::ParseError(format!( - "{kind} {id} references meas_id {mid}, but the \ - circuit has only {} measurement(s)", + "{kind} {id} references meas_id {mid}, which is \ + not present in the circuit's {} measurement(s)", self.num_measurements ))); } @@ -537,6 +555,19 @@ impl<'a> DemBuilder<'a> { self.num_measurements ))); } + // Internal-consistency guard: stable MeasIds must be unique. A + // duplicate would make stamped-id resolution bind to the wrong + // measurement; it indicates a trace/replay bug, not bad caller input. + let mut seen = std::collections::HashSet::with_capacity(self.influence_map.meas_ids.len()); + for mid in &self.influence_map.meas_ids { + if !seen.insert(mid.0) { + return Err(DemBuilderError::ParseError(format!( + "duplicate stable MeasId {} in the traced circuit; each \ + measurement must have a unique stamped id", + mid.0 + ))); + } + } Ok(()) } @@ -1062,7 +1093,9 @@ impl<'a> DemBuilder<'a> { for det in &self.detectors { if det.records.is_empty() { for &meas_id in &det.meas_ids { - if let Some(&influence_idx) = tc_to_influence.get(&meas_id) { + if let Some(tc_idx) = self.resolve_meas_id_to_tc_index(meas_id) + && let Some(&influence_idx) = tc_to_influence.get(&tc_idx) + { meas_to_detectors .entry(influence_idx) .or_default() @@ -1090,7 +1123,9 @@ impl<'a> DemBuilder<'a> { } if obs.records.is_empty() { for &meas_id in &obs.meas_ids { - if let Some(&influence_idx) = tc_to_influence.get(&meas_id) { + if let Some(tc_idx) = self.resolve_meas_id_to_tc_index(meas_id) + && let Some(&influence_idx) = tc_to_influence.get(&tc_idx) + { meas_to_observables .entry(influence_idx) .or_default() @@ -1301,7 +1336,7 @@ fn parse_detectors_json(json: &str) -> Result, DemBuilderErr })?; let array = parsed .as_array() - .ok_or_else(|| DemBuilderError::ParseError("detectors JSON must be an array".into()))?; + .ok_or_else(|| DemBuilderError::ParseError("detectors_json must be a JSON list".into()))?; array.iter().map(parse_single_detector).collect() } @@ -1310,9 +1345,11 @@ fn parse_single_detector(value: &serde_json::Value) -> Result Result, DemBuilde let parsed: serde_json::Value = serde_json::from_str(json).map_err(|err| { DemBuilderError::ParseError(format!("observables JSON is malformed: {err}")) })?; - let array = parsed - .as_array() - .ok_or_else(|| DemBuilderError::ParseError("observables JSON must be an array".into()))?; + let array = parsed.as_array().ok_or_else(|| { + DemBuilderError::ParseError("observables_json must be a JSON list".into()) + })?; array.iter().map(parse_single_observable).collect() } @@ -1349,9 +1386,11 @@ fn parse_single_observable(value: &serde_json::Value) -> Result Result, + kind: &str, +) -> Result<(), DemBuilderError> { + if object.get("kind").and_then(serde_json::Value::as_str) == Some("tracked_pauli") { + return Err(DemBuilderError::ParseError(format!( + "{kind} entry uses kind=\"tracked_pauli\", which is not supported \ + in detectors_json/observables_json (tracked Paulis come only \ + from circuit annotations)" + ))); + } + Ok(()) +} + +/// Reads an entry id as either an unsigned integer or the DEM-label string +/// form (`prefix` is `'D'` for detectors, `'L'` for observables, e.g. +/// `"D0"`/`"L0"`); both normalize to the same integer. A string id with the +/// wrong prefix or a non-numeric body is a hard error -- silently +/// reinterpreting it would risk a mislabeled DEM. fn extract_u32( object: &serde_json::Map, keys: &[&str], + prefix: char, missing_message: &str, range_message: &str, ) -> Result { @@ -1375,10 +1440,27 @@ fn extract_u32( .iter() .find_map(|key| object.get(*key)) .ok_or_else(|| DemBuilderError::ParseError(missing_message.into()))?; - let raw = value.as_u64().ok_or_else(|| { - DemBuilderError::ParseError(format!("{missing_message}: expected unsigned integer")) - })?; - u32::try_from(raw).map_err(|_| DemBuilderError::ParseError(range_message.into())) + if let Some(raw) = value.as_u64() { + return u32::try_from(raw).map_err(|_| DemBuilderError::ParseError(range_message.into())); + } + if let Some(s) = value.as_str() { + let body = s.strip_prefix(prefix); + if let Some(digits) = body + && !digits.is_empty() + && digits.bytes().all(|b| b.is_ascii_digit()) + { + return digits + .parse::() + .map_err(|_| DemBuilderError::ParseError(range_message.into())); + } + return Err(DemBuilderError::ParseError(format!( + "id {s:?} is not a valid identifier; expected an integer or the \ + {prefix:?}-prefixed form like {prefix}0" + ))); + } + Err(DemBuilderError::ParseError(format!( + "{missing_message}: expected an integer or {prefix:?}-prefixed string id" + ))) } /// Extracts coordinates array [x, y, t]. @@ -1448,6 +1530,13 @@ fn extract_measurement_refs( Vec::new() }; + if records.is_empty() && meas_ids.is_empty() { + return Err(DemBuilderError::ParseError(format!( + "{kind} entry has neither 'records' nor 'meas_ids'; it would \ + contribute nothing and silently weaken the DEM" + ))); + } + Ok((records, meas_ids)) } @@ -2194,6 +2283,48 @@ mod tests { ); } + #[test] + fn test_parse_accepts_dem_label_id_form() { + let det = parse_detectors_json(r#"[{"id": "D0", "records": [-1]}]"#).unwrap(); + assert_eq!(det[0].id, 0); + let obs = parse_observables_json(r#"[{"id": "L7", "records": [-1]}]"#).unwrap(); + assert_eq!(obs[0].id, 7); + // Wrong prefix / non-numeric body is a hard error, not a guess. + assert!(parse_detectors_json(r#"[{"id": "L0", "records": [-1]}]"#).is_err()); + assert!(parse_detectors_json(r#"[{"id": "X0", "records": [-1]}]"#).is_err()); + assert!(parse_observables_json(r#"[{"id": "Lx", "records": [-1]}]"#).is_err()); + } + + #[test] + fn test_parse_rejects_tracked_pauli_and_refless_entries() { + assert!( + parse_observables_json(r#"[{"kind": "tracked_pauli", "pauli": "X0"}]"#).is_err(), + "tracked_pauli must be rejected in observables_json", + ); + assert!( + parse_detectors_json(r#"[{"id": 0, "kind": "tracked_pauli"}]"#).is_err(), + "tracked_pauli must be rejected in detectors_json too", + ); + assert!( + parse_detectors_json(r#"[{"id": 0}]"#).is_err(), + "an entry with neither records nor meas_ids must be rejected", + ); + } + + #[test] + fn test_validate_measurement_count_rejects_duplicate_stamped_meas_id() { + let mut influence_map = DagFaultInfluenceMap::with_capacity(0); + influence_map.meas_ids = vec![pecos_core::MeasId(5), pecos_core::MeasId(5)]; + let result = DemBuilder::new(&influence_map) + .with_detectors_json(r#"[{"id": 0, "meas_ids": [5]}]"#) + .unwrap() + .try_build(); + assert!( + result.is_err(), + "a duplicate stable MeasId must fail loud, not bind to the first", + ); + } + #[test] fn test_parse_empty_json() { assert!(parse_detectors_json("").unwrap().is_empty()); diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 1668d2ea2..735fa318d 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -9,229 +9,24 @@ module defines a thin Python subclass that adds :meth:`from_guppy` and is re-exported as the public ``pecos.qec.DetectorErrorModel``. -The subclass is behaviorally identical to the Rust class for every other -operation; all existing methods (``from_circuit``, ``from_pecos_metadata_json``, -``to_string``, ``to_sampler``, ...) are inherited unchanged. +This wrapper is intentionally thin: it only traces the Guppy program into a +``TickCircuit`` and hands the caller's detector/observable JSON to the Rust +DEM builder verbatim. All metadata validation -- JSON shape, ``D0``/``L0`` id +forms, tracked-Pauli rejection, ``num_measurements`` consistency, out-of-range +records, and ``meas_id`` resolution against the circuit's stable stamped +``MeasId``s -- is the single responsibility of the Rust builder +(``pecos_qec::fault_tolerance::dem_builder``), so the same rules apply +identically whether a DEM is built via ``from_guppy``, ``from_circuit``, +``DemSampler.from_circuit``, or the public ``DemBuilder``. """ from __future__ import annotations -import json from typing import Any from pecos_rslib.qec import DetectorErrorModel as _RustDetectorErrorModel -class _MetadataError(ValueError): - """Malformed ``from_guppy`` detector/observable metadata. - - A ``ValueError`` subclass deliberately raised for type/shape violations - too (where ``TypeError`` would be the Pythonic default), so that *every* - way the caller's metadata can be wrong -- wrong type, wrong shape, - out-of-range ref -- surfaces as one consistent failure type matching - ``from_guppy``'s documented contract. Existing ``except ValueError`` / - ``pytest.raises(ValueError)`` callers keep working. - """ - - -def _collect_measurement_info(tc: Any) -> tuple[int, dict[int, int]]: - """Return (measurement count, MeasId -> measurement index) for the traced circuit. - - Counts measured qubits across all MZ gates and gathers the stable MeasIds - stamped on them. - """ - dag = tc.to_dag_circuit() - count = 0 - meas_id_to_index: dict[int, int] = {} - for node_id in dag.nodes(): - gate = dag.gate(node_id) - if gate is None or gate.gate_type.name != "MZ": - continue - qubits = list(gate.qubits) - ids = list(gate.meas_ids) - if len(ids) != len(qubits): - msg = ( - "Traced Guppy circuit has an MZ gate without a stable MeasId " - f"(qubits={qubits}, meas_ids={ids}) after replay and " - "assign_missing_meas_ids(); this indicates an internal " - "inconsistency in the traced-circuit pipeline, not a problem " - "with the caller's inputs." - ) - raise ValueError(msg) - for local_idx, mid in enumerate(ids): - stable_id = int(mid) - if stable_id in meas_id_to_index: - msg = f"Duplicate measurement id {stable_id} in TickCircuit" - raise ValueError(msg) - meas_id_to_index[stable_id] = count + local_idx - count += len(qubits) - return count, meas_id_to_index - - -def _validate_measurement_contract( - tc: Any, - *, - detectors_json: str, - observables_json: str, - num_measurements: int | None, -) -> tuple[str, str]: - """Fail loudly if the caller's detector/observable JSON is inconsistent. - - Catches the common ``from_guppy`` misuse where detector ``records``/ - ``meas_ids`` do not line up with the measurements the traced program - actually emits, instead of silently building a wrong DEM. ``meas_ids`` are - normalized to negative ``records`` offsets because the Rust DEM metadata - parser consumes positional records. - """ - measured, meas_id_to_index = _collect_measurement_info(tc) - - if num_measurements is not None and num_measurements != measured: - msg = ( - f"num_measurements={num_measurements} does not match the " - f"{measured} measurement(s) the traced Guppy program emits. The " - "detector/observable record offsets are defined against the " - "traced measurement order; a mismatch means the DEM would be " - "silently wrong." - ) - raise ValueError(msg) - effective = num_measurements if num_measurements is not None else measured - - def _require_int(value: Any, label: str) -> int: - if not isinstance(value, int) or isinstance(value, bool): - msg = f"{label} must be an integer" - raise _MetadataError(msg) - return value - - def _require_list(value: Any, label: str) -> list[Any]: - if not isinstance(value, list): - msg = f"{label} must be a list" - raise _MetadataError(msg) - return value - - def _check(kind: str, entries: list[dict[str, Any]]) -> list[dict[str, Any]]: - alt_id = "detector_id" if kind == "Detector" else "observable_id" - normalized_entries: list[dict[str, Any]] = [] - for entry in entries: - if not isinstance(entry, dict): - msg = f"{kind} entry is not a JSON object: {entry!r}" - raise _MetadataError(msg) - # Tracked Paulis reference qubits via "pauli", not measurements. - if entry.get("kind") == "tracked_pauli": - msg = ( - f"{kind} entry {entry!r} uses kind='tracked_pauli', " - "which is not supported by from_guppy JSON metadata." - ) - raise ValueError(msg) - # Schema: the Rust DEM-builder JSON parser requires an integer id - # and records; on a parse failure it silently builds an empty DEM. - # Validate here so malformed input fails loud instead. - raw_id = entry.get("id", entry.get(alt_id)) - try: - _require_int(raw_id, f"{kind} id") - except ValueError as exc: - msg = ( - f"{kind} entry {entry!r} is missing a valid integer " - f"'id' (or '{alt_id}'); the DEM builder would silently " - "drop it and produce an empty DEM." - ) - raise ValueError(msg) from exc - - records = _require_list(entry.get("records", []), f"{kind} {raw_id} records") - meas_ids = _require_list(entry.get("meas_ids", []), f"{kind} {raw_id} meas_ids") - if not (records or meas_ids): - msg = ( - f"{kind} {raw_id} has no 'records' or 'meas_ids'; it " - "would contribute nothing and silently weaken the DEM." - ) - raise ValueError(msg) - normalized_records: list[int] = [] - for rec in records: - rec_int = _require_int(rec, f"{kind} {raw_id} record offset") - idx = effective + rec_int - if not 0 <= idx < effective: - msg = ( - f"{kind} {entry.get('id', entry)} references record " - f"{rec_int}, which is out of range for a circuit with " - f"{effective} measurement(s)." - ) - raise ValueError(msg) - normalized_records.append(rec_int) - for mid in meas_ids: - stable_id = _require_int(mid, f"{kind} {raw_id} meas_id") - if stable_id not in meas_id_to_index: - msg = ( - f"{kind} {entry.get('id', entry)} references " - f"meas_id {stable_id}, which is not present in the traced " - "circuit. meas_ids must match the stable MeasIds the " - "traced program assigns (one per measured qubit, in " - "trace order)." - ) - raise ValueError(msg) - normalized_records.append(meas_id_to_index[stable_id] - effective) - - normalized = dict(entry) - normalized.pop("meas_ids", None) - normalized["records"] = normalized_records - normalized_entries.append(normalized) - - return normalized_entries - - try: - detectors = json.loads(detectors_json) if detectors_json else [] - observables = json.loads(observables_json) if observables_json else [] - except json.JSONDecodeError as exc: - msg = f"detectors_json/observables_json is not valid JSON: {exc}" - raise ValueError(msg) from exc - - if not isinstance(detectors, list) or not isinstance(observables, list): - msg = "detectors_json and observables_json must each be a JSON list" - raise _MetadataError(msg) - - normalized_detectors = _check("Detector", detectors) - normalized_observables = _check("Observable", observables) - return ( - json.dumps(normalized_detectors, separators=(",", ":")), - json.dumps(normalized_observables, separators=(",", ":")), - ) - - -def _normalize_entry_ids(blob: str, prefix: str) -> str: - """Normalize ``"id": "D0"``/``"L0"`` to the integer the pipeline expects. - - ``prefix`` is ``"D"`` for detectors, ``"L"`` for observables. Integer ids - and entries without ``"id"`` (e.g. those using ``detector_id`` / - ``observable_id``) pass through unchanged. A string id with the wrong - prefix or a non-numeric body is a hard error -- silently reinterpreting it - would risk a mislabeled DEM. - """ - if not blob: - return blob - try: - entries = json.loads(blob) - except json.JSONDecodeError: - return blob # validation downstream reports the parse error - if not isinstance(entries, list): - return blob - - changed = False - for entry in entries: - if not isinstance(entry, dict) or not isinstance(entry.get("id"), str): - continue - raw = entry["id"].strip() - body = raw[len(prefix) :] if raw.startswith(prefix) else None - if body is None or not body.isdigit(): - msg = ( - f"id {entry['id']!r} is not a valid identifier for this list; " - f"expected an integer or {prefix!r}-prefixed form like " - f"{prefix}0 (detectors use 'D', observables use 'L')." - ) - raise ValueError(msg) - entry["id"] = int(body) - changed = True - - return json.dumps(entries, separators=(",", ":")) if changed else blob - - class DetectorErrorModel(_RustDetectorErrorModel): """Detector error model with a Guppy/QIS-trace convenience constructor. @@ -271,7 +66,8 @@ def from_guppy( Runs ``guppy`` under the Selene QIS engine with operation tracing, replays the captured gate stream into a ``TickCircuit``, attaches the caller-supplied detector/observable definitions, and builds the DEM via - native PECOS fault propagation. + native PECOS fault propagation. All metadata validation happens in the + Rust DEM builder (single source of truth). Args: guppy: Anything ``pecos.sim`` accepts -- a ``@guppy``-decorated @@ -285,24 +81,24 @@ def from_guppy( ``[{"id": 0, "records": [-1, -5]}, ...]``. ``id`` may be a bare integer or, for convenience, the DEM-label form ``"D0"`` (observables likewise accept ``"L0"``); both normalize to the - same integer. ``records`` are - negative measurement offsets (Stim convention); ``meas_ids`` - may be used instead. Defined against the *traced* program's own - measurement order. + same integer. ``records`` are negative measurement offsets + (Stim convention), positional in the traced measurement record. + ``meas_ids`` may be used instead and reference the *stable + stamped* ``MeasId``s -- resolved in Rust against the circuit's + actual ids, so they are robust to any measurement reordering + introduced by Guppy/Selene compilation. observables_json: Observable definitions as a JSON list, e.g. ``[{"id": 0, "records": [-1]}]`` (same id/records rules as detectors). Tracked Paulis: **hand-authored JSON tracked Paulis are NOT - supported** by this path. The DEM builder's JSON observable - parser reads only ``id``/``records``; it ignores ``kind`` / - ``label`` / ``pauli``. Tracked Paulis are only produced from + supported** by this path. Tracked Paulis are only produced from circuit *annotations* (e.g. the surface builder), not from - ``observables_json``. A ``{"kind": "tracked_pauli", ...}`` - entry here is rejected. + ``observables_json``; a ``{"kind": "tracked_pauli", ...}`` + entry here is rejected by the builder. num_measurements: Total measurement count, used to resolve negative ``records`` offsets. If omitted, it is inferred from the traced - circuit. + circuit; if given, it must match the traced count. p1: Single-qubit gate depolarizing rate. p2: Two-qubit gate depolarizing rate. p_meas: Measurement flip rate. @@ -336,9 +132,6 @@ def from_guppy( ``measure()`` itself allocates the result slot in the trace (a ``result(...)`` call is not required for MeasId assignment). - Detector/observable ``records``/``meas_ids`` reference measurements - by *traced (post-compilation)* order and are therefore sensitive to - any measurement reordering introduced by Guppy/Selene compilation. Source-anchored tag-referenced detectors are **not exposed here**: the sound HUGR-based binding (``pecos_hugr_qis::extract_result_tag_measurements``) only covers @@ -349,27 +142,19 @@ def from_guppy( """ from pecos.qec.surface.decode import trace_guppy_into_tick_circuit - # Convenience: allow "id": "D0" / "L0" (matching DEM labels) in - # addition to bare integers. Normalized to ints here so the schema, - # Rust parser, and surface path are untouched. - detectors_json = _normalize_entry_ids(detectors_json, "D") - observables_json = _normalize_entry_ids(observables_json, "L") - tc = trace_guppy_into_tick_circuit(guppy, num_qubits, seed=seed) # Compilation passes required for traced QIS circuits before fault # analysis: normalize parameterized Clifford rotations to named gates - # and stamp stable MeasIds onto measurement gates. + # and stamp stable MeasIds onto measurement gates. After this every + # MZ carries the stable id the Rust builder resolves meas_ids against. tc.lower_clifford_rotations() tc.assign_missing_meas_ids() - detectors_json, observables_json = _validate_measurement_contract( - tc, - detectors_json=detectors_json, - observables_json=observables_json, - num_measurements=num_measurements, - ) - + # Hand the caller's metadata to the Rust builder verbatim; it owns all + # schema/ref validation (including D0/L0 id forms, tracked-Pauli + # rejection, num_measurements consistency, and stamped-MeasId + # resolution). tc.set_meta("detectors", detectors_json) tc.set_meta("observables", observables_json) if num_measurements is not None: diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index 9c1b45bb5..b871dc0f7 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -196,3 +196,29 @@ def test_from_guppy_out_of_range_record_fails_loud() -> None: def test_from_guppy_out_of_range_meas_id_fails_loud() -> None: with pytest.raises(ValueError, match=r"meas_id|not present"): _dem_text(detectors_json='[{"id":0,"meas_ids":[999]}]') + + +def test_from_guppy_accepts_dem_label_id_forms() -> None: + """The "D0"/"L0" id convenience form is now normalized in the Rust + builder (single source of truth), equivalent to the bare integer.""" + assert _dem_text(detectors_json='[{"id":"D0","records":[-1]}]') == _dem_text( + detectors_json='[{"id":0,"records":[-1]}]', + ) + assert _dem_text(observables_json='[{"id":"L0","records":[-1]}]') == _dem_text( + observables_json='[{"id":0,"records":[-1]}]', + ) + + +def test_from_guppy_rejects_bad_string_id() -> None: + with pytest.raises(ValueError, match=r"not a valid identifier"): + _dem_text(detectors_json='[{"id":"X0","records":[-1]}]') + + +def test_from_guppy_rejects_detector_tracked_pauli() -> None: + with pytest.raises(ValueError, match="tracked_pauli"): + _dem_text(detectors_json='[{"kind":"tracked_pauli","label":"x","pauli":"X0"}]') + + +def test_from_guppy_rejects_entry_without_records_or_meas_ids() -> None: + with pytest.raises(ValueError, match=r"records|meas_ids|neither"): + _dem_text(detectors_json='[{"id":0}]') From 1fd29376ade7e5fc41ca6dcfbd8297aa8964aca7 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 19 May 2026 13:54:08 -0600 Subject: [PATCH 16/36] Tolerate redundant records+meas_ids co-presence, fail loud on non-redundant; fix logical_circuit regression --- .../fault_tolerance/dem_builder/builder.rs | 114 +++++++++++++----- ...001-from-guppy-tag-referenced-detectors.md | 14 ++- .../tests/qec/test_from_guppy_dem.py | 11 ++ 3 files changed, 109 insertions(+), 30 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 1c3961d87..91b2466be 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -465,39 +465,55 @@ impl<'a> DemBuilder<'a> { /// Fail loud if any detector/observable references a measurement that does /// not exist, instead of silently dropping it and weakening the DEM. /// - /// Checks only the references that are actually consumed: when `records` - /// is non-empty it is used (and `meas_ids` ignored, so `meas_ids` is not - /// checked here -- consistent with [`Self::effective_record_offsets`]); - /// otherwise `meas_ids` is used. `records`+`meas_ids` may legitimately - /// both be present and redundant (surface metadata does this), so their - /// co-presence is *not* an error. + /// `records` and `meas_ids` are alternative ways to name the *same* + /// measurements (the parser allows neither both-empty). Each used + /// reference must resolve in range. When an entry carries **both**, they + /// must be redundant -- `meas_ids` must resolve to exactly the `records` + /// set -- otherwise the DEM the builder produces (which consumes + /// `records`) would silently differ from what `meas_ids` asked for. The + /// surface `logical_circuit` path emits both redundantly; a non-redundant + /// pair is a caller error and fails loud here. /// /// # Errors /// Returns [`DemBuilderError::ParseError`] if a used record offset is out - /// of range for `num_measurements`, or a used `meas_id` is `>= - /// num_measurements`. + /// of range, a used `meas_id` is absent, or a both-present entry's + /// `records` and `meas_ids` disagree. fn validate_metadata_refs(&self) -> Result<(), DemBuilderError> { let check = |kind: &str, id: u32, records: &[i32], meas_ids: &[usize]| { - if records.is_empty() { - for &mid in meas_ids { - if self.resolve_meas_id_to_tc_index(mid).is_none() { - return Err(DemBuilderError::ParseError(format!( - "{kind} {id} references meas_id {mid}, which is \ - not present in the circuit's {} measurement(s)", - self.num_measurements - ))); - } + for &rec in records { + if record_offset_to_absolute_index(self.num_measurements, rec).is_none() { + return Err(DemBuilderError::ParseError(format!( + "{kind} {id} references record offset {rec}, which \ + is out of range for a circuit with {} \ + measurement(s)", + self.num_measurements + ))); } - } else { - for &rec in records { - if record_offset_to_absolute_index(self.num_measurements, rec).is_none() { - return Err(DemBuilderError::ParseError(format!( - "{kind} {id} references record offset {rec}, which \ - is out of range for a circuit with {} \ - measurement(s)", - self.num_measurements - ))); - } + } + let mut resolved_offsets = Vec::with_capacity(meas_ids.len()); + for &mid in meas_ids { + let offset = self.meas_id_to_record_offset(mid).ok_or_else(|| { + DemBuilderError::ParseError(format!( + "{kind} {id} references meas_id {mid}, which is not \ + present in the circuit's {} measurement(s)", + self.num_measurements + )) + })?; + resolved_offsets.push(offset); + } + if !records.is_empty() && !meas_ids.is_empty() { + let mut a = records.to_vec(); + let mut b = resolved_offsets; + a.sort_unstable(); + b.sort_unstable(); + if a != b { + return Err(DemBuilderError::ParseError(format!( + "{kind} {id} has both 'records' and 'meas_ids' but \ + they reference different measurements (records map \ + to offsets {a:?}, meas_ids resolve to {b:?}); they \ + are alternatives, not additive -- the builder would \ + consume only 'records' and silently drop the rest" + ))); } } Ok(()) @@ -1537,6 +1553,13 @@ fn extract_measurement_refs( ))); } + // `records` and `meas_ids` are alternative ways to reference the *same* + // measurements, not additive. Co-presence is allowed but must be + // redundant; that equality is enforced fail-loud in + // `validate_metadata_refs` (which has the circuit context needed to + // resolve `meas_ids`), not here at the pure-parse stage. The surface + // `logical_circuit` path legitimately emits both (records = legacy Stim + // offsets, meas_ids = the same measurements as stable ids). Ok((records, meas_ids)) } @@ -2309,6 +2332,43 @@ mod tests { parse_detectors_json(r#"[{"id": 0}]"#).is_err(), "an entry with neither records nor meas_ids must be rejected", ); + // Both-present is allowed at parse time (surface logical_circuit + // legitimately emits redundant records+meas_ids); the + // redundancy/fail-loud decision is made later in try_build. + assert!( + parse_detectors_json(r#"[{"id": 0, "records": [-1], "meas_ids": [0]}]"#).is_ok(), + "both records and meas_ids must parse; redundancy is checked in try_build", + ); + } + + #[test] + fn test_try_build_mixed_records_meas_ids_must_be_redundant() { + // Empty influence map => positional meas_id resolution (deterministic): + // num_measurements=3, meas_id k resolves to record offset k-3. + let influence_map = DagFaultInfluenceMap::with_capacity(0); + + // Redundant: records [-3] and meas_ids [0] both name measurement 0. + let redundant = DemBuilder::new(&influence_map) + .with_detectors_json(r#"[{"id": 0, "records": [-3], "meas_ids": [0]}]"#) + .unwrap() + .with_num_measurements(3) + .try_build(); + assert!( + redundant.is_ok(), + "redundant records+meas_ids must be accepted: {redundant:?}", + ); + + // Non-redundant: records [-3] (measurement 0) vs meas_ids [1] + // (measurement 1) -> fail loud, not silently records-only. + let conflicting = DemBuilder::new(&influence_map) + .with_detectors_json(r#"[{"id": 0, "records": [-3], "meas_ids": [1]}]"#) + .unwrap() + .with_num_measurements(3) + .try_build(); + assert!( + conflicting.is_err(), + "non-redundant records+meas_ids must fail loud, not collapse to records", + ); } #[test] diff --git a/docs/proposals/001-from-guppy-tag-referenced-detectors.md b/docs/proposals/001-from-guppy-tag-referenced-detectors.md index 9deac9953..cff5c7cdc 100644 --- a/docs/proposals/001-from-guppy-tag-referenced-detectors.md +++ b/docs/proposals/001-from-guppy-tag-referenced-detectors.md @@ -420,9 +420,17 @@ Resolution: escape hatch) so existing callers are unaffected. - Also addressed: clippy `-D warnings` (missing `# Panics` docs); the public subclass-identity hazard (#6) is documented (no internal `isinstance` use; - public-API caveat only). The Rust/Python schema duplication/divergence - remains a known follow-up (not a correctness defect now that all ingest - paths fail loud). + public-API caveat only). +- Rust/Python schema duplication/divergence: **now closed.** All + detector/observable validation (JSON shape, `D0`/`L0` id forms, + tracked-Pauli rejection, "exactly one of records/meas_ids", + `num_measurements` consistency, out-of-range records, and `meas_id` + resolution against the circuit's stable stamped `MeasId`s) lives solely in + the Rust DEM builder; `from_guppy` is a thin wrapper that traces and + forwards metadata verbatim. `meas_ids` is an alternative to `records` (not + additive); an entry carrying both is rejected fail-loud rather than + silently collapsing to `records` (a regression a fourth review caught in + the intermediate consolidation). So the previous "proven sound for straight-line / surface byte-identical" statement is accurate again *only after the guard revert*; it was false in the diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index b871dc0f7..c0220f32e 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -222,3 +222,14 @@ def test_from_guppy_rejects_detector_tracked_pauli() -> None: def test_from_guppy_rejects_entry_without_records_or_meas_ids() -> None: with pytest.raises(ValueError, match=r"records|meas_ids|neither"): _dem_text(detectors_json='[{"id":0}]') + + +def test_from_guppy_redundant_records_and_meas_ids_are_accepted() -> None: + """Co-present records + meas_ids that name the SAME measurement are + tolerated (the surface logical_circuit path emits both redundantly) and + produce the same DEM as either form alone. (Non-redundant co-presence is + rejected fail-loud; that precise semantics is pinned by the deterministic + Rust unit test ``test_try_build_mixed_records_meas_ids_must_be_redundant``, + since stamped MeasId values are not predictable from Python here.)""" + both = _dem_text(detectors_json='[{"id":0,"records":[-1],"meas_ids":[0]}]') + assert both == _dem_text(detectors_json='[{"id":0,"records":[-1]}]') From b47bbce81ad76373f617996d2fadd48c463f3fd1 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 19 May 2026 14:13:45 -0600 Subject: [PATCH 17/36] Correct stale proposal 001 and try_build rustdoc to match redundancy-tolerant meas_ids semantics --- .../src/fault_tolerance/dem_builder/builder.rs | 6 ++++-- .../001-from-guppy-tag-referenced-detectors.md | 12 ++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 91b2466be..9793124c0 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -590,8 +590,10 @@ impl<'a> DemBuilder<'a> { /// # Errors /// /// Returns [`DemBuilderError::ParseError`] if `num_measurements` disagrees - /// with a non-empty influence map, a used record offset is out of range - /// for `num_measurements`, or a used `meas_id` is `>= num_measurements`. + /// with a non-empty influence map, a used record offset is out of range, + /// a used `meas_id` is not present in the circuit (resolved against the + /// stable stamped ids when available, else positionally), or a + /// both-present entry's `records` and `meas_ids` are not redundant. pub fn try_build(&self) -> Result { self.validate_measurement_count()?; self.validate_metadata_refs()?; diff --git a/docs/proposals/001-from-guppy-tag-referenced-detectors.md b/docs/proposals/001-from-guppy-tag-referenced-detectors.md index cff5c7cdc..3d6dd281f 100644 --- a/docs/proposals/001-from-guppy-tag-referenced-detectors.md +++ b/docs/proposals/001-from-guppy-tag-referenced-detectors.md @@ -423,14 +423,18 @@ Resolution: public-API caveat only). - Rust/Python schema duplication/divergence: **now closed.** All detector/observable validation (JSON shape, `D0`/`L0` id forms, - tracked-Pauli rejection, "exactly one of records/meas_ids", + tracked-Pauli rejection, at-least-one of records/meas_ids, `num_measurements` consistency, out-of-range records, and `meas_id` resolution against the circuit's stable stamped `MeasId`s) lives solely in the Rust DEM builder; `from_guppy` is a thin wrapper that traces and forwards metadata verbatim. `meas_ids` is an alternative to `records` (not - additive); an entry carrying both is rejected fail-loud rather than - silently collapsing to `records` (a regression a fourth review caught in - the intermediate consolidation). + additive). Co-presence of both is *allowed but must be redundant*: the + surface `logical_circuit` path legitimately emits both (records = legacy + Stim offsets, meas_ids = the same measurements as stable ids), so + `validate_metadata_refs` resolves `meas_ids` and requires the resolved + offset set to equal `records`; a non-redundant pair is rejected fail-loud + rather than silently collapsing to `records` (a regression the fourth and + sixth reviews caught in the intermediate consolidation). So the previous "proven sound for straight-line / surface byte-identical" statement is accurate again *only after the guard revert*; it was false in the From b8e73b105a53293db0d449d1a5d0d5206c49a038 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 19 May 2026 20:24:49 -0600 Subject: [PATCH 18/36] Wire sound result_tags into from_guppy for the straight-line case; commit cross-check that HUGR ordinal == trace MeasId order --- crates/pecos-hugr-qis/src/result_tags.rs | 16 +- .../src/fault_tolerance/dem_builder.rs | 2 +- .../fault_tolerance/dem_builder/builder.rs | 101 +++++++++++ ...001-from-guppy-tag-referenced-detectors.md | 60 ++++++- .../pecos-rslib/src/dag_circuit_bindings.rs | 49 +++++ python/quantum-pecos/src/pecos/qec/dem.py | 113 ++++++++++-- .../tests/qec/test_from_guppy_result_tags.py | 169 ++++++++++++++++++ 7 files changed, 484 insertions(+), 26 deletions(-) create mode 100644 python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py diff --git a/crates/pecos-hugr-qis/src/result_tags.rs b/crates/pecos-hugr-qis/src/result_tags.rs index 7543f7219..e0c7466e5 100644 --- a/crates/pecos-hugr-qis/src/result_tags.rs +++ b/crates/pecos-hugr-qis/src/result_tags.rs @@ -7,9 +7,19 @@ //! unlike a runtime op-stream heuristic. //! //! Measurement identity here is the *ordinal* of the measurement op in HUGR -//! traversal order. Whether that ordinal coincides with the QIS-trace -//! `result_id`/`MeasId` is a separate, verified property (see the dem-polish -//! foundation tests); this module only recovers the structural binding. +//! traversal order. This module only recovers the structural binding; whether +//! that HUGR ordinal coincides with the QIS-trace `result_id`/`MeasId` order +//! is a separate property of the Guppy -> HUGR / Guppy -> trace pipelines +//! agreeing on measurement ordering. Within the narrow scope this module +//! supports (straight-line `result_bool <- tket.bool:read <- +//! Measure/MeasureFree`), that correspondence is **committed-test verified** +//! end-to-end by +//! `tests/qec/test_from_guppy_result_tags.py::test_result_tags_match_positional_records` +//! (a scrambled-`result()`-order Guppy program: `result_tags` DEM +//! byte-identical to the positional-records DEM). Outside that scope +//! (computed / constant / array-valued `result()`, runtime loops) the +//! correspondence is undefined and the extractor / runtime-loop guard reject +//! the case rather than relying on it. //! //! Note: a *runtime* loop (e.g. `for _ in range(comptime(n))`, as the surface //! code uses for rounds) is NOT unrolled in the HUGR -- it has one static diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs index 59d79fd0a..a6af42108 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs @@ -83,7 +83,7 @@ mod mem_builder; pub(crate) mod sampler; mod types; -pub use builder::{DemBuilder, DemBuilderError}; +pub use builder::{DemBuilder, DemBuilderError, resolve_result_tags}; pub use dem_sampler::{SamplingEngine, SamplingStatistics}; pub use equivalence::{ ComparisonDetails, ComparisonMethod, DemParseError, EffectKey, EquivalenceResult, diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 9793124c0..4d3ef2fab 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -1677,6 +1677,107 @@ fn observable_records_from_annotations( .collect() } +// ============================================================================ +// Tag-referenced detector resolution +// ============================================================================ + +/// Resolve `result_tags` on detector/observable JSON into record offsets. +/// +/// `tag_to_ords` is the **sound** Guppy `result(tag, ...)` -> measurement +/// ordinal binding recovered structurally from the compiled HUGR +/// (reorder-immune; see `pecos_hugr_qis::result_tags`). Each referenced tag's +/// ordinals are converted to record offsets (`ordinal - traced_meas_count`) +/// and merged into the entry's `records`; `result_tags` is removed so the +/// downstream parser is unchanged. +/// +/// Fail-loud (returns `Err`), never silently misbinds: +/// - **Loop guard**: if `static_meas_count != traced_meas_count` the program +/// has un-unrolled runtime loops (the HUGR has one static measure op per +/// loop body), so per-occurrence tag binding is not statically available. +/// - An unknown tag, malformed `result_tags`, or invalid JSON is an error. +/// +/// # Errors +/// Returns [`DemBuilderError::ParseError`] on the loop guard, an unknown tag, +/// malformed `result_tags`, or invalid JSON. +pub fn resolve_result_tags( + detectors_json: &str, + observables_json: &str, + tag_to_ords: &std::collections::BTreeMap>, + static_meas_count: usize, + traced_meas_count: usize, +) -> Result<(String, String), DemBuilderError> { + if static_meas_count != traced_meas_count { + return Err(DemBuilderError::ParseError(format!( + "result_tags (tag-referenced detectors) is not supported for Guppy \ + programs with runtime loops: the HUGR has {static_meas_count} \ + static measurement op(s) but the traced program emits \ + {traced_meas_count} measurement(s). Per-occurrence tag binding is \ + not statically available; use positional records (see \ + docs/proposals/001-from-guppy-tag-referenced-detectors.md)." + ))); + } + let traced = i64::try_from(traced_meas_count).map_err(|_| { + DemBuilderError::ParseError("traced measurement count too large".to_string()) + })?; + + let rewrite = |json: &str, kind: &str| -> Result { + if json.trim().is_empty() { + return Ok(json.to_string()); + } + let mut value: serde_json::Value = serde_json::from_str(json).map_err(|e| { + DemBuilderError::ParseError(format!("invalid detector/observable JSON: {e}")) + })?; + let Some(entries) = value.as_array_mut() else { + return Ok(json.to_string()); + }; + for entry in entries.iter_mut() { + let Some(obj) = entry.as_object_mut() else { + continue; + }; + let Some(tags) = obj.remove("result_tags") else { + continue; + }; + let mut records: Vec = obj + .get("records") + .and_then(|r| r.as_array()) + .map(|a| a.iter().filter_map(serde_json::Value::as_i64).collect()) + .unwrap_or_default(); + let tag_list = tags.as_array().ok_or_else(|| { + DemBuilderError::ParseError( + "result_tags must be a JSON array of strings".to_string(), + ) + })?; + for tag in tag_list { + let tag = tag.as_str().ok_or_else(|| { + DemBuilderError::ParseError("result_tags entries must be strings".to_string()) + })?; + let ords = tag_to_ords.get(tag).ok_or_else(|| { + DemBuilderError::ParseError(format!( + "{kind} references result_tag {tag:?}, which the Guppy \ + program never records via result(...)" + )) + })?; + for &ord in ords { + records.push(i64::try_from(ord).unwrap_or(i64::MAX) - traced); + } + } + obj.insert( + "records".to_string(), + serde_json::Value::Array( + records.into_iter().map(serde_json::Value::from).collect(), + ), + ); + } + serde_json::to_string(&value) + .map_err(|e| DemBuilderError::ParseError(format!("failed to re-serialize JSON: {e}"))) + }; + + Ok(( + rewrite(detectors_json, "Detector")?, + rewrite(observables_json, "Observable")?, + )) +} + // ============================================================================ // Error Type // ============================================================================ diff --git a/docs/proposals/001-from-guppy-tag-referenced-detectors.md b/docs/proposals/001-from-guppy-tag-referenced-detectors.md index 3d6dd281f..67779919c 100644 --- a/docs/proposals/001-from-guppy-tag-referenced-detectors.md +++ b/docs/proposals/001-from-guppy-tag-referenced-detectors.md @@ -1,9 +1,12 @@ # 001 - Tag-referenced detectors for `DetectorErrorModel.from_guppy` -**Status:** Partially delivered — see "Final outcome (dem-polish)" at the -bottom. The sections between here and there record the investigation history -(including a runtime approach that was implemented then **proven unsound and -removed**); the final section is authoritative. +**Status:** Delivered for the supported scope (straight-line canonical +`result(tag, measure(q))`); runtime-loop case remains deferred (upstream- +blocked). See "Closure (round 9)" at the bottom -- it is authoritative and +supersedes every earlier section. The sections in between record the +investigation history (including a runtime approach that was implemented then +**proven unsound and removed**, and a first wiring attempt that was reverted +in review and is now re-introduced correctly). **Author:** (dem-polish working notes) @@ -439,3 +442,52 @@ Resolution: So the previous "proven sound for straight-line / surface byte-identical" statement is accurate again *only after the guard revert*; it was false in the intermediate broken tree. + +## Closure (round 9) -- AUTHORITATIVE + +This section supersedes all earlier "outcome" sections. The proposal's Goal +(tag-referenced detectors that survive measurement reordering) is now +**delivered** for its supported scope, with the prior review's blockers +addressed: + +**What ships:** + +- The Rust `resolve_result_tags` + (`pecos_qec::fault_tolerance::dem_builder::resolve_result_tags`) and pyo3 + `resolve_result_tags_for_guppy` reintroduced from the reverted gap-4, with + the same fail-loud loop guard (static vs traced measurement count). +- `DetectorErrorModel.from_guppy` now accepts a `result_tags` field on each + detector/observable entry (alongside or instead of `records`/`meas_ids`). + Resolution: HUGR -> sound `tag -> ordinal` map via + `pecos_hugr_qis::extract_result_tag_measurements` -> record offsets -> + passed to the existing DEM builder. The full schema/redundancy/range + validation already in the builder applies to the rewritten metadata. +- The previously-revert-triggering "wrapper-input regression" is closed by + an **upfront** `guppy_to_hugr` compile in `from_guppy` (only when + `result_tags` is requested) with a clear `ValueError` mentioning the + `@guppy` requirement, instead of a late crash inside the HUGR step. +- The HUGR-traversal-ordinal == traced-`MeasId`-order correspondence the + earlier review (item #7) flagged as unproven is now **committed-test + verified** end-to-end for the supported scope by + `tests/qec/test_from_guppy_result_tags.py::test_result_tags_match_positional_records` + (a scrambled-`result()`-order Guppy program: `result_tags` DEM + byte-identical to the positional-records DEM, across all three tags and + multi-tag / observables variants). +- `result_tags.rs` module docs no longer claim a "verified property" that + had no committed test; they now accurately reference the committed + cross-check and the narrow scope. + +**What is still deferred (upstream-blocked):** per-occurrence tag binding +for `for _ in range(comptime(n))` runtime-loop programs (the surface code's +round structure). The HUGR has one static measure op per loop body, not per +occurrence; bridging that to per-iteration `MeasId`s needs CFG-interpreter- +class machinery (~= the excluded `HugrEngine`) or upstream `tket-qsystem` +lowering carrying measurement provenance. `from_guppy` rejects this case +fail-loud (`result_tags ... runtime loops`); positional `records`/`meas_ids` +remain available for surface-code use. + +**Rejected fail-loud cases (committed tests):** runtime-loop programs; +non-`@guppy` callable inputs when `result_tags` is requested; references to +tags the program never records; computed / constant / array-valued +`result(...)` shapes (the extractor excludes them by construction, and an +unrecognized tag falls through to the unknown-tag error). diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index 31bd12985..f033a6b22 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -1800,6 +1800,51 @@ fn py_hugr_to_dag_circuit(hugr_bytes: &Bound<'_, PyBytes>) -> PyResult measurement binding recovered +/// from the compiled HUGR. +/// +/// All logic (HUGR extraction, the runtime-loop guard, tag->record resolution, +/// unknown-tag validation) is performed in Rust; this is a thin entry point. +/// Returns the rewritten `(detectors_json, observables_json)` with +/// `result_tags` replaced by record offsets. +/// +/// Args: +/// `detectors_json` / `observables_json`: detector/observable JSON. +/// `hugr_bytes`: HUGR envelope bytes (e.g. `guppy_to_hugr(program)`). +/// `traced_meas_count`: number of measurements in the traced circuit. +/// +/// Raises: +/// `ValueError`: on the runtime-loop guard, an unknown tag, malformed +/// `result_tags`, or invalid JSON. +#[pyfunction] +#[pyo3(name = "resolve_result_tags_for_guppy")] +fn py_resolve_result_tags_for_guppy( + detectors_json: &str, + observables_json: &str, + hugr_bytes: &Bound<'_, PyBytes>, + traced_meas_count: usize, +) -> PyResult<(String, String)> { + use pecos_hugr_qis::{ + extract_result_tag_measurements, measurement_op_count, read_hugr_envelope, + }; + use pecos_qec::fault_tolerance::dem_builder::resolve_result_tags; + + let hugr = read_hugr_envelope(hugr_bytes.as_bytes()) + .map_err(|e| PyErr::new::(format!("Failed to parse HUGR: {e}")))?; + let tag_to_ords = extract_result_tag_measurements(&hugr); + let static_meas_count = measurement_op_count(&hugr); + + resolve_result_tags( + detectors_json, + observables_json, + &tag_to_ords, + static_meas_count, + traced_meas_count, + ) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) +} + /// Map a HUGR operation name to a `GateType`. /// /// Args: @@ -3773,6 +3818,10 @@ pub fn register_quantum_circuit_types(parent_module: &Bound<'_, PyModule>) -> Py parent_module.add_function(wrap_pyfunction!(py_hugr_op_to_gate_type, parent_module)?)?; parent_module.add_function(wrap_pyfunction!(py_gate_type_to_hugr_op, parent_module)?)?; parent_module.add_function(wrap_pyfunction!(py_is_quantum_operation, parent_module)?)?; + parent_module.add_function(wrap_pyfunction!( + py_resolve_result_tags_for_guppy, + parent_module + )?)?; Ok(()) } diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 735fa318d..c30b096c4 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -9,12 +9,16 @@ module defines a thin Python subclass that adds :meth:`from_guppy` and is re-exported as the public ``pecos.qec.DetectorErrorModel``. -This wrapper is intentionally thin: it only traces the Guppy program into a -``TickCircuit`` and hands the caller's detector/observable JSON to the Rust -DEM builder verbatim. All metadata validation -- JSON shape, ``D0``/``L0`` id -forms, tracked-Pauli rejection, ``num_measurements`` consistency, out-of-range -records, and ``meas_id`` resolution against the circuit's stable stamped -``MeasId``s -- is the single responsibility of the Rust builder +This wrapper is intentionally thin: it traces the Guppy program into a +``TickCircuit``, optionally compiles the program to a HUGR (only when +``result_tags`` is requested -- to recover the sound tag -> measurement +binding via ``pecos_hugr_qis::extract_result_tag_measurements``), and hands +the caller's detector/observable JSON to the Rust DEM builder. All metadata +validation -- JSON shape, ``D0``/``L0`` id forms, tracked-Pauli rejection, +``num_measurements`` consistency, out-of-range records, ``meas_id`` +resolution against the circuit's stable stamped ``MeasId``s, and +``result_tags`` -> record-offset resolution with its loop guard -- is the +single responsibility of the Rust builder (``pecos_qec::fault_tolerance::dem_builder``), so the same rules apply identically whether a DEM is built via ``from_guppy``, ``from_circuit``, ``DemSampler.from_circuit``, or the public ``DemBuilder``. @@ -81,12 +85,38 @@ def from_guppy( ``[{"id": 0, "records": [-1, -5]}, ...]``. ``id`` may be a bare integer or, for convenience, the DEM-label form ``"D0"`` (observables likewise accept ``"L0"``); both normalize to the - same integer. ``records`` are negative measurement offsets - (Stim convention), positional in the traced measurement record. - ``meas_ids`` may be used instead and reference the *stable - stamped* ``MeasId``s -- resolved in Rust against the circuit's - actual ids, so they are robust to any measurement reordering - introduced by Guppy/Selene compilation. + same integer. + + Each entry references measurements in one of three ways + (provide exactly one form; co-presence is allowed only if the + forms reference the same measurements): + + - ``records``: negative measurement offsets (Stim convention), + positional in the traced measurement record. + - ``meas_ids``: stable stamped ``MeasId``s -- resolved in Rust + against the circuit's actual ids, so robust to any + measurement reordering Guppy/Selene compilation may + introduce. + - ``result_tags``: Guppy ``result(tag, ...)`` tag strings + (e.g. ``[{"id": 0, "result_tags": ["syn_a"]}]``). The + reorder-immune ``tag -> measurement`` binding is recovered + from the compiled HUGR by + ``pecos_hugr_qis::extract_result_tag_measurements`` and + resolved to record offsets in Rust. Supported only for + **straight-line, canonical** programs: + ``result(tag, measure(q))`` of a raw scalar measurement. + Computed (``result(tag, m0 == m1)``), constant + (``result(tag, True)``), and array-valued + (``result(tag, measure_array(qs))``) forms are not + resolvable and an unknown tag is a hard error. Runtime + ``for _ in range(comptime(n))`` loops (e.g. the surface + code's round structure) have one static measure op per + loop body in the HUGR, not per occurrence -- ``result_tags`` + is rejected fail-loud for such programs. ``result_tags`` + also requires ``guppy`` to be a ``@guppy``-decorated + function / ``GuppyFunctionDefinition`` (not an arbitrary + ``pecos.sim``-acceptable wrapper); use ``records`` for the + surface-code path. observables_json: Observable definitions as a JSON list, e.g. ``[{"id": 0, "records": [-1]}]`` (same id/records rules as detectors). @@ -132,16 +162,40 @@ def from_guppy( ``measure()`` itself allocates the result slot in the trace (a ``result(...)`` call is not required for MeasId assignment). - Source-anchored tag-referenced detectors are **not exposed here**: - the sound HUGR-based binding - (``pecos_hugr_qis::extract_result_tag_measurements``) only covers - the canonical scalar ``result(tag, measure(q))`` pattern and is not - yet wired into ``from_guppy``; runtime-loop programs remain - unsupported. See + Source-anchored tag-referenced detectors are exposed via the + ``result_tags`` field on detectors/observables (see the + ``detectors_json`` argument). The supported scope is canonical + scalar ``result(tag, measure(q))`` in straight-line programs; the + runtime-loop case (per-occurrence binding) remains deferred. See ``docs/proposals/001-from-guppy-tag-referenced-detectors.md``. """ from pecos.qec.surface.decode import trace_guppy_into_tick_circuit + # Tag-referenced detectors require the compiled HUGR (to recover the + # sound, reorder-immune Guppy `result(tag, ...)` -> measurement + # binding). `guppy_to_hugr` only accepts a raw @guppy function -- not + # a compiled program or wrapper that the trace path otherwise + # accepts. Compile upfront so a wrong input fails loud here, before + # tracing, with a clear message instead of crashing inside the HUGR + # compile step (the "wrapper-input regression" caught in review). + needs_tags = _result_tags_present(detectors_json, observables_json) + hugr_bytes: bytes | None = None + if needs_tags: + from pecos._compilation import guppy_to_hugr + + try: + hugr_bytes = guppy_to_hugr(guppy) + except ValueError as exc: + msg = ( + "result_tags requires a @guppy-decorated function -- not a " + "compiled program or program wrapper. Pass the raw @guppy " + "function directly. For surface-code / runtime-loop " + "programs, use positional 'records' instead: loops are not " + "unrolled in the HUGR, so per-occurrence tag binding is " + "not statically available (see proposal 001)." + ) + raise ValueError(msg) from exc + tc = trace_guppy_into_tick_circuit(guppy, num_qubits, seed=seed) # Compilation passes required for traced QIS circuits before fault @@ -151,6 +205,20 @@ def from_guppy( tc.lower_clifford_rotations() tc.assign_missing_meas_ids() + # Resolve `result_tags` -> record offsets via Rust (sound HUGR + # extraction + runtime-loop guard via static-vs-traced measurement + # count). After this, `detectors_json` / `observables_json` no longer + # contain `result_tags`; the downstream Rust DEM builder is unchanged. + if needs_tags: + from pecos_rslib import resolve_result_tags_for_guppy + + detectors_json, observables_json = resolve_result_tags_for_guppy( + detectors_json, + observables_json, + hugr_bytes, + tc.num_measurements(), + ) + # Hand the caller's metadata to the Rust builder verbatim; it owns all # schema/ref validation (including D0/L0 id forms, tracked-Pauli # rejection, num_measurements consistency, and stamped-MeasId @@ -167,3 +235,12 @@ def from_guppy( p_meas=p_meas, p_prep=p_prep, ) + + +def _result_tags_present(detectors_json: str, observables_json: str) -> bool: + """Cheap gate: does any entry use ``result_tags``? (substring check). + + Only decides whether to compile the Guppy program to HUGR; the actual + extraction, loop-guard, resolution, and validation are all done in Rust. + """ + return '"result_tags"' in (detectors_json or "") or '"result_tags"' in (observables_json or "") diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py b/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py new file mode 100644 index 000000000..ca9fe3a6d --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py @@ -0,0 +1,169 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Tests for tag-referenced detectors in ``DetectorErrorModel.from_guppy``. + +Covers: + +1. **Correspondence cross-check (load-bearing):** for a scrambled straight-line + Guppy program where ``result()`` calls are declared in non-source order, a + DEM built via ``result_tags`` (which goes through + ``pecos_hugr_qis::extract_result_tag_measurements`` to recover the + reorder-immune tag -> measurement binding from the compiled HUGR) is + **byte-identical** to the DEM built via the equivalent positional + ``records``. This is the committed verification of the + HUGR-traversal-ordinal == traced-``MeasId``-order property the prior + review (proposal 001 item #7) flagged as unproven. +2. **Runtime-loop guard:** a Guppy program with a runtime loop (the surface + code) using ``result_tags`` fails loud with the documented "static N vs + traced M" message, instead of silently misbinding. +3. **Wrapper-input rejection:** ``result_tags`` requires a ``@guppy``-decorated + function -- a compiled program (e.g. the object ``make_surface_code`` + returns) is rejected fail-loud upfront, not crashed inside the HUGR + compile step. +4. **Unknown-tag rejection:** referencing a tag the program never records is + an error. +""" + +import pytest +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import measure, qubit +from pecos.guppy import get_num_qubits, make_surface_code +from pecos.qec import DetectorErrorModel + + +# A scrambled straight-line program: three measurements in source order +# qa, qb, qc; but ``result()`` is called for them in reverse-then-mixed order +# (c, a, b). The HUGR-side extractor binds tag_a -> [0], tag_b -> [1], +# tag_c -> [2] (ordinals of the measurements the tags actually record, not the +# order of the result() calls). If the HUGR traversal ordinal of the i-th +# measurement op equals the traced MeasId order position of the same +# measurement (the property under test), then result_tags resolution produces +# the same DEM as the obvious positional one. +@guppy +def _scrambled_three_measurements() -> None: + qa = qubit() + qb = qubit() + qc = qubit() + a = measure(qa) + b = measure(qb) + c = measure(qc) + result("tag_c", c) + result("tag_a", a) + result("tag_b", b) + + +_NOISE = {"p1": 0.0, "p2": 0.0, "p_meas": 0.1, "p_prep": 0.0} + + +def _from_guppy(detectors_json: str, *, observables_json: str = "[]") -> str: + """Build the scrambled-program DEM with the given metadata and return it as a string.""" + dem = DetectorErrorModel.from_guppy( + _scrambled_three_measurements, + num_qubits=3, + detectors_json=detectors_json, + observables_json=observables_json, + seed=0, + **_NOISE, + ) + return dem.to_string() + + +# --------------------------------------------------------------------------- +# 1. Correspondence: result_tags DEM == positional-records DEM (byte-identical) +# --------------------------------------------------------------------------- + +# Three measurements (a, b, c) in trace order; record offsets are +# (a -> -3, b -> -2, c -> -1) under the Stim convention. If +# HUGR-traversal-ordinal == traced-MeasId-order, then result_tags=["tag_X"] +# resolves to the same record offset as the positional form for tag X. + + +@pytest.mark.parametrize( + ("tag", "record"), + [("tag_a", -3), ("tag_b", -2), ("tag_c", -1)], +) +def test_result_tags_match_positional_records(tag: str, record: int) -> None: + """Each tag resolves to the same DEM as its positional-record equivalent.""" + via_tags = _from_guppy(f'[{{"id":0,"result_tags":["{tag}"]}}]') + via_records = _from_guppy(f'[{{"id":0,"records":[{record}]}}]') + assert via_tags == via_records, ( + f"result_tags={tag!r} should resolve to records={record}; HUGR-ordinal != traced-MeasId-order for this case" + ) + + +def test_result_tags_multi_tag_detector_matches_positional() -> None: + """A detector referencing multiple tags resolves to the same DEM as + the positional equivalent (asserts the property for combined refs too).""" + via_tags = _from_guppy('[{"id":0,"result_tags":["tag_a","tag_c"]}]') + via_records = _from_guppy('[{"id":0,"records":[-3,-1]}]') + assert via_tags == via_records + + +def test_result_tags_observables_path_matches_positional() -> None: + """The observables_json path resolves result_tags identically.""" + via_tags = _from_guppy( + "[]", + observables_json='[{"id":0,"result_tags":["tag_b"]}]', + ) + via_records = _from_guppy( + "[]", + observables_json='[{"id":0,"records":[-2]}]', + ) + assert via_tags == via_records + + +# --------------------------------------------------------------------------- +# 2. Runtime-loop guard: surface code fails loud, not silent +# --------------------------------------------------------------------------- + + +def test_result_tags_with_runtime_loop_program_fails_loud() -> None: + """The surface code uses ``for _ in range(comptime(n))`` rounds; the HUGR + has one static measure op per loop body, not per occurrence. The Rust + static-vs-traced count guard rejects this case rather than silently + misbinding (per-occurrence tag binding requires CFG-interpreter-class + machinery; see proposal 001).""" + with pytest.raises(ValueError, match=r"runtime loops|not supported"): + DetectorErrorModel.from_guppy( + make_surface_code(distance=3, num_rounds=3, basis="Z"), + num_qubits=get_num_qubits(3), + detectors_json='[{"id":0,"result_tags":["any_tag"]}]', + **_NOISE, + ) + + +# --------------------------------------------------------------------------- +# 3. Wrapper-input rejection: result_tags requires @guppy directly +# --------------------------------------------------------------------------- + + +def test_result_tags_with_non_guppy_callable_fails_loud_upfront() -> None: + """``result_tags`` requires a ``@guppy``-decorated function (or a + ``GuppyFunctionDefinition`` such as ``make_surface_code`` returns). + A plain Python callable cannot be compiled to a HUGR; ``from_guppy`` + must reject it upfront with the clear ``@guppy`` message instead of + crashing later inside the HUGR compile step (the upfront guard the + review flagged as needed).""" + + def not_a_guppy_function() -> None: + pass + + with pytest.raises(ValueError, match=r"@guppy-decorated function"): + DetectorErrorModel.from_guppy( + not_a_guppy_function, + num_qubits=1, + detectors_json='[{"id":0,"result_tags":["any_tag"]}]', + **_NOISE, + ) + + +# --------------------------------------------------------------------------- +# 4. Unknown-tag rejection +# --------------------------------------------------------------------------- + + +def test_result_tags_unknown_tag_fails_loud() -> None: + with pytest.raises(ValueError, match=r"never records|result_tag"): + _from_guppy('[{"id":0,"result_tags":["nonexistent_tag"]}]') From e072aac8cd54f6f6e307720b50ce0a5a48791b6d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 19 May 2026 21:22:16 -0600 Subject: [PATCH 19/36] Fix result_tags rewriter: strict-parse + redundancy-check instead of mask + additive; make correspondence test load-bearing --- .../fault_tolerance/dem_builder/builder.rs | 66 +++++++++-- python/quantum-pecos/src/pecos/qec/dem.py | 22 ++-- .../tests/qec/test_from_guppy_result_tags.py | 105 ++++++++++++++---- 3 files changed, 148 insertions(+), 45 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 4d3ef2fab..e0d895fc2 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -1737,16 +1737,14 @@ pub fn resolve_result_tags( let Some(tags) = obj.remove("result_tags") else { continue; }; - let mut records: Vec = obj - .get("records") - .and_then(|r| r.as_array()) - .map(|a| a.iter().filter_map(serde_json::Value::as_i64).collect()) - .unwrap_or_default(); + + // Resolve `result_tags` strictly into a list of record offsets. let tag_list = tags.as_array().ok_or_else(|| { DemBuilderError::ParseError( "result_tags must be a JSON array of strings".to_string(), ) })?; + let mut tag_offsets: Vec = Vec::new(); for tag in tag_list { let tag = tag.as_str().ok_or_else(|| { DemBuilderError::ParseError("result_tags entries must be strings".to_string()) @@ -1758,15 +1756,59 @@ pub fn resolve_result_tags( )) })?; for &ord in ords { - records.push(i64::try_from(ord).unwrap_or(i64::MAX) - traced); + tag_offsets.push(i64::try_from(ord).unwrap_or(i64::MAX) - traced); + } + } + + // `result_tags` is an *alternative* to `records` (and `meas_ids`), + // following the same redundancy discipline as records-vs-meas_ids: + // co-presence is allowed only when the two forms reference the + // *same* measurements (sorted-set equality). Additive merging + // would either silently weaken the DEM (when callers expected + // alternatives) or corrupt parity by double-referencing (when + // they were actually redundant). + match obj.get("records") { + None => { + obj.insert( + "records".to_string(), + serde_json::Value::Array( + tag_offsets + .into_iter() + .map(serde_json::Value::from) + .collect(), + ), + ); + } + Some(records_value) => { + let records_array = records_value.as_array().ok_or_else(|| { + DemBuilderError::ParseError(format!( + "{kind} records must be a JSON array of integers" + )) + })?; + let mut existing: Vec = Vec::with_capacity(records_array.len()); + for rec in records_array { + let r = rec.as_i64().ok_or_else(|| { + DemBuilderError::ParseError(format!( + "{kind} records entries must be integers" + )) + })?; + existing.push(r); + } + let mut a = existing; + let mut b = tag_offsets; + a.sort_unstable(); + b.sort_unstable(); + if a != b { + return Err(DemBuilderError::ParseError(format!( + "{kind} entry has both 'records' and 'result_tags' but \ + they reference different measurements (records {a:?}, \ + result_tags resolve to {b:?}); they are alternatives, \ + not additive -- provide one, or make them redundant" + ))); + } + // Records left unchanged; tag offsets are redundant. } } - obj.insert( - "records".to_string(), - serde_json::Value::Array( - records.into_iter().map(serde_json::Value::from).collect(), - ), - ); } serde_json::to_string(&value) .map_err(|e| DemBuilderError::ParseError(format!("failed to re-serialize JSON: {e}"))) diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index c30b096c4..73f5c16ee 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -173,11 +173,12 @@ def from_guppy( # Tag-referenced detectors require the compiled HUGR (to recover the # sound, reorder-immune Guppy `result(tag, ...)` -> measurement - # binding). `guppy_to_hugr` only accepts a raw @guppy function -- not - # a compiled program or wrapper that the trace path otherwise - # accepts. Compile upfront so a wrong input fails loud here, before - # tracing, with a clear message instead of crashing inside the HUGR - # compile step (the "wrapper-input regression" caught in review). + # binding). `guppy_to_hugr` accepts @guppy-decorated functions and + # `GuppyFunctionDefinition`s (e.g. `make_surface_code(...)`), but + # not arbitrary callables / non-Guppy `pecos.sim`-acceptable inputs. + # Compile upfront so a wrong input fails loud here, before tracing, + # with a clear @guppy-mentioning message instead of crashing later + # inside the HUGR step. needs_tags = _result_tags_present(detectors_json, observables_json) hugr_bytes: bytes | None = None if needs_tags: @@ -187,12 +188,11 @@ def from_guppy( hugr_bytes = guppy_to_hugr(guppy) except ValueError as exc: msg = ( - "result_tags requires a @guppy-decorated function -- not a " - "compiled program or program wrapper. Pass the raw @guppy " - "function directly. For surface-code / runtime-loop " - "programs, use positional 'records' instead: loops are not " - "unrolled in the HUGR, so per-occurrence tag binding is " - "not statically available (see proposal 001)." + "result_tags requires a @guppy-decorated function (or a " + "GuppyFunctionDefinition, e.g. the object " + "make_surface_code(...) returns) so the program can be " + "compiled to a HUGR. Pass such an input directly, or use " + "positional 'records' / 'meas_ids' instead." ) raise ValueError(msg) from exc diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py b/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py index ca9fe3a6d..fa8fa4e51 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py @@ -17,10 +17,11 @@ 2. **Runtime-loop guard:** a Guppy program with a runtime loop (the surface code) using ``result_tags`` fails loud with the documented "static N vs traced M" message, instead of silently misbinding. -3. **Wrapper-input rejection:** ``result_tags`` requires a ``@guppy``-decorated - function -- a compiled program (e.g. the object ``make_surface_code`` - returns) is rejected fail-loud upfront, not crashed inside the HUGR - compile step. +3. **Non-Guppy callable rejection:** ``result_tags`` requires a HUGR-compilable + input (a ``@guppy``-decorated function or ``GuppyFunctionDefinition`` such + as ``make_surface_code`` returns). A plain Python callable cannot be + compiled to a HUGR and is rejected fail-loud upfront with a clear + ``@guppy`` message, not crashed later inside the HUGR compile step. 4. **Unknown-tag rejection:** referencing a tag the program never records is an error. """ @@ -28,24 +29,34 @@ import pytest from guppylang import guppy from guppylang.std.builtins import result -from guppylang.std.quantum import measure, qubit +from guppylang.std.quantum import measure, qubit, x from pecos.guppy import get_num_qubits, make_surface_code from pecos.qec import DetectorErrorModel # A scrambled straight-line program: three measurements in source order -# qa, qb, qc; but ``result()`` is called for them in reverse-then-mixed order +# qa, qb, qc; ``result()`` is called for them in reverse-then-mixed order # (c, a, b). The HUGR-side extractor binds tag_a -> [0], tag_b -> [1], -# tag_c -> [2] (ordinals of the measurements the tags actually record, not the -# order of the result() calls). If the HUGR traversal ordinal of the i-th -# measurement op equals the traced MeasId order position of the same -# measurement (the property under test), then result_tags resolution produces -# the same DEM as the obvious positional one. +# tag_c -> [2] (ordinals of the measurements the tags actually record, not +# the order of the result() calls). +# +# Each qubit gets a *different* number of single-qubit gates before measure +# (qa: 0, qb: 1, qc: 2). With p1 > 0 those gates contribute distinct error +# mechanisms touching only that qubit's measurement, so the DEMs for +# detectors anchored to records [-3], [-2], [-1] differ in their (number of) +# mechanisms / probabilities. A test asserting result_tags equals positional +# records is then load-bearing: a wrong ordinal mapping would produce a +# different DEM string, not coincidentally equal as it does for symmetric +# programs. The test asserts up front that the three positional DEMs differ +# (so a future-symmetric refactor self-fails rather than silently passing). @guppy def _scrambled_three_measurements() -> None: qa = qubit() qb = qubit() qc = qubit() + x(qb) + x(qc) + x(qc) a = measure(qa) b = measure(qb) c = measure(qc) @@ -54,7 +65,7 @@ def _scrambled_three_measurements() -> None: result("tag_b", b) -_NOISE = {"p1": 0.0, "p2": 0.0, "p_meas": 0.1, "p_prep": 0.0} +_NOISE = {"p1": 0.01, "p2": 0.0, "p_meas": 0.1, "p_prep": 0.005} def _from_guppy(detectors_json: str, *, observables_json: str = "[]") -> str: @@ -80,18 +91,33 @@ def _from_guppy(detectors_json: str, *, observables_json: str = "[]") -> str: # resolves to the same record offset as the positional form for tag X. -@pytest.mark.parametrize( - ("tag", "record"), - [("tag_a", -3), ("tag_b", -2), ("tag_c", -1)], -) -def test_result_tags_match_positional_records(tag: str, record: int) -> None: - """Each tag resolves to the same DEM as its positional-record equivalent.""" - via_tags = _from_guppy(f'[{{"id":0,"result_tags":["{tag}"]}}]') - via_records = _from_guppy(f'[{{"id":0,"records":[{record}]}}]') - assert via_tags == via_records, ( - f"result_tags={tag!r} should resolve to records={record}; HUGR-ordinal != traced-MeasId-order for this case" +def test_result_tags_match_positional_records() -> None: + """Each tag resolves to the same DEM as the corresponding positional + record AND a wrong mapping would produce a different DEM. + + This is the load-bearing cross-check for the HUGR-ordinal == traced- + MeasId-order claim (proposal 001 item #7). The asymmetric pre-history + on qb (1 X gate) and qc (2 X gates) makes the three measurements + DEM-distinguishable, so swapping which tag points to which measurement + would yield a different DEM byte-string. + """ + via_records = { + -3: _from_guppy('[{"id":0,"records":[-3]}]'), + -2: _from_guppy('[{"id":0,"records":[-2]}]'), + -1: _from_guppy('[{"id":0,"records":[-1]}]'), + } + # Sanity: the three reference DEMs must differ -- otherwise the test + # is symmetric and a wrong ordinal mapping would pass spuriously. + assert via_records[-3] != via_records[-2] != via_records[-1] != via_records[-3], ( + "scrambled program is DEM-symmetric across the three measurements; the " + "correspondence test is no longer load-bearing. Restore asymmetric gates." ) + # Now the substantive claim: each tag's DEM matches the positional one. + assert _from_guppy('[{"id":0,"result_tags":["tag_a"]}]') == via_records[-3] + assert _from_guppy('[{"id":0,"result_tags":["tag_b"]}]') == via_records[-2] + assert _from_guppy('[{"id":0,"result_tags":["tag_c"]}]') == via_records[-1] + def test_result_tags_multi_tag_detector_matches_positional() -> None: """A detector referencing multiple tags resolves to the same DEM as @@ -167,3 +193,38 @@ def not_a_guppy_function() -> None: def test_result_tags_unknown_tag_fails_loud() -> None: with pytest.raises(ValueError, match=r"never records|result_tag"): _from_guppy('[{"id":0,"result_tags":["nonexistent_tag"]}]') + + +# --------------------------------------------------------------------------- +# 5. result_tags + records: strict + redundancy-checked, not additive +# --------------------------------------------------------------------------- + + +def test_result_tags_with_redundant_records_builds_unchanged() -> None: + """When ``records`` exactly matches the resolved ``result_tags`` offsets + (sorted-set equality), the entry is accepted and the DEM equals the + records-only equivalent (no double-reference / parity corruption).""" + # tag_a resolves to record -3 in the asymmetric three-measurement program. + with_both = _from_guppy('[{"id":0,"records":[-3],"result_tags":["tag_a"]}]') + records_only = _from_guppy('[{"id":0,"records":[-3]}]') + assert with_both == records_only + + +def test_result_tags_non_redundant_with_records_fails_loud() -> None: + """When ``records`` and ``result_tags`` reference *different* measurements + they are not redundant; the rewriter must fail loud rather than silently + combine them additively (which would either weaken the DEM or, on + accidental duplicate, XOR-cancel the detector's mechanisms).""" + # tag_c resolves to -1; records=-3 is a different measurement. + with pytest.raises(ValueError, match=r"alternatives|not additive|different measurements"): + _from_guppy('[{"id":0,"records":[-3],"result_tags":["tag_c"]}]') + + +def test_result_tags_with_malformed_records_fails_loud() -> None: + """The rewriter must strict-parse existing ``records`` -- not silently + drop malformed entries via filter_map (a regression a previous review + caught). Both a non-integer entry and a non-array shape must fail loud.""" + with pytest.raises(ValueError, match=r"records entries must be integers"): + _from_guppy('[{"id":0,"records":["bad"],"result_tags":["tag_a"]}]') + with pytest.raises(ValueError, match=r"records must be a JSON array"): + _from_guppy('[{"id":0,"records":-3,"result_tags":["tag_a"]}]') From e31389910fd5156cc86413dda92ebc8f2897236e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 19 May 2026 22:19:55 -0600 Subject: [PATCH 20/36] Round-10 doc nits: clarify result_tags is from_guppy-only and alternatives-not-additive --- .../fault_tolerance/dem_builder/builder.rs | 9 ++++--- python/quantum-pecos/src/pecos/qec/dem.py | 27 ++++++++++++------- .../tests/qec/test_from_guppy_result_tags.py | 2 +- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index e0d895fc2..0a5c7601c 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -1686,9 +1686,12 @@ fn observable_records_from_annotations( /// `tag_to_ords` is the **sound** Guppy `result(tag, ...)` -> measurement /// ordinal binding recovered structurally from the compiled HUGR /// (reorder-immune; see `pecos_hugr_qis::result_tags`). Each referenced tag's -/// ordinals are converted to record offsets (`ordinal - traced_meas_count`) -/// and merged into the entry's `records`; `result_tags` is removed so the -/// downstream parser is unchanged. +/// ordinals are converted to record offsets (`ordinal - traced_meas_count`). +/// `result_tags` is an *alternative* to `records` (not additive): if the +/// entry has no `records`, the resolved offsets become its `records`; if it +/// has both, they must be redundant (sorted-set equality) and `records` is +/// left unchanged. `result_tags` is then removed so the downstream parser is +/// unchanged. /// /// Fail-loud (returns `Err`), never silently misbinds: /// - **Loop guard**: if `static_meas_count != traced_meas_count` the program diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 73f5c16ee..9d8260e8c 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -13,15 +13,24 @@ ``TickCircuit``, optionally compiles the program to a HUGR (only when ``result_tags`` is requested -- to recover the sound tag -> measurement binding via ``pecos_hugr_qis::extract_result_tag_measurements``), and hands -the caller's detector/observable JSON to the Rust DEM builder. All metadata -validation -- JSON shape, ``D0``/``L0`` id forms, tracked-Pauli rejection, -``num_measurements`` consistency, out-of-range records, ``meas_id`` -resolution against the circuit's stable stamped ``MeasId``s, and -``result_tags`` -> record-offset resolution with its loop guard -- is the -single responsibility of the Rust builder -(``pecos_qec::fault_tolerance::dem_builder``), so the same rules apply -identically whether a DEM is built via ``from_guppy``, ``from_circuit``, -``DemSampler.from_circuit``, or the public ``DemBuilder``. +the caller's detector/observable JSON to the Rust DEM builder. The metadata +validation that applies to **every** ingest path (``from_guppy``, +``from_circuit``, ``DemSampler.from_circuit``, public ``DemBuilder``) lives +solely in the Rust DEM builder +(``pecos_qec::fault_tolerance::dem_builder``): JSON shape, ``D0``/``L0`` id +forms, tracked-Pauli rejection, ``num_measurements`` consistency, +out-of-range records, ``meas_id`` resolution against the circuit's stable +stamped ``MeasId``s, and the ``records``-vs-``meas_ids`` redundancy rule. + +The ``result_tags`` -> record-offset resolution (loop guard included) is +applied **only** through ``from_guppy``: the rewriter +(``pecos_qec::resolve_result_tags``, invoked via the pyo3 +``resolve_result_tags_for_guppy`` binding) runs from this wrapper before +``from_circuit`` is called, so the downstream DEM builder only ever sees +already-resolved ``records``. ``result_tags`` in circuit metadata fed +directly to ``from_circuit`` / ``DemSampler.from_circuit`` / +``DemBuilder.build`` is **not** resolved -- those paths build from +``records``/``meas_ids`` as usual. """ from __future__ import annotations diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py b/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py index fa8fa4e51..132407a64 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py @@ -161,7 +161,7 @@ def test_result_tags_with_runtime_loop_program_fails_loud() -> None: # --------------------------------------------------------------------------- -# 3. Wrapper-input rejection: result_tags requires @guppy directly +# 3. Non-Guppy callable rejection: result_tags requires a HUGR-compilable input # --------------------------------------------------------------------------- From 653b04c1749c049628b3b6dad67577ebcf567264 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 19 May 2026 23:27:26 -0600 Subject: [PATCH 21/36] Add proposal 002: runtime-loop result_tags via dataflow-bound measurement provenance --- ...oop-result-tags-via-dataflow-provenance.md | 344 ++++++++++++++++++ docs/proposals/README.md | 3 +- 2 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 docs/proposals/002-runtime-loop-result-tags-via-dataflow-provenance.md diff --git a/docs/proposals/002-runtime-loop-result-tags-via-dataflow-provenance.md b/docs/proposals/002-runtime-loop-result-tags-via-dataflow-provenance.md new file mode 100644 index 000000000..f03c4cfcb --- /dev/null +++ b/docs/proposals/002-runtime-loop-result-tags-via-dataflow-provenance.md @@ -0,0 +1,344 @@ +# 002 - Runtime-loop `result_tags` via dataflow-bound measurement provenance + +**Status:** Draft — spike pending. Authoritative on the design *shape*; not yet +validated against Selene's actual lowering behavior. + +**Author:** (dem-polish working notes; design refined by external review) + +**Depends on / extends:** [001 - Tag-referenced detectors for +`DetectorErrorModel.from_guppy`](001-from-guppy-tag-referenced-detectors.md). + +## Summary + +Proposal 001 delivered sound, source-named `result_tags` detectors in +`DetectorErrorModel.from_guppy` for **straight-line, canonical** +`result(tag, measure(q))` programs. The deferred case — runtime +`for _ in range(comptime(n))` loops where the HUGR is not unrolled (one +static measure op per loop body, but N runtime occurrences) — was marked +"upstream-blocked" and `from_guppy` fails loud rather than silently misbind. + +This proposal sketches a PECOS-side path to close that gap. The mechanism is +to extend the QIS trace with a **dataflow-bound measurement-provenance +token**: each static measure op in the HUGR is given a stable static op id +(its `extract_result_tag_measurements` ordinal), and a side-effecting FFI +call attached by **dataflow** to the measurement's result records the +`result_id -> static_op_id` pairing into the per-`ExecutionContext` runtime +state, which is then surfaced in the operation trace. Resolution of +`result_tags` becomes a pure data join: `tag -> static_op_id` (from HUGR, +already implemented) ⋈ `static_op_id -> [MeasIds]` (new, from the trace), +no CFG interpretation required. + +The single load-bearing assumption — that Selene's lowering preserves a +dataflow edge between an injected `record_static_measure` call and the +measurement op that produced its input — is what the spike must +falsify or confirm. + +## Background — why this is deferred today + +Per [proposal 001's authoritative closure section][001-closure]: + +- HUGR-side: `pecos_hugr_qis::extract_result_tag_measurements` recovers + `tag -> static-measure-op` from the compiled HUGR, sound-by-construction + for the canonical scalar `result(tag, measure(q))` pattern. +- For straight-line programs, the HUGR-traversal ordinal of the static + measure op equals its trace `MeasId` order (committed-test verified in + `test_from_guppy_result_tags.py::test_result_tags_match_positional_records`). +- For runtime loops, the HUGR has one static measure op per loop body but + the trace has N runtime occurrences with distinct `MeasId`s. The static + binding tells you `tag -> {static_op_id}`, but **nothing in the current + trace tells you which trace measurements came from which static op**. + +Proposal 001 evaluated three forks for closing this — (1) Selene emits +`RecordOutput`+`result_id`, (2) correlate Selene's named-result stream by +order, (3) non-Selene QIS-FFI backend — and concluded "NOT feasible +PECOS-side." That conclusion scoped the spike to *making Selene cooperate*. +This proposal explores a different scoping: **PECOS modifies the HUGR +itself before Selene compiles it**, injecting structural provenance markers +that propagate through Selene's lowering chain as ordinary side-effecting +FFI calls into PECOS-owned shims. Selene does not need to know anything +special about them. + +[001-closure]: 001-from-guppy-tag-referenced-detectors.md + +## Goal + +For a Guppy program with a runtime `for _ in range(comptime(n))` loop body +emitting `result("syn", measure(q))`, allow +`detectors_json='[{"id":0,"result_tags":[{"tag":"syn","iter":k}]}]'` (or an +equivalent shape — final syntax TBD) to resolve to the `k`-th occurrence of +the `syn` static measure op in trace order, where "trace order" is +empirically the iteration order. Provide a sound, reorder-immune +tag-referenced detector for runtime-loop programs without a CFG +interpreter and without upstream Selene changes. + +## Design + +The design has four parts; each is in a PECOS-owned crate. + +### 1. HUGR pass: inject `record_static_measure` after each measure op + +In `pecos-hugr-qis`, add a new module (e.g. +`crates/pecos-hugr-qis/src/instrument_provenance.rs`) exposing a pass +`instrument_measurement_provenance(hugr: &mut Hugr)` that: + +- Walks `hugr.nodes()` filtering by `is_measurement` (already defined in + `crates/pecos-hugr-qis/src/result_tags.rs:38`). +- Assigns each measurement op the same stable id `extract_result_tag_measurements` + uses — its traversal ordinal (see `result_tags.rs:78` for the existing + numbering). +- Inserts a `tket.qsystem`-or-equivalent `__pecos__rt__record_static_measure` + call **after** each measure op, taking the measurement's result value (or + future) as a dataflow input and the static op id as a constant attribute. + +The critical structural property: `record_static_measure` consumes the +measurement's result. The dataflow edge is what guarantees lowering preserves +the pairing — Selene's compilation cannot reorder the call across the measure +or drop it without breaking dataflow semantics, because dataflow IRs preserve +dataflow edges by construction. + +A `marker-before-measure` variant (`__pecos__rt__set_current_static_op_id(N)` +emitted *before* each measure op, paired with a thread-local read inside the +measure FFI) was considered and rejected: "before" is not a stable semantic +relation unless the IR has an explicit ordering dependency, and standalone +side-effecting calls can be reordered/sunk/hoisted by lowering passes. +TLS-based state is also fragile across parallel-shot batching. Dataflow +binding sidesteps both issues. + +### 2. QIS FFI: per-`ExecutionContext` provenance map + +In `pecos-qis-ffi`, add a new entry point alongside the existing measurement +FFI (`crates/pecos-qis-ffi/src/ffi.rs:208` is where `mz` queues +`QuantumOp::Measure(qubit, result_id)`): + +```rust +extern "C" fn __pecos__rt__record_static_measure(result_id: u64, static_op_id: u64); +``` + +Implementation: lookup the current `ExecutionContext` (the existing per-shot +isolation primitive — bare TLS is rejected; we need per-context state to +survive parallel-shot batching), and write `result_id -> static_op_id` into +a `Vec<(u64, u64)>` or `HashMap` on the context. + +### 3. Trace schema: surface the map per-shot + +Extend `pecos-qis-ffi-types::operations` (the trace event schema, current +`QuantumOp::Measure` at `crates/pecos-qis-ffi-types/src/operations.rs:68`): + +- Either add a new top-level trace event `MeasurementProvenance { result_id, + static_op_id }`, emitted at `record_static_measure` time and consumed + alongside `Quantum::Measure` events; **or** +- Extend the `Quantum::Measure` variant to carry `Option` static op id + populated at flush time by looking up `result_id` in the context's + provenance map. + +The new-event form is less ABI-invasive (existing `Measure` consumers +ignore the new event). Final choice deferred to the spike. + +### 4. DEM resolution: extend `resolve_result_tags` + +In `pecos-qec` (`crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs`, +where `resolve_result_tags` lives today): + +- `extract_result_tag_measurements(hugr)` already returns `tag -> [static_op_id]`. +- The new trace data gives `static_op_id -> [MeasId₀, MeasId₁, …]` in + trace (== iteration) order. +- Compose: `tag -> [all MeasIds attributable to that static op]`, with + optional per-occurrence selection via a richer `result_tags` shape: + + ```json + [{"id": 0, "result_tags": [{"tag": "syn", "iter": 3}]}] + [{"id": 0, "result_tags": ["syn:all"]}] // alternative sugar TBD + ``` + + Final syntax TBD; the resolution is a pure data lookup and adding a richer + syntax later is non-breaking on top of the existing `result_tags` + semantics. + +The pyo3 binding `resolve_result_tags_for_guppy` and the `from_guppy` +wrapper in `python/quantum-pecos/src/pecos/qec/dem.py` would need only the +extra `static_op_id -> [MeasId]` map to be passed in (already obtainable +from the trace consumption in `decode.py`). + +## Why dataflow, not "marker before" + +Critique from review (paraphrased): + +> MLIR's locations/debug attrs are propagated by compiler discipline; your +> FFI marker is an executable side effect. "Before" is not a stable +> semantic relation unless the IR has an explicit ordering dependency. +> Treat it as part of semantics for tracing, with tests that prove it +> survives lowering. … TLS is acceptable only if you can prove marker and +> measure are ordered on the same worker thread and reset correctly. A +> dataflow-attached call is much less fragile. + +The honest framing is: this is a **lowered provenance token**, not debug +metadata. We are extending the program's runtime semantics (the trace now +records provenance), and the soundness of that extension rests on dataflow +preservation, which dataflow IRs do by construction. + +## Critical assumption (the one thing the spike must answer) + +> Can PECOS inject a side-effecting call that consumes a measurement result +> into the HUGR such that Selene's full lowering chain (HUGR -> LLVM IR -> +> compiled binary -> runtime execution) preserves the dataflow edge, +> ensuring the call fires once per dynamic measurement with the correct +> `result_id`? + +If yes: the rest is straightforward engineering. If no: PECOS-side closure +is infeasible and proposal 001's "upstream-blocked" conclusion stands — the +remaining path is upstream `tket-qsystem` measurement provenance. + +The spike is shaped to answer this question minimally and decisively. + +## Spike plan (minimum scope to answer the critical question) + +1. **HUGR pass prototype.** Hand-write or programmatically construct a HUGR + for a Guppy program with two distinct measurements inside a runtime loop + body (sufficient to expose any "single static op assumption" we might + accidentally rely on). Apply + `instrument_measurement_provenance` (which need only be a sketchy + first-pass; this is a spike). +2. **Run through Selene, not just analysis.** Pass the *mutated* + `pecos.Hugr(...)` to `pecos.sim(...)` via the same trace path + `from_guppy` uses (see `python/quantum-pecos/src/pecos/qec/surface/decode.py:719`). + The mutated HUGR must traverse Selene's full lowering, not just + `extract_result_tag_measurements`. +3. **Assert trace pairing.** Capture the trace; assert it contains exactly + the expected `result_id -> static_op_id` pairs for: + - a straight-line two-measure program (control: must agree with the + existing committed `extract_result_tag_measurements` ordering); + - a single-static-measure runtime-loop body (the headline case: N pairs); + - a two-static-measures-per-iteration loop body (catches single-op + assumptions and exposes ordering questions); + - a branch (`if cond: measure(qa) else: measure(qb)`) — closes + dynamic-shape control with provenance, even if measurement-dependent + control flow remains unsupported elsewhere (see "Out of scope"). +4. **LLVM IR inspection.** At the optimization level Selene actually uses, + inspect the lowered LLVM to confirm `record_static_measure` is inside + the loop, data-dependent on the measurement's result, and not + dead-code-eliminated / hoisted / fused. This is the falsification step + for the critical assumption. +5. **Parallel-shot isolation.** Run the multi-shot batching path; assert + per-`ExecutionContext` provenance maps stay isolated (no cross-shot + leakage). + +Outcome: either a green spike that proves the design works under Selene's +actual lowering, or a falsifying observation that pins down the precise +behavior that breaks it (and so guides whether upstream is the only path). + +## Soundness scope + +This design closes: + +- **Per-occurrence runtime-loop tag binding** (the proposal-001 deferred + item) for fixed/comptime-bounded loops, where execution order is + deterministic. +- **Branch-as-observed provenance**: dynamic-shape branches with no + measurement-dependent control flow get correct per-execution provenance + (which static measure op fired). + +This design does **not** close: + +- **Measurement-dependent dynamic control flow.** `from_guppy` traces one + ideal execution; a Guppy program whose quantum operations depend on a + measurement *outcome* still yields a DEM from a single sampled branch, + silently wrong and seed-dependent. Provenance tells you *what fired in + this trace*, not *what would fire across all possible measurement + outcomes*. Sound treatment of measurement-dependent control still needs + static rejection (HUGR conditional-on-measurement analysis) or + branch-aware DEM construction. Out of scope here. +- **Soundness of the surrounding fault model under non-deterministic + control.** Same reason. + +## Out of scope / explicitly rejected alternatives + +- **TLS-marker `set_current_static_op_id` before the measure op.** Rejected + on the design-review grounds above: "before" is not a stable semantic + relation; lowering can reorder/sink/hoist; TLS is fragile across parallel + shots. +- **Selene named-result-stream correlation by order.** Same class of + order-dependent mechanism that proposal 001 excised (it was unsound for + the straight-line case via the runtime read/store linkage, by the same + argument). It might work for a narrow loop pattern but is brittle around + computed results, repeated reads, arrays, CSE, and Selene's scheduling. +- **CFG interpreter inside `pecos-hugr-qis`.** A different solution to a + different half of the problem: CFG interpretation gives all-paths + reasoning needed for sound dynamic-control DEM semantics. Runtime + provenance gives what-fired reasoning for fixed loops and branches as + observed. For surface-code-style fixed loops, runtime provenance is + simpler and likely more robust; for dynamic control, neither suffices in + isolation. Punted to a future proposal. + +## Open questions + +1. **Final `result_tags` syntax for per-occurrence selection.** The + resolution is a pure data lookup; the syntax (e.g. `{"tag":"syn","iter":k}` + vs `"syn:k"` vs `"syn:all"`) is a JSON-schema decision deferred to the + spike. Prefer something extensible and unambiguous to misuse. +2. **Trace event shape.** New top-level `MeasurementProvenance` event vs. + extended `Measure` variant. Choose at spike time based on Selene's + actual behavior (the new-event form is less ABI-invasive). +3. **HUGR pass position.** Should + `instrument_measurement_provenance` run as part of `from_guppy`'s + `guppy_to_hugr` step, or as a separate explicit pass callers opt into? + The former is invisible to callers (preferred for `result_tags` users); + the latter keeps the trace clean for non-`result_tags` consumers. +4. **Whether to use the same provenance for `meas_ids`.** Today + `meas_ids` resolves against stamped `MeasId`s positionally. With + provenance available, `meas_ids` could be redefined as stamped MeasIds + tagged by static op — but the existing semantics are sound and the + redundancy discipline (`records`/`meas_ids` alternatives) works. Don't + change without reason. + +## Effort estimate (spike) + +Roughly: + +- HUGR pass prototype: 1–2 days (existing `pecos-hugr-qis` machinery covers + most of it; need to investigate `tket`'s op-construction API for the new + FFI call type). +- FFI + trace schema additions: 1 day. +- End-to-end through Selene + LLVM IR inspection + parallel-shot stress: + 2–3 days. + +Total spike: ~1 work week, with clear go/no-go signal at the end. + +If the spike succeeds: productionizing it (resolver extension, syntax +finalization, tests, docs) is another ~1 work week. + +If the spike fails: the failure mode tells us exactly what upstream +`tket-qsystem` measurement-provenance support PECOS would need, which is +useful even if PECOS-side closure is abandoned. + +## What this proposal does NOT change + +- `dem-polish` is unchanged. Today's `from_guppy` continues to support + sound straight-line `result_tags` and fails loud on runtime-loop programs + using `result_tags`. Positional `records`/`meas_ids` continue to work + for all programs (including the surface code). This proposal is + forward-looking work, not a fix to merged code. + +## Code paths the spike touches (reference) + +- `crates/pecos-hugr-qis/src/result_tags.rs` — existing + `extract_result_tag_measurements`, `measurement_op_count`, + `is_measurement`. The new pass module lives alongside. +- `crates/pecos-hugr-qis/src/lib.rs` — re-exports. +- `crates/pecos-qis-ffi/src/ffi.rs:208` — current measurement FFI; new + `__pecos__rt__record_static_measure` FFI added here. +- `crates/pecos-qis-ffi-types/src/operations.rs:68` — `QuantumOp::Measure` + trace event; new `MeasurementProvenance` event or extension added here. +- `crates/pecos-qis/src/ccengine.rs` — `ExecutionContext`; provenance map + attached as new field. +- `crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs` — existing + `resolve_result_tags`; extended to consume `static_op_id -> [MeasId]`. +- `python/pecos-rslib/src/dag_circuit_bindings.rs` — existing + `resolve_result_tags_for_guppy` pyo3 binding; extended argument. +- `python/quantum-pecos/src/pecos/qec/dem.py` — existing `from_guppy` + wrapper; passes the new provenance map through. +- `python/quantum-pecos/src/pecos/qec/surface/decode.py:719` — existing + trace path; spike must use this same path (`pecos.sim(...)` on the + mutated HUGR), not the analysis-only `extract_result_tag_measurements` + path. +- `python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py` — new + tests for the loop-occurrence case once the spike succeeds. diff --git a/docs/proposals/README.md b/docs/proposals/README.md index a728ac98e..5bc78a72e 100644 --- a/docs/proposals/README.md +++ b/docs/proposals/README.md @@ -15,7 +15,8 @@ This directory contains architectural proposals and design explorations for PECO | Folder/File | Status | Summary | |-------------|--------|---------| -| [001-from-guppy-tag-referenced-detectors.md](001-from-guppy-tag-referenced-detectors.md) | Draft | Capture Guppy `result()` tags so `DetectorErrorModel.from_guppy` detectors are reorder-proof | +| [001-from-guppy-tag-referenced-detectors.md](001-from-guppy-tag-referenced-detectors.md) | Draft | Capture Guppy `result()` tags so `DetectorErrorModel.from_guppy` detectors are reorder-proof (delivered for the straight-line scope; runtime-loop case deferred to 002) | +| [002-runtime-loop-result-tags-via-dataflow-provenance.md](002-runtime-loop-result-tags-via-dataflow-provenance.md) | Draft | Close 001's runtime-loop deferral PECOS-side via a dataflow-bound `record_static_measure` FFI injected into the HUGR before Selene compiles it; spike pending | ## Contributing From a620255dad4d3ee089074235801e2487cae79f42 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 19 May 2026 23:36:06 -0600 Subject: [PATCH 22/36] Add proposals 003 (hand-authored tracked Paulis) and 004 (sound DEM for measurement-dependent control) --- ...ored-tracked-paulis-in-observables-json.md | 262 ++++++++++++++ ...-measurement-dependent-control-flow-dem.md | 327 ++++++++++++++++++ docs/proposals/README.md | 2 + 3 files changed, 591 insertions(+) create mode 100644 docs/proposals/003-hand-authored-tracked-paulis-in-observables-json.md create mode 100644 docs/proposals/004-measurement-dependent-control-flow-dem.md diff --git a/docs/proposals/003-hand-authored-tracked-paulis-in-observables-json.md b/docs/proposals/003-hand-authored-tracked-paulis-in-observables-json.md new file mode 100644 index 000000000..3da5dde44 --- /dev/null +++ b/docs/proposals/003-hand-authored-tracked-paulis-in-observables-json.md @@ -0,0 +1,262 @@ +# 003 - Hand-authored tracked Paulis in `observables_json` + +**Status:** Draft — spike pending. Captures the design question and the +soundness assumption that distinguishes this from the existing +positional/annotation-only path. + +**Author:** (dem-polish working notes) + +**Depends on / extends:** [001 - Tag-referenced detectors for +`DetectorErrorModel.from_guppy`](001-from-guppy-tag-referenced-detectors.md) +(which explicitly out-of-scoped this); applies the same +structural-HUGR-binding pattern. + +## Summary + +Today `observables_json` actively rejects `{"kind": "tracked_pauli", ...}` +entries. Tracked Paulis are produced only from **circuit annotations** (e.g. +`dag.tracked_pauli(PauliString.from_str("X0 Z2"), label="x_check")`), never +from caller JSON. The rejection is committed-test pinned +(`test_from_guppy_rejects_json_tracked_pauli_observables`) and the +`from_guppy` docstring documents it as a hard limitation. The reason isn't +schema laziness — it's that **qubit identity through Guppy/Selene +compilation is not stable enough today** to safely accept positional qubit +references from caller text. A positional `"X0 Z2"` written against the +user's mental model of the program is not guaranteed to mean the same qubit +the trace actually allocates first. + +This proposal lays out a path to soundly accept hand-authored tracked Paulis +in `observables_json` by giving qubits the same treatment proposal 001 gave +measurements: a stable, structural HUGR-derived qubit identifier that +travels through compilation. The MLIR-pattern shape from 002 also applies if +qubit identity needs to track runtime-loop instances of `qubit()` +allocations. + +## Background + +- `from_guppy` docstring (`python/quantum-pecos/src/pecos/qec/dem.py`) + observable section: *"hand-authored JSON tracked Paulis are NOT supported + by this path. … A `{"kind": "tracked_pauli", ...}` entry here is rejected + by the builder."* +- Rust parser (`reject_tracked_pauli` in + `crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs`) errors fail- + loud on `kind=="tracked_pauli"` for both detectors and observables. +- The existing annotation path + (`dag.tracked_pauli(PauliString.from_str(...))`) builds tracked Paulis + from in-Rust qubit indices in the circuit-construction order. For the + surface builder this is well-defined (the builder controls qubit + numbering); for `from_guppy`, the program is compiled and traced through + Selene and the qubit numbering is post-trace allocation order. Caller text + written against source-level intent may not agree. +- Prior dem-polish commit `46243b0d` ("Document tracked-Pauli qubit- + numbering limitation in `from_guppy`") records exactly this concern. + +## Goal + +Allow `observables_json` to include hand-authored tracked Paulis, e.g. + +```json +[{"id": 0, "kind": "tracked_pauli", "label": "X_logical", + "pauli": [{"qubit_ord": 0, "pauli": "X"}, + {"qubit_ord": 2, "pauli": "Z"}]}] +``` + +resolved soundly against the traced circuit — i.e. `qubit_ord: 0` means +*the qubit allocated by the 0th `AllocateQubit` op in HUGR traversal order*, +which is the same reorder-immune binding `extract_result_tag_measurements` +gives for measurements. + +Alternatively (or additionally) allow a Pauli-string form `"+X0 Z2"` where +the bare integer is reinterpreted as the same HUGR-derived qubit ordinal, +once the correspondence to traced slot is committed-test verified. + +## Design + +Four parts, mirroring proposal 001's measurement-tag work. + +### 1. HUGR pass: structural qubit ordinals + +New module in `pecos-hugr-qis` (e.g. `qubit_ordinals.rs`): + +```rust +pub fn extract_qubit_allocation_ordinals>( + hugr: &H, +) -> BTreeMap; // ordinal -> AllocateQubit node + +pub fn qubit_allocation_count>(hugr: &H) -> usize; +``` + +Numbering: HUGR traversal ordinal of `AllocateQubit` ops (parallel to how +`extract_result_tag_measurements` numbers `Measure` ops). Sound by +construction — purely structural. + +### 2. Rust parser: accept tracked_pauli observables + +Extend `parse_single_observable` in +`crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs`: + +- Detect `kind=="tracked_pauli"`. +- Parse `pauli` as either: + - A list of `{"qubit_ord": N, "pauli": "X" | "Y" | "Z"}` objects, **or** + - A string `"+X0 Z2"` whose integers are interpreted as qubit ordinals + (post-resolution). +- Parse `label` as an optional string. + +Schema validation: integer ordinals only, single Pauli letter per qubit +(X/Y/Z), no duplicate qubit ordinals within a single observable. + +### 3. Resolver: ordinal → traced qubit slot + +Same shape as `resolve_result_tags` for measurements: + +```rust +pub fn resolve_tracked_pauli_qubit_ordinals( + observables_json: &str, + qubit_ord_to_slot: &BTreeMap, + static_qubit_count: usize, + traced_qubit_count: usize, +) -> Result; +``` + +Resolves each `qubit_ord` to a traced qubit slot; rewrites +`{"qubit_ord": N, "pauli": "X"}` entries into the slot-indexed form the +existing tracked-Pauli machinery already consumes. Same fail-loud +discipline: unknown ordinal → error; static-vs-traced count mismatch → loop +guard error (the qubit analog of 002's measurement loop guard, for runtime- +loop qubit allocation). + +### 4. `from_guppy` wiring (thin, in `dem.py`) + +Already compiles the HUGR when `result_tags` is requested; do the same when +any observable has `kind=="tracked_pauli"`. Pass HUGR bytes + traced qubit +count to a pyo3 `resolve_tracked_pauli_for_guppy`. The downstream Rust DEM +builder consumes already-resolved tracked Paulis. + +## Critical assumption (the one thing the spike must answer) + +> **For a straight-line Guppy program, the HUGR-traversal ordinal of the +> `i`-th `AllocateQubit` op equals the trace allocation order of the qubit +> slot that `qubit()` produced.** + +This is the qubit-side analog of the measurement-side property 001 left +"unproven and no longer relied upon," and that 001's wiring then proved by +committed cross-check for the supported scope. The same test pattern +applies here: write a Guppy program with N source-scrambled `qubit()` +allocations + a tracked Pauli on a specific subset; the DEM via +`qubit_ord`-resolved tracked Pauli must equal the DEM built via the +positional annotation form for the same qubits. If they match across an +asymmetric program, the correspondence is committed-test-verified for the +supported scope. + +If the correspondence fails: this proposal needs 002's measurement- +provenance mechanism extended to qubits (a `record_static_qubit_alloc` +FFI), and effort grows considerably. + +## Spike plan + +1. Build the `extract_qubit_allocation_ordinals` HUGR pass and a + `qubit_allocation_count` companion. Foundation-test on a scrambled + straight-line Guppy program (mirror of 001's + `scrambled_three_measurements`). +2. Prototype the parser + resolver minimally; bypass `from_guppy` wiring. +3. **Correspondence cross-check**: write a Guppy program with three qubits + in scrambled allocation order, each with a distinct gate history (so the + DEMs for tracked Paulis on each are distinguishable, à la 002's + asymmetric scrambled test). Build a tracked Pauli via the new ordinal + form and via the existing positional annotation form; assert byte- + identical DEMs. +4. Wire into `from_guppy`, replace the rejection-of-`tracked_pauli` test + with a positive test, add unknown-ordinal / loop-guard / non-@guppy / + wrapper-input fail-loud tests (mirroring `test_from_guppy_result_tags.py`). + +## Soundness scope + +Covers: +- Straight-line Guppy programs with statically-allocated qubits. +- Caller observables of the form *"this logical observable is the parity of + Pauli operators on these qubits"* — the canonical Stim-style tracked + Pauli. + +Does **not** cover: +- Runtime-loop qubit allocation (`for _ in range(comptime(n)): q = qubit(); + …`). Same gap structure as 002's measurement case: one static + `AllocateQubit` op, N traced slots, no static-op → traced-slot + correspondence without 002-style provenance. The loop guard rejects this + case fail-loud; closing it composes with 002 (extend provenance to qubits) + if/when 002 lands. +- Source-named qubits (`{"qubit_name": "qa"}`). Would require Guppy to + expose source-level qubit names through the HUGR. Orthogonal extension. +- Dynamic qubit allocation under measurement-dependent control flow. + Inherits 004's scope. + +## Out of scope / alternatives considered + +- **Just accept the existing `PauliString.from_str("X0 Z2")` form + positionally without HUGR resolution.** Rejected: this is exactly the + fragile path 001 set out to fix for measurements; doing it for qubits + would reintroduce the same silent-misbind risk on + Guppy/Selene-recompilation. +- **Surface source qubit names from Guppy.** Would be cleaner from a UX + perspective (`"qubit_name": "qa"`) but depends on Guppy preserving + variable names through HUGR generation, which is upstream and not + guaranteed. The HUGR ordinal form is available today. +- **Make tracked Paulis reference measurements (records/meas_ids/result_tags) + instead of qubits.** That's a different conceptual model — tracked + Paulis are physical observables on qubits, not on measurement records. + Conflating them would be a category error. + +## Open questions + +1. **`pauli` JSON shape.** Object list vs string-with-integers vs both? + String form is concise but ambiguous (`"X10"` could be `X on qubit 10` + or `X10 (rank-10 Pauli)`); object form is verbose but unambiguous. + Preference: support both, with the object form as the canonical / safer + one. +2. **Should the `result_tags` HUGR-compile in `from_guppy` be shared with + the new tracked-Pauli HUGR-compile?** Both need `guppy_to_hugr(guppy)`; + compile once if either is present. Trivial optimization. +3. **Naming.** `qubit_ord` vs `qubit_ordinal` vs `qubit_id`. The last + collides with PECOS's internal `QubitId` type (not the same thing). + Prefer `qubit_ord` for clarity. + +## Code paths the spike touches + +- `crates/pecos-hugr-qis/src/qubit_ordinals.rs` — new (parallel to + `result_tags.rs`). +- `crates/pecos-hugr-qis/src/lib.rs` — re-export + `extract_qubit_allocation_ordinals`, `qubit_allocation_count`. +- `crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs` — + `parse_single_observable` extension; new `resolve_tracked_pauli_qubit_ordinals`. +- `python/pecos-rslib/src/dag_circuit_bindings.rs` — new pyo3 wrapper + `resolve_tracked_pauli_for_guppy`. +- `python/quantum-pecos/src/pecos/qec/dem.py` — `from_guppy` thin pass- + through; share `guppy_to_hugr` compile with `result_tags` when both are + present. +- `python/quantum-pecos/tests/qec/test_from_guppy_dem.py` — replace + `test_from_guppy_rejects_json_tracked_pauli_observables` with a positive + test of the new form; add unknown-ordinal / loop-guard / asymmetric + correspondence tests. +- `python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py` may grow + a mixed test (result_tags detector + tracked-Pauli observable on the + same scrambled program). + +## Effort estimate + +- HUGR pass + foundation tests: 1 day. +- Parser/resolver/binding: 1–2 days. +- Wiring + correspondence test + asymmetric/edge-case tests: 2 days. +- Documentation updates + proposal 001 closure addendum: 0.5 day. + +Total spike + production: ~1 work week. Significantly smaller than 002 +because it does **not** require trace-schema or FFI changes — purely HUGR- +side analysis and JSON-side handling, plus a single critical-assumption +correspondence test analogous to one we've already done for measurements. + +## What this proposal does NOT change + +- `dem-polish` is unchanged. Today's `from_guppy` continues to fail loud on + hand-authored tracked Paulis in `observables_json`; circuit-annotation + tracked Paulis continue to work unchanged. +- Proposal 002's runtime-loop closure is independent. If 002 lands first + and adds measurement provenance, this proposal naturally extends to add + qubit-allocation provenance using the same mechanism. diff --git a/docs/proposals/004-measurement-dependent-control-flow-dem.md b/docs/proposals/004-measurement-dependent-control-flow-dem.md new file mode 100644 index 000000000..2293c2c6e --- /dev/null +++ b/docs/proposals/004-measurement-dependent-control-flow-dem.md @@ -0,0 +1,327 @@ +# 004 - Sound DEM construction for measurement-dependent control flow + +**Status:** Draft — spike pending. Two options laid out (static rejection + +branch-aware DEM); strong recommendation to ship option A first. + +**Author:** (dem-polish working notes) + +**Depends on:** [001 - Tag-referenced detectors for +`DetectorErrorModel.from_guppy`](001-from-guppy-tag-referenced-detectors.md) +(which documents the limitation), and overlaps with +[002 - Runtime-loop `result_tags` via dataflow-bound measurement +provenance](002-runtime-loop-result-tags-via-dataflow-provenance.md) +(which explicitly does *not* close this). + +## Summary + +`DetectorErrorModel.from_guppy` traces **one ideal execution** of a Guppy +program and builds a DEM from that trace. A program with +**measurement-dependent quantum control flow** — e.g. `if measure(q): +x(other)` — yields a DEM built from a single sampled branch: silently +wrong, seed-dependent, undefined. The `from_guppy` docstring explicitly +documents this as unsupported, and `from_guppy` does **not currently reject +such programs** — it builds a DEM that callers must not rely on. + +A prior dem-polish round attempted a runtime-trace heuristic +(`reject_dynamic_control`) to detect this case; it false-positived on the +standard surface code (which has statically-scheduled post-measurement +gates per round, indistinguishable from genuine measurement-dependent +feedback in the runtime trace) and was reverted. The reverted-guard +analysis is committed in proposal 001's "Second external review + +outcome" section. The proposal recommendation that came out of that +analysis is **sound detection requires static HUGR analysis, not a runtime- +trace heuristic**. + +This proposal lays out two viable sound paths and recommends starting with +**option A: static rejection** to close the silent-misbind hole, with +**option B: branch-aware DEM construction** as a separate larger +follow-up if/when there's user demand. + +## Background — why this is currently a silent-wrong hole + +- `from_guppy` runs `pecos.sim(program).classical(pecos.selene_engine()) + .quantum(pecos.stabilizer()).qubits(N).seed(s).capture_operation_trace()`. +- The trace is the gates that fired on this one sampled execution. For a + measurement-dependent branch, exactly one branch's gates are in the + trace; the other branches are invisible. +- The DEM is then built over those traced gates as if they were a static + circuit. The "fault propagation" calculation is structurally fine on the + traced circuit, but **the DEM does not model the gates that would have + fired in other branches** — so the result is correct *only* for the one + branch that happened to fire, and wrong for any decoding scenario where a + different branch was active. +- For typical Guppy QEC workflows this hole is hypothetical (programs are + straight-line or have only statically-scheduled feedback), but the + language allows it, the docstring says it's "unsupported / undefined," + and no machinery enforces that. + +## The sound vs unsound boundary (why the reverted guard failed) + +Runtime trace observation: +- Surface code: ancilla measure → statically-scheduled per-round gate on + ancilla qubit (e.g. ancilla reset / classical update) → next round + measure. The trace shows "Measure(q_a, r0); … other gates; Measure(q_a, + r1)" with classical ops between. Indistinguishable from "Measure(q_a, + r0); if r0: X(q_other); Measure(q_a, r1)" at the trace level. +- The reverted `reject_dynamic_control` heuristic looked for non-MZ + quantum ops in `pending_continue` chunks after a measurement and rejected + them. It false-positive-rejected the surface code; the only way to + avoid that false positive was to admit the false-negatives. + +Static HUGR observation: +- The HUGR dataflow makes "Quantum op X depends on Measure op M's result" + a **structural property**: there is a dataflow path from M's classical + output (through `tket.bool:read`, possibly through `tket.bool:eq` / + classical computations, into a `Conditional` op whose body contains X). +- The surface code's post-measurement gates have **no dataflow edge** from + any Measure op's output to their control inputs — they're statically + scheduled, not conditional. The structural check classifies them + correctly as not-measurement-dependent. +- A `Conditional` whose discriminant traces back to a Measure op + unambiguously *is* measurement-dependent control flow. + +This is the same sound-vs-unsound boundary 001's `result_tags` work +established for measurement identity: **structural (HUGR) is sound, +behavioral (runtime trace) is not**. + +## Goal + +Close the silent-misbind hole. Either: + +- **(A)** Soundly **reject** Guppy programs whose DEM cannot be built + faithfully (measurement-dependent quantum control flow), with a clear, + actionable error message. +- **(B)** Soundly **build a DEM** that captures all reachable branches and + is correct for any execution path the program could take. + +Option A is mandatory for soundness. Option B is a feature on top. + +## Option A: static rejection (recommended first) + +A HUGR pass: + +```rust +pub fn detect_measurement_dependent_quantum_ops>( + hugr: &H, +) -> Vec; + +pub struct MeasurementDependentOp { + pub quantum_op: Node, + pub measure_source: Node, + pub via_path: Vec, +} +``` + +It walks every Quantum op (or every op that PECOS classifies as a quantum +operation), checks whether any of its control/discriminant inputs has a +dataflow ancestor that is a Measure op. The reverse-walk is bounded: stop +at a Measure (positive — measurement-dependent), at a function input or +`Const` (negative — independent), at a comptime classical value (negative — +not a runtime measurement). + +Wiring: `from_guppy` runs this check after `guppy_to_hugr`. If any +measurement-dependent Quantum op is detected, fail loud: + +``` +ValueError: from_guppy cannot soundly build a DEM for a program with +measurement-dependent quantum control flow. Detected: Quantum op `x(q1)` +at … is conditional on Measure result from … (path: …). DEM construction +traces one ideal execution and does not model alternate branches; build +the static-circuit-equivalent program explicitly, or use +`pecos.sim`-based sampling for measurement-dependent dynamics. +``` + +This **closes the silent-misbind hole**. It does not enable the feature. + +### Cases the spike must validate + +| Program | Expected | +|---|---| +| straight-line `qubit() ; measure(q)` | not flagged | +| `if measure(q1) == measure(q2): x(q3)` (computed conditional) | flagged via `tket.bool:eq` | +| `if measure(q): x(other)` | flagged | +| surface code (`make_surface_code(...)`) | **not flagged** (no Measure→Quantum dataflow edge) | +| `if comptime_const: x(q)` | not flagged (comptime, not measurement-derived) | +| `for _ in range(comptime(n)): x(q)` | not flagged (comptime loop, not measurement-dependent) | +| `if measure(q): result("x", True)` (classical-only conditional) | **not flagged** (no Quantum op in the conditional body) | + +The last row is important: measurement-dependent **classical** updates +(producing more `result()` tags) are common and don't affect DEM +construction. The check is specifically "any Quantum op that's +measurement-conditional." + +## Option B: branch-aware DEM (separate follow-up) + +For a `Conditional` op whose discriminant depends on a measurement, both +branches can fire on different shots. A sound DEM must include the fault +mechanisms from both branches' Quantum ops, conditioned on the relevant +measurement outcomes. + +Strategies: + +- **Static enumeration.** Walk the HUGR, identify all measurement- + dependent `Conditional` ops, enumerate the cross product of branches + (2^k for k boolean measurements). For each combination, generate a + hypothetical static circuit by inlining the chosen branches, build a per- + combination DEM, combine. Tractable for small `k`; explodes for + surface-code-scale measurement counts. Likely scoped to "k ≤ small N" + with a guard. +- **CFG abstract interpreter.** A proper symbolic execution of the HUGR + treating measurement outcomes as symbolic boolean inputs. This is + essentially the excluded `HugrEngine`. Substantial. +- **Path summarization.** For specific structural patterns (e.g. + syndrome-conditional Pauli correction), the branch effect on the fault + model is summarizable analytically. Pattern-specific, not general. + +Option B's design space is large and depends on actual user demand. **The +recommendation is to defer it.** Option A alone is a complete soundness +fix; option B is a feature whose cost/benefit needs concrete use cases to +evaluate. + +## Critical assumption (the one thing option A's spike must answer) + +> **The structural HUGR analysis `detect_measurement_dependent_quantum_ops` +> can distinguish measurement-conditional Quantum ops from +> statically-scheduled post-measurement Quantum ops with no false positives +> on real QEC workflows (surface code in particular) and no false negatives +> on genuine dynamic-control programs.** + +Falsifiable: run on the cases in the table above; surface code is the +critical non-false-positive case (the same one that killed the reverted +runtime-trace guard); `if measure(q): x(other)` is the critical true- +positive case. + +If the HUGR dataflow analysis can't cleanly classify e.g. `comptime` +conditionals or `tket.bool` chains, the spike answer is the specific +HUGR-op pattern that needs special-casing — and the scope of the special- +case set determines feasibility. + +## Spike plan (option A only) + +1. **Catalog the HUGR-op shapes involved**: `Conditional` op, `tket.bool:*` + ops, `Const`, function inputs, `comptime`-derived constants. Verify how + Guppy lowers each branch pattern. +2. **Implement `detect_measurement_dependent_quantum_ops`** in + `pecos-hugr-qis` as a reverse-dataflow walk from each Quantum op's + control/discriminant inputs. Terminate at Measure (positive), at + Const/Input/comptime-constant (negative). +3. **Foundation tests** in `pecos-hugr-qis` on hand-built or compiled-from- + Guppy fixture HUGRs covering the case table. +4. **Wire into `from_guppy`** — call the analysis on the HUGR after + `guppy_to_hugr` (and share that compile with `result_tags` / + tracked-Pauli paths if either is also active). Fail loud on detection. +5. **Update tests**: replace + `test_from_guppy_dynamic_control_is_unsupported_and_unguarded` (which + currently asserts no guard rejects, pinning the absence of a + detector) with `test_from_guppy_statically_rejects_measurement_dependent_quantum_control` + (asserts the new sound detector rejects dynamic programs *and* accepts + the surface code — the critical regression test). +6. **Sanity sweep**: run the full qec pytest to confirm no existing + workflow trips the new check. + +## Soundness scope + +Option A covers: +- Closing the silent-wrong-DEM hole for measurement-dependent quantum + control flow. After A, `from_guppy` either produces a correct DEM (for + programs without such control) or refuses fail-loud (for programs with + it). + +Option A does **not** cover: +- Building correct DEMs for measurement-dependent programs. That's + option B. +- Measurement-dependent **classical** control (e.g. + measurement-dependent `result()` outputs that don't change the quantum + state). Such programs are not flagged because they don't affect DEM + construction. The trace simply records different `result()` values per + shot, which is fine. + +## Relationship to 001 and 002 + +- **001** introduced the soundness discipline (structural HUGR vs runtime + heuristic) and explicitly noted measurement-dependent control as a + separate deferred case requiring static analysis. This proposal closes + that. +- **002** addresses *measurement identity through runtime loops* — the + question "which trace measurement came from which static measure op?". + That's orthogonal to *whether a Quantum op is measurement-conditional*. + 002's `record_static_measure` mechanism doesn't help here, and 004's + static analysis doesn't help 002 — different problems, different + mechanisms. They can be implemented independently and in either order. +- **003** (tracked Paulis in JSON) is also independent. Programs with + measurement-dependent quantum control flow that *also* use hand-authored + tracked Paulis would be rejected by 004's check before 003's resolution + fires. + +## Out of scope / alternatives considered + +- **Runtime guards.** Already-attempted-and-reverted. Cannot + soundly distinguish surface code from genuine dynamic control. +- **Documentation-only.** "Don't write programs with measurement-dependent + control flow if you use `from_guppy`" is the current state. It's + inadequate because the resulting silent-wrong DEM is a correctness defect + callers may not notice; the language allows the construct and `from_guppy` + produces *some* DEM that callers may use. +- **Optional accept-anyway flag** (e.g. `unsafe_allow_dynamic_control`). + Tempting but contrary to the project's "fail loud, no silent-wrong" + values. If a user genuinely needs DEM for a specific dynamic program, + they should expand it into the static-circuit-equivalent themselves (the + error message suggests this), or option B should be built. + +## Open questions + +1. **What exactly counts as a "Quantum op" for the check?** Probably + anything classified by `pecos_hugr_qis`'s existing + measurement/quantum-op recognition (`is_measurement` and any analog for + Pauli/Clifford ops). Should `Measure` ops themselves under a measurement- + dependent conditional also be flagged? (Today: yes — a conditional + measurement is measurement-dependent.) +2. **Should the check run *unconditionally* in `from_guppy`, or only when + no measurement-dependent feature is explicitly opted into?** Probably + unconditional — the cost is a HUGR walk, and the goal is to close the + silent-wrong hole for every caller. +3. **`from_circuit` and `DemSampler`.** Should the analogous check run on + circuits not built via `from_guppy`? Probably not — `from_circuit` + consumes an already-constructed circuit that doesn't have HUGR-level + conditional ops at all (it's a flat TickCircuit). Measurement- + dependent control is a Guppy-source concern. +4. **Sharing the HUGR compile with `result_tags` / proposal 003.** If any + of {result_tags, tracked_pauli, the new check} is active, `guppy_to_hugr` + runs once. The new check fires *first* (cheap, fail-loud short-circuit + before resolving tags). + +## Code paths the spike touches + +- `crates/pecos-hugr-qis/src/conditional_on_measurement.rs` — new module + implementing the analysis. +- `crates/pecos-hugr-qis/src/lib.rs` — re-export. +- `python/pecos-rslib/src/dag_circuit_bindings.rs` — new pyo3 wrapper + `detect_measurement_dependent_quantum_ops_for_guppy`. +- `python/quantum-pecos/src/pecos/qec/dem.py` — `from_guppy` runs the + check after `guppy_to_hugr` (whenever the HUGR is compiled), or + unconditionally; raises `ValueError` with a clear message on detection. +- `python/quantum-pecos/tests/qec/test_from_guppy_dem.py` — replace + `test_from_guppy_dynamic_control_is_unsupported_and_unguarded` with the + positive rejection test; ensure surface-code and `result_tags` + byte-identical tests continue to pass (critical regression check). + +## Effort estimate (option A) + +- HUGR analysis + foundation tests: 2–3 days (the reverse-dataflow walk is + not large, but it must correctly handle the `tket.bool:*`/`Conditional`/ + `Const`/comptime cases). +- Wiring + test updates: 1 day. +- Surface-code regression confirmation + edge cases: 1 day. + +Total: ~1 work week, with a clear go/no-go signal (does the analysis +classify the case-table cases correctly?). + +Option B is a much larger separate proposal if/when it becomes needed. + +## What this proposal does NOT change + +- `dem-polish` is unchanged. Today's `from_guppy` continues to build a DEM + (silently wrong) for measurement-dependent control programs and is + documented as not-supported for such programs. This proposal adds the + static guard. +- 002 and 003 are independent and can land in any order relative to 004. diff --git a/docs/proposals/README.md b/docs/proposals/README.md index 5bc78a72e..d2ff1fc17 100644 --- a/docs/proposals/README.md +++ b/docs/proposals/README.md @@ -17,6 +17,8 @@ This directory contains architectural proposals and design explorations for PECO |-------------|--------|---------| | [001-from-guppy-tag-referenced-detectors.md](001-from-guppy-tag-referenced-detectors.md) | Draft | Capture Guppy `result()` tags so `DetectorErrorModel.from_guppy` detectors are reorder-proof (delivered for the straight-line scope; runtime-loop case deferred to 002) | | [002-runtime-loop-result-tags-via-dataflow-provenance.md](002-runtime-loop-result-tags-via-dataflow-provenance.md) | Draft | Close 001's runtime-loop deferral PECOS-side via a dataflow-bound `record_static_measure` FFI injected into the HUGR before Selene compiles it; spike pending | +| [003-hand-authored-tracked-paulis-in-observables-json.md](003-hand-authored-tracked-paulis-in-observables-json.md) | Draft | Soundly accept hand-authored tracked-Pauli observables in `observables_json` by giving qubits structural HUGR ordinals (the same MLIR-pattern proposal 001 applied to measurements); spike pending | +| [004-measurement-dependent-control-flow-dem.md](004-measurement-dependent-control-flow-dem.md) | Draft | Close the silent-wrong-DEM hole for Guppy programs with measurement-dependent quantum control flow via a static HUGR dataflow analysis (option A: rejection); branch-aware DEM construction (option B) deferred | ## Contributing From e6338f68718f8e56ee8b0e147c6d34f6d2c15f6c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 19 May 2026 23:47:20 -0600 Subject: [PATCH 23/36] Add proposals 005 (array-valued result) and 006 (linear-combination result); index relationships --- .../005-array-valued-result-support.md | 228 +++++++++++++++++ .../006-linear-combination-result-support.md | 238 ++++++++++++++++++ docs/proposals/README.md | 38 ++- 3 files changed, 502 insertions(+), 2 deletions(-) create mode 100644 docs/proposals/005-array-valued-result-support.md create mode 100644 docs/proposals/006-linear-combination-result-support.md diff --git a/docs/proposals/005-array-valued-result-support.md b/docs/proposals/005-array-valued-result-support.md new file mode 100644 index 000000000..451759d64 --- /dev/null +++ b/docs/proposals/005-array-valued-result-support.md @@ -0,0 +1,228 @@ +# 005 - Array-valued `result()` support in `result_tags` + +**Status:** Draft — spike pending. Smallest of the 002/003/004/005 follow-up +set. Composes with 002 (depends on 002 if runtime-loop arrays are needed; +straight-line arrays can be done independently). + +**Author:** (dem-polish working notes) + +**Depends on / extends:** [001 - Tag-referenced detectors for +`DetectorErrorModel.from_guppy`](001-from-guppy-tag-referenced-detectors.md) +(which excludes array-valued `result()` by construction); +composes with [002 - Runtime-loop `result_tags` via dataflow-bound +measurement provenance](002-runtime-loop-result-tags-via-dataflow-provenance.md) +for the runtime-loop case. + +## Summary + +`extract_result_tag_measurements` (in +`crates/pecos-hugr-qis/src/result_tags.rs`) is **sound by construction**: +it accepts *only* the canonical pattern +`tket.result:result_bool ← tket.bool:read ← Measure/MeasureFree`. Among the +three deliberately-excluded shapes (computed values, constants, array- +valued), **array-valued `result()` is the only one that's a usability gap +rather than a category error**: it's a legitimate, common pattern (e.g. +`result("round_0_syndrome", measure_array(ancillas))`) that the extractor +rejects today only because `tket.result:result_array_bool` lowers through +`collections.borrow_arr` machinery that doesn't cleanly expose per-element +measurement provenance to the static extractor. + +This proposal extends `extract_result_tag_measurements` to recognize +`result_array_bool` and walk back through `borrow_arr` ops to the +individual `Measure` op(s) that produced each array element. The result is +that a tag bound to an array of measurements resolves to a list of record +offsets (one per element, in array order), exactly the same shape as a +multi-tag detector or a multi-record detector. + +If the per-element static walk turns out to be infeasible from +`borrow_arr` alone, the proposal falls back to 002's runtime measurement- +provenance mechanism: each array element's `Measure` op carries a +`record_static_measure` call, and the trace tells us per-element which +static op fired. Either path produces the same end-user surface. + +## Background + +- Foundation test `array_valued_tag_is_excluded` in + `crates/pecos-hugr-qis/src/result_tags.rs` pins the current exclusion + using the `arr.hugr` fixture (`result("pair", measure_array(qs))`). +- The 001 closure justifies this as "array-valued `result(...)` (`result_array_bool` + lowers through `collections.borrow_arr` machinery that does not cleanly + expose per-element measurement provenance). Resolving those structurally + would silently misbind … so they are not returned." +- The user-facing impact: a natural Guppy idiom for a round of QEC syndrome + measurement — + + ```python + @guppy + def round() -> None: + syndrome = measure_array(ancillas) + result("round_0_syn", syndrome) + ``` + + — currently can't be referenced via `result_tags`; the user must either + break the array apart into individual `result("…_0", measure(a0))` + calls, or use positional `records`. Both are workable but verbose. + +## Goal + +Allow `result_tags` to reference array-valued `result()` outputs, with each +element mapped to its corresponding measurement. The expected surface is +unchanged from 001's scalar case — `result_tags: ["round_0_syn"]` simply +expands to the list of records the array elements correspond to. + +Equivalent to: an array-valued tag is sugar for the per-element list of +individual scalar tags, *as if* the program had written + +```python +result("round_0_syn[0]", measure(a0)) +result("round_0_syn[1]", measure(a1)) +… +``` + +Per-element selection syntax (e.g. `result_tags: [{"tag": "round_0_syn", +"index": 3}]`) is a natural extension but TBD; the headline case is the +whole array. + +## Design + +Two viable paths. + +### Path A: pure HUGR-side resolution (cheapest if it works) + +Extend `extract_result_tag_measurements` to recognize the +`tket.result:result_array_bool` pattern: + +- The op carries the tag in its `args()` (same as `result_bool`). +- Its value input is wired (via `tket.bool:read` of each element? or via + `collections.borrow_arr` ops? — to be determined by inspection of + `arr.hugr`) from a structure that ultimately traces back to N `Measure` + ops, one per array element. +- The walk-back must traverse `borrow_arr`'s element-access pattern + unambiguously to identify which `Measure` op feeds which array index. + +If `borrow_arr`'s element-access pattern preserves a clean source-element +correspondence (which is the question this spike must answer for the +static path), the extractor returns `tag → [ord_0, ord_1, …, ord_{N-1}]` +in array order — same data shape as the multi-occurrence `tag → [ord_0, +…]` proposal 002 produces for loops, and 001's existing resolver consumes +without modification. + +### Path B: runtime provenance via 002 + +If the static walk through `borrow_arr` is too tangled (or if the +correspondence isn't sound under Selene's lowering), 002's +`record_static_measure` mechanism gives us the data we need without any +new static analysis: each underlying `Measure` op carries a +`record_static_measure(result_id, static_op_id)` dataflow-bound call; the +trace surfaces `static_op_id → [MeasIds]`; the extractor only needs to +identify the *static op id of each Measure feeding the array result*, +which doesn't require resolving `borrow_arr`'s indexing semantics — only +identifying the set of measure-op ids associated with the array tag. + +Path B is structurally cleaner and composes with 002. The cost is a +dependency on 002 landing first (or being co-implemented). + +## Critical assumption (the one thing the spike must answer) + +> **Can the structural walk-back from a `tket.result:result_array_bool` op, +> through `collections.borrow_arr` machinery, identify per-element which +> `Measure` op produced each element of the array, in array index order?** + +Falsifiable by reading `arr.hugr` (committed fixture in +`crates/pecos-hugr-qis/tests/fixtures/`) at the HUGR level and tracing the +dataflow. If yes → Path A. If no → Path B (and 002 must land first). + +## Spike plan + +1. **Read the existing `arr.hugr` fixture** and document the actual + `borrow_arr` lowering pattern: which ops sit between the `Measure` ops + and the `result_array_bool` consumer, and what relations between them + constitute the "this measure feeds element k" link. +2. **Path A prototype**: extend `extract_result_tag_measurements` to walk + the `result_array_bool` → `borrow_arr` → `Measure` chain. Foundation- + test on `arr.hugr` (asserting the array maps to the expected + per-element ordinals). +3. **If Path A unambiguous**: wire into `resolve_result_tags` (no change + needed — already accepts `tag → [multiple ordinals]`); add a + `from_guppy` test verifying `result_tags: ["arr_tag"]` resolves to the + same DEM as the per-element scalar form. +4. **If Path A ambiguous or unsafe**: punt to Path B, document the + blocking observation, defer until 002 lands. + +## Soundness scope + +Covers: +- Straight-line `result(tag, measure_array(qs))` for fixed-length comptime + arrays. (Length must be comptime-known for the trace to have a fixed + number of measurements; runtime-length arrays would need 002's machinery + even more.) + +Does **not** cover: +- Computed array values like `result("foo", [m0 ^ m1, m2 ^ m3])` — + inherits 006's scope question (linear-combination resolution) over + arrays. +- Array results inside runtime loops — composes with 002. Until 002 + lands, runtime-loop array results fail loud at the loop guard. +- Per-element value access in the tag itself (`result_tags: [{"tag": + "syn", "index": 3}]`) — natural extension if useful, but the headline + whole-array form is sufficient for the most common pattern (a detector + spanning a whole syndrome). + +## Out of scope / explicitly rejected + +- **Treating array-valued `result()` as a single opaque parity** (i.e. + resolving to a single record). The semantics in Guppy is N independent + values, not their parity; collapsing them would silently misbind. +- **Supporting heterogeneous arrays** (e.g. `[m0, True, m1 ^ m2]`). The + array element walk must terminate at raw `Measure` ops for each + element; mixing in constants or computed values falls under 001's + deliberate exclusions (or, partially, 006's linear-combination + refinement). + +## Open questions + +1. **`borrow_arr` semantics.** Is the static walk Path A asks for actually + feasible? Spike step 1 answers this. +2. **Per-element syntax.** Is `result_tags: [{"tag": "syn", "index": 3}]` + wanted, or just the whole-array form? Defer until a user requests it. +3. **Composition with 002 if both land.** If 002 has injected + `record_static_measure` for every measure op, Path B becomes the + simpler implementation regardless of whether Path A would have worked. + In that case, drop Path A in favor of consistency with the rest of + the 002 mechanism. + +## Code paths the spike touches + +- `crates/pecos-hugr-qis/src/result_tags.rs` — extend + `extract_result_tag_measurements` to recognize `result_array_bool` / + `borrow_arr`. +- `crates/pecos-hugr-qis/tests/fixtures/arr.hugr` — existing fixture used + as the structural reference; possibly add a new fixture for the + positive case. +- `crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs` — no + change needed (the resolver already handles `tag → [multiple + ordinals]`). +- `python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py` — add a + test for array-valued `result()` resolution, mirroring the asymmetric + correspondence pattern (`result_tags: ["arr_tag"]` equals the + per-element record list, verified via a load-bearing asymmetric + fixture). + +## Effort estimate + +- Path A: spike step 1 (read `arr.hugr`): 0.5 day. If feasible: extractor + extension + tests: ~2 days. Total: ~2.5 days. +- Path B (if Path A infeasible): waits on 002. Implementation on top of + 002 once it lands: ~1 day (the per-measurement provenance is already + available; only need to extract the static-op set associated with the + array tag from the HUGR). + +The smallest of the 002/003/004/005 set; can ship independently if Path A +works. + +## What this proposal does NOT change + +- `dem-polish` is unchanged. Today's `from_guppy` continues to reject + array-valued `result()` in `result_tags` via the extractor's deliberate + narrowing; the rejection is committed-test pinned and that test stays + green until the extractor is extended. diff --git a/docs/proposals/006-linear-combination-result-support.md b/docs/proposals/006-linear-combination-result-support.md new file mode 100644 index 000000000..c439d32f1 --- /dev/null +++ b/docs/proposals/006-linear-combination-result-support.md @@ -0,0 +1,238 @@ +# 006 - Linear-combination `result()` support (XOR/EQ/NOT chains) + +**Status:** Draft — narrow refinement of 001's extractor. Worth capturing +because 001's closure-section claim *"equality is not parity"* is actually +incorrect for the specific case of `bool:eq`/`bool:xor`/`bool:not` chains +over raw measurement bits, and a small, sound extension recovers a +legitimate user-facing pattern. + +**Author:** (dem-polish working notes) + +**Depends on / extends:** [001 - Tag-referenced detectors for +`DetectorErrorModel.from_guppy`](001-from-guppy-tag-referenced-detectors.md) +(which excludes "computed values" by construction). + +## Summary + +`extract_result_tag_measurements` rejects `result("x", m0 == m1)` and +similar computed values by construction, citing — in 001's closure +section — that "equality is not parity." That blanket exclusion is **sound +but overly conservative**: for the specific case of a chain of `tket.bool:eq` +(boolean equality), `tket.bool:xor`, and `tket.bool:not` over raw +measurement bits (each via `tket.bool:read` of a `Measure`/`MeasureFree` +op), the resulting boolean value's *flip behavior under DEM error +mechanisms* is exactly the XOR-parity of the underlying measurements' +flip behaviors — which is the same as a detector with `records` on those +measurements. The semantics is sound; only the user-visible *value* of +the computed result differs from the parity (one is the negation of the +other), but DEM mechanisms describe *flip conditions*, not values. + +This proposal extends `extract_result_tag_measurements` to soundly resolve +the linear (XOR-closed) subset of `tket.bool` computations, returning the +parity-equivalent record-offset list. `tket.bool:and` and `tket.bool:or` +remain excluded (they're genuinely non-linear in the error mechanisms and +not representable as a DEM detector). + +This is the smallest proposal in the dem-polish follow-up set. It refines +existing committed behavior; no new trace data, FFI, or wiring. + +## Background — why "equality is not parity" is too strong + +Consider `b = m0 == m1`: + +- Ideal values: `m0_i`, `m1_i`; ideal `b` = `(m0_i == m1_i) = NOT(m0_i + XOR m1_i)`. +- With error mechanisms `e0`, `e1` cumulatively flipping `m0`, `m1`: + observed `m0 = m0_i XOR e0`, observed `m1 = m1_i XOR e1`. Observed + `b = NOT((m0_i XOR e0) XOR (m1_i XOR e1)) = NOT(m0_i XOR m1_i) XOR + (e0 XOR e1)`. +- Observed `b` XOR ideal `b` = `e0 XOR e1`. + +So `b` flips relative to its ideal value iff the parity of `e0` and `e1` +is odd — **the same flip condition as a DEM detector with records on +`m0` and `m1`**. A detector tagged `result("x", m0 == m1)` resolved to +`records: [m0_ord, m1_ord]` is sound. + +The same holds for any chain built from `eq`, `xor`, `not` over raw +measurement bits: each such op preserves XOR-linearity, and the resulting +boolean's flip condition is the XOR of the underlying error mechanisms +(with cancellation for repeated occurrences — `m0 == m0` is constant True +regardless of errors, so its flip condition is zero, mapping to an empty +record set, which is correctly *not* a detector — sound rejection). + +`and`/`or` are different: their value depends non-linearly on the +operands, so their flip condition depends on the *intended values* of the +operands, not just on the error mechanisms. They cannot be represented +as a DEM detector with a fixed record list. + +## Goal + +Extend `extract_result_tag_measurements` so the following Guppy patterns +soundly resolve through `result_tags`: + +```python +result("x", m0 == m1) # parity of m0, m1 +result("y", not m0) # flip-equivalent to m0 alone +result("z", m0 ^ m1 ^ m2) # parity of m0, m1, m2 (if `^` lowers to tket.bool:xor) +result("w", (m0 == m1) == m2) # parity of m0, m1, m2 (associativity) +result("v", m0 == m0) # empty set; rejected as not-a-detector +``` + +All of these should produce `tag → [ord_i_0, ord_i_1, …]` (the symmetric- +difference set of the measurement ordinals, with even-count duplicates +canceling). The downstream resolver already accepts this shape unchanged. + +`and`/`or` continue to be rejected fail-loud (already are; no change). +Mixing `and`/`or` anywhere in the chain rejects the whole chain — the +walk-back terminates as soon as it sees a non-linear op. + +## Design + +Single change site: `extract_result_tag_measurements` in +`crates/pecos-hugr-qis/src/result_tags.rs`. + +Current behavior: the walk-back from `result_bool` accepts exactly +`tket.bool:read ← Measure/MeasureFree`. New behavior: the walk-back is +an XOR-symmetric-difference set accumulator that traverses: + +- `tket.bool:read ← Measure/MeasureFree` → emit the measurement's + ordinal into the accumulator. +- `tket.bool:not(x)` → recurse into `x` (NOT preserves XOR behavior). +- `tket.bool:eq(a, b)` → recurse into both; symmetric-difference accumulate. +- `tket.bool:xor(a, b)` (if Guppy lowers `^` this way; TBD by inspection) → + recurse into both; symmetric-difference accumulate. +- Anything else (`tket.bool:and`, `tket.bool:or`, `Const`, + `collections.borrow_arr`, computed values not in the above set) → + bail out, exclude this tag from the result map (same as today). + +Symmetric-difference semantics: an ordinal appearing twice in the chain +cancels (e.g. `m0 == m0` → empty set, which is then *not* added to the +output because empty record sets are not detectors — sound). + +Implementation: a recursive `walk_linear(node, visited) -> Option>` +where the set is the symmetric-difference of all measurement ordinals +contributing to `node`'s value, and `None` means "non-linear / bailout." + +## Critical assumption (the one thing the spike must answer) + +> **Guppy's `==`, `!=`, `^`, and `not` over boolean values lower to +> `tket.bool:eq`, `tket.bool:xor`, and `tket.bool:not` respectively +> (or some specific set we can enumerate), and these are the *only* +> linear-boolean ops Guppy uses for measurement-derived booleans.** + +If yes → straightforward implementation. If Guppy lowers `^` through +`tket.bool:and` + `tket.bool:not` (or some other non-direct route), the +walk-back has to recognize that compound pattern. The spike inspects +representative Guppy programs to enumerate the actual lowering. + +## Spike plan + +1. **Enumerate Guppy's boolean-op lowering.** Compile a small Guppy program + `result("x", m0 == m1)`, `result("y", not m0)`, + `result("z", m0 ^ m1)` (if Guppy supports `^` on booleans), and any + other XOR-equivalent forms. Inspect the resulting HUGR to identify the + `tket.bool:*` ops involved. +2. **Implement `walk_linear`** in `pecos-hugr-qis/src/result_tags.rs`. + The set of recognized linear ops is what step 1 enumerated. +3. **Foundation tests** in `pecos-hugr-qis` mirroring the existing + `scrambled` / `computed` / `arr` style: hand-built or compiled-from- + Guppy HUGRs covering the cases listed in "Goal" plus the rejection + cases (any `and`/`or` in the chain). +4. **Replace** `computed_and_constant_tags_are_excluded` (in + `crates/pecos-hugr-qis/src/result_tags.rs` tests) to assert the + refined semantics: linear shapes resolve; `and`/`or` / constants / + empty-symmetric-difference cases stay excluded. +5. **End-to-end via `from_guppy`**: add an asymmetric correspondence test + à la 001's `test_result_tags_match_positional_records` — a Guppy + program where `result("eq", m0 == m1)` resolves to a detector + byte-identical to the `records: [m0_ord, m1_ord]` form, with + pre-measurement gate asymmetry so the test is load-bearing. + +## Soundness scope + +Covers: +- Boolean equality/xor/negation chains over raw measurement bits. +- Repeated measurement references with XOR cancellation + (`m0 == m0` → empty set → not a detector, soundly rejected). + +Does **not** cover: +- `and`/`or` (non-linear in error mechanisms; rejected fail-loud). +- Computed values mixing classical constants with measurements (e.g. + `result("x", m0 == True)`) — this simplifies to `result("x", m0)` in + value space and `record_offset(m0)` in flip space, so it *is* soundly + resolvable. Borderline: include in the spike if cheap, else defer. +- Array-valued computations (e.g. `result("xs", [m0 == m1, m2 == m3])`) — + inherits 005's scope. +- Linear ops *inside* runtime loops — inherits 002's scope (need + per-occurrence binding for the underlying measurements first). + +## Out of scope / explicitly rejected + +- **`tket.bool:and`/`or` support via case analysis on intended values.** + Theoretically you could split the DEM into multiple sub-mechanisms + conditioned on intended values, but that's 004's branch-aware DEM + territory, not a refinement of the extractor. +- **Generalized rational-combination support.** DEM is XOR-linear by + definition; non-XOR-linear computations aren't representable. Sticking + to the XOR-closed subset is the entire soundness story. + +## Open questions + +1. **What ops does Guppy actually use for `==`, `^`, `!=`, `not` on + booleans?** Step 1 of the spike. Until known, the proposal is + "support whichever XOR-linear ops Guppy emits." +2. **Should `m0 == True` simplify to `m0`?** Borderline — yes if it's + easy. (`tket.bool:eq` with one operand a `Const` simplifies to either + the other operand or its negation, both of which are linear.) +3. **Naming.** "Linear-combination" or "XOR-closed" or "parity- + equivalent" — pick one and stick with it. I've used "linear- + combination" throughout; "XOR-closed" is more precise. + +## Code paths the spike touches + +- `crates/pecos-hugr-qis/src/result_tags.rs` — `extract_result_tag_measurements` + extended with the `walk_linear` recursion; existing + `computed_and_constant_tags_are_excluded` test updated to reflect the + refined semantics. +- `crates/pecos-hugr-qis/tests/fixtures/` — potentially a new HUGR fixture + for the positive linear-combination case. +- `python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py` — add an + end-to-end correspondence test for `result("eq", m0 == m1)`. + +No change to `resolve_result_tags`, the pyo3 binding, or `dem.py` — the +output shape (`tag → [multiple ordinals]`) is unchanged. + +## Effort estimate + +- Spike step 1 (enumerate Guppy's boolean lowering): 0.5 day. +- `walk_linear` implementation + foundation tests: 1–1.5 days. +- End-to-end correspondence test: 0.5 day. +- Update closure-section claim in proposal 001 ("equality is not parity" + → "XOR-closed boolean computations resolve to the parity of their + contributing measurements; AND/OR remain excluded"): 0.25 day. + +Total: ~2.5 days. The smallest of the dem-polish follow-ups, and +self-contained. + +## Cost/benefit honesty + +This is a **narrow** refinement. Users with `result("x", m0 == m1)` can +already express the equivalent detector as `records: [-2, -1]` directly +without `result_tags`. The benefit is purely: + +- Source-anchored, reorder-immune naming for computed booleans (e.g. + syndrome equality checks). +- Removing an over-conservative exclusion that a careful reader of 001's + closure might (correctly) recognize as too strong. + +The cost is moderate-to-low: ~2.5 days of focused work, small surface +change, no new APIs, well-bounded test surface. It's the kind of +refinement that's worth doing if there's user demand for the pattern +(common in QEC syndrome processing) but skippable if not. + +## What this proposal does NOT change + +- `dem-polish` is unchanged. Today's extractor continues to fail-loud- + exclude computed values; the `computed_and_constant_tags_are_excluded` + test stays green until the extractor is extended. +- 002, 003, 004, 005 are all independent of 006 (and vice versa). diff --git a/docs/proposals/README.md b/docs/proposals/README.md index d2ff1fc17..f3cce2c24 100644 --- a/docs/proposals/README.md +++ b/docs/proposals/README.md @@ -15,10 +15,44 @@ This directory contains architectural proposals and design explorations for PECO | Folder/File | Status | Summary | |-------------|--------|---------| -| [001-from-guppy-tag-referenced-detectors.md](001-from-guppy-tag-referenced-detectors.md) | Draft | Capture Guppy `result()` tags so `DetectorErrorModel.from_guppy` detectors are reorder-proof (delivered for the straight-line scope; runtime-loop case deferred to 002) | +| [001-from-guppy-tag-referenced-detectors.md](001-from-guppy-tag-referenced-detectors.md) | Draft | Capture Guppy `result()` tags so `DetectorErrorModel.from_guppy` detectors are reorder-proof (delivered for the straight-line scope; remaining gaps split out to 002–006) | | [002-runtime-loop-result-tags-via-dataflow-provenance.md](002-runtime-loop-result-tags-via-dataflow-provenance.md) | Draft | Close 001's runtime-loop deferral PECOS-side via a dataflow-bound `record_static_measure` FFI injected into the HUGR before Selene compiles it; spike pending | | [003-hand-authored-tracked-paulis-in-observables-json.md](003-hand-authored-tracked-paulis-in-observables-json.md) | Draft | Soundly accept hand-authored tracked-Pauli observables in `observables_json` by giving qubits structural HUGR ordinals (the same MLIR-pattern proposal 001 applied to measurements); spike pending | -| [004-measurement-dependent-control-flow-dem.md](004-measurement-dependent-control-flow-dem.md) | Draft | Close the silent-wrong-DEM hole for Guppy programs with measurement-dependent quantum control flow via a static HUGR dataflow analysis (option A: rejection); branch-aware DEM construction (option B) deferred | +| [004-measurement-dependent-control-flow-dem.md](004-measurement-dependent-control-flow-dem.md) | Draft | Close the silent-wrong-DEM hole for Guppy programs with measurement-dependent quantum control flow via a static HUGR dataflow analysis (option A: rejection); branch-aware DEM construction (option B) sub-scoped, deferred | +| [005-array-valued-result-support.md](005-array-valued-result-support.md) | Draft | Extend `extract_result_tag_measurements` to recognize `tket.result:result_array_bool` so `result(tag, measure_array(qs))` resolves as a list of records; spike pending. Smallest of 002–006; composes with 002 for runtime-loop arrays | +| [006-linear-combination-result-support.md](006-linear-combination-result-support.md) | Draft | Extend the extractor to soundly resolve XOR-closed `bool:eq`/`xor`/`not` chains over raw measurements (`result("x", m0 == m1)` → records:[m0_ord, m1_ord]); narrow refinement of 001's "computed values excluded" rule | + +## Relationships and what is *not* separately proposed + +The dem-polish follow-ups split 001's residual scope as follows: + +| 001 deferred / out-of-scope item | Follow-up | +|---|---| +| Runtime-loop `result_tags` | 002 | +| Hand-authored tracked Paulis in JSON | 003 | +| Measurement-dependent quantum control flow | 004 (option A: static rejection) | +| Array-valued `result()` in `result_tags` | 005 | +| Linear-combination (XOR/EQ/NOT) `result()` in `result_tags` | 006 | + +Items intentionally **not** given a separate proposal: + +- **Branch-aware DEM construction** for measurement-dependent control flow + is sub-scoped as "option B" of 004, deferred until a concrete use case + motivates the substantial design space (CFG abstract interpretation, + branch enumeration cost, semantic combination of per-branch DEMs). +- **Selene-side cooperation** for direct measurement provenance is the + alternative 002 set aside. It would be an upstream proposal, not a + PECOS one. +- **HUGR CFG abstract interpreter** (`HugrEngine`-equivalent) is the + alternative both 002 and 004 set aside. Explicitly excluded as a wrong + direction for the dem-polish scope; substantial work that duplicates + what upstream `tket-qsystem` is expected to provide eventually. +- **Source-named qubit / measurement references** (`{"qubit_name": "qa"}`) + depend on upstream Guppy preserving source-level variable names through + HUGR generation. Mentioned as out-of-scope in 003 and elsewhere. +- **Genuinely non-linear computed `result()`** (`and`/`or`) is a category + error — not representable as a DEM detector. 001's exclusion is correct + for this case; 006 only relaxes it for the linear sub-case. ## Contributing From 64d44e4b637c88e591fe44aaf115901e2d0b7572 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 20 May 2026 07:48:08 -0600 Subject: [PATCH 24/36] Move docs/proposals/ to pecos-docs; update code refs to by-name-only; fix stale qalloc.py docs path --- .../fault_tolerance/dem_builder/builder.rs | 4 +- ...001-from-guppy-tag-referenced-detectors.md | 493 ------------------ ...oop-result-tags-via-dataflow-provenance.md | 344 ------------ ...ored-tracked-paulis-in-observables-json.md | 262 ---------- ...-measurement-dependent-control-flow-dem.md | 327 ------------ .../005-array-valued-result-support.md | 228 -------- .../006-linear-combination-result-support.md | 238 --------- docs/proposals/README.md | 64 --- python/quantum-pecos/src/pecos/qec/dem.py | 3 +- python/quantum-pecos/src/pecos/slr/qalloc.py | 3 +- 10 files changed, 6 insertions(+), 1960 deletions(-) delete mode 100644 docs/proposals/001-from-guppy-tag-referenced-detectors.md delete mode 100644 docs/proposals/002-runtime-loop-result-tags-via-dataflow-provenance.md delete mode 100644 docs/proposals/003-hand-authored-tracked-paulis-in-observables-json.md delete mode 100644 docs/proposals/004-measurement-dependent-control-flow-dem.md delete mode 100644 docs/proposals/005-array-valued-result-support.md delete mode 100644 docs/proposals/006-linear-combination-result-support.md delete mode 100644 docs/proposals/README.md diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 0a5c7601c..f7dbe9bcd 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -1715,8 +1715,8 @@ pub fn resolve_result_tags( programs with runtime loops: the HUGR has {static_meas_count} \ static measurement op(s) but the traced program emits \ {traced_meas_count} measurement(s). Per-occurrence tag binding is \ - not statically available; use positional records (see \ - docs/proposals/001-from-guppy-tag-referenced-detectors.md)." + not statically available; use positional records (see proposal \ + 001 from-guppy-tag-referenced-detectors in pecos-docs)." ))); } let traced = i64::try_from(traced_meas_count).map_err(|_| { diff --git a/docs/proposals/001-from-guppy-tag-referenced-detectors.md b/docs/proposals/001-from-guppy-tag-referenced-detectors.md deleted file mode 100644 index 67779919c..000000000 --- a/docs/proposals/001-from-guppy-tag-referenced-detectors.md +++ /dev/null @@ -1,493 +0,0 @@ -# 001 - Tag-referenced detectors for `DetectorErrorModel.from_guppy` - -**Status:** Delivered for the supported scope (straight-line canonical -`result(tag, measure(q))`); runtime-loop case remains deferred (upstream- -blocked). See "Closure (round 9)" at the bottom -- it is authoritative and -supersedes every earlier section. The sections in between record the -investigation history (including a runtime approach that was implemented then -**proven unsound and removed**, and a first wiring attempt that was reverted -in review and is now re-introduced correctly). - -**Author:** (dem-polish working notes) - -## Summary - -`DetectorErrorModel.from_guppy(...)` builds a circuit-level DEM by tracing a -Guppy program through Selene/QIS and replaying the captured gate stream into a -`TickCircuit`. Detectors and observables are supplied by the caller as JSON and -reference measurements **positionally** (`records` = negative offsets, or -`meas_ids` = the sequential MeasIds assigned in trace order). - -Positional references are correct only if the caller's assumed measurement -order matches the order the *post-compilation* trace emits. Guppy/Selene -lowering may reorder measurements, which would silently misalign detectors and -produce a wrong-but-plausible DEM. This proposal captures Guppy `result(tag, -value)` **tag identities** through to the `TickCircuit` so detectors can -reference measurements by stable tag, immune to reordering. - -## Background / current state - -- `measure()` intrinsically allocates the result slot: each measured qubit is - backed by exactly one `Operation::AllocateResult { id }`, strictly - interleaved 1:1 with its `Operation::Quantum(Measure)` in the lowered trace. - The replay (`_replay_lowered_qis_trace_into_tick_circuit`) pairs the k-th - `AllocateResult` with the k-th measurement (global trace order) and stamps - that id as the MeasId. This is deterministic and self-consistent *within a - trace*, but the mapping from a *logical* measurement to its trace position is - not guaranteed stable across compilation. -- `result(tag, value)` carries a stable name. In the **direct QIS FFI path** - this becomes `Operation::RecordOutput { result_id, register_name }` - (`crates/pecos-qis-ffi/src/ffi.rs:604,626`) and is serialized into the - operation trace. -- **However, under `selene_engine()` (the only engine that runs Guppy/HUGR in - PECOS today), `RecordOutput` is never emitted into the operation trace** - (empirically: 0 `RecordOutput` ops for a surface program with many - `result(...)` calls). Selene routes `result(...)` to its per-shot - *named-result stream* (`store_named_bool` -> `get_named_results()`, - `crates/pecos-qis/src/ccengine.rs:1191`), which exposes `name -> values`, - **not** `name -> result_id`. So tag<->measurement linkage is unavailable - from anything `capture_operation_trace()` currently returns under Selene. -- The surface `traced_qis` path does not use tags either; its reorder-safety - comes from validating traced measurement order against an abstract reference - circuit (`decode.py`, `_build_surface_tick_circuit_for_native_model`). A - general `from_guppy` has no such reference. - -## Goal - -Allow `detectors_json`/`observables_json` to reference measurements by a stable -`result(...)` tag (e.g. `{"id": 0, "result_tags": ["syn:r0:s3"]}`), resolved to -MeasIds during replay, so detectors survive Guppy/Selene measurement -reordering. - -## Open question / fork (must be resolved before implementation) - -The feasibility hinges on getting tag<->measurement linkage out of the Selene -path: - -1. **Emit `RecordOutput` (with `result_id`) into the Selene operation trace.** - Requires Selene's `result()` lowering to produce a tag-bearing op carrying - the `result_id` (which links to `AllocateResult`/`Measure`). Selene is an - external Quantinuum component; feasibility/locus of change is unknown and - must be investigated. *Cleanest if feasible.* -2. **Correlate the named-result stream with the op trace.** Use - `get_named_results()` (`tag -> values`) plus the op trace - (`result_id -> measurement`). The named-result entries do not currently - carry `result_id`; matching by order/value reintroduces the very - order-dependence we are trying to remove. *Likely unacceptable.* -3. **Non-Selene QIS-FFI trace backend.** The direct QIS FFI path *does* emit - `RecordOutput`+tag. Investigate whether a Guppy/HUGR program can be traced - without the Selene runtime. If so, the feature becomes mostly Python-side. - *Risk: this path may not run HUGR.* - -## Sketch of the end-to-end change (assuming a tag-bearing op is available) - -1. **Trace capture**: ensure an op carrying `(result_id, tag)` reaches - `capture_operation_trace()` chunks. (Rust: `pecos-qis` / serialization in - `crates/pecos-qis/src/ccengine.rs:851`; Python binding `sim.rs:674`.) -2. **Replay** (`decode.py`): while building `meas_ids_in_order`, also build - `tag -> MeasId` (via `result_id`). Attach as TickCircuit metadata - (e.g. `set_meta("meas_tags", json)`). -3. **DemBuilder detector parsing** (`crates/pecos-qec/.../dem_builder/builder.rs`, - `parse_detectors_json`/`ParsedDetector`/`extract_records`): accept an - optional `result_tags` field and resolve it against the `meas_tags` - metadata to record indices/MeasIds. -4. **`from_guppy` API**: document tag-referenced detectors; keep positional - references working for back-compat. Update `_validate_measurement_contract` - to validate tags against the captured tag set. -5. **Tests**: a Guppy program where compilation reorders measurements; - positional detectors give a wrong DEM, tag-referenced detectors give the - correct one. - -## Out of scope / already done in dem-polish - -- `from_guppy` itself, the strict global-order replay rework (removed the - buggy program-vs-slot qubit-match heuristic + silent fallback), and the - corrected `result()` semantics in docs are landed independently of this - proposal. - -## Empirical findings (dem-polish spike) - -Confirmed by direct probing of `capture_operation_trace()` under -`selene_engine()`: - -- A Guppy program with `result("UNIQTAG_A", ...)` / `result("UNIQTAG_B", ...)` - produces an operation trace in which the tag string **does not appear - anywhere** (full chunk JSON searched). `RecordOutput` count is **0**. -- Op kinds present: `AllocateQubit`, `AllocateResult`, `Quantum`, - `ReleaseQubit` only. `AllocateResult` **is** present (so the QIS FFI - `__quantum__rt__result_allocate` is invoked), but - `__quantum__rt__result_record_output` (which would queue - `RecordOutput { result_id, register_name }`) is **not** invoked. -- Conclusion: under Selene, Guppy `result()` does **not** lower to the QIS FFI - result-record symbol. The tag reaches only Selene's per-shot named-result - stream (`tag -> value`), with **no `result_id` linkage**, so it cannot be - associated with a measurement from anything `capture_operation_trace()` - currently exposes. - -## Consequence - -Source-stable, reorder-proof MeasIds **cannot** be achieved from the -operation-trace path without a Rust/runtime change in `pecos-qis`/ -`pecos-qis-ffi`. The Python `from_guppy` work (committed) is faithful to the -*post-compilation traced order* only; that is explicitly insufficient for the -reorder-robustness requirement. - -## Required spike (Rust) - -1. Determine the exact QIR/runtime symbol Guppy `result(tag, value)` lowers to - under Selene, and whether it exposes the result pointer/id at a point PECOS - can intercept (FFI shim in `crates/pecos-qis-ffi/src/ffi.rs`, or the Selene - runtime integration in `crates/pecos-qis/src/selene_runtime.rs`). -2. If interceptable: add a trace op (or extend an existing one) carrying - `(result_id, tag)` into the serialized operation trace - (`crates/pecos-qis/src/ccengine.rs` `OperationTraceChunk`). -3. Consume it in `_replay_*_qis_trace_into_tick_circuit` to build - `tag -> MeasId`; attach as TickCircuit metadata. -4. Extend DemBuilder detector parsing to resolve a `result_tags` field - (`crates/pecos-qec/.../dem_builder/builder.rs`). -5. `from_guppy`: accept tag-referenced detectors; keep positional for - back-compat. - -Feasibility hinges on step 1, which involves Selene/Guppy lowering conventions -that may live outside this repo. No multi-crate implementation should start -before step 1 concludes. - -## Spike conclusion (step 1 resolved -- NOT feasible PECOS-side) - -Direct reading of the FFI surface (`crates/pecos-qis-ffi/src/ffi.rs`): - -- `measure()` -> `___lazy_measure(qubit)` (l.435) allocates and returns the - `result_id`; `___read_future_bool(result_id)` (l.467) consumes it and - returns a plain `bool`. -- Guppy `result(tag, bool)` lowers to the Selene-style `print_bool(label_ptr, - label_len, value: bool)` (l.668) / `print_bool_selene` (l.824) / - `print_bool_arr_selene` (l.874). These receive **only the tag string and - the concrete bool value** -- the `result_id` is not a parameter and is - structurally absent at the record site. -- `__quantum__rt__record` (l.336) only logs; `__quantum__rt__result_record_output` - (l.604, the QIR convention that *does* carry `result`+tag) is **not invoked** - by Guppy/Selene-lowered programs. - -Therefore the tag and the measurement identity (`result_id`) are never -co-present at any single interceptable call. Adjacency-pairing -`___read_future_bool(result_id)` with a following `print_bool(tag,...)` is -fragile (intervening classical logic / conditionals) and **fundamentally -impossible for array-valued `result()`**, which records many measurements -under one tag with no per-element identity (surface round syndromes and final -readout use exactly this form). - -**Result-record lowering must change upstream (Guppy/tket2/Selene) to carry -the QIS result pointer/id** (e.g. adopt `__quantum__rt__result_record_output( -result, tag)`), or expose a result-id-bearing measurement-tagging API. This is -out of scope for `pecos-qis`. PECOS-side options that remain: - -- **Reference-order safeguard** (generalize the surface path's traced-vs- - reference measurement-order equality check to `from_guppy`; caller supplies - the expected order). Robust against silent reordering corruption; no tags. -- **Documented positional contract** (status quo of the committed work). - -Recommend raising the lowering gap with the Guppy/Selene owners; track here -until upstream provides a result-id-bearing record. - -## CORRECTION: feasible PECOS-side via ExecutionContext read->name linkage - -The "not feasible" conclusion above was wrong. The connection does not need to -exist at a single FFI call; it can be reconstructed and maintained in QIS code -because `ExecutionContext` (`crates/pecos-qis-ffi/src/lib.rs:54`) already holds -both halves: - -- `measurement_results: Mutex>>` -- values indexed by - `result_id` (QIS measurement identity). -- `store_named_bool`/`store_named_array` -> `get_named_results()` -- the - Selene-returned tagged results. - -`___read_future_bool(result_id)` (ffi.rs:467) is invoked to obtain each -measurement bool immediately before the `print_bool*` -> -`store_named_*` that records it under its tag. So the robust linkage is: - -1. `ExecutionContext`: add `pending_read_result_ids: Mutex>` and - `named_result_ids: Mutex>>`. -2. `___read_future_bool(result_id)`: push `result_id` to the pending buffer - (when an execution context is present). -3. `store_named_bool`/`store_named_array`: drain the pending buffer into - `named_result_ids[name]`. -4. Expose `get_named_result_ids() -> {tag: [result_id, ...]}` and surface it - through the trace-capture plumbing (`python/pecos-rslib/src/sim.rs`, - `crates/pecos-qis/src/ccengine.rs`). -5. Replay builds `tag -> MeasId` (since `result_id == AllocateResult id == - MeasId` under the strict replay) and attaches it as TickCircuit metadata. -6. DemBuilder detector parsing resolves an optional `result_tags` field - against that metadata. -7. `from_guppy` accepts tag-referenced detectors; positional kept for - back-compat. - -This is source-stable and immune to measurement reordering. - -## Implemented (dem-polish) - -Delivered exactly as above: - -- `ExecutionContext` (`crates/pecos-qis-ffi/src/lib.rs`): - `pending_read_result_ids` + `named_result_ids`; `___read_future_bool` - records the read; `store_named_bool`/`store_named_array` attribute pending - reads to the tag; `pecos_get_named_result_ids_json` FFI export. -- `DynamicSyncHandle::get_named_result_ids` (default empty) + - `HeliosSyncHandle` impl; surfaced via an authoritative end-of-shot - `OperationTraceChunk { stage: "named_result_ids_final", named_result_ids }` - emitted from `QisEngine::get_results` (per-op-chunk snapshots dropped -- - they missed tail `result(...)` stores). -- Replay (`decode.py::trace_guppy_into_tick_circuit`) attaches - `tc.set_meta("meas_tags", {tag: [MeasId]})`. -- `build_dem_from_circuit` resolves an optional `result_tags` field on - detectors/observables into record offsets via `meas_tags` (serde_json; - hand-rolled detector parser untouched). -- `DetectorErrorModel.from_guppy` accepts `result_tags`, auto-sets - `num_measurements` when tags are used, and fails loud on unknown tags. - -Validated: `meas_tags` == MZ MeasIds; tag-referenced DEM byte-identical to the -positional equivalent; surface positional path byte-identical (Z+X, no -regression); surface LER workflow with distance suppression; unknown-tag -raises; ruff + cargo + 51 pytest pass. The fingerprint idea was dropped (not a -substitute) per the decision to implement the real linkage. - -## Scope: tracked Paulis are NOT covered - -`result_tags` anchors detectors/observables, which are *measurement*-anchored. -A tracked Pauli (`"kind": "tracked_pauli"` in `observables_json`) references -**qubits** via its `pauli` string, not measurements -- it is a propagated -Pauli frame, not a measurement outcome -- so `result()` tags do not apply. -Its qubit indices are interpreted in the traced (post-compilation) qubit -numbering and are therefore **not** source-stable the way tag-referenced -detectors/observables now are. Guppy exposes no `result()`-equivalent identity -for a qubit, so there is no analogous anchor. - -Impact: geometry-derived paths (e.g. the surface builder, which validates -traced-vs-abstract measurement order and derives logical-operator support from -geometry) are unaffected. Hand-authored tracked Paulis for a *general* -`from_guppy` program must use traced qubit numbering and are reorder-fragile. -Decision: documented as a known limitation (in `from_guppy`'s docstring and -here); a qubit-identity anchor is possible future work, not in scope now. - -## Final outcome (dem-polish) -- AUTHORITATIVE - -This section supersedes the "Implemented" and "CORRECTION" sections above. - -What was tried and what landed: - -1. **Runtime read->store linkage (ExecutionContext) -- REMOVED.** Implemented, - then disproved by a foundation test: a program doing all `measure()`s then - all `result()`s yields `{tag_c: [0,1,2]}` instead of the correct per-tag - binding, because measurement-future reads are batched before the stores. - The mechanism (pending_read_result_ids / named_result_ids / - note_read_result_id / pecos_get_named_result_ids_json / OperationTraceChunk - field + end-of-shot emission / DemBuilder `resolve_result_tags_in_json` / - decode.py `meas_tags` / dem.py `result_tags`) was fully excised as unsound. - -2. **Sound HUGR extraction -- KEPT (committed).** - `pecos_hugr_qis::extract_result_tag_measurements` recovers - `tag -> measurement` from the compiled HUGR by structural wire-tracing - (proper `ext_op.args()` tag read; value-port-0 reverse walk). Proven sound - and reorder-immune for straight-line programs by the `scrambled` fixture - test (the exact case the runtime heuristic failed). It is a self-contained - building block; it is **not** wired into `from_guppy`. - -3. **Loops are the unsolved gap.** A `for _ in range(comptime(n))` loop (the - surface-code round structure) is **not unrolled in the HUGR** -- it is a - CFG with one static measure/result op. Static extraction therefore yields - `tag -> static-measure-op`, not per-round MeasIds. Bridging that to runtime - per-occurrence MeasIds requires one of: - - a HUGR CFG abstract interpreter (~= the excluded `HugrEngine`), or - - `tket-qsystem` lowering carrying measurement provenance (upstream), or - - reconstructing the deterministic unrolling from the comptime-bounded CFG - (still requires CFG interpretation). - -Net delivered in `from_guppy`: **sound positional `records`/`meas_ids` -detectors only.** These are byte-identical to the reference and LER-correct -for the surface code (verified), but are *order-sensitive* to Guppy/Selene -recompilation. Reorder-robust tag-referenced detectors are **deferred**; the -sound HUGR building block (#2) is committed for the eventual straight-line -wiring, and the loop case needs CFG-interpreter-class machinery or upstream -`tket-qsystem` provenance. - -## Update (gap-4): sound result_tags wired into from_guppy (Rust-centric) - -The committed HUGR extractor is now wired into `from_guppy` for the -**straight-line** case, with all logic in Rust and a thin Python pass-through -(per architectural review): - -- `pecos_qec::fault_tolerance::dem_builder::resolve_result_tags` (Rust): runtime- - loop guard (static vs traced measurement count), `result_tags`->record - resolution, unknown-tag validation -- all fail-loud (`Result`/`ValueError`). -- `pecos_rslib.resolve_result_tags_for_guppy` (thin pyo3): HUGR-bytes + - detectors/observables JSON + traced count -> resolved JSON, or raises. - Internally calls `pecos_hugr_qis::extract_result_tag_measurements` + - `measurement_op_count`. -- `from_guppy` (thin Python): if `result_tags` present, ferry - `guppy_to_hugr(guppy)` + the traced measurement count to the Rust call. No - tag logic in Python. - -Verified: straight-line `result_tags` DEM is byte-identical to the positional -equivalent (proves the Rust chain and that the HUGR measurement ordinal equals -the traced MeasId order for the supported case); unknown tags fail loud; -runtime-loop programs (incl. surface) **fail loud** rather than silently -misbind; surface positional path byte-identical + LER unaffected. - -Remaining deferred: per-occurrence tag binding for runtime-loop programs -(needs CFG-interpreter-class machinery or upstream `tket-qsystem` -provenance). `from_guppy` now hard-errors that case instead of being silent. - -## External review response (AUTHORITATIVE — supersedes "Update (gap-4)") - -An external review found real defects. Resolution: - -- **#1 (critical, fixed):** the HUGR extractor over-collected — `result("x", - m0==m1)` (lowers through `tket.bool:eq`) gave `records:[-2,-1]` and - `result("x", True)` gave `records:[]`, both silently wrong. - `pecos_hugr_qis::extract_result_tag_measurements` is now **sound by - construction**: it accepts ONLY `result_bool <- tket.bool:read <- - Measure/MeasureFree` (canonical scalar raw measurement). Computed values, - constants, and array-valued `result()` (`collections.borrow_arr` machinery) - are deliberately excluded, with regression tests. -- **#2 (fixed):** `from_guppy` now validates detector/observable schema - (integer id + records/meas_ids) and **fails loud**, instead of letting the - DEM builder swallow the parse error and return an empty DEM. -- **#3 (doc fixed):** corrected the docstring — hand-authored JSON tracked - Paulis are **not** supported by the `observables_json` path (the JSON - observable parser ignores `kind`/`label`/`pauli`; tracked Paulis come only - from circuit annotations). -- **#4 (moot):** the gap-4 user-facing `result_tags` wiring is **reverted** - (dem.py thin block, `pecos_rslib.resolve_result_tags_for_guppy`, - `pecos_qec::resolve_result_tags`). With no `guppy_to_hugr` call in - `from_guppy`, the wrapper-input regression no longer exists. -- **#5 (fixed, broader than gap-4):** the lowered-replay no longer assumes a - strict AllocateResult/Measure 1:1 interleave. `Quantum.Measure` carries - `[qubit, result_id]`; the replay now reads that `result_id` directly (== the - MeasId), so batched allocate-allocate-measure-measure is handled and the - overstated invariant is gone. -- **#6 (fixed):** the non-lowered replay now stamps the real `result_id` via - `mz_with_ids` instead of discarding it and relying on - `assign_missing_meas_ids()` to invent sequential ids. -- **#7 / overstatements (corrected):** "proven sound for straight-line" and - "tag DEM == positional-equivalent" claims are withdrawn. The only retained, - tested guarantee is the narrow `extract_result_tag_measurements` contract - above; it is a building block, **not wired into `from_guppy`**. Whether HUGR - traversal order equals trace MeasId order in general remains unproven and is - no longer relied upon by any shipped path. - -Net shipped: sound positional `from_guppy` (records/meas_ids, schema-validated -fail-loud, `D0`/`L0`), the corrected strict/non-lowered replay (#5/#6), and a -sound-but-narrow standalone HUGR extractor with tests. Tag-referenced -detectors are NOT exposed to users; the loop case remains deferred (CFG- -interpreter-class machinery or upstream `tket-qsystem` provenance). - -## Second external review + outcome (AUTHORITATIVE) - -A second independent review (re-verified by a third) found the prior fix round -shipped a **critical regression**: a `reject_dynamic_control` guard added to -`trace_guppy_into_tick_circuit` false-positived on the *standard surface code* -(it has statically-scheduled gates after ancilla measurements every round, in -`pending_continue` chunks), so `from_guppy(make_surface_code(...))` raised -before DEM construction. The same heuristic also missed genuinely -measurement-dependent programs on some seeds. - -Resolution: - -- **Guard reverted.** A runtime-trace heuristic cannot distinguish - statically-scheduled post-measurement gates from true data-dependent - branching. Measurement-dependent (dynamic) control flow is now documented as - **unsupported / undefined** for `from_guppy` (one sampled branch is not a - static DEM); sound detection needs HUGR conditional-on-measurement analysis - (deferred). With the guard gone, surface `from_guppy` is byte-identical to - the `traced_qis` reference again (independently confirmed) -- so the - ~425-line `builder.rs` serde rewrite did **not** change DEM output. -- Confirmed-fixed by the prior round and retained: `meas_ids` end-to-end (now - normalized to records), malformed-JSON fail-loud in `from_guppy`, JSON - `tracked_pauli` rejection, direct `from_circuit`/`DemSampler` fail-loud on - missing-id / malformed metadata. -- Residual out-of-range / unresolved `records`/`meas_ids` (a third review - found the first fix only covered `DetectorErrorModel.from_circuit`, leaving - `DemSampler.from_circuit` and the public `DemBuilder.build` still - silently-weakening, and a declared `num_measurements` that disagreed with - the circuit bypassed the range check entirely). **Now fully addressed:** a - single fallible `DemBuilder::try_build` runs both - `validate_measurement_count` and `validate_metadata_refs`, and every - circuit-ingest / public path (`from_circuit`, `DemSampler::from_circuit`, - `PyDemBuilder::build`) routes through it. A fourth review then found the - count check was duplicated only in the two circuit-ingest paths, so the - public `DemBuilder` with a non-empty influence map plus an inconsistent - `with_num_measurements` still bypassed it; the check now lives in - `try_build` itself (single source of truth) and rejects any - `num_measurements` that disagrees with a non-empty influence map. The - infallible `build` stays lax only for the decoupled/raw construction case - (empty influence map, opaque pass-through record offsets — the declarative - escape hatch) so existing callers are unaffected. -- Also addressed: clippy `-D warnings` (missing `# Panics` docs); the public - subclass-identity hazard (#6) is documented (no internal `isinstance` use; - public-API caveat only). -- Rust/Python schema duplication/divergence: **now closed.** All - detector/observable validation (JSON shape, `D0`/`L0` id forms, - tracked-Pauli rejection, at-least-one of records/meas_ids, - `num_measurements` consistency, out-of-range records, and `meas_id` - resolution against the circuit's stable stamped `MeasId`s) lives solely in - the Rust DEM builder; `from_guppy` is a thin wrapper that traces and - forwards metadata verbatim. `meas_ids` is an alternative to `records` (not - additive). Co-presence of both is *allowed but must be redundant*: the - surface `logical_circuit` path legitimately emits both (records = legacy - Stim offsets, meas_ids = the same measurements as stable ids), so - `validate_metadata_refs` resolves `meas_ids` and requires the resolved - offset set to equal `records`; a non-redundant pair is rejected fail-loud - rather than silently collapsing to `records` (a regression the fourth and - sixth reviews caught in the intermediate consolidation). - -So the previous "proven sound for straight-line / surface byte-identical" -statement is accurate again *only after the guard revert*; it was false in the -intermediate broken tree. - -## Closure (round 9) -- AUTHORITATIVE - -This section supersedes all earlier "outcome" sections. The proposal's Goal -(tag-referenced detectors that survive measurement reordering) is now -**delivered** for its supported scope, with the prior review's blockers -addressed: - -**What ships:** - -- The Rust `resolve_result_tags` - (`pecos_qec::fault_tolerance::dem_builder::resolve_result_tags`) and pyo3 - `resolve_result_tags_for_guppy` reintroduced from the reverted gap-4, with - the same fail-loud loop guard (static vs traced measurement count). -- `DetectorErrorModel.from_guppy` now accepts a `result_tags` field on each - detector/observable entry (alongside or instead of `records`/`meas_ids`). - Resolution: HUGR -> sound `tag -> ordinal` map via - `pecos_hugr_qis::extract_result_tag_measurements` -> record offsets -> - passed to the existing DEM builder. The full schema/redundancy/range - validation already in the builder applies to the rewritten metadata. -- The previously-revert-triggering "wrapper-input regression" is closed by - an **upfront** `guppy_to_hugr` compile in `from_guppy` (only when - `result_tags` is requested) with a clear `ValueError` mentioning the - `@guppy` requirement, instead of a late crash inside the HUGR step. -- The HUGR-traversal-ordinal == traced-`MeasId`-order correspondence the - earlier review (item #7) flagged as unproven is now **committed-test - verified** end-to-end for the supported scope by - `tests/qec/test_from_guppy_result_tags.py::test_result_tags_match_positional_records` - (a scrambled-`result()`-order Guppy program: `result_tags` DEM - byte-identical to the positional-records DEM, across all three tags and - multi-tag / observables variants). -- `result_tags.rs` module docs no longer claim a "verified property" that - had no committed test; they now accurately reference the committed - cross-check and the narrow scope. - -**What is still deferred (upstream-blocked):** per-occurrence tag binding -for `for _ in range(comptime(n))` runtime-loop programs (the surface code's -round structure). The HUGR has one static measure op per loop body, not per -occurrence; bridging that to per-iteration `MeasId`s needs CFG-interpreter- -class machinery (~= the excluded `HugrEngine`) or upstream `tket-qsystem` -lowering carrying measurement provenance. `from_guppy` rejects this case -fail-loud (`result_tags ... runtime loops`); positional `records`/`meas_ids` -remain available for surface-code use. - -**Rejected fail-loud cases (committed tests):** runtime-loop programs; -non-`@guppy` callable inputs when `result_tags` is requested; references to -tags the program never records; computed / constant / array-valued -`result(...)` shapes (the extractor excludes them by construction, and an -unrecognized tag falls through to the unknown-tag error). diff --git a/docs/proposals/002-runtime-loop-result-tags-via-dataflow-provenance.md b/docs/proposals/002-runtime-loop-result-tags-via-dataflow-provenance.md deleted file mode 100644 index f03c4cfcb..000000000 --- a/docs/proposals/002-runtime-loop-result-tags-via-dataflow-provenance.md +++ /dev/null @@ -1,344 +0,0 @@ -# 002 - Runtime-loop `result_tags` via dataflow-bound measurement provenance - -**Status:** Draft — spike pending. Authoritative on the design *shape*; not yet -validated against Selene's actual lowering behavior. - -**Author:** (dem-polish working notes; design refined by external review) - -**Depends on / extends:** [001 - Tag-referenced detectors for -`DetectorErrorModel.from_guppy`](001-from-guppy-tag-referenced-detectors.md). - -## Summary - -Proposal 001 delivered sound, source-named `result_tags` detectors in -`DetectorErrorModel.from_guppy` for **straight-line, canonical** -`result(tag, measure(q))` programs. The deferred case — runtime -`for _ in range(comptime(n))` loops where the HUGR is not unrolled (one -static measure op per loop body, but N runtime occurrences) — was marked -"upstream-blocked" and `from_guppy` fails loud rather than silently misbind. - -This proposal sketches a PECOS-side path to close that gap. The mechanism is -to extend the QIS trace with a **dataflow-bound measurement-provenance -token**: each static measure op in the HUGR is given a stable static op id -(its `extract_result_tag_measurements` ordinal), and a side-effecting FFI -call attached by **dataflow** to the measurement's result records the -`result_id -> static_op_id` pairing into the per-`ExecutionContext` runtime -state, which is then surfaced in the operation trace. Resolution of -`result_tags` becomes a pure data join: `tag -> static_op_id` (from HUGR, -already implemented) ⋈ `static_op_id -> [MeasIds]` (new, from the trace), -no CFG interpretation required. - -The single load-bearing assumption — that Selene's lowering preserves a -dataflow edge between an injected `record_static_measure` call and the -measurement op that produced its input — is what the spike must -falsify or confirm. - -## Background — why this is deferred today - -Per [proposal 001's authoritative closure section][001-closure]: - -- HUGR-side: `pecos_hugr_qis::extract_result_tag_measurements` recovers - `tag -> static-measure-op` from the compiled HUGR, sound-by-construction - for the canonical scalar `result(tag, measure(q))` pattern. -- For straight-line programs, the HUGR-traversal ordinal of the static - measure op equals its trace `MeasId` order (committed-test verified in - `test_from_guppy_result_tags.py::test_result_tags_match_positional_records`). -- For runtime loops, the HUGR has one static measure op per loop body but - the trace has N runtime occurrences with distinct `MeasId`s. The static - binding tells you `tag -> {static_op_id}`, but **nothing in the current - trace tells you which trace measurements came from which static op**. - -Proposal 001 evaluated three forks for closing this — (1) Selene emits -`RecordOutput`+`result_id`, (2) correlate Selene's named-result stream by -order, (3) non-Selene QIS-FFI backend — and concluded "NOT feasible -PECOS-side." That conclusion scoped the spike to *making Selene cooperate*. -This proposal explores a different scoping: **PECOS modifies the HUGR -itself before Selene compiles it**, injecting structural provenance markers -that propagate through Selene's lowering chain as ordinary side-effecting -FFI calls into PECOS-owned shims. Selene does not need to know anything -special about them. - -[001-closure]: 001-from-guppy-tag-referenced-detectors.md - -## Goal - -For a Guppy program with a runtime `for _ in range(comptime(n))` loop body -emitting `result("syn", measure(q))`, allow -`detectors_json='[{"id":0,"result_tags":[{"tag":"syn","iter":k}]}]'` (or an -equivalent shape — final syntax TBD) to resolve to the `k`-th occurrence of -the `syn` static measure op in trace order, where "trace order" is -empirically the iteration order. Provide a sound, reorder-immune -tag-referenced detector for runtime-loop programs without a CFG -interpreter and without upstream Selene changes. - -## Design - -The design has four parts; each is in a PECOS-owned crate. - -### 1. HUGR pass: inject `record_static_measure` after each measure op - -In `pecos-hugr-qis`, add a new module (e.g. -`crates/pecos-hugr-qis/src/instrument_provenance.rs`) exposing a pass -`instrument_measurement_provenance(hugr: &mut Hugr)` that: - -- Walks `hugr.nodes()` filtering by `is_measurement` (already defined in - `crates/pecos-hugr-qis/src/result_tags.rs:38`). -- Assigns each measurement op the same stable id `extract_result_tag_measurements` - uses — its traversal ordinal (see `result_tags.rs:78` for the existing - numbering). -- Inserts a `tket.qsystem`-or-equivalent `__pecos__rt__record_static_measure` - call **after** each measure op, taking the measurement's result value (or - future) as a dataflow input and the static op id as a constant attribute. - -The critical structural property: `record_static_measure` consumes the -measurement's result. The dataflow edge is what guarantees lowering preserves -the pairing — Selene's compilation cannot reorder the call across the measure -or drop it without breaking dataflow semantics, because dataflow IRs preserve -dataflow edges by construction. - -A `marker-before-measure` variant (`__pecos__rt__set_current_static_op_id(N)` -emitted *before* each measure op, paired with a thread-local read inside the -measure FFI) was considered and rejected: "before" is not a stable semantic -relation unless the IR has an explicit ordering dependency, and standalone -side-effecting calls can be reordered/sunk/hoisted by lowering passes. -TLS-based state is also fragile across parallel-shot batching. Dataflow -binding sidesteps both issues. - -### 2. QIS FFI: per-`ExecutionContext` provenance map - -In `pecos-qis-ffi`, add a new entry point alongside the existing measurement -FFI (`crates/pecos-qis-ffi/src/ffi.rs:208` is where `mz` queues -`QuantumOp::Measure(qubit, result_id)`): - -```rust -extern "C" fn __pecos__rt__record_static_measure(result_id: u64, static_op_id: u64); -``` - -Implementation: lookup the current `ExecutionContext` (the existing per-shot -isolation primitive — bare TLS is rejected; we need per-context state to -survive parallel-shot batching), and write `result_id -> static_op_id` into -a `Vec<(u64, u64)>` or `HashMap` on the context. - -### 3. Trace schema: surface the map per-shot - -Extend `pecos-qis-ffi-types::operations` (the trace event schema, current -`QuantumOp::Measure` at `crates/pecos-qis-ffi-types/src/operations.rs:68`): - -- Either add a new top-level trace event `MeasurementProvenance { result_id, - static_op_id }`, emitted at `record_static_measure` time and consumed - alongside `Quantum::Measure` events; **or** -- Extend the `Quantum::Measure` variant to carry `Option` static op id - populated at flush time by looking up `result_id` in the context's - provenance map. - -The new-event form is less ABI-invasive (existing `Measure` consumers -ignore the new event). Final choice deferred to the spike. - -### 4. DEM resolution: extend `resolve_result_tags` - -In `pecos-qec` (`crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs`, -where `resolve_result_tags` lives today): - -- `extract_result_tag_measurements(hugr)` already returns `tag -> [static_op_id]`. -- The new trace data gives `static_op_id -> [MeasId₀, MeasId₁, …]` in - trace (== iteration) order. -- Compose: `tag -> [all MeasIds attributable to that static op]`, with - optional per-occurrence selection via a richer `result_tags` shape: - - ```json - [{"id": 0, "result_tags": [{"tag": "syn", "iter": 3}]}] - [{"id": 0, "result_tags": ["syn:all"]}] // alternative sugar TBD - ``` - - Final syntax TBD; the resolution is a pure data lookup and adding a richer - syntax later is non-breaking on top of the existing `result_tags` - semantics. - -The pyo3 binding `resolve_result_tags_for_guppy` and the `from_guppy` -wrapper in `python/quantum-pecos/src/pecos/qec/dem.py` would need only the -extra `static_op_id -> [MeasId]` map to be passed in (already obtainable -from the trace consumption in `decode.py`). - -## Why dataflow, not "marker before" - -Critique from review (paraphrased): - -> MLIR's locations/debug attrs are propagated by compiler discipline; your -> FFI marker is an executable side effect. "Before" is not a stable -> semantic relation unless the IR has an explicit ordering dependency. -> Treat it as part of semantics for tracing, with tests that prove it -> survives lowering. … TLS is acceptable only if you can prove marker and -> measure are ordered on the same worker thread and reset correctly. A -> dataflow-attached call is much less fragile. - -The honest framing is: this is a **lowered provenance token**, not debug -metadata. We are extending the program's runtime semantics (the trace now -records provenance), and the soundness of that extension rests on dataflow -preservation, which dataflow IRs do by construction. - -## Critical assumption (the one thing the spike must answer) - -> Can PECOS inject a side-effecting call that consumes a measurement result -> into the HUGR such that Selene's full lowering chain (HUGR -> LLVM IR -> -> compiled binary -> runtime execution) preserves the dataflow edge, -> ensuring the call fires once per dynamic measurement with the correct -> `result_id`? - -If yes: the rest is straightforward engineering. If no: PECOS-side closure -is infeasible and proposal 001's "upstream-blocked" conclusion stands — the -remaining path is upstream `tket-qsystem` measurement provenance. - -The spike is shaped to answer this question minimally and decisively. - -## Spike plan (minimum scope to answer the critical question) - -1. **HUGR pass prototype.** Hand-write or programmatically construct a HUGR - for a Guppy program with two distinct measurements inside a runtime loop - body (sufficient to expose any "single static op assumption" we might - accidentally rely on). Apply - `instrument_measurement_provenance` (which need only be a sketchy - first-pass; this is a spike). -2. **Run through Selene, not just analysis.** Pass the *mutated* - `pecos.Hugr(...)` to `pecos.sim(...)` via the same trace path - `from_guppy` uses (see `python/quantum-pecos/src/pecos/qec/surface/decode.py:719`). - The mutated HUGR must traverse Selene's full lowering, not just - `extract_result_tag_measurements`. -3. **Assert trace pairing.** Capture the trace; assert it contains exactly - the expected `result_id -> static_op_id` pairs for: - - a straight-line two-measure program (control: must agree with the - existing committed `extract_result_tag_measurements` ordering); - - a single-static-measure runtime-loop body (the headline case: N pairs); - - a two-static-measures-per-iteration loop body (catches single-op - assumptions and exposes ordering questions); - - a branch (`if cond: measure(qa) else: measure(qb)`) — closes - dynamic-shape control with provenance, even if measurement-dependent - control flow remains unsupported elsewhere (see "Out of scope"). -4. **LLVM IR inspection.** At the optimization level Selene actually uses, - inspect the lowered LLVM to confirm `record_static_measure` is inside - the loop, data-dependent on the measurement's result, and not - dead-code-eliminated / hoisted / fused. This is the falsification step - for the critical assumption. -5. **Parallel-shot isolation.** Run the multi-shot batching path; assert - per-`ExecutionContext` provenance maps stay isolated (no cross-shot - leakage). - -Outcome: either a green spike that proves the design works under Selene's -actual lowering, or a falsifying observation that pins down the precise -behavior that breaks it (and so guides whether upstream is the only path). - -## Soundness scope - -This design closes: - -- **Per-occurrence runtime-loop tag binding** (the proposal-001 deferred - item) for fixed/comptime-bounded loops, where execution order is - deterministic. -- **Branch-as-observed provenance**: dynamic-shape branches with no - measurement-dependent control flow get correct per-execution provenance - (which static measure op fired). - -This design does **not** close: - -- **Measurement-dependent dynamic control flow.** `from_guppy` traces one - ideal execution; a Guppy program whose quantum operations depend on a - measurement *outcome* still yields a DEM from a single sampled branch, - silently wrong and seed-dependent. Provenance tells you *what fired in - this trace*, not *what would fire across all possible measurement - outcomes*. Sound treatment of measurement-dependent control still needs - static rejection (HUGR conditional-on-measurement analysis) or - branch-aware DEM construction. Out of scope here. -- **Soundness of the surrounding fault model under non-deterministic - control.** Same reason. - -## Out of scope / explicitly rejected alternatives - -- **TLS-marker `set_current_static_op_id` before the measure op.** Rejected - on the design-review grounds above: "before" is not a stable semantic - relation; lowering can reorder/sink/hoist; TLS is fragile across parallel - shots. -- **Selene named-result-stream correlation by order.** Same class of - order-dependent mechanism that proposal 001 excised (it was unsound for - the straight-line case via the runtime read/store linkage, by the same - argument). It might work for a narrow loop pattern but is brittle around - computed results, repeated reads, arrays, CSE, and Selene's scheduling. -- **CFG interpreter inside `pecos-hugr-qis`.** A different solution to a - different half of the problem: CFG interpretation gives all-paths - reasoning needed for sound dynamic-control DEM semantics. Runtime - provenance gives what-fired reasoning for fixed loops and branches as - observed. For surface-code-style fixed loops, runtime provenance is - simpler and likely more robust; for dynamic control, neither suffices in - isolation. Punted to a future proposal. - -## Open questions - -1. **Final `result_tags` syntax for per-occurrence selection.** The - resolution is a pure data lookup; the syntax (e.g. `{"tag":"syn","iter":k}` - vs `"syn:k"` vs `"syn:all"`) is a JSON-schema decision deferred to the - spike. Prefer something extensible and unambiguous to misuse. -2. **Trace event shape.** New top-level `MeasurementProvenance` event vs. - extended `Measure` variant. Choose at spike time based on Selene's - actual behavior (the new-event form is less ABI-invasive). -3. **HUGR pass position.** Should - `instrument_measurement_provenance` run as part of `from_guppy`'s - `guppy_to_hugr` step, or as a separate explicit pass callers opt into? - The former is invisible to callers (preferred for `result_tags` users); - the latter keeps the trace clean for non-`result_tags` consumers. -4. **Whether to use the same provenance for `meas_ids`.** Today - `meas_ids` resolves against stamped `MeasId`s positionally. With - provenance available, `meas_ids` could be redefined as stamped MeasIds - tagged by static op — but the existing semantics are sound and the - redundancy discipline (`records`/`meas_ids` alternatives) works. Don't - change without reason. - -## Effort estimate (spike) - -Roughly: - -- HUGR pass prototype: 1–2 days (existing `pecos-hugr-qis` machinery covers - most of it; need to investigate `tket`'s op-construction API for the new - FFI call type). -- FFI + trace schema additions: 1 day. -- End-to-end through Selene + LLVM IR inspection + parallel-shot stress: - 2–3 days. - -Total spike: ~1 work week, with clear go/no-go signal at the end. - -If the spike succeeds: productionizing it (resolver extension, syntax -finalization, tests, docs) is another ~1 work week. - -If the spike fails: the failure mode tells us exactly what upstream -`tket-qsystem` measurement-provenance support PECOS would need, which is -useful even if PECOS-side closure is abandoned. - -## What this proposal does NOT change - -- `dem-polish` is unchanged. Today's `from_guppy` continues to support - sound straight-line `result_tags` and fails loud on runtime-loop programs - using `result_tags`. Positional `records`/`meas_ids` continue to work - for all programs (including the surface code). This proposal is - forward-looking work, not a fix to merged code. - -## Code paths the spike touches (reference) - -- `crates/pecos-hugr-qis/src/result_tags.rs` — existing - `extract_result_tag_measurements`, `measurement_op_count`, - `is_measurement`. The new pass module lives alongside. -- `crates/pecos-hugr-qis/src/lib.rs` — re-exports. -- `crates/pecos-qis-ffi/src/ffi.rs:208` — current measurement FFI; new - `__pecos__rt__record_static_measure` FFI added here. -- `crates/pecos-qis-ffi-types/src/operations.rs:68` — `QuantumOp::Measure` - trace event; new `MeasurementProvenance` event or extension added here. -- `crates/pecos-qis/src/ccengine.rs` — `ExecutionContext`; provenance map - attached as new field. -- `crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs` — existing - `resolve_result_tags`; extended to consume `static_op_id -> [MeasId]`. -- `python/pecos-rslib/src/dag_circuit_bindings.rs` — existing - `resolve_result_tags_for_guppy` pyo3 binding; extended argument. -- `python/quantum-pecos/src/pecos/qec/dem.py` — existing `from_guppy` - wrapper; passes the new provenance map through. -- `python/quantum-pecos/src/pecos/qec/surface/decode.py:719` — existing - trace path; spike must use this same path (`pecos.sim(...)` on the - mutated HUGR), not the analysis-only `extract_result_tag_measurements` - path. -- `python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py` — new - tests for the loop-occurrence case once the spike succeeds. diff --git a/docs/proposals/003-hand-authored-tracked-paulis-in-observables-json.md b/docs/proposals/003-hand-authored-tracked-paulis-in-observables-json.md deleted file mode 100644 index 3da5dde44..000000000 --- a/docs/proposals/003-hand-authored-tracked-paulis-in-observables-json.md +++ /dev/null @@ -1,262 +0,0 @@ -# 003 - Hand-authored tracked Paulis in `observables_json` - -**Status:** Draft — spike pending. Captures the design question and the -soundness assumption that distinguishes this from the existing -positional/annotation-only path. - -**Author:** (dem-polish working notes) - -**Depends on / extends:** [001 - Tag-referenced detectors for -`DetectorErrorModel.from_guppy`](001-from-guppy-tag-referenced-detectors.md) -(which explicitly out-of-scoped this); applies the same -structural-HUGR-binding pattern. - -## Summary - -Today `observables_json` actively rejects `{"kind": "tracked_pauli", ...}` -entries. Tracked Paulis are produced only from **circuit annotations** (e.g. -`dag.tracked_pauli(PauliString.from_str("X0 Z2"), label="x_check")`), never -from caller JSON. The rejection is committed-test pinned -(`test_from_guppy_rejects_json_tracked_pauli_observables`) and the -`from_guppy` docstring documents it as a hard limitation. The reason isn't -schema laziness — it's that **qubit identity through Guppy/Selene -compilation is not stable enough today** to safely accept positional qubit -references from caller text. A positional `"X0 Z2"` written against the -user's mental model of the program is not guaranteed to mean the same qubit -the trace actually allocates first. - -This proposal lays out a path to soundly accept hand-authored tracked Paulis -in `observables_json` by giving qubits the same treatment proposal 001 gave -measurements: a stable, structural HUGR-derived qubit identifier that -travels through compilation. The MLIR-pattern shape from 002 also applies if -qubit identity needs to track runtime-loop instances of `qubit()` -allocations. - -## Background - -- `from_guppy` docstring (`python/quantum-pecos/src/pecos/qec/dem.py`) - observable section: *"hand-authored JSON tracked Paulis are NOT supported - by this path. … A `{"kind": "tracked_pauli", ...}` entry here is rejected - by the builder."* -- Rust parser (`reject_tracked_pauli` in - `crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs`) errors fail- - loud on `kind=="tracked_pauli"` for both detectors and observables. -- The existing annotation path - (`dag.tracked_pauli(PauliString.from_str(...))`) builds tracked Paulis - from in-Rust qubit indices in the circuit-construction order. For the - surface builder this is well-defined (the builder controls qubit - numbering); for `from_guppy`, the program is compiled and traced through - Selene and the qubit numbering is post-trace allocation order. Caller text - written against source-level intent may not agree. -- Prior dem-polish commit `46243b0d` ("Document tracked-Pauli qubit- - numbering limitation in `from_guppy`") records exactly this concern. - -## Goal - -Allow `observables_json` to include hand-authored tracked Paulis, e.g. - -```json -[{"id": 0, "kind": "tracked_pauli", "label": "X_logical", - "pauli": [{"qubit_ord": 0, "pauli": "X"}, - {"qubit_ord": 2, "pauli": "Z"}]}] -``` - -resolved soundly against the traced circuit — i.e. `qubit_ord: 0` means -*the qubit allocated by the 0th `AllocateQubit` op in HUGR traversal order*, -which is the same reorder-immune binding `extract_result_tag_measurements` -gives for measurements. - -Alternatively (or additionally) allow a Pauli-string form `"+X0 Z2"` where -the bare integer is reinterpreted as the same HUGR-derived qubit ordinal, -once the correspondence to traced slot is committed-test verified. - -## Design - -Four parts, mirroring proposal 001's measurement-tag work. - -### 1. HUGR pass: structural qubit ordinals - -New module in `pecos-hugr-qis` (e.g. `qubit_ordinals.rs`): - -```rust -pub fn extract_qubit_allocation_ordinals>( - hugr: &H, -) -> BTreeMap; // ordinal -> AllocateQubit node - -pub fn qubit_allocation_count>(hugr: &H) -> usize; -``` - -Numbering: HUGR traversal ordinal of `AllocateQubit` ops (parallel to how -`extract_result_tag_measurements` numbers `Measure` ops). Sound by -construction — purely structural. - -### 2. Rust parser: accept tracked_pauli observables - -Extend `parse_single_observable` in -`crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs`: - -- Detect `kind=="tracked_pauli"`. -- Parse `pauli` as either: - - A list of `{"qubit_ord": N, "pauli": "X" | "Y" | "Z"}` objects, **or** - - A string `"+X0 Z2"` whose integers are interpreted as qubit ordinals - (post-resolution). -- Parse `label` as an optional string. - -Schema validation: integer ordinals only, single Pauli letter per qubit -(X/Y/Z), no duplicate qubit ordinals within a single observable. - -### 3. Resolver: ordinal → traced qubit slot - -Same shape as `resolve_result_tags` for measurements: - -```rust -pub fn resolve_tracked_pauli_qubit_ordinals( - observables_json: &str, - qubit_ord_to_slot: &BTreeMap, - static_qubit_count: usize, - traced_qubit_count: usize, -) -> Result; -``` - -Resolves each `qubit_ord` to a traced qubit slot; rewrites -`{"qubit_ord": N, "pauli": "X"}` entries into the slot-indexed form the -existing tracked-Pauli machinery already consumes. Same fail-loud -discipline: unknown ordinal → error; static-vs-traced count mismatch → loop -guard error (the qubit analog of 002's measurement loop guard, for runtime- -loop qubit allocation). - -### 4. `from_guppy` wiring (thin, in `dem.py`) - -Already compiles the HUGR when `result_tags` is requested; do the same when -any observable has `kind=="tracked_pauli"`. Pass HUGR bytes + traced qubit -count to a pyo3 `resolve_tracked_pauli_for_guppy`. The downstream Rust DEM -builder consumes already-resolved tracked Paulis. - -## Critical assumption (the one thing the spike must answer) - -> **For a straight-line Guppy program, the HUGR-traversal ordinal of the -> `i`-th `AllocateQubit` op equals the trace allocation order of the qubit -> slot that `qubit()` produced.** - -This is the qubit-side analog of the measurement-side property 001 left -"unproven and no longer relied upon," and that 001's wiring then proved by -committed cross-check for the supported scope. The same test pattern -applies here: write a Guppy program with N source-scrambled `qubit()` -allocations + a tracked Pauli on a specific subset; the DEM via -`qubit_ord`-resolved tracked Pauli must equal the DEM built via the -positional annotation form for the same qubits. If they match across an -asymmetric program, the correspondence is committed-test-verified for the -supported scope. - -If the correspondence fails: this proposal needs 002's measurement- -provenance mechanism extended to qubits (a `record_static_qubit_alloc` -FFI), and effort grows considerably. - -## Spike plan - -1. Build the `extract_qubit_allocation_ordinals` HUGR pass and a - `qubit_allocation_count` companion. Foundation-test on a scrambled - straight-line Guppy program (mirror of 001's - `scrambled_three_measurements`). -2. Prototype the parser + resolver minimally; bypass `from_guppy` wiring. -3. **Correspondence cross-check**: write a Guppy program with three qubits - in scrambled allocation order, each with a distinct gate history (so the - DEMs for tracked Paulis on each are distinguishable, à la 002's - asymmetric scrambled test). Build a tracked Pauli via the new ordinal - form and via the existing positional annotation form; assert byte- - identical DEMs. -4. Wire into `from_guppy`, replace the rejection-of-`tracked_pauli` test - with a positive test, add unknown-ordinal / loop-guard / non-@guppy / - wrapper-input fail-loud tests (mirroring `test_from_guppy_result_tags.py`). - -## Soundness scope - -Covers: -- Straight-line Guppy programs with statically-allocated qubits. -- Caller observables of the form *"this logical observable is the parity of - Pauli operators on these qubits"* — the canonical Stim-style tracked - Pauli. - -Does **not** cover: -- Runtime-loop qubit allocation (`for _ in range(comptime(n)): q = qubit(); - …`). Same gap structure as 002's measurement case: one static - `AllocateQubit` op, N traced slots, no static-op → traced-slot - correspondence without 002-style provenance. The loop guard rejects this - case fail-loud; closing it composes with 002 (extend provenance to qubits) - if/when 002 lands. -- Source-named qubits (`{"qubit_name": "qa"}`). Would require Guppy to - expose source-level qubit names through the HUGR. Orthogonal extension. -- Dynamic qubit allocation under measurement-dependent control flow. - Inherits 004's scope. - -## Out of scope / alternatives considered - -- **Just accept the existing `PauliString.from_str("X0 Z2")` form - positionally without HUGR resolution.** Rejected: this is exactly the - fragile path 001 set out to fix for measurements; doing it for qubits - would reintroduce the same silent-misbind risk on - Guppy/Selene-recompilation. -- **Surface source qubit names from Guppy.** Would be cleaner from a UX - perspective (`"qubit_name": "qa"`) but depends on Guppy preserving - variable names through HUGR generation, which is upstream and not - guaranteed. The HUGR ordinal form is available today. -- **Make tracked Paulis reference measurements (records/meas_ids/result_tags) - instead of qubits.** That's a different conceptual model — tracked - Paulis are physical observables on qubits, not on measurement records. - Conflating them would be a category error. - -## Open questions - -1. **`pauli` JSON shape.** Object list vs string-with-integers vs both? - String form is concise but ambiguous (`"X10"` could be `X on qubit 10` - or `X10 (rank-10 Pauli)`); object form is verbose but unambiguous. - Preference: support both, with the object form as the canonical / safer - one. -2. **Should the `result_tags` HUGR-compile in `from_guppy` be shared with - the new tracked-Pauli HUGR-compile?** Both need `guppy_to_hugr(guppy)`; - compile once if either is present. Trivial optimization. -3. **Naming.** `qubit_ord` vs `qubit_ordinal` vs `qubit_id`. The last - collides with PECOS's internal `QubitId` type (not the same thing). - Prefer `qubit_ord` for clarity. - -## Code paths the spike touches - -- `crates/pecos-hugr-qis/src/qubit_ordinals.rs` — new (parallel to - `result_tags.rs`). -- `crates/pecos-hugr-qis/src/lib.rs` — re-export - `extract_qubit_allocation_ordinals`, `qubit_allocation_count`. -- `crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs` — - `parse_single_observable` extension; new `resolve_tracked_pauli_qubit_ordinals`. -- `python/pecos-rslib/src/dag_circuit_bindings.rs` — new pyo3 wrapper - `resolve_tracked_pauli_for_guppy`. -- `python/quantum-pecos/src/pecos/qec/dem.py` — `from_guppy` thin pass- - through; share `guppy_to_hugr` compile with `result_tags` when both are - present. -- `python/quantum-pecos/tests/qec/test_from_guppy_dem.py` — replace - `test_from_guppy_rejects_json_tracked_pauli_observables` with a positive - test of the new form; add unknown-ordinal / loop-guard / asymmetric - correspondence tests. -- `python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py` may grow - a mixed test (result_tags detector + tracked-Pauli observable on the - same scrambled program). - -## Effort estimate - -- HUGR pass + foundation tests: 1 day. -- Parser/resolver/binding: 1–2 days. -- Wiring + correspondence test + asymmetric/edge-case tests: 2 days. -- Documentation updates + proposal 001 closure addendum: 0.5 day. - -Total spike + production: ~1 work week. Significantly smaller than 002 -because it does **not** require trace-schema or FFI changes — purely HUGR- -side analysis and JSON-side handling, plus a single critical-assumption -correspondence test analogous to one we've already done for measurements. - -## What this proposal does NOT change - -- `dem-polish` is unchanged. Today's `from_guppy` continues to fail loud on - hand-authored tracked Paulis in `observables_json`; circuit-annotation - tracked Paulis continue to work unchanged. -- Proposal 002's runtime-loop closure is independent. If 002 lands first - and adds measurement provenance, this proposal naturally extends to add - qubit-allocation provenance using the same mechanism. diff --git a/docs/proposals/004-measurement-dependent-control-flow-dem.md b/docs/proposals/004-measurement-dependent-control-flow-dem.md deleted file mode 100644 index 2293c2c6e..000000000 --- a/docs/proposals/004-measurement-dependent-control-flow-dem.md +++ /dev/null @@ -1,327 +0,0 @@ -# 004 - Sound DEM construction for measurement-dependent control flow - -**Status:** Draft — spike pending. Two options laid out (static rejection + -branch-aware DEM); strong recommendation to ship option A first. - -**Author:** (dem-polish working notes) - -**Depends on:** [001 - Tag-referenced detectors for -`DetectorErrorModel.from_guppy`](001-from-guppy-tag-referenced-detectors.md) -(which documents the limitation), and overlaps with -[002 - Runtime-loop `result_tags` via dataflow-bound measurement -provenance](002-runtime-loop-result-tags-via-dataflow-provenance.md) -(which explicitly does *not* close this). - -## Summary - -`DetectorErrorModel.from_guppy` traces **one ideal execution** of a Guppy -program and builds a DEM from that trace. A program with -**measurement-dependent quantum control flow** — e.g. `if measure(q): -x(other)` — yields a DEM built from a single sampled branch: silently -wrong, seed-dependent, undefined. The `from_guppy` docstring explicitly -documents this as unsupported, and `from_guppy` does **not currently reject -such programs** — it builds a DEM that callers must not rely on. - -A prior dem-polish round attempted a runtime-trace heuristic -(`reject_dynamic_control`) to detect this case; it false-positived on the -standard surface code (which has statically-scheduled post-measurement -gates per round, indistinguishable from genuine measurement-dependent -feedback in the runtime trace) and was reverted. The reverted-guard -analysis is committed in proposal 001's "Second external review + -outcome" section. The proposal recommendation that came out of that -analysis is **sound detection requires static HUGR analysis, not a runtime- -trace heuristic**. - -This proposal lays out two viable sound paths and recommends starting with -**option A: static rejection** to close the silent-misbind hole, with -**option B: branch-aware DEM construction** as a separate larger -follow-up if/when there's user demand. - -## Background — why this is currently a silent-wrong hole - -- `from_guppy` runs `pecos.sim(program).classical(pecos.selene_engine()) - .quantum(pecos.stabilizer()).qubits(N).seed(s).capture_operation_trace()`. -- The trace is the gates that fired on this one sampled execution. For a - measurement-dependent branch, exactly one branch's gates are in the - trace; the other branches are invisible. -- The DEM is then built over those traced gates as if they were a static - circuit. The "fault propagation" calculation is structurally fine on the - traced circuit, but **the DEM does not model the gates that would have - fired in other branches** — so the result is correct *only* for the one - branch that happened to fire, and wrong for any decoding scenario where a - different branch was active. -- For typical Guppy QEC workflows this hole is hypothetical (programs are - straight-line or have only statically-scheduled feedback), but the - language allows it, the docstring says it's "unsupported / undefined," - and no machinery enforces that. - -## The sound vs unsound boundary (why the reverted guard failed) - -Runtime trace observation: -- Surface code: ancilla measure → statically-scheduled per-round gate on - ancilla qubit (e.g. ancilla reset / classical update) → next round - measure. The trace shows "Measure(q_a, r0); … other gates; Measure(q_a, - r1)" with classical ops between. Indistinguishable from "Measure(q_a, - r0); if r0: X(q_other); Measure(q_a, r1)" at the trace level. -- The reverted `reject_dynamic_control` heuristic looked for non-MZ - quantum ops in `pending_continue` chunks after a measurement and rejected - them. It false-positive-rejected the surface code; the only way to - avoid that false positive was to admit the false-negatives. - -Static HUGR observation: -- The HUGR dataflow makes "Quantum op X depends on Measure op M's result" - a **structural property**: there is a dataflow path from M's classical - output (through `tket.bool:read`, possibly through `tket.bool:eq` / - classical computations, into a `Conditional` op whose body contains X). -- The surface code's post-measurement gates have **no dataflow edge** from - any Measure op's output to their control inputs — they're statically - scheduled, not conditional. The structural check classifies them - correctly as not-measurement-dependent. -- A `Conditional` whose discriminant traces back to a Measure op - unambiguously *is* measurement-dependent control flow. - -This is the same sound-vs-unsound boundary 001's `result_tags` work -established for measurement identity: **structural (HUGR) is sound, -behavioral (runtime trace) is not**. - -## Goal - -Close the silent-misbind hole. Either: - -- **(A)** Soundly **reject** Guppy programs whose DEM cannot be built - faithfully (measurement-dependent quantum control flow), with a clear, - actionable error message. -- **(B)** Soundly **build a DEM** that captures all reachable branches and - is correct for any execution path the program could take. - -Option A is mandatory for soundness. Option B is a feature on top. - -## Option A: static rejection (recommended first) - -A HUGR pass: - -```rust -pub fn detect_measurement_dependent_quantum_ops>( - hugr: &H, -) -> Vec; - -pub struct MeasurementDependentOp { - pub quantum_op: Node, - pub measure_source: Node, - pub via_path: Vec, -} -``` - -It walks every Quantum op (or every op that PECOS classifies as a quantum -operation), checks whether any of its control/discriminant inputs has a -dataflow ancestor that is a Measure op. The reverse-walk is bounded: stop -at a Measure (positive — measurement-dependent), at a function input or -`Const` (negative — independent), at a comptime classical value (negative — -not a runtime measurement). - -Wiring: `from_guppy` runs this check after `guppy_to_hugr`. If any -measurement-dependent Quantum op is detected, fail loud: - -``` -ValueError: from_guppy cannot soundly build a DEM for a program with -measurement-dependent quantum control flow. Detected: Quantum op `x(q1)` -at … is conditional on Measure result from … (path: …). DEM construction -traces one ideal execution and does not model alternate branches; build -the static-circuit-equivalent program explicitly, or use -`pecos.sim`-based sampling for measurement-dependent dynamics. -``` - -This **closes the silent-misbind hole**. It does not enable the feature. - -### Cases the spike must validate - -| Program | Expected | -|---|---| -| straight-line `qubit() ; measure(q)` | not flagged | -| `if measure(q1) == measure(q2): x(q3)` (computed conditional) | flagged via `tket.bool:eq` | -| `if measure(q): x(other)` | flagged | -| surface code (`make_surface_code(...)`) | **not flagged** (no Measure→Quantum dataflow edge) | -| `if comptime_const: x(q)` | not flagged (comptime, not measurement-derived) | -| `for _ in range(comptime(n)): x(q)` | not flagged (comptime loop, not measurement-dependent) | -| `if measure(q): result("x", True)` (classical-only conditional) | **not flagged** (no Quantum op in the conditional body) | - -The last row is important: measurement-dependent **classical** updates -(producing more `result()` tags) are common and don't affect DEM -construction. The check is specifically "any Quantum op that's -measurement-conditional." - -## Option B: branch-aware DEM (separate follow-up) - -For a `Conditional` op whose discriminant depends on a measurement, both -branches can fire on different shots. A sound DEM must include the fault -mechanisms from both branches' Quantum ops, conditioned on the relevant -measurement outcomes. - -Strategies: - -- **Static enumeration.** Walk the HUGR, identify all measurement- - dependent `Conditional` ops, enumerate the cross product of branches - (2^k for k boolean measurements). For each combination, generate a - hypothetical static circuit by inlining the chosen branches, build a per- - combination DEM, combine. Tractable for small `k`; explodes for - surface-code-scale measurement counts. Likely scoped to "k ≤ small N" - with a guard. -- **CFG abstract interpreter.** A proper symbolic execution of the HUGR - treating measurement outcomes as symbolic boolean inputs. This is - essentially the excluded `HugrEngine`. Substantial. -- **Path summarization.** For specific structural patterns (e.g. - syndrome-conditional Pauli correction), the branch effect on the fault - model is summarizable analytically. Pattern-specific, not general. - -Option B's design space is large and depends on actual user demand. **The -recommendation is to defer it.** Option A alone is a complete soundness -fix; option B is a feature whose cost/benefit needs concrete use cases to -evaluate. - -## Critical assumption (the one thing option A's spike must answer) - -> **The structural HUGR analysis `detect_measurement_dependent_quantum_ops` -> can distinguish measurement-conditional Quantum ops from -> statically-scheduled post-measurement Quantum ops with no false positives -> on real QEC workflows (surface code in particular) and no false negatives -> on genuine dynamic-control programs.** - -Falsifiable: run on the cases in the table above; surface code is the -critical non-false-positive case (the same one that killed the reverted -runtime-trace guard); `if measure(q): x(other)` is the critical true- -positive case. - -If the HUGR dataflow analysis can't cleanly classify e.g. `comptime` -conditionals or `tket.bool` chains, the spike answer is the specific -HUGR-op pattern that needs special-casing — and the scope of the special- -case set determines feasibility. - -## Spike plan (option A only) - -1. **Catalog the HUGR-op shapes involved**: `Conditional` op, `tket.bool:*` - ops, `Const`, function inputs, `comptime`-derived constants. Verify how - Guppy lowers each branch pattern. -2. **Implement `detect_measurement_dependent_quantum_ops`** in - `pecos-hugr-qis` as a reverse-dataflow walk from each Quantum op's - control/discriminant inputs. Terminate at Measure (positive), at - Const/Input/comptime-constant (negative). -3. **Foundation tests** in `pecos-hugr-qis` on hand-built or compiled-from- - Guppy fixture HUGRs covering the case table. -4. **Wire into `from_guppy`** — call the analysis on the HUGR after - `guppy_to_hugr` (and share that compile with `result_tags` / - tracked-Pauli paths if either is also active). Fail loud on detection. -5. **Update tests**: replace - `test_from_guppy_dynamic_control_is_unsupported_and_unguarded` (which - currently asserts no guard rejects, pinning the absence of a - detector) with `test_from_guppy_statically_rejects_measurement_dependent_quantum_control` - (asserts the new sound detector rejects dynamic programs *and* accepts - the surface code — the critical regression test). -6. **Sanity sweep**: run the full qec pytest to confirm no existing - workflow trips the new check. - -## Soundness scope - -Option A covers: -- Closing the silent-wrong-DEM hole for measurement-dependent quantum - control flow. After A, `from_guppy` either produces a correct DEM (for - programs without such control) or refuses fail-loud (for programs with - it). - -Option A does **not** cover: -- Building correct DEMs for measurement-dependent programs. That's - option B. -- Measurement-dependent **classical** control (e.g. - measurement-dependent `result()` outputs that don't change the quantum - state). Such programs are not flagged because they don't affect DEM - construction. The trace simply records different `result()` values per - shot, which is fine. - -## Relationship to 001 and 002 - -- **001** introduced the soundness discipline (structural HUGR vs runtime - heuristic) and explicitly noted measurement-dependent control as a - separate deferred case requiring static analysis. This proposal closes - that. -- **002** addresses *measurement identity through runtime loops* — the - question "which trace measurement came from which static measure op?". - That's orthogonal to *whether a Quantum op is measurement-conditional*. - 002's `record_static_measure` mechanism doesn't help here, and 004's - static analysis doesn't help 002 — different problems, different - mechanisms. They can be implemented independently and in either order. -- **003** (tracked Paulis in JSON) is also independent. Programs with - measurement-dependent quantum control flow that *also* use hand-authored - tracked Paulis would be rejected by 004's check before 003's resolution - fires. - -## Out of scope / alternatives considered - -- **Runtime guards.** Already-attempted-and-reverted. Cannot - soundly distinguish surface code from genuine dynamic control. -- **Documentation-only.** "Don't write programs with measurement-dependent - control flow if you use `from_guppy`" is the current state. It's - inadequate because the resulting silent-wrong DEM is a correctness defect - callers may not notice; the language allows the construct and `from_guppy` - produces *some* DEM that callers may use. -- **Optional accept-anyway flag** (e.g. `unsafe_allow_dynamic_control`). - Tempting but contrary to the project's "fail loud, no silent-wrong" - values. If a user genuinely needs DEM for a specific dynamic program, - they should expand it into the static-circuit-equivalent themselves (the - error message suggests this), or option B should be built. - -## Open questions - -1. **What exactly counts as a "Quantum op" for the check?** Probably - anything classified by `pecos_hugr_qis`'s existing - measurement/quantum-op recognition (`is_measurement` and any analog for - Pauli/Clifford ops). Should `Measure` ops themselves under a measurement- - dependent conditional also be flagged? (Today: yes — a conditional - measurement is measurement-dependent.) -2. **Should the check run *unconditionally* in `from_guppy`, or only when - no measurement-dependent feature is explicitly opted into?** Probably - unconditional — the cost is a HUGR walk, and the goal is to close the - silent-wrong hole for every caller. -3. **`from_circuit` and `DemSampler`.** Should the analogous check run on - circuits not built via `from_guppy`? Probably not — `from_circuit` - consumes an already-constructed circuit that doesn't have HUGR-level - conditional ops at all (it's a flat TickCircuit). Measurement- - dependent control is a Guppy-source concern. -4. **Sharing the HUGR compile with `result_tags` / proposal 003.** If any - of {result_tags, tracked_pauli, the new check} is active, `guppy_to_hugr` - runs once. The new check fires *first* (cheap, fail-loud short-circuit - before resolving tags). - -## Code paths the spike touches - -- `crates/pecos-hugr-qis/src/conditional_on_measurement.rs` — new module - implementing the analysis. -- `crates/pecos-hugr-qis/src/lib.rs` — re-export. -- `python/pecos-rslib/src/dag_circuit_bindings.rs` — new pyo3 wrapper - `detect_measurement_dependent_quantum_ops_for_guppy`. -- `python/quantum-pecos/src/pecos/qec/dem.py` — `from_guppy` runs the - check after `guppy_to_hugr` (whenever the HUGR is compiled), or - unconditionally; raises `ValueError` with a clear message on detection. -- `python/quantum-pecos/tests/qec/test_from_guppy_dem.py` — replace - `test_from_guppy_dynamic_control_is_unsupported_and_unguarded` with the - positive rejection test; ensure surface-code and `result_tags` - byte-identical tests continue to pass (critical regression check). - -## Effort estimate (option A) - -- HUGR analysis + foundation tests: 2–3 days (the reverse-dataflow walk is - not large, but it must correctly handle the `tket.bool:*`/`Conditional`/ - `Const`/comptime cases). -- Wiring + test updates: 1 day. -- Surface-code regression confirmation + edge cases: 1 day. - -Total: ~1 work week, with a clear go/no-go signal (does the analysis -classify the case-table cases correctly?). - -Option B is a much larger separate proposal if/when it becomes needed. - -## What this proposal does NOT change - -- `dem-polish` is unchanged. Today's `from_guppy` continues to build a DEM - (silently wrong) for measurement-dependent control programs and is - documented as not-supported for such programs. This proposal adds the - static guard. -- 002 and 003 are independent and can land in any order relative to 004. diff --git a/docs/proposals/005-array-valued-result-support.md b/docs/proposals/005-array-valued-result-support.md deleted file mode 100644 index 451759d64..000000000 --- a/docs/proposals/005-array-valued-result-support.md +++ /dev/null @@ -1,228 +0,0 @@ -# 005 - Array-valued `result()` support in `result_tags` - -**Status:** Draft — spike pending. Smallest of the 002/003/004/005 follow-up -set. Composes with 002 (depends on 002 if runtime-loop arrays are needed; -straight-line arrays can be done independently). - -**Author:** (dem-polish working notes) - -**Depends on / extends:** [001 - Tag-referenced detectors for -`DetectorErrorModel.from_guppy`](001-from-guppy-tag-referenced-detectors.md) -(which excludes array-valued `result()` by construction); -composes with [002 - Runtime-loop `result_tags` via dataflow-bound -measurement provenance](002-runtime-loop-result-tags-via-dataflow-provenance.md) -for the runtime-loop case. - -## Summary - -`extract_result_tag_measurements` (in -`crates/pecos-hugr-qis/src/result_tags.rs`) is **sound by construction**: -it accepts *only* the canonical pattern -`tket.result:result_bool ← tket.bool:read ← Measure/MeasureFree`. Among the -three deliberately-excluded shapes (computed values, constants, array- -valued), **array-valued `result()` is the only one that's a usability gap -rather than a category error**: it's a legitimate, common pattern (e.g. -`result("round_0_syndrome", measure_array(ancillas))`) that the extractor -rejects today only because `tket.result:result_array_bool` lowers through -`collections.borrow_arr` machinery that doesn't cleanly expose per-element -measurement provenance to the static extractor. - -This proposal extends `extract_result_tag_measurements` to recognize -`result_array_bool` and walk back through `borrow_arr` ops to the -individual `Measure` op(s) that produced each array element. The result is -that a tag bound to an array of measurements resolves to a list of record -offsets (one per element, in array order), exactly the same shape as a -multi-tag detector or a multi-record detector. - -If the per-element static walk turns out to be infeasible from -`borrow_arr` alone, the proposal falls back to 002's runtime measurement- -provenance mechanism: each array element's `Measure` op carries a -`record_static_measure` call, and the trace tells us per-element which -static op fired. Either path produces the same end-user surface. - -## Background - -- Foundation test `array_valued_tag_is_excluded` in - `crates/pecos-hugr-qis/src/result_tags.rs` pins the current exclusion - using the `arr.hugr` fixture (`result("pair", measure_array(qs))`). -- The 001 closure justifies this as "array-valued `result(...)` (`result_array_bool` - lowers through `collections.borrow_arr` machinery that does not cleanly - expose per-element measurement provenance). Resolving those structurally - would silently misbind … so they are not returned." -- The user-facing impact: a natural Guppy idiom for a round of QEC syndrome - measurement — - - ```python - @guppy - def round() -> None: - syndrome = measure_array(ancillas) - result("round_0_syn", syndrome) - ``` - - — currently can't be referenced via `result_tags`; the user must either - break the array apart into individual `result("…_0", measure(a0))` - calls, or use positional `records`. Both are workable but verbose. - -## Goal - -Allow `result_tags` to reference array-valued `result()` outputs, with each -element mapped to its corresponding measurement. The expected surface is -unchanged from 001's scalar case — `result_tags: ["round_0_syn"]` simply -expands to the list of records the array elements correspond to. - -Equivalent to: an array-valued tag is sugar for the per-element list of -individual scalar tags, *as if* the program had written - -```python -result("round_0_syn[0]", measure(a0)) -result("round_0_syn[1]", measure(a1)) -… -``` - -Per-element selection syntax (e.g. `result_tags: [{"tag": "round_0_syn", -"index": 3}]`) is a natural extension but TBD; the headline case is the -whole array. - -## Design - -Two viable paths. - -### Path A: pure HUGR-side resolution (cheapest if it works) - -Extend `extract_result_tag_measurements` to recognize the -`tket.result:result_array_bool` pattern: - -- The op carries the tag in its `args()` (same as `result_bool`). -- Its value input is wired (via `tket.bool:read` of each element? or via - `collections.borrow_arr` ops? — to be determined by inspection of - `arr.hugr`) from a structure that ultimately traces back to N `Measure` - ops, one per array element. -- The walk-back must traverse `borrow_arr`'s element-access pattern - unambiguously to identify which `Measure` op feeds which array index. - -If `borrow_arr`'s element-access pattern preserves a clean source-element -correspondence (which is the question this spike must answer for the -static path), the extractor returns `tag → [ord_0, ord_1, …, ord_{N-1}]` -in array order — same data shape as the multi-occurrence `tag → [ord_0, -…]` proposal 002 produces for loops, and 001's existing resolver consumes -without modification. - -### Path B: runtime provenance via 002 - -If the static walk through `borrow_arr` is too tangled (or if the -correspondence isn't sound under Selene's lowering), 002's -`record_static_measure` mechanism gives us the data we need without any -new static analysis: each underlying `Measure` op carries a -`record_static_measure(result_id, static_op_id)` dataflow-bound call; the -trace surfaces `static_op_id → [MeasIds]`; the extractor only needs to -identify the *static op id of each Measure feeding the array result*, -which doesn't require resolving `borrow_arr`'s indexing semantics — only -identifying the set of measure-op ids associated with the array tag. - -Path B is structurally cleaner and composes with 002. The cost is a -dependency on 002 landing first (or being co-implemented). - -## Critical assumption (the one thing the spike must answer) - -> **Can the structural walk-back from a `tket.result:result_array_bool` op, -> through `collections.borrow_arr` machinery, identify per-element which -> `Measure` op produced each element of the array, in array index order?** - -Falsifiable by reading `arr.hugr` (committed fixture in -`crates/pecos-hugr-qis/tests/fixtures/`) at the HUGR level and tracing the -dataflow. If yes → Path A. If no → Path B (and 002 must land first). - -## Spike plan - -1. **Read the existing `arr.hugr` fixture** and document the actual - `borrow_arr` lowering pattern: which ops sit between the `Measure` ops - and the `result_array_bool` consumer, and what relations between them - constitute the "this measure feeds element k" link. -2. **Path A prototype**: extend `extract_result_tag_measurements` to walk - the `result_array_bool` → `borrow_arr` → `Measure` chain. Foundation- - test on `arr.hugr` (asserting the array maps to the expected - per-element ordinals). -3. **If Path A unambiguous**: wire into `resolve_result_tags` (no change - needed — already accepts `tag → [multiple ordinals]`); add a - `from_guppy` test verifying `result_tags: ["arr_tag"]` resolves to the - same DEM as the per-element scalar form. -4. **If Path A ambiguous or unsafe**: punt to Path B, document the - blocking observation, defer until 002 lands. - -## Soundness scope - -Covers: -- Straight-line `result(tag, measure_array(qs))` for fixed-length comptime - arrays. (Length must be comptime-known for the trace to have a fixed - number of measurements; runtime-length arrays would need 002's machinery - even more.) - -Does **not** cover: -- Computed array values like `result("foo", [m0 ^ m1, m2 ^ m3])` — - inherits 006's scope question (linear-combination resolution) over - arrays. -- Array results inside runtime loops — composes with 002. Until 002 - lands, runtime-loop array results fail loud at the loop guard. -- Per-element value access in the tag itself (`result_tags: [{"tag": - "syn", "index": 3}]`) — natural extension if useful, but the headline - whole-array form is sufficient for the most common pattern (a detector - spanning a whole syndrome). - -## Out of scope / explicitly rejected - -- **Treating array-valued `result()` as a single opaque parity** (i.e. - resolving to a single record). The semantics in Guppy is N independent - values, not their parity; collapsing them would silently misbind. -- **Supporting heterogeneous arrays** (e.g. `[m0, True, m1 ^ m2]`). The - array element walk must terminate at raw `Measure` ops for each - element; mixing in constants or computed values falls under 001's - deliberate exclusions (or, partially, 006's linear-combination - refinement). - -## Open questions - -1. **`borrow_arr` semantics.** Is the static walk Path A asks for actually - feasible? Spike step 1 answers this. -2. **Per-element syntax.** Is `result_tags: [{"tag": "syn", "index": 3}]` - wanted, or just the whole-array form? Defer until a user requests it. -3. **Composition with 002 if both land.** If 002 has injected - `record_static_measure` for every measure op, Path B becomes the - simpler implementation regardless of whether Path A would have worked. - In that case, drop Path A in favor of consistency with the rest of - the 002 mechanism. - -## Code paths the spike touches - -- `crates/pecos-hugr-qis/src/result_tags.rs` — extend - `extract_result_tag_measurements` to recognize `result_array_bool` / - `borrow_arr`. -- `crates/pecos-hugr-qis/tests/fixtures/arr.hugr` — existing fixture used - as the structural reference; possibly add a new fixture for the - positive case. -- `crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs` — no - change needed (the resolver already handles `tag → [multiple - ordinals]`). -- `python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py` — add a - test for array-valued `result()` resolution, mirroring the asymmetric - correspondence pattern (`result_tags: ["arr_tag"]` equals the - per-element record list, verified via a load-bearing asymmetric - fixture). - -## Effort estimate - -- Path A: spike step 1 (read `arr.hugr`): 0.5 day. If feasible: extractor - extension + tests: ~2 days. Total: ~2.5 days. -- Path B (if Path A infeasible): waits on 002. Implementation on top of - 002 once it lands: ~1 day (the per-measurement provenance is already - available; only need to extract the static-op set associated with the - array tag from the HUGR). - -The smallest of the 002/003/004/005 set; can ship independently if Path A -works. - -## What this proposal does NOT change - -- `dem-polish` is unchanged. Today's `from_guppy` continues to reject - array-valued `result()` in `result_tags` via the extractor's deliberate - narrowing; the rejection is committed-test pinned and that test stays - green until the extractor is extended. diff --git a/docs/proposals/006-linear-combination-result-support.md b/docs/proposals/006-linear-combination-result-support.md deleted file mode 100644 index c439d32f1..000000000 --- a/docs/proposals/006-linear-combination-result-support.md +++ /dev/null @@ -1,238 +0,0 @@ -# 006 - Linear-combination `result()` support (XOR/EQ/NOT chains) - -**Status:** Draft — narrow refinement of 001's extractor. Worth capturing -because 001's closure-section claim *"equality is not parity"* is actually -incorrect for the specific case of `bool:eq`/`bool:xor`/`bool:not` chains -over raw measurement bits, and a small, sound extension recovers a -legitimate user-facing pattern. - -**Author:** (dem-polish working notes) - -**Depends on / extends:** [001 - Tag-referenced detectors for -`DetectorErrorModel.from_guppy`](001-from-guppy-tag-referenced-detectors.md) -(which excludes "computed values" by construction). - -## Summary - -`extract_result_tag_measurements` rejects `result("x", m0 == m1)` and -similar computed values by construction, citing — in 001's closure -section — that "equality is not parity." That blanket exclusion is **sound -but overly conservative**: for the specific case of a chain of `tket.bool:eq` -(boolean equality), `tket.bool:xor`, and `tket.bool:not` over raw -measurement bits (each via `tket.bool:read` of a `Measure`/`MeasureFree` -op), the resulting boolean value's *flip behavior under DEM error -mechanisms* is exactly the XOR-parity of the underlying measurements' -flip behaviors — which is the same as a detector with `records` on those -measurements. The semantics is sound; only the user-visible *value* of -the computed result differs from the parity (one is the negation of the -other), but DEM mechanisms describe *flip conditions*, not values. - -This proposal extends `extract_result_tag_measurements` to soundly resolve -the linear (XOR-closed) subset of `tket.bool` computations, returning the -parity-equivalent record-offset list. `tket.bool:and` and `tket.bool:or` -remain excluded (they're genuinely non-linear in the error mechanisms and -not representable as a DEM detector). - -This is the smallest proposal in the dem-polish follow-up set. It refines -existing committed behavior; no new trace data, FFI, or wiring. - -## Background — why "equality is not parity" is too strong - -Consider `b = m0 == m1`: - -- Ideal values: `m0_i`, `m1_i`; ideal `b` = `(m0_i == m1_i) = NOT(m0_i - XOR m1_i)`. -- With error mechanisms `e0`, `e1` cumulatively flipping `m0`, `m1`: - observed `m0 = m0_i XOR e0`, observed `m1 = m1_i XOR e1`. Observed - `b = NOT((m0_i XOR e0) XOR (m1_i XOR e1)) = NOT(m0_i XOR m1_i) XOR - (e0 XOR e1)`. -- Observed `b` XOR ideal `b` = `e0 XOR e1`. - -So `b` flips relative to its ideal value iff the parity of `e0` and `e1` -is odd — **the same flip condition as a DEM detector with records on -`m0` and `m1`**. A detector tagged `result("x", m0 == m1)` resolved to -`records: [m0_ord, m1_ord]` is sound. - -The same holds for any chain built from `eq`, `xor`, `not` over raw -measurement bits: each such op preserves XOR-linearity, and the resulting -boolean's flip condition is the XOR of the underlying error mechanisms -(with cancellation for repeated occurrences — `m0 == m0` is constant True -regardless of errors, so its flip condition is zero, mapping to an empty -record set, which is correctly *not* a detector — sound rejection). - -`and`/`or` are different: their value depends non-linearly on the -operands, so their flip condition depends on the *intended values* of the -operands, not just on the error mechanisms. They cannot be represented -as a DEM detector with a fixed record list. - -## Goal - -Extend `extract_result_tag_measurements` so the following Guppy patterns -soundly resolve through `result_tags`: - -```python -result("x", m0 == m1) # parity of m0, m1 -result("y", not m0) # flip-equivalent to m0 alone -result("z", m0 ^ m1 ^ m2) # parity of m0, m1, m2 (if `^` lowers to tket.bool:xor) -result("w", (m0 == m1) == m2) # parity of m0, m1, m2 (associativity) -result("v", m0 == m0) # empty set; rejected as not-a-detector -``` - -All of these should produce `tag → [ord_i_0, ord_i_1, …]` (the symmetric- -difference set of the measurement ordinals, with even-count duplicates -canceling). The downstream resolver already accepts this shape unchanged. - -`and`/`or` continue to be rejected fail-loud (already are; no change). -Mixing `and`/`or` anywhere in the chain rejects the whole chain — the -walk-back terminates as soon as it sees a non-linear op. - -## Design - -Single change site: `extract_result_tag_measurements` in -`crates/pecos-hugr-qis/src/result_tags.rs`. - -Current behavior: the walk-back from `result_bool` accepts exactly -`tket.bool:read ← Measure/MeasureFree`. New behavior: the walk-back is -an XOR-symmetric-difference set accumulator that traverses: - -- `tket.bool:read ← Measure/MeasureFree` → emit the measurement's - ordinal into the accumulator. -- `tket.bool:not(x)` → recurse into `x` (NOT preserves XOR behavior). -- `tket.bool:eq(a, b)` → recurse into both; symmetric-difference accumulate. -- `tket.bool:xor(a, b)` (if Guppy lowers `^` this way; TBD by inspection) → - recurse into both; symmetric-difference accumulate. -- Anything else (`tket.bool:and`, `tket.bool:or`, `Const`, - `collections.borrow_arr`, computed values not in the above set) → - bail out, exclude this tag from the result map (same as today). - -Symmetric-difference semantics: an ordinal appearing twice in the chain -cancels (e.g. `m0 == m0` → empty set, which is then *not* added to the -output because empty record sets are not detectors — sound). - -Implementation: a recursive `walk_linear(node, visited) -> Option>` -where the set is the symmetric-difference of all measurement ordinals -contributing to `node`'s value, and `None` means "non-linear / bailout." - -## Critical assumption (the one thing the spike must answer) - -> **Guppy's `==`, `!=`, `^`, and `not` over boolean values lower to -> `tket.bool:eq`, `tket.bool:xor`, and `tket.bool:not` respectively -> (or some specific set we can enumerate), and these are the *only* -> linear-boolean ops Guppy uses for measurement-derived booleans.** - -If yes → straightforward implementation. If Guppy lowers `^` through -`tket.bool:and` + `tket.bool:not` (or some other non-direct route), the -walk-back has to recognize that compound pattern. The spike inspects -representative Guppy programs to enumerate the actual lowering. - -## Spike plan - -1. **Enumerate Guppy's boolean-op lowering.** Compile a small Guppy program - `result("x", m0 == m1)`, `result("y", not m0)`, - `result("z", m0 ^ m1)` (if Guppy supports `^` on booleans), and any - other XOR-equivalent forms. Inspect the resulting HUGR to identify the - `tket.bool:*` ops involved. -2. **Implement `walk_linear`** in `pecos-hugr-qis/src/result_tags.rs`. - The set of recognized linear ops is what step 1 enumerated. -3. **Foundation tests** in `pecos-hugr-qis` mirroring the existing - `scrambled` / `computed` / `arr` style: hand-built or compiled-from- - Guppy HUGRs covering the cases listed in "Goal" plus the rejection - cases (any `and`/`or` in the chain). -4. **Replace** `computed_and_constant_tags_are_excluded` (in - `crates/pecos-hugr-qis/src/result_tags.rs` tests) to assert the - refined semantics: linear shapes resolve; `and`/`or` / constants / - empty-symmetric-difference cases stay excluded. -5. **End-to-end via `from_guppy`**: add an asymmetric correspondence test - à la 001's `test_result_tags_match_positional_records` — a Guppy - program where `result("eq", m0 == m1)` resolves to a detector - byte-identical to the `records: [m0_ord, m1_ord]` form, with - pre-measurement gate asymmetry so the test is load-bearing. - -## Soundness scope - -Covers: -- Boolean equality/xor/negation chains over raw measurement bits. -- Repeated measurement references with XOR cancellation - (`m0 == m0` → empty set → not a detector, soundly rejected). - -Does **not** cover: -- `and`/`or` (non-linear in error mechanisms; rejected fail-loud). -- Computed values mixing classical constants with measurements (e.g. - `result("x", m0 == True)`) — this simplifies to `result("x", m0)` in - value space and `record_offset(m0)` in flip space, so it *is* soundly - resolvable. Borderline: include in the spike if cheap, else defer. -- Array-valued computations (e.g. `result("xs", [m0 == m1, m2 == m3])`) — - inherits 005's scope. -- Linear ops *inside* runtime loops — inherits 002's scope (need - per-occurrence binding for the underlying measurements first). - -## Out of scope / explicitly rejected - -- **`tket.bool:and`/`or` support via case analysis on intended values.** - Theoretically you could split the DEM into multiple sub-mechanisms - conditioned on intended values, but that's 004's branch-aware DEM - territory, not a refinement of the extractor. -- **Generalized rational-combination support.** DEM is XOR-linear by - definition; non-XOR-linear computations aren't representable. Sticking - to the XOR-closed subset is the entire soundness story. - -## Open questions - -1. **What ops does Guppy actually use for `==`, `^`, `!=`, `not` on - booleans?** Step 1 of the spike. Until known, the proposal is - "support whichever XOR-linear ops Guppy emits." -2. **Should `m0 == True` simplify to `m0`?** Borderline — yes if it's - easy. (`tket.bool:eq` with one operand a `Const` simplifies to either - the other operand or its negation, both of which are linear.) -3. **Naming.** "Linear-combination" or "XOR-closed" or "parity- - equivalent" — pick one and stick with it. I've used "linear- - combination" throughout; "XOR-closed" is more precise. - -## Code paths the spike touches - -- `crates/pecos-hugr-qis/src/result_tags.rs` — `extract_result_tag_measurements` - extended with the `walk_linear` recursion; existing - `computed_and_constant_tags_are_excluded` test updated to reflect the - refined semantics. -- `crates/pecos-hugr-qis/tests/fixtures/` — potentially a new HUGR fixture - for the positive linear-combination case. -- `python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py` — add an - end-to-end correspondence test for `result("eq", m0 == m1)`. - -No change to `resolve_result_tags`, the pyo3 binding, or `dem.py` — the -output shape (`tag → [multiple ordinals]`) is unchanged. - -## Effort estimate - -- Spike step 1 (enumerate Guppy's boolean lowering): 0.5 day. -- `walk_linear` implementation + foundation tests: 1–1.5 days. -- End-to-end correspondence test: 0.5 day. -- Update closure-section claim in proposal 001 ("equality is not parity" - → "XOR-closed boolean computations resolve to the parity of their - contributing measurements; AND/OR remain excluded"): 0.25 day. - -Total: ~2.5 days. The smallest of the dem-polish follow-ups, and -self-contained. - -## Cost/benefit honesty - -This is a **narrow** refinement. Users with `result("x", m0 == m1)` can -already express the equivalent detector as `records: [-2, -1]` directly -without `result_tags`. The benefit is purely: - -- Source-anchored, reorder-immune naming for computed booleans (e.g. - syndrome equality checks). -- Removing an over-conservative exclusion that a careful reader of 001's - closure might (correctly) recognize as too strong. - -The cost is moderate-to-low: ~2.5 days of focused work, small surface -change, no new APIs, well-bounded test surface. It's the kind of -refinement that's worth doing if there's user demand for the pattern -(common in QEC syndrome processing) but skippable if not. - -## What this proposal does NOT change - -- `dem-polish` is unchanged. Today's extractor continues to fail-loud- - exclude computed values; the `computed_and_constant_tags_are_excluded` - test stays green until the extractor is extended. -- 002, 003, 004, 005 are all independent of 006 (and vice versa). diff --git a/docs/proposals/README.md b/docs/proposals/README.md deleted file mode 100644 index f3cce2c24..000000000 --- a/docs/proposals/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# PECOS Proposals - -This directory contains architectural proposals and design explorations for PECOS. These documents capture ideas that may influence future development directions. - -## Status Labels - -- **Draft** - Initial exploration, gathering feedback -- **Under Discussion** - Being actively considered -- **Accepted** - Approved for implementation -- **Implemented** - Completed and merged -- **Deferred** - Good idea, but not now -- **Rejected** - Decided against - -## Proposals - -| Folder/File | Status | Summary | -|-------------|--------|---------| -| [001-from-guppy-tag-referenced-detectors.md](001-from-guppy-tag-referenced-detectors.md) | Draft | Capture Guppy `result()` tags so `DetectorErrorModel.from_guppy` detectors are reorder-proof (delivered for the straight-line scope; remaining gaps split out to 002–006) | -| [002-runtime-loop-result-tags-via-dataflow-provenance.md](002-runtime-loop-result-tags-via-dataflow-provenance.md) | Draft | Close 001's runtime-loop deferral PECOS-side via a dataflow-bound `record_static_measure` FFI injected into the HUGR before Selene compiles it; spike pending | -| [003-hand-authored-tracked-paulis-in-observables-json.md](003-hand-authored-tracked-paulis-in-observables-json.md) | Draft | Soundly accept hand-authored tracked-Pauli observables in `observables_json` by giving qubits structural HUGR ordinals (the same MLIR-pattern proposal 001 applied to measurements); spike pending | -| [004-measurement-dependent-control-flow-dem.md](004-measurement-dependent-control-flow-dem.md) | Draft | Close the silent-wrong-DEM hole for Guppy programs with measurement-dependent quantum control flow via a static HUGR dataflow analysis (option A: rejection); branch-aware DEM construction (option B) sub-scoped, deferred | -| [005-array-valued-result-support.md](005-array-valued-result-support.md) | Draft | Extend `extract_result_tag_measurements` to recognize `tket.result:result_array_bool` so `result(tag, measure_array(qs))` resolves as a list of records; spike pending. Smallest of 002–006; composes with 002 for runtime-loop arrays | -| [006-linear-combination-result-support.md](006-linear-combination-result-support.md) | Draft | Extend the extractor to soundly resolve XOR-closed `bool:eq`/`xor`/`not` chains over raw measurements (`result("x", m0 == m1)` → records:[m0_ord, m1_ord]); narrow refinement of 001's "computed values excluded" rule | - -## Relationships and what is *not* separately proposed - -The dem-polish follow-ups split 001's residual scope as follows: - -| 001 deferred / out-of-scope item | Follow-up | -|---|---| -| Runtime-loop `result_tags` | 002 | -| Hand-authored tracked Paulis in JSON | 003 | -| Measurement-dependent quantum control flow | 004 (option A: static rejection) | -| Array-valued `result()` in `result_tags` | 005 | -| Linear-combination (XOR/EQ/NOT) `result()` in `result_tags` | 006 | - -Items intentionally **not** given a separate proposal: - -- **Branch-aware DEM construction** for measurement-dependent control flow - is sub-scoped as "option B" of 004, deferred until a concrete use case - motivates the substantial design space (CFG abstract interpretation, - branch enumeration cost, semantic combination of per-branch DEMs). -- **Selene-side cooperation** for direct measurement provenance is the - alternative 002 set aside. It would be an upstream proposal, not a - PECOS one. -- **HUGR CFG abstract interpreter** (`HugrEngine`-equivalent) is the - alternative both 002 and 004 set aside. Explicitly excluded as a wrong - direction for the dem-polish scope; substantial work that duplicates - what upstream `tket-qsystem` is expected to provide eventually. -- **Source-named qubit / measurement references** (`{"qubit_name": "qa"}`) - depend on upstream Guppy preserving source-level variable names through - HUGR generation. Mentioned as out-of-scope in 003 and elsewhere. -- **Genuinely non-linear computed `result()`** (`and`/`or`) is a category - error — not representable as a DEM detector. 001's exclusion is correct - for this case; 006 only relaxes it for the linear sub-case. - -## Contributing - -When adding a new proposal: - -1. For a single document: Create `NNN-short-title.md` -2. For a multi-document exploration: Create a folder with `README.md` and related docs -3. Add an entry to this README -4. Open for discussion diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 9d8260e8c..a36b39483 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -176,7 +176,8 @@ def from_guppy( ``detectors_json`` argument). The supported scope is canonical scalar ``result(tag, measure(q))`` in straight-line programs; the runtime-loop case (per-occurrence binding) remains deferred. See - ``docs/proposals/001-from-guppy-tag-referenced-detectors.md``. + proposal 001 (``from-guppy-tag-referenced-detectors``) in the + ``pecos-docs`` repository. """ from pecos.qec.surface.decode import trace_guppy_into_tick_circuit diff --git a/python/quantum-pecos/src/pecos/slr/qalloc.py b/python/quantum-pecos/src/pecos/slr/qalloc.py index 397b2332a..155fc3e56 100644 --- a/python/quantum-pecos/src/pecos/slr/qalloc.py +++ b/python/quantum-pecos/src/pecos/slr/qalloc.py @@ -15,7 +15,8 @@ Inspired by Zig's allocator pattern and NASA's Power of 10 rules. Provides hierarchical qubit slot management with explicit lifecycle states. -See docs/proposals/slr-qubit-allocators.md for full design documentation. +See the SLR Qubit Allocator proposal (``slr-qubit-allocators``) in the +``pecos-docs`` repository for full design documentation. """ from __future__ import annotations From d178482fa42a4cd867983c587472d7dc13f89a5c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 20 May 2026 09:55:22 -0600 Subject: [PATCH 25/36] Fix typos pre-commit hook flag in reject_tracked_pauli rustdoc (mis-ingested -> parsed as the wrong thing) --- crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index f7dbe9bcd..2f35d2953 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -1427,7 +1427,7 @@ fn parse_single_observable(value: &serde_json::Value) -> Result, kind: &str, From f9b2ed061576ebfe52e7f69302560a454225e6a0 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 20 May 2026 11:48:30 -0600 Subject: [PATCH 26/36] Drop "in pecos-docs" pointer from public code refs; refresh uv.lock for pymdown-extensions --- crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs | 2 +- python/quantum-pecos/src/pecos/qec/dem.py | 3 +-- python/quantum-pecos/src/pecos/slr/qalloc.py | 4 ++-- uv.lock | 6 +++--- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 2f35d2953..337168ad7 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -1716,7 +1716,7 @@ pub fn resolve_result_tags( static measurement op(s) but the traced program emits \ {traced_meas_count} measurement(s). Per-occurrence tag binding is \ not statically available; use positional records (see proposal \ - 001 from-guppy-tag-referenced-detectors in pecos-docs)." + 001 from-guppy-tag-referenced-detectors)." ))); } let traced = i64::try_from(traced_meas_count).map_err(|_| { diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index a36b39483..0ad315c60 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -176,8 +176,7 @@ def from_guppy( ``detectors_json`` argument). The supported scope is canonical scalar ``result(tag, measure(q))`` in straight-line programs; the runtime-loop case (per-occurrence binding) remains deferred. See - proposal 001 (``from-guppy-tag-referenced-detectors``) in the - ``pecos-docs`` repository. + proposal 001 (``from-guppy-tag-referenced-detectors``). """ from pecos.qec.surface.decode import trace_guppy_into_tick_circuit diff --git a/python/quantum-pecos/src/pecos/slr/qalloc.py b/python/quantum-pecos/src/pecos/slr/qalloc.py index 155fc3e56..a8226238e 100644 --- a/python/quantum-pecos/src/pecos/slr/qalloc.py +++ b/python/quantum-pecos/src/pecos/slr/qalloc.py @@ -15,8 +15,8 @@ Inspired by Zig's allocator pattern and NASA's Power of 10 rules. Provides hierarchical qubit slot management with explicit lifecycle states. -See the SLR Qubit Allocator proposal (``slr-qubit-allocators``) in the -``pecos-docs`` repository for full design documentation. +See the SLR Qubit Allocator proposal (``slr-qubit-allocators``) for full +design documentation. """ from __future__ import annotations diff --git a/uv.lock b/uv.lock index 721e11ca6..f0e9303b7 100644 --- a/uv.lock +++ b/uv.lock @@ -3442,15 +3442,15 @@ wheels = [ [[package]] name = "pymdown-extensions" -version = "10.21.2" +version = "10.21.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, ] [[package]] From 2f2c283ecbf5c4fab490494ac2b2395e7f582220 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 20 May 2026 13:48:26 -0600 Subject: [PATCH 27/36] Remove all 'see proposal' / pecos-docs references from public source; pecos-docs is private by design --- crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs | 3 +-- crates/pecos-qec/tests/stim_dem_export_tests.rs | 3 +-- python/quantum-pecos/src/pecos/qec/dem.py | 6 ++---- python/quantum-pecos/src/pecos/qec/surface/decode.py | 2 +- python/quantum-pecos/src/pecos/slr/qalloc.py | 3 --- .../quantum-pecos/tests/qec/test_from_guppy_result_tags.py | 2 +- 6 files changed, 6 insertions(+), 13 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 337168ad7..2de359c13 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -1715,8 +1715,7 @@ pub fn resolve_result_tags( programs with runtime loops: the HUGR has {static_meas_count} \ static measurement op(s) but the traced program emits \ {traced_meas_count} measurement(s). Per-occurrence tag binding is \ - not statically available; use positional records (see proposal \ - 001 from-guppy-tag-referenced-detectors)." + not statically available; use positional records." ))); } let traced = i64::try_from(traced_meas_count).map_err(|_| { diff --git a/crates/pecos-qec/tests/stim_dem_export_tests.rs b/crates/pecos-qec/tests/stim_dem_export_tests.rs index 6c6663e6c..14c1d151c 100644 --- a/crates/pecos-qec/tests/stim_dem_export_tests.rs +++ b/crates/pecos-qec/tests/stim_dem_export_tests.rs @@ -11,8 +11,7 @@ // the License. //! Integration tests for Stim-format DEM export from `DemStabSim` with -//! per-gate noise. Closes the -//! `~/Repos/pecos-docs/ideas/stim-compat-dem-export.md` gap. +//! per-gate noise. use pecos_core::QubitId; use pecos_qec::dem_stab::DemStabSim; diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 0ad315c60..1adf274c2 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -164,8 +164,7 @@ def from_guppy( statically-scheduled post-measurement gates a normal QEC circuit has (the surface code has these every round), so no guard is attempted -- pass straight-line programs only. Sound detection - would require HUGR conditional-on-measurement analysis (deferred; - see proposal 001). + would require HUGR conditional-on-measurement analysis (deferred). Every measurement is anchored to a stable MeasId automatically: ``measure()`` itself allocates the result slot in the trace (a @@ -175,8 +174,7 @@ def from_guppy( ``result_tags`` field on detectors/observables (see the ``detectors_json`` argument). The supported scope is canonical scalar ``result(tag, measure(q))`` in straight-line programs; the - runtime-loop case (per-occurrence binding) remains deferred. See - proposal 001 (``from-guppy-tag-referenced-detectors``). + runtime-loop case (per-occurrence binding) remains deferred. """ from pecos.qec.surface.decode import trace_guppy_into_tick_circuit diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 25469f3ec..e9f9c4cb6 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -701,7 +701,7 @@ def trace_guppy_into_tick_circuit(program: Any, num_qubits: int, *, seed: int = a single sampled branch is not a static circuit. No reliable runtime-trace heuristic distinguishes that from statically-scheduled post-measurement gates (the surface code legitimately has those), so no guard is attempted; - callers must pass straight-line programs (see proposal 001). + callers must pass straight-line programs. Args: program: Anything ``pecos.sim`` accepts -- a ``@guppy`` function, a diff --git a/python/quantum-pecos/src/pecos/slr/qalloc.py b/python/quantum-pecos/src/pecos/slr/qalloc.py index a8226238e..cabdf12a1 100644 --- a/python/quantum-pecos/src/pecos/slr/qalloc.py +++ b/python/quantum-pecos/src/pecos/slr/qalloc.py @@ -14,9 +14,6 @@ Inspired by Zig's allocator pattern and NASA's Power of 10 rules. Provides hierarchical qubit slot management with explicit lifecycle states. - -See the SLR Qubit Allocator proposal (``slr-qubit-allocators``) for full -design documentation. """ from __future__ import annotations diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py b/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py index 132407a64..160bff950 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py @@ -150,7 +150,7 @@ def test_result_tags_with_runtime_loop_program_fails_loud() -> None: has one static measure op per loop body, not per occurrence. The Rust static-vs-traced count guard rejects this case rather than silently misbinding (per-occurrence tag binding requires CFG-interpreter-class - machinery; see proposal 001).""" + machinery).""" with pytest.raises(ValueError, match=r"runtime loops|not supported"): DetectorErrorModel.from_guppy( make_surface_code(distance=3, num_rounds=3, basis="Z"), From 02ca5a4c111f1221c688e5b6b8541dd11c05222c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 20 May 2026 13:57:22 -0600 Subject: [PATCH 28/36] Remove stale doc stubs and the in-source pointer to a moved future-work note --- exp/pecos-stab-tn/docs/approach.md | 3 --- exp/pecos-stab-tn/docs/future_work.md | 3 --- exp/pecos-stab-tn/docs/landscape.md | 3 --- exp/pecos-stab-tn/docs/literature_status.md | 3 --- exp/pecos-stab-tn/docs/ofd_plan.md | 3 --- exp/pecos-stab-tn/docs/priorities.md | 3 --- exp/pecos-stab-tn/docs/references.md | 3 --- exp/pecos-stab-tn/src/stab_mps/measure.rs | 2 +- 8 files changed, 1 insertion(+), 22 deletions(-) delete mode 100644 exp/pecos-stab-tn/docs/approach.md delete mode 100644 exp/pecos-stab-tn/docs/future_work.md delete mode 100644 exp/pecos-stab-tn/docs/landscape.md delete mode 100644 exp/pecos-stab-tn/docs/literature_status.md delete mode 100644 exp/pecos-stab-tn/docs/ofd_plan.md delete mode 100644 exp/pecos-stab-tn/docs/priorities.md delete mode 100644 exp/pecos-stab-tn/docs/references.md diff --git a/exp/pecos-stab-tn/docs/approach.md b/exp/pecos-stab-tn/docs/approach.md deleted file mode 100644 index e82b71940..000000000 --- a/exp/pecos-stab-tn/docs/approach.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/stab-tn/approach.md`. diff --git a/exp/pecos-stab-tn/docs/future_work.md b/exp/pecos-stab-tn/docs/future_work.md deleted file mode 100644 index f1c47453b..000000000 --- a/exp/pecos-stab-tn/docs/future_work.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/stab-tn/future-work.md`. diff --git a/exp/pecos-stab-tn/docs/landscape.md b/exp/pecos-stab-tn/docs/landscape.md deleted file mode 100644 index 8361359f2..000000000 --- a/exp/pecos-stab-tn/docs/landscape.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/stab-tn/landscape.md`. diff --git a/exp/pecos-stab-tn/docs/literature_status.md b/exp/pecos-stab-tn/docs/literature_status.md deleted file mode 100644 index 8968ce8fb..000000000 --- a/exp/pecos-stab-tn/docs/literature_status.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/stab-tn/literature-status.md`. diff --git a/exp/pecos-stab-tn/docs/ofd_plan.md b/exp/pecos-stab-tn/docs/ofd_plan.md deleted file mode 100644 index ff9606fdd..000000000 --- a/exp/pecos-stab-tn/docs/ofd_plan.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/stab-tn/ofd-plan.md`. diff --git a/exp/pecos-stab-tn/docs/priorities.md b/exp/pecos-stab-tn/docs/priorities.md deleted file mode 100644 index 6c0b00b78..000000000 --- a/exp/pecos-stab-tn/docs/priorities.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/stab-tn/priorities.md`. diff --git a/exp/pecos-stab-tn/docs/references.md b/exp/pecos-stab-tn/docs/references.md deleted file mode 100644 index a16b0eb15..000000000 --- a/exp/pecos-stab-tn/docs/references.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/stab-tn/references.md`. diff --git a/exp/pecos-stab-tn/src/stab_mps/measure.rs b/exp/pecos-stab-tn/src/stab_mps/measure.rs index fcd5d7d92..735a0e16a 100644 --- a/exp/pecos-stab-tn/src/stab_mps/measure.rs +++ b/exp/pecos-stab-tn/src/stab_mps/measure.rs @@ -184,7 +184,7 @@ pub fn pre_reduce_for_measurement_pub(tableau: &mut SparseStabY, mps: &mut Mps, /// Proper long-term fix: lazy virtual-frame tracking — accumulate a /// deferred Clifford V such that effective MPS = V·stored MPS, conjugate /// Pauli strings by V before applying to stored MPS, flush only when MPS -/// must be read directly. See `docs/future_work.md`. +/// must be read directly. fn pre_reduce_for_measurement( tableau: &mut SparseStabY, mps: &mut Mps, From 9815c00ac8689a2b00144cb5850996fc62b215c8 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 20 May 2026 16:31:35 -0600 Subject: [PATCH 29/36] Extract _batched_stabilizers and _normalize_ancilla_budget into pecos.qec.surface._ancilla_batching --- .../pecos/qec/surface/_ancilla_batching.py | 69 +++++++++++++++++++ .../src/pecos/qec/surface/circuit_builder.py | 45 ++++-------- 2 files changed, 81 insertions(+), 33 deletions(-) create mode 100644 python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py diff --git a/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py new file mode 100644 index 000000000..efb295b35 --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py @@ -0,0 +1,69 @@ +"""Shared helpers for ancilla-budget reasoning across surface paths. + +Both the abstract surface-circuit builder +(``pecos.qec.surface.circuit_builder``) and the Guppy emitter +(``pecos.guppy.surface``) need to agree, byte-for-byte, on how +stabilizers are partitioned into ancilla-reuse batches. Otherwise the +abstract reference TickCircuit and the traced Guppy program produce +different measurement orders, the detector record offsets the caller +passes reference the wrong measurements, and the DEM is silently +wrong. + +Keeping the partitioning logic in this single helper -- imported by +both consumers -- is the only source of truth. A unit test pins +identical batch sequences from both call sites. + +The two functions are intentionally pure (no circuit object created) +so neither consumer pulls in the other's dependencies. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pecos.qec.surface.geometry import SurfacePatch + + +def normalize_ancilla_budget(total_ancilla: int, ancilla_budget: int | None) -> int: + """Clamp an ancilla budget to the valid range for a patch. + + ``None`` collapses to the unconstrained ``total_ancilla``. A budget + ``>= total_ancilla`` clamps to ``total_ancilla`` so callers + requesting "no constraint" via either ``None`` or a large + integer resolve to the same effective budget. ``< 1`` is rejected + fail-loud. + """ + if ancilla_budget is None: + return total_ancilla + + if ancilla_budget < 1: + msg = f"ancilla_budget must be >= 1, got {ancilla_budget}" + raise ValueError(msg) + + return min(ancilla_budget, total_ancilla) + + +def batched_stabilizers( + patch: SurfacePatch, + ancilla_budget: int, +) -> list[list[tuple[str, int]]]: + """Partition stabilizers into ancilla-reuse batches. + + Returns a list of batches, each a list of ``(stab_type, stab_idx)`` + pairs where ``stab_type`` is ``"X"`` or ``"Z"`` and ``stab_idx`` is + the patch-internal stabilizer index. Batches are at most + ``ancilla_budget`` stabilizers each; within each batch every + stabilizer is measured concurrently using one ancilla qubit. + + The stabilizer order is **load-bearing** and shared between the + abstract circuit and the Guppy emitter: ascending stabilizer index, + X before Z on ties. Any change here will diverge the abstract DEM + from the traced-Guppy DEM in the Selene parity tests; preserve it. + """ + geom = patch.geometry + stabilizers = [("X", stab.index) for stab in geom.x_stabilizers] + stabilizers.extend(("Z", stab.index) for stab in geom.z_stabilizers) + stabilizers.sort(key=lambda stab: (stab[1], 0 if stab[0] == "X" else 1)) + + return [stabilizers[start : start + ancilla_budget] for start in range(0, len(stabilizers), ancilla_budget)] diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 14ce63767..92a4ae419 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -21,6 +21,18 @@ from enum import Enum, auto from typing import TYPE_CHECKING, TypedDict +# `_batched_stabilizers` and `_normalize_ancilla_budget` are imported from +# the shared `_ancilla_batching` helper so this builder and the Guppy +# emitter (`pecos.guppy.surface`) compute identical batches by +# construction. The local aliases preserve existing call sites; do not +# fork the partitioning logic. +from pecos.qec.surface._ancilla_batching import ( + batched_stabilizers as _batched_stabilizers, +) +from pecos.qec.surface._ancilla_batching import ( + normalize_ancilla_budget as _normalize_ancilla_budget, +) + if TYPE_CHECKING: from pecos.qec.surface.patch import ( LogicalDescriptor, @@ -129,39 +141,6 @@ def total(self) -> int: return len(set(self.data_qubits) | set(self.x_ancilla_qubits) | set(self.z_ancilla_qubits)) -def _normalize_ancilla_budget(total_ancilla: int, ancilla_budget: int | None) -> int: - """Clamp ancilla budget to the valid range for a patch.""" - if ancilla_budget is None: - return total_ancilla - - if ancilla_budget < 1: - msg = f"ancilla_budget must be >= 1, got {ancilla_budget}" - raise ValueError(msg) - - return min(ancilla_budget, total_ancilla) - - -def _batched_stabilizers( - patch: SurfacePatch, - ancilla_budget: int, -) -> list[list[tuple[str, int]]]: - """Partition stabilizers into ancilla-reuse batches. - - This mirrors the public Guppy batching order so the abstract circuit and - its native DEMs match the actual low-ancilla circuit family. - """ - geom = patch.geometry - stabilizers = [("X", stab.index) for stab in geom.x_stabilizers] - stabilizers.extend(("Z", stab.index) for stab in geom.z_stabilizers) - # Sort key is load-bearing: it mirrors Guppy's stabilizer ordering (ascending - # index, X before Z on ties). Batched DEMs are compared against Guppy output - # shot-for-shot in the Selene parity tests, so any change here will diverge - # from the low-ancilla reference family. - stabilizers.sort(key=lambda stab: (stab[1], 0 if stab[0] == "X" else 1)) - - return [stabilizers[start : start + ancilla_budget] for start in range(0, len(stabilizers), ancilla_budget)] - - def build_surface_code_circuit( patch: SurfacePatch, num_rounds: int, From 77d725bcd1c53096eb8e70aa57972fc456999750 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 20 May 2026 16:34:56 -0600 Subject: [PATCH 30/36] Add ancilla_budget kwarg to get_num_qubits with same clamping as normalize_ancilla_budget --- .../quantum-pecos/src/pecos/guppy/surface.py | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 66883176e..39d301423 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -366,18 +366,33 @@ def generate_memory_experiment( return factory(num_rounds) -def get_num_qubits(d: int) -> int: +def get_num_qubits(d: int, *, ancilla_budget: int | None = None) -> int: """Get total number of qubits for a distance-d surface code. - Peak qubit count: d^2 data qubits + (d^2 - 1) ancilla qubits. + Unconstrained (``ancilla_budget=None``): peak qubit count is + ``d^2 data + (d^2 - 1) ancilla = 2 * d^2 - 1``. + + Constrained (``ancilla_budget`` provided): the program reuses + ancilla slots across stabilizer-measurement batches, so only + ``d^2 data + min(ancilla_budget, d^2 - 1) ancilla`` physical + slots are simultaneously live. The clamping is the same as + ``pecos.qec.surface._ancilla_batching.normalize_ancilla_budget`` + so the unconstrained-via-``None`` and unconstrained-via-large-int + cases collapse to the same value. Args: d: Code distance + ancilla_budget: Optional cap on simultaneously live ancillas. + ``None`` (default) returns the peak count. Returns: - Total qubits (2 * d^2 - 1) + Total qubits the traced program will simultaneously use. """ - return 2 * d * d - 1 + from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget + + total_ancilla = d * d - 1 + effective = normalize_ancilla_budget(total_ancilla, ancilla_budget) + return d * d + effective def generate_surface_code_module(d: int) -> str: From e342ef1e694990c63076ecb7bda228f4a55c1ee9 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 20 May 2026 16:50:43 -0600 Subject: [PATCH 31/36] Support ancilla-budgeted surface-code memory in from_guppy via batched Guppy codegen --- .../quantum-pecos/src/pecos/guppy/surface.py | 260 +++++++++++++----- .../src/pecos/qec/surface/decode.py | 39 ++- .../tests/qec/test_from_guppy_dem.py | 128 +++++++++ 3 files changed, 353 insertions(+), 74 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 39d301423..4a13f96fe 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -29,7 +29,7 @@ class _ModuleState: temp_dir: ClassVar[Path | None] = None module_cache: ClassVar[dict[str, object]] = {} - distance_module_cache: ClassVar[dict[int, dict]] = {} + distance_module_cache: ClassVar[dict[tuple[int, int], dict]] = {} _state = _ModuleState() @@ -42,22 +42,51 @@ def _get_temp_dir() -> Path: return _state.temp_dir -def generate_guppy_source(patch: "SurfacePatch") -> str: +def generate_guppy_source( + patch: "SurfacePatch", + *, + ancilla_budget: int | None = None, +) -> str: """Generate Guppy source code for a surface code patch. - Uses a 4-round parallel CNOT schedule with dedicated per-stabilizer - ancillas for syndrome extraction. + Uses a 4-round parallel CNOT schedule for syndrome extraction. + + ``ancilla_budget=None`` (default) emits the unconstrained shape: + one ancilla per stabilizer, all measured in parallel at the end of + one round. This matches the abstract circuit's unconstrained-path + measurement order (X stabilizers first by index, then Z). + + A finite ``ancilla_budget`` emits a stabilizer-batched syndrome- + extraction routine that mirrors the abstract circuit's + ``_batched_stabilizers`` schedule (shared helper at + ``pecos.qec.surface._ancilla_batching``): per batch, allocate + ``min(ancilla_budget, total_ancilla)`` fresh ancillas, run the + 4-round CX schedule restricted to that batch's stabilizers, + measure, then move to the next batch (which allocates fresh + qubits whose physical slots are reused by Selene's lowering). + The same per-stabilizer ``result("...:meas:N", …)`` calls fire + in the abstract's batched measurement order, keeping + detector record offsets transferable between abstract and traced + paths. Args: - patch: SurfacePatch with geometry configuration + patch: SurfacePatch with geometry configuration. + ancilla_budget: Optional cap on simultaneously live ancillas. + ``None`` or a value ``>= total_ancilla`` emits the + unconstrained shape; ``< total_ancilla`` emits batched. Returns: - Python/Guppy source code as a string + Python/Guppy source code as a string. """ + from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget + geom = patch.geometry num_data = geom.num_data num_x_stab = len(geom.x_stabilizers) num_z_stab = len(geom.z_stabilizers) + total_ancilla = num_x_stab + num_z_stab + effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) + constrained = effective_budget < total_ancilla dx, dz = geom.dx, geom.dz lines = [ @@ -123,7 +152,7 @@ def generate_guppy_source(patch: "SurfacePatch") -> str: ], ) - # Generate syndrome extraction with parallel CNOT schedule + # Generate syndrome extraction with parallel CNOT schedule. rounds = compute_cnot_schedule(patch) lines.extend( @@ -132,52 +161,115 @@ def generate_guppy_source(patch: "SurfacePatch") -> str: "", "@guppy", f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz}) -> Syndrome_{dx}x{dz}:", - ' """Extract full syndrome using 4-round parallel CNOT schedule."""', - " # Allocate ancilla qubits (one per stabilizer)", ], ) - lines.extend(f" ax{stab.index} = qubit()" for stab in geom.x_stabilizers) - lines.extend(f" az{stab.index} = qubit()" for stab in geom.z_stabilizers) + if not constrained: + # Unconstrained: one ancilla per stabilizer, X-stabs first then + # Z-stabs, measured in parallel at the end. Matches the + # abstract circuit's unconstrained-path measurement order. + lines.extend( + [ + ' """Extract full syndrome using 4-round parallel CNOT schedule."""', + " # Allocate ancilla qubits (one per stabilizer)", + ], + ) + + lines.extend(f" ax{stab.index} = qubit()" for stab in geom.x_stabilizers) + lines.extend(f" az{stab.index} = qubit()" for stab in geom.z_stabilizers) + + lines.append("") + lines.append(" # Hadamard on X ancillas") + lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + + for rnd_idx, rnd_gates in enumerate(rounds): + lines.append("") + lines.append(f" # Round {rnd_idx + 1}") + for stab_type, stab_idx, data_q in rnd_gates: + if stab_type == "X": + lines.append(f" cx(ax{stab_idx}, surf.data[{data_q}])") + else: + lines.append(f" cx(surf.data[{data_q}], az{stab_idx})") - lines.append("") - lines.append(" # Hadamard on X ancillas") - lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + lines.append("") + lines.append(" # Hadamard on X ancillas") + lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) - # Emit 4 rounds of CX gates - for rnd_idx, rnd_gates in enumerate(rounds): lines.append("") - lines.append(f" # Round {rnd_idx + 1}") - for stab_type, stab_idx, data_q in rnd_gates: - if stab_type == "X": - lines.append(f" cx(ax{stab_idx}, surf.data[{data_q}])") - else: - lines.append(f" cx(surf.data[{data_q}], az{stab_idx})") - - lines.append("") - lines.append(" # Hadamard on X ancillas") - lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) - - # Measure ancillas (destructive) - # Each measurement gets a per-measurement result() call that ties the - # physical measurement to a MeasId. The result() names encode the - # stabilizer type and index. The AllocateResult IDs generated by - # these calls flow through the trace and become MeasIds on the TickCircuit. - lines.append("") - # Measure ancillas with per-measurement result() identity. - # Tag format: "label:idx" where label is the stabilizer name and idx is the - # round-local measurement index. The global MeasId is assigned by the runtime - # via AllocateResult and flows through the trace automatically. - lines.append(" # Measure ancillas") - idx = 0 - for stab in geom.x_stabilizers: - lines.append(f" sx{stab.index} = measure(ax{stab.index})") - lines.append(f' result("sx{stab.index}:meas:{idx}", sx{stab.index})') - idx += 1 - for stab in geom.z_stabilizers: - lines.append(f" sz{stab.index} = measure(az{stab.index})") - lines.append(f' result("sz{stab.index}:meas:{idx}", sz{stab.index})') - idx += 1 + lines.append(" # Measure ancillas") + idx = 0 + for stab in geom.x_stabilizers: + lines.append(f" sx{stab.index} = measure(ax{stab.index})") + lines.append(f' result("sx{stab.index}:meas:{idx}", sx{stab.index})') + idx += 1 + for stab in geom.z_stabilizers: + lines.append(f" sz{stab.index} = measure(az{stab.index})") + lines.append(f' result("sz{stab.index}:meas:{idx}", sz{stab.index})') + idx += 1 + else: + # Constrained: stabilizer-batched. The batch sequence is the + # shared `batched_stabilizers(patch, effective_budget)` so the + # abstract circuit's measurement order matches by construction. + batches = batched_stabilizers(patch, effective_budget) + lines.append( + f' """Extract full syndrome in {len(batches)} ancilla-reuse batches (budget={effective_budget})."""', + ) + idx = 0 + for batch_idx, batch in enumerate(batches): + lines.append("") + lines.append(f" # Batch {batch_idx + 1}/{len(batches)} of stabilizers") + + # Per-batch ancilla variable names: _a_b{batch}_p{pos}. Each + # `qubit()` call here allocates a fresh logical qubit that + # Selene's lowering reuses the physical slot freed by the + # previous batch's `measure()` calls (empirically verified + # in the spike). + batch_anc_var: dict[tuple[str, int], str] = {} + for pos, (stab_type, stab_idx) in enumerate(batch): + var = f"_a_b{batch_idx}_p{pos}" + batch_anc_var[(stab_type, stab_idx)] = var + lines.append(f" {var} = qubit()") + + x_in_batch = [(t, i) for (t, i) in batch if t == "X"] + if x_in_batch: + lines.append(" # Hadamard on X ancillas in this batch") + for stab_type, stab_idx in x_in_batch: + lines.append(f" h({batch_anc_var[(stab_type, stab_idx)]})") + + # Filter the full CX schedule to just this batch's stabilizers. + batch_keys = set(batch_anc_var.keys()) + for rnd_idx, rnd_gates in enumerate(rounds): + rnd_in_batch = [ + (stab_type, stab_idx, data_q) + for stab_type, stab_idx, data_q in rnd_gates + if (stab_type, stab_idx) in batch_keys + ] + if not rnd_in_batch: + continue + lines.append("") + lines.append(f" # Batch {batch_idx + 1} round {rnd_idx + 1}") + for stab_type, stab_idx, data_q in rnd_in_batch: + anc = batch_anc_var[(stab_type, stab_idx)] + if stab_type == "X": + lines.append(f" cx({anc}, surf.data[{data_q}])") + else: + lines.append(f" cx(surf.data[{data_q}], {anc})") + + if x_in_batch: + lines.append("") + lines.append(" # Hadamard on X ancillas in this batch") + for stab_type, stab_idx in x_in_batch: + lines.append(f" h({batch_anc_var[(stab_type, stab_idx)]})") + + lines.append("") + lines.append(f" # Measure batch {batch_idx + 1} ancillas") + for stab_type, stab_idx in batch: + anc = batch_anc_var[(stab_type, stab_idx)] + syn_var = f"sx{stab_idx}" if stab_type == "X" else f"sz{stab_idx}" + tag_prefix = syn_var + lines.append(f" {syn_var} = measure({anc})") + lines.append(f' result("{tag_prefix}:meas:{idx}", {syn_var})') + idx += 1 x_calls = ", ".join(f"sx{s.index}" for s in geom.x_stabilizers) z_calls = ", ".join(f"sz{s.index}" for s in geom.z_stabilizers) @@ -301,24 +393,39 @@ def generate_guppy_source(patch: "SurfacePatch") -> str: return "\n".join(lines) -def _load_guppy_module(patch: "SurfacePatch") -> dict: +def _load_guppy_module( + patch: "SurfacePatch", + *, + ancilla_budget: int | None = None, +) -> dict: """Load a Guppy module for a patch, using caching. + The cache key is widened by the **effective** budget (after + clamping via ``normalize_ancilla_budget``), so ``ancilla_budget=None`` + and ``ancilla_budget >= total_ancilla`` resolve to the same cache + entry and don't produce two equivalent generated modules. + Args: patch: SurfacePatch with geometry + ancilla_budget: Optional cap on simultaneously live ancillas Returns: Module dictionary with generated functions """ - cache_key = f"{patch.dx}x{patch.dz}" + from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget + + geom = patch.geometry + total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) + effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) + cache_key = f"{patch.dx}x{patch.dz}_b{effective_budget}" if cache_key in _state.module_cache: return _state.module_cache[cache_key] - # Generate source - source = generate_guppy_source(patch) + # Generate source for this (patch, effective_budget) combination. + source = generate_guppy_source(patch, ancilla_budget=ancilla_budget) - # Write to temp file (required for Guppy introspection) + # Write to temp file (required for Guppy introspection). temp_dir = _get_temp_dir() temp_file = temp_dir / f"patch_{cache_key}.py" temp_file.write_text(source) @@ -342,6 +449,8 @@ def generate_memory_experiment( patch: "SurfacePatch", num_rounds: int, basis: str, + *, + ancilla_budget: int | None = None, ) -> object: """Generate a memory experiment for a patch. @@ -349,11 +458,12 @@ def generate_memory_experiment( patch: SurfacePatch configuration num_rounds: Number of syndrome rounds basis: 'Z' or 'X' + ancilla_budget: Optional cap on simultaneously live ancillas Returns: Guppy function for the experiment """ - module = _load_guppy_module(patch) + module = _load_guppy_module(patch, ancilla_budget=ancilla_budget) if basis.upper() == "Z": factory = module["make_memory_z"] @@ -395,11 +505,13 @@ def get_num_qubits(d: int, *, ancilla_budget: int | None = None) -> int: return d * d + effective -def generate_surface_code_module(d: int) -> str: +def generate_surface_code_module(d: int, *, ancilla_budget: int | None = None) -> str: """Generate source code for a distance-d surface code module. Args: d: Code distance (must be odd >= 3) + ancilla_budget: Optional cap on simultaneously live ancillas; + forwarded to ``generate_guppy_source``. Returns: Python/Guppy source code as a string @@ -411,42 +523,64 @@ def generate_surface_code_module(d: int) -> str: from pecos.qec.surface import SurfacePatch patch = SurfacePatch.create(distance=d) - return generate_guppy_source(patch) + return generate_guppy_source(patch, ancilla_budget=ancilla_budget) -def get_surface_code_module(d: int) -> dict: +def get_surface_code_module(d: int, *, ancilla_budget: int | None = None) -> dict: """Get a loaded surface code module for distance d. + Cache key is widened to ``(d, effective_budget)`` so the + unconstrained-via-``None`` and unconstrained-via-large-int cases + collapse to one cached module. + Args: d: Code distance + ancilla_budget: Optional cap on simultaneously live ancillas Returns: Dictionary with module contents and metadata """ - if d in _state.distance_module_cache: - return _state.distance_module_cache[d] - from pecos.qec.surface import SurfacePatch + from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget + + total_ancilla = d * d - 1 + effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) + cache_key = (d, effective_budget) + + if cache_key in _state.distance_module_cache: + return _state.distance_module_cache[cache_key] patch = SurfacePatch.create(distance=d) - module = _load_guppy_module(patch) + module = _load_guppy_module(patch, ancilla_budget=ancilla_budget) # Add metadata module["distance"] = d module["num_data"] = d * d module["num_stab"] = (d * d - 1) // 2 + module["ancilla_budget"] = effective_budget - _state.distance_module_cache[d] = module + _state.distance_module_cache[cache_key] = module return module -def make_surface_code(distance: int, num_rounds: int, basis: str) -> object: +def make_surface_code( + distance: int, + num_rounds: int, + basis: str, + *, + ancilla_budget: int | None = None, +) -> object: """Create a surface code memory experiment. Args: distance: Code distance (must be odd >= 3) num_rounds: Number of syndrome extraction rounds basis: 'Z' or 'X' + ancilla_budget: Optional cap on simultaneously live ancillas. + ``None`` (default) emits the unconstrained Guppy program; + a finite budget emits a stabilizer-batched program that + matches the abstract circuit's + ``batched_stabilizers(patch, effective_budget)`` schedule. Returns: Compiled Guppy program @@ -455,7 +589,7 @@ def make_surface_code(distance: int, num_rounds: int, basis: str) -> object: msg = f"basis must be 'Z' or 'X', got {basis!r}" raise ValueError(msg) - module = get_surface_code_module(distance) + module = get_surface_code_module(distance, ancilla_budget=ancilla_budget) factory = module["make_memory_z"] if basis.upper() == "Z" else module["make_memory_x"] diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index e9f9c4cb6..268d0dabf 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -399,6 +399,7 @@ def _copy_surface_tick_circuit_metadata(source_tc: Any, target_tc: Any) -> None: "num_detectors", "detector_descriptors", "observable_descriptors", + "ancilla_budget", ): value = source_tc.get_meta(key) if value is not None: @@ -734,12 +735,30 @@ def _generate_traced_surface_tick_circuit( patch: SurfacePatch, num_rounds: int, basis: str, + *, + ancilla_budget: int | None = None, ) -> Any: - """Trace the lowered ideal Selene/QIS op stream and replay it into a TickCircuit.""" + """Trace the lowered ideal Selene/QIS op stream and replay it into a TickCircuit. + + With ``ancilla_budget=None``, emits the unconstrained Guppy program + (one ancilla per stabilizer, all measured at the end of one round). + With a finite budget, emits the stabilizer-batched program; Selene's + lowering reuses ancilla slots across batches so the traced TickCircuit + uses only ``d^2 + min(budget, d^2-1)`` physical qubits simultaneously. + """ from pecos.guppy import get_num_qubits, make_surface_code - program = make_surface_code(distance=patch.distance, num_rounds=num_rounds, basis=basis) - return trace_guppy_into_tick_circuit(program, get_num_qubits(patch.distance), seed=0) + program = make_surface_code( + distance=patch.distance, + num_rounds=num_rounds, + basis=basis, + ancilla_budget=ancilla_budget, + ) + return trace_guppy_into_tick_circuit( + program, + get_num_qubits(patch.distance, ancilla_budget=ancilla_budget), + seed=0, + ) def _build_surface_tick_circuit_for_native_model( @@ -770,14 +789,12 @@ def _build_surface_tick_circuit_for_native_model( msg = f"Unknown circuit_source {circuit_source!r}" raise ValueError(msg) - if ancilla_budget is not None: - msg = ( - "circuit_source='traced_qis' does not currently support ancilla_budget because " - "pecos.guppy.surface.make_surface_code does not yet expose ancilla budgeting" - ) - raise ValueError(msg) - - traced_tc = _generate_traced_surface_tick_circuit(patch, num_rounds, basis) + traced_tc = _generate_traced_surface_tick_circuit( + patch, + num_rounds, + basis, + ancilla_budget=ancilla_budget, + ) traced_measurement_order = _extract_measurement_order(traced_tc) abstract_measurement_order = _extract_measurement_order(abstract_tc) if traced_measurement_order != abstract_measurement_order: diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index c0220f32e..e821855a4 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -233,3 +233,131 @@ def test_from_guppy_redundant_records_and_meas_ids_are_accepted() -> None: since stamped MeasId values are not predictable from Python here.)""" both = _dem_text(detectors_json='[{"id":0,"records":[-1],"meas_ids":[0]}]') assert both == _dem_text(detectors_json='[{"id":0,"records":[-1]}]') + + +# --------------------------------------------------------------------------- +# Constrained-ancilla surface support +# --------------------------------------------------------------------------- + + +def _constrained_surface_via_guppy(*, d, basis, rounds, budget, noise): + """Build the constrained-surface DEM through `from_guppy`.""" + patch = SurfacePatch.create(distance=d) + ref = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=rounds, + basis=basis, + ancilla_budget=budget, + circuit_source="traced_qis", + ) + ref.lower_clifford_rotations() + ref.assign_missing_meas_ids() + ref_dem = DetectorErrorModel.from_circuit(ref, **noise).to_string() + + got = DetectorErrorModel.from_guppy( + make_surface_code(distance=d, num_rounds=rounds, basis=basis, ancilla_budget=budget), + num_qubits=get_num_qubits(d, ancilla_budget=budget), + detectors_json=ref.get_meta("detectors"), + observables_json=ref.get_meta("observables"), + num_measurements=int(ref.get_meta("num_measurements")), + **noise, + ).to_string() + return ref_dem, got, ref + + +def test_from_guppy_constrained_surface_dem_byte_identical() -> None: + """`from_guppy(make_surface_code(..., ancilla_budget=b))` must produce a + DEM byte-identical to the reference DEM built through the + `_build_surface_tick_circuit_for_native_model(circuit_source="traced_qis", + ancilla_budget=b)` path. Covers (d=3, budget=1, Z), (d=3, budget=2, X), and + (d=9, budget=17, Z) -- small-and-fast + asymmetric basis + canonical + high-distance stress.""" + noise = {"p1": 0.005, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} + for d, basis, rounds, budget in [(3, "Z", 2, 1), (3, "X", 2, 2), (9, "Z", 3, 17)]: + ref_dem, got, _ = _constrained_surface_via_guppy( + d=d, + basis=basis, + rounds=rounds, + budget=budget, + noise=noise, + ) + assert got == ref_dem, ( + f"constrained surface from_guppy not byte-identical for " + f"d={d}, budget={budget}, basis={basis}, rounds={rounds}" + ) + + +def test_constrained_surface_traced_metadata_matches_abstract() -> None: + """The traced TickCircuit's surface metadata is copied verbatim from the + abstract reference. Specifically pins that + ``_copy_surface_tick_circuit_metadata`` propagates ``ancilla_budget`` + (the new key added when the constrained codegen landed) alongside the + existing detectors/observables/counts.""" + patch = SurfacePatch.create(distance=3) + abstract_tc = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=2, + basis="Z", + ancilla_budget=2, + circuit_source="abstract", + ) + traced_tc = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=2, + basis="Z", + ancilla_budget=2, + circuit_source="traced_qis", + ) + for key in ( + "basis", + "detectors", + "observables", + "num_measurements", + "num_detectors", + "ancilla_budget", + ): + a = abstract_tc.get_meta(key) + b = traced_tc.get_meta(key) + assert a == b, f"metadata mismatch on key {key!r}: abstract={a!r}, traced={b!r}" + # ancilla_budget specifically must be the requested budget (stored as a string by set_meta). + assert traced_tc.get_meta("ancilla_budget") == "2" + + +def test_constrained_surface_lowered_qubit_stream_within_budget() -> None: + """The lowered-trace physical qubit IDs must stay within the budgeted + pool, and ancilla slots must be empirically reused (more measurements + than physical ancilla qubits). Pins the load-bearing assumption the + spike validated.""" + import pecos + + d, budget = 3, 2 + program = make_surface_code(distance=d, num_rounds=2, basis="Z", ancilla_budget=budget) + n_q = get_num_qubits(d, ancilla_budget=budget) + chunks = list( + pecos.sim(program) + .classical(pecos.selene_engine()) + .quantum(pecos.stabilizer()) + .qubits(n_q) + .seed(0) + .capture_operation_trace(), + ) + + all_qubits: set[int] = set() + mz_qubits: list[int] = [] + for chunk in chunks: + for gate in chunk.get("lowered_quantum_ops") or []: + qs = [int(q) for q in gate.get("qubits", [])] + all_qubits.update(qs) + if str(gate.get("gate_type")) == "MZ": + mz_qubits.extend(qs) + + max_q = max(all_qubits) if all_qubits else -1 + # Budget enforcement: total physical qubits used must fit in d^2 + budget. + assert max_q < n_q, ( + f"max physical qubit id {max_q} exceeds budgeted pool size {n_q}; " + "Selene's lowering did not reuse ancilla slots as expected" + ) + # Reuse demonstrated: some physical qubit appears in multiple MZ ops. + assert any( + mz_qubits.count(q) > 1 for q in set(mz_qubits) + ), "no physical qubit appears in more than one MZ op; expected ancilla-slot reuse across batches" From 9b50098847dd043d1e6aee42a9390a95535525a7 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 20 May 2026 17:53:09 -0600 Subject: [PATCH 32/36] Address round-4 review: fix ruff/black format conflict, add concrete batch-order pin tests, type-validate ancilla_budget kwarg, add PECOS-native decoder smoke --- .../pecos/qec/surface/_ancilla_batching.py | 20 ++- .../qec/surface/test_ancilla_batching.py | 130 ++++++++++++++++++ .../tests/qec/test_from_guppy_dem.py | 61 +++++++- 3 files changed, 200 insertions(+), 11 deletions(-) create mode 100644 python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py diff --git a/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py index efb295b35..45c64db11 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py @@ -11,7 +11,11 @@ Keeping the partitioning logic in this single helper -- imported by both consumers -- is the only source of truth. A unit test pins -identical batch sequences from both call sites. +concrete expected batch sequences for small ``(distance, budget)`` +combinations (see +``tests/qec/surface/test_ancilla_batching.py``) so a regression in +the partitioning policy itself fails fast, independent of any DEM- +level oracle. The two functions are intentionally pure (no circuit object created) so neither consumer pulls in the other's dependencies. @@ -30,13 +34,21 @@ def normalize_ancilla_budget(total_ancilla: int, ancilla_budget: int | None) -> ``None`` collapses to the unconstrained ``total_ancilla``. A budget ``>= total_ancilla`` clamps to ``total_ancilla`` so callers - requesting "no constraint" via either ``None`` or a large - integer resolve to the same effective budget. ``< 1`` is rejected - fail-loud. + requesting "no constraint" via either ``None`` or a large integer + resolve to the same effective budget. ``< 1`` is rejected fail-loud. + + Non-``int`` (including ``bool``, ``float``) is rejected fail-loud + so the public ``ancilla_budget`` kwarg has a strict integer + contract -- avoiding silently-wrong cache keys or qubit counts. """ if ancilla_budget is None: return total_ancilla + # Reject bool first (bool is a subclass of int in Python). + if isinstance(ancilla_budget, bool) or not isinstance(ancilla_budget, int): + msg = f"ancilla_budget must be int or None, got {type(ancilla_budget).__name__}" + raise TypeError(msg) + if ancilla_budget < 1: msg = f"ancilla_budget must be >= 1, got {ancilla_budget}" raise ValueError(msg) diff --git a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py new file mode 100644 index 000000000..ca2554c73 --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py @@ -0,0 +1,130 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Tests for the shared ancilla-batching helper. + +This is the single source of truth for stabilizer-batch ordering used +by both the abstract surface-circuit builder +(``pecos.qec.surface.circuit_builder``) and the Guppy emitter +(``pecos.guppy.surface``). The byte-identical traced-vs-traced surface +DEM oracle in ``tests/qec/test_from_guppy_dem.py`` exercises this +helper indirectly, but a regression in the partitioning *policy* +itself (e.g. someone changes the sort key) could pass that oracle +spuriously because both sides share the same shared helper. Concrete +expected-output pins below catch that case directly. +""" + +from __future__ import annotations + +import pytest +from pecos.qec.surface import SurfacePatch +from pecos.qec.surface._ancilla_batching import ( + batched_stabilizers, + normalize_ancilla_budget, +) + + +# --- normalize_ancilla_budget ----------------------------------------------- + + +@pytest.mark.parametrize( + ("total", "budget", "expected"), + [ + (8, None, 8), # None means "no constraint" + (8, 8, 8), # exact match + (8, 9, 8), # >= total collapses to total + (8, 999, 8), # large budget collapses to total + (8, 1, 1), # minimum valid + (8, 4, 4), # interior + ], +) +def test_normalize_ancilla_budget_clamps(total: int, budget: int | None, expected: int) -> None: + assert normalize_ancilla_budget(total, budget) == expected + + +def test_normalize_ancilla_budget_rejects_zero_and_negative() -> None: + with pytest.raises(ValueError, match=r"must be >= 1"): + normalize_ancilla_budget(8, 0) + with pytest.raises(ValueError, match=r"must be >= 1"): + normalize_ancilla_budget(8, -1) + + +def test_normalize_ancilla_budget_rejects_non_int() -> None: + """Public ``ancilla_budget`` kwarg has a strict ``int | None`` contract. + + bool is a Python subclass of int but a separate semantic type; rejecting + it explicitly avoids ``True``-as-``1`` silently working, which would mask + caller-side bugs.""" + with pytest.raises(TypeError, match=r"must be int or None, got bool"): + normalize_ancilla_budget(8, True) + with pytest.raises(TypeError, match=r"must be int or None, got float"): + normalize_ancilla_budget(8, 1.5) + with pytest.raises(TypeError, match=r"must be int or None, got str"): + normalize_ancilla_budget(8, "1") + + +# --- batched_stabilizers (concrete sequences) ------------------------------- + + +def test_batched_stabilizers_d3_budget1_one_stabilizer_per_batch() -> None: + """Budget=1 produces one stabilizer per batch, alternating X/Z by + ascending index per the shared sort key. Pinning this concrete order + catches "shared batching policy regressed" independent of any DEM- + level oracle.""" + patch = SurfacePatch.create(distance=3) + batches = batched_stabilizers(patch, 1) + assert batches == [ + [("X", 0)], + [("Z", 0)], + [("X", 1)], + [("Z", 1)], + [("X", 2)], + [("Z", 2)], + [("X", 3)], + [("Z", 3)], + ] + + +def test_batched_stabilizers_d3_budget2_pairs_xz_by_index() -> None: + """Budget=2 pairs (X_k, Z_k) per batch for ascending k.""" + patch = SurfacePatch.create(distance=3) + batches = batched_stabilizers(patch, 2) + assert batches == [ + [("X", 0), ("Z", 0)], + [("X", 1), ("Z", 1)], + [("X", 2), ("Z", 2)], + [("X", 3), ("Z", 3)], + ] + + +def test_batched_stabilizers_full_budget_one_batch() -> None: + """Budget == total_ancilla collapses to a single batch containing + every stabilizer in the canonical sort order.""" + patch = SurfacePatch.create(distance=3) + total = len(patch.geometry.x_stabilizers) + len(patch.geometry.z_stabilizers) + batches = batched_stabilizers(patch, total) + assert len(batches) == 1 + assert batches[0] == [ + ("X", 0), + ("Z", 0), + ("X", 1), + ("Z", 1), + ("X", 2), + ("Z", 2), + ("X", 3), + ("Z", 3), + ] + + +def test_batched_stabilizers_distance_5_budget_3_covers_all_stabilizers() -> None: + """For a slightly bigger patch, every stabilizer appears exactly once + across the returned batches, with batch sizes ``<= budget``.""" + patch = SurfacePatch.create(distance=5) + total = len(patch.geometry.x_stabilizers) + len(patch.geometry.z_stabilizers) + batches = batched_stabilizers(patch, 3) + + assert all(len(batch) <= 3 for batch in batches) + + flat = [pair for batch in batches for pair in batch] + assert len(flat) == total + assert len(set(flat)) == total # no duplicates diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index e821855a4..966136acc 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -353,11 +353,58 @@ def test_constrained_surface_lowered_qubit_stream_within_budget() -> None: max_q = max(all_qubits) if all_qubits else -1 # Budget enforcement: total physical qubits used must fit in d^2 + budget. - assert max_q < n_q, ( - f"max physical qubit id {max_q} exceeds budgeted pool size {n_q}; " - "Selene's lowering did not reuse ancilla slots as expected" - ) + over_budget_msg = f"max physical qubit id {max_q} exceeds budgeted pool size {n_q}" + assert max_q < n_q, over_budget_msg # Reuse demonstrated: some physical qubit appears in multiple MZ ops. - assert any( - mz_qubits.count(q) > 1 for q in set(mz_qubits) - ), "no physical qubit appears in more than one MZ op; expected ancilla-slot reuse across batches" + reuse = any(mz_qubits.count(q) > 1 for q in set(mz_qubits)) + assert reuse, "no physical qubit appears in more than one MZ op" + + +def test_constrained_from_guppy_dem_is_consumable_by_pecos_native_decoder() -> None: + """PECOS-native decoder smoke for the constrained-ancilla DEM: the DEM + returned by ``from_guppy(...)`` must be consumable by both the PECOS + sampler (``dem.to_sampler()``) and the PECOS Rust-backed + ``PyMatchingDecoder.from_dem(...)`` -- the actual downstream surfaces + callers use, not an external ``pymatching`` install. + + Also asserts ``stim.DetectorErrorModel(dem.to_string_decomposed())`` + parses as a lightweight syntax-compatibility smoke (optional reference, + not the correctness oracle). + """ + from pecos_rslib.decoders import PyMatchingDecoder + + p = {"p1": 0.005, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} + patch = SurfacePatch.create(distance=3) + abstract_tc = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=2, + basis="Z", + ancilla_budget=2, + circuit_source="abstract", + ) + dem = DetectorErrorModel.from_guppy( + make_surface_code(distance=3, num_rounds=2, basis="Z", ancilla_budget=2), + num_qubits=get_num_qubits(3, ancilla_budget=2), + detectors_json=abstract_tc.get_meta("detectors"), + observables_json=abstract_tc.get_meta("observables"), + num_measurements=int(abstract_tc.get_meta("num_measurements")), + **p, + ) + + # PECOS-native sampler path: DEM is well-formed for sampling. + sampler = dem.to_sampler() + assert sampler.num_dem_outputs >= 0 + assert sampler.num_observables >= 0 + + # PECOS-native Rust-backed matching decoder: DEM is consumable by + # the actual downstream decoder surface. + decomp = dem.to_string_decomposed() + decoder = PyMatchingDecoder.from_dem(decomp) + assert decoder is not None + + # Lightweight format-compatibility smoke (optional reference coverage, + # not the correctness oracle). Stim should parse the decomposed DEM. + import stim + + parsed = stim.DetectorErrorModel(decomp) + assert parsed.num_detectors >= 0 From 9abf4a5a009aeb7dcd24e504c404d573589b686e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 20 May 2026 18:08:16 -0600 Subject: [PATCH 33/36] Add constrained-surface mismatched-num_measurements fail-loud test through the generic Rust validation path --- .../qec/surface/test_ancilla_batching.py | 1 - .../tests/qec/test_from_guppy_dem.py | 30 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py index ca2554c73..7a8be16ae 100644 --- a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py +++ b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py @@ -23,7 +23,6 @@ normalize_ancilla_budget, ) - # --- normalize_ancilla_budget ----------------------------------------------- diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index 966136acc..932dda9c0 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -408,3 +408,33 @@ def test_constrained_from_guppy_dem_is_consumable_by_pecos_native_decoder() -> N parsed = stim.DetectorErrorModel(decomp) assert parsed.num_detectors >= 0 + + +def test_constrained_from_guppy_fails_loud_on_mismatched_num_measurements() -> None: + """The constrained-ancilla surface program must flow through the same + Rust metadata-validation fail-loud path as any other Guppy program. + No surface-specific bypass: passing a wrong ``num_measurements`` (here, + the unconstrained count, which differs from the constrained traced + program's count) is rejected by the generic builder, not by anything + surface-aware in ``from_guppy``.""" + p = {"p1": 0.005, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} + patch = SurfacePatch.create(distance=3) + abstract_tc = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=2, + basis="Z", + ancilla_budget=2, + circuit_source="abstract", + ) + actual = int(abstract_tc.get_meta("num_measurements")) + wrong = actual + 1 + + with pytest.raises(ValueError, match=r"num_measurements"): + DetectorErrorModel.from_guppy( + make_surface_code(distance=3, num_rounds=2, basis="Z", ancilla_budget=2), + num_qubits=get_num_qubits(3, ancilla_budget=2), + detectors_json=abstract_tc.get_meta("detectors"), + observables_json=abstract_tc.get_meta("observables"), + num_measurements=wrong, + **p, + ) From 3206712115e40c116c0bbf348e1c0b5832e9c053 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 20 May 2026 19:49:03 -0600 Subject: [PATCH 34/36] Harden constrained-ancilla from_guppy path: fail-loud trace contract, constrained topology caching, honest measurement-order check, and regression tests --- .../pecos/qec/surface/_ancilla_batching.py | 15 +- .../src/pecos/qec/surface/decode.py | 310 ++++++++---------- .../qec/surface/test_ancilla_batching.py | 127 +++++++ .../tests/qec/surface/test_surface_decoder.py | 80 +++++ .../tests/qec/test_from_guppy_dem.py | 221 +++++++++++-- 5 files changed, 557 insertions(+), 196 deletions(-) diff --git a/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py index 45c64db11..b375bd389 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py @@ -72,10 +72,23 @@ def batched_stabilizers( abstract circuit and the Guppy emitter: ascending stabilizer index, X before Z on ties. Any change here will diverge the abstract DEM from the traced-Guppy DEM in the Selene parity tests; preserve it. + + ``ancilla_budget`` is validated through + :func:`normalize_ancilla_budget` (rejects ``None``, ``bool``, + ``float``, ``str``, ``< 1``; clamps ``>= total_ancilla``) so direct + callers of this helper get the same fail-loud guarantees as the + public ``ancilla_budget`` API surface, not an opaque ``range()`` or + silent-empty failure. """ geom = patch.geometry + total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) + effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) + stabilizers = [("X", stab.index) for stab in geom.x_stabilizers] stabilizers.extend(("Z", stab.index) for stab in geom.z_stabilizers) stabilizers.sort(key=lambda stab: (stab[1], 0 if stab[0] == "X" else 1)) - return [stabilizers[start : start + ancilla_budget] for start in range(0, len(stabilizers), ancilla_budget)] + return [ + stabilizers[start : start + effective_budget] + for start in range(0, len(stabilizers), effective_budget) + ] diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 268d0dabf..b95e384ba 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -689,6 +689,55 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> return tick_circuit +def _chunk_has_lowerable_op(chunk: dict[str, Any]) -> bool: + """True if a chunk carries an operation that lowers to a TickCircuit gate. + + A raw ``Quantum`` op (gate / measure / reset) lowers to a gate, and an + ``AllocateQubit`` lowers to a prep (``PZ``) -- both appear in + ``lowered_quantum_ops`` after Selene lowering, and both are emitted as + gates by the raw replay (see :func:`_replay_qis_trace_into_tick_circuit`). + ``AllocateResult``, ``RecordOutput``, ``Barrier``, and ``ReleaseQubit`` + emit no gate and are pass-through bookkeeping, so a chunk containing only + those legitimately has no lowered ops. + """ + return any( + isinstance(op, dict) and ("Quantum" in op or "AllocateQubit" in op) + for op in (chunk.get("operations") or []) + ) + + +def _reject_partially_lowered_trace(chunks: list[dict[str, Any]]) -> None: + """Fail loud on a mixed/partially-lowered trace. + + The lowered replay consumes a chunk's gates from ``lowered_quantum_ops`` + only (it reads ``operations`` solely for measurement result ids). So once + *any* chunk is lowered, a chunk that carries a lowerable operation (a raw + ``Quantum`` gate/measure/reset, or an ``AllocateQubit`` prep) but an empty + ``lowered_quantum_ops`` would have those gates silently dropped -- the + resulting TickCircuit would be missing operations with no error. A dropped + *measurement* is already caught downstream by the meas-count guard in + :func:`_replay_lowered_qis_trace_into_tick_circuit`, but a dropped prep or + non-measurement gate (H, CX, ...) would pass silently. Reject the + incomplete trace here instead of building from a partial gate stream. + + This is the explicit trace-format contract for live + ``capture_operation_trace()`` output: lowered and raw forms must not be + mixed across chunks. (Per-chunk completeness of lowering is assumed and is + exercised end-to-end by the byte-identical surface DEM regressions.) + """ + for idx, chunk in enumerate(chunks): + if _chunk_has_lowerable_op(chunk) and not chunk.get("lowered_quantum_ops"): + msg = ( + f"Traced chunk {idx} carries lowerable operations (a quantum " + "gate/measure/reset or an AllocateQubit prep) but no " + "lowered_quantum_ops while other chunks are lowered. This " + "mixed/partially-lowered trace would silently drop the chunk's " + "gates in the lowered replay; refusing to build from an " + "incomplete gate stream." + ) + raise ValueError(msg) + + def trace_guppy_into_tick_circuit(program: Any, num_qubits: int, *, seed: int = 0) -> Any: """Trace a Guppy/QIS program's lowered Selene op stream into a ``TickCircuit``. @@ -722,9 +771,16 @@ def trace_guppy_into_tick_circuit(program: Any, num_qubits: int, *, seed: int = ) chunks = list(sim_builder.capture_operation_trace()) + # Selene lowers QIS gates into per-chunk `lowered_quantum_ops` (the gate + # shape actually executed; e.g. cx -> RZZ + rotations). When any chunk is + # lowered we replay from those, but first reject a mixed/partially-lowered + # trace that would silently drop a chunk's raw gates (see + # `_reject_partially_lowered_trace`). if any(chunk.get("lowered_quantum_ops") for chunk in chunks): + _reject_partially_lowered_trace(chunks) return _replay_lowered_qis_trace_into_tick_circuit(chunks) + # No chunk was lowered: replay the uniformly-raw QIS operation stream. operations: list[dict[str, Any]] = [] for chunk in chunks: operations.extend(list(chunk.get("operations", []))) @@ -795,12 +851,29 @@ def _build_surface_tick_circuit_for_native_model( basis, ancilla_budget=ancilla_budget, ) + # Coarse sanity check: the traced and abstract circuits must agree on the + # sequence of *measured qubit indices*. This catches gross drift (a dropped + # or added measurement, a wrong-qubit measurement, a different schedule + # shape). It is NOT an identity-level check: `_extract_measurement_order` + # returns physical qubit indices, and under ancilla reuse the same physical + # qubit appears in many measurements -- so two different stabilizer + # orderings can produce an identical qubit-index sequence and pass here. + # There is no independent stabilizer-identity oracle in the stack today: + # the detector/observable record offsets are the production binding (not a + # validator), and the byte-identical traced-vs-traced DEM regression shares + # the same shared batching policy on both sides (so it cannot catch a + # policy bug). The current safeguards against identity drift are the shared + # `batched_stabilizers` source-of-truth and the source-level CX-emission + # pins; a true identity check here would need stabilizer provenance the + # replayed TickCircuit does not currently carry (future work). traced_measurement_order = _extract_measurement_order(traced_tc) abstract_measurement_order = _extract_measurement_order(abstract_tc) if traced_measurement_order != abstract_measurement_order: msg = ( - "Lowered traced circuit measurement order does not match the abstract surface " - "metadata; refusing to build a mismatched native DEM/sampler" + "Traced and abstract surface circuits disagree on the measured-qubit " + "sequence (a dropped/added/wrong-qubit measurement or a different " + "schedule shape); refusing to build a native DEM/sampler from a " + "circuit that does not match the abstract detector/observable metadata" ) raise ValueError(msg) @@ -866,12 +939,31 @@ def build_memory_circuit( ) -def _can_use_cached_surface_topology( - *, - ancilla_budget: int | None, -) -> bool: - """Return True when we can safely use the shared native topology cache.""" - return ancilla_budget is None +def _canonical_ancilla_budget(patch: SurfacePatch, ancilla_budget: int | None) -> int | None: + """Canonicalize an ancilla budget for the shared native topology cache. + + Collapses every "unconstrained" spelling -- ``None``, a budget equal to + ``total_ancilla``, or any larger value -- to ``None`` so they share one + cache entry and use the unconstrained codegen path; a genuine constraint + (``< total_ancilla``) passes through unchanged. Routing through + :func:`normalize_ancilla_budget` also validates type/range fail-loud at the + cache boundary. + + All cache parameters (``ancilla_budget``, ``circuit_source``, idle-gate + insertion) are independent keys on the cached functions, so constrained + budgets cache correctly -- there is no correctness reason to bypass the + cache for them. ``None``/``== total``/``>> total`` were verified to produce + byte-identical DEMs for both circuit sources, so canonicalizing them + together is behavior-preserving. + """ + if ancilla_budget is None: + return None + from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget + + geom = patch.geometry + total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) + effective = normalize_ancilla_budget(total_ancilla, ancilla_budget) + return None if effective >= total_ancilla else effective def _uses_dedicated_idle_noise( @@ -1053,88 +1145,6 @@ def _build_native_sampler_from_cached_surface_topology( ) -def _build_native_sampler_from_tick_circuit( - tc: Any, - noise: NoiseModel, - *, - sampling_model: Literal[ - "dem", - "influence_dem", - "mnm", - ] = "dem", # "mnm" accepted for compat, mapped to "influence_dem", -) -> NativeSampler: - """Construct a native sampler directly from a TickCircuit.""" - import json - - from pecos.qec import DagFaultAnalyzer, DemSampler, ParsedDem - from pecos.qec.surface.circuit_builder import generate_dem_from_tick_circuit - - if _noise_uses_dedicated_idle_noise(noise): - tc.fill_idle_gates() - - dag = tc.to_dag_circuit() - analyzer = DagFaultAnalyzer(dag) - influence_map = analyzer.build_influence_map() - - detectors_json = tc.get_meta("detectors") or "[]" - observables_json = tc.get_meta("observables") or "[]" - num_detectors = len(json.loads(detectors_json)) if detectors_json else 0 - num_observables = len(json.loads(observables_json)) if observables_json else 0 - - if sampling_model == "dem": - dem_str = generate_dem_from_tick_circuit( - tc, - p1=noise.p1, - p2=noise.p2, - p_meas=noise.p_meas, - p_prep=noise.p_prep, - p_idle=noise.p_idle, - t1=noise.t1, - t2=noise.t2, - decompose_errors=True, - ) - sampler = ParsedDem.from_string(dem_str).to_dem_sampler() - elif sampling_model in ("influence_dem", "mnm"): - det_records = [d["records"] for d in json.loads(detectors_json)] - obs_records = [o["records"] for o in json.loads(observables_json)] if observables_json else [] - sampler = DemSampler.with_detectors( - influence_map, - det_records, - obs_records, - noise.p1, - noise.p2, - noise.p_meas, - noise.p_prep, - p_idle=noise.p_idle, - t1=noise.t1, - t2=noise.t2, - ) - sampling_model = "influence_dem" - elif sampling_model == "from_circuit": - # Direct from_circuit path: uses DagCircuit annotations and any - # explicit idle locations inserted above for dedicated idle noise. - sampler = DemSampler.from_circuit( - dag, - p1=noise.p1, - p2=noise.p2, - p_meas=noise.p_meas, - p_prep=noise.p_prep, - p_idle=noise.p_idle, - ) - else: - msg = f"Unknown native sampling_model {sampling_model!r}" - raise ValueError(msg) - - return NativeSampler( - sampler=sampler, - detectors_json=detectors_json, - observables_json=observables_json, - num_detectors=num_detectors, - num_observables=num_observables, - sampling_model=sampling_model, - ) - - def generate_circuit_level_dem_from_builder( patch: SurfacePatch, num_rounds: int, @@ -1183,45 +1193,22 @@ def generate_circuit_level_dem_from_builder( >>> noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01) >>> dem = generate_circuit_level_dem_from_builder(patch, num_rounds=3, noise=noise) """ - from pecos.qec.surface.circuit_builder import generate_dem_from_tick_circuit - - if _can_use_cached_surface_topology(ancilla_budget=ancilla_budget): - patch_key = _surface_patch_cache_key(patch) - return _cached_surface_native_dem_string( - patch_key, - num_rounds, - basis.upper(), - ancilla_budget, - circuit_source, - noise.p1, - noise.p2, - noise.p_meas, - noise.p_prep, - decompose_errors=decompose_errors, - p_idle=noise.p_idle, - t1=noise.t1, - t2=noise.t2, - ) - - tc = _build_surface_tick_circuit_for_native_model( - patch, + ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) + patch_key = _surface_patch_cache_key(patch) + return _cached_surface_native_dem_string( + patch_key, num_rounds, - basis, - ancilla_budget=ancilla_budget, - circuit_source=circuit_source, - ) - if _noise_uses_dedicated_idle_noise(noise): - tc.fill_idle_gates() - return generate_dem_from_tick_circuit( - tc, - p1=noise.p1, - p2=noise.p2, - p_meas=noise.p_meas, - p_prep=noise.p_prep, + basis.upper(), + ancilla_budget, + circuit_source, + noise.p1, + noise.p2, + noise.p_meas, + noise.p_prep, + decompose_errors=decompose_errors, p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2, - decompose_errors=decompose_errors, ) @@ -2862,57 +2849,44 @@ def build_native_sampler( >>> sampler = build_native_sampler(patch, num_rounds=5, noise=noise) >>> detection_events, observable_flips = sampler.sample(num_shots=10000) """ - if _can_use_cached_surface_topology(ancilla_budget=ancilla_budget): - basis = basis.upper() - patch_key = _surface_patch_cache_key(patch) - topology = _cached_surface_native_topology( + ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) + basis = basis.upper() + patch_key = _surface_patch_cache_key(patch) + topology = _cached_surface_native_topology( + patch_key, + num_rounds, + basis, + ancilla_budget, + circuit_source, + _noise_uses_dedicated_idle_noise(noise), + ) + if sampling_model == "dem": + dem_str = _cached_surface_native_dem_string( patch_key, num_rounds, basis, ancilla_budget, circuit_source, - _noise_uses_dedicated_idle_noise(noise), + noise.p1, + noise.p2, + noise.p_meas, + noise.p_prep, + decompose_errors=True, + p_idle=noise.p_idle, + t1=noise.t1, + t2=noise.t2, ) - if sampling_model == "dem": - dem_str = _cached_surface_native_dem_string( - patch_key, - num_rounds, - basis, - ancilla_budget, - circuit_source, - noise.p1, - noise.p2, - noise.p_meas, - noise.p_prep, - decompose_errors=True, - p_idle=noise.p_idle, - t1=noise.t1, - t2=noise.t2, - ) - sampler = _cached_parsed_dem(dem_str).to_dem_sampler() - return NativeSampler( - sampler=sampler, - detectors_json=topology.detectors_json, - observables_json=topology.observables_json, - num_detectors=topology.num_detectors, - num_observables=topology.num_observables, - sampling_model=sampling_model, - ) - return _build_native_sampler_from_cached_surface_topology( - topology, - noise, + sampler = _cached_parsed_dem(dem_str).to_dem_sampler() + return NativeSampler( + sampler=sampler, + detectors_json=topology.detectors_json, + observables_json=topology.observables_json, + num_detectors=topology.num_detectors, + num_observables=topology.num_observables, sampling_model=sampling_model, ) - - tc = _build_surface_tick_circuit_for_native_model( - patch, - num_rounds, - basis, - ancilla_budget=ancilla_budget, - circuit_source=circuit_source, - ) - return _build_native_sampler_from_tick_circuit( - tc, + return _build_native_sampler_from_cached_surface_topology( + topology, noise, sampling_model=sampling_model, ) diff --git a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py index 7a8be16ae..f6bdc67e2 100644 --- a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py +++ b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py @@ -127,3 +127,130 @@ def test_batched_stabilizers_distance_5_budget_3_covers_all_stabilizers() -> Non flat = [pair for batch in batches for pair in batch] assert len(flat) == total assert len(set(flat)) == total # no duplicates + + +# --- batched_stabilizers input validation --------------------------------- + + +def test_batched_stabilizers_rejects_invalid_budget_directly() -> None: + """``batched_stabilizers`` validates its own ``ancilla_budget`` (routes + through ``normalize_ancilla_budget``) rather than producing an opaque + ``range()`` error or a silent-empty failure on ``0`` / non-int input. + Closes the self-review's A2 finding.""" + patch = SurfacePatch.create(distance=3) + with pytest.raises(ValueError, match=r"must be >= 1"): + batched_stabilizers(patch, 0) + with pytest.raises(ValueError, match=r"must be >= 1"): + batched_stabilizers(patch, -2) + with pytest.raises(TypeError, match=r"must be int or None"): + batched_stabilizers(patch, True) + with pytest.raises(TypeError, match=r"must be int or None"): + batched_stabilizers(patch, 1.5) + + +def test_batched_stabilizers_clamps_oversized_budget() -> None: + """A budget larger than ``total_ancilla`` clamps to one big batch, + matching ``normalize_ancilla_budget`` behavior. Direct callers get the + same clamping the public API surface gets.""" + patch = SurfacePatch.create(distance=3) + total = len(patch.geometry.x_stabilizers) + len(patch.geometry.z_stabilizers) + huge = batched_stabilizers(patch, 10**6) + assert len(huge) == 1 + assert len(huge[0]) == total + + +# --- D1: pin emitted CX sequences for the constrained Guppy codegen -------- +# The byte-identical traced-vs-traced DEM oracle and the lowered-qubit-stream +# invariant catch many constrained-codegen errors, but not a wrong-CX-order / +# wrong-CX-control / dropped-CX bug inside the emitter (the lowered Selene +# trace uses RZZ + surrounding rotations, not raw CX, so the trace doesn't +# expose the emitted CX shape directly). These tests pin the literal CX +# emission at the **source** level so a regression in +# ``generate_guppy_source``'s per-batch CX restriction fails fast, +# independent of any DEM-level oracle. + + +def _emitted_cx_lines(distance: int, ancilla_budget: int | None) -> list[str]: + """Return the ``cx(...)`` lines emitted in the syndrome_extraction + function for a given (distance, budget).""" + import re + + from pecos.guppy.surface import generate_surface_code_module + + src = generate_surface_code_module(distance, ancilla_budget=ancilla_budget) + in_se = False + cx_lines: list[str] = [] + for line in src.split("\n"): + if line.startswith("def syndrome_extraction"): + in_se = True + continue + # Stop at the next top-level def or @ decorator (next function). + if in_se and line and not line.startswith(" ") and not line.startswith("#"): + break + if in_se: + m = re.match(r"^\s*(cx\([^)]+\))", line) + if m: + cx_lines.append(m.group(1)) + return cx_lines + + +def test_constrained_d3_budget1_emits_expected_cx_sequence() -> None: + """Catches wrong-CX-order / wrong-control / dropped-CX bugs in the + constrained emitter that the DEM-level and trace-level oracles miss.""" + assert _emitted_cx_lines(3, 1) == [ + "cx(_a_b0_p0, surf.data[1])", + "cx(_a_b0_p0, surf.data[0])", + "cx(surf.data[3], _a_b1_p0)", + "cx(surf.data[6], _a_b1_p0)", + "cx(_a_b2_p0, surf.data[2])", + "cx(_a_b2_p0, surf.data[1])", + "cx(_a_b2_p0, surf.data[5])", + "cx(_a_b2_p0, surf.data[4])", + "cx(surf.data[1], _a_b3_p0)", + "cx(surf.data[4], _a_b3_p0)", + "cx(surf.data[0], _a_b3_p0)", + "cx(surf.data[3], _a_b3_p0)", + "cx(_a_b4_p0, surf.data[4])", + "cx(_a_b4_p0, surf.data[3])", + "cx(_a_b4_p0, surf.data[7])", + "cx(_a_b4_p0, surf.data[6])", + "cx(surf.data[5], _a_b5_p0)", + "cx(surf.data[8], _a_b5_p0)", + "cx(surf.data[4], _a_b5_p0)", + "cx(surf.data[7], _a_b5_p0)", + "cx(_a_b6_p0, surf.data[8])", + "cx(_a_b6_p0, surf.data[7])", + "cx(surf.data[2], _a_b7_p0)", + "cx(surf.data[5], _a_b7_p0)", + ] + + +def test_constrained_d3_budget2_emits_expected_cx_sequence() -> None: + """Pins the budget=2 batched CX schedule (pairs X_k with Z_k each batch, + CXs filtered to that batch's stabilizers across 4 schedule rounds).""" + assert _emitted_cx_lines(3, 2) == [ + "cx(surf.data[3], _a_b0_p1)", + "cx(surf.data[6], _a_b0_p1)", + "cx(_a_b0_p0, surf.data[1])", + "cx(_a_b0_p0, surf.data[0])", + "cx(_a_b1_p0, surf.data[2])", + "cx(surf.data[1], _a_b1_p1)", + "cx(_a_b1_p0, surf.data[1])", + "cx(surf.data[4], _a_b1_p1)", + "cx(_a_b1_p0, surf.data[5])", + "cx(surf.data[0], _a_b1_p1)", + "cx(_a_b1_p0, surf.data[4])", + "cx(surf.data[3], _a_b1_p1)", + "cx(_a_b2_p0, surf.data[4])", + "cx(surf.data[5], _a_b2_p1)", + "cx(_a_b2_p0, surf.data[3])", + "cx(surf.data[8], _a_b2_p1)", + "cx(_a_b2_p0, surf.data[7])", + "cx(surf.data[4], _a_b2_p1)", + "cx(_a_b2_p0, surf.data[6])", + "cx(surf.data[7], _a_b2_p1)", + "cx(_a_b3_p0, surf.data[8])", + "cx(_a_b3_p0, surf.data[7])", + "cx(surf.data[2], _a_b3_p1)", + "cx(surf.data[5], _a_b3_p1)", + ] diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py index c89112f6b..26704f575 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -363,6 +363,86 @@ def test_native_circuit_level_dem_threads_ancilla_budget(self) -> None: ) assert decoder.get_dem("X", circuit_level=True) == batched_dem + def test_constrained_budget_uses_cache_and_matches_fresh_build(self) -> None: + """A constrained ancilla budget now flows through the shared topology + cache (previously bypassed). The cached constrained DEM must equal a + DEM built fresh from the corresponding TickCircuit, for both the + ``abstract`` and ``traced_qis`` sources -- pinning that caching is + sound for constrained budgets, not just unconstrained ones.""" + from pecos.qec.surface.circuit_builder import generate_dem_from_tick_circuit, generate_tick_circuit_from_patch + from pecos.qec.surface.decode import ( + _build_surface_tick_circuit_for_native_model, + generate_circuit_level_dem_from_builder, + ) + + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + params = {"p1": noise.p1, "p2": noise.p2, "p_meas": noise.p_meas, "p_prep": noise.p_prep} + + # abstract source + abstract_tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis="Z", ancilla_budget=2) + cached_abstract = generate_circuit_level_dem_from_builder( + patch, num_rounds=2, noise=noise, basis="Z", ancilla_budget=2, + ) + assert cached_abstract == generate_dem_from_tick_circuit(abstract_tc, **params, decompose_errors=False) + + # traced_qis source + _require_selene_runtime() + traced_tc = _build_surface_tick_circuit_for_native_model( + patch, 2, "Z", ancilla_budget=2, circuit_source="traced_qis", + ) + cached_traced = generate_circuit_level_dem_from_builder( + patch, num_rounds=2, noise=noise, basis="Z", ancilla_budget=2, circuit_source="traced_qis", + ) + assert cached_traced == generate_dem_from_tick_circuit(traced_tc, **params, decompose_errors=False) + + def test_unconstrained_budget_spellings_collapse_to_one_dem(self) -> None: + """``ancilla_budget`` of ``None``, ``== total_ancilla``, and a value + ``>> total_ancilla`` are all "unconstrained" and must produce the same + DEM. ``_canonical_ancilla_budget`` collapses them so they also share a + single cache entry rather than fragmenting it.""" + from pecos.qec.surface.decode import _canonical_ancilla_budget, generate_circuit_level_dem_from_builder + + patch = SurfacePatch.create(distance=3) + total = len(patch.geometry.x_stabilizers) + len(patch.geometry.z_stabilizers) + noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + + # Canonicalization: every unconstrained spelling -> None; a real + # constraint passes through unchanged. + assert _canonical_ancilla_budget(patch, None) is None + assert _canonical_ancilla_budget(patch, total) is None + assert _canonical_ancilla_budget(patch, 10**6) is None + assert _canonical_ancilla_budget(patch, 2) == 2 + + dem_none = generate_circuit_level_dem_from_builder(patch, num_rounds=2, noise=noise, basis="Z") + dem_total = generate_circuit_level_dem_from_builder( + patch, num_rounds=2, noise=noise, basis="Z", ancilla_budget=total, + ) + dem_huge = generate_circuit_level_dem_from_builder( + patch, num_rounds=2, noise=noise, basis="Z", ancilla_budget=10**6, + ) + assert dem_none == dem_total == dem_huge + + def test_constrained_budget_sampler_builds_for_all_models(self) -> None: + """The native sampler path also caches constrained budgets and builds + for every supported sampling model, with a detector count matching the + constrained circuit's surface metadata.""" + from pecos.qec.surface import build_native_sampler + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + abstract_tc = _build_surface_tick_circuit_for_native_model( + patch, 2, "Z", ancilla_budget=2, circuit_source="abstract", + ) + expected_detectors = int(abstract_tc.get_meta("num_detectors")) + + for model in ("dem", "influence_dem", "mnm"): + sampler = build_native_sampler( + patch, num_rounds=2, noise=noise, basis="Z", ancilla_budget=2, sampling_model=model, + ) + assert sampler.num_detectors == expected_detectors + def test_native_circuit_level_dem_cache_respects_patch_geometry(self) -> None: """Shared native DEM caching should preserve asymmetric patch geometry.""" from pecos.qec.surface.circuit_builder import generate_dem_from_tick_circuit, generate_tick_circuit_from_patch diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index 932dda9c0..5d493b5fc 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -12,6 +12,7 @@ from pecos.qec.surface import SurfacePatch from pecos.qec.surface.decode import ( _build_surface_tick_circuit_for_native_model, + _reject_partially_lowered_trace, _replay_lowered_qis_trace_into_tick_circuit, _replay_qis_trace_into_tick_circuit, ) @@ -148,6 +149,61 @@ def test_lowered_replay_fails_on_measurement_count_mismatch() -> None: _replay_lowered_qis_trace_into_tick_circuit(chunks) +def test_reject_partially_lowered_trace_passes_on_uniformly_lowered() -> None: + """A trace where every quantum-carrying chunk is also lowered is accepted + (this is the real Selene shape; the byte-identical regressions exercise it + end-to-end). A chunk with only non-quantum ops and no lowered form is fine + -- there are no gates to drop.""" + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 7]}}], + "lowered_quantum_ops": [{"gate_type": "MZ", "qubits": [0], "angles": []}], + }, + { # allocation/output bookkeeping only; legitimately has no lowered ops + "operations": [{"AllocateResult": {"id": 7}}, {"RecordOutput": {"id": 7}}], + "lowered_quantum_ops": [], + }, + ] + _reject_partially_lowered_trace(chunks) # must not raise + + +def test_reject_partially_lowered_trace_fails_on_mixed_format() -> None: + """A chunk carrying raw quantum gates but no lowered form, alongside a + lowered chunk, is rejected fail-loud: the lowered replay would silently + drop that chunk's (non-measurement) gates, and the meas-count guard would + not catch it.""" + chunks = [ + { + "operations": [{"Quantum": {"H": 0}}], + "lowered_quantum_ops": [{"gate_type": "H", "qubits": [0], "angles": []}], + }, + { # raw quantum gate present, but not lowered -> would be dropped + "operations": [{"Quantum": {"CX": [0, 1]}}], + "lowered_quantum_ops": [], + }, + ] + with pytest.raises(ValueError, match=r"mixed/partially-lowered|incomplete gate stream"): + _reject_partially_lowered_trace(chunks) + + +def test_reject_partially_lowered_trace_fails_on_unlowered_allocation() -> None: + """``AllocateQubit`` lowers to a prep (PZ), so an unlowered chunk that + carries only an allocation alongside a lowered chunk would silently drop + that prep -- it must fail loud too, not just chunks with raw gate ops.""" + chunks = [ + { + "operations": [{"Quantum": {"H": 0}}], + "lowered_quantum_ops": [{"gate_type": "H", "qubits": [0], "angles": []}], + }, + { # allocation present (lowers to PZ) but not lowered -> would be dropped + "operations": [{"AllocateQubit": {"id": 1}}], + "lowered_quantum_ops": [], + }, + ] + with pytest.raises(ValueError, match=r"mixed/partially-lowered|incomplete gate stream"): + _reject_partially_lowered_trace(chunks) + + def test_non_lowered_replay_preserves_non_sequential_result_ids() -> None: operations = [ {"AllocateQubit": {"id": 10}}, @@ -265,26 +321,37 @@ def _constrained_surface_via_guppy(*, d, basis, rounds, budget, noise): return ref_dem, got, ref -def test_from_guppy_constrained_surface_dem_byte_identical() -> None: +@pytest.mark.parametrize( + ("d", "basis", "rounds", "budget"), + [ + (3, "Z", 2, 1), # small-and-fast, minimum budget (one stabilizer/batch) + (3, "X", 2, 2), # asymmetric basis, X/Z paired per batch + (9, "Z", 3, 17), # canonical high-distance stress + ], +) +def test_from_guppy_constrained_surface_dem_byte_identical( + d: int, + basis: str, + rounds: int, + budget: int, +) -> None: """`from_guppy(make_surface_code(..., ancilla_budget=b))` must produce a DEM byte-identical to the reference DEM built through the `_build_surface_tick_circuit_for_native_model(circuit_source="traced_qis", - ancilla_budget=b)` path. Covers (d=3, budget=1, Z), (d=3, budget=2, X), and - (d=9, budget=17, Z) -- small-and-fast + asymmetric basis + canonical - high-distance stress.""" + ancilla_budget=b)` path. Parametrized so a regression isolates to the + specific (distance, budget, basis) case rather than failing the whole set.""" noise = {"p1": 0.005, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} - for d, basis, rounds, budget in [(3, "Z", 2, 1), (3, "X", 2, 2), (9, "Z", 3, 17)]: - ref_dem, got, _ = _constrained_surface_via_guppy( - d=d, - basis=basis, - rounds=rounds, - budget=budget, - noise=noise, - ) - assert got == ref_dem, ( - f"constrained surface from_guppy not byte-identical for " - f"d={d}, budget={budget}, basis={basis}, rounds={rounds}" - ) + ref_dem, got, _ = _constrained_surface_via_guppy( + d=d, + basis=basis, + rounds=rounds, + budget=budget, + noise=noise, + ) + assert got == ref_dem, ( + f"constrained surface from_guppy not byte-identical for " + f"d={d}, budget={budget}, basis={basis}, rounds={rounds}" + ) def test_constrained_surface_traced_metadata_matches_abstract() -> None: @@ -323,14 +390,15 @@ def test_constrained_surface_traced_metadata_matches_abstract() -> None: assert traced_tc.get_meta("ancilla_budget") == "2" -def test_constrained_surface_lowered_qubit_stream_within_budget() -> None: +@pytest.mark.parametrize(("d", "budget"), [(3, 1), (3, 2), (5, 3)]) +def test_constrained_surface_lowered_qubit_stream_within_budget(d: int, budget: int) -> None: """The lowered-trace physical qubit IDs must stay within the budgeted pool, and ancilla slots must be empirically reused (more measurements than physical ancilla qubits). Pins the load-bearing assumption the - spike validated.""" + spike validated, across several (distance, budget) combinations so the + reuse invariant isn't only checked at one point.""" import pecos - d, budget = 3, 2 program = make_surface_code(distance=d, num_rounds=2, basis="Z", ancilla_budget=budget) n_q = get_num_qubits(d, ancilla_budget=budget) chunks = list( @@ -391,10 +459,20 @@ def test_constrained_from_guppy_dem_is_consumable_by_pecos_native_decoder() -> N **p, ) - # PECOS-native sampler path: DEM is well-formed for sampling. + # PECOS-native sampler path: the sampler must agree with the DEM it was + # built from (substantive, not merely ``>= 0``) and actually produce + # well-shaped samples. sampler = dem.to_sampler() - assert sampler.num_dem_outputs >= 0 - assert sampler.num_observables >= 0 + assert sampler.num_detectors == dem.num_detectors + assert sampler.num_observables == dem.num_observables + assert dem.num_observables == 1 # one logical observable for a single patch + + batch = sampler.generate_samples(16, 0) + assert batch.num_shots == 16 + # Each shot's syndrome covers exactly the DEM's detectors. + assert len(batch.get_syndrome(0)) == dem.num_detectors + # The observable mask fits within ``num_observables`` bits (no stray bits). + assert batch.get_observable_mask(0) >> dem.num_observables == 0 # PECOS-native Rust-backed matching decoder: DEM is consumable by # the actual downstream decoder surface. @@ -413,10 +491,12 @@ def test_constrained_from_guppy_dem_is_consumable_by_pecos_native_decoder() -> N def test_constrained_from_guppy_fails_loud_on_mismatched_num_measurements() -> None: """The constrained-ancilla surface program must flow through the same Rust metadata-validation fail-loud path as any other Guppy program. - No surface-specific bypass: passing a wrong ``num_measurements`` (here, - the unconstrained count, which differs from the constrained traced - program's count) is rejected by the generic builder, not by anything - surface-aware in ``from_guppy``.""" + No surface-specific bypass: passing a ``num_measurements`` that disagrees + with the count the traced program actually performs (here, one greater + than the true count) is rejected by the generic builder, not by anything + surface-aware in ``from_guppy``. The regex pins the builder's specific + 'declared count disagrees' diagnostic, not just the bare key name, so a + different ``num_measurements``-mentioning error wouldn't pass spuriously.""" p = {"p1": 0.005, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} patch = SurfacePatch.create(distance=3) abstract_tc = _build_surface_tick_circuit_for_native_model( @@ -429,7 +509,10 @@ def test_constrained_from_guppy_fails_loud_on_mismatched_num_measurements() -> N actual = int(abstract_tc.get_meta("num_measurements")) wrong = actual + 1 - with pytest.raises(ValueError, match=r"num_measurements"): + with pytest.raises( + ValueError, + match=r"num_measurements=\d+ disagrees with the \d+ measurement", + ): DetectorErrorModel.from_guppy( make_surface_code(distance=3, num_rounds=2, basis="Z", ancilla_budget=2), num_qubits=get_num_qubits(3, ancilla_budget=2), @@ -438,3 +521,87 @@ def test_constrained_from_guppy_fails_loud_on_mismatched_num_measurements() -> N num_measurements=wrong, **p, ) + + +@pytest.mark.parametrize("entry", ["get_num_qubits", "make_surface_code"]) +def test_constrained_public_api_rejects_invalid_ancilla_budget(entry: str) -> None: + """Both public entry points that accept ``ancilla_budget`` -- ``get_num_qubits`` + and ``make_surface_code`` -- validate it fail-loud at the boundary (routing + through ``normalize_ancilla_budget``), so a bad budget never reaches codegen or + the qubit-count math. ``bool``/``float``/``str`` raise ``TypeError``; ``< 1`` + raises ``ValueError``.""" + + def call(budget: object): + if entry == "get_num_qubits": + return get_num_qubits(3, ancilla_budget=budget) + return make_surface_code(distance=3, num_rounds=2, basis="Z", ancilla_budget=budget) + + for bad in (True, 1.5, "2"): + with pytest.raises(TypeError, match=r"must be int or None"): + call(bad) + for bad in (0, -1): + with pytest.raises(ValueError, match=r"must be >= 1"): + call(bad) + + +def test_copy_surface_metadata_propagates_descriptors() -> None: + """``_copy_surface_tick_circuit_metadata`` must propagate the structured + detector/observable *descriptor* metadata, not just the raw + detectors/observables JSON. The constrained build path doesn't populate + descriptors lazily, so the byte-identical and metadata-match tests above + never exercise the descriptor branch of the copy helper -- this seeds them + explicitly on the source and pins that the copy carries them across.""" + from pecos.qec.surface import ( + get_detector_descriptors_from_tick_circuit, + get_observable_descriptors_from_tick_circuit, + ) + from pecos.qec.surface.decode import _copy_surface_tick_circuit_metadata + from pecos_rslib.quantum import TickCircuit + + patch = SurfacePatch.create(distance=3) + source = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=2, + basis="Z", + ancilla_budget=2, + circuit_source="abstract", + ) + # Seed the lazily-built descriptor metadata on the source. + det_desc = get_detector_descriptors_from_tick_circuit(source, patch) + obs_desc = get_observable_descriptors_from_tick_circuit(source, patch) + assert source.get_meta("detector_descriptors") is not None + assert source.get_meta("observable_descriptors") is not None + + target = TickCircuit() + _copy_surface_tick_circuit_metadata(source, target) + + assert target.get_meta("detector_descriptors") == source.get_meta("detector_descriptors") + assert target.get_meta("observable_descriptors") == source.get_meta("observable_descriptors") + # Sanity: the seeded descriptors are non-trivial (real content was copied). + assert len(det_desc) > 0 + assert len(obs_desc) > 0 + + +def test_surface_module_cache_collapses_unconstrained_budget_forms() -> None: + """``get_surface_code_module`` keys its cache on the *effective* budget + (``normalize_ancilla_budget(d*d-1, budget)``), so ``ancilla_budget=None`` + and any ``budget >= total_ancilla`` resolve to the SAME cached module -- + no redundant codegen for the two ways of saying "unconstrained". A finite + constrained budget is a distinct entry.""" + from pecos.guppy.surface import get_surface_code_module + + d = 3 + total_ancilla = d * d - 1 # all stabilizer ancillas live simultaneously + + unconstrained_none = get_surface_code_module(d, ancilla_budget=None) + unconstrained_exact = get_surface_code_module(d, ancilla_budget=total_ancilla) + unconstrained_large = get_surface_code_module(d, ancilla_budget=10**6) + # All three "unconstrained" spellings are the identical cached object. + assert unconstrained_none is unconstrained_exact + assert unconstrained_none is unconstrained_large + assert unconstrained_none["ancilla_budget"] == total_ancilla + + constrained = get_surface_code_module(d, ancilla_budget=2) + # A genuinely-constrained budget is a separate cache entry. + assert constrained is not unconstrained_none + assert constrained["ancilla_budget"] == 2 From 2c53db322f7e9e2c2a38b6c3c69fddb2d0cac761 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 21 May 2026 08:38:25 -0600 Subject: [PATCH 35/36] Generalize surface/sampler DEM paths: patch-faithful traced_qis + cache identity, unified distance validation, de-duplicated geometry helpers, and DemBuilder-parity sampler JSON validation Sampler JSON now shares DemBuilder-style stamped-MeasId resolution, duplicate-ID rejection, records/meas_ids redundancy, and measurement-order frame validation. --- .../fault_tolerance/dem_builder/builder.rs | 170 ++++++++++++++++++ .../dem_builder/dem_sampler.rs | 146 ++++++++------- .../fault_tolerance/dem_builder/sampler.rs | 126 +++---------- .../quantum-pecos/src/pecos/guppy/surface.py | 150 +++++++++++----- .../src/pecos/qec/surface/__init__.py | 8 +- .../pecos/qec/surface/_ancilla_batching.py | 11 +- .../src/pecos/qec/surface/circuit_builder.py | 73 +------- .../src/pecos/qec/surface/decode.py | 21 ++- .../src/pecos/qec/surface/patch.py | 28 ++- .../tests/qec/surface/test_surface_decoder.py | 58 ++++++ .../tests/qec/test_dem_metadata_fail_loud.py | 119 ++++++++++++ 11 files changed, 596 insertions(+), 314 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 2de359c13..268ce918d 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -1422,6 +1422,176 @@ fn parse_single_observable(value: &serde_json::Value) -> Result Result>, DemBuilderError> { + reject_duplicate_stamped_meas_ids(influence_map)?; + parse_detectors_json(json)? + .iter() + .map(|d| resolve_sampler_record_vector("Detector", d.id, &d.records, &d.meas_ids, influence_map)) + .collect() +} + +/// Observable counterpart of [`parse_detector_record_vectors`]. +pub(crate) fn parse_observable_record_vectors( + json: &str, + influence_map: &DagFaultInfluenceMap, +) -> Result>, DemBuilderError> { + reject_duplicate_stamped_meas_ids(influence_map)?; + parse_observables_json(json)? + .iter() + .map(|o| resolve_sampler_record_vector("Observable", o.id, &o.records, &o.meas_ids, influence_map)) + .collect() +} + +/// Reject a circuit whose stable `MeasId`s are not unique, before resolving any +/// `meas_ids`. A duplicate would make stamped-id resolution bind to the first +/// occurrence (an ambiguous, silently-wrong bind); it indicates a trace/replay +/// bug, not bad caller input. Mirrors the guard in +/// `DemBuilder::validate_measurement_count` so the sampler JSON path rejects +/// exactly what `DemBuilder` does. +fn reject_duplicate_stamped_meas_ids( + influence_map: &DagFaultInfluenceMap, +) -> Result<(), DemBuilderError> { + let mut seen = std::collections::HashSet::with_capacity(influence_map.meas_ids.len()); + for mid in &influence_map.meas_ids { + if !seen.insert(mid.0) { + return Err(DemBuilderError::ParseError(format!( + "duplicate stable MeasId {} in the traced circuit; each \ + measurement must have a unique stamped id", + mid.0 + ))); + } + } + Ok(()) +} + +/// Resolve a stamped/positional `meas_id` against the influence map, mirroring +/// `DemBuilder::resolve_meas_id_to_tc_index`: a stamped stable id when the +/// circuit carries them, a positional index only when it does not. +fn resolve_sampler_meas_id(influence_map: &DagFaultInfluenceMap, meas_id: usize) -> Option { + if influence_map.meas_ids.is_empty() { + (meas_id < influence_map.measurements.len()).then_some(meas_id) + } else { + influence_map.meas_ids.iter().position(|mid| mid.0 == meas_id) + } +} + +/// Resolve a parsed `records`/`meas_ids` pair to the sampler's single-`Vec` +/// convention, with `DemBuilder`-equivalent validation. See +/// [`parse_detector_record_vectors`] for the contract. +fn resolve_sampler_record_vector( + kind: &str, + id: u32, + records: &[i32], + meas_ids: &[usize], + influence_map: &DagFaultInfluenceMap, +) -> Result, DemBuilderError> { + let num_measurements = influence_map.measurements.len(); + + // Escape hatch: an empty influence map makes refs opaque pass-through + // coordinates with no circuit to resolve against. Prefer records; emit + // meas_ids verbatim as positional indices (there are no stable ids). + if num_measurements == 0 { + if !records.is_empty() { + return Ok(records.to_vec()); + } + return meas_ids + .iter() + .map(|&m| { + i32::try_from(m).map_err(|_| { + DemBuilderError::ParseError(format!( + "{kind} {id} meas_id {m} is out of range for an i32 record vector" + )) + }) + }) + .collect(); + } + + // Resolve each form to absolute measurement indices, fail-loud. + let records_abs = records + .iter() + .map(|&offset| { + record_offset_to_absolute_index(num_measurements, offset).ok_or_else(|| { + DemBuilderError::ParseError(format!( + "{kind} {id} references record offset {offset}, which is out of \ + range for a circuit with {num_measurements} measurement(s)" + )) + }) + }) + .collect::, _>>()?; + let meas_ids_abs = meas_ids + .iter() + .map(|&meas_id| { + resolve_sampler_meas_id(influence_map, meas_id).ok_or_else(|| { + DemBuilderError::ParseError(format!( + "{kind} {id} references meas_id {meas_id}, which is not present in \ + the circuit's {num_measurements} measurement(s)" + )) + }) + }) + .collect::, _>>()?; + + // Co-present records and meas_ids must reference the same measurements + // (mirrors `validate_metadata_refs`); they are alternatives, not additive. + if !records.is_empty() && !meas_ids.is_empty() { + let mut a = records_abs.clone(); + let mut b = meas_ids_abs.clone(); + a.sort_unstable(); + b.sort_unstable(); + if a != b { + return Err(DemBuilderError::ParseError(format!( + "{kind} {id} has both 'records' and 'meas_ids' but they reference \ + different measurements (records -> {a:?}, meas_ids -> {b:?}); they \ + are alternatives, not additive" + ))); + } + } + + // Prefer records (kept as Stim offsets, like `DemBuilder`); otherwise emit + // the resolved absolute indices, which the sampler reads as positive + // (absolute-index) record values. + if !records.is_empty() { + return Ok(records.to_vec()); + } + meas_ids_abs + .iter() + .map(|&idx| { + i32::try_from(idx).map_err(|_| { + DemBuilderError::ParseError(format!( + "{kind} {id} resolved measurement index {idx} exceeds i32 range" + )) + }) + }) + .collect() +} + /// Rejects a JSON entry that declares `kind: "tracked_pauli"`. /// /// Tracked Paulis reference qubits via `pauli`, not measurements, and are diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs index 7ba0febe2..9b2da0b07 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs @@ -1916,7 +1916,9 @@ impl<'a> SamplingEngineBuilder<'a> { /// # Errors /// Returns an error if the JSON is malformed or missing required fields. pub fn with_detectors_json(mut self, json: &str) -> Result { - self.detector_records = parse_records_json(json, "detector")?; + self.detector_records = + super::builder::parse_detector_record_vectors(json, self.influence_map) + .map_err(|err| err.to_string())?; Ok(self) } @@ -1925,9 +1927,12 @@ impl<'a> SamplingEngineBuilder<'a> { /// Format: `[{"id": 0, "records": [-1, -3, -5]}, ...]` /// /// # Errors - /// Returns an error if the JSON is malformed or missing required fields. + /// Returns an error if the JSON is malformed, fails schema validation, or + /// references measurements out of range for the circuit. pub fn with_observables_json(mut self, json: &str) -> Result { - self.observable_records = parse_records_json(json, "observable")?; + self.observable_records = + super::builder::parse_observable_record_vectors(json, self.influence_map) + .map_err(|err| err.to_string())?; Ok(self) } @@ -2579,68 +2584,6 @@ where result } -/// Parse detector or observable definitions from JSON. -/// -/// Uses a simple custom parser to avoid `serde_json` dependency. -/// Expected format: `[{"id": 0, "records": [-1, -5]}, ...]` -#[allow(clippy::unnecessary_wraps)] -fn parse_records_json(json: &str, _kind: &str) -> Result>, String> { - let json = json.trim(); - if json.is_empty() || json == "[]" { - return Ok(Vec::new()); - } - - let mut results = Vec::new(); - - // Simple state machine to find each object - let mut depth = 0; - let mut start = None; - - for (i, c) in json.char_indices() { - match c { - '{' => { - if depth == 1 { - start = Some(i); - } - depth += 1; - } - '}' => { - depth -= 1; - if depth == 1 { - if let Some(s) = start { - let obj_str = &json[s..i + c.len_utf8()]; - let records = extract_records_from_object(obj_str); - results.push(records); - } - start = None; - } - } - '[' if depth == 0 => depth = 1, - ']' if depth == 1 => depth = 0, - _ => {} - } - } - - Ok(results) -} - -/// Extract the "records" array from a JSON object string. -fn extract_records_from_object(json: &str) -> Vec { - if let Some(pos) = json.find("\"records\"") { - let rest = &json[pos..]; - if let (Some(arr_start), Some(arr_end)) = (rest.find('['), rest.find(']')) - && arr_start < arr_end - { - let arr_str = &rest[arr_start + 1..arr_end]; - return arr_str - .split(',') - .filter_map(|s| s.trim().parse::().ok()) - .collect(); - } - } - Vec::new() -} - #[cfg(test)] mod tests { use super::*; @@ -2718,23 +2661,74 @@ mod tests { assert!(result.is_empty()); } - #[test] - fn test_parse_records_json_empty() { - let result = parse_records_json("[]", "test").unwrap(); - assert!(result.is_empty()); + /// Build an influence map for a circuit with `n` independent measurements + /// (no stable `MeasId`s, so meas_ids resolve positionally). + fn im_with_n_measurements(n: usize) -> crate::fault_tolerance::propagator::DagFaultInfluenceMap { + use crate::fault_tolerance::propagator::DagFaultAnalyzer; + use pecos_quantum::DagCircuit; + let mut dag = DagCircuit::new(); + for q in 0..n { + dag.pz(&[q]); + dag.mz(&[q]); + } + DagFaultAnalyzer::new(&dag).build_influence_map() + } - let result = parse_records_json("", "test").unwrap(); - assert!(result.is_empty()); + #[test] + fn test_record_vectors_empty() { + use super::super::builder::parse_detector_record_vectors; + let im = im_with_n_measurements(8); + assert!(parse_detector_record_vectors("[]", &im).unwrap().is_empty()); + assert!(parse_detector_record_vectors("", &im).unwrap().is_empty()); } #[test] - fn test_parse_records_json_valid() { + fn test_record_vectors_valid() { + use super::super::builder::parse_detector_record_vectors; + let im = im_with_n_measurements(8); let json = r#"[{"id": 0, "records": [-1, -5]}, {"id": 1, "records": [-2, -3, -4]}]"#; - let result = parse_records_json(json, "detector").unwrap(); + let result = parse_detector_record_vectors(json, &im).unwrap(); + assert_eq!(result, vec![vec![-1, -5], vec![-2, -3, -4]]); + } - assert_eq!(result.len(), 2); - assert_eq!(result[0], vec![-1, -5]); - assert_eq!(result[1], vec![-2, -3, -4]); + #[test] + fn test_record_vectors_reject_malformed_metadata() { + // The consolidated parser fails loud where the old hand-rolled scanner + // silently produced empty / partial records. + use super::super::builder::parse_detector_record_vectors; + let im = im_with_n_measurements(8); + // Non-list top level (previously -> empty, accepted). + assert!(parse_detector_record_vectors("{}", &im).is_err()); + // Non-integer record value (previously dropped via filter_map(parse.ok)). + assert!(parse_detector_record_vectors(r#"[{"id":0,"records":[-1,"bad"]}]"#, &im).is_err()); + // Entry referencing neither records nor meas_ids (previously -> empty vec). + assert!(parse_detector_record_vectors(r#"[{"id":0}]"#, &im).is_err()); + } + + #[test] + fn test_record_vectors_reject_out_of_range_and_nonredundant() { + // Context-aware validation: out-of-range refs and non-redundant + // records+meas_ids fail loud instead of being silently dropped + // downstream (the M-E sampler-validation gap). meas_ids resolve + // positionally here because these circuits carry no stable ids; the + // stamped-MeasId semantic is exercised from Python (mz_with_ids). + use super::super::builder::{parse_detector_record_vectors, parse_observable_record_vectors}; + let im1 = im_with_n_measurements(1); + let im3 = im_with_n_measurements(3); + let im0 = im_with_n_measurements(0); + // Out-of-range negative offset on a 1-measurement circuit. + assert!(parse_detector_record_vectors(r#"[{"id":0,"records":[-1,-2]}]"#, &im1).is_err()); + // Out-of-range observable offset, too. + assert!(parse_observable_record_vectors(r#"[{"id":0,"records":[-1,-2]}]"#, &im1).is_err()); + // Out-of-range (positional) meas_id. + assert!(parse_detector_record_vectors(r#"[{"id":0,"meas_ids":[0,999]}]"#, &im1).is_err()); + // Non-redundant co-present records + meas_ids (3-measurement circuit: + // records[-1] -> index 2, meas_ids[0] -> index 0). + assert!(parse_detector_record_vectors(r#"[{"id":0,"records":[-1],"meas_ids":[0]}]"#, &im3).is_err()); + // Redundant co-presence is accepted (both -> index 0). + assert!(parse_detector_record_vectors(r#"[{"id":0,"records":[-1],"meas_ids":[0]}]"#, &im1).is_ok()); + // Empty influence map keeps the opaque escape hatch (no range check). + assert!(parse_detector_record_vectors(r#"[{"id":0,"records":[-1,-99]}]"#, &im0).is_ok()); } #[test] diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs index 5aad8b64e..76c09ced5 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -1053,7 +1053,8 @@ impl<'a> DemSamplerBuilder<'a> { /// # Errors /// Returns an error if the JSON is malformed. pub fn with_detectors_json(self, json: &str) -> Result { - let records = parse_records_json(json); + let records = super::builder::parse_detector_record_vectors(json, self.influence_map) + .map_err(|err| err.to_string())?; Ok(self.with_detector_records(records)) } @@ -1062,9 +1063,11 @@ impl<'a> DemSamplerBuilder<'a> { /// Format: `[{"id": 0, "records": [-1, -3, -5]}, ...]` /// /// # Errors - /// Returns an error if the JSON is malformed. + /// Returns an error if the JSON is malformed, fails schema validation, or + /// references measurements out of range for the circuit. pub fn with_observables_json(self, json: &str) -> Result { - let records = parse_records_json(json); + let records = super::builder::parse_observable_record_vectors(json, self.influence_map) + .map_err(|err| err.to_string())?; Ok(self.with_observable_records(records)) } @@ -1213,6 +1216,23 @@ impl<'a> DemSamplerBuilder<'a> { /// Returns an error if detector definitions reference non-deterministic /// measurements or are not linearly independent over `Z_2`. pub fn build(self) -> Result { + // A supplied measurement order must cover every measurement, otherwise + // detector/observable record offsets validated against the circuit's + // measurement count would resolve in a different (shorter/longer) frame + // at sample time and silently mis-map. (See sampler-JSON validation.) + if let Some(ref order) = self.measurement_order { + let expected = self.influence_map.measurements.len(); + if order.len() != expected { + return Err(DetectorValidationError::InvalidMetadata { + message: format!( + "measurement_order has {} entries but the circuit performs \ + {expected} measurement(s); a measurement order must cover \ + every measurement so record offsets resolve in the same frame", + order.len() + ), + }); + } + } match self.output_mode { OutputMode::RawMeasurements => Ok(self.build_raw()), OutputMode::DetectorEvents => self.build_detector(), @@ -1459,106 +1479,6 @@ pub(crate) fn gate_location_prob_from_locations( 0.0 } -/// Parse detector or observable definitions from JSON. -/// -/// Run noiseless symbolic simulation on a `TickCircuit` to identify non-deterministic measurements. -/// -/// Returns a Vec where true = non-deterministic (needs coin flip). -/// Uses `SymbolicSparseStab` which tracks measurement determinism symbolically. -/// Run noiseless symbolic simulation to identify non-deterministic measurements -/// and their dependency structure. -/// -/// Returns: -/// - `Vec`: non-det mask (true = needs coin flip) -/// - `Vec, bool)>>`: per-measurement dependencies -/// (Some((deps, flip)) for deterministic measurements, None for non-det) -/// -/// Only supports the Clifford gate subset. Returns error for unsupported gates. -fn parse_records_json(json: &str) -> Vec> { - let json = json.trim(); - if json.is_empty() || json == "[]" { - return Vec::new(); - } - - let mut results = Vec::new(); - let mut depth = 0; - let mut start = None; - - for (i, c) in json.char_indices() { - match c { - '{' => { - if depth == 1 { - start = Some(i); - } - depth += 1; - } - '}' => { - depth -= 1; - if depth == 1 { - if let Some(s) = start { - let obj_str = &json[s..i + c.len_utf8()]; - results.push(extract_records_array(obj_str)); - } - start = None; - } - } - '[' if depth == 0 => depth = 1, - ']' if depth == 1 => depth = 0, - _ => {} - } - } - - results -} - -/// Extract measurement record indices from a JSON object string. -/// -/// Prefers `"meas_ids"` (absolute `MeasId` IDs) when available. -/// Also accepts `"records"` for DEM-style negative offsets. -fn extract_records_array(json: &str) -> Vec { - // Prefer meas_ids (absolute, stable IDs from MeasId) - if let Some(pos) = json.find("\"meas_ids\"") { - let rest = &json[pos..]; - if let (Some(arr_start), Some(arr_end)) = (rest.find('['), rest.find(']')) - && arr_start < arr_end - { - let arr_str = &rest[arr_start + 1..arr_end]; - let ids: Vec = arr_str - .split(',') - .filter_map(|s| s.trim().parse::().ok()) - .collect(); - if !ids.is_empty() { - // Convert absolute MeasId IDs to negative offsets: - // not needed — the DemBuilder resolves negative offsets against - // num_measurements. With absolute IDs, we store them as positive - // values and handle them in the DemBuilder's build_measurement_mappings. - // - // For now, keep the negative-offset convention internally but - // convert: absolute ID i becomes offset -(num_measurements - i). - // We don't know num_measurements here, so return the absolute IDs - // as positive i32. The DemBuilder recognizes positive values as - // absolute MeasId indices. - return ids; - } - } - } - - // Fallback: "records" with negative offsets - if let Some(pos) = json.find("\"records\"") { - let rest = &json[pos..]; - if let (Some(arr_start), Some(arr_end)) = (rest.find('['), rest.find(']')) - && arr_start < arr_end - { - let arr_str = &rest[arr_start + 1..arr_end]; - return arr_str - .split(',') - .filter_map(|s| s.trim().parse::().ok()) - .collect(); - } - } - Vec::new() -} - #[cfg(test)] mod tests { use super::*; diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 4a13f96fe..3307f294c 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -29,7 +29,10 @@ class _ModuleState: temp_dir: ClassVar[Path | None] = None module_cache: ClassVar[dict[str, object]] = {} - distance_module_cache: ClassVar[dict[tuple[int, int], dict]] = {} + # Keyed by full patch identity + effective budget (dx, dz, orientation, + # rotated, effective_budget) so distinct patch geometries -- e.g. rotated + # vs non-rotated at the same dx/dz -- never collide on a cached module. + distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int], dict]] = {} _state = _ModuleState() @@ -393,6 +396,34 @@ def generate_guppy_source( return "\n".join(lines) +def _validate_surface_memory_distance(d: int) -> None: + """Enforce the surface-memory Guppy entry-point distance contract. + + The distance-based public entry points (:func:`get_num_qubits`, + :func:`get_surface_code_module`, :func:`make_surface_code`, + :func:`generate_surface_code_module`) document and require an odd code + distance ``>= 3``. Validate it in one place so they fail loud + consistently rather than silently building an out-of-contract program + (the patch-based entry points validate via ``SurfacePatch`` instead). + """ + if d < 3 or d % 2 == 0: + msg = f"Distance must be odd >= 3, got {d}" + raise ValueError(msg) + + +def _guppy_module_cache_key(patch: "SurfacePatch", effective_budget: int) -> str: + """Filesystem-safe cache key spanning full patch identity + budget. + + Mirrors the topology identity used by the native cache + (``decode._surface_patch_cache_key``): dx, dz, orientation, and the + rotated flag. Keying on distance/dx-dz alone would collide a rotated and + a non-rotated patch of the same shape onto one generated module. + """ + geom = patch.geometry + rotated = "rot" if geom.rotated else "unrot" + return f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}_b{effective_budget}" + + def _load_guppy_module( patch: "SurfacePatch", *, @@ -400,10 +431,11 @@ def _load_guppy_module( ) -> dict: """Load a Guppy module for a patch, using caching. - The cache key is widened by the **effective** budget (after - clamping via ``normalize_ancilla_budget``), so ``ancilla_budget=None`` - and ``ancilla_budget >= total_ancilla`` resolve to the same cache - entry and don't produce two equivalent generated modules. + The cache key spans the full patch identity (dx, dz, orientation, + rotated) and the **effective** budget (after clamping via + ``normalize_ancilla_budget``), so ``ancilla_budget=None`` and + ``ancilla_budget >= total_ancilla`` resolve to the same cache entry + while distinct patch geometries never collide. Args: patch: SurfacePatch with geometry @@ -417,7 +449,7 @@ def _load_guppy_module( geom = patch.geometry total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) - cache_key = f"{patch.dx}x{patch.dz}_b{effective_budget}" + cache_key = _guppy_module_cache_key(patch, effective_budget) if cache_key in _state.module_cache: return _state.module_cache[cache_key] @@ -476,33 +508,48 @@ def generate_memory_experiment( return factory(num_rounds) -def get_num_qubits(d: int, *, ancilla_budget: int | None = None) -> int: - """Get total number of qubits for a distance-d surface code. +def get_num_qubits( + d: int | None = None, + *, + patch: "SurfacePatch | None" = None, + ancilla_budget: int | None = None, +) -> int: + """Get the peak simultaneously-live qubit count for a surface-code program. - Unconstrained (``ancilla_budget=None``): peak qubit count is - ``d^2 data + (d^2 - 1) ancilla = 2 * d^2 - 1``. + Provide exactly one of ``d`` or ``patch``: - Constrained (``ancilla_budget`` provided): the program reuses - ancilla slots across stabilizer-measurement batches, so only - ``d^2 data + min(ancilla_budget, d^2 - 1) ancilla`` physical - slots are simultaneously live. The clamping is the same as - ``pecos.qec.surface._ancilla_batching.normalize_ancilla_budget`` - so the unconstrained-via-``None`` and unconstrained-via-large-int - cases collapse to the same value. + - ``d`` (odd >= 3): the default symmetric rotated patch, with + ``d^2`` data and ``d^2 - 1`` ancilla qubits. + - ``patch``: any geometry (asymmetric / non-rotated included); counts + are derived from ``patch.geometry`` so the result is faithful to the + patch actually being traced -- not a scalar-distance approximation. - Args: - d: Code distance - ancilla_budget: Optional cap on simultaneously live ancillas. - ``None`` (default) returns the peak count. + Unconstrained (``ancilla_budget=None``): peak count is + ``num_data + total_ancilla``. Constrained: the program reuses ancilla + slots across stabilizer-measurement batches, so only + ``num_data + min(ancilla_budget, total_ancilla)`` slots are live at once. + Clamping matches ``normalize_ancilla_budget``, so the + unconstrained-via-``None`` and unconstrained-via-large-int cases collapse. Returns: Total qubits the traced program will simultaneously use. """ from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget - total_ancilla = d * d - 1 - effective = normalize_ancilla_budget(total_ancilla, ancilla_budget) - return d * d + effective + if (d is None) == (patch is None): + msg = "get_num_qubits requires exactly one of d=... or patch=..." + raise ValueError(msg) + + if patch is not None: + geom = patch.geometry + num_data = geom.num_data + total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) + else: + _validate_surface_memory_distance(d) + num_data = d * d + total_ancilla = d * d - 1 + + return num_data + normalize_ancilla_budget(total_ancilla, ancilla_budget) def generate_surface_code_module(d: int, *, ancilla_budget: int | None = None) -> str: @@ -516,9 +563,7 @@ def generate_surface_code_module(d: int, *, ancilla_budget: int | None = None) - Returns: Python/Guppy source code as a string """ - if d < 3 or d % 2 == 0: - msg = f"Distance must be odd >= 3, got {d}" - raise ValueError(msg) + _validate_surface_memory_distance(d) from pecos.qec.surface import SurfacePatch @@ -526,43 +571,54 @@ def generate_surface_code_module(d: int, *, ancilla_budget: int | None = None) - return generate_guppy_source(patch, ancilla_budget=ancilla_budget) -def get_surface_code_module(d: int, *, ancilla_budget: int | None = None) -> dict: - """Get a loaded surface code module for distance d. - - Cache key is widened to ``(d, effective_budget)`` so the - unconstrained-via-``None`` and unconstrained-via-large-int cases - collapse to one cached module. - - Args: - d: Code distance - ancilla_budget: Optional cap on simultaneously live ancillas +def _surface_code_module_for_patch(patch: "SurfacePatch", *, ancilla_budget: int | None = None) -> dict: + """Load + cache a surface-code module for an arbitrary patch. - Returns: - Dictionary with module contents and metadata + Cache key spans full patch identity (dx, dz, orientation, rotated) plus + the effective budget, so distinct geometries never collide and the + unconstrained-via-``None`` / unconstrained-via-large-int cases share one + entry. Module metadata is derived from the patch geometry (faithful for + asymmetric / non-rotated patches), not from a scalar distance. """ - from pecos.qec.surface import SurfacePatch from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget - total_ancilla = d * d - 1 + geom = patch.geometry + total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) - cache_key = (d, effective_budget) + cache_key = (patch.dx, patch.dz, geom.orientation.name, geom.rotated, effective_budget) if cache_key in _state.distance_module_cache: return _state.distance_module_cache[cache_key] - patch = SurfacePatch.create(distance=d) module = _load_guppy_module(patch, ancilla_budget=ancilla_budget) - # Add metadata - module["distance"] = d - module["num_data"] = d * d - module["num_stab"] = (d * d - 1) // 2 + # Metadata derived from the actual patch geometry. + module["distance"] = patch.distance + module["num_data"] = geom.num_data + module["num_stab"] = total_ancilla module["ancilla_budget"] = effective_budget _state.distance_module_cache[cache_key] = module return module +def get_surface_code_module(d: int, *, ancilla_budget: int | None = None) -> dict: + """Get a loaded surface code module for distance d. + + Args: + d: Code distance (must be odd >= 3) + ancilla_budget: Optional cap on simultaneously live ancillas + + Returns: + Dictionary with module contents and metadata + """ + from pecos.qec.surface import SurfacePatch + + _validate_surface_memory_distance(d) + patch = SurfacePatch.create(distance=d) + return _surface_code_module_for_patch(patch, ancilla_budget=ancilla_budget) + + def make_surface_code( distance: int, num_rounds: int, diff --git a/python/quantum-pecos/src/pecos/qec/surface/__init__.py b/python/quantum-pecos/src/pecos/qec/surface/__init__.py index fb702c3f1..5aee25b2e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/surface/__init__.py @@ -40,10 +40,6 @@ get_detector_descriptors_from_tick_circuit, get_measurement_order_from_tick_circuit, get_observable_descriptors_from_tick_circuit, - get_stabilizer_region, - get_stabilizer_schedule_entries, - get_stabilizer_schedule_metadata, - get_stabilizer_touch_label, tick_circuit_to_stim, ) from pecos.qec.surface.circuit_builder import ( @@ -99,6 +95,10 @@ SurfacePatch, SurfacePatchBuilder, SurfacePatchDescriptor, + get_stabilizer_region, + get_stabilizer_schedule_entries, + get_stabilizer_schedule_metadata, + get_stabilizer_touch_label, ) from pecos.qec.surface.plot import plot_patch, plot_surface_code from pecos.qec.surface.schedule import ( diff --git a/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py index b375bd389..7928b8e95 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py @@ -68,10 +68,13 @@ def batched_stabilizers( ``ancilla_budget`` stabilizers each; within each batch every stabilizer is measured concurrently using one ancilla qubit. - The stabilizer order is **load-bearing** and shared between the - abstract circuit and the Guppy emitter: ascending stabilizer index, - X before Z on ties. Any change here will diverge the abstract DEM - from the traced-Guppy DEM in the Selene parity tests; preserve it. + The stabilizer order is **load-bearing** production semantics shared by + the abstract circuit and the Guppy emitter: ascending stabilizer index, + X before Z on ties. Note the traced-vs-traced Selene parity tests cannot + catch a regression here -- both sides import this one helper, so a policy + change moves them together. The concrete batch-order and source-level + CX-emission pins (``tests/qec/surface/test_ancilla_batching.py``) are what + actually guard this order; preserve it. ``ancilla_budget`` is validated through :func:`normalize_ancilla_budget` (rejects ``None``, ``bool``, diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 92a4ae419..f88604c10 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -33,10 +33,17 @@ normalize_ancilla_budget as _normalize_ancilla_budget, ) +# Stabilizer geometry helpers live in the low-level patch module (single +# source of truth). Only the two used by the circuit renderer are imported +# here; the full set is exported publicly from the package __init__. +from pecos.qec.surface.patch import ( + get_stabilizer_region, + get_stabilizer_touch_label, +) + if TYPE_CHECKING: from pecos.qec.surface.patch import ( LogicalDescriptor, - Stabilizer, StabilizerDescriptor, SurfacePatch, SurfacePatchDescriptor, @@ -375,70 +382,6 @@ def classify_stabilizer_boundary(stab_type: str, data_qubits: tuple[int, ...], d return _classify_boundary(stab_type, data_qubits, d, dz) -def get_stabilizer_region(stab: Stabilizer, patch: SurfacePatch) -> str: - """Return a coarse region label like ``top+left`` for a stabilizer.""" - geom = patch.geometry - positions = [geom.id_to_pos[q] for q in stab.data_qubits] - avg_row = sum(row for row, _ in positions) / len(positions) - avg_col = sum(col for _, col in positions) / len(positions) - row_label = "top" if avg_row < (geom.dx - 1) / 2 else "bottom" - col_label = "left" if avg_col < (geom.dz - 1) / 2 else "right" - return f"{row_label}+{col_label}" - - -def get_stabilizer_touch_label(stab: Stabilizer, patch: SurfacePatch, data_qubit: int) -> str: - """Label how a data qubit sits relative to a stabilizer support.""" - geom = patch.geometry - if data_qubit not in stab.data_qubits: - msg = f"Qubit {data_qubit} is not in stabilizer {stab.stab_type}{stab.index}" - raise ValueError(msg) - - positions = [geom.id_to_pos[q] for q in stab.data_qubits] - data_row, data_col = geom.id_to_pos[data_qubit] - rows = [row for row, _ in positions] - cols = [col for _, col in positions] - - if len(set(rows)) == 1: - return "left" if data_col == min(cols) else "right" - if len(set(cols)) == 1: - return "top" if data_row == min(rows) else "bottom" - - vertical = "T" if data_row == min(rows) else "B" - horizontal = "L" if data_col == min(cols) else "R" - return vertical + horizontal - - -def get_stabilizer_schedule_entries(stab: Stabilizer, patch: SurfacePatch) -> list[dict[str, int | str]]: - """Return the per-round touch schedule for one stabilizer.""" - from pecos.qec.surface.schedule import get_stab_schedule - - schedule = get_stab_schedule(stab.stab_type, stab.data_qubits, stab.is_boundary, patch.dx, patch.dz) - return [ - { - "round_0based": round_0based, - "data_qubit": data_qubit, - "touch_label": get_stabilizer_touch_label(stab, patch, data_qubit), - } - for round_0based, data_qubit in schedule - ] - - -def get_stabilizer_schedule_metadata(stab: Stabilizer, patch: SurfacePatch) -> dict[str, object]: - """Return metadata describing one stabilizer's schedule and geometry.""" - entries = get_stabilizer_schedule_entries(stab, patch) - rounds = [int(entry["round_0based"]) for entry in entries] - return { - "stabilizer_kind": stab.stab_type, - "stabilizer_index": stab.index, - "stabilizer_is_boundary": stab.is_boundary, - "stabilizer_region": get_stabilizer_region(stab, patch), - "schedule_rounds": rounds, - "schedule_start_round": rounds[0] if rounds else None, - "schedule_end_round": rounds[-1] if rounds else None, - "schedule_entries": entries, - } - - def _build_detector_descriptors( detectors: list[dict[str, object]], patch: SurfacePatch, diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index b95e384ba..023915000 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -800,19 +800,26 @@ def _generate_traced_surface_tick_circuit( (one ancilla per stabilizer, all measured at the end of one round). With a finite budget, emits the stabilizer-batched program; Selene's lowering reuses ancilla slots across batches so the traced TickCircuit - uses only ``d^2 + min(budget, d^2-1)`` physical qubits simultaneously. + uses only ``num_data + min(budget, total_ancilla)`` physical qubits + simultaneously. + + The program and qubit count are derived from the **actual patch**, not + its scalar distance, so a non-default patch (non-rotated, asymmetric) is + traced faithfully rather than silently substituting the default rotated + patch of the same distance. """ - from pecos.guppy import get_num_qubits, make_surface_code + from pecos.guppy import get_num_qubits + from pecos.guppy.surface import generate_memory_experiment - program = make_surface_code( - distance=patch.distance, - num_rounds=num_rounds, - basis=basis, + program = generate_memory_experiment( + patch, + num_rounds, + basis, ancilla_budget=ancilla_budget, ) return trace_guppy_into_tick_circuit( program, - get_num_qubits(patch.distance, ancilla_budget=ancilla_budget), + get_num_qubits(patch=patch, ancilla_budget=ancilla_budget), seed=0, ) diff --git a/python/quantum-pecos/src/pecos/qec/surface/patch.py b/python/quantum-pecos/src/pecos/qec/surface/patch.py index ef48dd28b..82bb28f62 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/patch.py +++ b/python/quantum-pecos/src/pecos/qec/surface/patch.py @@ -107,7 +107,14 @@ class LogicalDescriptor(TypedDict): support_axis: str -def _get_stabilizer_region(stab: Stabilizer, patch: SurfacePatch) -> str: +# --- Stabilizer geometry/schedule metadata (single source of truth) --------- +# These live here (the low-level geometry module) and are re-exposed by +# ``circuit_builder`` as the public API; both the abstract circuit's detector +# descriptors and ``SurfacePatch.get_stabilizer_descriptor`` consume them, so a +# single implementation prevents the two sides from silently diverging. + + +def get_stabilizer_region(stab: Stabilizer, patch: SurfacePatch) -> str: """Return a coarse region label like ``top+left`` for a stabilizer.""" geom = patch.geometry positions = [geom.id_to_pos[q] for q in stab.data_qubits] @@ -118,7 +125,7 @@ def _get_stabilizer_region(stab: Stabilizer, patch: SurfacePatch) -> str: return f"{row_label}+{col_label}" -def _get_stabilizer_touch_label(stab: Stabilizer, patch: SurfacePatch, data_qubit: int) -> str: +def get_stabilizer_touch_label(stab: Stabilizer, patch: SurfacePatch, data_qubit: int) -> str: """Label how a data qubit sits relative to a stabilizer support.""" geom = patch.geometry if data_qubit not in stab.data_qubits: @@ -140,13 +147,13 @@ def _get_stabilizer_touch_label(stab: Stabilizer, patch: SurfacePatch, data_qubi return vertical + horizontal -def _get_stabilizer_schedule_metadata(stab: Stabilizer, patch: SurfacePatch) -> dict[str, object]: - """Return metadata describing one stabilizer's schedule and geometry.""" - entries: list[StabilizerScheduleEntry] = [ +def get_stabilizer_schedule_entries(stab: Stabilizer, patch: SurfacePatch) -> list[StabilizerScheduleEntry]: + """Return the per-round touch schedule for one stabilizer.""" + return [ { "round_0based": round_0based, "data_qubit": data_qubit, - "touch_label": _get_stabilizer_touch_label(stab, patch, data_qubit), + "touch_label": get_stabilizer_touch_label(stab, patch, data_qubit), } for round_0based, data_qubit in get_stab_schedule( stab.stab_type, @@ -156,12 +163,17 @@ def _get_stabilizer_schedule_metadata(stab: Stabilizer, patch: SurfacePatch) -> patch.dz, ) ] + + +def get_stabilizer_schedule_metadata(stab: Stabilizer, patch: SurfacePatch) -> dict[str, object]: + """Return metadata describing one stabilizer's schedule and geometry.""" + entries = get_stabilizer_schedule_entries(stab, patch) rounds = [int(entry["round_0based"]) for entry in entries] return { "stabilizer_kind": stab.stab_type, "stabilizer_index": stab.index, "stabilizer_is_boundary": stab.is_boundary, - "stabilizer_region": _get_stabilizer_region(stab, patch), + "stabilizer_region": get_stabilizer_region(stab, patch), "schedule_rounds": rounds, "schedule_start_round": rounds[0] if rounds else None, "schedule_end_round": rounds[-1] if rounds else None, @@ -401,7 +413,7 @@ def get_stabilizer_descriptor( """Return one public stabilizer descriptor.""" stabs = self.x_stabilizers if stab_type.upper() == "X" else self.z_stabilizers stab = stabs[index] - metadata = _get_stabilizer_schedule_metadata(stab, self) + metadata = get_stabilizer_schedule_metadata(stab, self) positions = [list(self.geometry.id_to_pos[q]) for q in stab.data_qubits] return { **metadata, diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py index 26704f575..3db86f799 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -443,6 +443,64 @@ def test_constrained_budget_sampler_builds_for_all_models(self) -> None: ) assert sampler.num_detectors == expected_detectors + def test_traced_qis_traces_the_given_patch_not_its_distance(self) -> None: + """A non-rotated patch must be traced from its OWN Guppy program, not + the default rotated patch of the same distance. Before the patch- + identity fix, the traced path rebuilt make_surface_code(distance=d) and + the module cache keyed on dx/dz only, so rotated and non-rotated d=3 + collapsed to one cached (rotated) module and produced identical DEMs. + They must now differ.""" + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + + _require_selene_runtime() + params = {"p1": 0.005, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} + + def traced_dem(*, rotated: bool) -> str: + patch = SurfacePatch.create(distance=3, rotated=rotated) + tc = _build_surface_tick_circuit_for_native_model(patch, 2, "Z", circuit_source="traced_qis") + return generate_dem_from_tick_circuit(tc, **params, decompose_errors=False) + + assert traced_dem(rotated=True) != traced_dem(rotated=False) + + def test_guppy_module_cache_keys_on_full_patch_identity(self) -> None: + """Rotated and non-rotated patches of the same dx/dz/budget must NOT + share a cached Guppy module (they generate different circuits).""" + from pecos.guppy.surface import _load_guppy_module + + rotated = _load_guppy_module(SurfacePatch.create(distance=3, rotated=True), ancilla_budget=2) + non_rotated = _load_guppy_module(SurfacePatch.create(distance=3, rotated=False), ancilla_budget=2) + assert rotated is not non_rotated + + def test_surface_memory_distance_validation_is_consistent(self) -> None: + """All distance-based Guppy entry points enforce the documented + 'odd >= 3' contract (previously make_surface_code/get_surface_code_module + accepted even/<3 and get_num_qubits(0) returned -1).""" + from pecos.guppy.surface import ( + generate_surface_code_module, + get_num_qubits, + get_surface_code_module, + make_surface_code, + ) + + for bad in (0, 1, 2, 4): + with pytest.raises(ValueError, match=r"odd >= 3"): + get_num_qubits(bad) + with pytest.raises(ValueError, match=r"odd >= 3"): + get_surface_code_module(bad) + with pytest.raises(ValueError, match=r"odd >= 3"): + make_surface_code(distance=bad, num_rounds=2, basis="Z") + with pytest.raises(ValueError, match=r"odd >= 3"): + generate_surface_code_module(bad) + assert get_num_qubits(3) == 2 * 9 - 1 # valid distance still works + + def test_get_num_qubits_requires_exactly_one_of_d_or_patch(self) -> None: + from pecos.guppy.surface import get_num_qubits + + with pytest.raises(ValueError, match=r"exactly one of"): + get_num_qubits() + with pytest.raises(ValueError, match=r"exactly one of"): + get_num_qubits(3, patch=SurfacePatch.create(distance=3)) + def test_native_circuit_level_dem_cache_respects_patch_geometry(self) -> None: """Shared native DEM caching should preserve asymmetric patch geometry.""" from pecos.qec.surface.circuit_builder import generate_dem_from_tick_circuit, generate_tick_circuit_from_patch diff --git a/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py b/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py index 84789f454..5fb613241 100644 --- a/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py +++ b/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py @@ -144,3 +144,122 @@ def test_public_dem_builder_consistent_num_measurements_still_builds() -> None: builder.with_num_measurements(1) builder.with_detectors_json('[{"id": 0, "records": [-1]}]') assert builder.build().num_detectors == 1 + + +# --- DemSamplerBuilder JSON path (M-E): context-aware fail-loud ------------- +# The public sampler builder previously parsed detector/observable JSON with a +# hand-rolled string scanner that silently dropped out-of-range refs. It now +# resolves refs against the circuit's measurement count, like DemBuilder. + + +def test_dem_sampler_builder_out_of_range_record_fails_loud() -> None: + from pecos_rslib.qec import DemSamplerBuilder + + im = DagFaultAnalyzer(_one_measurement_dag()).build_influence_map() + builder = DemSamplerBuilder(im).with_noise(**_NOISE).with_detectors_json( + '[{"id": 0, "records": [-1, -2]}]', # -2 out of range for 1 measurement + ) + with pytest.raises(ValueError, match=r"out of range"): + builder.build() + + +def test_dem_sampler_builder_out_of_range_observable_fails_loud() -> None: + from pecos_rslib.qec import DemSamplerBuilder + + im = DagFaultAnalyzer(_one_measurement_dag()).build_influence_map() + builder = DemSamplerBuilder(im).with_noise(**_NOISE).with_observables_json( + '[{"id": 0, "records": [-1, -2]}]', + ) + with pytest.raises(ValueError, match=r"out of range"): + builder.build() + + +def test_dem_sampler_builder_out_of_range_meas_id_fails_loud() -> None: + from pecos_rslib.qec import DemSamplerBuilder + + im = DagFaultAnalyzer(_one_measurement_dag()).build_influence_map() + builder = DemSamplerBuilder(im).with_noise(**_NOISE).with_detectors_json( + '[{"id": 0, "meas_ids": [0, 999]}]', # 999 absent / out of range + ) + with pytest.raises(ValueError, match=r"not present|out of range"): + builder.build() + + +def test_dem_sampler_builder_valid_metadata_still_builds() -> None: + """Positive control: an in-range record still builds.""" + from pecos_rslib.qec import DemSamplerBuilder + + im = DagFaultAnalyzer(_one_measurement_dag()).build_influence_map() + sampler = DemSamplerBuilder(im).with_noise(**_NOISE).with_detectors_json( + '[{"id": 0, "records": [-1]}]', + ).build() + assert sampler is not None + + +def test_dem_sampler_builder_resolves_stamped_meas_ids() -> None: + """meas_ids are stamped MeasIds resolved via the influence map (matching + DemBuilder), not positional indices. A stamped id present in the circuit + resolves; a value absent from the stamped set fails loud. Previously the + sampler treated meas_ids positionally, so a stamped id raised 'out of range' + and an absent id silently misbound.""" + from pecos_rslib.qec import DemSamplerBuilder + from pecos_rslib.quantum import TickCircuit + + tc = TickCircuit() + tc.tick().pz([0, 1]) + tc.tick().mz_with_ids([0, 1], [10, 5]) # non-positional stamped ids + im = DagFaultAnalyzer(tc.to_dag_circuit()).build_influence_map() + + # Stamped id 10 is present -> resolves and builds. + DemSamplerBuilder(im).with_noise(**_NOISE).with_detectors_json( + '[{"id": 0, "meas_ids": [10]}]', + ).build() + + # Stamped id 0 is absent -> fail loud (positional would have accepted index 0). + builder = DemSamplerBuilder(im).with_noise(**_NOISE).with_detectors_json( + '[{"id": 0, "meas_ids": [0]}]', + ) + with pytest.raises(ValueError, match=r"not present|out of range"): + builder.build() + + +def test_dem_sampler_builder_rejects_inconsistent_measurement_order() -> None: + """A measurement_order must cover every measurement; a shorter order would + let validated record offsets resolve in a different frame and silently + mis-map (the count-frame hole).""" + from pecos_rslib.qec import DemSamplerBuilder + + dag = DagCircuit() + for q in range(3): + dag.pz([q]) + dag.mz([q]) + dag.set_attr("num_measurements", "3") + im = DagFaultAnalyzer(dag).build_influence_map() + + builder = ( + DemSamplerBuilder(im) + .with_noise(**_NOISE) + .with_detectors_json('[{"id": 0, "records": [-3]}]') + .with_measurement_order([0, 1]) # only 2 of 3 measurements + ) + with pytest.raises(ValueError, match=r"measurement_order|cover every measurement"): + builder.build() + + +def test_dem_sampler_builder_rejects_duplicate_stamped_meas_ids() -> None: + """Duplicate stable MeasIds make stamped-id resolution ambiguous (bind to + the first occurrence). DemBuilder rejects them; the sampler JSON path must + too, rather than silently binding.""" + from pecos_rslib.qec import DemSamplerBuilder + from pecos_rslib.quantum import TickCircuit + + tc = TickCircuit() + tc.tick().pz([0, 1]) + tc.tick().mz_with_ids([0, 1], [7, 7]) # duplicate stamped id 7 + im = DagFaultAnalyzer(tc.to_dag_circuit()).build_influence_map() + + builder = DemSamplerBuilder(im).with_noise(**_NOISE).with_detectors_json( + '[{"id": 0, "meas_ids": [7]}]', + ) + with pytest.raises(ValueError, match=r"duplicate stable MeasId"): + builder.build() From 791b4be9793d4974a6ff0a697c2bf035fa74661a Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 21 May 2026 08:43:45 -0600 Subject: [PATCH 36/36] Apply just lint: rustfmt, black, doc-markdown backticks, and typo fix --- .../fault_tolerance/dem_builder/builder.rs | 19 +++++-- .../dem_builder/dem_sampler.rs | 20 +++++-- .../fault_tolerance/dem_builder/sampler.rs | 2 +- .../pecos/qec/surface/_ancilla_batching.py | 5 +- .../src/pecos/qec/surface/decode.py | 3 +- .../tests/qec/surface/test_surface_decoder.py | 44 ++++++++++++--- .../tests/qec/test_dem_metadata_fail_loud.py | 53 ++++++++++++++----- 7 files changed, 110 insertions(+), 36 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 268ce918d..b6ce524d5 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -1454,7 +1454,9 @@ pub(crate) fn parse_detector_record_vectors( reject_duplicate_stamped_meas_ids(influence_map)?; parse_detectors_json(json)? .iter() - .map(|d| resolve_sampler_record_vector("Detector", d.id, &d.records, &d.meas_ids, influence_map)) + .map(|d| { + resolve_sampler_record_vector("Detector", d.id, &d.records, &d.meas_ids, influence_map) + }) .collect() } @@ -1466,7 +1468,15 @@ pub(crate) fn parse_observable_record_vectors( reject_duplicate_stamped_meas_ids(influence_map)?; parse_observables_json(json)? .iter() - .map(|o| resolve_sampler_record_vector("Observable", o.id, &o.records, &o.meas_ids, influence_map)) + .map(|o| { + resolve_sampler_record_vector( + "Observable", + o.id, + &o.records, + &o.meas_ids, + influence_map, + ) + }) .collect() } @@ -1499,7 +1509,10 @@ fn resolve_sampler_meas_id(influence_map: &DagFaultInfluenceMap, meas_id: usize) if influence_map.meas_ids.is_empty() { (meas_id < influence_map.measurements.len()).then_some(meas_id) } else { - influence_map.meas_ids.iter().position(|mid| mid.0 == meas_id) + influence_map + .meas_ids + .iter() + .position(|mid| mid.0 == meas_id) } } diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs index 9b2da0b07..72c102c43 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs @@ -2662,8 +2662,10 @@ mod tests { } /// Build an influence map for a circuit with `n` independent measurements - /// (no stable `MeasId`s, so meas_ids resolve positionally). - fn im_with_n_measurements(n: usize) -> crate::fault_tolerance::propagator::DagFaultInfluenceMap { + /// (no stable `MeasId`s, so `meas_ids` resolve positionally). + fn im_with_n_measurements( + n: usize, + ) -> crate::fault_tolerance::propagator::DagFaultInfluenceMap { use crate::fault_tolerance::propagator::DagFaultAnalyzer; use pecos_quantum::DagCircuit; let mut dag = DagCircuit::new(); @@ -2712,7 +2714,9 @@ mod tests { // downstream (the M-E sampler-validation gap). meas_ids resolve // positionally here because these circuits carry no stable ids; the // stamped-MeasId semantic is exercised from Python (mz_with_ids). - use super::super::builder::{parse_detector_record_vectors, parse_observable_record_vectors}; + use super::super::builder::{ + parse_detector_record_vectors, parse_observable_record_vectors, + }; let im1 = im_with_n_measurements(1); let im3 = im_with_n_measurements(3); let im0 = im_with_n_measurements(0); @@ -2724,9 +2728,15 @@ mod tests { assert!(parse_detector_record_vectors(r#"[{"id":0,"meas_ids":[0,999]}]"#, &im1).is_err()); // Non-redundant co-present records + meas_ids (3-measurement circuit: // records[-1] -> index 2, meas_ids[0] -> index 0). - assert!(parse_detector_record_vectors(r#"[{"id":0,"records":[-1],"meas_ids":[0]}]"#, &im3).is_err()); + assert!( + parse_detector_record_vectors(r#"[{"id":0,"records":[-1],"meas_ids":[0]}]"#, &im3) + .is_err() + ); // Redundant co-presence is accepted (both -> index 0). - assert!(parse_detector_record_vectors(r#"[{"id":0,"records":[-1],"meas_ids":[0]}]"#, &im1).is_ok()); + assert!( + parse_detector_record_vectors(r#"[{"id":0,"records":[-1],"meas_ids":[0]}]"#, &im1) + .is_ok() + ); // Empty influence map keeps the opaque escape hatch (no range check). assert!(parse_detector_record_vectors(r#"[{"id":0,"records":[-1,-99]}]"#, &im0).is_ok()); } diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs index 76c09ced5..53d29c1c5 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -1219,7 +1219,7 @@ impl<'a> DemSamplerBuilder<'a> { // A supplied measurement order must cover every measurement, otherwise // detector/observable record offsets validated against the circuit's // measurement count would resolve in a different (shorter/longer) frame - // at sample time and silently mis-map. (See sampler-JSON validation.) + // at sample time and silently misbind. (See sampler-JSON validation.) if let Some(ref order) = self.measurement_order { let expected = self.influence_map.measurements.len(); if order.len() != expected { diff --git a/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py index 7928b8e95..256b8a565 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py @@ -91,7 +91,4 @@ def batched_stabilizers( stabilizers.extend(("Z", stab.index) for stab in geom.z_stabilizers) stabilizers.sort(key=lambda stab: (stab[1], 0 if stab[0] == "X" else 1)) - return [ - stabilizers[start : start + effective_budget] - for start in range(0, len(stabilizers), effective_budget) - ] + return [stabilizers[start : start + effective_budget] for start in range(0, len(stabilizers), effective_budget)] diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 023915000..e65994390 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -701,8 +701,7 @@ def _chunk_has_lowerable_op(chunk: dict[str, Any]) -> bool: those legitimately has no lowered ops. """ return any( - isinstance(op, dict) and ("Quantum" in op or "AllocateQubit" in op) - for op in (chunk.get("operations") or []) + isinstance(op, dict) and ("Quantum" in op or "AllocateQubit" in op) for op in (chunk.get("operations") or []) ) diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py index 3db86f799..0cdcd1486 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -382,17 +382,30 @@ def test_constrained_budget_uses_cache_and_matches_fresh_build(self) -> None: # abstract source abstract_tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis="Z", ancilla_budget=2) cached_abstract = generate_circuit_level_dem_from_builder( - patch, num_rounds=2, noise=noise, basis="Z", ancilla_budget=2, + patch, + num_rounds=2, + noise=noise, + basis="Z", + ancilla_budget=2, ) assert cached_abstract == generate_dem_from_tick_circuit(abstract_tc, **params, decompose_errors=False) # traced_qis source _require_selene_runtime() traced_tc = _build_surface_tick_circuit_for_native_model( - patch, 2, "Z", ancilla_budget=2, circuit_source="traced_qis", + patch, + 2, + "Z", + ancilla_budget=2, + circuit_source="traced_qis", ) cached_traced = generate_circuit_level_dem_from_builder( - patch, num_rounds=2, noise=noise, basis="Z", ancilla_budget=2, circuit_source="traced_qis", + patch, + num_rounds=2, + noise=noise, + basis="Z", + ancilla_budget=2, + circuit_source="traced_qis", ) assert cached_traced == generate_dem_from_tick_circuit(traced_tc, **params, decompose_errors=False) @@ -416,10 +429,18 @@ def test_unconstrained_budget_spellings_collapse_to_one_dem(self) -> None: dem_none = generate_circuit_level_dem_from_builder(patch, num_rounds=2, noise=noise, basis="Z") dem_total = generate_circuit_level_dem_from_builder( - patch, num_rounds=2, noise=noise, basis="Z", ancilla_budget=total, + patch, + num_rounds=2, + noise=noise, + basis="Z", + ancilla_budget=total, ) dem_huge = generate_circuit_level_dem_from_builder( - patch, num_rounds=2, noise=noise, basis="Z", ancilla_budget=10**6, + patch, + num_rounds=2, + noise=noise, + basis="Z", + ancilla_budget=10**6, ) assert dem_none == dem_total == dem_huge @@ -433,13 +454,22 @@ def test_constrained_budget_sampler_builds_for_all_models(self) -> None: patch = SurfacePatch.create(distance=3) noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) abstract_tc = _build_surface_tick_circuit_for_native_model( - patch, 2, "Z", ancilla_budget=2, circuit_source="abstract", + patch, + 2, + "Z", + ancilla_budget=2, + circuit_source="abstract", ) expected_detectors = int(abstract_tc.get_meta("num_detectors")) for model in ("dem", "influence_dem", "mnm"): sampler = build_native_sampler( - patch, num_rounds=2, noise=noise, basis="Z", ancilla_budget=2, sampling_model=model, + patch, + num_rounds=2, + noise=noise, + basis="Z", + ancilla_budget=2, + sampling_model=model, ) assert sampler.num_detectors == expected_detectors diff --git a/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py b/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py index 5fb613241..97da0d66a 100644 --- a/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py +++ b/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py @@ -156,8 +156,12 @@ def test_dem_sampler_builder_out_of_range_record_fails_loud() -> None: from pecos_rslib.qec import DemSamplerBuilder im = DagFaultAnalyzer(_one_measurement_dag()).build_influence_map() - builder = DemSamplerBuilder(im).with_noise(**_NOISE).with_detectors_json( - '[{"id": 0, "records": [-1, -2]}]', # -2 out of range for 1 measurement + builder = ( + DemSamplerBuilder(im) + .with_noise(**_NOISE) + .with_detectors_json( + '[{"id": 0, "records": [-1, -2]}]', # -2 out of range for 1 measurement + ) ) with pytest.raises(ValueError, match=r"out of range"): builder.build() @@ -167,8 +171,12 @@ def test_dem_sampler_builder_out_of_range_observable_fails_loud() -> None: from pecos_rslib.qec import DemSamplerBuilder im = DagFaultAnalyzer(_one_measurement_dag()).build_influence_map() - builder = DemSamplerBuilder(im).with_noise(**_NOISE).with_observables_json( - '[{"id": 0, "records": [-1, -2]}]', + builder = ( + DemSamplerBuilder(im) + .with_noise(**_NOISE) + .with_observables_json( + '[{"id": 0, "records": [-1, -2]}]', + ) ) with pytest.raises(ValueError, match=r"out of range"): builder.build() @@ -178,8 +186,12 @@ def test_dem_sampler_builder_out_of_range_meas_id_fails_loud() -> None: from pecos_rslib.qec import DemSamplerBuilder im = DagFaultAnalyzer(_one_measurement_dag()).build_influence_map() - builder = DemSamplerBuilder(im).with_noise(**_NOISE).with_detectors_json( - '[{"id": 0, "meas_ids": [0, 999]}]', # 999 absent / out of range + builder = ( + DemSamplerBuilder(im) + .with_noise(**_NOISE) + .with_detectors_json( + '[{"id": 0, "meas_ids": [0, 999]}]', # 999 absent / out of range + ) ) with pytest.raises(ValueError, match=r"not present|out of range"): builder.build() @@ -190,9 +202,14 @@ def test_dem_sampler_builder_valid_metadata_still_builds() -> None: from pecos_rslib.qec import DemSamplerBuilder im = DagFaultAnalyzer(_one_measurement_dag()).build_influence_map() - sampler = DemSamplerBuilder(im).with_noise(**_NOISE).with_detectors_json( - '[{"id": 0, "records": [-1]}]', - ).build() + sampler = ( + DemSamplerBuilder(im) + .with_noise(**_NOISE) + .with_detectors_json( + '[{"id": 0, "records": [-1]}]', + ) + .build() + ) assert sampler is not None @@ -216,8 +233,12 @@ def test_dem_sampler_builder_resolves_stamped_meas_ids() -> None: ).build() # Stamped id 0 is absent -> fail loud (positional would have accepted index 0). - builder = DemSamplerBuilder(im).with_noise(**_NOISE).with_detectors_json( - '[{"id": 0, "meas_ids": [0]}]', + builder = ( + DemSamplerBuilder(im) + .with_noise(**_NOISE) + .with_detectors_json( + '[{"id": 0, "meas_ids": [0]}]', + ) ) with pytest.raises(ValueError, match=r"not present|out of range"): builder.build() @@ -226,7 +247,7 @@ def test_dem_sampler_builder_resolves_stamped_meas_ids() -> None: def test_dem_sampler_builder_rejects_inconsistent_measurement_order() -> None: """A measurement_order must cover every measurement; a shorter order would let validated record offsets resolve in a different frame and silently - mis-map (the count-frame hole).""" + misbind (the count-frame hole).""" from pecos_rslib.qec import DemSamplerBuilder dag = DagCircuit() @@ -258,8 +279,12 @@ def test_dem_sampler_builder_rejects_duplicate_stamped_meas_ids() -> None: tc.tick().mz_with_ids([0, 1], [7, 7]) # duplicate stamped id 7 im = DagFaultAnalyzer(tc.to_dag_circuit()).build_influence_map() - builder = DemSamplerBuilder(im).with_noise(**_NOISE).with_detectors_json( - '[{"id": 0, "meas_ids": [7]}]', + builder = ( + DemSamplerBuilder(im) + .with_noise(**_NOISE) + .with_detectors_json( + '[{"id": 0, "meas_ids": [7]}]', + ) ) with pytest.raises(ValueError, match=r"duplicate stable MeasId"): builder.build()