From f9c30efb4b3edeaa4488af09f8094a33c44d45e3 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 14:51:47 -0600 Subject: [PATCH 01/62] Add idle-gate passes and fail-loud idle-noise guard to the Guppy DEM pipeline, with user-guide coverage --- docs/user-guide/decoders.md | 26 +- docs/user-guide/dem-from-guppy.md | 222 +++++++++++++++++- python/quantum-pecos/src/pecos/qec/dem.py | 153 +++++++++++- .../tests/qec/test_from_guppy_dem.py | 200 +++++++++++++++- 4 files changed, 585 insertions(+), 16 deletions(-) diff --git a/docs/user-guide/decoders.md b/docs/user-guide/decoders.md index e6588ee75..25b7623fc 100644 --- a/docs/user-guide/decoders.md +++ b/docs/user-guide/decoders.md @@ -30,12 +30,24 @@ The decoder system in PECOS is designed around modularity and performance: ### Python Decoders -The following decoders are currently available in Python: - -| Decoder | Description | Use Case | -|---------|-------------|----------| -| `MWPM2D` | Minimum Weight Perfect Matching for 2D codes | Surface codes, repetition codes | -| `DummyDecoder` | No-op decoder for testing | Testing and benchmarking | +The following decoder APIs and supporting types are publicly re-exported from +`pecos.decoders`: + +| API | Primary input | Description | +|-----|---------------|-------------| +| `MWPM2D` | QECC object | Legacy minimum-weight perfect matching for 2D codes. | +| `DummyDecoder` | None | No-op decoder for tests and interface benchmarks. | +| `PyMatchingDecoder` | Graph-like DEM text or `CheckMatrix` | PyMatching minimum-weight perfect matching, with optional correlated decoding. | +| `FusionBlossomDecoder` | Check matrix, standard-code parameters, or a manual graph | Pure-Rust minimum-weight perfect matching. | +| `TesseractDecoder` | DEM text | Search-based decoder that accepts raw hyperedges. | +| `DemAwareDecoder` | DEM text | Maps DEM mechanisms and observables onto BP-OSD and other check-matrix decoders. | +| `BpOsdBuilder` / `BpOsdDecoder` | `SparseMatrix` check matrix | Belief propagation with ordered-statistics post-processing. | +| `BpLsdBuilder` / `BpLsdDecoder` | `SparseMatrix` check matrix | Belief propagation with localized-statistics post-processing. | +| `MinSumBpBuilder` / `MinSumBpDecoder` | Dense check matrix and error priors | Min-sum belief propagation. | +| `RelayBpBuilder` / `RelayBpDecoder` | Dense check matrix and error priors | Relay belief propagation. | +| `UnionFindBuilder` / `UnionFindDecoder` | `SparseMatrix` check matrix | Union-find decoding with inversion or peeling. | +| `CheckMatrix` / `SparseMatrix` | Dense or coordinate-form matrix data | Matrix containers used by matching and LDPC decoder constructors. | +| `MwpmResult` / `BpResult` / `TesseractResult` | Decoder output | Result objects for matching, belief-propagation, and Tesseract decoders. | ### Rust Decoders @@ -68,7 +80,7 @@ The Rust API provides access to a broader set of decoders: pip install quantum-pecos ``` - The Python decoders (`MWPM2D`, `DummyDecoder`) are included by default. + The Python package exports the decoder APIs listed above. === ":fontawesome-brands-rust: Rust" diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index c6a593250..e0736a8bf 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -11,7 +11,10 @@ a logical circuit you intend to run on a Selene-compatible runtime. - Referencing measurements with `records`, `meas_ids`, and `result_tags` - Building a DEM for a generated surface-code memory experiment - Sampling and decoding from the resulting DEM -- Choosing the Selene runtime, and the limitations to know about +- Adding explicit idle gates so idle-noise parameters take effect +- Exporting native Stim DEM text and graph-like projections +- Comparing PyMatching, Tesseract, and BP-OSD on the same samples +- Choosing the Selene runtime and understanding the limitations ## Overview @@ -192,6 +195,215 @@ Because the trace records the runtime-lowered QIS operation stream, a runtime that schedules or lowers differently produces a (correctly) different DEM. +## Idle Noise + +The default Selene runtime does not emit idle gates. Idle-noise parameters +such as `p_idle`, `t1`/`t2`, and the `p_idle_*_rate` family therefore have no +locations to attach to unless the runtime supplies scheduled idles or you +insert them explicitly. `from_guppy` raises `ValueError` when any of these +parameters is supplied but the final traced circuit contains no `Idle` gates; +it does not silently build a DEM without the requested noise. + +Both `DetectorErrorModel.from_guppy` and `build_dem_from_guppy` accept two +passes for controlling those locations: + +- `strip_traced_idles=True` removes identity-like gates from the normalized + trace, including `I`, `Idle`, and zero-angle rotations. +- `idle_after_2q_duration=` inserts an `Idle` of that duration + on both qubits after every two-qubit gate. + +When both options are present, stripping runs before insertion. This is useful +when you want a consistent idle convention independent of the selected +runtime. + + +```python +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import cx, measure, qubit + +from pecos.qec import DetectorErrorModel + + +@guppy +def idle_demo() -> None: + q0, q1 = qubit(), qubit() + cx(q0, q1) + result("m0", measure(q0)) + result("m1", measure(q1)) + + +common = { + "num_qubits": 2, + "detectors_json": '[{"id": "D0", "result_tags": ["m0"]}]', + "observables_json": '[{"id": "L0", "result_tags": ["m1"]}]', + "p1": 0.0, + "p2": 0.0, + "p_meas": 0.0, + "p_prep": 0.0, + "seed": 0, +} + +without_idle_noise = DetectorErrorModel.from_guppy( + idle_demo, + idle_after_2q_duration=1.0, + **common, +) +with_idle_noise = DetectorErrorModel.from_guppy( + idle_demo, + idle_after_2q_duration=1.0, + p_idle=0.01, + **common, +) + + +def count_errors(model: DetectorErrorModel) -> int: + return model.to_string().count("error(") + + +assert count_errors(with_idle_noise) > count_errors(without_idle_noise) + +try: + DetectorErrorModel.from_guppy(idle_demo, p_idle=0.01, **common) +except ValueError as exc: + assert "idle-noise parameters have no idle gates" in str(exc) +else: + raise AssertionError("idle noise without Idle gates should fail") +``` + +Runtime-emitted idle durations are replayed as nanosecond `TimeUnits`. +Inserted idles instead carry the duration passed to +`idle_after_2q_duration`, which must be finite and positive. Linear and +sine-law idle rates are per time unit (for example, uniform idle noise uses +`p_idle * duration`, clamped to the probability range), while quadratic +rates multiply `duration**2` and therefore scale as inverse time squared. +T1 and T2 values must use the same units as the idle duration. + +## Exporting the DEM as Stim Text + +PECOS's native DEM text is the Stim DEM format; there is no separate +`to_stim()` conversion. `dem.to_string()` emits standard +`error(p) D... L...` mechanisms and can be parsed directly as a Stim detector +error model. + +`dem.to_string_decomposed()` uses decomposition components attached to the +original fault source, writing `^`-separated components when that provenance +is available. It preserves residual hyperedges when a true hyperedge has no +source-attached decomposition. Graph matchers instead need +`dem.to_string_terminal_graphlike_decomposed()`, an explicitly lossy +hyperedge-to-edge projection based on detector terminals rather than a proof +of source provenance. + + +```python +import stim +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import cx, measure, qubit + +from pecos.qec import DetectorErrorModel + + +@guppy +def idle_demo() -> None: + q0, q1 = qubit(), qubit() + cx(q0, q1) + result("m0", measure(q0)) + result("m1", measure(q1)) + + +dem = DetectorErrorModel.from_guppy( + idle_demo, + num_qubits=2, + detectors_json='[{"id": "D0", "result_tags": ["m0"]}]', + observables_json='[{"id": "L0", "result_tags": ["m1"]}]', + idle_after_2q_duration=1.0, + p1=0.0, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p_idle=0.01, + seed=0, +) + +raw_text = dem.to_string() +source_decomposed_text = dem.to_string_decomposed() +graphlike_text = dem.to_string_terminal_graphlike_decomposed() + +print(raw_text) +print(source_decomposed_text) +print(graphlike_text) +assert "error(" in raw_text +stim.DetectorErrorModel(raw_text) +stim.DetectorErrorModel(source_decomposed_text) +stim.DetectorErrorModel(graphlike_text) +``` + +## Decoding: PyMatching, Tesseract, and BP-OSD + +A sampled `SampleBatch` provides the uniform +`batch.decode_count(dem_text, name)` interface. The names used here are +`"pymatching"` (correlated matching by default), `"tesseract"`, and +`"bp_osd"`. Passing the same batch to each decoder compares them on identical +shots rather than on three independently sampled experiments. + + +```python +from pecos.decoders import DemAwareDecoder, TesseractDecoder +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.circuit_builder import generate_tick_circuit_from_patch + +patch = SurfacePatch.create(distance=3) +meta_tc = generate_tick_circuit_from_patch(patch, num_rounds=3, basis="Z") +dem = DetectorErrorModel.from_guppy( + make_surface_code(distance=3, num_rounds=3, basis="Z"), + num_qubits=get_num_qubits(3), + detectors_json=meta_tc.get_meta("detectors"), + observables_json=meta_tc.get_meta("observables"), + num_measurements=int(meta_tc.get_meta("num_measurements")), + p1=0.005, + p2=0.005, + p_meas=0.005, + p_prep=0.005, +) + +batch = dem.to_sampler().generate_samples(1000, 0) +error_counts = { + "pymatching": batch.decode_count( + dem.to_string_terminal_graphlike_decomposed(), + "pymatching", + ), + "tesseract": batch.decode_count( + dem.to_string_source_graphlike_decomposed(), + "tesseract", + ), + "bp_osd": batch.decode_count(dem.to_string(), "bp_osd"), +} +assert all(0 <= count <= batch.num_shots for count in error_counts.values()) +print(error_counts) + +# Construct a decoder directly when you need per-shot results. The "fast" +# preset matches the configuration decode_count(..., "tesseract") uses. +syndrome = batch.get_syndrome(0) +tesseract = TesseractDecoder.from_dem(dem.to_string(), preset="fast") +tesseract_result = tesseract.decode_syndrome(syndrome) +assert tesseract_result.observables_mask >= 0 + +bp_osd = DemAwareDecoder.from_dem(dem.to_string(), decoder_type="bp_osd") +bp_osd_result = bp_osd.decode_syndrome(syndrome) +assert bp_osd_result.observables_mask >= 0 +``` + +For direct PyMatching construction, use the +`PyMatchingDecoder.from_dem(...)` pattern in the +[surface-memory example](#surface-code-memory-dem). That DEM's source-attached +decomposition is already graph-like; in general, matching decoders require the +terminal-decomposed graph-like projection. Tesseract and BP-OSD can consume the +raw hyperedge DEM directly; the batch comparison above uses the established +source-graphlike form for Tesseract so it matches the QEC-with-Guppy workflow. + ## Limitations - **Measurement-dependent quantum control flow is unsupported and @@ -216,10 +428,10 @@ different DEM. - **`num_qubits` is required** for HUGR-bytes programs; use `get_num_qubits(...)` for the built-in generators. - **Idle noise needs idle gates.** The default simple runtime does not emit - explicit idles, while other compatible runtimes may emit scheduled idle - durations. Runtime-emitted idles are preserved in the traced circuit as - nanosecond `TimeUnits`; idle/T1/T2 noise parameters apply only where those - gates are present. + explicit idles. Use `idle_after_2q_duration` to insert them, optionally after + `strip_traced_idles` removes runtime-provided identity-like gates. Passing + idle-noise parameters without any final `Idle` gates raises `ValueError`; see + [Idle Noise](#idle-noise). - **Hand-authored tracked-Pauli observables are rejected** in `observables_json`; tracked Paulis come from circuit annotations only. diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index ac9d1c986..bf79d362c 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -38,6 +38,7 @@ import hashlib import json +import math from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any @@ -179,6 +180,34 @@ def _from_circuit_with_noise( ) +def _apply_traced_idle_passes( + circuit: Any, + *, + strip_traced_idles: bool, + idle_after_2q_duration: float | None, + idle_noise_parameters: Sequence[float | None], +) -> None: + """Apply requested idle passes and reject idle noise with no target gates.""" + if strip_traced_idles: + circuit.remove_identity() + if idle_after_2q_duration is not None: + if not math.isfinite(idle_after_2q_duration) or idle_after_2q_duration <= 0.0: + msg = ( + "idle_after_2q_duration must be a finite, positive duration; " + f"got {idle_after_2q_duration!r} (a non-positive duration would insert idle " + "gates that contribute zero idle noise)" + ) + raise ValueError(msg) + circuit.insert_idle_after_two_qubit_gates(idle_after_2q_duration) + + if any(value is not None for value in idle_noise_parameters) and circuit.gate_counts_by_type().get("Idle", 0) == 0: + msg = ( + "idle-noise parameters have no idle gates to attach to; either pass " + "idle_after_2q_duration=..., or use a Selene runtime that emits scheduled idles" + ) + raise ValueError(msg) + + class _DetectorErrorModelMixin: """Namespace for the Python Guppy/QIS-trace convenience constructor.""" @@ -215,6 +244,8 @@ def from_guppy( p_idle_x_quadratic_sine_rate: float | None = None, p_idle_y_quadratic_sine_rate: float | None = None, p_idle_z_quadratic_sine_rate: float | None = None, + strip_traced_idles: bool = False, + idle_after_2q_duration: float | None = None, runtime: object | None = None, seed: int = 0, require_hosted_operation_order: bool = False, @@ -326,6 +357,15 @@ def from_guppy( p_idle_x_quadratic_sine_rate: Optional stochastic X-memory sine-law rate. p_idle_y_quadratic_sine_rate: Optional stochastic Y-memory sine-law rate. p_idle_z_quadratic_sine_rate: Optional stochastic Z-memory sine-law rate. + strip_traced_idles: If true, remove identity-like gates from the + normalized traced circuit, including ``I``, ``Idle``, and + zero-angle rotations. This pass runs before idle insertion + when both idle-pass options are set. + idle_after_2q_duration: If set, insert an ``Idle`` gate of this + duration on both qubits after every two-qubit gate in the + normalized traced circuit. Insertion runs after + ``strip_traced_idles`` and before result-tag resolution and + detector/observable metadata attachment. runtime: Optional Selene runtime selector/plugin. ``None`` selects the default Selene runtime. Runtime plugin objects are passed through to ``pecos.selene_engine(runtime)``. @@ -344,8 +384,12 @@ def from_guppy( ValueError: If ``num_measurements`` disagrees with the traced 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. + ``meas_id``, if ``idle_after_2q_duration`` is not a finite + positive number, if any idle-noise parameter is set but the + final traced circuit has no ``Idle`` gates, or if the traced + operation stream cannot be replayed. To provide targets for + idle noise, pass ``idle_after_2q_duration`` or use a Selene + runtime that emits scheduled idles. Note: Runtime-lowered idles are replayed as nanosecond PECOS @@ -434,6 +478,28 @@ def from_guppy( # stamp stable MeasIds onto measurement gates, and fail loudly if raw # traced-QIS rotations survived normalization. normalize_traced_tick_circuit(tc, context="DetectorErrorModel.from_guppy") + _apply_traced_idle_passes( + tc, + strip_traced_idles=strip_traced_idles, + idle_after_2q_duration=idle_after_2q_duration, + idle_noise_parameters=( + p_idle, + t1, + t2, + p_idle_linear_rate, + p_idle_quadratic_rate, + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, + ), + ) # Resolve `result_tags` -> record offsets via Rust (sound HUGR # extraction + runtime-loop guard via static-vs-traced measurement @@ -794,6 +860,8 @@ def build_dem_from_guppy( p_idle_x_quadratic_sine_rate: float | None = None, p_idle_y_quadratic_sine_rate: float | None = None, p_idle_z_quadratic_sine_rate: float | None = None, + strip_traced_idles: bool = False, + idle_after_2q_duration: float | None = None, runtime: object | None = None, seed: int = 0, require_hosted_operation_order: bool = False, @@ -809,6 +877,65 @@ def build_dem_from_guppy( Measurement-dependent quantum control remains unsupported because one captured execution is not a static circuit model. + + Args: + guppy: A HUGR-certifiable Guppy program to trace once under the Selene + QIS engine. + num_qubits: Number of qubits to allocate for the trace. + detectors: Typed detector definitions using ``rec[...]`` or + ``result_ref(...)`` measurement references. + observables: Typed logical-observable definitions using the same + measurement-reference forms as ``detectors``. + p1: Single-qubit gate Pauli error rate. + p1_weights: Optional relative probabilities over single-qubit Pauli + error labels ``"X"``, ``"Y"``, and ``"Z"``. + p2: Two-qubit gate depolarizing rate. + p2_weights: Optional relative probabilities over two-qubit Pauli error + labels, including starred replacement branches. + p2_replacement_approximation: Approximation used for starred + replacement labels in ``p2_weights``. + p_meas: Measurement flip rate. + p_prep: Preparation (reset) error rate. + p_idle: Optional uniform depolarizing idle-noise rate per idle duration. + t1: Optional T1 relaxation time for explicit idle gates. + t2: Optional T2 dephasing time for explicit idle gates. + p_idle_linear_rate: Optional legacy alias for stochastic Z-memory rate + linear in idle duration. + p_idle_quadratic_rate: Optional legacy alias for stochastic Z-memory + rate quadratic in idle duration. + p_idle_x_linear_rate: Optional stochastic X-memory rate linear in idle duration. + p_idle_y_linear_rate: Optional stochastic Y-memory rate linear in idle duration. + p_idle_z_linear_rate: Optional stochastic Z-memory rate linear in idle duration. + p_idle_x_quadratic_rate: Optional stochastic X-memory rate quadratic in idle duration. + p_idle_y_quadratic_rate: Optional stochastic Y-memory rate quadratic in idle duration. + p_idle_z_quadratic_rate: Optional stochastic Z-memory rate quadratic in idle duration. + p_idle_quadratic_sine_rate: Optional legacy alias for stochastic + Z-memory rate with probability ``sin(rate * duration)^2``. + p_idle_x_quadratic_sine_rate: Optional stochastic X-memory sine-law rate. + p_idle_y_quadratic_sine_rate: Optional stochastic Y-memory sine-law rate. + p_idle_z_quadratic_sine_rate: Optional stochastic Z-memory sine-law rate. + strip_traced_idles: If true, remove identity-like gates from the + normalized trace, including ``I``, ``Idle``, and zero-angle + rotations. This pass runs before idle insertion when both + idle-pass options are set. + idle_after_2q_duration: If set, insert an ``Idle`` gate of this + duration on both qubits after every two-qubit gate. Insertion runs + after ``strip_traced_idles`` and before typed result-reference + resolution and detector/observable metadata attachment. + runtime: Optional Selene runtime selector/plugin. ``None`` selects the + default Selene runtime. + seed: Seed for the ideal trace run. + require_hosted_operation_order: If true, validate generic + hosted-operation metadata after trace replay. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. + + Raises: + ValueError: If ``idle_after_2q_duration`` is not a finite positive + number, or if any idle-noise parameter is set but the final traced + circuit has no ``Idle`` gates. Pass ``idle_after_2q_duration`` or + use a Selene runtime that emits scheduled idles to provide targets + for idle noise. """ from pecos.tracing import _trace_program_to_tick_circuit_with_result_traces @@ -835,6 +962,28 @@ def build_dem_from_guppy( allow_raw_measurement_id_fallback=False, ) normalize_traced_tick_circuit(circuit, context="build_dem_from_guppy") + _apply_traced_idle_passes( + circuit, + strip_traced_idles=strip_traced_idles, + idle_after_2q_duration=idle_after_2q_duration, + idle_noise_parameters=( + p_idle, + t1, + t2, + p_idle_linear_rate, + p_idle_quadratic_rate, + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, + ), + ) result_traces = _compiler_certified_result_traces( generator_layout, hugr_bytes, 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 f9905b751..ff9b7943c 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -9,7 +9,7 @@ import pytest from guppylang import guppy from guppylang.std.builtins import barrier, owned, result -from guppylang.std.quantum import h, measure, qubit, x +from guppylang.std.quantum import cx, h, measure, qubit, x from pecos._qis_trace_replay import ( _reject_partially_lowered_trace, _replay_lowered_qis_trace_into_tick_circuit, @@ -21,7 +21,7 @@ normalize_traced_tick_circuit, ) from pecos.guppy import get_num_qubits, make_surface_code -from pecos.qec import DetectorErrorModel +from pecos.qec import Detector, DetectorErrorModel, Observable, build_dem_from_guppy, rec from pecos.qec.surface import RUNTIME_IDLE_TIME_UNITS_PER_SECOND, NoiseModel, SurfacePatch from pecos.qec.surface.circuit_builder import ( generate_tick_circuit_from_patch, @@ -46,6 +46,17 @@ def _single_measurement() -> None: result("m", b) +@guppy +def _two_qubit_idle_target() -> None: + q0 = qubit() + q1 = qubit() + cx(q0, q1) + m0 = measure(q0) + m1 = measure(q1) + result("m0", m0) + result("m1", m1) + + @guppy def _measurement_feedback() -> None: q0 = qubit() @@ -167,6 +178,191 @@ def _dem_text(*, detectors_json: str = "[]", observables_json: str = "[]") -> st return dem.to_string() +_TWO_QUBIT_DETECTORS_JSON = '[{"id":0,"records":[-2]}]' +_TWO_QUBIT_OBSERVABLES_JSON = '[{"id":0,"records":[-1]}]' +_NO_GATE_NOISE = {"p1": 0.0, "p2": 0.0, "p_meas": 0.0, "p_prep": 0.0} + + +def _two_qubit_dem(**kwargs): + return DetectorErrorModel.from_guppy( + _two_qubit_idle_target, + num_qubits=2, + detectors_json=_TWO_QUBIT_DETECTORS_JSON, + observables_json=_TWO_QUBIT_OBSERVABLES_JSON, + num_measurements=2, + seed=0, + **_NO_GATE_NOISE, + **kwargs, + ) + + +def test_from_guppy_idle_insertion_matches_manual_pass_pipeline() -> None: + from pecos.tracing import trace_program_to_tick_circuit + + reference_circuit = trace_program_to_tick_circuit(_two_qubit_idle_target, 2, seed=0) + normalize_traced_tick_circuit(reference_circuit, context="from_guppy idle insertion reference") + reference_circuit.insert_idle_after_two_qubit_gates(1.0) + reference_circuit.set_meta("detectors", _TWO_QUBIT_DETECTORS_JSON) + reference_circuit.set_meta("observables", _TWO_QUBIT_OBSERVABLES_JSON) + reference_circuit.set_meta("num_measurements", "2") + reference = DetectorErrorModel.from_circuit(reference_circuit, p_idle=0.01, **_NO_GATE_NOISE) + + composed = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle=0.01) + + assert composed.to_string() == reference.to_string() + + +def test_from_guppy_inserted_idles_make_idle_noise_effective() -> None: + without_idle_noise = _two_qubit_dem(idle_after_2q_duration=1.0) + with_idle_noise = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle=0.01) + + assert with_idle_noise.to_string() != without_idle_noise.to_string() + assert with_idle_noise.num_contributions > without_idle_noise.num_contributions + + +# Every idle-noise parameter the guard must observe; omitting any one from the +# guard wiring in dem.py must fail the corresponding parametrized case below. +_ALL_IDLE_NOISE_PARAMS = { + "p_idle": 0.01, + "t1": 100.0, + "t2": 100.0, + "p_idle_linear_rate": 0.01, + "p_idle_quadratic_rate": 0.01, + "p_idle_x_linear_rate": 0.01, + "p_idle_y_linear_rate": 0.01, + "p_idle_z_linear_rate": 0.01, + "p_idle_x_quadratic_rate": 0.01, + "p_idle_y_quadratic_rate": 0.01, + "p_idle_z_quadratic_rate": 0.01, + "p_idle_quadratic_sine_rate": 0.01, + "p_idle_x_quadratic_sine_rate": 0.01, + "p_idle_y_quadratic_sine_rate": 0.01, + "p_idle_z_quadratic_sine_rate": 0.01, +} + + +@pytest.mark.parametrize("idle_param", sorted(_ALL_IDLE_NOISE_PARAMS)) +def test_from_guppy_rejects_idle_noise_without_idle_gates(idle_param: str) -> None: + with pytest.raises(ValueError, match=r"idle-noise parameters have no idle gates"): + _two_qubit_dem(**{idle_param: _ALL_IDLE_NOISE_PARAMS[idle_param]}) + + +@pytest.mark.parametrize("bad_duration", [0.0, -1.0, float("nan"), float("inf")]) +def test_from_guppy_rejects_non_positive_idle_duration(bad_duration: float) -> None: + with pytest.raises(ValueError, match=r"finite, positive duration"): + _two_qubit_dem(idle_after_2q_duration=bad_duration, p_idle=0.01) + + +def test_from_guppy_idle_guard_accepts_inserted_idles_and_idles_without_noise() -> None: + with_noise = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle=0.01) + without_noise = _two_qubit_dem(idle_after_2q_duration=1.0) + + assert with_noise.num_contributions > 0 + assert without_noise is not None + + +def test_from_guppy_idle_guard_accepts_runtime_emitted_idles(monkeypatch: pytest.MonkeyPatch) -> None: + from pecos_rslib.quantum import TickCircuit + + circuit = TickCircuit() + circuit.tick().pz([0, 1]) + circuit.tick().cx([(0, 1)]) + circuit.tick().idle(1, [0, 1]) + circuit.tick().mz_with_ids([0, 1], [0, 1]) + monkeypatch.setattr("pecos.tracing.trace_program_to_tick_circuit", lambda *_args, **_kwargs: circuit) + + dem = _two_qubit_dem(p_idle=0.01) + + assert dem.num_contributions > 0 + + +def test_from_guppy_strip_traced_idles_is_noop_when_trace_has_no_idles() -> None: + baseline = _two_qubit_dem() + stripped = _two_qubit_dem(strip_traced_idles=True) + + assert stripped.to_string() == baseline.to_string() + + +def test_from_guppy_strip_traced_idles_removes_runtime_emitted_idles(monkeypatch: pytest.MonkeyPatch) -> None: + from pecos_rslib.quantum import TickCircuit + + circuit = TickCircuit() + circuit.tick().pz([0, 1]) + circuit.tick().cx([(0, 1)]) + circuit.tick().idle(1, [0, 1]) + circuit.tick().mz_with_ids([0, 1], [0, 1]) + monkeypatch.setattr("pecos.tracing.trace_program_to_tick_circuit", lambda *_args, **_kwargs: circuit) + + # The same runtime-emitted-idle circuit passes the guard when idles are kept + # (test_from_guppy_idle_guard_accepts_runtime_emitted_idles); with + # strip_traced_idles the guard must find no idle gates left. + with pytest.raises(ValueError, match=r"idle-noise parameters have no idle gates"): + _two_qubit_dem(strip_traced_idles=True, p_idle=0.01) + + +def test_build_dem_from_guppy_rejects_idle_noise_without_idle_gates() -> None: + for idle_param, value in _ALL_IDLE_NOISE_PARAMS.items(): + with pytest.raises(ValueError, match=r"idle-noise parameters have no idle gates"): + build_dem_from_guppy( + _two_qubit_idle_target, + num_qubits=2, + detectors=[Detector(rec[-2])], + observables=[Observable(rec[-1])], + **{idle_param: value}, + **_NO_GATE_NOISE, + ) + + +def test_build_dem_from_guppy_rejects_non_positive_idle_duration() -> None: + with pytest.raises(ValueError, match=r"finite, positive duration"): + build_dem_from_guppy( + _two_qubit_idle_target, + num_qubits=2, + detectors=[Detector(rec[-2])], + observables=[Observable(rec[-1])], + idle_after_2q_duration=0.0, + p_idle=0.01, + **_NO_GATE_NOISE, + ) + + +def test_build_dem_from_guppy_strips_then_inserts_idles() -> None: + build = build_dem_from_guppy( + _two_qubit_idle_target, + num_qubits=2, + detectors=[Detector(rec[-2])], + observables=[Observable(rec[-1])], + strip_traced_idles=True, + idle_after_2q_duration=1.0, + p_idle=0.01, + **_NO_GATE_NOISE, + ) + + assert build.circuit.gate_counts_by_type().get("Idle") == 2 + assert build.dem.num_contributions > 0 + + +def test_from_guppy_result_tags_coexist_with_idle_insertion() -> None: + via_tags = DetectorErrorModel.from_guppy( + _two_qubit_idle_target, + num_qubits=2, + detectors_json='[{"id":0,"result_tags":["m0"]}]', + idle_after_2q_duration=1.0, + seed=0, + **_NO_GATE_NOISE, + ) + via_records = DetectorErrorModel.from_guppy( + _two_qubit_idle_target, + num_qubits=2, + detectors_json=_TWO_QUBIT_DETECTORS_JSON, + idle_after_2q_duration=1.0, + seed=0, + **_NO_GATE_NOISE, + ) + + assert via_tags.to_string() == via_records.to_string() + + def _flat_mz_ids(tc) -> list[int]: dag = tc.to_dag_circuit() ids: list[int] = [] From fdf321f8bb63c27b0b321ac0c861cd4af859e86c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 14:59:55 -0600 Subject: [PATCH 02/62] Default strip_traced_idles to stripping when idle_after_2q_duration is set --- docs/user-guide/dem-from-guppy.md | 8 ++++--- python/quantum-pecos/src/pecos/qec/dem.py | 21 +++++++++++++---- .../tests/qec/test_from_guppy_dem.py | 23 +++++++++++++++++++ 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index e0736a8bf..b946e3ed3 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -212,9 +212,11 @@ passes for controlling those locations: - `idle_after_2q_duration=` inserts an `Idle` of that duration on both qubits after every two-qubit gate. -When both options are present, stripping runs before insertion. This is useful -when you want a consistent idle convention independent of the selected -runtime. +Stripping runs before insertion. By default, setting `idle_after_2q_duration` +also strips first: inserting a uniform idle convention on top of +runtime-emitted idles would double-count idle noise. Pass +`strip_traced_idles=False` explicitly to keep runtime-emitted idles alongside +the inserted ones. ```python diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index bf79d362c..165fc2714 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -183,11 +183,15 @@ def _from_circuit_with_noise( def _apply_traced_idle_passes( circuit: Any, *, - strip_traced_idles: bool, + strip_traced_idles: bool | None, idle_after_2q_duration: float | None, idle_noise_parameters: Sequence[float | None], ) -> None: """Apply requested idle passes and reject idle noise with no target gates.""" + if strip_traced_idles is None: + # Inserting a uniform idle convention on top of runtime-emitted idles + # would double-count idle noise, so insertion implies stripping first. + strip_traced_idles = idle_after_2q_duration is not None if strip_traced_idles: circuit.remove_identity() if idle_after_2q_duration is not None: @@ -244,7 +248,7 @@ def from_guppy( p_idle_x_quadratic_sine_rate: float | None = None, p_idle_y_quadratic_sine_rate: float | None = None, p_idle_z_quadratic_sine_rate: float | None = None, - strip_traced_idles: bool = False, + strip_traced_idles: bool | None = None, idle_after_2q_duration: float | None = None, runtime: object | None = None, seed: int = 0, @@ -360,7 +364,11 @@ def from_guppy( strip_traced_idles: If true, remove identity-like gates from the normalized traced circuit, including ``I``, ``Idle``, and zero-angle rotations. This pass runs before idle insertion - when both idle-pass options are set. + when both idle-pass options are set. Defaults to ``None``, + which strips exactly when ``idle_after_2q_duration`` is set: + inserting a uniform idle convention on top of runtime-emitted + idles would double-count idle noise. Pass ``False`` explicitly + to keep runtime-emitted idles alongside inserted ones. idle_after_2q_duration: If set, insert an ``Idle`` gate of this duration on both qubits after every two-qubit gate in the normalized traced circuit. Insertion runs after @@ -860,7 +868,7 @@ def build_dem_from_guppy( p_idle_x_quadratic_sine_rate: float | None = None, p_idle_y_quadratic_sine_rate: float | None = None, p_idle_z_quadratic_sine_rate: float | None = None, - strip_traced_idles: bool = False, + strip_traced_idles: bool | None = None, idle_after_2q_duration: float | None = None, runtime: object | None = None, seed: int = 0, @@ -917,7 +925,10 @@ def build_dem_from_guppy( strip_traced_idles: If true, remove identity-like gates from the normalized trace, including ``I``, ``Idle``, and zero-angle rotations. This pass runs before idle insertion when both - idle-pass options are set. + idle-pass options are set. Defaults to ``None``, which strips + exactly when ``idle_after_2q_duration`` is set; pass ``False`` + explicitly to keep runtime-emitted idles alongside inserted + ones. idle_after_2q_duration: If set, insert an ``Idle`` gate of this duration on both qubits after every two-qubit gate. Insertion runs after ``strip_traced_idles`` and before typed result-reference 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 ff9b7943c..343c33be2 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -300,6 +300,29 @@ def test_from_guppy_strip_traced_idles_removes_runtime_emitted_idles(monkeypatch _two_qubit_dem(strip_traced_idles=True, p_idle=0.01) +def test_from_guppy_insertion_strips_runtime_idles_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + from pecos_rslib.quantum import TickCircuit + + def _traced_circuit_with_runtime_idles(*_args, **_kwargs): + circuit = TickCircuit() + circuit.tick().pz([0, 1]) + circuit.tick().cx([(0, 1)]) + circuit.tick().idle(1, [0, 1]) + circuit.tick().mz_with_ids([0, 1], [0, 1]) + return circuit + + monkeypatch.setattr("pecos.tracing.trace_program_to_tick_circuit", _traced_circuit_with_runtime_idles) + + default_strip = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle=0.01) + explicit_strip = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle=0.01, strip_traced_idles=True) + keep_runtime_idles = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle=0.01, strip_traced_idles=False) + + # Insertion implies stripping unless explicitly disabled; keeping the + # runtime idles doubles the idle content and must change the DEM. + assert default_strip.to_string() == explicit_strip.to_string() + assert keep_runtime_idles.to_string() != default_strip.to_string() + + def test_build_dem_from_guppy_rejects_idle_noise_without_idle_gates() -> None: for idle_param, value in _ALL_IDLE_NOISE_PARAMS.items(): with pytest.raises(ValueError, match=r"idle-noise parameters have no idle gates"): From 04eb8daf20049d8b06a1dde7253cb6c5c9cc4dda Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 15:19:22 -0600 Subject: [PATCH 03/62] Note the stim extra for the DEM export parse checks --- docs/user-guide/dem-from-guppy.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index b946e3ed3..1428c8043 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -286,7 +286,9 @@ T1 and T2 values must use the same units as the idle duration. PECOS's native DEM text is the Stim DEM format; there is no separate `to_stim()` conversion. `dem.to_string()` emits standard `error(p) D... L...` mechanisms and can be parsed directly as a Stim detector -error model. +error model. The export itself has no extra dependency; the parse checks in +the example below use the optional interoperability extra +(`pip install "quantum-pecos[stim]"`). `dem.to_string_decomposed()` uses decomposition components attached to the original fault source, writing `^`-separated components when that provenance From cb2a910ed21245979ade902419f118dd36542970 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 15:38:21 -0600 Subject: [PATCH 04/62] Split the stim parse check into a self-contained optional-extra block --- docs/user-guide/dem-from-guppy.md | 49 ++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index 1428c8043..15b408793 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -286,9 +286,7 @@ T1 and T2 values must use the same units as the idle duration. PECOS's native DEM text is the Stim DEM format; there is no separate `to_stim()` conversion. `dem.to_string()` emits standard `error(p) D... L...` mechanisms and can be parsed directly as a Stim detector -error model. The export itself has no extra dependency; the parse checks in -the example below use the optional interoperability extra -(`pip install "quantum-pecos[stim]"`). +error model. The export itself has no extra dependency. `dem.to_string_decomposed()` uses decomposition components attached to the original fault source, writing `^`-separated components when that provenance @@ -300,7 +298,6 @@ of source provenance. ```python -import stim from guppylang import guppy from guppylang.std.builtins import result from guppylang.std.quantum import cx, measure, qubit @@ -338,9 +335,47 @@ print(raw_text) print(source_decomposed_text) print(graphlike_text) assert "error(" in raw_text -stim.DetectorErrorModel(raw_text) -stim.DetectorErrorModel(source_decomposed_text) -stim.DetectorErrorModel(graphlike_text) +``` + +To verify interoperability against Stim itself, install the optional extra +(`pip install "quantum-pecos[stim]"`) — the base install does not depend on +stim: + + +```python +import stim +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import cx, measure, qubit + +from pecos.qec import DetectorErrorModel + + +@guppy +def idle_demo() -> None: + q0, q1 = qubit(), qubit() + cx(q0, q1) + result("m0", measure(q0)) + result("m1", measure(q1)) + + +dem = DetectorErrorModel.from_guppy( + idle_demo, + num_qubits=2, + detectors_json='[{"id": "D0", "result_tags": ["m0"]}]', + observables_json='[{"id": "L0", "result_tags": ["m1"]}]', + idle_after_2q_duration=1.0, + p1=0.0, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p_idle=0.01, + seed=0, +) + +stim.DetectorErrorModel(dem.to_string()) +stim.DetectorErrorModel(dem.to_string_decomposed()) +stim.DetectorErrorModel(dem.to_string_terminal_graphlike_decomposed()) ``` ## Decoding: PyMatching, Tesseract, and BP-OSD From 5fdb5cded7c54a46e3b0ad310083803c90779d2e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 16:06:18 -0600 Subject: [PATCH 05/62] Add engines-consistent structured idle-noise interface (p_idle_linear/_model, p_idle_quadratic, p_idle_coherent) --- docs/user-guide/dem-from-guppy.md | 47 ++- python/quantum-pecos/src/pecos/qec/dem.py | 287 +++++++++++++++++- .../tests/qec/test_from_guppy_dem.py | 226 ++++++++++++++ 3 files changed, 535 insertions(+), 25 deletions(-) diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index 15b408793..79b7c0dfa 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -197,12 +197,34 @@ different DEM. ## Idle Noise -The default Selene runtime does not emit idle gates. Idle-noise parameters -such as `p_idle`, `t1`/`t2`, and the `p_idle_*_rate` family therefore have no -locations to attach to unless the runtime supplies scheduled idles or you -insert them explicitly. `from_guppy` raises `ValueError` when any of these -parameters is supplied but the final traced circuit contains no `Idle` gates; -it does not silently build a DEM without the requested noise. +The recommended structured interface mirrors the engines +`GeneralNoiseModel`: + +- `p_idle_linear` is the total stochastic idle rate, linear in duration. + `p_idle_linear_model` supplies relative `X`, `Y`, and `Z` weights that sum + to 1; its default is the uniform `{X: 1/3, Y: 1/3, Z: 1/3}` engines model. + The engines `L` leakage key is reserved but rejected because DEM construction + cannot represent leakage. `p_idle` remains shorthand for the uniform model. +- `p_idle_quadratic` is the engines-style quadratic dephasing rate. By default, + an idle of duration `t` has a stochastic Z probability + `sin(p_idle_quadratic * t) ** 2`. With `p_idle_coherent=True`, it instead + contributes an `RZ(p_idle_quadratic * t)` angle. The DEM coherently adds + angles for matching detector sets and uses the RZ half-angle probability + `sin(total_angle / 2) ** 2`. + +The per-axis `p_idle_{x,y,z}_linear_rate`, +`p_idle_{x,y,z}_quadratic_rate`, and +`p_idle_{x,y,z}_quadratic_sine_rate` parameters remain available as low-level +knobs. The bare Z-only aliases `p_idle_linear_rate`, +`p_idle_quadratic_rate`, and `p_idle_quadratic_sine_rate` are deprecated; use +the structured interface or the explicitly named `p_idle_z_*` equivalent. + +The default Selene runtime does not emit idle gates. These parameters and +`t1`/`t2` therefore have no locations to attach to unless the runtime supplies +scheduled idles or you insert them explicitly. `from_guppy` raises +`ValueError` when an idle-noise rate is supplied but the final traced circuit +contains no `Idle` gates; it does not silently build a DEM without the +requested noise. Both `DetectorErrorModel.from_guppy` and `build_dem_from_guppy` accept two passes for controlling those locations: @@ -254,7 +276,9 @@ without_idle_noise = DetectorErrorModel.from_guppy( with_idle_noise = DetectorErrorModel.from_guppy( idle_demo, idle_after_2q_duration=1.0, - p_idle=0.01, + p_idle_linear=0.01, + p_idle_linear_model={"X": 0.25, "Z": 0.75}, + p_idle_quadratic=0.02, **common, ) @@ -266,7 +290,7 @@ def count_errors(model: DetectorErrorModel) -> int: assert count_errors(with_idle_noise) > count_errors(without_idle_noise) try: - DetectorErrorModel.from_guppy(idle_demo, p_idle=0.01, **common) + DetectorErrorModel.from_guppy(idle_demo, p_idle_linear=0.01, **common) except ValueError as exc: assert "idle-noise parameters have no idle gates" in str(exc) else: @@ -277,9 +301,10 @@ Runtime-emitted idle durations are replayed as nanosecond `TimeUnits`. Inserted idles instead carry the duration passed to `idle_after_2q_duration`, which must be finite and positive. Linear and sine-law idle rates are per time unit (for example, uniform idle noise uses -`p_idle * duration`, clamped to the probability range), while quadratic -rates multiply `duration**2` and therefore scale as inverse time squared. -T1 and T2 values must use the same units as the idle duration. +`p_idle * duration`, clamped to the probability range). The low-level +coefficient-style quadratic rates multiply `duration**2` and therefore scale +as inverse time squared. T1 and T2 values must use the same units as the idle +duration. ## Exporting the DEM as Stim Text diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 165fc2714..1eb690f4b 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -39,6 +39,7 @@ import hashlib import json import math +import warnings from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any @@ -57,6 +58,158 @@ P2Weights = Mapping[str, float] _GENERATOR_LAYOUT_ATTR = "__pecos_named_measurement_layout_v2__" +_IDLE_MODEL_NORMALIZATION_TOLERANCE = 1.0e-5 +_IDLE_MODEL_FLOAT_EPSILON = 1.0e-10 + + +def _translate_structured_idle_noise( + *, + p_idle: float | None, + p_idle_linear: float | None, + p_idle_linear_model: Mapping[str, float] | None, + p_idle_quadratic: float | None, + p_idle_coherent: bool, + p_idle_linear_rate: float | None, + p_idle_quadratic_rate: float | None, + p_idle_x_linear_rate: float | None, + p_idle_y_linear_rate: float | None, + p_idle_z_linear_rate: float | None, + p_idle_x_quadratic_rate: float | None, + p_idle_y_quadratic_rate: float | None, + p_idle_z_quadratic_rate: float | None, + p_idle_quadratic_sine_rate: float | None, + p_idle_x_quadratic_sine_rate: float | None, + p_idle_y_quadratic_sine_rate: float | None, + p_idle_z_quadratic_sine_rate: float | None, +) -> tuple[float | None, float | None, float | None, float | None, float | None]: + """Validate and translate engines-style idle noise to DEM primitives.""" + linear_primitives = { + "p_idle_linear_rate": p_idle_linear_rate, + "p_idle_x_linear_rate": p_idle_x_linear_rate, + "p_idle_y_linear_rate": p_idle_y_linear_rate, + "p_idle_z_linear_rate": p_idle_z_linear_rate, + } + if (p_idle_linear is not None or p_idle_linear_model is not None) and any( + value is not None for value in linear_primitives.values() + ): + conflicts = ", ".join(name for name, value in linear_primitives.items() if value is not None) + msg = f"p_idle_linear/p_idle_linear_model cannot be combined with low-level idle rate(s): {conflicts}" + raise ValueError(msg) + if p_idle is not None and p_idle_linear is not None: + msg = "p_idle and p_idle_linear cannot be combined; p_idle is the uniform-model shorthand" + raise ValueError(msg) + + quadratic_primitives = { + "p_idle_quadratic_rate": p_idle_quadratic_rate, + "p_idle_x_quadratic_rate": p_idle_x_quadratic_rate, + "p_idle_y_quadratic_rate": p_idle_y_quadratic_rate, + "p_idle_z_quadratic_rate": p_idle_z_quadratic_rate, + "p_idle_quadratic_sine_rate": p_idle_quadratic_sine_rate, + "p_idle_x_quadratic_sine_rate": p_idle_x_quadratic_sine_rate, + "p_idle_y_quadratic_sine_rate": p_idle_y_quadratic_sine_rate, + "p_idle_z_quadratic_sine_rate": p_idle_z_quadratic_sine_rate, + } + if (p_idle_quadratic is not None or p_idle_coherent) and any( + value is not None for value in quadratic_primitives.values() + ): + conflicts = ", ".join(name for name, value in quadratic_primitives.items() if value is not None) + msg = f"p_idle_quadratic/p_idle_coherent cannot be combined with low-level idle rate(s): {conflicts}" + raise ValueError(msg) + + if p_idle_linear_model is not None and p_idle_linear is None: + msg = "p_idle_linear_model requires p_idle_linear; otherwise the model is inert" + raise ValueError(msg) + if p_idle_coherent and p_idle_quadratic is None: + msg = "p_idle_coherent=True requires p_idle_quadratic; otherwise it is inert" + raise ValueError(msg) + + legacy_replacements = { + "p_idle_linear_rate": ( + p_idle_linear_rate, + ( + "p_idle_linear with p_idle_linear_model={'Z': 1.0} for the engines-consistent interface, " + "or p_idle_z_linear_rate for literal Z-only behavior" + ), + ), + "p_idle_quadratic_rate": ( + p_idle_quadratic_rate, + ( + "p_idle_quadratic for the engines-consistent quadratic interface, " + "or p_idle_z_quadratic_rate for literal coefficient-style Z-only behavior" + ), + ), + "p_idle_quadratic_sine_rate": ( + p_idle_quadratic_sine_rate, + ( + "p_idle_quadratic for the engines-consistent quadratic interface, " + "or p_idle_z_quadratic_sine_rate for literal Z-only behavior" + ), + ), + } + for name, (value, replacement) in legacy_replacements.items(): + if value is not None: + warnings.warn( + f"{name} is deprecated; use {replacement}", + DeprecationWarning, + stacklevel=3, + ) + + if p_idle_linear is not None: + if p_idle_linear_model is not None and not isinstance(p_idle_linear_model, Mapping): + msg = "p_idle_linear_model must be a mapping from 'X', 'Y', and 'Z' to weights" + raise ValueError(msg) + model = ( + p_idle_linear_model if p_idle_linear_model is not None else {"X": 1.0 / 3.0, "Y": 1.0 / 3.0, "Z": 1.0 / 3.0} + ) + normalized_model: dict[str, float] = {} + for key, weight in model.items(): + if key == "L": + msg = ( + "p_idle_linear_model key 'L' denotes leakage, which is supported by the engines simulators " + "but not by DEM construction" + ) + raise ValueError(msg) + if key not in {"X", "Y", "Z"}: + msg = f"invalid p_idle_linear_model key {key!r}; expected 'X', 'Y', or 'Z'" + raise ValueError(msg) + try: + numeric_weight = float(weight) + except (TypeError, ValueError) as exc: + msg = f"p_idle_linear_model weight for {key!r} must be a finite, non-negative float" + raise ValueError(msg) from exc + if not math.isfinite(numeric_weight) or numeric_weight < 0.0: + msg = f"p_idle_linear_model weight for {key!r} must be a finite, non-negative float" + raise ValueError(msg) + normalized_model[key] = numeric_weight + + total_weight = sum(normalized_model.values()) + if total_weight <= 0.0 or abs(total_weight - 1.0) > _IDLE_MODEL_NORMALIZATION_TOLERANCE: + msg = ( + "p_idle_linear_model weights must sum to 1.0 within tolerance " + f"{_IDLE_MODEL_NORMALIZATION_TOLERANCE:g}; got {total_weight}" + ) + raise ValueError(msg) + if abs(total_weight - 1.0) > _IDLE_MODEL_FLOAT_EPSILON: + normalized_model = {key: weight / total_weight for key, weight in normalized_model.items()} + + p_idle_x_linear_rate = p_idle_linear * normalized_model.get("X", 0.0) + p_idle_y_linear_rate = p_idle_linear * normalized_model.get("Y", 0.0) + p_idle_z_linear_rate = p_idle_linear * normalized_model.get("Z", 0.0) + + idle_rz = None + if p_idle_quadratic is not None: + if p_idle_coherent: + idle_rz = p_idle_quadratic + else: + p_idle_z_quadratic_sine_rate = p_idle_quadratic + + return ( + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_z_quadratic_sine_rate, + idle_rz, + ) def _certifiable_hugr_bytes(guppy: Any) -> bytes | None: @@ -140,6 +293,7 @@ def _from_circuit_with_noise( p_idle: float | None, t1: float | None, t2: float | None, + idle_rz: float | None, p_idle_linear_rate: float | None, p_idle_quadratic_rate: float | None, p_idle_x_linear_rate: float | None, @@ -165,6 +319,7 @@ def _from_circuit_with_noise( p_idle=p_idle, t1=t1, t2=t2, + idle_rz=idle_rz, p_idle_linear_rate=p_idle_linear_rate, p_idle_quadratic_rate=p_idle_quadratic_rate, p_idle_x_linear_rate=p_idle_x_linear_rate, @@ -234,6 +389,10 @@ def from_guppy( p_meas: float = 0.001, p_prep: float = 0.001, p_idle: float | None = None, + p_idle_linear: float | None = None, + p_idle_linear_model: Mapping[str, float] | None = None, + p_idle_quadratic: float | None = None, + p_idle_coherent: bool = False, t1: float | None = None, t2: float | None = None, p_idle_linear_rate: float | None = None, @@ -343,21 +502,42 @@ def from_guppy( entries like plain post-gate Pauli entries. p_meas: Measurement flip rate. p_prep: Preparation (reset) error rate. - p_idle: Optional uniform depolarizing idle-noise rate per idle duration. + p_idle: Optional shorthand for ``p_idle_linear`` with the uniform + ``{"X": 1/3, "Y": 1/3, "Z": 1/3}`` model. + p_idle_linear: Optional total stochastic idle-noise rate linear in + duration. Uses the engines ``GeneralNoiseModel`` convention. + p_idle_linear_model: Optional relative weights over ``"X"``, ``"Y"``, + and ``"Z"`` for ``p_idle_linear``. Weights must be finite, + non-negative, and sum to 1.0 within ``1e-5``. Defaults to the + engines' uniform model. The engines leakage key ``"L"`` is + reserved but unsupported by DEM construction. + p_idle_quadratic: Optional quadratic dephasing rate. With + ``p_idle_coherent=False``, an idle of duration ``t`` produces a + stochastic Z fault with probability ``sin(rate * t)^2``. + With ``p_idle_coherent=True``, the same rate is forwarded as a + coherent ``RZ(rate * t)`` angle; the DEM converts an isolated + rotation to ``sin(rate * t / 2)^2`` and coherently accumulates + angles for matching detector sets. + p_idle_coherent: Select coherent RZ rather than stochastic Z + interpretation for ``p_idle_quadratic``. Defaults to ``False``. t1: Optional T1 relaxation time for explicit idle gates. t2: Optional T2 dephasing time for explicit idle gates. - p_idle_linear_rate: Optional legacy alias for stochastic Z-memory rate - linear in idle duration. - p_idle_quadratic_rate: Optional legacy alias for stochastic Z-memory rate - quadratic in idle duration. + p_idle_linear_rate: Deprecated bare Z-only alias for a stochastic + rate linear in idle duration. Use ``p_idle_linear`` with a + Z-only model, or ``p_idle_z_linear_rate`` for literal behavior. + p_idle_quadratic_rate: Deprecated bare Z-only coefficient-style + rate quadratic in idle duration. Use ``p_idle_quadratic`` for + engines semantics, or ``p_idle_z_quadratic_rate`` for literal + behavior. p_idle_x_linear_rate: Optional stochastic X-memory rate linear in idle duration. p_idle_y_linear_rate: Optional stochastic Y-memory rate linear in idle duration. p_idle_z_linear_rate: Optional stochastic Z-memory rate linear in idle duration. p_idle_x_quadratic_rate: Optional stochastic X-memory rate quadratic in idle duration. p_idle_y_quadratic_rate: Optional stochastic Y-memory rate quadratic in idle duration. p_idle_z_quadratic_rate: Optional stochastic Z-memory rate quadratic in idle duration. - p_idle_quadratic_sine_rate: Optional legacy alias for stochastic Z-memory - rate with probability ``sin(rate * duration)^2``. + p_idle_quadratic_sine_rate: Deprecated bare Z-only alias for a + stochastic rate with probability ``sin(rate * duration)^2``. + Use ``p_idle_quadratic`` or ``p_idle_z_quadratic_sine_rate``. p_idle_x_quadratic_sine_rate: Optional stochastic X-memory sine-law rate. p_idle_y_quadratic_sine_rate: Optional stochastic Y-memory sine-law rate. p_idle_z_quadratic_sine_rate: Optional stochastic Z-memory sine-law rate. @@ -426,6 +606,32 @@ def from_guppy( """ from pecos.tracing import trace_program_to_tick_circuit + ( + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_z_quadratic_sine_rate, + idle_rz, + ) = _translate_structured_idle_noise( + p_idle=p_idle, + p_idle_linear=p_idle_linear, + p_idle_linear_model=p_idle_linear_model, + p_idle_quadratic=p_idle_quadratic, + p_idle_coherent=p_idle_coherent, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, + p_idle_x_linear_rate=p_idle_x_linear_rate, + p_idle_y_linear_rate=p_idle_y_linear_rate, + p_idle_z_linear_rate=p_idle_z_linear_rate, + p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, + ) + # Tag-referenced detectors require the compiled HUGR (to recover the # sound, reorder-immune Guppy `result(tag, ...)` -> measurement # binding). `guppy_to_hugr` accepts @guppy-decorated functions and @@ -492,6 +698,8 @@ def from_guppy( idle_after_2q_duration=idle_after_2q_duration, idle_noise_parameters=( p_idle, + p_idle_linear, + p_idle_quadratic, t1, t2, p_idle_linear_rate, @@ -548,6 +756,7 @@ def from_guppy( p_idle=p_idle, t1=t1, t2=t2, + idle_rz=idle_rz, p_idle_linear_rate=p_idle_linear_rate, p_idle_quadratic_rate=p_idle_quadratic_rate, p_idle_x_linear_rate=p_idle_x_linear_rate, @@ -854,6 +1063,10 @@ def build_dem_from_guppy( p_meas: float = 0.001, p_prep: float = 0.001, p_idle: float | None = None, + p_idle_linear: float | None = None, + p_idle_linear_model: Mapping[str, float] | None = None, + p_idle_quadratic: float | None = None, + p_idle_coherent: bool = False, t1: float | None = None, t2: float | None = None, p_idle_linear_rate: float | None = None, @@ -904,21 +1117,38 @@ def build_dem_from_guppy( replacement labels in ``p2_weights``. p_meas: Measurement flip rate. p_prep: Preparation (reset) error rate. - p_idle: Optional uniform depolarizing idle-noise rate per idle duration. + p_idle: Optional shorthand for ``p_idle_linear`` with the uniform + ``{"X": 1/3, "Y": 1/3, "Z": 1/3}`` model. + p_idle_linear: Optional total stochastic idle-noise rate linear in + duration. Uses the engines ``GeneralNoiseModel`` convention. + p_idle_linear_model: Optional relative weights over ``"X"``, ``"Y"``, + and ``"Z"`` for ``p_idle_linear``. Weights must be finite, + non-negative, and sum to 1.0 within ``1e-5``. Defaults to the + engines' uniform model. The engines leakage key ``"L"`` is + reserved but unsupported by DEM construction. + p_idle_quadratic: Optional quadratic dephasing rate. The default + stochastic interpretation gives probability ``sin(rate * t)^2``; + the coherent interpretation forwards ``RZ(rate * t)`` and the DEM + uses its ``sin(rate * t / 2)^2`` half-angle convention. + p_idle_coherent: Select coherent RZ rather than stochastic Z + interpretation for ``p_idle_quadratic``. Defaults to ``False``. t1: Optional T1 relaxation time for explicit idle gates. t2: Optional T2 dephasing time for explicit idle gates. - p_idle_linear_rate: Optional legacy alias for stochastic Z-memory rate - linear in idle duration. - p_idle_quadratic_rate: Optional legacy alias for stochastic Z-memory - rate quadratic in idle duration. + p_idle_linear_rate: Deprecated bare Z-only alias for a stochastic rate + linear in idle duration. Use ``p_idle_linear`` with a Z-only model, + or ``p_idle_z_linear_rate`` for literal behavior. + p_idle_quadratic_rate: Deprecated bare Z-only coefficient-style rate + quadratic in idle duration. Use ``p_idle_quadratic`` for engines + semantics, or ``p_idle_z_quadratic_rate`` for literal behavior. p_idle_x_linear_rate: Optional stochastic X-memory rate linear in idle duration. p_idle_y_linear_rate: Optional stochastic Y-memory rate linear in idle duration. p_idle_z_linear_rate: Optional stochastic Z-memory rate linear in idle duration. p_idle_x_quadratic_rate: Optional stochastic X-memory rate quadratic in idle duration. p_idle_y_quadratic_rate: Optional stochastic Y-memory rate quadratic in idle duration. p_idle_z_quadratic_rate: Optional stochastic Z-memory rate quadratic in idle duration. - p_idle_quadratic_sine_rate: Optional legacy alias for stochastic - Z-memory rate with probability ``sin(rate * duration)^2``. + p_idle_quadratic_sine_rate: Deprecated bare Z-only alias for a + stochastic rate with probability ``sin(rate * duration)^2``. Use + ``p_idle_quadratic`` or ``p_idle_z_quadratic_sine_rate``. p_idle_x_quadratic_sine_rate: Optional stochastic X-memory sine-law rate. p_idle_y_quadratic_sine_rate: Optional stochastic Y-memory sine-law rate. p_idle_z_quadratic_sine_rate: Optional stochastic Z-memory sine-law rate. @@ -950,6 +1180,32 @@ def build_dem_from_guppy( """ from pecos.tracing import _trace_program_to_tick_circuit_with_result_traces + ( + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_z_quadratic_sine_rate, + idle_rz, + ) = _translate_structured_idle_noise( + p_idle=p_idle, + p_idle_linear=p_idle_linear, + p_idle_linear_model=p_idle_linear_model, + p_idle_quadratic=p_idle_quadratic, + p_idle_coherent=p_idle_coherent, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, + p_idle_x_linear_rate=p_idle_x_linear_rate, + p_idle_y_linear_rate=p_idle_y_linear_rate, + p_idle_z_linear_rate=p_idle_z_linear_rate, + p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, + ) + referenced_tags = sorted( {ref.tag for item in (*detectors, *observables) for ref in item.refs if isinstance(ref, ResultRef)}, ) @@ -979,6 +1235,8 @@ def build_dem_from_guppy( idle_after_2q_duration=idle_after_2q_duration, idle_noise_parameters=( p_idle, + p_idle_linear, + p_idle_quadratic, t1, t2, p_idle_linear_rate, @@ -1035,6 +1293,7 @@ def build_dem_from_guppy( p_idle=p_idle, t1=t1, t2=t2, + idle_rz=idle_rz, p_idle_linear_rate=p_idle_linear_rate, p_idle_quadratic_rate=p_idle_quadratic_rate, p_idle_x_linear_rate=p_idle_x_linear_rate, 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 343c33be2..7ef4407fd 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -57,6 +57,17 @@ def _two_qubit_idle_target() -> None: result("m1", m1) +@guppy +def _structured_idle_noise_target() -> None: + q0 = qubit() + q1 = qubit() + cx(q0, q1) + h(q0) + h(q1) + result("m0", measure(q0)) + result("m1", measure(q1)) + + @guppy def _measurement_feedback() -> None: q0 = qubit() @@ -196,6 +207,219 @@ def _two_qubit_dem(**kwargs): ) +def _structured_idle_dem(entrypoint: str, **kwargs): + if entrypoint == "from_guppy": + return DetectorErrorModel.from_guppy( + _structured_idle_noise_target, + num_qubits=2, + detectors_json=_TWO_QUBIT_DETECTORS_JSON, + observables_json=_TWO_QUBIT_OBSERVABLES_JSON, + num_measurements=2, + seed=0, + **_NO_GATE_NOISE, + **kwargs, + ) + return build_dem_from_guppy( + _structured_idle_noise_target, + num_qubits=2, + detectors=[Detector(rec[-2])], + observables=[Observable(rec[-1])], + seed=0, + **_NO_GATE_NOISE, + **kwargs, + ).dem + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_linear_default_matches_axis_primitives(entrypoint: str) -> None: + rate = 0.03 + + structured = _structured_idle_dem(entrypoint, idle_after_2q_duration=1.0, p_idle_linear=rate) + primitive = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_x_linear_rate=rate / 3.0, + p_idle_y_linear_rate=rate / 3.0, + p_idle_z_linear_rate=rate / 3.0, + ) + + assert structured.to_string() == primitive.to_string() + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_linear_custom_z_model_matches_axis_primitive(entrypoint: str) -> None: + rate = 0.03 + + structured = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_linear=rate, + p_idle_linear_model={"Z": 1.0}, + ) + primitive = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_z_linear_rate=rate, + ) + + assert structured.to_string() == primitive.to_string() + assert structured.num_contributions > 0 + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_linear_model_uses_engines_normalization_tolerance(entrypoint: str) -> None: + rate = 0.03 + + within_tolerance = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_linear=rate, + p_idle_linear_model={"Z": 1.0 + 5.0e-6}, + ) + normalized = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_linear=rate, + p_idle_linear_model={"Z": 1.0}, + ) + + assert within_tolerance.to_string() == normalized.to_string() + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_quadratic_stochastic_matches_sine_primitive(entrypoint: str) -> None: + rate = 0.17 + + structured = _structured_idle_dem(entrypoint, idle_after_2q_duration=1.0, p_idle_quadratic=rate) + primitive = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_z_quadratic_sine_rate=rate, + ) + + assert structured.to_string() == primitive.to_string() + assert structured.num_contributions > 0 + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_p_idle_shorthand_is_byte_identical_to_structured_uniform_linear(entrypoint: str) -> None: + rate = 0.03 + + shorthand = _structured_idle_dem(entrypoint, idle_after_2q_duration=1.0, p_idle=rate) + structured = _structured_idle_dem(entrypoint, idle_after_2q_duration=1.0, p_idle_linear=rate) + + assert shorthand.to_string() == structured.to_string() + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_quadratic_coherent_matches_from_circuit(entrypoint: str) -> None: + from pecos.tracing import trace_program_to_tick_circuit + + rate = 0.17 + structured = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_quadratic=rate, + p_idle_coherent=True, + ) + reference_circuit = trace_program_to_tick_circuit(_structured_idle_noise_target, 2, seed=0) + normalize_traced_tick_circuit(reference_circuit, context="structured idle coherent reference") + reference_circuit.insert_idle_after_two_qubit_gates(1.0) + reference_circuit.set_meta("detectors", _TWO_QUBIT_DETECTORS_JSON) + reference_circuit.set_meta("observables", _TWO_QUBIT_OBSERVABLES_JSON) + reference_circuit.set_meta("num_measurements", "2") + reference = DetectorErrorModel.from_circuit(reference_circuit, idle_rz=rate, **_NO_GATE_NOISE) + + assert structured.to_string() == reference.to_string() + assert structured.num_contributions > 0 + + +_LINEAR_IDLE_PRIMITIVES = ( + "p_idle_linear_rate", + "p_idle_x_linear_rate", + "p_idle_y_linear_rate", + "p_idle_z_linear_rate", +) +_QUADRATIC_IDLE_PRIMITIVES = ( + "p_idle_quadratic_rate", + "p_idle_x_quadratic_rate", + "p_idle_y_quadratic_rate", + "p_idle_z_quadratic_rate", + "p_idle_quadratic_sine_rate", + "p_idle_x_quadratic_sine_rate", + "p_idle_y_quadratic_sine_rate", + "p_idle_z_quadratic_sine_rate", +) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +@pytest.mark.parametrize("primitive", _LINEAR_IDLE_PRIMITIVES) +def test_structured_idle_linear_rejects_each_low_level_primitive(entrypoint: str, primitive: str) -> None: + with pytest.raises(ValueError, match=primitive): + _structured_idle_dem(entrypoint, p_idle_linear=0.01, **{primitive: 0.02}) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_linear_model_rejects_low_level_primitive_without_rate(entrypoint: str) -> None: + with pytest.raises(ValueError, match="p_idle_z_linear_rate"): + _structured_idle_dem( + entrypoint, + p_idle_linear_model={"Z": 1.0}, + p_idle_z_linear_rate=0.02, + ) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +@pytest.mark.parametrize("primitive", _QUADRATIC_IDLE_PRIMITIVES) +@pytest.mark.parametrize("structured", [{"p_idle_quadratic": 0.01}, {"p_idle_coherent": True}]) +def test_structured_idle_quadratic_rejects_each_low_level_primitive( + entrypoint: str, + primitive: str, + structured: dict[str, float | bool], +) -> None: + with pytest.raises(ValueError, match=primitive): + _structured_idle_dem(entrypoint, **structured, **{primitive: 0.02}) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_p_idle_rejects_structured_linear_rate(entrypoint: str) -> None: + with pytest.raises(ValueError, match=r"p_idle and p_idle_linear"): + _structured_idle_dem(entrypoint, p_idle=0.01, p_idle_linear=0.01) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"p_idle_linear": 0.01, "p_idle_linear_model": {"A": 1.0}}, "invalid.*key"), + ({"p_idle_linear": 0.01, "p_idle_linear_model": {"L": 1.0}}, "leakage.*engines|engines.*leakage"), + ({"p_idle_linear": 0.01, "p_idle_linear_model": {"X": 0.4, "Z": 0.4}}, "sum to 1.0"), + ({"p_idle_linear": 0.01, "p_idle_linear_model": {"X": -0.1, "Z": 1.1}}, "non-negative"), + ({"p_idle_linear_model": {"Z": 1.0}}, "requires p_idle_linear"), + ({"p_idle_coherent": True}, "requires p_idle_quadratic"), + ], +) +def test_structured_idle_model_validation(entrypoint: str, kwargs: dict[str, object], message: str) -> None: + with pytest.raises(ValueError, match=message): + _structured_idle_dem(entrypoint, **kwargs) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +@pytest.mark.parametrize( + ("alias", "replacement"), + [ + ("p_idle_linear_rate", "p_idle_linear"), + ("p_idle_quadratic_rate", "p_idle_quadratic"), + ("p_idle_quadratic_sine_rate", "p_idle_quadratic"), + ], +) +def test_legacy_idle_alias_warns_and_remains_functional(entrypoint: str, alias: str, replacement: str) -> None: + with pytest.warns(DeprecationWarning, match=rf"{alias}.*{replacement}"): + dem = _structured_idle_dem(entrypoint, idle_after_2q_duration=1.0, **{alias: 0.03}) + + assert dem.num_contributions > 0 + + def test_from_guppy_idle_insertion_matches_manual_pass_pipeline() -> None: from pecos.tracing import trace_program_to_tick_circuit @@ -224,6 +448,8 @@ def test_from_guppy_inserted_idles_make_idle_noise_effective() -> None: # guard wiring in dem.py must fail the corresponding parametrized case below. _ALL_IDLE_NOISE_PARAMS = { "p_idle": 0.01, + "p_idle_linear": 0.01, + "p_idle_quadratic": 0.01, "t1": 100.0, "t2": 100.0, "p_idle_linear_rate": 0.01, From 48f7f4c592bbf1176332fca4c974bc209bc6bfa6 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 16:15:47 -0600 Subject: [PATCH 06/62] Fail loud on idle-channel combinations the Rust NoiseConfig silently clobbers; correct coherent-branch docs --- docs/user-guide/dem-from-guppy.md | 19 +++++-- python/quantum-pecos/src/pecos/qec/dem.py | 40 +++++++++++-- .../tests/qec/test_from_guppy_dem.py | 57 +++++++++++++++++++ 3 files changed, 106 insertions(+), 10 deletions(-) diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index 79b7c0dfa..14179a630 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -207,10 +207,21 @@ The recommended structured interface mirrors the engines cannot represent leakage. `p_idle` remains shorthand for the uniform model. - `p_idle_quadratic` is the engines-style quadratic dephasing rate. By default, an idle of duration `t` has a stochastic Z probability - `sin(p_idle_quadratic * t) ** 2`. With `p_idle_coherent=True`, it instead - contributes an `RZ(p_idle_quadratic * t)` angle. The DEM coherently adds - angles for matching detector sets and uses the RZ half-angle probability - `sin(total_angle / 2) ** 2`. + `sin(p_idle_quadratic * t) ** 2`. With `p_idle_coherent=True`, the rate is + interpreted as a coherent `RZ` angle per unit idle time and stored as its + exact Pauli twirl — a stochastic Z channel with the half-angle probability + `sin(p_idle_quadratic / 2) ** 2` per unit idle time. Coherent + cross-location accumulation belongs to the EEG tooling, not this + constructor. Because the conversion replaces the base idle channel, + `p_idle_coherent=True` cannot be combined with `p_idle` or `t1`/`t2` + (and `p_idle` with `t1`/`t2` is likewise rejected — the T1/T2 channel + replaces the depolarizing base channel). + +These rates match the engines *runtime* application semantics +(`GeneralNoiseModel`'s internal fields); the engines `GeneralNoiseModelBuilder` +additionally rescales its public inputs (square-root scaling, an +incoherent-conversion factor, and cycles-to-radians), so builder inputs are +not directly interchangeable with these parameters. The per-axis `p_idle_{x,y,z}_linear_rate`, `p_idle_{x,y,z}_quadratic_rate`, and diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 1eb690f4b..cbfc6ad10 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -65,6 +65,8 @@ def _translate_structured_idle_noise( *, p_idle: float | None, + t1: float | None, + t2: float | None, p_idle_linear: float | None, p_idle_linear_model: Mapping[str, float] | None, p_idle_quadratic: float | None, @@ -98,6 +100,20 @@ def _translate_structured_idle_noise( if p_idle is not None and p_idle_linear is not None: msg = "p_idle and p_idle_linear cannot be combined; p_idle is the uniform-model shorthand" raise ValueError(msg) + if p_idle is not None and (t1 is not None or t2 is not None): + # The Rust NoiseConfig base idle channel is T1/T2 when set, else + # p_idle-depolarizing -- passing both would silently ignore p_idle. + msg = "p_idle and t1/t2 cannot be combined; the T1/T2 channel replaces the depolarizing base channel" + raise ValueError(msg) + if p_idle_coherent: + # NoiseConfig::set_idle_rz replaces the T1/T2 fields with a synthetic + # T2 and zeroes p_idle -- both combinations would be silently lost. + if p_idle is not None: + msg = "p_idle_coherent=True cannot be combined with p_idle; the coherent RZ conversion replaces the base idle channel" + raise ValueError(msg) + if t1 is not None or t2 is not None: + msg = "p_idle_coherent=True cannot be combined with t1/t2; the coherent RZ conversion overwrites the T1/T2 channel" + raise ValueError(msg) quadratic_primitives = { "p_idle_quadratic_rate": p_idle_quadratic_rate, @@ -514,10 +530,15 @@ def from_guppy( p_idle_quadratic: Optional quadratic dephasing rate. With ``p_idle_coherent=False``, an idle of duration ``t`` produces a stochastic Z fault with probability ``sin(rate * t)^2``. - With ``p_idle_coherent=True``, the same rate is forwarded as a - coherent ``RZ(rate * t)`` angle; the DEM converts an isolated - rotation to ``sin(rate * t / 2)^2`` and coherently accumulates - angles for matching detector sets. + With ``p_idle_coherent=True``, the rate is interpreted as a + coherent ``RZ(rate)`` angle per unit idle time and stored by + the DEM builder as its exact Pauli twirl: a stochastic Z + channel with ``sin(rate / 2)^2`` per unit idle time, + represented internally via an equivalent T2. Coherent + cross-location angle accumulation is the EEG pipeline's + domain, not this constructor's. Because that conversion + replaces the base idle channel, ``p_idle_coherent=True`` + cannot be combined with ``p_idle`` or ``t1``/``t2``. p_idle_coherent: Select coherent RZ rather than stochastic Z interpretation for ``p_idle_quadratic``. Defaults to ``False``. t1: Optional T1 relaxation time for explicit idle gates. @@ -614,6 +635,8 @@ def from_guppy( idle_rz, ) = _translate_structured_idle_noise( p_idle=p_idle, + t1=t1, + t2=t2, p_idle_linear=p_idle_linear, p_idle_linear_model=p_idle_linear_model, p_idle_quadratic=p_idle_quadratic, @@ -1128,8 +1151,11 @@ def build_dem_from_guppy( reserved but unsupported by DEM construction. p_idle_quadratic: Optional quadratic dephasing rate. The default stochastic interpretation gives probability ``sin(rate * t)^2``; - the coherent interpretation forwards ``RZ(rate * t)`` and the DEM - uses its ``sin(rate * t / 2)^2`` half-angle convention. + the coherent interpretation stores the exact Pauli twirl of an + ``RZ(rate)`` per unit idle time (stochastic Z with + ``sin(rate / 2)^2``, via an equivalent T2) and cannot be combined + with ``p_idle`` or ``t1``/``t2``. Coherent cross-location + accumulation belongs to the EEG pipeline. p_idle_coherent: Select coherent RZ rather than stochastic Z interpretation for ``p_idle_quadratic``. Defaults to ``False``. t1: Optional T1 relaxation time for explicit idle gates. @@ -1188,6 +1214,8 @@ def build_dem_from_guppy( idle_rz, ) = _translate_structured_idle_noise( p_idle=p_idle, + t1=t1, + t2=t2, p_idle_linear=p_idle_linear, p_idle_linear_model=p_idle_linear_model, p_idle_quadratic=p_idle_quadratic, 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 7ef4407fd..a2985c1b9 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -479,6 +479,63 @@ def test_from_guppy_rejects_non_positive_idle_duration(bad_duration: float) -> N _two_qubit_dem(idle_after_2q_duration=bad_duration, p_idle=0.01) +def test_from_guppy_rejects_coherent_with_base_idle_channel() -> None: + # NoiseConfig::set_idle_rz zeroes p_idle in Rust; the combination must + # fail loud instead of silently dropping the depolarizing channel. + with pytest.raises(ValueError, match=r"coherent RZ conversion replaces the base idle channel"): + _two_qubit_dem( + idle_after_2q_duration=1.0, + p_idle=0.01, + p_idle_quadratic=0.02, + p_idle_coherent=True, + ) + + +def test_from_guppy_rejects_coherent_with_t1_t2() -> None: + # NoiseConfig::set_idle_rz overwrites the T1/T2 fields in Rust. + with pytest.raises(ValueError, match=r"overwrites the T1/T2 channel"): + _two_qubit_dem( + idle_after_2q_duration=1.0, + t1=100.0, + t2=50.0, + p_idle_quadratic=0.02, + p_idle_coherent=True, + ) + + +def test_from_guppy_rejects_p_idle_with_t1_t2() -> None: + # The Rust base idle channel is T1/T2 when set, silently ignoring p_idle. + with pytest.raises(ValueError, match=r"T1/T2 channel replaces the depolarizing base channel"): + _two_qubit_dem(idle_after_2q_duration=1.0, p_idle=0.01, t1=100.0, t2=50.0) + + +def test_from_guppy_coherent_composes_with_structured_linear() -> None: + # p_idle_linear maps to the dedicated idle-memory fields, which compose + # with the RZ-derived channel -- this combination must stay legal. + dem = _two_qubit_dem( + idle_after_2q_duration=1.0, + p_idle_linear=0.01, + p_idle_quadratic=0.02, + p_idle_coherent=True, + ) + assert dem.num_contributions > 0 + + +def test_build_dem_from_guppy_rejects_coherent_with_base_idle_channel() -> None: + with pytest.raises(ValueError, match=r"coherent RZ conversion replaces the base idle channel"): + build_dem_from_guppy( + _two_qubit_idle_target, + num_qubits=2, + detectors=[Detector(rec[-2])], + observables=[Observable(rec[-1])], + idle_after_2q_duration=1.0, + p_idle=0.01, + p_idle_quadratic=0.02, + p_idle_coherent=True, + **_NO_GATE_NOISE, + ) + + def test_from_guppy_idle_guard_accepts_inserted_idles_and_idles_without_noise() -> None: with_noise = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle=0.01) without_noise = _two_qubit_dem(idle_after_2q_duration=1.0) From f889a416cf05bd8b59036a607b71981285b6f058 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 16:33:09 -0600 Subject: [PATCH 07/62] Reshape layer-1 idle API into three rate+model families with relative-rate semantics --- docs/user-guide/dem-from-guppy.md | 51 ++- python/quantum-pecos/src/pecos/qec/dem.py | 355 ++++++++++++------ .../tests/qec/test_from_guppy_dem.py | 136 +++++-- 3 files changed, 378 insertions(+), 164 deletions(-) diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index 14179a630..75524c5dd 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -197,25 +197,36 @@ different DEM. ## Idle Noise -The recommended structured interface mirrors the engines -`GeneralNoiseModel`: - -- `p_idle_linear` is the total stochastic idle rate, linear in duration. - `p_idle_linear_model` supplies relative `X`, `Y`, and `Z` weights that sum - to 1; its default is the uniform `{X: 1/3, Y: 1/3, Z: 1/3}` engines model. - The engines `L` leakage key is reserved but rejected because DEM construction - cannot represent leakage. `p_idle` remains shorthand for the uniform model. -- `p_idle_quadratic` is the engines-style quadratic dephasing rate. By default, - an idle of duration `t` has a stochastic Z probability - `sin(p_idle_quadratic * t) ** 2`. With `p_idle_coherent=True`, the rate is - interpreted as a coherent `RZ` angle per unit idle time and stored as its - exact Pauli twirl — a stochastic Z channel with the half-angle probability - `sin(p_idle_quadratic / 2) ** 2` per unit idle time. Coherent - cross-location accumulation belongs to the EEG tooling, not this - constructor. Because the conversion replaces the base idle channel, - `p_idle_coherent=True` cannot be combined with `p_idle` or `t1`/`t2` - (and `p_idle` with `t1`/`t2` is likewise rejected — the T1/T2 channel - replaces the depolarizing base channel). +The recommended structured interface has three uniform rate-and-model +families. Every model value is a **relative-rate multiplier**: for each axis, +`axis_rate = family_rate * axis_multiplier`. The linear law is additive, so +its multipliers are exactly the engines relative-probability distribution and +must sum to 1. The nonlinear laws are not additive, so their finite, +non-negative multipliers have no sum constraint. + +- Linear: `p_idle_linear` with `p_idle_linear_model`. An axis fault has + probability `(p_idle_linear * m_axis) * t`. The model keys are `X`, `Y`, and + `Z`, and the default is the uniform `{X: 1/3, Y: 1/3, Z: 1/3}` engines + model. `p_idle` remains shorthand for that uniform model. +- Sine-squared: `p_idle_sin_squared` with `p_idle_sin_squared_model`. An axis + fault has probability `sin((p_idle_sin_squared * m_axis) * t) ** 2`. The + model keys are `X`, `Y`, and `Z`; there is no sum constraint, and the default + `{Z: 1.0}` means scalar-only use is full-rate stochastic Z dephasing. +- Coherent: `p_idle_coherent` with `p_idle_coherent_model`. An axis rotation has + angle `(p_idle_coherent * m_axis) * t`. The default is `{RZ: 1.0}`. DEM v1 + represents only RZ, so nonzero `RX`/`RY` multipliers are rejected and `U` is + reserved for future Hamiltonian-level support. This constructor stores RZ as + its exact Pauli twirl — a stochastic Z channel with half-angle probability + `sin(rate / 2) ** 2` per unit idle time, via an equivalent T2. True coherent + cross-location accumulation belongs to the EEG tooling. Because this + conversion replaces the base idle channel, the coherent family cannot be + combined with `p_idle` or `t1`/`t2` (and `p_idle` with `t1`/`t2` is likewise + rejected — the T1/T2 channel replaces the depolarizing base channel). + +The engines `L` leakage key is reserved but rejected for all three models +because DEM construction cannot represent leakage. Multi-qubit idle faults +are outside the scope of these keyword arguments and will arrive through a +typed channel interface. These rates match the engines *runtime* application semantics (`GeneralNoiseModel`'s internal fields); the engines `GeneralNoiseModelBuilder` @@ -289,7 +300,7 @@ with_idle_noise = DetectorErrorModel.from_guppy( idle_after_2q_duration=1.0, p_idle_linear=0.01, p_idle_linear_model={"X": 0.25, "Z": 0.75}, - p_idle_quadratic=0.02, + p_idle_sin_squared=0.02, **common, ) diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index cbfc6ad10..dd8255e34 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -60,6 +60,81 @@ _GENERATOR_LAYOUT_ATTR = "__pecos_named_measurement_layout_v2__" _IDLE_MODEL_NORMALIZATION_TOLERANCE = 1.0e-5 _IDLE_MODEL_FLOAT_EPSILON = 1.0e-10 +_EMPTY_IDLE_MODEL_KEYS: frozenset[str] = frozenset() + + +def _validate_idle_family_model( + *, + rate: float | None, + rate_name: str, + model: Mapping[str, float] | None, + model_name: str, + default_model: Mapping[str, float], + valid_keys: frozenset[str], + require_normalized: bool, + unsupported_keys: frozenset[str] = _EMPTY_IDLE_MODEL_KEYS, +) -> tuple[float, dict[str, float]] | None: + """Validate one structured idle family and return its rate and multipliers.""" + if model is not None and rate is None: + msg = f"{model_name} requires {rate_name}; otherwise the model is inert" + raise ValueError(msg) + if rate is None: + return None + + if isinstance(rate, bool): + msg = f"{rate_name} must be a finite, non-negative float" + raise TypeError(msg) + try: + numeric_rate = float(rate) + except (TypeError, ValueError) as exc: + msg = f"{rate_name} must be a finite, non-negative float" + raise ValueError(msg) from exc + if not math.isfinite(numeric_rate) or numeric_rate < 0.0: + msg = f"{rate_name} must be a finite, non-negative float" + raise ValueError(msg) + + if model is not None and not isinstance(model, Mapping): + expected = ", ".join(repr(key) for key in sorted(valid_keys)) + msg = f"{model_name} must be a mapping from {expected} to relative-rate multipliers" + raise ValueError(msg) + selected_model = model if model is not None else default_model + validated_model: dict[str, float] = {} + for key, multiplier in selected_model.items(): + if key == "L": + msg = ( + f"{model_name} key 'L' denotes leakage, which is supported by the engines simulators " + "but not by DEM construction" + ) + raise ValueError(msg) + if key in unsupported_keys: + msg = f"{model_name} key {key!r} is reserved; only 'RZ' is representable by DEM construction today" + raise ValueError(msg) + if key not in valid_keys: + expected = ", ".join(repr(valid_key) for valid_key in sorted(valid_keys)) + msg = f"invalid {model_name} key {key!r}; expected {expected}" + raise ValueError(msg) + try: + numeric_multiplier = float(multiplier) + except (TypeError, ValueError) as exc: + msg = f"{model_name} multiplier for {key!r} must be a finite, non-negative float" + raise ValueError(msg) from exc + if not math.isfinite(numeric_multiplier) or numeric_multiplier < 0.0: + msg = f"{model_name} multiplier for {key!r} must be a finite, non-negative float" + raise ValueError(msg) + validated_model[key] = numeric_multiplier + + if require_normalized: + total_multiplier = sum(validated_model.values()) + if total_multiplier <= 0.0 or abs(total_multiplier - 1.0) > _IDLE_MODEL_NORMALIZATION_TOLERANCE: + msg = ( + f"{model_name} multipliers must sum to 1.0 within tolerance " + f"{_IDLE_MODEL_NORMALIZATION_TOLERANCE:g}; got {total_multiplier}" + ) + raise ValueError(msg) + if abs(total_multiplier - 1.0) > _IDLE_MODEL_FLOAT_EPSILON: + validated_model = {key: multiplier / total_multiplier for key, multiplier in validated_model.items()} + + return numeric_rate, validated_model def _translate_structured_idle_noise( @@ -69,21 +144,28 @@ def _translate_structured_idle_noise( t2: float | None, p_idle_linear: float | None, p_idle_linear_model: Mapping[str, float] | None, - p_idle_quadratic: float | None, - p_idle_coherent: bool, + p_idle_sin_squared: float | None, + p_idle_sin_squared_model: Mapping[str, float] | None, + p_idle_coherent: float | None, + p_idle_coherent_model: Mapping[str, float] | None, p_idle_linear_rate: float | None, p_idle_quadratic_rate: float | None, p_idle_x_linear_rate: float | None, p_idle_y_linear_rate: float | None, p_idle_z_linear_rate: float | None, - p_idle_x_quadratic_rate: float | None, - p_idle_y_quadratic_rate: float | None, - p_idle_z_quadratic_rate: float | None, p_idle_quadratic_sine_rate: float | None, p_idle_x_quadratic_sine_rate: float | None, p_idle_y_quadratic_sine_rate: float | None, p_idle_z_quadratic_sine_rate: float | None, -) -> tuple[float | None, float | None, float | None, float | None, float | None]: +) -> tuple[ + float | None, + float | None, + float | None, + float | None, + float | None, + float | None, + float | None, +]: """Validate and translate engines-style idle noise to DEM primitives.""" linear_primitives = { "p_idle_linear_rate": p_idle_linear_rate, @@ -105,38 +187,32 @@ def _translate_structured_idle_noise( # p_idle-depolarizing -- passing both would silently ignore p_idle. msg = "p_idle and t1/t2 cannot be combined; the T1/T2 channel replaces the depolarizing base channel" raise ValueError(msg) - if p_idle_coherent: + if p_idle_coherent is not None: # NoiseConfig::set_idle_rz replaces the T1/T2 fields with a synthetic # T2 and zeroes p_idle -- both combinations would be silently lost. if p_idle is not None: - msg = "p_idle_coherent=True cannot be combined with p_idle; the coherent RZ conversion replaces the base idle channel" + msg = ( + "p_idle_coherent cannot be combined with p_idle; " + "the coherent RZ conversion replaces the base idle channel" + ) raise ValueError(msg) if t1 is not None or t2 is not None: - msg = "p_idle_coherent=True cannot be combined with t1/t2; the coherent RZ conversion overwrites the T1/T2 channel" + msg = ( + "p_idle_coherent cannot be combined with t1/t2; the coherent RZ conversion overwrites the T1/T2 channel" + ) raise ValueError(msg) - quadratic_primitives = { - "p_idle_quadratic_rate": p_idle_quadratic_rate, - "p_idle_x_quadratic_rate": p_idle_x_quadratic_rate, - "p_idle_y_quadratic_rate": p_idle_y_quadratic_rate, - "p_idle_z_quadratic_rate": p_idle_z_quadratic_rate, + sine_primitives = { "p_idle_quadratic_sine_rate": p_idle_quadratic_sine_rate, "p_idle_x_quadratic_sine_rate": p_idle_x_quadratic_sine_rate, "p_idle_y_quadratic_sine_rate": p_idle_y_quadratic_sine_rate, "p_idle_z_quadratic_sine_rate": p_idle_z_quadratic_sine_rate, } - if (p_idle_quadratic is not None or p_idle_coherent) and any( - value is not None for value in quadratic_primitives.values() + if (p_idle_sin_squared is not None or p_idle_sin_squared_model is not None) and any( + value is not None for value in sine_primitives.values() ): - conflicts = ", ".join(name for name, value in quadratic_primitives.items() if value is not None) - msg = f"p_idle_quadratic/p_idle_coherent cannot be combined with low-level idle rate(s): {conflicts}" - raise ValueError(msg) - - if p_idle_linear_model is not None and p_idle_linear is None: - msg = "p_idle_linear_model requires p_idle_linear; otherwise the model is inert" - raise ValueError(msg) - if p_idle_coherent and p_idle_quadratic is None: - msg = "p_idle_coherent=True requires p_idle_quadratic; otherwise it is inert" + conflicts = ", ".join(name for name, value in sine_primitives.items() if value is not None) + msg = f"p_idle_sin_squared/p_idle_sin_squared_model cannot be combined with sine-law idle rate(s): {conflicts}" raise ValueError(msg) legacy_replacements = { @@ -150,14 +226,14 @@ def _translate_structured_idle_noise( "p_idle_quadratic_rate": ( p_idle_quadratic_rate, ( - "p_idle_quadratic for the engines-consistent quadratic interface, " + "p_idle_sin_squared for the engines-consistent dephasing interface, " "or p_idle_z_quadratic_rate for literal coefficient-style Z-only behavior" ), ), "p_idle_quadratic_sine_rate": ( p_idle_quadratic_sine_rate, ( - "p_idle_quadratic for the engines-consistent quadratic interface, " + "p_idle_sin_squared for the engines-consistent sine-law interface, " "or p_idle_z_quadratic_sine_rate for literal Z-only behavior" ), ), @@ -170,59 +246,70 @@ def _translate_structured_idle_noise( stacklevel=3, ) - if p_idle_linear is not None: - if p_idle_linear_model is not None and not isinstance(p_idle_linear_model, Mapping): - msg = "p_idle_linear_model must be a mapping from 'X', 'Y', and 'Z' to weights" - raise ValueError(msg) - model = ( - p_idle_linear_model if p_idle_linear_model is not None else {"X": 1.0 / 3.0, "Y": 1.0 / 3.0, "Z": 1.0 / 3.0} + linear_family = _validate_idle_family_model( + rate=p_idle_linear, + rate_name="p_idle_linear", + model=p_idle_linear_model, + model_name="p_idle_linear_model", + default_model={"X": 1.0 / 3.0, "Y": 1.0 / 3.0, "Z": 1.0 / 3.0}, + valid_keys=frozenset({"X", "Y", "Z"}), + require_normalized=True, + ) + if linear_family is not None: + linear_rate, linear_model = linear_family + p_idle_x_linear_rate = linear_rate * linear_model.get("X", 0.0) + p_idle_y_linear_rate = linear_rate * linear_model.get("Y", 0.0) + p_idle_z_linear_rate = linear_rate * linear_model.get("Z", 0.0) + + sin_squared_family = _validate_idle_family_model( + rate=p_idle_sin_squared, + rate_name="p_idle_sin_squared", + model=p_idle_sin_squared_model, + model_name="p_idle_sin_squared_model", + default_model={"Z": 1.0}, + valid_keys=frozenset({"X", "Y", "Z"}), + require_normalized=False, + ) + if sin_squared_family is not None: + sin_squared_rate, sin_squared_model = sin_squared_family + p_idle_x_quadratic_sine_rate = ( + sin_squared_rate * sin_squared_model["X"] if sin_squared_model.get("X", 0.0) != 0.0 else None + ) + p_idle_y_quadratic_sine_rate = ( + sin_squared_rate * sin_squared_model["Y"] if sin_squared_model.get("Y", 0.0) != 0.0 else None + ) + p_idle_z_quadratic_sine_rate = ( + sin_squared_rate * sin_squared_model["Z"] if sin_squared_model.get("Z", 0.0) != 0.0 else None ) - normalized_model: dict[str, float] = {} - for key, weight in model.items(): - if key == "L": - msg = ( - "p_idle_linear_model key 'L' denotes leakage, which is supported by the engines simulators " - "but not by DEM construction" - ) - raise ValueError(msg) - if key not in {"X", "Y", "Z"}: - msg = f"invalid p_idle_linear_model key {key!r}; expected 'X', 'Y', or 'Z'" - raise ValueError(msg) - try: - numeric_weight = float(weight) - except (TypeError, ValueError) as exc: - msg = f"p_idle_linear_model weight for {key!r} must be a finite, non-negative float" - raise ValueError(msg) from exc - if not math.isfinite(numeric_weight) or numeric_weight < 0.0: - msg = f"p_idle_linear_model weight for {key!r} must be a finite, non-negative float" - raise ValueError(msg) - normalized_model[key] = numeric_weight - total_weight = sum(normalized_model.values()) - if total_weight <= 0.0 or abs(total_weight - 1.0) > _IDLE_MODEL_NORMALIZATION_TOLERANCE: + coherent_family = _validate_idle_family_model( + rate=p_idle_coherent, + rate_name="p_idle_coherent", + model=p_idle_coherent_model, + model_name="p_idle_coherent_model", + default_model={"RZ": 1.0}, + valid_keys=frozenset({"RX", "RY", "RZ"}), + require_normalized=False, + unsupported_keys=frozenset({"U"}), + ) + idle_rz = None + if coherent_family is not None: + coherent_rate, coherent_model = coherent_family + unsupported_axes = [axis for axis in ("RX", "RY") if coherent_model.get(axis, 0.0) != 0.0] + if unsupported_axes: msg = ( - "p_idle_linear_model weights must sum to 1.0 within tolerance " - f"{_IDLE_MODEL_NORMALIZATION_TOLERANCE:g}; got {total_weight}" + f"p_idle_coherent_model has nonzero {', '.join(unsupported_axes)} multiplier(s); " + "only 'RZ' is representable by DEM construction today" ) raise ValueError(msg) - if abs(total_weight - 1.0) > _IDLE_MODEL_FLOAT_EPSILON: - normalized_model = {key: weight / total_weight for key, weight in normalized_model.items()} - - p_idle_x_linear_rate = p_idle_linear * normalized_model.get("X", 0.0) - p_idle_y_linear_rate = p_idle_linear * normalized_model.get("Y", 0.0) - p_idle_z_linear_rate = p_idle_linear * normalized_model.get("Z", 0.0) - - idle_rz = None - if p_idle_quadratic is not None: - if p_idle_coherent: - idle_rz = p_idle_quadratic - else: - p_idle_z_quadratic_sine_rate = p_idle_quadratic + idle_rz = coherent_rate * coherent_model.get("RZ", 0.0) return ( p_idle_x_linear_rate, p_idle_y_linear_rate, p_idle_z_linear_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, p_idle_z_quadratic_sine_rate, idle_rz, ) @@ -407,8 +494,10 @@ def from_guppy( p_idle: float | None = None, p_idle_linear: float | None = None, p_idle_linear_model: Mapping[str, float] | None = None, - p_idle_quadratic: float | None = None, - p_idle_coherent: bool = False, + p_idle_sin_squared: float | None = None, + p_idle_sin_squared_model: Mapping[str, float] | None = None, + p_idle_coherent: float | None = None, + p_idle_coherent_model: Mapping[str, float] | None = None, t1: float | None = None, t2: float | None = None, p_idle_linear_rate: float | None = None, @@ -438,6 +527,13 @@ def from_guppy( native PECOS fault propagation. All metadata validation happens in the Rust DEM builder (single source of truth). + The three structured idle models contain relative-rate multipliers: + each axis rate is ``family_rate * axis_multiplier``. The linear law is + additive, so its multipliers must sum to 1.0 and coincide with the + engines relative-probability distribution. The nonlinear sine-squared + and coherent laws are not additive, so their finite, non-negative + multipliers have no sum constraint. + Args: guppy: A HUGR-certifiable program: a ``@guppy``-decorated function, a compiled Guppy program (e.g. the object returned by @@ -527,29 +623,38 @@ def from_guppy( non-negative, and sum to 1.0 within ``1e-5``. Defaults to the engines' uniform model. The engines leakage key ``"L"`` is reserved but unsupported by DEM construction. - p_idle_quadratic: Optional quadratic dephasing rate. With - ``p_idle_coherent=False``, an idle of duration ``t`` produces a - stochastic Z fault with probability ``sin(rate * t)^2``. - With ``p_idle_coherent=True``, the rate is interpreted as a - coherent ``RZ(rate)`` angle per unit idle time and stored by - the DEM builder as its exact Pauli twirl: a stochastic Z - channel with ``sin(rate / 2)^2`` per unit idle time, - represented internally via an equivalent T2. Coherent - cross-location angle accumulation is the EEG pipeline's - domain, not this constructor's. Because that conversion - replaces the base idle channel, ``p_idle_coherent=True`` + p_idle_sin_squared: Optional stochastic sine-law dephasing rate. An + axis multiplier ``m`` gives probability + ``sin((p_idle_sin_squared * m) * t)^2`` for an idle of duration + ``t``. Defaults to Z-only dephasing at the full family rate. + p_idle_sin_squared_model: Optional relative-rate multipliers over + ``"X"``, ``"Y"``, and ``"Z"`` for ``p_idle_sin_squared``. + Values must be finite and non-negative, with no sum constraint. + Defaults to ``{"Z": 1.0}``. The engines leakage key ``"L"`` is + reserved but unsupported by DEM construction. + p_idle_coherent: Optional coherent-rotation rate. An ``RZ`` + multiplier ``m`` gives angle ``(p_idle_coherent * m) * t``. + DEM construction currently supports only RZ and stores its + exact Pauli twirl: a stochastic Z channel with half-angle + probability ``sin(rate / 2)^2`` per unit idle time, represented + internally via an equivalent T2. Coherent cross-location angle + accumulation is the EEG pipeline's domain. This conversion cannot be combined with ``p_idle`` or ``t1``/``t2``. - p_idle_coherent: Select coherent RZ rather than stochastic Z - interpretation for ``p_idle_quadratic``. Defaults to ``False``. + p_idle_coherent_model: Optional relative-rate multipliers over + ``"RX"``, ``"RY"``, and ``"RZ"`` for ``p_idle_coherent``. + Values must be finite and non-negative, with no sum constraint; + the default is ``{"RZ": 1.0}``. Nonzero ``"RX"``/``"RY"`` are + rejected because DEM v1 represents only RZ. ``"U"`` is reserved + for future Hamiltonian-level support and is rejected today. t1: Optional T1 relaxation time for explicit idle gates. t2: Optional T2 dephasing time for explicit idle gates. p_idle_linear_rate: Deprecated bare Z-only alias for a stochastic rate linear in idle duration. Use ``p_idle_linear`` with a Z-only model, or ``p_idle_z_linear_rate`` for literal behavior. p_idle_quadratic_rate: Deprecated bare Z-only coefficient-style - rate quadratic in idle duration. Use ``p_idle_quadratic`` for - engines semantics, or ``p_idle_z_quadratic_rate`` for literal - behavior. + rate quadratic in idle duration. Use ``p_idle_sin_squared`` for + the structured engines-style dephasing interface, or + ``p_idle_z_quadratic_rate`` for literal behavior. p_idle_x_linear_rate: Optional stochastic X-memory rate linear in idle duration. p_idle_y_linear_rate: Optional stochastic Y-memory rate linear in idle duration. p_idle_z_linear_rate: Optional stochastic Z-memory rate linear in idle duration. @@ -558,7 +663,7 @@ def from_guppy( p_idle_z_quadratic_rate: Optional stochastic Z-memory rate quadratic in idle duration. p_idle_quadratic_sine_rate: Deprecated bare Z-only alias for a stochastic rate with probability ``sin(rate * duration)^2``. - Use ``p_idle_quadratic`` or ``p_idle_z_quadratic_sine_rate``. + Use ``p_idle_sin_squared`` or ``p_idle_z_quadratic_sine_rate``. p_idle_x_quadratic_sine_rate: Optional stochastic X-memory sine-law rate. p_idle_y_quadratic_sine_rate: Optional stochastic Y-memory sine-law rate. p_idle_z_quadratic_sine_rate: Optional stochastic Z-memory sine-law rate. @@ -631,6 +736,8 @@ def from_guppy( p_idle_x_linear_rate, p_idle_y_linear_rate, p_idle_z_linear_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, p_idle_z_quadratic_sine_rate, idle_rz, ) = _translate_structured_idle_noise( @@ -639,16 +746,15 @@ def from_guppy( t2=t2, p_idle_linear=p_idle_linear, p_idle_linear_model=p_idle_linear_model, - p_idle_quadratic=p_idle_quadratic, + p_idle_sin_squared=p_idle_sin_squared, + p_idle_sin_squared_model=p_idle_sin_squared_model, p_idle_coherent=p_idle_coherent, + p_idle_coherent_model=p_idle_coherent_model, p_idle_linear_rate=p_idle_linear_rate, p_idle_quadratic_rate=p_idle_quadratic_rate, p_idle_x_linear_rate=p_idle_x_linear_rate, p_idle_y_linear_rate=p_idle_y_linear_rate, p_idle_z_linear_rate=p_idle_z_linear_rate, - p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, @@ -722,7 +828,8 @@ def from_guppy( idle_noise_parameters=( p_idle, p_idle_linear, - p_idle_quadratic, + p_idle_sin_squared, + p_idle_coherent, t1, t2, p_idle_linear_rate, @@ -1088,8 +1195,10 @@ def build_dem_from_guppy( p_idle: float | None = None, p_idle_linear: float | None = None, p_idle_linear_model: Mapping[str, float] | None = None, - p_idle_quadratic: float | None = None, - p_idle_coherent: bool = False, + p_idle_sin_squared: float | None = None, + p_idle_sin_squared_model: Mapping[str, float] | None = None, + p_idle_coherent: float | None = None, + p_idle_coherent_model: Mapping[str, float] | None = None, t1: float | None = None, t2: float | None = None, p_idle_linear_rate: float | None = None, @@ -1122,6 +1231,13 @@ def build_dem_from_guppy( Measurement-dependent quantum control remains unsupported because one captured execution is not a static circuit model. + The three structured idle models contain relative-rate multipliers: each + axis rate is ``family_rate * axis_multiplier``. The linear law is additive, + so its multipliers must sum to 1.0 and coincide with the engines + relative-probability distribution. The nonlinear sine-squared and coherent + laws are not additive, so their finite, non-negative multipliers have no + sum constraint. + Args: guppy: A HUGR-certifiable Guppy program to trace once under the Selene QIS engine. @@ -1149,23 +1265,34 @@ def build_dem_from_guppy( non-negative, and sum to 1.0 within ``1e-5``. Defaults to the engines' uniform model. The engines leakage key ``"L"`` is reserved but unsupported by DEM construction. - p_idle_quadratic: Optional quadratic dephasing rate. The default - stochastic interpretation gives probability ``sin(rate * t)^2``; - the coherent interpretation stores the exact Pauli twirl of an - ``RZ(rate)`` per unit idle time (stochastic Z with - ``sin(rate / 2)^2``, via an equivalent T2) and cannot be combined - with ``p_idle`` or ``t1``/``t2``. Coherent cross-location - accumulation belongs to the EEG pipeline. - p_idle_coherent: Select coherent RZ rather than stochastic Z - interpretation for ``p_idle_quadratic``. Defaults to ``False``. + p_idle_sin_squared: Optional stochastic sine-law dephasing rate. An + axis multiplier ``m`` gives probability + ``sin((p_idle_sin_squared * m) * t)^2``. Defaults to Z-only + dephasing at the full family rate. + p_idle_sin_squared_model: Optional finite, non-negative relative-rate + multipliers over ``"X"``, ``"Y"``, and ``"Z"``. There is no sum + constraint; the default is ``{"Z": 1.0}``. The engines leakage + key ``"L"`` is reserved but unsupported by DEM construction. + p_idle_coherent: Optional coherent-rotation rate. An ``RZ`` multiplier + ``m`` gives angle ``(p_idle_coherent * m) * t``. DEM construction + stores the exact RZ Pauli twirl (stochastic Z with + ``sin(rate / 2)^2`` per unit idle time, via an equivalent T2), and + cannot combine it with ``p_idle`` or ``t1``/``t2``. True coherent + cross-location accumulation belongs to the EEG pipeline. + p_idle_coherent_model: Optional finite, non-negative relative-rate + multipliers over ``"RX"``, ``"RY"``, and ``"RZ"``, with no sum + constraint. Defaults to ``{"RZ": 1.0}``. DEM v1 rejects nonzero + ``"RX"``/``"RY"``; ``"U"`` is reserved for future + Hamiltonian-level support and is rejected today. t1: Optional T1 relaxation time for explicit idle gates. t2: Optional T2 dephasing time for explicit idle gates. p_idle_linear_rate: Deprecated bare Z-only alias for a stochastic rate linear in idle duration. Use ``p_idle_linear`` with a Z-only model, or ``p_idle_z_linear_rate`` for literal behavior. p_idle_quadratic_rate: Deprecated bare Z-only coefficient-style rate - quadratic in idle duration. Use ``p_idle_quadratic`` for engines - semantics, or ``p_idle_z_quadratic_rate`` for literal behavior. + quadratic in idle duration. Use ``p_idle_sin_squared`` for the + structured engines-style dephasing interface, or + ``p_idle_z_quadratic_rate`` for literal behavior. p_idle_x_linear_rate: Optional stochastic X-memory rate linear in idle duration. p_idle_y_linear_rate: Optional stochastic Y-memory rate linear in idle duration. p_idle_z_linear_rate: Optional stochastic Z-memory rate linear in idle duration. @@ -1174,7 +1301,7 @@ def build_dem_from_guppy( p_idle_z_quadratic_rate: Optional stochastic Z-memory rate quadratic in idle duration. p_idle_quadratic_sine_rate: Deprecated bare Z-only alias for a stochastic rate with probability ``sin(rate * duration)^2``. Use - ``p_idle_quadratic`` or ``p_idle_z_quadratic_sine_rate``. + ``p_idle_sin_squared`` or ``p_idle_z_quadratic_sine_rate``. p_idle_x_quadratic_sine_rate: Optional stochastic X-memory sine-law rate. p_idle_y_quadratic_sine_rate: Optional stochastic Y-memory sine-law rate. p_idle_z_quadratic_sine_rate: Optional stochastic Z-memory sine-law rate. @@ -1210,6 +1337,8 @@ def build_dem_from_guppy( p_idle_x_linear_rate, p_idle_y_linear_rate, p_idle_z_linear_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, p_idle_z_quadratic_sine_rate, idle_rz, ) = _translate_structured_idle_noise( @@ -1218,16 +1347,15 @@ def build_dem_from_guppy( t2=t2, p_idle_linear=p_idle_linear, p_idle_linear_model=p_idle_linear_model, - p_idle_quadratic=p_idle_quadratic, + p_idle_sin_squared=p_idle_sin_squared, + p_idle_sin_squared_model=p_idle_sin_squared_model, p_idle_coherent=p_idle_coherent, + p_idle_coherent_model=p_idle_coherent_model, p_idle_linear_rate=p_idle_linear_rate, p_idle_quadratic_rate=p_idle_quadratic_rate, p_idle_x_linear_rate=p_idle_x_linear_rate, p_idle_y_linear_rate=p_idle_y_linear_rate, p_idle_z_linear_rate=p_idle_z_linear_rate, - p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, @@ -1264,7 +1392,8 @@ def build_dem_from_guppy( idle_noise_parameters=( p_idle, p_idle_linear, - p_idle_quadratic, + p_idle_sin_squared, + p_idle_coherent, t1, t2, p_idle_linear_rate, 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 a2985c1b9..55379ae01 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -287,10 +287,10 @@ def test_structured_idle_linear_model_uses_engines_normalization_tolerance(entry @pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) -def test_structured_idle_quadratic_stochastic_matches_sine_primitive(entrypoint: str) -> None: +def test_structured_idle_sin_squared_default_matches_z_sine_primitive(entrypoint: str) -> None: rate = 0.17 - structured = _structured_idle_dem(entrypoint, idle_after_2q_duration=1.0, p_idle_quadratic=rate) + structured = _structured_idle_dem(entrypoint, idle_after_2q_duration=1.0, p_idle_sin_squared=rate) primitive = _structured_idle_dem( entrypoint, idle_after_2q_duration=1.0, @@ -301,6 +301,26 @@ def test_structured_idle_quadratic_stochastic_matches_sine_primitive(entrypoint: assert structured.num_contributions > 0 +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_sin_squared_custom_model_matches_axis_sine_primitives(entrypoint: str) -> None: + rate = 0.17 + + structured = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_sin_squared=rate, + p_idle_sin_squared_model={"X": 1.0, "Z": 0.5}, + ) + primitive = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_x_quadratic_sine_rate=rate, + p_idle_z_quadratic_sine_rate=rate / 2.0, + ) + + assert structured.to_string() == primitive.to_string() + + @pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) def test_p_idle_shorthand_is_byte_identical_to_structured_uniform_linear(entrypoint: str) -> None: rate = 0.03 @@ -312,15 +332,14 @@ def test_p_idle_shorthand_is_byte_identical_to_structured_uniform_linear(entrypo @pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) -def test_structured_idle_quadratic_coherent_matches_from_circuit(entrypoint: str) -> None: +def test_structured_idle_coherent_default_matches_from_circuit(entrypoint: str) -> None: from pecos.tracing import trace_program_to_tick_circuit rate = 0.17 structured = _structured_idle_dem( entrypoint, idle_after_2q_duration=1.0, - p_idle_quadratic=rate, - p_idle_coherent=True, + p_idle_coherent=rate, ) reference_circuit = trace_program_to_tick_circuit(_structured_idle_noise_target, 2, seed=0) normalize_traced_tick_circuit(reference_circuit, context="structured idle coherent reference") @@ -340,11 +359,7 @@ def test_structured_idle_quadratic_coherent_matches_from_circuit(entrypoint: str "p_idle_y_linear_rate", "p_idle_z_linear_rate", ) -_QUADRATIC_IDLE_PRIMITIVES = ( - "p_idle_quadratic_rate", - "p_idle_x_quadratic_rate", - "p_idle_y_quadratic_rate", - "p_idle_z_quadratic_rate", +_SINE_IDLE_PRIMITIVES = ( "p_idle_quadratic_sine_rate", "p_idle_x_quadratic_sine_rate", "p_idle_y_quadratic_sine_rate", @@ -370,15 +385,32 @@ def test_structured_idle_linear_model_rejects_low_level_primitive_without_rate(e @pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) -@pytest.mark.parametrize("primitive", _QUADRATIC_IDLE_PRIMITIVES) -@pytest.mark.parametrize("structured", [{"p_idle_quadratic": 0.01}, {"p_idle_coherent": True}]) -def test_structured_idle_quadratic_rejects_each_low_level_primitive( - entrypoint: str, - primitive: str, - structured: dict[str, float | bool], -) -> None: - with pytest.raises(ValueError, match=primitive): - _structured_idle_dem(entrypoint, **structured, **{primitive: 0.02}) +@pytest.mark.parametrize("primitive", _SINE_IDLE_PRIMITIVES) +def test_structured_idle_sin_squared_rejects_each_sine_primitive(entrypoint: str, primitive: str) -> None: + with pytest.raises(ValueError, match=rf"sine-law idle rate.*{primitive}"): + _structured_idle_dem(entrypoint, p_idle_sin_squared=0.01, **{primitive: 0.02}) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_sin_squared_model_rejects_sine_primitive_without_rate(entrypoint: str) -> None: + with pytest.raises(ValueError, match=r"sine-law idle rate.*p_idle_z_quadratic_sine_rate"): + _structured_idle_dem( + entrypoint, + p_idle_sin_squared_model={"Z": 1.0}, + p_idle_z_quadratic_sine_rate=0.02, + ) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_sin_squared_composes_with_coefficient_quadratic_primitive(entrypoint: str) -> None: + dem = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_sin_squared=0.01, + p_idle_x_quadratic_rate=0.02, + ) + + assert dem.num_contributions > 0 @pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) @@ -396,7 +428,23 @@ def test_p_idle_rejects_structured_linear_rate(entrypoint: str) -> None: ({"p_idle_linear": 0.01, "p_idle_linear_model": {"X": 0.4, "Z": 0.4}}, "sum to 1.0"), ({"p_idle_linear": 0.01, "p_idle_linear_model": {"X": -0.1, "Z": 1.1}}, "non-negative"), ({"p_idle_linear_model": {"Z": 1.0}}, "requires p_idle_linear"), - ({"p_idle_coherent": True}, "requires p_idle_quadratic"), + ({"p_idle_sin_squared": 0.01, "p_idle_sin_squared_model": {"A": 1.0}}, "invalid.*key"), + ( + {"p_idle_sin_squared": 0.01, "p_idle_sin_squared_model": {"L": 1.0}}, + "leakage.*engines|engines.*leakage", + ), + ({"p_idle_sin_squared": 0.01, "p_idle_sin_squared_model": {"X": -0.1}}, "non-negative"), + ({"p_idle_sin_squared_model": {"Z": 1.0}}, "requires p_idle_sin_squared"), + ({"p_idle_coherent": 0.01, "p_idle_coherent_model": {"A": 1.0}}, "invalid.*key"), + ( + {"p_idle_coherent": 0.01, "p_idle_coherent_model": {"L": 1.0}}, + "leakage.*engines|engines.*leakage", + ), + ({"p_idle_coherent": 0.01, "p_idle_coherent_model": {"RX": 1.0}}, "only 'RZ'.*representable"), + ({"p_idle_coherent": 0.01, "p_idle_coherent_model": {"RY": 1.0}}, "only 'RZ'.*representable"), + ({"p_idle_coherent": 0.01, "p_idle_coherent_model": {"U": 0.0}}, "only 'RZ'.*representable"), + ({"p_idle_coherent": 0.01, "p_idle_coherent_model": {"RZ": -0.1}}, "non-negative"), + ({"p_idle_coherent_model": {"RZ": 1.0}}, "requires p_idle_coherent"), ], ) def test_structured_idle_model_validation(entrypoint: str, kwargs: dict[str, object], message: str) -> None: @@ -409,8 +457,8 @@ def test_structured_idle_model_validation(entrypoint: str, kwargs: dict[str, obj ("alias", "replacement"), [ ("p_idle_linear_rate", "p_idle_linear"), - ("p_idle_quadratic_rate", "p_idle_quadratic"), - ("p_idle_quadratic_sine_rate", "p_idle_quadratic"), + ("p_idle_quadratic_rate", "p_idle_sin_squared"), + ("p_idle_quadratic_sine_rate", "p_idle_sin_squared"), ], ) def test_legacy_idle_alias_warns_and_remains_functional(entrypoint: str, alias: str, replacement: str) -> None: @@ -420,6 +468,35 @@ def test_legacy_idle_alias_warns_and_remains_functional(entrypoint: str, alias: assert dem.num_contributions > 0 +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +@pytest.mark.parametrize("rate_name", ["p_idle_linear", "p_idle_sin_squared", "p_idle_coherent"]) +@pytest.mark.parametrize("bad_rate", [-0.01, float("nan"), float("inf")]) +def test_structured_idle_family_rate_must_be_finite_and_non_negative( + entrypoint: str, + rate_name: str, + bad_rate: float, +) -> None: + with pytest.raises(ValueError, match=rf"{rate_name} must be a finite, non-negative float"): + _structured_idle_dem(entrypoint, **{rate_name: bad_rate}) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +@pytest.mark.parametrize( + "kwargs", + [ + {"p_idle_sin_squared": 0.01, "p_idle_sin_squared_model": {"X": 1.0, "Z": 0.5}}, + {"p_idle_coherent": 0.01, "p_idle_coherent_model": {"RZ": 2.0}}, + ], +) +def test_nonlinear_idle_models_do_not_require_normalized_multipliers( + entrypoint: str, + kwargs: dict[str, object], +) -> None: + dem = _structured_idle_dem(entrypoint, idle_after_2q_duration=1.0, **kwargs) + + assert dem.num_contributions > 0 + + def test_from_guppy_idle_insertion_matches_manual_pass_pipeline() -> None: from pecos.tracing import trace_program_to_tick_circuit @@ -449,7 +526,8 @@ def test_from_guppy_inserted_idles_make_idle_noise_effective() -> None: _ALL_IDLE_NOISE_PARAMS = { "p_idle": 0.01, "p_idle_linear": 0.01, - "p_idle_quadratic": 0.01, + "p_idle_sin_squared": 0.01, + "p_idle_coherent": 0.01, "t1": 100.0, "t2": 100.0, "p_idle_linear_rate": 0.01, @@ -486,8 +564,7 @@ def test_from_guppy_rejects_coherent_with_base_idle_channel() -> None: _two_qubit_dem( idle_after_2q_duration=1.0, p_idle=0.01, - p_idle_quadratic=0.02, - p_idle_coherent=True, + p_idle_coherent=0.02, ) @@ -498,8 +575,7 @@ def test_from_guppy_rejects_coherent_with_t1_t2() -> None: idle_after_2q_duration=1.0, t1=100.0, t2=50.0, - p_idle_quadratic=0.02, - p_idle_coherent=True, + p_idle_coherent=0.02, ) @@ -515,8 +591,7 @@ def test_from_guppy_coherent_composes_with_structured_linear() -> None: dem = _two_qubit_dem( idle_after_2q_duration=1.0, p_idle_linear=0.01, - p_idle_quadratic=0.02, - p_idle_coherent=True, + p_idle_coherent=0.02, ) assert dem.num_contributions > 0 @@ -530,8 +605,7 @@ def test_build_dem_from_guppy_rejects_coherent_with_base_idle_channel() -> None: observables=[Observable(rec[-1])], idle_after_2q_duration=1.0, p_idle=0.01, - p_idle_quadratic=0.02, - p_idle_coherent=True, + p_idle_coherent=0.02, **_NO_GATE_NOISE, ) From 878276cf324c360bd3ca67aa8af9e628a90203d4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 17:00:28 -0600 Subject: [PATCH 08/62] Symmetric model defaults, first-class L key, and route-level rejection of coherent idle noise in the standard DEM builder --- docs/user-guide/dem-from-guppy.md | 55 +++-- python/quantum-pecos/src/pecos/qec/dem.py | 220 +++++++++--------- .../tests/qec/test_from_guppy_dem.py | 184 ++++++++------- 3 files changed, 235 insertions(+), 224 deletions(-) diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index 75524c5dd..f400145b3 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -197,36 +197,47 @@ different DEM. ## Idle Noise -The recommended structured interface has three uniform rate-and-model -families. Every model value is a **relative-rate multiplier**: for each axis, -`axis_rate = family_rate * axis_multiplier`. The linear law is additive, so +The recommended structured interface has three rate-and-model families. Every +model value is a **relative-rate multiplier**: for each channel, +`channel_rate = family_rate * channel_multiplier`. The linear law is additive, so its multipliers are exactly the engines relative-probability distribution and must sum to 1. The nonlinear laws are not additive, so their finite, non-negative multipliers have no sum constraint. - Linear: `p_idle_linear` with `p_idle_linear_model`. An axis fault has probability `(p_idle_linear * m_axis) * t`. The model keys are `X`, `Y`, and - `Z`, and the default is the uniform `{X: 1/3, Y: 1/3, Z: 1/3}` engines - model. `p_idle` remains shorthand for that uniform model. -- Sine-squared: `p_idle_sin_squared` with `p_idle_sin_squared_model`. An axis + `Z`, plus the engines leakage key `L`. The default is the uniform + `{X: 1/3, Y: 1/3, Z: 1/3}` engines model. An explicit `L` weight participates + in the sum-to-1 requirement. `p_idle` remains shorthand for the uniform + Pauli model. +- Sine-squared: `p_idle_sin_squared` with `p_idle_sin_squared_model`. A Pauli fault has probability `sin((p_idle_sin_squared * m_axis) * t) ** 2`. The - model keys are `X`, `Y`, and `Z`; there is no sum constraint, and the default - `{Z: 1.0}` means scalar-only use is full-rate stochastic Z dephasing. + model keys are `X`, `Y`, `Z`, and `L`; there is no sum constraint. The + symmetric default `{X: 1.0, Y: 1.0, Z: 1.0}` applies the full family rate to + every Pauli axis—these are multipliers, not shares of a normalized total. + To request pure dephasing instead, pass the explicit model `{"Z": 1.0}`. - Coherent: `p_idle_coherent` with `p_idle_coherent_model`. An axis rotation has - angle `(p_idle_coherent * m_axis) * t`. The default is `{RZ: 1.0}`. DEM v1 - represents only RZ, so nonzero `RX`/`RY` multipliers are rejected and `U` is - reserved for future Hamiltonian-level support. This constructor stores RZ as - its exact Pauli twirl — a stochastic Z channel with half-angle probability - `sin(rate / 2) ** 2` per unit idle time, via an equivalent T2. True coherent - cross-location accumulation belongs to the EEG tooling. Because this - conversion replaces the base idle channel, the coherent family cannot be - combined with `p_idle` or `t1`/`t2` (and `p_idle` with `t1`/`t2` is likewise - rejected — the T1/T2 channel replaces the depolarizing base channel). - -The engines `L` leakage key is reserved but rejected for all three models -because DEM construction cannot represent leakage. Multi-qubit idle faults -are outside the scope of these keyword arguments and will arrive through a -typed channel interface. + angle `(p_idle_coherent * m_axis) * t`. The symmetric default is + `{RX: 1.0, RY: 1.0, RZ: 1.0}`. The standard DEM builder cannot represent + coherent idle noise, so it rejects every nonzero family rate at call time; + its previous lowering silently stored the Pauli twirl and discarded exactly + the coherence requested. The EEG coherent route in `exp/pecos-eeg` is the + consumer that can represent coherent idle noise, and only with an RZ + generator even there. For an honest stochastic equivalent, the exact Pauli + twirl of `RZ(rate * t)`, use `p_idle_sin_squared=rate/2` with + `p_idle_sin_squared_model={"Z": 1.0}`. A coherent rate of zero or `None` has + no effect. The `RX`, `RY`, and `RZ` model keys are validation-only on this DEM + route; `L` and `U` are not valid coherent-model keys. + +The `p_idle` shorthand cannot be combined with `t1`/`t2`: the T1/T2 channel +replaces the depolarizing base channel, so the combination is rejected. + +The engines simulators can consume leakage models, such as an engines-bound +linear model `{"X": 0.8, "L": 0.2}`. DEM fault propagation is Pauli-only: +these DEM entry points accept `L` in linear and sine-squared models for model +compatibility, but reject it at call time when its weight is nonzero. A zero +`L` weight is silently accepted. Multi-qubit idle faults are outside the scope +of these keyword arguments and will arrive through a typed channel interface. These rates match the engines *runtime* application semantics (`GeneralNoiseModel`'s internal fields); the engines `GeneralNoiseModelBuilder` diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index dd8255e34..4c8556aaf 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -60,7 +60,6 @@ _GENERATOR_LAYOUT_ATTR = "__pecos_named_measurement_layout_v2__" _IDLE_MODEL_NORMALIZATION_TOLERANCE = 1.0e-5 _IDLE_MODEL_FLOAT_EPSILON = 1.0e-10 -_EMPTY_IDLE_MODEL_KEYS: frozenset[str] = frozenset() def _validate_idle_family_model( @@ -70,9 +69,10 @@ def _validate_idle_family_model( model: Mapping[str, float] | None, model_name: str, default_model: Mapping[str, float], - valid_keys: frozenset[str], + accepted_keys: frozenset[str], require_normalized: bool, - unsupported_keys: frozenset[str] = _EMPTY_IDLE_MODEL_KEYS, + nonzero_rate_guidance: str | None = None, + zero_only_key_guidance: Mapping[str, str] | None = None, ) -> tuple[float, dict[str, float]] | None: """Validate one structured idle family and return its rate and multipliers.""" if model is not None and rate is None: @@ -92,25 +92,18 @@ def _validate_idle_family_model( if not math.isfinite(numeric_rate) or numeric_rate < 0.0: msg = f"{rate_name} must be a finite, non-negative float" raise ValueError(msg) + if numeric_rate != 0.0 and nonzero_rate_guidance is not None: + raise ValueError(nonzero_rate_guidance) if model is not None and not isinstance(model, Mapping): - expected = ", ".join(repr(key) for key in sorted(valid_keys)) + expected = ", ".join(repr(key) for key in sorted(accepted_keys)) msg = f"{model_name} must be a mapping from {expected} to relative-rate multipliers" raise ValueError(msg) selected_model = model if model is not None else default_model validated_model: dict[str, float] = {} for key, multiplier in selected_model.items(): - if key == "L": - msg = ( - f"{model_name} key 'L' denotes leakage, which is supported by the engines simulators " - "but not by DEM construction" - ) - raise ValueError(msg) - if key in unsupported_keys: - msg = f"{model_name} key {key!r} is reserved; only 'RZ' is representable by DEM construction today" - raise ValueError(msg) - if key not in valid_keys: - expected = ", ".join(repr(valid_key) for valid_key in sorted(valid_keys)) + if key not in accepted_keys: + expected = ", ".join(repr(valid_key) for valid_key in sorted(accepted_keys)) msg = f"invalid {model_name} key {key!r}; expected {expected}" raise ValueError(msg) try: @@ -134,6 +127,11 @@ def _validate_idle_family_model( if abs(total_multiplier - 1.0) > _IDLE_MODEL_FLOAT_EPSILON: validated_model = {key: multiplier / total_multiplier for key, multiplier in validated_model.items()} + for key, guidance in (zero_only_key_guidance or {}).items(): + if validated_model.get(key, 0.0) != 0.0: + msg = f"{model_name} key {key!r} has a nonzero multiplier; {guidance}" + raise ValueError(msg) + return numeric_rate, validated_model @@ -164,9 +162,25 @@ def _translate_structured_idle_noise( float | None, float | None, float | None, - float | None, ]: """Validate and translate engines-style idle noise to DEM primitives.""" + _validate_idle_family_model( + rate=p_idle_coherent, + rate_name="p_idle_coherent", + model=p_idle_coherent_model, + model_name="p_idle_coherent_model", + default_model={"RX": 1.0, "RY": 1.0, "RZ": 1.0}, + accepted_keys=frozenset({"RX", "RY", "RZ"}), + require_normalized=False, + nonzero_rate_guidance=( + "the standard DEM builder cannot represent coherent idle noise; its previous behavior silently stored " + "the Pauli twirl, discarding exactly the coherence that was requested. The EEG coherent route in " + "exp/pecos-eeg is the consumer that can represent it, and only with an RZ generator even there. The " + "honest stochastic equivalent, which is the exact Pauli twirl of RZ(rate * t), is " + "p_idle_sin_squared=rate/2 with p_idle_sin_squared_model={'Z': 1.0}" + ), + ) + linear_primitives = { "p_idle_linear_rate": p_idle_linear_rate, "p_idle_x_linear_rate": p_idle_x_linear_rate, @@ -187,21 +201,6 @@ def _translate_structured_idle_noise( # p_idle-depolarizing -- passing both would silently ignore p_idle. msg = "p_idle and t1/t2 cannot be combined; the T1/T2 channel replaces the depolarizing base channel" raise ValueError(msg) - if p_idle_coherent is not None: - # NoiseConfig::set_idle_rz replaces the T1/T2 fields with a synthetic - # T2 and zeroes p_idle -- both combinations would be silently lost. - if p_idle is not None: - msg = ( - "p_idle_coherent cannot be combined with p_idle; " - "the coherent RZ conversion replaces the base idle channel" - ) - raise ValueError(msg) - if t1 is not None or t2 is not None: - msg = ( - "p_idle_coherent cannot be combined with t1/t2; the coherent RZ conversion overwrites the T1/T2 channel" - ) - raise ValueError(msg) - sine_primitives = { "p_idle_quadratic_sine_rate": p_idle_quadratic_sine_rate, "p_idle_x_quadratic_sine_rate": p_idle_x_quadratic_sine_rate, @@ -252,8 +251,11 @@ def _translate_structured_idle_noise( model=p_idle_linear_model, model_name="p_idle_linear_model", default_model={"X": 1.0 / 3.0, "Y": 1.0 / 3.0, "Z": 1.0 / 3.0}, - valid_keys=frozenset({"X", "Y", "Z"}), + accepted_keys=frozenset({"X", "Y", "Z", "L"}), require_normalized=True, + zero_only_key_guidance={ + "L": "DEM fault propagation is Pauli-only; the engines simulators support and consume leakage models", + }, ) if linear_family is not None: linear_rate, linear_model = linear_family @@ -266,9 +268,12 @@ def _translate_structured_idle_noise( rate_name="p_idle_sin_squared", model=p_idle_sin_squared_model, model_name="p_idle_sin_squared_model", - default_model={"Z": 1.0}, - valid_keys=frozenset({"X", "Y", "Z"}), + default_model={"X": 1.0, "Y": 1.0, "Z": 1.0}, + accepted_keys=frozenset({"X", "Y", "Z", "L"}), require_normalized=False, + zero_only_key_guidance={ + "L": "DEM fault propagation is Pauli-only; the engines simulators support and consume leakage models", + }, ) if sin_squared_family is not None: sin_squared_rate, sin_squared_model = sin_squared_family @@ -282,28 +287,6 @@ def _translate_structured_idle_noise( sin_squared_rate * sin_squared_model["Z"] if sin_squared_model.get("Z", 0.0) != 0.0 else None ) - coherent_family = _validate_idle_family_model( - rate=p_idle_coherent, - rate_name="p_idle_coherent", - model=p_idle_coherent_model, - model_name="p_idle_coherent_model", - default_model={"RZ": 1.0}, - valid_keys=frozenset({"RX", "RY", "RZ"}), - require_normalized=False, - unsupported_keys=frozenset({"U"}), - ) - idle_rz = None - if coherent_family is not None: - coherent_rate, coherent_model = coherent_family - unsupported_axes = [axis for axis in ("RX", "RY") if coherent_model.get(axis, 0.0) != 0.0] - if unsupported_axes: - msg = ( - f"p_idle_coherent_model has nonzero {', '.join(unsupported_axes)} multiplier(s); " - "only 'RZ' is representable by DEM construction today" - ) - raise ValueError(msg) - idle_rz = coherent_rate * coherent_model.get("RZ", 0.0) - return ( p_idle_x_linear_rate, p_idle_y_linear_rate, @@ -311,7 +294,6 @@ def _translate_structured_idle_noise( p_idle_x_quadratic_sine_rate, p_idle_y_quadratic_sine_rate, p_idle_z_quadratic_sine_rate, - idle_rz, ) @@ -396,7 +378,6 @@ def _from_circuit_with_noise( p_idle: float | None, t1: float | None, t2: float | None, - idle_rz: float | None, p_idle_linear_rate: float | None, p_idle_quadratic_rate: float | None, p_idle_x_linear_rate: float | None, @@ -422,7 +403,6 @@ def _from_circuit_with_noise( p_idle=p_idle, t1=t1, t2=t2, - idle_rz=idle_rz, p_idle_linear_rate=p_idle_linear_rate, p_idle_quadratic_rate=p_idle_quadratic_rate, p_idle_x_linear_rate=p_idle_x_linear_rate, @@ -619,33 +599,40 @@ def from_guppy( p_idle_linear: Optional total stochastic idle-noise rate linear in duration. Uses the engines ``GeneralNoiseModel`` convention. p_idle_linear_model: Optional relative weights over ``"X"``, ``"Y"``, - and ``"Z"`` for ``p_idle_linear``. Weights must be finite, - non-negative, and sum to 1.0 within ``1e-5``. Defaults to the - engines' uniform model. The engines leakage key ``"L"`` is - reserved but unsupported by DEM construction. - p_idle_sin_squared: Optional stochastic sine-law dephasing rate. An + ``"Z"``, and ``"L"`` for ``p_idle_linear``. Weights must be + finite, non-negative, and sum to 1.0 within ``1e-5``, including + any explicit ``"L"`` weight. Defaults to the engines' uniform + Pauli model. DEM fault propagation is Pauli-only, so ``"L"`` + must be zero here; the engines simulators support nonzero + leakage weights. + p_idle_sin_squared: Optional stochastic sine-law idle rate. An axis multiplier ``m`` gives probability ``sin((p_idle_sin_squared * m) * t)^2`` for an idle of duration - ``t``. Defaults to Z-only dephasing at the full family rate. + ``t``. By default X, Y, and Z each use the full family rate. p_idle_sin_squared_model: Optional relative-rate multipliers over - ``"X"``, ``"Y"``, and ``"Z"`` for ``p_idle_sin_squared``. - Values must be finite and non-negative, with no sum constraint. - Defaults to ``{"Z": 1.0}``. The engines leakage key ``"L"`` is - reserved but unsupported by DEM construction. - p_idle_coherent: Optional coherent-rotation rate. An ``RZ`` - multiplier ``m`` gives angle ``(p_idle_coherent * m) * t``. - DEM construction currently supports only RZ and stores its - exact Pauli twirl: a stochastic Z channel with half-angle - probability ``sin(rate / 2)^2`` per unit idle time, represented - internally via an equivalent T2. Coherent cross-location angle - accumulation is the EEG pipeline's domain. This conversion - cannot be combined with ``p_idle`` or ``t1``/``t2``. + ``"X"``, ``"Y"``, ``"Z"``, and ``"L"`` for + ``p_idle_sin_squared``. Values must be finite and non-negative, + with no sum constraint. Defaults to + ``{"X": 1.0, "Y": 1.0, "Z": 1.0}``. DEM fault propagation + is Pauli-only, so ``"L"`` must be zero here; the engines + simulators support nonzero leakage weights. + p_idle_coherent: Optional coherent-rotation rate. The standard DEM + builder cannot represent coherent idle noise and rejects every + nonzero rate rather than silently storing a Pauli twirl that + discards the requested coherence. Use the EEG coherent route + in ``exp/pecos-eeg`` for coherent idle noise; it supports only + an RZ generator. The honest stochastic equivalent—the exact + Pauli twirl of ``RZ(rate * t)``—is + ``p_idle_sin_squared=rate/2`` with + ``p_idle_sin_squared_model={"Z": 1.0}``. Zero has no effect. p_idle_coherent_model: Optional relative-rate multipliers over ``"RX"``, ``"RY"``, and ``"RZ"`` for ``p_idle_coherent``. Values must be finite and non-negative, with no sum constraint; - the default is ``{"RZ": 1.0}``. Nonzero ``"RX"``/``"RY"`` are - rejected because DEM v1 represents only RZ. ``"U"`` is reserved - for future Hamiltonian-level support and is rejected today. + the default is ``{"RX": 1.0, "RY": 1.0, "RZ": 1.0}``. The + keys are validation-only on this route because any nonzero + coherent family rate is rejected. ``"L"`` is not a + coherent-model key, and ``"U"`` is reserved for future + Hamiltonian-level support and rejected. t1: Optional T1 relaxation time for explicit idle gates. t2: Optional T2 dephasing time for explicit idle gates. p_idle_linear_rate: Deprecated bare Z-only alias for a stochastic @@ -699,11 +686,12 @@ def from_guppy( measurement count, if a detector/observable is malformed or references an out-of-range ``record`` or an absent ``meas_id``, if ``idle_after_2q_duration`` is not a finite - positive number, if any idle-noise parameter is set but the - final traced circuit has no ``Idle`` gates, or if the traced - operation stream cannot be replayed. To provide targets for - idle noise, pass ``idle_after_2q_duration`` or use a Selene - runtime that emits scheduled idles. + positive number, if ``p_idle_coherent`` is nonzero, if any + representable idle-noise parameter is set but the final traced + circuit has no ``Idle`` gates, or if the traced operation stream + cannot be replayed. To provide targets for idle noise, pass + ``idle_after_2q_duration`` or use a Selene runtime that emits + scheduled idles. Note: Runtime-lowered idles are replayed as nanosecond PECOS @@ -739,7 +727,6 @@ def from_guppy( p_idle_x_quadratic_sine_rate, p_idle_y_quadratic_sine_rate, p_idle_z_quadratic_sine_rate, - idle_rz, ) = _translate_structured_idle_noise( p_idle=p_idle, t1=t1, @@ -825,11 +812,11 @@ def from_guppy( tc, strip_traced_idles=strip_traced_idles, idle_after_2q_duration=idle_after_2q_duration, + # Nonzero coherent rates were rejected before tracing; zero emits no noise. idle_noise_parameters=( p_idle, p_idle_linear, p_idle_sin_squared, - p_idle_coherent, t1, t2, p_idle_linear_rate, @@ -886,7 +873,6 @@ def from_guppy( p_idle=p_idle, t1=t1, t2=t2, - idle_rz=idle_rz, p_idle_linear_rate=p_idle_linear_rate, p_idle_quadratic_rate=p_idle_quadratic_rate, p_idle_x_linear_rate=p_idle_x_linear_rate, @@ -1261,29 +1247,36 @@ def build_dem_from_guppy( p_idle_linear: Optional total stochastic idle-noise rate linear in duration. Uses the engines ``GeneralNoiseModel`` convention. p_idle_linear_model: Optional relative weights over ``"X"``, ``"Y"``, - and ``"Z"`` for ``p_idle_linear``. Weights must be finite, - non-negative, and sum to 1.0 within ``1e-5``. Defaults to the - engines' uniform model. The engines leakage key ``"L"`` is - reserved but unsupported by DEM construction. - p_idle_sin_squared: Optional stochastic sine-law dephasing rate. An + ``"Z"``, and ``"L"`` for ``p_idle_linear``. Weights must be finite, + non-negative, and sum to 1.0 within ``1e-5``, including any + explicit ``"L"`` weight. Defaults to the engines' uniform Pauli + model. DEM fault propagation is Pauli-only, so ``"L"`` must be zero + here; the engines simulators support nonzero leakage weights. + p_idle_sin_squared: Optional stochastic sine-law idle rate. An axis multiplier ``m`` gives probability - ``sin((p_idle_sin_squared * m) * t)^2``. Defaults to Z-only - dephasing at the full family rate. + ``sin((p_idle_sin_squared * m) * t)^2``. By default X, Y, and Z + each use the full family rate. p_idle_sin_squared_model: Optional finite, non-negative relative-rate - multipliers over ``"X"``, ``"Y"``, and ``"Z"``. There is no sum - constraint; the default is ``{"Z": 1.0}``. The engines leakage - key ``"L"`` is reserved but unsupported by DEM construction. - p_idle_coherent: Optional coherent-rotation rate. An ``RZ`` multiplier - ``m`` gives angle ``(p_idle_coherent * m) * t``. DEM construction - stores the exact RZ Pauli twirl (stochastic Z with - ``sin(rate / 2)^2`` per unit idle time, via an equivalent T2), and - cannot combine it with ``p_idle`` or ``t1``/``t2``. True coherent - cross-location accumulation belongs to the EEG pipeline. + multipliers over ``"X"``, ``"Y"``, ``"Z"``, and ``"L"``. There is + no sum constraint; the default is + ``{"X": 1.0, "Y": 1.0, "Z": 1.0}``. DEM fault propagation is + Pauli-only, so ``"L"`` must be zero here; the engines simulators + support nonzero leakage weights. + p_idle_coherent: Optional coherent-rotation rate. The standard DEM + builder cannot represent coherent idle noise and rejects every + nonzero rate rather than silently storing a Pauli twirl that + discards the requested coherence. Use the EEG coherent route in + ``exp/pecos-eeg`` for coherent idle noise; it supports only an RZ + generator. The honest stochastic equivalent—the exact Pauli twirl + of ``RZ(rate * t)``—is ``p_idle_sin_squared=rate/2`` with + ``p_idle_sin_squared_model={"Z": 1.0}``. Zero has no effect. p_idle_coherent_model: Optional finite, non-negative relative-rate multipliers over ``"RX"``, ``"RY"``, and ``"RZ"``, with no sum - constraint. Defaults to ``{"RZ": 1.0}``. DEM v1 rejects nonzero - ``"RX"``/``"RY"``; ``"U"`` is reserved for future - Hamiltonian-level support and is rejected today. + constraint. Defaults to ``{"RX": 1.0, "RY": 1.0, "RZ": 1.0}``. + The keys are validation-only on this route because any nonzero + coherent family rate is rejected. ``"L"`` is not a coherent-model + key; ``"U"`` is reserved for future Hamiltonian-level support and + rejected. t1: Optional T1 relaxation time for explicit idle gates. t2: Optional T2 dephasing time for explicit idle gates. p_idle_linear_rate: Deprecated bare Z-only alias for a stochastic rate @@ -1326,10 +1319,11 @@ def build_dem_from_guppy( Raises: ValueError: If ``idle_after_2q_duration`` is not a finite positive - number, or if any idle-noise parameter is set but the final traced - circuit has no ``Idle`` gates. Pass ``idle_after_2q_duration`` or - use a Selene runtime that emits scheduled idles to provide targets - for idle noise. + number, if ``p_idle_coherent`` is nonzero, or if any representable + idle-noise parameter is set but the final traced circuit has no + ``Idle`` gates. Pass ``idle_after_2q_duration`` or use a Selene + runtime that emits scheduled idles to provide targets for idle + noise. """ from pecos.tracing import _trace_program_to_tick_circuit_with_result_traces @@ -1340,7 +1334,6 @@ def build_dem_from_guppy( p_idle_x_quadratic_sine_rate, p_idle_y_quadratic_sine_rate, p_idle_z_quadratic_sine_rate, - idle_rz, ) = _translate_structured_idle_noise( p_idle=p_idle, t1=t1, @@ -1389,11 +1382,11 @@ def build_dem_from_guppy( circuit, strip_traced_idles=strip_traced_idles, idle_after_2q_duration=idle_after_2q_duration, + # Nonzero coherent rates were rejected before tracing; zero emits no noise. idle_noise_parameters=( p_idle, p_idle_linear, p_idle_sin_squared, - p_idle_coherent, t1, t2, p_idle_linear_rate, @@ -1450,7 +1443,6 @@ def build_dem_from_guppy( p_idle=p_idle, t1=t1, t2=t2, - idle_rz=idle_rz, p_idle_linear_rate=p_idle_linear_rate, p_idle_quadratic_rate=p_idle_quadratic_rate, p_idle_x_linear_rate=p_idle_x_linear_rate, 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 55379ae01..52a410976 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -287,10 +287,32 @@ def test_structured_idle_linear_model_uses_engines_normalization_tolerance(entry @pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) -def test_structured_idle_sin_squared_default_matches_z_sine_primitive(entrypoint: str) -> None: +def test_structured_idle_sin_squared_default_matches_all_axis_sine_primitives(entrypoint: str) -> None: rate = 0.17 structured = _structured_idle_dem(entrypoint, idle_after_2q_duration=1.0, p_idle_sin_squared=rate) + primitive = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_x_quadratic_sine_rate=rate, + p_idle_y_quadratic_sine_rate=rate, + p_idle_z_quadratic_sine_rate=rate, + ) + + assert structured.to_string() == primitive.to_string() + assert structured.num_contributions > 0 + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_sin_squared_explicit_z_model_matches_z_sine_primitive(entrypoint: str) -> None: + rate = 0.17 + + structured = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_sin_squared=rate, + p_idle_sin_squared_model={"Z": 1.0}, + ) primitive = _structured_idle_dem( entrypoint, idle_after_2q_duration=1.0, @@ -332,25 +354,62 @@ def test_p_idle_shorthand_is_byte_identical_to_structured_uniform_linear(entrypo @pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) -def test_structured_idle_coherent_default_matches_from_circuit(entrypoint: str) -> None: - from pecos.tracing import trace_program_to_tick_circuit - - rate = 0.17 - structured = _structured_idle_dem( +@pytest.mark.parametrize( + ("rate_name", "model_name"), + [ + ("p_idle_linear", "p_idle_linear_model"), + ("p_idle_sin_squared", "p_idle_sin_squared_model"), + ], +) +def test_structured_idle_pauli_models_accept_zero_leakage_weight( + entrypoint: str, + rate_name: str, + model_name: str, +) -> None: + dem = _structured_idle_dem( entrypoint, idle_after_2q_duration=1.0, - p_idle_coherent=rate, + **{rate_name: 0.03, model_name: {"X": 0.5, "Z": 0.5, "L": 0.0}}, ) - reference_circuit = trace_program_to_tick_circuit(_structured_idle_noise_target, 2, seed=0) - normalize_traced_tick_circuit(reference_circuit, context="structured idle coherent reference") - reference_circuit.insert_idle_after_two_qubit_gates(1.0) - reference_circuit.set_meta("detectors", _TWO_QUBIT_DETECTORS_JSON) - reference_circuit.set_meta("observables", _TWO_QUBIT_OBSERVABLES_JSON) - reference_circuit.set_meta("num_measurements", "2") - reference = DetectorErrorModel.from_circuit(reference_circuit, idle_rz=rate, **_NO_GATE_NOISE) - assert structured.to_string() == reference.to_string() - assert structured.num_contributions > 0 + assert dem.num_contributions > 0 + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +@pytest.mark.parametrize("model", [None, {"not_a_coherent_key": 1.0}]) +def test_structured_idle_coherent_nonzero_rate_is_rejected( + entrypoint: str, + model: dict[str, float] | None, +) -> None: + with pytest.raises(ValueError, match="standard DEM builder cannot represent coherent idle noise") as exc_info: + _structured_idle_dem(entrypoint, p_idle_coherent=0.17, p_idle_coherent_model=model) + + message = str(exc_info.value) + assert "standard DEM builder cannot represent coherent idle noise" in message + assert "silently stored the Pauli twirl" in message + assert "EEG" in message + assert "p_idle_sin_squared=rate/2" in message + assert "p_idle_sin_squared_model={'Z': 1.0}" in message + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_coherent_zero_rate_is_byte_identical_to_omitting_family(entrypoint: str) -> None: + omitted = _structured_idle_dem(entrypoint) + zero_rate = _structured_idle_dem(entrypoint, p_idle_coherent=0.0) + + assert zero_rate.to_string() == omitted.to_string() + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_coherent_model_keys_are_validation_only_at_zero_rate(entrypoint: str) -> None: + omitted = _structured_idle_dem(entrypoint) + zero_rate = _structured_idle_dem( + entrypoint, + p_idle_coherent=0.0, + p_idle_coherent_model={"RX": 1.0, "RY": 2.0, "RZ": 3.0}, + ) + + assert zero_rate.to_string() == omitted.to_string() _LINEAR_IDLE_PRIMITIVES = ( @@ -424,26 +483,28 @@ def test_p_idle_rejects_structured_linear_rate(entrypoint: str) -> None: ("kwargs", "message"), [ ({"p_idle_linear": 0.01, "p_idle_linear_model": {"A": 1.0}}, "invalid.*key"), - ({"p_idle_linear": 0.01, "p_idle_linear_model": {"L": 1.0}}, "leakage.*engines|engines.*leakage"), + ( + {"p_idle_linear": 0.01, "p_idle_linear_model": {"X": 0.5, "Z": 0.3, "L": 0.2}}, + "'L'.*DEM fault propagation is Pauli-only.*engines simulators", + ), ({"p_idle_linear": 0.01, "p_idle_linear_model": {"X": 0.4, "Z": 0.4}}, "sum to 1.0"), + ( + {"p_idle_linear": 0.01, "p_idle_linear_model": {"X": 0.5, "Z": 0.6, "L": 0.2}}, + "sum to 1.0", + ), ({"p_idle_linear": 0.01, "p_idle_linear_model": {"X": -0.1, "Z": 1.1}}, "non-negative"), ({"p_idle_linear_model": {"Z": 1.0}}, "requires p_idle_linear"), ({"p_idle_sin_squared": 0.01, "p_idle_sin_squared_model": {"A": 1.0}}, "invalid.*key"), ( - {"p_idle_sin_squared": 0.01, "p_idle_sin_squared_model": {"L": 1.0}}, - "leakage.*engines|engines.*leakage", + {"p_idle_sin_squared": 0.01, "p_idle_sin_squared_model": {"X": 0.5, "Z": 0.3, "L": 0.2}}, + "'L'.*DEM fault propagation is Pauli-only.*engines simulators", ), ({"p_idle_sin_squared": 0.01, "p_idle_sin_squared_model": {"X": -0.1}}, "non-negative"), ({"p_idle_sin_squared_model": {"Z": 1.0}}, "requires p_idle_sin_squared"), - ({"p_idle_coherent": 0.01, "p_idle_coherent_model": {"A": 1.0}}, "invalid.*key"), - ( - {"p_idle_coherent": 0.01, "p_idle_coherent_model": {"L": 1.0}}, - "leakage.*engines|engines.*leakage", - ), - ({"p_idle_coherent": 0.01, "p_idle_coherent_model": {"RX": 1.0}}, "only 'RZ'.*representable"), - ({"p_idle_coherent": 0.01, "p_idle_coherent_model": {"RY": 1.0}}, "only 'RZ'.*representable"), - ({"p_idle_coherent": 0.01, "p_idle_coherent_model": {"U": 0.0}}, "only 'RZ'.*representable"), - ({"p_idle_coherent": 0.01, "p_idle_coherent_model": {"RZ": -0.1}}, "non-negative"), + ({"p_idle_coherent": 0.0, "p_idle_coherent_model": {"A": 1.0}}, "invalid.*key"), + ({"p_idle_coherent": 0.0, "p_idle_coherent_model": {"L": 1.0}}, "invalid.*key.*'L'"), + ({"p_idle_coherent": 0.0, "p_idle_coherent_model": {"U": 0.0}}, "invalid.*key.*'U'"), + ({"p_idle_coherent": 0.0, "p_idle_coherent_model": {"RZ": -0.1}}, "non-negative"), ({"p_idle_coherent_model": {"RZ": 1.0}}, "requires p_idle_coherent"), ], ) @@ -481,18 +542,13 @@ def test_structured_idle_family_rate_must_be_finite_and_non_negative( @pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) -@pytest.mark.parametrize( - "kwargs", - [ - {"p_idle_sin_squared": 0.01, "p_idle_sin_squared_model": {"X": 1.0, "Z": 0.5}}, - {"p_idle_coherent": 0.01, "p_idle_coherent_model": {"RZ": 2.0}}, - ], -) -def test_nonlinear_idle_models_do_not_require_normalized_multipliers( - entrypoint: str, - kwargs: dict[str, object], -) -> None: - dem = _structured_idle_dem(entrypoint, idle_after_2q_duration=1.0, **kwargs) +def test_sin_squared_idle_model_does_not_require_normalized_multipliers(entrypoint: str) -> None: + dem = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_sin_squared=0.01, + p_idle_sin_squared_model={"X": 1.0, "Z": 0.5}, + ) assert dem.num_contributions > 0 @@ -527,7 +583,6 @@ def test_from_guppy_inserted_idles_make_idle_noise_effective() -> None: "p_idle": 0.01, "p_idle_linear": 0.01, "p_idle_sin_squared": 0.01, - "p_idle_coherent": 0.01, "t1": 100.0, "t2": 100.0, "p_idle_linear_rate": 0.01, @@ -557,59 +612,12 @@ def test_from_guppy_rejects_non_positive_idle_duration(bad_duration: float) -> N _two_qubit_dem(idle_after_2q_duration=bad_duration, p_idle=0.01) -def test_from_guppy_rejects_coherent_with_base_idle_channel() -> None: - # NoiseConfig::set_idle_rz zeroes p_idle in Rust; the combination must - # fail loud instead of silently dropping the depolarizing channel. - with pytest.raises(ValueError, match=r"coherent RZ conversion replaces the base idle channel"): - _two_qubit_dem( - idle_after_2q_duration=1.0, - p_idle=0.01, - p_idle_coherent=0.02, - ) - - -def test_from_guppy_rejects_coherent_with_t1_t2() -> None: - # NoiseConfig::set_idle_rz overwrites the T1/T2 fields in Rust. - with pytest.raises(ValueError, match=r"overwrites the T1/T2 channel"): - _two_qubit_dem( - idle_after_2q_duration=1.0, - t1=100.0, - t2=50.0, - p_idle_coherent=0.02, - ) - - def test_from_guppy_rejects_p_idle_with_t1_t2() -> None: # The Rust base idle channel is T1/T2 when set, silently ignoring p_idle. with pytest.raises(ValueError, match=r"T1/T2 channel replaces the depolarizing base channel"): _two_qubit_dem(idle_after_2q_duration=1.0, p_idle=0.01, t1=100.0, t2=50.0) -def test_from_guppy_coherent_composes_with_structured_linear() -> None: - # p_idle_linear maps to the dedicated idle-memory fields, which compose - # with the RZ-derived channel -- this combination must stay legal. - dem = _two_qubit_dem( - idle_after_2q_duration=1.0, - p_idle_linear=0.01, - p_idle_coherent=0.02, - ) - assert dem.num_contributions > 0 - - -def test_build_dem_from_guppy_rejects_coherent_with_base_idle_channel() -> None: - with pytest.raises(ValueError, match=r"coherent RZ conversion replaces the base idle channel"): - build_dem_from_guppy( - _two_qubit_idle_target, - num_qubits=2, - detectors=[Detector(rec[-2])], - observables=[Observable(rec[-1])], - idle_after_2q_duration=1.0, - p_idle=0.01, - p_idle_coherent=0.02, - **_NO_GATE_NOISE, - ) - - def test_from_guppy_idle_guard_accepts_inserted_idles_and_idles_without_noise() -> None: with_noise = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle=0.01) without_noise = _two_qubit_dem(idle_after_2q_duration=1.0) From 6e5b1a0dac69b46f02a5f5eac2ad9cc3e08a8982 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 17:16:53 -0600 Subject: [PATCH 09/62] Remove the p_idle shorthand from the Guppy DEM entry points; migrate twirl handoff test off deprecated aliases --- docs/development/from-guppy-dem-handoff.md | 2 +- docs/user-guide/dem-from-guppy.md | 20 +++---- examples/surface/validate_dem_generators.py | 5 +- python/quantum-pecos/src/pecos/qec/dem.py | 30 +--------- .../qec/surface/test_pauli_twirl_handoff.py | 4 +- .../tests/qec/test_from_guppy_dem.py | 60 ++++++++----------- 6 files changed, 41 insertions(+), 80 deletions(-) diff --git a/docs/development/from-guppy-dem-handoff.md b/docs/development/from-guppy-dem-handoff.md index e7ee07817..6e6076f28 100644 --- a/docs/development/from-guppy-dem-handoff.md +++ b/docs/development/from-guppy-dem-handoff.md @@ -179,7 +179,7 @@ line. - Runtime-produced `Idle` gates are preserved in the QIS operation trace and replayed into QEC circuits as `TimeUnits` with the convention `1 TimeUnit = 1 ns`. They only affect DEMs when an idle-noise parameter such - as `p_idle`, `t1/t2`, `p_idle_linear_rate`, or `p_idle_quadratic_rate` is set. + as `p_idle_linear`, `t1/t2`, `p_idle_linear_rate`, or `p_idle_quadratic_rate` is set. - Keep fail-closed regression coverage for entirely raw traces, transformed scalar results, and aggregate arrays. Generated adapters may expose direct scalar sideband tags while retaining aggregate results for researcher-facing diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index f400145b3..ec4af96d7 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -208,8 +208,7 @@ non-negative multipliers have no sum constraint. probability `(p_idle_linear * m_axis) * t`. The model keys are `X`, `Y`, and `Z`, plus the engines leakage key `L`. The default is the uniform `{X: 1/3, Y: 1/3, Z: 1/3}` engines model. An explicit `L` weight participates - in the sum-to-1 requirement. `p_idle` remains shorthand for the uniform - Pauli model. + in the sum-to-1 requirement. - Sine-squared: `p_idle_sin_squared` with `p_idle_sin_squared_model`. A Pauli fault has probability `sin((p_idle_sin_squared * m_axis) * t) ** 2`. The model keys are `X`, `Y`, `Z`, and `L`; there is no sum constraint. The @@ -229,9 +228,6 @@ non-negative multipliers have no sum constraint. no effect. The `RX`, `RY`, and `RZ` model keys are validation-only on this DEM route; `L` and `U` are not valid coherent-model keys. -The `p_idle` shorthand cannot be combined with `t1`/`t2`: the T1/T2 channel -replaces the depolarizing base channel, so the combination is rejected. - The engines simulators can consume leakage models, such as an engines-bound linear model `{"X": 0.8, "L": 0.2}`. DEM fault propagation is Pauli-only: these DEM entry points accept `L` in linear and sine-squared models for model @@ -333,11 +329,11 @@ else: Runtime-emitted idle durations are replayed as nanosecond `TimeUnits`. Inserted idles instead carry the duration passed to `idle_after_2q_duration`, which must be finite and positive. Linear and -sine-law idle rates are per time unit (for example, uniform idle noise uses -`p_idle * duration`, clamped to the probability range). The low-level -coefficient-style quadratic rates multiply `duration**2` and therefore scale -as inverse time squared. T1 and T2 values must use the same units as the idle -duration. +sine-law idle rates are per time unit. For example, uniform linear idle noise +uses `(p_idle_linear / 3) * duration` per Pauli axis, clamped to the probability +range. The low-level coefficient-style quadratic rates multiply `duration**2` +and therefore scale as inverse time squared. T1 and T2 values must use the same +units as the idle duration. ## Exporting the DEM as Stim Text @@ -381,7 +377,7 @@ dem = DetectorErrorModel.from_guppy( p2=0.0, p_meas=0.0, p_prep=0.0, - p_idle=0.01, + p_idle_linear=0.01, seed=0, ) @@ -427,7 +423,7 @@ dem = DetectorErrorModel.from_guppy( p2=0.0, p_meas=0.0, p_prep=0.0, - p_idle=0.01, + p_idle_linear=0.01, seed=0, ) diff --git a/examples/surface/validate_dem_generators.py b/examples/surface/validate_dem_generators.py index 66efd020a..1f36bc618 100644 --- a/examples/surface/validate_dem_generators.py +++ b/examples/surface/validate_dem_generators.py @@ -86,7 +86,10 @@ def build_circuit(distance, rounds, basis, circuit_source="abstract", *, fill_id # Optional passes applied to all circuits: if fill_idle: - # Insert Idle(1) after 2q gates (for idle_rz noise modeling) + # Insert Idle(1) after 2q gates: gives the DEM duration-based idle + # attachment points mirroring the sim's after-2q coherent channel + # placement (the sim idle_rz channel reacts to 2q gates directly, + # not to Idle gates). tc.insert_idle_after_two_qubit_gates(1.0) # Fill remaining inactive qubits with Idle gates tc.fill_idle_gates() diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 4c8556aaf..8cdb4eb23 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -137,9 +137,6 @@ def _validate_idle_family_model( def _translate_structured_idle_noise( *, - p_idle: float | None, - t1: float | None, - t2: float | None, p_idle_linear: float | None, p_idle_linear_model: Mapping[str, float] | None, p_idle_sin_squared: float | None, @@ -193,14 +190,6 @@ def _translate_structured_idle_noise( conflicts = ", ".join(name for name, value in linear_primitives.items() if value is not None) msg = f"p_idle_linear/p_idle_linear_model cannot be combined with low-level idle rate(s): {conflicts}" raise ValueError(msg) - if p_idle is not None and p_idle_linear is not None: - msg = "p_idle and p_idle_linear cannot be combined; p_idle is the uniform-model shorthand" - raise ValueError(msg) - if p_idle is not None and (t1 is not None or t2 is not None): - # The Rust NoiseConfig base idle channel is T1/T2 when set, else - # p_idle-depolarizing -- passing both would silently ignore p_idle. - msg = "p_idle and t1/t2 cannot be combined; the T1/T2 channel replaces the depolarizing base channel" - raise ValueError(msg) sine_primitives = { "p_idle_quadratic_sine_rate": p_idle_quadratic_sine_rate, "p_idle_x_quadratic_sine_rate": p_idle_x_quadratic_sine_rate, @@ -375,7 +364,6 @@ def _from_circuit_with_noise( p2_replacement_approximation: str | None, p_meas: float, p_prep: float, - p_idle: float | None, t1: float | None, t2: float | None, p_idle_linear_rate: float | None, @@ -400,7 +388,7 @@ def _from_circuit_with_noise( p2_replacement_approximation=p2_replacement_approximation, p_meas=p_meas, p_prep=p_prep, - p_idle=p_idle, + p_idle=None, t1=t1, t2=t2, p_idle_linear_rate=p_idle_linear_rate, @@ -471,7 +459,6 @@ def from_guppy( p2_replacement_approximation: str | None = None, p_meas: float = 0.001, p_prep: float = 0.001, - p_idle: float | None = None, p_idle_linear: float | None = None, p_idle_linear_model: Mapping[str, float] | None = None, p_idle_sin_squared: float | None = None, @@ -594,8 +581,6 @@ def from_guppy( entries like plain post-gate Pauli entries. p_meas: Measurement flip rate. p_prep: Preparation (reset) error rate. - p_idle: Optional shorthand for ``p_idle_linear`` with the uniform - ``{"X": 1/3, "Y": 1/3, "Z": 1/3}`` model. p_idle_linear: Optional total stochastic idle-noise rate linear in duration. Uses the engines ``GeneralNoiseModel`` convention. p_idle_linear_model: Optional relative weights over ``"X"``, ``"Y"``, @@ -728,9 +713,6 @@ def from_guppy( p_idle_y_quadratic_sine_rate, p_idle_z_quadratic_sine_rate, ) = _translate_structured_idle_noise( - p_idle=p_idle, - t1=t1, - t2=t2, p_idle_linear=p_idle_linear, p_idle_linear_model=p_idle_linear_model, p_idle_sin_squared=p_idle_sin_squared, @@ -814,7 +796,6 @@ def from_guppy( idle_after_2q_duration=idle_after_2q_duration, # Nonzero coherent rates were rejected before tracing; zero emits no noise. idle_noise_parameters=( - p_idle, p_idle_linear, p_idle_sin_squared, t1, @@ -870,7 +851,6 @@ def from_guppy( p2_replacement_approximation=p2_replacement_approximation, p_meas=p_meas, p_prep=p_prep, - p_idle=p_idle, t1=t1, t2=t2, p_idle_linear_rate=p_idle_linear_rate, @@ -1178,7 +1158,6 @@ def build_dem_from_guppy( p2_replacement_approximation: str | None = None, p_meas: float = 0.001, p_prep: float = 0.001, - p_idle: float | None = None, p_idle_linear: float | None = None, p_idle_linear_model: Mapping[str, float] | None = None, p_idle_sin_squared: float | None = None, @@ -1242,8 +1221,6 @@ def build_dem_from_guppy( replacement labels in ``p2_weights``. p_meas: Measurement flip rate. p_prep: Preparation (reset) error rate. - p_idle: Optional shorthand for ``p_idle_linear`` with the uniform - ``{"X": 1/3, "Y": 1/3, "Z": 1/3}`` model. p_idle_linear: Optional total stochastic idle-noise rate linear in duration. Uses the engines ``GeneralNoiseModel`` convention. p_idle_linear_model: Optional relative weights over ``"X"``, ``"Y"``, @@ -1335,9 +1312,6 @@ def build_dem_from_guppy( p_idle_y_quadratic_sine_rate, p_idle_z_quadratic_sine_rate, ) = _translate_structured_idle_noise( - p_idle=p_idle, - t1=t1, - t2=t2, p_idle_linear=p_idle_linear, p_idle_linear_model=p_idle_linear_model, p_idle_sin_squared=p_idle_sin_squared, @@ -1384,7 +1358,6 @@ def build_dem_from_guppy( idle_after_2q_duration=idle_after_2q_duration, # Nonzero coherent rates were rejected before tracing; zero emits no noise. idle_noise_parameters=( - p_idle, p_idle_linear, p_idle_sin_squared, t1, @@ -1440,7 +1413,6 @@ def build_dem_from_guppy( p2_replacement_approximation=p2_replacement_approximation, p_meas=p_meas, p_prep=p_prep, - p_idle=p_idle, t1=t1, t2=t2, p_idle_linear_rate=p_idle_linear_rate, diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py index 4fbecf65a..a8d705039 100644 --- a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py @@ -348,8 +348,8 @@ def test_twirl_sine_law_idle_noise_builds_dem_and_sampler() -> None: ("depolarizing", NoiseModel(p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001)), ("uniform_idle", NoiseModel(p_idle=0.002)), ("t1_t2", NoiseModel(t1=1000.0, t2=800.0)), - ("linear_idle", NoiseModel(p_idle_linear_rate=0.001)), - ("quadratic_idle", NoiseModel(p_idle_quadratic_rate=0.01)), + ("linear_idle", NoiseModel(p_idle_z_linear_rate=0.001)), + ("quadratic_idle", NoiseModel(p_idle_z_quadratic_rate=0.01)), ("sine_law_idle", NoiseModel(p_idle_x_quadratic_sine_rate=0.03)), ], ) 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 52a410976..1fff15244 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -3,6 +3,7 @@ """Regression tests for the Guppy-to-DEM convenience path.""" +import inspect import json from typing import ClassVar @@ -230,6 +231,11 @@ def _structured_idle_dem(entrypoint: str, **kwargs): ).dem +def test_guppy_dem_entrypoints_do_not_expose_p_idle_shorthand() -> None: + assert "p_idle" not in inspect.signature(DetectorErrorModel.from_guppy).parameters + assert "p_idle" not in inspect.signature(build_dem_from_guppy).parameters + + @pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) def test_structured_idle_linear_default_matches_axis_primitives(entrypoint: str) -> None: rate = 0.03 @@ -343,16 +349,6 @@ def test_structured_idle_sin_squared_custom_model_matches_axis_sine_primitives(e assert structured.to_string() == primitive.to_string() -@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) -def test_p_idle_shorthand_is_byte_identical_to_structured_uniform_linear(entrypoint: str) -> None: - rate = 0.03 - - shorthand = _structured_idle_dem(entrypoint, idle_after_2q_duration=1.0, p_idle=rate) - structured = _structured_idle_dem(entrypoint, idle_after_2q_duration=1.0, p_idle_linear=rate) - - assert shorthand.to_string() == structured.to_string() - - @pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) @pytest.mark.parametrize( ("rate_name", "model_name"), @@ -472,12 +468,6 @@ def test_structured_idle_sin_squared_composes_with_coefficient_quadratic_primiti assert dem.num_contributions > 0 -@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) -def test_p_idle_rejects_structured_linear_rate(entrypoint: str) -> None: - with pytest.raises(ValueError, match=r"p_idle and p_idle_linear"): - _structured_idle_dem(entrypoint, p_idle=0.01, p_idle_linear=0.01) - - @pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) @pytest.mark.parametrize( ("kwargs", "message"), @@ -556,22 +546,29 @@ def test_sin_squared_idle_model_does_not_require_normalized_multipliers(entrypoi def test_from_guppy_idle_insertion_matches_manual_pass_pipeline() -> None: from pecos.tracing import trace_program_to_tick_circuit + rate = 0.01 reference_circuit = trace_program_to_tick_circuit(_two_qubit_idle_target, 2, seed=0) normalize_traced_tick_circuit(reference_circuit, context="from_guppy idle insertion reference") reference_circuit.insert_idle_after_two_qubit_gates(1.0) reference_circuit.set_meta("detectors", _TWO_QUBIT_DETECTORS_JSON) reference_circuit.set_meta("observables", _TWO_QUBIT_OBSERVABLES_JSON) reference_circuit.set_meta("num_measurements", "2") - reference = DetectorErrorModel.from_circuit(reference_circuit, p_idle=0.01, **_NO_GATE_NOISE) + reference = DetectorErrorModel.from_circuit( + reference_circuit, + p_idle_x_linear_rate=rate / 3.0, + p_idle_y_linear_rate=rate / 3.0, + p_idle_z_linear_rate=rate / 3.0, + **_NO_GATE_NOISE, + ) - composed = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle=0.01) + composed = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle_linear=rate) assert composed.to_string() == reference.to_string() def test_from_guppy_inserted_idles_make_idle_noise_effective() -> None: without_idle_noise = _two_qubit_dem(idle_after_2q_duration=1.0) - with_idle_noise = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle=0.01) + with_idle_noise = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle_linear=0.01) assert with_idle_noise.to_string() != without_idle_noise.to_string() assert with_idle_noise.num_contributions > without_idle_noise.num_contributions @@ -580,7 +577,6 @@ def test_from_guppy_inserted_idles_make_idle_noise_effective() -> None: # Every idle-noise parameter the guard must observe; omitting any one from the # guard wiring in dem.py must fail the corresponding parametrized case below. _ALL_IDLE_NOISE_PARAMS = { - "p_idle": 0.01, "p_idle_linear": 0.01, "p_idle_sin_squared": 0.01, "t1": 100.0, @@ -609,17 +605,11 @@ def test_from_guppy_rejects_idle_noise_without_idle_gates(idle_param: str) -> No @pytest.mark.parametrize("bad_duration", [0.0, -1.0, float("nan"), float("inf")]) def test_from_guppy_rejects_non_positive_idle_duration(bad_duration: float) -> None: with pytest.raises(ValueError, match=r"finite, positive duration"): - _two_qubit_dem(idle_after_2q_duration=bad_duration, p_idle=0.01) - - -def test_from_guppy_rejects_p_idle_with_t1_t2() -> None: - # The Rust base idle channel is T1/T2 when set, silently ignoring p_idle. - with pytest.raises(ValueError, match=r"T1/T2 channel replaces the depolarizing base channel"): - _two_qubit_dem(idle_after_2q_duration=1.0, p_idle=0.01, t1=100.0, t2=50.0) + _two_qubit_dem(idle_after_2q_duration=bad_duration, p_idle_linear=0.01) def test_from_guppy_idle_guard_accepts_inserted_idles_and_idles_without_noise() -> None: - with_noise = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle=0.01) + with_noise = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle_linear=0.01) without_noise = _two_qubit_dem(idle_after_2q_duration=1.0) assert with_noise.num_contributions > 0 @@ -636,7 +626,7 @@ def test_from_guppy_idle_guard_accepts_runtime_emitted_idles(monkeypatch: pytest circuit.tick().mz_with_ids([0, 1], [0, 1]) monkeypatch.setattr("pecos.tracing.trace_program_to_tick_circuit", lambda *_args, **_kwargs: circuit) - dem = _two_qubit_dem(p_idle=0.01) + dem = _two_qubit_dem(p_idle_linear=0.01) assert dem.num_contributions > 0 @@ -662,7 +652,7 @@ def test_from_guppy_strip_traced_idles_removes_runtime_emitted_idles(monkeypatch # (test_from_guppy_idle_guard_accepts_runtime_emitted_idles); with # strip_traced_idles the guard must find no idle gates left. with pytest.raises(ValueError, match=r"idle-noise parameters have no idle gates"): - _two_qubit_dem(strip_traced_idles=True, p_idle=0.01) + _two_qubit_dem(strip_traced_idles=True, p_idle_linear=0.01) def test_from_guppy_insertion_strips_runtime_idles_by_default(monkeypatch: pytest.MonkeyPatch) -> None: @@ -678,9 +668,9 @@ def _traced_circuit_with_runtime_idles(*_args, **_kwargs): monkeypatch.setattr("pecos.tracing.trace_program_to_tick_circuit", _traced_circuit_with_runtime_idles) - default_strip = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle=0.01) - explicit_strip = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle=0.01, strip_traced_idles=True) - keep_runtime_idles = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle=0.01, strip_traced_idles=False) + default_strip = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle_linear=0.01) + explicit_strip = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle_linear=0.01, strip_traced_idles=True) + keep_runtime_idles = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle_linear=0.01, strip_traced_idles=False) # Insertion implies stripping unless explicitly disabled; keeping the # runtime idles doubles the idle content and must change the DEM. @@ -709,7 +699,7 @@ def test_build_dem_from_guppy_rejects_non_positive_idle_duration() -> None: detectors=[Detector(rec[-2])], observables=[Observable(rec[-1])], idle_after_2q_duration=0.0, - p_idle=0.01, + p_idle_linear=0.01, **_NO_GATE_NOISE, ) @@ -722,7 +712,7 @@ def test_build_dem_from_guppy_strips_then_inserts_idles() -> None: observables=[Observable(rec[-1])], strip_traced_idles=True, idle_after_2q_duration=1.0, - p_idle=0.01, + p_idle_linear=0.01, **_NO_GATE_NOISE, ) From ae1ce2eee37c98d84236d97316629ea542835923 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 18:11:42 -0600 Subject: [PATCH 10/62] Add the first docs/workflows page: Guppy QEC to decoded logical error rates with idle noise --- docs/workflows/guppy-dem-decoding.md | 146 +++++++++++++++++++++++++++ mkdocs.yml | 4 +- 2 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 docs/workflows/guppy-dem-decoding.md diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md new file mode 100644 index 000000000..fcf929a89 --- /dev/null +++ b/docs/workflows/guppy-dem-decoding.md @@ -0,0 +1,146 @@ +# Decode a Guppy QEC experiment with idle noise + +Use this workflow when you already have a Guppy QEC experiment and want to +estimate its decoded logical error rate with circuit-level gate and idle noise. + +## 1. Build the program and its metadata + +Create a distance-3 surface-code memory experiment with three syndrome rounds. +The abstract surface-code builder supplies detector and observable metadata in +the same measurement order as the generated Guppy program. Hand-written Guppy +works identically; see [Detector Error Models from Guppy](../user-guide/dem-from-guppy.md#referencing-measurements) +for metadata-authoring details. + +## 2. Attach idle gates and noise + +Insert an idle of duration `1.0` on both qubits after every two-qubit gate. This +option strips traced identity-like gates by default before inserting the uniform +idle convention, which prevents double-counting runtime-emitted idles. + +The linear family uses a custom Z-biased distribution to retain smaller X/Y +memory errors while making dephasing dominant. Its weights are an additive +probability distribution and must sum to 1. The sine-squared family deliberately +uses only Z because the sine-law dephasing remnant is Z by nature. Its default +is symmetric across X, Y, and Z, so the single-axis choice is spelled out +explicitly. See [Idle Noise](../user-guide/dem-from-guppy.md#idle-noise) for the +full family and model semantics. + +## 3. Choose the DEM text for each decoder + +`to_string()` returns Stim-format DEM text with raw hyperedges, which BP+OSD and +Tesseract can consume. PyMatching requires a graph-like model, so use +`to_string_terminal_graphlike_decomposed()` for its terminal projection. The +source-informed `to_string_source_graphlike_decomposed()` form is used for the +Tesseract comparison below; Tesseract can also take the raw hyperedge form. + +## 4. Sample detector events + +Generate one batch and inspect individual shots with `get_syndrome()` and +`get_observable_mask()`. The observable mask contains the actual logical flips +against which decoder predictions are scored. + +## 5. Decode the same batch three ways + +Pass the appropriate text form and decoder name to `decode_count()`. Reusing the +same batch makes the logical error rates directly comparable rather than mixing +in sampling variation from separate experiments. + + +```python +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.circuit_builder import generate_tick_circuit_from_patch + +distance = 3 +num_rounds = 3 +basis = "Z" + +# Build the Guppy program and matching abstract-circuit metadata. +program = make_surface_code( + distance=distance, + num_rounds=num_rounds, + basis=basis, +) +patch = SurfacePatch.create(distance=distance) +metadata_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=num_rounds, + basis=basis, +) + +# Trace the program, insert idle gates, and attach gate and memory noise. +dem = DetectorErrorModel.from_guppy( + program, + num_qubits=get_num_qubits(distance), + detectors_json=metadata_circuit.get_meta("detectors"), + observables_json=metadata_circuit.get_meta("observables"), + num_measurements=int(metadata_circuit.get_meta("num_measurements")), + idle_after_2q_duration=1.0, + p_idle_linear=0.002, + p_idle_linear_model={"X": 0.25, "Y": 0.25, "Z": 0.5}, + p_idle_sin_squared=0.01, + p_idle_sin_squared_model={"Z": 1.0}, + p1=0.001, + p2=0.005, + p_meas=0.005, + p_prep=0.005, +) + +# Inspect the model and prepare the decoder-specific text forms. +raw_text = dem.to_string() +terminal_graphlike_text = dem.to_string_terminal_graphlike_decomposed() +source_graphlike_text = dem.to_string_source_graphlike_decomposed() +num_mechanisms = raw_text.count("error(") + +assert dem.num_detectors > 0 +assert dem.num_observables == 1 +assert num_mechanisms > 0 +assert all( + "error(" in text + for text in (raw_text, terminal_graphlike_text, source_graphlike_text) +) +print(f"detectors: {dem.num_detectors}") +print(f"error mechanisms: {num_mechanisms}") + +# Draw one reproducible batch, then access a couple of shots explicitly. +sampler = dem.to_sampler() +batch = sampler.generate_samples(2000, 1) +assert batch.num_shots == 2000 +for i in range(2): + syndrome = batch.get_syndrome(i) + observable_mask = batch.get_observable_mask(i) + assert len(syndrome) == dem.num_detectors + assert observable_mask in (0, 1) + print(f"shot {i}: syndrome={syndrome}, observable_mask={observable_mask}") + +# Decode identical detector events with all three decoders. +decoder_inputs = { + "pymatching": terminal_graphlike_text, + "tesseract": source_graphlike_text, + "bp_osd": raw_text, +} +error_counts = { + name: batch.decode_count(text, name) + for name, text in decoder_inputs.items() +} +logical_error_rates = { + name: errors / batch.num_shots + for name, errors in error_counts.items() +} + +assert all(0 < errors < batch.num_shots for errors in error_counts.values()) +print("decoder errors logical error rate") +for name in decoder_inputs: + print(f"{name:10} {error_counts[name]:6} {logical_error_rates[name]:.4%}") +``` + +## Where to go next + +- [Detector Error Models from Guppy](../user-guide/dem-from-guppy.md) explains + metadata references, idle-noise models, and DEM representations in detail. +- [QEC with Guppy](../user-guide/qec-guppy.md) covers the built-in QEC program + generators and execution workflow. +- [Decoders](../user-guide/decoders.md) describes the available decoder APIs. +- [Runtime QIS Tracing](../user-guide/runtime-qis-tracing.md) explains how PECOS + captures the runtime-lowered gate stream used to build this DEM. diff --git a/mkdocs.yml b/mkdocs.yml index b58629fb6..ffcc311b0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -79,6 +79,8 @@ nav: - LLVM Setup: user-guide/llvm-setup.md - CUDA Setup: user-guide/cuda-setup.md - cmake Setup (MWPF): user-guide/cmake-setup.md +- Workflows: + - Decode a Guppy QEC experiment with idle noise: workflows/guppy-dem-decoding.md - Concepts: - concepts/index.md - StabVec Simulator: concepts/clifford-rz-simulator.md @@ -96,8 +98,6 @@ nav: - Experimental: - experimental/index.md - Composable Noise (pecos-neo): experimental/composable-noise.md -- Proposals: - - proposals/README.md - Releases: - releases/changelog.md markdown_extensions: From 4ddd887c14e5f06d6ca08eca9a79a7235ffe8afc Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 18:32:22 -0600 Subject: [PATCH 11/62] Base the workflow guide on a hand-written repetition-code program instead of the surface-code generator --- docs/workflows/guppy-dem-decoding.md | 216 ++++++++++++++++----------- 1 file changed, 127 insertions(+), 89 deletions(-) diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index fcf929a89..16a562aee 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -1,118 +1,158 @@ # Decode a Guppy QEC experiment with idle noise -Use this workflow when you already have a Guppy QEC experiment and want to -estimate its decoded logical error rate with circuit-level gate and idle noise. +Use this workflow when you have a Guppy QEC experiment and want to estimate its +decoded logical error rate under circuit-level gate and idle noise. The example +is a hand-written three-qubit repetition-code memory, small enough to read in +full: everything here applies unchanged to larger hand-written programs. -## 1. Build the program and its metadata +## 1. Write the program -Create a distance-3 surface-code memory experiment with three syndrome rounds. -The abstract surface-code builder supplies detector and observable metadata in -the same measurement order as the generated Guppy program. Hand-written Guppy -works identically; see [Detector Error Models from Guppy](../user-guide/dem-from-guppy.md#referencing-measurements) -for metadata-authoring details. +The program prepares three data qubits in the logical `|0>` state, extracts the +two parity checks twice with fresh ancillas, and reads out the data qubits in +the Z basis. Every measurement that a detector will reference is tagged with +`result(...)`, so detectors can be written by name instead of by counting +positions. -## 2. Attach idle gates and noise +Two constraints shape the program. Quantum control flow must be static, so the +rounds are written out rather than looped, and the circuit must be Clifford. +Ancillas are freshly allocated each round because `measure()` consumes its +qubit. For larger codes, the built-in generators in +[QEC with Guppy](../user-guide/qec-guppy.md) produce this structure for you. -Insert an idle of duration `1.0` on both qubits after every two-qubit gate. This -option strips traced identity-like gates by default before inserting the uniform -idle convention, which prevents double-counting runtime-emitted idles. +## 2. Define detectors and observables -The linear family uses a custom Z-biased distribution to retain smaller X/Y -memory errors while making dephasing dominant. Its weights are an additive -probability distribution and must sum to 1. The sine-squared family deliberately -uses only Z because the sine-law dephasing remnant is Z by nature. Its default -is symmetric across X, Y, and Z, so the single-axis choice is spelled out -explicitly. See [Idle Noise](../user-guide/dem-from-guppy.md#idle-noise) for the -full family and model semantics. +A detector is the **parity** of the measurements it references, chosen so that +it is deterministic in the absence of noise: -## 3. Choose the DEM text for each decoder +- `D0`, `D1` — the first-round checks, deterministic because the data qubits + start in `|000>`. +- `D2`, `D3` — each second-round check compared against the same check in the + first round. +- `D4`, `D5` — each second-round check compared against the corresponding parity + of the final data readout. + +The observable is the logical Z value, which for this code is any single data +qubit measurement. + +## 3. Attach idle gates and noise + +`idle_after_2q_duration=1.0` inserts an idle of that duration on both qubits +after every two-qubit gate; traced identity-like gates are stripped first by +default, so runtime-emitted idles are not double-counted. + +The linear family uses a custom Z-biased distribution, keeping smaller X and Y +memory errors while making dephasing dominant; its weights are an additive +probability distribution and must sum to 1. The sine-squared family uses Z only, +because the sine-law dephasing remnant is Z by nature — its default is symmetric +across X, Y, and Z, so the single-axis choice is spelled out explicitly. See +[Idle Noise](../user-guide/dem-from-guppy.md#idle-noise) for the full family and +model semantics. + +## 4. Choose the DEM text for each decoder `to_string()` returns Stim-format DEM text with raw hyperedges, which BP+OSD and -Tesseract can consume. PyMatching requires a graph-like model, so use -`to_string_terminal_graphlike_decomposed()` for its terminal projection. The -source-informed `to_string_source_graphlike_decomposed()` form is used for the -Tesseract comparison below; Tesseract can also take the raw hyperedge form. +Tesseract consume directly. PyMatching requires a graph-like model, so it gets +the terminal projection from `to_string_terminal_graphlike_decomposed()`. The +source-informed `to_string_source_graphlike_decomposed()` form is used for +Tesseract below. -## 4. Sample detector events +## 5. Sample detector events -Generate one batch and inspect individual shots with `get_syndrome()` and -`get_observable_mask()`. The observable mask contains the actual logical flips -against which decoder predictions are scored. +`to_sampler()` draws detector events and observable flips directly from the DEM. +`get_syndrome()` returns one shot's detector bits and `get_observable_mask()` the +actual logical flips those shots incurred — the ground truth that decoder +predictions are scored against. -## 5. Decode the same batch three ways +## 6. Decode the same batch three ways -Pass the appropriate text form and decoder name to `decode_count()`. Reusing the -same batch makes the logical error rates directly comparable rather than mixing -in sampling variation from separate experiments. +Passing the same batch to each decoder makes the logical error rates directly +comparable, rather than mixing in sampling variation from separate experiments. ```python -from pecos.guppy import get_num_qubits, make_surface_code +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import cx, measure, qubit + from pecos.qec import DetectorErrorModel -from pecos.qec.surface import SurfacePatch -from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch - -distance = 3 -num_rounds = 3 -basis = "Z" - -# Build the Guppy program and matching abstract-circuit metadata. -program = make_surface_code( - distance=distance, - num_rounds=num_rounds, - basis=basis, -) -patch = SurfacePatch.create(distance=distance) -metadata_circuit = generate_tick_circuit_from_patch( - patch, - num_rounds=num_rounds, - basis=basis, -) -# Trace the program, insert idle gates, and attach gate and memory noise. + +@guppy +def rep_code_memory() -> None: + # Data qubits, prepared in the logical |0> state. + d0, d1, d2 = qubit(), qubit(), qubit() + + # Round 0: the two Z-parity checks, (d0, d1) and (d1, d2). + a0, a1 = qubit(), qubit() + cx(d0, a0) + cx(d1, a0) + cx(d1, a1) + cx(d2, a1) + result("s0_r0", measure(a0)) + result("s1_r0", measure(a1)) + + # Round 1: same checks, fresh ancillas (measure() consumes its qubit). + b0, b1 = qubit(), qubit() + cx(d0, b0) + cx(d1, b0) + cx(d1, b1) + cx(d2, b1) + result("s0_r1", measure(b0)) + result("s1_r1", measure(b1)) + + # Final data readout in the Z basis. + result("m0", measure(d0)) + result("m1", measure(d1)) + result("m2", measure(d2)) + + +# Each detector is a parity that is deterministic without noise. +detectors_json = """[ + {"id": "D0", "result_tags": ["s0_r0"]}, + {"id": "D1", "result_tags": ["s1_r0"]}, + {"id": "D2", "result_tags": ["s0_r0", "s0_r1"]}, + {"id": "D3", "result_tags": ["s1_r0", "s1_r1"]}, + {"id": "D4", "result_tags": ["s0_r1", "m0", "m1"]}, + {"id": "D5", "result_tags": ["s1_r1", "m1", "m2"]} +]""" +observables_json = '[{"id": "L0", "result_tags": ["m0"]}]' + dem = DetectorErrorModel.from_guppy( - program, - num_qubits=get_num_qubits(distance), - detectors_json=metadata_circuit.get_meta("detectors"), - observables_json=metadata_circuit.get_meta("observables"), - num_measurements=int(metadata_circuit.get_meta("num_measurements")), + rep_code_memory, + num_qubits=7, + detectors_json=detectors_json, + observables_json=observables_json, idle_after_2q_duration=1.0, - p_idle_linear=0.002, + p_idle_linear=0.01, p_idle_linear_model={"X": 0.25, "Y": 0.25, "Z": 0.5}, - p_idle_sin_squared=0.01, + p_idle_sin_squared=0.03, p_idle_sin_squared_model={"Z": 1.0}, - p1=0.001, - p2=0.005, - p_meas=0.005, - p_prep=0.005, + p1=0.002, + p2=0.02, + p_meas=0.02, + p_prep=0.02, ) -# Inspect the model and prepare the decoder-specific text forms. +# The decoder-specific text forms. raw_text = dem.to_string() terminal_graphlike_text = dem.to_string_terminal_graphlike_decomposed() source_graphlike_text = dem.to_string_source_graphlike_decomposed() num_mechanisms = raw_text.count("error(") -assert dem.num_detectors > 0 +assert dem.num_detectors == 6 assert dem.num_observables == 1 assert num_mechanisms > 0 -assert all( - "error(" in text - for text in (raw_text, terminal_graphlike_text, source_graphlike_text) -) -print(f"detectors: {dem.num_detectors}") -print(f"error mechanisms: {num_mechanisms}") +print(f"detectors: {dem.num_detectors}, error mechanisms: {num_mechanisms}") -# Draw one reproducible batch, then access a couple of shots explicitly. +# Draw one reproducible batch, then inspect a couple of shots explicitly. sampler = dem.to_sampler() batch = sampler.generate_samples(2000, 1) assert batch.num_shots == 2000 -for i in range(2): - syndrome = batch.get_syndrome(i) - observable_mask = batch.get_observable_mask(i) +for shot in range(2): + syndrome = batch.get_syndrome(shot) + observable_mask = batch.get_observable_mask(shot) assert len(syndrome) == dem.num_detectors - assert observable_mask in (0, 1) - print(f"shot {i}: syndrome={syndrome}, observable_mask={observable_mask}") + print(f"shot {shot}: syndrome={syndrome}, observable_mask={observable_mask}") # Decode identical detector events with all three decoders. decoder_inputs = { @@ -120,27 +160,25 @@ decoder_inputs = { "tesseract": source_graphlike_text, "bp_osd": raw_text, } -error_counts = { - name: batch.decode_count(text, name) - for name, text in decoder_inputs.items() -} -logical_error_rates = { - name: errors / batch.num_shots - for name, errors in error_counts.items() -} +error_counts = {name: batch.decode_count(text, name) for name, text in decoder_inputs.items()} assert all(0 < errors < batch.num_shots for errors in error_counts.values()) print("decoder errors logical error rate") -for name in decoder_inputs: - print(f"{name:10} {error_counts[name]:6} {logical_error_rates[name]:.4%}") +for name, errors in error_counts.items(): + print(f"{name:10} {errors:6} {errors / batch.num_shots:.4%}") ``` +At this noise level the three decoders land within about a percentage point of +each other on this code; the gaps between decoders widen with code distance and +with genuinely hyperedge-like noise, which is where BP+OSD and Tesseract consume +the raw model rather than a graph-like projection. + ## Where to go next - [Detector Error Models from Guppy](../user-guide/dem-from-guppy.md) explains metadata references, idle-noise models, and DEM representations in detail. - [QEC with Guppy](../user-guide/qec-guppy.md) covers the built-in QEC program - generators and execution workflow. + generators for larger codes. - [Decoders](../user-guide/decoders.md) describes the available decoder APIs. - [Runtime QIS Tracing](../user-guide/runtime-qis-tracing.md) explains how PECOS captures the runtime-lowered gate stream used to build this DEM. From 416546ae37bc45cb7ed7cae15c9c8c294c14bd79 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 18:47:35 -0600 Subject: [PATCH 12/62] Implement the documented continuation marker in the doc-test generator and split the workflow guide into stages --- docs/user-guide/fault-catalog.md | 6 +- docs/workflows/guppy-dem-decoding.md | 147 +++++++++++++++------------ scripts/docs/generate_doc_tests.py | 19 +++- 3 files changed, 100 insertions(+), 72 deletions(-) diff --git a/docs/user-guide/fault-catalog.md b/docs/user-guide/fault-catalog.md index f5a7e2f18..8d5700ce1 100644 --- a/docs/user-guide/fault-catalog.md +++ b/docs/user-guide/fault-catalog.md @@ -28,7 +28,7 @@ print(f"{len(result)} shots, {len(result[0])} measurements each") If you want to inspect what faults are possible in that circuit: - + ```python from pecos_rslib_exp import fault_catalog @@ -106,7 +106,6 @@ structural fields like `affected_detectors` will be empty, but The expensive work (Pauli propagation, detector mapping) is done once during construction. Changing noise is cheap -- it just updates probability fields: - ```python catalog = fault_catalog(circuit) @@ -126,7 +125,6 @@ update existing decoders or plans. The returned object is sequence-like: - ```python print(len(catalog)) print(catalog[0]) @@ -419,7 +417,6 @@ catalog.with_noise(&noise); Iterate locations and alternatives: - ```rust for loc in &catalog.locations { println!( @@ -446,7 +443,6 @@ for loc in &catalog.locations { Iterate configurations: - ```rust for event in catalog.fault_configurations(2) { println!( diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 16a562aee..2ae6a88d7 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -5,7 +5,18 @@ decoded logical error rate under circuit-level gate and idle noise. The example is a hand-written three-qubit repetition-code memory, small enough to read in full: everything here applies unchanged to larger hand-written programs. -## 1. Write the program +The stages are: + +1. Define the code in Guppy +2. Define detectors and observables +3. Generate the DEM with gate and idle noise +4. Sample the DEM +5. Decode the samples and compute logical error rates + +Each stage builds on the previous one; the code blocks form a single script when +read in order. + +## 1. Define the code in Guppy The program prepares three data qubits in the logical `|0>` state, extracts the two parity checks twice with fresh ancillas, and reads out the data qubits in @@ -13,69 +24,17 @@ the Z basis. Every measurement that a detector will reference is tagged with `result(...)`, so detectors can be written by name instead of by counting positions. -Two constraints shape the program. Quantum control flow must be static, so the +Two constraints shape the program: quantum control flow must be static, so the rounds are written out rather than looped, and the circuit must be Clifford. Ancillas are freshly allocated each round because `measure()` consumes its qubit. For larger codes, the built-in generators in [QEC with Guppy](../user-guide/qec-guppy.md) produce this structure for you. -## 2. Define detectors and observables - -A detector is the **parity** of the measurements it references, chosen so that -it is deterministic in the absence of noise: - -- `D0`, `D1` — the first-round checks, deterministic because the data qubits - start in `|000>`. -- `D2`, `D3` — each second-round check compared against the same check in the - first round. -- `D4`, `D5` — each second-round check compared against the corresponding parity - of the final data readout. - -The observable is the logical Z value, which for this code is any single data -qubit measurement. - -## 3. Attach idle gates and noise - -`idle_after_2q_duration=1.0` inserts an idle of that duration on both qubits -after every two-qubit gate; traced identity-like gates are stripped first by -default, so runtime-emitted idles are not double-counted. - -The linear family uses a custom Z-biased distribution, keeping smaller X and Y -memory errors while making dephasing dominant; its weights are an additive -probability distribution and must sum to 1. The sine-squared family uses Z only, -because the sine-law dephasing remnant is Z by nature — its default is symmetric -across X, Y, and Z, so the single-axis choice is spelled out explicitly. See -[Idle Noise](../user-guide/dem-from-guppy.md#idle-noise) for the full family and -model semantics. - -## 4. Choose the DEM text for each decoder - -`to_string()` returns Stim-format DEM text with raw hyperedges, which BP+OSD and -Tesseract consume directly. PyMatching requires a graph-like model, so it gets -the terminal projection from `to_string_terminal_graphlike_decomposed()`. The -source-informed `to_string_source_graphlike_decomposed()` form is used for -Tesseract below. - -## 5. Sample detector events - -`to_sampler()` draws detector events and observable flips directly from the DEM. -`get_syndrome()` returns one shot's detector bits and `get_observable_mask()` the -actual logical flips those shots incurred — the ground truth that decoder -predictions are scored against. - -## 6. Decode the same batch three ways - -Passing the same batch to each decoder makes the logical error rates directly -comparable, rather than mixing in sampling variation from separate experiments. - - ```python from guppylang import guppy from guppylang.std.builtins import result from guppylang.std.quantum import cx, measure, qubit -from pecos.qec import DetectorErrorModel - @guppy def rep_code_memory() -> None: @@ -104,9 +63,25 @@ def rep_code_memory() -> None: result("m0", measure(d0)) result("m1", measure(d1)) result("m2", measure(d2)) +``` + +## 2. Define detectors and observables +A detector is the **parity** of the measurements it references, chosen so that +it is deterministic in the absence of noise: -# Each detector is a parity that is deterministic without noise. +- `D0`, `D1` — the first-round checks, deterministic because the data qubits + start in `|000>`. +- `D2`, `D3` — each second-round check compared against the same check in the + first round. +- `D4`, `D5` — each second-round check compared against the corresponding parity + of the final data readout. + +The observable is the logical Z value, which for this code is any single data +qubit measurement. + + +```python detectors_json = """[ {"id": "D0", "result_tags": ["s0_r0"]}, {"id": "D1", "result_tags": ["s1_r0"]}, @@ -116,6 +91,25 @@ detectors_json = """[ {"id": "D5", "result_tags": ["s1_r1", "m1", "m2"]} ]""" observables_json = '[{"id": "L0", "result_tags": ["m0"]}]' +``` + +## 3. Generate the DEM with gate and idle noise + +`idle_after_2q_duration=1.0` inserts an idle of that duration on both qubits +after every two-qubit gate; traced identity-like gates are stripped first by +default, so runtime-emitted idles are not double-counted. + +The linear family uses a custom Z-biased distribution, keeping smaller X and Y +memory errors while making dephasing dominant; its weights are an additive +probability distribution and must sum to 1. The sine-squared family uses Z only, +because the sine-law dephasing remnant is Z by nature — its default is symmetric +across X, Y, and Z, so the single-axis choice is spelled out explicitly. See +[Idle Noise](../user-guide/dem-from-guppy.md#idle-noise) for the full family and +model semantics. + + +```python +from pecos.qec import DetectorErrorModel dem = DetectorErrorModel.from_guppy( rep_code_memory, @@ -133,28 +127,55 @@ dem = DetectorErrorModel.from_guppy( p_prep=0.02, ) -# The decoder-specific text forms. +assert dem.num_detectors == 6 +assert dem.num_observables == 1 +print(f"detectors: {dem.num_detectors}, mechanisms: {dem.to_string().count('error(')}") +``` + +The DEM has several text forms, one per decoder appetite. `to_string()` returns +Stim-format text with raw hyperedges, which BP+OSD and Tesseract consume +directly. PyMatching requires a graph-like model, so it gets the terminal +projection from `to_string_terminal_graphlike_decomposed()`; the source-informed +`to_string_source_graphlike_decomposed()` form is used for Tesseract below. + + +```python raw_text = dem.to_string() terminal_graphlike_text = dem.to_string_terminal_graphlike_decomposed() source_graphlike_text = dem.to_string_source_graphlike_decomposed() -num_mechanisms = raw_text.count("error(") -assert dem.num_detectors == 6 -assert dem.num_observables == 1 -assert num_mechanisms > 0 -print(f"detectors: {dem.num_detectors}, error mechanisms: {num_mechanisms}") +assert all("error(" in text for text in (raw_text, terminal_graphlike_text, source_graphlike_text)) +``` -# Draw one reproducible batch, then inspect a couple of shots explicitly. +## 4. Sample the DEM + +`to_sampler()` draws detector events and observable flips directly from the DEM. +`get_syndrome()` returns one shot's detector bits and `get_observable_mask()` the +actual logical flips those shots incurred — the ground truth that decoder +predictions are scored against. + + +```python sampler = dem.to_sampler() batch = sampler.generate_samples(2000, 1) + assert batch.num_shots == 2000 for shot in range(2): syndrome = batch.get_syndrome(shot) observable_mask = batch.get_observable_mask(shot) assert len(syndrome) == dem.num_detectors print(f"shot {shot}: syndrome={syndrome}, observable_mask={observable_mask}") +``` -# Decode identical detector events with all three decoders. +## 5. Decode the samples and compute logical error rates + +`decode_count()` decodes every shot and returns the number whose predicted +observable flip disagreed with the sampled one. Passing the same batch to each +decoder makes the rates directly comparable, rather than mixing in sampling +variation from separate experiments. + + +```python decoder_inputs = { "pymatching": terminal_graphlike_text, "tesseract": source_graphlike_text, diff --git a/scripts/docs/generate_doc_tests.py b/scripts/docs/generate_doc_tests.py index 594f29116..79f1291f8 100755 --- a/scripts/docs/generate_doc_tests.py +++ b/scripts/docs/generate_doc_tests.py @@ -332,6 +332,7 @@ def extract_code_blocks(file_path: Path, language: str = "python") -> list[CodeB blocks = [] preamble_parts: list[str] = [] + chain_parts: list[str] = [] setup_code = "" block_number = 0 @@ -378,19 +379,29 @@ def extract_code_blocks(file_path: Path, language: str = "python") -> list[CodeB # Regular visible block block_number += 1 + # Each generated test runs in a fresh interpreter, so a continuation + # block carries state by re-executing the visible blocks before it. A + # block without the marker starts a new chain. + if attrs["is_continuation"] and chain_parts: + body = "\n\n".join([*chain_parts, cleaned_code]) + else: + body = cleaned_code + chain_parts = [] + chain_parts.append(cleaned_code) + # Build full code with preamble if preamble_parts: preamble = "\n\n".join(preamble_parts) # Check for placeholder pattern: // CODE or /* CODE */ if "// CODE" in preamble: - full_code = preamble.replace("// CODE", cleaned_code) + full_code = preamble.replace("// CODE", body) elif "/* CODE */" in preamble: - full_code = preamble.replace("/* CODE */", cleaned_code) + full_code = preamble.replace("/* CODE */", body) else: # Default: append code after preamble - full_code = preamble + "\n\n" + cleaned_code + full_code = preamble + "\n\n" + body else: - full_code = cleaned_code + full_code = body # Add setup code if present if setup_code: From 1000cf867d09e70a756deac53705116e68c4d99e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 18:53:09 -0600 Subject: [PATCH 13/62] Show both sampling paths in the workflow guide: DEM sampler and simulated shots --- docs/workflows/guppy-dem-decoding.md | 92 ++++++++++++++++++++++------ 1 file changed, 73 insertions(+), 19 deletions(-) diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 2ae6a88d7..068e9fca8 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -10,7 +10,7 @@ The stages are: 1. Define the code in Guppy 2. Define detectors and observables 3. Generate the DEM with gate and idle noise -4. Sample the DEM +4. Sample — from the DEM, or by simulating the program 5. Decode the samples and compute logical error rates Each stage builds on the previous one; the code blocks form a single script when @@ -80,17 +80,22 @@ it is deterministic in the absence of noise: The observable is the logical Z value, which for this code is any single data qubit measurement. +`result_ref("tag")` refers to a tagged measurement by name; `rec[-k]` refers to +one by position in the canonical Guppy measurement stream, as in Stim. + ```python -detectors_json = """[ - {"id": "D0", "result_tags": ["s0_r0"]}, - {"id": "D1", "result_tags": ["s1_r0"]}, - {"id": "D2", "result_tags": ["s0_r0", "s0_r1"]}, - {"id": "D3", "result_tags": ["s1_r0", "s1_r1"]}, - {"id": "D4", "result_tags": ["s0_r1", "m0", "m1"]}, - {"id": "D5", "result_tags": ["s1_r1", "m1", "m2"]} -]""" -observables_json = '[{"id": "L0", "result_tags": ["m0"]}]' +from pecos.qec import Detector, Observable, result_ref + +detectors = [ + Detector(result_ref("s0_r0")), + Detector(result_ref("s1_r0")), + Detector(result_ref("s0_r0"), result_ref("s0_r1")), + Detector(result_ref("s1_r0"), result_ref("s1_r1")), + Detector(result_ref("s0_r1"), result_ref("m0"), result_ref("m1")), + Detector(result_ref("s1_r1"), result_ref("m1"), result_ref("m2")), +] +observables = [Observable(result_ref("m0"))] ``` ## 3. Generate the DEM with gate and idle noise @@ -107,15 +112,19 @@ across X, Y, and Z, so the single-axis choice is spelled out explicitly. See [Idle Noise](../user-guide/dem-from-guppy.md#idle-noise) for the full family and model semantics. +`build_dem_from_guppy` returns the DEM together with the audit trail and the +result-column evaluator used in stage 4b. `DetectorErrorModel.from_guppy` builds +the same DEM from JSON metadata instead of typed specifications. + ```python -from pecos.qec import DetectorErrorModel +from pecos.qec import build_dem_from_guppy -dem = DetectorErrorModel.from_guppy( +build = build_dem_from_guppy( rep_code_memory, num_qubits=7, - detectors_json=detectors_json, - observables_json=observables_json, + detectors=detectors, + observables=observables, idle_after_2q_duration=1.0, p_idle_linear=0.01, p_idle_linear_model={"X": 0.25, "Y": 0.25, "Z": 0.5}, @@ -126,6 +135,7 @@ dem = DetectorErrorModel.from_guppy( p_meas=0.02, p_prep=0.02, ) +dem = build.dem assert dem.num_detectors == 6 assert dem.num_observables == 1 @@ -147,12 +157,12 @@ source_graphlike_text = dem.to_string_source_graphlike_decomposed() assert all("error(" in text for text in (raw_text, terminal_graphlike_text, source_graphlike_text)) ``` -## 4. Sample the DEM +## 4a. Sample the DEM -`to_sampler()` draws detector events and observable flips directly from the DEM. -`get_syndrome()` returns one shot's detector bits and `get_observable_mask()` the -actual logical flips those shots incurred — the ground truth that decoder -predictions are scored against. +`to_sampler()` draws detector events and observable flips directly from the +error model, without simulating the circuit. `get_syndrome()` returns one shot's +detector bits and `get_observable_mask()` the actual logical flips those shots +incurred — the ground truth that decoder predictions are scored against. ```python @@ -167,6 +177,45 @@ for shot in range(2): print(f"shot {shot}: syndrome={syndrome}, observable_mask={observable_mask}") ``` +## 4b. Or generate shots by simulating the program + +Instead of sampling the error model, you can execute the Guppy program itself +under a noisy simulator and score those shots against the same DEM. +`build.evaluate_result_columns()` maps the run's tagged result columns into the +same detector-event and observable-flip pairs a DEM sample would produce, so +either source can feed the decoders. + +The two paths do not use the same noise: the DEM carries the idle families +configured in stage 3, while the simulation applies whatever noise model is +given to `sim(...)` — and the default Selene runtime emits no idle gates, so +idle noise has nothing to attach to on that path. Treat the numbers as coming +from two different experiments rather than as a like-for-like comparison. + + +```python +from pecos import depolarizing_noise, selene_engine, sim, stabilizer +from pecos_rslib.qec import SampleBatch + +columns = ( + sim(rep_code_memory) + .classical(selene_engine()) + .quantum(stabilizer()) + .qubits(7) + .noise(depolarizing_noise().with_uniform_probability(0.01)) + .seed(42) + .run(500) + .to_shot_map() + .to_dict() +) +evaluated = build.evaluate_result_columns(columns) +sim_batch = SampleBatch( + [events for events, _ in evaluated], + [mask for _, mask in evaluated], +) + +assert sim_batch.num_shots == 500 +``` + ## 5. Decode the samples and compute logical error rates `decode_count()` decodes every shot and returns the number whose predicted @@ -184,9 +233,14 @@ decoder_inputs = { error_counts = {name: batch.decode_count(text, name) for name, text in decoder_inputs.items()} assert all(0 < errors < batch.num_shots for errors in error_counts.values()) +print("DEM-sampled shots") print("decoder errors logical error rate") for name, errors in error_counts.items(): print(f"{name:10} {errors:6} {errors / batch.num_shots:.4%}") + +# The simulated shots decode against the same DEM. +sim_errors = sim_batch.decode_count(terminal_graphlike_text, "pymatching") +print(f"\nsimulated shots, pymatching: {sim_errors}/{sim_batch.num_shots}") ``` At this noise level the three decoders land within about a percentage point of From a273efe8a491ccba7e8ea812d2e7a6945a18a64e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 19:04:01 -0600 Subject: [PATCH 14/62] Accept bare tag strings as measurement references in Detector and Observable --- docs/workflows/guppy-dem-decoding.md | 25 +++++++----- .../quantum-pecos/src/pecos/qec/dem_spec.py | 37 ++++++++++++----- .../tests/qec/test_guppy_dem_build.py | 40 ++++++++++++++++++- 3 files changed, 80 insertions(+), 22 deletions(-) diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 068e9fca8..c9eebfbc6 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -80,22 +80,27 @@ it is deterministic in the absence of noise: The observable is the logical Z value, which for this code is any single data qubit measurement. -`result_ref("tag")` refers to a tagged measurement by name; `rec[-k]` refers to -one by position in the canonical Guppy measurement stream, as in Stim. +A bare string names a tagged measurement. `rec[-k]` refers to one by position +in the canonical Guppy measurement stream, as in Stim, and +`result_ref("tag", occurrence=...)` is the explicit form when you need its +extra selectors. A tag that no `result()` call emits is a hard error, so +mistyped names fail loudly rather than silently dropping a detector term; in +larger programs you can also define each tag once as a module-level constant +and use it in both places, passing it to Guppy as `result(comptime(TAG), ...)`. ```python -from pecos.qec import Detector, Observable, result_ref +from pecos.qec import Detector, Observable detectors = [ - Detector(result_ref("s0_r0")), - Detector(result_ref("s1_r0")), - Detector(result_ref("s0_r0"), result_ref("s0_r1")), - Detector(result_ref("s1_r0"), result_ref("s1_r1")), - Detector(result_ref("s0_r1"), result_ref("m0"), result_ref("m1")), - Detector(result_ref("s1_r1"), result_ref("m1"), result_ref("m2")), + Detector("s0_r0"), + Detector("s1_r0"), + Detector("s0_r0", "s0_r1"), + Detector("s1_r0", "s1_r1"), + Detector("s0_r1", "m0", "m1"), + Detector("s1_r1", "m1", "m2"), ] -observables = [Observable(result_ref("m0"))] +observables = [Observable("m0")] ``` ## 3. Generate the DEM with gate and idle noise diff --git a/python/quantum-pecos/src/pecos/qec/dem_spec.py b/python/quantum-pecos/src/pecos/qec/dem_spec.py index 60fdf4735..647cff91d 100644 --- a/python/quantum-pecos/src/pecos/qec/dem_spec.py +++ b/python/quantum-pecos/src/pecos/qec/dem_spec.py @@ -75,11 +75,20 @@ def result_ref(tag: str, *, occurrence: int = 0, element: int | None = None) -> MeasurementRef = RecordRef | ResultRef -def _validate_refs(refs: tuple[MeasurementRef, ...]) -> None: +def _coerce_refs(refs: tuple[MeasurementRef | str, ...]) -> tuple[MeasurementRef, ...]: + """Accept a bare tag string as shorthand for ``result_ref(tag)``.""" if not refs: raise ValueError("detectors and observables must reference at least one measurement") - if any(not isinstance(ref, (RecordRef, ResultRef)) for ref in refs): - raise TypeError("measurement references must be rec[...] or result_ref(...) values") + coerced: list[MeasurementRef] = [] + for ref in refs: + if isinstance(ref, str): + coerced.append(ResultRef(ref)) + elif isinstance(ref, (RecordRef, ResultRef)): + coerced.append(ref) + else: + msg = 'measurement references must be rec[...], result_ref(...), or a "tag" string' + raise TypeError(msg) + return tuple(coerced) @dataclass(frozen=True, slots=True, init=False) @@ -93,14 +102,17 @@ class Detector: def __init__( self, - *refs: MeasurementRef, + *refs: MeasurementRef | str, id: int | None = None, coords: Sequence[float] | None = None, metadata: Mapping[str, Any] | None = None, ) -> None: - """Create a detector from typed measurement references.""" - refs_tuple = tuple(refs) - _validate_refs(refs_tuple) + """Create a detector from measurement references. + + Each reference is ``rec[-k]``, ``result_ref(...)``, or a bare tag + string, which is shorthand for ``result_ref(tag)``. + """ + refs_tuple = _coerce_refs(tuple(refs)) object.__setattr__(self, "refs", refs_tuple) object.__setattr__(self, "id", id) object.__setattr__(self, "coords", tuple(float(value) for value in coords) if coords is not None else None) @@ -117,13 +129,16 @@ class Observable: def __init__( self, - *refs: MeasurementRef, + *refs: MeasurementRef | str, id: int | None = None, metadata: Mapping[str, Any] | None = None, ) -> None: - """Create an observable from typed measurement references.""" - refs_tuple = tuple(refs) - _validate_refs(refs_tuple) + """Create an observable from measurement references. + + Each reference is ``rec[-k]``, ``result_ref(...)``, or a bare tag + string, which is shorthand for ``result_ref(tag)``. + """ + refs_tuple = _coerce_refs(tuple(refs)) object.__setattr__(self, "refs", refs_tuple) object.__setattr__(self, "id", id) object.__setattr__(self, "metadata", dict(metadata) if metadata is not None else None) diff --git a/python/quantum-pecos/tests/qec/test_guppy_dem_build.py b/python/quantum-pecos/tests/qec/test_guppy_dem_build.py index 0526ee295..6a03bac08 100644 --- a/python/quantum-pecos/tests/qec/test_guppy_dem_build.py +++ b/python/quantum-pecos/tests/qec/test_guppy_dem_build.py @@ -26,7 +26,7 @@ surface_memory_dem_spec, ) from pecos.qec.dem import _generator_certified_result_traces -from pecos.qec.dem_spec import GuppyDemBuild, _resolve_dem_specs +from pecos.qec.dem_spec import GuppyDemBuild, RecordRef, ResultRef, _resolve_dem_specs from pecos_rslib.quantum import TickCircuit @@ -132,6 +132,44 @@ def test_real_guppy_rec_and_result_ref_builds_are_byte_identical() -> None: assert via_records.dem.to_string() == via_results.dem.to_string() +def test_bare_tag_strings_are_shorthand_for_result_ref() -> None: + noise = {"p1": 0.01, "p2": 0.02, "p_meas": 0.1, "p_prep": 0.0} + via_result_ref = build_dem_from_guppy( + _scrambled_tagged_measurements, + num_qubits=2, + detectors=[Detector(result_ref("a"))], + observables=[Observable(result_ref("b"))], + **noise, + ) + via_strings = build_dem_from_guppy( + _scrambled_tagged_measurements, + num_qubits=2, + detectors=[Detector("a")], + observables=[Observable("b")], + **noise, + ) + + assert via_strings.detectors_json == via_result_ref.detectors_json + assert via_strings.observables_json == via_result_ref.observables_json + assert via_strings.schema_fingerprint == via_result_ref.schema_fingerprint + assert via_strings.dem.to_string() == via_result_ref.dem.to_string() + + +def test_tag_strings_mix_with_rec_and_result_ref_refs() -> None: + detector = Detector("a", result_ref("b"), rec[-1]) + + assert detector.refs == (ResultRef("a"), ResultRef("b"), RecordRef(-1)) + + +def test_tag_string_shorthand_rejects_empty_and_wrong_types() -> None: + with pytest.raises(ValueError, match="non-empty string"): + Detector("") + with pytest.raises(TypeError, match="rec\\[...\\], result_ref"): + Detector(3.5) + with pytest.raises(ValueError, match="at least one measurement"): + Observable() + + def test_trace_once_build_evaluates_runtime_and_rejects_uncertified_named_results( monkeypatch: pytest.MonkeyPatch, ) -> None: From 911ea953d0301abccad80592678488748f7118ab Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 19:06:57 -0600 Subject: [PATCH 15/62] Name the Guppy DEM build result dem_build in the workflow guide --- docs/workflows/guppy-dem-decoding.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index c9eebfbc6..1e889b6b9 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -125,7 +125,7 @@ the same DEM from JSON metadata instead of typed specifications. ```python from pecos.qec import build_dem_from_guppy -build = build_dem_from_guppy( +dem_build = build_dem_from_guppy( rep_code_memory, num_qubits=7, detectors=detectors, @@ -140,7 +140,7 @@ build = build_dem_from_guppy( p_meas=0.02, p_prep=0.02, ) -dem = build.dem +dem = dem_build.dem assert dem.num_detectors == 6 assert dem.num_observables == 1 @@ -186,7 +186,7 @@ for shot in range(2): Instead of sampling the error model, you can execute the Guppy program itself under a noisy simulator and score those shots against the same DEM. -`build.evaluate_result_columns()` maps the run's tagged result columns into the +`dem_build.evaluate_result_columns()` maps the run's tagged result columns into the same detector-event and observable-flip pairs a DEM sample would produce, so either source can feed the decoders. @@ -212,7 +212,7 @@ columns = ( .to_shot_map() .to_dict() ) -evaluated = build.evaluate_result_columns(columns) +evaluated = dem_build.evaluate_result_columns(columns) sim_batch = SampleBatch( [events for events, _ in evaluated], [mask for _, mask in evaluated], From b67eb929a2a3d65580d502e868382edeaad853a0 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 19:10:20 -0600 Subject: [PATCH 16/62] Decode with imported decoder objects in the workflow guide --- docs/workflows/guppy-dem-decoding.md | 65 ++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 1e889b6b9..65408ad85 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -223,31 +223,60 @@ assert sim_batch.num_shots == 500 ## 5. Decode the samples and compute logical error rates -`decode_count()` decodes every shot and returns the number whose predicted -observable flip disagreed with the sampled one. Passing the same batch to each -decoder makes the rates directly comparable, rather than mixing in sampling -variation from separate experiments. +Each decoder is constructed from the DEM text form it accepts, then asked for a +prediction per shot. A shot counts as a logical error when the predicted +observable flip disagrees with the flip the sample actually carried. + +The three decoders expose slightly different call shapes today: PyMatching +returns a per-observable `correction` vector, while Tesseract and BP+OSD return +an `observables_mask` bitmask. ```python -decoder_inputs = { - "pymatching": terminal_graphlike_text, - "tesseract": source_graphlike_text, - "bp_osd": raw_text, -} -error_counts = {name: batch.decode_count(text, name) for name, text in decoder_inputs.items()} - -assert all(0 < errors < batch.num_shots for errors in error_counts.values()) +from pecos.decoders import DemAwareDecoder, PyMatchingDecoder, TesseractDecoder + +pymatching = PyMatchingDecoder.from_dem(terminal_graphlike_text) +tesseract = TesseractDecoder.from_dem(source_graphlike_text, preset="fast") +bp_osd = DemAwareDecoder.from_dem(raw_text, decoder_type="bp_osd") + +pymatching_errors = 0 +tesseract_errors = 0 +bp_osd_errors = 0 + +for shot in range(batch.num_shots): + syndrome = batch.get_syndrome(shot) + actual = batch.get_observable_mask(shot) & 1 + + pymatching_errors += pymatching.decode(syndrome).correction[0] != actual + tesseract_errors += (tesseract.decode_syndrome(syndrome).observables_mask & 1) != actual + bp_osd_errors += (bp_osd.decode_syndrome(syndrome).observables_mask & 1) != actual + +shots = batch.num_shots +assert 0 < pymatching_errors < shots +assert 0 < tesseract_errors < shots +assert 0 < bp_osd_errors < shots + print("DEM-sampled shots") -print("decoder errors logical error rate") -for name, errors in error_counts.items(): - print(f"{name:10} {errors:6} {errors / batch.num_shots:.4%}") +print(f"pymatching {pymatching_errors:5} {pymatching_errors / shots:.4%}") +print(f"tesseract {tesseract_errors:5} {tesseract_errors / shots:.4%}") +print(f"bp_osd {bp_osd_errors:5} {bp_osd_errors / shots:.4%}") +``` -# The simulated shots decode against the same DEM. -sim_errors = sim_batch.decode_count(terminal_graphlike_text, "pymatching") -print(f"\nsimulated shots, pymatching: {sim_errors}/{sim_batch.num_shots}") +The simulated shots decode the same way, against the same decoders: + + +```python +sim_errors = 0 +for shot in range(sim_batch.num_shots): + predicted = pymatching.decode(sim_batch.get_syndrome(shot)).correction[0] + sim_errors += predicted != (sim_batch.get_observable_mask(shot) & 1) + +print(f"simulated shots, pymatching: {sim_errors}/{sim_batch.num_shots}") ``` +When you only need the count, `batch.decode_count(dem_text, "pymatching")` runs +this same loop natively and returns the number of mismatches. + At this noise level the three decoders land within about a percentage point of each other on this code; the gaps between decoders widen with code distance and with genuinely hyperedge-like noise, which is where BP+OSD and Tesseract consume From f0b23a8c390e2d146bef915681e6085967693ba0 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 19:54:15 -0600 Subject: [PATCH 17/62] Widen DemAwareDecoder observable predictions past 64 and address workflow review comments --- docs/workflows/guppy-dem-decoding.md | 64 +++++++++---------- python/pecos-rslib/src/decoder_bindings.rs | 43 +++++++++++-- .../src/fault_tolerance_bindings.rs | 2 +- .../quantum-pecos/src/pecos/qec/__init__.py | 2 + .../tests/qec/test_dem_aware_decoder_width.py | 54 ++++++++++++++++ .../tests/qec/test_guppy_dem_build.py | 2 +- 6 files changed, 126 insertions(+), 41 deletions(-) create mode 100644 python/quantum-pecos/tests/qec/test_dem_aware_decoder_width.py diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 65408ad85..a981c1bfb 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -83,7 +83,11 @@ qubit measurement. A bare string names a tagged measurement. `rec[-k]` refers to one by position in the canonical Guppy measurement stream, as in Stim, and `result_ref("tag", occurrence=...)` is the explicit form when you need its -extra selectors. A tag that no `result()` call emits is a hard error, so +extra selectors. + +Detectors and observables are not named: each one's DEM label is its position in +the list, so `detectors[0]` is `D0` and `observables[0]` is `L0`. That is the +identity the decoders and the DEM text use. A tag that no `result()` call emits is a hard error, so mistyped names fail loudly rather than silently dropping a detector term; in larger programs you can also define each tag once as a module-level constant and use it in both places, passing it to Guppy as `result(comptime(TAG), ...)`. @@ -172,7 +176,7 @@ incurred — the ground truth that decoder predictions are scored against. ```python sampler = dem.to_sampler() -batch = sampler.generate_samples(2000, 1) +batch = sampler.generate_samples(2000, seed=1) assert batch.num_shots == 2000 for shot in range(2): @@ -186,39 +190,35 @@ for shot in range(2): Instead of sampling the error model, you can execute the Guppy program itself under a noisy simulator and score those shots against the same DEM. -`dem_build.evaluate_result_columns()` maps the run's tagged result columns into the -same detector-event and observable-flip pairs a DEM sample would produce, so +`dem_build.evaluate_result_columns()` maps the run's tagged result columns into +the same (detector events, observable flips) pairs a DEM sample carries, so either source can feed the decoders. -The two paths do not use the same noise: the DEM carries the idle families -configured in stage 3, while the simulation applies whatever noise model is -given to `sim(...)` — and the default Selene runtime emits no idle gates, so -idle noise has nothing to attach to on that path. Treat the numbers as coming -from two different experiments rather than as a like-for-like comparison. +The gate noise below mirrors stage 3 exactly, so idle is the one remaining +difference: the DEM carries the idle families configured there, while the +default Selene runtime emits no idle gates for the simulator to attach idle +noise to. The simulated numbers are therefore the same experiment without the +idle contribution, not an independent estimate of the same quantity. ```python -from pecos import depolarizing_noise, selene_engine, sim, stabilizer -from pecos_rslib.qec import SampleBatch - -columns = ( - sim(rep_code_memory) - .classical(selene_engine()) - .quantum(stabilizer()) - .qubits(7) - .noise(depolarizing_noise().with_uniform_probability(0.01)) - .seed(42) - .run(500) - .to_shot_map() - .to_dict() -) -evaluated = dem_build.evaluate_result_columns(columns) -sim_batch = SampleBatch( - [events for events, _ in evaluated], - [mask for _, mask in evaluated], +from pecos import general_noise, selene_engine, sim, stabilizer + +# The same gate noise the DEM was built with, so only the idle treatment differs. +noise = ( + general_noise() + .with_p1_probability(0.002) + .with_p2_probability(0.02) + .with_meas_probability(0.02) + .with_prep_probability(0.02) ) -assert sim_batch.num_shots == 500 +results = sim(rep_code_memory).classical(selene_engine()).quantum(stabilizer()).qubits(7).noise(noise).seed(42).run(500) + +columns = results.to_shot_map().to_dict() +sim_shots = dem_build.evaluate_result_columns(columns) + +assert len(sim_shots) == 500 ``` ## 5. Decode the samples and compute logical error rates @@ -267,11 +267,11 @@ The simulated shots decode the same way, against the same decoders: ```python sim_errors = 0 -for shot in range(sim_batch.num_shots): - predicted = pymatching.decode(sim_batch.get_syndrome(shot)).correction[0] - sim_errors += predicted != (sim_batch.get_observable_mask(shot) & 1) +for syndrome, observable_mask in sim_shots: + predicted = pymatching.decode(syndrome).correction[0] + sim_errors += predicted != (observable_mask & 1) -print(f"simulated shots, pymatching: {sim_errors}/{sim_batch.num_shots}") +print(f"simulated shots, pymatching: {sim_errors}/{len(sim_shots)}") ``` When you only need the count, `batch.decode_count(dem_text, "pymatching")` runs diff --git a/python/pecos-rslib/src/decoder_bindings.rs b/python/pecos-rslib/src/decoder_bindings.rs index ac9f51ac5..93a466ca3 100644 --- a/python/pecos-rslib/src/decoder_bindings.rs +++ b/python/pecos-rslib/src/decoder_bindings.rs @@ -2219,9 +2219,8 @@ pub struct PyDemAwareDecoder { )] #[derive(Clone)] pub struct PyDemAwareResult { - /// Bitmask of predicted observable flips. - #[pyo3(get)] - pub observables_mask: u64, + /// Predicted observable flips, wide enough for more than 64 observables. + pub observables: pecos_decoder_core::obs_mask::ObsMask, /// Whether the BP decoder converged. #[pyo3(get)] pub converged: bool, @@ -2230,12 +2229,41 @@ pub struct PyDemAwareResult { pub iterations: usize, } +impl PyDemAwareResult { + /// Render the mask for `__repr__`: the plain integer when it fits in 64 + /// bits, otherwise the set observable indices. + fn mask_display(&self) -> String { + self.observables.to_u64().map_or_else( + || { + let bits: Vec = self + .observables + .iter_set_bits() + .map(|bit| bit.to_string()) + .collect(); + format!("", bits.join(",")) + }, + |value| value.to_string(), + ) + } +} + #[pymethods] impl PyDemAwareResult { + /// Bitmask of predicted observable flips. + /// + /// A Python integer of arbitrary precision: DEMs with at most 64 + /// observables yield exactly the value the previous `u64` field held. + #[getter] + fn observables_mask(&self, py: Python<'_>) -> PyResult> { + crate::fault_tolerance_bindings::obsmask_to_py(py, &self.observables) + } + fn __repr__(&self) -> String { format!( "DemAwareResult(observables_mask={}, converged={}, iterations={})", - self.observables_mask, self.converged, self.iterations + self.mask_display(), + self.converged, + self.iterations ) } } @@ -2414,12 +2442,13 @@ impl PyDemAwareDecoder { }; let correction: Vec = decoding.iter().map(|&v| v & 1).collect(); - let observables_mask = self + // Wide packing: the u64 variant silently wraps observable bits at 64. + let observables = self .dem_check_matrix - .observables_mask_from_correction(&correction); + .observables_obsmask_from_correction(&correction); Ok(PyDemAwareResult { - observables_mask, + observables, converged, iterations, }) diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index fbb855d8b..d6e89a996 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -5522,7 +5522,7 @@ impl PyCssUfDecoder { /// `<= 64` observables become a plain `int` from the single `u64` (identical to /// the historical return); `> 64` observables become a big `int` built from the /// mask's little-endian words, with no truncation. -fn obsmask_to_py( +pub(crate) fn obsmask_to_py( py: Python<'_>, mask: &pecos_decoder_core::obs_mask::ObsMask, ) -> PyResult> { diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index 1662aa7e7..a15af3a9d 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -39,6 +39,7 @@ InfluenceBuilder, ParsedDem, PauliFrameLookup, + SampleBatch, assert_dems_equivalent, compare_dems_exact, compare_dems_statistical, @@ -128,6 +129,7 @@ "DemBuilder", "DemSampler", "DemSamplerBuilder", + "SampleBatch", "DetectorErrorModel", "Detector", "EquivalenceResult", diff --git a/python/quantum-pecos/tests/qec/test_dem_aware_decoder_width.py b/python/quantum-pecos/tests/qec/test_dem_aware_decoder_width.py new file mode 100644 index 000000000..73b7985c7 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_dem_aware_decoder_width.py @@ -0,0 +1,54 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License.You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""`DemAwareDecoder` must not wrap observable bits at 64 (issue #430).""" + +from __future__ import annotations + +import pytest +from pecos.decoders import DemAwareDecoder + +# Observable 70 is the probe: a u64 mask would fold it onto bit 70 % 64 == 6. +_WIDE_OBSERVABLE = 70 +_WRAPPED_BIT = _WIDE_OBSERVABLE % 64 + + +def _wide_dem(num_observables: int = _WIDE_OBSERVABLE + 1) -> str: + lines = [f"error(0.1) D0 L0", f"error(0.1) D1 L{_WIDE_OBSERVABLE}"] + lines += [f"detector D{index}" for index in range(2)] + lines += [f"logical_observable L{index}" for index in range(num_observables)] + return "\n".join(lines) + + +@pytest.fixture +def wide_decoder() -> DemAwareDecoder: + return DemAwareDecoder.from_dem(_wide_dem(), decoder_type="bp_osd") + + +def test_observable_past_64_sets_its_own_bit(wide_decoder: DemAwareDecoder) -> None: + mask = wide_decoder.decode_syndrome([0, 1]).observables_mask + + assert mask >> _WIDE_OBSERVABLE & 1, "observable 70 must set bit 70" + assert not mask >> _WRAPPED_BIT & 1, "observable 70 must not wrap onto bit 6" + assert mask == 1 << _WIDE_OBSERVABLE + + +def test_narrow_observables_are_unchanged(wide_decoder: DemAwareDecoder) -> None: + # Values that fit in 64 bits must stay exactly what the previous u64 + # field held, so widening is not a behavior change for existing users. + assert wide_decoder.decode_syndrome([1, 0]).observables_mask == 1 + + +def test_repr_reports_wide_masks(wide_decoder: DemAwareDecoder) -> None: + text = repr(wide_decoder.decode_syndrome([0, 1])) + + assert "DemAwareResult(" in text + assert str(_WIDE_OBSERVABLE) in text diff --git a/python/quantum-pecos/tests/qec/test_guppy_dem_build.py b/python/quantum-pecos/tests/qec/test_guppy_dem_build.py index 6a03bac08..1de4495a3 100644 --- a/python/quantum-pecos/tests/qec/test_guppy_dem_build.py +++ b/python/quantum-pecos/tests/qec/test_guppy_dem_build.py @@ -164,7 +164,7 @@ def test_tag_strings_mix_with_rec_and_result_ref_refs() -> None: def test_tag_string_shorthand_rejects_empty_and_wrong_types() -> None: with pytest.raises(ValueError, match="non-empty string"): Detector("") - with pytest.raises(TypeError, match="rec\\[...\\], result_ref"): + with pytest.raises(TypeError, match="measurement references must be"): Detector(3.5) with pytest.raises(ValueError, match="at least one measurement"): Observable() From ba6fa1c267827f3097b9f60db8b80639c5ac83e8 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 20:42:40 -0600 Subject: [PATCH 18/62] Reject idle-noise parameter combinations the noise config would silently resolve --- .../src/fault_tolerance_bindings.rs | 26 ++++++ .../tests/qec/test_dem_aware_decoder_width.py | 2 +- .../tests/qec/test_noise_option_conflicts.py | 81 +++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 python/quantum-pecos/tests/qec/test_noise_option_conflicts.py diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index d6e89a996..6bc91a219 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -346,6 +346,32 @@ fn apply_noise_options( p2_gate_rates: Option>, p1_gate_rates: Option>, ) -> PyResult { + // Reject the base-idle-channel combinations this function would otherwise + // resolve silently: `set_t1_t2` makes T1/T2 the base channel that shadows + // `p_idle`, and `set_idle_rz` zeroes `p_idle` and overwrites T1/T2 with a + // synthetic T2. Each combination discards a caller-supplied rate without + // any signal (issue #426). + if p_idle.is_some() && (t1.is_some() || t2.is_some()) { + return Err(PyErr::new::( + "p_idle cannot be combined with t1/t2; the T1/T2 channel replaces the \ + depolarizing base idle channel, so p_idle would be ignored", + )); + } + if idle_rz.is_some() { + if p_idle.is_some() { + return Err(PyErr::new::( + "idle_rz cannot be combined with p_idle; the coherent RZ conversion \ + replaces the base idle channel, so p_idle would be ignored", + )); + } + if t1.is_some() || t2.is_some() { + return Err(PyErr::new::( + "idle_rz cannot be combined with t1/t2; the coherent RZ conversion \ + overwrites the T1/T2 channel with an equivalent T2", + )); + } + } + noise.p_idle = p_idle.unwrap_or(0.0); if let (Some(t1_val), Some(t2_val)) = (t1, t2) { noise = noise.set_t1_t2(t1_val, t2_val); diff --git a/python/quantum-pecos/tests/qec/test_dem_aware_decoder_width.py b/python/quantum-pecos/tests/qec/test_dem_aware_decoder_width.py index 73b7985c7..43fc14c5d 100644 --- a/python/quantum-pecos/tests/qec/test_dem_aware_decoder_width.py +++ b/python/quantum-pecos/tests/qec/test_dem_aware_decoder_width.py @@ -22,7 +22,7 @@ def _wide_dem(num_observables: int = _WIDE_OBSERVABLE + 1) -> str: - lines = [f"error(0.1) D0 L0", f"error(0.1) D1 L{_WIDE_OBSERVABLE}"] + lines = ["error(0.1) D0 L0", f"error(0.1) D1 L{_WIDE_OBSERVABLE}"] lines += [f"detector D{index}" for index in range(2)] lines += [f"logical_observable L{index}" for index in range(num_observables)] return "\n".join(lines) diff --git a/python/quantum-pecos/tests/qec/test_noise_option_conflicts.py b/python/quantum-pecos/tests/qec/test_noise_option_conflicts.py new file mode 100644 index 000000000..47774e42c --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_noise_option_conflicts.py @@ -0,0 +1,81 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License.You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Base-idle-channel combinations must fail loud, not resolve silently (issue #426). + +`set_t1_t2` makes T1/T2 the base channel that shadows ``p_idle``, and +`set_idle_rz` zeroes ``p_idle`` and overwrites T1/T2. Each combination used to +discard a caller-supplied rate with no signal. +""" + +from __future__ import annotations + +import pytest +from pecos.quantum import TickCircuit +from pecos.qec import DemSampler, DetectorErrorModel + +_GATE_NOISE = {"p1": 0.001, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} + + +def _circuit() -> TickCircuit: + circuit = TickCircuit() + circuit.tick().pz([0]) + circuit.tick().idle(1, [0]) + circuit.tick().mz_with_ids([0], [0]) + circuit.set_meta("num_measurements", "1") + circuit.set_meta("detectors", '[{"id": 0, "records": [-1]}]') + return circuit + + +@pytest.mark.parametrize( + ("conflict", "message"), + [ + pytest.param( + {"p_idle": 0.01, "t1": 100.0, "t2": 50.0}, + "T1/T2 channel replaces", + id="p_idle-with-t1t2", + ), + pytest.param( + {"idle_rz": 0.01, "p_idle": 0.01}, + "coherent RZ conversion replaces", + id="idle_rz-with-p_idle", + ), + pytest.param( + {"idle_rz": 0.01, "t1": 100.0, "t2": 50.0}, + "overwrites the T1/T2 channel", + id="idle_rz-with-t1t2", + ), + ], +) +def test_from_circuit_rejects_shadowed_idle_channels(conflict: dict[str, float], message: str) -> None: + with pytest.raises(ValueError, match=message): + DetectorErrorModel.from_circuit(_circuit(), **conflict, **_GATE_NOISE) + + +def test_dem_sampler_shares_the_same_guard() -> None: + # The guard lives in the shared noise-option helper, so every ingest path + # is protected -- not only DetectorErrorModel.from_circuit. + with pytest.raises(ValueError, match="T1/T2 channel replaces"): + DemSampler.from_circuit(_circuit(), p_idle=0.01, t1=100.0, t2=50.0, **_GATE_NOISE) + + +@pytest.mark.parametrize( + "idle_noise", + [ + pytest.param({"p_idle": 0.01}, id="p_idle-alone"), + pytest.param({"t1": 100.0, "t2": 50.0}, id="t1t2-alone"), + pytest.param({"idle_rz": 0.01}, id="idle_rz-alone"), + ], +) +def test_each_base_idle_channel_alone_still_builds(idle_noise: dict[str, float]) -> None: + dem = DetectorErrorModel.from_circuit(_circuit(), **idle_noise, **_GATE_NOISE) + + assert dem.num_detectors == 1 From 13eb92c6863db1bb459cf6574792a51a2b7e2841 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 21:14:34 -0600 Subject: [PATCH 19/62] Close decoder surface defects: FusionBlossom DEM constructor, DemAwareResult export, registry reconciliation, stub refresh --- python/pecos-rslib/pecos_rslib.pyi | 68 ++++++++++++-- python/pecos-rslib/src/decoder_bindings.rs | 25 +++++ .../src/fault_tolerance_bindings.rs | 18 +++- .../src/pecos/decoders/__init__.py | 2 + .../tests/qec/test_decoder_surface_defects.py | 93 +++++++++++++++++++ .../tests/qec/test_noise_option_conflicts.py | 2 +- 6 files changed, 196 insertions(+), 12 deletions(-) create mode 100644 python/quantum-pecos/tests/qec/test_decoder_surface_defects.py diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index f5a792e9f..5a9e56dda 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -2087,12 +2087,22 @@ class decoders: class PyMatchingDecoder: """PyMatching MWPM decoder.""" - def __init__( - self, - check_matrix: decoders.CheckMatrix, - weights: list[float] | None = ..., - ) -> None: ... + def __init__(self, num_nodes: int, num_observables: int = ...) -> None: ... + @staticmethod + def from_dem(dem: str) -> decoders.PyMatchingDecoder: ... + @staticmethod + def from_dem_with_correlations( + dem: str, + enable_correlations: bool = ..., + ) -> decoders.PyMatchingDecoder: ... + @staticmethod + def from_check_matrix(check_matrix: decoders.CheckMatrix) -> decoders.PyMatchingDecoder: ... def decode(self, syndrome: list[int]) -> decoders.MwpmResult: ... + def decode_batch( + self, + detection_events: list[list[int]], + num_shots: int, + ) -> list[list[int]]: ... def __repr__(self) -> str: ... class FusionBlossomDecoder: @@ -2250,16 +2260,56 @@ class decoders: """Result from Tesseract decoder.""" @property - def correction(self) -> list[int]: ... + def observables_mask(self) -> int: ... @property - def weight(self) -> float: ... + def cost(self) -> float: ... + @property + def low_confidence(self) -> bool: ... + def observable_bits(self, num_observables: int) -> list[int]: ... def __repr__(self) -> str: ... class TesseractDecoder: """Tesseract decoder.""" - def __init__(self, dem_string: str) -> None: ... - def decode(self, syndrome: list[int]) -> decoders.TesseractResult: ... + @staticmethod + def from_dem( + dem: str, + preset: str = ..., + det_beam: int | None = ..., + beam_climbing: bool | None = ..., + verbose: bool = ..., + ) -> decoders.TesseractDecoder: ... + def decode(self, detections: list[int]) -> decoders.TesseractResult: ... + def decode_syndrome(self, syndrome: list[int]) -> decoders.TesseractResult: ... + def decode_batch( + self, + syndromes: list[list[int]], + num_workers: int | None = ..., + ) -> list[decoders.TesseractResult]: ... + def __repr__(self) -> str: ... + + class DemAwareResult: + """Result from a DEM-aware decoder.""" + + @property + def observables_mask(self) -> int: ... + @property + def converged(self) -> bool: ... + @property + def iterations(self) -> int: ... + def __repr__(self) -> str: ... + + class DemAwareDecoder: + """DEM-aware wrapper over a check-matrix decoder.""" + + @staticmethod + def from_dem( + dem: str, + decoder_type: str = ..., + error_rate: float | None = ..., + max_iter: int = ..., + ) -> decoders.DemAwareDecoder: ... + def decode_syndrome(self, syndrome: list[int]) -> decoders.DemAwareResult: ... def __repr__(self) -> str: ... class RelayBpBuilder: diff --git a/python/pecos-rslib/src/decoder_bindings.rs b/python/pecos-rslib/src/decoder_bindings.rs index 93a466ca3..656d989a2 100644 --- a/python/pecos-rslib/src/decoder_bindings.rs +++ b/python/pecos-rslib/src/decoder_bindings.rs @@ -702,6 +702,31 @@ impl PyFusionBlossomDecoder { /// H = [[1, 1, 0], [0, 1, 1]] /// decoder = FusionBlossomDecoder.from_check_matrix(H) /// ``` + /// Create a decoder from a Detector Error Model. + /// + /// # Arguments + /// + /// * `dem` - Detector error model string in Stim format + /// * `correlated` - Exploit X-Z correlations from decomposed mechanisms + /// + /// # Example + /// + /// ```python + /// decoder = FusionBlossomDecoder.from_dem(dem_string) + /// ``` + #[staticmethod] + #[pyo3(signature = (dem, correlated=false))] + fn from_dem(dem: &str, correlated: bool) -> PyResult { + let inner = if correlated { + RustFusionBlossomDecoder::from_dem_correlated(dem) + } else { + RustFusionBlossomDecoder::from_dem(dem) + }; + inner + .map(|inner| Self { inner }) + .map_err(|e| PyErr::new::(e.to_string())) + } + #[staticmethod] #[pyo3(signature = (check_matrix, weights=None, num_observables=None))] fn from_check_matrix( diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 6bc91a219..c84065ad8 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -6927,6 +6927,15 @@ fn mechanisms_to_dem_string(mechanisms: Vec<(f64, Vec, Vec)>) -> Strin #[pyfunction] fn decoder_dem_requirement(decoder_type: &str) -> PyResult { let base = decoder_type.split(':').next().unwrap_or(decoder_type); + // "perturbed" wraps an arbitrary inner decoder ("perturbed:K=15,inner=TYPE"), + // so its requirement is the inner decoder's. `inner=` takes the rest of the + // string, matching how create_observable_decoder parses nested specs. + if base == "perturbed" { + let inner = decoder_type + .split_once("inner=") + .map_or("pymatching", |(_, rest)| rest); + return decoder_dem_requirement(inner); + } match base { "pymatching" | "pymatching_correlated" @@ -6941,9 +6950,14 @@ fn decoder_dem_requirement(decoder_type: &str) -> PyResult { | "k_mwpm" | "perturbed_fb_corr" | "perturbed_fb" + | "beamsearch" + | "belief_matching" + | "belief_matching_correlated" + | "belief_matching_mgbp" + | "belief_matching_hybrid" | "ensemble" => Ok("graphlike".to_string()), - "tesseract" | "astar" | "astar_full" | "bp_osd" | "bp_lsd" | "union_find" - | "min_sum_bp" | "relay_bp" | "mwpf" | "chromobius" => Ok("any".to_string()), + "tesseract" | "astar" | "astar_full" | "bp_osd" | "bp_lsd" | "belief_find" + | "union_find" | "min_sum_bp" | "relay_bp" | "mwpf" | "chromobius" => Ok("any".to_string()), _ => Err(pyo3::exceptions::PyValueError::new_err(format!( "Unknown decoder type: {decoder_type:?}", ))), diff --git a/python/quantum-pecos/src/pecos/decoders/__init__.py b/python/quantum-pecos/src/pecos/decoders/__init__.py index 56119208e..2748fb254 100644 --- a/python/quantum-pecos/src/pecos/decoders/__init__.py +++ b/python/quantum-pecos/src/pecos/decoders/__init__.py @@ -24,6 +24,7 @@ BpResult, CheckMatrix, DemAwareDecoder, + DemAwareResult, FusionBlossomDecoder, MinSumBpBuilder, MinSumBpDecoder, @@ -50,6 +51,7 @@ "BpResult", "CheckMatrix", "DemAwareDecoder", + "DemAwareResult", "DummyDecoder", "FusionBlossomDecoder", "MinSumBpBuilder", diff --git a/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py b/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py new file mode 100644 index 000000000..a0f51820f --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py @@ -0,0 +1,93 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License.You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Decoder surface defects from issue #431. + +Covers the public re-export, the DEM constructor that existed in Rust but was +unreachable from Python, and the drift between the decoder string registry and +the DEM-requirement query. +""" + +from __future__ import annotations + +import pytest +from pecos.decoders import DemAwareResult, FusionBlossomDecoder +from pecos_rslib.qec import decoder_dem_requirement + +_DEM = "\n".join( + [ + "error(0.1) D0 D1 L0", + "error(0.1) D1 L0", + "detector D0", + "detector D1", + "logical_observable L0", + ], +) + + +def test_dem_aware_result_is_importable() -> None: + # Decoding returns this type, so users must be able to name it. + assert DemAwareResult.__name__ == "DemAwareResult" + + +def test_fusion_blossom_builds_from_a_dem() -> None: + assert FusionBlossomDecoder.from_dem(_DEM) is not None + assert FusionBlossomDecoder.from_dem(_DEM, correlated=True) is not None + + +# Every name `create_observable_decoder` accepts must also classify here. Add to +# both places when adding a decoder; the two lists drifted apart before. +@pytest.mark.parametrize( + ("decoder_type", "requirement"), + [ + ("pymatching", "graphlike"), + ("fusion_blossom", "graphlike"), + ("k_mwpm", "graphlike"), + ("windowed", "graphlike"), + ("beamsearch", "graphlike"), + ("belief_matching", "graphlike"), + ("belief_matching_correlated", "graphlike"), + ("belief_matching_mgbp", "graphlike"), + ("belief_matching_hybrid:inner=pymatching", "graphlike"), + ("tesseract", "any"), + ("astar", "any"), + ("bp_osd", "any"), + ("bp_lsd", "any"), + ("belief_find", "any"), + ("union_find", "any"), + ("relay_bp", "any"), + ("min_sum_bp", "any"), + ("mwpf", "any"), + ("pecos_uf:bp", "graphlike"), + ], +) +def test_registry_names_have_a_dem_requirement(decoder_type: str, requirement: str) -> None: + assert decoder_dem_requirement(decoder_type) == requirement + + +@pytest.mark.parametrize( + ("spec", "requirement"), + [ + pytest.param("perturbed", "graphlike", id="default-inner-pymatching"), + pytest.param("perturbed:K=5,inner=pymatching", "graphlike", id="matching-inner"), + pytest.param("perturbed:K=5,inner=tesseract", "any", id="hyperedge-inner"), + pytest.param("perturbed:K=5,inner=bp_osd", "any", id="check-matrix-inner"), + ], +) +def test_perturbed_requirement_follows_its_inner_decoder(spec: str, requirement: str) -> None: + # "perturbed" wraps an arbitrary inner decoder, so a fixed classification + # would be wrong for half its uses. + assert decoder_dem_requirement(spec) == requirement + + +def test_unknown_decoder_still_raises() -> None: + with pytest.raises(ValueError, match="Unknown decoder type"): + decoder_dem_requirement("not_a_decoder") diff --git a/python/quantum-pecos/tests/qec/test_noise_option_conflicts.py b/python/quantum-pecos/tests/qec/test_noise_option_conflicts.py index 47774e42c..3d8791962 100644 --- a/python/quantum-pecos/tests/qec/test_noise_option_conflicts.py +++ b/python/quantum-pecos/tests/qec/test_noise_option_conflicts.py @@ -19,8 +19,8 @@ from __future__ import annotations import pytest -from pecos.quantum import TickCircuit from pecos.qec import DemSampler, DetectorErrorModel +from pecos.quantum import TickCircuit _GATE_NOISE = {"p1": 0.001, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} From b04a10da937a06b0c5257fb0a8a32b49819418ee Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 21:33:38 -0600 Subject: [PATCH 20/62] Share the idle-noise translator between the Guppy and native-surface routes --- .../src/pecos/qec/_idle_noise.py | 237 ++++++++++++++++++ python/quantum-pecos/src/pecos/qec/dem.py | 228 +---------------- .../src/pecos/qec/surface/decode.py | 62 +++++ .../qec/surface/test_idle_noise_families.py | 112 +++++++++ .../tests/qec/test_decoder_surface_defects.py | 14 +- 5 files changed, 417 insertions(+), 236 deletions(-) create mode 100644 python/quantum-pecos/src/pecos/qec/_idle_noise.py create mode 100644 python/quantum-pecos/tests/qec/surface/test_idle_noise_families.py diff --git a/python/quantum-pecos/src/pecos/qec/_idle_noise.py b/python/quantum-pecos/src/pecos/qec/_idle_noise.py new file mode 100644 index 000000000..9017e518b --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/_idle_noise.py @@ -0,0 +1,237 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Shared translation of structured idle-noise families to DEM primitives.""" + +from __future__ import annotations + +import math +import warnings +from collections.abc import Mapping + +_IDLE_MODEL_NORMALIZATION_TOLERANCE = 1.0e-5 +_IDLE_MODEL_FLOAT_EPSILON = 1.0e-10 + + +def _validate_idle_family_model( + *, + rate: float | None, + rate_name: str, + model: Mapping[str, float] | None, + model_name: str, + default_model: Mapping[str, float], + accepted_keys: frozenset[str], + require_normalized: bool, + nonzero_rate_guidance: str | None = None, + zero_only_key_guidance: Mapping[str, str] | None = None, +) -> tuple[float, dict[str, float]] | None: + """Validate one structured idle family and return its rate and multipliers.""" + if model is not None and rate is None: + msg = f"{model_name} requires {rate_name}; otherwise the model is inert" + raise ValueError(msg) + if rate is None: + return None + + if isinstance(rate, bool): + msg = f"{rate_name} must be a finite, non-negative float" + raise TypeError(msg) + try: + numeric_rate = float(rate) + except (TypeError, ValueError) as exc: + msg = f"{rate_name} must be a finite, non-negative float" + raise ValueError(msg) from exc + if not math.isfinite(numeric_rate) or numeric_rate < 0.0: + msg = f"{rate_name} must be a finite, non-negative float" + raise ValueError(msg) + if numeric_rate != 0.0 and nonzero_rate_guidance is not None: + raise ValueError(nonzero_rate_guidance) + + if model is not None and not isinstance(model, Mapping): + expected = ", ".join(repr(key) for key in sorted(accepted_keys)) + msg = f"{model_name} must be a mapping from {expected} to relative-rate multipliers" + raise ValueError(msg) + selected_model = model if model is not None else default_model + validated_model: dict[str, float] = {} + for key, multiplier in selected_model.items(): + if key not in accepted_keys: + expected = ", ".join(repr(valid_key) for valid_key in sorted(accepted_keys)) + msg = f"invalid {model_name} key {key!r}; expected {expected}" + raise ValueError(msg) + try: + numeric_multiplier = float(multiplier) + except (TypeError, ValueError) as exc: + msg = f"{model_name} multiplier for {key!r} must be a finite, non-negative float" + raise ValueError(msg) from exc + if not math.isfinite(numeric_multiplier) or numeric_multiplier < 0.0: + msg = f"{model_name} multiplier for {key!r} must be a finite, non-negative float" + raise ValueError(msg) + validated_model[key] = numeric_multiplier + + if require_normalized: + total_multiplier = sum(validated_model.values()) + if total_multiplier <= 0.0 or abs(total_multiplier - 1.0) > _IDLE_MODEL_NORMALIZATION_TOLERANCE: + msg = ( + f"{model_name} multipliers must sum to 1.0 within tolerance " + f"{_IDLE_MODEL_NORMALIZATION_TOLERANCE:g}; got {total_multiplier}" + ) + raise ValueError(msg) + if abs(total_multiplier - 1.0) > _IDLE_MODEL_FLOAT_EPSILON: + validated_model = {key: multiplier / total_multiplier for key, multiplier in validated_model.items()} + + for key, guidance in (zero_only_key_guidance or {}).items(): + if validated_model.get(key, 0.0) != 0.0: + msg = f"{model_name} key {key!r} has a nonzero multiplier; {guidance}" + raise ValueError(msg) + + return numeric_rate, validated_model + + +def _translate_structured_idle_noise( + *, + p_idle_linear: float | None, + p_idle_linear_model: Mapping[str, float] | None, + p_idle_sin_squared: float | None, + p_idle_sin_squared_model: Mapping[str, float] | None, + p_idle_coherent: float | None, + p_idle_coherent_model: Mapping[str, float] | None, + p_idle_linear_rate: float | None, + p_idle_quadratic_rate: float | None, + p_idle_x_linear_rate: float | None, + p_idle_y_linear_rate: float | None, + p_idle_z_linear_rate: float | None, + p_idle_quadratic_sine_rate: float | None, + p_idle_x_quadratic_sine_rate: float | None, + p_idle_y_quadratic_sine_rate: float | None, + p_idle_z_quadratic_sine_rate: float | None, +) -> tuple[ + float | None, + float | None, + float | None, + float | None, + float | None, + float | None, +]: + """Validate and translate engines-style idle noise to DEM primitives.""" + _validate_idle_family_model( + rate=p_idle_coherent, + rate_name="p_idle_coherent", + model=p_idle_coherent_model, + model_name="p_idle_coherent_model", + default_model={"RX": 1.0, "RY": 1.0, "RZ": 1.0}, + accepted_keys=frozenset({"RX", "RY", "RZ"}), + require_normalized=False, + nonzero_rate_guidance=( + "the standard DEM builder cannot represent coherent idle noise; its previous behavior silently stored " + "the Pauli twirl, discarding exactly the coherence that was requested. The EEG coherent route in " + "exp/pecos-eeg is the consumer that can represent it, and only with an RZ generator even there. The " + "honest stochastic equivalent, which is the exact Pauli twirl of RZ(rate * t), is " + "p_idle_sin_squared=rate/2 with p_idle_sin_squared_model={'Z': 1.0}" + ), + ) + + linear_primitives = { + "p_idle_linear_rate": p_idle_linear_rate, + "p_idle_x_linear_rate": p_idle_x_linear_rate, + "p_idle_y_linear_rate": p_idle_y_linear_rate, + "p_idle_z_linear_rate": p_idle_z_linear_rate, + } + if (p_idle_linear is not None or p_idle_linear_model is not None) and any( + value is not None for value in linear_primitives.values() + ): + conflicts = ", ".join(name for name, value in linear_primitives.items() if value is not None) + msg = f"p_idle_linear/p_idle_linear_model cannot be combined with low-level idle rate(s): {conflicts}" + raise ValueError(msg) + sine_primitives = { + "p_idle_quadratic_sine_rate": p_idle_quadratic_sine_rate, + "p_idle_x_quadratic_sine_rate": p_idle_x_quadratic_sine_rate, + "p_idle_y_quadratic_sine_rate": p_idle_y_quadratic_sine_rate, + "p_idle_z_quadratic_sine_rate": p_idle_z_quadratic_sine_rate, + } + if (p_idle_sin_squared is not None or p_idle_sin_squared_model is not None) and any( + value is not None for value in sine_primitives.values() + ): + conflicts = ", ".join(name for name, value in sine_primitives.items() if value is not None) + msg = f"p_idle_sin_squared/p_idle_sin_squared_model cannot be combined with sine-law idle rate(s): {conflicts}" + raise ValueError(msg) + + legacy_replacements = { + "p_idle_linear_rate": ( + p_idle_linear_rate, + ( + "p_idle_linear with p_idle_linear_model={'Z': 1.0} for the engines-consistent interface, " + "or p_idle_z_linear_rate for literal Z-only behavior" + ), + ), + "p_idle_quadratic_rate": ( + p_idle_quadratic_rate, + ( + "p_idle_sin_squared for the engines-consistent dephasing interface, " + "or p_idle_z_quadratic_rate for literal coefficient-style Z-only behavior" + ), + ), + "p_idle_quadratic_sine_rate": ( + p_idle_quadratic_sine_rate, + ( + "p_idle_sin_squared for the engines-consistent sine-law interface, " + "or p_idle_z_quadratic_sine_rate for literal Z-only behavior" + ), + ), + } + for name, (value, replacement) in legacy_replacements.items(): + if value is not None: + warnings.warn( + f"{name} is deprecated; use {replacement}", + DeprecationWarning, + stacklevel=3, + ) + + linear_family = _validate_idle_family_model( + rate=p_idle_linear, + rate_name="p_idle_linear", + model=p_idle_linear_model, + model_name="p_idle_linear_model", + default_model={"X": 1.0 / 3.0, "Y": 1.0 / 3.0, "Z": 1.0 / 3.0}, + accepted_keys=frozenset({"X", "Y", "Z", "L"}), + require_normalized=True, + zero_only_key_guidance={ + "L": "DEM fault propagation is Pauli-only; the engines simulators support and consume leakage models", + }, + ) + if linear_family is not None: + linear_rate, linear_model = linear_family + p_idle_x_linear_rate = linear_rate * linear_model.get("X", 0.0) + p_idle_y_linear_rate = linear_rate * linear_model.get("Y", 0.0) + p_idle_z_linear_rate = linear_rate * linear_model.get("Z", 0.0) + + sin_squared_family = _validate_idle_family_model( + rate=p_idle_sin_squared, + rate_name="p_idle_sin_squared", + model=p_idle_sin_squared_model, + model_name="p_idle_sin_squared_model", + default_model={"X": 1.0, "Y": 1.0, "Z": 1.0}, + accepted_keys=frozenset({"X", "Y", "Z", "L"}), + require_normalized=False, + zero_only_key_guidance={ + "L": "DEM fault propagation is Pauli-only; the engines simulators support and consume leakage models", + }, + ) + if sin_squared_family is not None: + sin_squared_rate, sin_squared_model = sin_squared_family + p_idle_x_quadratic_sine_rate = ( + sin_squared_rate * sin_squared_model["X"] if sin_squared_model.get("X", 0.0) != 0.0 else None + ) + p_idle_y_quadratic_sine_rate = ( + sin_squared_rate * sin_squared_model["Y"] if sin_squared_model.get("Y", 0.0) != 0.0 else None + ) + p_idle_z_quadratic_sine_rate = ( + sin_squared_rate * sin_squared_model["Z"] if sin_squared_model.get("Z", 0.0) != 0.0 else None + ) + + return ( + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, + ) diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 8cdb4eb23..3e9e4d999 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -39,7 +39,6 @@ import hashlib import json import math -import warnings from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any @@ -49,6 +48,7 @@ measurement_ids_in_execution_order, normalize_traced_tick_circuit, ) +from pecos.qec._idle_noise import _translate_structured_idle_noise from pecos.qec.dem_spec import GuppyDemBuild, ResultRef, _resolve_dem_specs if TYPE_CHECKING: @@ -58,232 +58,6 @@ P2Weights = Mapping[str, float] _GENERATOR_LAYOUT_ATTR = "__pecos_named_measurement_layout_v2__" -_IDLE_MODEL_NORMALIZATION_TOLERANCE = 1.0e-5 -_IDLE_MODEL_FLOAT_EPSILON = 1.0e-10 - - -def _validate_idle_family_model( - *, - rate: float | None, - rate_name: str, - model: Mapping[str, float] | None, - model_name: str, - default_model: Mapping[str, float], - accepted_keys: frozenset[str], - require_normalized: bool, - nonzero_rate_guidance: str | None = None, - zero_only_key_guidance: Mapping[str, str] | None = None, -) -> tuple[float, dict[str, float]] | None: - """Validate one structured idle family and return its rate and multipliers.""" - if model is not None and rate is None: - msg = f"{model_name} requires {rate_name}; otherwise the model is inert" - raise ValueError(msg) - if rate is None: - return None - - if isinstance(rate, bool): - msg = f"{rate_name} must be a finite, non-negative float" - raise TypeError(msg) - try: - numeric_rate = float(rate) - except (TypeError, ValueError) as exc: - msg = f"{rate_name} must be a finite, non-negative float" - raise ValueError(msg) from exc - if not math.isfinite(numeric_rate) or numeric_rate < 0.0: - msg = f"{rate_name} must be a finite, non-negative float" - raise ValueError(msg) - if numeric_rate != 0.0 and nonzero_rate_guidance is not None: - raise ValueError(nonzero_rate_guidance) - - if model is not None and not isinstance(model, Mapping): - expected = ", ".join(repr(key) for key in sorted(accepted_keys)) - msg = f"{model_name} must be a mapping from {expected} to relative-rate multipliers" - raise ValueError(msg) - selected_model = model if model is not None else default_model - validated_model: dict[str, float] = {} - for key, multiplier in selected_model.items(): - if key not in accepted_keys: - expected = ", ".join(repr(valid_key) for valid_key in sorted(accepted_keys)) - msg = f"invalid {model_name} key {key!r}; expected {expected}" - raise ValueError(msg) - try: - numeric_multiplier = float(multiplier) - except (TypeError, ValueError) as exc: - msg = f"{model_name} multiplier for {key!r} must be a finite, non-negative float" - raise ValueError(msg) from exc - if not math.isfinite(numeric_multiplier) or numeric_multiplier < 0.0: - msg = f"{model_name} multiplier for {key!r} must be a finite, non-negative float" - raise ValueError(msg) - validated_model[key] = numeric_multiplier - - if require_normalized: - total_multiplier = sum(validated_model.values()) - if total_multiplier <= 0.0 or abs(total_multiplier - 1.0) > _IDLE_MODEL_NORMALIZATION_TOLERANCE: - msg = ( - f"{model_name} multipliers must sum to 1.0 within tolerance " - f"{_IDLE_MODEL_NORMALIZATION_TOLERANCE:g}; got {total_multiplier}" - ) - raise ValueError(msg) - if abs(total_multiplier - 1.0) > _IDLE_MODEL_FLOAT_EPSILON: - validated_model = {key: multiplier / total_multiplier for key, multiplier in validated_model.items()} - - for key, guidance in (zero_only_key_guidance or {}).items(): - if validated_model.get(key, 0.0) != 0.0: - msg = f"{model_name} key {key!r} has a nonzero multiplier; {guidance}" - raise ValueError(msg) - - return numeric_rate, validated_model - - -def _translate_structured_idle_noise( - *, - p_idle_linear: float | None, - p_idle_linear_model: Mapping[str, float] | None, - p_idle_sin_squared: float | None, - p_idle_sin_squared_model: Mapping[str, float] | None, - p_idle_coherent: float | None, - p_idle_coherent_model: Mapping[str, float] | None, - p_idle_linear_rate: float | None, - p_idle_quadratic_rate: float | None, - p_idle_x_linear_rate: float | None, - p_idle_y_linear_rate: float | None, - p_idle_z_linear_rate: float | None, - p_idle_quadratic_sine_rate: float | None, - p_idle_x_quadratic_sine_rate: float | None, - p_idle_y_quadratic_sine_rate: float | None, - p_idle_z_quadratic_sine_rate: float | None, -) -> tuple[ - float | None, - float | None, - float | None, - float | None, - float | None, - float | None, -]: - """Validate and translate engines-style idle noise to DEM primitives.""" - _validate_idle_family_model( - rate=p_idle_coherent, - rate_name="p_idle_coherent", - model=p_idle_coherent_model, - model_name="p_idle_coherent_model", - default_model={"RX": 1.0, "RY": 1.0, "RZ": 1.0}, - accepted_keys=frozenset({"RX", "RY", "RZ"}), - require_normalized=False, - nonzero_rate_guidance=( - "the standard DEM builder cannot represent coherent idle noise; its previous behavior silently stored " - "the Pauli twirl, discarding exactly the coherence that was requested. The EEG coherent route in " - "exp/pecos-eeg is the consumer that can represent it, and only with an RZ generator even there. The " - "honest stochastic equivalent, which is the exact Pauli twirl of RZ(rate * t), is " - "p_idle_sin_squared=rate/2 with p_idle_sin_squared_model={'Z': 1.0}" - ), - ) - - linear_primitives = { - "p_idle_linear_rate": p_idle_linear_rate, - "p_idle_x_linear_rate": p_idle_x_linear_rate, - "p_idle_y_linear_rate": p_idle_y_linear_rate, - "p_idle_z_linear_rate": p_idle_z_linear_rate, - } - if (p_idle_linear is not None or p_idle_linear_model is not None) and any( - value is not None for value in linear_primitives.values() - ): - conflicts = ", ".join(name for name, value in linear_primitives.items() if value is not None) - msg = f"p_idle_linear/p_idle_linear_model cannot be combined with low-level idle rate(s): {conflicts}" - raise ValueError(msg) - sine_primitives = { - "p_idle_quadratic_sine_rate": p_idle_quadratic_sine_rate, - "p_idle_x_quadratic_sine_rate": p_idle_x_quadratic_sine_rate, - "p_idle_y_quadratic_sine_rate": p_idle_y_quadratic_sine_rate, - "p_idle_z_quadratic_sine_rate": p_idle_z_quadratic_sine_rate, - } - if (p_idle_sin_squared is not None or p_idle_sin_squared_model is not None) and any( - value is not None for value in sine_primitives.values() - ): - conflicts = ", ".join(name for name, value in sine_primitives.items() if value is not None) - msg = f"p_idle_sin_squared/p_idle_sin_squared_model cannot be combined with sine-law idle rate(s): {conflicts}" - raise ValueError(msg) - - legacy_replacements = { - "p_idle_linear_rate": ( - p_idle_linear_rate, - ( - "p_idle_linear with p_idle_linear_model={'Z': 1.0} for the engines-consistent interface, " - "or p_idle_z_linear_rate for literal Z-only behavior" - ), - ), - "p_idle_quadratic_rate": ( - p_idle_quadratic_rate, - ( - "p_idle_sin_squared for the engines-consistent dephasing interface, " - "or p_idle_z_quadratic_rate for literal coefficient-style Z-only behavior" - ), - ), - "p_idle_quadratic_sine_rate": ( - p_idle_quadratic_sine_rate, - ( - "p_idle_sin_squared for the engines-consistent sine-law interface, " - "or p_idle_z_quadratic_sine_rate for literal Z-only behavior" - ), - ), - } - for name, (value, replacement) in legacy_replacements.items(): - if value is not None: - warnings.warn( - f"{name} is deprecated; use {replacement}", - DeprecationWarning, - stacklevel=3, - ) - - linear_family = _validate_idle_family_model( - rate=p_idle_linear, - rate_name="p_idle_linear", - model=p_idle_linear_model, - model_name="p_idle_linear_model", - default_model={"X": 1.0 / 3.0, "Y": 1.0 / 3.0, "Z": 1.0 / 3.0}, - accepted_keys=frozenset({"X", "Y", "Z", "L"}), - require_normalized=True, - zero_only_key_guidance={ - "L": "DEM fault propagation is Pauli-only; the engines simulators support and consume leakage models", - }, - ) - if linear_family is not None: - linear_rate, linear_model = linear_family - p_idle_x_linear_rate = linear_rate * linear_model.get("X", 0.0) - p_idle_y_linear_rate = linear_rate * linear_model.get("Y", 0.0) - p_idle_z_linear_rate = linear_rate * linear_model.get("Z", 0.0) - - sin_squared_family = _validate_idle_family_model( - rate=p_idle_sin_squared, - rate_name="p_idle_sin_squared", - model=p_idle_sin_squared_model, - model_name="p_idle_sin_squared_model", - default_model={"X": 1.0, "Y": 1.0, "Z": 1.0}, - accepted_keys=frozenset({"X", "Y", "Z", "L"}), - require_normalized=False, - zero_only_key_guidance={ - "L": "DEM fault propagation is Pauli-only; the engines simulators support and consume leakage models", - }, - ) - if sin_squared_family is not None: - sin_squared_rate, sin_squared_model = sin_squared_family - p_idle_x_quadratic_sine_rate = ( - sin_squared_rate * sin_squared_model["X"] if sin_squared_model.get("X", 0.0) != 0.0 else None - ) - p_idle_y_quadratic_sine_rate = ( - sin_squared_rate * sin_squared_model["Y"] if sin_squared_model.get("Y", 0.0) != 0.0 else None - ) - p_idle_z_quadratic_sine_rate = ( - sin_squared_rate * sin_squared_model["Z"] if sin_squared_model.get("Z", 0.0) != 0.0 else None - ) - - return ( - p_idle_x_linear_rate, - p_idle_y_linear_rate, - p_idle_z_linear_rate, - p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate, - ) def _certifiable_hugr_bytes(guppy: Any) -> bytes | None: diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 482d09752..e9322106d 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -57,6 +57,7 @@ measurement_ids_in_execution_order, normalize_traced_tick_circuit, ) +from pecos.qec._idle_noise import _translate_structured_idle_noise from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan if TYPE_CHECKING: @@ -174,6 +175,28 @@ class NoiseModel: p_idle: Idle noise rate per time unit (uniform depolarizing). t1: T1 relaxation time for idle noise (same units as idle duration). t2: T2 dephasing time (must satisfy t2 <= 2*t1). + p_idle_linear: Optional total stochastic idle-noise rate linear in idle + duration. By default, the total rate is split equally over X, Y, + and Z errors. + p_idle_linear_model: Optional relative weights over ``"X"``, ``"Y"``, + ``"Z"``, and ``"L"`` for ``p_idle_linear``. Weights must be finite, + non-negative, and sum to 1.0; ``"L"`` must have zero weight because + DEM fault propagation is Pauli-only. + p_idle_sin_squared: Optional stochastic sine-law idle rate. An axis + multiplier ``m`` produces probability + ``sin((p_idle_sin_squared * m) * duration)^2``. By default X, Y, + and Z each use multiplier 1.0. + p_idle_sin_squared_model: Optional relative-rate multipliers over + ``"X"``, ``"Y"``, ``"Z"``, and ``"L"`` for + ``p_idle_sin_squared``. Values must be finite and non-negative; + ``"L"`` must have zero weight because DEM fault propagation is + Pauli-only. + p_idle_coherent: Optional coherent-rotation rate. The standard DEM + route rejects nonzero coherent idle noise because it cannot + represent coherence. Zero has no effect. + p_idle_coherent_model: Optional relative-rate multipliers over ``"RX"``, + ``"RY"``, and ``"RZ"`` for ``p_idle_coherent``. Values must be + finite and non-negative. p_idle_linear_rate: Legacy alias for stochastic Z-memory rate linear in idle duration. p_idle_quadratic_rate: Legacy alias for stochastic Z-memory rate quadratic in idle duration. p_idle_x_linear_rate: Stochastic X-memory rate linear in idle duration. @@ -188,6 +211,9 @@ class NoiseModel: p_idle_y_quadratic_sine_rate: Stochastic Y-memory sine-law rate. p_idle_z_quadratic_sine_rate: Stochastic Z-memory sine-law rate. + Structured family inputs are normalized to the corresponding per-axis + fields during construction; the family fields are then cleared. + Runtime idle units: For ``traced_qis`` DEMs, runtime idles are replayed as nanosecond ``TimeUnits``; use :meth:`for_runtime_idle_time_units` when carrying @@ -218,6 +244,12 @@ class NoiseModel: p_idle_x_quadratic_sine_rate: float | None = None p_idle_y_quadratic_sine_rate: float | None = None p_idle_z_quadratic_sine_rate: float | None = None + p_idle_linear: float | None = None + p_idle_linear_model: Mapping[str, float] | None = None + p_idle_sin_squared: float | None = None + p_idle_sin_squared_model: Mapping[str, float] | None = None + p_idle_coherent: float | None = None + p_idle_coherent_model: Mapping[str, float] | None = None def __post_init__(self) -> None: """Normalize cache-sensitive inputs after dataclass initialization.""" @@ -227,6 +259,36 @@ def __post_init__(self) -> None: self.p2_szz = _validate_probability("p2_szz", self.p2_szz) if self.p2_szzdg is not None: self.p2_szzdg = _validate_probability("p2_szzdg", self.p2_szzdg) + ( + self.p_idle_x_linear_rate, + self.p_idle_y_linear_rate, + self.p_idle_z_linear_rate, + self.p_idle_x_quadratic_sine_rate, + self.p_idle_y_quadratic_sine_rate, + self.p_idle_z_quadratic_sine_rate, + ) = _translate_structured_idle_noise( + p_idle_linear=self.p_idle_linear, + p_idle_linear_model=self.p_idle_linear_model, + p_idle_sin_squared=self.p_idle_sin_squared, + p_idle_sin_squared_model=self.p_idle_sin_squared_model, + p_idle_coherent=self.p_idle_coherent, + p_idle_coherent_model=self.p_idle_coherent_model, + p_idle_linear_rate=self.p_idle_linear_rate, + p_idle_quadratic_rate=self.p_idle_quadratic_rate, + p_idle_x_linear_rate=self.p_idle_x_linear_rate, + p_idle_y_linear_rate=self.p_idle_y_linear_rate, + p_idle_z_linear_rate=self.p_idle_z_linear_rate, + p_idle_quadratic_sine_rate=self.p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=self.p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=self.p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=self.p_idle_z_quadratic_sine_rate, + ) + self.p_idle_linear = None + self.p_idle_linear_model = None + self.p_idle_sin_squared = None + self.p_idle_sin_squared_model = None + self.p_idle_coherent = None + self.p_idle_coherent_model = None @property def effective_p_idle_z_linear_rate(self) -> float | None: diff --git a/python/quantum-pecos/tests/qec/surface/test_idle_noise_families.py b/python/quantum-pecos/tests/qec/surface/test_idle_noise_families.py new file mode 100644 index 000000000..edf74b93e --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_idle_noise_families.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import pytest +from pecos.qec.surface import NoiseModel, SurfacePatch, TwirlConfig +from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder + + +def _native_surface_dem(noise: NoiseModel) -> bytes: + patch = SurfacePatch.create(distance=3) + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + basis="Z", + decompose_errors=True, + twirl=TwirlConfig(), + ) + return dem.encode() + + +def test_linear_family_matches_per_axis_native_surface_dem() -> None: + rate = 0.003 + + structured = _native_surface_dem(NoiseModel(p_idle_linear=rate)) + primitive = _native_surface_dem( + NoiseModel( + p_idle_x_linear_rate=rate / 3.0, + p_idle_y_linear_rate=rate / 3.0, + p_idle_z_linear_rate=rate / 3.0, + ), + ) + + assert structured == primitive + + +def test_sin_squared_family_matches_per_axis_native_surface_dem() -> None: + rate = 0.03 + + structured = _native_surface_dem( + NoiseModel( + p_idle_sin_squared=rate, + p_idle_sin_squared_model={"Z": 1.0}, + ), + ) + primitive = _native_surface_dem(NoiseModel(p_idle_z_quadratic_sine_rate=rate)) + + assert structured == primitive + + +def test_structured_families_survive_runtime_idle_unit_conversion() -> None: + noise = NoiseModel( + p_idle_linear=0.3, + p_idle_sin_squared=0.2, + p_idle_sin_squared_model={"Z": 1.0}, + ) + + converted = noise.for_runtime_idle_time_units(time_units_per_second=10.0) + + assert converted.p_idle_x_linear_rate == pytest.approx(0.01) + assert converted.p_idle_y_linear_rate == pytest.approx(0.01) + assert converted.p_idle_z_linear_rate == pytest.approx(0.01) + assert converted.p_idle_x_quadratic_sine_rate is None + assert converted.p_idle_y_quadratic_sine_rate is None + assert converted.p_idle_z_quadratic_sine_rate == pytest.approx(0.02) + assert converted.p_idle_linear is None + assert converted.p_idle_linear_model is None + assert converted.p_idle_sin_squared is None + assert converted.p_idle_sin_squared_model is None + assert converted.p_idle_coherent is None + assert converted.p_idle_coherent_model is None + + +@pytest.mark.parametrize( + "kwargs", + [ + {"p_idle_linear": 0.01, "p_idle_x_linear_rate": 0.02}, + {"p_idle_sin_squared": 0.01, "p_idle_y_quadratic_sine_rate": 0.02}, + ], +) +def test_structured_family_conflicts_with_corresponding_primitive(kwargs: dict[str, object]) -> None: + with pytest.raises(ValueError, match="cannot be combined"): + NoiseModel(**kwargs) + + +@pytest.mark.parametrize( + "field", + [ + "p_idle_linear_rate", + "p_idle_quadratic_rate", + "p_idle_quadratic_sine_rate", + ], +) +def test_bare_z_only_alias_warns_through_noise_model(field: str) -> None: + with pytest.warns(DeprecationWarning, match=field): + NoiseModel(**{field: 0.01}) + + +def test_idle_memory_rates_include_translated_family_values() -> None: + noise = NoiseModel( + p_idle_linear=0.3, + p_idle_sin_squared=0.2, + p_idle_sin_squared_model={"Z": 1.0}, + ) + + assert noise.idle_memory_rates[:3] == pytest.approx((0.1, 0.1, 0.1)) + assert noise.idle_memory_rates[3:8] == (None, None, None, None, None) + assert noise.idle_memory_rates[8] == pytest.approx(0.2) + + +def test_nonzero_coherent_family_is_rejected_by_standard_dem_model() -> None: + with pytest.raises(ValueError, match="cannot represent coherent idle noise"): + NoiseModel(p_idle_coherent=0.01) diff --git a/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py b/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py index a0f51820f..4841ba066 100644 --- a/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py +++ b/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py @@ -22,15 +22,11 @@ from pecos.decoders import DemAwareResult, FusionBlossomDecoder from pecos_rslib.qec import decoder_dem_requirement -_DEM = "\n".join( - [ - "error(0.1) D0 D1 L0", - "error(0.1) D1 L0", - "detector D0", - "detector D1", - "logical_observable L0", - ], -) +_DEM = """error(0.1) D0 D1 L0 +error(0.1) D1 L0 +detector D0 +detector D1 +logical_observable L0""" def test_dem_aware_result_is_importable() -> None: From 321b7e04dfb76e15c59096b2545ec5a34b98fa70 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 22:10:24 -0600 Subject: [PATCH 21/62] Pin the records-versus-meas_ids agreement with a characterization test --- .../qec/test_record_vs_meas_id_semantics.py | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 python/quantum-pecos/tests/qec/test_record_vs_meas_id_semantics.py diff --git a/python/quantum-pecos/tests/qec/test_record_vs_meas_id_semantics.py b/python/quantum-pecos/tests/qec/test_record_vs_meas_id_semantics.py new file mode 100644 index 000000000..9c1e7d935 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_record_vs_meas_id_semantics.py @@ -0,0 +1,104 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License.You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Characterize what ``records`` offsets mean relative to ``meas_ids``. + +The two spellings resolve through different code paths: ``records`` becomes an +absolute index into the measurement record, while ``meas_ids`` is looked up by +position in the influence map's stamped ids. They coincide whenever the +influence order matches the canonical order, which is the case for every +runtime available here. These tests pin that agreement so a change that makes +the two diverge -- a reordering runtime, or a change to either resolver -- +fails loudly instead of silently rebinding detectors. + +The builder's redundancy rule is the oracle: co-present ``records`` and +``meas_ids`` must resolve to the same measurement set, so an accepted pair +proves the two spellings name the same measurement. +""" + +from __future__ import annotations + +import pytest +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import cx, measure, qubit +from pecos.qec import DetectorErrorModel +from pecos.quantum import TickCircuit + +_NOISE = {"p1": 0.001, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} + + +@guppy +def _five_measurement_program() -> None: + d0, d1, d2 = qubit(), qubit(), qubit() + a0, a1 = qubit(), qubit() + cx(d0, a0) + cx(d1, a0) + cx(d1, a1) + cx(d2, a1) + result("s0", measure(a0)) + result("s1", measure(a1)) + result("m0", measure(d0)) + result("m1", measure(d1)) + result("m2", measure(d2)) + + +def _stamped_circuit() -> TickCircuit: + # Stamped ids deliberately differ from execution position. + circuit = TickCircuit() + circuit.tick().pz([0, 1, 2]) + circuit.tick().mz_with_ids([2, 0, 1], [2, 0, 1]) + circuit.set_meta("num_measurements", "3") + return circuit + + +def _accepts(detectors_json: str, *, circuit: TickCircuit | None = None) -> bool: + """True when the builder accepts the co-present references as redundant.""" + if circuit is None: + try: + DetectorErrorModel.from_guppy( + _five_measurement_program, + num_qubits=5, + detectors_json=detectors_json, + **_NOISE, + ) + except ValueError: + return False + return True + circuit.set_meta("detectors", detectors_json) + try: + DetectorErrorModel.from_circuit(circuit, **_NOISE) + except ValueError: + return False + return True + + +@pytest.mark.parametrize("offset_from_end", range(1, 6)) +def test_traced_records_agree_with_meas_ids(offset_from_end: int) -> None: + num_measurements = 5 + expected_meas_id = num_measurements - offset_from_end + detectors = f'[{{"id":0,"records":[-{offset_from_end}],"meas_ids":[{expected_meas_id}]}}]' + + assert _accepts(detectors), f"records[-{offset_from_end}] should name meas_id {expected_meas_id}" + + +def test_traced_records_reject_every_other_meas_id() -> None: + # Guards against an accept-everything redundancy check. + mismatched = [ + meas_id for meas_id in range(5) if meas_id != 0 and _accepts(f'[{{"id":0,"records":[-5],"meas_ids":[{meas_id}]}}]') + ] + + assert mismatched == [] + + +def test_stamped_circuit_records_agree_with_meas_ids() -> None: + assert _accepts('[{"id":0,"records":[-3],"meas_ids":[0]}]', circuit=_stamped_circuit()) + assert not _accepts('[{"id":0,"records":[-3],"meas_ids":[2]}]', circuit=_stamped_circuit()) From 722afefb85bdb93b4dc49ff059123347dfdddb47 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 22:57:06 -0600 Subject: [PATCH 22/62] Accept a NoiseModel on the Guppy DEM entry points --- docs/user-guide/dem-from-guppy.md | 54 ++++ python/quantum-pecos/src/pecos/qec/dem.py | 277 ++++++++++++++---- .../tests/qec/test_from_guppy_dem.py | 97 ++++++ .../qec/test_record_vs_meas_id_semantics.py | 4 +- 4 files changed, 376 insertions(+), 56 deletions(-) diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index ec4af96d7..ed9f84184 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -195,6 +195,60 @@ Because the trace records the runtime-lowered QIS operation stream, a runtime that schedules or lowers differently produces a (correctly) different DEM. +## Grouping Noise Parameters + +Both Guppy DEM entry points accept either the existing flat noise keywords or +one `NoiseModel` containing the complete noise configuration. The forms below +are equivalent. Do not mix them in one call: even an explicitly passed flat +default conflicts with `noise`. When `noise` is present, its defaults fully +replace the entry point's defaults; for example, `NoiseModel().p1` is `0.0`, +not the flat `p1=0.001` default. + + +```python +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import cx, measure, qubit + +from pecos.qec import DetectorErrorModel +from pecos.qec.surface import NoiseModel + + +@guppy +def noisy_pair() -> None: + q0, q1 = qubit(), qubit() + cx(q0, q1) + result("m0", measure(q0)) + result("m1", measure(q1)) + + +common = { + "num_qubits": 2, + "detectors_json": '[{"id": "D0", "result_tags": ["m0"]}]', + "observables_json": '[{"id": "L0", "result_tags": ["m1"]}]', + "seed": 0, +} +noise = NoiseModel(p1=0.002, p2=0.004, p_meas=0.006, p_prep=0.008) + +grouped = DetectorErrorModel.from_guppy(noisy_pair, noise=noise, **common) +flat = DetectorErrorModel.from_guppy( + noisy_pair, + p1=0.002, + p2=0.004, + p_meas=0.006, + p_prep=0.008, + **common, +) +assert grouped.to_string() == flat.to_string() + +try: + DetectorErrorModel.from_guppy(noisy_pair, noise=noise, p1=0.002, **common) +except ValueError as exc: + assert "p1" in str(exc) +else: + raise AssertionError("grouped and flat noise must not be mixed") +``` + ## Idle Noise The recommended structured interface has three rate-and-model families. Every diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 3e9e4d999..7264e4683 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -53,12 +53,105 @@ if TYPE_CHECKING: from pecos.qec.dem_spec import Detector, Observable + from pecos.qec.surface.decode import NoiseModel P1Weights = Mapping[str, float] P2Weights = Mapping[str, float] _GENERATOR_LAYOUT_ATTR = "__pecos_named_measurement_layout_v2__" +_GUPPY_NOISE_KEYWORDS = ( + "p1", + "p1_weights", + "p2", + "p2_weights", + "p2_replacement_approximation", + "p_meas", + "p_prep", + "p_idle_linear", + "p_idle_linear_model", + "p_idle_sin_squared", + "p_idle_sin_squared_model", + "p_idle_coherent", + "p_idle_coherent_model", + "t1", + "t2", + "p_idle_linear_rate", + "p_idle_quadratic_rate", + "p_idle_x_linear_rate", + "p_idle_y_linear_rate", + "p_idle_z_linear_rate", + "p_idle_x_quadratic_rate", + "p_idle_y_quadratic_rate", + "p_idle_z_quadratic_rate", + "p_idle_quadratic_sine_rate", + "p_idle_x_quadratic_sine_rate", + "p_idle_y_quadratic_sine_rate", + "p_idle_z_quadratic_sine_rate", +) + + +class _NoiseKeywordDefault: + """Track whether a flat noise keyword was explicitly supplied.""" + + __slots__ = ("value",) + + def __init__(self, value: Any) -> None: + self.value = value + + def __repr__(self) -> str: + return repr(self.value) + + +_NOISE_DEFAULT_NONE = _NoiseKeywordDefault(None) +_NOISE_DEFAULT_P1 = _NoiseKeywordDefault(0.001) +_NOISE_DEFAULT_P2 = _NoiseKeywordDefault(0.01) + + +def _resolve_guppy_noise(noise: NoiseModel | None, call_arguments: Mapping[str, Any]) -> dict[str, Any]: + """Resolve one grouped or flat Guppy DEM noise configuration.""" + explicitly_flat = [ + name for name in _GUPPY_NOISE_KEYWORDS if not isinstance(call_arguments[name], _NoiseKeywordDefault) + ] + if noise is None: + return { + name: ( + call_arguments[name].value + if isinstance(call_arguments[name], _NoiseKeywordDefault) + else call_arguments[name] + ) + for name in _GUPPY_NOISE_KEYWORDS + } + + if explicitly_flat: + conflicts = ", ".join(explicitly_flat) + msg = f"noise cannot be combined with flat noise keyword(s): {conflicts}" + raise ValueError(msg) + + # Import locally so dem.py remains below surface.decode in the package's + # initialization graph instead of introducing a module-level back edge. + from pecos.qec.surface.decode import NoiseModel + + if not isinstance(noise, NoiseModel): + msg = f"noise must be a NoiseModel or None, got {type(noise).__name__}" + raise TypeError(msg) + + unsupported = { + "p_idle": "use p_idle_linear instead", + "p2_szz": "use the shared p2 rate instead", + "p2_szzdg": "use the shared p2 rate instead", + } + for field, guidance in unsupported.items(): + if getattr(noise, field) is not None: + msg = f"NoiseModel.{field} is not supported by the Guppy DEM entry points; {guidance}" + raise ValueError(msg) + + expanded = {name: getattr(noise, name) for name in _GUPPY_NOISE_KEYWORDS} + for weights_name in ("p1_weights", "p2_weights"): + if expanded[weights_name] is not None: + expanded[weights_name] = dict(expanded[weights_name]) + return expanded + def _certifiable_hugr_bytes(guppy: Any) -> bytes | None: """Return the HUGR bytes that certify this program's static schedule. @@ -226,33 +319,34 @@ def from_guppy( detectors_json: str, observables_json: str = "[]", num_measurements: int | None = None, - p1: float = 0.001, - p1_weights: P1Weights | None = None, - p2: float = 0.01, - p2_weights: P2Weights | None = None, - p2_replacement_approximation: str | None = None, - p_meas: float = 0.001, - p_prep: float = 0.001, - p_idle_linear: float | None = None, - p_idle_linear_model: Mapping[str, float] | None = None, - p_idle_sin_squared: float | None = None, - p_idle_sin_squared_model: Mapping[str, float] | None = None, - p_idle_coherent: float | None = None, - p_idle_coherent_model: Mapping[str, float] | None = None, - t1: float | None = None, - t2: float | None = None, - p_idle_linear_rate: float | None = None, - p_idle_quadratic_rate: float | None = None, - p_idle_x_linear_rate: float | None = None, - p_idle_y_linear_rate: float | None = None, - p_idle_z_linear_rate: float | None = None, - p_idle_x_quadratic_rate: float | None = None, - p_idle_y_quadratic_rate: float | None = None, - p_idle_z_quadratic_rate: float | None = None, - p_idle_quadratic_sine_rate: float | None = None, - p_idle_x_quadratic_sine_rate: float | None = None, - p_idle_y_quadratic_sine_rate: float | None = None, - p_idle_z_quadratic_sine_rate: float | None = None, + noise: NoiseModel | None = None, + p1: float = _NOISE_DEFAULT_P1, + p1_weights: P1Weights | None = _NOISE_DEFAULT_NONE, + p2: float = _NOISE_DEFAULT_P2, + p2_weights: P2Weights | None = _NOISE_DEFAULT_NONE, + p2_replacement_approximation: str | None = _NOISE_DEFAULT_NONE, + p_meas: float = _NOISE_DEFAULT_P1, + p_prep: float = _NOISE_DEFAULT_P1, + p_idle_linear: float | None = _NOISE_DEFAULT_NONE, + p_idle_linear_model: Mapping[str, float] | None = _NOISE_DEFAULT_NONE, + p_idle_sin_squared: float | None = _NOISE_DEFAULT_NONE, + p_idle_sin_squared_model: Mapping[str, float] | None = _NOISE_DEFAULT_NONE, + p_idle_coherent: float | None = _NOISE_DEFAULT_NONE, + p_idle_coherent_model: Mapping[str, float] | None = _NOISE_DEFAULT_NONE, + t1: float | None = _NOISE_DEFAULT_NONE, + t2: float | None = _NOISE_DEFAULT_NONE, + p_idle_linear_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_quadratic_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_x_linear_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_y_linear_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_z_linear_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_x_quadratic_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_y_quadratic_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_z_quadratic_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_quadratic_sine_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_x_quadratic_sine_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_y_quadratic_sine_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_z_quadratic_sine_rate: float | None = _NOISE_DEFAULT_NONE, strip_traced_idles: bool | None = None, idle_after_2q_duration: float | None = None, runtime: object | None = None, @@ -334,6 +428,11 @@ def from_guppy( num_measurements: Total measurement count, used to resolve negative ``records`` offsets. If omitted, it is inferred from the traced circuit; if given, it must match the traced count. + noise: Complete grouped noise configuration. When supplied, its + values replace all flat noise keywords, including this entry + point's defaults. In particular, ``NoiseModel`` defaults such + as ``p1=0.0`` apply instead of this function's ``p1=0.001``. + Mixing ``noise`` with any flat noise keyword is rejected. p1: Single-qubit gate Pauli error rate. p1_weights: Optional relative probabilities over single-qubit Pauli error labels ``"X"``, ``"Y"``, and ``"Z"``. Values must @@ -457,7 +556,7 @@ def from_guppy( ``TimeUnits``. If idle parameters come from a per-second simulator/runtime model, use ``noise.for_runtime_idle_time_units()`` and pass the converted - scalar idle-rate fields to this constructor. + model through this constructor's ``noise`` keyword. **Measurement-dependent (dynamic) control flow is unsupported.** ``from_guppy`` traces one ideal execution; a Guppy program whose @@ -479,6 +578,37 @@ def from_guppy( """ from pecos.tracing import trace_program_to_tick_circuit + noise_parameters = _resolve_guppy_noise(noise, locals()) + ( + p1, + p1_weights, + p2, + p2_weights, + p2_replacement_approximation, + p_meas, + p_prep, + p_idle_linear, + p_idle_linear_model, + p_idle_sin_squared, + p_idle_sin_squared_model, + p_idle_coherent, + p_idle_coherent_model, + t1, + t2, + p_idle_linear_rate, + p_idle_quadratic_rate, + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, + ) = (noise_parameters[name] for name in _GUPPY_NOISE_KEYWORDS) + ( p_idle_x_linear_rate, p_idle_y_linear_rate, @@ -925,33 +1055,34 @@ def build_dem_from_guppy( num_qubits: int, detectors: Sequence[Detector], observables: Sequence[Observable] = (), - p1: float = 0.001, - p1_weights: P1Weights | None = None, - p2: float = 0.01, - p2_weights: P2Weights | None = None, - p2_replacement_approximation: str | None = None, - p_meas: float = 0.001, - p_prep: float = 0.001, - p_idle_linear: float | None = None, - p_idle_linear_model: Mapping[str, float] | None = None, - p_idle_sin_squared: float | None = None, - p_idle_sin_squared_model: Mapping[str, float] | None = None, - p_idle_coherent: float | None = None, - p_idle_coherent_model: Mapping[str, float] | None = None, - t1: float | None = None, - t2: float | None = None, - p_idle_linear_rate: float | None = None, - p_idle_quadratic_rate: float | None = None, - p_idle_x_linear_rate: float | None = None, - p_idle_y_linear_rate: float | None = None, - p_idle_z_linear_rate: float | None = None, - p_idle_x_quadratic_rate: float | None = None, - p_idle_y_quadratic_rate: float | None = None, - p_idle_z_quadratic_rate: float | None = None, - p_idle_quadratic_sine_rate: float | None = None, - p_idle_x_quadratic_sine_rate: float | None = None, - p_idle_y_quadratic_sine_rate: float | None = None, - p_idle_z_quadratic_sine_rate: float | None = None, + noise: NoiseModel | None = None, + p1: float = _NOISE_DEFAULT_P1, + p1_weights: P1Weights | None = _NOISE_DEFAULT_NONE, + p2: float = _NOISE_DEFAULT_P2, + p2_weights: P2Weights | None = _NOISE_DEFAULT_NONE, + p2_replacement_approximation: str | None = _NOISE_DEFAULT_NONE, + p_meas: float = _NOISE_DEFAULT_P1, + p_prep: float = _NOISE_DEFAULT_P1, + p_idle_linear: float | None = _NOISE_DEFAULT_NONE, + p_idle_linear_model: Mapping[str, float] | None = _NOISE_DEFAULT_NONE, + p_idle_sin_squared: float | None = _NOISE_DEFAULT_NONE, + p_idle_sin_squared_model: Mapping[str, float] | None = _NOISE_DEFAULT_NONE, + p_idle_coherent: float | None = _NOISE_DEFAULT_NONE, + p_idle_coherent_model: Mapping[str, float] | None = _NOISE_DEFAULT_NONE, + t1: float | None = _NOISE_DEFAULT_NONE, + t2: float | None = _NOISE_DEFAULT_NONE, + p_idle_linear_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_quadratic_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_x_linear_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_y_linear_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_z_linear_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_x_quadratic_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_y_quadratic_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_z_quadratic_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_quadratic_sine_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_x_quadratic_sine_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_y_quadratic_sine_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_z_quadratic_sine_rate: float | None = _NOISE_DEFAULT_NONE, strip_traced_idles: bool | None = None, idle_after_2q_duration: float | None = None, runtime: object | None = None, @@ -985,6 +1116,11 @@ def build_dem_from_guppy( ``result_ref(...)`` measurement references. observables: Typed logical-observable definitions using the same measurement-reference forms as ``detectors``. + noise: Complete grouped noise configuration. When supplied, its values + replace all flat noise keywords, including this entry point's + defaults. In particular, ``NoiseModel`` defaults such as + ``p1=0.0`` apply instead of this function's ``p1=0.001``. Mixing + ``noise`` with any flat noise keyword is rejected. p1: Single-qubit gate Pauli error rate. p1_weights: Optional relative probabilities over single-qubit Pauli error labels ``"X"``, ``"Y"``, and ``"Z"``. @@ -1078,6 +1214,37 @@ def build_dem_from_guppy( """ from pecos.tracing import _trace_program_to_tick_circuit_with_result_traces + noise_parameters = _resolve_guppy_noise(noise, locals()) + ( + p1, + p1_weights, + p2, + p2_weights, + p2_replacement_approximation, + p_meas, + p_prep, + p_idle_linear, + p_idle_linear_model, + p_idle_sin_squared, + p_idle_sin_squared_model, + p_idle_coherent, + p_idle_coherent_model, + t1, + t2, + p_idle_linear_rate, + p_idle_quadratic_rate, + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, + ) = (noise_parameters[name] for name in _GUPPY_NOISE_KEYWORDS) + ( p_idle_x_linear_rate, p_idle_y_linear_rate, 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 1fff15244..674d05242 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -236,6 +236,103 @@ def test_guppy_dem_entrypoints_do_not_expose_p_idle_shorthand() -> None: assert "p_idle" not in inspect.signature(build_dem_from_guppy).parameters +def _noise_model_entrypoint_dem(entrypoint: str, **kwargs): + if entrypoint == "from_guppy": + return DetectorErrorModel.from_guppy( + _structured_idle_noise_target, + num_qubits=2, + detectors_json=_TWO_QUBIT_DETECTORS_JSON, + observables_json=_TWO_QUBIT_OBSERVABLES_JSON, + num_measurements=2, + **kwargs, + ) + return build_dem_from_guppy( + _structured_idle_noise_target, + num_qubits=2, + detectors=[Detector(rec[-2])], + observables=[Observable(rec[-1])], + **kwargs, + ).dem + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_noise_model_matches_flat_gate_noise(entrypoint: str) -> None: + rates = {"p1": 0.003, "p2": 0.007, "p_meas": 0.011, "p_prep": 0.013} + + grouped = _noise_model_entrypoint_dem(entrypoint, noise=NoiseModel(**rates)) + flat = _noise_model_entrypoint_dem(entrypoint, **rates) + + assert grouped.to_string() == flat.to_string() + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_noise_model_matches_flat_pauli_weights(entrypoint: str) -> None: + noise_kwargs = { + "p1": 0.003, + "p1_weights": {"X": 0.6, "Y": 0.3, "Z": 0.1}, + "p2": 0.007, + "p2_weights": {"IX": 0.4, "XI": 0.6}, + "p_meas": 0.011, + "p_prep": 0.013, + } + + grouped = _noise_model_entrypoint_dem(entrypoint, noise=NoiseModel(**noise_kwargs)) + flat = _noise_model_entrypoint_dem(entrypoint, **noise_kwargs) + + assert grouped.to_string() == flat.to_string() + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_noise_model_structured_idle_family_matches_flat_axis_rates(entrypoint: str) -> None: + rate = 0.03 + model = {"X": 0.25, "Z": 0.75} + + grouped = _noise_model_entrypoint_dem( + entrypoint, + noise=NoiseModel(p_idle_linear=rate, p_idle_linear_model=model), + idle_after_2q_duration=2.0, + ) + flat = _noise_model_entrypoint_dem( + entrypoint, + p1=0.0, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p_idle_x_linear_rate=rate * model["X"], + p_idle_z_linear_rate=rate * model["Z"], + idle_after_2q_duration=2.0, + ) + + assert grouped.to_string() == flat.to_string() + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +@pytest.mark.parametrize("keyword", ["p1", "p2", "p_meas", "p_idle_linear"]) +def test_noise_model_rejects_flat_noise_keyword(entrypoint: str, keyword: str) -> None: + with pytest.raises(ValueError, match=keyword): + _noise_model_entrypoint_dem(entrypoint, noise=NoiseModel(), **{keyword: 0.01}) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +@pytest.mark.parametrize("field", ["p_idle", "p2_szz", "p2_szzdg"]) +def test_noise_model_rejects_fields_not_supported_by_guppy_dem(entrypoint: str, field: str) -> None: + with pytest.raises(ValueError, match=field): + _noise_model_entrypoint_dem(entrypoint, noise=NoiseModel(**{field: 0.01})) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_noise_model_combines_with_non_noise_keywords(entrypoint: str) -> None: + dem = _noise_model_entrypoint_dem( + entrypoint, + noise=NoiseModel(p_idle_linear=0.01), + idle_after_2q_duration=1.0, + strip_traced_idles=True, + seed=17, + ) + + assert "error(" in dem.to_string() + + @pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) def test_structured_idle_linear_default_matches_axis_primitives(entrypoint: str) -> None: rate = 0.03 diff --git a/python/quantum-pecos/tests/qec/test_record_vs_meas_id_semantics.py b/python/quantum-pecos/tests/qec/test_record_vs_meas_id_semantics.py index 9c1e7d935..d52e832ed 100644 --- a/python/quantum-pecos/tests/qec/test_record_vs_meas_id_semantics.py +++ b/python/quantum-pecos/tests/qec/test_record_vs_meas_id_semantics.py @@ -93,7 +93,9 @@ def test_traced_records_agree_with_meas_ids(offset_from_end: int) -> None: def test_traced_records_reject_every_other_meas_id() -> None: # Guards against an accept-everything redundancy check. mismatched = [ - meas_id for meas_id in range(5) if meas_id != 0 and _accepts(f'[{{"id":0,"records":[-5],"meas_ids":[{meas_id}]}}]') + meas_id + for meas_id in range(5) + if meas_id != 0 and _accepts(f'[{{"id":0,"records":[-5],"meas_ids":[{meas_id}]}}]') ] assert mismatched == [] From 51bef50d5e3237b04bbd6ace744b920dd0b35311 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 23:58:16 -0600 Subject: [PATCH 23/62] Unify the Guppy DEM entry points behind a chained builder --- .../quantum-pecos/src/pecos/qec/__init__.py | 3 +- python/quantum-pecos/src/pecos/qec/dem.py | 814 ++++++++++-------- .../quantum-pecos/src/pecos/qec/dem_spec.py | 80 ++ python/quantum-pecos/src/pecos/tracing.py | 35 +- .../tests/qec/test_guppy_dem_builder.py | 282 ++++++ 5 files changed, 844 insertions(+), 370 deletions(-) create mode 100644 python/quantum-pecos/tests/qec/test_guppy_dem_builder.py diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index a15af3a9d..63792404d 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -78,7 +78,7 @@ # Python from_guppy convenience constructor attached. 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, build_dem_from_guppy +from pecos.qec.dem import DetectorErrorModel, GuppyDemBuilder, build_dem_from_guppy from pecos.qec.dem_spec import ( Detector, GuppyDemBuild, @@ -138,6 +138,7 @@ "PauliFrameLookup", "ParsedDem", "GuppyDemBuild", + "GuppyDemBuilder", "Observable", "assert_dems_equivalent", "compare_dems_exact", diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 7264e4683..30cb899cc 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -49,9 +49,16 @@ normalize_traced_tick_circuit, ) from pecos.qec._idle_noise import _translate_structured_idle_noise -from pecos.qec.dem_spec import GuppyDemBuild, ResultRef, _resolve_dem_specs +from pecos.qec.dem_spec import ( + GuppyDemBuild, + ResultRef, + _resolve_dem_specs, + _resolved_schema_from_validated_json, +) if TYPE_CHECKING: + from typing_extensions import Self + from pecos.qec.dem_spec import Detector, Observable from pecos.qec.surface.decode import NoiseModel @@ -310,6 +317,11 @@ class _DetectorErrorModelMixin: __slots__ = () + @classmethod + def builder(cls) -> GuppyDemBuilder: + """Create a chained builder for an audited Guppy detector error model.""" + return GuppyDemBuilder() + @classmethod def from_guppy( cls, @@ -576,200 +588,25 @@ def from_guppy( scalar ``result(tag, measure(q))`` in straight-line programs; the runtime-loop case (per-occurrence binding) remains deferred. """ - from pecos.tracing import trace_program_to_tick_circuit - - noise_parameters = _resolve_guppy_noise(noise, locals()) - ( - p1, - p1_weights, - p2, - p2_weights, - p2_replacement_approximation, - p_meas, - p_prep, - p_idle_linear, - p_idle_linear_model, - p_idle_sin_squared, - p_idle_sin_squared_model, - p_idle_coherent, - p_idle_coherent_model, - t1, - t2, - p_idle_linear_rate, - p_idle_quadratic_rate, - p_idle_x_linear_rate, - p_idle_y_linear_rate, - p_idle_z_linear_rate, - p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate, - p_idle_quadratic_sine_rate, - p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate, - ) = (noise_parameters[name] for name in _GUPPY_NOISE_KEYWORDS) - - ( - p_idle_x_linear_rate, - p_idle_y_linear_rate, - p_idle_z_linear_rate, - p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate, - ) = _translate_structured_idle_noise( - p_idle_linear=p_idle_linear, - p_idle_linear_model=p_idle_linear_model, - p_idle_sin_squared=p_idle_sin_squared, - p_idle_sin_squared_model=p_idle_sin_squared_model, - p_idle_coherent=p_idle_coherent, - p_idle_coherent_model=p_idle_coherent_model, - p_idle_linear_rate=p_idle_linear_rate, - p_idle_quadratic_rate=p_idle_quadratic_rate, - p_idle_x_linear_rate=p_idle_x_linear_rate, - p_idle_y_linear_rate=p_idle_y_linear_rate, - p_idle_z_linear_rate=p_idle_z_linear_rate, - p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, - p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, - ) - - # Tag-referenced detectors require the compiled HUGR (to recover the - # sound, reorder-immune Guppy `result(tag, ...)` -> measurement - # 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 = _certifiable_hugr_bytes(guppy) - if hugr_bytes is None: - if needs_tags: - msg = ( - "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) - msg = ( - "DetectorErrorModel.from_guppy requires a HUGR-certifiable program " - "(a @guppy function, pecos.Guppy, pecos.Hugr, or HUGR envelope " - f"bytes); a {type(guppy).__name__!r} input cannot be certified as " - "statically scheduled, so an audited DEM cannot be built from it" - ) - raise ValueError(msg) - certificate_carrier = _certificate_carrier(guppy) - generator_layout = ( - _generator_certified_layout(certificate_carrier, hugr_bytes) if certificate_carrier is not None else None - ) - if generator_layout is None: - from pecos_rslib import guppy_hugr_has_nontrivial_control_flow - - if guppy_hugr_has_nontrivial_control_flow(hugr_bytes): - msg = ( - "DetectorErrorModel.from_guppy requires a statically straight-line Guppy program; " - "branching or looping control flow cannot be certified from one runtime trace" - ) - raise ValueError(msg) - - # Trace the EXACT bytes that were certified above: re-compiling the - # original object for execution would let the audit and the execution - # diverge (and pays a second compile for nothing). - from pecos.programs import Hugr as _HugrProgram - - tc = trace_program_to_tick_circuit( - _HugrProgram(hugr_bytes), - num_qubits, - seed=seed, - runtime=runtime, - require_hosted_operation_order=require_hosted_operation_order, - max_hosted_tick_separation=max_hosted_tick_separation, - ) - - # Compilation passes required for traced QIS circuits before fault - # analysis: normalize parameterized Clifford rotations to named gates, - # stamp stable MeasIds onto measurement gates, and fail loudly if raw - # traced-QIS rotations survived normalization. - normalize_traced_tick_circuit(tc, context="DetectorErrorModel.from_guppy") - _apply_traced_idle_passes( - tc, - strip_traced_idles=strip_traced_idles, - idle_after_2q_duration=idle_after_2q_duration, - # Nonzero coherent rates were rejected before tracing; zero emits no noise. - idle_noise_parameters=( - p_idle_linear, - p_idle_sin_squared, - t1, - t2, - p_idle_linear_rate, - p_idle_quadratic_rate, - p_idle_x_linear_rate, - p_idle_y_linear_rate, - p_idle_z_linear_rate, - p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate, - p_idle_quadratic_sine_rate, - p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate, - ), + noise_keywords = {name: value for name, value in locals().items() if name in _GUPPY_NOISE_KEYWORDS} + builder = ( + cls.builder() + .program(guppy) + .qubits(num_qubits) + .detectors_json(detectors_json) + .observables_json(observables_json) + .strip_traced_idles(strip_traced_idles) + .idle_after_2q(idle_after_2q_duration) + .runtime(runtime) + .seed(seed) + .require_hosted_operation_order(require_hosted_operation_order) + .max_hosted_tick_separation(max_hosted_tick_separation) ) - - # 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 - - source_ids_json = tc.get_meta("qis_source_measurement_ids") or tc.get_meta("guppy_source_measurement_ids") - source_measurement_ids = json.loads(source_ids_json) if source_ids_json else [] - - detectors_json, observables_json = resolve_result_tags_for_guppy( - detectors_json, - observables_json, - hugr_bytes, - source_measurement_ids, - measurement_ids_in_execution_order(tc), - ) - - # 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: - tc.set_meta("num_measurements", str(num_measurements)) - - return _from_circuit_with_noise( - tc, - p1=p1, - p1_weights=p1_weights, - p2=p2, - p2_weights=p2_weights, - p2_replacement_approximation=p2_replacement_approximation, - p_meas=p_meas, - p_prep=p_prep, - t1=t1, - t2=t2, - p_idle_linear_rate=p_idle_linear_rate, - p_idle_quadratic_rate=p_idle_quadratic_rate, - p_idle_x_linear_rate=p_idle_x_linear_rate, - p_idle_y_linear_rate=p_idle_y_linear_rate, - p_idle_z_linear_rate=p_idle_z_linear_rate, - p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, - p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, - p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, - ) + builder.num_measurements(num_measurements) + # Same-module private seam: the flat keyword surface stays on this + # function while noise() remains strictly a NoiseModel setter. + return builder._legacy_noise(noise, noise_keywords).build().dem # noqa: SLF001 def _result_tags_present(detectors_json: str, observables_json: str) -> bool: @@ -808,6 +645,7 @@ def _preflight_guppy_static_schedule( guppy: Any, *, required_tags: Sequence[str], + json_result_tags: bool = False, ) -> tuple[Sequence[Any] | None, bytes]: """Validate program-level trust before any runtime trace is captured. @@ -821,13 +659,23 @@ def _preflight_guppy_static_schedule( hugr_bytes = _certifiable_hugr_bytes(guppy) if hugr_bytes is None: if required_tags: - msg = "result_ref(...) requires a HUGR-compilable Guppy program" + msg = ( + "result_ref(...) requires a HUGR-compilable Guppy program; use " + "DetectorErrorModel.from_circuit(...) for circuit inputs" + ) + raise ValueError(msg) + if json_result_tags: + msg = ( + "result_tags requires a @guppy-decorated function (or a GuppyFunctionDefinition) so the program can " + "be compiled to a HUGR; use DetectorErrorModel.from_circuit(...) for circuit inputs" + ) raise ValueError(msg) msg = ( - "build_dem_from_guppy requires a HUGR-certifiable program (a @guppy " + "GuppyDemBuilder.program() requires a HUGR-certifiable program (a @guppy " "function, pecos.Guppy, pecos.Hugr, or HUGR envelope bytes); a " f"{type(guppy).__name__!r} input cannot be certified as statically " - "scheduled, so an audited DEM cannot be built from it" + "scheduled, so an audited DEM cannot be built from it; use " + "DetectorErrorModel.from_circuit(...) for circuit inputs" ) raise ValueError(msg) @@ -842,7 +690,7 @@ def _preflight_guppy_static_schedule( if guppy_hugr_has_nontrivial_control_flow(hugr_bytes): msg = ( - "build_dem_from_guppy requires a statically straight-line Guppy program unless it carries " + "GuppyDemBuilder requires a statically straight-line Guppy program unless it carries " "a trusted generator-owned measurement layout; branching or looping control flow cannot be " "certified from one runtime trace" ) @@ -1049,6 +897,394 @@ def _generator_certified_result_traces( ] +_UNSET = object() +_LEGACY_NOISE = object() + + +def _builder_noise_defaults() -> dict[str, Any]: + """Return the legacy Guppy entry-point defaults with explicitness intact.""" + defaults = dict.fromkeys(_GUPPY_NOISE_KEYWORDS, _NOISE_DEFAULT_NONE) + defaults.update( + p1=_NOISE_DEFAULT_P1, + p2=_NOISE_DEFAULT_P2, + p_meas=_NOISE_DEFAULT_P1, + p_prep=_NOISE_DEFAULT_P1, + ) + return defaults + + +class GuppyDemBuilder: + """Configure and build an audited detector error model from a Guppy program.""" + + __slots__ = ( + "_detectors_kind", + "_detectors_value", + "_idle_after_2q", + "_max_hosted_tick_separation", + "_noise", + "_num_measurements", + "_observables_kind", + "_observables_value", + "_program", + "_qubits", + "_require_hosted_operation_order", + "_runtime", + "_seed", + "_strip_traced_idles", + ) + + def __init__(self) -> None: + """Create an empty builder whose required inputs are not yet set.""" + self._program: Any = _UNSET + self._qubits: Any = _UNSET + self._detectors_kind: str | object = _UNSET + self._detectors_value: Any = _UNSET + self._observables_kind: str | object = _UNSET + self._observables_value: Any = _UNSET + self._num_measurements: Any = _UNSET + self._noise: Any = _UNSET + self._idle_after_2q: Any = _UNSET + self._strip_traced_idles: Any = _UNSET + self._runtime: Any = _UNSET + self._seed: Any = _UNSET + self._require_hosted_operation_order: Any = _UNSET + self._max_hosted_tick_separation: Any = _UNSET + + def _set_once(self, attribute: str, value: Any, setter: str) -> None: + if getattr(self, attribute) is not _UNSET: + msg = f"{setter}() may only be called once" + raise ValueError(msg) + setattr(self, attribute, value) + + def _set_specs(self, role: str, kind: str, value: Any) -> None: + kind_attribute = f"_{role}_kind" + value_attribute = f"_{role}_value" + current_kind = getattr(self, kind_attribute) + setter = role if kind == "typed" else f"{role}_json" + if current_kind is not _UNSET: + previous = role if current_kind == "typed" else f"{role}_json" + if current_kind == kind: + msg = f"{setter}() may only be called once" + raise ValueError(msg) + msg = f"{setter}() cannot be combined with {previous}()" + raise ValueError(msg) + if kind == "typed" and self._num_measurements is not _UNSET: + msg = f"{setter}() cannot be combined with num_measurements()" + raise ValueError(msg) + setattr(self, kind_attribute, kind) + setattr(self, value_attribute, value) + + def program(self, program: Any) -> Self: + """Set the Guppy or HUGR program to trace.""" + self._set_once("_program", program, "program") + return self + + def qubits(self, num_qubits: int) -> Self: + """Set the number of qubits allocated to the trace.""" + self._set_once("_qubits", num_qubits, "qubits") + return self + + def detectors(self, specs: Sequence[Detector]) -> Self: + """Set typed detector specifications.""" + self._set_specs("detectors", "typed", tuple(specs)) + return self + + def observables(self, specs: Sequence[Observable]) -> Self: + """Set typed logical-observable specifications.""" + self._set_specs("observables", "typed", tuple(specs)) + return self + + def detectors_json(self, text: str) -> Self: + """Set raw JSON detector specifications.""" + self._set_specs("detectors", "json", text) + return self + + def observables_json(self, text: str) -> Self: + """Set raw JSON logical-observable specifications.""" + self._set_specs("observables", "json", text) + return self + + def num_measurements(self, count: int) -> Self: + """Set the measurement count used by raw JSON record references.""" + if self._detectors_kind == "typed" or self._observables_kind == "typed": + msg = "num_measurements() cannot be combined with typed detectors() or observables()" + raise ValueError(msg) + self._set_once("_num_measurements", count, "num_measurements") + return self + + def noise(self, noise_model: NoiseModel) -> Self: + """Set the complete grouped noise configuration.""" + from pecos.qec.surface.decode import NoiseModel + + if not isinstance(noise_model, NoiseModel): + msg = f"noise() requires a NoiseModel, got {type(noise_model).__name__}" + raise TypeError(msg) + self._set_once("_noise", noise_model, "noise") + return self + + def _legacy_noise(self, noise_model: NoiseModel | None, flat_keywords: Mapping[str, Any]) -> Self: + """Carry the legacy entry points' flat noise keywords through the builder. + + Private: the flat keyword surface stays on ``from_guppy`` and + ``build_dem_from_guppy``; ``noise()`` accepts only a ``NoiseModel``. + """ + self._set_once("_noise", (_LEGACY_NOISE, noise_model, dict(flat_keywords)), "noise") + return self + + def idle_after_2q(self, duration: float | None) -> Self: + """Set the idle duration inserted after every two-qubit gate.""" + self._set_once("_idle_after_2q", duration, "idle_after_2q") + return self + + def strip_traced_idles(self, flag: bool | None) -> Self: + """Choose whether runtime-emitted identity-like gates are stripped.""" + self._set_once("_strip_traced_idles", flag, "strip_traced_idles") + return self + + def runtime(self, runtime: object | None) -> Self: + """Set the Selene runtime used for the trace.""" + self._set_once("_runtime", runtime, "runtime") + return self + + def seed(self, seed: int) -> Self: + """Set the ideal trace seed.""" + self._set_once("_seed", seed, "seed") + return self + + def require_hosted_operation_order(self, flag: bool) -> Self: + """Choose whether hosted-operation ordering is validated.""" + self._set_once("_require_hosted_operation_order", flag, "require_hosted_operation_order") + return self + + def max_hosted_tick_separation(self, count: int | None) -> Self: + """Set the maximum hosted-operation tick separation.""" + self._set_once("_max_hosted_tick_separation", count, "max_hosted_tick_separation") + return self + + def _noise_parameters(self) -> dict[str, Any]: + if isinstance(self._noise, tuple) and len(self._noise) == 3 and self._noise[0] is _LEGACY_NOISE: + _, noise, call_arguments = self._noise + return _resolve_guppy_noise(noise, call_arguments) + noise = None if self._noise is _UNSET else self._noise + return _resolve_guppy_noise(noise, _builder_noise_defaults()) + + def _required(self, attribute: str, setter: str) -> Any: + value = getattr(self, attribute) + if value is _UNSET: + msg = f"build() requires {setter}()" + raise ValueError(msg) + return value + + def build(self) -> GuppyDemBuild: + """Trace the configured program once and return its audited DEM build.""" + from pecos.programs import Hugr as _HugrProgram + from pecos.tracing import _collect_program_result_traces, trace_program_to_tick_circuit + + program = self._required("_program", "program") + num_qubits = self._required("_qubits", "qubits") + if self._detectors_kind is _UNSET: + msg = "build() requires detectors() or detectors_json()" + raise ValueError(msg) + + noise_parameters = self._noise_parameters() + ( + p1, + p1_weights, + p2, + p2_weights, + p2_replacement_approximation, + p_meas, + p_prep, + p_idle_linear, + p_idle_linear_model, + p_idle_sin_squared, + p_idle_sin_squared_model, + p_idle_coherent, + p_idle_coherent_model, + t1, + t2, + p_idle_linear_rate, + p_idle_quadratic_rate, + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, + ) = (noise_parameters[name] for name in _GUPPY_NOISE_KEYWORDS) + ( + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, + ) = _translate_structured_idle_noise( + p_idle_linear=p_idle_linear, + p_idle_linear_model=p_idle_linear_model, + p_idle_sin_squared=p_idle_sin_squared, + p_idle_sin_squared_model=p_idle_sin_squared_model, + p_idle_coherent=p_idle_coherent, + p_idle_coherent_model=p_idle_coherent_model, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, + p_idle_x_linear_rate=p_idle_x_linear_rate, + p_idle_y_linear_rate=p_idle_y_linear_rate, + p_idle_z_linear_rate=p_idle_z_linear_rate, + p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, + ) + typed_detectors = self._detectors_value if self._detectors_kind == "typed" else () + typed_observables = self._observables_value if self._observables_kind == "typed" else () + referenced_tags = sorted( + { + ref.tag + for item in (*typed_detectors, *typed_observables) + for ref in item.refs + if isinstance(ref, ResultRef) + }, + ) + raw_detectors_json = self._detectors_value if self._detectors_kind == "json" else "[]" + raw_observables_json = self._observables_value if self._observables_kind == "json" else "[]" + json_needs_tags = _result_tags_present(raw_detectors_json, raw_observables_json) + generator_layout, hugr_bytes = _preflight_guppy_static_schedule( + program, + required_tags=referenced_tags, + json_result_tags=json_needs_tags, + ) + with _collect_program_result_traces() as result_traces: + circuit = trace_program_to_tick_circuit( + _HugrProgram(hugr_bytes), + num_qubits, + seed=0 if self._seed is _UNSET else self._seed, + runtime=None if self._runtime is _UNSET else self._runtime, + require_hosted_operation_order=( + False if self._require_hosted_operation_order is _UNSET else self._require_hosted_operation_order + ), + max_hosted_tick_separation=( + None if self._max_hosted_tick_separation is _UNSET else self._max_hosted_tick_separation + ), + ) + normalize_traced_tick_circuit(circuit, context="GuppyDemBuilder.build") + + _apply_traced_idle_passes( + circuit, + strip_traced_idles=None if self._strip_traced_idles is _UNSET else self._strip_traced_idles, + idle_after_2q_duration=None if self._idle_after_2q is _UNSET else self._idle_after_2q, + idle_noise_parameters=( + p_idle_linear, + p_idle_sin_squared, + t1, + t2, + p_idle_linear_rate, + p_idle_quadratic_rate, + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, + ), + ) + result_traces = _compiler_certified_result_traces( + generator_layout, + hugr_bytes, + circuit, + result_traces, + required_tags=referenced_tags, + ) + typed_schema = _resolve_dem_specs( + typed_detectors, + typed_observables, + circuit=circuit, + result_traces=result_traces, + ) + detectors_json = typed_schema.detectors_json if self._detectors_kind == "typed" else raw_detectors_json + observables_json = typed_schema.observables_json if self._observables_kind == "typed" else raw_observables_json + if json_needs_tags: + from pecos_rslib import resolve_result_tags_for_guppy + + source_ids_json = circuit.get_meta("qis_source_measurement_ids") or circuit.get_meta( + "guppy_source_measurement_ids", + ) + source_measurement_ids = json.loads(source_ids_json) if source_ids_json else [] + detectors_json, observables_json = resolve_result_tags_for_guppy( + detectors_json, + observables_json, + hugr_bytes, + source_measurement_ids, + measurement_ids_in_execution_order(circuit), + ) + + circuit.set_meta("detectors", detectors_json) + circuit.set_meta("observables", observables_json) + measurement_count = circuit.num_measurements() if self._num_measurements is _UNSET else self._num_measurements + circuit.set_meta("num_measurements", str(measurement_count)) + dem = _from_circuit_with_noise( + circuit, + p1=p1, + p1_weights=p1_weights, + p2=p2, + p2_weights=p2_weights, + p2_replacement_approximation=p2_replacement_approximation, + p_meas=p_meas, + p_prep=p_prep, + t1=t1, + t2=t2, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, + p_idle_x_linear_rate=p_idle_x_linear_rate, + p_idle_y_linear_rate=p_idle_y_linear_rate, + p_idle_z_linear_rate=p_idle_z_linear_rate, + p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, + ) + schema = _resolved_schema_from_validated_json( + detectors_json, + observables_json, + circuit=circuit, + result_traces=result_traces, + ) + circuit.set_meta("dem_schema_fingerprint", schema.schema_fingerprint) + if generator_layout is not None: + named_result_binding = "generator_layout_v2_program_bound" + else: + result_ids = [result_id for _, ids in schema.result_ids_by_tag for result_id in ids] + if not result_ids or all(result_id is None for result_id in result_ids): + named_result_binding = "none" + elif any(result_id is None for result_id in result_ids): + named_result_binding = "compiler_direct_scalar_partial" + else: + named_result_binding = "compiler_direct_scalar_complete" + return GuppyDemBuild( + dem=dem, + circuit=circuit, + detectors_json=schema.detectors_json, + observables_json=schema.observables_json, + measurement_ledger=schema.ledger, + schema_fingerprint=schema.schema_fingerprint, + named_result_binding=named_result_binding, + _detector_meas_ids=schema.detector_meas_ids, + _observable_meas_ids=schema.observable_meas_ids, + _result_ids_by_tag=schema.result_ids_by_tag, + ) + + def build_dem_from_guppy( guppy: Any, *, @@ -1212,176 +1448,24 @@ def build_dem_from_guppy( runtime that emits scheduled idles to provide targets for idle noise. """ - from pecos.tracing import _trace_program_to_tick_circuit_with_result_traces - - noise_parameters = _resolve_guppy_noise(noise, locals()) - ( - p1, - p1_weights, - p2, - p2_weights, - p2_replacement_approximation, - p_meas, - p_prep, - p_idle_linear, - p_idle_linear_model, - p_idle_sin_squared, - p_idle_sin_squared_model, - p_idle_coherent, - p_idle_coherent_model, - t1, - t2, - p_idle_linear_rate, - p_idle_quadratic_rate, - p_idle_x_linear_rate, - p_idle_y_linear_rate, - p_idle_z_linear_rate, - p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate, - p_idle_quadratic_sine_rate, - p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate, - ) = (noise_parameters[name] for name in _GUPPY_NOISE_KEYWORDS) - - ( - p_idle_x_linear_rate, - p_idle_y_linear_rate, - p_idle_z_linear_rate, - p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate, - ) = _translate_structured_idle_noise( - p_idle_linear=p_idle_linear, - p_idle_linear_model=p_idle_linear_model, - p_idle_sin_squared=p_idle_sin_squared, - p_idle_sin_squared_model=p_idle_sin_squared_model, - p_idle_coherent=p_idle_coherent, - p_idle_coherent_model=p_idle_coherent_model, - p_idle_linear_rate=p_idle_linear_rate, - p_idle_quadratic_rate=p_idle_quadratic_rate, - p_idle_x_linear_rate=p_idle_x_linear_rate, - p_idle_y_linear_rate=p_idle_y_linear_rate, - p_idle_z_linear_rate=p_idle_z_linear_rate, - p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, - p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, - ) - - referenced_tags = sorted( - {ref.tag for item in (*detectors, *observables) for ref in item.refs if isinstance(ref, ResultRef)}, - ) - generator_layout, hugr_bytes = _preflight_guppy_static_schedule( - guppy, - required_tags=referenced_tags, - ) - has_generator_layout = generator_layout is not None - # Trace the EXACT bytes that were certified: re-compiling the original - # object for execution would let the audit and the execution diverge (and - # pays a second compile for nothing). - from pecos.programs import Hugr as _HugrProgram - - circuit, result_traces = _trace_program_to_tick_circuit_with_result_traces( - _HugrProgram(hugr_bytes), - num_qubits, - seed=seed, - runtime=runtime, - require_hosted_operation_order=require_hosted_operation_order, - max_hosted_tick_separation=max_hosted_tick_separation, - allow_raw_measurement_id_fallback=False, - ) - normalize_traced_tick_circuit(circuit, context="build_dem_from_guppy") - _apply_traced_idle_passes( - circuit, - strip_traced_idles=strip_traced_idles, - idle_after_2q_duration=idle_after_2q_duration, - # Nonzero coherent rates were rejected before tracing; zero emits no noise. - idle_noise_parameters=( - p_idle_linear, - p_idle_sin_squared, - t1, - t2, - p_idle_linear_rate, - p_idle_quadratic_rate, - p_idle_x_linear_rate, - p_idle_y_linear_rate, - p_idle_z_linear_rate, - p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate, - p_idle_quadratic_sine_rate, - p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate, - ), - ) - result_traces = _compiler_certified_result_traces( - generator_layout, - hugr_bytes, - circuit, - result_traces, - required_tags=referenced_tags, - ) - schema = _resolve_dem_specs( - detectors, - observables, - circuit=circuit, - result_traces=result_traces, - ) - if has_generator_layout: - named_result_binding = "generator_layout_v2_program_bound" - else: - result_ids = [result_id for _, ids in schema.result_ids_by_tag for result_id in ids] - if not result_ids or all(result_id is None for result_id in result_ids): - named_result_binding = "none" - elif any(result_id is None for result_id in result_ids): - named_result_binding = "compiler_direct_scalar_partial" - else: - named_result_binding = "compiler_direct_scalar_complete" - circuit.set_meta("detectors", schema.detectors_json) - circuit.set_meta("observables", schema.observables_json) - circuit.set_meta("num_measurements", str(circuit.num_measurements())) - circuit.set_meta("dem_schema_fingerprint", schema.schema_fingerprint) - - dem = _from_circuit_with_noise( - circuit, - p1=p1, - p1_weights=p1_weights, - p2=p2, - p2_weights=p2_weights, - p2_replacement_approximation=p2_replacement_approximation, - p_meas=p_meas, - p_prep=p_prep, - t1=t1, - t2=t2, - p_idle_linear_rate=p_idle_linear_rate, - p_idle_quadratic_rate=p_idle_quadratic_rate, - p_idle_x_linear_rate=p_idle_x_linear_rate, - p_idle_y_linear_rate=p_idle_y_linear_rate, - p_idle_z_linear_rate=p_idle_z_linear_rate, - p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, - p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, - p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, - ) - return GuppyDemBuild( - dem=dem, - circuit=circuit, - detectors_json=schema.detectors_json, - observables_json=schema.observables_json, - measurement_ledger=schema.ledger, - schema_fingerprint=schema.schema_fingerprint, - named_result_binding=named_result_binding, - _detector_meas_ids=schema.detector_meas_ids, - _observable_meas_ids=schema.observable_meas_ids, - _result_ids_by_tag=schema.result_ids_by_tag, + noise_keywords = {name: value for name, value in locals().items() if name in _GUPPY_NOISE_KEYWORDS} + builder = ( + GuppyDemBuilder() + .program(guppy) + .qubits(num_qubits) + .detectors(detectors) + .observables(observables) + .strip_traced_idles(strip_traced_idles) + .idle_after_2q(idle_after_2q_duration) + .runtime(runtime) + .seed(seed) + .require_hosted_operation_order(require_hosted_operation_order) + .max_hosted_tick_separation(max_hosted_tick_separation) ) + # Same-module private seam: see the note in DetectorErrorModel.from_guppy. + return builder._legacy_noise(noise, noise_keywords).build() # noqa: SLF001 DetectorErrorModel = _RustDetectorErrorModel +DetectorErrorModel.builder = classmethod(_DetectorErrorModelMixin.__dict__["builder"].__func__) DetectorErrorModel.from_guppy = classmethod(_DetectorErrorModelMixin.__dict__["from_guppy"].__func__) diff --git a/python/quantum-pecos/src/pecos/qec/dem_spec.py b/python/quantum-pecos/src/pecos/qec/dem_spec.py index 647cff91d..e71fe9177 100644 --- a/python/quantum-pecos/src/pecos/qec/dem_spec.py +++ b/python/quantum-pecos/src/pecos/qec/dem_spec.py @@ -593,3 +593,83 @@ def _resolve_dem_specs( result_ids_by_tag=result_ids_by_tag, schema_fingerprint=fingerprint, ) + + +def _resolved_schema_from_validated_json( + detectors_json: str, + observables_json: str, + *, + circuit: Any, + result_traces: Sequence[Mapping[str, Any]], +) -> _ResolvedSchema: + """Build audit data from metadata already validated by the Rust DEM builder.""" + runtime_order = _measurement_ids_in_runtime_order(circuit) + result_calls, refs_by_id = _index_result_traces(result_traces) + detector_entries = json.loads(detectors_json) if detectors_json.strip() else [] + observable_entries = json.loads(observables_json) if observables_json.strip() else [] + + def normalized_id(raw_id: Any, *, prefix: str) -> int: + if isinstance(raw_id, str) and raw_id.startswith(prefix): + return int(raw_id[len(prefix) :]) + return int(raw_id) + + def entry_meas_ids(entry: Mapping[str, Any]) -> tuple[int, ...]: + records = entry.get("records", ()) + if records: + return tuple(runtime_order[len(runtime_order) + int(record)] for record in records) + return tuple(int(meas_id) for meas_id in entry["meas_ids"]) + + resolved_detectors = sorted( + ( + normalized_id(entry["id"] if "id" in entry else entry["detector_id"], prefix="D"), + entry_meas_ids(entry), + ) + for entry in detector_entries + ) + resolved_observables = sorted( + ( + normalized_id(entry["id"] if "id" in entry else entry["observable_id"], prefix="L"), + entry_meas_ids(entry), + ) + for entry in observable_entries + ) + result_ids_by_tag = tuple( + ( + tag, + tuple(result_id for occurrence in range(len(calls)) for result_id in calls[occurrence]), + ) + for tag, calls in sorted(result_calls.items()) + if calls + and sorted(calls) == list(range(len(calls))) + and all(len(call) == 1 or all(result_id is None for result_id in call) for call in calls.values()) + and all(result_id is None or isinstance(result_id, int) for call in calls.values() for result_id in call) + ) + fingerprint_payload = { + "detectors": detector_entries, + "observables": observable_entries, + "runtime_measurement_order": runtime_order, + "named_result_measurements": result_ids_by_tag, + } + fingerprint = hashlib.sha256( + json.dumps(fingerprint_payload, sort_keys=True, separators=(",", ":")).encode(), + ).hexdigest() + runtime_ids = set(runtime_order) + dense_ids = sorted(runtime_ids) == list(range(len(runtime_order))) + ledger = tuple( + MeasurementLedgerEntry( + meas_id=meas_id, + runtime_record_index=runtime_index, + canonical_record_index=meas_id if dense_ids else None, + result_refs=tuple(refs_by_id.get(meas_id, ())), + ) + for runtime_index, meas_id in enumerate(runtime_order) + ) + return _ResolvedSchema( + detectors_json=detectors_json, + observables_json=observables_json, + detector_meas_ids=tuple(meas_ids for _, meas_ids in resolved_detectors), + observable_meas_ids=tuple(resolved_observables), + ledger=ledger, + result_ids_by_tag=result_ids_by_tag, + schema_fingerprint=fingerprint, + ) diff --git a/python/quantum-pecos/src/pecos/tracing.py b/python/quantum-pecos/src/pecos/tracing.py index b850f9c54..23d47965e 100644 --- a/python/quantum-pecos/src/pecos/tracing.py +++ b/python/quantum-pecos/src/pecos/tracing.py @@ -13,6 +13,8 @@ import json from collections import Counter +from contextlib import contextmanager +from contextvars import ContextVar from typing import TYPE_CHECKING, Any from pecos._qis_trace_replay import ( @@ -24,9 +26,17 @@ from pecos._traced_circuit import measurement_ids_in_execution_order if TYPE_CHECKING: + from collections.abc import Iterator + from pecos.quantum import TickCircuit +_RESULT_TRACE_COLLECTOR: ContextVar[list[dict[str, Any]] | None] = ContextVar( + "pecos_result_trace_collector", + default=None, +) + + def capture_qis_operation_trace( program: object, num_qubits: int, @@ -182,6 +192,17 @@ def _trace_program_to_tick_circuit_with_result_traces( return tick_circuit, named_result_traces_from_operation_trace(chunks) +@contextmanager +def _collect_program_result_traces() -> Iterator[list[dict[str, Any]]]: + """Collect result provenance when tracing through the stable public helper.""" + result_traces: list[dict[str, Any]] = [] + token = _RESULT_TRACE_COLLECTOR.set(result_traces) + try: + yield result_traces + finally: + _RESULT_TRACE_COLLECTOR.reset(token) + + def trace_program_to_tick_circuit( program: object, num_qubits: int, @@ -223,14 +244,20 @@ def trace_program_to_tick_circuit( not use a trace from measurement-dependent branches or loops as though it represented all possible executions. """ - trace = capture_qis_operation_trace(program, num_qubits, seed=seed, runtime=runtime) - return _qis_operation_trace_to_tick_circuit( - trace, + tick_circuit, result_traces = _trace_program_to_tick_circuit_with_result_traces( + program, + num_qubits, + seed=seed, + runtime=runtime, measurement_crosstalk_topology=measurement_crosstalk_topology, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, - context="trace_program_to_tick_circuit", + allow_raw_measurement_id_fallback=False, ) + collector = _RESULT_TRACE_COLLECTOR.get() + if collector is not None: + collector.extend(result_traces) + return tick_circuit # Compatibility aliases for the original surface-code-internal names. These diff --git a/python/quantum-pecos/tests/qec/test_guppy_dem_builder.py b/python/quantum-pecos/tests/qec/test_guppy_dem_builder.py new file mode 100644 index 000000000..5893a8848 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_guppy_dem_builder.py @@ -0,0 +1,282 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Contract tests for the unified Guppy detector-error-model builder.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pecos +import pytest +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import cx, measure, qubit +from pecos.qec import ( + Detector, + DetectorErrorModel, + GuppyDemBuild, + GuppyDemBuilder, + Observable, + build_dem_from_guppy, + rec, +) +from pecos.qec.surface import NoiseModel +from pecos_rslib.quantum import TickCircuit + +if TYPE_CHECKING: + from collections.abc import Callable + + +@guppy +def _tagged_two_qubit_program() -> None: + q0 = qubit() + q1 = qubit() + cx(q0, q1) + result("m0", measure(q0)) + result("m1", measure(q1)) + + +_DETECTORS_JSON = '[{"id":0,"records":[-2]}]' +_OBSERVABLES_JSON = '[{"id":0,"records":[-1]}]' + + +def test_builder_matches_both_wrappers_with_noise_and_inserted_idles() -> None: + noise = NoiseModel(p1=0.0, p2=0.01, p_meas=0.02, p_prep=0.0, p_idle_z_linear_rate=0.03) + via_json_builder = ( + DetectorErrorModel.builder() + .program(_tagged_two_qubit_program) + .qubits(2) + .detectors_json(_DETECTORS_JSON) + .observables_json(_OBSERVABLES_JSON) + .noise(noise) + .idle_after_2q(1.0) + .build() + ) + via_from_guppy = DetectorErrorModel.from_guppy( + _tagged_two_qubit_program, + num_qubits=2, + detectors_json=_DETECTORS_JSON, + observables_json=_OBSERVABLES_JSON, + noise=noise, + idle_after_2q_duration=1.0, + ) + via_typed_builder = ( + DetectorErrorModel.builder() + .program(_tagged_two_qubit_program) + .qubits(2) + .detectors([Detector(rec[-2])]) + .observables([Observable(rec[-1])]) + .noise(noise) + .idle_after_2q(1.0) + .build() + ) + via_typed_wrapper = build_dem_from_guppy( + _tagged_two_qubit_program, + num_qubits=2, + detectors=[Detector(rec[-2])], + observables=[Observable(rec[-1])], + noise=noise, + idle_after_2q_duration=1.0, + ) + + expected = via_from_guppy.to_string() + assert isinstance(via_json_builder, GuppyDemBuild) + assert isinstance(DetectorErrorModel.builder(), GuppyDemBuilder) + assert via_json_builder.dem.to_string() == expected + assert via_typed_builder.dem.to_string() == expected + assert via_typed_wrapper.dem.to_string() == expected + + +def test_builder_matches_both_wrappers_with_result_tags() -> None: + detectors_json = '[{"id":0,"result_tags":["m0"]}]' + observables_json = '[{"id":0,"result_tags":["m1"]}]' + noise = NoiseModel(p1=0.0, p2=0.0, p_meas=0.1, p_prep=0.0) + via_json_builder = ( + DetectorErrorModel.builder() + .program(_tagged_two_qubit_program) + .qubits(2) + .detectors_json(detectors_json) + .observables_json(observables_json) + .noise(noise) + .build() + ) + via_from_guppy = DetectorErrorModel.from_guppy( + _tagged_two_qubit_program, + num_qubits=2, + detectors_json=detectors_json, + observables_json=observables_json, + noise=noise, + ) + via_typed_builder = ( + DetectorErrorModel.builder() + .program(_tagged_two_qubit_program) + .qubits(2) + .detectors([Detector("m0")]) + .observables([Observable("m1")]) + .noise(noise) + .build() + ) + via_typed_wrapper = build_dem_from_guppy( + _tagged_two_qubit_program, + num_qubits=2, + detectors=[Detector("m0")], + observables=[Observable("m1")], + noise=noise, + ) + + expected = via_from_guppy.to_string() + assert via_json_builder.dem.to_string() == expected + assert via_typed_builder.dem.to_string() == expected + assert via_typed_wrapper.dem.to_string() == expected + + +@pytest.mark.parametrize( + ("configure", "missing"), + [ + (lambda builder: builder.qubits(2).detectors_json(_DETECTORS_JSON), "program"), + (lambda builder: builder.program(_tagged_two_qubit_program).detectors_json(_DETECTORS_JSON), "qubits"), + (lambda builder: builder.program(_tagged_two_qubit_program).qubits(2), "detectors"), + ], +) +def test_builder_reports_missing_required_setters( + configure: Callable[[GuppyDemBuilder], GuppyDemBuilder], + missing: str, +) -> None: + with pytest.raises(ValueError, match=missing): + configure(DetectorErrorModel.builder()).build() + + +@pytest.mark.parametrize( + ("setter", "value"), + [ + ("program", _tagged_two_qubit_program), + ("qubits", 2), + ("detectors", [Detector(rec[-1])]), + ("observables", [Observable(rec[-1])]), + ("detectors_json", _DETECTORS_JSON), + ("observables_json", _OBSERVABLES_JSON), + ("num_measurements", 2), + ("noise", NoiseModel()), + ("idle_after_2q", 1.0), + ("strip_traced_idles", True), + ("runtime", None), + ("seed", 7), + ("require_hosted_operation_order", True), + ("max_hosted_tick_separation", 3), + ], +) +def test_every_setter_rejects_a_second_call(setter: str, value: Any) -> None: + builder = DetectorErrorModel.builder() + getattr(builder, setter)(value) + + with pytest.raises(ValueError, match=setter): + getattr(builder, setter)(value) + + +@pytest.mark.parametrize( + ("first", "second"), + [ + ("detectors", "detectors_json"), + ("detectors_json", "detectors"), + ("observables", "observables_json"), + ("observables_json", "observables"), + ], +) +def test_typed_and_json_spellings_for_one_role_conflict(first: str, second: str) -> None: + values = { + "detectors": [Detector(rec[-1])], + "detectors_json": _DETECTORS_JSON, + "observables": [Observable(rec[-1])], + "observables_json": _OBSERVABLES_JSON, + } + builder = DetectorErrorModel.builder() + getattr(builder, first)(values[first]) + + with pytest.raises(ValueError, match="cannot be combined"): + getattr(builder, second)(values[second]) + + +@pytest.mark.parametrize("typed_setter", ["detectors", "observables"]) +@pytest.mark.parametrize("typed_first", [False, True]) +def test_num_measurements_conflicts_with_typed_specs(typed_setter: str, typed_first: bool) -> None: + specs = [Detector(rec[-1])] if typed_setter == "detectors" else [Observable(rec[-1])] + builder = DetectorErrorModel.builder() + + def combine_typed_specs_and_measurement_count() -> None: + if typed_first: + getattr(builder, typed_setter)(specs).num_measurements(1) + else: + getattr(builder.num_measurements(1), typed_setter)(specs) + + with pytest.raises(ValueError, match="num_measurements"): + combine_typed_specs_and_measurement_count() + + +def test_builder_result_evaluates_simulation_result_columns() -> None: + build = ( + DetectorErrorModel.builder() + .program(_tagged_two_qubit_program) + .qubits(2) + .detectors([Detector("m0")]) + .observables([Observable("m1")]) + .noise(NoiseModel(p_meas=0.1)) + .build() + ) + columns = ( + pecos.sim(_tagged_two_qubit_program) + .classical(pecos.selene_engine()) + .quantum(pecos.stabilizer()) + .qubits(2) + .seed(7) + .run(3) + .to_shot_map() + .to_dict() + ) + + assert build.evaluate_result_columns(columns) == [([0], 0)] * 3 + + +def test_json_builder_audit_accepts_legacy_id_aliases() -> None: + build = ( + DetectorErrorModel.builder() + .program(_tagged_two_qubit_program) + .qubits(2) + .detectors_json('[{"detector_id":"D0","records":[-2]}]') + .observables_json('[{"observable_id":"L0","records":[-1]}]') + .noise(NoiseModel(p_meas=0.1)) + .build() + ) + + assert build.evaluate_measurements({0: 1, 1: 1}) == ([1], 1) + + +def test_builder_setter_order_does_not_change_the_dem() -> None: + noise = NoiseModel(p1=0.01, p2=0.02, p_meas=0.03, p_prep=0.04) + first = ( + DetectorErrorModel.builder() + .program(_tagged_two_qubit_program) + .qubits(2) + .detectors([Detector(rec[-2])]) + .observables([Observable(rec[-1])]) + .noise(noise) + .seed(11) + .build() + ) + second = ( + DetectorErrorModel.builder() + .seed(11) + .observables([Observable(rec[-1])]) + .noise(noise) + .detectors([Detector(rec[-2])]) + .qubits(2) + .program(_tagged_two_qubit_program) + .build() + ) + + assert first.dem.to_string() == second.dem.to_string() + + +def test_builder_rejects_circuit_inputs_with_from_circuit_guidance() -> None: + with pytest.raises(ValueError, match="from_circuit"): + (DetectorErrorModel.builder().program(TickCircuit()).qubits(1).detectors_json(_DETECTORS_JSON).build()) From a27a8c308f190db85e820d7a796865eeac9c1251 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 4 Aug 2026 08:38:01 -0600 Subject: [PATCH 24/62] Use the unified builder and grouped noise in the workflow guide --- docs/workflows/guppy-dem-decoding.md | 42 ++++++++++++++++++---------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index a981c1bfb..5795ae193 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -121,28 +121,40 @@ across X, Y, and Z, so the single-axis choice is spelled out explicitly. See [Idle Noise](../user-guide/dem-from-guppy.md#idle-noise) for the full family and model semantics. -`build_dem_from_guppy` returns the DEM together with the audit trail and the -result-column evaluator used in stage 4b. `DetectorErrorModel.from_guppy` builds -the same DEM from JSON metadata instead of typed specifications. +`DetectorErrorModel.builder()` configures the run through chained setters and +returns the DEM together with the audit trail and the result-column evaluator +used in stage 4b. A `NoiseModel` carries the entire noise configuration as one +argument. + +The one-call forms `DetectorErrorModel.from_guppy(...)` and +`build_dem_from_guppy(...)` remain available and run this same pipeline; they +take the noise settings as individual keyword arguments instead. ```python -from pecos.qec import build_dem_from_guppy - -dem_build = build_dem_from_guppy( - rep_code_memory, - num_qubits=7, - detectors=detectors, - observables=observables, - idle_after_2q_duration=1.0, - p_idle_linear=0.01, - p_idle_linear_model={"X": 0.25, "Y": 0.25, "Z": 0.5}, - p_idle_sin_squared=0.03, - p_idle_sin_squared_model={"Z": 1.0}, +from pecos.qec import DetectorErrorModel +from pecos.qec.surface import NoiseModel + +noise = NoiseModel( p1=0.002, p2=0.02, p_meas=0.02, p_prep=0.02, + p_idle_linear=0.01, + p_idle_linear_model={"X": 0.25, "Y": 0.25, "Z": 0.5}, + p_idle_sin_squared=0.03, + p_idle_sin_squared_model={"Z": 1.0}, +) + +dem_build = ( + DetectorErrorModel.builder() + .program(rep_code_memory) + .qubits(7) + .detectors(detectors) + .observables(observables) + .noise(noise) + .idle_after_2q(1.0) + .build() ) dem = dem_build.dem From edaf984ad89d1ec91a68880c701e9882ce01fb85 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 4 Aug 2026 09:35:48 -0600 Subject: [PATCH 25/62] Rename NoiseModel to NoiseParameters, export from pecos, add a fluent interface --- docs/user-guide/dem-from-guppy.md | 13 +- docs/workflows/guppy-dem-decoding.md | 8 +- examples/surface/decoder_comparison.py | 8 +- .../surface/dem_decomposition_diagnostics.py | 4 +- examples/surface/dem_method_ler_comparison.py | 4 +- examples/surface/generate_data.py | 8 +- .../graphlike_dem_projection_benchmark.py | 4 +- examples/surface/ml_lookup_decoder.py | 4 +- .../surface/native_dem_threshold_sweep.py | 4 +- .../surface/szz_circuit_quality_report.py | 4 +- examples/surface_code_experiments.ipynb | 6 +- examples/surface_code_noisy_decoding.ipynb | 8 +- examples/surface_code_threshold.ipynb | 6 +- examples/surface_code_thresholds.ipynb | 8 +- python/quantum-pecos/src/pecos/__init__.py | 2 + python/quantum-pecos/src/pecos/qec/dem.py | 34 +-- .../src/pecos/qec/surface/__init__.py | 19 +- .../src/pecos/qec/surface/circuit_gen.py | 4 +- .../src/pecos/qec/surface/decode.py | 234 +++++++++++++++--- .../tests/qec/surface/test_check_plan.py | 8 +- .../qec/surface/test_clifford_deformation.py | 6 +- .../qec/surface/test_idle_noise_families.py | 22 +- .../qec/surface/test_noise_parameters.py | 181 ++++++++++++++ .../qec/surface/test_pauli_mask_harvest.py | 12 +- .../qec/surface/test_pauli_twirl_handoff.py | 34 +-- .../tests/qec/surface/test_surface_decoder.py | 76 +++--- .../qec/surface/test_szz_interaction_basis.py | 38 +-- .../qec/test_decomposed_dem_invariants.py | 8 +- .../tests/qec/test_from_guppy_dem.py | 20 +- .../tests/qec/test_guppy_dem_builder.py | 14 +- .../tests/qec/test_qec_ux_entrypoints.py | 12 +- 31 files changed, 585 insertions(+), 228 deletions(-) create mode 100644 python/quantum-pecos/tests/qec/surface/test_noise_parameters.py diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index ed9f84184..e571aaf0c 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -198,10 +198,13 @@ different DEM. ## Grouping Noise Parameters Both Guppy DEM entry points accept either the existing flat noise keywords or -one `NoiseModel` containing the complete noise configuration. The forms below -are equivalent. Do not mix them in one call: even an explicitly passed flat +one `NoiseParameters` instance containing the complete noise configuration. +`NoiseParameters` is available from the `pecos` top level, and supports both +its original dataclass constructor and immutable `with_` chaining. +The grouped and flat forms below are equivalent. Do not mix them in one call: +even an explicitly passed flat default conflicts with `noise`. When `noise` is present, its defaults fully -replace the entry point's defaults; for example, `NoiseModel().p1` is `0.0`, +replace the entry point's defaults; for example, `NoiseParameters().p1` is `0.0`, not the flat `p1=0.001` default. @@ -210,8 +213,8 @@ from guppylang import guppy from guppylang.std.builtins import result from guppylang.std.quantum import cx, measure, qubit +from pecos import NoiseParameters from pecos.qec import DetectorErrorModel -from pecos.qec.surface import NoiseModel @guppy @@ -228,7 +231,7 @@ common = { "observables_json": '[{"id": "L0", "result_tags": ["m1"]}]', "seed": 0, } -noise = NoiseModel(p1=0.002, p2=0.004, p_meas=0.006, p_prep=0.008) +noise = NoiseParameters().with_p1(0.002).with_p2(0.004).with_p_meas(0.006).with_p_prep(0.008) grouped = DetectorErrorModel.from_guppy(noisy_pair, noise=noise, **common) flat = DetectorErrorModel.from_guppy( diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 5795ae193..17239f4c8 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -123,8 +123,8 @@ model semantics. `DetectorErrorModel.builder()` configures the run through chained setters and returns the DEM together with the audit trail and the result-column evaluator -used in stage 4b. A `NoiseModel` carries the entire noise configuration as one -argument. +used in stage 4b. A `NoiseParameters` instance carries the entire noise +configuration as one argument. The one-call forms `DetectorErrorModel.from_guppy(...)` and `build_dem_from_guppy(...)` remain available and run this same pipeline; they @@ -132,10 +132,10 @@ take the noise settings as individual keyword arguments instead. ```python +from pecos import NoiseParameters from pecos.qec import DetectorErrorModel -from pecos.qec.surface import NoiseModel -noise = NoiseModel( +noise = NoiseParameters( p1=0.002, p2=0.02, p_meas=0.02, diff --git a/examples/surface/decoder_comparison.py b/examples/surface/decoder_comparison.py index 926b5de7b..4d6d77483 100644 --- a/examples/surface/decoder_comparison.py +++ b/examples/surface/decoder_comparison.py @@ -31,7 +31,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from pecos.qec.surface import NoiseModel + from pecos.qec.surface import NoiseParameters @dataclass @@ -69,7 +69,7 @@ class ComparisonPoint: def _build_sampler( distance: int, num_rounds: int, - noise: NoiseModel, + noise: NoiseParameters, basis: str, circuit_source: str, ) -> tuple: @@ -172,7 +172,7 @@ def run_comparison( p_prep_scale: float, ) -> list[ComparisonPoint]: """Run the full comparison and return results.""" - from pecos.qec.surface import NoiseModel + from pecos.qec.surface import NoiseParameters points: list[ComparisonPoint] = [] total_configs = len(distances) * len(error_rates) @@ -182,7 +182,7 @@ def run_comparison( num_rounds = 2 * distance for p in error_rates: config_idx += 1 - noise = NoiseModel( + noise = NoiseParameters( p1=p * p1_scale, p2=p, p_meas=p * p_meas_scale, diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py index 799da3ac2..d7938f8ef 100644 --- a/examples/surface/dem_decomposition_diagnostics.py +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -532,7 +532,7 @@ def run_case( pair_analysis_max_effects: int, ) -> CaseResult: from pecos._traced_circuit import normalize_traced_tick_circuit - from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler + from pecos.qec.surface import NoiseParameters, SurfacePatch, build_native_sampler from pecos.qec.surface.circuit_builder import ( generate_dem_from_tick_circuit_via_stim, ) @@ -542,7 +542,7 @@ def run_case( ) patch = SurfacePatch.create(distance=distance) - noise = NoiseModel(p1=p / 30.0, p2=p, p_meas=p / 3.0, p_prep=p / 3.0) + noise = NoiseParameters(p1=p / 30.0, p2=p, p_meas=p / 3.0, p_prep=p / 3.0) noise_args = { "p1": noise.p1, "p1_gate_rates": SZZ_Z_FRAME_P1_GATE_RATES if interaction_basis == "szz" else None, diff --git a/examples/surface/dem_method_ler_comparison.py b/examples/surface/dem_method_ler_comparison.py index 84dd7ef28..ba692e934 100644 --- a/examples/surface/dem_method_ler_comparison.py +++ b/examples/surface/dem_method_ler_comparison.py @@ -131,12 +131,12 @@ def generate_dems( Returns list of (method_name, raw_dem, decomposed_dem_or_None). decomposed_dem is None when the method cannot produce a graphlike DEM. """ - from pecos.qec.surface import NoiseModel + from pecos.qec.surface import NoiseParameters from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder results = [] - noise = NoiseModel( + noise = NoiseParameters( p1=noise_params.get("p1", 0.0), p2=noise_params.get("p2", 0.0), p_meas=noise_params.get("p_meas", 0.0), diff --git a/examples/surface/generate_data.py b/examples/surface/generate_data.py index 1eb99df7b..9a510ded6 100644 --- a/examples/surface/generate_data.py +++ b/examples/surface/generate_data.py @@ -28,7 +28,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from pecos.qec.surface import NoiseModel + from pecos.qec.surface import NoiseParameters # -- Data model --------------------------------------------------------------- @@ -97,7 +97,7 @@ def _decoder_base_name(name: str) -> str: def _build_sampler( distance: int, num_rounds: int, - noise: NoiseModel, + noise: NoiseParameters, basis: str, circuit_source: str, ) -> tuple: @@ -159,7 +159,7 @@ def generate( duration_multipliers: list[float], ) -> DataShard: """Run the full data generation and return a shard.""" - from pecos.qec.surface import NoiseModel + from pecos.qec.surface import NoiseParameters config = { "distances": distances, @@ -200,7 +200,7 @@ def generate( for d in distances: for p in error_rates: - noise = NoiseModel( + noise = NoiseParameters( p1=p * p1_scale, p2=p, p_meas=p * p_meas_scale, diff --git a/examples/surface/graphlike_dem_projection_benchmark.py b/examples/surface/graphlike_dem_projection_benchmark.py index 98745d728..e6878c097 100644 --- a/examples/surface/graphlike_dem_projection_benchmark.py +++ b/examples/surface/graphlike_dem_projection_benchmark.py @@ -103,7 +103,7 @@ def build_case( variants: list[str], ) -> BenchmarkResult: from pecos._traced_circuit import normalize_traced_tick_circuit - from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler + from pecos.qec.surface import NoiseParameters, SurfacePatch, build_native_sampler from pecos.qec.surface.circuit_builder import ( generate_dem_from_tick_circuit_via_stim, ) @@ -118,7 +118,7 @@ def build_case( ) setup_timings: list[TimedValue] = [] patch = SurfacePatch.create(distance=distance) - noise = NoiseModel(p1=p / 30.0, p2=p, p_meas=p / 3.0, p_prep=p / 3.0) + noise = NoiseParameters(p1=p / 30.0, p2=p, p_meas=p / 3.0, p_prep=p / 3.0) noise_args = { "p1": noise.p1, "p1_gate_rates": SZZ_Z_FRAME_P1_GATE_RATES if interaction_basis == "szz" else None, diff --git a/examples/surface/ml_lookup_decoder.py b/examples/surface/ml_lookup_decoder.py index 6fefa9ca2..9d9ac1a19 100644 --- a/examples/surface/ml_lookup_decoder.py +++ b/examples/surface/ml_lookup_decoder.py @@ -205,10 +205,10 @@ def main(): ler_lookup = errors_lookup / n # Compare with pymatching - from pecos.qec.surface import NoiseModel + from pecos.qec.surface import NoiseParameters from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder - noise_obj = NoiseModel( + noise_obj = NoiseParameters( p1=noise_params["p1"], p2=noise_params["p2"], p_meas=noise_params["p_meas"], diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index 9ce2b9124..c5b5b8183 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -669,11 +669,11 @@ def _decoder_runtime( p_prep_scale: float = 0.5, ) -> _DecoderRuntime: """Build and cache the expensive native decoder-side objects once.""" - from pecos.qec.surface import NoiseModel, SurfaceDecoder + from pecos.qec.surface import NoiseParameters, SurfaceDecoder basis = basis.upper() patch = _surface_patch(distance) - noise = NoiseModel( + noise = NoiseParameters( p1=physical_error_rate * p1_scale, p2=physical_error_rate, p_meas=physical_error_rate * p_meas_scale, diff --git a/examples/surface/szz_circuit_quality_report.py b/examples/surface/szz_circuit_quality_report.py index a050d4f09..04e6d0678 100644 --- a/examples/surface/szz_circuit_quality_report.py +++ b/examples/surface/szz_circuit_quality_report.py @@ -24,7 +24,7 @@ from dem_decomposition_diagnostics import compare_raw_dems, dem_stats from pecos._traced_circuit import normalize_traced_tick_circuit -from pecos.qec.surface import NoiseModel, OpType, SurfacePatch, build_surface_code_circuit +from pecos.qec.surface import NoiseParameters, OpType, SurfacePatch, build_surface_code_circuit from pecos.qec.surface.circuit_builder import ( _analyze_szz_forward_flow, generate_dem_from_tick_circuit_via_stim, @@ -315,7 +315,7 @@ def _dem_report( p: float, p1_ratio: float, ) -> DemReport: - noise = NoiseModel(p1=p / p1_ratio, p2=p, p_prep=p / 3.0, p_meas=p / 3.0) + noise = NoiseParameters(p1=p / p1_ratio, p2=p, p_prep=p / 3.0, p_meas=p / 3.0) noise_args = { "p1": noise.p1, "p1_gate_rates": SZZ_Z_FRAME_P1_GATE_RATES if interaction_basis == "szz" else None, diff --git a/examples/surface_code_experiments.ipynb b/examples/surface_code_experiments.ipynb index 40d1ff49f..8f05fcd51 100644 --- a/examples/surface_code_experiments.ipynb +++ b/examples/surface_code_experiments.ipynb @@ -45,7 +45,7 @@ "import numpy as np\n", "from pecos.compilation_pipeline import compile_guppy_to_hugr\n", "from pecos.guppy.surface import get_num_qubits, make_surface_code\n", - "from pecos.qec.surface import NoiseModel, SurfaceDecoder, SurfacePatch, plot_surface_code\n", + "from pecos.qec.surface import NoiseParameters, SurfaceDecoder, SurfacePatch, plot_surface_code\n", "from selene_sim import DepolarizingErrorModel, IdealErrorModel, SimpleRuntime, Stim, build" ] }, @@ -542,7 +542,7 @@ "\n", " for p in ERROR_RATES:\n", " error_model = DepolarizingErrorModel(p_1q=p, p_2q=p, p_meas=p, p_init=p)\n", - " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p)\n", + " noise = NoiseParameters(p1=p, p2=p, p_meas=p, p_prep=p)\n", "\n", " # Simulate once, decode with all decoders\n", " shots = run_shots(instance, nq, NUM_SHOTS, error_model)\n", @@ -744,7 +744,7 @@ "\n", " for p in ERROR_RATES:\n", " error_model = DepolarizingErrorModel(p_1q=p, p_2q=p, p_meas=p, p_init=p)\n", - " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p)\n", + " noise = NoiseParameters(p1=p, p2=p, p_meas=p, p_prep=p)\n", "\n", " shots = run_shots(instance, nq, NUM_SHOTS, error_model)\n", "\n", diff --git a/examples/surface_code_noisy_decoding.ipynb b/examples/surface_code_noisy_decoding.ipynb index 330d661d7..80ad70990 100644 --- a/examples/surface_code_noisy_decoding.ipynb +++ b/examples/surface_code_noisy_decoding.ipynb @@ -45,7 +45,7 @@ "from pecos.compilation_pipeline import compile_guppy_to_hugr\n", "from pecos.guppy.surface import get_num_qubits, make_surface_code\n", "from pecos.qec.surface import (\n", - " NoiseModel,\n", + " NoiseParameters,\n", " SurfaceDecoder,\n", " SurfacePatch,\n", " plot_surface_code,\n", @@ -101,7 +101,7 @@ } }, "outputs": [], - "source": "from typing import Any\n\n\ndef get_logical_qubits(distance: int, basis: str) -> tuple:\n \"\"\"Get qubits in the logical operator.\"\"\"\n patch = SurfacePatch.create(distance=distance)\n if basis == \"Z\":\n return patch.geometry.logical_z.data_qubits\n return patch.geometry.logical_x.data_qubits\n\n\ndef run_memory_experiment(\n distance: int,\n num_rounds: int,\n num_shots: int,\n basis: str,\n error_model: Any,\n *,\n decode: bool = False,\n decoder_type: str = \"pymatching\",\n) -> dict:\n \"\"\"Run memory experiment and compute logical error rate.\n\n For Z-basis: prepare |0_L>, measure in Z basis, check logical Z parity.\n For X-basis: prepare |+_L>, measure in X basis, check logical X parity.\n\n Args:\n distance: Code distance\n num_rounds: Number of syndrome extraction rounds\n num_shots: Number of shots to run\n basis: 'Z' or 'X' basis\n error_model: Selene error model (IdealErrorModel or DepolarizingErrorModel)\n decode: If True, use decoding to correct errors\n decoder_type: Decoder backend ('pymatching', 'fusion_blossom', 'bp_osd', 'bp_lsd', 'union_find', 'tesseract')\n\n Returns:\n Dictionary with experiment results\n \"\"\"\n patch = SurfacePatch.create(distance=distance)\n logical_qubits = get_logical_qubits(distance, basis)\n\n # Create decoder if needed\n decoder = None\n if decode:\n # Extract noise parameters from error model\n noise = NoiseModel(\n p1=getattr(error_model, \"p_1q\", 0.01),\n p2=getattr(error_model, \"p_2q\", 0.01),\n p_meas=getattr(error_model, \"p_meas\", 0.01),\n p_prep=getattr(error_model, \"p_init\", 0.01),\n )\n decoder = SurfaceDecoder(patch, num_rounds=num_rounds, noise=noise, decoder_type=decoder_type)\n\n # Build circuit\n num_qubits = get_num_qubits(distance)\n prog = make_surface_code(distance=distance, num_rounds=num_rounds, basis=basis)\n hugr_bytes = compile_guppy_to_hugr(prog)\n instance = build(hugr_bytes, name=f\"surface_d{distance}\")\n\n # Run\n num_logical_errors = 0\n num_raw_errors = 0\n\n for shot_results in instance.run_shots(\n simulator=Stim(),\n n_qubits=num_qubits,\n n_shots=num_shots,\n error_model=error_model,\n runtime=SimpleRuntime(),\n n_processes=1,\n ):\n # Collect all syndromes properly (multiple entries per key)\n synx_list = []\n synz_list = []\n final = None\n\n for name, values in shot_results:\n vals = list(values)\n if name == \"synx\":\n synx_list.append(np.array(vals, dtype=np.uint8))\n elif name == \"synz\":\n synz_list.append(np.array(vals, dtype=np.uint8))\n elif name == \"final\":\n final = vals\n\n if final is None:\n continue\n\n # Raw parity check (no decoding)\n raw_parity = sum(final[q] for q in logical_qubits) % 2\n if raw_parity != 0:\n num_raw_errors += 1\n\n if decode and decoder is not None:\n final_arr = np.array(final, dtype=np.uint8)\n\n # Decode based on basis\n if basis == \"Z\":\n is_error, _ = decoder.decode_memory_z(synx_list, synz_list, final_arr)\n else:\n is_error, _ = decoder.decode_memory_x(synx_list, synz_list, final_arr)\n\n if is_error:\n num_logical_errors += 1\n else:\n # No decoding - use raw parity\n if raw_parity != 0:\n num_logical_errors += 1\n\n return {\n \"distance\": distance,\n \"num_shots\": num_shots,\n \"num_logical_errors\": num_logical_errors,\n \"num_raw_errors\": num_raw_errors,\n \"logical_error_rate\": num_logical_errors / num_shots,\n \"raw_error_rate\": num_raw_errors / num_shots,\n \"decoded\": decode,\n \"decoder_type\": decoder_type if decode else None,\n }" + "source": "from typing import Any\n\n\ndef get_logical_qubits(distance: int, basis: str) -> tuple:\n \"\"\"Get qubits in the logical operator.\"\"\"\n patch = SurfacePatch.create(distance=distance)\n if basis == \"Z\":\n return patch.geometry.logical_z.data_qubits\n return patch.geometry.logical_x.data_qubits\n\n\ndef run_memory_experiment(\n distance: int,\n num_rounds: int,\n num_shots: int,\n basis: str,\n error_model: Any,\n *,\n decode: bool = False,\n decoder_type: str = \"pymatching\",\n) -> dict:\n \"\"\"Run memory experiment and compute logical error rate.\n\n For Z-basis: prepare |0_L>, measure in Z basis, check logical Z parity.\n For X-basis: prepare |+_L>, measure in X basis, check logical X parity.\n\n Args:\n distance: Code distance\n num_rounds: Number of syndrome extraction rounds\n num_shots: Number of shots to run\n basis: 'Z' or 'X' basis\n error_model: Selene error model (IdealErrorModel or DepolarizingErrorModel)\n decode: If True, use decoding to correct errors\n decoder_type: Decoder backend ('pymatching', 'fusion_blossom', 'bp_osd', 'bp_lsd', 'union_find', 'tesseract')\n\n Returns:\n Dictionary with experiment results\n \"\"\"\n patch = SurfacePatch.create(distance=distance)\n logical_qubits = get_logical_qubits(distance, basis)\n\n # Create decoder if needed\n decoder = None\n if decode:\n # Extract noise parameters from error model\n noise = NoiseParameters(\n p1=getattr(error_model, \"p_1q\", 0.01),\n p2=getattr(error_model, \"p_2q\", 0.01),\n p_meas=getattr(error_model, \"p_meas\", 0.01),\n p_prep=getattr(error_model, \"p_init\", 0.01),\n )\n decoder = SurfaceDecoder(patch, num_rounds=num_rounds, noise=noise, decoder_type=decoder_type)\n\n # Build circuit\n num_qubits = get_num_qubits(distance)\n prog = make_surface_code(distance=distance, num_rounds=num_rounds, basis=basis)\n hugr_bytes = compile_guppy_to_hugr(prog)\n instance = build(hugr_bytes, name=f\"surface_d{distance}\")\n\n # Run\n num_logical_errors = 0\n num_raw_errors = 0\n\n for shot_results in instance.run_shots(\n simulator=Stim(),\n n_qubits=num_qubits,\n n_shots=num_shots,\n error_model=error_model,\n runtime=SimpleRuntime(),\n n_processes=1,\n ):\n # Collect all syndromes properly (multiple entries per key)\n synx_list = []\n synz_list = []\n final = None\n\n for name, values in shot_results:\n vals = list(values)\n if name == \"synx\":\n synx_list.append(np.array(vals, dtype=np.uint8))\n elif name == \"synz\":\n synz_list.append(np.array(vals, dtype=np.uint8))\n elif name == \"final\":\n final = vals\n\n if final is None:\n continue\n\n # Raw parity check (no decoding)\n raw_parity = sum(final[q] for q in logical_qubits) % 2\n if raw_parity != 0:\n num_raw_errors += 1\n\n if decode and decoder is not None:\n final_arr = np.array(final, dtype=np.uint8)\n\n # Decode based on basis\n if basis == \"Z\":\n is_error, _ = decoder.decode_memory_z(synx_list, synz_list, final_arr)\n else:\n is_error, _ = decoder.decode_memory_x(synx_list, synz_list, final_arr)\n\n if is_error:\n num_logical_errors += 1\n else:\n # No decoding - use raw parity\n if raw_parity != 0:\n num_logical_errors += 1\n\n return {\n \"distance\": distance,\n \"num_shots\": num_shots,\n \"num_logical_errors\": num_logical_errors,\n \"num_raw_errors\": num_raw_errors,\n \"logical_error_rate\": num_logical_errors / num_shots,\n \"raw_error_rate\": num_raw_errors / num_shots,\n \"decoded\": decode,\n \"decoder_type\": decoder_type if decode else None,\n }" }, { "cell_type": "markdown", @@ -1094,7 +1094,7 @@ "\n", "# Create a decoder configuration\n", "patch = SurfacePatch.create(distance=3)\n", - "noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001)\n", + "noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001)\n", "\n", "# Generate DEM using PECOS native pipeline\n", "tc = generate_tick_circuit_from_patch(patch, num_rounds=3, basis=\"Z\")\n", @@ -1646,7 +1646,7 @@ "\n", "**Noise model**:\n", "```python\n", - "noise = NoiseModel(\n", + "noise = NoiseParameters(\n", " p1=0.001, # Single-qubit gate error rate\n", " p2=0.01, # Two-qubit gate error rate\n", " p_meas=0.01, # Measurement error rate\n", diff --git a/examples/surface_code_threshold.ipynb b/examples/surface_code_threshold.ipynb index 90e15a997..99c4a54ce 100644 --- a/examples/surface_code_threshold.ipynb +++ b/examples/surface_code_threshold.ipynb @@ -46,7 +46,7 @@ "from pecos.guppy.surface import get_num_qubits, make_surface_code\n", "from pecos.misc.threshold_curve import func, func6, threshold_fit\n", "from pecos.qec import DagFaultAnalyzer, DemBuilder\n", - "from pecos.qec.surface import NoiseModel, SurfaceDecoder, SurfacePatch\n", + "from pecos.qec.surface import NoiseParameters, SurfaceDecoder, SurfacePatch\n", "from pecos.qec.surface.circuit_builder import _extract_measurement_order, generate_tick_circuit_from_patch\n", "from pecos.qec.surface.decode import build_stim_circuit_from_patch\n", "from selene_sim import DepolarizingErrorModel, SimpleRuntime, Stim, build" @@ -438,7 +438,7 @@ "\n", " for p in ERROR_RATES:\n", " error_model = DepolarizingErrorModel(p_1q=p, p_2q=p, p_meas=p, p_init=p)\n", - " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p)\n", + " noise = NoiseParameters(p1=p, p2=p, p_meas=p, p_prep=p)\n", "\n", " t0 = time.time()\n", " shots = run_shots(instance, nq, NUM_SHOTS, error_model)\n", @@ -487,7 +487,7 @@ "\n", " for p in ERROR_RATES:\n", " error_model = DepolarizingErrorModel(p_1q=p, p_2q=p, p_meas=p, p_init=p)\n", - " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p)\n", + " noise = NoiseParameters(p1=p, p2=p, p_meas=p, p_prep=p)\n", "\n", " t0 = time.time()\n", " shots = run_shots(instance, nq, NUM_SHOTS, error_model)\n", diff --git a/examples/surface_code_thresholds.ipynb b/examples/surface_code_thresholds.ipynb index 2f5050c2e..7a4cd36e4 100644 --- a/examples/surface_code_thresholds.ipynb +++ b/examples/surface_code_thresholds.ipynb @@ -60,7 +60,7 @@ "# For Stim-based sampling (fast)\n", "import stim\n", "from pecos.qec.surface import (\n", - " NoiseModel,\n", + " NoiseParameters,\n", " SurfacePatch,\n", " generate_surface_code_dem,\n", ")\n", @@ -339,7 +339,7 @@ "def generate_code_capacity_dem(patch: SurfacePatch, num_rounds: int, p: float) -> str:\n", " \"\"\"Generate a code-capacity DEM (data errors only, perfect measurements).\"\"\"\n", " # Use phenomenological DEM with p_meas=0\n", - " noise = NoiseModel(p1=0, p2=p, p_meas=0, p_prep=0)\n", + " noise = NoiseParameters(p1=0, p2=p, p_meas=0, p_prep=0)\n", " return generate_surface_code_dem(patch, num_rounds=1, noise=noise, stab_type=\"Z\")\n", "\n", "# Test DEM generation\n", @@ -424,7 +424,7 @@ "source": [ "def generate_phenomenological_dem(patch: SurfacePatch, num_rounds: int, p: float) -> str:\n", " \"\"\"Generate a phenomenological DEM (data + measurement errors).\"\"\"\n", - " noise = NoiseModel(p1=0, p2=p, p_meas=p, p_prep=0)\n", + " noise = NoiseParameters(p1=0, p2=p, p_meas=p, p_prep=0)\n", " return generate_surface_code_dem(patch, num_rounds=num_rounds, noise=noise, stab_type=\"Z\")\n", "\n", "# Test DEM generation\n", @@ -510,7 +510,7 @@ "def generate_circuit_level_dem(patch: SurfacePatch, num_rounds: int, p: float) -> str:\n", " \"\"\"Generate a circuit-level DEM using PECOS native fault propagation.\"\"\"\n", " # Use same error rate for all noise sources (standard depolarizing)\n", - " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p)\n", + " noise = NoiseParameters(p1=p, p2=p, p_meas=p, p_prep=p)\n", " return generate_circuit_level_dem_from_builder(patch, num_rounds=num_rounds, noise=noise, basis=\"Z\")\n", "\n", "# Test DEM generation\n", diff --git a/python/quantum-pecos/src/pecos/__init__.py b/python/quantum-pecos/src/pecos/__init__.py index 2a6854a93..16e2bd99e 100644 --- a/python/quantum-pecos/src/pecos/__init__.py +++ b/python/quantum-pecos/src/pecos/__init__.py @@ -281,6 +281,7 @@ def __getattr__(name: str): # Import program wrappers from programs submodule for convenience # These can also be accessed via pecos.programs.Qasm, etc. from pecos.programs import Guppy, Hugr, PhirJson, ProgramWrapper, Qasm, Qis, Wasm, Wat +from pecos.qec.surface.decode import NoiseParameters from pecos.tracing import ( capture_qis_operation_trace, qis_operation_trace_to_tick_circuit, @@ -333,6 +334,7 @@ def __getattr__(name: str): "Inexact", "Integer", "Nanoseconds", + "NoiseParameters", "Numeric", "Pauli", "PauliString", diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 30cb899cc..502917e15 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -60,7 +60,7 @@ from typing_extensions import Self from pecos.qec.dem_spec import Detector, Observable - from pecos.qec.surface.decode import NoiseModel + from pecos.qec.surface.decode import NoiseParameters P1Weights = Mapping[str, float] P2Weights = Mapping[str, float] @@ -115,7 +115,7 @@ def __repr__(self) -> str: _NOISE_DEFAULT_P2 = _NoiseKeywordDefault(0.01) -def _resolve_guppy_noise(noise: NoiseModel | None, call_arguments: Mapping[str, Any]) -> dict[str, Any]: +def _resolve_guppy_noise(noise: NoiseParameters | None, call_arguments: Mapping[str, Any]) -> dict[str, Any]: """Resolve one grouped or flat Guppy DEM noise configuration.""" explicitly_flat = [ name for name in _GUPPY_NOISE_KEYWORDS if not isinstance(call_arguments[name], _NoiseKeywordDefault) @@ -137,10 +137,10 @@ def _resolve_guppy_noise(noise: NoiseModel | None, call_arguments: Mapping[str, # Import locally so dem.py remains below surface.decode in the package's # initialization graph instead of introducing a module-level back edge. - from pecos.qec.surface.decode import NoiseModel + from pecos.qec.surface.decode import NoiseParameters - if not isinstance(noise, NoiseModel): - msg = f"noise must be a NoiseModel or None, got {type(noise).__name__}" + if not isinstance(noise, NoiseParameters): + msg = f"noise must be a NoiseParameters instance or None, got {type(noise).__name__}" raise TypeError(msg) unsupported = { @@ -150,7 +150,7 @@ def _resolve_guppy_noise(noise: NoiseModel | None, call_arguments: Mapping[str, } for field, guidance in unsupported.items(): if getattr(noise, field) is not None: - msg = f"NoiseModel.{field} is not supported by the Guppy DEM entry points; {guidance}" + msg = f"NoiseParameters.{field} is not supported by the Guppy DEM entry points; {guidance}" raise ValueError(msg) expanded = {name: getattr(noise, name) for name in _GUPPY_NOISE_KEYWORDS} @@ -331,7 +331,7 @@ def from_guppy( detectors_json: str, observables_json: str = "[]", num_measurements: int | None = None, - noise: NoiseModel | None = None, + noise: NoiseParameters | None = None, p1: float = _NOISE_DEFAULT_P1, p1_weights: P1Weights | None = _NOISE_DEFAULT_NONE, p2: float = _NOISE_DEFAULT_P2, @@ -442,7 +442,7 @@ def from_guppy( circuit; if given, it must match the traced count. noise: Complete grouped noise configuration. When supplied, its values replace all flat noise keywords, including this entry - point's defaults. In particular, ``NoiseModel`` defaults such + point's defaults. In particular, ``NoiseParameters`` defaults such as ``p1=0.0`` apply instead of this function's ``p1=0.001``. Mixing ``noise`` with any flat noise keyword is rejected. p1: Single-qubit gate Pauli error rate. @@ -605,7 +605,7 @@ def from_guppy( if num_measurements is not None: builder.num_measurements(num_measurements) # Same-module private seam: the flat keyword surface stays on this - # function while noise() remains strictly a NoiseModel setter. + # function while noise() remains strictly a NoiseParameters-instance setter. return builder._legacy_noise(noise, noise_keywords).build().dem # noqa: SLF001 @@ -1012,21 +1012,21 @@ def num_measurements(self, count: int) -> Self: self._set_once("_num_measurements", count, "num_measurements") return self - def noise(self, noise_model: NoiseModel) -> Self: + def noise(self, noise_model: NoiseParameters) -> Self: """Set the complete grouped noise configuration.""" - from pecos.qec.surface.decode import NoiseModel + from pecos.qec.surface.decode import NoiseParameters - if not isinstance(noise_model, NoiseModel): - msg = f"noise() requires a NoiseModel, got {type(noise_model).__name__}" + if not isinstance(noise_model, NoiseParameters): + msg = f"noise() requires a NoiseParameters instance, got {type(noise_model).__name__}" raise TypeError(msg) self._set_once("_noise", noise_model, "noise") return self - def _legacy_noise(self, noise_model: NoiseModel | None, flat_keywords: Mapping[str, Any]) -> Self: + def _legacy_noise(self, noise_model: NoiseParameters | None, flat_keywords: Mapping[str, Any]) -> Self: """Carry the legacy entry points' flat noise keywords through the builder. Private: the flat keyword surface stays on ``from_guppy`` and - ``build_dem_from_guppy``; ``noise()`` accepts only a ``NoiseModel``. + ``build_dem_from_guppy``; ``noise()`` accepts only a ``NoiseParameters``. """ self._set_once("_noise", (_LEGACY_NOISE, noise_model, dict(flat_keywords)), "noise") return self @@ -1291,7 +1291,7 @@ def build_dem_from_guppy( num_qubits: int, detectors: Sequence[Detector], observables: Sequence[Observable] = (), - noise: NoiseModel | None = None, + noise: NoiseParameters | None = None, p1: float = _NOISE_DEFAULT_P1, p1_weights: P1Weights | None = _NOISE_DEFAULT_NONE, p2: float = _NOISE_DEFAULT_P2, @@ -1354,7 +1354,7 @@ def build_dem_from_guppy( measurement-reference forms as ``detectors``. noise: Complete grouped noise configuration. When supplied, its values replace all flat noise keywords, including this entry point's - defaults. In particular, ``NoiseModel`` defaults such as + defaults. In particular, ``NoiseParameters`` defaults such as ``p1=0.0`` apply instead of this function's ``p1=0.001``. Mixing ``noise`` with any flat noise keyword is rejected. p1: Single-qubit gate Pauli error rate. diff --git a/python/quantum-pecos/src/pecos/qec/surface/__init__.py b/python/quantum-pecos/src/pecos/qec/surface/__init__.py index f2c0df689..3789be7a7 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/surface/__init__.py @@ -17,6 +17,8 @@ parity_matrix_z: Generate Z parity check matrix """ +import warnings + # Circuit generation from geometry (unified abstraction) from pecos.qec.surface._clifford_deformation import ( LocalCliffordFrame, @@ -62,7 +64,7 @@ DecoderType, DecodingResult, NativeSampler, - NoiseModel, + NoiseParameters, SimulationResult, SurfaceDecoder, build_memory_circuit, @@ -124,6 +126,20 @@ get_stab_schedule, ) + +def __getattr__(name: str) -> type[NoiseParameters]: + """Resolve deprecated surface-code attributes lazily.""" + if name == "NoiseModel": + warnings.warn( + "NoiseModel is deprecated; use NoiseParameters instead (from pecos import NoiseParameters).", + DeprecationWarning, + stacklevel=2, + ) + return NoiseParameters + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) + + __all__ = [ # Twirling config (Pauli-frame randomization) "GuppyRngMaskConfig", @@ -170,6 +186,7 @@ "DecodingResult", "NativeSampler", "NoiseModel", + "NoiseParameters", "RUNTIME_IDLE_TIME_UNITS_PER_SECOND", "SimulationResult", "SurfaceDecoder", diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_gen.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_gen.py index 0886f965e..742f997bf 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_gen.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_gen.py @@ -468,13 +468,13 @@ def compare_dems( Returns: Dictionary with comparison results """ - from pecos.qec.surface.decode import NoiseModel, generate_surface_code_dem + from pecos.qec.surface.decode import NoiseParameters, generate_surface_code_dem # Generate circuit-level DEM via Stim stim_dem = generate_circuit_level_dem(patch, num_rounds, basis, p=p) # Generate phenomenological DEM - noise = NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p) + noise = NoiseParameters(p1=p, p2=p, p_meas=p, p_prep=p) stab_type = "X" if basis.upper() == "X" else "Z" phenom_dem = generate_surface_code_dem(patch, num_rounds, noise, stab_type) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index e9322106d..9eac2e134 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -44,6 +44,7 @@ from __future__ import annotations import math +import warnings from collections.abc import Mapping, Sequence from dataclasses import dataclass, replace from enum import Enum @@ -139,8 +140,8 @@ class DecoderType(str, Enum): @dataclass -class NoiseModel: - """Circuit-level noise parameters for QEC simulation. +class NoiseParameters: + """Noise parameters consumed during detector error model construction. Matches the Rust ``NoiseConfig`` type. All parameters are optional beyond the four base rates. @@ -290,6 +291,146 @@ def __post_init__(self) -> None: self.p_idle_coherent = None self.p_idle_coherent_model = None + def with_p1(self, p1: float) -> NoiseParameters: + """Return a copy with ``p1`` set to the given value.""" + return replace(self, p1=p1) + + def with_p1_weights(self, p1_weights: P1Weights | None) -> NoiseParameters: + """Return a copy with ``p1_weights`` set to the given value.""" + return replace(self, p1_weights=p1_weights) + + def with_p2(self, p2: float) -> NoiseParameters: + """Return a copy with ``p2`` set to the given value.""" + return replace(self, p2=p2) + + def with_p2_szz(self, p2_szz: float | None) -> NoiseParameters: + """Return a copy with ``p2_szz`` set to the given value.""" + return replace(self, p2_szz=p2_szz) + + def with_p2_szzdg(self, p2_szzdg: float | None) -> NoiseParameters: + """Return a copy with ``p2_szzdg`` set to the given value.""" + return replace(self, p2_szzdg=p2_szzdg) + + def with_p2_weights(self, p2_weights: P2Weights | None) -> NoiseParameters: + """Return a copy with ``p2_weights`` set to the given value.""" + return replace(self, p2_weights=p2_weights) + + def with_p2_replacement_approximation( + self, + p2_replacement_approximation: str | None, + ) -> NoiseParameters: + """Return a copy with ``p2_replacement_approximation`` set to the given value.""" + return replace(self, p2_replacement_approximation=p2_replacement_approximation) + + def with_p_meas(self, p_meas: float) -> NoiseParameters: + """Return a copy with ``p_meas`` set to the given value.""" + return replace(self, p_meas=p_meas) + + def with_p_prep(self, p_prep: float) -> NoiseParameters: + """Return a copy with ``p_prep`` set to the given value.""" + return replace(self, p_prep=p_prep) + + def with_p_idle(self, p_idle: float | None) -> NoiseParameters: + """Return a copy with ``p_idle`` set to the given value.""" + return replace(self, p_idle=p_idle) + + def with_t1(self, t1: float | None) -> NoiseParameters: + """Return a copy with ``t1`` set to the given value.""" + return replace(self, t1=t1) + + def with_t2(self, t2: float | None) -> NoiseParameters: + """Return a copy with ``t2`` set to the given value.""" + return replace(self, t2=t2) + + def with_p_idle_linear_rate(self, p_idle_linear_rate: float | None) -> NoiseParameters: + """Return a copy with ``p_idle_linear_rate`` set to the given value.""" + return replace(self, p_idle_linear_rate=p_idle_linear_rate) + + def with_p_idle_quadratic_rate(self, p_idle_quadratic_rate: float | None) -> NoiseParameters: + """Return a copy with ``p_idle_quadratic_rate`` set to the given value.""" + return replace(self, p_idle_quadratic_rate=p_idle_quadratic_rate) + + def with_p_idle_x_linear_rate(self, p_idle_x_linear_rate: float | None) -> NoiseParameters: + """Return a copy with ``p_idle_x_linear_rate`` set to the given value.""" + return replace(self, p_idle_x_linear_rate=p_idle_x_linear_rate) + + def with_p_idle_y_linear_rate(self, p_idle_y_linear_rate: float | None) -> NoiseParameters: + """Return a copy with ``p_idle_y_linear_rate`` set to the given value.""" + return replace(self, p_idle_y_linear_rate=p_idle_y_linear_rate) + + def with_p_idle_z_linear_rate(self, p_idle_z_linear_rate: float | None) -> NoiseParameters: + """Return a copy with ``p_idle_z_linear_rate`` set to the given value.""" + return replace(self, p_idle_z_linear_rate=p_idle_z_linear_rate) + + def with_p_idle_x_quadratic_rate(self, p_idle_x_quadratic_rate: float | None) -> NoiseParameters: + """Return a copy with ``p_idle_x_quadratic_rate`` set to the given value.""" + return replace(self, p_idle_x_quadratic_rate=p_idle_x_quadratic_rate) + + def with_p_idle_y_quadratic_rate(self, p_idle_y_quadratic_rate: float | None) -> NoiseParameters: + """Return a copy with ``p_idle_y_quadratic_rate`` set to the given value.""" + return replace(self, p_idle_y_quadratic_rate=p_idle_y_quadratic_rate) + + def with_p_idle_z_quadratic_rate(self, p_idle_z_quadratic_rate: float | None) -> NoiseParameters: + """Return a copy with ``p_idle_z_quadratic_rate`` set to the given value.""" + return replace(self, p_idle_z_quadratic_rate=p_idle_z_quadratic_rate) + + def with_p_idle_quadratic_sine_rate( + self, + p_idle_quadratic_sine_rate: float | None, + ) -> NoiseParameters: + """Return a copy with ``p_idle_quadratic_sine_rate`` set to the given value.""" + return replace(self, p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate) + + def with_p_idle_x_quadratic_sine_rate( + self, + p_idle_x_quadratic_sine_rate: float | None, + ) -> NoiseParameters: + """Return a copy with ``p_idle_x_quadratic_sine_rate`` set to the given value.""" + return replace(self, p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate) + + def with_p_idle_y_quadratic_sine_rate( + self, + p_idle_y_quadratic_sine_rate: float | None, + ) -> NoiseParameters: + """Return a copy with ``p_idle_y_quadratic_sine_rate`` set to the given value.""" + return replace(self, p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate) + + def with_p_idle_z_quadratic_sine_rate( + self, + p_idle_z_quadratic_sine_rate: float | None, + ) -> NoiseParameters: + """Return a copy with ``p_idle_z_quadratic_sine_rate`` set to the given value.""" + return replace(self, p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate) + + # The idle families take their rate and model together: a model without a + # rate is inert and rejected, and __post_init__ translates a family into the + # canonical per-axis fields and then clears it -- so setting the two halves + # in separate calls would make the second call collide with the per-axis + # values the first one produced. + def with_p_idle_linear( + self, + p_idle_linear: float | None, + model: Mapping[str, float] | None = None, + ) -> NoiseParameters: + """Return a copy with the linear idle family set to the given rate and model.""" + return replace(self, p_idle_linear=p_idle_linear, p_idle_linear_model=model) + + def with_p_idle_sin_squared( + self, + p_idle_sin_squared: float | None, + model: Mapping[str, float] | None = None, + ) -> NoiseParameters: + """Return a copy with the sine-law idle family set to the given rate and model.""" + return replace(self, p_idle_sin_squared=p_idle_sin_squared, p_idle_sin_squared_model=model) + + def with_p_idle_coherent( + self, + p_idle_coherent: float | None, + model: Mapping[str, float] | None = None, + ) -> NoiseParameters: + """Return a copy with the coherent idle family set to the given rate and model.""" + return replace(self, p_idle_coherent=p_idle_coherent, p_idle_coherent_model=model) + @property def effective_p_idle_z_linear_rate(self) -> float | None: """Z-axis linear idle rate, accepting the legacy alias.""" @@ -331,7 +472,7 @@ def for_runtime_idle_time_units( self, *, time_units_per_second: float = RUNTIME_IDLE_TIME_UNITS_PER_SECOND, - ) -> NoiseModel: + ) -> NoiseParameters: """Return a copy whose idle noise is expressed in runtime replay units. Selene-compatible runtimes emit idle durations in seconds, but the @@ -370,10 +511,10 @@ def for_runtime_idle_time_units( ) @staticmethod - def uniform(physical_error_rate: float) -> NoiseModel: + def uniform(physical_error_rate: float) -> NoiseParameters: """Create a uniform circuit-level noise model from one physical error rate.""" p = _validate_probability("physical_error_rate", physical_error_rate) - return NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p) + return NoiseParameters(p1=p, p2=p, p_meas=p, p_prep=p) @property def is_noiseless(self) -> bool: @@ -399,6 +540,19 @@ def physical_error_rate(self) -> float: return max(rates) +def __getattr__(name: str) -> Any: + """Resolve deprecated module attributes lazily.""" + if name == "NoiseModel": + warnings.warn( + "NoiseModel is deprecated; use NoiseParameters instead (from pecos import NoiseParameters).", + DeprecationWarning, + stacklevel=2, + ) + return NoiseParameters + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) + + def _normalize_pauli_weights(weights: P1Weights | P2Weights | None) -> tuple[tuple[str, float], ...] | None: if weights is None: return None @@ -424,7 +578,7 @@ def _p2_weights_dict(p2_weights: P2Weights | None) -> dict[str, float] | None: return None if normalized is None else dict(normalized) -def _p2_gate_rates_dict(noise: NoiseModel) -> dict[str, float] | None: +def _p2_gate_rates_dict(noise: NoiseParameters) -> dict[str, float] | None: rates: dict[str, float] = {} if noise.p2_szz is not None: rates["SZZ"] = noise.p2_szz @@ -600,7 +754,7 @@ def det_id(round_: int, check: int) -> int: def generate_surface_code_dem( patch: SurfacePatch, num_rounds: int, - noise: NoiseModel, + noise: NoiseParameters, stab_type: str = "Z", ) -> str: """Generate a phenomenological DEM for surface code decoding. @@ -1359,7 +1513,7 @@ def _uses_dedicated_idle_noise( ) -def _noise_uses_dedicated_idle_noise(noise: NoiseModel) -> bool: +def _noise_uses_dedicated_idle_noise(noise: NoiseParameters) -> bool: """Return True when this noise model requires explicit idle locations.""" return _uses_dedicated_idle_noise( p_idle=noise.p_idle, @@ -1381,7 +1535,7 @@ def _noise_uses_dedicated_idle_noise(noise: NoiseModel) -> bool: def _reject_szz_unlowered_physical_noise( - noise: NoiseModel, + noise: NoiseParameters, interaction_basis: str, circuit_source: Literal["abstract", "traced_qis"], ) -> None: @@ -1403,7 +1557,7 @@ def _reject_szz_unlowered_physical_noise( def _use_szz_physical_prefixes( - noise: NoiseModel, + noise: NoiseParameters, interaction_basis: str, circuit_source: Literal["abstract", "traced_qis"], ) -> bool: @@ -1430,7 +1584,7 @@ def _szz_z_frame_p1_gate_rates(topology: _CachedNativeSurfaceTopology) -> dict[s def _with_noise_compat( builder: Any, - noise: NoiseModel, + noise: NoiseParameters, *, p1_gate_rates: Mapping[str, float] | None = None, ) -> Any: @@ -1645,7 +1799,7 @@ def _cached_surface_native_topology( def _dem_string_from_cached_surface_topology( topology: _CachedNativeSurfaceTopology, - noise: NoiseModel, + noise: NoiseParameters, *, decompose_errors: bool, dem_decomposition: NativeDemDecomposition = "source_graphlike", @@ -1772,7 +1926,7 @@ def _cached_surface_native_dem_string( ) return _dem_string_from_cached_surface_topology( topology, - NoiseModel( + NoiseParameters( p1=p1, p1_weights=p1_weights, p2=p2, @@ -1813,7 +1967,7 @@ def _cached_parsed_dem(dem_str: str) -> Any: def _build_native_sampler_from_cached_surface_topology( topology: _CachedNativeSurfaceTopology, - noise: NoiseModel, + noise: NoiseParameters, *, sampling_model: Literal[ "dem", @@ -1871,7 +2025,7 @@ def _build_native_sampler_from_cached_surface_topology( def generate_circuit_level_dem_from_builder( patch: SurfacePatch, num_rounds: int, - noise: NoiseModel, + noise: NoiseParameters, basis: str = "Z", *, decompose_errors: bool = False, @@ -1956,10 +2110,10 @@ def generate_circuit_level_dem_from_builder( DEM string in standard format Example: - >>> from pecos.qec.surface import SurfacePatch, NoiseModel + >>> from pecos.qec.surface import SurfacePatch, NoiseParameters >>> from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder >>> patch = SurfacePatch.create(distance=3) - >>> noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01) + >>> noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01) >>> dem = generate_circuit_level_dem_from_builder(patch, num_rounds=3, noise=noise) """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) @@ -2047,7 +2201,7 @@ def generate_circuit_level_dem_from_builder( def generate_circuit_level_dem( distance: int, num_rounds: int, - noise: NoiseModel, + noise: NoiseParameters, basis: str = "Z", ) -> str: """Generate a circuit-level DEM using Stim's surface code generator. @@ -2071,8 +2225,8 @@ def generate_circuit_level_dem( DEM string in Stim format Example: - >>> from pecos.qec.surface import generate_circuit_level_dem, NoiseModel - >>> noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01) + >>> from pecos.qec.surface import generate_circuit_level_dem, NoiseParameters + >>> noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01) >>> dem = generate_circuit_level_dem(distance=3, num_rounds=3, noise=noise, basis="Z") """ import stim @@ -2103,7 +2257,7 @@ def generate_circuit_level_dem( def build_stim_circuit_from_patch( patch: SurfacePatch, num_rounds: int, - noise: NoiseModel | None = None, + noise: NoiseParameters | None = None, basis: str = "Z", ) -> stim.Circuit: """Build a Stim circuit from our patch geometry and CNOT schedule. @@ -2134,11 +2288,11 @@ def build_stim_circuit_from_patch( Example: >>> from pecos.qec.surface import ( ... SurfacePatch, - ... NoiseModel, + ... NoiseParameters, ... build_stim_circuit_from_patch, ... ) >>> patch = SurfacePatch.create(distance=3) - >>> noise = NoiseModel(p2=0.01, p_meas=0.01) + >>> noise = NoiseParameters(p2=0.01, p_meas=0.01) >>> circuit = build_stim_circuit_from_patch(patch, num_rounds=3, noise=noise) >>> dem = circuit.detector_error_model() """ @@ -2345,7 +2499,7 @@ def stab_coords(stab: Stabilizer) -> tuple[float, float]: def generate_dem_from_patch( patch: SurfacePatch, num_rounds: int, - noise: NoiseModel, + noise: NoiseParameters, basis: str = "Z", *, decompose_errors: bool = True, @@ -2371,11 +2525,11 @@ def generate_dem_from_patch( Example: >>> from pecos.qec.surface import ( ... SurfacePatch, - ... NoiseModel, + ... NoiseParameters, ... generate_dem_from_patch, ... ) >>> patch = SurfacePatch.create(distance=3) - >>> noise = NoiseModel(p2=0.01, p_meas=0.01) + >>> noise = NoiseParameters(p2=0.01, p_meas=0.01) >>> dem = generate_dem_from_patch(patch, num_rounds=3, noise=noise) """ circuit = build_stim_circuit_from_patch(patch, num_rounds, noise, basis) @@ -2393,7 +2547,7 @@ class SurfaceDecoder: >>> from pecos.qec.surface import SurfacePatch, SurfaceDecoder >>> patch = SurfacePatch.create(distance=3) >>> # Default: PyMatching MWPM - >>> decoder = SurfaceDecoder(patch, num_rounds=3, noise=NoiseModel(p2=0.01, p_meas=0.01)) + >>> decoder = SurfaceDecoder(patch, num_rounds=3, noise=NoiseParameters(p2=0.01, p_meas=0.01)) >>> # Alternative: FusionBlossom MWPM >>> decoder = SurfaceDecoder(patch, num_rounds=3, decoder_type="fusion_blossom") >>> # Alternative: BP+OSD (LDPC) @@ -2405,7 +2559,7 @@ def __init__( self, patch: SurfacePatch, num_rounds: int = 1, - noise: NoiseModel | None = None, + noise: NoiseParameters | None = None, decoder_type: Literal[ "pymatching", "pymatching_correlated", @@ -2479,7 +2633,7 @@ def __init__( self.patch = patch self.num_rounds = num_rounds - self.noise = noise or NoiseModel(p2=0.01, p_meas=0.01) + self.noise = noise or NoiseParameters(p2=0.01, p_meas=0.01) self.decoder_type = DecoderType(decoder_type) self.use_circuit_level_dem = use_circuit_level_dem if circuit_level_dem_mode not in { @@ -3420,16 +3574,16 @@ class SimulationResult: def _memory_noise_model( physical_error_rate: float | None, - noise_model: NoiseModel | None, -) -> NoiseModel: - """Resolve the surface-memory noise inputs into an explicit NoiseModel.""" + noise_model: NoiseParameters | None, +) -> NoiseParameters: + """Resolve the surface-memory noise inputs into explicit noise parameters.""" if noise_model is not None: if physical_error_rate is not None: msg = "pass either physical_error_rate or noise_model, not both" raise ValueError(msg) return noise_model p = 0.001 if physical_error_rate is None else physical_error_rate - return NoiseModel.uniform(p) + return NoiseParameters.uniform(p) def _recommended_graphlike_decomposition_for_decoder(decoder_type: str) -> NativeDemDecomposition: @@ -3443,7 +3597,7 @@ def surface_code_memory( *, distance: int = 3, physical_error_rate: float | None = None, - noise_model: NoiseModel | None = None, + noise_model: NoiseParameters | None = None, shots: int = 1000, rounds: int | None = None, basis: str = "Z", @@ -3571,7 +3725,7 @@ def run_noisy_memory_experiment( num_rounds: int, num_shots: int, basis: str, - noise: NoiseModel, + noise: NoiseParameters, *, decode: bool = True, decoder_type: str = "pymatching", @@ -3604,8 +3758,8 @@ def run_noisy_memory_experiment( SimulationResult with error rate statistics Example: - >>> from pecos.qec.surface import run_noisy_memory_experiment, NoiseModel - >>> noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + >>> from pecos.qec.surface import run_noisy_memory_experiment, NoiseParameters + >>> noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) >>> result = run_noisy_memory_experiment( ... distance=3, ... num_rounds=3, @@ -3831,7 +3985,7 @@ def sample( def build_native_sampler( patch: SurfacePatch, num_rounds: int, - noise: NoiseModel, + noise: NoiseParameters, basis: str = "Z", ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", @@ -3905,9 +4059,9 @@ def build_native_sampler( NativeSampler that can generate samples for threshold estimation Example: - >>> from pecos.qec.surface import SurfacePatch, NoiseModel, build_native_sampler + >>> from pecos.qec.surface import SurfacePatch, NoiseParameters, build_native_sampler >>> patch = SurfacePatch.create(distance=5) - >>> noise = NoiseModel(p1=0.001, p2=0.001, p_meas=0.001) + >>> noise = NoiseParameters(p1=0.001, p2=0.001, p_meas=0.001) >>> sampler = build_native_sampler(patch, num_rounds=5, noise=noise) >>> detection_events, observable_flips = sampler.sample(num_shots=10000) """ diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py index fa45b9696..385ee7831 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -316,11 +316,11 @@ def test_surface_code_memory_rejects_plan_basis_mismatch() -> None: def test_check_plan_does_not_change_current_szz_dem() -> None: - from pecos.qec.surface import NoiseModel, SurfacePatch + from pecos.qec.surface import NoiseParameters, SurfacePatch from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.001, p_meas=0.001, p_prep=0.001) + noise = NoiseParameters(p2=0.001, p_meas=0.001, p_prep=0.001) by_basis = generate_circuit_level_dem_from_builder( patch, @@ -689,13 +689,13 @@ def test_direct_surface_renderers_reject_plan_basis_mismatch() -> None: def test_native_sampler_records_resolved_check_plan() -> None: - from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler + from pecos.qec.surface import NoiseParameters, SurfacePatch, build_native_sampler patch = SurfacePatch.create(distance=3) sampler = build_native_sampler( patch, num_rounds=1, - noise=NoiseModel(p2=0.001), + noise=NoiseParameters(p2=0.001), check_plan="szz_current_v1", sampling_model="influence_dem", ) diff --git a/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py index 401d0fbff..4e966df65 100644 --- a/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py +++ b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py @@ -3,7 +3,7 @@ import pytest from pecos.qec.surface import ( LocalCliffordFrame, - NoiseModel, + NoiseParameters, OpType, SignedPauli, SurfacePatch, @@ -206,7 +206,7 @@ def test_global_axis_cycle_f_native_abstract_dem_path_accepts_frame_policy() -> dem = generate_circuit_level_dem_from_builder( patch, num_rounds=1, - noise=NoiseModel(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), + noise=NoiseParameters(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), basis="Z", circuit_source="abstract", interaction_basis="szz", @@ -223,7 +223,7 @@ def test_checkerboard_native_abstract_dem_path_accepts_frame_policy(policy: str) dem = generate_circuit_level_dem_from_builder( patch, num_rounds=1, - noise=NoiseModel(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), + noise=NoiseParameters(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), basis="Z", circuit_source="abstract", interaction_basis="szz", diff --git a/python/quantum-pecos/tests/qec/surface/test_idle_noise_families.py b/python/quantum-pecos/tests/qec/surface/test_idle_noise_families.py index edf74b93e..20f947c33 100644 --- a/python/quantum-pecos/tests/qec/surface/test_idle_noise_families.py +++ b/python/quantum-pecos/tests/qec/surface/test_idle_noise_families.py @@ -1,11 +1,11 @@ from __future__ import annotations import pytest -from pecos.qec.surface import NoiseModel, SurfacePatch, TwirlConfig +from pecos.qec.surface import NoiseParameters, SurfacePatch, TwirlConfig from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder -def _native_surface_dem(noise: NoiseModel) -> bytes: +def _native_surface_dem(noise: NoiseParameters) -> bytes: patch = SurfacePatch.create(distance=3) dem = generate_circuit_level_dem_from_builder( patch, @@ -21,9 +21,9 @@ def _native_surface_dem(noise: NoiseModel) -> bytes: def test_linear_family_matches_per_axis_native_surface_dem() -> None: rate = 0.003 - structured = _native_surface_dem(NoiseModel(p_idle_linear=rate)) + structured = _native_surface_dem(NoiseParameters(p_idle_linear=rate)) primitive = _native_surface_dem( - NoiseModel( + NoiseParameters( p_idle_x_linear_rate=rate / 3.0, p_idle_y_linear_rate=rate / 3.0, p_idle_z_linear_rate=rate / 3.0, @@ -37,18 +37,18 @@ def test_sin_squared_family_matches_per_axis_native_surface_dem() -> None: rate = 0.03 structured = _native_surface_dem( - NoiseModel( + NoiseParameters( p_idle_sin_squared=rate, p_idle_sin_squared_model={"Z": 1.0}, ), ) - primitive = _native_surface_dem(NoiseModel(p_idle_z_quadratic_sine_rate=rate)) + primitive = _native_surface_dem(NoiseParameters(p_idle_z_quadratic_sine_rate=rate)) assert structured == primitive def test_structured_families_survive_runtime_idle_unit_conversion() -> None: - noise = NoiseModel( + noise = NoiseParameters( p_idle_linear=0.3, p_idle_sin_squared=0.2, p_idle_sin_squared_model={"Z": 1.0}, @@ -79,7 +79,7 @@ def test_structured_families_survive_runtime_idle_unit_conversion() -> None: ) def test_structured_family_conflicts_with_corresponding_primitive(kwargs: dict[str, object]) -> None: with pytest.raises(ValueError, match="cannot be combined"): - NoiseModel(**kwargs) + NoiseParameters(**kwargs) @pytest.mark.parametrize( @@ -92,11 +92,11 @@ def test_structured_family_conflicts_with_corresponding_primitive(kwargs: dict[s ) def test_bare_z_only_alias_warns_through_noise_model(field: str) -> None: with pytest.warns(DeprecationWarning, match=field): - NoiseModel(**{field: 0.01}) + NoiseParameters(**{field: 0.01}) def test_idle_memory_rates_include_translated_family_values() -> None: - noise = NoiseModel( + noise = NoiseParameters( p_idle_linear=0.3, p_idle_sin_squared=0.2, p_idle_sin_squared_model={"Z": 1.0}, @@ -109,4 +109,4 @@ def test_idle_memory_rates_include_translated_family_values() -> None: def test_nonzero_coherent_family_is_rejected_by_standard_dem_model() -> None: with pytest.raises(ValueError, match="cannot represent coherent idle noise"): - NoiseModel(p_idle_coherent=0.01) + NoiseParameters(p_idle_coherent=0.01) diff --git a/python/quantum-pecos/tests/qec/surface/test_noise_parameters.py b/python/quantum-pecos/tests/qec/surface/test_noise_parameters.py new file mode 100644 index 000000000..d7a943c5f --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_noise_parameters.py @@ -0,0 +1,181 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Contract tests for DEM-construction noise parameters.""" + +from __future__ import annotations + +import warnings +from dataclasses import fields + +import pecos.qec.surface as surface +import pytest +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import cx, measure, qubit +from pecos import NoiseParameters +from pecos.qec import DetectorErrorModel + + +@guppy +def _two_qubit_program() -> None: + q0 = qubit() + q1 = qubit() + cx(q0, q1) + result("m0", measure(q0)) + result("m1", measure(q1)) + + +def _dem_bytes(noise: NoiseParameters) -> bytes: + build = ( + DetectorErrorModel.builder() + .program(_two_qubit_program) + .qubits(2) + .detectors_json('[{"id":0,"result_tags":["m0"]}]') + .observables_json('[{"id":0,"result_tags":["m1"]}]') + .noise(noise) + .build() + ) + return build.dem.to_string().encode() + + +def test_fluent_chain_matches_constructor_and_dem() -> None: + constructor = NoiseParameters( + p1=0.001, + p1_weights={"X": 0.2, "Y": 0.3, "Z": 0.5}, + p2=0.01, + p2_weights={"IX": 1.0}, + p2_replacement_approximation="ignore_gate_removal", + p_meas=0.002, + p_prep=0.003, + ) + fluent = ( + NoiseParameters() + .with_p1(0.001) + .with_p1_weights({"X": 0.2, "Y": 0.3, "Z": 0.5}) + .with_p2(0.01) + .with_p2_weights({"IX": 1.0}) + .with_p2_replacement_approximation("ignore_gate_removal") + .with_p_meas(0.002) + .with_p_prep(0.003) + ) + + assert fluent == constructor + assert _dem_bytes(fluent) == _dem_bytes(constructor) + + +# The idle-family model fields are the one deliberate exception to the +# mechanical rule: they are set through their family's rate setter, because a +# model without a rate is inert and the two cannot be set in separate calls. +_FAMILY_MODEL_FIELDS = { + "p_idle_linear_model", + "p_idle_sin_squared_model", + "p_idle_coherent_model", +} + + +def test_every_field_has_a_mechanical_fluent_setter() -> None: + field_names = {field.name for field in fields(NoiseParameters)} + + assert len(field_names) == 30 + for field_name in field_names - _FAMILY_MODEL_FIELDS: + assert callable(getattr(NoiseParameters, f"with_{field_name}")), field_name + + +def test_family_models_are_set_through_their_rate_setter() -> None: + import inspect + + for family in ("p_idle_linear", "p_idle_sin_squared", "p_idle_coherent"): + signature = inspect.signature(getattr(NoiseParameters, f"with_{family}")) + assert "model" in signature.parameters, family + + +def test_fluent_setter_returns_a_new_object() -> None: + original = NoiseParameters(p1=0.001) + + updated = original.with_p1(0.002) + + assert updated is not original + assert original.p1 == 0.001 + assert updated.p1 == 0.002 + + +def test_structured_family_survives_fluent_chain_and_runtime_conversion() -> None: + noise = NoiseParameters().with_p_idle_linear(0.3).with_p1(0.001).with_p_meas(0.002) + + converted = noise.for_runtime_idle_time_units(time_units_per_second=10.0) + + assert converted.p_idle_x_linear_rate == pytest.approx(0.01) + assert converted.p_idle_y_linear_rate == pytest.approx(0.01) + assert converted.p_idle_z_linear_rate == pytest.approx(0.01) + assert converted.p_idle_linear is None + assert converted.p_idle_linear_model is None + + +def test_idle_family_rate_and_model_set_together() -> None: + # The family halves must be settable in ONE call: __post_init__ translates a + # family into per-axis fields and clears it, so a separate model-setting call + # would collide with the per-axis values the rate call just produced. + noise = NoiseParameters().with_p_idle_linear(0.01, {"Z": 1.0}).with_p1(0.001) + + assert noise.p_idle_z_linear_rate == pytest.approx(0.01) + assert noise.p_idle_x_linear_rate in (None, 0.0) + assert noise.p_idle_linear is None + assert noise.p1 == pytest.approx(0.001) + + +def test_idle_families_have_no_separate_model_setters() -> None: + # A model without a rate is inert and rejected, so exposing a lone model + # setter would only ever produce an error or a collision. + for name in ( + "with_p_idle_linear_model", + "with_p_idle_sin_squared_model", + "with_p_idle_coherent_model", + ): + assert not hasattr(NoiseParameters, name), name + + +def test_each_idle_family_round_trips_through_runtime_conversion() -> None: + linear = NoiseParameters().with_p_idle_linear(0.3, {"Z": 1.0}) + sine = NoiseParameters().with_p_idle_sin_squared(0.2, {"X": 1.0}) + + assert linear.for_runtime_idle_time_units(time_units_per_second=10.0).p_idle_z_linear_rate == pytest.approx(0.03) + converted_sine = sine.for_runtime_idle_time_units(time_units_per_second=10.0) + assert converted_sine.p_idle_x_quadratic_sine_rate == pytest.approx(0.02) + + +def test_deprecated_alias_warns_and_returns_noise_parameters() -> None: + with pytest.warns( + DeprecationWarning, + match=r"NoiseModel.*NoiseParameters.*from pecos import NoiseParameters", + ): + legacy = surface.NoiseModel(p1=0.001) + + assert type(legacy) is NoiseParameters + assert legacy == NoiseParameters(p1=0.001) + + +def test_public_import_paths_refer_to_the_same_class() -> None: + from pecos import NoiseParameters as TopLevelNoiseParameters + from pecos.qec.surface import NoiseParameters as SurfaceNoiseParameters + + assert TopLevelNoiseParameters is NoiseParameters + assert SurfaceNoiseParameters is NoiseParameters + + +def test_legacy_idle_alias_setter_warns_but_family_setter_does_not() -> None: + with pytest.warns(DeprecationWarning, match="p_idle_linear_rate"): + NoiseParameters().with_p_idle_linear_rate(0.01) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + NoiseParameters().with_p_idle_linear(0.01) + + assert not caught + + +def test_chaining_order_does_not_matter() -> None: + first = NoiseParameters().with_p1(0.001).with_p2(0.01).with_p_meas(0.002).with_p_prep(0.003) + second = NoiseParameters().with_p_prep(0.003).with_p_meas(0.002).with_p2(0.01).with_p1(0.001) + + assert first == second diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py index e627c41e2..d4848e4ec 100644 --- a/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py @@ -4,7 +4,7 @@ import pytest from pecos.qec.surface import ( GuppyRngMaskConfig, - NoiseModel, + NoiseParameters, SurfacePatch, TwirlConfig, build_memory_circuit, @@ -482,7 +482,7 @@ def test_runtime_twirled_theta0_demask_null( sampler = build_native_sampler( patch_d3, num_rounds=num_rounds, - noise=NoiseModel(), + noise=NoiseParameters(), basis=basis, twirl=TwirlConfig(), ) @@ -542,7 +542,7 @@ def test_runtime_gate_local_twirled_theta0_demask_null( sampler = build_native_sampler( patch_d3, num_rounds=num_rounds, - noise=NoiseModel(), + noise=NoiseParameters(), basis=basis, twirl=twirl, ) @@ -608,7 +608,7 @@ def _assert_canonical_frame_output_matches_lookup( sampler = build_native_sampler( patch, num_rounds=num_rounds, - noise=NoiseModel(), + noise=NoiseParameters(), basis=basis, twirl=TwirlConfig(), ) @@ -673,7 +673,7 @@ def test_runtime_gate_local_canonical_frame_output_matches_lookup( sampler = build_native_sampler( patch_d3, num_rounds=num_rounds, - noise=NoiseModel(), + noise=NoiseParameters(), basis=basis, twirl=abstract_twirl, ) @@ -763,7 +763,7 @@ def test_harvested_runtime_masks_drive_fixed_dem_sampler_null(patch_d3: SurfaceP sampler = build_native_sampler( patch_d3, num_rounds=num_rounds, - noise=NoiseModel(), + noise=NoiseParameters(), basis="Z", twirl=twirl, ) diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py index a8d705039..c05923587 100644 --- a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py @@ -4,7 +4,7 @@ import pytest from pecos.qec.surface import ( GuppyRngMaskConfig, - NoiseModel, + NoiseParameters, SurfacePatch, TwirlConfig, build_memory_circuit, @@ -177,7 +177,7 @@ def test_demask_helper_cancels_known_pauli_frame_xor() -> None: sampler = build_native_sampler( patch, num_rounds=2, - noise=NoiseModel(), + noise=NoiseParameters(), basis="Z", twirl=TwirlConfig(), ) @@ -212,7 +212,7 @@ def test_native_sampler_accepts_harvested_uint8_pauli_masks() -> None: sampler = build_native_sampler( patch, num_rounds=2, - noise=NoiseModel(), + noise=NoiseParameters(), basis="Z", twirl=TwirlConfig(), ) @@ -233,21 +233,21 @@ def test_canonical_frame_output_reuses_raw_abstract_sampler_topology() -> None: raw = build_native_sampler( patch, num_rounds=2, - noise=NoiseModel(), + noise=NoiseParameters(), basis="Z", twirl=TwirlConfig(), ) canonical = build_native_sampler( patch, num_rounds=2, - noise=NoiseModel(), + noise=NoiseParameters(), basis="Z", twirl=TwirlConfig(frame_output="canonical"), ) scaled = build_native_sampler( patch, num_rounds=2, - noise=NoiseModel(), + noise=NoiseParameters(), basis="Z", twirl=TwirlConfig(twirl_probability=0.5), ) @@ -298,7 +298,7 @@ def test_abstract_twirl_builders_reject_unsupported_config( build_native_sampler( patch, num_rounds=2, - noise=NoiseModel(), + noise=NoiseParameters(), basis="Z", twirl=twirl, ) @@ -307,7 +307,7 @@ def test_abstract_twirl_builders_reject_unsupported_config( generate_circuit_level_dem_from_builder( patch, num_rounds=2, - noise=NoiseModel(), + noise=NoiseParameters(), basis="Z", twirl=twirl, ) @@ -315,7 +315,7 @@ def test_abstract_twirl_builders_reject_unsupported_config( def test_twirl_sine_law_idle_noise_builds_dem_and_sampler() -> None: patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p_idle_x_quadratic_sine_rate=0.03) + noise = NoiseParameters(p_idle_x_quadratic_sine_rate=0.03) twirl = TwirlConfig() dem = generate_circuit_level_dem_from_builder( @@ -345,17 +345,17 @@ def test_twirl_sine_law_idle_noise_builds_dem_and_sampler() -> None: @pytest.mark.parametrize( ("label", "noise"), [ - ("depolarizing", NoiseModel(p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001)), - ("uniform_idle", NoiseModel(p_idle=0.002)), - ("t1_t2", NoiseModel(t1=1000.0, t2=800.0)), - ("linear_idle", NoiseModel(p_idle_z_linear_rate=0.001)), - ("quadratic_idle", NoiseModel(p_idle_z_quadratic_rate=0.01)), - ("sine_law_idle", NoiseModel(p_idle_x_quadratic_sine_rate=0.03)), + ("depolarizing", NoiseParameters(p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001)), + ("uniform_idle", NoiseParameters(p_idle=0.002)), + ("t1_t2", NoiseParameters(t1=1000.0, t2=800.0)), + ("linear_idle", NoiseParameters(p_idle_z_linear_rate=0.001)), + ("quadratic_idle", NoiseParameters(p_idle_z_quadratic_rate=0.01)), + ("sine_law_idle", NoiseParameters(p_idle_x_quadratic_sine_rate=0.03)), ], ) def test_twirling_does_not_change_canonical_dem( label: str, - noise: NoiseModel, + noise: NoiseParameters, ) -> None: del label patch = SurfacePatch.create(distance=3) @@ -381,7 +381,7 @@ def test_twirling_does_not_change_canonical_dem( def test_gate_local_twirling_does_not_change_canonical_dem() -> None: patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001) + noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001) untwirled = generate_circuit_level_dem_from_builder( patch, 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 1c3af712a..94d8190fc 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -13,7 +13,7 @@ import numpy as np import pytest from pecos.qec.surface import ( - NoiseModel, + NoiseParameters, SurfaceDecoder, SurfacePatch, generate_dem_from_tick_circuit, @@ -51,11 +51,11 @@ def _count_singleton_error_parts(dem: str) -> int: class TestNoiseModel: - """Tests for NoiseModel dataclass.""" + """Tests for NoiseParameters dataclass.""" def test_default_values(self) -> None: """Default noise model should have zero error rates.""" - noise = NoiseModel() + noise = NoiseParameters() assert noise.p1 == 0.0 assert noise.p2 == 0.0 assert noise.p_meas == 0.0 @@ -63,15 +63,15 @@ def test_default_values(self) -> None: def test_is_noiseless(self) -> None: """Test is_noiseless property.""" - assert NoiseModel().is_noiseless - assert not NoiseModel(p1=0.01).is_noiseless - assert not NoiseModel(p2=0.01).is_noiseless - assert not NoiseModel(p_meas=0.01).is_noiseless - assert not NoiseModel(p_prep=0.01).is_noiseless + assert NoiseParameters().is_noiseless + assert not NoiseParameters(p1=0.01).is_noiseless + assert not NoiseParameters(p2=0.01).is_noiseless + assert not NoiseParameters(p_meas=0.01).is_noiseless + assert not NoiseParameters(p_prep=0.01).is_noiseless def test_physical_error_rate(self) -> None: """Test physical_error_rate property.""" - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.005, p_prep=0.002) + noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.005, p_prep=0.002) assert noise.physical_error_rate == 0.01 # max of all rates @@ -154,7 +154,7 @@ class TestSurfaceDecoder: def test_create_decoder_d3(self) -> None: """Create decoder for distance-3 patch.""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) decoder = SurfaceDecoder(patch, num_rounds=1, noise=noise) assert decoder.patch == patch @@ -164,7 +164,7 @@ def test_create_decoder_d3(self) -> None: def test_create_decoder_d5(self) -> None: """Create decoder for distance-5 patch.""" patch = SurfacePatch.create(distance=5) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) decoder = SurfaceDecoder(patch, num_rounds=3, noise=noise) assert decoder.patch == patch @@ -173,7 +173,7 @@ def test_create_decoder_d5(self) -> None: def test_decoder_types(self) -> None: """Test different decoder type options.""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) # PyMatching (default) d1 = SurfaceDecoder(patch, decoder_type="pymatching", noise=noise) @@ -200,7 +200,7 @@ def test_circuit_level_pymatching_uses_correlations_by_default(self, monkeypatch import pecos.qec.surface.decode as decode_module patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) seen: dict[str, object] = {} def wrapped_generate(*_args: object, **_kwargs: object) -> str: @@ -242,7 +242,7 @@ def test_circuit_level_uncorrelated_pymatching_uses_plain_dem(self, monkeypatch: import pecos.qec.surface.decode as decode_module patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) seen: dict[str, object] = {} def wrapped_generate(*_args: object, **_kwargs: object) -> str: @@ -279,7 +279,7 @@ def from_dem(cls, dem: str) -> object: def test_correlated_pymatching_requires_circuit_level_dem(self) -> None: """The correlated option needs DEM metadata and should fail without it.""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) decoder = SurfaceDecoder( patch, decoder_type="pymatching_correlated", @@ -293,7 +293,7 @@ def test_correlated_pymatching_requires_circuit_level_dem(self) -> None: def test_correlated_pymatching_requires_decomposed_dem_mode(self) -> None: """The explicit correlated option needs decomposed DEM metadata.""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) decoder = SurfaceDecoder( patch, decoder_type="pymatching_correlated", @@ -315,7 +315,7 @@ def test_recommended_memory_workflow_uses_terminal_graphlike_for_pymatching(self def test_get_dem(self) -> None: """Test DEM generation via decoder.""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) decoder = SurfaceDecoder(patch, num_rounds=3, noise=noise) # Test circuit-level DEM (default) @@ -337,7 +337,7 @@ def test_get_dem_caches_circuit_level_dem(self, monkeypatch: pytest.MonkeyPatch) import pecos.qec.surface.decode as decode_module patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) decoder = SurfaceDecoder( patch, num_rounds=3, @@ -366,7 +366,7 @@ def test_get_dem_passes_interaction_basis_to_native_builder(self, monkeypatch: p import pecos.qec.surface.decode as decode_module patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) seen: dict[str, object] = {} def wrapped_generate(*_args: object, **kwargs: object) -> str: @@ -395,7 +395,7 @@ def test_get_dem_passes_terminal_graphlike_mode_to_native_builder( import pecos.qec.surface.decode as decode_module patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) seen: dict[str, object] = {} def wrapped_generate(*_args: object, **kwargs: object) -> str: @@ -421,7 +421,7 @@ def wrapped_generate(*_args: object, **kwargs: object) -> str: def test_decode_trivial_syndrome_z(self) -> None: """Decode trivial Z syndrome (no errors).""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) decoder = SurfaceDecoder(patch, num_rounds=1, noise=noise) # All-zero syndrome @@ -446,7 +446,7 @@ def test_decode_trivial_syndrome_z(self) -> None: def test_decode_trivial_syndrome_x(self) -> None: """Decode trivial X syndrome (no errors).""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) decoder = SurfaceDecoder(patch, num_rounds=1, noise=noise) num_x_stab = len(patch.geometry.x_stabilizers) @@ -536,7 +536,7 @@ class TestDemGeneration: def test_generate_surface_code_dem_z(self) -> None: """Generate Z-stabilizer DEM.""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) dem = generate_surface_code_dem(patch, num_rounds=3, noise=noise, stab_type="Z") @@ -548,7 +548,7 @@ def test_generate_surface_code_dem_z(self) -> None: def test_generate_surface_code_dem_x(self) -> None: """Generate X-stabilizer DEM.""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) dem = generate_surface_code_dem(patch, num_rounds=3, noise=noise, stab_type="X") @@ -560,7 +560,7 @@ def test_generate_dem_from_patch_can_skip_stim_decomposition(self) -> None: from pecos.qec.surface.decode import generate_dem_from_patch patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) full_dem = generate_dem_from_patch(patch, num_rounds=4, noise=noise, basis="X", decompose_errors=False) decomposed_dem = generate_dem_from_patch(patch, num_rounds=4, noise=noise, basis="X", decompose_errors=True) @@ -586,7 +586,7 @@ def test_native_circuit_level_dem_threads_ancilla_budget(self) -> None: from pecos.qec.surface.decode import 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) + noise = NoiseParameters(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} full_tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis="X") @@ -636,7 +636,7 @@ def test_constrained_budget_uses_cache_and_matches_fresh_build(self) -> None: ) patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + noise = NoiseParameters(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 @@ -682,7 +682,7 @@ def test_unconstrained_budget_spellings_collapse_to_one_dem(self) -> None: 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) + noise = NoiseParameters(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. @@ -716,7 +716,7 @@ def test_constrained_budget_sampler_builds_for_all_models(self) -> None: 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) + noise = NoiseParameters(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, @@ -864,7 +864,7 @@ def test_native_circuit_level_dem_cache_respects_patch_geometry(self) -> None: from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder patch = SurfacePatch.create(dx=3, dz=5) - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + noise = NoiseParameters(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} tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis="X") @@ -884,7 +884,7 @@ def test_native_circuit_level_dem_cache_inserts_idle_gates_only_for_idle_noise(s from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder patch = SurfacePatch.create(distance=3) - base_noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + base_noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) base_params = { "p1": base_noise.p1, "p2": base_noise.p2, @@ -902,7 +902,7 @@ def test_native_circuit_level_dem_cache_inserts_idle_gates_only_for_idle_noise(s basis="X", ) - idle_noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001, p_idle=0.002) + idle_noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001, p_idle=0.002) idle_tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis="X") idle_tc.fill_idle_gates() expected_idle_dem = generate_dem_from_tick_circuit( @@ -929,7 +929,7 @@ def test_traced_qis_native_dem_and_sampler_build(self) -> None: _require_selene_runtime() patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.001, p2=0.001, p_meas=0.001, p_prep=0.001) + noise = NoiseParameters(p1=0.001, p2=0.001, p_meas=0.001, p_prep=0.001) dem = generate_circuit_level_dem_from_builder( patch, @@ -1014,7 +1014,7 @@ def extract_errors(dem_str: str) -> dict[str, float]: return errors patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.003, p2=0.003, p_meas=0.003, p_prep=0.003) + noise = NoiseParameters(p1=0.003, p2=0.003, p_meas=0.003, p_prep=0.003) for basis in ("X", "Z"): tc = _build_surface_tick_circuit_for_native_model( @@ -1069,8 +1069,8 @@ def test_traced_qis_native_topology_cache_is_shared_across_public_apis(self) -> _require_selene_runtime() patch = SurfacePatch.create(distance=3) - noise_a = NoiseModel(p1=0.001, p2=0.001, p_meas=0.001, p_prep=0.001) - noise_b = NoiseModel(p1=0.002, p2=0.002, p_meas=0.002, p_prep=0.002) + noise_a = NoiseParameters(p1=0.001, p2=0.001, p_meas=0.001, p_prep=0.001) + noise_b = NoiseParameters(p1=0.002, p2=0.002, p_meas=0.002, p_prep=0.002) _cached_surface_native_topology.cache_clear() _cached_surface_native_dem_string.cache_clear() @@ -1136,7 +1136,7 @@ def test_generate_dem_from_tick_circuit_maximal_decomposition_prefers_singletons def test_dem_detector_count(self) -> None: """DEM should have correct number of detectors.""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) num_rounds = 3 dem = generate_surface_code_dem( @@ -1156,7 +1156,7 @@ def test_dem_detector_count(self) -> None: def test_dem_single_round(self) -> None: """DEM with single round should have boundary measurement errors.""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) dem = generate_surface_code_dem(patch, num_rounds=1, noise=noise, stab_type="Z") diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py index 83a9e7799..f6580fce7 100644 --- a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -11,7 +11,7 @@ import pytest import stim from pecos._traced_circuit import normalize_traced_tick_circuit -from pecos.qec.surface import NoiseModel, SurfacePatch, TwirlConfig +from pecos.qec.surface import NoiseParameters, SurfacePatch, TwirlConfig from pecos.qec.surface.circuit_builder import ( OpType, SurfaceCircuitStep, @@ -492,7 +492,7 @@ def test_szz_runtime_barriers_allow_strict_traced_hosted_order() -> None: dem = generate_circuit_level_dem_from_builder( patch, num_rounds=1, - noise=NoiseModel(p1=0.0, p2=0.001, p_meas=0.0, p_prep=0.0), + noise=NoiseParameters(p1=0.0, p2=0.001, p_meas=0.0, p_prep=0.0), circuit_source="traced_qis", interaction_basis="szz", szz_runtime_barriers="data-prefix", @@ -581,7 +581,7 @@ def test_round_order_szz_noiseless_detector_record_equivalence( def test_szz_native_dem_path_uses_interaction_basis() -> None: patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.0, p2=0.01, p2_weights={"ZI": 1.0}, p_meas=0.001, p_prep=0.001) + noise = NoiseParameters(p1=0.0, p2=0.01, p2_weights={"ZI": 1.0}, p_meas=0.001, p_prep=0.001) cx_dem = generate_circuit_level_dem_from_builder( patch, @@ -606,25 +606,25 @@ def test_szz_native_dem_respects_gate_specific_p2_overrides() -> None: inherited_dem = generate_circuit_level_dem_from_builder( patch, num_rounds=2, - noise=NoiseModel(p1=0.0, p2=0.01, p2_weights={"ZI": 1.0}), + noise=NoiseParameters(p1=0.0, p2=0.01, p2_weights={"ZI": 1.0}), interaction_basis="szz", ) no_szz_dem = generate_circuit_level_dem_from_builder( patch, num_rounds=2, - noise=NoiseModel(p1=0.0, p2=0.01, p2_szz=0.0, p2_weights={"ZI": 1.0}), + noise=NoiseParameters(p1=0.0, p2=0.01, p2_szz=0.0, p2_weights={"ZI": 1.0}), interaction_basis="szz", ) no_szzdg_dem = generate_circuit_level_dem_from_builder( patch, num_rounds=2, - noise=NoiseModel(p1=0.0, p2=0.01, p2_szzdg=0.0, p2_weights={"ZI": 1.0}), + noise=NoiseParameters(p1=0.0, p2=0.01, p2_szzdg=0.0, p2_weights={"ZI": 1.0}), interaction_basis="szz", ) override_only_dem = generate_circuit_level_dem_from_builder( patch, num_rounds=2, - noise=NoiseModel( + noise=NoiseParameters( p1=0.0, p2=0.0, p2_szz=0.01, @@ -645,14 +645,14 @@ def test_szz_native_influence_sampler_respects_override_only_p2() -> None: zero_sampler = build_native_sampler( patch, num_rounds=2, - noise=NoiseModel(p1=0.0, p2=0.0, p2_szz=0.0, p2_szzdg=0.0, p2_weights={"ZI": 1.0}), + noise=NoiseParameters(p1=0.0, p2=0.0, p2_szz=0.0, p2_szzdg=0.0, p2_weights={"ZI": 1.0}), interaction_basis="szz", sampling_model="influence_dem", ) active_sampler = build_native_sampler( patch, num_rounds=2, - noise=NoiseModel(p1=0.0, p2=0.0, p2_szz=0.01, p2_szzdg=0.01, p2_weights={"ZI": 1.0}), + noise=NoiseParameters(p1=0.0, p2=0.0, p2_szz=0.01, p2_szzdg=0.01, p2_weights={"ZI": 1.0}), interaction_basis="szz", sampling_model="influence_dem", ) @@ -665,7 +665,7 @@ def test_szz_native_influence_sampler_respects_override_only_p2() -> None: def test_szz_prefix_lowering_preserves_p2_influence_dem(basis: str) -> None: patch = SurfacePatch.create(distance=3) patch_key = _surface_patch_cache_key(patch) - noise = NoiseModel(p1=0.0, p2=0.01, p_meas=0.0, p_prep=0.0) + noise = NoiseParameters(p1=0.0, p2=0.01, p_meas=0.0, p_prep=0.0) plain = _surface_native_topology( patch_key, @@ -831,7 +831,7 @@ def test_szz_native_sampler_accepts_p1_with_physical_prefix_lowering(sampling_mo sampler = build_native_sampler( patch, num_rounds=1, - noise=NoiseModel(p1=0.001), + noise=NoiseParameters(p1=0.001), interaction_basis="szz", sampling_model=sampling_model, ) @@ -853,7 +853,7 @@ def test_szz_native_dem_rejects_traced_qis_idle_noise() -> None: generate_circuit_level_dem_from_builder( patch, num_rounds=1, - noise=NoiseModel(p_idle=0.001), + noise=NoiseParameters(p_idle=0.001), interaction_basis="szz", circuit_source="traced_qis", ) @@ -973,7 +973,7 @@ def test_szz_public_native_dem_accepts_traced_qis_p1() -> None: dem = generate_circuit_level_dem_from_builder( patch, num_rounds=1, - noise=NoiseModel(p1=0.001), + noise=NoiseParameters(p1=0.001), interaction_basis="szz", circuit_source="traced_qis", ) @@ -995,7 +995,7 @@ def test_szz_public_traced_qis_dem_matches_stim_with_z_frame_p1_free(basis: str) interaction_basis="szz", ) normalize_traced_tick_circuit(tick_circuit, context="SZZ public traced-QIS p1 test") - noise = NoiseModel(p1=0.001) + noise = NoiseParameters(p1=0.001) native_errors = _raw_dem_errors( generate_circuit_level_dem_from_builder( @@ -1052,7 +1052,7 @@ def test_szz_native_dem_accepts_p1_with_physical_prefix_lowering() -> None: dem = generate_circuit_level_dem_from_builder( patch, num_rounds=1, - noise=NoiseModel(p1=0.001), + noise=NoiseParameters(p1=0.001), interaction_basis="szz", ) @@ -1067,7 +1067,7 @@ def test_szz_native_sampler_accepts_idle_with_physical_prefix_lowering(sampling_ sampler = build_native_sampler( patch, num_rounds=1, - noise=NoiseModel(p_idle=0.001), + noise=NoiseParameters(p_idle=0.001), interaction_basis="szz", sampling_model=sampling_model, ) @@ -1084,7 +1084,7 @@ def test_szz_native_dem_accepts_idle_with_physical_prefix_lowering() -> None: dem = generate_circuit_level_dem_from_builder( patch, num_rounds=1, - noise=NoiseModel(p_idle=0.001), + noise=NoiseParameters(p_idle=0.001), interaction_basis="szz", ) @@ -1096,7 +1096,7 @@ def test_szz_native_dem_accepts_idle_with_physical_prefix_lowering() -> None: def test_szz_idle_dem_uses_lowered_prefix_topology(basis: str) -> None: patch = SurfacePatch.create(distance=3) patch_key = _surface_patch_cache_key(patch) - noise = NoiseModel(p_idle_z_linear_rate=0.01) + noise = NoiseParameters(p_idle_z_linear_rate=0.01) actual = generate_circuit_level_dem_from_builder( patch, @@ -1178,7 +1178,7 @@ def test_szz_virtual_prefix_ticks_do_not_contribute_idle_dem() -> None: patch, num_rounds=1, basis="Z", - noise=NoiseModel(p_idle_z_linear_rate=0.01), + noise=NoiseParameters(p_idle_z_linear_rate=0.01), interaction_basis="szz", decompose_errors=False, ) diff --git a/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py b/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py index 45db9a669..ec3d99dd9 100644 --- a/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py +++ b/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py @@ -147,7 +147,7 @@ def build_source_tracked_dem(distance: int, basis: str, rounds: int = 20) -> obj """Build and cache a source-tracked native DEM for one surface-code shape.""" from pecos.qec import DagFaultAnalyzer, DemBuilder from pecos.qec.surface import ( - NoiseModel, + NoiseParameters, SurfacePatch, generate_tick_circuit_from_patch, get_measurement_order_from_tick_circuit, @@ -158,7 +158,7 @@ def build_source_tracked_dem(distance: int, basis: str, rounds: int = 20) -> obj dag = tc.to_dag_circuit() analyzer = DagFaultAnalyzer(dag) influence_map = analyzer.build_influence_map() - noise = NoiseModel(p1=0.01, p2=0.01, p_meas=0.01, p_prep=0.01) + noise = NoiseParameters(p1=0.01, p2=0.01, p_meas=0.01, p_prep=0.01) builder = DemBuilder(influence_map) builder.with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_prep) @@ -175,7 +175,7 @@ def test_dem_builder_accepts_public_surface_descriptor_json() -> None: """Public surface descriptor JSON should reproduce the legacy builder output.""" from pecos.qec import DagFaultAnalyzer, DemBuilder from pecos.qec.surface import ( - NoiseModel, + NoiseParameters, SurfacePatch, generate_tick_circuit_from_patch, get_detector_descriptors_from_tick_circuit, @@ -187,7 +187,7 @@ def test_dem_builder_accepts_public_surface_descriptor_json() -> None: tc = generate_tick_circuit_from_patch(patch, num_rounds=4, basis="X") dag = tc.to_dag_circuit() influence_map = DagFaultAnalyzer(dag).build_influence_map() - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) def _build(detectors_json: str, observables_json: str | None) -> object: """Build one source-tracked DEM from serialized detector metadata.""" 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 674d05242..c8d2a824d 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -23,7 +23,7 @@ ) from pecos.guppy import get_num_qubits, make_surface_code from pecos.qec import Detector, DetectorErrorModel, Observable, build_dem_from_guppy, rec -from pecos.qec.surface import RUNTIME_IDLE_TIME_UNITS_PER_SECOND, NoiseModel, SurfacePatch +from pecos.qec.surface import RUNTIME_IDLE_TIME_UNITS_PER_SECOND, NoiseParameters, SurfacePatch from pecos.qec.surface.circuit_builder import ( generate_tick_circuit_from_patch, ) @@ -259,7 +259,7 @@ def _noise_model_entrypoint_dem(entrypoint: str, **kwargs): def test_noise_model_matches_flat_gate_noise(entrypoint: str) -> None: rates = {"p1": 0.003, "p2": 0.007, "p_meas": 0.011, "p_prep": 0.013} - grouped = _noise_model_entrypoint_dem(entrypoint, noise=NoiseModel(**rates)) + grouped = _noise_model_entrypoint_dem(entrypoint, noise=NoiseParameters(**rates)) flat = _noise_model_entrypoint_dem(entrypoint, **rates) assert grouped.to_string() == flat.to_string() @@ -276,7 +276,7 @@ def test_noise_model_matches_flat_pauli_weights(entrypoint: str) -> None: "p_prep": 0.013, } - grouped = _noise_model_entrypoint_dem(entrypoint, noise=NoiseModel(**noise_kwargs)) + grouped = _noise_model_entrypoint_dem(entrypoint, noise=NoiseParameters(**noise_kwargs)) flat = _noise_model_entrypoint_dem(entrypoint, **noise_kwargs) assert grouped.to_string() == flat.to_string() @@ -289,7 +289,7 @@ def test_noise_model_structured_idle_family_matches_flat_axis_rates(entrypoint: grouped = _noise_model_entrypoint_dem( entrypoint, - noise=NoiseModel(p_idle_linear=rate, p_idle_linear_model=model), + noise=NoiseParameters(p_idle_linear=rate, p_idle_linear_model=model), idle_after_2q_duration=2.0, ) flat = _noise_model_entrypoint_dem( @@ -310,21 +310,21 @@ def test_noise_model_structured_idle_family_matches_flat_axis_rates(entrypoint: @pytest.mark.parametrize("keyword", ["p1", "p2", "p_meas", "p_idle_linear"]) def test_noise_model_rejects_flat_noise_keyword(entrypoint: str, keyword: str) -> None: with pytest.raises(ValueError, match=keyword): - _noise_model_entrypoint_dem(entrypoint, noise=NoiseModel(), **{keyword: 0.01}) + _noise_model_entrypoint_dem(entrypoint, noise=NoiseParameters(), **{keyword: 0.01}) @pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) @pytest.mark.parametrize("field", ["p_idle", "p2_szz", "p2_szzdg"]) def test_noise_model_rejects_fields_not_supported_by_guppy_dem(entrypoint: str, field: str) -> None: with pytest.raises(ValueError, match=field): - _noise_model_entrypoint_dem(entrypoint, noise=NoiseModel(**{field: 0.01})) + _noise_model_entrypoint_dem(entrypoint, noise=NoiseParameters(**{field: 0.01})) @pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) def test_noise_model_combines_with_non_noise_keywords(entrypoint: str) -> None: dem = _noise_model_entrypoint_dem( entrypoint, - noise=NoiseModel(p_idle_linear=0.01), + noise=NoiseParameters(p_idle_linear=0.01), idle_after_2q_duration=1.0, strip_traced_idles=True, seed=17, @@ -992,7 +992,7 @@ def test_lowered_replay_converts_runtime_idle_seconds_to_nanosecond_time_units() def test_noise_model_converts_runtime_idle_rates_from_seconds_to_dem_time_units() -> None: - noise = NoiseModel( + noise = NoiseParameters( p1=0.001, p2=0.002, p_meas=0.003, @@ -1021,7 +1021,7 @@ def test_noise_model_converts_runtime_idle_rates_from_seconds_to_dem_time_units( def test_noise_model_rejects_invalid_runtime_idle_time_unit_scale() -> None: with pytest.raises(ValueError, match="time_units_per_second"): - NoiseModel(p_idle_z_linear_rate=1.0).for_runtime_idle_time_units(time_units_per_second=0.0) + NoiseParameters(p_idle_z_linear_rate=1.0).for_runtime_idle_time_units(time_units_per_second=0.0) def test_lowered_replay_preserves_gate_metadata() -> None: @@ -1682,7 +1682,7 @@ def test_native_abstract_surface_dem_uses_record_metadata_only_for_r0(basis: str assert json.loads(native_tc.get_meta("detectors") or "[]") assert json.loads(native_tc.get_meta("observables") or "[]") - noise = NoiseModel(p1=0.0, p2=0.001, p_meas=0.0, p_prep=0.0) + noise = NoiseParameters(p1=0.0, p2=0.001, p_meas=0.0, p_prep=0.0) for decompose_errors in (False, True): dem_text = generate_circuit_level_dem_from_builder( patch, diff --git a/python/quantum-pecos/tests/qec/test_guppy_dem_builder.py b/python/quantum-pecos/tests/qec/test_guppy_dem_builder.py index 5893a8848..6143af3f1 100644 --- a/python/quantum-pecos/tests/qec/test_guppy_dem_builder.py +++ b/python/quantum-pecos/tests/qec/test_guppy_dem_builder.py @@ -21,7 +21,7 @@ build_dem_from_guppy, rec, ) -from pecos.qec.surface import NoiseModel +from pecos.qec.surface import NoiseParameters from pecos_rslib.quantum import TickCircuit if TYPE_CHECKING: @@ -42,7 +42,7 @@ def _tagged_two_qubit_program() -> None: def test_builder_matches_both_wrappers_with_noise_and_inserted_idles() -> None: - noise = NoiseModel(p1=0.0, p2=0.01, p_meas=0.02, p_prep=0.0, p_idle_z_linear_rate=0.03) + noise = NoiseParameters(p1=0.0, p2=0.01, p_meas=0.02, p_prep=0.0, p_idle_z_linear_rate=0.03) via_json_builder = ( DetectorErrorModel.builder() .program(_tagged_two_qubit_program) @@ -91,7 +91,7 @@ def test_builder_matches_both_wrappers_with_noise_and_inserted_idles() -> None: def test_builder_matches_both_wrappers_with_result_tags() -> None: detectors_json = '[{"id":0,"result_tags":["m0"]}]' observables_json = '[{"id":0,"result_tags":["m1"]}]' - noise = NoiseModel(p1=0.0, p2=0.0, p_meas=0.1, p_prep=0.0) + noise = NoiseParameters(p1=0.0, p2=0.0, p_meas=0.1, p_prep=0.0) via_json_builder = ( DetectorErrorModel.builder() .program(_tagged_two_qubit_program) @@ -157,7 +157,7 @@ def test_builder_reports_missing_required_setters( ("detectors_json", _DETECTORS_JSON), ("observables_json", _OBSERVABLES_JSON), ("num_measurements", 2), - ("noise", NoiseModel()), + ("noise", NoiseParameters()), ("idle_after_2q", 1.0), ("strip_traced_idles", True), ("runtime", None), @@ -220,7 +220,7 @@ def test_builder_result_evaluates_simulation_result_columns() -> None: .qubits(2) .detectors([Detector("m0")]) .observables([Observable("m1")]) - .noise(NoiseModel(p_meas=0.1)) + .noise(NoiseParameters(p_meas=0.1)) .build() ) columns = ( @@ -244,7 +244,7 @@ def test_json_builder_audit_accepts_legacy_id_aliases() -> None: .qubits(2) .detectors_json('[{"detector_id":"D0","records":[-2]}]') .observables_json('[{"observable_id":"L0","records":[-1]}]') - .noise(NoiseModel(p_meas=0.1)) + .noise(NoiseParameters(p_meas=0.1)) .build() ) @@ -252,7 +252,7 @@ def test_json_builder_audit_accepts_legacy_id_aliases() -> None: def test_builder_setter_order_does_not_change_the_dem() -> None: - noise = NoiseModel(p1=0.01, p2=0.02, p_meas=0.03, p_prep=0.04) + noise = NoiseParameters(p1=0.01, p2=0.02, p_meas=0.03, p_prep=0.04) first = ( DetectorErrorModel.builder() .program(_tagged_two_qubit_program) 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 44f228224..da58f8899 100644 --- a/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py +++ b/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py @@ -99,12 +99,12 @@ def test_surface_code_memory_accepts_traced_qis_runtime() -> None: def test_surface_decoder_accepts_traced_qis_runtime() -> None: - from pecos.qec.surface import NoiseModel, SurfaceDecoder, SurfacePatch + from pecos.qec.surface import NoiseParameters, SurfaceDecoder, SurfacePatch decoder = SurfaceDecoder( SurfacePatch.create(distance=3), num_rounds=1, - noise=NoiseModel(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), + noise=NoiseParameters(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), decoder_type="pymatching_uncorrelated", circuit_level_dem_source="traced_qis", runtime=_NON_DEFAULT_RUNTIME, @@ -114,12 +114,12 @@ def test_surface_decoder_accepts_traced_qis_runtime() -> None: def test_build_native_sampler_accepts_traced_qis_runtime() -> None: - from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler + from pecos.qec.surface import NoiseParameters, SurfacePatch, build_native_sampler sampler = build_native_sampler( SurfacePatch.create(distance=3), num_rounds=1, - noise=NoiseModel(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), + noise=NoiseParameters(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), circuit_source="traced_qis", runtime=_NON_DEFAULT_RUNTIME, ) @@ -128,12 +128,12 @@ def test_build_native_sampler_accepts_traced_qis_runtime() -> None: def test_surface_code_memory_rejects_ambiguous_noise_inputs() -> None: - from pecos.qec.surface import NoiseModel, surface_code_memory + from pecos.qec.surface import NoiseParameters, surface_code_memory with pytest.raises(ValueError, match="either physical_error_rate or noise_model"): surface_code_memory( physical_error_rate=0.0, - noise_model=NoiseModel.uniform(0.001), + noise_model=NoiseParameters.uniform(0.001), shots=0, rounds=1, ) From 4392bb66308d5f5f37758bd3d9dba3bd0871544a Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 4 Aug 2026 10:14:34 -0600 Subject: [PATCH 26/62] Document the paired idle-family setters in the noise section --- docs/user-guide/dem-from-guppy.md | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index e571aaf0c..f82ffda5c 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -202,10 +202,30 @@ one `NoiseParameters` instance containing the complete noise configuration. `NoiseParameters` is available from the `pecos` top level, and supports both its original dataclass constructor and immutable `with_` chaining. The grouped and flat forms below are equivalent. Do not mix them in one call: -even an explicitly passed flat -default conflicts with `noise`. When `noise` is present, its defaults fully -replace the entry point's defaults; for example, `NoiseParameters().p1` is `0.0`, -not the flat `p1=0.001` default. +even explicitly passing a flat parameter at its default value conflicts with +`noise`. When `noise` is present, its defaults fully replace the entry point's +defaults — `NoiseParameters().p1` is `0.0`, not the flat `p1=0.001` default. + +Each `with_` returns a new `NoiseParameters`, so chains never mutate +the object they start from. The idle families are the one exception to the +one-method-per-field rule: each takes its rate and model **together**, because a +model without a rate is inert and the two halves cannot be set in separate +calls. + +```python +from pecos import NoiseParameters + +noise = ( + NoiseParameters() + .with_p1(0.002) + .with_p_idle_linear(0.01, {"X": 0.25, "Y": 0.25, "Z": 0.5}) + .with_p_idle_sin_squared(0.03, {"Z": 1.0}) +) + +# The families translate into canonical per-axis rates. +assert noise.p_idle_z_linear_rate == 0.005 +assert noise.p_idle_z_quadratic_sine_rate == 0.03 +``` ```python From 4c267b4da44e791da011293b4e7cfcc8b8b9cf5f Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 19:29:54 -0600 Subject: [PATCH 27/62] Add paired DUT/reference decoder comparison with joint outcome counts --- .../src/fault_tolerance_bindings.rs | 47 ++ .../decoder_comparison.rs | 443 ++++++++++++++++++ .../tests/qec/test_decoder_comparison.py | 29 ++ 3 files changed, 519 insertions(+) create mode 100644 python/pecos-rslib/src/fault_tolerance_bindings/decoder_comparison.rs create mode 100644 python/quantum-pecos/tests/qec/test_decoder_comparison.py diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index c84065ad8..5fbd6278b 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -74,6 +74,10 @@ use pyo3::prelude::*; use std::collections::BTreeMap; use std::str::FromStr; +mod decoder_comparison; + +use decoder_comparison::{PyDecoderComparisonResult, compare_decoder_outcomes}; + type PyDemMechanismTuple = (f64, Vec, Vec); type PyDemFitResult = (Vec, Vec); /// Per-shot detector rows paired with per-shot observable/DEM-output rows. @@ -3672,6 +3676,48 @@ impl PySampleBatch { Ok(predictions) } + /// Decode every shot with a decoder under test (DUT) and a reference decoder. + /// + /// Both decoders receive the same shots in the same order. Each result is + /// independently classified as correct, mismatch, or decode error, and a + /// decode error is counted for that shot without aborting the comparison. + /// Predictions and truth are compared as wide observable masks, with no + /// 64-observable limit. + /// + /// Args: + /// dem: DEM string shared by both decoders. + /// `dut_decoder_type`: Decoder type string for the decoder under test. + /// `reference_decoder_type`: Decoder type string for the reference. + /// alpha: Tail probability for equal-tailed Jeffreys intervals. + /// + /// Returns: + /// A `DecoderComparisonResult` containing the raw 3x3 counts and + /// headline DUT-only-failure and both-failed proportions. + #[pyo3(signature = (dem, dut_decoder_type, reference_decoder_type, alpha=0.05))] + fn compare_decoders( + &self, + dem: &str, + dut_decoder_type: &str, + reference_decoder_type: &str, + alpha: f64, + ) -> PyResult { + let mut dut = create_observable_decoder(dem, dut_decoder_type)?; + let mut reference = create_observable_decoder(dem, reference_decoder_type)?; + let mut syndrome = vec![0u8; self.num_detectors]; + let counts = compare_decoder_outcomes( + self.num_shots, + &mut syndrome, + |shot, buffer| { + self.extract_syndrome(shot, buffer); + self.extract_obs_mask_wide(shot) + }, + dut.as_mut(), + reference.as_mut(), + ); + PyDecoderComparisonResult::new(counts, alpha) + .map_err(|error| pyo3::exceptions::PyRuntimeError::new_err(error.to_string())) + } + /// Parallel decode: distributes samples across rayon workers. /// /// Each worker creates its own decoder instance. Faster for slow decoders. @@ -6980,6 +7026,7 @@ pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; + qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; diff --git a/python/pecos-rslib/src/fault_tolerance_bindings/decoder_comparison.rs b/python/pecos-rslib/src/fault_tolerance_bindings/decoder_comparison.rs new file mode 100644 index 000000000..d961d6799 --- /dev/null +++ b/python/pecos-rslib/src/fault_tolerance_bindings/decoder_comparison.rs @@ -0,0 +1,443 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Paired DUT/reference decoder comparison over a shared sequence of shots. + +use pecos_decoder_core::obs_mask::ObsMask; +use pecos_decoder_core::{DecoderError, ObservableDecoder}; +use pecos_num::stats::{JeffreysError, JeffreysInterval, jeffreys_interval}; +use pyo3::prelude::*; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DecoderOutcome { + Correct, + Mismatch, + Error, +} + +impl DecoderOutcome { + const fn index(self) -> usize { + match self { + Self::Correct => 0, + Self::Mismatch => 1, + Self::Error => 2, + } + } +} + +fn classify(result: Result, truth: &ObsMask) -> DecoderOutcome { + match result { + Ok(prediction) if prediction == *truth => DecoderOutcome::Correct, + Ok(_) => DecoderOutcome::Mismatch, + Err(_) => DecoderOutcome::Error, + } +} + +/// Counts indexed by DUT outcome first, then reference outcome. +/// +/// In each dimension the order is correct, mismatch, decode error. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(super) struct DecoderComparisonCounts { + cells: [[u64; 3]; 3], +} + +impl DecoderComparisonCounts { + fn record(&mut self, dut: DecoderOutcome, reference: DecoderOutcome) { + self.cells[dut.index()][reference.index()] += 1; + } + + pub(super) const fn cells(&self) -> &[[u64; 3]; 3] { + &self.cells + } + + fn total_shots(&self) -> u64 { + self.cells.iter().flatten().sum() + } + + const fn dut_only_failures(&self) -> u64 { + self.cells[DecoderOutcome::Mismatch.index()][DecoderOutcome::Correct.index()] + } + + const fn both_failed(&self) -> u64 { + self.cells[DecoderOutcome::Mismatch.index()][DecoderOutcome::Mismatch.index()] + } +} + +/// Compare two decoders on the same shots in the same order. +/// +/// `prepare_shot` writes the selected syndrome into the reusable buffer and +/// returns that shot's wide true-observable mask. +pub(super) fn compare_decoder_outcomes( + num_shots: usize, + syndrome: &mut [u8], + mut prepare_shot: impl FnMut(usize, &mut [u8]) -> ObsMask, + dut: &mut dyn ObservableDecoder, + reference: &mut dyn ObservableDecoder, +) -> DecoderComparisonCounts { + let mut counts = DecoderComparisonCounts::default(); + for shot in 0..num_shots { + let truth = prepare_shot(shot, syndrome); + // Run both decoders before classifying either result. In particular, a + // DUT error must not prevent the reference from seeing this shot. + let dut_result = dut.decode_obs(syndrome); + let reference_result = reference.decode_obs(syndrome); + counts.record( + classify(dut_result, &truth), + classify(reference_result, &truth), + ); + } + counts +} + +#[derive(Clone, Copy, Debug)] +struct HeadlineProportion { + point: f64, + interval: JeffreysInterval, +} + +impl HeadlineProportion { + fn new(count: u64, total_shots: u64, alpha: f64) -> Result { + let interval = jeffreys_interval(count, total_shots, alpha)?; + Ok(Self { + point: interval.point, + interval, + }) + } +} + +/// Python-facing paired decoder contingency counts and headline proportions. +#[pyclass( + name = "DecoderComparisonResult", + module = "pecos_rslib.qec", + skip_from_py_object +)] +#[derive(Clone, Debug)] +pub(super) struct PyDecoderComparisonResult { + counts: DecoderComparisonCounts, + total_shots: u64, + alpha: f64, + dut_only_failure: HeadlineProportion, + both_failed: HeadlineProportion, +} + +impl PyDecoderComparisonResult { + pub(super) fn new(counts: DecoderComparisonCounts, alpha: f64) -> Result { + let total_shots = counts.total_shots(); + let dut_only_failure = + HeadlineProportion::new(counts.dut_only_failures(), total_shots, alpha)?; + let both_failed = HeadlineProportion::new(counts.both_failed(), total_shots, alpha)?; + Ok(Self { + counts, + total_shots, + alpha, + dut_only_failure, + both_failed, + }) + } +} + +#[pymethods] +impl PyDecoderComparisonResult { + /// Raw 3x3 counts in correct, mismatch, error order on both axes. + #[getter] + fn counts(&self) -> Vec> { + self.counts.cells().iter().map(|row| row.to_vec()).collect() + } + + /// Number of shots compared. + #[getter] + const fn total_shots(&self) -> u64 { + self.total_shots + } + + /// Tail probability used for the equal-tailed Jeffreys intervals. + #[getter] + const fn alpha(&self) -> f64 { + self.alpha + } + + #[getter] + const fn dut_correct_reference_correct(&self) -> u64 { + self.counts.cells[0][0] + } + + #[getter] + const fn dut_correct_reference_mismatch(&self) -> u64 { + self.counts.cells[0][1] + } + + #[getter] + const fn dut_correct_reference_error(&self) -> u64 { + self.counts.cells[0][2] + } + + #[getter] + const fn dut_mismatch_reference_correct(&self) -> u64 { + self.counts.cells[1][0] + } + + #[getter] + const fn dut_mismatch_reference_mismatch(&self) -> u64 { + self.counts.cells[1][1] + } + + #[getter] + const fn dut_mismatch_reference_error(&self) -> u64 { + self.counts.cells[1][2] + } + + #[getter] + const fn dut_error_reference_correct(&self) -> u64 { + self.counts.cells[2][0] + } + + #[getter] + const fn dut_error_reference_mismatch(&self) -> u64 { + self.counts.cells[2][1] + } + + #[getter] + const fn dut_error_reference_error(&self) -> u64 { + self.counts.cells[2][2] + } + + /// DUT mismatches on shots where the reference was correct. + #[getter] + const fn dut_only_failures(&self) -> u64 { + self.counts.dut_only_failures() + } + + /// Jeffreys posterior-mean proportion for DUT-only failures. + #[getter] + const fn dut_only_failure_proportion(&self) -> f64 { + self.dut_only_failure.point + } + + /// Equal-tailed Jeffreys interval for the DUT-only-failure proportion. + #[getter] + const fn dut_only_failure_interval(&self) -> (f64, f64) { + ( + self.dut_only_failure.interval.lo, + self.dut_only_failure.interval.hi, + ) + } + + /// Shots on which both decoders returned mismatching predictions. + #[getter] + const fn both_failed(&self) -> u64 { + self.counts.both_failed() + } + + /// Jeffreys posterior-mean proportion for shots where both decoders failed. + #[getter] + const fn both_failed_proportion(&self) -> f64 { + self.both_failed.point + } + + /// Equal-tailed Jeffreys interval for the both-failed proportion. + #[getter] + const fn both_failed_interval(&self) -> (f64, f64) { + (self.both_failed.interval.lo, self.both_failed.interval.hi) + } + + fn __repr__(&self) -> String { + format!( + "DecoderComparisonResult(shots={}, dut_only_failures={}, both_failed={})", + self.total_shots, + self.counts.dut_only_failures(), + self.counts.both_failed(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone, Debug)] + enum StubResult { + Prediction(ObsMask), + Error, + } + + struct StubDecoder { + expected_syndromes: Vec>, + results: Vec, + next: usize, + } + + impl StubDecoder { + fn new(expected_syndromes: &[Vec], results: Vec) -> Self { + assert_eq!(expected_syndromes.len(), results.len()); + Self { + expected_syndromes: expected_syndromes.to_vec(), + results, + next: 0, + } + } + } + + impl ObservableDecoder for StubDecoder { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + assert_eq!(syndrome, self.expected_syndromes[self.next]); + let result = match &self.results[self.next] { + StubResult::Prediction(mask) => Ok(mask.clone()), + StubResult::Error => Err(DecoderError::DecodingFailed("stub error".into())), + }; + self.next += 1; + result + } + } + + fn mask(bits: &[usize]) -> ObsMask { + let mut mask = ObsMask::new(); + for &bit in bits { + mask.set(bit); + } + mask + } + + fn predictions(masks: &[ObsMask]) -> Vec { + masks.iter().cloned().map(StubResult::Prediction).collect() + } + + fn compare( + shots: &[(Vec, ObsMask)], + dut_results: Vec, + reference_results: Vec, + ) -> DecoderComparisonCounts { + let syndromes: Vec> = shots.iter().map(|(s, _)| s.clone()).collect(); + let mut dut = StubDecoder::new(&syndromes, dut_results); + let mut reference = StubDecoder::new(&syndromes, reference_results); + let mut syndrome = vec![0; syndromes.first().map_or(0, Vec::len)]; + compare_decoder_outcomes( + shots.len(), + &mut syndrome, + |shot, buffer| { + buffer.copy_from_slice(&shots[shot].0); + shots[shot].1.clone() + }, + &mut dut, + &mut reference, + ) + } + + fn sample_shots() -> Vec<(Vec, ObsMask)> { + vec![ + (vec![0, 0], mask(&[])), + (vec![1, 0], mask(&[0])), + (vec![0, 1], mask(&[1])), + (vec![1, 1], mask(&[0, 1])), + ] + } + + #[test] + fn both_decoders_correct_puts_all_mass_in_correct_correct() { + let shots = sample_shots(); + let truths: Vec = shots.iter().map(|(_, truth)| truth.clone()).collect(); + let counts = compare(&shots, predictions(&truths), predictions(&truths)); + + assert_eq!(counts.cells(), &[[4, 0, 0], [0, 0, 0], [0, 0, 0]]); + assert_eq!(counts.dut_only_failures(), 0); + } + + #[test] + fn dut_only_failures_count_a_known_wrong_subset() { + let shots = sample_shots(); + let truths: Vec = shots.iter().map(|(_, truth)| truth.clone()).collect(); + let mut dut = truths.clone(); + dut[1] = mask(&[]); + dut[3] = mask(&[]); + + let counts = compare(&shots, predictions(&dut), predictions(&truths)); + + // Shots 1 and 3 are deliberately wrong for the DUT: 2 DUT-only failures. + assert_eq!(counts.dut_only_failures(), 2); + assert_eq!(counts.cells(), &[[2, 0, 0], [2, 0, 0], [0, 0, 0]]); + } + + #[test] + fn dut_errors_are_not_mismatches_and_do_not_abort() { + let shots = sample_shots(); + let truths: Vec = shots.iter().map(|(_, truth)| truth.clone()).collect(); + let mut dut = predictions(&truths); + dut[1] = StubResult::Error; + dut[3] = StubResult::Error; + + let counts = compare(&shots, dut, predictions(&truths)); + + assert_eq!(counts.cells(), &[[2, 0, 0], [0, 0, 0], [2, 0, 0]]); + assert_eq!(counts.cells()[DecoderOutcome::Mismatch.index()][0], 0); + } + + #[test] + fn reference_errors_are_counted_and_do_not_abort() { + let shots = sample_shots(); + let truths: Vec = shots.iter().map(|(_, truth)| truth.clone()).collect(); + let mut reference = predictions(&truths); + reference[0] = StubResult::Error; + reference[2] = StubResult::Error; + + let counts = compare(&shots, predictions(&truths), reference); + + assert_eq!(counts.cells(), &[[2, 0, 2], [0, 0, 0], [0, 0, 0]]); + } + + #[test] + fn wide_observable_difference_above_bit_63_is_preserved() { + let wide_truth = mask(&[70]); + let shots = vec![(vec![1], wide_truth.clone())]; + let counts = compare( + &shots, + predictions(&[ObsMask::new()]), + predictions(&[wide_truth]), + ); + + assert_eq!(counts.cells(), &[[0, 0, 0], [1, 0, 0], [0, 0, 0]]); + assert_eq!(counts.dut_only_failures(), 1); + } + + #[test] + fn headline_interval_matches_pecos_num_helper() { + let shots = sample_shots(); + let truths: Vec = shots.iter().map(|(_, truth)| truth.clone()).collect(); + let mut dut = truths.clone(); + dut[1] = mask(&[]); + let summary = PyDecoderComparisonResult::new( + compare(&shots, predictions(&dut), predictions(&truths)), + 0.05, + ) + .expect("valid Jeffreys inputs"); + let expected = jeffreys_interval(1, 4, 0.05).expect("valid direct helper inputs"); + + assert_eq!(summary.dut_only_failure.interval, expected); + // Both sides come from the same helper call, so the point estimate must be + // bit-identical; compare bit patterns rather than floats. + assert_eq!( + summary.dut_only_failure.point.to_bits(), + expected.point.to_bits() + ); + } + + #[test] + fn comparison_is_deterministic_for_the_same_batch() { + let shots = sample_shots(); + let truths: Vec = shots.iter().map(|(_, truth)| truth.clone()).collect(); + let mut dut = truths.clone(); + dut[2] = mask(&[]); + + let first = compare(&shots, predictions(&dut), predictions(&truths)); + let second = compare(&shots, predictions(&dut), predictions(&truths)); + + assert_eq!(first, second); + } +} diff --git a/python/quantum-pecos/tests/qec/test_decoder_comparison.py b/python/quantum-pecos/tests/qec/test_decoder_comparison.py new file mode 100644 index 000000000..7217b364e --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_decoder_comparison.py @@ -0,0 +1,29 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Python coverage for paired DUT/reference decoder comparison.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("pecos_rslib") + +from pecos_rslib.qec import SampleBatch # noqa: E402 + + +def test_sample_batch_compare_decoders_exposes_joint_counts() -> None: + dem = "error(0.1) D0 L0\n" + batch = SampleBatch([[0], [1], [0], [1]], [0, 1, 0, 1]) + + first = batch.compare_decoders(dem, "pymatching", "pymatching") + second = batch.compare_decoders(dem, "pymatching", "pymatching") + + assert first.total_shots == 4 + assert first.counts == [[4, 0, 0], [0, 0, 0], [0, 0, 0]] + assert first.dut_correct_reference_correct == 4 + assert first.dut_only_failures == 0 + assert first.both_failed == 0 + assert 0.0 <= first.dut_only_failure_interval[0] + assert first.dut_only_failure_interval[1] <= 1.0 + assert second.counts == first.counts From 72f0552f1bad527d5ef0575cf8739653970f1520 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 19:32:39 -0600 Subject: [PATCH 28/62] Apply lint autofixes to decoder comparison test --- python/quantum-pecos/tests/qec/test_decoder_comparison.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/quantum-pecos/tests/qec/test_decoder_comparison.py b/python/quantum-pecos/tests/qec/test_decoder_comparison.py index 7217b364e..9d06316b1 100644 --- a/python/quantum-pecos/tests/qec/test_decoder_comparison.py +++ b/python/quantum-pecos/tests/qec/test_decoder_comparison.py @@ -9,7 +9,7 @@ pytest.importorskip("pecos_rslib") -from pecos_rslib.qec import SampleBatch # noqa: E402 +from pecos_rslib.qec import SampleBatch def test_sample_batch_compare_decoders_exposes_joint_counts() -> None: @@ -24,6 +24,6 @@ def test_sample_batch_compare_decoders_exposes_joint_counts() -> None: assert first.dut_correct_reference_correct == 4 assert first.dut_only_failures == 0 assert first.both_failed == 0 - assert 0.0 <= first.dut_only_failure_interval[0] + assert first.dut_only_failure_interval[0] >= 0.0 assert first.dut_only_failure_interval[1] <= 1.0 assert second.counts == first.counts From d1ea28a42632e14a2be79c417f8be9f0509d5778 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 4 Aug 2026 11:33:11 -0600 Subject: [PATCH 29/62] Show the fluent noise form in the workflow guide --- docs/workflows/guppy-dem-decoding.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 17239f4c8..6941d1135 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -135,15 +135,14 @@ take the noise settings as individual keyword arguments instead. from pecos import NoiseParameters from pecos.qec import DetectorErrorModel -noise = NoiseParameters( - p1=0.002, - p2=0.02, - p_meas=0.02, - p_prep=0.02, - p_idle_linear=0.01, - p_idle_linear_model={"X": 0.25, "Y": 0.25, "Z": 0.5}, - p_idle_sin_squared=0.03, - p_idle_sin_squared_model={"Z": 1.0}, +noise = ( + NoiseParameters() + .with_p1(0.002) + .with_p2(0.02) + .with_p_meas(0.02) + .with_p_prep(0.02) + .with_p_idle_linear(0.01, {"X": 0.25, "Y": 0.25, "Z": 0.5}) + .with_p_idle_sin_squared(0.03, {"Z": 1.0}) ) dem_build = ( @@ -212,6 +211,10 @@ default Selene runtime emits no idle gates for the simulator to attach idle noise to. The simulated numbers are therefore the same experiment without the idle contribution, not an independent estimate of the same quantity. +The simulator's noise builder spells its setters with explicit suffixes +(`with_p1_probability`), while `NoiseParameters` names each setter after its +field (`with_p1`). The two describe the same rates. + ```python from pecos import general_noise, selene_engine, sim, stabilizer From b9b8a797fb2de8f748fab93029178bc6c97c9e1a Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 4 Aug 2026 11:52:42 -0600 Subject: [PATCH 30/62] Adopt the with_ setter convention on the Guppy DEM builder --- docs/workflows/guppy-dem-decoding.md | 12 +- python/quantum-pecos/src/pecos/qec/dem.py | 106 ++++++------ .../qec/surface/test_noise_parameters.py | 10 +- .../tests/qec/test_guppy_dem_builder.py | 159 +++++++++--------- 4 files changed, 148 insertions(+), 139 deletions(-) diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 6941d1135..928c46b6a 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -147,12 +147,12 @@ noise = ( dem_build = ( DetectorErrorModel.builder() - .program(rep_code_memory) - .qubits(7) - .detectors(detectors) - .observables(observables) - .noise(noise) - .idle_after_2q(1.0) + .with_program(rep_code_memory) + .with_qubits(7) + .with_detectors(detectors) + .with_observables(observables) + .with_noise(noise) + .with_idle_after_2q(1.0) .build() ) dem = dem_build.dem diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 75e46298c..b78124b5f 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -591,19 +591,19 @@ def from_guppy( noise_keywords = {name: value for name, value in locals().items() if name in _GUPPY_NOISE_KEYWORDS} builder = ( cls.builder() - .program(guppy) - .qubits(num_qubits) - .detectors_json(detectors_json) - .observables_json(observables_json) - .strip_traced_idles(strip_traced_idles) - .idle_after_2q(idle_after_2q_duration) - .runtime(runtime) - .seed(seed) - .require_hosted_operation_order(require_hosted_operation_order) - .max_hosted_tick_separation(max_hosted_tick_separation) + .with_program(guppy) + .with_qubits(num_qubits) + .with_detectors_json(detectors_json) + .with_observables_json(observables_json) + .with_strip_traced_idles(strip_traced_idles) + .with_idle_after_2q(idle_after_2q_duration) + .with_runtime(runtime) + .with_seed(seed) + .with_require_hosted_operation_order(require_hosted_operation_order) + .with_max_hosted_tick_separation(max_hosted_tick_separation) ) if num_measurements is not None: - builder.num_measurements(num_measurements) + builder.with_num_measurements(num_measurements) # Same-module private seam: the flat keyword surface stays on this # function while noise() remains strictly a NoiseParameters-instance setter. return builder._legacy_noise(noise, noise_keywords).build().dem # noqa: SLF001 @@ -960,66 +960,66 @@ def _set_specs(self, role: str, kind: str, value: Any) -> None: kind_attribute = f"_{role}_kind" value_attribute = f"_{role}_value" current_kind = getattr(self, kind_attribute) - setter = role if kind == "typed" else f"{role}_json" + setter = f"with_{role}" if kind == "typed" else f"with_{role}_json" if current_kind is not _UNSET: - previous = role if current_kind == "typed" else f"{role}_json" + previous = f"with_{role}" if current_kind == "typed" else f"with_{role}_json" if current_kind == kind: msg = f"{setter}() may only be called once" raise ValueError(msg) msg = f"{setter}() cannot be combined with {previous}()" raise ValueError(msg) if kind == "typed" and self._num_measurements is not _UNSET: - msg = f"{setter}() cannot be combined with num_measurements()" + msg = f"{setter}() cannot be combined with with_num_measurements()" raise ValueError(msg) setattr(self, kind_attribute, kind) setattr(self, value_attribute, value) - def program(self, program: Any) -> Self: + def with_program(self, program: Any) -> Self: """Set the Guppy or HUGR program to trace.""" - self._set_once("_program", program, "program") + self._set_once("_program", program, "with_program") return self - def qubits(self, num_qubits: int) -> Self: + def with_qubits(self, num_qubits: int) -> Self: """Set the number of qubits allocated to the trace.""" - self._set_once("_qubits", num_qubits, "qubits") + self._set_once("_qubits", num_qubits, "with_qubits") return self - def detectors(self, specs: Sequence[Detector]) -> Self: + def with_detectors(self, specs: Sequence[Detector]) -> Self: """Set typed detector specifications.""" self._set_specs("detectors", "typed", tuple(specs)) return self - def observables(self, specs: Sequence[Observable]) -> Self: + def with_observables(self, specs: Sequence[Observable]) -> Self: """Set typed logical-observable specifications.""" self._set_specs("observables", "typed", tuple(specs)) return self - def detectors_json(self, text: str) -> Self: + def with_detectors_json(self, text: str) -> Self: """Set raw JSON detector specifications.""" self._set_specs("detectors", "json", text) return self - def observables_json(self, text: str) -> Self: + def with_observables_json(self, text: str) -> Self: """Set raw JSON logical-observable specifications.""" self._set_specs("observables", "json", text) return self - def num_measurements(self, count: int) -> Self: + def with_num_measurements(self, count: int) -> Self: """Set the measurement count used by raw JSON record references.""" if self._detectors_kind == "typed" or self._observables_kind == "typed": - msg = "num_measurements() cannot be combined with typed detectors() or observables()" + msg = "with_num_measurements() cannot be combined with typed with_detectors() or with_observables()" raise ValueError(msg) - self._set_once("_num_measurements", count, "num_measurements") + self._set_once("_num_measurements", count, "with_num_measurements") return self - def noise(self, noise_model: NoiseParameters) -> Self: + def with_noise(self, noise_model: NoiseParameters) -> Self: """Set the complete grouped noise configuration.""" from pecos.qec.surface.decode import NoiseParameters if not isinstance(noise_model, NoiseParameters): msg = f"noise() requires a NoiseParameters instance, got {type(noise_model).__name__}" raise TypeError(msg) - self._set_once("_noise", noise_model, "noise") + self._set_once("_noise", noise_model, "with_noise") return self def _legacy_noise(self, noise_model: NoiseParameters | None, flat_keywords: Mapping[str, Any]) -> Self: @@ -1028,37 +1028,37 @@ def _legacy_noise(self, noise_model: NoiseParameters | None, flat_keywords: Mapp Private: the flat keyword surface stays on ``from_guppy`` and ``build_dem_from_guppy``; ``noise()`` accepts only a ``NoiseParameters``. """ - self._set_once("_noise", (_LEGACY_NOISE, noise_model, dict(flat_keywords)), "noise") + self._set_once("_noise", (_LEGACY_NOISE, noise_model, dict(flat_keywords)), "with_noise") return self - def idle_after_2q(self, duration: float | None) -> Self: + def with_idle_after_2q(self, duration: float | None) -> Self: """Set the idle duration inserted after every two-qubit gate.""" - self._set_once("_idle_after_2q", duration, "idle_after_2q") + self._set_once("_idle_after_2q", duration, "with_idle_after_2q") return self - def strip_traced_idles(self, flag: bool | None) -> Self: + def with_strip_traced_idles(self, flag: bool | None) -> Self: """Choose whether runtime-emitted identity-like gates are stripped.""" - self._set_once("_strip_traced_idles", flag, "strip_traced_idles") + self._set_once("_strip_traced_idles", flag, "with_strip_traced_idles") return self - def runtime(self, runtime: object | None) -> Self: + def with_runtime(self, runtime: object | None) -> Self: """Set the Selene runtime used for the trace.""" - self._set_once("_runtime", runtime, "runtime") + self._set_once("_runtime", runtime, "with_runtime") return self - def seed(self, seed: int) -> Self: + def with_seed(self, seed: int) -> Self: """Set the ideal trace seed.""" - self._set_once("_seed", seed, "seed") + self._set_once("_seed", seed, "with_seed") return self - def require_hosted_operation_order(self, flag: bool) -> Self: + def with_require_hosted_operation_order(self, flag: bool) -> Self: """Choose whether hosted-operation ordering is validated.""" - self._set_once("_require_hosted_operation_order", flag, "require_hosted_operation_order") + self._set_once("_require_hosted_operation_order", flag, "with_require_hosted_operation_order") return self - def max_hosted_tick_separation(self, count: int | None) -> Self: + def with_max_hosted_tick_separation(self, count: int | None) -> Self: """Set the maximum hosted-operation tick separation.""" - self._set_once("_max_hosted_tick_separation", count, "max_hosted_tick_separation") + self._set_once("_max_hosted_tick_separation", count, "with_max_hosted_tick_separation") return self def _noise_parameters(self) -> dict[str, Any]: @@ -1080,10 +1080,10 @@ def build(self) -> GuppyDemBuild: from pecos.programs import Hugr as _HugrProgram from pecos.tracing import _collect_program_result_traces, trace_program_to_tick_circuit - program = self._required("_program", "program") - num_qubits = self._required("_qubits", "qubits") + program = self._required("_program", "with_program") + num_qubits = self._required("_qubits", "with_qubits") if self._detectors_kind is _UNSET: - msg = "build() requires detectors() or detectors_json()" + msg = "build() requires with_detectors() or with_detectors_json()" raise ValueError(msg) noise_parameters = self._noise_parameters() @@ -1451,16 +1451,16 @@ def build_dem_from_guppy( noise_keywords = {name: value for name, value in locals().items() if name in _GUPPY_NOISE_KEYWORDS} builder = ( GuppyDemBuilder() - .program(guppy) - .qubits(num_qubits) - .detectors(detectors) - .observables(observables) - .strip_traced_idles(strip_traced_idles) - .idle_after_2q(idle_after_2q_duration) - .runtime(runtime) - .seed(seed) - .require_hosted_operation_order(require_hosted_operation_order) - .max_hosted_tick_separation(max_hosted_tick_separation) + .with_program(guppy) + .with_qubits(num_qubits) + .with_detectors(detectors) + .with_observables(observables) + .with_strip_traced_idles(strip_traced_idles) + .with_idle_after_2q(idle_after_2q_duration) + .with_runtime(runtime) + .with_seed(seed) + .with_require_hosted_operation_order(require_hosted_operation_order) + .with_max_hosted_tick_separation(max_hosted_tick_separation) ) # Same-module private seam: see the note in DetectorErrorModel.from_guppy. return builder._legacy_noise(noise, noise_keywords).build() # noqa: SLF001 diff --git a/python/quantum-pecos/tests/qec/surface/test_noise_parameters.py b/python/quantum-pecos/tests/qec/surface/test_noise_parameters.py index d7a943c5f..6b717ffe1 100644 --- a/python/quantum-pecos/tests/qec/surface/test_noise_parameters.py +++ b/python/quantum-pecos/tests/qec/surface/test_noise_parameters.py @@ -29,11 +29,11 @@ def _two_qubit_program() -> None: def _dem_bytes(noise: NoiseParameters) -> bytes: build = ( DetectorErrorModel.builder() - .program(_two_qubit_program) - .qubits(2) - .detectors_json('[{"id":0,"result_tags":["m0"]}]') - .observables_json('[{"id":0,"result_tags":["m1"]}]') - .noise(noise) + .with_program(_two_qubit_program) + .with_qubits(2) + .with_detectors_json('[{"id":0,"result_tags":["m0"]}]') + .with_observables_json('[{"id":0,"result_tags":["m1"]}]') + .with_noise(noise) .build() ) return build.dem.to_string().encode() diff --git a/python/quantum-pecos/tests/qec/test_guppy_dem_builder.py b/python/quantum-pecos/tests/qec/test_guppy_dem_builder.py index 6143af3f1..a0b76c193 100644 --- a/python/quantum-pecos/tests/qec/test_guppy_dem_builder.py +++ b/python/quantum-pecos/tests/qec/test_guppy_dem_builder.py @@ -45,12 +45,12 @@ def test_builder_matches_both_wrappers_with_noise_and_inserted_idles() -> None: noise = NoiseParameters(p1=0.0, p2=0.01, p_meas=0.02, p_prep=0.0, p_idle_z_linear_rate=0.03) via_json_builder = ( DetectorErrorModel.builder() - .program(_tagged_two_qubit_program) - .qubits(2) - .detectors_json(_DETECTORS_JSON) - .observables_json(_OBSERVABLES_JSON) - .noise(noise) - .idle_after_2q(1.0) + .with_program(_tagged_two_qubit_program) + .with_qubits(2) + .with_detectors_json(_DETECTORS_JSON) + .with_observables_json(_OBSERVABLES_JSON) + .with_noise(noise) + .with_idle_after_2q(1.0) .build() ) via_from_guppy = DetectorErrorModel.from_guppy( @@ -63,12 +63,12 @@ def test_builder_matches_both_wrappers_with_noise_and_inserted_idles() -> None: ) via_typed_builder = ( DetectorErrorModel.builder() - .program(_tagged_two_qubit_program) - .qubits(2) - .detectors([Detector(rec[-2])]) - .observables([Observable(rec[-1])]) - .noise(noise) - .idle_after_2q(1.0) + .with_program(_tagged_two_qubit_program) + .with_qubits(2) + .with_detectors([Detector(rec[-2])]) + .with_observables([Observable(rec[-1])]) + .with_noise(noise) + .with_idle_after_2q(1.0) .build() ) via_typed_wrapper = build_dem_from_guppy( @@ -94,11 +94,11 @@ def test_builder_matches_both_wrappers_with_result_tags() -> None: noise = NoiseParameters(p1=0.0, p2=0.0, p_meas=0.1, p_prep=0.0) via_json_builder = ( DetectorErrorModel.builder() - .program(_tagged_two_qubit_program) - .qubits(2) - .detectors_json(detectors_json) - .observables_json(observables_json) - .noise(noise) + .with_program(_tagged_two_qubit_program) + .with_qubits(2) + .with_detectors_json(detectors_json) + .with_observables_json(observables_json) + .with_noise(noise) .build() ) via_from_guppy = DetectorErrorModel.from_guppy( @@ -110,11 +110,11 @@ def test_builder_matches_both_wrappers_with_result_tags() -> None: ) via_typed_builder = ( DetectorErrorModel.builder() - .program(_tagged_two_qubit_program) - .qubits(2) - .detectors([Detector("m0")]) - .observables([Observable("m1")]) - .noise(noise) + .with_program(_tagged_two_qubit_program) + .with_qubits(2) + .with_detectors([Detector("m0")]) + .with_observables([Observable("m1")]) + .with_noise(noise) .build() ) via_typed_wrapper = build_dem_from_guppy( @@ -134,9 +134,12 @@ def test_builder_matches_both_wrappers_with_result_tags() -> None: @pytest.mark.parametrize( ("configure", "missing"), [ - (lambda builder: builder.qubits(2).detectors_json(_DETECTORS_JSON), "program"), - (lambda builder: builder.program(_tagged_two_qubit_program).detectors_json(_DETECTORS_JSON), "qubits"), - (lambda builder: builder.program(_tagged_two_qubit_program).qubits(2), "detectors"), + (lambda builder: builder.with_qubits(2).with_detectors_json(_DETECTORS_JSON), "with_program"), + ( + lambda builder: builder.with_program(_tagged_two_qubit_program).with_detectors_json(_DETECTORS_JSON), + "with_qubits", + ), + (lambda builder: builder.with_program(_tagged_two_qubit_program).with_qubits(2), "with_detectors"), ], ) def test_builder_reports_missing_required_setters( @@ -150,20 +153,20 @@ def test_builder_reports_missing_required_setters( @pytest.mark.parametrize( ("setter", "value"), [ - ("program", _tagged_two_qubit_program), - ("qubits", 2), - ("detectors", [Detector(rec[-1])]), - ("observables", [Observable(rec[-1])]), - ("detectors_json", _DETECTORS_JSON), - ("observables_json", _OBSERVABLES_JSON), - ("num_measurements", 2), - ("noise", NoiseParameters()), - ("idle_after_2q", 1.0), - ("strip_traced_idles", True), - ("runtime", None), - ("seed", 7), - ("require_hosted_operation_order", True), - ("max_hosted_tick_separation", 3), + ("with_program", _tagged_two_qubit_program), + ("with_qubits", 2), + ("with_detectors", [Detector(rec[-1])]), + ("with_observables", [Observable(rec[-1])]), + ("with_detectors_json", _DETECTORS_JSON), + ("with_observables_json", _OBSERVABLES_JSON), + ("with_num_measurements", 2), + ("with_noise", NoiseParameters()), + ("with_idle_after_2q", 1.0), + ("with_strip_traced_idles", True), + ("with_runtime", None), + ("with_seed", 7), + ("with_require_hosted_operation_order", True), + ("with_max_hosted_tick_separation", 3), ], ) def test_every_setter_rejects_a_second_call(setter: str, value: Any) -> None: @@ -177,18 +180,18 @@ def test_every_setter_rejects_a_second_call(setter: str, value: Any) -> None: @pytest.mark.parametrize( ("first", "second"), [ - ("detectors", "detectors_json"), - ("detectors_json", "detectors"), - ("observables", "observables_json"), - ("observables_json", "observables"), + ("with_detectors", "with_detectors_json"), + ("with_detectors_json", "with_detectors"), + ("with_observables", "with_observables_json"), + ("with_observables_json", "with_observables"), ], ) def test_typed_and_json_spellings_for_one_role_conflict(first: str, second: str) -> None: values = { - "detectors": [Detector(rec[-1])], - "detectors_json": _DETECTORS_JSON, - "observables": [Observable(rec[-1])], - "observables_json": _OBSERVABLES_JSON, + "with_detectors": [Detector(rec[-1])], + "with_detectors_json": _DETECTORS_JSON, + "with_observables": [Observable(rec[-1])], + "with_observables_json": _OBSERVABLES_JSON, } builder = DetectorErrorModel.builder() getattr(builder, first)(values[first]) @@ -197,30 +200,30 @@ def test_typed_and_json_spellings_for_one_role_conflict(first: str, second: str) getattr(builder, second)(values[second]) -@pytest.mark.parametrize("typed_setter", ["detectors", "observables"]) +@pytest.mark.parametrize("typed_setter", ["with_detectors", "with_observables"]) @pytest.mark.parametrize("typed_first", [False, True]) def test_num_measurements_conflicts_with_typed_specs(typed_setter: str, typed_first: bool) -> None: - specs = [Detector(rec[-1])] if typed_setter == "detectors" else [Observable(rec[-1])] + specs = [Detector(rec[-1])] if typed_setter == "with_detectors" else [Observable(rec[-1])] builder = DetectorErrorModel.builder() def combine_typed_specs_and_measurement_count() -> None: if typed_first: - getattr(builder, typed_setter)(specs).num_measurements(1) + getattr(builder, typed_setter)(specs).with_num_measurements(1) else: - getattr(builder.num_measurements(1), typed_setter)(specs) + getattr(builder.with_num_measurements(1), typed_setter)(specs) - with pytest.raises(ValueError, match="num_measurements"): + with pytest.raises(ValueError, match="with_num_measurements"): combine_typed_specs_and_measurement_count() def test_builder_result_evaluates_simulation_result_columns() -> None: build = ( DetectorErrorModel.builder() - .program(_tagged_two_qubit_program) - .qubits(2) - .detectors([Detector("m0")]) - .observables([Observable("m1")]) - .noise(NoiseParameters(p_meas=0.1)) + .with_program(_tagged_two_qubit_program) + .with_qubits(2) + .with_detectors([Detector("m0")]) + .with_observables([Observable("m1")]) + .with_noise(NoiseParameters(p_meas=0.1)) .build() ) columns = ( @@ -240,11 +243,11 @@ def test_builder_result_evaluates_simulation_result_columns() -> None: def test_json_builder_audit_accepts_legacy_id_aliases() -> None: build = ( DetectorErrorModel.builder() - .program(_tagged_two_qubit_program) - .qubits(2) - .detectors_json('[{"detector_id":"D0","records":[-2]}]') - .observables_json('[{"observable_id":"L0","records":[-1]}]') - .noise(NoiseParameters(p_meas=0.1)) + .with_program(_tagged_two_qubit_program) + .with_qubits(2) + .with_detectors_json('[{"detector_id":"D0","records":[-2]}]') + .with_observables_json('[{"observable_id":"L0","records":[-1]}]') + .with_noise(NoiseParameters(p_meas=0.1)) .build() ) @@ -255,22 +258,22 @@ def test_builder_setter_order_does_not_change_the_dem() -> None: noise = NoiseParameters(p1=0.01, p2=0.02, p_meas=0.03, p_prep=0.04) first = ( DetectorErrorModel.builder() - .program(_tagged_two_qubit_program) - .qubits(2) - .detectors([Detector(rec[-2])]) - .observables([Observable(rec[-1])]) - .noise(noise) - .seed(11) + .with_program(_tagged_two_qubit_program) + .with_qubits(2) + .with_detectors([Detector(rec[-2])]) + .with_observables([Observable(rec[-1])]) + .with_noise(noise) + .with_seed(11) .build() ) second = ( DetectorErrorModel.builder() - .seed(11) - .observables([Observable(rec[-1])]) - .noise(noise) - .detectors([Detector(rec[-2])]) - .qubits(2) - .program(_tagged_two_qubit_program) + .with_seed(11) + .with_observables([Observable(rec[-1])]) + .with_noise(noise) + .with_detectors([Detector(rec[-2])]) + .with_qubits(2) + .with_program(_tagged_two_qubit_program) .build() ) @@ -279,4 +282,10 @@ def test_builder_setter_order_does_not_change_the_dem() -> None: def test_builder_rejects_circuit_inputs_with_from_circuit_guidance() -> None: with pytest.raises(ValueError, match="from_circuit"): - (DetectorErrorModel.builder().program(TickCircuit()).qubits(1).detectors_json(_DETECTORS_JSON).build()) + ( + DetectorErrorModel.builder() + .with_program(TickCircuit()) + .with_qubits(1) + .with_detectors_json(_DETECTORS_JSON) + .build() + ) From 33cdf413b2ddf5629fd568e7068db7883a7a2ac7 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 4 Aug 2026 14:34:31 -0600 Subject: [PATCH 31/62] Make after-2q idle noise duration-based and rename the noise-builder setters to field names --- .../benches/modules/noise_models.rs | 8 +- crates/pecos-cli/src/main.rs | 10 +- .../examples/biased_depolarizing_example.rs | 30 +- .../examples/compare_noise_models.rs | 24 +- .../examples/general_noise_test.rs | 40 +-- .../examples/run_noisy_circ_with_general.rs | 10 +- .../src/noise/biased_depolarizing.rs | 34 +- .../pecos-engines/src/noise/depolarizing.rs | 36 +- crates/pecos-engines/src/noise/general.rs | 307 ++++++++++++------ .../src/noise/general/builder.rs | 98 +++--- .../src/noise/general/default.rs | 2 +- .../tests/measure_leaked_test.rs | 30 +- crates/pecos-engines/tests/mpz_test.rs | 10 +- .../pecos-engines/tests/noise_determinism.rs | 40 +-- crates/pecos-engines/tests/noise_test.rs | 200 ++++++------ .../examples/general_noise_builder.rs | 32 +- .../examples/general_noise_config.rs | 20 +- crates/pecos-qasm/src/config.rs | 19 +- crates/pecos-qasm/src/simulation.rs | 8 +- .../tests/general_noise_builder_test.rs | 54 ++- .../general_noise_builder_test.rs.disabled | 52 +-- crates/pecos-qasm/tests/qasm_sim_api_test.rs | 14 +- crates/pecos-qasm/tests/run_qasm_test.rs | 8 +- crates/pecos/tests/neo_emission_test.rs | 20 +- .../tests/neo_equivalence_matrix_test.rs | 34 +- crates/pecos/tests/neo_routing_test.rs | 33 +- crates/pecos/tests/neo_surface_ler_test.rs | 8 +- docs/user-guide/hugr-simulation.md | 10 +- docs/user-guide/noise-model-builders.md | 105 +++--- docs/user-guide/qasm-simulation.md | 57 ++-- docs/workflows/guppy-dem-decoding.md | 10 +- examples/Dusting off color code code.ipynb | 8 +- .../python_examples/noise_builder_example.py | 54 ++- .../surface/native_dem_threshold_sweep.py | 8 +- exp/pecos-neo/benches/hot_path.rs | 4 +- exp/pecos-neo/tests/engine_comparison_test.rs | 8 +- exp/pecos-neo/tests/noise_comparison_test.rs | 60 ++-- .../tests/sim_neo_comparison_test.rs | 38 +-- .../tests/statistical_validation_test.rs | 4 +- .../tests/surface_code_comparison_test.rs | 40 +-- python/pecos-rslib/examples/namespace_demo.py | 12 +- .../pecos-rslib/examples/namespace_example.py | 8 +- .../examples/qasm_simulation_examples.py | 51 +-- .../examples/structured_config_examples.py | 54 ++- python/pecos-rslib/src/engine_builders.rs | 95 +++--- .../pecos-rslib/tests/test_direct_builder.py | 18 +- .../pecos-rslib/tests/test_qasm_pythonic.py | 8 +- python/pecos-rslib/tests/test_sim_qasm.py | 6 +- .../tests/test_structured_config.py | 26 +- .../tests/user_guide_qasm_simulation.rs | 29 +- .../tests/guppy/test_missing_coverage.py | 4 +- .../tests/guppy/test_noise_models.py | 59 ++-- .../test_qasm_sim_comprehensive.py | 6 +- .../pecos/integration/test_qasm_sim_config.py | 18 +- .../integration/test_qasm_sim_custom_noise.py | 16 +- .../integration/test_qasm_sim_defaults.py | 6 +- .../pecos/test_noise_builder_setter_names.py | 90 +++++ .../tests/pecos/test_selene_sim_parity.py | 6 +- .../tests/pecos/test_sim_stack_routing.py | 8 +- 59 files changed, 1120 insertions(+), 987 deletions(-) create mode 100644 python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py diff --git a/crates/benchmarks/benches/modules/noise_models.rs b/crates/benchmarks/benches/modules/noise_models.rs index fa009072c..135c7d970 100644 --- a/crates/benchmarks/benches/modules/noise_models.rs +++ b/crates/benchmarks/benches/modules/noise_models.rs @@ -97,8 +97,8 @@ fn bench_depolarizing_noise(c: &mut Criterion) { // Benchmark mixed gate set (more realistic) group.bench_with_input(BenchmarkId::new("mixed", num_gates), &num_gates, |b, &n| { let mut noise = DepolarizingNoiseModel::builder() - .with_prep_probability(0.001) - .with_meas_probability(0.001) + .with_p_prep(0.001) + .with_p_meas(0.001) .with_single_qubit_probability(0.0005) .with_two_qubit_probability(0.002) .with_seed(42) @@ -130,8 +130,8 @@ fn bench_depolarizing_noise(c: &mut Criterion) { b.iter(|| { // Recreate with seed for reproducibility noise = DepolarizingNoiseModel::builder() - .with_prep_probability(0.001) - .with_meas_probability(0.001) + .with_p_prep(0.001) + .with_p_meas(0.001) .with_single_qubit_probability(0.0005) .with_two_qubit_probability(0.002) .with_seed(42) diff --git a/crates/pecos-cli/src/main.rs b/crates/pecos-cli/src/main.rs index a0258f3dd..bc44be2e3 100644 --- a/crates/pecos-cli/src/main.rs +++ b/crates/pecos-cli/src/main.rs @@ -565,11 +565,11 @@ fn run_program(args: &RunArgs) -> Result<(), PecosError> { parse_general_noise_probabilities(args.noise_probability.as_ref()); builder = builder.noise( GeneralNoiseModelBuilder::new() - .with_prep_probability(prep) - .with_meas_0_probability(meas_0) - .with_meas_1_probability(meas_1) - .with_p1_probability(single_qubit) - .with_p2_probability(two_qubit), + .with_p_prep(prep) + .with_p_meas_0(meas_0) + .with_p_meas_1(meas_1) + .with_p1(single_qubit) + .with_p2(two_qubit), ); } } diff --git a/crates/pecos-engines/examples/biased_depolarizing_example.rs b/crates/pecos-engines/examples/biased_depolarizing_example.rs index c344619ee..e2bd9a910 100644 --- a/crates/pecos-engines/examples/biased_depolarizing_example.rs +++ b/crates/pecos-engines/examples/biased_depolarizing_example.rs @@ -51,11 +51,11 @@ fn example1_different_bias_levels(circ: &ByteMessage, quantum: &StateVecEngine) for (p_flip_0, p_flip_1, desc) in configs { // Create the biased depolarizing noise model let noise = BiasedDepolarizingNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_flip_0) // Probability of flipping 0 to 1 - .with_meas_1_probability(p_flip_1) // Probability of flipping 1 to 0 - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(p_flip_0) // Probability of flipping 0 to 1 + .with_p_meas_1(p_flip_1) // Probability of flipping 1 to 0 + .with_p1(0.0) + .with_p2(0.0) .build(); let mut system = QuantumSystem::new(Box::new(noise), Box::new(quantum.clone())); @@ -110,11 +110,11 @@ fn example2_with_seed(circ: &ByteMessage) { println!("Example 2: Using direct constructor with seed"); let noise = BiasedDepolarizingNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.4) // Probability of flipping 0 to 1 - .with_meas_1_probability(0.1) // Probability of flipping 1 to 0 - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.4) // Probability of flipping 0 to 1 + .with_p_meas_1(0.1) // Probability of flipping 1 to 0 + .with_p1(0.0) + .with_p2(0.0) .with_seed(123) .build(); let quantum = Box::new(StateVecEngine::new(1)); @@ -170,11 +170,11 @@ fn example3_bell_state() { // Create a new quantum system with 2 qubits let quantum2 = Box::new(StateVecEngine::new(2)); let noise2 = BiasedDepolarizingNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.2) // Probability of flipping 0 to 1 - .with_meas_1_probability(0.3) // Probability of flipping 1 to 0 - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.2) // Probability of flipping 0 to 1 + .with_p_meas_1(0.3) // Probability of flipping 1 to 0 + .with_p1(0.0) + .with_p2(0.0) .build(); let mut system2 = QuantumSystem::new(Box::new(noise2), quantum2); diff --git a/crates/pecos-engines/examples/compare_noise_models.rs b/crates/pecos-engines/examples/compare_noise_models.rs index b3a7c71e1..c0f729254 100644 --- a/crates/pecos-engines/examples/compare_noise_models.rs +++ b/crates/pecos-engines/examples/compare_noise_models.rs @@ -41,11 +41,11 @@ fn compare_depolarizing_with_general(circ: &ByteMessage) { // Create equivalent general noise model let general_noise = GeneralNoiseModel::builder() - .with_prep_probability(p_noise) - .with_meas_0_probability(p_noise) - .with_meas_1_probability(p_noise) - .with_p1_probability(p_noise) - .with_p2_probability(p_noise) + .with_p_prep(p_noise) + .with_p_meas_0(p_noise) + .with_p_meas_1(p_noise) + .with_p1(p_noise) + .with_p2(p_noise) .with_seed(seed) .build(); let mut general_system = QuantumSystem::new(Box::new(general_noise), Box::new(quantum.clone())); @@ -183,11 +183,11 @@ fn test_asymmetric_measurements() { let p1 = 0.05; let general_noise = GeneralNoiseModel::builder() - .with_prep_probability(p_prep) - .with_meas_0_probability(p_meas_0) - .with_meas_1_probability(p_meas_1) - .with_p1_probability(p1) - .with_p2_probability(0.0) // Not used in this circuit + .with_p_prep(p_prep) + .with_p_meas_0(p_meas_0) + .with_p_meas_1(p_meas_1) + .with_p1(p1) + .with_p2(0.0) // Not used in this circuit .with_seed(seed) .build(); let mut general_system = QuantumSystem::new(Box::new(general_noise), Box::new(quantum.clone())); @@ -195,8 +195,8 @@ fn test_asymmetric_measurements() { // For comparison, a depolarizing model with symmetric errors let p_depolarizing = f64::midpoint(p_meas_0, p_meas_1); // Average of the asymmetric errors let depolarizing_noise = DepolarizingNoiseModel::builder() - .with_prep_probability(p_prep) - .with_meas_probability(p_depolarizing) + .with_p_prep(p_prep) + .with_p_meas(p_depolarizing) .with_single_qubit_probability(p1) .with_two_qubit_probability(0.0) .with_seed(seed) diff --git a/crates/pecos-engines/examples/general_noise_test.rs b/crates/pecos-engines/examples/general_noise_test.rs index 84cb1a709..c2315b99c 100644 --- a/crates/pecos-engines/examples/general_noise_test.rs +++ b/crates/pecos-engines/examples/general_noise_test.rs @@ -49,11 +49,11 @@ fn compare_biased_and_general(circ: &ByteMessage, quantum: &StateVecEngine) { for (p_flip_0, p_flip_1, desc) in configs { // Create biased depolarizing noise model with custom settings let biased_noise = BiasedDepolarizingNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_flip_0) // Probability of flipping 0 to 1 - .with_meas_1_probability(p_flip_1) // Probability of flipping 1 to 0 - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(p_flip_0) // Probability of flipping 0 to 1 + .with_p_meas_1(p_flip_1) // Probability of flipping 1 to 0 + .with_p1(0.0) + .with_p2(0.0) .with_seed(seed) .build(); let mut biased_system = @@ -61,11 +61,11 @@ fn compare_biased_and_general(circ: &ByteMessage, quantum: &StateVecEngine) { // Create equivalent general noise model (with gate noise set to 0) let general_noise = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_flip_0) - .with_meas_1_probability(p_flip_1) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(p_flip_0) + .with_p_meas_1(p_flip_1) + .with_p1(0.0) + .with_p2(0.0) .with_seed(seed) .build(); let mut general_system = @@ -155,22 +155,22 @@ fn bell_state_comparison() { // Create biased depolarizing noise model with custom settings let biased_noise = BiasedDepolarizingNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_flip_0) // Probability of flipping 0 to 1 - .with_meas_1_probability(p_flip_1) // Probability of flipping 1 to 0 - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(p_flip_0) // Probability of flipping 0 to 1 + .with_p_meas_1(p_flip_1) // Probability of flipping 1 to 0 + .with_p1(0.0) + .with_p2(0.0) .with_seed(seed) .build(); let mut biased_system = QuantumSystem::new(Box::new(biased_noise), Box::new(quantum.clone())); // Create equivalent general noise model let general_noise = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_flip_0) - .with_meas_1_probability(p_flip_1) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(p_flip_0) + .with_p_meas_1(p_flip_1) + .with_p1(0.0) + .with_p2(0.0) .with_seed(seed) .build(); let mut general_system = QuantumSystem::new(Box::new(general_noise), Box::new(quantum.clone())); diff --git a/crates/pecos-engines/examples/run_noisy_circ_with_general.rs b/crates/pecos-engines/examples/run_noisy_circ_with_general.rs index 2a6da30f6..37eb7fbe7 100644 --- a/crates/pecos-engines/examples/run_noisy_circ_with_general.rs +++ b/crates/pecos-engines/examples/run_noisy_circ_with_general.rs @@ -30,11 +30,11 @@ fn main() { // Create GeneralNoise with uniform probability for all error types let mut noise_builder = GeneralNoiseModel::builder() - .with_prep_probability(0.1) - .with_meas_0_probability(0.1) - .with_meas_1_probability(0.1) - .with_p1_probability(0.1) - .with_p2_probability(0.1); + .with_p_prep(0.1) + .with_p_meas_0(0.1) + .with_p_meas_1(0.1) + .with_p1(0.1) + .with_p2(0.1); // Set seed if provided if let Some(seed) = seed_option { diff --git a/crates/pecos-engines/src/noise/biased_depolarizing.rs b/crates/pecos-engines/src/noise/biased_depolarizing.rs index bf9e9fd4f..c36fa3a8c 100644 --- a/crates/pecos-engines/src/noise/biased_depolarizing.rs +++ b/crates/pecos-engines/src/noise/biased_depolarizing.rs @@ -41,9 +41,9 @@ use std::any::Any; /// /// // Or use the builder pattern /// let noise_model = BiasedDepolarizingNoiseModel::builder() -/// .with_prep_probability(0.01) -/// .with_meas_0_probability(0.02) -/// .with_meas_1_probability(0.03) +/// .with_p_prep(0.01) +/// .with_p_meas_0(0.02) +/// .with_p_meas_1(0.03) /// .with_single_qubit_probability(0.04) /// .with_two_qubit_probability(0.05) /// .with_seed(42) @@ -561,53 +561,53 @@ impl BiasedDepolarizingNoiseModelBuilder { /// Set the probability of error during preparation #[must_use] - pub fn with_prep_probability(mut self, probability: f64) -> Self { + pub fn with_p_prep(mut self, probability: f64) -> Self { self.p_prep = Some(probability); self } /// Set the probability of flipping 0 to 1 during measurement #[must_use] - pub fn with_meas_0_probability(mut self, probability: f64) -> Self { + pub fn with_p_meas_0(mut self, probability: f64) -> Self { self.p_meas_0 = Some(probability); self } /// Set the probability of flipping 1 to 0 during measurement #[must_use] - pub fn with_meas_1_probability(mut self, probability: f64) -> Self { + pub fn with_p_meas_1(mut self, probability: f64) -> Self { self.p_meas_1 = Some(probability); self } /// Set the probability of error after single-qubit gates #[must_use] - pub fn with_p1_probability(mut self, probability: f64) -> Self { + pub fn with_p1(mut self, probability: f64) -> Self { self.p1 = Some(probability); self } /// Set the probability of error after single-qubit gates /// - /// This is an alias for `with_p1_probability` for API consistency. + /// This is an alias for `with_p1` for API consistency. #[must_use] pub fn with_single_qubit_probability(self, probability: f64) -> Self { - self.with_p1_probability(probability) + self.with_p1(probability) } /// Set the probability of error after two-qubit gates #[must_use] - pub fn with_p2_probability(mut self, probability: f64) -> Self { + pub fn with_p2(mut self, probability: f64) -> Self { self.p2 = Some(probability); self } /// Set the probability of error after two-qubit gates /// - /// This is an alias for `with_p2_probability` for API consistency. + /// This is an alias for `with_p2` for API consistency. #[must_use] pub fn with_two_qubit_probability(self, probability: f64) -> Self { - self.with_p2_probability(probability) + self.with_p2(probability) } /// Set the seed for the random number generator @@ -715,11 +715,11 @@ mod tests { fn test_builder() { // Create a noise model with the builder let noise = BiasedDepolarizingNoiseModel::builder() - .with_prep_probability(0.1) - .with_meas_0_probability(0.2) - .with_meas_1_probability(0.3) - .with_p1_probability(0.4) - .with_p2_probability(0.5) + .with_p_prep(0.1) + .with_p_meas_0(0.2) + .with_p_meas_1(0.3) + .with_p1(0.4) + .with_p2(0.5) .build(); // Get the boxed noise model's probabilities using any_ref downcast diff --git a/crates/pecos-engines/src/noise/depolarizing.rs b/crates/pecos-engines/src/noise/depolarizing.rs index cfa7d9ac6..b91a67457 100644 --- a/crates/pecos-engines/src/noise/depolarizing.rs +++ b/crates/pecos-engines/src/noise/depolarizing.rs @@ -40,8 +40,8 @@ use std::any::Any; /// /// // Or use the builder pattern /// let noise_model = DepolarizingNoiseModel::builder() -/// .with_prep_probability(0.01) -/// .with_meas_probability(0.02) +/// .with_p_prep(0.01) +/// .with_p_meas(0.02) /// .with_single_qubit_probability(0.03) /// .with_two_qubit_probability(0.04) /// .with_seed(42) @@ -485,46 +485,46 @@ impl DepolarizingNoiseModelBuilder { /// Set the probability of error during preparation #[must_use] - pub fn with_prep_probability(mut self, probability: f64) -> Self { + pub fn with_p_prep(mut self, probability: f64) -> Self { self.p_prep = Some(probability); self } /// Set the probability of error during measurement #[must_use] - pub fn with_meas_probability(mut self, probability: f64) -> Self { + pub fn with_p_meas(mut self, probability: f64) -> Self { self.p_meas = Some(probability); self } /// Set the probability of error after single-qubit gates #[must_use] - pub fn with_p1_probability(mut self, probability: f64) -> Self { + pub fn with_p1(mut self, probability: f64) -> Self { self.p1 = Some(probability); self } /// Set the probability of error after single-qubit gates /// - /// This is an alias for `with_p1_probability` for API consistency. + /// This is an alias for `with_p1` for API consistency. #[must_use] pub fn with_single_qubit_probability(self, probability: f64) -> Self { - self.with_p1_probability(probability) + self.with_p1(probability) } /// Set the probability of error after two-qubit gates #[must_use] - pub fn with_p2_probability(mut self, probability: f64) -> Self { + pub fn with_p2(mut self, probability: f64) -> Self { self.p2 = Some(probability); self } /// Set the probability of error after two-qubit gates /// - /// This is an alias for `with_p2_probability` for API consistency. + /// This is an alias for `with_p2` for API consistency. #[must_use] pub fn with_two_qubit_probability(self, probability: f64) -> Self { - self.with_p2_probability(probability) + self.with_p2(probability) } /// Set the seed for the random number generator @@ -697,10 +697,10 @@ mod tests { fn test_builder() { // Create a noise model with the builder let mut noise = DepolarizingNoiseModel::builder() - .with_prep_probability(0.1) - .with_meas_probability(0.2) - .with_p1_probability(0.3) - .with_p2_probability(0.4) + .with_p_prep(0.1) + .with_p_meas(0.2) + .with_p1(0.3) + .with_p2(0.4) .build(); // Create a direct instance with the same probabilities @@ -784,10 +784,10 @@ mod tests { fn test_builder_with_probability() { // Create a noise model with the builder let mut noise = DepolarizingNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_probability(0.02) - .with_p1_probability(0.03) - .with_p2_probability(0.04) + .with_p_prep(0.01) + .with_p_meas(0.02) + .with_p1(0.03) + .with_p2(0.04) .build(); // Create a direct instance with the same probabilities diff --git a/crates/pecos-engines/src/noise/general.rs b/crates/pecos-engines/src/noise/general.rs index 8b423d700..eb5cae3ce 100644 --- a/crates/pecos-engines/src/noise/general.rs +++ b/crates/pecos-engines/src/noise/general.rs @@ -63,11 +63,11 @@ //! //! // Using the builder with explicit error rates //! let noise_model = GeneralNoiseModel::builder() -//! .with_prep_probability(0.01) -//! .with_meas_0_probability(0.02) -//! .with_meas_1_probability(0.03) -//! .with_p1_probability(0.04) -//! .with_p2_probability(0.05) +//! .with_p_prep(0.01) +//! .with_p_meas_0(0.02) +//! .with_p_meas_1(0.03) +//! .with_p1(0.04) +//! .with_p2(0.05) //! .with_seed(42) //! .build(); //! ``` @@ -282,11 +282,14 @@ pub struct GeneralNoiseModel { /// The distribution is stored as pre-computed, cached sampler instead of the `HashMap` that is the input. p2_pauli_model: TwoQubitWeightedSampler, - /// Idle noise after each two-qubit gate where noise will be applied stochastically based on - /// `p2_idle`. + /// Duration of the idle-noise site applied to each qubit after a two-qubit gate. /// - /// This may be useful for memory sweeping. - p2_idle: f64, + /// A value of `0.0` disables these sites. For a nonzero duration, the sites receive the same + /// configured idle mechanisms as a real [`GateType::Idle`] operation: linear stochastic noise + /// from `p_idle_linear_rate` and `p_idle_linear_model`, plus quadratic dephasing from + /// `p_idle_quadratic_rate` honoring `p_idle_coherent`. The duration is not itself an error + /// probability. + idle_after_2q: f64, /// Probability of flipping a 0 measurement to 1 /// @@ -819,26 +822,31 @@ impl GeneralNoiseModel { linear_rate: f64, quadratic_rate: f64, builder: &mut ByteMessageBuilder, + ) { + let qubits: Vec = gate.qubits.iter().map(|q| usize::from(*q)).collect(); + self.apply_idle_faults_for_duration( + linear_rate, + quadratic_rate, + gate.idle_duration(), + &qubits, + builder, + ); + } + + fn apply_idle_faults_for_duration( + &mut self, + linear_rate: f64, + quadratic_rate: f64, + duration: f64, + qubits: &[usize], + builder: &mut ByteMessageBuilder, ) { if linear_rate > f64::EPSILON { - let qubits_usize: Vec = gate.qubits.iter().map(|q| usize::from(*q)).collect(); - self.apply_idle_linear_stochastic_noise( - linear_rate, - gate.idle_duration(), - &qubits_usize, - builder, - ); + self.apply_idle_linear_stochastic_noise(linear_rate, duration, qubits, builder); } if quadratic_rate.abs() > f64::EPSILON { - // TODO: add test - let qubits_usize: Vec = gate.qubits.iter().map(|q| usize::from(*q)).collect(); - self.apply_idle_quadratic_dephasing( - quadratic_rate, - gate.idle_duration(), - &qubits_usize, - builder, - ); + self.apply_idle_quadratic_dephasing(quadratic_rate, duration, qubits, builder); } } @@ -1243,11 +1251,17 @@ impl GeneralNoiseModel { builder.add_gate_commands(&noise); - if self.p2_idle > f64::EPSILON { - self.apply_idle_linear_stochastic_noise( - self.p2_idle, - 1.0, - &original_gate_qubits, + if self.idle_after_2q > f64::EPSILON { + let gate_qubits = gate + .qubits + .iter() + .map(|q| usize::from(*q)) + .collect::>(); + self.apply_idle_faults_for_duration( + self.p_idle_linear_rate, + self.p_idle_quadratic_rate, + self.idle_after_2q, + &gate_qubits, builder, ); } @@ -1532,11 +1546,11 @@ mod tests { fn test_builder() { // Create a noise model with the builder let noise = GeneralNoiseModel::builder() - .with_prep_probability(0.1) - .with_meas_0_probability(0.2) - .with_meas_1_probability(0.3) - .with_average_p1_probability(0.4) - .with_average_p2_probability(0.5) + .with_p_prep(0.1) + .with_p_meas_0(0.2) + .with_p_meas_1(0.3) + .with_average_p1(0.4) + .with_average_p2(0.5) .with_prep_leak_ratio(0.6) .build(); @@ -1691,7 +1705,7 @@ mod tests { // Create a noise model with 100% prep error probability and 100% leakage ratio // using the builder pattern let mut model = GeneralNoiseModel::builder() - .with_prep_probability(1.0) + .with_p_prep(1.0) .with_prep_leak_ratio(1.0) .build(); let noise = model @@ -1721,7 +1735,7 @@ mod tests { // Now, create a noise model with 100% prep error probability but 0% leakage ratio let mut model = GeneralNoiseModel::builder() - .with_prep_probability(1.0) + .with_p_prep(1.0) .with_prep_leak_ratio(0.0) .build(); let noise = model @@ -1744,11 +1758,11 @@ mod tests { // Test builder configuration let noise = GeneralNoiseModel::builder() - .with_prep_probability(0.1) - .with_meas_0_probability(0.1) - .with_meas_1_probability(0.1) - .with_p1_probability(0.1) - .with_p2_probability(0.1) + .with_p_prep(0.1) + .with_p_meas_0(0.1) + .with_p_meas_1(0.1) + .with_p1(0.1) + .with_p2(0.1) .with_prep_leak_ratio(0.7) .build(); @@ -1765,11 +1779,11 @@ mod tests { fn test_leaked_qubit_measurement_behavior() { // Create a noise model with no spontaneous errors let mut model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .build(); // Manually mark qubit 0 as leaked @@ -1800,11 +1814,11 @@ mod tests { fn test_repeated_measurement_of_leaked_qubit() { // Create a noise model with no spontaneous errors let mut model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .build(); // Manually mark qubit 0 as leaked @@ -1847,11 +1861,11 @@ mod tests { // Create a noise model with no spontaneous errors let mut model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .build(); let noise = model .as_any_mut() @@ -1914,8 +1928,8 @@ mod tests { // Create a noise model with biased measurement probabilities let mut model = GeneralNoiseModel::builder() - .with_meas_0_probability(0.3) // 30% chance of flipping 0 to 1 - .with_meas_1_probability(0.2) // 20% chance of flipping 1 to 0 + .with_p_meas_0(0.3) // 30% chance of flipping 0 to 1 + .with_p_meas_1(0.2) // 20% chance of flipping 1 to 0 .with_seed(42) // Use fixed seed for deterministic test .build(); let noise = model @@ -1982,8 +1996,8 @@ mod tests { // Create a noise model with no measurement errors let mut model = GeneralNoiseModel::builder() - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) .build(); let noise = model .as_any_mut() @@ -2041,8 +2055,8 @@ mod tests { // Create a noise model with strong asymmetric bias // 80% chance of flipping 0->1, only 10% chance of flipping 1->0 let mut model = GeneralNoiseModel::builder() - .with_meas_0_probability(0.8) // Strong bias: 0 -> 1 - .with_meas_1_probability(0.1) // Weak bias: 1 -> 0 + .with_p_meas_0(0.8) // Strong bias: 0 -> 1 + .with_p_meas_1(0.1) // Weak bias: 1 -> 0 .with_seed(12345) // Fixed seed for reproducibility .build(); let noise = model @@ -2135,8 +2149,8 @@ mod tests { // Test with extreme biases to make the effect very clear // Case 1: Always flip 0->1, never flip 1->0 let mut model = GeneralNoiseModel::builder() - .with_meas_0_probability(1.0) // Always flip 0->1 - .with_meas_1_probability(0.0) // Never flip 1->0 + .with_p_meas_0(1.0) // Always flip 0->1 + .with_p_meas_1(0.0) // Never flip 1->0 .build(); let noise = model .as_any_mut() @@ -2172,8 +2186,8 @@ mod tests { // Case 2: Never flip 0->1, always flip 1->0 let mut model = GeneralNoiseModel::builder() - .with_meas_0_probability(0.0) // Never flip 0->1 - .with_meas_1_probability(1.0) // Always flip 1->0 + .with_p_meas_0(0.0) // Never flip 0->1 + .with_p_meas_1(1.0) // Always flip 1->0 .build(); let noise = model .as_any_mut() @@ -2216,11 +2230,11 @@ mod tests { // Create a noise model with no errors (deterministic) let mut model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .build(); let noise = model @@ -2283,11 +2297,11 @@ mod tests { // Create a noise model with no measurement errors (deterministic) let mut model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .build(); let noise = model @@ -2336,11 +2350,11 @@ mod tests { // Create a noise model with no measurement errors (deterministic) let mut model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .build(); let noise = model @@ -2396,8 +2410,8 @@ mod tests { // Test that leaked qubits are forced to 1, then bias is applied let mut model = GeneralNoiseModel::builder() - .with_meas_0_probability(0.0) // No 0->1 flips - .with_meas_1_probability(0.5) // 50% chance to flip 1->0 + .with_p_meas_0(0.0) // No 0->1 flips + .with_p_meas_1(0.5) // 50% chance to flip 1->0 .with_seed(42) .build(); let noise = model @@ -2612,11 +2626,11 @@ mod tests { fn test_parameter_scaling() { // Test that scaling factors are applied correctly - use builder pattern let mut model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.01) - .with_average_p2_probability(0.01) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.01) + .with_average_p2(0.01) .with_scale(2.0) .with_p1_scale(3.0) .with_p2_scale(4.0) @@ -2686,11 +2700,11 @@ mod tests { fn test_builder_with_scaling() { // Test that builder applies scaling factors correctly let noise = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.01) - .with_average_p2_probability(0.01) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.01) + .with_average_p2(0.01) .with_prep_leak_ratio(0.01) .with_scale(2.0) .with_p1_scale(3.0) @@ -2798,6 +2812,93 @@ mod tests { assert!((noise.p2_emission_ratio - 0.6).abs() < 1e-6); } + fn after_2q_outputs( + duration: f64, + linear_rate: f64, + quadratic_rate: f64, + coherent: bool, + seed: u64, + shots: usize, + ) -> Vec> { + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.cx(&[(0, 1)]); + let input = input_builder.build(); + + let mut model = GeneralNoiseModel::builder() + .with_p2(0.0) + .with_p_idle_linear_rate(linear_rate) + .with_p_idle_quadratic_rate(quadratic_rate) + .with_p_idle_coherent(coherent) + .with_idle_after_2q(duration) + .with_seed(seed) + .build(); + + (0..shots) + .map(|_| { + model + .apply_noise_on_start(&input) + .unwrap() + .quantum_ops() + .unwrap() + }) + .collect() + } + + fn emitted_after_2q_noise_count(outputs: &[Vec]) -> usize { + outputs + .iter() + .flatten() + .filter(|gate| gate.gate_type != GateType::CX) + .count() + } + + #[test] + fn idle_after_2q_duration_scales_linear_noise() { + let smaller = after_2q_outputs(0.1, 1.0, 0.0, false, 42, 1_000); + let larger = after_2q_outputs(1.0, 1.0, 0.0, false, 42, 1_000); + + assert!( + emitted_after_2q_noise_count(&larger) > emitted_after_2q_noise_count(&smaller), + "a larger after-2q idle duration should emit strictly more idle-noise gates" + ); + } + + #[test] + fn idle_after_2q_applies_quadratic_dephasing() { + let outputs = after_2q_outputs(0.5, 0.0, 0.25, true, 42, 1); + let rz_gate = outputs + .iter() + .flatten() + .find(|gate| gate.gate_type == GateType::RZ) + .expect("quadratic coherent dephasing should emit an RZ gate"); + + assert_eq!(rz_gate.qubits.len(), 2); + assert!(rz_gate.qubits.contains(&QubitId(0))); + assert!(rz_gate.qubits.contains(&QubitId(1))); + } + + #[test] + fn zero_idle_after_2q_duration_emits_no_idle_noise() { + let outputs = after_2q_outputs(0.0, 1.0, 0.0, false, 42, 100); + + assert_eq!(emitted_after_2q_noise_count(&outputs), 0); + } + + #[test] + fn zero_idle_rates_emit_no_after_2q_idle_noise() { + let outputs = after_2q_outputs(1.0, 0.0, 0.0, false, 42, 100); + + assert_eq!(emitted_after_2q_noise_count(&outputs), 0); + } + + #[test] + fn idle_after_2q_is_deterministic_for_same_seed() { + let first = after_2q_outputs(0.5, 0.4, 0.0, false, 42, 100); + let second = after_2q_outputs(0.5, 0.4, 0.0, false, 42, 100); + + assert_eq!(first, second); + } + #[test] fn test_p_idle_coherent() { // Create a circuit builder @@ -2910,7 +3011,7 @@ mod tests { #[allow(clippy::unreadable_literal)] fn test_rzz_error_rate() { let mut model = GeneralNoiseModel::builder() - .with_average_p2_probability(0.1) + .with_average_p2(0.1) .with_p2_angle_params(0.1, 0.0, 0.25, 0.0) .with_p2_angle_power(1.0) .build(); @@ -2939,7 +3040,7 @@ mod tests { // Test quadratic scaling let mut model = GeneralNoiseModel::builder() - .with_average_p2_probability(0.1) + .with_average_p2(0.1) .with_p2_angle_params(0.1, 0.0, 0.25, 0.0) .with_p2_angle_power(2.0) .build(); @@ -2960,7 +3061,7 @@ mod tests { fn test_noiseless_gates() { // Create a noise model and mark RZ as a noiseless gate let mut model = GeneralNoiseModel::builder() - .with_p1_probability(0.5) // Use a moderate valid probability + .with_p1(0.5) // Use a moderate valid probability .with_noiseless_gate(GateType::RZ) .build(); let noise = model @@ -3065,7 +3166,7 @@ mod tests { #[test] fn test_rzz_error_rate_debug() { let mut model = GeneralNoiseModel::builder() - .with_average_p2_probability(0.1) + .with_average_p2(0.1) .with_p2_angle_params(0.1, 0.0, 0.25, 0.0) .build(); let noise = model @@ -3090,7 +3191,7 @@ mod tests { // Check scaled przz error rate let mut model = GeneralNoiseModel::builder() - .with_average_p2_probability(0.1) + .with_average_p2(0.1) .with_p2_angle_params(0.1, 0.0, 0.25, 0.0) .with_scale(2.0) .build(); @@ -3140,11 +3241,11 @@ mod tests { // Create a noise model with custom Pauli and emission models using the builder let model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_p1_probability(0.1) - .with_p2_probability(0.2) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_p1(0.1) + .with_p2(0.2) .with_p1_pauli_model(&custom_p1_pauli) .with_p1_emission_model(&custom_p1_emission) .with_p2_pauli_model(&custom_p2_pauli) diff --git a/crates/pecos-engines/src/noise/general/builder.rs b/crates/pecos-engines/src/noise/general/builder.rs index b6662620a..18821a04f 100644 --- a/crates/pecos-engines/src/noise/general/builder.rs +++ b/crates/pecos-engines/src/noise/general/builder.rs @@ -63,7 +63,11 @@ pub struct GeneralNoiseModelBuilder { p2_emission_model: Option, p2_seepage_prob: Option, p2_pauli_model: Option, - p2_idle: Option, + /// Duration of the idle-noise sites applied after two-qubit gates. + /// + /// These sites use the configured linear and quadratic idle mechanisms; this value is not a + /// standalone error probability. + idle_after_2q: Option, p2_scale: Option, // measurement noise p_meas_0: Option, @@ -120,7 +124,7 @@ impl GeneralNoiseModelBuilder { p2_emission_model: None, p2_seepage_prob: None, p2_pauli_model: None, - p2_idle: None, + idle_after_2q: None, p2_scale: None, // measurement noise p_meas_0: None, @@ -252,8 +256,8 @@ impl GeneralNoiseModelBuilder { model.p2_pauli_model = model_map; } - if let Some(p2_idle) = self.p2_idle { - model.p2_idle = p2_idle; + if let Some(idle_after_2q) = self.idle_after_2q { + model.idle_after_2q = idle_after_2q; } // measurement noise @@ -427,7 +431,7 @@ impl GeneralNoiseModelBuilder { /// Set the probability of error during preparation #[must_use] - pub fn with_prep_probability(mut self, probability: f64) -> Self { + pub fn with_p_prep(mut self, probability: f64) -> Self { self.p_prep = Some(Self::validate_probability(probability)); self } @@ -483,7 +487,7 @@ impl GeneralNoiseModelBuilder { /// Set the probability of error after single-qubit gates #[must_use] - pub fn with_p1_probability(mut self, probability: f64) -> Self { + pub fn with_p1(mut self, probability: f64) -> Self { self.p1 = Some(Self::validate_probability(probability)); self } @@ -498,7 +502,7 @@ impl GeneralNoiseModelBuilder { /// For a single-qubit gate with uniform error distribution across 3 Pauli errors, /// the ratio of total error rate to average error rate is 3/2. #[must_use] - pub fn with_average_p1_probability(mut self, probability: f64) -> Self { + pub fn with_average_p1(mut self, probability: f64) -> Self { self.p1 = Some(Self::validate_probability(probability * 3.0 / 2.0)); self } @@ -547,7 +551,7 @@ impl GeneralNoiseModelBuilder { /// Set the probability of error after two-qubit gates #[must_use] - pub fn with_p2_probability(mut self, probability: f64) -> Self { + pub fn with_p2(mut self, probability: f64) -> Self { self.p2 = Some(Self::validate_probability(probability)); self } @@ -562,7 +566,7 @@ impl GeneralNoiseModelBuilder { /// For a two-qubit gate with uniform error distribution across 15 Pauli errors, /// the ratio of total error rate to average error rate is 5/4. #[must_use] - pub fn with_average_p2_probability(mut self, probability: f64) -> Self { + pub fn with_average_p2(mut self, probability: f64) -> Self { self.p2 = Some(Self::validate_probability(probability * 5.0 / 4.0)); self } @@ -635,9 +639,19 @@ impl GeneralNoiseModelBuilder { self } + /// Set the duration of the idle-noise site applied to each qubit after a two-qubit gate. + /// + /// A duration of `0.0` disables these sites. Nonzero sites receive all configured idle + /// mechanisms over the given duration: linear stochastic noise from `p_idle_linear_rate` and + /// `p_idle_linear_model`, and quadratic dephasing from `p_idle_quadratic_rate`, honoring + /// `p_idle_coherent`. + /// + /// Anyone who previously wrote `with_p2_idle(0.01)` and no linear rate now gets no after-2q + /// idle noise; the equivalent is + /// `with_p_idle_linear_rate(0.01).with_idle_after_2q(1.0)`. #[must_use] - pub fn with_p2_idle(mut self, probability: f64) -> Self { - self.p2_idle = Some(Self::validate_probability(probability)); + pub fn with_idle_after_2q(mut self, duration: f64) -> Self { + self.idle_after_2q = Some(Self::validate_duration(duration)); self } @@ -658,21 +672,21 @@ impl GeneralNoiseModelBuilder { /// Set the probability of flipping 0 to 1 during measurement #[must_use] - pub fn with_meas_0_probability(mut self, probability: f64) -> Self { + pub fn with_p_meas_0(mut self, probability: f64) -> Self { self.p_meas_0 = Some(Self::validate_probability(probability)); self } /// Set the probability of flipping 1 to 0 during measurement #[must_use] - pub fn with_meas_1_probability(mut self, probability: f64) -> Self { + pub fn with_p_meas_1(mut self, probability: f64) -> Self { self.p_meas_1 = Some(Self::validate_probability(probability)); self } /// Set the probability of bit flipping the measurement result #[must_use] - pub fn with_meas_probability(mut self, probability: f64) -> Self { + pub fn with_p_meas(mut self, probability: f64) -> Self { self.p_meas_0 = Some(Self::validate_probability(probability)); self.p_meas_1 = Some(Self::validate_probability(probability)); self @@ -755,6 +769,15 @@ impl GeneralNoiseModelBuilder { value } + /// Validate that a duration is finite and non-negative + fn validate_duration(duration: f64) -> f64 { + assert!( + duration.is_finite() && duration >= 0.0, + "Duration must be finite and non-negative, got {duration}" + ); + duration + } + // ========================================================================================== // /// The simple Pauli-probability subset of this configuration, if the /// physics reduces to it. @@ -859,7 +882,7 @@ impl GeneralNoiseModelBuilder { // Neutral model defaults: unset is fine. let optional_features_off = zero_or_unset(self.p_idle_quadratic_rate) && zero_or_unset(self.p_prep_crosstalk) - && zero_or_unset(self.p2_idle) + && zero_or_unset(self.idle_after_2q) && zero_or_unset(self.p_meas_crosstalk_global) && zero_or_unset(self.p_meas_crosstalk_local); @@ -1015,7 +1038,6 @@ impl GeneralNoiseModelBuilder { model.p_idle_quadratic_rate *= 2.0 * std::f64::consts::PI; model.p_idle_linear_rate = model.p_idle_linear_rate * scale * idle_scale; - model.p2_idle = Self::validate_probability(model.p2_idle * scale * idle_scale); } } @@ -1041,7 +1063,7 @@ mod tests { // Setting only a probability does not neutralize the defaults. assert!( GeneralNoiseModelBuilder::new() - .with_average_p1_probability(0.2) + .with_average_p1(0.2) .simple_probabilities() .is_none() ); @@ -1050,11 +1072,11 @@ mod tests { #[test] fn simple_probabilities_returns_stored_convention_values() { let simple = GeneralNoiseModelBuilder::new() - .with_average_p1_probability(0.2) - .with_average_p2_probability(0.4) - .with_prep_probability(0.01) - .with_meas_0_probability(0.02) - .with_meas_1_probability(0.03) + .with_average_p1(0.2) + .with_average_p2(0.4) + .with_p_prep(0.01) + .with_p_meas_0(0.02) + .with_p_meas_1(0.03) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_prep_leak_ratio(0.0) @@ -1091,8 +1113,8 @@ mod tests { #[test] fn pauli_with_angle_scaling_matches_simple_when_no_angle() { let builder = GeneralNoiseModelBuilder::new() - .with_average_p1_probability(0.2) - .with_average_p2_probability(0.4) + .with_average_p1(0.2) + .with_average_p2(0.4) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_prep_leak_ratio(0.0) @@ -1114,17 +1136,17 @@ mod tests { #[test] fn pauli_with_angle_scaling_extracts_configured_angle() { let builder = GeneralNoiseModelBuilder::new() - .with_p2_probability(0.3) + .with_p2(0.3) .with_p2_angle_params(1.5, 0.0, 1.0, 0.0) .with_p2_angle_power(2.0) - .with_average_p1_probability(0.0) + .with_average_p1(0.0) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_prep_leak_ratio(0.0) .with_p_idle_linear_rate(0.0) - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0); + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0); // Angle scaling is outside the STRICT simple subset. assert!(builder.simple_probabilities().is_none()); @@ -1140,16 +1162,16 @@ mod tests { #[test] fn pauli_with_angle_scaling_fills_unset_power_from_default() { let builder = GeneralNoiseModelBuilder::new() - .with_p2_probability(0.3) + .with_p2(0.3) .with_p2_angle_params(1.5, 0.0, 1.0, 0.0) - .with_average_p1_probability(0.0) + .with_average_p1(0.0) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_prep_leak_ratio(0.0) .with_p_idle_linear_rate(0.0) - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0); + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0); let (_, _, _, _, _, angle, _, _) = builder .pauli_with_angle_scaling() @@ -1166,7 +1188,7 @@ mod tests { // because they are never explicitly zeroed -> beyond the subset. // (Emission ratios are NOT a blocker -- they are part of the subset.) let builder = GeneralNoiseModelBuilder::new() - .with_p2_probability(0.3) + .with_p2(0.3) .with_p2_angle_params(1.5, 0.0, 1.0, 0.0); assert!(builder.pauli_with_angle_scaling().is_none()); } @@ -1176,8 +1198,8 @@ mod tests { #[test] fn pauli_with_angle_scaling_extracts_emission_ratios() { let builder = GeneralNoiseModelBuilder::new() - .with_average_p1_probability(0.2) - .with_average_p2_probability(0.4) + .with_average_p1(0.2) + .with_average_p2(0.4) .with_p1_emission_ratio(0.25) .with_p2_emission_ratio(0.75) .with_prep_leak_ratio(0.0) @@ -1200,7 +1222,7 @@ mod tests { #[test] fn pauli_with_angle_scaling_rejects_emission_scale() { let builder = GeneralNoiseModelBuilder::new() - .with_average_p1_probability(0.2) + .with_average_p1(0.2) .with_p1_emission_ratio(0.25) .with_emission_scale(2.0) .with_prep_leak_ratio(0.0) diff --git a/crates/pecos-engines/src/noise/general/default.rs b/crates/pecos-engines/src/noise/general/default.rs index 66aba12fd..42f8e1c06 100644 --- a/crates/pecos-engines/src/noise/general/default.rs +++ b/crates/pecos-engines/src/noise/general/default.rs @@ -103,7 +103,7 @@ impl Default for GeneralNoiseModel { p2_angle_c: 0.0, p2_angle_d: 1.0, p2_angle_power: 1.0, - p2_idle: 0.0, + idle_after_2q: 0.0, leaked_qubits: BTreeSet::new(), rng: NoiseRng::default(), prepared_qubits: BTreeSet::new(), diff --git a/crates/pecos-engines/tests/measure_leaked_test.rs b/crates/pecos-engines/tests/measure_leaked_test.rs index d79acc256..8426b6a10 100644 --- a/crates/pecos-engines/tests/measure_leaked_test.rs +++ b/crates/pecos-engines/tests/measure_leaked_test.rs @@ -31,11 +31,11 @@ fn test_measure_leaked_basic_functionality() { fn test_measure_leaked_with_general_noise_model() { // Create a noise model let mut noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .with_seed(42) .build(); @@ -120,11 +120,11 @@ fn test_measure_leaked_preserves_quantum_state() { fn test_measure_leaked_sequential_measurements() { // Test that leaked state persists across multiple measurements let mut noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .with_seed(42) .build(); @@ -181,11 +181,11 @@ fn test_measure_leaked_sequential_measurements() { fn test_measure_leaked_with_prep_unleaks() { // Test that Prep operation unleaks qubits let mut noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .with_seed(42) .build(); diff --git a/crates/pecos-engines/tests/mpz_test.rs b/crates/pecos-engines/tests/mpz_test.rs index db1d2366f..da87ba834 100644 --- a/crates/pecos-engines/tests/mpz_test.rs +++ b/crates/pecos-engines/tests/mpz_test.rs @@ -44,11 +44,11 @@ fn mpz_runs_through_the_general_noise_model() { use pecos_engines::noise::general::GeneralNoiseModel; let noise = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .with_seed(7) .build(); let engine = Box::new(StateVecEngine::new(1)); diff --git a/crates/pecos-engines/tests/noise_determinism.rs b/crates/pecos-engines/tests/noise_determinism.rs index 42082427c..f78ddbf1f 100644 --- a/crates/pecos-engines/tests/noise_determinism.rs +++ b/crates/pecos-engines/tests/noise_determinism.rs @@ -49,11 +49,11 @@ fn create_noise_model() -> GeneralNoiseModel { // Use builder to construct the model with all parameters set let mut model = GeneralNoiseModel::builder() - .with_prep_probability(0.1) - .with_meas_0_probability(0.1) - .with_meas_1_probability(0.1) - .with_p1_probability(0.1) - .with_p2_probability(0.1) + .with_p_prep(0.1) + .with_p_meas_0(0.1) + .with_p_meas_1(0.1) + .with_p1(0.1) + .with_p2(0.1) .with_p1_pauli_model(&single_qubit_weights) .with_p2_pauli_model(&two_qubit_weights) .with_p1_emission_ratio(0.5) @@ -472,11 +472,11 @@ fn test_deterministic_measurement() { // Create a noise model with significant measurement error let model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.2) - .with_meas_1_probability(0.2) - .with_average_p1_probability(0.1) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.2) + .with_p_meas_1(0.2) + .with_average_p1(0.1) + .with_average_p2(0.1) .build(); // Box the model for use with the NoiseModel trait @@ -600,14 +600,14 @@ fn test_comprehensive_noise_determinism() { // Create a noise model with all types of noise let model = GeneralNoiseModel::builder() // Preparation errors - .with_prep_probability(0.05) + .with_p_prep(0.05) .with_prep_leak_ratio(0.2) // Measurement errors - .with_meas_0_probability(0.1) - .with_meas_1_probability(0.15) + .with_p_meas_0(0.1) + .with_p_meas_1(0.15) // Gate errors - .with_average_p1_probability(0.2) - .with_average_p2_probability(0.1) + .with_average_p1(0.2) + .with_average_p2(0.1) // Leakage and emission errors .with_p1_emission_ratio(0.3) .with_p2_emission_ratio(0.3) @@ -736,11 +736,11 @@ fn test_long_running_determinism() { // Create a noise model with moderate error rates let model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.02) - .with_meas_1_probability(0.02) - .with_average_p1_probability(0.1) - .with_average_p2_probability(0.05) + .with_p_prep(0.01) + .with_p_meas_0(0.02) + .with_p_meas_1(0.02) + .with_average_p1(0.1) + .with_average_p2(0.05) .build(); // Box the model diff --git a/crates/pecos-engines/tests/noise_test.rs b/crates/pecos-engines/tests/noise_test.rs index df11a3c6b..085db8ca9 100644 --- a/crates/pecos-engines/tests/noise_test.rs +++ b/crates/pecos-engines/tests/noise_test.rs @@ -76,11 +76,11 @@ fn test_single_qubit_gate_noise_distributions() { // Create noise model with high error rates using the builder pattern let noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) // Disable emission errors .with_seed(42) .build(); @@ -159,11 +159,11 @@ fn test_rotation_gate_with_different_angles() { // Create noise model with high error rates for clearer results using the builder pattern // Explicitly avoid marking RZ as a noiseless gate for this test let noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.05) - .with_meas_0_probability(0.05) - .with_meas_1_probability(0.05) - .with_average_p1_probability(0.1) - .with_average_p2_probability(0.2) + .with_p_prep(0.05) + .with_p_meas_0(0.05) + .with_p_meas_1(0.05) + .with_average_p1(0.1) + .with_average_p2(0.2) .build(); // Test rotation gates with different angles @@ -299,11 +299,11 @@ fn test_two_qubit_gate_noise_distributions() { // Create noise model with high error rates for clearer results using the builder pattern let noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.05) - .with_meas_0_probability(0.05) - .with_meas_1_probability(0.05) - .with_average_p1_probability(0.1) - .with_average_p2_probability(0.2) + .with_p_prep(0.05) + .with_p_meas_0(0.05) + .with_p_meas_1(0.05) + .with_average_p1(0.1) + .with_average_p2(0.2) .build(); // Test CNOT gate with different input states @@ -435,11 +435,11 @@ fn test_rzz_angle_dependent_error_model() { // Create noise model with RZZ angle-dependent error parameters using the builder pattern let noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.05) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.05) + .with_average_p2(0.1) .with_p2_angle_params(0.05, 0.0, 0.1, 0.0) // a=0.05, b=0, c=0.1, d=0 .with_p2_angle_power(1.0) // Linear scaling with angle .with_seed(42) @@ -519,11 +519,11 @@ fn test_leakage_model() { // Create noise model with significant leakage using the builder pattern let noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.05) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.05) + .with_average_p2(0.1) .with_p2_emission_ratio(0.8) // High emission ratio for obvious effect .with_prep_leak_ratio(0.5) // 50% of prep errors lead to leakage .with_seed(42) @@ -562,11 +562,11 @@ fn test_software_gates_not_affected_by_noise() { // Create noise model with high error rates using the builder pattern let noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.3) - .with_average_p2_probability(0.3) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.3) + .with_average_p2(0.3) .with_seed(42) .with_noiseless_gate(GateType::RZ) .build(); @@ -618,11 +618,11 @@ fn test_coherent_vs_incoherent_dephasing() { // Create two noise models with different dephasing types using the builder pattern let coherent_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.05) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.05) + .with_average_p2(0.1) .with_p_idle_coherent(true) .with_seed(42) .build(); @@ -631,11 +631,11 @@ fn test_coherent_vs_incoherent_dephasing() { // The build() method now returns GeneralNoiseModel directly let incoherent_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.05) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.05) + .with_average_p2(0.1) .with_p_idle_coherent(false) .with_p_idle_coherent_to_incoherent_factor(2.0) .with_seed(42) @@ -701,11 +701,11 @@ fn test_parameter_scaling_impact() { for scale in scale_factors { // Create a noise model with the given scale factor using the builder pattern let noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.05) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.05) + .with_average_p2(0.1) .with_scale(scale) // Apply overall scaling .with_seed(42) .build(); @@ -756,11 +756,11 @@ fn test_debug_x_gate_noise() { // Create a simple noise model with high error rate but no emission errors using the builder pattern let noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) .build(); @@ -810,11 +810,11 @@ fn test_seed_effect() { // Create a simple noise model with high error rate but no emission errors using the builder pattern let noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) .build(); @@ -890,11 +890,11 @@ fn test_seed_effect() { .collect(); let complex_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) .with_p1_pauli_model(&pauli_model) .with_p1_emission_model(&emission_model) @@ -923,11 +923,11 @@ fn test_combined_comparison() { println!("=== TESTING SIMPLER MODEL ==="); // Create a simple noise model with high error rate but no emission errors using the builder pattern let simple_noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) .with_seed(42) .build(); @@ -977,11 +977,11 @@ fn test_combined_comparison() { // Create the model with the builder let complex_noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.8) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.8) .with_p1_emission_ratio(0.0) // No leakage errors .with_p1_pauli_model(&pauli_model) .with_p1_emission_model(&emission_model) @@ -1041,11 +1041,11 @@ fn test_pauli_model_effect() { println!("=== Test with default Pauli model ==="); // Create a noise model with default Pauli model using the builder pattern let noise_model1 = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) .with_seed(42) .build(); @@ -1085,11 +1085,11 @@ fn test_pauli_model_effect() { .collect(); let noise_model2 = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) .with_p1_pauli_model(&x_biased_model) .with_p1_emission_model(&emission_model) @@ -1120,11 +1120,11 @@ fn test_pauli_model_effect() { .collect(); let noise_model3 = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) .with_p1_pauli_model(&z_biased_model) .with_p1_emission_model(&emission_model) @@ -1160,11 +1160,11 @@ fn test_pauli_model_behavior() { // ====== Model 1: Default model (equal distribution of X, Y, Z errors) ====== let model1 = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) // Turn off emission errors .with_seed(42) .build(); @@ -1192,11 +1192,11 @@ fn test_pauli_model_behavior() { .collect(); let model2 = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) // Turn off emission errors .with_p1_pauli_model(&x_biased_model) .with_seed(42) @@ -1225,11 +1225,11 @@ fn test_pauli_model_behavior() { .collect(); let model3 = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) // Turn off emission errors .with_p1_pauli_model(&z_biased_model) .with_seed(42) diff --git a/crates/pecos-qasm/examples/general_noise_builder.rs b/crates/pecos-qasm/examples/general_noise_builder.rs index da6ac4acd..32af92b77 100644 --- a/crates/pecos-qasm/examples/general_noise_builder.rs +++ b/crates/pecos-qasm/examples/general_noise_builder.rs @@ -10,10 +10,10 @@ fn run_basic_noise_example(qasm: &str) -> Result<(), Box> println!("Example 1: Basic noise configuration"); let basic_noise = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002); + .with_p1(0.001) + .with_p2(0.01) + .with_p_meas_0(0.002) + .with_p_meas_1(0.002); let results = sim_builder() .classical(qasm_engine().program(Qasm::from_string(qasm))) @@ -68,11 +68,11 @@ fn main() -> Result<(), Box> { let complex_noise = GeneralNoiseModel::builder() .with_seed(123) .with_scale(1.5) // Scale all error rates by 1.5x - .with_average_p1_probability(0.001) + .with_average_p1(0.001) .with_p1_pauli_model(&p1_pauli) - .with_average_p2_probability(0.01) + .with_average_p2(0.01) .with_p2_pauli_model(&p2_pauli) - .with_prep_probability(0.001) + .with_p_prep(0.001) .with_leakage_scale(0.1) .with_emission_scale(0.8); @@ -89,8 +89,8 @@ fn main() -> Result<(), Box> { let selective_noise = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.1) // High single-qubit error - .with_p2_probability(0.1) // High two-qubit error + .with_p1(0.1) // High single-qubit error + .with_p2(0.1) // High two-qubit error .with_noiseless_gate(pecos_core::prelude::GateType::H) // H gates have no noise .with_noiseless_gate(pecos_core::prelude::GateType::MZ); // Measurements have no noise @@ -110,13 +110,13 @@ fn main() -> Result<(), Box> { .with_scale(1.2) .with_leakage_scale(0.2) .with_emission_scale(0.7) - .with_prep_probability(0.0005) - .with_p1_probability(0.001) - .with_average_p1_probability(0.0008) - .with_p2_probability(0.01) - .with_average_p2_probability(0.008) - .with_meas_0_probability(0.001) - .with_meas_1_probability(0.003) + .with_p_prep(0.0005) + .with_p1(0.001) + .with_average_p1(0.0008) + .with_p2(0.01) + .with_average_p2(0.008) + .with_p_meas_0(0.001) + .with_p_meas_1(0.003) .with_p_idle_coherent(false) .with_p_idle_linear_rate(0.0001) .with_noiseless_gate(GateType::H) diff --git a/crates/pecos-qasm/examples/general_noise_config.rs b/crates/pecos-qasm/examples/general_noise_config.rs index 109e3822e..3d86a9913 100644 --- a/crates/pecos-qasm/examples/general_noise_config.rs +++ b/crates/pecos-qasm/examples/general_noise_config.rs @@ -26,11 +26,13 @@ fn main() -> Result<(), Box> { // Example 1: General noise model with detailed configuration println!("Example 1: GeneralNoiseModelBuilder with unified API"); let general_noise = GeneralNoiseModel::builder() - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_prep_probability(0.001) - .with_meas_0_probability(0.001) - .with_meas_1_probability(0.001) + .with_p1(0.001) + .with_p2(0.01) + .with_p_prep(0.001) + .with_p_meas_0(0.001) + .with_p_meas_1(0.001) + .with_p_idle_linear_rate(0.0001) + .with_idle_after_2q(1.0) .with_seed(42); let results = sim_builder() @@ -56,10 +58,10 @@ fn main() -> Result<(), Box> { // Example 3: Custom depolarizing noise with different rates println!("\nExample 3: Custom depolarizing noise"); let custom_depolarizing = DepolarizingNoiseModel::builder() - .with_prep_probability(0.001) - .with_meas_probability(0.002) - .with_p1_probability(0.001) - .with_p2_probability(0.01); + .with_p_prep(0.001) + .with_p_meas(0.002) + .with_p1(0.001) + .with_p2(0.01); let results = sim_builder() .classical(qasm_engine().program(Qasm::from_string(qasm))) diff --git a/crates/pecos-qasm/src/config.rs b/crates/pecos-qasm/src/config.rs index ad792ce1e..9cee77246 100644 --- a/crates/pecos-qasm/src/config.rs +++ b/crates/pecos-qasm/src/config.rs @@ -114,8 +114,11 @@ pub struct GeneralNoiseFields { pub p2_seepage_prob: Option, #[serde(skip_serializing_if = "Option::is_none")] pub p2_pauli_model: Option>, + /// Duration of the idle-noise sites applied to both qubits after a two-qubit gate. + /// + /// The configured linear and quadratic idle mechanisms determine the noise at these sites. #[serde(skip_serializing_if = "Option::is_none")] - pub p2_idle: Option, + pub idle_after_2q: Option, #[serde(skip_serializing_if = "Option::is_none")] pub p2_scale: Option, @@ -229,7 +232,7 @@ impl GeneralNoiseFields { /// Apply prep noise parameters to the builder fn apply_prep_params(&self, mut builder: GeneralNoiseModelBuilder) -> GeneralNoiseModelBuilder { if let Some(v) = self.p_prep { - builder = builder.with_prep_probability(v); + builder = builder.with_p_prep(v); } if let Some(v) = self.p_prep_leak_ratio { builder = builder.with_prep_leak_ratio(v); @@ -252,7 +255,7 @@ impl GeneralNoiseFields { mut builder: GeneralNoiseModelBuilder, ) -> GeneralNoiseModelBuilder { if let Some(v) = self.p1 { - builder = builder.with_p1_probability(v); + builder = builder.with_p1(v); } if let Some(v) = self.p1_emission_ratio { builder = builder.with_p1_emission_ratio(v); @@ -278,7 +281,7 @@ impl GeneralNoiseFields { mut builder: GeneralNoiseModelBuilder, ) -> GeneralNoiseModelBuilder { if let Some(v) = self.p2 { - builder = builder.with_p2_probability(v); + builder = builder.with_p2(v); } if let Some((a, b, c, d)) = self.p2_angle_params { builder = builder.with_p2_angle_params(a, b, c, d); @@ -298,8 +301,8 @@ impl GeneralNoiseFields { if let Some(model) = self.p2_pauli_model.as_ref() { builder = builder.with_p2_pauli_model(model); } - if let Some(v) = self.p2_idle { - builder = builder.with_p2_idle(v); + if let Some(v) = self.idle_after_2q { + builder = builder.with_idle_after_2q(v); } if let Some(v) = self.p2_scale { builder = builder.with_p2_scale(v); @@ -310,10 +313,10 @@ impl GeneralNoiseFields { /// Apply measurement noise parameters to the builder fn apply_meas_params(&self, mut builder: GeneralNoiseModelBuilder) -> GeneralNoiseModelBuilder { if let Some(v) = self.p_meas_0 { - builder = builder.with_meas_0_probability(v); + builder = builder.with_p_meas_0(v); } if let Some(v) = self.p_meas_1 { - builder = builder.with_meas_1_probability(v); + builder = builder.with_p_meas_1(v); } if let Some(v) = self.p_meas_crosstalk { builder = builder.with_p_meas_crosstalk(v); diff --git a/crates/pecos-qasm/src/simulation.rs b/crates/pecos-qasm/src/simulation.rs index 4e9813524..e26c84e27 100644 --- a/crates/pecos-qasm/src/simulation.rs +++ b/crates/pecos-qasm/src/simulation.rs @@ -38,10 +38,10 @@ use pecos_programs::Qasm; /// /// // Run with noise /// let noise_builder = DepolarizingNoiseModel::builder() -/// .with_p1_probability(0.001) -/// .with_p2_probability(0.01) -/// .with_prep_probability(0.001) -/// .with_meas_probability(0.001); +/// .with_p1(0.001) +/// .with_p2(0.01) +/// .with_p_prep(0.001) +/// .with_p_meas(0.001); /// /// let results = qasm_engine() /// .program(Qasm::from_string(qasm)) diff --git a/crates/pecos-qasm/tests/general_noise_builder_test.rs b/crates/pecos-qasm/tests/general_noise_builder_test.rs index cc5094628..c4dca3b0d 100644 --- a/crates/pecos-qasm/tests/general_noise_builder_test.rs +++ b/crates/pecos-qasm/tests/general_noise_builder_test.rs @@ -23,10 +23,10 @@ fn test_general_noise_builder_basic() { // Create builder with fluent API let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002); + .with_p1(0.001) + .with_p2(0.01) + .with_p_meas_0(0.002) + .with_p_meas_1(0.002); let results = sim_builder() .classical(qasm_engine().program(Qasm::from_string(qasm))) @@ -70,7 +70,7 @@ fn test_general_noise_builder_with_pauli_models() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.1) // High error rate for testing + .with_p1(0.1) // High error rate for testing .with_p1_pauli_model(&p1_model); let results = sim_builder() @@ -122,13 +122,13 @@ fn test_general_noise_builder_complex_configuration() { .with_scale(1.5) .with_leakage_scale(0.1) .with_emission_scale(0.8) - .with_prep_probability(0.001) - .with_average_p1_probability(0.0008) + .with_p_prep(0.001) + .with_average_p1(0.0008) .with_p1_pauli_model(&p1_model) - .with_average_p2_probability(0.008) + .with_average_p2(0.008) .with_p2_pauli_model(&p2_model) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.003) + .with_p_meas_0(0.002) + .with_p_meas_1(0.003) .with_noiseless_gate(GateType::H); let results = sim_builder() @@ -157,8 +157,8 @@ fn test_general_noise_builder_noiseless_gates() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.5) // Very high error rate - .with_p2_probability(0.5) // Very high error rate + .with_p1(0.5) // Very high error rate + .with_p2(0.5) // Very high error rate .with_noiseless_gate(GateType::H) // H gate is noiseless .with_noiseless_gate(GateType::MZ); // Measurement is noiseless @@ -192,9 +192,7 @@ fn test_general_noise_builder_with_prep_errors() { measure q -> c; "#; - let noise_builder = GeneralNoiseModel::builder() - .with_seed(42) - .with_prep_probability(0.1); // 10% prep error + let noise_builder = GeneralNoiseModel::builder().with_seed(42).with_p_prep(0.1); // 10% prep error let results = sim_builder() .classical(qasm_engine().program(Qasm::from_string(qasm))) @@ -237,8 +235,8 @@ fn test_general_noise_builder_measurement_errors() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_meas_0_probability(0.05) // 5% chance |0> measured as |1> - .with_meas_1_probability(0.10); // 10% chance |1> measured as |0> + .with_p_meas_0(0.05) // 5% chance |0> measured as |1> + .with_p_meas_1(0.10); // 10% chance |1> measured as |0> let results = sim_builder() .classical(qasm_engine().program(Qasm::from_string(qasm))) @@ -287,13 +285,13 @@ fn test_general_noise_builder_chaining_all_methods() { .with_scale(1.2) .with_leakage_scale(0.1) .with_emission_scale(0.9) - .with_prep_probability(0.001) - .with_p1_probability(0.001) - .with_average_p1_probability(0.0008) - .with_p2_probability(0.01) - .with_average_p2_probability(0.008) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.003) + .with_p_prep(0.001) + .with_p1(0.001) + .with_average_p1(0.0008) + .with_p2(0.01) + .with_average_p2(0.008) + .with_p_meas_0(0.002) + .with_p_meas_1(0.003) .with_p_idle_coherent(false) .with_p_idle_linear_rate(0.0001) .with_noiseless_gate(GateType::H) @@ -326,8 +324,8 @@ fn test_general_noise_builder_with_multiple_noiseless_gates() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.1) // High noise - .with_p2_probability(0.1) // High noise + .with_p1(0.1) // High noise + .with_p2(0.1) // High noise .with_noiseless_gate(GateType::H) .with_noiseless_gate(GateType::SZ) // S gate .with_noiseless_gate(GateType::T) @@ -381,8 +379,8 @@ fn test_general_noise_builder_comparison_with_sim_builder() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01); + .with_p1(0.001) + .with_p2(0.01); // Test full method chaining with simulation builder let results = sim_builder() diff --git a/crates/pecos-qasm/tests/general_noise_builder_test.rs.disabled b/crates/pecos-qasm/tests/general_noise_builder_test.rs.disabled index a130c6fe1..6e62139e1 100644 --- a/crates/pecos-qasm/tests/general_noise_builder_test.rs.disabled +++ b/crates/pecos-qasm/tests/general_noise_builder_test.rs.disabled @@ -23,10 +23,10 @@ fn test_general_noise_builder_basic() { // Create builder with fluent API let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002); + .with_p1(0.001) + .with_p2(0.01) + .with_p_meas_0(0.002) + .with_p_meas_1(0.002); let noise_model = NoiseModelType::General(Box::new(noise_builder)); @@ -71,7 +71,7 @@ fn test_general_noise_builder_with_pauli_models() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.1) // High error rate for testing + .with_p1(0.1) // High error rate for testing .with_p1_pauli_model(&p1_model); let noise_model = NoiseModelType::General(Box::new(noise_builder)); @@ -116,13 +116,13 @@ fn test_general_noise_builder_complex_configuration() { .with_scale(1.5) .with_leakage_scale(0.1) .with_emission_scale(0.8) - .with_prep_probability(0.001) - .with_average_p1_probability(0.0008) + .with_p_prep(0.001) + .with_average_p1(0.0008) .with_p1_pauli_model(&p1_model) - .with_average_p2_probability(0.008) + .with_average_p2(0.008) .with_p2_pauli_model(&p2_model) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.003) + .with_p_meas_0(0.002) + .with_p_meas_1(0.003) .with_noiseless_gate(GateType::H); let noise_model = NoiseModelType::General(Box::new(noise_builder)); @@ -152,8 +152,8 @@ fn test_general_noise_builder_noiseless_gates() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.5) // Very high error rate - .with_p2_probability(0.5) // Very high error rate + .with_p1(0.5) // Very high error rate + .with_p2(0.5) // Very high error rate .with_noiseless_gate(GateType::H) // H gate is noiseless .with_noiseless_gate(GateType::Measure); // Measurement is noiseless @@ -186,7 +186,7 @@ fn test_general_noise_builder_with_prep_errors() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_prep_probability(0.1); // 10% prep error + .with_p_prep(0.1); // 10% prep error let noise_model = NoiseModelType::General(Box::new(noise_builder)); @@ -230,8 +230,8 @@ fn test_general_noise_builder_measurement_errors() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_meas_0_probability(0.05) // 5% chance |0> measured as |1> - .with_meas_1_probability(0.10); // 10% chance |1> measured as |0> + .with_p_meas_0(0.05) // 5% chance |0> measured as |1> + .with_p_meas_1(0.10); // 10% chance |1> measured as |0> let noise_model = NoiseModelType::General(Box::new(noise_builder)); @@ -277,13 +277,13 @@ fn test_general_noise_builder_chaining_all_methods() { .with_scale(1.2) .with_leakage_scale(0.1) .with_emission_scale(0.9) - .with_prep_probability(0.001) - .with_p1_probability(0.001) - .with_average_p1_probability(0.0008) - .with_p2_probability(0.01) - .with_average_p2_probability(0.008) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.003) + .with_p_prep(0.001) + .with_p1(0.001) + .with_average_p1(0.0008) + .with_p2(0.01) + .with_average_p2(0.008) + .with_p_meas_0(0.002) + .with_p_meas_1(0.003) .with_p_idle_coherent(false) .with_p_idle_linear_rate(0.0001) .with_noiseless_gate(GateType::H) @@ -313,8 +313,8 @@ fn test_general_noise_builder_with_multiple_noiseless_gates() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.1) // High noise - .with_p2_probability(0.1) // High noise + .with_p1(0.1) // High noise + .with_p2(0.1) // High noise .with_noiseless_gate(GateType::H) .with_noiseless_gate(GateType::SZ) // S gate .with_noiseless_gate(GateType::T) @@ -368,8 +368,8 @@ fn test_general_noise_builder_comparison_with_sim_builder() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01); + .with_p1(0.001) + .with_p2(0.01); let noise_model = NoiseModelType::General(Box::new(noise_builder)); diff --git a/crates/pecos-qasm/tests/qasm_sim_api_test.rs b/crates/pecos-qasm/tests/qasm_sim_api_test.rs index ffc6ab2a3..447f62f24 100644 --- a/crates/pecos-qasm/tests/qasm_sim_api_test.rs +++ b/crates/pecos-qasm/tests/qasm_sim_api_test.rs @@ -109,10 +109,10 @@ fn test_custom_depolarizing_noise() { // Use builder for custom depolarizing noise let noise_builder = DepolarizingNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_probability(0.01) - .with_p1_probability(0.001) - .with_p2_probability(0.1); // High two-qubit error + .with_p_prep(0.01) + .with_p_meas(0.01) + .with_p1(0.001) + .with_p2(0.1); // High two-qubit error let results = qasm_engine() .program(Qasm::from_string(qasm)) @@ -327,9 +327,9 @@ fn test_general_noise() { // Use GeneralNoiseModelBuilder instead of old GeneralNoise let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.001) - .with_meas_0_probability(0.001) - .with_meas_1_probability(0.001); + .with_p1(0.001) + .with_p_meas_0(0.001) + .with_p_meas_1(0.001); let results = qasm_engine() .program(Qasm::from_string(qasm)) diff --git a/crates/pecos-qasm/tests/run_qasm_test.rs b/crates/pecos-qasm/tests/run_qasm_test.rs index 28f3854d0..8fda645cf 100644 --- a/crates/pecos-qasm/tests/run_qasm_test.rs +++ b/crates/pecos-qasm/tests/run_qasm_test.rs @@ -113,10 +113,10 @@ fn test_run_qasm_with_config_structs() { // Test with config struct converted to enum let noise_config = DepolarizingNoiseModelBuilder::new() - .with_prep_probability(0.01) - .with_meas_probability(0.01) - .with_p1_probability(0.001) - .with_p2_probability(0.1); + .with_p_prep(0.01) + .with_p_meas(0.01) + .with_p1(0.001) + .with_p2(0.1); let results = sim_builder() .classical(qasm_engine().program(Qasm::from_string(qasm))) diff --git a/crates/pecos/tests/neo_emission_test.rs b/crates/pecos/tests/neo_emission_test.rs index 36224887b..6221220ff 100644 --- a/crates/pecos/tests/neo_emission_test.rs +++ b/crates/pecos/tests/neo_emission_test.rs @@ -64,12 +64,12 @@ fn rate_zero(shots: &pecos_engines::shot_results::ShotVec) -> (u64, f64) { /// fresh builder each call since `.noise()` consumes it. fn emission_noise_1q() -> pecos_engines::noise::GeneralNoiseModelBuilder { pecos_engines::noise::GeneralNoiseModel::builder() - .with_p1_probability(P1) + .with_p1(P1) .with_p1_emission_ratio(EMISSION) - .with_p2_probability(0.0) - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) + .with_p2(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) .with_prep_leak_ratio(0.0) .with_p_idle_linear_rate(0.0) } @@ -193,12 +193,12 @@ const CX_MEASURE: &str = r#" fn emission_noise_2q() -> pecos_engines::noise::GeneralNoiseModelBuilder { pecos_engines::noise::GeneralNoiseModel::builder() - .with_p1_probability(0.0) - .with_p2_probability(P2) + .with_p1(0.0) + .with_p2(P2) .with_p2_emission_ratio(1.0) - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) .with_prep_leak_ratio(0.0) .with_p_idle_linear_rate(0.0) } diff --git a/crates/pecos/tests/neo_equivalence_matrix_test.rs b/crates/pecos/tests/neo_equivalence_matrix_test.rs index cf0037d9a..1ed2803d8 100644 --- a/crates/pecos/tests/neo_equivalence_matrix_test.rs +++ b/crates/pecos/tests/neo_equivalence_matrix_test.rs @@ -160,10 +160,10 @@ impl NoiseCell { let builder = sim(Qasm::from_string(qasm)).stack(stack).seed(seed); let depol = |p_prep: f64, p_meas: f64, p1: f64, p2: f64| { pecos_engines::noise::DepolarizingNoiseModel::builder() - .with_prep_probability(p_prep) - .with_meas_probability(p_meas) - .with_p1_probability(p1) - .with_p2_probability(p2) + .with_p_prep(p_prep) + .with_p_meas(p_meas) + .with_p1(p1) + .with_p2(p2) }; let results = match *self { Self::Meas(p) => builder.noise(depol(0.0, p, 0.0, 0.0)).shots(SHOTS).run(), @@ -180,15 +180,15 @@ impl NoiseCell { // zero everything outside the simple Pauli subset so // the cell physics is exactly known. pecos_engines::noise::GeneralNoiseModel::builder() - .with_average_p1_probability(average_p1) - .with_average_p2_probability(0.0) + .with_average_p1(average_p1) + .with_average_p2(0.0) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_prep_leak_ratio(0.0) .with_p_idle_linear_rate(0.0) - .with_prep_probability(0.0) - .with_meas_0_probability(p_meas) - .with_meas_1_probability(p_meas), + .with_p_prep(0.0) + .with_p_meas_0(p_meas) + .with_p_meas_1(p_meas), ) .shots(SHOTS) .run(), @@ -202,17 +202,17 @@ impl NoiseCell { // every other channel and the non-neutral GNM defaults so // only the angle-scaled RZZ depolarizing noise remains. pecos_engines::noise::GeneralNoiseModel::builder() - .with_p2_probability(p2) + .with_p2(p2) .with_p2_angle_params(a, b, c, d) .with_p2_angle_power(angle_power) - .with_average_p1_probability(0.0) + .with_average_p1(0.0) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_prep_leak_ratio(0.0) .with_p_idle_linear_rate(0.0) - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0), + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0), ) .shots(SHOTS) .run(), @@ -220,9 +220,9 @@ impl NoiseCell { .noise( // Asymmetric record-flip measurement, no gate/prep noise. pecos_engines::noise::BiasedDepolarizingNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_meas_0) - .with_meas_1_probability(p_meas_1) + .with_p_prep(0.0) + .with_p_meas_0(p_meas_0) + .with_p_meas_1(p_meas_1) .with_single_qubit_probability(0.0) .with_two_qubit_probability(0.0), ) diff --git a/crates/pecos/tests/neo_routing_test.rs b/crates/pecos/tests/neo_routing_test.rs index abfd77804..9c311eae0 100644 --- a/crates/pecos/tests/neo_routing_test.rs +++ b/crates/pecos/tests/neo_routing_test.rs @@ -176,10 +176,10 @@ fn neo_stack_measurement_noise_rate_matches_engines() { let p_meas = 0.2; let shots = 4000; let noise = pecos_engines::noise::DepolarizingNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_probability(p_meas) - .with_p1_probability(0.0) - .with_p2_probability(0.0); + .with_p_prep(0.0) + .with_p_meas(p_meas) + .with_p1(0.0) + .with_p2(0.0); let engines = sim(x_measure_qasm()) .stack(SimStack::Engines) @@ -274,7 +274,7 @@ fn neo_stack_biased_depolarizing_struct_rate_matches_engines() { #[test] fn neo_stack_general_noise_average_convention_matches() { - // The critical convention test: engines' with_average_p1_probability + // The critical convention test: engines' with_average_p1 // stores p1 = 1.5 x average internally (standard depolarizing // convention), which the mapping carries one-to-one to neo. With // average_p1 = 0.2 the effective depolarizing p1 is 0.3, so the @@ -288,15 +288,15 @@ fn neo_stack_general_noise_average_convention_matches() { // leak, idle, and base probabilities); zero everything except the // 1q Pauli channel so the physics is plain depolarizing. let noise = pecos_engines::noise::GeneralNoiseModel::builder() - .with_average_p1_probability(0.2) + .with_average_p1(0.2) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_prep_leak_ratio(0.0) .with_p_idle_linear_rate(0.0) - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_average_p2_probability(0.0); + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_average_p2(0.0); sim(x_measure_qasm()) .stack(stack) .noise(noise) @@ -326,8 +326,7 @@ fn neo_stack_rejects_unmapped_noise() { // subset, so the mapping must refuse rather than silently change the // model. (Spontaneous emission IS now mapped, so it is the prep-leak // and idle defaults that force the rejection here.) - let general = - pecos_engines::noise::GeneralNoiseModel::builder().with_average_p1_probability(0.01); + let general = pecos_engines::noise::GeneralNoiseModel::builder().with_average_p1(0.01); let err = sim(deterministic_conditional_qasm()) .stack(SimStack::Neo) .noise(general) @@ -348,13 +347,13 @@ fn neo_stack_rejects_nonunit_emission_scale() { // DIFFERENT emission rate to neo than engines runs. The facade must reject // it rather than silently diverge. (Codex batch-4 finding 1.) let general = pecos_engines::noise::GeneralNoiseModel::builder() - .with_p1_probability(0.3) + .with_p1(0.3) .with_p1_emission_ratio(0.25) .with_emission_scale(2.0) - .with_p2_probability(0.0) - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) + .with_p2(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) .with_prep_leak_ratio(0.0) .with_p_idle_linear_rate(0.0); let err = sim(deterministic_conditional_qasm()) diff --git a/crates/pecos/tests/neo_surface_ler_test.rs b/crates/pecos/tests/neo_surface_ler_test.rs index 755089eaa..dc8eb30c6 100644 --- a/crates/pecos/tests/neo_surface_ler_test.rs +++ b/crates/pecos/tests/neo_surface_ler_test.rs @@ -279,10 +279,10 @@ fn shots_to_syndromes( /// Uniform circuit-level depolarizing noise for the engines/neo mapping. fn depolarizing_noise(p: f64) -> pecos_engines::noise::DepolarizingNoiseModelBuilder { pecos_engines::noise::DepolarizingNoiseModel::builder() - .with_prep_probability(p) - .with_meas_probability(p) - .with_p1_probability(p) - .with_p2_probability(p) + .with_p_prep(p) + .with_p_meas(p) + .with_p1(p) + .with_p2(p) } /// Run the experiment on one stack and return its `ShotVec`. diff --git a/docs/user-guide/hugr-simulation.md b/docs/user-guide/hugr-simulation.md index 1c2373722..da6d53b12 100644 --- a/docs/user-guide/hugr-simulation.md +++ b/docs/user-guide/hugr-simulation.md @@ -423,11 +423,11 @@ Add realistic noise to your Guppy simulations: # Custom noise model noise = ( GeneralNoiseModelBuilder() - .with_prep_probability(0.001) - .with_p1_probability(0.0001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.02) - .with_meas_1_probability(0.03) + .with_p_prep(0.001) + .with_p1(0.0001) + .with_p2(0.01) + .with_p_meas_0(0.02) + .with_p_meas_1(0.03) ) results = sim(Guppy(noisy_bell)).qubits(2).quantum(state_vector()).noise(noise).run(1000) diff --git a/docs/user-guide/noise-model-builders.md b/docs/user-guide/noise-model-builders.md index 02eaf751b..971f9c098 100644 --- a/docs/user-guide/noise-model-builders.md +++ b/docs/user-guide/noise-model-builders.md @@ -28,8 +28,8 @@ measure q -> c; noise = ( GeneralNoiseModelBuilder() .with_seed(42) # Reproducible randomness - .with_p1_probability(0.001) # Single-qubit gate error - .with_p2_probability(0.01) + .with_p1(0.001) # Single-qubit gate error + .with_p2(0.01) ) # Two-qubit gate error # Use with sim() @@ -46,12 +46,12 @@ The `GeneralNoiseModelBuilder` provides methods to configure all aspects of quan noise = ( GeneralNoiseModelBuilder() # Gate errors - .with_p1_probability(0.001) # Single-qubit gate error - .with_p2_probability(0.01) # Two-qubit gate error + .with_p1(0.001) # Single-qubit gate error + .with_p2(0.01) # Two-qubit gate error # State preparation and measurement - .with_prep_probability(0.0005) # State preparation error - .with_meas_0_probability(0.002) # Measurement 0→1 flip - .with_meas_1_probability(0.003) + .with_p_prep(0.0005) # State preparation error + .with_p_meas_0(0.002) # Measurement 0→1 flip + .with_p_meas_1(0.003) ) # Measurement 1→0 flip ``` @@ -61,16 +61,10 @@ The builder supports both "total" and "average" error probabilities: ```python # Average probability (recommended for physical intuition) -noise = ( - GeneralNoiseModelBuilder() - .with_average_p1_probability(0.001) # Converted to total internally - .with_average_p2_probability(0.01) -) +noise = GeneralNoiseModelBuilder().with_average_p1(0.001).with_average_p2(0.01) # Converted to total internally # Total probability (used internally by the engine) -noise = ( - GeneralNoiseModelBuilder().with_p1_probability(0.00133).with_p2_probability(0.0133) # Total for single-qubit -) # Total for two-qubit +noise = GeneralNoiseModelBuilder().with_p1(0.00133).with_p2(0.0133) # Total for single-qubit # Total for two-qubit ``` **Note**: Average probabilities are more intuitive as they represent the actual error rate per gate. Total probabilities include a conversion factor based on the number of Pauli operators. @@ -120,8 +114,8 @@ Make specific gates ideal (no noise): ```python noise = ( GeneralNoiseModelBuilder() - .with_p1_probability(0.001) - .with_p2_probability(0.01) + .with_p1(0.001) + .with_p2(0.01) # Single gate .with_noiseless_gate("H") # Multiple gates @@ -134,13 +128,27 @@ noise = ( ### Idle Locations `Idle` gates are timing markers by default. They do not silently inherit -single-qubit gate noise from `p1` or `with_p1_probability(...)`. +single-qubit gate noise from `p1` or `with_p1(...)`. + +Configure idle decoherence with `with_p_idle_linear_rate(...)` and optionally +`with_p_idle_linear_model(...)`, or with `with_p_idle_quadratic_rate(...)` and +`with_p_idle_coherent(...)`. The rates are combined with each `Idle` gate's +duration. -This is intentional: adding an idle location changes circuit timing, while -adding idle noise changes the physical noise model. To model idle decoherence, -use an API that explicitly attaches idle noise or an explicit channel to idle -locations. This keeps scheduling changes from accidentally changing the noise -model. +To add the same kind of idle-noise site to both qubits after every two-qubit +gate, set its duration with `with_idle_after_2q(...)`: + +```python +noise = GeneralNoiseModelBuilder().with_p_idle_linear_rate(0.01).with_idle_after_2q(1.0) +``` + +The duration only chooses where and how long idling occurs. It is not a +standalone probability: all configured linear and quadratic idle mechanisms +apply at these sites just as they do at a scheduled `Idle` gate. A duration of +`0.0` disables the after-two-qubit sites. Consequently, code that previously +used `with_p2_idle(0.01)` without a linear idle rate now produces no after-2q +idle noise; the equivalent configuration is +`with_p_idle_linear_rate(0.01).with_idle_after_2q(1.0)`. ## Common Noise Model Examples @@ -151,12 +159,7 @@ Simple uniform noise on all operations: ```python # Uniform depolarizing noise noise = ( - GeneralNoiseModelBuilder() - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_prep_probability(0.001) - .with_meas_0_probability(0.001) - .with_meas_1_probability(0.001) + GeneralNoiseModelBuilder().with_p1(0.001).with_p2(0.01).with_p_prep(0.001).with_p_meas_0(0.001).with_p_meas_1(0.001) ) ``` @@ -169,12 +172,12 @@ noise = ( GeneralNoiseModelBuilder() .with_seed(42) # Gate errors (two-qubit gates are typically 10x worse) - .with_average_p1_probability(0.0001) # 0.01% single-qubit error - .with_average_p2_probability(0.001) # 0.1% two-qubit error + .with_average_p1(0.0001) # 0.01% single-qubit error + .with_average_p2(0.001) # 0.1% two-qubit error # State prep and measurement (often dominant errors) - .with_prep_probability(0.001) # 0.1% prep error - .with_meas_0_probability(0.01) # 1% false positive - .with_meas_1_probability(0.005) + .with_p_prep(0.001) # 0.1% prep error + .with_p_meas_0(0.01) # 1% false positive + .with_p_meas_1(0.005) ) # 0.5% false negative ``` @@ -187,14 +190,14 @@ noise = ( GeneralNoiseModelBuilder() .with_seed(42) # Excellent single-qubit gates - .with_average_p1_probability(0.00001) # 0.001% error + .with_average_p1(0.00001) # 0.001% error # Two-qubit gates are the limiting factor - .with_average_p2_probability(0.003) # 0.3% error + .with_average_p2(0.003) # 0.3% error # State preparation - .with_prep_probability(0.001) # 0.1% error + .with_p_prep(0.001) # 0.1% error # Asymmetric measurement (bright/dark state detection) - .with_meas_0_probability(0.001) # Dark state error - .with_meas_1_probability(0.005) + .with_p_meas_0(0.001) # Dark state error + .with_p_meas_1(0.005) ) # Bright state error (higher) ``` @@ -206,7 +209,7 @@ Model with biased errors (e.g., more phase errors than bit flips): noise = ( GeneralNoiseModelBuilder() # Biased single-qubit errors - .with_average_p1_probability(0.001) + .with_average_p1(0.001) .with_p1_pauli_model( { "X": 0.1, # 10% bit flips @@ -215,7 +218,7 @@ noise = ( } ) # Biased two-qubit errors - .with_average_p2_probability(0.01) + .with_average_p2(0.01) .with_p2_pauli_model( { "IZ": 0.3, # 30% phase on second qubit @@ -259,9 +262,9 @@ noise = ( # Make Hadamard gates perfect .with_noiseless_gate("H") # State preparation - .with_prep_probability(0.001) + .with_p_prep(0.001) # Single-qubit gates with biased errors - .with_average_p1_probability(0.0001) + .with_average_p1(0.0001) .with_p1_pauli_model( { "X": 0.2, @@ -270,10 +273,10 @@ noise = ( } ) # Two-qubit gates - .with_average_p2_probability(0.001) + .with_average_p2(0.001) # Asymmetric measurement - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.005) + .with_p_meas_0(0.002) + .with_p_meas_1(0.005) ) # Run simulation @@ -314,11 +317,11 @@ simple = depolarizing_noise().with_uniform_probability(0.001) # Equivalent with GeneralNoiseModelBuilder builder = ( GeneralNoiseModelBuilder() - .with_p1_probability(0.001) - .with_p2_probability(0.001) - .with_prep_probability(0.001) - .with_meas_0_probability(0.001) - .with_meas_1_probability(0.001) + .with_p1(0.001) + .with_p2(0.001) + .with_p_prep(0.001) + .with_p_meas_0(0.001) + .with_p_meas_1(0.001) ) # Builder advantages: diff --git a/docs/user-guide/qasm-simulation.md b/docs/user-guide/qasm-simulation.md index 4b8adaf03..52d72fac3 100644 --- a/docs/user-guide/qasm-simulation.md +++ b/docs/user-guide/qasm-simulation.md @@ -232,10 +232,10 @@ Real quantum computers are noisy. PECOS helps you understand how noise affects y # Custom depolarizing per operation type ( depolarizing_noise() - .with_prep_probability(0.001) # State preparation error - .with_meas_probability(0.002) # Measurement error - .with_p1_probability(0.003) # Single-qubit gate error - .with_p2_probability(0.004) # Two-qubit gate error + .with_p_prep(0.001) # State preparation error + .with_p_meas(0.002) # Measurement error + .with_p1(0.003) # Single-qubit gate error + .with_p2(0.004) # Two-qubit gate error ) # Biased depolarizing (asymmetric error distribution) @@ -256,10 +256,10 @@ Real quantum computers are noisy. PECOS helps you understand how noise affects y // Custom depolarizing per operation type let _custom = DepolarizingNoiseModel::builder() - .with_prep_probability(0.001) // State preparation error - .with_meas_probability(0.002) // Measurement error - .with_p1_probability(0.003) // Single-qubit gate error - .with_p2_probability(0.004); // Two-qubit gate error + .with_p_prep(0.001) // State preparation error + .with_p_meas(0.002) // Measurement error + .with_p1(0.003) // Single-qubit gate error + .with_p2(0.004); // Two-qubit gate error // Biased depolarizing (asymmetric error distribution) let _biased = BiasedDepolarizingNoiseModel::builder() @@ -278,11 +278,11 @@ For research or to match specific hardware characteristics, you can create detai # Direct builder usage noise = ( GeneralNoiseModelBuilder() - .with_prep_probability(0.001) # State prep error - .with_meas_0_probability(0.005) # Measurement error |0> → |1> - .with_meas_1_probability(0.01) # Measurement error |1> → |0> - .with_p1_probability(0.0001) # Single-qubit gate error - .with_p2_probability(0.01) # Two-qubit gate error + .with_p_prep(0.001) # State prep error + .with_p_meas_0(0.005) # Measurement error |0> → |1> + .with_p_meas_1(0.01) # Measurement error |1> → |0> + .with_p1(0.0001) # Single-qubit gate error + .with_p2(0.01) # Two-qubit gate error .with_seed(42) # Deterministic noise ) ``` @@ -293,12 +293,13 @@ For research or to match specific hardware characteristics, you can create detai use pecos::noise::GeneralNoiseModelBuilder; let noise = GeneralNoiseModelBuilder::new() - .with_prep_probability(0.001) // State prep error - .with_meas_0_probability(0.005) // Measurement error |0> → |1> - .with_meas_1_probability(0.01) // Measurement error |1> → |0> - .with_p1_probability(0.0001) // Single-qubit gate error - .with_p2_probability(0.01) // Two-qubit gate error + .with_p_prep(0.001) // State prep error + .with_p_meas_0(0.005) // Measurement error |0> → |1> + .with_p_meas_1(0.01) // Measurement error |1> → |0> + .with_p1(0.0001) // Single-qubit gate error + .with_p2(0.01) // Two-qubit gate error .with_p_idle_linear_rate(0.0001) // Idle noise rate + .with_idle_after_2q(1.0) // Idle duration after two-qubit gates .with_seed(42); // Deterministic noise // Use with sim() @@ -525,11 +526,11 @@ Here's how to simulate a GHZ state with realistic noise: # Create advanced noise model with builder noise = ( GeneralNoiseModelBuilder() - .with_prep_probability(0.001) # 0.1% state prep error - .with_p1_probability(0.0001) # 0.01% single-qubit gate error - .with_p2_probability(0.01) # 1% two-qubit gate error - .with_meas_0_probability(0.02) # 2% false positive rate - .with_meas_1_probability(0.03) # 3% false negative rate + .with_p_prep(0.001) # 0.1% state prep error + .with_p1(0.0001) # 0.01% single-qubit gate error + .with_p2(0.01) # 1% two-qubit gate error + .with_p_meas_0(0.02) # 2% false positive rate + .with_p_meas_1(0.03) # 3% false negative rate .with_seed(12345) # Deterministic noise ) @@ -560,11 +561,11 @@ Here's how to simulate a GHZ state with realistic noise: // Create advanced noise model with builder let noise = GeneralNoiseModelBuilder::new() - .with_prep_probability(0.001) // 0.1% state prep error - .with_p1_probability(0.0001) // 0.01% single-qubit gate error - .with_p2_probability(0.01) // 1% two-qubit gate error - .with_meas_0_probability(0.02) // 2% false positive rate - .with_meas_1_probability(0.03) // 3% false negative rate + .with_p_prep(0.001) // 0.1% state prep error + .with_p1(0.0001) // 0.01% single-qubit gate error + .with_p2(0.01) // 1% two-qubit gate error + .with_p_meas_0(0.02) // 2% false positive rate + .with_p_meas_1(0.03) // 3% false negative rate .with_seed(12345); // Deterministic noise // Run simulation diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 928c46b6a..47527870d 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -212,7 +212,7 @@ noise to. The simulated numbers are therefore the same experiment without the idle contribution, not an independent estimate of the same quantity. The simulator's noise builder spells its setters with explicit suffixes -(`with_p1_probability`), while `NoiseParameters` names each setter after its +(`with_p1`), while `NoiseParameters` names each setter after its field (`with_p1`). The two describe the same rates. @@ -220,13 +220,7 @@ field (`with_p1`). The two describe the same rates. from pecos import general_noise, selene_engine, sim, stabilizer # The same gate noise the DEM was built with, so only the idle treatment differs. -noise = ( - general_noise() - .with_p1_probability(0.002) - .with_p2_probability(0.02) - .with_meas_probability(0.02) - .with_prep_probability(0.02) -) +noise = general_noise().with_p1(0.002).with_p2(0.02).with_p_meas(0.02).with_p_prep(0.02) results = sim(rep_code_memory).classical(selene_engine()).quantum(stabilizer()).qubits(7).noise(noise).seed(42).run(500) diff --git a/examples/Dusting off color code code.ipynb b/examples/Dusting off color code code.ipynb index 6f6916fed..f8af0cb3f 100644 --- a/examples/Dusting off color code code.ipynb +++ b/examples/Dusting off color code code.ipynb @@ -955,10 +955,10 @@ "source": [ "# Create noise model using builder\n", "noise = (DepolarizingNoiseModelBuilder()\n", - " .with_prep_probability(0.003)\n", - " .with_meas_probability(0.003)\n", - " .with_p1_probability(0.003)\n", - " .with_p2_probability(0.003))\n", + " .with_p_prep(0.003)\n", + " .with_p_meas(0.003)\n", + " .with_p1(0.003)\n", + " .with_p2(0.003))\n", "\n", "data = (\n", " qasm_engine()\n", diff --git a/examples/python_examples/noise_builder_example.py b/examples/python_examples/noise_builder_example.py index cb44637e7..ee8279082 100755 --- a/examples/python_examples/noise_builder_example.py +++ b/examples/python_examples/noise_builder_example.py @@ -27,7 +27,7 @@ def simple_noise_example() -> None: """ # Simple uniform noise - noise = GeneralNoiseModelBuilder().with_seed(42).with_p1_probability(0.001).with_p2_probability(0.01) + noise = GeneralNoiseModelBuilder().with_seed(42).with_p1(0.001).with_p2(0.01) results = qasm_engine().program(QasmProgram.from_string(qasm)).to_sim().noise(noise).run(1000) results_dict = results.to_dict() @@ -58,12 +58,12 @@ def hardware_realistic_noise() -> None: GeneralNoiseModelBuilder() .with_seed(42) # Gate errors (two-qubit much worse) - .with_average_p1_probability(0.0001) # 0.01% - .with_average_p2_probability(0.001) # 0.1% + .with_average_p1(0.0001) # 0.01% + .with_average_p2(0.001) # 0.1% # Measurement is often the dominant error - .with_prep_probability(0.001) - .with_meas_0_probability(0.01) # 1% false positive - .with_meas_1_probability(0.005) + .with_p_prep(0.001) + .with_p_meas_0(0.01) # 1% false positive + .with_p_meas_1(0.005) ) # 0.5% false negative results = qasm_engine().program(QasmProgram.from_string(qasm)).to_sim().noise(noise).run(1000) @@ -99,7 +99,7 @@ def biased_noise_example() -> None: noise = ( GeneralNoiseModelBuilder() .with_seed(42) - .with_average_p1_probability(0.01) # Higher error for visibility + .with_average_p1(0.01) # Higher error for visibility .with_p1_pauli_model( { "X": 0.1, # 10% bit flips @@ -136,12 +136,15 @@ def ion_trap_noise() -> None: GeneralNoiseModelBuilder() .with_seed(42) # Excellent single-qubit gates - .with_average_p1_probability(0.00001) # 0.001% error + .with_average_p1(0.00001) # 0.001% error # Two-qubit gates are limiting factor - .with_average_p2_probability(0.003) # 0.3% error + .with_average_p2(0.003) # 0.3% error + # Apply configured idle noise for one time unit after each two-qubit gate + .with_p_idle_linear_rate(0.0001) + .with_idle_after_2q(1.0) # Asymmetric measurement - .with_meas_0_probability(0.001) # Dark state error - .with_meas_1_probability(0.005) + .with_p_meas_0(0.001) # Dark state error + .with_p_meas_1(0.005) ) # Bright state error results = qasm_engine().program(QasmProgram.from_string(qasm)).to_sim().noise(noise).run(1000) @@ -171,8 +174,8 @@ def noiseless_gates_example() -> None: noise = ( GeneralNoiseModelBuilder() .with_seed(42) - .with_p1_probability(0.01) # High error for visibility - .with_p2_probability(0.01) + .with_p1(0.01) # High error for visibility + .with_p2(0.01) .with_noiseless_gate("H") ) # H gates have no error @@ -200,22 +203,17 @@ def scaled_noise_example() -> None: # Base noise model base_noise = ( - GeneralNoiseModelBuilder() - .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002) + GeneralNoiseModelBuilder().with_seed(42).with_p1(0.001).with_p2(0.01).with_p_meas_0(0.002).with_p_meas_1(0.002) ) # Same model scaled up 3x scaled_noise = ( GeneralNoiseModelBuilder() .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002) + .with_p1(0.001) + .with_p2(0.01) + .with_p_meas_0(0.002) + .with_p_meas_1(0.002) .with_scale(3.0) ) # Triple all error rates! @@ -262,9 +260,9 @@ def full_noise_model_example() -> None: # Make Hadamard noiseless .with_noiseless_gate("h") # State preparation - .with_prep_probability(0.001) + .with_p_prep(0.001) # Single-qubit with custom Pauli - .with_average_p1_probability(0.0001) + .with_average_p1(0.0001) .with_p1_pauli_model( { "X": 0.2, @@ -273,10 +271,10 @@ def full_noise_model_example() -> None: }, ) # Two-qubit gates - .with_average_p2_probability(0.001) + .with_average_p2(0.001) # Measurement errors - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.005) + .with_p_meas_0(0.002) + .with_p_meas_1(0.005) ) results = qasm_engine().program(QasmProgram.from_string(qasm)).to_sim().noise(noise).run(1000) diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index 3131f2731..2a0bb5f6f 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -957,10 +957,10 @@ def run_direct_selene_backend(*, simulator: object) -> dict[str, list[list[int]] use_coherent_idle = False noise_model = ( pecos.general_noise() - .with_prep_probability(physical_error_rate * p_prep_scale) - .with_meas_probability(physical_error_rate * p_meas_scale) - .with_p1_probability(physical_error_rate * p1_scale) - .with_p2_probability(physical_error_rate) + .with_p_prep(physical_error_rate * p_prep_scale) + .with_p_meas(physical_error_rate * p_meas_scale) + .with_p1(physical_error_rate * p1_scale) + .with_p2(physical_error_rate) .with_leakage_scale(0.0) .with_p_idle_coherent(use_coherent_idle) .with_seed(seed) diff --git a/exp/pecos-neo/benches/hot_path.rs b/exp/pecos-neo/benches/hot_path.rs index f11053cbd..e44a5f964 100644 --- a/exp/pecos-neo/benches/hot_path.rs +++ b/exp/pecos-neo/benches/hot_path.rs @@ -648,8 +648,8 @@ fn bench_monte_carlo_comparison(c: &mut Criterion) { // Setup: parse QASM and create noise model (not timed) let engine = QASMEngine::from_str(bell_qasm).unwrap(); let noise = GeneralNoiseModel::builder() - .with_average_p1_probability(0.001) - .with_average_p2_probability(0.01) + .with_average_p1(0.001) + .with_average_p2(0.01) .build(); (engine, noise) }, diff --git a/exp/pecos-neo/tests/engine_comparison_test.rs b/exp/pecos-neo/tests/engine_comparison_test.rs index e30f71455..be80e0691 100644 --- a/exp/pecos-neo/tests/engine_comparison_test.rs +++ b/exp/pecos-neo/tests/engine_comparison_test.rs @@ -186,7 +186,7 @@ fn test_monte_carlo_with_depolarizing_noise() { // Build equivalent noise models // pecos-engines uses scaled probabilities let engines_noise = GeneralNoiseModel::builder() - .with_average_p1_probability(p1 / 1.5) // Scale down for engines + .with_average_p1(p1 / 1.5) // Scale down for engines .build(); // Simple circuit: prep, apply X (identity on |0>), measure @@ -266,8 +266,8 @@ fn test_monte_carlo_measurement_errors() { // pecos-engines noise model let engines_noise = GeneralNoiseModel::builder() - .with_meas_0_probability(p_meas) - .with_meas_1_probability(p_meas) + .with_p_meas_0(p_meas) + .with_p_meas_1(p_meas) .build(); // Circuit: prep |0>, measure (should be 0, but measurement errors flip some) @@ -401,7 +401,7 @@ fn test_monte_carlo_two_qubit_noise() { // pecos-engines noise model (scaled) let engines_noise = GeneralNoiseModel::builder() - .with_average_p2_probability(p2 / 1.25) // Scale down for engines + .with_average_p2(p2 / 1.25) // Scale down for engines .build(); // Circuit: Bell state creation, errors will decorrelate outcomes diff --git a/exp/pecos-neo/tests/noise_comparison_test.rs b/exp/pecos-neo/tests/noise_comparison_test.rs index 8e4d3c7d7..70fe42818 100644 --- a/exp/pecos-neo/tests/noise_comparison_test.rs +++ b/exp/pecos-neo/tests/noise_comparison_test.rs @@ -163,11 +163,11 @@ fn test_single_qubit_depolarizing_comparison() { // GeneralNoiseModel setup let general_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_average_p1_probability(average_p1) - .with_average_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_average_p1(average_p1) + .with_average_p2(0.0) .with_p1_emission_ratio(0.0) // No leakage .with_seed(42) .build(); @@ -227,11 +227,11 @@ fn test_two_qubit_depolarizing_comparison() { // GeneralNoiseModel setup let general_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_average_p1_probability(0.0) - .with_average_p2_probability(average_p2) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_average_p1(0.0) + .with_average_p2(average_p2) .with_p2_emission_ratio(0.0) // No leakage .with_seed(42) .build(); @@ -296,11 +296,11 @@ fn test_measurement_error_comparison() { // GeneralNoiseModel setup let general_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_meas_0) - .with_meas_1_probability(0.0) - .with_average_p1_probability(0.0) - .with_average_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(p_meas_0) + .with_p_meas_1(0.0) + .with_average_p1(0.0) + .with_average_p2(0.0) .with_seed(42) .build(); @@ -361,12 +361,12 @@ fn test_preparation_error_comparison() { // GeneralNoiseModel setup let general_model = GeneralNoiseModel::builder() - .with_prep_probability(p_prep) + .with_p_prep(p_prep) .with_prep_leak_ratio(0.0) // No leakage - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_average_p1_probability(0.0) - .with_average_p2_probability(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_average_p1(0.0) + .with_average_p2(0.0) .with_seed(42) .build(); @@ -418,12 +418,12 @@ fn test_combined_noise_comparison() { // GeneralNoiseModel setup let general_model = GeneralNoiseModel::builder() - .with_prep_probability(p_prep) + .with_p_prep(p_prep) .with_prep_leak_ratio(0.0) - .with_meas_0_probability(p_meas) - .with_meas_1_probability(p_meas) - .with_average_p1_probability(p1 / 1.5) - .with_average_p2_probability(p2 / 1.25) + .with_p_meas_0(p_meas) + .with_p_meas_1(p_meas) + .with_average_p1(p1 / 1.5) + .with_average_p2(p2 / 1.25) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_seed(42) @@ -503,12 +503,12 @@ fn test_general_noise_model_builder_comparison() { // Original GeneralNoiseModel from pecos-engines let general_model = GeneralNoiseModel::builder() - .with_prep_probability(p_prep) + .with_p_prep(p_prep) .with_prep_leak_ratio(0.0) - .with_meas_0_probability(p_meas_0) - .with_meas_1_probability(p_meas_1) - .with_average_p1_probability(p1 / 1.5) - .with_average_p2_probability(p2 / 1.25) + .with_p_meas_0(p_meas_0) + .with_p_meas_1(p_meas_1) + .with_average_p1(p1 / 1.5) + .with_average_p2(p2 / 1.25) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_seed(42) diff --git a/exp/pecos-neo/tests/sim_neo_comparison_test.rs b/exp/pecos-neo/tests/sim_neo_comparison_test.rs index 08d17996d..f099a0edd 100644 --- a/exp/pecos-neo/tests/sim_neo_comparison_test.rs +++ b/exp/pecos-neo/tests/sim_neo_comparison_test.rs @@ -283,7 +283,7 @@ fn test_sim_neo_vs_sim_depolarizing_noise() { let p1 = 0.05; // pecos-engines noise model builder - let engines_noise = EnginesNoiseBuilder::new().with_average_p1_probability(p1 / 1.5); // Scale factor for engines + let engines_noise = EnginesNoiseBuilder::new().with_average_p1(p1 / 1.5); // Scale factor for engines let qasm = r#" OPENQASM 2.0; @@ -348,8 +348,8 @@ fn test_sim_neo_vs_sim_measurement_noise() { // pecos-engines noise model let engines_noise = EnginesNoiseBuilder::new() - .with_meas_0_probability(p_meas) - .with_meas_1_probability(p_meas); + .with_p_meas_0(p_meas) + .with_p_meas_1(p_meas); let qasm = r#" OPENQASM 2.0; @@ -514,7 +514,7 @@ fn test_sim_neo_vs_sim_conditional_with_noise() { measure q[0] -> c[0]; "#; - let engines_noise = EnginesNoiseBuilder::new().with_average_p1_probability(p1 / 1.5); + let engines_noise = EnginesNoiseBuilder::new().with_average_p1(p1 / 1.5); // Run with sim() let engines_results = sim(qasm_engine().qasm(qasm)) @@ -660,7 +660,7 @@ fn test_sim_neo_ergonomic_builder_direct() { let p1 = 0.10; // pecos-engines - let engines_noise = EnginesNoiseBuilder::new().with_average_p1_probability(p1 / 1.5); + let engines_noise = EnginesNoiseBuilder::new().with_average_p1(p1 / 1.5); let qasm = r#" OPENQASM 2.0; @@ -890,7 +890,7 @@ fn test_sim_neo_noise_level_scaling() { for &p1 in &noise_levels { // pecos-engines - let engines_noise = EnginesNoiseBuilder::new().with_average_p1_probability(p1 / 1.5); + let engines_noise = EnginesNoiseBuilder::new().with_average_p1(p1 / 1.5); let qasm = r#" OPENQASM 2.0; @@ -958,11 +958,11 @@ fn test_sim_neo_noise_level_scaling() { fn test_sim_neo_vs_sim_zero_noise() { // Explicitly test with noise model but zero error rates let engines_noise = EnginesNoiseBuilder::new() - .with_prep_probability(0.0) - .with_average_p1_probability(0.0) - .with_average_p2_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0); + .with_p_prep(0.0) + .with_average_p1(0.0) + .with_average_p2(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0); let qasm = r#" OPENQASM 2.0; @@ -1029,7 +1029,7 @@ fn test_sim_neo_high_noise_chaos() { // Test behavior at high noise levels (near 50% depolarizing) let p1 = 0.40; // 40% depolarizing - very noisy - let engines_noise = EnginesNoiseBuilder::new().with_average_p1_probability(p1 / 1.5); + let engines_noise = EnginesNoiseBuilder::new().with_average_p1(p1 / 1.5); let qasm = r#" OPENQASM 2.0; @@ -1092,7 +1092,7 @@ fn test_sim_neo_vs_sim_two_qubit_noise() { let p2 = 0.10; // pecos-engines noise model with scaling factor - let engines_noise = EnginesNoiseBuilder::new().with_average_p2_probability(p2 / 1.25); // Scale factor for engines + let engines_noise = EnginesNoiseBuilder::new().with_average_p2(p2 / 1.25); // Scale factor for engines let qasm = r#" OPENQASM 2.0; @@ -1165,7 +1165,7 @@ fn test_sim_neo_vs_sim_preparation_noise() { let p_prep = 0.15; // pecos-engines noise model - let engines_noise = EnginesNoiseBuilder::new().with_prep_probability(p_prep); + let engines_noise = EnginesNoiseBuilder::new().with_p_prep(p_prep); let qasm = r#" OPENQASM 2.0; @@ -1227,11 +1227,11 @@ fn test_sim_neo_vs_sim_combined_noise() { // pecos-engines noise model (with scaling factors) let engines_noise = EnginesNoiseBuilder::new() - .with_prep_probability(p_prep) - .with_average_p1_probability(p1 / 1.5) - .with_average_p2_probability(p2 / 1.25) - .with_meas_0_probability(p_meas) - .with_meas_1_probability(p_meas); + .with_p_prep(p_prep) + .with_average_p1(p1 / 1.5) + .with_average_p2(p2 / 1.25) + .with_p_meas_0(p_meas) + .with_p_meas_1(p_meas); // Bell state circuit with noise let qasm = r#" diff --git a/exp/pecos-neo/tests/statistical_validation_test.rs b/exp/pecos-neo/tests/statistical_validation_test.rs index f4f73da04..87ef92484 100644 --- a/exp/pecos-neo/tests/statistical_validation_test.rs +++ b/exp/pecos-neo/tests/statistical_validation_test.rs @@ -476,8 +476,8 @@ fn test_neo_vs_engines_noisy_comparison() { // pecos-engines noise model let engines_noise = GeneralNoiseModel::builder() - .with_average_p1_probability(p1 / 1.5) - .with_average_p2_probability(p2 / 1.25) + .with_average_p1(p1 / 1.5) + .with_average_p2(p2 / 1.25) .build(); let engine = QASMEngine::from_str(qasm).unwrap(); diff --git a/exp/pecos-neo/tests/surface_code_comparison_test.rs b/exp/pecos-neo/tests/surface_code_comparison_test.rs index b3884d998..21506b6d0 100644 --- a/exp/pecos-neo/tests/surface_code_comparison_test.rs +++ b/exp/pecos-neo/tests/surface_code_comparison_test.rs @@ -413,11 +413,11 @@ fn test_repetition_code_logical_error_vs_rounds() { // GeneralNoiseModel let general_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_meas) - .with_meas_1_probability(p_meas) - .with_average_p1_probability(p1 / 1.5) // Scale for average probability - .with_average_p2_probability(p2 / 1.25) + .with_p_prep(0.0) + .with_p_meas_0(p_meas) + .with_p_meas_1(p_meas) + .with_average_p1(p1 / 1.5) // Scale for average probability + .with_average_p2(p2 / 1.25) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_seed(42) @@ -477,11 +477,11 @@ fn test_repetition_code_syndrome_rates() { let p_meas = 0.02; let general_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_meas) - .with_meas_1_probability(p_meas) - .with_average_p1_probability(p1 / 1.5) - .with_average_p2_probability(p2 / 1.25) + .with_p_prep(0.0) + .with_p_meas_0(p_meas) + .with_p_meas_1(p_meas) + .with_average_p1(p1 / 1.5) + .with_average_p2(p2 / 1.25) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_seed(42) @@ -542,11 +542,11 @@ fn test_repetition_code_syndrome_correlations() { let p_meas = 0.02; let general_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_meas) - .with_meas_1_probability(p_meas) - .with_average_p1_probability(p1 / 1.5) - .with_average_p2_probability(p2 / 1.25) + .with_p_prep(0.0) + .with_p_meas_0(p_meas) + .with_p_meas_1(p_meas) + .with_average_p1(p1 / 1.5) + .with_average_p2(p2 / 1.25) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_seed(42) @@ -620,11 +620,11 @@ fn test_repetition_code_error_scaling() { for &p in &error_rates { let general_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p) - .with_meas_1_probability(p) - .with_average_p1_probability(p / 1.5) - .with_average_p2_probability(p / 1.25) + .with_p_prep(0.0) + .with_p_meas_0(p) + .with_p_meas_1(p) + .with_average_p1(p / 1.5) + .with_average_p2(p / 1.25) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_seed(42) diff --git a/python/pecos-rslib/examples/namespace_demo.py b/python/pecos-rslib/examples/namespace_demo.py index 415878cde..18eae4131 100755 --- a/python/pecos-rslib/examples/namespace_demo.py +++ b/python/pecos-rslib/examples/namespace_demo.py @@ -85,8 +85,8 @@ def namespace_usage_examples() -> None: .seed(42)\\ .quantum_engine(quantum.sparse_stab())\\ .noise(noise.depolarizing() - .with_prep_probability(0.001) - .with_p1_probability(0.01))\\ + .with_p_prep(0.001) + .with_p1(0.01))\\ .run(1000) """, ) @@ -130,11 +130,7 @@ def run_example_simulations() -> None: .to_sim() .quantum_engine(pecos_rslib.quantum.sparse_stab()) .noise( - pecos_rslib.noise.depolarizing() - .with_prep_probability(0.001) - .with_meas_probability(0.001) - .with_p1_probability(0.002) - .with_p2_probability(0.01), + pecos_rslib.noise.depolarizing().with_p_prep(0.001).with_p_meas(0.001).with_p1(0.002).with_p2(0.01), ) .run(1000) ) @@ -148,7 +144,7 @@ def run_example_simulations() -> None: sim = engines.qasm().program(bell_state).to_sim() sim.seed(12345) sim.quantum_engine(quantum.sparse_stab()) # Using the alias - sim.noise(noise.general().with_p1_probability(0.001)) + sim.noise(noise.general().with_p1(0.001)) results = sim.run(500) print(" Ran 500 shots with imported namespaces") diff --git a/python/pecos-rslib/examples/namespace_example.py b/python/pecos-rslib/examples/namespace_example.py index 37b5c228d..3be05ebcc 100644 --- a/python/pecos-rslib/examples/namespace_example.py +++ b/python/pecos-rslib/examples/namespace_example.py @@ -51,10 +51,10 @@ def main() -> None: # Configure depolarizing noise noise_model = ( pecos_rslib.noise.depolarizing() - .with_prep_probability(0.001) # State preparation errors - .with_meas_probability(0.005) # Measurement errors - .with_p1_probability(0.002) # Single-qubit gate errors - .with_p2_probability(0.01) # Two-qubit gate errors + .with_p_prep(0.001) # State preparation errors + .with_p_meas(0.005) # Measurement errors + .with_p1(0.002) # Single-qubit gate errors + .with_p2(0.01) # Two-qubit gate errors ) # Run simulation using namespace API diff --git a/python/pecos-rslib/examples/qasm_simulation_examples.py b/python/pecos-rslib/examples/qasm_simulation_examples.py index c7185275b..3daabee28 100755 --- a/python/pecos-rslib/examples/qasm_simulation_examples.py +++ b/python/pecos-rslib/examples/qasm_simulation_examples.py @@ -42,13 +42,7 @@ def example_bell_state() -> None: print(f" |{outcome:02b}⟩: {count} times") # Run with depolarizing noise - noise = ( - depolarizing_noise() - .with_prep_probability(0.001) - .with_meas_probability(0.002) - .with_p1_probability(0.02) - .with_p2_probability(0.02) - ) + noise = depolarizing_noise().with_p_prep(0.001).with_p_meas(0.002).with_p1(0.02).with_p2(0.02) results_noisy = qasm_engine().program(Qasm.from_string(qasm)).to_sim().seed(42).noise(noise).run(1000) results_noisy_dict = results_noisy.to_dict() counts_noisy = Counter(results_noisy_dict["c"]) @@ -76,10 +70,10 @@ def example_ghz_state() -> None: # Run with custom depolarizing noise noise = ( depolarizing_noise() - .with_prep_probability(0.001) # Low preparation error - .with_meas_probability(0.005) # Moderate measurement error - .with_p1_probability(0.001) # Low single-qubit gate error - .with_p2_probability(0.01) + .with_p_prep(0.001) # Low preparation error + .with_p_meas(0.005) # Moderate measurement error + .with_p1(0.001) # Low single-qubit gate error + .with_p2(0.01) ) # Higher two-qubit gate error # Different ways to specify quantum engine: @@ -127,14 +121,7 @@ def example_biased_depolarizing() -> None: ideal_counts = Counter(results_ideal_dict["c"]) # Biased depolarizing noise - noise = ( - biased_depolarizing_noise() - .with_prep_probability(0.1) - .with_meas_0_probability(0.1) - .with_meas_1_probability(0.1) - .with_p1_probability(0.1) - .with_p2_probability(0.1) - ) + noise = biased_depolarizing_noise().with_p_prep(0.1).with_p_meas_0(0.1).with_p_meas_1(0.1).with_p1(0.1).with_p2(0.1) results_biased = qasm_engine().program(Qasm.from_string(qasm)).to_sim().seed(42).noise(noise).run(1000) results_biased_dict = results_biased.to_dict() @@ -203,13 +190,7 @@ def example_builder_pattern() -> None: """ # Build once, run multiple times with different shot counts - noise = ( - depolarizing_noise() - .with_prep_probability(0.01) - .with_meas_probability(0.01) - .with_p1_probability(0.01) - .with_p2_probability(0.01) - ) + noise = depolarizing_noise().with_p_prep(0.01).with_p_meas(0.01).with_p1(0.01).with_p2(0.01) sim = ( qasm_engine() @@ -232,11 +213,11 @@ def example_builder_pattern() -> None: # Or run directly without building noise_biased = ( biased_depolarizing_noise() - .with_prep_probability(0.005) - .with_meas_0_probability(0.005) - .with_meas_1_probability(0.005) - .with_p1_probability(0.005) - .with_p2_probability(0.005) + .with_p_prep(0.005) + .with_p_meas_0(0.005) + .with_p_meas_1(0.005) + .with_p1(0.005) + .with_p2(0.005) ) results = qasm_engine().program(Qasm.from_string(qasm)).to_sim().noise(noise_biased).run(500) @@ -305,13 +286,7 @@ def example_parallel_execution() -> None: measure q -> c; """ - noise = ( - depolarizing_noise() - .with_prep_probability(0.001) - .with_meas_probability(0.001) - .with_p1_probability(0.001) - .with_p2_probability(0.001) - ) + noise = depolarizing_noise().with_p_prep(0.001).with_p_meas(0.001).with_p1(0.001).with_p2(0.001) # Single worker start = time.time() diff --git a/python/pecos-rslib/examples/structured_config_examples.py b/python/pecos-rslib/examples/structured_config_examples.py index 972ea52ac..f7b4bb69f 100644 --- a/python/pecos-rslib/examples/structured_config_examples.py +++ b/python/pecos-rslib/examples/structured_config_examples.py @@ -35,10 +35,10 @@ def example_basic_noise_builder() -> None: noise = ( general_noise() .with_seed(42) - .with_p1_probability(0.001) # Single-qubit gate error - .with_p2_probability(0.01) # Two-qubit gate error - .with_meas_0_probability(0.002) # 0->1 measurement flip - .with_meas_1_probability(0.002) # 1->0 measurement flip + .with_p1(0.001) # Single-qubit gate error + .with_p2(0.01) # Two-qubit gate error + .with_p_meas_0(0.002) # 0->1 measurement flip + .with_p_meas_1(0.002) # 1->0 measurement flip ) # Use noise directly with .noise() @@ -74,7 +74,7 @@ def example_advanced_noise_builder() -> None: .with_scale(1.2) # Scale all error rates by 1.2 .with_noiseless_gate("H") # H gates have no noise # Single-qubit gate noise with Pauli distribution - .with_average_p1_probability(0.001) # Average error (converted to total) + .with_average_p1(0.001) # Average error (converted to total) .with_p1_pauli_model( { "X": 0.5, # 50% X errors @@ -83,11 +83,11 @@ def example_advanced_noise_builder() -> None: }, ) # Two-qubit gate noise - .with_average_p2_probability(0.008) # Average error (converted to total) + .with_average_p2(0.008) # Average error (converted to total) # Preparation and measurement noise - .with_prep_probability(0.001) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.003) # Asymmetric measurement error + .with_p_prep(0.001) + .with_p_meas_0(0.002) + .with_p_meas_1(0.003) # Asymmetric measurement error ) results = sim(qasm).noise(noise).run(1000) @@ -113,7 +113,7 @@ def example_direct_configuration() -> None: """ # Create noise using functional API - noise = general_noise().with_p1_probability(0.001).with_p2_probability(0.01) + noise = general_noise().with_p1(0.001).with_p2(0.01) # Configure entire simulation with method chaining simulation = ( @@ -157,10 +157,10 @@ def example_builder_vs_direct() -> None: print("Using general_noise() with method chaining:") noise_via_builder = ( general_noise() - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002) + .with_p1(0.001) + .with_p2(0.01) + .with_p_meas_0(0.002) + .with_p_meas_1(0.002) .with_noiseless_gate("H") .with_p1_pauli_model({"X": 0.5, "Y": 0.3, "Z": 0.2}) ) @@ -173,10 +173,10 @@ def example_builder_vs_direct() -> None: noise_equivalent = ( general_noise() .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002) + .with_p1(0.001) + .with_p2(0.01) + .with_p_meas_0(0.002) + .with_p_meas_1(0.002) .set_noiseless_gates(["H"]) .with_p1_pauli_model({"X": 0.5, "Y": 0.3, "Z": 0.2}) ) @@ -205,16 +205,12 @@ def example_different_noise_models() -> None: ("Depolarizing", depolarizing_noise().with_probability(0.1)), ( "Custom depolarizing", - depolarizing_noise() - .with_prep_probability(0.01) - .with_meas_probability(0.05) - .with_p1_probability(0.02) - .with_p2_probability(0.03), + depolarizing_noise().with_p_prep(0.01).with_p_meas(0.05).with_p1(0.02).with_p2(0.03), ), ("Biased depolarizing", biased_depolarizing_noise().with_probability(0.1)), ( "General", - general_noise().with_meas_1_probability(0.1), # 10% chance to flip 1->0 + general_noise().with_p_meas_1(0.1), # 10% chance to flip 1->0 ), ] @@ -251,14 +247,14 @@ def example_ion_trap_noise() -> None: general_noise() .with_seed(42) # Ion trap typical parameters - .with_prep_probability(0.001) # State prep error + .with_p_prep(0.001) # State prep error # Single-qubit gates (typically very good) - .with_p1_probability(0.0001) + .with_p1(0.0001) # Two-qubit gates (main error source) - .with_p2_probability(0.003) + .with_p2(0.003) # Measurement (asymmetric for ions) - .with_meas_0_probability(0.001) # Dark state error - .with_meas_1_probability(0.005) # Bright state error + .with_p_meas_0(0.001) # Dark state error + .with_p_meas_1(0.005) # Bright state error ) results = sim(qasm).noise(noise).run(1000) diff --git a/python/pecos-rslib/src/engine_builders.rs b/python/pecos-rslib/src/engine_builders.rs index 2f6210c35..f3298144c 100644 --- a/python/pecos-rslib/src/engine_builders.rs +++ b/python/pecos-rslib/src/engine_builders.rs @@ -887,37 +887,37 @@ impl PyGeneralNoiseModelBuilder { } /// Set single-qubit gate error probability - fn with_p1_probability(&self, p: f64) -> PyResult { + fn with_p1(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_p1_probability(p), + inner: self.inner.clone().with_p1(p), }) } /// Set two-qubit gate error probability - fn with_p2_probability(&self, p: f64) -> PyResult { + fn with_p2(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_p2_probability(p), + inner: self.inner.clone().with_p2(p), }) } /// Set preparation error probability - fn with_prep_probability(&self, p: f64) -> PyResult { + fn with_p_prep(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_prep_probability(p), + inner: self.inner.clone().with_p_prep(p), }) } /// Set measurement error probability for |0⟩ state - fn with_meas_0_probability(&self, p: f64) -> PyResult { + fn with_p_meas_0(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_meas_0_probability(p), + inner: self.inner.clone().with_p_meas_0(p), }) } /// Set measurement error probability for |1⟩ state - fn with_meas_1_probability(&self, p: f64) -> PyResult { + fn with_p_meas_1(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_meas_1_probability(p), + inner: self.inner.clone().with_p_meas_1(p), }) } @@ -974,41 +974,37 @@ impl PyGeneralNoiseModelBuilder { } /// Set average single-qubit gate error probability - fn with_average_p1_probability(&self, p: f64) -> PyResult { + fn with_average_p1(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_average_p1_probability(p), + inner: self.inner.clone().with_average_p1(p), }) } /// Set average two-qubit gate error probability - fn with_average_p2_probability(&self, p: f64) -> PyResult { + fn with_average_p2(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_average_p2_probability(p), + inner: self.inner.clone().with_average_p2(p), }) } /// Set measurement error probability (symmetric) - fn with_meas_probability(&self, p: f64) -> PyResult { + fn with_p_meas(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_meas_probability(p), + inner: self.inner.clone().with_p_meas(p), }) } /// Set preparation error probability fn with_preparation_probability(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_prep_probability(p), + inner: self.inner.clone().with_p_prep(p), }) } /// Set measurement error probability (asymmetric) fn with_measurement_probability(&self, p0: f64, p1: f64) -> PyResult { Ok(Self { - inner: self - .inner - .clone() - .with_meas_0_probability(p0) - .with_meas_1_probability(p1), + inner: self.inner.clone().with_p_meas_0(p0).with_p_meas_1(p1), }) } @@ -1220,10 +1216,19 @@ impl PyGeneralNoiseModelBuilder { }) } - /// Set idle probability for two-qubit gates - fn with_p2_idle(&self, probability: f64) -> PyResult { + /// Set the duration of the idle-noise site applied to each qubit after a two-qubit gate. + /// + /// A duration of `0.0` disables these sites. Nonzero sites receive all configured idle + /// mechanisms over the given duration: linear stochastic noise from `p_idle_linear_rate` and + /// `p_idle_linear_model`, and quadratic dephasing from `p_idle_quadratic_rate`, honoring + /// `p_idle_coherent`. + /// + /// Anyone who previously wrote `with_p2_idle(0.01)` and no linear rate now gets no after-2q + /// idle noise; the equivalent is + /// `with_p_idle_linear_rate(0.01).with_idle_after_2q(1.0)`. + fn with_idle_after_2q(&self, duration: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_p2_idle(probability), + inner: self.inner.clone().with_idle_after_2q(duration), }) } @@ -1299,30 +1304,30 @@ impl PyDepolarizingNoiseModelBuilder { } /// Set preparation error probability - fn with_prep_probability(&self, p: f64) -> PyResult { + fn with_p_prep(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_prep_probability(p), + inner: self.inner.clone().with_p_prep(p), }) } /// Set measurement error probability - fn with_meas_probability(&self, p: f64) -> PyResult { + fn with_p_meas(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_meas_probability(p), + inner: self.inner.clone().with_p_meas(p), }) } /// Set single-qubit gate error probability - fn with_p1_probability(&self, p: f64) -> PyResult { + fn with_p1(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_p1_probability(p), + inner: self.inner.clone().with_p1(p), }) } /// Set two-qubit gate error probability - fn with_p2_probability(&self, p: f64) -> PyResult { + fn with_p2(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_p2_probability(p), + inner: self.inner.clone().with_p2(p), }) } @@ -1340,9 +1345,9 @@ impl PyDepolarizingNoiseModelBuilder { }) } - /// Set preparation error probability (alias for `with_prep_probability`) + /// Set preparation error probability (alias for `with_p_prep`) fn with_preparation_probability(&self, p: f64) -> PyResult { - self.with_prep_probability(p) + self.with_p_prep(p) } } @@ -1363,37 +1368,37 @@ impl PyBiasedDepolarizingNoiseModelBuilder { } /// Set preparation error probability - fn with_prep_probability(&self, p: f64) -> PyResult { + fn with_p_prep(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_prep_probability(p), + inner: self.inner.clone().with_p_prep(p), }) } /// Set measurement 0->1 flip probability - fn with_meas_0_probability(&self, p: f64) -> PyResult { + fn with_p_meas_0(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_meas_0_probability(p), + inner: self.inner.clone().with_p_meas_0(p), }) } /// Set measurement 1->0 flip probability - fn with_meas_1_probability(&self, p: f64) -> PyResult { + fn with_p_meas_1(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_meas_1_probability(p), + inner: self.inner.clone().with_p_meas_1(p), }) } /// Set single-qubit gate error probability - fn with_p1_probability(&self, p: f64) -> PyResult { + fn with_p1(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_p1_probability(p), + inner: self.inner.clone().with_p1(p), }) } /// Set two-qubit gate error probability - fn with_p2_probability(&self, p: f64) -> PyResult { + fn with_p2(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_p2_probability(p), + inner: self.inner.clone().with_p2(p), }) } diff --git a/python/pecos-rslib/tests/test_direct_builder.py b/python/pecos-rslib/tests/test_direct_builder.py index 4f81dfd0b..77ab9387d 100644 --- a/python/pecos-rslib/tests/test_direct_builder.py +++ b/python/pecos-rslib/tests/test_direct_builder.py @@ -29,10 +29,10 @@ def test_direct_builder_noise(self) -> None: builder = ( GeneralNoiseModelBuilder() .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002) + .with_p1(0.001) + .with_p2(0.01) + .with_p_meas_0(0.002) + .with_p_meas_1(0.002) ) # Use sim() with noise builder @@ -59,7 +59,7 @@ def test_builder_with_pauli_model(self) -> None: builder = ( GeneralNoiseModelBuilder() .with_seed(42) - .with_p1_probability(0.1) # High error rate for testing + .with_p1(0.1) # High error rate for testing .with_p1_pauli_model({"X": 0.5, "Y": 0.3, "Z": 0.2}) ) @@ -90,7 +90,7 @@ def test_builder_with_method_chaining(self) -> None: prog = Qasm.from_string(qasm) # Create builder with fluent API - builder = GeneralNoiseModelBuilder().with_seed(42).with_p2_probability(0.01) + builder = GeneralNoiseModelBuilder().with_seed(42).with_p2(0.01) # Use sim() with direct method chaining results = sim(prog).seed(42).noise(builder).run(100).to_dict() @@ -103,7 +103,7 @@ def test_builder_chaining_validation(self) -> None: """Test that builder methods validate parameters.""" # Test validation - Rust panics raise BaseException with "PanicException" in the name with pytest.raises(BaseException, match="Probability must be between 0 and 1"): - GeneralNoiseModelBuilder().with_p1_probability(1.5) + GeneralNoiseModelBuilder().with_p1(1.5) # Scale validation happens at build time, not when setting the value # So we need to build and use the noise model to trigger validation @@ -133,8 +133,8 @@ def test_rust_vs_native_noise_models(self) -> None: # Create builder builder = GeneralNoiseModelBuilder() builder.with_seed(42) - builder.with_p1_probability(0.001) - builder.with_p2_probability(0.01) + builder.with_p1(0.001) + builder.with_p2(0.01) # Test that builder can be used directly in .noise() method results = sim(prog).noise(builder).seed(42).run(100).to_dict() diff --git a/python/pecos-rslib/tests/test_qasm_pythonic.py b/python/pecos-rslib/tests/test_qasm_pythonic.py index cfaeefa0c..21329a01c 100644 --- a/python/pecos-rslib/tests/test_qasm_pythonic.py +++ b/python/pecos-rslib/tests/test_qasm_pythonic.py @@ -109,10 +109,10 @@ def test_sim_qasm_with_custom_noise_builder(self) -> None: noise_builder = ( general_noise() .with_seed(42) - .with_p1_probability(0.001) # Low single-qubit error - .with_p2_probability(0.1) # High two-qubit error - .with_meas_0_probability(0.02) - .with_meas_1_probability(0.02) + .with_p1(0.001) # Low single-qubit error + .with_p2(0.1) # High two-qubit error + .with_p_meas_0(0.02) + .with_p_meas_1(0.02) ) results = sim(prog).noise(noise_builder).run(1000).to_dict() diff --git a/python/pecos-rslib/tests/test_sim_qasm.py b/python/pecos-rslib/tests/test_sim_qasm.py index 9ee6fe934..ebe5dfba4 100644 --- a/python/pecos-rslib/tests/test_sim_qasm.py +++ b/python/pecos-rslib/tests/test_sim_qasm.py @@ -156,11 +156,7 @@ def test_noise_models(self) -> None: sim(Qasm.from_string(qasm_bell)) .seed(42) .noise( - depolarizing_noise() - .with_prep_probability(0.01) - .with_meas_probability(0.01) - .with_p1_probability(0.001) - .with_p2_probability(0.1), + depolarizing_noise().with_p_prep(0.01).with_p_meas(0.01).with_p1(0.001).with_p2(0.1), ) .run(1000) ) diff --git a/python/pecos-rslib/tests/test_structured_config.py b/python/pecos-rslib/tests/test_structured_config.py index f06932b89..82cf66db5 100644 --- a/python/pecos-rslib/tests/test_structured_config.py +++ b/python/pecos-rslib/tests/test_structured_config.py @@ -17,19 +17,12 @@ class TestDirectMethodChaining: def test_general_noise_model_builder_basic(self) -> None: """Test basic general_noise() usage.""" - noise = ( - general_noise() - .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002) - ) + noise = general_noise().with_seed(42).with_p1(0.001).with_p2(0.01).with_p_meas_0(0.002).with_p_meas_1(0.002) # The noise object is already a builder, can be used directly # Test that it's a valid builder by checking it has builder methods assert hasattr(noise, "with_seed") - assert hasattr(noise, "with_p1_probability") + assert hasattr(noise, "with_p1") def test_general_noise_model_builder_validation(self) -> None: """Test general_noise() parameter validation.""" @@ -38,11 +31,11 @@ def test_general_noise_model_builder_validation(self) -> None: # Test invalid probability values # Rust panics raise BaseException with pytest.raises(BaseException, match=r".*"): # Rust panic - any error message - builder.with_p1_probability(-0.1) # Negative probability + builder.with_p1(-0.1) # Negative probability builder = general_noise() with pytest.raises(BaseException, match=r".*"): # Rust panic - any error message - builder.with_p2_probability(1.5) # > 1 probability + builder.with_p2(1.5) # > 1 probability def test_direct_noise_builder_with_sim(self) -> None: """Test using builders directly with sim().""" @@ -59,7 +52,7 @@ def test_direct_noise_builder_with_sim(self) -> None: prog = Qasm.from_string(qasm) # Create a configured noise builder - noise = general_noise().with_seed(42).with_p1_probability(0.001).with_p2_probability(0.01) + noise = general_noise().with_seed(42).with_p1(0.001).with_p2(0.01) # Use the builder directly with sim() results = sim(prog).noise(noise).run(1000).to_dict() @@ -133,14 +126,7 @@ def test_complex_circuit_with_noise(self) -> None: prog = Qasm.from_string(qasm) # Configure general noise with specific parameters - noise = ( - general_noise() - .with_seed(123) - .with_p1_probability(0.005) - .with_p2_probability(0.02) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - ) + noise = general_noise().with_seed(123).with_p1(0.005).with_p2(0.02).with_p_meas_0(0.01).with_p_meas_1(0.01) results = sim(prog).noise(noise).run(1000).to_dict() diff --git a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs index b143bfa86..2be33dbd4 100644 --- a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs +++ b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs @@ -152,10 +152,10 @@ let _depol = DepolarizingNoiseModel::builder() // Custom depolarizing per operation type let _custom = DepolarizingNoiseModel::builder() - .with_prep_probability(0.001) // State preparation error - .with_meas_probability(0.002) // Measurement error - .with_p1_probability(0.003) // Single-qubit gate error - .with_p2_probability(0.004); // Two-qubit gate error + .with_p_prep(0.001) // State preparation error + .with_p_meas(0.002) // Measurement error + .with_p1(0.003) // Single-qubit gate error + .with_p2(0.004); // Two-qubit gate error // Biased depolarizing (asymmetric error distribution) let _biased = BiasedDepolarizingNoiseModel::builder() @@ -182,12 +182,13 @@ fn test_user_guide_qasm_simulation_rust_5() -> Result<(), Box → |1> - .with_meas_1_probability(0.01) // Measurement error |1> → |0> - .with_p1_probability(0.0001) // Single-qubit gate error - .with_p2_probability(0.01) // Two-qubit gate error + .with_p_prep(0.001) // State prep error + .with_p_meas_0(0.005) // Measurement error |0> → |1> + .with_p_meas_1(0.01) // Measurement error |1> → |0> + .with_p1(0.0001) // Single-qubit gate error + .with_p2(0.01) // Two-qubit gate error .with_p_idle_linear_rate(0.0001) // Idle noise rate + .with_idle_after_2q(1.0) // Idle duration after two-qubit gates .with_seed(42); // Deterministic noise // Use with sim() @@ -363,11 +364,11 @@ fn ghz_noise_example() -> Result<(), PecosError> { // Create advanced noise model with builder let noise = GeneralNoiseModelBuilder::new() - .with_prep_probability(0.001) // 0.1% state prep error - .with_p1_probability(0.0001) // 0.01% single-qubit gate error - .with_p2_probability(0.01) // 1% two-qubit gate error - .with_meas_0_probability(0.02) // 2% false positive rate - .with_meas_1_probability(0.03) // 3% false negative rate + .with_p_prep(0.001) // 0.1% state prep error + .with_p1(0.0001) // 0.01% single-qubit gate error + .with_p2(0.01) // 1% two-qubit gate error + .with_p_meas_0(0.02) // 2% false positive rate + .with_p_meas_1(0.03) // 3% false negative rate .with_seed(12345); // Deterministic noise // Run simulation diff --git a/python/quantum-pecos/tests/guppy/test_missing_coverage.py b/python/quantum-pecos/tests/guppy/test_missing_coverage.py index 7ff726727..cd1ec9fc7 100644 --- a/python/quantum-pecos/tests/guppy/test_missing_coverage.py +++ b/python/quantum-pecos/tests/guppy/test_missing_coverage.py @@ -157,8 +157,8 @@ def prep_measure_circuit() -> bool: 0.01, 0.01, ) - .with_p1_probability(0.05) # 5% single-qubit gate error - .with_p2_probability(0.1) # 10% two-qubit gate error + .with_p1(0.05) # 5% single-qubit gate error + .with_p2(0.1) # 10% two-qubit gate error ) results = sim(prep_measure_circuit).qubits(1).quantum(state_vector()).seed(456).noise(noise).run(100).to_dict() diff --git a/python/quantum-pecos/tests/guppy/test_noise_models.py b/python/quantum-pecos/tests/guppy/test_noise_models.py index b79d40cf7..677e59bb9 100644 --- a/python/quantum-pecos/tests/guppy/test_noise_models.py +++ b/python/quantum-pecos/tests/guppy/test_noise_models.py @@ -48,10 +48,10 @@ def simple_circuit() -> bool: # Create depolarizing noise - must chain all probability setters noise = ( depolarizing_noise() - .with_prep_probability(0.0) # No prep errors - .with_p1_probability(0.2) # 20% chance of error on single-qubit gates - .with_p2_probability(0.0) # No two-qubit gate errors - .with_meas_probability(0.0) + .with_p_prep(0.0) # No prep errors + .with_p1(0.2) # 20% chance of error on single-qubit gates + .with_p2(0.0) # No two-qubit gate errors + .with_p_meas(0.0) ) # No measurement errors # High depolarizing probability to see effect @@ -77,11 +77,11 @@ def simple_circuit() -> bool: # Use biased depolarizing - must chain all probability setters noise = ( biased_depolarizing_noise() - .with_prep_probability(0.05) # State prep errors - .with_p1_probability(0.1) # Single-qubit gate errors - .with_p2_probability(0.0) # No two-qubit gate errors - .with_meas_0_probability(0.05) # Measurement errors for |0⟩ - .with_meas_1_probability(0.05) + .with_p_prep(0.05) # State prep errors + .with_p1(0.1) # Single-qubit gate errors + .with_p2(0.0) # No two-qubit gate errors + .with_p_meas_0(0.05) # Measurement errors for |0⟩ + .with_p_meas_1(0.05) ) # Measurement errors for |1⟩ results = sim(simple_circuit).qubits(10).quantum(state_vector()).noise(noise).seed(42).run(100).to_dict() @@ -104,7 +104,7 @@ def simple_circuit() -> bool: # Use general noise model with multiple error types noise_builder = ( - general_noise().with_p1_probability(0.01).with_prep_probability(0.01) # Single-qubit gate errors + general_noise().with_p1(0.01).with_p_prep(0.01) # Single-qubit gate errors ) # Preparation errors results = ( @@ -137,10 +137,10 @@ def bell_circuit() -> tuple[bool, bool]: # Run with depolarizing noise - chain all probability setters noise = ( depolarizing_noise() - .with_prep_probability(0.0) # No prep errors - .with_p1_probability(0.05) # 5% error on single-qubit gates - .with_p2_probability(0.05) # 5% error on two-qubit gates - .with_meas_probability(0.0) + .with_p_prep(0.0) # No prep errors + .with_p1(0.05) # 5% error on single-qubit gates + .with_p2(0.05) # 5% error on two-qubit gates + .with_p_meas(0.0) ) # No measurement errors results_noisy = sim(bell_circuit).qubits(10).quantum(state_vector()).noise(noise).seed(42).run(100).to_dict() @@ -171,26 +171,12 @@ def simple_x_circuit() -> bool: return measure(q) # Test that builder pattern works - chain all probability setters - noise1 = ( - depolarizing_noise() - .with_prep_probability(0.0) - .with_p1_probability(0.1) - .with_p2_probability(0.0) - .with_meas_probability(0.0) - .with_seed(1) - ) + noise1 = depolarizing_noise().with_p_prep(0.0).with_p1(0.1).with_p2(0.0).with_p_meas(0.0).with_seed(1) results1 = sim(simple_x_circuit).qubits(10).quantum(state_vector()).noise(noise1).seed(42).run(10).to_dict() # Different seed should give different results - noise2 = ( - depolarizing_noise() - .with_prep_probability(0.0) - .with_p1_probability(0.1) - .with_p2_probability(0.0) - .with_meas_probability(0.0) - .with_seed(2) - ) + noise2 = depolarizing_noise().with_p_prep(0.0).with_p1(0.1).with_p2(0.0).with_p_meas(0.0).with_seed(2) results2 = sim(simple_x_circuit).qubits(10).quantum(state_vector()).noise(noise2).seed(43).run(10).to_dict() @@ -205,6 +191,15 @@ def simple_x_circuit() -> bool: assert len(measurements2) == 10 +def test_general_noise_idle_after_2q_api() -> None: + """The after-2q idle API accepts a duration and has no probability alias.""" + builder = general_noise() + + assert callable(builder.with_idle_after_2q) + assert builder.with_p_idle_linear_rate(0.01).with_idle_after_2q(1.0) is not None + assert not hasattr(builder, "with_p2_idle") + + def test_noise_on_single_qubit_gates() -> None: """Test noise specifically on single-qubit gates.""" @@ -216,7 +211,7 @@ def multi_gate_circuit() -> bool: return measure(q) # Configure noise only for single-qubit gates - noise = general_noise().with_p1_probability(0.3) # High error rate to see effect + noise = general_noise().with_p1(0.3) # High error rate to see effect results = sim(multi_gate_circuit).qubits(10).quantum(state_vector()).noise(noise).seed(42).run(100).to_dict() @@ -239,7 +234,7 @@ def simple_circuit() -> bool: return measure(q) # Configure noise only for measurements - noise = general_noise().with_meas_probability(0.2) # High measurement error + noise = general_noise().with_p_meas(0.2) # High measurement error results = sim(simple_circuit).qubits(10).quantum(state_vector()).noise(noise).seed(42).run(100).to_dict() diff --git a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_comprehensive.py b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_comprehensive.py index 5456ba489..a66afded8 100644 --- a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_comprehensive.py +++ b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_comprehensive.py @@ -258,11 +258,7 @@ def test_all_noise_models_builder(self) -> None: GeneralNoiseModelBuilder(), depolarizing_noise().with_uniform_probability(0.1), biased_depolarizing_noise().with_uniform_probability(0.033), - depolarizing_noise() - .with_prep_probability(0.1) - .with_meas_probability(0.1) - .with_p1_probability(0.1) - .with_p2_probability(0.1), + depolarizing_noise().with_p_prep(0.1).with_p_meas(0.1).with_p1(0.1).with_p2(0.1), ] for noise_builder in noise_builders: diff --git a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_config.py b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_config.py index 911d15109..bf2a3dd90 100644 --- a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_config.py +++ b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_config.py @@ -143,11 +143,7 @@ def test_custom_noise_config(self) -> None: .to_sim() .seed(42) .noise( - depolarizing_noise() - .with_prep_probability(0.001) - .with_meas_probability(0.002) - .with_p1_probability(0.003) - .with_p2_probability(0.004), + depolarizing_noise().with_p_prep(0.001).with_p_meas(0.002).with_p1(0.003).with_p2(0.004), ) .build() ) @@ -221,7 +217,7 @@ def test_structured_config(self) -> None: """ # Create noise using functional API - pass it directly to noise() method - noise_builder = general_noise().with_seed(42).with_p1_probability(0.001).with_p2_probability(0.01) + noise_builder = general_noise().with_seed(42).with_p1(0.001).with_p2(0.01) # Use builder pattern instead of config dict sim = ( @@ -263,11 +259,11 @@ def test_general_noise_config(self) -> None: noise_builder = ( general_noise() .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_prep_probability(0.001) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002) + .with_p1(0.001) + .with_p2(0.01) + .with_p_prep(0.001) + .with_p_meas_0(0.002) + .with_p_meas_1(0.002) # TODO: Add these methods to Python bindings: # .with_noiseless_gates(["H"]) # .with_p1_pauli_model(x=0.5, y=0.3, z=0.2) diff --git a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_custom_noise.py b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_custom_noise.py index 68f66d0f3..983d433ef 100644 --- a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_custom_noise.py +++ b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_custom_noise.py @@ -13,17 +13,11 @@ def test_built_in_noise_builders(self) -> None: ) # Test depolarizing noise builder - dep = depolarizing_noise().with_p1_probability(0.05) + dep = depolarizing_noise().with_p1(0.05) assert dep is not None # Test depolarizing noise with multiple parameters - dep_custom = ( - depolarizing_noise() - .with_prep_probability(0.002) - .with_meas_probability(0.001) - .with_p1_probability(0.003) - .with_p2_probability(0.002) - ) + dep_custom = depolarizing_noise().with_p_prep(0.002).with_p_meas(0.001).with_p1(0.003).with_p2(0.002) assert dep_custom is not None # Test BiasedDepolarizingNoise @@ -106,11 +100,7 @@ def test_noise_builder_validation(self) -> None: .program(Qasm.from_string(qasm_valid)) .to_sim() .noise( - depolarizing_noise() - .with_prep_probability(0.1) - .with_meas_probability(0.2) - .with_p1_probability(0.3) - .with_p2_probability(0.4), + depolarizing_noise().with_p_prep(0.1).with_p_meas(0.2).with_p1(0.3).with_p2(0.4), ) .build() ) diff --git a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_defaults.py b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_defaults.py index d4c10d65c..32477e293 100644 --- a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_defaults.py +++ b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_defaults.py @@ -68,14 +68,14 @@ def test_noise_model_defaults(self) -> None: # Test default values for noise models using builder pattern # Note: depolarizing_noise() builder requires explicit probability - depolarizing_noise().with_p1_probability(0.001) + depolarizing_noise().with_p1(0.001) # Can't directly assert on builder properties # General noise model has defaults that can be overridden GeneralNoiseModelBuilder() # Default values are set when building - (biased_depolarizing_noise().with_p1_probability(0.001).with_p2_probability(0.001).with_prep_probability(0.001)) + (biased_depolarizing_noise().with_p1(0.001).with_p2(0.001).with_p_prep(0.001)) # Builder pattern requires explicit values def test_builder_defaults_new_api(self) -> None: @@ -141,7 +141,7 @@ def test_default_summary(self) -> None: # - bit_format: BigInt (integers, not binary strings) # # Noise model builders: - # - depolarizing_noise(): requires explicit .with_p1_probability() + # - depolarizing_noise(): requires explicit .with_p1() # - biased_depolarizing_noise(): requires probability settings # - GeneralNoiseModelBuilder(): has internal defaults # diff --git a/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py b/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py new file mode 100644 index 000000000..d77e7d54b --- /dev/null +++ b/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py @@ -0,0 +1,90 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""The simulator noise builders name each setter after the field it sets. + +The suffixed spellings (``with_p1_probability``, ``with_meas_probability``, ...) were +replaced outright rather than aliased, so this pins both halves: the field-name setters +work through the pyo3 surface, and the old spellings are gone. +""" + +import pytest +from guppylang import guppy +from guppylang.std.quantum import measure, qubit +from pecos import sim +from pecos_rslib import ( + biased_depolarizing_noise, + depolarizing_noise, + general_noise, + state_vector, +) + +# (builder factory, setters that builder is expected to expose) +BUILDER_SETTERS = [ + (general_noise, ("with_p1", "with_p2", "with_p_prep", "with_p_meas", "with_p_meas_0", "with_p_meas_1")), + (depolarizing_noise, ("with_p1", "with_p2", "with_p_prep", "with_p_meas")), + (biased_depolarizing_noise, ("with_p1", "with_p2", "with_p_prep", "with_p_meas_0", "with_p_meas_1")), +] + +REMOVED_SETTERS = ( + "with_p1_probability", + "with_p2_probability", + "with_prep_probability", + "with_meas_probability", + "with_meas_0_probability", + "with_meas_1_probability", + "with_average_p1_probability", + "with_average_p2_probability", +) + + +@pytest.mark.parametrize(("factory", "setters"), BUILDER_SETTERS) +def test_field_name_setters_are_chainable(factory, setters) -> None: + """Every field-name setter exists and returns a builder that keeps chaining.""" + builder = factory() + for setter in setters: + builder = getattr(builder, setter)(0.01) + assert builder is not None + + +@pytest.mark.parametrize(("factory", "_setters"), BUILDER_SETTERS) +def test_suffixed_setters_are_gone(factory, _setters) -> None: + """The replaced spellings must not linger as aliases.""" + builder = factory() + for removed in REMOVED_SETTERS: + assert not hasattr(builder, removed), f"{removed} should have been renamed away" + + +def test_average_setters_keep_their_conversion() -> None: + """``with_average_p*`` survives the rename; it converts from average gate error.""" + builder = general_noise() + assert callable(builder.with_average_p1) + assert callable(builder.with_average_p2) + assert builder.with_average_p1(0.01).with_average_p2(0.02) is not None + + +def test_with_p_meas_actually_configures_measurement_noise() -> None: + """A renamed setter still reaches the model: certain measurement flips flip every shot.""" + + @guppy + def prepare_and_measure() -> bool: + q = qubit() + return measure(q) + + noise = general_noise().with_p_prep(0.0).with_p1(0.0).with_p2(0.0).with_p_meas(1.0) + results = sim(prepare_and_measure).qubits(1).quantum(state_vector()).noise(noise).seed(42).run(20).to_dict() + + raw = results["measurements"] + measurements = [m[-1] if isinstance(m, list) else m for m in raw] + assert all(m == 1 for m in measurements), "p_meas=1.0 should flip every |0> measurement" diff --git a/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py b/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py index 849bb31cc..988c2f1f2 100644 --- a/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py +++ b/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py @@ -507,11 +507,7 @@ def test_tiny_syndrome_memory_p2_only_matches_between_selene_backends_statistica .quantum(pecos.stabilizer()) .qubits(2) .noise( - pecos.depolarizing_noise() - .with_p1_probability(0.0) - .with_p2_probability(p2) - .with_meas_probability(0.0) - .with_prep_probability(0.0), + pecos.depolarizing_noise().with_p1(0.0).with_p2(p2).with_p_meas(0.0).with_p_prep(0.0), ) .seed(123) .run(shots) diff --git a/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py b/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py index 23954c2af..5fa5d8f8d 100644 --- a/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py +++ b/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py @@ -64,13 +64,7 @@ def test_neo_stack_measurement_noise_rate_matches_engines() -> None: shots = 4000 def rate_of_zero(stack: str) -> float: - noise = ( - depolarizing_noise() - .with_prep_probability(0.0) - .with_meas_probability(p_meas) - .with_p1_probability(0.0) - .with_p2_probability(0.0) - ) + noise = depolarizing_noise().with_p_prep(0.0).with_p_meas(p_meas).with_p1(0.0).with_p2(0.0) builder = sim(Qasm.from_string(X_MEASURE)).noise(noise).seed(42) if stack == "neo": builder = builder.stack("neo") From 6b91fa5a91f84ecc8be40787a4e811712eacbc02 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 4 Aug 2026 16:00:09 -0600 Subject: [PATCH 32/62] Correct the noise-setter naming note left vacuous by the field-name rename --- docs/workflows/guppy-dem-decoding.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 47527870d..dbd9ce089 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -211,9 +211,8 @@ default Selene runtime emits no idle gates for the simulator to attach idle noise to. The simulated numbers are therefore the same experiment without the idle contribution, not an independent estimate of the same quantity. -The simulator's noise builder spells its setters with explicit suffixes -(`with_p1`), while `NoiseParameters` names each setter after its -field (`with_p1`). The two describe the same rates. +The simulator's noise builder and `NoiseParameters` both name each setter after +the field it sets, so the same rate carries the same spelling on either side. ```python From c46974a8878bf7ac9cb78f8b38655a2bc16e4bf0 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 4 Aug 2026 16:00:09 -0600 Subject: [PATCH 33/62] Give pecos-neo after-2q idle the full idle treatment and fix the incoherent dephasing twirl --- exp/pecos-neo/docs/design/noise-composite.md | 8 +- exp/pecos-neo/docs/dev/noise.md | 5 +- exp/pecos-neo/src/noise/builder.rs | 156 ++++++++- exp/pecos-neo/src/noise/composite/builder.rs | 253 +++++--------- exp/pecos-neo/src/noise/general_builder.rs | 77 ++++- exp/pecos-neo/src/noise/idle.rs | 337 ++++++++++++++++--- exp/pecos-neo/src/noise/two_qubit.rs | 112 ++++-- 7 files changed, 675 insertions(+), 273 deletions(-) diff --git a/exp/pecos-neo/docs/design/noise-composite.md b/exp/pecos-neo/docs/design/noise-composite.md index a62f62ea6..e156b14f3 100644 --- a/exp/pecos-neo/docs/design/noise-composite.md +++ b/exp/pecos-neo/docs/design/noise-composite.md @@ -194,7 +194,7 @@ Similar to single-qubit, with additions: - Angle-dependent probability: `prob_fn(|gate| p2_angle_rate(gate.angle()))` - Skip if ANY qubit leaked - Two-qubit Pauli model -- Optional idle noise after +- Optional operand-local idle duration after the gate, owned by `IdleChannel` ```rust let tq_noise = seq([ @@ -208,11 +208,13 @@ let tq_noise = seq([ ]) ) ), - // Idle noise always applies (regardless of fault) - prob(p2_idle, idle_pauli()), ]); ``` +Idle policy is not part of the two-qubit primitive. A separate `IdleChannel` +subscribes to both explicit idle events and two-qubit `AfterGate` events, applying +the same configured linear and quadratic mechanisms for the requested duration. + ### Measurement Noise (Tricky Case #1) **Why it's tricky:** Operates on outcomes, not gates. Outcome-dependent. Leaked qubits force outcome before flip noise applies. diff --git a/exp/pecos-neo/docs/dev/noise.md b/exp/pecos-neo/docs/dev/noise.md index 26917c1aa..4d50919b2 100644 --- a/exp/pecos-neo/docs/dev/noise.md +++ b/exp/pecos-neo/docs/dev/noise.md @@ -63,8 +63,9 @@ CompositeNoiseModelBuilder::new() // Angle-dependent scaling (for RZZ, etc.) .with_p2_angle_scaling(AngleScaling::Quadratic) - // Idle error during two-qubit gate - .with_p2_idle_rate(0.001) + // Apply configured idle noise for one time unit after each two-qubit gate + .with_p_idle_linear(0.001) + .with_idle_after_2q(1.0) // Custom two-qubit Pauli model .with_p2_pauli_model(TwoQubitPauliWeights { ... }) diff --git a/exp/pecos-neo/src/noise/builder.rs b/exp/pecos-neo/src/noise/builder.rs index babd99ad9..d29163ac4 100644 --- a/exp/pecos-neo/src/noise/builder.rs +++ b/exp/pecos-neo/src/noise/builder.rs @@ -137,7 +137,7 @@ pub struct NoiseModelBuilder { p2_emission_weights: TwoQubitEmissionWeights, p2_pauli_weights: TwoQubitPauliWeights, p2_seepage_prob: f64, - p2_idle: f64, + idle_after_2q: f64, // Measurement p_meas_0: f64, @@ -201,7 +201,7 @@ impl NoiseModelBuilder { p2_emission_weights: TwoQubitEmissionWeights::uniform_pauli(), p2_pauli_weights: TwoQubitPauliWeights::uniform(), p2_seepage_prob: 0.0, - p2_idle: 0.0, + idle_after_2q: 0.0, // Measurement p_meas_0: 0.0, @@ -270,6 +270,16 @@ impl NoiseModelBuilder { self } + /// Set the duration of the idle-noise site applied after each two-qubit gate. + /// + /// A duration of zero disables these sites. Nonzero sites receive all + /// configured linear and quadratic idle mechanisms. + #[must_use] + pub fn with_idle_after_2q(mut self, duration: f64) -> Self { + self.idle_after_2q = duration; + self + } + /// Set symmetric measurement error probability. #[must_use] pub fn with_measurement_error(mut self, p_meas: f64) -> Self { @@ -541,7 +551,6 @@ impl NoiseModelBuilder { self.p2_emission_ratio, self.p2_emission_weights, self.p2_seepage_prob, - self.p2_idle, ); model = model.add_channel(channel); } @@ -573,15 +582,18 @@ impl NoiseModelBuilder { } // Add idle channel - if self.p_idle_linear_rate > 0.0 { - let mut channel = IdleChannel::linear(self.p_idle_linear_rate); - if self.p_idle_coherent { - channel = channel - .with_coherent_dephasing(true) - .with_coherent_to_incoherent_factor(self.p_idle_coherent_factor); - } else { - channel = channel.with_linear_weights(self.p_idle_linear_weights); - } + if self.p_idle_linear_rate > 0.0 + || self.p_idle_quadratic_rate > 0.0 + || self.idle_after_2q > 0.0 + { + let channel = IdleChannel { + linear_rate: self.p_idle_linear_rate, + linear_weights: self.p_idle_linear_weights, + quadratic_rate: self.p_idle_quadratic_rate, + coherent_dephasing: self.p_idle_coherent, + coherent_to_incoherent_factor: self.p_idle_coherent_factor, + idle_after_2q: self.idle_after_2q, + }; model = model.add_channel(channel); } @@ -606,10 +618,22 @@ mod tests { use super::*; use crate::command::CommandBuilder; use crate::noise::composite::prelude::*; + use crate::noise::{NoiseEvent, NoiseResponse}; use crate::runner::CircuitRunner; use pecos_core::QubitId; + use pecos_random::PecosRng; use pecos_simulators::SparseStab; + fn collect_gates(response: NoiseResponse) -> Vec { + match response { + NoiseResponse::InjectGates(gates) => (*gates).into_vec(), + NoiseResponse::Multiple(responses) => { + responses.into_iter().flat_map(collect_gates).collect() + } + _ => Vec::new(), + } + } + #[test] fn test_simple_depolarizing() { let model = NoiseModelBuilder::new().with_depolarizing(0.1, 0.2).build(); @@ -647,6 +671,114 @@ mod tests { assert!(model.channel_count() >= 1); } + #[test] + fn after_2q_builder_routes_quadratic_noise_without_a_two_qubit_channel() { + let mut model = NoiseModelBuilder::new() + .with_idle_noise(0.0, std::f64::consts::PI) + .with_idle_after_2q(1.0) + .build(); + assert_eq!(model.channel_names(), ["IdleChannel"]); + + let qubits = [QubitId(0), QubitId(1)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(47))); + + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|gate| gate.gate_type == GateType::Z)); + } + + #[test] + fn idle_builder_routes_weights_and_quadratic_rate_in_coherent_mode() { + let mut builder = NoiseModelBuilder::new() + .with_idle_noise(1.0, 0.75) + .with_coherent_idle(1.0) + .with_idle_after_2q(2.0); + builder.p_idle_linear_weights = crate::noise::PauliWeights::custom(1.0, 0.0, 0.0); + let mut model = builder.build(); + let qubits = [QubitId(0), QubitId(1)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(53))); + let x_count = gates + .iter() + .filter(|gate| gate.gate_type == GateType::X) + .count(); + let rz_angles = gates + .iter() + .filter(|gate| gate.gate_type == GateType::RZ) + .map(|gate| gate.angles[0].to_radians()) + .collect::>(); + + assert_eq!(x_count, 2); + assert_eq!(rz_angles.len(), 2); + assert!(rz_angles.iter().all(|angle| (*angle - 1.5).abs() < 1e-15)); + } + + #[test] + fn idle_builder_routes_the_incoherent_conversion_factor() { + let mut builder = NoiseModelBuilder::new() + .with_idle_noise(0.0, std::f64::consts::FRAC_PI_2) + .with_idle_after_2q(1.0); + builder.p_idle_coherent_factor = 2.0; + let mut model = builder.build(); + let qubits = std::array::from_fn::<_, 16, _>(QubitId); + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(59))); + assert_eq!(gates.len(), qubits.len()); + } + + #[test] + fn after_2q_duration_alone_still_builds_the_idle_policy_channel() { + let mut model = NoiseModelBuilder::new().with_idle_after_2q(1.0).build(); + assert_eq!(model.channel_names(), ["IdleChannel"]); + + let qubits = [QubitId(0), QubitId(1)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + assert!( + model + .emit(&event, &mut PecosRng::seed_from_u64(61)) + .is_none() + ); + } + + #[test] + fn quadratic_only_configuration_builds_the_idle_channel() { + let mut model = NoiseModelBuilder::new() + .with_idle_noise(0.0, std::f64::consts::PI) + .build(); + assert_eq!(model.channel_names(), ["IdleChannel"]); + + let qubits = [QubitId(0)]; + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: 1_u64.into(), + }; + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(67))); + assert_eq!(gates.len(), 1); + assert_eq!(gates[0].gate_type, GateType::Z); + } + #[test] fn test_composed_vs_simple_parity() { // Build the same noise model two ways and verify similar behavior diff --git a/exp/pecos-neo/src/noise/composite/builder.rs b/exp/pecos-neo/src/noise/composite/builder.rs index 5b1bf99dd..cceda9118 100644 --- a/exp/pecos-neo/src/noise/composite/builder.rs +++ b/exp/pecos-neo/src/noise/composite/builder.rs @@ -43,7 +43,7 @@ use super::prelude::*; use crate::command::GateType; use crate::noise::two_qubit::AngleScaling; use crate::noise::{ - ComposableNoiseModel, CrosstalkTransitions, SingleQubitEmissionWeights, + ComposableNoiseModel, CrosstalkTransitions, IdleChannel, SingleQubitEmissionWeights, TwoQubitEmissionWeights, TwoQubitPauliWeights, }; use pecos_core::TimeScale; @@ -122,7 +122,7 @@ pub struct CompositeNoiseModelBuilder { p2_pauli_model: Option, p2_emission_model: Option, p2_angle_scaling: Option, - p2_idle_rate: f64, + idle_after_2q: f64, // Preparation parameters p_prep: f64, @@ -177,7 +177,7 @@ impl Default for CompositeNoiseModelBuilder { p2_pauli_model: None, p2_emission_model: None, p2_angle_scaling: None, - p2_idle_rate: 0.0, + idle_after_2q: 0.0, p_prep: 0.0, p_prep_leak_ratio: 0.0, p_prep_crosstalk: 0.0, @@ -395,13 +395,13 @@ impl CompositeNoiseModelBuilder { self } - /// Set idle noise rate after two-qubit gates. + /// Set the duration of the idle-noise site applied after each two-qubit gate. /// - /// This applies additional stochastic noise after each two-qubit gate, - /// modeling the fact that two-qubit gates often take longer. + /// A duration of zero disables these sites. Nonzero sites receive all + /// configured linear and quadratic idle mechanisms. #[must_use] - pub fn with_p2_idle(mut self, rate: f64) -> Self { - self.p2_idle_rate = validate_probability(rate, "p2_idle"); + pub fn with_idle_after_2q(mut self, duration: f64) -> Self { + self.idle_after_2q = validate_rate(duration, "idle_after_2q"); self } @@ -576,7 +576,8 @@ impl CompositeNoiseModelBuilder { /// Set the quadratic idle noise rate (T2-like dephasing). /// - /// The error probability follows: `p = sin(rate * duration)^2` + /// The error probability follows: `p = sin(rate * duration / 2)^2`, + /// the exact Pauli twirl of the coherent RZ rotation. /// /// This models coherent dephasing converted to stochastic errors. /// Use `with_p_idle_coherent(true)` to use actual coherent RZ rotations instead. @@ -593,7 +594,7 @@ impl CompositeNoiseModelBuilder { /// such as frequency offsets in physical systems. /// /// When `false` (default), dephasing is modeled as stochastic Z errors - /// with probability `sin(rate * duration)^2`. + /// with probability `sin(rate * duration / 2)^2`. #[must_use] pub fn with_p_idle_coherent(mut self, coherent: bool) -> Self { self.p_idle_coherent = coherent; @@ -793,8 +794,8 @@ impl CompositeNoiseModelBuilder { model = model.add_channel(channel); } - // Two-qubit gate noise (including p2_idle) - if self.p2 > 0.0 || self.p2_idle_rate > 0.0 { + // Two-qubit gate noise + if self.p2 > 0.0 { let tq_noise = self.build_two_qubit_noise(); let channel = CompositeChannelBuilder::two_qubit("flow_tq", tq_noise); model = model.add_channel(channel); @@ -853,10 +854,29 @@ impl CompositeNoiseModelBuilder { model = model.add_channel(channel); } - // Idle noise (T1/T2) - if self.p_idle_linear_rate > 0.0 || self.p_idle_quadratic_rate > 0.0 { - let idle_noise = self.build_idle_noise(); - let channel = CompositeChannelBuilder::idle("flow_idle", idle_noise); + // Idle noise (T1/T2 and operand-local sites after two-qubit gates) + if self.p_idle_linear_rate > 0.0 + || self.p_idle_quadratic_rate > 0.0 + || self.idle_after_2q > 0.0 + { + let composite_weights = self + .p_idle_linear_pauli_weights + .unwrap_or_else(PauliWeights::uniform) + .normalized(); + let total = composite_weights.x + composite_weights.y + composite_weights.z; + let linear_weights = crate::noise::PauliWeights::custom( + composite_weights.x / total, + composite_weights.y / total, + composite_weights.z / total, + ); + let channel = IdleChannel { + linear_rate: self.p_idle_linear_rate, + linear_weights, + quadratic_rate: self.p_idle_quadratic_rate, + coherent_dephasing: self.p_idle_coherent, + coherent_to_incoherent_factor: self.p_idle_coherent_to_incoherent_factor, + idle_after_2q: self.idle_after_2q, + }; model = model.add_channel(channel); } @@ -983,18 +1003,10 @@ impl CompositeNoiseModelBuilder { /// When angle scaling is configured, uses `prob_fn` for dynamic probability. /// Otherwise, uses constant `prob`. fn build_two_qubit_noise(&self) -> BoxSeq { - // Check if we need angle-dependent probability - let main_noise = if let Some(scaling) = self.p2_angle_scaling { + if let Some(scaling) = self.p2_angle_scaling { self.build_two_qubit_noise_angle_scaled(scaling) } else { self.build_two_qubit_noise_constant() - }; - - // Add idle noise after the gate if configured (memory sweeping) - if self.p2_idle_rate > 0.0 { - seq![main_noise, prob(self.p2_idle_rate, inject_z()),] - } else { - main_noise } } @@ -1196,74 +1208,29 @@ impl CompositeNoiseModelBuilder { ] } } - - /// Build idle noise primitive (T1/T2). - fn build_idle_noise(&self) -> BoxSeq { - use super::action::InjectCoherentRZ; - use super::primitive::{ProbLinear, ProbQuadratic}; - - // T1 uses custom Pauli weights if specified, otherwise uniform - let make_t1_pauli = || match self.p_idle_linear_pauli_weights { - Some(w) => Pauli::new(w), - None => Pauli::uniform(), - }; - - // T2: either coherent RZ rotations or stochastic Z errors - let make_t2_stochastic = || { - ProbQuadratic::new(self.p_idle_quadratic_rate, inject_z()) - .with_factor(self.p_idle_coherent_to_incoherent_factor) - }; - - let make_t2_coherent = || InjectCoherentRZ::new(self.p_idle_quadratic_rate); - - match ( - self.p_idle_linear_rate > 0.0, - self.p_idle_quadratic_rate > 0.0, - self.p_idle_coherent, - ) { - (true, true, false) => { - // Both T1 (linear) and T2 (quadratic stochastic) noise - seq![ - ProbLinear::new(self.p_idle_linear_rate, make_t1_pauli()), - make_t2_stochastic(), - ] - } - (true, true, true) => { - // Both T1 (linear stochastic) and T2 (coherent RZ) noise - seq![ - ProbLinear::new(self.p_idle_linear_rate, make_t1_pauli()), - make_t2_coherent(), - ] - } - (true, false, _) => { - // Only T1 (linear) noise - seq![ProbLinear::new(self.p_idle_linear_rate, make_t1_pauli()),] - } - (false, true, false) => { - // Only T2 (quadratic stochastic) noise - seq![make_t2_stochastic(),] - } - (false, true, true) => { - // Only T2 (coherent RZ) noise - seq![make_t2_coherent(),] - } - (false, false, _) => { - // No idle noise (shouldn't reach here due to caller check) - seq![nothing(),] - } - } - } } #[cfg(test)] #[allow(clippy::cast_precision_loss)] // statistical tests use count as f64 mod tests { use super::*; - use crate::command::CommandBuilder; + use crate::command::{CommandBuilder, GateCommand}; + use crate::noise::{NoiseEvent, NoiseResponse}; use crate::runner::CircuitRunner; use pecos_core::QubitId; + use pecos_random::PecosRng; use pecos_simulators::SparseStab; + fn collect_gates(response: NoiseResponse) -> Vec { + match response { + NoiseResponse::InjectGates(gates) => (*gates).into_vec(), + NoiseResponse::Multiple(responses) => { + responses.into_iter().flat_map(collect_gates).collect() + } + _ => Vec::new(), + } + } + #[test] fn test_empty_builder() { let model = CompositeNoiseModelBuilder::new().build(); @@ -1495,112 +1462,54 @@ mod tests { } #[test] - fn test_p2_idle() { - let model = CompositeNoiseModelBuilder::new() - .with_p2(0.01) - .with_p2_idle(0.001) + fn after_2q_idle_uses_idle_channel_without_p2() { + let mut model = CompositeNoiseModelBuilder::new() + .with_p_idle_linear(1.0) + .with_p_idle_linear_weights(PauliWeights::custom(1.0, 0.0, 0.0)) + .with_idle_after_2q(1.0) .build(); + assert_eq!(model.channel_names(), ["IdleChannel"]); - // Should have TQ channel (p2_idle is integrated into the TQ noise) - assert_eq!(model.channel_count(), 1); + let qubits = [QubitId(0), QubitId(1)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(73))); + + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|gate| gate.gate_type == GateType::X)); } #[test] - fn test_p2_idle_primitive() { - use crate::noise::NoiseChannel; - use pecos_random::PecosRng; - - // Test that the primitive applies idle noise correctly - // Build the primitive directly - let tq_noise = seq![ - prob(0.0, pauli()), // No main error - prob(1.0, inject_z()), // 100% idle Z error - ]; - - let channel = CompositeChannelBuilder::two_qubit("test_idle", tq_noise); - - let mut ctx = crate::noise::NoiseContext::new(); - let mut rng = PecosRng::seed_from_u64(42); + fn quadratic_only_after_2q_noise_uses_the_idle_channel() { + let mut model = CompositeNoiseModelBuilder::new() + .with_p_idle_quadratic(std::f64::consts::PI) + .with_idle_after_2q(1.0) + .build(); + assert_eq!(model.channel_names(), ["IdleChannel"]); - // Create a two-qubit gate event let qubits = [QubitId(0), QubitId(1)]; - let event = crate::noise::NoiseEvent::AfterGate { + let event = NoiseEvent::AfterGate { gate_type: GateType::CX, qubits: &qubits, angles: &[], gate_id: None, }; + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(79))); - // Check that the channel responds to this event - assert!( - channel.responds_to(&event), - "Channel should respond to AfterGate with 2 qubits" - ); - - // Apply and check that response is not None (gates were injected) - let response = channel.apply(&event, &mut ctx, &mut rng); - assert!( - !response.is_none(), - "With 100% idle error, should inject Z gates (response should not be None)" - ); + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|gate| gate.gate_type == GateType::Z)); } #[test] - fn test_p2_idle_statistical() { - // Test that p2_idle works via the builder - // Use high p2 (which also applies p2_idle) to ensure the channel triggers - let commands = CommandBuilder::new() - .pz(&[0]) - .pz(&[1]) - .h(&[0]) // Make qubit 0 in superposition - .cx(&[(0, 1)]) // Two-qubit gate - .mz(&[0]) - .mz(&[1]) + fn after_2q_duration_without_rates_builds_only_the_idle_channel() { + let model = CompositeNoiseModelBuilder::new() + .with_idle_after_2q(2.0) .build(); - - let shots = 500; - let p2 = 0.5; // 50% gate error rate - - let mut errors_with = 0; - let mut errors_without = 0; - - for seed in 0..shots { - // With p2 error - let model_with = CompositeNoiseModelBuilder::new().with_p2(p2).build(); - let mut state = SparseStab::new(2); - let mut runner = CircuitRunner::::new() - .with_noise(model_with) - .with_seed(seed); - let outcomes = runner.apply_circuit(&mut state, &commands).unwrap(); - let q0 = outcomes.get(QubitId(0)).is_some_and(|o| o.outcome); - let q1 = outcomes.get(QubitId(1)).is_some_and(|o| o.outcome); - // Bell state: q0 != q1 indicates error - if q0 != q1 { - errors_with += 1; - } - - // Without noise - let model_without = CompositeNoiseModelBuilder::new().build(); - let mut state = SparseStab::new(2); - let mut runner = CircuitRunner::::new() - .with_noise(model_without) - .with_seed(seed); - let outcomes = runner.apply_circuit(&mut state, &commands).unwrap(); - let q0 = outcomes.get(QubitId(0)).is_some_and(|o| o.outcome); - let q1 = outcomes.get(QubitId(1)).is_some_and(|o| o.outcome); - if q0 != q1 { - errors_without += 1; - } - } - - // With 50% error, should see significantly more errors - let rate_with = f64::from(errors_with) / shots as f64; - let rate_without = f64::from(errors_without) / shots as f64; - - assert!( - rate_with > rate_without + 0.1, - "With p2={p2}, expected more errors ({rate_with}) than without ({rate_without})" - ); + assert_eq!(model.channel_names(), ["IdleChannel"]); } // Note: CompositeNoiseModelBuilder no longer implements Clone because it can hold diff --git a/exp/pecos-neo/src/noise/general_builder.rs b/exp/pecos-neo/src/noise/general_builder.rs index 6033c727f..54445b3f4 100644 --- a/exp/pecos-neo/src/noise/general_builder.rs +++ b/exp/pecos-neo/src/noise/general_builder.rs @@ -96,7 +96,7 @@ pub struct GeneralNoiseModelBuilder { p2_emission_weights: TwoQubitEmissionWeights, p2_pauli_weights: TwoQubitPauliWeights, p2_seepage_prob: f64, - p2_idle: f64, + idle_after_2q: f64, // Measurement p_meas_0: f64, @@ -156,7 +156,7 @@ impl GeneralNoiseModelBuilder { p2_emission_weights: TwoQubitEmissionWeights::uniform_pauli(), p2_pauli_weights: TwoQubitPauliWeights::uniform(), p2_seepage_prob: 0.0, - p2_idle: 0.0, + idle_after_2q: 0.0, // Measurement p_meas_0: 0.0, @@ -328,10 +328,13 @@ impl GeneralNoiseModelBuilder { self } - /// Set idle noise rate applied after two-qubit gates. + /// Set the duration of the idle-noise site applied after each two-qubit gate. + /// + /// A duration of zero disables these sites. Nonzero sites receive all + /// configured linear and quadratic idle mechanisms. #[must_use] - pub fn with_p2_idle(mut self, rate: f64) -> Self { - self.p2_idle = rate; + pub fn with_idle_after_2q(mut self, duration: f64) -> Self { + self.idle_after_2q = duration; self } @@ -564,7 +567,6 @@ impl GeneralNoiseModelBuilder { self.p2_emission_ratio, self.p2_emission_weights, self.p2_seepage_prob, - self.p2_idle, ); model = model.add_channel(channel); } @@ -594,13 +596,17 @@ impl GeneralNoiseModelBuilder { } // Idle channel - if self.p_idle_linear_rate > 0.0 || self.p_idle_quadratic_rate > 0.0 { + if self.p_idle_linear_rate > 0.0 + || self.p_idle_quadratic_rate > 0.0 + || self.idle_after_2q > 0.0 + { let channel = IdleChannel { linear_rate: self.p_idle_linear_rate, linear_weights: self.p_idle_linear_weights, quadratic_rate: self.p_idle_quadratic_rate, coherent_dephasing: self.p_idle_coherent, coherent_to_incoherent_factor: self.p_idle_coherent_to_incoherent_factor, + idle_after_2q: self.idle_after_2q, }; model = model.add_channel(channel); } @@ -641,6 +647,20 @@ pub fn general_noise() -> GeneralNoiseModelBuilder { #[allow(clippy::cast_precision_loss)] // statistical tests use count as f64 mod tests { use super::*; + use crate::command::GateCommand; + use crate::noise::{NoiseEvent, NoiseResponse}; + use pecos_core::QubitId; + use pecos_random::PecosRng; + + fn collect_gates(response: NoiseResponse) -> Vec { + match response { + NoiseResponse::InjectGates(gates) => (*gates).into_vec(), + NoiseResponse::Multiple(responses) => { + responses.into_iter().flat_map(collect_gates).collect() + } + _ => Vec::new(), + } + } #[test] fn test_empty_builder() { @@ -704,6 +724,49 @@ mod tests { assert_eq!(model.channel_count(), 2); } + #[test] + fn after_2q_idle_works_without_p2_or_a_two_qubit_channel() { + let mut model = GeneralNoiseModelBuilder::new() + .with_p_idle_linear(1.0) + .with_p_idle_linear_weights(PauliWeights::custom(1.0, 0.0, 0.0)) + .with_idle_after_2q(1.0) + .build(); + assert_eq!(model.channel_names(), ["IdleChannel"]); + + let qubits = [QubitId(0), QubitId(1)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(67))); + + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|gate| gate.gate_type == GateType::X)); + } + + #[test] + fn quadratic_only_after_2q_configuration_builds_and_emits() { + let mut model = GeneralNoiseModelBuilder::new() + .with_p_idle_quadratic(std::f64::consts::PI) + .with_idle_after_2q(1.0) + .build(); + assert_eq!(model.channel_names(), ["IdleChannel"]); + + let qubits = [QubitId(0), QubitId(1)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(71))); + + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|gate| gate.gate_type == GateType::Z)); + } + #[test] fn test_full_configuration() { let model = GeneralNoiseModelBuilder::new() diff --git a/exp/pecos-neo/src/noise/idle.rs b/exp/pecos-neo/src/noise/idle.rs index 761daf01a..4e198678b 100644 --- a/exp/pecos-neo/src/noise/idle.rs +++ b/exp/pecos-neo/src/noise/idle.rs @@ -47,8 +47,8 @@ //! - **Coherent**: Deterministic RZ rotation with angle = rate * duration. //! Represents systematic phase errors. //! -//! - **Incoherent**: Stochastic Z error with probability = sin(rate * duration)^2. -//! Represents random dephasing. +//! - **Incoherent**: Stochastic Z error with probability = sin(rate * duration / 2)^2. +//! This is the exact Pauli twirl of the coherent RZ rotation. use super::{NoiseChannel, NoiseContext, NoiseEvent, NoiseResponse, PauliWeights}; use crate::command::{GateCommand, GateType}; @@ -77,7 +77,11 @@ pub struct IdleChannel { /// Error rate per time unit for quadratic (dephasing) noise. /// /// For coherent: angle = `quadratic_rate` * duration. - /// For incoherent: probability = sin(`quadratic_rate` * duration)^2. + /// For incoherent: probability = sin(`quadratic_rate` * duration / 2)^2. + /// + /// The factor of one half makes the incoherent model the exact Pauli twirl + /// of the coherent RZ rotation. This deliberately changes numerical results + /// from earlier versions for incoherent quadratic idle noise. pub quadratic_rate: f64, /// Whether to model quadratic dephasing coherently (RZ) or incoherently (stochastic Z). @@ -93,6 +97,13 @@ pub struct IdleChannel { /// Default is 1.0 (no adjustment). Values > 1.0 increase the effective /// incoherent dephasing rate. pub coherent_to_incoherent_factor: f64, + + /// Duration of the idle-noise site applied after a two-qubit gate. + /// + /// A duration of zero disables after-two-qubit idle sites. When enabled, + /// the same linear and quadratic mechanisms used for explicit idle events + /// are applied to every distinct gate operand. + pub idle_after_2q: f64, } impl Default for IdleChannel { @@ -103,6 +114,7 @@ impl Default for IdleChannel { quadratic_rate: 0.0, coherent_dephasing: false, coherent_to_incoherent_factor: 1.0, + idle_after_2q: 0.0, } } } @@ -174,47 +186,57 @@ impl IdleChannel { self } + /// Set the duration of the idle-noise site after each two-qubit gate. + /// + /// The duration uses the channel's abstract time units. A duration of zero + /// disables these sites. + #[must_use] + pub fn with_idle_after_2q(mut self, duration: f64) -> Self { + self.idle_after_2q = duration; + self + } + /// Calculate linear (stochastic) error probability for a given duration. - fn linear_probability(&self, duration: TimeUnits) -> f64 { - let t = duration.as_f64(); - (self.linear_rate * t).min(1.0) + fn linear_probability(&self, duration: f64) -> f64 { + (self.linear_rate * duration).min(1.0) } /// Calculate quadratic dephasing probability (for incoherent mode). /// - /// Applies the coherent-to-incoherent factor to compensate for - /// not modeling coherent phase accumulation. - fn quadratic_probability(&self, duration: TimeUnits) -> f64 { - let t = duration.as_f64(); - let effective_rate = self.quadratic_rate * self.coherent_to_incoherent_factor; - let angle = effective_rate * t; - angle.sin().powi(2) + /// Applies the coherent-to-incoherent factor as a multiplier on the rate, + /// then uses the exact Pauli-twirl probability `sin(effective_angle / 2)^2`. + /// This deliberately changes numerical results from earlier versions, + /// which omitted the factor of one half. + fn quadratic_probability(&self, duration: f64) -> f64 { + let effective_angle = self.quadratic_rate * self.coherent_to_incoherent_factor * duration; + (effective_angle / 2.0).sin().powi(2) } /// Calculate quadratic dephasing angle (for coherent mode). - fn quadratic_angle(&self, duration: TimeUnits) -> f64 { - let t = duration.as_f64(); - self.quadratic_rate * t - } -} - -impl NoiseChannel for IdleChannel { - fn responds_to(&self, event: &NoiseEvent<'_>) -> bool { - if self.linear_rate <= 0.0 && self.quadratic_rate <= 0.0 { - return false; - } - matches!(event, NoiseEvent::IdleTime { .. }) + fn quadratic_angle(&self, duration: f64) -> f64 { + self.quadratic_rate * duration } - fn apply( + /// Apply every configured idle mechanism for one duration. + fn apply_for_duration( &self, - event: &NoiseEvent<'_>, + qubits: &[pecos_core::QubitId], + duration: f64, ctx: &mut NoiseContext, rng: &mut PecosRng, ) -> NoiseResponse { - let NoiseEvent::IdleTime { qubits, duration } = event else { + if duration <= 0.0 || (self.linear_rate <= 0.0 && self.quadratic_rate <= 0.0) { return NoiseResponse::None; - }; + } + + // A batched two-qubit command can contain multiple pairs. Preserve the + // operand order while applying one idle site to each distinct qubit. + let mut unique_qubits = SmallVec::<[pecos_core::QubitId; 4]>::new(); + for &qubit in qubits { + if !unique_qubits.contains(&qubit) { + unique_qubits.push(qubit); + } + } let mut gates = SmallVec::new(); @@ -223,8 +245,8 @@ impl NoiseChannel for IdleChannel { // Apply linear (stochastic) noise if self.linear_rate > 0.0 { - let p_linear = self.linear_probability(*duration); - for &qubit in *qubits { + let p_linear = self.linear_probability(duration); + for &qubit in &unique_qubits { // Skip leaked qubits (fast path skips check if no leakage exists) if (!has_any_leakage || !ctx.is_leaked(qubit)) && rng.random::() < p_linear { // Sample Pauli error from linear weights @@ -238,9 +260,9 @@ impl NoiseChannel for IdleChannel { if self.quadratic_rate > 0.0 { if self.coherent_dephasing { // Coherent dephasing: deterministic RZ rotation - let angle = self.quadratic_angle(*duration); + let angle = self.quadratic_angle(duration); if angle.abs() > f64::EPSILON { - for &qubit in *qubits { + for &qubit in &unique_qubits { // Skip leaked qubits (fast path skips check if no leakage exists) if !has_any_leakage || !ctx.is_leaked(qubit) { gates.push(GateCommand::rz(qubit, Angle64::from_radians(angle))); @@ -248,10 +270,10 @@ impl NoiseChannel for IdleChannel { } } } else { - // Incoherent dephasing: stochastic Z with sin^2 probability - let p_quad = self.quadratic_probability(*duration); + // Incoherent dephasing: stochastic Z with exact Pauli-twirl probability + let p_quad = self.quadratic_probability(duration); if p_quad > 0.0 { - for &qubit in *qubits { + for &qubit in &unique_qubits { // Skip leaked qubits (fast path skips check if no leakage exists) if (!has_any_leakage || !ctx.is_leaked(qubit)) && rng.random::() < p_quad @@ -269,6 +291,40 @@ impl NoiseChannel for IdleChannel { NoiseResponse::inject_gates(gates) } } +} + +impl NoiseChannel for IdleChannel { + fn responds_to(&self, event: &NoiseEvent<'_>) -> bool { + if self.linear_rate <= 0.0 && self.quadratic_rate <= 0.0 { + return false; + } + match event { + NoiseEvent::IdleTime { duration, .. } => *duration != TimeUnits::ZERO, + NoiseEvent::AfterGate { gate_type, .. } => { + self.idle_after_2q > 0.0 && gate_type.is_two_qubit() + } + _ => false, + } + } + + fn apply( + &self, + event: &NoiseEvent<'_>, + ctx: &mut NoiseContext, + rng: &mut PecosRng, + ) -> NoiseResponse { + let (qubits, duration) = match event { + NoiseEvent::IdleTime { qubits, duration } => (*qubits, duration.as_f64()), + NoiseEvent::AfterGate { + gate_type, qubits, .. + } if self.idle_after_2q > 0.0 && gate_type.is_two_qubit() => { + (*qubits, self.idle_after_2q) + } + _ => return NoiseResponse::None, + }; + + self.apply_for_duration(qubits, duration, ctx, rng) + } fn name(&self) -> &'static str { "IdleChannel" @@ -284,6 +340,25 @@ mod tests { use super::*; use pecos_core::QubitId; + fn collect_gates(response: NoiseResponse) -> Vec { + match response { + NoiseResponse::InjectGates(gates) => (*gates).into_vec(), + NoiseResponse::Multiple(responses) => { + responses.into_iter().flat_map(collect_gates).collect() + } + _ => Vec::new(), + } + } + + fn after_cx(qubits: &[QubitId]) -> NoiseEvent<'_> { + NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits, + angles: &[], + gate_id: None, + } + } + #[test] fn test_idle_error() { let channel = IdleChannel::linear(1.0); // 100% error per ns @@ -329,7 +404,7 @@ mod tests { let channel = IdleChannel::linear(0.001); // At 10ns: p = 0.001 * 10 = 0.01 - let p = channel.linear_probability(TimeUnits::new(10)); + let p = channel.linear_probability(TimeUnits::new(10).as_f64()); assert!((p - 0.01).abs() < 1e-10); } @@ -404,9 +479,9 @@ mod tests { #[test] fn test_incoherent_dephasing() { - // pi/2 rad/ns -> sin^2(pi/2) = 1 + // pi rad/ns -> sin^2(pi/2) = 1 let channel = IdleChannel { - quadratic_rate: std::f64::consts::FRAC_PI_2, + quadratic_rate: std::f64::consts::PI, ..Default::default() }; @@ -433,10 +508,10 @@ mod tests { #[test] fn test_coherent_to_incoherent_factor() { - // With factor = 2.0 and rate = pi/4, effective rate = pi/2 + // With factor = 2.0 and rate = pi/2, effective angle = pi // sin^2(pi/2) = 1.0 -> always error let channel = IdleChannel { - quadratic_rate: std::f64::consts::FRAC_PI_4, + quadratic_rate: std::f64::consts::FRAC_PI_2, coherent_to_incoherent_factor: 2.0, ..Default::default() }; @@ -461,4 +536,184 @@ mod tests { panic!("Expected InjectGates response"); } } + + #[test] + fn after_2q_duration_scales_linear_noise() { + let qubits = std::array::from_fn::<_, 64, _>(QubitId); + let short = IdleChannel::linear(0.25).with_idle_after_2q(1.0); + let long = IdleChannel::linear(0.25).with_idle_after_2q(4.0); + + let mut short_rng = PecosRng::seed_from_u64(17); + let short_gates = collect_gates(short.apply( + &after_cx(&qubits), + &mut NoiseContext::new(), + &mut short_rng, + )); + + let mut long_rng = PecosRng::seed_from_u64(17); + let long_gates = + collect_gates(long.apply(&after_cx(&qubits), &mut NoiseContext::new(), &mut long_rng)); + + assert!(short_gates.len() < qubits.len()); + assert_eq!(long_gates.len(), qubits.len()); + } + + #[test] + fn quadratic_only_noise_reaches_after_2q_sites() { + let channel = IdleChannel { + quadratic_rate: std::f64::consts::PI, + idle_after_2q: 1.0, + ..Default::default() + }; + let qubits = [QubitId(0), QubitId(1)]; + let mut rng = PecosRng::seed_from_u64(8); + + let gates = + collect_gates(channel.apply(&after_cx(&qubits), &mut NoiseContext::new(), &mut rng)); + + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|gate| gate.gate_type == GateType::Z)); + } + + #[test] + fn linear_weights_are_honored_at_after_2q_sites() { + let channel = IdleChannel::linear(1.0) + .with_linear_weights(PauliWeights::custom(1.0, 0.0, 0.0)) + .with_idle_after_2q(1.0); + let qubits = [QubitId(0), QubitId(1)]; + let mut rng = PecosRng::seed_from_u64(23); + + let gates = + collect_gates(channel.apply(&after_cx(&qubits), &mut NoiseContext::new(), &mut rng)); + + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|gate| gate.gate_type == GateType::X)); + } + + #[test] + fn batched_after_2q_idles_every_distinct_operand() { + let channel = IdleChannel::linear(1.0) + .with_linear_weights(PauliWeights::custom(1.0, 0.0, 0.0)) + .with_idle_after_2q(1.0); + let qubits = [QubitId(0), QubitId(1), QubitId(2), QubitId(3), QubitId(1)]; + let mut rng = PecosRng::seed_from_u64(29); + + let gates = + collect_gates(channel.apply(&after_cx(&qubits), &mut NoiseContext::new(), &mut rng)); + let affected = gates.iter().map(|gate| gate.qubits[0]).collect::>(); + + assert_eq!( + affected, + vec![QubitId(0), QubitId(1), QubitId(2), QubitId(3)] + ); + } + + #[test] + fn after_2q_channel_ignores_single_qubit_gates() { + let channel = IdleChannel::linear(1.0).with_idle_after_2q(1.0); + let qubits = [QubitId(0)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::H, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + + assert!(!channel.responds_to(&event)); + let mut actual_rng = PecosRng::seed_from_u64(31); + assert!( + channel + .apply(&event, &mut NoiseContext::new(), &mut actual_rng) + .is_none() + ); + let mut expected_rng = PecosRng::seed_from_u64(31); + assert_eq!(actual_rng.random::(), expected_rng.random::()); + } + + #[test] + fn zero_duration_and_zero_rates_produce_nothing_without_rng_draws() { + let qubits = [QubitId(0), QubitId(1)]; + + let zero_duration = IdleChannel::linear(1.0); + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: TimeUnits::ZERO, + }; + assert!(!zero_duration.responds_to(&event)); + let mut actual_rng = PecosRng::seed_from_u64(31); + assert!( + zero_duration + .apply(&event, &mut NoiseContext::new(), &mut actual_rng) + .is_none() + ); + let mut expected_rng = PecosRng::seed_from_u64(31); + assert_eq!(actual_rng.random::(), expected_rng.random::()); + + let zero_after_2q_duration = IdleChannel::linear(1.0).with_idle_after_2q(0.0); + let event = after_cx(&qubits); + assert!(!zero_after_2q_duration.responds_to(&event)); + let mut actual_rng = PecosRng::seed_from_u64(37); + assert!( + zero_after_2q_duration + .apply(&event, &mut NoiseContext::new(), &mut actual_rng) + .is_none() + ); + let mut expected_rng = PecosRng::seed_from_u64(37); + assert_eq!(actual_rng.random::(), expected_rng.random::()); + + let zero_rates = IdleChannel::default().with_idle_after_2q(10.0); + let event = after_cx(&qubits); + assert!(!zero_rates.responds_to(&event)); + let mut actual_rng = PecosRng::seed_from_u64(41); + assert!( + zero_rates + .apply(&event, &mut NoiseContext::new(), &mut actual_rng) + .is_none() + ); + let mut expected_rng = PecosRng::seed_from_u64(41); + assert_eq!(actual_rng.random::(), expected_rng.random::()); + } + + #[test] + fn after_2q_noise_reproduces_exactly_for_the_same_seed() { + let channel = IdleChannel::linear(0.4) + .with_linear_depolarizing() + .with_idle_after_2q(2.0); + let qubits = std::array::from_fn::<_, 16, _>(QubitId); + + let sample = || { + let mut rng = PecosRng::seed_from_u64(43); + collect_gates(channel.apply(&after_cx(&qubits), &mut NoiseContext::new(), &mut rng)) + }; + + assert_eq!(sample(), sample()); + } + + #[test] + fn incoherent_quadratic_probability_is_exact_twirl_of_coherent_angle() { + let theta = 1.0; + let incoherent = IdleChannel { + quadratic_rate: theta, + coherent_to_incoherent_factor: 1.0, + ..Default::default() + }; + let probability = incoherent.quadratic_probability(1.0); + assert!((probability - 0.229_848_847_065_930_15).abs() < 1e-15); + + let coherent = IdleChannel { + coherent_dephasing: true, + ..incoherent + }; + let qubits = [QubitId(0)]; + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: TimeUnits::new(1), + }; + let mut rng = PecosRng::seed_from_u64(47); + let gates = collect_gates(coherent.apply(&event, &mut NoiseContext::new(), &mut rng)); + + assert_eq!(gates.len(), 1); + assert_eq!(gates[0].gate_type, GateType::RZ); + assert!((gates[0].angles[0].to_radians() - theta).abs() < 1e-15); + } } diff --git a/exp/pecos-neo/src/noise/two_qubit.rs b/exp/pecos-neo/src/noise/two_qubit.rs index 857964ec1..513347305 100644 --- a/exp/pecos-neo/src/noise/two_qubit.rs +++ b/exp/pecos-neo/src/noise/two_qubit.rs @@ -282,16 +282,9 @@ pub struct TwoQubitChannel { /// Seepage probability for leaked qubits. pub seepage_probability: f64, - /// Idle noise rate applied after two-qubit gates. - /// - /// If non-zero, applies stochastic Z errors to involved qubits - /// after the gate (for memory sweeping). - pub idle_rate: f64, - // Precomputed probability thresholds for fast sampling seepage_threshold: u64, emission_threshold: u64, - idle_threshold: u64, } impl Default for TwoQubitChannel { @@ -303,10 +296,8 @@ impl Default for TwoQubitChannel { emission_ratio: 0.0, emission_weights: TwoQubitEmissionWeights::uniform_pauli(), seepage_probability: 0.0, - idle_rate: 0.0, seepage_threshold: 0, emission_threshold: 0, - idle_threshold: 0, } } } @@ -316,7 +307,6 @@ impl TwoQubitChannel { /// /// Precomputes probability thresholds for faster sampling. #[must_use] - #[allow(clippy::too_many_arguments)] pub fn new( error_probability: f64, angle_scaling: AngleScaling, @@ -324,7 +314,6 @@ impl TwoQubitChannel { emission_ratio: f64, emission_weights: TwoQubitEmissionWeights, seepage_probability: f64, - idle_rate: f64, ) -> Self { Self { error_probability, @@ -333,10 +322,8 @@ impl TwoQubitChannel { emission_ratio, emission_weights, seepage_probability, - idle_rate, seepage_threshold: PecosRng::probability_threshold(seepage_probability), emission_threshold: PecosRng::probability_threshold(emission_ratio), - idle_threshold: PecosRng::probability_threshold(idle_rate), } } @@ -393,16 +380,6 @@ impl TwoQubitChannel { self } - /// Set the idle noise rate applied after two-qubit gates. - /// - /// This models memory errors (T1/T2) that occur during the gate. - #[must_use] - pub fn with_idle_rate(mut self, rate: f64) -> Self { - self.idle_rate = rate; - self.idle_threshold = PecosRng::probability_threshold(rate); - self - } - /// Scale the error probability by a factor. /// /// This multiplies the current error probability by `scale`. @@ -661,19 +638,6 @@ impl TwoQubitChannel { response = response.combine(NoiseResponse::MarkUnleaked(unleaked)); } - // Apply idle noise after the gate (memory sweeping, using precomputed threshold) - if self.idle_rate > 0.0 { - let mut idle_gates = SmallVec::new(); - for &qubit in &[qubit0, qubit1] { - if !ctx.is_leaked(qubit) && rng.check_probability(self.idle_threshold) { - idle_gates.push(GateCommand::new(GateType::Z, smallvec::smallvec![qubit])); - } - } - if !idle_gates.is_empty() { - response = response.combine(NoiseResponse::inject_gates(idle_gates)); - } - } - response } } @@ -681,8 +645,19 @@ impl TwoQubitChannel { #[cfg(test)] mod tests { use super::*; + use crate::noise::{ComposableNoiseModel, IdleChannel, PauliWeights}; use pecos_core::QubitId; + fn collect_gates(response: NoiseResponse) -> Vec { + match response { + NoiseResponse::InjectGates(gates) => (*gates).into_vec(), + NoiseResponse::Multiple(responses) => { + responses.into_iter().flat_map(collect_gates).collect() + } + _ => Vec::new(), + } + } + #[test] fn test_depolarizing_channel() { let channel = TwoQubitChannel::depolarizing(1.0); @@ -722,6 +697,71 @@ mod tests { assert!(!channel.responds_to(&event)); } + #[test] + fn zero_error_probability_keeps_both_dispatch_guards() { + let channel = TwoQubitChannel::depolarizing(0.0); + let qubits = [QubitId(0), QubitId(1)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + + assert!(!channel.responds_to(&event)); + let mut rng = PecosRng::seed_from_u64(5); + assert!( + channel + .try_apply(&event, &mut NoiseContext::new(), &mut rng) + .is_none() + ); + } + + #[test] + fn after_2q_idle_is_independent_of_the_gate_pauli_sample() { + let mut weights = [0.0; 15]; + weights[14] = 1.0; + let two_qubit = TwoQubitChannel::depolarizing(0.5) + .with_pauli_weights(TwoQubitPauliWeights::custom(weights)); + let idle = IdleChannel::linear(1.0) + .with_linear_weights(PauliWeights::custom(1.0, 0.0, 0.0)) + .with_idle_after_2q(1.0); + let qubits = [QubitId(0), QubitId(1)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + let mut model = ComposableNoiseModel::new() + .add_channel(two_qubit) + .add_channel(idle); + let mut saw_pauli_error = false; + let mut saw_no_pauli_error = false; + + for seed in 0..64 { + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(seed))); + let x_count = gates + .iter() + .filter(|gate| gate.gate_type == GateType::X) + .count(); + let z_count = gates + .iter() + .filter(|gate| gate.gate_type == GateType::Z) + .count(); + + assert_eq!(x_count, 2, "idle missing for seed {seed}"); + match z_count { + 0 => saw_no_pauli_error = true, + 2 => saw_pauli_error = true, + _ => panic!("unexpected two-qubit Pauli response for seed {seed}: {gates:?}"), + } + } + + assert!(saw_pauli_error); + assert!(saw_no_pauli_error); + } + #[test] fn test_angle_scaling() { let linear = AngleScaling::linear(); From 514bf373a5c43033a069e0f9091c21a13240e882 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 4 Aug 2026 16:09:51 -0600 Subject: [PATCH 34/62] Give the workflow example equivalent simulator idle noise with the sine-law unit conversion --- docs/workflows/guppy-dem-decoding.md | 41 +++++++++++++++++++++------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index dbd9ce089..a09e79a37 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -205,21 +205,42 @@ under a noisy simulator and score those shots against the same DEM. the same (detector events, observable flips) pairs a DEM sample carries, so either source can feed the decoders. -The gate noise below mirrors stage 3 exactly, so idle is the one remaining -difference: the DEM carries the idle families configured there, while the -default Selene runtime emits no idle gates for the simulator to attach idle -noise to. The simulated numbers are therefore the same experiment without the -idle contribution, not an independent estimate of the same quantity. - -The simulator's noise builder and `NoiseParameters` both name each setter after -the field it sets, so the same rate carries the same spelling on either side. +The gate noise below mirrors stage 3, including the idle families. The +simulator does not need the runtime to emit idle gates: `with_idle_after_2q` +adds an idle site on each two-qubit gate operand, the same placement the DEM +pass uses. + +The two sides express the idle families in different units, so the sine-law +rate has to be converted rather than copied. `NoiseParameters` takes it in +radians per time unit, while the simulator takes cycles per time unit and folds +in `coherent_to_incoherent_factor / 2`. Dividing by `factor / 2 * 2 * pi` -- +that is, `1.5 * pi` at the default factor -- makes the two agree. The linear +family needs no conversion, and its model dictionary is a normalized +distribution on both sides. ```python +import math + from pecos import general_noise, selene_engine, sim, stabilizer -# The same gate noise the DEM was built with, so only the idle treatment differs. -noise = general_noise().with_p1(0.002).with_p2(0.02).with_p_meas(0.02).with_p_prep(0.02) +# The same gate and idle noise the DEM was built with. +noise = ( + general_noise() + .with_p1(0.002) + .with_p2(0.02) + .with_p_meas(0.02) + .with_p_prep(0.02) + .with_p_idle_linear_rate(0.01) + .with_p_idle_linear_model({"X": 0.25, "Y": 0.25, "Z": 0.5}) + .with_p_idle_coherent(False) + .with_p_idle_coherent_to_incoherent_factor(1.5) + .with_p_idle_quadratic_rate(0.03 / (1.5 * math.pi)) + .with_idle_after_2q(1.0) +) + +# The conversion above reproduces the DEM's sine-law probability exactly. +assert math.isclose(math.sin(0.03 / (1.5 * math.pi) * 1.5 * math.pi) ** 2, math.sin(0.03) ** 2) results = sim(rep_code_memory).classical(selene_engine()).quantum(stabilizer()).qubits(7).noise(noise).seed(42).run(500) From 11a043c639cf494e7f1e6bd318327c92fca97960 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 4 Aug 2026 17:00:15 -0600 Subject: [PATCH 35/62] Add DEM-shaped idle family setters to the engines noise builder --- crates/pecos-engines/src/noise/general.rs | 336 +++++++++++++++++- .../src/noise/general/builder.rs | 126 ++++++- .../src/noise/general/default.rs | 2 + python/pecos-rslib/src/engine_builders.rs | 74 +++- .../src/phir_classical_interpreter.rs | 2 +- python/pecos-rslib/src/sim.rs | 14 +- .../pecos/test_noise_builder_setter_names.py | 79 +++- 7 files changed, 616 insertions(+), 17 deletions(-) diff --git a/crates/pecos-engines/src/noise/general.rs b/crates/pecos-engines/src/noise/general.rs index eb5cae3ce..d45783b0a 100644 --- a/crates/pecos-engines/src/noise/general.rs +++ b/crates/pecos-engines/src/noise/general.rs @@ -94,7 +94,7 @@ use pecos_core::errors::PecosError; use pecos_core::{Angle64, QubitId}; use pecos_random::PecosRng; use std::any::Any; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; /// General noise model with parameterized error channels. /// @@ -148,6 +148,12 @@ pub struct GeneralNoiseModel { /// false it will apply Z to each qubit quadratic dependency on time p_idle_quadratic_rate: f64, + /// DEM-style stochastic sine-squared idle rate in radians per time unit. + p_idle_sin_squared_rate: f64, + + /// Unnormalized per-axis relative multipliers for the sine-squared idle family. + p_idle_sin_squared_model: BTreeMap, + /// Scaling factor to convert coherent dephasing rates to incoherent rates /// /// When using incoherent (stochastic) dephasing, this factor adjusts the dephasing rate. This @@ -286,9 +292,9 @@ pub struct GeneralNoiseModel { /// /// A value of `0.0` disables these sites. For a nonzero duration, the sites receive the same /// configured idle mechanisms as a real [`GateType::Idle`] operation: linear stochastic noise - /// from `p_idle_linear_rate` and `p_idle_linear_model`, plus quadratic dephasing from - /// `p_idle_quadratic_rate` honoring `p_idle_coherent`. The duration is not itself an error - /// probability. + /// from `p_idle_linear_rate` and `p_idle_linear_model`, quadratic dephasing from + /// `p_idle_quadratic_rate` honoring `p_idle_coherent`, and the independent per-axis + /// sine-squared family. The duration is not itself an error probability. idle_after_2q: f64, /// Probability of flipping a 0 measurement to 1 @@ -848,6 +854,10 @@ impl GeneralNoiseModel { if quadratic_rate.abs() > f64::EPSILON { self.apply_idle_quadratic_dephasing(quadratic_rate, duration, qubits, builder); } + + if self.p_idle_sin_squared_rate > f64::EPSILON && duration.abs() > f64::EPSILON { + self.apply_idle_sin_squared(duration, qubits, builder); + } } /// Assuming a general single-qubit stochastic noise for idling that depends on some rate and @@ -923,6 +933,58 @@ impl GeneralNoiseModel { } } + /// Apply the DEM-style stochastic sine-squared family independently per axis. + fn apply_idle_sin_squared( + &mut self, + duration: f64, + qubits: &[usize], + builder: &mut ByteMessageBuilder, + ) { + for axis in ["X", "Y", "Z", "L"] { + let Some(multiplier) = self.p_idle_sin_squared_model.get(axis).copied() else { + continue; + }; + let probability = + Self::sin_squared_probability(self.p_idle_sin_squared_rate, multiplier, duration); + if probability <= f64::EPSILON { + continue; + } + + let affected = qubits + .iter() + .copied() + .filter(|qubit| !self.is_leaked(*qubit) && self.rng.occurs(probability)) + .collect::>(); + if affected.is_empty() { + continue; + } + + match axis { + "X" => { + builder.x(&affected); + } + "Y" => { + builder.y(&affected); + } + "Z" => { + builder.z(&affected); + } + "L" => { + for qubit in affected { + if let Some(gate) = self.leak(qubit) { + builder.add_gate_command(&gate); + } + } + } + _ => unreachable!("sine-family model was validated by the builder"), + } + } + } + + fn sin_squared_probability(rate: f64, multiplier: f64, duration: f64) -> f64 { + (rate * multiplier * duration).sin().powi(2) + } + /// Apply preparation (initialization) noise /// /// State prep noise model: @@ -1512,6 +1574,8 @@ mod tests { (model.p_meas_1 - 0.01).abs() < f64::EPSILON, "Default p_meas_1 should be 0.01" ); + assert!(model.p_idle_sin_squared_rate.abs() < f64::EPSILON); + assert!(model.p_idle_sin_squared_model.is_empty()); assert!( (model.p1 - 0.001).abs() < f64::EPSILON, "Default p1 should be 0.001" @@ -2899,6 +2963,270 @@ mod tests { assert_eq!(first, second); } + #[test] + fn dem_sine_and_legacy_quadratic_have_identical_z_probability() { + let sine_rate = 0.03; + let duration = 10.0; + let expected_probability = 0.087_332_192_545_160_84; + let legacy_rate = sine_rate / (1.5 * std::f64::consts::PI); + let z_model = BTreeMap::from([("Z".to_string(), 1.0)]); + + let mut sine = GeneralNoiseModel::builder() + .with_seed(424) + .with_p_idle_linear_rate(0.0) + .with_p_idle_sin_squared(sine_rate, &z_model) + .build(); + let mut legacy = GeneralNoiseModel::builder() + .with_seed(424) + .with_p_idle_linear_rate(0.0) + .with_p_idle_quadratic_rate(legacy_rate) + .build(); + + assert!((sine.p_idle_sin_squared_rate - sine_rate).abs() < f64::EPSILON); + assert!((legacy.p_idle_quadratic_rate - sine_rate).abs() < f64::EPSILON); + assert!( + (GeneralNoiseModel::sin_squared_probability(sine_rate, 1.0, duration) + - expected_probability) + .abs() + < f64::EPSILON + ); + + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.idle(duration, &[0]); + let input = input_builder.build(); + let sine_outputs = (0..256) + .map(|_| sine.apply_noise_on_start(&input).unwrap().into_bytes()) + .collect::>(); + let legacy_outputs = (0..256) + .map(|_| legacy.apply_noise_on_start(&input).unwrap().into_bytes()) + .collect::>(); + + assert_eq!(sine_outputs, legacy_outputs); + assert!(sine_outputs.iter().any(|output| output.len() > 16)); + } + + #[test] + fn x_weighted_sine_model_emits_x_not_z() { + let x_model = BTreeMap::from([("X".to_string(), 1.0)]); + let mut model = GeneralNoiseModel::builder() + .with_p_idle_linear_rate(0.0) + .with_p_idle_sin_squared(std::f64::consts::FRAC_PI_2, &x_model) + .build(); + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.idle(1.0, &[0]); + + let gates = model + .apply_noise_on_start(&input_builder.build()) + .unwrap() + .quantum_ops() + .unwrap(); + assert_eq!(gates.len(), 1); + assert_eq!(gates[0].gate_type, GateType::X); + } + + #[test] + fn sine_model_axes_are_independent_unnormalized_multipliers() { + let model_map = BTreeMap::from([ + ("X".to_string(), 1.0), + ("Y".to_string(), 1.0), + ("Z".to_string(), 1.0), + ("L".to_string(), 1.0), + ]); + let mut model = GeneralNoiseModel::builder() + .with_p_idle_linear_rate(0.0) + .with_p_idle_sin_squared(std::f64::consts::FRAC_PI_2, &model_map) + .build(); + + assert_eq!(model.p_idle_sin_squared_model, model_map); + for multiplier in model.p_idle_sin_squared_model.values() { + assert!((*multiplier - 1.0).abs() < f64::EPSILON); + } + + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.idle(1.0, &[0]); + let gate_types = model + .apply_noise_on_start(&input_builder.build()) + .unwrap() + .quantum_ops() + .unwrap() + .into_iter() + .map(|gate| gate.gate_type) + .collect::>(); + assert_eq!( + gate_types, + vec![GateType::X, GateType::Y, GateType::Z, GateType::PZ] + ); + } + + #[test] + fn linear_family_rejects_unnormalized_model() { + let model = BTreeMap::from([("X".to_string(), 1.0), ("Z".to_string(), 1.0)]); + let panic = std::panic::catch_unwind(|| { + let _ = GeneralNoiseModel::builder().with_p_idle_linear(0.1, &model); + }); + assert!( + panic.is_err(), + "an unnormalized linear model must be rejected" + ); + } + + #[test] + fn sine_family_rejects_invalid_rates_axes_and_multipliers() { + let cases = [ + (f64::INFINITY, BTreeMap::from([("X".to_string(), 1.0)])), + (0.1, BTreeMap::from([("A".to_string(), 1.0)])), + (0.1, BTreeMap::from([("X".to_string(), -1.0)])), + ]; + for (rate, model) in cases { + assert!( + std::panic::catch_unwind(|| { + let _ = GeneralNoiseModel::builder().with_p_idle_sin_squared(rate, &model); + }) + .is_err(), + "invalid sine rate/model must be rejected: rate={rate}, model={model:?}" + ); + } + } + + #[test] + fn sine_family_conflicts_with_both_legacy_quadratic_spellings() { + let z_model = BTreeMap::from([("Z".to_string(), 1.0)]); + let builders = [ + GeneralNoiseModel::builder() + .with_p_idle_quadratic_rate(0.1) + .with_p_idle_sin_squared(0.1, &z_model), + GeneralNoiseModel::builder() + .with_average_p_idle_quadratic_rate(0.1) + .with_p_idle_sin_squared(0.1, &z_model), + ]; + for builder in builders { + let error = builder.validate_configuration().unwrap_err(); + assert!(error.contains("with_p_idle_quadratic_rate")); + assert!(error.contains("with_average_p_idle_quadratic_rate")); + assert!(error.contains("radians per time unit")); + assert!(error.contains("cycles per time unit")); + assert!( + std::panic::catch_unwind(|| builder.build()).is_err(), + "the conflict must fail when the Rust model is built" + ); + } + } + + #[test] + fn sine_family_conflicts_with_coherent_legacy_path() { + let z_model = BTreeMap::from([("Z".to_string(), 1.0)]); + let builder = GeneralNoiseModel::builder() + .with_p_idle_sin_squared(0.1, &z_model) + .with_p_idle_coherent(true); + let error = builder.validate_configuration().unwrap_err(); + assert!(error.contains("with_p_idle_sin_squared")); + assert!(error.contains("with_p_idle_coherent(true)")); + assert!(error.contains("stochastic by definition")); + assert!( + std::panic::catch_unwind(|| builder.build()).is_err(), + "the conflict must fail when the Rust model is built" + ); + } + + #[test] + fn zero_sine_rate_and_zero_idle_duration_emit_nothing() { + assert!(GeneralNoiseModel::sin_squared_probability(0.0, 1.0, 1.0) < f64::EPSILON); + assert!( + GeneralNoiseModel::sin_squared_probability(std::f64::consts::FRAC_PI_2, 1.0, 0.0) + < f64::EPSILON + ); + let x_model = BTreeMap::from([("X".to_string(), 1.0)]); + let mut zero_rate = GeneralNoiseModel::builder() + .with_p_idle_linear_rate(0.0) + .with_p_idle_sin_squared(0.0, &x_model) + .build(); + let mut nonzero_rate = GeneralNoiseModel::builder() + .with_p_idle_linear_rate(0.0) + .with_p_idle_sin_squared(std::f64::consts::FRAC_PI_2, &x_model) + .build(); + let mut duration_one = ByteMessage::quantum_operations_builder(); + duration_one.idle(1.0, &[0]); + let mut duration_zero = ByteMessage::quantum_operations_builder(); + duration_zero.idle(0.0, &[0]); + + assert!( + zero_rate + .apply_noise_on_start(&duration_one.build()) + .unwrap() + .quantum_ops() + .unwrap() + .is_empty() + ); + assert!( + nonzero_rate + .apply_noise_on_start(&duration_zero.build()) + .unwrap() + .quantum_ops() + .unwrap() + .is_empty() + ); + } + + #[test] + fn sine_family_is_deterministic_for_same_seed() { + let sine_model = BTreeMap::from([ + ("X".to_string(), 0.5), + ("Y".to_string(), 0.75), + ("Z".to_string(), 1.0), + ]); + let make_model = || { + GeneralNoiseModel::builder() + .with_seed(424) + .with_p_idle_linear_rate(0.0) + .with_p_idle_sin_squared(0.6, &sine_model) + .build() + }; + let mut first = make_model(); + let mut second = make_model(); + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.idle(0.7, &[0, 1, 2, 3]); + let input = input_builder.build(); + let first_outputs = (0..128) + .map(|_| first.apply_noise_on_start(&input).unwrap().into_bytes()) + .collect::>(); + let second_outputs = (0..128) + .map(|_| second.apply_noise_on_start(&input).unwrap().into_bytes()) + .collect::>(); + + assert_eq!(first_outputs, second_outputs); + assert!(first_outputs.iter().any(|output| output.len() > 16)); + } + + #[test] + fn legacy_idle_setters_keep_their_pre_change_bytes() { + let mut input_builder = ByteMessage::quantum_operations_builder(); + for _ in 0..8 { + input_builder.idle(0.75, &[0, 1, 2, 3]); + } + let input = input_builder.build(); + let mut model = GeneralNoiseModel::builder() + .with_seed(424) + .with_p_idle_linear_rate(0.35) + .with_p_idle_quadratic_rate(0.07) + .with_p_idle_coherent(false) + .build(); + assert!( + (model.p_idle_quadratic_rate - 0.07 * 1.5 * std::f64::consts::PI).abs() < f64::EPSILON + ); + + let output = model.apply_noise_on_start(&input).unwrap().into_bytes(); + let expected = vec![ + 83, 67, 69, 80, 1, 0, 0, 0, 10, 0, 0, 0, 176, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 3, 1, + 0, 0, 1, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 1, 1, 0, 0, 3, 0, 0, 0, 10, 0, 0, 0, 8, 0, + 0, 0, 2, 1, 0, 0, 2, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 3, 0, 0, 0, 10, 0, + 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 1, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 1, 0, 0, + 0, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 3, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 1, 1, 0, + 0, 0, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 1, 1, 0, 0, 2, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, + 0, 2, 1, 0, 0, 1, 0, 0, 0, + ]; + assert_eq!(output, expected); + } + #[test] fn test_p_idle_coherent() { // Create a circuit builder diff --git a/crates/pecos-engines/src/noise/general/builder.rs b/crates/pecos-engines/src/noise/general/builder.rs index 18821a04f..fbdf0c9a9 100644 --- a/crates/pecos-engines/src/noise/general/builder.rs +++ b/crates/pecos-engines/src/noise/general/builder.rs @@ -40,6 +40,7 @@ pub struct GeneralNoiseModelBuilder { p_idle_linear_rate: Option, p_idle_linear_model: Option, p_idle_quadratic_rate: Option, + p_idle_sin_squared: Option<(f64, BTreeMap)>, p_idle_coherent_to_incoherent_factor: Option, idle_scale: Option, // prep noise @@ -100,6 +101,7 @@ impl GeneralNoiseModelBuilder { p_idle_linear_rate: None, p_idle_linear_model: None, p_idle_quadratic_rate: None, + p_idle_sin_squared: None, p_idle_coherent: None, p_idle_coherent_to_incoherent_factor: None, idle_scale: None, @@ -148,6 +150,9 @@ impl GeneralNoiseModelBuilder { /// Panics if any probabilities are not set or are not between 0 and 1. #[must_use] pub fn build(mut self) -> GeneralNoiseModel { + self.validate_configuration() + .unwrap_or_else(|message| panic!("{message}")); + // Start with the default noise model as a base let mut model = GeneralNoiseModel::default(); @@ -186,6 +191,11 @@ impl GeneralNoiseModelBuilder { model.p_idle_quadratic_rate = p_idle_quadratic_rate; } + if let Some((rate, sine_model)) = self.p_idle_sin_squared.clone() { + model.p_idle_sin_squared_rate = rate; + model.p_idle_sin_squared_model = sine_model; + } + if let Some(factor) = self.p_idle_coherent_to_incoherent_factor { model.p_idle_coherent_to_incoherent_factor = factor; } @@ -387,6 +397,35 @@ impl GeneralNoiseModelBuilder { self } + /// Set the DEM-style linear idle-noise family. + /// + /// `rate` is the total event rate per time unit. For an idle of duration `d`, one event is + /// sampled with probability `rate * d`, then its X, Y, Z, or leakage axis is drawn from + /// `model`. The model must therefore be a normalized distribution: this linear family splits + /// one total rate across its axes. This is exactly the pairing convenience + /// `with_p_idle_linear_rate(rate).with_p_idle_linear_model(model)`. + /// + /// In contrast, [`Self::with_p_idle_sin_squared`] takes radians per time unit and an + /// unnormalized model because sine laws do not add linearly: each axis carries its own + /// independent rate. That setter applies no `2*pi` conversion and no + /// `coherent_to_incoherent_factor`, unlike [`Self::with_p_idle_quadratic_rate`]. With neutral + /// global and idle scales, `with_p_idle_quadratic_rate(r)` equals + /// `with_p_idle_sin_squared(r * factor/2 * 2*pi, {"Z": 1.0})`, or `r * 1.5 * pi` at the + /// default factor. + /// + /// Engines idle noise is on by default (`p_idle_linear_rate = 0.001`). When translating a DEM + /// configuration, explicitly set every idle family that was not requested to zero. + /// + /// The linear sampling structure deliberately remains different from the DEM: engines emits + /// at most one linear event followed by a categorical axis choice, while the DEM emits + /// independent per-axis mechanisms. The difference is second order in the rates; this setter + /// aligns the units and axis alphabet, not that sampling structure. + #[must_use] + pub fn with_p_idle_linear(self, rate: f64, model: &BTreeMap) -> Self { + self.with_p_idle_linear_rate(rate) + .with_p_idle_linear_model(model) + } + /// Set the idling noise error rate for the quadratic term #[must_use] pub fn with_p_idle_quadratic_rate(mut self, rate: f64) -> Self { @@ -402,6 +441,43 @@ impl GeneralNoiseModelBuilder { self } + /// Set the DEM-style stochastic sine-squared idle-noise family. + /// + /// `rate` is in radians per time unit. No `2*pi` conversion and no + /// `coherent_to_incoherent_factor` is applied, unlike + /// [`Self::with_p_idle_quadratic_rate`]. For each axis P with multiplier `n_P` and an idle of + /// duration `d`, engines independently samples `P(P) = sin^2(rate * n_P * d)`. + /// + /// The model accepts X, Y, Z, and L and is intentionally unnormalized: sine laws do not add + /// linearly, so every axis carries its own independent rate. By comparison, + /// [`Self::with_p_idle_linear`] requires a normalized distribution because its one total + /// linear event rate is split across axes. + /// + /// With neutral global and idle scales, `with_p_idle_quadratic_rate(r)` equals + /// `with_p_idle_sin_squared(r * factor/2 * 2*pi, {"Z": 1.0})`, or `r * 1.5 * pi` at the + /// default factor. The legacy spelling is in cycles per time unit and also folds the factor + /// into its runtime rate; this setter is the radians-per-time-unit spelling. + /// + /// Engines idle noise is on by default (`p_idle_linear_rate = 0.001`). When translating a DEM + /// configuration, explicitly set every idle family that was not requested to zero. + /// + /// The linear sampling structure deliberately remains different from the DEM: engines emits + /// at most one linear event followed by a categorical axis choice, while the DEM emits + /// independent per-axis mechanisms. The difference is second order in the rates; this setter + /// aligns the units and axis alphabet, not that sampling structure. + /// + /// # Panics + /// + /// Panics if `rate` or a multiplier is not finite and non-negative, or if `model` contains a + /// key other than X, Y, Z, or L. + #[must_use] + pub fn with_p_idle_sin_squared(mut self, rate: f64, model: &BTreeMap) -> Self { + let rate = Self::validate_finite_non_negative(rate, "sine-squared idling rate"); + Self::validate_sine_model(model); + self.p_idle_sin_squared = Some((rate, model.clone())); + self + } + /// Set the coherent-to-incoherent conversion factor /// /// # Parameters @@ -643,8 +719,8 @@ impl GeneralNoiseModelBuilder { /// /// A duration of `0.0` disables these sites. Nonzero sites receive all configured idle /// mechanisms over the given duration: linear stochastic noise from `p_idle_linear_rate` and - /// `p_idle_linear_model`, and quadratic dephasing from `p_idle_quadratic_rate`, honoring - /// `p_idle_coherent`. + /// `p_idle_linear_model`, quadratic dephasing from `p_idle_quadratic_rate` honoring + /// `p_idle_coherent`, and the independent per-axis sine-squared family. /// /// Anyone who previously wrote `with_p2_idle(0.01)` and no linear rate now gets no after-2q /// idle noise; the equivalent is @@ -769,6 +845,51 @@ impl GeneralNoiseModelBuilder { value } + /// Validate that a value is finite and non-negative. + fn validate_finite_non_negative(value: f64, name: &str) -> f64 { + assert!( + value.is_finite() && value >= 0.0, + "{name} must be finite and non-negative, got {value}" + ); + value + } + + /// Validate an unnormalized sine-family multiplier model. + fn validate_sine_model(model: &BTreeMap) { + for (axis, multiplier) in model { + assert!( + matches!(axis.as_str(), "X" | "Y" | "Z" | "L"), + "p_idle_sin_squared model has invalid key '{axis}'; expected X, Y, Z, or L" + ); + Self::validate_finite_non_negative( + *multiplier, + &format!("p_idle_sin_squared multiplier for '{axis}'"), + ); + } + } + + /// Validate combinations whose interpretation would otherwise depend on silent precedence. + /// + /// # Errors + /// + /// Returns a description of the conflicting spellings and their incompatible semantics. + pub fn validate_configuration(&self) -> Result<(), &'static str> { + if self.p_idle_sin_squared.is_some() && self.p_idle_quadratic_rate.is_some() { + return Err("with_p_idle_sin_squared cannot be combined with \ + with_p_idle_quadratic_rate/with_average_p_idle_quadratic_rate: \ + with_p_idle_sin_squared uses radians per time unit, while the legacy quadratic \ + spellings use cycles per time unit and apply coherent_to_incoherent_factor"); + } + if self.p_idle_sin_squared.is_some() && self.p_idle_coherent == Some(true) { + return Err( + "with_p_idle_sin_squared cannot be combined with with_p_idle_coherent(true): \ + with_p_idle_sin_squared is stochastic by definition, while \ + with_p_idle_coherent(true) selects the legacy coherent path", + ); + } + Ok(()) + } + /// Validate that a duration is finite and non-negative fn validate_duration(duration: f64) -> f64 { assert!( @@ -881,6 +1002,7 @@ impl GeneralNoiseModelBuilder { // Neutral model defaults: unset is fine. let optional_features_off = zero_or_unset(self.p_idle_quadratic_rate) + && self.p_idle_sin_squared.is_none() && zero_or_unset(self.p_prep_crosstalk) && zero_or_unset(self.idle_after_2q) && zero_or_unset(self.p_meas_crosstalk_global) diff --git a/crates/pecos-engines/src/noise/general/default.rs b/crates/pecos-engines/src/noise/general/default.rs index 42f8e1c06..ef0c1e8d2 100644 --- a/crates/pecos-engines/src/noise/general/default.rs +++ b/crates/pecos-engines/src/noise/general/default.rs @@ -85,6 +85,8 @@ impl Default for GeneralNoiseModel { p_idle_linear_rate: 0.001, p_idle_linear_model: SingleQubitWeightedSampler::new(&p1_pauli_model), p_idle_quadratic_rate: 0.0, + p_idle_sin_squared_rate: 0.0, + p_idle_sin_squared_model: BTreeMap::new(), p_meas_0, p_meas_1, p1: 0.001, diff --git a/python/pecos-rslib/src/engine_builders.rs b/python/pecos-rslib/src/engine_builders.rs index f3298144c..5286cae29 100644 --- a/python/pecos-rslib/src/engine_builders.rs +++ b/python/pecos-rslib/src/engine_builders.rs @@ -877,6 +877,15 @@ pub struct PyGeneralNoiseModelBuilder { pub(crate) inner: GeneralNoiseModelBuilder, } +impl PyGeneralNoiseModelBuilder { + pub(crate) fn validated_inner(&self) -> PyResult { + self.inner + .validate_configuration() + .map_err(|message| pyo3::exceptions::PyValueError::new_err(message.to_string()))?; + Ok(self.inner.clone()) + } +} + #[pymethods] impl PyGeneralNoiseModelBuilder { #[new] @@ -1065,6 +1074,36 @@ impl PyGeneralNoiseModelBuilder { }) } + /// Set the DEM-style linear idle-noise family. + /// + /// ``rate`` is the total event rate per time unit. The X/Y/Z/L ``model`` must be a + /// normalized distribution because this family splits one total linear rate across axes. + /// This is a pairing convenience over ``with_p_idle_linear_rate`` plus + /// ``with_p_idle_linear_model``. + /// + /// By contrast, ``with_p_idle_sin_squared`` uses radians per time unit and unnormalized + /// relative multipliers because sine laws do not add linearly: each axis has its own + /// independent rate. It applies no ``2*pi`` conversion and no + /// ``coherent_to_incoherent_factor``, unlike ``with_p_idle_quadratic_rate``. With neutral + /// global and idle scales, ``with_p_idle_quadratic_rate(r)`` equals + /// ``with_p_idle_sin_squared(r * factor/2 * 2*pi, {"Z": 1.0})``, or ``r * 1.5 * pi`` at the + /// default factor. + /// + /// Engines idle noise is on by default (``p_idle_linear_rate = 0.001``), so translating a DEM + /// configuration requires explicitly zeroing every idle family not requested. Engines also + /// keeps its existing linear sampling structure: one event followed by a categorical axis + /// choice, versus the DEM's independent per-axis mechanisms. The difference is second order + /// in the rates; this setter aligns units and the axis alphabet, not the sampling structure. + fn with_p_idle_linear( + &self, + rate: f64, + model: std::collections::BTreeMap, + ) -> PyResult { + Ok(Self { + inner: self.inner.clone().with_p_idle_linear(rate, &model), + }) + } + /// Set the idling noise error rate for the quadratic term fn with_p_idle_quadratic_rate(&self, rate: f64) -> PyResult { Ok(Self { @@ -1072,6 +1111,37 @@ impl PyGeneralNoiseModelBuilder { }) } + /// Set the DEM-style stochastic sine-squared idle-noise family. + /// + /// ``rate`` is radians per time unit; no ``2*pi`` conversion and no + /// ``coherent_to_incoherent_factor`` is applied, unlike + /// ``with_p_idle_quadratic_rate``. For each X/Y/Z/L axis P, multiplier ``n_P``, and duration + /// ``d``, engines independently samples ``P(P) = sin^2(rate * n_P * d)``. + /// + /// The model is intentionally unnormalized because sine laws do not add linearly: each axis + /// carries its own independent rate. ``with_p_idle_linear`` instead requires a normalized + /// distribution because it splits one total linear rate across axes. + /// + /// With neutral global and idle scales, ``with_p_idle_quadratic_rate(r)`` equals + /// ``with_p_idle_sin_squared(r * factor/2 * 2*pi, {"Z": 1.0})``, or ``r * 1.5 * pi`` at the + /// default factor. Engines idle noise is on by default (``p_idle_linear_rate = 0.001``), so + /// translating a DEM configuration requires explicitly zeroing every idle family not + /// requested. + /// + /// Engines deliberately retains its existing linear sampling structure: one event followed + /// by a categorical axis choice, versus the DEM's independent per-axis mechanisms. The + /// difference is second order in the rates; this setter aligns units and the axis alphabet, + /// not the sampling structure. + fn with_p_idle_sin_squared( + &self, + rate: f64, + model: std::collections::BTreeMap, + ) -> PyResult { + Ok(Self { + inner: self.inner.clone().with_p_idle_sin_squared(rate, &model), + }) + } + /// Set the stochastic model for idling that is linearly dependent on time fn with_p_idle_linear_model( &self, @@ -1220,8 +1290,8 @@ impl PyGeneralNoiseModelBuilder { /// /// A duration of `0.0` disables these sites. Nonzero sites receive all configured idle /// mechanisms over the given duration: linear stochastic noise from `p_idle_linear_rate` and - /// `p_idle_linear_model`, and quadratic dephasing from `p_idle_quadratic_rate`, honoring - /// `p_idle_coherent`. + /// `p_idle_linear_model`, quadratic dephasing from `p_idle_quadratic_rate` honoring + /// `p_idle_coherent`, and the independent per-axis sine-squared family. /// /// Anyone who previously wrote `with_p2_idle(0.01)` and no linear rate now gets no after-2q /// idle noise; the equivalent is diff --git a/python/pecos-rslib/src/phir_classical_interpreter.rs b/python/pecos-rslib/src/phir_classical_interpreter.rs index 7e0fc7201..156896c27 100644 --- a/python/pecos-rslib/src/phir_classical_interpreter.rs +++ b/python/pecos-rslib/src/phir_classical_interpreter.rs @@ -1006,7 +1006,7 @@ fn build_noise_model( return Ok(Box::new(builder.inner.build())); } if let Ok(builder) = obj.extract::() { - return Ok(Box::new(builder.inner.build())); + return Ok(Box::new(builder.validated_inner()?.build())); } if let Ok(builder) = obj.extract::() { return Ok(Box::new(builder.inner.build())); diff --git a/python/pecos-rslib/src/sim.rs b/python/pecos-rslib/src/sim.rs index efe540af8..382c2762c 100644 --- a/python/pecos-rslib/src/sim.rs +++ b/python/pecos-rslib/src/sim.rs @@ -761,7 +761,7 @@ impl PySimBuilder { if let Some(ref noise_py) = builder.noise_builder { sim_builder = if let Ok(general) = noise_py.extract::(py) { - sim_builder.noise(general.inner.clone()) + sim_builder.noise(general.validated_inner()?) } else if let Ok(depolarizing) = noise_py.extract::(py) { @@ -946,7 +946,7 @@ impl PySimBuilder { if let Some(ref noise_py) = builder.noise_builder { sim_builder = Python::attach(|py| -> PyResult<_> { if let Ok(general) = noise_py.extract::(py) { - Ok(sim_builder.noise(general.inner.clone())) + Ok(sim_builder.noise(general.validated_inner()?)) } else if let Ok(depolarizing) = noise_py.extract::(py) { @@ -1122,7 +1122,7 @@ impl PySimBuilder { if let Some(ref noise_py) = builder.noise_builder { sim_builder = Python::attach(|py| -> PyResult<_> { if let Ok(general) = noise_py.extract::(py) { - Ok(sim_builder.noise(general.inner.clone())) + Ok(sim_builder.noise(general.validated_inner()?)) } else if let Ok(depolarizing) = noise_py.extract::(py) { @@ -1288,7 +1288,7 @@ impl PySimBuilder { sim_builder = Python::attach(|py| -> PyResult<_> { if let Ok(general) = noise_py.extract::(py) { - Ok(sim_builder.noise(general.inner.clone())) + Ok(sim_builder.noise(general.validated_inner()?)) } else if let Ok(depolarizing) = noise_py.extract::(py) { @@ -1488,7 +1488,7 @@ impl PySimBuilder { sim_builder = Python::attach(|py| -> PyResult<_> { if let Ok(general) = noise_py.extract::(py) { - Ok(sim_builder.noise(general.inner.clone())) + Ok(sim_builder.noise(general.validated_inner()?)) } else if let Ok(depolarizing) = noise_py.extract::(py) { @@ -1660,7 +1660,7 @@ impl PySimBuilder { sim_builder = Python::attach(|py| -> PyResult<_> { if let Ok(general) = noise_py.extract::(py) { - Ok(sim_builder.noise(general.inner.clone())) + Ok(sim_builder.noise(general.validated_inner()?)) } else if let Ok(depolarizing) = noise_py.extract::(py) { @@ -1873,7 +1873,7 @@ fn apply_noise_to_facade( Python::attach(|py| -> PyResult<_> { if let Ok(general) = noise_py.extract::(py) { - Ok(facade.noise(general.inner.clone())) + Ok(facade.noise(general.validated_inner()?)) } else if let Ok(depolarizing) = noise_py.extract::(py) { Ok(facade.noise(depolarizing.inner.clone())) } else if let Ok(biased) = noise_py.extract::(py) { diff --git a/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py b/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py index d77e7d54b..08b7abf64 100644 --- a/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py +++ b/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py @@ -19,10 +19,12 @@ work through the pyo3 surface, and the old spellings are gone. """ +import math + import pytest from guppylang import guppy from guppylang.std.quantum import measure, qubit -from pecos import sim +from pecos import Qasm, qasm_engine, sim from pecos_rslib import ( biased_depolarizing_noise, depolarizing_noise, @@ -48,6 +50,34 @@ "with_average_p2_probability", ) +_AFTER_2Q_QASM = """ +OPENQASM 2.0; +include "qelib1.inc"; +qreg q[2]; +creg c[2]; +cx q[0], q[1]; +measure q -> c; +""" + + +def _run_after_2q_noise(noise, shots: int = 1, seed: int = 424) -> list[int]: + results = qasm_engine().program(Qasm.from_string(_AFTER_2Q_QASM)).to_sim().noise(noise).seed(seed).run(shots) + return results.to_dict()["c"] + + +def _idle_family_noise(*, sine_rate: float, sine_model: dict[str, float], seed: int = 424): + return ( + general_noise() + .with_seed(seed) + .with_p_prep(0.0) + .with_p1(0.0) + .with_p2(0.0) + .with_p_meas(0.0) + .with_p_idle_linear(0.0, {"Z": 1.0}) + .with_p_idle_sin_squared(sine_rate, sine_model) + .with_idle_after_2q(1.0) + ) + @pytest.mark.parametrize(("factory", "setters"), BUILDER_SETTERS) def test_field_name_setters_are_chainable(factory, setters) -> None: @@ -74,6 +104,53 @@ def test_average_setters_keep_their_conversion() -> None: assert builder.with_average_p1(0.01).with_average_p2(0.02) is not None +def test_idle_family_setters_are_chainable() -> None: + """The structured linear and sine families are present on the pyo3 fluent builder.""" + builder = general_noise().with_p_idle_linear(0.01, {"X": 0.5, "L": 0.5}) + assert builder.with_p_idle_sin_squared(0.02, {"X": 1.0, "Z": 2.0, "L": 0.25}) is not None + + +def test_linear_idle_family_rejects_unnormalized_model() -> None: + """The linear family reuses the normalized weighted-sampler contract.""" + with pytest.raises(BaseException, match=r"total weight 2.*deviates from 1.0"): + general_noise().with_p_idle_linear(0.01, {"X": 1.0, "Z": 1.0}) + + +def test_sine_idle_family_x_axis_reaches_runtime() -> None: + """A certain X sine event after CX flips both measured qubits; a Z-only path would not.""" + noise = _idle_family_noise(sine_rate=math.pi / 2, sine_model={"X": 1.0}) + assert _run_after_2q_noise(noise, 10) == [3] * 10 + + +def test_sine_idle_multipliers_are_not_normalized() -> None: + """X=Z=1 keeps a certain X event at rate pi/2 instead of reducing it to probability 1/2.""" + noise = _idle_family_noise(sine_rate=math.pi / 2, sine_model={"X": 1.0, "Z": 1.0}) + assert _run_after_2q_noise(noise, 32) == [3] * 32 + + +def test_legacy_quadratic_and_sine_family_conflict_at_build() -> None: + """The pyo3 build route names both incompatible rate spellings and their units.""" + noise = general_noise().with_p_idle_quadratic_rate(0.01).with_p_idle_sin_squared(0.02, {"Z": 1.0}) + with pytest.raises(ValueError, match=r"with_p_idle_quadratic_rate.*radians.*cycles"): + _run_after_2q_noise(noise) + + +def test_sine_family_and_coherent_legacy_path_conflict_at_build() -> None: + """The stochastic family cannot silently ignore the legacy coherent switch.""" + noise = general_noise().with_p_idle_sin_squared(0.02, {"Z": 1.0}).with_p_idle_coherent(True) + with pytest.raises(ValueError, match=r"with_p_idle_coherent\(true\).*stochastic by definition"): + _run_after_2q_noise(noise) + + +def test_sine_idle_family_is_deterministic_for_same_seed() -> None: + """The pyo3 surface preserves the Rust model's fixed-seed draw sequence.""" + first = _run_after_2q_noise(_idle_family_noise(sine_rate=0.6, sine_model={"X": 1.0}), 128) + second = _run_after_2q_noise(_idle_family_noise(sine_rate=0.6, sine_model={"X": 1.0}), 128) + assert first == second + assert 0 in first + assert 3 in first + + def test_with_p_meas_actually_configures_measurement_noise() -> None: """A renamed setter still reaches the model: certain measurement flips flip every shot.""" From 7093ab9057bca650c17a8865aec3782c69c0e312 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 4 Aug 2026 17:47:14 -0600 Subject: [PATCH 36/62] Make the engines noise builder noiseless by default and add auto() for the legacy preset --- crates/pecos-engines/src/noise/general.rs | 197 ++++++++++++------ .../src/noise/general/builder.rs | 188 ++++++++++++----- .../src/noise/general/default.rs | 42 ++-- .../tests/general_noise_builder_test.rs | 3 +- .../tests/neo_equivalence_matrix_test.rs | 10 +- crates/pecos/tests/neo_routing_test.rs | 26 +-- docs/workflows/guppy-dem-decoding.md | 12 +- python/pecos-rslib/src/engine_builders.rs | 48 ++++- python/pecos-rslib/tests/test_sim_api.py | 7 +- python/pecos-rslib/tests/test_sim_qasm.py | 4 +- .../test_qasm_sim_comprehensive.py | 6 +- .../integration/test_qasm_sim_defaults.py | 2 +- .../pecos/test_noise_builder_setter_names.py | 62 +++++- 13 files changed, 416 insertions(+), 191 deletions(-) diff --git a/crates/pecos-engines/src/noise/general.rs b/crates/pecos-engines/src/noise/general.rs index d45783b0a..315fe51a8 100644 --- a/crates/pecos-engines/src/noise/general.rs +++ b/crates/pecos-engines/src/noise/general.rs @@ -154,12 +154,10 @@ pub struct GeneralNoiseModel { /// Unnormalized per-axis relative multipliers for the sine-squared idle family. p_idle_sin_squared_model: BTreeMap, - /// Scaling factor to convert coherent dephasing rates to incoherent rates + /// Scaling factor to convert coherent dephasing rates to incoherent rates. /// - /// When using incoherent (stochastic) dephasing, this factor adjusts the dephasing rate. This - /// is a fudge factor used to artificially increase the dephasing rate when modeling the - /// quadratic dephasing stochastically since such modeling does not account for coherent - /// effects. + /// A factor of one gives the exact Pauli twirl of the coherent rotation. Values above one can + /// be used to deliberately inflate stochastic dephasing. /// /// # Panics /// @@ -463,9 +461,9 @@ impl GeneralNoiseModel { /// Create a new noise model with the specified error parameters /// - /// Creates a `GeneralNoiseModel` with the specified error probabilities while using default values - /// for all other parameters. This is a convenience method for cases where you only need to customize - /// the basic error rates. + /// Creates a `GeneralNoiseModel` with the specified error probabilities while using no-effect + /// defaults for all other parameters. This is a convenience method for cases where you only need + /// to customize the basic error rates. /// /// * `p_prep` - Preparation (initialization) error probability /// * `p_meas_0` - Probability of measuring 1 when the state is |0⟩ @@ -474,7 +472,7 @@ impl GeneralNoiseModel { /// * `p2` - Two-qubit gate error probability (average error rate) /// /// For more extensive customization, use the builder pattern with `GeneralNoiseModel::builder()`. - /// For default parameters, use `GeneralNoiseModel::default()`. + /// For a noiseless model, use `GeneralNoiseModel::default()`. /// /// # Example /// ``` @@ -1556,56 +1554,79 @@ mod tests { use crate::byte_message::GateType; use pecos_core::Angle64; + fn assert_float_eq(actual: f64, expected: f64) { + assert!( + (actual - expected).abs() < f64::EPSILON, + "expected {expected}, got {actual}" + ); + } + #[test] fn test_default() { - // Create a noise model with the default settings let model = GeneralNoiseModel::default(); - // Check the default values - assert!( - (model.p_prep - 0.01).abs() < f64::EPSILON, - "Default p_prep should be 0.01" - ); - assert!( - (model.p_meas_0 - 0.01).abs() < f64::EPSILON, - "Default p_meas_0 should be 0.01" - ); - assert!( - (model.p_meas_1 - 0.01).abs() < f64::EPSILON, - "Default p_meas_1 should be 0.01" - ); - assert!(model.p_idle_sin_squared_rate.abs() < f64::EPSILON); + assert_float_eq(model.p_prep, 0.0); + assert_float_eq(model.p_meas_0, 0.0); + assert_float_eq(model.p_meas_1, 0.0); + assert_float_eq(model.p1, 0.0); + assert_float_eq(model.p2, 0.0); + assert_float_eq(model.p_idle_linear_rate, 0.0); + assert_float_eq(model.p_idle_quadratic_rate, 0.0); + assert_float_eq(model.p_idle_sin_squared_rate, 0.0); + assert!(!model.p_idle_coherent); assert!(model.p_idle_sin_squared_model.is_empty()); - assert!( - (model.p1 - 0.001).abs() < f64::EPSILON, - "Default p1 should be 0.001" - ); - assert!( - (model.p2 - 0.01).abs() < f64::EPSILON, - "Default p2 should be 0.01" - ); - assert!( - (model.p1_emission_ratio - 0.5).abs() < f64::EPSILON, - "Default p1_emission_ratio should be 0.5" - ); - assert!( - (model.p_prep_leak_ratio - 0.5).abs() < f64::EPSILON, - "Default p_prep_leak_ratio should be 0.5" - ); - assert!( - (model.p2_emission_ratio - 0.5).abs() < f64::EPSILON, - "Default p2_emission_ratio should be 0.5" - ); - assert!( - (model.p1_seepage_prob - 0.5).abs() < f64::EPSILON, - "Default seepage_prob should be 0.5" + assert_float_eq(model.p1_emission_ratio, 0.0); + assert_float_eq(model.p_prep_leak_ratio, 0.0); + assert_float_eq(model.p2_emission_ratio, 0.0); + assert_float_eq(model.p1_seepage_prob, 0.0); + assert_float_eq(model.p2_seepage_prob, 0.0); + assert_float_eq(model.idle_after_2q, 0.0); + assert_float_eq(model.p_meas_crosstalk_global, 0.0); + assert_float_eq(model.p_meas_crosstalk_local, 0.0); + assert_float_eq(model.p_prep_crosstalk, 0.0); + assert_float_eq(model.p_idle_coherent_to_incoherent_factor, 1.0); + assert_float_eq(model.p2_angle_a, 0.0); + assert_float_eq(model.p2_angle_b, 1.0); + assert_float_eq(model.p2_angle_c, 0.0); + assert_float_eq(model.p2_angle_d, 1.0); + assert_float_eq(model.p2_angle_power, 1.0); + assert_float_eq(model.leakage_scale, 1.0); + assert_eq!( + model.p_meas_crosstalk_model.get_weighted_map(0), + &BTreeMap::from([("0->0".to_string(), 1.0)]) ); - assert!( - (model.p2_seepage_prob - 0.5).abs() < f64::EPSILON, - "Default seepage_prob should be 0.5" + assert_eq!( + model.p_meas_crosstalk_model.get_weighted_map(1), + &BTreeMap::from([("1->1".to_string(), 1.0)]) ); } + #[test] + fn default_model_emits_no_noise_gates_for_full_circuit() { + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.pz(&[0, 1]); + input_builder.h(&[0]); + input_builder.cx(&[(0, 1)]); + input_builder.idle(2.0, &[0, 1]); + input_builder.mz(&[0, 1]); + let input = input_builder.build(); + let expected = input + .quantum_ops() + .unwrap() + .into_iter() + .filter(|gate| gate.gate_type != GateType::Idle) + .collect::>(); + + let mut model = GeneralNoiseModel::default(); + let emitted = model + .apply_noise_on_start(&input) + .unwrap() + .quantum_ops() + .unwrap(); + + assert_eq!(emitted, expected); + } + #[test] fn test_builder() { // Create a noise model with the builder @@ -1664,22 +1685,15 @@ mod tests { assert!((p_prep_leak_ratio - 0.6).abs() < f64::EPSILON); - // Test the builder with no parameters (should use defaults) + // Test the builder with no parameters (should use no-effect defaults) let default_noise = GeneralNoiseModel::builder().build(); let default_ref = default_noise .as_any() .downcast_ref::() .unwrap(); - // Verify a few key default values - assert!( - (default_ref.p1 - 0.001).abs() < 1e-6, - "Default p1 should be 0.001" - ); - assert!( - (default_ref.p2 - 0.01).abs() < 1e-6, - "Default p2 should be 0.01" - ); + assert_float_eq(default_ref.p1, 0.0); + assert_float_eq(default_ref.p2, 0.0); } /// Helper function to invoke a measurement request from the user to the noise @@ -2700,6 +2714,7 @@ mod tests { .with_p2_scale(4.0) .with_prep_scale(5.0) .with_meas_scale(6.0) + .with_prep_leak_ratio(0.5) .with_leakage_scale(0.25) .build(); let noise = model @@ -2716,8 +2731,7 @@ mod tests { let expected_p1 = 0.01 * 3.0 * 2.0 * (3.0 / 2.0); // Base * p1_scale * overall scale * avg->total let expected_p2 = 0.01 * 4.0 * 2.0 * (5.0 / 4.0); // Base * p2_scale * overall scale * avg->total - // Initial value in constructor is 0.5 - // and we scale it by overall scale (2.0) + // The configured ratio is scaled by the overall scale (2.0). let expected_leak_ratio = 0.5 * 2.0; // Base * overall scale, capped at 1.0 println!( @@ -2837,8 +2851,9 @@ mod tests { #[test] fn test_emission_ratio_scaling() { // Test that emission ratios are properly scaled and capped at a maximum of 1.0 - // Default emission ratios are 0.5 let mut model = GeneralNoiseModel::builder() + .with_p1_emission_ratio(0.5) + .with_p2_emission_ratio(0.5) .with_scale(3.0) .with_emission_scale(4.0) .build(); @@ -2847,7 +2862,7 @@ mod tests { .downcast_mut::() .unwrap(); - // Verify both ratios are 0.5 after scaling + // Verify both configured ratios are capped after scaling. // When scaled: 0.5 * 3.0 (scale) * 4.0 (emission_scale) = 6.0 // But capped at 1.0 assert!( @@ -2968,7 +2983,7 @@ mod tests { let sine_rate = 0.03; let duration = 10.0; let expected_probability = 0.087_332_192_545_160_84; - let legacy_rate = sine_rate / (1.5 * std::f64::consts::PI); + let legacy_rate = sine_rate / std::f64::consts::PI; let z_model = BTreeMap::from([("Z".to_string(), 1.0)]); let mut sine = GeneralNoiseModel::builder() @@ -2984,10 +2999,12 @@ mod tests { assert!((sine.p_idle_sin_squared_rate - sine_rate).abs() < f64::EPSILON); assert!((legacy.p_idle_quadratic_rate - sine_rate).abs() < f64::EPSILON); + let coherent_angle = 2.0 * sine_rate * duration; + assert!(((coherent_angle / 2.0).sin().powi(2) - expected_probability).abs() < f64::EPSILON); assert!( (GeneralNoiseModel::sin_squared_probability(sine_rate, 1.0, duration) - - expected_probability) - .abs() + - (coherent_angle / 2.0).sin().powi(2)) + .abs() < f64::EPSILON ); @@ -3005,6 +3022,51 @@ mod tests { assert!(sine_outputs.iter().any(|output| output.len() > 16)); } + #[test] + fn default_angle_scaling_is_identity_for_p2_only_model() { + let model = GeneralNoiseModel::builder().with_p2(0.37).build(); + + assert_float_eq( + model.p2_angle_error_rate(-std::f64::consts::FRAC_PI_3), + 0.37, + ); + assert_float_eq(model.p2_angle_error_rate(0.0), 0.37); + assert_float_eq(model.p2_angle_error_rate(std::f64::consts::FRAC_PI_3), 0.37); + } + + #[test] + fn same_seed_and_configuration_emit_identical_noise() { + let make_model = || { + GeneralNoiseModel::builder() + .with_seed(4_242) + .with_p_prep(0.4) + .with_p1(0.4) + .with_p2(0.4) + .with_p_idle_linear_rate(0.4) + .with_p1_emission_ratio(0.5) + .with_p2_emission_ratio(0.5) + .with_prep_leak_ratio(0.5) + .build() + }; + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.pz(&[0, 1]); + input_builder.h(&[0]); + input_builder.cx(&[(0, 1)]); + input_builder.idle(0.5, &[0, 1]); + let input = input_builder.build(); + let collect = |model: &mut GeneralNoiseModel| { + (0..64) + .map(|_| model.apply_noise_on_start(&input).unwrap().into_bytes()) + .collect::>() + }; + + let first = collect(&mut make_model()); + let second = collect(&mut make_model()); + + assert_eq!(first, second); + assert!(first.iter().any(|output| output.len() > 16)); + } + #[test] fn x_weighted_sine_model_emits_x_not_z() { let x_model = BTreeMap::from([("X".to_string(), 1.0)]); @@ -3207,6 +3269,7 @@ mod tests { let mut model = GeneralNoiseModel::builder() .with_seed(424) .with_p_idle_linear_rate(0.35) + .with_p_idle_coherent_to_incoherent_factor(1.5) .with_p_idle_quadratic_rate(0.07) .with_p_idle_coherent(false) .build(); diff --git a/crates/pecos-engines/src/noise/general/builder.rs b/crates/pecos-engines/src/noise/general/builder.rs index fbdf0c9a9..03d24d203 100644 --- a/crates/pecos-engines/src/noise/general/builder.rs +++ b/crates/pecos-engines/src/noise/general/builder.rs @@ -12,7 +12,7 @@ use std::collections::{BTreeMap, BTreeSet}; /// Layout: /// `(p_prep, p_meas_0, p_meas_1, p1, p2, angle, p1_emission_ratio, p2_emission_ratio)` /// where `angle` is `Some((a, b, c, d, power))` when angle scaling is -/// configured. The emission ratios use the model default (0.5) when unset, and +/// configured. The emission ratios use the model default (zero) when unset, and /// the emission DISTRIBUTION is required to be the default (uniform Pauli) -- /// custom emission models keep the config out of this subset. pub type PauliWithAngleScaling = ( @@ -139,9 +139,43 @@ impl GeneralNoiseModelBuilder { } } - /// Build the general noise model + /// Fill unset parameters with the legacy demonstration preset. + /// + /// This preset reproduces the general noise model's historical defaults. It is intended for + /// demonstrations and is not a calibrated device model. Parameters already set by the caller + /// are preserved, so explicit setters win whether they appear before or after `auto()`. /// - /// TODO: Consider another build with noiseless default + /// The preset uses preparation, measurement, one-qubit, two-qubit, and linear-idle error rates + /// of 0.01, 0.01/0.01, 0.001, 0.01, and 0.001 respectively. Its other effects are: + /// + /// - `p_prep_leak_ratio = 0.5`: half of preparation faults leak the qubit out of the + /// computational subspace. + /// - `p1_emission_ratio = p2_emission_ratio = 0.5`: half of gate errors take the spontaneous- + /// emission branch, which removes the original gate and substitutes a sample from the + /// emission model. The preset's uniform emission models contain Pauli keys only, so these + /// branches do not cause leakage. + /// - `p1_seepage_prob = p2_seepage_prob = 0.5`: seepage is attempted only for qubits that are + /// already leaked. + /// - `p_idle_coherent_to_incoherent_factor = 1.5`: stochastic quadratic idle dephasing is + /// inflated by 50% relative to the exact Pauli twirl. + #[must_use] + pub fn auto(mut self) -> Self { + self.p_prep.get_or_insert(0.01); + self.p_meas_0.get_or_insert(0.01); + self.p_meas_1.get_or_insert(0.01); + self.p1.get_or_insert(0.001); + self.p2.get_or_insert(0.01); + self.p_idle_linear_rate.get_or_insert(0.001); + self.p1_emission_ratio.get_or_insert(0.5); + self.p2_emission_ratio.get_or_insert(0.5); + self.p_prep_leak_ratio.get_or_insert(0.5); + self.p1_seepage_prob.get_or_insert(0.5); + self.p2_seepage_prob.get_or_insert(0.5); + self.p_idle_coherent_to_incoherent_factor.get_or_insert(1.5); + self + } + + /// Build the general noise model /// /// # Returns /// A `GeneralNoiseModel` @@ -410,11 +444,11 @@ impl GeneralNoiseModelBuilder { /// independent rate. That setter applies no `2*pi` conversion and no /// `coherent_to_incoherent_factor`, unlike [`Self::with_p_idle_quadratic_rate`]. With neutral /// global and idle scales, `with_p_idle_quadratic_rate(r)` equals - /// `with_p_idle_sin_squared(r * factor/2 * 2*pi, {"Z": 1.0})`, or `r * 1.5 * pi` at the + /// `with_p_idle_sin_squared(r * factor/2 * 2*pi, {"Z": 1.0})`, or `r * pi` at the /// default factor. /// - /// Engines idle noise is on by default (`p_idle_linear_rate = 0.001`). When translating a DEM - /// configuration, explicitly set every idle family that was not requested to zero. + /// All engines idle-noise families are off by default, so translating a DEM configuration only + /// requires setting the requested families. /// /// The linear sampling structure deliberately remains different from the DEM: engines emits /// at most one linear event followed by a categorical axis choice, while the DEM emits @@ -454,12 +488,12 @@ impl GeneralNoiseModelBuilder { /// linear event rate is split across axes. /// /// With neutral global and idle scales, `with_p_idle_quadratic_rate(r)` equals - /// `with_p_idle_sin_squared(r * factor/2 * 2*pi, {"Z": 1.0})`, or `r * 1.5 * pi` at the + /// `with_p_idle_sin_squared(r * factor/2 * 2*pi, {"Z": 1.0})`, or `r * pi` at the /// default factor. The legacy spelling is in cycles per time unit and also folds the factor /// into its runtime rate; this setter is the radians-per-time-unit spelling. /// - /// Engines idle noise is on by default (`p_idle_linear_rate = 0.001`). When translating a DEM - /// configuration, explicitly set every idle family that was not requested to zero. + /// All engines idle-noise families are off by default, so translating a DEM configuration only + /// requires setting the requested families. /// /// The linear sampling structure deliberately remains different from the DEM: engines emits /// at most one linear event followed by a categorical axis choice, while the DEM emits @@ -906,17 +940,13 @@ impl GeneralNoiseModelBuilder { /// Returns `(p_prep, p_meas_0, p_meas_1, p1, p2)`. `p1`/`p2` are in the /// standard depolarizing convention the builder stores internally (the /// `with_average_*` setters convert on the way in). Unset probabilities - /// take their `GeneralNoiseModel::default()` values — this model's - /// philosophy is realistic defaults, NOT unset-means-off. + /// take their no-effect `GeneralNoiseModel::default()` values. /// /// Returns `Some` only when the noise shape is plain Pauli noise: /// - /// - Knobs whose model defaults are non-neutral must be EXPLICITLY - /// zeroed: emission ratios (default 0.5 — half the errors replace the - /// gate instead of following it), prep leak ratio (default 0.5), and - /// the linear idle rate (default 0.001). - /// - Knobs with neutral defaults (crosstalk, quadratic idle, scales, - /// noiseless gates) may be unset or set to their neutral value. + /// - Emission ratios, preparation leakage, idle noise, crosstalk, and other optional + /// mechanisms may be unset or set to their neutral value. + /// - Scales may be unset or set to one, and the noiseless-gate set must be empty. /// - Custom Pauli/emission/crosstalk models and angle-dependent /// two-qubit noise must be unset. /// @@ -925,8 +955,9 @@ impl GeneralNoiseModelBuilder { /// common configuration without re-deriving probability conventions. #[must_use] pub fn simple_probabilities(&self) -> Option<(f64, f64, f64, f64, f64)> { + let zero_or_unset = |v: Option| v.is_none() || v == Some(0.0); let emission_off = - self.p1_emission_ratio == Some(0.0) && self.p2_emission_ratio == Some(0.0); + zero_or_unset(self.p1_emission_ratio) && zero_or_unset(self.p2_emission_ratio); if self.is_plain_pauli_except_angle_and_emission() && self.resolved_angle_scaling().is_none() && emission_off @@ -983,25 +1014,19 @@ impl GeneralNoiseModelBuilder { /// True when every non-Pauli feature is off EXCEPT possibly the /// angle-dependent two-qubit scaling and the spontaneous-emission ratios. /// Shared by `simple_probabilities` (which additionally requires both the - /// angle scaling unset and emission explicitly off) and + /// angle scaling unset and emission off) and /// `pauli_with_angle_scaling` (which extracts them). The emission DISTRIBUTION /// must still be the default uniform model (`p1/p2_emission_model` unset) -- /// custom emission samplers are NOT in this subset. fn is_plain_pauli_except_angle_and_emission(&self) -> bool { - let explicitly_zero = |v: Option| v == Some(0.0); let zero_or_unset = |v: Option| v.is_none() || v == Some(0.0); let one_or_unset = |v: Option| v.is_none() || v == Some(1.0); - // Non-neutral model defaults: unset means the default applies, so - // these must be explicitly zeroed for the physics to be plain Pauli. - // (Emission ratios are intentionally NOT required off here -- they are - // handled separately, since neo now matches engines' gate-removing - // emission with the default uniform distribution.) - let defaulted_features_off = - explicitly_zero(self.p_prep_leak_ratio) && explicitly_zero(self.p_idle_linear_rate); - - // Neutral model defaults: unset is fine. - let optional_features_off = zero_or_unset(self.p_idle_quadratic_rate) + // Emission ratios are intentionally NOT required off here: they are handled separately, + // since neo matches engines' gate-removing emission with the default uniform distribution. + let optional_features_off = zero_or_unset(self.p_prep_leak_ratio) + && zero_or_unset(self.p_idle_linear_rate) + && zero_or_unset(self.p_idle_quadratic_rate) && self.p_idle_sin_squared.is_none() && zero_or_unset(self.p_prep_crosstalk) && zero_or_unset(self.idle_after_2q) @@ -1035,11 +1060,7 @@ impl GeneralNoiseModelBuilder { let gates_default = self.noiseless_gates.as_ref().is_none_or(BTreeSet::is_empty); - defaulted_features_off - && optional_features_off - && custom_models_off - && scales_neutral - && gates_default + optional_features_off && custom_models_off && scales_neutral && gates_default } /// Resolve the base Pauli probabilities `(p_prep, p_meas_0, p_meas_1, p1, @@ -1173,19 +1194,92 @@ impl crate::noise::IntoNoiseModel for GeneralNoiseModelBuilder { mod tests { use super::*; + fn assert_float_eq(actual: f64, expected: f64) { + assert!( + (actual - expected).abs() < f64::EPSILON, + "expected {expected}, got {actual}" + ); + } + #[test] - fn simple_probabilities_requires_explicit_zeros_for_defaulted_features() { - // Bare builder: model defaults include emission 0.5, prep leak 0.5, - // idle 0.001 — physics beyond the simple Pauli subset. + fn auto_reproduces_legacy_demonstration_preset() { + let model = GeneralNoiseModelBuilder::new().auto().build(); + + assert_float_eq(model.p_prep, 0.01); + assert_float_eq(model.p_meas_0, 0.01); + assert_float_eq(model.p_meas_1, 0.01); + assert_float_eq(model.p1, 0.001); + assert_float_eq(model.p2, 0.01); + assert_float_eq(model.p_idle_linear_rate, 0.001); + assert_float_eq(model.p1_emission_ratio, 0.5); + assert_float_eq(model.p2_emission_ratio, 0.5); + assert_float_eq(model.p_prep_leak_ratio, 0.5); + assert_float_eq(model.p1_seepage_prob, 0.5); + assert_float_eq(model.p2_seepage_prob, 0.5); + assert_float_eq(model.p_idle_coherent_to_incoherent_factor, 1.5); + } + + #[test] + fn explicit_setter_beats_auto_in_both_orders() { + let set_explicit = |builder: GeneralNoiseModelBuilder| { + builder + .with_p_prep(0.11) + .with_p_meas_0(0.12) + .with_p_meas_1(0.13) + .with_p1(0.14) + .with_p2(0.15) + .with_p_idle_linear_rate(0.16) + .with_p1_emission_ratio(0.17) + .with_p2_emission_ratio(0.18) + .with_prep_leak_ratio(0.19) + .with_p1_seepage_prob(0.20) + .with_p2_seepage_prob(0.21) + .with_p_idle_coherent_to_incoherent_factor(1.25) + }; + let models = [ + set_explicit(GeneralNoiseModelBuilder::new().auto()).build(), + set_explicit(GeneralNoiseModelBuilder::new()).auto().build(), + ]; + + for model in models { + assert_float_eq(model.p_prep, 0.11); + assert_float_eq(model.p_meas_0, 0.12); + assert_float_eq(model.p_meas_1, 0.13); + assert_float_eq(model.p1, 0.14); + assert_float_eq(model.p2, 0.15); + assert_float_eq(model.p_idle_linear_rate, 0.16); + assert_float_eq(model.p1_emission_ratio, 0.17); + assert_float_eq(model.p2_emission_ratio, 0.18); + assert_float_eq(model.p_prep_leak_ratio, 0.19); + assert_float_eq(model.p1_seepage_prob, 0.20); + assert_float_eq(model.p2_seepage_prob, 0.21); + assert_float_eq(model.p_idle_coherent_to_incoherent_factor, 1.25); + } + } + + #[test] + fn auto_does_not_overwrite_explicit_zero() { + let model = GeneralNoiseModelBuilder::new().with_p2(0.0).auto().build(); + + assert_float_eq(model.p2, 0.0); + assert_float_eq(model.p1, 0.001); + } + + #[test] + fn simple_probabilities_accepts_neutral_defaults_and_rejects_auto_features() { + assert_eq!( + GeneralNoiseModelBuilder::new().simple_probabilities(), + Some((0.0, 0.0, 0.0, 0.0, 0.0)) + ); assert!( GeneralNoiseModelBuilder::new() + .with_average_p1(0.2) .simple_probabilities() - .is_none() + .is_some() ); - // Setting only a probability does not neutralize the defaults. assert!( GeneralNoiseModelBuilder::new() - .with_average_p1(0.2) + .auto() .simple_probabilities() .is_none() ); @@ -1204,7 +1298,7 @@ mod tests { .with_prep_leak_ratio(0.0) .with_p_idle_linear_rate(0.0) .simple_probabilities() - .expect("fully zeroed config is simple"); + .expect("plain Pauli config is simple"); let (p_prep, p_meas_0, p_meas_1, p1, p2) = simple; assert!((p_prep - 0.01).abs() < 1e-12); @@ -1223,7 +1317,7 @@ mod tests { .with_prep_leak_ratio(0.0) .with_p_idle_linear_rate(0.0) .simple_probabilities() - .expect("zeroed features with default probabilities is simple"); + .expect("neutral defaults are simple"); let (d_prep, d_meas_0, d_meas_1, d_p1, d_p2, _) = GeneralNoiseModel::default().probabilities(); @@ -1248,7 +1342,7 @@ mod tests { .expect("simple config is also pauli-with-angle"); assert_eq!((p_prep, p_meas_0, p_meas_1, p1, p2), simple); assert!(angle.is_none()); - // Emission was explicitly zeroed to land in the strict simple subset. + // Emission is zero in the strict simple subset. assert_eq!((p1_emission, p2_emission), (0.0, 0.0)); } @@ -1306,11 +1400,11 @@ mod tests { /// angle configured. #[test] fn pauli_with_angle_scaling_rejects_non_angle_features() { - // Prep-leakage and linear idling keep their (non-zero) model defaults - // because they are never explicitly zeroed -> beyond the subset. - // (Emission ratios are NOT a blocker -- they are part of the subset.) + // Explicit preparation leakage is beyond the subset. (Emission ratios are NOT a + // blocker -- they are part of the subset.) let builder = GeneralNoiseModelBuilder::new() .with_p2(0.3) + .with_prep_leak_ratio(0.5) .with_p2_angle_params(1.5, 0.0, 1.0, 0.0); assert!(builder.pauli_with_angle_scaling().is_none()); } diff --git a/crates/pecos-engines/src/noise/general/default.rs b/crates/pecos-engines/src/noise/general/default.rs index ef0c1e8d2..ae25b55cf 100644 --- a/crates/pecos-engines/src/noise/general/default.rs +++ b/crates/pecos-engines/src/noise/general/default.rs @@ -6,23 +6,17 @@ use crate::noise::{ use std::collections::{BTreeMap, BTreeSet}; impl Default for GeneralNoiseModel { - /// Create a new noise model with default error parameters + /// Create a noiseless general noise model. /// - /// Creates a `GeneralNoiseModel` with sensible default error probabilities: - /// * `p_prep` - Preparation (initialization) error probability: 0.01 - /// * `p_meas_0` - Probability of measuring 1 when the state is |0⟩: 0.01 - /// * `p_meas_1` - Probability of measuring 0 when the state is |1⟩: 0.01 - /// * `p1` - Single-qubit gate error probability (average error rate): 0.001 - /// * `p2` - Two-qubit gate error probability (average error rate): 0.01 - /// - /// Other parameters are initialized with sensible defaults, including uniform - /// distributions for Pauli errors and emission errors. + /// All rates and probabilities use their no-effect value. Multipliers and angle-scaling + /// parameters use their identity values, and the sampler distributions remain uniform so + /// that explicitly enabling a rate without replacing its sampler remains well-defined. /// /// # Example /// ``` /// use pecos_engines::noise::GeneralNoiseModel; /// - /// // Create model with default error probabilities + /// // The default model adds no noise. /// let mut model = GeneralNoiseModel::default(); /// ``` fn default() -> Self { @@ -71,35 +65,35 @@ impl Default for GeneralNoiseModel { p2_emission_model.insert("YI".to_string(), 1.0 / 15.0); p2_emission_model.insert("ZI".to_string(), 1.0 / 15.0); - let p_meas_0: f64 = 0.01; // 1% probability of measuring 1 when state is |0⟩ - let p_meas_1: f64 = 0.01; // 1% probability of measuring 0 when state is |1⟩ + let p_meas_0: f64 = 0.0; + let p_meas_1: f64 = 0.0; let mut p_meas_crosstalk_model = BTreeMap::new(); p_meas_crosstalk_model.insert("0->0".to_string(), 1.0); p_meas_crosstalk_model.insert("1->1".to_string(), 1.0); - // Default error probabilities + // No-effect defaults Self { - p_prep: 0.01, + p_prep: 0.0, p_idle_coherent: false, - p_idle_linear_rate: 0.001, + p_idle_linear_rate: 0.0, p_idle_linear_model: SingleQubitWeightedSampler::new(&p1_pauli_model), p_idle_quadratic_rate: 0.0, p_idle_sin_squared_rate: 0.0, p_idle_sin_squared_model: BTreeMap::new(), p_meas_0, p_meas_1, - p1: 0.001, - p2: 0.01, - p1_emission_ratio: 0.5, - p_prep_leak_ratio: 0.5, - p2_emission_ratio: 0.5, + p1: 0.0, + p2: 0.0, + p1_emission_ratio: 0.0, + p_prep_leak_ratio: 0.0, + p2_emission_ratio: 0.0, p1_pauli_model: SingleQubitWeightedSampler::new(&p1_pauli_model), p1_emission_model: SingleQubitWeightedSampler::new(&p1_emission_model), p2_pauli_model: TwoQubitWeightedSampler::new(&p2_pauli_model), p2_emission_model: TwoQubitWeightedSampler::new(&p2_emission_model), - p1_seepage_prob: 0.5, - p2_seepage_prob: 0.5, + p1_seepage_prob: 0.0, + p2_seepage_prob: 0.0, p2_angle_a: 0.0, p2_angle_b: 1.0, p2_angle_c: 0.0, @@ -115,7 +109,7 @@ impl Default for GeneralNoiseModel { p_meas_crosstalk_model: CrosstalkWeightedSampler::new(&p_meas_crosstalk_model), p_prep_crosstalk: 0.0, - p_idle_coherent_to_incoherent_factor: 1.5, + p_idle_coherent_to_incoherent_factor: 1.0, noiseless_gates: BTreeSet::new(), p_meas_max: p_meas_0.max(p_meas_1), leakage_scale: 1.0, diff --git a/crates/pecos-qasm/tests/general_noise_builder_test.rs b/crates/pecos-qasm/tests/general_noise_builder_test.rs index c4dca3b0d..995621fbb 100644 --- a/crates/pecos-qasm/tests/general_noise_builder_test.rs +++ b/crates/pecos-qasm/tests/general_noise_builder_test.rs @@ -188,7 +188,8 @@ fn test_general_noise_builder_with_prep_errors() { include "qelib1.inc"; qreg q[2]; creg c[2]; - // No gates, just measure initialized qubits + // Explicit preparation followed by measurement + reset q; measure q -> c; "#; diff --git a/crates/pecos/tests/neo_equivalence_matrix_test.rs b/crates/pecos/tests/neo_equivalence_matrix_test.rs index 1ed2803d8..7af70edfd 100644 --- a/crates/pecos/tests/neo_equivalence_matrix_test.rs +++ b/crates/pecos/tests/neo_equivalence_matrix_test.rs @@ -176,9 +176,8 @@ impl NoiseCell { .run(), Self::GnmSimple { average_p1, p_meas } => builder .noise( - // GeneralNoiseModel has realistic non-zero defaults; - // zero everything outside the simple Pauli subset so - // the cell physics is exactly known. + // Spell out the zero channels so the cell physics is immediately visible, + // even though GeneralNoiseModel now defaults them off. pecos_engines::noise::GeneralNoiseModel::builder() .with_average_p1(average_p1) .with_average_p2(0.0) @@ -198,9 +197,8 @@ impl NoiseCell { angle_power, } => builder .noise( - // Plain Pauli two-qubit noise with angle scaling; zero - // every other channel and the non-neutral GNM defaults so - // only the angle-scaled RZZ depolarizing noise remains. + // Plain Pauli two-qubit noise with angle scaling; spell out every other + // channel as zero so only angle-scaled RZZ depolarizing noise remains. pecos_engines::noise::GeneralNoiseModel::builder() .with_p2(p2) .with_p2_angle_params(a, b, c, d) diff --git a/crates/pecos/tests/neo_routing_test.rs b/crates/pecos/tests/neo_routing_test.rs index 9c311eae0..c81e5f482 100644 --- a/crates/pecos/tests/neo_routing_test.rs +++ b/crates/pecos/tests/neo_routing_test.rs @@ -284,19 +284,8 @@ fn neo_stack_general_noise_average_convention_matches() { let shots = 4000; let expected_flip = 0.2; let run = |stack: SimStack| { - // GeneralNoiseModel defaults are realistic (nonzero emission, prep - // leak, idle, and base probabilities); zero everything except the - // 1q Pauli channel so the physics is plain depolarizing. - let noise = pecos_engines::noise::GeneralNoiseModel::builder() - .with_average_p1(0.2) - .with_p1_emission_ratio(0.0) - .with_p2_emission_ratio(0.0) - .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0) - .with_p_prep(0.0) - .with_p_meas_0(0.0) - .with_p_meas_1(0.0) - .with_average_p2(0.0); + // No-effect defaults leave only the explicitly configured 1q Pauli channel. + let noise = pecos_engines::noise::GeneralNoiseModel::builder().with_average_p1(0.2); sim(x_measure_qasm()) .stack(stack) .noise(noise) @@ -321,12 +310,11 @@ fn neo_stack_general_noise_average_convention_matches() { #[test] fn neo_stack_rejects_unmapped_noise() { - // A bare GeneralNoiseModel keeps its realistic defaults for prep leak - // (0.5) and linear idling (0.001) — physics beyond the simple Pauli - // subset, so the mapping must refuse rather than silently change the - // model. (Spontaneous emission IS now mapped, so it is the prep-leak - // and idle defaults that force the rejection here.) - let general = pecos_engines::noise::GeneralNoiseModel::builder().with_average_p1(0.01); + // Explicit preparation leakage is beyond the simple Pauli subset, so the mapping must refuse + // rather than silently change the model. Spontaneous emission is mapped separately. + let general = pecos_engines::noise::GeneralNoiseModel::builder() + .with_average_p1(0.01) + .with_prep_leak_ratio(0.5); let err = sim(deterministic_conditional_qasm()) .stack(SimStack::Neo) .noise(general) diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index a09e79a37..e12206b4f 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -214,9 +214,10 @@ The two sides express the idle families in different units, so the sine-law rate has to be converted rather than copied. `NoiseParameters` takes it in radians per time unit, while the simulator takes cycles per time unit and folds in `coherent_to_incoherent_factor / 2`. Dividing by `factor / 2 * 2 * pi` -- -that is, `1.5 * pi` at the default factor -- makes the two agree. The linear -family needs no conversion, and its model dictionary is a normalized -distribution on both sides. +that is, `pi` at the default factor of one -- makes the two agree. At one, the +stochastic branch is the exact Pauli twirl of the coherent rotation. The linear +family needs no conversion, and its model dictionary is a normalized distribution +on both sides. ```python @@ -234,13 +235,12 @@ noise = ( .with_p_idle_linear_rate(0.01) .with_p_idle_linear_model({"X": 0.25, "Y": 0.25, "Z": 0.5}) .with_p_idle_coherent(False) - .with_p_idle_coherent_to_incoherent_factor(1.5) - .with_p_idle_quadratic_rate(0.03 / (1.5 * math.pi)) + .with_p_idle_quadratic_rate(0.03 / math.pi) .with_idle_after_2q(1.0) ) # The conversion above reproduces the DEM's sine-law probability exactly. -assert math.isclose(math.sin(0.03 / (1.5 * math.pi) * 1.5 * math.pi) ** 2, math.sin(0.03) ** 2) +assert math.isclose(math.sin(0.03 / math.pi * math.pi) ** 2, math.sin(0.03) ** 2) results = sim(rep_code_memory).classical(selene_engine()).quantum(stabilizer()).qubits(7).noise(noise).seed(42).run(500) diff --git a/python/pecos-rslib/src/engine_builders.rs b/python/pecos-rslib/src/engine_builders.rs index 5286cae29..68e91eaae 100644 --- a/python/pecos-rslib/src/engine_builders.rs +++ b/python/pecos-rslib/src/engine_builders.rs @@ -852,7 +852,9 @@ pub fn hugr_engine() -> PyHugrEngineBuilder { PyHugrEngineBuilder::new() } -/// Create a general noise model builder +/// Create a general noise model builder with no-effect defaults. +/// +/// Call ``.auto()`` to opt into the legacy demonstration preset. #[pyfunction] pub fn general_noise() -> PyGeneralNoiseModelBuilder { PyGeneralNoiseModelBuilder::new() @@ -895,6 +897,31 @@ impl PyGeneralNoiseModelBuilder { } } + /// Fill unset parameters with the legacy demonstration preset. + /// + /// This reproduces the general noise model's historical defaults for demonstrations; it is + /// not a calibrated device model. Explicit setters win in either call order because ``auto`` + /// fills only parameters that the caller has not set. + /// + /// The preset sets preparation, measurement, one-qubit, two-qubit, and linear-idle rates to + /// 0.01, 0.01/0.01, 0.001, 0.01, and 0.001 respectively. In addition: + /// + /// * ``p_prep_leak_ratio = 0.5`` means half of preparation faults leak the qubit out of the + /// computational subspace. + /// * ``p1_emission_ratio = p2_emission_ratio = 0.5`` means half of gate errors take the + /// spontaneous-emission branch, which removes the original gate and substitutes a sample + /// from the emission model. The preset emission models contain Pauli keys only, so these + /// branches cause no leakage. + /// * ``p1_seepage_prob = p2_seepage_prob = 0.5`` applies only to qubits that are already + /// leaked. + /// * ``p_idle_coherent_to_incoherent_factor = 1.5`` inflates stochastic quadratic idle + /// dephasing by 50% relative to the exact Pauli twirl. + fn auto(&self) -> Self { + Self { + inner: self.inner.clone().auto(), + } + } + /// Set single-qubit gate error probability fn with_p1(&self, p: f64) -> PyResult { Ok(Self { @@ -1086,14 +1113,14 @@ impl PyGeneralNoiseModelBuilder { /// independent rate. It applies no ``2*pi`` conversion and no /// ``coherent_to_incoherent_factor``, unlike ``with_p_idle_quadratic_rate``. With neutral /// global and idle scales, ``with_p_idle_quadratic_rate(r)`` equals - /// ``with_p_idle_sin_squared(r * factor/2 * 2*pi, {"Z": 1.0})``, or ``r * 1.5 * pi`` at the + /// ``with_p_idle_sin_squared(r * factor/2 * 2*pi, {"Z": 1.0})``, or ``r * pi`` at the /// default factor. /// - /// Engines idle noise is on by default (``p_idle_linear_rate = 0.001``), so translating a DEM - /// configuration requires explicitly zeroing every idle family not requested. Engines also - /// keeps its existing linear sampling structure: one event followed by a categorical axis - /// choice, versus the DEM's independent per-axis mechanisms. The difference is second order - /// in the rates; this setter aligns units and the axis alphabet, not the sampling structure. + /// All engines idle-noise families are off by default, so translating a DEM configuration only + /// requires setting the requested families. Engines keeps its existing linear sampling + /// structure: one event followed by a categorical axis choice, versus the DEM's independent + /// per-axis mechanisms. The difference is second order in the rates; this setter aligns units + /// and the axis alphabet, not the sampling structure. fn with_p_idle_linear( &self, rate: f64, @@ -1123,10 +1150,9 @@ impl PyGeneralNoiseModelBuilder { /// distribution because it splits one total linear rate across axes. /// /// With neutral global and idle scales, ``with_p_idle_quadratic_rate(r)`` equals - /// ``with_p_idle_sin_squared(r * factor/2 * 2*pi, {"Z": 1.0})``, or ``r * 1.5 * pi`` at the - /// default factor. Engines idle noise is on by default (``p_idle_linear_rate = 0.001``), so - /// translating a DEM configuration requires explicitly zeroing every idle family not - /// requested. + /// ``with_p_idle_sin_squared(r * factor/2 * 2*pi, {"Z": 1.0})``, or ``r * pi`` at the + /// default factor. All engines idle-noise families are off by default, so translating a DEM + /// configuration only requires setting the requested families. /// /// Engines deliberately retains its existing linear sampling structure: one event followed /// by a categorical axis choice, versus the DEM's independent per-axis mechanisms. The diff --git a/python/pecos-rslib/tests/test_sim_api.py b/python/pecos-rslib/tests/test_sim_api.py index 482e585a6..daa4f8df4 100644 --- a/python/pecos-rslib/tests/test_sim_api.py +++ b/python/pecos-rslib/tests/test_sim_api.py @@ -149,12 +149,11 @@ def test_general_noise_model(self) -> None: program = Qasm.from_string(qasm) engine = qasm_engine().program(program) - # Test with general noise model - noise = general_noise() + # Preserve the historical demonstration preset for this broad smoke test. + noise = general_noise().auto() results = sim(program).classical(engine).noise(noise).run(100).to_dict() - # General noise model may introduce errors even without explicit configuration - # Just check that we get results + # Just check that we get results. assert "c" in results assert len(results["c"]) == 100 diff --git a/python/pecos-rslib/tests/test_sim_qasm.py b/python/pecos-rslib/tests/test_sim_qasm.py index ebe5dfba4..895e2ab3d 100644 --- a/python/pecos-rslib/tests/test_sim_qasm.py +++ b/python/pecos-rslib/tests/test_sim_qasm.py @@ -188,8 +188,8 @@ def test_noise_models(self) -> None: errors = sum(1 for val in results["c"] if val == 0) assert errors > 0 - # General noise - shot_vec = sim(Qasm.from_string(qasm)).noise(general_noise()).run(10) + # Preserve the historical demonstration preset in this all-model smoke test. + shot_vec = sim(Qasm.from_string(qasm)).noise(general_noise().auto()).run(10) results = shot_vec.to_dict() assert len(results["c"]) == 10 diff --git a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_comprehensive.py b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_comprehensive.py index a66afded8..1192b903a 100644 --- a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_comprehensive.py +++ b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_comprehensive.py @@ -43,8 +43,10 @@ def test_general_noise(self) -> None: measure q -> c; """ - # GeneralNoise uses default configuration - results = qasm_engine().program(Qasm.from_string(qasm)).to_sim().seed(42).noise(general_noise()).run(1000) + # Preserve the historical demonstration preset for this broad integration smoke test. + results = ( + qasm_engine().program(Qasm.from_string(qasm)).to_sim().seed(42).noise(general_noise().auto()).run(1000) + ) results_dict = results.to_dict() assert isinstance(results_dict, dict) diff --git a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_defaults.py b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_defaults.py index 32477e293..69a74b556 100644 --- a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_defaults.py +++ b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_defaults.py @@ -143,7 +143,7 @@ def test_default_summary(self) -> None: # Noise model builders: # - depolarizing_noise(): requires explicit .with_p1() # - biased_depolarizing_noise(): requires probability settings - # - GeneralNoiseModelBuilder(): has internal defaults + # - GeneralNoiseModelBuilder(): no-effect defaults; .auto() opts into the legacy preset # # New unified API defaults: # - All optional fields use builder defaults when not specified diff --git a/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py b/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py index 08b7abf64..52273e4b7 100644 --- a/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py +++ b/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py @@ -23,7 +23,7 @@ import pytest from guppylang import guppy -from guppylang.std.quantum import measure, qubit +from guppylang.std.quantum import measure, qubit, x from pecos import Qasm, qasm_engine, sim from pecos_rslib import ( biased_depolarizing_noise, @@ -104,6 +104,66 @@ def test_average_setters_keep_their_conversion() -> None: assert builder.with_average_p1(0.01).with_average_p2(0.02) is not None +def test_auto_is_chainable_and_explicit_zeros_win_in_both_orders() -> None: + """The pyo3 preset preserves explicit zero rates before and after ``auto``.""" + + @guppy + def deterministic_x() -> bool: + q = qubit() + x(q) + return measure(q) + + auto_then_zeros = ( + general_noise().auto().with_p_prep(0.0).with_p1(0.0).with_p2(0.0).with_p_meas(0.0).with_p_idle_linear_rate(0.0) + ) + zeros_then_auto = ( + general_noise().with_p_prep(0.0).with_p1(0.0).with_p2(0.0).with_p_meas(0.0).with_p_idle_linear_rate(0.0).auto() + ) + + for noise in (auto_then_zeros, zeros_then_auto): + results = sim(deterministic_x).qubits(1).quantum(state_vector()).noise(noise).seed(42).run(20).to_dict() + raw = results["measurements"] + measurements = [m[-1] if isinstance(m, list) else m for m in raw] + assert measurements == [1] * 20 + + +def test_auto_matches_explicit_legacy_preset_at_python_surface() -> None: + """The pyo3 ``auto`` method delegates to the complete Rust legacy preset.""" + + @guppy + def deterministic_x() -> bool: + q = qubit() + x(q) + return measure(q) + + explicit = ( + general_noise() + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_p1(0.001) + .with_p2(0.01) + .with_p_idle_linear_rate(0.001) + .with_p1_emission_ratio(0.5) + .with_p2_emission_ratio(0.5) + .with_prep_leak_ratio(0.5) + .with_p1_seepage_prob(0.5) + .with_p2_seepage_prob(0.5) + .with_p_idle_coherent_to_incoherent_factor(1.5) + ) + + def run(noise) -> list[bool]: + results = sim(deterministic_x).qubits(1).quantum(state_vector()).noise(noise).seed(424).run(512).to_dict() + raw = results["measurements"] + return [m[-1] if isinstance(m, list) else m for m in raw] + + auto_results = run(general_noise().auto()) + explicit_results = run(explicit) + + assert auto_results == explicit_results + assert auto_results != [1] * 512 + + def test_idle_family_setters_are_chainable() -> None: """The structured linear and sine families are present on the pyo3 fluent builder.""" builder = general_noise().with_p_idle_linear(0.01, {"X": 0.5, "L": 0.5}) From c5e784d6cfc52e0efd2ea93fd2061db9816b2534 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 4 Aug 2026 19:53:12 -0600 Subject: [PATCH 37/62] Give pecos-neo the same DEM-shaped idle family vocabulary as the engines builder --- exp/pecos-neo/src/noise/builder.rs | 2 + exp/pecos-neo/src/noise/composite/builder.rs | 2 + exp/pecos-neo/src/noise/general_builder.rs | 375 ++++++++++++++++++- exp/pecos-neo/src/noise/idle.rs | 305 ++++++++++++++- 4 files changed, 665 insertions(+), 19 deletions(-) diff --git a/exp/pecos-neo/src/noise/builder.rs b/exp/pecos-neo/src/noise/builder.rs index d29163ac4..5e0ce669d 100644 --- a/exp/pecos-neo/src/noise/builder.rs +++ b/exp/pecos-neo/src/noise/builder.rs @@ -589,6 +589,8 @@ impl NoiseModelBuilder { let channel = IdleChannel { linear_rate: self.p_idle_linear_rate, linear_weights: self.p_idle_linear_weights, + sin_squared_rate: 0.0, + sin_squared_model: std::collections::BTreeMap::new(), quadratic_rate: self.p_idle_quadratic_rate, coherent_dephasing: self.p_idle_coherent, coherent_to_incoherent_factor: self.p_idle_coherent_factor, diff --git a/exp/pecos-neo/src/noise/composite/builder.rs b/exp/pecos-neo/src/noise/composite/builder.rs index cceda9118..eeb09cbd3 100644 --- a/exp/pecos-neo/src/noise/composite/builder.rs +++ b/exp/pecos-neo/src/noise/composite/builder.rs @@ -872,6 +872,8 @@ impl CompositeNoiseModelBuilder { let channel = IdleChannel { linear_rate: self.p_idle_linear_rate, linear_weights, + sin_squared_rate: 0.0, + sin_squared_model: std::collections::BTreeMap::new(), quadratic_rate: self.p_idle_quadratic_rate, coherent_dephasing: self.p_idle_coherent, coherent_to_incoherent_factor: self.p_idle_coherent_to_incoherent_factor, diff --git a/exp/pecos-neo/src/noise/general_builder.rs b/exp/pecos-neo/src/noise/general_builder.rs index 54445b3f4..b4ccfe4a2 100644 --- a/exp/pecos-neo/src/noise/general_builder.rs +++ b/exp/pecos-neo/src/noise/general_builder.rs @@ -49,6 +49,7 @@ use super::{ }; use crate::command::GateType; use pecos_core::TimeScale; +use std::collections::BTreeMap; /// Builder for creating a noise model equivalent to `GeneralNoiseModel`. /// @@ -110,6 +111,8 @@ pub struct GeneralNoiseModelBuilder { p_idle_linear_rate: f64, p_idle_linear_weights: PauliWeights, p_idle_quadratic_rate: f64, + p_idle_quadratic_configured: bool, + p_idle_sin_squared: Option<(f64, BTreeMap)>, p_idle_coherent: bool, p_idle_coherent_to_incoherent_factor: f64, @@ -170,6 +173,8 @@ impl GeneralNoiseModelBuilder { p_idle_linear_rate: 0.0, p_idle_linear_weights: PauliWeights::custom(0.0, 0.0, 1.0), // Z-only p_idle_quadratic_rate: 0.0, + p_idle_quadratic_configured: false, + p_idle_sin_squared: None, p_idle_coherent: false, p_idle_coherent_to_incoherent_factor: 1.0, @@ -388,19 +393,39 @@ impl GeneralNoiseModelBuilder { // Idle noise parameters // ======================================================================== - /// Set the linear idle noise rate (per time unit). + /// Set the DEM-style linear idle-noise family. /// - /// The rate interpretation depends on your `TimeScale` configuration. - #[must_use] - pub fn with_p_idle_linear(mut self, rate: f64) -> Self { - self.p_idle_linear_rate = rate; - self - } - - /// Set the Pauli distribution for linear idle noise. + /// `rate` is the total event rate per time unit. For an idle of duration `d`, one event is + /// sampled with probability `rate * d`, then its X, Y, or Z axis is drawn from `model`. The + /// model must therefore be a normalized distribution: this linear family splits one total + /// rate across its axes. + /// + /// In contrast, [`Self::with_p_idle_sin_squared`] takes radians per time unit and an + /// unnormalized model because sine laws do not add linearly: each axis carries its own + /// independent rate. That setter applies no `2*pi` conversion and no + /// `coherent_to_incoherent_factor`, unlike [`Self::with_p_idle_quadratic`]. + /// + /// Neo's linear family stores its model in [`PauliWeights`], so it cannot represent the DEM's + /// L axis. An L key is rejected; use neo's [`LeakageChannel`] for linear leakage. The new + /// sine-squared family uses separate map storage and accepts X, Y, Z, and L. + /// + /// All neo idle-noise families are off by default, so translating a DEM configuration only + /// requires setting the requested families. + /// + /// The linear sampling structure deliberately remains different from the DEM: neo emits at + /// most one linear event followed by a categorical axis choice, while the DEM emits independent + /// per-axis mechanisms. The difference is second order in the rates; this setter aligns the + /// units and axis alphabet that neo can represent, not that sampling structure. + /// + /// # Panics + /// + /// Panics if `rate` or a model value is not finite and non-negative, if the model is not + /// normalized, or if it contains a key other than X, Y, or Z. L is rejected with guidance to + /// use [`LeakageChannel`]. #[must_use] - pub fn with_p_idle_linear_weights(mut self, weights: PauliWeights) -> Self { - self.p_idle_linear_weights = weights; + pub fn with_p_idle_linear(mut self, rate: f64, model: &BTreeMap) -> Self { + self.p_idle_linear_rate = Self::validate_finite_non_negative(rate, "linear idling rate"); + self.p_idle_linear_weights = Self::validate_linear_model(model); self } @@ -410,6 +435,42 @@ impl GeneralNoiseModelBuilder { #[must_use] pub fn with_p_idle_quadratic(mut self, rate: f64) -> Self { self.p_idle_quadratic_rate = rate; + self.p_idle_quadratic_configured = true; + self + } + + /// Set the DEM-style stochastic sine-squared idle-noise family. + /// + /// `rate` is in radians per time unit. No `2*pi` conversion and no + /// `coherent_to_incoherent_factor` is applied, unlike [`Self::with_p_idle_quadratic`]. For each + /// axis P with multiplier `n_P` and an idle of duration `d`, neo independently samples + /// `P(P) = sin^2(rate * n_P * d)`. + /// + /// The model accepts X, Y, Z, and L and is intentionally unnormalized: sine laws do not add + /// linearly, so every axis carries its own independent rate. By comparison, + /// [`Self::with_p_idle_linear`] requires a normalized distribution because its one total + /// linear event rate is split across axes. + /// + /// Unlike the linear family's [`PauliWeights`] storage, this family has separate map storage + /// that can represent the DEM's L axis. Sine-family leakage is tracked by neo and enables its + /// [`LeakageChannel`]. + /// + /// The legacy quadratic spelling has a different unit contract and folds + /// `coherent_to_incoherent_factor` and the exact `sin^2(theta/2)` Pauli twirl into its + /// stochastic path; this setter is the direct radians-per-time-unit spelling. + /// + /// All neo idle-noise families are off by default, so translating a DEM configuration only + /// requires setting the requested families. + /// + /// # Panics + /// + /// Panics if `rate` or a multiplier is not finite and non-negative, or if `model` contains a + /// key other than X, Y, Z, or L. + #[must_use] + pub fn with_p_idle_sin_squared(mut self, rate: f64, model: &BTreeMap) -> Self { + let rate = Self::validate_finite_non_negative(rate, "sine-squared idling rate"); + Self::validate_sine_model(model); + self.p_idle_sin_squared = Some((rate, model.clone())); self } @@ -499,6 +560,7 @@ impl GeneralNoiseModelBuilder { // Set rates: linear_rate = 1/T1, quadratic_rate = 1/T2^2 self.p_idle_linear_rate = 1.0 / t1_units.max(1.0); self.p_idle_quadratic_rate = 1.0 / (t2_units * t2_units).max(1.0); + self.p_idle_quadratic_configured = true; self } @@ -506,6 +568,86 @@ impl GeneralNoiseModelBuilder { // Build // ======================================================================== + /// Validate that a value is finite and non-negative. + fn validate_finite_non_negative(value: f64, name: &str) -> f64 { + assert!( + value.is_finite() && value >= 0.0, + "{name} must be finite and non-negative, got {value}" + ); + value + } + + /// Validate and convert a normalized X/Y/Z linear-family model. + fn validate_linear_model(model: &BTreeMap) -> PauliWeights { + const NORMALIZATION_TOLERANCE: f64 = 1e-5; + + let mut x = 0.0; + let mut y = 0.0; + let mut z = 0.0; + for (axis, weight) in model { + match axis.as_str() { + "X" => x = *weight, + "Y" => y = *weight, + "Z" => z = *weight, + "L" => panic!( + "neo's idle linear family cannot represent leakage; use neo's \ + LeakageChannel for linear leakage" + ), + _ => panic!("p_idle_linear model has invalid key '{axis}'; expected X, Y, Z, or L"), + } + Self::validate_finite_non_negative( + *weight, + &format!("p_idle_linear weight for '{axis}'"), + ); + } + + let total = x + y + z; + assert!( + total.is_finite() && (total - 1.0).abs() <= NORMALIZATION_TOLERANCE, + "p_idle_linear model weights must sum to 1.0 within tolerance \ + {NORMALIZATION_TOLERANCE}, got {total}" + ); + PauliWeights::custom(x / total, y / total, z / total) + } + + /// Validate an unnormalized sine-family multiplier model. + fn validate_sine_model(model: &BTreeMap) { + for (axis, multiplier) in model { + assert!( + matches!(axis.as_str(), "X" | "Y" | "Z" | "L"), + "p_idle_sin_squared model has invalid key '{axis}'; expected X, Y, Z, or L" + ); + Self::validate_finite_non_negative( + *multiplier, + &format!("p_idle_sin_squared multiplier for '{axis}'"), + ); + } + } + + /// Validate combinations whose interpretation would otherwise depend on silent precedence. + /// + /// # Errors + /// + /// Returns a description of the conflicting spellings and their incompatible semantics. + pub fn validate_configuration(&self) -> Result<(), &'static str> { + if self.p_idle_sin_squared.is_some() && self.p_idle_quadratic_configured { + return Err( + "with_p_idle_sin_squared cannot be combined with with_p_idle_quadratic: \ + the spellings use different units; with_p_idle_sin_squared uses radians per \ + time unit with no conversion, while with_p_idle_quadratic uses the legacy \ + quadratic-rate units and applies coherent_to_incoherent_factor", + ); + } + if self.p_idle_sin_squared.is_some() && self.p_idle_coherent { + return Err( + "with_p_idle_sin_squared cannot be combined with with_p_idle_coherent(true): \ + with_p_idle_sin_squared is stochastic by definition, while \ + with_p_idle_coherent(true) selects the legacy coherent path", + ); + } + Ok(()) + } + /// Check if any configured parameters can cause leakage. fn has_leakage_potential(&self) -> bool { self.p_prep_leak_ratio > 0.0 @@ -515,13 +657,31 @@ impl GeneralNoiseModelBuilder { .p_meas_crosstalk_transitions .as_ref() .is_some_and(|t| t.from_0_leak > 0.0 || t.from_1_leak > 0.0) + || self + .p_idle_sin_squared + .as_ref() + .is_some_and(|(rate, model)| { + *rate > 0.0 && model.get("L").is_some_and(|multiplier| *multiplier > 0.0) + }) } /// Build the configured noise model. /// /// Returns a [`ComposableNoiseModel`] with all the configured channels. + /// + /// # Panics + /// + /// Panics if sine-squared idle noise is combined with the legacy quadratic or coherent idle + /// path. #[must_use] pub fn build(self) -> ComposableNoiseModel { + self.validate_configuration() + .unwrap_or_else(|message| panic!("{message}")); + + let (p_idle_sin_squared_rate, p_idle_sin_squared_model) = self + .p_idle_sin_squared + .clone() + .unwrap_or_else(|| (0.0, BTreeMap::new())); let mut model = ComposableNoiseModel::new().add_plugin(&CorePlugin); // Set time scale if configured @@ -598,11 +758,14 @@ impl GeneralNoiseModelBuilder { // Idle channel if self.p_idle_linear_rate > 0.0 || self.p_idle_quadratic_rate > 0.0 + || p_idle_sin_squared_rate > 0.0 || self.idle_after_2q > 0.0 { let channel = IdleChannel { linear_rate: self.p_idle_linear_rate, linear_weights: self.p_idle_linear_weights, + sin_squared_rate: p_idle_sin_squared_rate, + sin_squared_model: p_idle_sin_squared_model, quadratic_rate: self.p_idle_quadratic_rate, coherent_dephasing: self.p_idle_coherent, coherent_to_incoherent_factor: self.p_idle_coherent_to_incoherent_factor, @@ -652,6 +815,16 @@ mod tests { use pecos_core::QubitId; use pecos_random::PecosRng; + fn panic_message(panic: &(dyn std::any::Any + Send)) -> String { + if let Some(message) = panic.downcast_ref::() { + message.clone() + } else if let Some(message) = panic.downcast_ref::<&str>() { + (*message).to_string() + } else { + "non-string panic".to_string() + } + } + fn collect_gates(response: NoiseResponse) -> Vec { match response { NoiseResponse::InjectGates(gates) => (*gates).into_vec(), @@ -726,9 +899,9 @@ mod tests { #[test] fn after_2q_idle_works_without_p2_or_a_two_qubit_channel() { + let linear_model = BTreeMap::from([("X".to_string(), 1.0)]); let mut model = GeneralNoiseModelBuilder::new() - .with_p_idle_linear(1.0) - .with_p_idle_linear_weights(PauliWeights::custom(1.0, 0.0, 0.0)) + .with_p_idle_linear(1.0, &linear_model) .with_idle_after_2q(1.0) .build(); assert_eq!(model.channel_names(), ["IdleChannel"]); @@ -767,15 +940,189 @@ mod tests { assert!(gates.iter().all(|gate| gate.gate_type == GateType::Z)); } + #[test] + fn sine_family_reaches_after_2q_idle_sites() { + let sine_model = BTreeMap::from([("X".to_string(), 1.0)]); + let mut model = GeneralNoiseModelBuilder::new() + .with_p_idle_sin_squared(std::f64::consts::FRAC_PI_2, &sine_model) + .with_idle_after_2q(1.0) + .build(); + let qubits = [QubitId(0), QubitId(1)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(73))); + + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|gate| gate.gate_type == GateType::X)); + } + + #[test] + fn sine_multipliers_are_not_normalized_by_the_builder() { + let sine_model = BTreeMap::from([("X".to_string(), 1.0), ("Z".to_string(), 1.0)]); + let mut model = GeneralNoiseModelBuilder::new() + .with_p_idle_sin_squared(std::f64::consts::FRAC_PI_2, &sine_model) + .build(); + let qubits = std::array::from_fn::<_, 16, _>(QubitId); + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: 1.into(), + }; + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(79))); + + assert_eq!(gates.len(), 32); + assert!(gates[..16].iter().all(|gate| gate.gate_type == GateType::X)); + assert!(gates[16..].iter().all(|gate| gate.gate_type == GateType::Z)); + } + + #[test] + fn sine_family_accepts_leakage_and_enables_leakage_channel() { + let sine_model = BTreeMap::from([("L".to_string(), 1.0)]); + let mut model = GeneralNoiseModelBuilder::new() + .with_p_idle_sin_squared(std::f64::consts::FRAC_PI_2, &sine_model) + .build(); + assert_eq!(model.channel_names(), ["LeakageChannel", "IdleChannel"]); + + let qubits = [QubitId(0)]; + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: 1.into(), + }; + let response = model.emit(&event, &mut PecosRng::seed_from_u64(83)); + + assert!(matches!(response, NoiseResponse::MarkLeaked(_))); + assert!(model.context().is_leaked(QubitId(0))); + } + + #[test] + fn linear_family_rejects_unnormalized_model() { + let linear_model = BTreeMap::from([("X".to_string(), 1.0), ("Z".to_string(), 1.0)]); + let panic = std::panic::catch_unwind(|| { + let _ = GeneralNoiseModelBuilder::new().with_p_idle_linear(0.1, &linear_model); + }) + .unwrap_err(); + + assert!(panic_message(panic.as_ref()).contains("must sum to 1.0")); + } + + #[test] + fn linear_family_rejects_leakage_with_neo_guidance() { + let linear_model = BTreeMap::from([("X".to_string(), 0.5), ("L".to_string(), 0.5)]); + let panic = std::panic::catch_unwind(|| { + let _ = GeneralNoiseModelBuilder::new().with_p_idle_linear(0.1, &linear_model); + }) + .unwrap_err(); + let message = panic_message(panic.as_ref()); + + assert!(message.contains("neo's idle linear family cannot represent leakage")); + assert!(message.contains("neo's LeakageChannel")); + } + + #[test] + fn linear_family_rejects_invalid_rates_axes_and_weights() { + let normalized = BTreeMap::from([("X".to_string(), 1.0)]); + for rate in [f64::INFINITY, f64::NAN, -0.1] { + let panic = std::panic::catch_unwind(|| { + let _ = GeneralNoiseModelBuilder::new().with_p_idle_linear(rate, &normalized); + }) + .unwrap_err(); + assert!(panic_message(panic.as_ref()).contains("finite and non-negative")); + } + + let invalid_axis = BTreeMap::from([("A".to_string(), 1.0)]); + let panic = std::panic::catch_unwind(|| { + let _ = GeneralNoiseModelBuilder::new().with_p_idle_linear(0.1, &invalid_axis); + }) + .unwrap_err(); + assert!(panic_message(panic.as_ref()).contains("invalid key 'A'")); + + for invalid_weight in [f64::INFINITY, f64::NAN, -1.0] { + let invalid_model = BTreeMap::from([ + ("X".to_string(), invalid_weight), + ("Z".to_string(), 1.0 - invalid_weight), + ]); + let panic = std::panic::catch_unwind(|| { + let _ = GeneralNoiseModelBuilder::new().with_p_idle_linear(0.1, &invalid_model); + }) + .unwrap_err(); + assert!(panic_message(panic.as_ref()).contains("finite and non-negative")); + } + } + + #[test] + fn sine_family_rejects_invalid_rates_axes_and_multipliers() { + let valid_model = BTreeMap::from([("X".to_string(), 1.0)]); + for rate in [f64::INFINITY, f64::NAN, -0.1] { + let panic = std::panic::catch_unwind(|| { + let _ = GeneralNoiseModelBuilder::new().with_p_idle_sin_squared(rate, &valid_model); + }) + .unwrap_err(); + assert!(panic_message(panic.as_ref()).contains("finite and non-negative")); + } + + let invalid_axis = BTreeMap::from([("A".to_string(), 1.0)]); + let panic = std::panic::catch_unwind(|| { + let _ = GeneralNoiseModelBuilder::new().with_p_idle_sin_squared(0.1, &invalid_axis); + }) + .unwrap_err(); + assert!(panic_message(panic.as_ref()).contains("invalid key 'A'")); + + for multiplier in [f64::INFINITY, f64::NAN, -1.0] { + let invalid_model = BTreeMap::from([("X".to_string(), multiplier)]); + let panic = std::panic::catch_unwind(|| { + let _ = + GeneralNoiseModelBuilder::new().with_p_idle_sin_squared(0.1, &invalid_model); + }) + .unwrap_err(); + assert!(panic_message(panic.as_ref()).contains("finite and non-negative")); + } + } + + #[test] + fn sine_family_conflicts_with_legacy_quadratic_spelling() { + let sine_model = BTreeMap::from([("Z".to_string(), 1.0)]); + let builder = GeneralNoiseModelBuilder::new() + .with_p_idle_quadratic(0.1) + .with_p_idle_sin_squared(0.1, &sine_model); + let error = builder.validate_configuration().unwrap_err(); + + assert!(error.contains("with_p_idle_sin_squared")); + assert!(error.contains("with_p_idle_quadratic")); + assert!(error.contains("different units")); + assert!( + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| builder.build())).is_err() + ); + } + + #[test] + fn sine_family_conflicts_with_coherent_legacy_path() { + let sine_model = BTreeMap::from([("Z".to_string(), 1.0)]); + let builder = GeneralNoiseModelBuilder::new() + .with_p_idle_sin_squared(0.1, &sine_model) + .with_p_idle_coherent(true); + let error = builder.validate_configuration().unwrap_err(); + + assert!(error.contains("with_p_idle_sin_squared")); + assert!(error.contains("with_p_idle_coherent(true)")); + assert!(error.contains("stochastic by definition")); + assert!( + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| builder.build())).is_err() + ); + } + #[test] fn test_full_configuration() { + let linear_model = BTreeMap::from([("Z".to_string(), 1.0)]); let model = GeneralNoiseModelBuilder::new() .with_p_prep(0.001) .with_p_prep_leak_ratio(0.1) // Enable leakage potential .with_p1(0.01) .with_p2(0.02) .with_p_meas(0.03, 0.04) - .with_p_idle_linear(0.0001) + .with_p_idle_linear(0.0001, &linear_model) .with_leakage_scale(1.0) .build(); diff --git a/exp/pecos-neo/src/noise/idle.rs b/exp/pecos-neo/src/noise/idle.rs index 4e198678b..f6f151663 100644 --- a/exp/pecos-neo/src/noise/idle.rs +++ b/exp/pecos-neo/src/noise/idle.rs @@ -42,6 +42,9 @@ //! - **Quadratic noise**: Can be coherent (RZ rotations) or incoherent (stochastic Z). //! Models T2-like dephasing. //! +//! - **Sine-squared noise**: Independent stochastic X, Y, Z, or leakage events with +//! per-axis probability `sin(rate * multiplier * duration)^2`. +//! //! ## Coherent vs Incoherent Dephasing //! //! - **Coherent**: Deterministic RZ rotation with angle = rate * duration. @@ -56,6 +59,7 @@ use pecos_core::{Angle64, TimeUnits}; use pecos_random::PecosRng; use rand::RngExt; use smallvec::SmallVec; +use std::collections::BTreeMap; /// Noise channel for idle time (memory errors). /// @@ -74,6 +78,12 @@ pub struct IdleChannel { /// or any custom distribution. pub linear_weights: PauliWeights, + /// DEM-style stochastic sine-squared idle rate in radians per time unit. + pub sin_squared_rate: f64, + + /// Unnormalized per-axis relative multipliers for the sine-squared idle family. + pub sin_squared_model: BTreeMap, + /// Error rate per time unit for quadratic (dephasing) noise. /// /// For coherent: angle = `quadratic_rate` * duration. @@ -101,8 +111,8 @@ pub struct IdleChannel { /// Duration of the idle-noise site applied after a two-qubit gate. /// /// A duration of zero disables after-two-qubit idle sites. When enabled, - /// the same linear and quadratic mechanisms used for explicit idle events - /// are applied to every distinct gate operand. + /// the same linear, quadratic, and sine-squared mechanisms used for + /// explicit idle events are applied to every distinct gate operand. pub idle_after_2q: f64, } @@ -111,6 +121,8 @@ impl Default for IdleChannel { Self { linear_rate: 0.0, linear_weights: PauliWeights::custom(0.0, 0.0, 1.0), // Z-only by default + sin_squared_rate: 0.0, + sin_squared_model: BTreeMap::new(), quadratic_rate: 0.0, coherent_dephasing: false, coherent_to_incoherent_factor: 1.0, @@ -217,6 +229,11 @@ impl IdleChannel { self.quadratic_rate * duration } + /// Calculate one axis's DEM-style sine-squared error probability. + fn sin_squared_probability(rate: f64, multiplier: f64, duration: f64) -> f64 { + (rate * multiplier * duration).sin().powi(2) + } + /// Apply every configured idle mechanism for one duration. fn apply_for_duration( &self, @@ -225,7 +242,11 @@ impl IdleChannel { ctx: &mut NoiseContext, rng: &mut PecosRng, ) -> NoiseResponse { - if duration <= 0.0 || (self.linear_rate <= 0.0 && self.quadratic_rate <= 0.0) { + if duration <= 0.0 + || (self.linear_rate <= 0.0 + && self.quadratic_rate <= 0.0 + && self.sin_squared_rate <= 0.0) + { return NoiseResponse::None; } @@ -239,6 +260,7 @@ impl IdleChannel { } let mut gates = SmallVec::new(); + let mut leaked = SmallVec::new(); // Fast path: check if any leakage exists at all let has_any_leakage = ctx.leaked_count() > 0; @@ -285,17 +307,53 @@ impl IdleChannel { } } - if gates.is_empty() { + // Apply the DEM-style stochastic sine-squared family independently per axis. + if self.sin_squared_rate > 0.0 { + for axis in ["X", "Y", "Z", "L"] { + let Some(multiplier) = self.sin_squared_model.get(axis).copied() else { + continue; + }; + let probability = + Self::sin_squared_probability(self.sin_squared_rate, multiplier, duration); + if probability <= f64::EPSILON { + continue; + } + + for &qubit in &unique_qubits { + if (!has_any_leakage || !ctx.is_leaked(qubit)) + && rng.random::() < probability + { + match axis { + "X" => gates + .push(GateCommand::new(GateType::X, smallvec::smallvec![qubit])), + "Y" => gates + .push(GateCommand::new(GateType::Y, smallvec::smallvec![qubit])), + "Z" => gates + .push(GateCommand::new(GateType::Z, smallvec::smallvec![qubit])), + "L" => leaked.push(qubit), + _ => unreachable!("sine-family model was validated by the builder"), + } + } + } + } + } + + let response = if gates.is_empty() { NoiseResponse::None } else { NoiseResponse::inject_gates(gates) + }; + if leaked.is_empty() { + response + } else { + response.combine(NoiseResponse::MarkLeaked(leaked)) } } } impl NoiseChannel for IdleChannel { fn responds_to(&self, event: &NoiseEvent<'_>) -> bool { - if self.linear_rate <= 0.0 && self.quadratic_rate <= 0.0 { + if self.linear_rate <= 0.0 && self.quadratic_rate <= 0.0 && self.sin_squared_rate <= 0.0 { return false; } match event { @@ -350,6 +408,16 @@ mod tests { } } + fn collect_leaked(response: NoiseResponse) -> Vec { + match response { + NoiseResponse::MarkLeaked(qubits) => qubits.into_vec(), + NoiseResponse::Multiple(responses) => { + responses.into_iter().flat_map(collect_leaked).collect() + } + _ => Vec::new(), + } + } + fn after_cx(qubits: &[QubitId]) -> NoiseEvent<'_> { NoiseEvent::AfterGate { gate_type: GateType::CX, @@ -537,6 +605,104 @@ mod tests { } } + #[test] + fn sine_probability_matches_engines_numeric_value() { + let probability = IdleChannel::sin_squared_probability(0.03, 1.0, 10.0); + assert!((probability - 0.087_332_192_545_160_84).abs() < f64::EPSILON); + } + + #[test] + fn sine_application_uses_rate_multiplier_and_duration() { + let channel = IdleChannel { + sin_squared_rate: 0.03, + sin_squared_model: BTreeMap::from([("X".to_string(), 2.0)]), + ..Default::default() + }; + let qubits = std::array::from_fn::<_, 32, _>(QubitId); + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: TimeUnits::new(5), + }; + let expected_probability = 0.087_332_192_545_160_84; + let mut expected_rng = PecosRng::seed_from_u64(3); + let expected_qubits = qubits + .iter() + .copied() + .filter(|_| expected_rng.random::() < expected_probability) + .collect::>(); + let expected_next = expected_rng.random::(); + + let mut actual_rng = PecosRng::seed_from_u64(3); + let actual_gates = + collect_gates(channel.apply(&event, &mut NoiseContext::new(), &mut actual_rng)); + assert_eq!( + actual_gates + .iter() + .map(|gate| gate.qubits[0]) + .collect::>(), + expected_qubits + ); + assert!( + actual_gates + .iter() + .all(|gate| gate.gate_type == GateType::X) + ); + assert_eq!(actual_rng.random::(), expected_next); + } + + #[test] + fn x_weighted_sine_model_emits_x_not_z() { + let channel = IdleChannel { + sin_squared_rate: std::f64::consts::FRAC_PI_2, + sin_squared_model: BTreeMap::from([("X".to_string(), 1.0)]), + ..Default::default() + }; + let qubits = [QubitId(0)]; + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: TimeUnits::new(1), + }; + let gates = collect_gates(channel.apply( + &event, + &mut NoiseContext::new(), + &mut PecosRng::seed_from_u64(5), + )); + + assert_eq!(gates.len(), 1); + assert_eq!(gates[0].gate_type, GateType::X); + } + + #[test] + fn sine_model_axes_are_independent_in_xyzl_order() { + let channel = IdleChannel { + sin_squared_rate: std::f64::consts::FRAC_PI_2, + sin_squared_model: BTreeMap::from([ + ("X".to_string(), 1.0), + ("Y".to_string(), 1.0), + ("Z".to_string(), 1.0), + ("L".to_string(), 1.0), + ]), + ..Default::default() + }; + let qubits = [QubitId(0)]; + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: TimeUnits::new(1), + }; + let response = channel.apply( + &event, + &mut NoiseContext::new(), + &mut PecosRng::seed_from_u64(7), + ); + let gates = collect_gates(response.clone()); + + assert_eq!( + gates.iter().map(|gate| gate.gate_type).collect::>(), + [GateType::X, GateType::Y, GateType::Z] + ); + assert_eq!(collect_leaked(response), [QubitId(0)]); + } + #[test] fn after_2q_duration_scales_linear_noise() { let qubits = std::array::from_fn::<_, 64, _>(QubitId); @@ -672,6 +838,42 @@ mod tests { ); let mut expected_rng = PecosRng::seed_from_u64(41); assert_eq!(actual_rng.random::(), expected_rng.random::()); + + let zero_sine_rate = IdleChannel { + sin_squared_model: BTreeMap::from([("X".to_string(), 1.0)]), + ..Default::default() + }; + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: TimeUnits::new(1), + }; + assert!(!zero_sine_rate.responds_to(&event)); + let mut actual_rng = PecosRng::seed_from_u64(43); + assert!( + zero_sine_rate + .apply(&event, &mut NoiseContext::new(), &mut actual_rng) + .is_none() + ); + let mut expected_rng = PecosRng::seed_from_u64(43); + assert_eq!(actual_rng.random::(), expected_rng.random::()); + + let nonzero_sine_rate = IdleChannel { + sin_squared_rate: std::f64::consts::FRAC_PI_2, + sin_squared_model: BTreeMap::from([("X".to_string(), 1.0)]), + ..Default::default() + }; + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: TimeUnits::ZERO, + }; + let mut actual_rng = PecosRng::seed_from_u64(47); + assert!( + nonzero_sine_rate + .apply(&event, &mut NoiseContext::new(), &mut actual_rng) + .is_none() + ); + let mut expected_rng = PecosRng::seed_from_u64(47); + assert_eq!(actual_rng.random::(), expected_rng.random::()); } #[test] @@ -689,6 +891,36 @@ mod tests { assert_eq!(sample(), sample()); } + #[test] + fn sine_noise_reproduces_exactly_for_the_same_seed() { + let channel = IdleChannel { + sin_squared_rate: 0.6, + sin_squared_model: BTreeMap::from([ + ("X".to_string(), 0.5), + ("Y".to_string(), 0.75), + ("Z".to_string(), 1.0), + ]), + ..Default::default() + }; + let qubits = std::array::from_fn::<_, 16, _>(QubitId); + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: TimeUnits::new(1), + }; + + let sample = || { + collect_gates(channel.apply( + &event, + &mut NoiseContext::new(), + &mut PecosRng::seed_from_u64(53), + )) + }; + + let first = sample(); + assert!(!first.is_empty()); + assert_eq!(first, sample()); + } + #[test] fn incoherent_quadratic_probability_is_exact_twirl_of_coherent_angle() { let theta = 1.0; @@ -716,4 +948,67 @@ mod tests { assert_eq!(gates[0].gate_type, GateType::RZ); assert!((gates[0].angles[0].to_radians() - theta).abs() < 1e-15); } + + #[test] + fn legacy_quadratic_paths_keep_their_pre_change_output_exactly() { + let qubits = std::array::from_fn::<_, 8, _>(QubitId); + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: TimeUnits::new(2), + }; + let incoherent = IdleChannel { + quadratic_rate: 0.7, + coherent_to_incoherent_factor: 1.3, + ..Default::default() + }; + let mut incoherent_rng = PecosRng::seed_from_u64(424); + let incoherent_outputs = (0..4) + .map(|_| { + collect_gates(incoherent.apply( + &event, + &mut NoiseContext::new(), + &mut incoherent_rng, + )) + }) + .collect::>(); + let expected_incoherent_qubits: [&[usize]; 4] = [ + &[1, 3, 4, 5, 7], + &[0, 1, 3, 4, 5, 7], + &[0, 1, 4, 6, 7], + &[0, 1, 2, 4, 6, 7], + ]; + let expected_incoherent = expected_incoherent_qubits + .iter() + .map(|qubits| { + qubits + .iter() + .map(|&qubit| { + GateCommand::new(GateType::Z, smallvec::smallvec![QubitId(qubit)]) + }) + .collect::>() + }) + .collect::>(); + assert_eq!( + incoherent_outputs, expected_incoherent, + "the complete incoherent gate payload changed" + ); + assert_eq!(incoherent_rng.random::(), 13_820_570_602_603_389_690); + + let coherent = IdleChannel { + coherent_dephasing: true, + ..incoherent + }; + let mut coherent_rng = PecosRng::seed_from_u64(424); + let coherent_output = + collect_gates(coherent.apply(&event, &mut NoiseContext::new(), &mut coherent_rng)); + let expected_coherent = qubits + .iter() + .map(|&qubit| GateCommand::rz(qubit, Angle64::from_radians(1.4))) + .collect::>(); + assert_eq!( + coherent_output, expected_coherent, + "the complete coherent gate payload changed" + ); + assert_eq!(coherent_rng.random::(), 15_629_358_259_572_395_946); + } } From 0bd8ecfa7db6e86cebb5fa61233378364181fd35 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 4 Aug 2026 20:47:34 -0600 Subject: [PATCH 38/62] Add a coherent idle family to the engines noise builder and retire the mode-switch bool --- crates/pecos-engines/src/noise/general.rs | 398 +++++++++++++++++- .../src/noise/general/builder.rs | 86 +++- .../src/noise/general/default.rs | 10 +- crates/pecos-engines/tests/noise_test.rs | 4 +- .../examples/general_noise_builder.rs | 2 +- crates/pecos-qasm/src/config.rs | 6 +- .../tests/general_noise_builder_test.rs | 2 +- .../general_noise_builder_test.rs.disabled | 2 +- .../general_noise_config_test.rs.disabled | 4 +- docs/user-guide/noise-model-builders.md | 40 +- docs/workflows/guppy-dem-decoding.md | 2 +- .../surface/native_dem_threshold_sweep.py | 6 +- python/pecos-rslib/src/engine_builders.rs | 60 ++- .../pecos/test_noise_builder_setter_names.py | 59 ++- 14 files changed, 619 insertions(+), 62 deletions(-) diff --git a/crates/pecos-engines/src/noise/general.rs b/crates/pecos-engines/src/noise/general.rs index 315fe51a8..a7379b53a 100644 --- a/crates/pecos-engines/src/noise/general.rs +++ b/crates/pecos-engines/src/noise/general.rs @@ -127,7 +127,7 @@ pub struct GeneralNoiseModel { /// /// In physical systems, coherent dephasing represents systematic phase evolution /// such as frequency offsets. - p_idle_coherent: bool, + p_idle_quadratic_coherent: bool, /// The idle noise rate for linear dependency on time (seconds). /// @@ -144,8 +144,8 @@ pub struct GeneralNoiseModel { /// The idle noise rate for quadratic dependency on time (seconds). /// - /// This will be a coherent noise channel unless `p_idle_coherent` is set to false. If it is - /// false it will apply Z to each qubit quadratic dependency on time + /// This will be a coherent noise channel unless `p_idle_quadratic_coherent` is set to false. + /// If it is false it will apply Z to each qubit quadratic dependency on time. p_idle_quadratic_rate: f64, /// DEM-style stochastic sine-squared idle rate in radians per time unit. @@ -154,6 +154,12 @@ pub struct GeneralNoiseModel { /// Unnormalized per-axis relative multipliers for the sine-squared idle family. p_idle_sin_squared_model: BTreeMap, + /// DEM-style coherent idle rate in radians per time unit. + p_idle_coherent_rate: f64, + + /// Unnormalized RX/RY/RZ relative multipliers for the coherent idle family. + p_idle_coherent_model: BTreeMap, + /// Scaling factor to convert coherent dephasing rates to incoherent rates. /// /// A factor of one gives the exact Pauli twirl of the coherent rotation. Values above one can @@ -291,8 +297,8 @@ pub struct GeneralNoiseModel { /// A value of `0.0` disables these sites. For a nonzero duration, the sites receive the same /// configured idle mechanisms as a real [`GateType::Idle`] operation: linear stochastic noise /// from `p_idle_linear_rate` and `p_idle_linear_model`, quadratic dephasing from - /// `p_idle_quadratic_rate` honoring `p_idle_coherent`, and the independent per-axis - /// sine-squared family. The duration is not itself an error probability. + /// `p_idle_quadratic_rate` honoring `p_idle_quadratic_coherent`, and the independent per-axis + /// sine-squared and coherent families. The duration is not itself an error probability. idle_after_2q: f64, /// Probability of flipping a 0 measurement to 1 @@ -856,6 +862,10 @@ impl GeneralNoiseModel { if self.p_idle_sin_squared_rate > f64::EPSILON && duration.abs() > f64::EPSILON { self.apply_idle_sin_squared(duration, qubits, builder); } + + if self.p_idle_coherent_rate > f64::EPSILON && duration.abs() > f64::EPSILON { + self.apply_idle_coherent(duration, qubits, builder); + } } /// Assuming a general single-qubit stochastic noise for idling that depends on some rate and @@ -907,7 +917,7 @@ impl GeneralNoiseModel { ) { let mut angle = rate * duration; - angle = if self.p_idle_coherent { + angle = if self.p_idle_quadratic_coherent { angle } else { angle.sin().powi(2) @@ -917,12 +927,14 @@ impl GeneralNoiseModel { let mut noisy_qubits = vec![]; for qubit in qubits { - if !self.is_leaked(*qubit) && (self.p_idle_coherent || self.rng.occurs(angle)) { + if !self.is_leaked(*qubit) + && (self.p_idle_quadratic_coherent || self.rng.occurs(angle)) + { noisy_qubits.push(*qubit); } } if !noisy_qubits.is_empty() { - if self.p_idle_coherent { + if self.p_idle_quadratic_coherent { builder.rz(Angle64::from_radians(angle), &noisy_qubits); } else { builder.z(&noisy_qubits); @@ -983,6 +995,51 @@ impl GeneralNoiseModel { (rate * multiplier * duration).sin().powi(2) } + /// Apply deterministic coherent idle rotations in RX/RY/RZ order. + fn apply_idle_coherent( + &self, + duration: f64, + qubits: &[usize], + builder: &mut ByteMessageBuilder, + ) { + let affected = qubits + .iter() + .copied() + .filter(|qubit| !self.is_leaked(*qubit)) + .collect::>(); + if affected.is_empty() { + return; + } + + for axis in ["RX", "RY", "RZ"] { + let Some(multiplier) = self.p_idle_coherent_model.get(axis).copied() else { + continue; + }; + let angle = + Self::coherent_rotation_angle(self.p_idle_coherent_rate, multiplier, duration); + if angle <= f64::EPSILON { + continue; + } + + match axis { + "RX" => { + builder.rx(Angle64::from_radians(angle), &affected); + } + "RY" => { + builder.ry(Angle64::from_radians(angle), &affected); + } + "RZ" => { + builder.rz(Angle64::from_radians(angle), &affected); + } + _ => unreachable!("coherent-family model was validated by the builder"), + } + } + } + + fn coherent_rotation_angle(rate: f64, multiplier: f64, duration: f64) -> f64 { + rate * multiplier * duration + } + /// Apply preparation (initialization) noise /// /// State prep noise model: @@ -1573,7 +1630,16 @@ mod tests { assert_float_eq(model.p_idle_linear_rate, 0.0); assert_float_eq(model.p_idle_quadratic_rate, 0.0); assert_float_eq(model.p_idle_sin_squared_rate, 0.0); - assert!(!model.p_idle_coherent); + assert!(!model.p_idle_quadratic_coherent); + assert_float_eq(model.p_idle_coherent_rate, 0.0); + assert_eq!( + model.p_idle_coherent_model, + BTreeMap::from([ + ("RX".to_string(), 1.0), + ("RY".to_string(), 1.0), + ("RZ".to_string(), 1.0), + ]) + ); assert!(model.p_idle_sin_squared_model.is_empty()); assert_float_eq(model.p1_emission_ratio, 0.0); assert_float_eq(model.p_prep_leak_ratio, 0.0); @@ -2907,7 +2973,7 @@ mod tests { .with_p2(0.0) .with_p_idle_linear_rate(linear_rate) .with_p_idle_quadratic_rate(quadratic_rate) - .with_p_idle_coherent(coherent) + .with_p_idle_quadratic_coherent(coherent) .with_idle_after_2q(duration) .with_seed(seed) .build(); @@ -3086,6 +3152,196 @@ mod tests { assert_eq!(gates[0].gate_type, GateType::X); } + fn coherent_idle_gates( + rate: f64, + coherent_model: &BTreeMap, + duration: f64, + seed: u64, + ) -> Vec { + let mut model = GeneralNoiseModel::builder() + .with_seed(seed) + .with_p_idle_coherent(rate, coherent_model) + .build(); + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.idle(duration, &[0]); + model + .apply_noise_on_start(&input_builder.build()) + .unwrap() + .quantum_ops() + .unwrap() + } + + #[test] + fn coherent_idle_angle_is_rate_times_multiplier_times_duration() { + let rate = 0.25; + let multiplier = 1.4; + let duration = 0.6; + let expected_angle = 0.21; + let model = BTreeMap::from([("RY".to_string(), multiplier)]); + + assert!( + (GeneralNoiseModel::coherent_rotation_angle(rate, multiplier, duration) + - expected_angle) + .abs() + < f64::EPSILON + ); + let gates = coherent_idle_gates(rate, &model, duration, 424); + assert_eq!(gates.len(), 1); + assert_eq!(gates[0].gate_type, GateType::RY); + assert_eq!( + gates[0].angles, + [Angle64::from_radians(expected_angle)].into() + ); + } + + #[test] + fn coherent_idle_emits_selected_generators_in_deterministic_order() { + let rx_only = BTreeMap::from([("RX".to_string(), 1.0)]); + let gates = coherent_idle_gates(0.2, &rx_only, 0.5, 424); + assert_eq!(gates.len(), 1); + assert_eq!(gates[0].gate_type, GateType::RX); + assert!(!gates.iter().any(|gate| gate.gate_type == GateType::RZ)); + + let all_axes = BTreeMap::from([ + ("RZ".to_string(), 3.0), + ("RX".to_string(), 1.0), + ("RY".to_string(), 2.0), + ]); + let gates = coherent_idle_gates(0.2, &all_axes, 0.5, 424); + assert_eq!( + gates.iter().map(|gate| gate.gate_type).collect::>(), + [GateType::RX, GateType::RY, GateType::RZ] + ); + assert_eq!(gates[0].angles, [Angle64::from_radians(0.1)].into()); + assert_eq!(gates[1].angles, [Angle64::from_radians(0.2)].into()); + assert_eq!( + gates[2].angles, + [Angle64::from_radians( + GeneralNoiseModel::coherent_rotation_angle(0.2, 3.0, 0.5), + )] + .into() + ); + } + + #[test] + fn coherent_idle_multipliers_are_not_normalized() { + let model = BTreeMap::from([("RX".to_string(), 1.0), ("RZ".to_string(), 1.0)]); + let gates = coherent_idle_gates(0.2, &model, 0.5, 424); + + assert_eq!(gates.len(), 2); + assert_eq!(gates[0].angles, [Angle64::from_radians(0.1)].into()); + assert_eq!(gates[1].angles, [Angle64::from_radians(0.1)].into()); + } + + #[test] + fn coherent_idle_is_seed_independent_and_consumes_no_rng_draws() { + let coherent_model = BTreeMap::from([("RZ".to_string(), 1.0)]); + let first = coherent_idle_gates(0.3, &coherent_model, 0.7, 1); + let second = coherent_idle_gates(0.3, &coherent_model, 0.7, 999); + assert_eq!(first, second); + + let linear_model = BTreeMap::from([("X".to_string(), 1.0)]); + let make_model = |with_coherent| { + let builder = GeneralNoiseModel::builder() + .with_seed(424) + .with_p_idle_linear(0.35, &linear_model); + if with_coherent { + builder.with_p_idle_coherent(0.3, &coherent_model) + } else { + builder + } + .build() + }; + let mut without_coherent = make_model(false); + let mut with_coherent = make_model(true); + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.idle(0.7, &[0, 1, 2, 3]); + let input = input_builder.build(); + for _ in 0..128 { + let baseline = without_coherent + .apply_noise_on_start(&input) + .unwrap() + .quantum_ops() + .unwrap(); + let composed = with_coherent + .apply_noise_on_start(&input) + .unwrap() + .quantum_ops() + .unwrap() + .into_iter() + .filter(|gate| gate.gate_type != GateType::RZ) + .collect::>(); + assert_eq!(composed, baseline); + } + } + + #[test] + fn coherent_idle_reaches_after_2q_sites() { + let coherent_model = BTreeMap::from([("RZ".to_string(), 2.0)]); + let mut model = GeneralNoiseModel::builder() + .with_p2(0.0) + .with_p_idle_coherent(0.25, &coherent_model) + .with_idle_after_2q(0.6) + .build(); + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.cx(&[(0, 1)]); + + let gates = model + .apply_noise_on_start(&input_builder.build()) + .unwrap() + .quantum_ops() + .unwrap(); + assert_eq!(gates.len(), 2); + assert_eq!(gates[0].gate_type, GateType::CX); + assert_eq!(gates[1].gate_type, GateType::RZ); + assert_eq!(gates[1].qubits, [QubitId(0), QubitId(1)].into()); + assert_eq!(gates[1].angles, [Angle64::from_radians(0.3)].into()); + } + + #[test] + fn coherent_sine_squared_and_linear_idle_families_compose() { + let linear_model = BTreeMap::from([("X".to_string(), 1.0)]); + let sine_model = BTreeMap::from([("Z".to_string(), 1.0)]); + let coherent_model = BTreeMap::from([("RY".to_string(), 1.0)]); + let mut model = GeneralNoiseModel::builder() + .with_p_idle_linear(1.0, &linear_model) + .with_p_idle_sin_squared(std::f64::consts::FRAC_PI_2, &sine_model) + .with_p_idle_coherent(0.25, &coherent_model) + .build(); + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.idle(1.0, &[0]); + + let gates = model + .apply_noise_on_start(&input_builder.build()) + .unwrap() + .quantum_ops() + .unwrap(); + assert_eq!( + gates.iter().map(|gate| gate.gate_type).collect::>(), + [GateType::X, GateType::Z, GateType::RY] + ); + assert_eq!(gates[2].angles, [Angle64::from_radians(0.25)].into()); + } + + #[test] + fn coherent_idle_skips_leaked_qubits() { + let coherent_model = BTreeMap::from([("RX".to_string(), 1.0)]); + let mut model = GeneralNoiseModel::builder() + .with_p_idle_coherent(0.25, &coherent_model) + .build(); + model.leaked_qubits.insert(0); + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.idle(1.0, &[0, 1]); + + let gates = model + .apply_noise_on_start(&input_builder.build()) + .unwrap() + .quantum_ops() + .unwrap(); + assert_eq!(gates.len(), 1); + assert_eq!(gates[0].qubits, [QubitId(1)].into()); + } + #[test] fn sine_model_axes_are_independent_unnormalized_multipliers() { let model_map = BTreeMap::from([ @@ -3179,10 +3435,10 @@ mod tests { let z_model = BTreeMap::from([("Z".to_string(), 1.0)]); let builder = GeneralNoiseModel::builder() .with_p_idle_sin_squared(0.1, &z_model) - .with_p_idle_coherent(true); + .with_p_idle_quadratic_coherent(true); let error = builder.validate_configuration().unwrap_err(); assert!(error.contains("with_p_idle_sin_squared")); - assert!(error.contains("with_p_idle_coherent(true)")); + assert!(error.contains("with_p_idle_quadratic_coherent(true)")); assert!(error.contains("stochastic by definition")); assert!( std::panic::catch_unwind(|| builder.build()).is_err(), @@ -3190,6 +3446,41 @@ mod tests { ); } + #[test] + fn coherent_family_conflicts_with_legacy_quadratic_coherent_switch() { + let coherent_model = BTreeMap::from([("RZ".to_string(), 1.0)]); + let builder = GeneralNoiseModel::builder() + .with_p_idle_coherent(0.1, &coherent_model) + .with_p_idle_quadratic_coherent(true); + let error = builder.validate_configuration().unwrap_err(); + assert!(error.contains("with_p_idle_coherent")); + assert!(error.contains("with_p_idle_quadratic_coherent(true)")); + assert!(error.contains("both would emit coherent idle rotations")); + assert!( + std::panic::catch_unwind(|| builder.build()).is_err(), + "the conflict must fail when the Rust model is built" + ); + } + + #[test] + fn coherent_family_rejects_invalid_rates_axes_and_multipliers() { + let cases = [ + (f64::INFINITY, BTreeMap::from([("RX".to_string(), 1.0)])), + (0.1, BTreeMap::from([("L".to_string(), 1.0)])), + (0.1, BTreeMap::from([("A".to_string(), 1.0)])), + (0.1, BTreeMap::from([("RX".to_string(), -1.0)])), + ]; + for (rate, model) in cases { + assert!( + std::panic::catch_unwind(|| { + let _ = GeneralNoiseModel::builder().with_p_idle_coherent(rate, &model); + }) + .is_err(), + "invalid coherent rate/model must be rejected: rate={rate}, model={model:?}" + ); + } + } + #[test] fn zero_sine_rate_and_zero_idle_duration_emit_nothing() { assert!(GeneralNoiseModel::sin_squared_probability(0.0, 1.0, 1.0) < f64::EPSILON); @@ -3229,6 +3520,38 @@ mod tests { ); } + #[test] + fn zero_coherent_rate_and_zero_idle_duration_emit_nothing() { + let coherent_model = BTreeMap::from([("RX".to_string(), 1.0)]); + let mut zero_rate = GeneralNoiseModel::builder() + .with_p_idle_coherent(0.0, &coherent_model) + .build(); + let mut nonzero_rate = GeneralNoiseModel::builder() + .with_p_idle_coherent(0.25, &coherent_model) + .build(); + let mut duration_one = ByteMessage::quantum_operations_builder(); + duration_one.idle(1.0, &[0]); + let mut duration_zero = ByteMessage::quantum_operations_builder(); + duration_zero.idle(0.0, &[0]); + + assert!( + zero_rate + .apply_noise_on_start(&duration_one.build()) + .unwrap() + .quantum_ops() + .unwrap() + .is_empty() + ); + assert!( + nonzero_rate + .apply_noise_on_start(&duration_zero.build()) + .unwrap() + .quantum_ops() + .unwrap() + .is_empty() + ); + } + #[test] fn sine_family_is_deterministic_for_same_seed() { let sine_model = BTreeMap::from([ @@ -3260,7 +3583,7 @@ mod tests { } #[test] - fn legacy_idle_setters_keep_their_pre_change_bytes() { + fn legacy_quadratic_stochastic_path_keeps_its_pre_change_bytes() { let mut input_builder = ByteMessage::quantum_operations_builder(); for _ in 0..8 { input_builder.idle(0.75, &[0, 1, 2, 3]); @@ -3271,7 +3594,7 @@ mod tests { .with_p_idle_linear_rate(0.35) .with_p_idle_coherent_to_incoherent_factor(1.5) .with_p_idle_quadratic_rate(0.07) - .with_p_idle_coherent(false) + .with_p_idle_quadratic_coherent(false) .build(); assert!( (model.p_idle_quadratic_rate - 0.07 * 1.5 * std::f64::consts::PI).abs() < f64::EPSILON @@ -3291,13 +3614,54 @@ mod tests { } #[test] - fn test_p_idle_coherent() { + fn legacy_quadratic_coherent_path_keeps_its_pre_change_bytes() { + let mut input_builder = ByteMessage::quantum_operations_builder(); + for _ in 0..8 { + input_builder.idle(0.75, &[0, 1, 2, 3]); + } + let input = input_builder.build(); + let mut model = GeneralNoiseModel::builder() + .with_seed(424) + .with_p_idle_linear_rate(0.35) + .with_p_idle_coherent_to_incoherent_factor(1.5) + .with_p_idle_quadratic_rate(0.07) + .with_p_idle_quadratic_coherent(true) + .build(); + assert!( + (model.p_idle_quadratic_rate - 0.07 * 2.0 * std::f64::consts::PI).abs() < f64::EPSILON + ); + + let output = model.apply_noise_on_start(&input).unwrap().into_bytes(); + let expected = vec![ + 83, 67, 69, 80, 1, 0, 0, 0, 16, 0, 0, 0, 176, 1, 0, 0, 10, 0, 0, 0, 28, 0, 0, 0, 32, 4, + 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 82, 99, 190, 111, 139, 28, 213, + 63, 10, 0, 0, 0, 28, 0, 0, 0, 32, 4, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, + 0, 82, 99, 190, 111, 139, 28, 213, 63, 10, 0, 0, 0, 8, 0, 0, 0, 3, 1, 0, 0, 1, 0, 0, 0, + 10, 0, 0, 0, 8, 0, 0, 0, 1, 1, 0, 0, 3, 0, 0, 0, 10, 0, 0, 0, 28, 0, 0, 0, 32, 4, 1, 0, + 0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 82, 99, 190, 111, 139, 28, 213, 63, 10, + 0, 0, 0, 8, 0, 0, 0, 1, 1, 0, 0, 2, 0, 0, 0, 10, 0, 0, 0, 28, 0, 0, 0, 32, 4, 1, 0, 0, + 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 82, 99, 190, 111, 139, 28, 213, 63, 10, 0, + 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 1, 0, 0, 0, 10, 0, 0, 0, 28, 0, 0, 0, 32, 4, 1, 0, 0, 0, + 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 82, 99, 190, 111, 139, 28, 213, 63, 10, 0, 0, + 0, 8, 0, 0, 0, 1, 1, 0, 0, 1, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 3, 0, 0, 0, + 10, 0, 0, 0, 28, 0, 0, 0, 32, 4, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, + 82, 99, 190, 111, 139, 28, 213, 63, 10, 0, 0, 0, 8, 0, 0, 0, 3, 1, 0, 0, 3, 0, 0, 0, + 10, 0, 0, 0, 28, 0, 0, 0, 32, 4, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, + 82, 99, 190, 111, 139, 28, 213, 63, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 2, 0, 0, 0, + 10, 0, 0, 0, 28, 0, 0, 0, 32, 4, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, + 82, 99, 190, 111, 139, 28, 213, 63, + ]; + assert_eq!(output, expected); + } + + #[test] + fn test_p_idle_quadratic_coherent() { // Create a circuit builder let mut builder = ByteMessage::quantum_operations_builder(); // Create a noise model with coherent dephasing let mut model = GeneralNoiseModel::builder() - .with_p_idle_coherent(true) + .with_p_idle_quadratic_coherent(true) .with_p_idle_quadratic_rate(0.2) .build(); @@ -3384,7 +3748,7 @@ mod tests { let mut builder = ByteMessage::quantum_operations_builder(); let mut model = GeneralNoiseModel::builder() - .with_p_idle_coherent(false) + .with_p_idle_quadratic_coherent(false) .with_seed(42) .build(); diff --git a/crates/pecos-engines/src/noise/general/builder.rs b/crates/pecos-engines/src/noise/general/builder.rs index 03d24d203..29f293eb6 100644 --- a/crates/pecos-engines/src/noise/general/builder.rs +++ b/crates/pecos-engines/src/noise/general/builder.rs @@ -36,11 +36,12 @@ pub struct GeneralNoiseModelBuilder { leakage_scale: Option, emission_scale: Option, // idle noise - p_idle_coherent: Option, + p_idle_quadratic_coherent: Option, p_idle_linear_rate: Option, p_idle_linear_model: Option, p_idle_quadratic_rate: Option, p_idle_sin_squared: Option<(f64, BTreeMap)>, + p_idle_coherent: Option<(f64, BTreeMap)>, p_idle_coherent_to_incoherent_factor: Option, idle_scale: Option, // prep noise @@ -102,6 +103,7 @@ impl GeneralNoiseModelBuilder { p_idle_linear_model: None, p_idle_quadratic_rate: None, p_idle_sin_squared: None, + p_idle_quadratic_coherent: None, p_idle_coherent: None, p_idle_coherent_to_incoherent_factor: None, idle_scale: None, @@ -209,8 +211,8 @@ impl GeneralNoiseModelBuilder { // idle noise // ----------------------------------------------------------------------------------------- - if let Some(coherent) = self.p_idle_coherent { - model.p_idle_coherent = coherent; + if let Some(coherent) = self.p_idle_quadratic_coherent { + model.p_idle_quadratic_coherent = coherent; } if let Some(p_idle_linear_rate) = self.p_idle_linear_rate { @@ -230,6 +232,11 @@ impl GeneralNoiseModelBuilder { model.p_idle_sin_squared_model = sine_model; } + if let Some((rate, coherent_model)) = self.p_idle_coherent.clone() { + model.p_idle_coherent_rate = rate; + model.p_idle_coherent_model = coherent_model; + } + if let Some(factor) = self.p_idle_coherent_to_incoherent_factor { model.p_idle_coherent_to_incoherent_factor = factor; } @@ -401,10 +408,14 @@ impl GeneralNoiseModelBuilder { // --- idle noise --- // - /// Set whether to use coherent dephasing + /// Set whether the legacy quadratic idle rate uses coherent dephasing. + /// + /// This changes only how [`Self::with_p_idle_quadratic_rate`] is interpreted. It does not + /// configure the independent coherent idle family; use [`Self::with_p_idle_coherent`] for + /// that. #[must_use] - pub fn with_p_idle_coherent(mut self, use_coherent: bool) -> Self { - self.p_idle_coherent = Some(use_coherent); + pub fn with_p_idle_quadratic_coherent(mut self, use_coherent: bool) -> Self { + self.p_idle_quadratic_coherent = Some(use_coherent); self } @@ -512,6 +523,36 @@ impl GeneralNoiseModelBuilder { self } + /// Set the DEM-style coherent idle-noise family. + /// + /// `rate` is in radians per time unit. No `2*pi` conversion and no + /// `coherent_to_incoherent_factor` is applied, just as for + /// [`Self::with_p_idle_sin_squared`]. For each RX/RY/RZ generator with multiplier `n_P` and + /// an idle of duration `d`, engines applies a deterministic rotation with angle + /// `rate * n_P * d`; coherent evolution is not sampled and consumes no random draw. + /// + /// The model is intentionally unnormalized because its values are relative rate multipliers, + /// not probabilities to be split from one total event rate. The symmetric default model is + /// `{"RX": 1.0, "RY": 1.0, "RZ": 1.0}`. Leakage and all keys other than RX, RY, and RZ + /// are rejected because leakage is not a rotation. + /// + /// Whether these rotations can be consumed depends on the downstream consumer. The standard + /// DEM builder rejects coherent idle noise; the EEG route in `exp/pecos-eeg` represents it + /// with an RZ generator; and a simulator applies it only when its rotation executor is + /// installed. PECOS #437 documents how a missing executor could otherwise silently drop it. + /// + /// # Panics + /// + /// Panics if `rate` or a multiplier is not finite and non-negative, or if `model` contains a + /// key other than RX, RY, or RZ. + #[must_use] + pub fn with_p_idle_coherent(mut self, rate: f64, model: &BTreeMap) -> Self { + let rate = Self::validate_finite_non_negative(rate, "coherent idling rate"); + Self::validate_coherent_model(model); + self.p_idle_coherent = Some((rate, model.clone())); + self + } + /// Set the coherent-to-incoherent conversion factor /// /// # Parameters @@ -754,7 +795,8 @@ impl GeneralNoiseModelBuilder { /// A duration of `0.0` disables these sites. Nonzero sites receive all configured idle /// mechanisms over the given duration: linear stochastic noise from `p_idle_linear_rate` and /// `p_idle_linear_model`, quadratic dephasing from `p_idle_quadratic_rate` honoring - /// `p_idle_coherent`, and the independent per-axis sine-squared family. + /// `p_idle_quadratic_coherent`, plus the independent per-axis sine-squared and coherent + /// families. /// /// Anyone who previously wrote `with_p2_idle(0.01)` and no linear rate now gets no after-2q /// idle noise; the equivalent is @@ -902,6 +944,20 @@ impl GeneralNoiseModelBuilder { } } + /// Validate an unnormalized coherent-family multiplier model. + fn validate_coherent_model(model: &BTreeMap) { + for (axis, multiplier) in model { + assert!( + matches!(axis.as_str(), "RX" | "RY" | "RZ"), + "p_idle_coherent model has invalid key '{axis}'; expected RX, RY, or RZ" + ); + Self::validate_finite_non_negative( + *multiplier, + &format!("p_idle_coherent multiplier for '{axis}'"), + ); + } + } + /// Validate combinations whose interpretation would otherwise depend on silent precedence. /// /// # Errors @@ -914,12 +970,15 @@ impl GeneralNoiseModelBuilder { with_p_idle_sin_squared uses radians per time unit, while the legacy quadratic \ spellings use cycles per time unit and apply coherent_to_incoherent_factor"); } - if self.p_idle_sin_squared.is_some() && self.p_idle_coherent == Some(true) { - return Err( - "with_p_idle_sin_squared cannot be combined with with_p_idle_coherent(true): \ + if self.p_idle_sin_squared.is_some() && self.p_idle_quadratic_coherent == Some(true) { + return Err("with_p_idle_sin_squared cannot be combined with \ + with_p_idle_quadratic_coherent(true): \ with_p_idle_sin_squared is stochastic by definition, while \ - with_p_idle_coherent(true) selects the legacy coherent path", - ); + with_p_idle_quadratic_coherent(true) selects the legacy coherent path"); + } + if self.p_idle_coherent.is_some() && self.p_idle_quadratic_coherent == Some(true) { + return Err("with_p_idle_coherent cannot be combined with \ + with_p_idle_quadratic_coherent(true): both would emit coherent idle rotations"); } Ok(()) } @@ -1028,6 +1087,7 @@ impl GeneralNoiseModelBuilder { && zero_or_unset(self.p_idle_linear_rate) && zero_or_unset(self.p_idle_quadratic_rate) && self.p_idle_sin_squared.is_none() + && self.p_idle_coherent.is_none() && zero_or_unset(self.p_prep_crosstalk) && zero_or_unset(self.idle_after_2q) && zero_or_unset(self.p_meas_crosstalk_global) @@ -1172,7 +1232,7 @@ impl GeneralNoiseModelBuilder { model.p_idle_quadratic_rate *= (idle_scale * scale).sqrt(); // If we need to do incoherent noise instead of coherent - if !model.p_idle_coherent { + if !model.p_idle_quadratic_coherent { // 0.5 to deal with the 0.5 in sin(rate x duration x 0.5)^2 let factor = model.p_idle_coherent_to_incoherent_factor * 0.5; model.p_idle_quadratic_rate *= factor; diff --git a/crates/pecos-engines/src/noise/general/default.rs b/crates/pecos-engines/src/noise/general/default.rs index ae25b55cf..638438f67 100644 --- a/crates/pecos-engines/src/noise/general/default.rs +++ b/crates/pecos-engines/src/noise/general/default.rs @@ -72,15 +72,23 @@ impl Default for GeneralNoiseModel { p_meas_crosstalk_model.insert("0->0".to_string(), 1.0); p_meas_crosstalk_model.insert("1->1".to_string(), 1.0); + let p_idle_coherent_model = BTreeMap::from([ + ("RX".to_string(), 1.0), + ("RY".to_string(), 1.0), + ("RZ".to_string(), 1.0), + ]); + // No-effect defaults Self { p_prep: 0.0, - p_idle_coherent: false, + p_idle_quadratic_coherent: false, p_idle_linear_rate: 0.0, p_idle_linear_model: SingleQubitWeightedSampler::new(&p1_pauli_model), p_idle_quadratic_rate: 0.0, p_idle_sin_squared_rate: 0.0, p_idle_sin_squared_model: BTreeMap::new(), + p_idle_coherent_rate: 0.0, + p_idle_coherent_model, p_meas_0, p_meas_1, p1: 0.0, diff --git a/crates/pecos-engines/tests/noise_test.rs b/crates/pecos-engines/tests/noise_test.rs index 085db8ca9..65788347e 100644 --- a/crates/pecos-engines/tests/noise_test.rs +++ b/crates/pecos-engines/tests/noise_test.rs @@ -623,7 +623,7 @@ fn test_coherent_vs_incoherent_dephasing() { .with_p_meas_1(0.01) .with_average_p1(0.05) .with_average_p2(0.1) - .with_p_idle_coherent(true) + .with_p_idle_quadratic_coherent(true) .with_seed(42) .build(); @@ -636,7 +636,7 @@ fn test_coherent_vs_incoherent_dephasing() { .with_p_meas_1(0.01) .with_average_p1(0.05) .with_average_p2(0.1) - .with_p_idle_coherent(false) + .with_p_idle_quadratic_coherent(false) .with_p_idle_coherent_to_incoherent_factor(2.0) .with_seed(42) .build(); diff --git a/crates/pecos-qasm/examples/general_noise_builder.rs b/crates/pecos-qasm/examples/general_noise_builder.rs index 32af92b77..f5ee53ad2 100644 --- a/crates/pecos-qasm/examples/general_noise_builder.rs +++ b/crates/pecos-qasm/examples/general_noise_builder.rs @@ -117,7 +117,7 @@ fn main() -> Result<(), Box> { .with_average_p2(0.008) .with_p_meas_0(0.001) .with_p_meas_1(0.003) - .with_p_idle_coherent(false) + .with_p_idle_quadratic_coherent(false) .with_p_idle_linear_rate(0.0001) .with_noiseless_gate(GateType::H) .with_noiseless_gate(GateType::CX); diff --git a/crates/pecos-qasm/src/config.rs b/crates/pecos-qasm/src/config.rs index 9cee77246..bf17d8cd2 100644 --- a/crates/pecos-qasm/src/config.rs +++ b/crates/pecos-qasm/src/config.rs @@ -61,7 +61,7 @@ pub struct GeneralNoiseFields { // Idle noise parameters #[serde(skip_serializing_if = "Option::is_none")] - pub p_idle_coherent: Option, + pub p_idle_quadratic_coherent: Option, #[serde(skip_serializing_if = "Option::is_none")] pub p_idle_linear_rate: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -208,8 +208,8 @@ impl GeneralNoiseFields { /// Apply idle noise parameters to the builder fn apply_idle_params(&self, mut builder: GeneralNoiseModelBuilder) -> GeneralNoiseModelBuilder { - if let Some(v) = self.p_idle_coherent { - builder = builder.with_p_idle_coherent(v); + if let Some(v) = self.p_idle_quadratic_coherent { + builder = builder.with_p_idle_quadratic_coherent(v); } if let Some(v) = self.p_idle_linear_rate { builder = builder.with_p_idle_linear_rate(v); diff --git a/crates/pecos-qasm/tests/general_noise_builder_test.rs b/crates/pecos-qasm/tests/general_noise_builder_test.rs index 995621fbb..3979ed1ce 100644 --- a/crates/pecos-qasm/tests/general_noise_builder_test.rs +++ b/crates/pecos-qasm/tests/general_noise_builder_test.rs @@ -293,7 +293,7 @@ fn test_general_noise_builder_chaining_all_methods() { .with_average_p2(0.008) .with_p_meas_0(0.002) .with_p_meas_1(0.003) - .with_p_idle_coherent(false) + .with_p_idle_quadratic_coherent(false) .with_p_idle_linear_rate(0.0001) .with_noiseless_gate(GateType::H) .with_noiseless_gate(GateType::CX); diff --git a/crates/pecos-qasm/tests/general_noise_builder_test.rs.disabled b/crates/pecos-qasm/tests/general_noise_builder_test.rs.disabled index 6e62139e1..cff8fda20 100644 --- a/crates/pecos-qasm/tests/general_noise_builder_test.rs.disabled +++ b/crates/pecos-qasm/tests/general_noise_builder_test.rs.disabled @@ -284,7 +284,7 @@ fn test_general_noise_builder_chaining_all_methods() { .with_average_p2(0.008) .with_p_meas_0(0.002) .with_p_meas_1(0.003) - .with_p_idle_coherent(false) + .with_p_idle_quadratic_coherent(false) .with_p_idle_linear_rate(0.0001) .with_noiseless_gate(GateType::H) .with_noiseless_gate(GateType::CX); diff --git a/crates/pecos-qasm/tests/general_noise_config_test.rs.disabled b/crates/pecos-qasm/tests/general_noise_config_test.rs.disabled index 897907966..d9a6b2ad9 100644 --- a/crates/pecos-qasm/tests/general_noise_config_test.rs.disabled +++ b/crates/pecos-qasm/tests/general_noise_config_test.rs.disabled @@ -68,7 +68,7 @@ fn test_general_noise_json_complex() { "ZY": 0.06, "ZZ": 0.04 }, - "p_idle_coherent": false, + "p_idle_quadratic_coherent": false, "p_idle_linear_rate": 0.0001, "leakage_scale": 0.5, "emission_scale": 0.8, @@ -89,7 +89,7 @@ fn test_general_noise_json_complex() { ); assert!(fields.p1_pauli_model.is_some()); assert!(fields.p2_pauli_model.is_some()); - assert_eq!(fields.p_idle_coherent, Some(false)); + assert_eq!(fields.p_idle_quadratic_coherent, Some(false)); assert_eq!(fields.p_idle_linear_rate, Some(0.0001)); assert_eq!(fields.leakage_scale, Some(0.5)); assert_eq!(fields.emission_scale, Some(0.8)); diff --git a/docs/user-guide/noise-model-builders.md b/docs/user-guide/noise-model-builders.md index 971f9c098..05b5fe4c4 100644 --- a/docs/user-guide/noise-model-builders.md +++ b/docs/user-guide/noise-model-builders.md @@ -130,10 +130,32 @@ noise = ( `Idle` gates are timing markers by default. They do not silently inherit single-qubit gate noise from `p1` or `with_p1(...)`. -Configure idle decoherence with `with_p_idle_linear_rate(...)` and optionally -`with_p_idle_linear_model(...)`, or with `with_p_idle_quadratic_rate(...)` and -`with_p_idle_coherent(...)`. The rates are combined with each `Idle` gate's -duration. +Configure idle decoherence with any combination of these independent families: + +- `with_p_idle_linear(rate, model)` samples one linear-rate event from a + normalized X/Y/Z/L distribution. +- `with_p_idle_sin_squared(rate, model)` independently samples each X/Y/Z/L + mechanism with `sin²(rate * multiplier * duration)`. Its rate is radians per + time unit and its multipliers are intentionally unnormalized because each + axis has its own rate. +- `with_p_idle_coherent(rate, model)` deterministically applies RX/RY/RZ with + angle `rate * multiplier * duration`. Its rate is radians per time unit, with + no `2*pi` or coherent-to-incoherent conversion. Its model is also + intentionally unnormalized: the values are relative generator-rate + multipliers, not probabilities. Omitting the Python model uses + `{"RX": 1.0, "RY": 1.0, "RZ": 1.0}`. + +The legacy `with_p_idle_quadratic_rate(...)` path remains available. +`with_p_idle_quadratic_coherent(...)` selects whether that one legacy rate emits +coherent RZ rotations or a stochastic sine-squared Z twirl; it does not control +the independent coherent family. Combining that switch set to `True` with the +coherent family is rejected because both would emit idle rotations. + +Coherent evolution is not sampled and consumes no RNG draws. Whether it can be +consumed depends on the downstream consumer: the standard DEM builder rejects +coherent idle noise, the EEG route in `exp/pecos-eeg` represents it with an RZ +generator, and a simulator applies it only when it has a rotation executor. +PECOS #437 tracks the case where a missing executor silently dropped rotations. To add the same kind of idle-noise site to both qubits after every two-qubit gate, set its duration with `with_idle_after_2q(...)`: @@ -143,11 +165,11 @@ noise = GeneralNoiseModelBuilder().with_p_idle_linear_rate(0.01).with_idle_after ``` The duration only chooses where and how long idling occurs. It is not a -standalone probability: all configured linear and quadratic idle mechanisms -apply at these sites just as they do at a scheduled `Idle` gate. A duration of -`0.0` disables the after-two-qubit sites. Consequently, code that previously -used `with_p2_idle(0.01)` without a linear idle rate now produces no after-2q -idle noise; the equivalent configuration is +standalone probability: all configured linear, sine-squared, coherent, and +legacy quadratic idle mechanisms apply at these sites just as they do at a +scheduled `Idle` gate. A duration of `0.0` disables the after-two-qubit sites. +Consequently, code that previously used `with_p2_idle(0.01)` without a linear +idle rate now produces no after-2q idle noise; the equivalent configuration is `with_p_idle_linear_rate(0.01).with_idle_after_2q(1.0)`. ## Common Noise Model Examples diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index e12206b4f..78dcf892f 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -234,7 +234,7 @@ noise = ( .with_p_prep(0.02) .with_p_idle_linear_rate(0.01) .with_p_idle_linear_model({"X": 0.25, "Y": 0.25, "Z": 0.5}) - .with_p_idle_coherent(False) + .with_p_idle_quadratic_coherent(False) .with_p_idle_quadratic_rate(0.03 / math.pi) .with_idle_after_2q(1.0) ) diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index 2a0bb5f6f..7acfba0a7 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -556,7 +556,7 @@ def _noise_model_description(args: argparse.Namespace) -> str: sim_noise_model = getattr(args, "sim_noise_model", "depolarizing") base = f"p1={p1s:.4g}*p, p2=p, p_meas={pms:.4g}*p, p_prep={pps:.4g}*p" if sim_noise_model == "general": - return f"general_noise runtime ({base}, leak2depolar=True, p_idle_coherent=False)" + return f"general_noise runtime ({base}, leak2depolar=True, p_idle_quadratic_coherent=False)" return f"depolarizing runtime ({base})" @@ -962,7 +962,7 @@ def run_direct_selene_backend(*, simulator: object) -> dict[str, list[list[int]] .with_p1(physical_error_rate * p1_scale) .with_p2(physical_error_rate) .with_leakage_scale(0.0) - .with_p_idle_coherent(use_coherent_idle) + .with_p_idle_quadratic_coherent(use_coherent_idle) .with_seed(seed) ) elif sim_noise_model == "depolarizing": @@ -3726,7 +3726,7 @@ def _parse_args() -> argparse.Namespace: default="depolarizing", help=( "Runtime noise model used by --sample-backend sim. The 'general' " - "option sets leak2depolar=True and p_idle_coherent=False." + "option sets leak2depolar=True and p_idle_quadratic_coherent=False." ), ) parser.add_argument( diff --git a/python/pecos-rslib/src/engine_builders.rs b/python/pecos-rslib/src/engine_builders.rs index 68e91eaae..959aa1587 100644 --- a/python/pecos-rslib/src/engine_builders.rs +++ b/python/pecos-rslib/src/engine_builders.rs @@ -24,6 +24,7 @@ type RustStateVectorEngineBuilder = StateVectorEngineBuilder; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; +use pyo3::types::PyBool; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -1087,10 +1088,16 @@ impl PyGeneralNoiseModelBuilder { }) } - /// Set whether to use coherent dephasing for idle errors - fn with_p_idle_coherent(&self, use_coherent: bool) -> PyResult { + /// Set whether the legacy quadratic idle rate uses coherent dephasing. + /// + /// This switch affects only ``with_p_idle_quadratic_rate``. Use + /// ``with_p_idle_coherent`` to configure the independent coherent family. + fn with_p_idle_quadratic_coherent(&self, use_coherent: bool) -> PyResult { Ok(Self { - inner: self.inner.clone().with_p_idle_coherent(use_coherent), + inner: self + .inner + .clone() + .with_p_idle_quadratic_coherent(use_coherent), }) } @@ -1168,6 +1175,50 @@ impl PyGeneralNoiseModelBuilder { }) } + /// Set the DEM-style coherent idle-noise family. + /// + /// ``rate`` is radians per time unit. No ``2*pi`` conversion and no + /// ``coherent_to_incoherent_factor`` is applied. For each RX/RY/RZ generator P, multiplier + /// ``n_P``, and duration ``d``, engines deterministically applies a rotation with angle + /// ``rate * n_P * d``. Coherent evolution is not sampled and consumes no random draw. + /// + /// The model is intentionally unnormalized because its values are relative rate multipliers, + /// not probabilities to be split from one total event rate. It defaults to + /// ``{"RX": 1.0, "RY": 1.0, "RZ": 1.0}``. Leakage and all other keys are rejected because + /// leakage is not a rotation. + /// + /// Consumption is consumer-dependent: the standard DEM builder rejects coherent idle noise; + /// the EEG route in ``exp/pecos-eeg`` represents it with an RZ generator; and a simulator + /// applies it only when its rotation executor is installed. PECOS #437 documents how a + /// missing executor could otherwise silently drop it. + #[pyo3(signature = (rate, model=None))] + fn with_p_idle_coherent( + &self, + rate: &Bound<'_, PyAny>, + model: Option>, + ) -> PyResult { + if rate.is_instance_of::() { + return Err(pyo3::exceptions::PyTypeError::new_err( + "coherent idling rate must be a finite, non-negative float, not bool", + )); + } + let rate = rate.extract::().map_err(|_| { + pyo3::exceptions::PyTypeError::new_err( + "coherent idling rate must be a finite, non-negative float", + ) + })?; + let model = model.unwrap_or_else(|| { + std::collections::BTreeMap::from([ + ("RX".to_string(), 1.0), + ("RY".to_string(), 1.0), + ("RZ".to_string(), 1.0), + ]) + }); + Ok(Self { + inner: self.inner.clone().with_p_idle_coherent(rate, &model), + }) + } + /// Set the stochastic model for idling that is linearly dependent on time fn with_p_idle_linear_model( &self, @@ -1317,7 +1368,8 @@ impl PyGeneralNoiseModelBuilder { /// A duration of `0.0` disables these sites. Nonzero sites receive all configured idle /// mechanisms over the given duration: linear stochastic noise from `p_idle_linear_rate` and /// `p_idle_linear_model`, quadratic dephasing from `p_idle_quadratic_rate` honoring - /// `p_idle_coherent`, and the independent per-axis sine-squared family. + /// `p_idle_quadratic_coherent`, plus the independent per-axis sine-squared and coherent + /// families. /// /// Anyone who previously wrote `with_p2_idle(0.01)` and no linear rate now gets no after-2q /// idle noise; the equivalent is diff --git a/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py b/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py index 52273e4b7..cadfdaa67 100644 --- a/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py +++ b/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py @@ -79,6 +79,21 @@ def _idle_family_noise(*, sine_rate: float, sine_model: dict[str, float], seed: ) +def _coherent_idle_noise(*, rate: float, model: dict[str, float] | None = None): + noise = ( + general_noise() + .with_p_prep(0.0) + .with_p1(0.0) + .with_p2(0.0) + .with_p_meas(0.0) + .with_p_idle_linear(0.0, {"Z": 1.0}) + .with_idle_after_2q(1.0) + ) + if model is None: + return noise.with_p_idle_coherent(rate) + return noise.with_p_idle_coherent(rate, model) + + @pytest.mark.parametrize(("factory", "setters"), BUILDER_SETTERS) def test_field_name_setters_are_chainable(factory, setters) -> None: """Every field-name setter exists and returns a builder that keeps chaining.""" @@ -165,9 +180,31 @@ def run(noise) -> list[bool]: def test_idle_family_setters_are_chainable() -> None: - """The structured linear and sine families are present on the pyo3 fluent builder.""" + """All structured idle families and the renamed quadratic switch are fluent.""" builder = general_noise().with_p_idle_linear(0.01, {"X": 0.5, "L": 0.5}) - assert builder.with_p_idle_sin_squared(0.02, {"X": 1.0, "Z": 2.0, "L": 0.25}) is not None + builder = builder.with_p_idle_sin_squared(0.02, {"X": 1.0, "Z": 2.0, "L": 0.25}) + builder = builder.with_p_idle_coherent(0.03, {"RX": 1.0, "RZ": 2.0}) + assert builder.with_p_idle_quadratic_coherent(False) is not None + + +def test_retired_coherent_bool_switch_is_not_an_alias() -> None: + """The old one-bool call cannot silently become a zero/one coherent-family rate.""" + with pytest.raises(TypeError, match=r"coherent idling rate.*not bool"): + general_noise().with_p_idle_coherent(False) + + +def test_coherent_idle_default_model_is_available() -> None: + """Omitting the pyo3 model selects the documented symmetric RX/RY/RZ multipliers.""" + implicit = _coherent_idle_noise(rate=math.pi) + explicit = _coherent_idle_noise(rate=math.pi, model={"RX": 1.0, "RY": 1.0, "RZ": 1.0}) + assert _run_after_2q_noise(implicit, 64, seed=424) == _run_after_2q_noise(explicit, 64, seed=424) + + +def test_coherent_idle_rx_reaches_runtime_deterministically() -> None: + """A pi RX idle rotation after CX flips both measured qubits for every seed.""" + noise = _coherent_idle_noise(rate=math.pi, model={"RX": 1.0}) + assert _run_after_2q_noise(noise, 10, seed=1) == [3] * 10 + assert _run_after_2q_noise(noise, 10, seed=999) == [3] * 10 def test_linear_idle_family_rejects_unnormalized_model() -> None: @@ -197,11 +234,25 @@ def test_legacy_quadratic_and_sine_family_conflict_at_build() -> None: def test_sine_family_and_coherent_legacy_path_conflict_at_build() -> None: """The stochastic family cannot silently ignore the legacy coherent switch.""" - noise = general_noise().with_p_idle_sin_squared(0.02, {"Z": 1.0}).with_p_idle_coherent(True) - with pytest.raises(ValueError, match=r"with_p_idle_coherent\(true\).*stochastic by definition"): + noise = general_noise().with_p_idle_sin_squared(0.02, {"Z": 1.0}).with_p_idle_quadratic_coherent(True) + with pytest.raises(ValueError, match=r"with_p_idle_quadratic_coherent\(true\).*stochastic by definition"): _run_after_2q_noise(noise) +def test_coherent_family_and_quadratic_coherent_path_conflict_at_build() -> None: + """The independent and legacy coherent paths cannot both emit rotations.""" + noise = general_noise().with_p_idle_coherent(0.02, {"RZ": 1.0}).with_p_idle_quadratic_coherent(True) + with pytest.raises(ValueError, match=r"with_p_idle_coherent.*with_p_idle_quadratic_coherent\(true\)"): + _run_after_2q_noise(noise) + + +@pytest.mark.parametrize("model", [{"L": 1.0}, {"A": 1.0}]) +def test_coherent_idle_family_rejects_non_rotation_keys(model: dict[str, float]) -> None: + """Leakage and unknown generators are rejected instead of being treated as rotations.""" + with pytest.raises(BaseException, match=r"invalid key.*expected RX, RY, or RZ"): + general_noise().with_p_idle_coherent(0.02, model) + + def test_sine_idle_family_is_deterministic_for_same_seed() -> None: """The pyo3 surface preserves the Rust model's fixed-seed draw sequence.""" first = _run_after_2q_noise(_idle_family_noise(sine_rate=0.6, sine_model={"X": 1.0}), 128) From 79e8aa833c4ed2279f65fe0954a78a45a9f3c474 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 4 Aug 2026 23:34:01 -0600 Subject: [PATCH 39/62] Convert idle noise to independent DEM mechanisms at the flip-signature layer --- .../src/fault_tolerance/dem_builder.rs | 13 +- .../fault_tolerance/dem_builder/builder.rs | 164 ++++- .../dem_builder/dem_sampler.rs | 329 ++++++++-- .../dem_builder/mem_builder.rs | 87 ++- .../fault_tolerance/dem_builder/sampler.rs | 2 +- .../src/fault_tolerance/dem_builder/types.rs | 572 +++++++++++++++--- .../src/fault_tolerance/lookup_decoder.rs | 2 +- crates/pecos-qec/tests/idle_noise_tests.rs | 457 +++++++++++++- docs/user-guide/dem-from-guppy.md | 15 +- .../src/fault_tolerance_bindings.rs | 41 +- .../src/pecos/qec/_idle_noise.py | 10 +- python/quantum-pecos/src/pecos/qec/dem.py | 36 +- .../quantum-pecos/src/pecos/qec/dem_spec.py | 1 + .../src/pecos/qec/surface/decode.py | 8 +- .../tests/qec/test_from_guppy_dem.py | 58 ++ 15 files changed, 1602 insertions(+), 193 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs index 01a6649dd..2c118bb1f 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs @@ -100,10 +100,11 @@ pub use sampler::{ pub use types::{ ContributionEffectSummary, ContributionRenderRecord, ContributionRenderStrategy, ContributionRenderSummary, DecomposedFault, DemOutput, DetectorDef, DetectorErrorModel, - DirectSourceFamily, FaultContribution, FaultMechanism, FaultSourceType, - MeasurementCrosstalkDemMode, MeasurementCrosstalkTransitionModel, MeasurementMechanism, - MeasurementNoiseModel, NoiseConfig, PAULI_1Q_ORDER, PAULI_2Q_ORDER, PauliProbs, PauliWeights, - PecosDemMetadataError, PerGateTypeNoise, ReplacementBranchApproximation, - ReplacementBranchImpact, TwoDetectorDirectRenderPolicy, combine_probabilities, - omitted_two_qubit_gate_pauli_twirl, record_offset_to_absolute_index, + DirectSourceFamily, FaultContribution, FaultMechanism, FaultSourceType, IdleNoiseError, + IdleNoiseResidual, MeasurementCrosstalkDemMode, MeasurementCrosstalkTransitionModel, + MeasurementIdleNoiseResidual, MeasurementMechanism, MeasurementNoiseModel, NoiseConfig, + PAULI_1Q_ORDER, PAULI_2Q_ORDER, PauliProbs, PauliWeights, PecosDemMetadataError, + PerGateTypeNoise, ReplacementBranchApproximation, ReplacementBranchImpact, + TwoDetectorDirectRenderPolicy, combine_probabilities, omitted_two_qubit_gate_pauli_twirl, + record_offset_to_absolute_index, }; 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 08133f20e..586793049 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -17,8 +17,9 @@ use super::types::{ DemOutput, DetectorDef, DetectorErrorModel, DirectSourceComponents, DirectSourceFamily, - FaultMechanism, MeasurementCrosstalkDemMode, NoiseConfig, PerGateTypeNoise, - ReplacementBranchApproximation, SourceMetadata, record_offset_to_absolute_index, + FaultMechanism, IdleChannelFamilies, IdleNoiseResidual, MeasurementCrosstalkDemMode, + NoiseConfig, PauliProbs, PerGateTypeNoise, ReplacementBranchApproximation, SourceMetadata, + fit_exclusive_idle_signatures, record_offset_to_absolute_index, validate_idle_probabilities, }; use crate::fault_tolerance::propagator::dag::DagSpacetimeLocation; use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Direction, Pauli, apply_gate}; @@ -432,8 +433,11 @@ impl<'a> DemBuilder<'a> { [per, per, per] } - /// Resolve `[rate_X, rate_Y, rate_Z]` for an explicit idle location. - fn idle_rates_for_loc(&self, loc: &DagSpacetimeLocation) -> [f64; 3] { + /// Resolve the categorical Pauli channel for an explicit idle location. + fn idle_probabilities_for_loc( + &self, + loc: &DagSpacetimeLocation, + ) -> Result { if let Some(pg) = &self.per_gate { let explicit_rates = loc .qubits @@ -441,22 +445,34 @@ impl<'a> DemBuilder<'a> { .and_then(|q| pg.explicit_1q_rates_on(GateType::Idle, *q)) .or_else(|| pg.explicit_1q_rates(GateType::Idle)); if let Some(rates) = explicit_rates { - return rates; + let probabilities = PauliProbs { + px: rates[0], + py: rates[1], + pz: rates[2], + }; + validate_idle_probabilities(probabilities, "per-gate") + .map_err(|error| DemBuilderError::ConfigurationError(error.to_string()))?; + return Ok(IdleChannelFamilies { + exclusive: smallvec::smallvec![probabilities], + independent: SmallVec::new(), + }); } if pg.base.uses_dedicated_idle_noise() { - let duration = loc.idle_duration.max(0.0); - let probs = pg.base.idle_pauli_probs(duration); - return [probs.px, probs.py, probs.pz]; + return pg + .base + .try_idle_channel_families(loc.idle_duration) + .map_err(|error| DemBuilderError::ConfigurationError(error.to_string())); } - return [0.0; 3]; + return Ok(IdleChannelFamilies::default()); } if self.noise.uses_dedicated_idle_noise() { - let duration = loc.idle_duration.max(0.0); - let probs = self.noise.idle_pauli_probs(duration); - return [probs.px, probs.py, probs.pz]; + return self + .noise + .try_idle_channel_families(loc.idle_duration) + .map_err(|error| DemBuilderError::ConfigurationError(error.to_string())); } - [0.0; 3] + Ok(IdleChannelFamilies::default()) } /// Resolve the 15-entry 2Q per-Pauli-pair rate array for a gate @@ -795,13 +811,16 @@ impl<'a> DemBuilder<'a> { /// 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. + /// both-present entry's `records` and `meas_ids` are not redundant. Returns + /// [`DemBuilderError::ConfigurationError`] for an invalid idle input or a + /// non-positive idle signature-channel eigenvalue. pub fn try_build(&self) -> Result { self.validate_measurement_count()?; self.validate_metadata_refs()?; self.validate_replacement_branch_approximation()?; self.validate_measurement_crosstalk_dem_mode()?; - Ok(self.build()) + self.validate_idle_noise()?; + self.build_inner() } /// Builds the Detector Error Model with source tracking. @@ -815,14 +834,22 @@ impl<'a> DemBuilder<'a> { /// circuit-derived metadata must use [`Self::try_build`] instead. /// # Panics /// - /// Panics if the configured replacement-branch approximation is invalid; - /// validity is established by construction-time validation. + /// Panics if the configured replacement-branch approximation is invalid, + /// or if an idle input or signature channel is invalid. Use + /// [`Self::try_build`] to receive those failures as errors. #[must_use] pub fn build(&self) -> DetectorErrorModel { self.validate_replacement_branch_approximation() .expect("invalid DEM replacement branch approximation"); self.validate_measurement_crosstalk_dem_mode() .expect("invalid DEM measurement crosstalk configuration"); + self.validate_idle_noise() + .expect("invalid DEM idle-noise configuration"); + self.build_inner() + .expect("invalid DEM idle signature conversion") + } + + fn build_inner(&self) -> Result { let num_influence_dem_outputs = self .num_influence_dem_outputs() .max(self.influence_map.dem_output_metadata.len()); @@ -887,9 +914,9 @@ impl<'a> DemBuilder<'a> { &mut dem, &meas_to_detectors, &meas_to_observables, - ); + )?; - dem + Ok(dem) } fn validate_replacement_branch_approximation(&self) -> Result<(), DemBuilderError> { @@ -1026,6 +1053,15 @@ impl<'a> DemBuilder<'a> { Ok(()) } + fn validate_idle_noise(&self) -> Result<(), DemBuilderError> { + for loc in &self.influence_map.locations { + if loc.gate_type == GateType::Idle && !loc.before { + let _ = self.idle_probabilities_for_loc(loc)?; + } + } + Ok(()) + } + fn hidden_mz_result_before_crosstalk_payload( context: ExactBranchReplayContext<'_>, loc: &DagSpacetimeLocation, @@ -1413,7 +1449,7 @@ impl<'a> DemBuilder<'a> { dem: &mut DetectorErrorModel, meas_to_detectors: &BTreeMap>, meas_to_observables: &BTreeMap>, - ) { + ) -> Result<(), DemBuilderError> { let locations = &self.influence_map.locations; for (loc_idx, loc) in locations.iter().enumerate() { @@ -1511,15 +1547,15 @@ impl<'a> DemBuilder<'a> { } } GateType::Idle if !loc.before => { - let rates = self.idle_rates_for_loc(loc); - if rates.iter().any(|r| *r > 0.0) { - self.process_single_qubit_fault_source_tracked( + let families = self.idle_probabilities_for_loc(loc)?; + if !families.exclusive.is_empty() || !families.independent.is_empty() { + self.process_idle_fault_source_tracked( loc_idx, - rates, + families, dem, meas_to_detectors, meas_to_observables, - ); + )?; } } _ => {} @@ -1560,6 +1596,7 @@ impl<'a> DemBuilder<'a> { ); } } + Ok(()) } /// Processes a prep fault with source tracking. @@ -1848,6 +1885,83 @@ impl<'a> DemBuilder<'a> { } } + /// Converts one categorical idle Pauli channel after propagation has + /// produced its concrete detector/observable flip signatures. + fn process_idle_fault_source_tracked( + &self, + loc_idx: usize, + families: IdleChannelFamilies, + dem: &mut DetectorErrorModel, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, + ) -> Result<(), DemBuilderError> { + let x_effect = + self.compute_mechanism(loc_idx, Pauli::X, meas_to_detectors, meas_to_observables); + let y_effect = + self.compute_mechanism(loc_idx, Pauli::Y, meas_to_detectors, meas_to_observables); + let z_effect = + self.compute_mechanism(loc_idx, Pauli::Z, meas_to_detectors, meas_to_observables); + debug_assert_eq!(y_effect, x_effect.xor(&z_effect)); + + let loc = &self.influence_map.locations[loc_idx]; + for (family_index, probabilities) in families.exclusive.into_iter().enumerate() { + let mut exclusive = BTreeMap::new(); + for (effect, probability) in [ + (x_effect.clone(), probabilities.px), + (y_effect.clone(), probabilities.py), + (z_effect.clone(), probabilities.pz), + ] { + if effect.is_empty() || probability == 0.0 { + continue; + } + *exclusive.entry(effect).or_insert(0.0) += probability; + } + + let context = format!("location {loc_idx} exclusive family {family_index}"); + let fit = fit_exclusive_idle_signatures(exclusive, FaultMechanism::xor, &context) + .map_err(|error| DemBuilderError::ConfigurationError(error.to_string()))?; + for (effect, probability) in fit.mechanisms { + Self::add_idle_signature_contribution(loc_idx, loc, effect, probability, dem); + } + if let Some((effect, magnitude)) = fit.residual { + dem.add_idle_noise_residual(IdleNoiseResidual { + location_index: u32::try_from(loc_idx) + .expect("fault-location index must fit in residual metadata"), + effect, + magnitude, + }); + } + } + for probabilities in families.independent { + for (effect, probability) in [ + (x_effect.clone(), probabilities.px), + (y_effect.clone(), probabilities.py), + (z_effect.clone(), probabilities.pz), + ] { + Self::add_idle_signature_contribution(loc_idx, loc, effect, probability, dem); + } + } + Ok(()) + } + + fn add_idle_signature_contribution( + loc_idx: usize, + loc: &DagSpacetimeLocation, + effect: FaultMechanism, + probability: f64, + dem: &mut DetectorErrorModel, + ) { + if effect.is_empty() || probability == 0.0 { + return; + } + dem.add_direct_contribution_with_source( + effect, + probability, + SourceMetadata::new(&[loc_idx], &[], &[loc.gate_type], &[loc.before]) + .with_direct_source_family(DirectSourceFamily::IdleSignature), + ); + } + /// Processes a single-qubit gate fault with source tracking. /// `rates` is `[rate_X, rate_Y, rate_Z]` -- zero entries are skipped. fn process_single_qubit_fault_source_tracked( 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 ce77e4336..f4da76610 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 @@ -72,8 +72,9 @@ use std::collections::{BTreeMap, BTreeSet}; use wide::u64x4; use super::types::{ - NoiseConfig, PauliWeights, PerGateTypeNoise, ReplacementBranchApproximation, - combine_probabilities, + FaultMechanism, IdleChannelFamilies, IdleNoiseResidual, NoiseConfig, PauliProbs, PauliWeights, + PerGateTypeNoise, ReplacementBranchApproximation, combine_probabilities, + fit_exclusive_idle_signatures, validate_idle_probabilities, }; // ============================================================================ @@ -110,6 +111,47 @@ impl DemMechanism { fn is_empty(&self) -> bool { self.detectors.is_empty() && self.dem_outputs.is_empty() } + + fn xor(&self, other: &Self) -> Self { + fn symmetric_difference( + left: &SmallVec<[u32; N]>, + right: &SmallVec<[u32; N]>, + ) -> SmallVec<[u32; N]> + where + [u32; N]: smallvec::Array, + { + let mut result = SmallVec::new(); + let (mut i, mut j) = (0, 0); + while i < left.len() && j < right.len() { + match left[i].cmp(&right[j]) { + std::cmp::Ordering::Less => { + result.push(left[i]); + i += 1; + } + std::cmp::Ordering::Greater => { + result.push(right[j]); + j += 1; + } + std::cmp::Ordering::Equal => { + i += 1; + j += 1; + } + } + } + result.extend_from_slice(&left[i..]); + result.extend_from_slice(&right[j..]); + result + } + + Self { + detectors: symmetric_difference(&self.detectors, &other.detectors), + dem_outputs: symmetric_difference(&self.dem_outputs, &other.dem_outputs), + } + } + + fn as_fault_mechanism(&self) -> FaultMechanism { + FaultMechanism::from_sorted(self.detectors.clone(), self.dem_outputs.clone()) + } } // ============================================================================ @@ -226,6 +268,8 @@ pub struct SamplingEngine { num_detectors: usize, /// Number of DEM `L` outputs. num_dem_outputs: usize, + /// Quantified approximations introduced by idle signature conversion. + idle_noise_residuals: Vec, } const U32_BASE_AS_F64: f64 = 4_294_967_296.0; @@ -266,6 +310,12 @@ impl SamplingEngine { self.num_dem_outputs } + /// Returns quantified idle-channel approximations made while building. + #[must_use] + pub fn idle_noise_residuals(&self) -> &[IdleNoiseResidual] { + &self.idle_noise_residuals + } + /// Reconstruct a [`DetectorErrorModel`] from the aggregated `SoA` /// mechanism state for text output (e.g. Stim-format via /// [`DetectorErrorModel::to_string`]). @@ -293,6 +343,9 @@ impl SamplingEngine { ); dem.add_direct_contribution(mechanism, prob); } + for residual in &self.idle_noise_residuals { + dem.add_idle_noise_residual(residual.clone()); + } dem } @@ -366,6 +419,7 @@ impl SamplingEngine { dem_output_data, num_detectors, num_dem_outputs, + idle_noise_residuals: Vec::new(), } } @@ -391,6 +445,11 @@ impl SamplingEngine { /// and each non-identity Pauli is equally likely (p/3 for 1-qubit, /// p/15 for 2-qubit). For idle gates with T1/T2 noise, the Pauli /// distribution is biased (more Z than X/Y). + /// + /// # Panics + /// + /// Panics if an idle-noise input or signature channel is invalid, or if an + /// idle gate event has no corresponding fault location. #[must_use] pub fn from_influence_map( influence_map: &DagFaultInfluenceMap, @@ -400,6 +459,7 @@ impl SamplingEngine { use pecos_core::gate_type::GateType; let mut aggregated: BTreeMap = BTreeMap::new(); + let mut idle_noise_residuals = Vec::new(); let gate_locs = influence_map.gate_fault_locations(); @@ -418,49 +478,120 @@ impl SamplingEngine { continue; } - // For idle gates with T1/T2 noise, use per-Pauli probabilities. - // For all other gates, divide equally among events. let is_idle = loc.gate_type == GateType::Idle; - let idle_pauli_probs = if is_idle { + if is_idle { let duration = influence_map .locations .iter() .find(|l| l.node == loc.node && l.before == loc.before) - .map_or(0.0, |l| l.idle_duration.max(0.0)); - Some(noise.idle_pauli_probs(duration)) - } else { - None - }; + .map_or(0.0, |l| l.idle_duration); + let families = noise + .try_idle_channel_families(duration) + .unwrap_or_else(|error| { + panic!("invalid DEM idle-noise configuration: {error}") + }); + let mut effects: [Option; 4] = [None, None, None, None]; + for event in &events { + let pauli = event + .pauli + .paulis() + .first() + .map_or(pecos_core::Pauli::I, |&(pauli, _)| pauli); + let detectors = event.detectors.iter().copied().collect(); + let dem_outputs = event + .dem_outputs + .iter() + .filter_map(|&idx| influence_map.observable_id_for_internal_dem_output(idx)) + .collect(); + let pauli_index = match pauli { + pecos_core::Pauli::I => 0, + pecos_core::Pauli::X => 1, + pecos_core::Pauli::Y => 2, + pecos_core::Pauli::Z => 3, + }; + effects[pauli_index] = Some(DemMechanism::new(detectors, dem_outputs)); + } + let x = effects[Pauli::X.as_u8() as usize] + .clone() + .unwrap_or_else(DemMechanism::empty); + let y = effects[Pauli::Y.as_u8() as usize] + .clone() + .unwrap_or_else(DemMechanism::empty); + let z = effects[Pauli::Z.as_u8() as usize] + .clone() + .unwrap_or_else(DemMechanism::empty); + debug_assert_eq!(y, x.xor(&z)); + let loc_idx = influence_map + .locations + .iter() + .position(|candidate| { + candidate.node == loc.node && candidate.before == loc.before + }) + .expect("idle gate location must have a fault location"); + + for (family_index, probabilities) in families.exclusive.into_iter().enumerate() { + let mut exclusive = BTreeMap::new(); + for (mechanism, probability) in [ + (x.clone(), probabilities.px), + (y.clone(), probabilities.py), + (z.clone(), probabilities.pz), + ] { + if mechanism.is_empty() || probability == 0.0 { + continue; + } + *exclusive.entry(mechanism).or_insert(0.0) += probability; + } + let context = format!("location {loc_idx} exclusive family {family_index}"); + let fit = fit_exclusive_idle_signatures(exclusive, DemMechanism::xor, &context) + .unwrap_or_else(|error| { + panic!("invalid DEM idle-noise configuration: {error}") + }); + for (mechanism, probability) in fit.mechanisms { + aggregated + .entry(mechanism) + .and_modify(|held| { + *held = combine_probabilities(*held, probability); + }) + .or_insert(probability); + } + if let Some((mechanism, magnitude)) = fit.residual { + idle_noise_residuals.push(IdleNoiseResidual { + location_index: u32::try_from(loc_idx) + .expect("fault-location index must fit in residual metadata"), + effect: mechanism.as_fault_mechanism(), + magnitude, + }); + } + } + for probabilities in families.independent { + for (mechanism, probability) in [ + (x.clone(), probabilities.px), + (y.clone(), probabilities.py), + (z.clone(), probabilities.pz), + ] { + if mechanism.is_empty() || probability == 0.0 { + continue; + } + aggregated + .entry(mechanism) + .and_modify(|held| { + *held = combine_probabilities(*held, probability); + }) + .or_insert(probability); + } + } + continue; + } // Get per-event probabilities based on gate type and noise config let n_qubits = loc.num_qubits(); - let custom_weights = if idle_pauli_probs.is_some() { - None - } else if n_qubits == 1 { + let custom_weights = if n_qubits == 1 { noise.p1_weights.as_ref() } else { noise.p2_weights.as_ref() }; - let event_weights: Vec = if let Some(pp) = &idle_pauli_probs { - // T1/T2 idle: absolute per-Pauli probabilities - events - .iter() - .map(|event| { - let pauli = event - .pauli - .paulis() - .first() - .map_or(pecos_core::Pauli::I, |&(pa, _)| pa); - match pauli { - pecos_core::Pauli::X => pp.px, - pecos_core::Pauli::Y => pp.py, - pecos_core::Pauli::Z => pp.pz, - pecos_core::Pauli::I => 0.0, - } - }) - .collect() - } else if let Some(weights) = custom_weights { + let event_weights: Vec = if let Some(weights) = custom_weights { // Custom per-Pauli weights: p * weight_for(pauli) events .iter() @@ -508,7 +639,9 @@ impl SamplingEngine { .into_iter() .map(|(mech, prob)| (prob, mech.detectors.to_vec(), mech.dem_outputs.to_vec())); - Self::from_mechanisms(mechanisms, num_detectors, num_dem_outputs) + let mut engine = Self::from_mechanisms(mechanisms, num_detectors, num_dem_outputs); + engine.idle_noise_residuals = idle_noise_residuals; + engine } /// Sample a single shot. @@ -2035,6 +2168,7 @@ impl<'a> SamplingEngineBuilder<'a> { // Aggregation map: mechanism -> probability let mut aggregated: BTreeMap = BTreeMap::new(); + let mut idle_noise_residuals = Vec::new(); // Group two-qubit gate locations by node for paired processing let mut cx_groups: BTreeMap> = BTreeMap::new(); @@ -2127,13 +2261,14 @@ impl<'a> SamplingEngineBuilder<'a> { // explicitly configured. if !loc.before => { - let rates = self.idle_rates(loc); - if rates.iter().any(|r| *r > 0.0) { - self.process_depolarizing_fault_rates( + let families = self.idle_families(loc); + if !families.exclusive.is_empty() || !families.independent.is_empty() { + self.process_idle_fault_families( loc_idx, - rates, + families, &mechanism_context, &mut aggregated, + &mut idle_noise_residuals, ); } } @@ -2229,6 +2364,7 @@ impl<'a> SamplingEngineBuilder<'a> { dem_output_data, num_detectors, num_dem_outputs, + idle_noise_residuals, } } @@ -2348,11 +2484,11 @@ impl<'a> SamplingEngineBuilder<'a> { } } - /// Resolve per-Pauli rates for an explicit idle location. - fn idle_rates( + /// Resolve categorical and independent families for an explicit idle location. + fn idle_families( &self, loc: &crate::fault_tolerance::propagator::dag::DagSpacetimeLocation, - ) -> [f64; 3] { + ) -> IdleChannelFamilies { if let Some(pg) = &self.per_gate { let explicit_rates = loc .qubits @@ -2360,24 +2496,38 @@ impl<'a> SamplingEngineBuilder<'a> { .and_then(|q| pg.explicit_1q_rates_on(GateType::Idle, *q)) .or_else(|| pg.explicit_1q_rates(GateType::Idle)); if let Some(rates) = explicit_rates { - return rates; + let probabilities = PauliProbs { + px: rates[0], + py: rates[1], + pz: rates[2], + }; + validate_idle_probabilities(probabilities, "per-gate").unwrap_or_else(|error| { + panic!("invalid DEM idle-noise configuration: {error}") + }); + return IdleChannelFamilies { + exclusive: smallvec::smallvec![probabilities], + independent: SmallVec::new(), + }; } if pg.base.uses_dedicated_idle_noise() { - let duration = loc.idle_duration.max(0.0); - let probs = pg.base.idle_pauli_probs(duration); - return [probs.px, probs.py, probs.pz]; + return pg + .base + .try_idle_channel_families(loc.idle_duration) + .unwrap_or_else(|error| { + panic!("invalid DEM idle-noise configuration: {error}") + }); } - return [0.0; 3]; + return IdleChannelFamilies::default(); } if let Some(noise) = &self.idle_noise && noise.uses_dedicated_idle_noise() { - let duration = loc.idle_duration.max(0.0); - let probs = noise.idle_pauli_probs(duration); - return [probs.px, probs.py, probs.pz]; + return noise + .try_idle_channel_families(loc.idle_duration) + .unwrap_or_else(|error| panic!("invalid DEM idle-noise configuration: {error}")); } - [0.0; 3] + IdleChannelFamilies::default() } /// Resolve per-Pauli-pair rates for a 2Q gate (15 non-II pairs) on a @@ -2435,6 +2585,87 @@ impl<'a> SamplingEngineBuilder<'a> { } } + fn process_idle_fault_families( + &self, + loc_idx: usize, + families: IdleChannelFamilies, + context: &FaultMechanismContext<'_>, + aggregated: &mut BTreeMap, + residuals: &mut Vec, + ) { + let x_mechanism = self.compute_mechanism( + loc_idx, + Pauli::X, + context.im_to_tc, + context.influence_observable_ids, + context.num_tc_measurements, + ); + let y_mechanism = self.compute_mechanism( + loc_idx, + Pauli::Y, + context.im_to_tc, + context.influence_observable_ids, + context.num_tc_measurements, + ); + let z_mechanism = self.compute_mechanism( + loc_idx, + Pauli::Z, + context.im_to_tc, + context.influence_observable_ids, + context.num_tc_measurements, + ); + debug_assert_eq!(y_mechanism, x_mechanism.xor(&z_mechanism)); + + let add = |mechanism: DemMechanism, + probability: f64, + aggregated: &mut BTreeMap| { + if mechanism.is_empty() || probability == 0.0 { + return; + } + aggregated + .entry(mechanism) + .and_modify(|held| *held = combine_probabilities(*held, probability)) + .or_insert(probability); + }; + + for (family_index, probabilities) in families.exclusive.into_iter().enumerate() { + let mut exclusive = BTreeMap::new(); + for (mechanism, probability) in [ + (x_mechanism.clone(), probabilities.px), + (y_mechanism.clone(), probabilities.py), + (z_mechanism.clone(), probabilities.pz), + ] { + if mechanism.is_empty() || probability == 0.0 { + continue; + } + *exclusive.entry(mechanism).or_insert(0.0) += probability; + } + let fit_context = format!("location {loc_idx} exclusive family {family_index}"); + let fit = fit_exclusive_idle_signatures(exclusive, DemMechanism::xor, &fit_context) + .unwrap_or_else(|error| panic!("invalid DEM idle-noise configuration: {error}")); + for (mechanism, probability) in fit.mechanisms { + add(mechanism, probability, aggregated); + } + if let Some((mechanism, magnitude)) = fit.residual { + residuals.push(IdleNoiseResidual { + location_index: u32::try_from(loc_idx) + .expect("fault-location index must fit in residual metadata"), + effect: mechanism.as_fault_mechanism(), + magnitude, + }); + } + } + for probabilities in families.independent { + for (mechanism, probability) in [ + (x_mechanism.clone(), probabilities.px), + (y_mechanism.clone(), probabilities.py), + (z_mechanism.clone(), probabilities.pz), + ] { + add(mechanism, probability, aggregated); + } + } + } + /// Process a two-qubit gate fault with explicit per-Pauli-pair rates. /// `rates[i]` corresponds to [`PAULI_2Q_ORDER[i]`] ordering. fn process_two_qubit_fault_rates( diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs index 48dbbbdaf..6da564ab5 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs @@ -17,7 +17,10 @@ //! the MNM maps faults directly to raw measurement flips for fast approximate //! sampling. -use super::types::{MeasurementMechanism, MeasurementNoiseModel, NoiseConfig}; +use super::types::{ + IdleChannelFamilies, MeasurementIdleNoiseResidual, MeasurementMechanism, MeasurementNoiseModel, + NoiseConfig, fit_exclusive_idle_signatures, +}; use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Pauli}; use pecos_core::gate_type::GateType; use smallvec::SmallVec; @@ -65,6 +68,10 @@ impl<'a> MemBuilder<'a> { } /// Builds the Measurement Noise Model. + /// + /// # Panics + /// + /// Panics if an idle-noise input or signature channel is invalid. #[must_use] pub fn build(&self) -> MeasurementNoiseModel { let num_measurements = self.influence_map.measurements.len(); @@ -130,17 +137,14 @@ impl<'a> MemBuilder<'a> { } GateType::Idle if !loc.before => { if self.noise.uses_dedicated_idle_noise() { - let duration = loc.idle_duration.max(0.0); - let probs = self.noise.idle_pauli_probs(duration); - if probs.px > 0.0 { - self.process_single_pauli_fault(loc_idx, Pauli::X, probs.px, &mut mem); - } - if probs.py > 0.0 { - self.process_single_pauli_fault(loc_idx, Pauli::Y, probs.py, &mut mem); - } - if probs.pz > 0.0 { - self.process_single_pauli_fault(loc_idx, Pauli::Z, probs.pz, &mut mem); - } + let duration = loc.idle_duration; + let families = self + .noise + .try_idle_channel_families(duration) + .unwrap_or_else(|error| { + panic!("invalid DEM idle-noise configuration: {error}") + }); + self.process_idle_fault(loc_idx, families, &mut mem); } else if self.noise.p1 > 0.0 { self.process_single_qubit_fault(loc_idx, &mut mem); } @@ -209,6 +213,65 @@ impl<'a> MemBuilder<'a> { } } + fn process_idle_fault( + &self, + loc_idx: usize, + families: IdleChannelFamilies, + mem: &mut MeasurementNoiseModel, + ) { + let x_mechanism = self.compute_mechanism(loc_idx, Pauli::X); + let y_mechanism = self.compute_mechanism(loc_idx, Pauli::Y); + let z_mechanism = self.compute_mechanism(loc_idx, Pauli::Z); + debug_assert_eq!( + y_mechanism, + xor_measurement_mechanisms(Some(&x_mechanism), Some(&z_mechanism)) + ); + + for (family_index, probabilities) in families.exclusive.into_iter().enumerate() { + let mut exclusive = std::collections::BTreeMap::new(); + for (mechanism, probability) in [ + (x_mechanism.clone(), probabilities.px), + (y_mechanism.clone(), probabilities.py), + (z_mechanism.clone(), probabilities.pz), + ] { + if mechanism.is_empty() || probability == 0.0 { + continue; + } + *exclusive.entry(mechanism).or_insert(0.0) += probability; + } + + let context = format!("location {loc_idx} exclusive family {family_index}"); + let fit = fit_exclusive_idle_signatures( + exclusive, + |left, right| xor_measurement_mechanisms(Some(left), Some(right)), + &context, + ) + .unwrap_or_else(|error| panic!("invalid DEM idle-noise configuration: {error}")); + for (mechanism, probability) in fit.mechanisms { + mem.add_mechanism(mechanism, probability); + } + if let Some((mechanism, magnitude)) = fit.residual { + mem.add_idle_noise_residual(MeasurementIdleNoiseResidual { + location_index: u32::try_from(loc_idx) + .expect("fault-location index must fit in residual metadata"), + mechanism, + magnitude, + }); + } + } + for probabilities in families.independent { + for (mechanism, probability) in [ + (x_mechanism.clone(), probabilities.px), + (y_mechanism.clone(), probabilities.py), + (z_mechanism.clone(), probabilities.pz), + ] { + if !mechanism.is_empty() && probability != 0.0 { + mem.add_mechanism(mechanism, probability); + } + } + } + } + fn process_two_qubit_fault(&self, loc1: usize, loc2: usize, mem: &mut MeasurementNoiseModel) { let prob = self.noise.p2 / 15.0; let paulis = [Pauli::I, Pauli::X, Pauli::Y, Pauli::Z]; 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 9ec0d40d0..40758639e 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -1525,7 +1525,7 @@ pub(crate) fn compute_location_probs_from_noise( | GateType::RZZ => noise.p2_rate_for_gate(loc.gate_type), GateType::Idle => { if noise.uses_dedicated_idle_noise() { - let duration = loc.idle_duration.max(0.0); + let duration = loc.idle_duration; noise.idle_pauli_probs(duration).total() } else { 0.0 diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index b65f8f351..ae5d863fb 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -113,6 +113,10 @@ pub enum FaultSourceType { /// rendered DEM behavior. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DirectSourceFamily { + /// Independent mechanism obtained from an idle location's flip signature. + /// No Pauli label is attached because signature aliases are merged first. + IdleSignature, + /// Single-location direct source without a Y Pauli label. SingleLocation, @@ -162,6 +166,9 @@ pub struct FaultContribution { pub location_indices: SmallVec<[u32; 2]>, /// Original Pauli channel at each tracked location. + /// + /// This is empty for idle-signature mechanisms, which are defined only + /// after equal Pauli effects have been merged. pub paulis: SmallVec<[Pauli; 2]>, /// Gate type at each tracked source location. @@ -317,7 +324,12 @@ impl FaultContribution { probability: f64, source: SourceMetadata<'_, u32>, ) -> Self { - debug_assert_eq!(source.location_indices.len(), source.paulis.len()); + debug_assert!( + source.location_indices.len() == source.paulis.len() + || (source.paulis.is_empty() + && source.direct_source_family_override + == Some(DirectSourceFamily::IdleSignature)) + ); debug_assert_eq!(source.location_indices.len(), source.gate_types.len()); debug_assert_eq!(source.location_indices.len(), source.before_flags.len()); Self { @@ -2473,8 +2485,9 @@ pub struct NoiseConfig { pub p_prep: f64, /// Idle gate error rate per time unit. /// - /// The actual error probability for an idle gate is `p_idle * duration` - /// (clamped to [0, 1]), where `duration` is the gate's `TimeUnits` value. + /// The actual error probability for an idle gate is `p_idle * duration`, + /// where `duration` is the gate's `TimeUnits` value. Values outside the + /// probability range are rejected during model construction. /// Default is 0.0 (no idle noise). pub p_idle: f64, /// Optional T1 relaxation time (in the same time units as idle duration). @@ -2531,6 +2544,10 @@ pub struct NoiseConfig { /// This is the legacy Z-axis alias for `p_idle_z_quadratic_sine_rate`. pub p_idle_quadratic_sine_rate: f64, /// Stochastic X-memory error rate linear in idle duration. + /// + /// Together with the Y and Z rates, this defines one categorical Pauli + /// channel. DEM construction converts that channel only after propagating + /// the Paulis to concrete detector/observable flip signatures. pub p_idle_x_linear_rate: f64, /// Stochastic Y-memory error rate linear in idle duration. pub p_idle_y_linear_rate: f64, @@ -2642,7 +2659,7 @@ impl Default for MeasurementCrosstalkTransitionModel { } /// Per-Pauli error probabilities for a single qubit. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Default)] pub struct PauliProbs { /// Probability of X error. pub px: f64, @@ -2652,6 +2669,228 @@ pub struct PauliProbs { pub pz: f64, } +#[derive(Debug, Clone, Default)] +pub(crate) struct IdleChannelFamilies { + /// Categorical Pauli channels whose equal signatures must be summed. + pub(crate) exclusive: SmallVec<[PauliProbs; 2]>, + /// Independent per-axis mechanism triples. + pub(crate) independent: SmallVec<[PauliProbs; 2]>, +} + +/// An invalid dedicated idle-noise configuration for DEM construction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IdleNoiseError { + message: String, +} + +impl IdleNoiseError { + fn new(message: String) -> Self { + Self { message } + } +} + +impl fmt::Display for IdleNoiseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for IdleNoiseError {} + +/// The non-negative boundary fit used for an infeasible idle signature channel. +/// +/// The fitted independent mechanisms preserve the two unaffected exclusive +/// signature probabilities exactly. `magnitude` is the unavoidable excess on +/// `effect` (and the equal deficit on the identity outcome) caused when both +/// retained mechanisms fire. +#[derive(Debug, Clone, PartialEq)] +pub struct IdleNoiseResidual { + /// Fault-location index whose idle channel required approximation. + pub location_index: u32, + /// Flip signature receiving the unavoidable excess probability. + pub effect: FaultMechanism, + /// Absolute probability transferred from identity to `effect`. + pub magnitude: f64, +} + +#[derive(Debug, Clone)] +pub(crate) struct IndependentSignatureFit { + pub(crate) mechanisms: BTreeMap, + pub(crate) residual: Option<(Signature, f64)>, +} + +pub(crate) fn validate_idle_probabilities( + probabilities: PauliProbs, + context: &str, +) -> Result<(), IdleNoiseError> { + let identity = 1.0 - probabilities.total(); + if !probabilities.px.is_finite() + || !probabilities.py.is_finite() + || !probabilities.pz.is_finite() + || !identity.is_finite() + || !(0.0..=1.0).contains(&probabilities.px) + || !(0.0..=1.0).contains(&probabilities.py) + || !(0.0..=1.0).contains(&probabilities.pz) + || !(0.0..=1.0).contains(&identity) + { + return Err(IdleNoiseError::new(format!( + "invalid {context} idle channel probabilities [I={identity}, X={}, Y={}, Z={}]; every probability must be finite and lie in [0, 1]", + probabilities.px, probabilities.py, probabilities.pz + ))); + } + Ok(()) +} + +fn validate_independent_idle_probabilities( + probabilities: PauliProbs, + context: &str, +) -> Result<(), IdleNoiseError> { + if [probabilities.px, probabilities.py, probabilities.pz] + .into_iter() + .any(|probability| !probability.is_finite() || !(0.0..=1.0).contains(&probability)) + { + return Err(IdleNoiseError::new(format!( + "invalid {context} idle mechanism probabilities [X={}, Y={}, Z={}]; every probability must be finite and lie in [0, 1]", + probabilities.px, probabilities.py, probabilities.pz + ))); + } + Ok(()) +} + +/// Fit one categorical channel over distinct non-empty XOR signatures with +/// independent Bernoulli mechanisms. +/// +/// With zero or one signature the categorical channel is already an exact DEM. +/// Otherwise the Pauli-channel eigenvalue formula gives the exact independent +/// rates. If exactly one rate is negative, its non-negative boundary is used: +/// that mechanism is omitted and the other two rates are uniquely chosen to +/// preserve their target signature probabilities. The only remaining error is +/// the both-fire probability transferred from identity to the omitted XOR +/// signature, returned as `residual`. +pub(crate) fn fit_exclusive_idle_signatures( + exclusive: BTreeMap, + xor: Xor, + context: &str, +) -> Result, IdleNoiseError> +where + Signature: Clone + Ord, + Xor: Fn(&Signature, &Signature) -> Signature, +{ + let total: f64 = exclusive.values().sum(); + if exclusive + .values() + .any(|probability| !probability.is_finite() || !(0.0..=1.0).contains(probability)) + || !total.is_finite() + || !(0.0..=1.0).contains(&total) + { + return Err(IdleNoiseError::new(format!( + "invalid {context} idle signature probabilities with total {total}; every probability and their total must be finite and lie in [0, 1]" + ))); + } + + if exclusive.len() <= 1 { + return Ok(IndependentSignatureFit { + mechanisms: exclusive, + residual: None, + }); + } + + debug_assert!(exclusive.len() <= 3); + let mut signatures: Vec = exclusive.keys().cloned().collect(); + let mut target: Vec = exclusive.values().copied().collect(); + if signatures.len() == 2 { + signatures.push(xor(&signatures[0], &signatures[1])); + target.push(0.0); + } else { + debug_assert!(xor(&signatures[0], &signatures[1]) == signatures[2]); + } + + let identity = 1.0 - total; + let eigenvalues = [ + identity + target[0] - target[1] - target[2], + identity - target[0] + target[1] - target[2], + identity - target[0] - target[1] + target[2], + ]; + if eigenvalues + .iter() + .any(|eigenvalue| !eigenvalue.is_finite() || *eigenvalue <= 0.0) + { + return Err(IdleNoiseError::new(format!( + "invalid {context} idle signature channel: eigenvalues [{}, {}, {}] must all be positive", + eigenvalues[0], eigenvalues[1], eigenvalues[2] + ))); + } + + let exact = [ + (1.0 - (eigenvalues[1] * eigenvalues[2] / eigenvalues[0]).sqrt()) / 2.0, + (1.0 - (eigenvalues[0] * eigenvalues[2] / eigenvalues[1]).sqrt()) / 2.0, + (1.0 - (eigenvalues[0] * eigenvalues[1] / eigenvalues[2]).sqrt()) / 2.0, + ]; + if exact + .iter() + .any(|probability| !probability.is_finite() || *probability > 0.5) + { + return Err(IdleNoiseError::new(format!( + "invalid {context} idle signature conversion probabilities [{}, {}, {}]", + exact[0], exact[1], exact[2] + ))); + } + + let negative: Vec = exact + .iter() + .enumerate() + .filter_map(|(index, probability)| (*probability < 0.0).then_some(index)) + .collect(); + if negative.is_empty() { + let mechanisms = signatures + .into_iter() + .zip(exact) + .filter(|(_, probability)| *probability > 0.0) + .collect(); + return Ok(IndependentSignatureFit { + mechanisms, + residual: None, + }); + } + + debug_assert_eq!(negative.len(), 1); + let omitted = negative[0]; + let retained: Vec = (0..3).filter(|index| *index != omitted).collect(); + let [first, second] = retained.as_slice() else { + unreachable!("one omitted signature leaves exactly two retained signatures") + }; + let discriminant = + (1.0 - target[*first] - target[*second]).powi(2) - 4.0 * target[*first] * target[*second]; + debug_assert!(discriminant >= 0.0); + let root = discriminant.sqrt(); + let first_denominator = 1.0 + target[*first] - target[*second] + root; + let second_denominator = 1.0 + target[*second] - target[*first] + root; + let first_probability = if target[*first] == 0.0 { + 0.0 + } else { + 2.0 * target[*first] / first_denominator + }; + let second_probability = if target[*second] == 0.0 { + 0.0 + } else { + 2.0 * target[*second] / second_denominator + }; + let residual = first_probability * second_probability - target[omitted]; + debug_assert!(residual > 0.0); + + let mut mechanisms = BTreeMap::new(); + if first_probability > 0.0 { + mechanisms.insert(signatures[*first].clone(), first_probability); + } + if second_probability > 0.0 { + mechanisms.insert(signatures[*second].clone(), second_probability); + } + Ok(IndependentSignatureFit { + mechanisms, + residual: Some((signatures[omitted].clone(), residual)), + }) +} + impl PauliProbs { /// Total error probability (px + py + pz). #[must_use] @@ -2685,7 +2924,7 @@ impl PauliProbs { let px = gamma / 4.0; let py = gamma / 4.0; - let pz = (lambda_t2 / 2.0 - gamma / 4.0).max(0.0); + let pz = lambda_t2 / 2.0 - gamma / 4.0; Self { px, py, pz } } @@ -2834,30 +3073,30 @@ impl NoiseConfig { /// Sets the linear stochastic Z-memory rate for explicit idle gates. #[must_use] pub fn set_idle_linear_rate(mut self, rate: f64) -> Self { - self.p_idle_linear_rate = rate.max(0.0); + self.p_idle_linear_rate = rate; self } /// Sets the quadratic stochastic Z-memory rate for explicit idle gates. #[must_use] pub fn set_idle_quadratic_rate(mut self, rate: f64) -> Self { - self.p_idle_quadratic_rate = rate.max(0.0); + self.p_idle_quadratic_rate = rate; self } /// Sets the sine-law quadratic stochastic Z-memory rate for explicit idle gates. #[must_use] pub fn set_idle_quadratic_sine_rate(mut self, rate: f64) -> Self { - self.p_idle_quadratic_sine_rate = rate.max(0.0); + self.p_idle_quadratic_sine_rate = rate; self } /// Sets the linear stochastic Pauli-memory rates for explicit idle gates. #[must_use] pub fn set_idle_pauli_linear_rates(mut self, px_rate: f64, py_rate: f64, pz_rate: f64) -> Self { - self.p_idle_x_linear_rate = px_rate.max(0.0); - self.p_idle_y_linear_rate = py_rate.max(0.0); - self.p_idle_linear_rate = pz_rate.max(0.0); + self.p_idle_x_linear_rate = px_rate; + self.p_idle_y_linear_rate = py_rate; + self.p_idle_linear_rate = pz_rate; self } @@ -2869,9 +3108,9 @@ impl NoiseConfig { py_rate: f64, pz_rate: f64, ) -> Self { - self.p_idle_x_quadratic_rate = px_rate.max(0.0); - self.p_idle_y_quadratic_rate = py_rate.max(0.0); - self.p_idle_quadratic_rate = pz_rate.max(0.0); + self.p_idle_x_quadratic_rate = px_rate; + self.p_idle_y_quadratic_rate = py_rate; + self.p_idle_quadratic_rate = pz_rate; self } @@ -2883,9 +3122,9 @@ impl NoiseConfig { py_rate: f64, pz_rate: f64, ) -> Self { - self.p_idle_x_quadratic_sine_rate = px_rate.max(0.0); - self.p_idle_y_quadratic_sine_rate = py_rate.max(0.0); - self.p_idle_quadratic_sine_rate = pz_rate.max(0.0); + self.p_idle_x_quadratic_sine_rate = px_rate; + self.p_idle_y_quadratic_sine_rate = py_rate; + self.p_idle_quadratic_sine_rate = pz_rate; self } @@ -3054,59 +3293,156 @@ impl NoiseConfig { self } - fn idle_memory_probability( - linear_rate: f64, - quadratic_rate: f64, - quadratic_sine_rate: f64, + fn validate_idle_rates(family: &str, rates: PauliProbs) -> Result<(), IdleNoiseError> { + if !rates.px.is_finite() + || !rates.py.is_finite() + || !rates.pz.is_finite() + || rates.px < 0.0 + || rates.py < 0.0 + || rates.pz < 0.0 + { + return Err(IdleNoiseError::new(format!( + "invalid {family} idle rate/model [X={}, Y={}, Z={}]; rates must be finite and non-negative", + rates.px, rates.py, rates.pz + ))); + } + Ok(()) + } + + fn base_idle_pauli_probs(&self, duration: f64) -> Result { + if !duration.is_finite() || duration < 0.0 { + return Err(IdleNoiseError::new(format!( + "invalid idle duration {duration}; duration must be finite and non-negative" + ))); + } + if !self.p_idle.is_finite() || self.p_idle < 0.0 { + return Err(IdleNoiseError::new(format!( + "invalid uniform idle rate {}; rates must be finite and non-negative", + self.p_idle + ))); + } + let probabilities = if let (Some(t1), Some(t2)) = (self.t1, self.t2) { + if !t1.is_finite() || !t2.is_finite() || t1 <= 0.0 || t2 <= 0.0 || t2 > 2.0 * t1 { + return Err(IdleNoiseError::new(format!( + "invalid idle T1/T2 values [T1={t1}, T2={t2}]; both must be finite and positive, with T2 <= 2*T1" + ))); + } + PauliProbs::from_t1_t2(duration, t1, t2) + } else { + if self.t1.is_some() || self.t2.is_some() { + return Err(IdleNoiseError::new( + "invalid idle T1/T2 configuration; T1 and T2 must be supplied together" + .to_string(), + )); + } + PauliProbs::depolarizing(self.p_idle * duration) + }; + validate_idle_probabilities(probabilities, "base")?; + Ok(probabilities) + } + + pub(crate) fn try_idle_channel_families( + &self, duration: f64, - ) -> f64 { - let duration = duration.max(0.0); - let sine_angle = quadratic_sine_rate.max(0.0) * duration; - (linear_rate.max(0.0) * duration - + quadratic_rate.max(0.0) * duration * duration - + sine_angle.sin().powi(2)) - .clamp(0.0, 1.0) + ) -> Result { + let base = self.base_idle_pauli_probs(duration)?; + let linear_rates = PauliProbs { + px: self.p_idle_x_linear_rate, + py: self.p_idle_y_linear_rate, + pz: self.p_idle_linear_rate, + }; + let quadratic_rates = PauliProbs { + px: self.p_idle_x_quadratic_rate, + py: self.p_idle_y_quadratic_rate, + pz: self.p_idle_quadratic_rate, + }; + let sine_rates = PauliProbs { + px: self.p_idle_x_quadratic_sine_rate, + py: self.p_idle_y_quadratic_sine_rate, + pz: self.p_idle_quadratic_sine_rate, + }; + Self::validate_idle_rates("linear", linear_rates)?; + Self::validate_idle_rates("coefficient-quadratic", quadratic_rates)?; + Self::validate_idle_rates("sine-squared", sine_rates)?; + + let duration_squared = duration * duration; + let linear = PauliProbs { + px: linear_rates.px * duration, + py: linear_rates.py * duration, + pz: linear_rates.pz * duration, + }; + validate_idle_probabilities(linear, "linear")?; + let quadratic = PauliProbs { + px: quadratic_rates.px * duration_squared, + py: quadratic_rates.py * duration_squared, + pz: quadratic_rates.pz * duration_squared, + }; + validate_independent_idle_probabilities(quadratic, "coefficient-quadratic")?; + let sine = PauliProbs { + px: (sine_rates.px * duration).sin().powi(2), + py: (sine_rates.py * duration).sin().powi(2), + pz: (sine_rates.pz * duration).sin().powi(2), + }; + validate_independent_idle_probabilities(sine, "sine-squared")?; + + let mut families = IdleChannelFamilies::default(); + if base.total() > 0.0 { + families.exclusive.push(base); + } + if linear.total() > 0.0 { + families.exclusive.push(linear); + } + if quadratic.total() > 0.0 { + families.independent.push(quadratic); + } + if sine.total() > 0.0 { + families.independent.push(sine); + } + Ok(families) } - /// Dedicated idle-memory Pauli probabilities for `Idle(duration, q)`. + /// Try to compute the effective Pauli channel of all dedicated + /// idle-memory terms. + /// + /// # Errors + /// + /// Returns an error for a negative/non-finite rate or duration, or when a + /// configured family produces an out-of-range probability. + pub fn try_idle_memory_pauli_probs(&self, duration: f64) -> Result { + let families = self.try_idle_channel_families(duration)?; + let mut channel = PauliProbs::default(); + let base_is_present = self.base_idle_pauli_probs(duration)?.total() > 0.0; + for exclusive in families + .exclusive + .into_iter() + .skip(usize::from(base_is_present)) + { + channel = Self::compose_pauli_channel(channel, exclusive); + } + for independent in families.independent { + channel = Self::compose_independent_pauli_mechanisms(channel, independent); + } + Ok(channel) + } + + /// Dedicated idle-memory effective Pauli channel for `Idle(duration, q)`. + /// + /// # Panics + /// + /// Panics if an idle input is invalid or produces an out-of-range channel. #[must_use] pub fn idle_memory_pauli_probs(&self, duration: f64) -> PauliProbs { - let mut probs = PauliProbs { - px: Self::idle_memory_probability( - self.p_idle_x_linear_rate, - self.p_idle_x_quadratic_rate, - self.p_idle_x_quadratic_sine_rate, - duration, - ), - py: Self::idle_memory_probability( - self.p_idle_y_linear_rate, - self.p_idle_y_quadratic_rate, - self.p_idle_y_quadratic_sine_rate, - duration, - ), - pz: Self::idle_memory_probability( - self.p_idle_linear_rate, - self.p_idle_quadratic_rate, - self.p_idle_quadratic_sine_rate, - duration, - ), - }; - let total = probs.total(); - if total > 1.0 { - probs.px /= total; - probs.py /= total; - probs.pz /= total; - } - probs + self.try_idle_memory_pauli_probs(duration) + .unwrap_or_else(|error| panic!("invalid DEM idle-noise configuration: {error}")) } fn compose_pauli_channel(probs: PauliProbs, channel: PauliProbs) -> PauliProbs { - if channel.total() <= f64::EPSILON { + if channel.total() == 0.0 { return probs; } - let p_identity = (1.0 - probs.total()).max(0.0); - let c_identity = (1.0 - channel.total()).max(0.0); + let p_identity = 1.0 - probs.total(); + let c_identity = 1.0 - channel.total(); PauliProbs { px: p_identity * channel.px + probs.px * c_identity @@ -3123,18 +3459,63 @@ impl NoiseConfig { } } + fn compose_independent_pauli_mechanisms( + mut channel: PauliProbs, + mechanisms: PauliProbs, + ) -> PauliProbs { + for mechanism in [ + PauliProbs { + px: mechanisms.px, + py: 0.0, + pz: 0.0, + }, + PauliProbs { + px: 0.0, + py: mechanisms.py, + pz: 0.0, + }, + PauliProbs { + px: 0.0, + py: 0.0, + pz: mechanisms.pz, + }, + ] { + channel = Self::compose_pauli_channel(channel, mechanism); + } + channel + } + /// Compute per-Pauli idle noise probabilities for a given duration. /// /// If T1/T2 are set, uses the Pauli-twirled model (biased noise). /// Otherwise, uses uniform depolarizing with `p_idle * duration`. + /// + /// # Panics + /// + /// Panics if an idle input is invalid or produces an out-of-range channel. #[must_use] pub fn idle_pauli_probs(&self, duration: f64) -> PauliProbs { - let probs = if let (Some(t1), Some(t2)) = (self.t1, self.t2) { - PauliProbs::from_t1_t2(duration, t1, t2) - } else { - PauliProbs::depolarizing((self.p_idle * duration).min(1.0)) - }; - Self::compose_pauli_channel(probs, self.idle_memory_pauli_probs(duration)) + self.try_idle_pauli_probs(duration) + .unwrap_or_else(|error| panic!("invalid DEM idle-noise configuration: {error}")) + } + + /// Try to compute the effective Pauli idle channel for a given duration. + /// + /// # Errors + /// + /// Returns an error for a negative/non-finite rate or duration, an invalid + /// T1/T2 pair, or an out-of-range categorical probability. + pub fn try_idle_pauli_probs(&self, duration: f64) -> Result { + let families = self.try_idle_channel_families(duration)?; + let mut channel = PauliProbs::default(); + for exclusive in families.exclusive { + channel = Self::compose_pauli_channel(channel, exclusive); + } + for independent in families.independent { + channel = Self::compose_independent_pauli_mechanisms(channel, independent); + } + validate_idle_probabilities(channel, "composed")?; + Ok(channel) } /// Returns true when idle locations use the dedicated idle-noise model. @@ -3142,17 +3523,18 @@ impl NoiseConfig { /// Otherwise `Idle` is a no-op for noise. #[must_use] pub fn uses_dedicated_idle_noise(&self) -> bool { - self.p_idle > 0.0 - || matches!((self.t1, self.t2), (Some(_), Some(_))) - || self.p_idle_linear_rate > 0.0 - || self.p_idle_quadratic_rate.abs() > f64::EPSILON - || self.p_idle_quadratic_sine_rate > 0.0 - || self.p_idle_x_linear_rate > 0.0 - || self.p_idle_y_linear_rate > 0.0 - || self.p_idle_x_quadratic_rate > 0.0 - || self.p_idle_y_quadratic_rate > 0.0 - || self.p_idle_x_quadratic_sine_rate > 0.0 - || self.p_idle_y_quadratic_sine_rate > 0.0 + self.p_idle != 0.0 + || self.t1.is_some() + || self.t2.is_some() + || self.p_idle_linear_rate != 0.0 + || self.p_idle_quadratic_rate != 0.0 + || self.p_idle_quadratic_sine_rate != 0.0 + || self.p_idle_x_linear_rate != 0.0 + || self.p_idle_y_linear_rate != 0.0 + || self.p_idle_x_quadratic_rate != 0.0 + || self.p_idle_y_quadratic_rate != 0.0 + || self.p_idle_x_quadratic_sine_rate != 0.0 + || self.p_idle_y_quadratic_sine_rate != 0.0 } } @@ -3899,6 +4281,17 @@ impl fmt::Debug for MeasurementMechanism { } } +/// A quantified idle-channel approximation in raw-measurement space. +#[derive(Debug, Clone, PartialEq)] +pub struct MeasurementIdleNoiseResidual { + /// Fault-location index whose idle channel required approximation. + pub location_index: u32, + /// Raw-measurement flip signature receiving the excess probability. + pub mechanism: MeasurementMechanism, + /// Absolute probability transferred from identity to `mechanism`. + pub magnitude: f64, +} + /// A measurement noise model for fast approximate raw-measurement sampling. #[derive(Debug, Clone, Default)] pub struct MeasurementNoiseModel { @@ -3908,6 +4301,8 @@ pub struct MeasurementNoiseModel { pub num_measurements: usize, /// Optional mapping from influence-map index to original circuit order. pub im_to_tc_order: Option>, + /// Quantified approximations introduced by idle signature conversion. + pub idle_noise_residuals: Vec, } impl MeasurementNoiseModel { @@ -3918,6 +4313,7 @@ impl MeasurementNoiseModel { mechanisms: BTreeMap::new(), num_measurements, im_to_tc_order: None, + idle_noise_residuals: Vec::new(), } } @@ -3952,6 +4348,10 @@ impl MeasurementNoiseModel { .or_insert(probability); } + pub(crate) fn add_idle_noise_residual(&mut self, residual: MeasurementIdleNoiseResidual) { + self.idle_noise_residuals.push(residual); + } + /// Samples measurement outcomes into a pre-sized buffer. pub fn sample_into(&self, outcomes: &mut [bool], rng: &mut R) { outcomes.fill(false); @@ -4132,6 +4532,8 @@ pub struct DetectorErrorModel { /// component effects are non-empty and graphlike (≤2 detectors). /// Used to determine output format: ≥2 → 3 forms, 1 → 2 forms, 0 → 1 form. graphlike_decomposable_counts: BTreeMap<(u32, u32), u32>, + /// Quantified approximations introduced by infeasible idle signature channels. + idle_noise_residuals: Vec, } /// Structured DEM mechanism tuple: `(probability, detector_ids, observable_ids)`. @@ -4150,6 +4552,7 @@ impl DetectorErrorModel { tracked_paulis: Vec::new(), contributions: Vec::new(), graphlike_decomposable_counts: BTreeMap::new(), + idle_noise_residuals: Vec::new(), } } @@ -4162,6 +4565,7 @@ impl DetectorErrorModel { tracked_paulis: Vec::new(), contributions: Vec::new(), graphlike_decomposable_counts: BTreeMap::new(), + idle_noise_residuals: Vec::new(), } } @@ -4247,6 +4651,21 @@ impl DetectorErrorModel { self.contributions.len() } + /// Returns every quantified idle-channel approximation made during build. + /// + /// Each record identifies the concrete flip signature that receives the + /// unavoidable both-fire excess and its matching probability magnitude. + /// An empty slice means idle conversion was exact at every location. + #[inline] + #[must_use] + pub fn idle_noise_residuals(&self) -> &[IdleNoiseResidual] { + &self.idle_noise_residuals + } + + pub(crate) fn add_idle_noise_residual(&mut self, residual: IdleNoiseResidual) { + self.idle_noise_residuals.push(residual); + } + /// Exports PECOS-only metadata that is not representable in standard DEM syntax. /// /// The standard DEM string remains decoder-compatible and uses ordinary @@ -4679,6 +5098,7 @@ impl DetectorErrorModel { fn direct_source_family_label(family: DirectSourceFamily) -> &'static str { match family { + DirectSourceFamily::IdleSignature => "IdleSignature", DirectSourceFamily::SingleLocation => "SingleLocation", DirectSourceFamily::SingleLocationY => "SingleLocationY", DirectSourceFamily::TwoLocationPlainY => "TwoLocationPlainY", diff --git a/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs b/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs index 33fe9229b..eec29a7f6 100644 --- a/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs +++ b/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs @@ -102,7 +102,7 @@ impl LookupDecoder { .locations .iter() .find(|l| l.node == loc.node && l.before == loc.before) - .map_or(0.0, |l| l.idle_duration.max(0.0)); + .map_or(0.0, |l| l.idle_duration); Some(noise.idle_pauli_probs(duration)) } else { None diff --git a/crates/pecos-qec/tests/idle_noise_tests.rs b/crates/pecos-qec/tests/idle_noise_tests.rs index 9f9af638a..c1886242c 100644 --- a/crates/pecos-qec/tests/idle_noise_tests.rs +++ b/crates/pecos-qec/tests/idle_noise_tests.rs @@ -14,12 +14,17 @@ //! noise is explicitly attached to idle locations via dedicated idle noise or //! per-gate idle rates. +use pecos_core::pauli::{X, Y, Z}; use pecos_core::{QubitId, TimeUnits}; use pecos_qec::fault_tolerance::dem_builder::{ - DemBuilder, DemSamplerBuilder, NoiseConfig, PerGateTypeNoise, + DemBuilder, DemSamplerBuilder, DetectorErrorModel, FaultMechanism, MemBuilder, NoiseConfig, + PauliProbs, PerGateTypeNoise, combine_probabilities, +}; +use pecos_qec::fault_tolerance::propagator::{ + DagFaultAnalyzer, DagFaultInfluenceMap, DagSpacetimeLocation, Pauli, }; -use pecos_qec::fault_tolerance::propagator::DagFaultAnalyzer; use pecos_quantum::{DagCircuit, GateType}; +use std::collections::BTreeMap; fn build_idle_then_measure(num_idles: usize) -> DagCircuit { // Prep N qubits, idle each once, measure each. Very simple fixture @@ -47,6 +52,158 @@ fn build_nanosecond_idle_x_basis_measure() -> DagCircuit { dag } +fn build_unit_idle_with_pauli_tracking() -> DagCircuit { + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + dag.idle(TimeUnits::new(1), &[0]); + dag.tracked_pauli_labeled("tracked_x", X(0)); + dag.tracked_pauli_labeled("tracked_y", Y(0)); + dag.tracked_pauli_labeled("tracked_z", Z(0)); + dag.mz(&[0]); + dag +} + +fn build_unit_idle_tracking_x() -> DagCircuit { + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + dag.idle(TimeUnits::new(1), &[0]); + dag.tracked_pauli_labeled("tracked_x", X(0)); + dag.mz(&[0]); + dag +} + +fn build_unit_idle_tracking_y() -> DagCircuit { + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + dag.idle(TimeUnits::new(1), &[0]); + dag.tracked_pauli_labeled("tracked_y", Y(0)); + dag.mz(&[0]); + dag +} + +fn build_tracked_idle_dem(noise: NoiseConfig) -> Result { + let dag = build_unit_idle_with_pauli_tracking(); + DemBuilder::try_from_circuit_with_noise_config(&dag, noise).map_err(|error| error.to_string()) +} + +fn synthetic_idle_influence( + x_signature: &[u32], + y_signature: &[u32], + z_signature: &[u32], +) -> DagFaultInfluenceMap { + let mut influence = DagFaultInfluenceMap::with_capacity(1); + influence.locations.push(DagSpacetimeLocation { + node: 0, + qubits: vec![QubitId::from(0usize)], + before: false, + gate_type: GateType::Idle, + idle_duration: 1.0, + }); + influence + .influences + .detectors_x + .extend(x_signature.iter().copied()); + influence + .influences + .detectors_y + .extend(y_signature.iter().copied()); + influence + .influences + .detectors_z + .extend(z_signature.iter().copied()); + influence.influences.finish_location(); + influence.measurements = vec![(0, 0, 0), (1, 0, 0)]; + influence +} + +fn build_synthetic_idle_dem( + influence: &DagFaultInfluenceMap, + noise: NoiseConfig, +) -> Result { + DemBuilder::new(influence) + .with_noise_config(noise) + .with_detectors_json(r#"[{"id": 0, "records": [-2]}, {"id": 1, "records": [-1]}]"#) + .map_err(|error| error.to_string())? + .try_build() + .map_err(|error| error.to_string()) +} + +fn compose_xyz_mechanisms(mechanisms: PauliProbs) -> [f64; 4] { + let PauliProbs { px, py, pz } = mechanisms; + [ + (1.0 - px) * (1.0 - py) * (1.0 - pz) + px * py * pz, + px * (1.0 - py) * (1.0 - pz) + (1.0 - px) * py * pz, + (1.0 - px) * py * (1.0 - pz) + px * (1.0 - py) * pz, + (1.0 - px) * (1.0 - py) * pz + px * py * (1.0 - pz), + ] +} + +fn compose_pauli_channels(left: [f64; 4], right: [f64; 4]) -> [f64; 4] { + let [li, lx, ly, lz] = left; + let [ri, rx, ry, rz] = right; + [ + li * ri + lx * rx + ly * ry + lz * rz, + li * rx + lx * ri + ly * rz + lz * ry, + li * ry + ly * ri + lx * rz + lz * rx, + li * rz + lz * ri + lx * ry + ly * rx, + ] +} + +fn idle_signature_contributions(dem: &DetectorErrorModel) -> Vec<(FaultMechanism, f64)> { + let mut mechanisms = Vec::new(); + for record in dem.contribution_render_records() { + let contribution = record.contribution; + assert_eq!(contribution.source_gate_types.as_slice(), [GateType::Idle]); + assert!(contribution.paulis.is_empty()); + mechanisms.push((contribution.effect, contribution.probability)); + } + mechanisms +} + +fn raw_idle_signature( + influence: &DagFaultInfluenceMap, + loc_idx: usize, + pauli: Pauli, +) -> FaultMechanism { + FaultMechanism::from_unsorted_with_tracked_paulis( + influence + .get_detector_indices(loc_idx, pauli.as_u8()) + .iter() + .copied(), + influence + .get_observable_indices(loc_idx, pauli.as_u8()) + .iter() + .copied(), + influence + .get_tracked_pauli_indices(loc_idx, pauli.as_u8()) + .iter() + .copied(), + ) +} + +fn idle_location(influence: &DagFaultInfluenceMap) -> usize { + influence + .locations + .iter() + .position(|location| location.gate_type == GateType::Idle && !location.before) + .expect("after-idle fault location") +} + +fn independent_signature_distribution( + mechanisms: &[(FaultMechanism, f64)], +) -> BTreeMap { + let mut distribution = BTreeMap::from([(FaultMechanism::new(), 1.0)]); + for (mechanism, probability) in mechanisms { + let mut next = BTreeMap::new(); + for (effect, mass) in distribution { + *next.entry(effect.clone()).or_insert(0.0) += mass * (1.0 - probability); + *next.entry(effect.xor(mechanism)).or_insert(0.0) += mass * probability; + } + distribution = next; + } + distribution +} + #[test] fn idle_locations_contribute_mechanisms_when_rates_set() { let dag = build_idle_then_measure(2); @@ -241,9 +398,17 @@ fn idle_memory_pauli_probabilities_match_linear_and_quadratic_model() { .set_idle_pauli_linear_rates(1.0e-3, 2.0e-3, 3.0e-3) .set_idle_pauli_quadratic_rates(1.0e-4, 2.0e-4, 3.0e-4) .idle_memory_pauli_probs(10.0); - assert!((pauli.px - 0.02).abs() < 1e-15); - assert!((pauli.py - 0.04).abs() < 1e-15); - assert!((pauli.pz - 0.06).abs() < 1e-15); + let expected = compose_pauli_channels( + [0.94, 0.01, 0.02, 0.03], + compose_xyz_mechanisms(PauliProbs { + px: 0.01, + py: 0.02, + pz: 0.03, + }), + ); + assert!((pauli.px - expected[1]).abs() < 1e-15); + assert!((pauli.py - expected[2]).abs() < 1e-15); + assert!((pauli.pz - expected[3]).abs() < 1e-15); } #[test] @@ -258,9 +423,285 @@ fn idle_memory_pauli_probabilities_support_quadratic_sine_model() { let pauli_sine = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) .set_idle_pauli_quadratic_sine_rates(0.1, 0.2, 0.3) .idle_memory_pauli_probs(2.0); - assert!((pauli_sine.px - 0.2_f64.sin().powi(2)).abs() < 1e-15); - assert!((pauli_sine.py - 0.4_f64.sin().powi(2)).abs() < 1e-15); - assert!((pauli_sine.pz - 0.6_f64.sin().powi(2)).abs() < 1e-15); + let expected = compose_xyz_mechanisms(PauliProbs { + px: 0.2_f64.sin().powi(2), + py: 0.4_f64.sin().powi(2), + pz: 0.6_f64.sin().powi(2), + }); + assert!((pauli_sine.px - expected[1]).abs() < 1e-15); + assert!((pauli_sine.py - expected[2]).abs() < 1e-15); + assert!((pauli_sine.pz - expected[3]).abs() < 1e-15); +} + +#[test] +fn equal_idle_signatures_sum_exclusive_probabilities_before_dem_merging() { + let influence = synthetic_idle_influence(&[0], &[], &[0]); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_pauli_linear_rates(0.25, 0.0, 0.75); + let dem = build_synthetic_idle_dem(&influence, noise) + .expect("equal signatures need no independent conversion"); + let contributions = idle_signature_contributions(&dem); + + assert_eq!(contributions.len(), 1); + assert_eq!(contributions[0].1.to_bits(), 1.0_f64.to_bits()); + assert_ne!( + contributions[0].1.to_bits(), + combine_probabilities(0.25, 0.75).to_bits(), + "exclusive aliases must sum, not use the independent XOR rule", + ); + assert!(dem.idle_noise_residuals().is_empty()); + + let measurement_model = MemBuilder::new(&influence) + .with_noise_config( + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_pauli_linear_rates(0.25, 0.0, 0.75), + ) + .build(); + assert_eq!(measurement_model.mechanisms.len(), 1); + let measurement_probability = *measurement_model + .mechanisms + .values() + .next() + .expect("equal raw-measurement signatures must emit one mechanism"); + assert_eq!(measurement_probability.to_bits(), 1.0_f64.to_bits()); + assert_ne!( + measurement_probability.to_bits(), + combine_probabilities(0.25, 0.75).to_bits() + ); +} + +#[test] +fn empty_idle_signature_is_dropped_before_conversion() { + let influence = synthetic_idle_influence(&[], &[0], &[0]); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_pauli_linear_rates(0.25, 0.0, 0.75); + let dem = build_synthetic_idle_dem(&influence, noise) + .expect("an undetectable X branch cannot obstruct the surviving Z branch"); + let contributions = idle_signature_contributions(&dem); + + assert_eq!(contributions.len(), 1); + assert_eq!(contributions[0].1.to_bits(), 0.75_f64.to_bits()); + assert!(dem.idle_noise_residuals().is_empty()); + + let measurement_model = MemBuilder::new(&influence) + .with_noise_config( + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_pauli_linear_rates(0.25, 0.0, 0.75), + ) + .build(); + assert_eq!(measurement_model.mechanisms.len(), 1); + assert_eq!( + measurement_model + .mechanisms + .values() + .next() + .expect("surviving Z measurement signature") + .to_bits(), + 0.75_f64.to_bits() + ); + assert!(measurement_model.idle_noise_residuals.is_empty()); +} + +#[test] +fn biased_xz_idle_channel_builds_with_quantified_boundary_residual() { + let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); + let loc_idx = idle_location(&influence); + let noise = + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_pauli_linear_rates(0.0075, 0.0, 0.0225); + let dem = build_synthetic_idle_dem(&influence, noise) + .expect("ordinary biased X/Z idle noise must produce a usable DEM"); + let mechanisms = idle_signature_contributions(&dem); + let distribution = independent_signature_distribution(&mechanisms); + let x_effect = raw_idle_signature(&influence, loc_idx, Pauli::X); + let y_effect = raw_idle_signature(&influence, loc_idx, Pauli::Y); + let z_effect = raw_idle_signature(&influence, loc_idx, Pauli::Z); + + assert!( + (distribution[&x_effect] - 0.0075).abs() < 1e-12, + "distribution={distribution:?}, mechanisms={mechanisms:?}" + ); + assert!((distribution[&z_effect] - 0.0225).abs() < 1e-12); + let [residual] = dem.idle_noise_residuals() else { + panic!("the infeasible two-signature channel must report one residual") + }; + assert_eq!(residual.effect, y_effect); + assert!((distribution[&y_effect] - residual.magnitude).abs() < 1e-12); + let qx = mechanisms + .iter() + .find_map(|(effect, probability)| (effect == &x_effect).then_some(*probability)) + .expect("X signature mechanism"); + let qz = mechanisms + .iter() + .find_map(|(effect, probability)| (effect == &z_effect).then_some(*probability)) + .expect("Z signature mechanism"); + assert!((residual.magnitude - qx * qz).abs() < 1e-15); +} + +#[test] +fn three_distinct_idle_signatures_match_engines_pauli_channel() { + // pecos-engines samples one event with probability 0.01, then selects + // X/Y/Z categorically with the configured relative weights. Include both + // the documented 0.25/0.25/0.50 model and an asymmetric model so every + // eigenvalue denominator is independently exercised. + let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); + let loc_idx = idle_location(&influence); + let x_effect = raw_idle_signature(&influence, loc_idx, Pauli::X); + let y_effect = raw_idle_signature(&influence, loc_idx, Pauli::Y); + let z_effect = raw_idle_signature(&influence, loc_idx, Pauli::Z); + + for [px, py, pz] in [[0.0025, 0.0025, 0.005], [0.002, 0.003, 0.005]] { + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_pauli_linear_rates(px, py, pz); + let dem = build_synthetic_idle_dem(&influence, noise) + .expect("three-signature engines channel is exactly representable"); + let distribution = independent_signature_distribution(&idle_signature_contributions(&dem)); + + assert!( + (distribution[&FaultMechanism::new()] - (1.0 - px - py - pz)).abs() < 1e-12, + "distribution={distribution:?}" + ); + assert!((distribution[&x_effect] - px).abs() < 1e-12); + assert!((distribution[&y_effect] - py).abs() < 1e-12); + assert!((distribution[&z_effect] - pz).abs() < 1e-12); + assert!(dem.idle_noise_residuals().is_empty()); + } +} + +#[test] +fn idle_y_signature_is_xor_of_x_and_z_at_every_tested_location() { + let synthetic = synthetic_idle_influence(&[0], &[0, 1], &[1]); + let synthetic_loc = idle_location(&synthetic); + assert_eq!( + raw_idle_signature(&synthetic, synthetic_loc, Pauli::Y), + raw_idle_signature(&synthetic, synthetic_loc, Pauli::X).xor(&raw_idle_signature( + &synthetic, + synthetic_loc, + Pauli::Z + )), + "synthetic non-empty signatures", + ); + + for dag in [ + build_unit_idle_with_pauli_tracking(), + build_unit_idle_tracking_x(), + build_unit_idle_tracking_y(), + build_idle_then_measure(3), + ] { + let influence = DagFaultAnalyzer::new(&dag).build_influence_map(); + for (loc_idx, location) in influence.locations.iter().enumerate() { + if location.gate_type != GateType::Idle || location.before { + continue; + } + let x_effect = raw_idle_signature(&influence, loc_idx, Pauli::X); + let y_effect = raw_idle_signature(&influence, loc_idx, Pauli::Y); + let z_effect = raw_idle_signature(&influence, loc_idx, Pauli::Z); + assert_eq!(y_effect, x_effect.xor(&z_effect), "idle location {loc_idx}"); + } + } +} + +#[test] +fn linear_and_sine_idle_families_emit_separate_contributions() { + let linear_probability = 0.01; + let sine_rate: f64 = 0.2; + let sine_probability = sine_rate.sin().powi(2); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_idle_linear_rate(linear_probability) + .set_idle_quadratic_sine_rate(sine_rate); + let dem = build_tracked_idle_dem(noise).expect("valid composed-family DEM"); + + let mut z_probabilities = dem + .contribution_render_records() + .into_iter() + .filter_map(|record| { + (record.contribution.source_gate_types.as_slice() == [GateType::Idle]) + .then_some(record.contribution.probability) + }) + .collect::>(); + z_probabilities.sort_by(f64::total_cmp); + + assert_eq!(z_probabilities.len(), 2); + assert!((z_probabilities[0] - linear_probability).abs() < 1e-15); + assert!((z_probabilities[1] - sine_probability).abs() < 1e-15); + assert!( + (z_probabilities.iter().sum::() - (linear_probability + sine_probability)).abs() + < 1e-15 + ); +} + +#[test] +fn nonpositive_signature_channel_eigenvalue_returns_specific_error() { + let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); + let error = build_synthetic_idle_dem( + &influence, + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_pauli_linear_rates(0.25, 0.0, 0.25), + ) + .expect_err("zero signature-channel eigenvalues are broken input"); + + assert!(error.contains("DEM builder configuration error")); + assert!(error.contains("location")); + assert!(error.contains("eigenvalues")); + assert!(error.contains("must all be positive")); +} + +#[test] +fn oversized_coefficient_quadratic_mechanism_returns_specific_error() { + let error = + build_tracked_idle_dem(NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_quadratic_rate(1.1)) + .expect_err("probabilities above one must not be clamped"); + + assert!(error.contains("coefficient-quadratic idle mechanism probabilities")); + assert!(error.contains("Z=1.1")); + assert!(error.contains("must be finite and lie in [0, 1]")); +} + +#[test] +fn negative_idle_rate_is_rejected_instead_of_clamped() { + let error = + build_tracked_idle_dem(NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear_rate(-0.01)) + .expect_err("negative rates must not be clamped"); + + assert!(error.contains("invalid linear idle rate/model [X=0, Y=0, Z=-0.01]")); + assert!(error.contains("rates must be finite and non-negative")); +} + +#[test] +fn negative_idle_duration_is_rejected_instead_of_clamped() { + let dag = build_unit_idle_tracking_x(); + let mut influence = DagFaultAnalyzer::new(&dag).build_influence_map(); + let loc_idx = idle_location(&influence); + influence.locations[loc_idx].idle_duration = -1.0; + let error = DemBuilder::new(&influence) + .with_noise_config(NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear_rate(0.01)) + .try_build() + .expect_err("negative idle durations must not be clamped"); + + assert!(error.to_string().contains("invalid idle duration -1")); + assert!( + error + .to_string() + .contains("duration must be finite and non-negative") + ); +} + +#[test] +fn identical_idle_configuration_produces_byte_identical_dem_text() { + let dag = build_idle_then_measure(3); + let influence = DagFaultAnalyzer::new(&dag).build_influence_map(); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_idle_pauli_linear_rates(1.0e-5, 2.0e-5, 3.0e-5) + .set_idle_pauli_quadratic_sine_rates(0.001, 0.002, 0.003); + let build = || { + DemBuilder::new(&influence) + .with_noise_config(noise.clone()) + .with_detectors_json( + r#"[{"id": 0, "records": [-3]}, {"id": 1, "records": [-2]}, {"id": 2, "records": [-1]}]"#, + ) + .expect("valid detector metadata") + .try_build() + .expect("valid deterministic DEM") + .to_string() + }; + + let expected = build(); + for _ in 0..16 { + assert_eq!(build().as_bytes(), expected.as_bytes()); + } } #[test] diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index bea50c06b..b54ac0375 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -285,7 +285,12 @@ non-negative multipliers have no sum constraint. probability `(p_idle_linear * m_axis) * t`. The model keys are `X`, `Y`, and `Z`, plus the engines leakage key `L`. The default is the uniform `{X: 1/3, Y: 1/3, Z: 1/3}` engines model. An explicit `L` weight participates - in the sum-to-1 requirement. + in the sum-to-1 requirement. This is a categorical Pauli channel. DEM + construction first propagates its Pauli branches to detector/observable flip + signatures, discards empty signatures, and adds probabilities for aliases. + Two or more distinct signatures are converted to independent mechanisms; a + non-negative boundary fit and its quantified residual are reported when the + exact conversion would require a negative mechanism. - Sine-squared: `p_idle_sin_squared` with `p_idle_sin_squared_model`. A Pauli fault has probability `sin((p_idle_sin_squared * m_axis) * t) ** 2`. The model keys are `X`, `Y`, `Z`, and `L`; there is no sum constraint. The @@ -318,6 +323,14 @@ additionally rescales its public inputs (square-root scaling, an incoherent-conversion factor, and cycles-to-radians), so builder inputs are not directly interchangeable with these parameters. +Every residual is readable from `dem.idle_noise_residuals` as a dictionary +containing `location_index`, the concrete `detectors`/`dem_outputs`/ +`tracked_paulis` signature, and `magnitude`. The magnitude is the unavoidable +both-fire excess on that signature and the equal deficit on the identity +outcome. Audited Guppy builds also copy this list to +`dem_build.audit["idle_noise_residuals"]`. An empty list certifies that all idle +signature conversions were exact. + The per-axis `p_idle_{x,y,z}_linear_rate`, `p_idle_{x,y,z}_quadratic_rate`, and `p_idle_{x,y,z}_quadratic_sine_rate` parameters remain available as low-level diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 5fbd6278b..3f4de7b23 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -390,34 +390,34 @@ fn apply_noise_options( noise = noise.set_idle_quadratic_rate(rate); } if let Some(rate) = p_idle_x_linear_rate { - noise.p_idle_x_linear_rate = rate.max(0.0); + noise.p_idle_x_linear_rate = rate; } if let Some(rate) = p_idle_y_linear_rate { - noise.p_idle_y_linear_rate = rate.max(0.0); + noise.p_idle_y_linear_rate = rate; } if let Some(rate) = p_idle_z_linear_rate { - noise.p_idle_linear_rate = rate.max(0.0); + noise.p_idle_linear_rate = rate; } if let Some(rate) = p_idle_x_quadratic_rate { - noise.p_idle_x_quadratic_rate = rate.max(0.0); + noise.p_idle_x_quadratic_rate = rate; } if let Some(rate) = p_idle_y_quadratic_rate { - noise.p_idle_y_quadratic_rate = rate.max(0.0); + noise.p_idle_y_quadratic_rate = rate; } if let Some(rate) = p_idle_z_quadratic_rate { - noise.p_idle_quadratic_rate = rate.max(0.0); + noise.p_idle_quadratic_rate = rate; } if let Some(rate) = p_idle_quadratic_sine_rate { noise = noise.set_idle_quadratic_sine_rate(rate); } if let Some(rate) = p_idle_x_quadratic_sine_rate { - noise.p_idle_x_quadratic_sine_rate = rate.max(0.0); + noise.p_idle_x_quadratic_sine_rate = rate; } if let Some(rate) = p_idle_y_quadratic_sine_rate { - noise.p_idle_y_quadratic_sine_rate = rate.max(0.0); + noise.p_idle_y_quadratic_sine_rate = rate; } if let Some(rate) = p_idle_z_quadratic_sine_rate { - noise.p_idle_quadratic_sine_rate = rate.max(0.0); + noise.p_idle_quadratic_sine_rate = rate; } if let Some(weights) = p1_weights { noise = noise.set_p1_weights(parse_p1_weights(weights)?); @@ -1468,6 +1468,7 @@ fn contribution_record_to_pydict( dict.set_item("before_flags", contribution.source_before_flags.to_vec())?; if let Some(family) = contribution.direct_source_family { let family_label = match family { + RustDirectSourceFamily::IdleSignature => "IdleSignature", RustDirectSourceFamily::SingleLocation => "SingleLocation", RustDirectSourceFamily::SingleLocationY => "SingleLocationY", RustDirectSourceFamily::TwoLocationPlainY => "TwoLocationPlainY", @@ -1758,6 +1759,28 @@ impl PyDetectorErrorModel { self.inner.num_contributions() } + /// Quantified residuals from infeasible idle exclusive-to-independent conversions. + /// + /// Each dictionary reports the idle fault location, the concrete flip + /// signature receiving excess probability, and the unavoidable both-fire + /// magnitude. An empty list means every idle conversion was exact. + #[getter] + fn idle_noise_residuals(&self, py: Python<'_>) -> PyResult>> { + self.inner + .idle_noise_residuals() + .iter() + .map(|residual| { + let dict = pyo3::types::PyDict::new(py); + dict.set_item("location_index", residual.location_index)?; + dict.set_item("detectors", residual.effect.detectors.to_vec())?; + dict.set_item("dem_outputs", residual.effect.dem_outputs.to_vec())?; + dict.set_item("tracked_paulis", residual.effect.tracked_paulis.to_vec())?; + dict.set_item("magnitude", residual.magnitude)?; + Ok(dict.unbind()) + }) + .collect() + } + /// Returns debug info about contributions for a specific mechanism. /// /// Args: diff --git a/python/quantum-pecos/src/pecos/qec/_idle_noise.py b/python/quantum-pecos/src/pecos/qec/_idle_noise.py index 9017e518b..7ba46872c 100644 --- a/python/quantum-pecos/src/pecos/qec/_idle_noise.py +++ b/python/quantum-pecos/src/pecos/qec/_idle_noise.py @@ -1,7 +1,15 @@ # Copyright 2026 The PECOS Developers # Licensed under the Apache License, Version 2.0 -"""Shared translation of structured idle-noise families to DEM primitives.""" +"""Shared translation of structured idle-noise families to DEM primitives. + +The linear family is a categorical Pauli channel. The Rust DEM builder first +groups its non-empty propagated flip signatures and only then converts distinct +signatures to independent mechanisms. An infeasible exact conversion uses a +non-negative boundary fit and exposes its quantified both-fire residual on the +DEM. Sine-squared axes are independent already and remain separate mechanisms +from the linear family. +""" from __future__ import annotations diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index b78124b5f..ea7e76d80 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -39,6 +39,7 @@ import hashlib import json import math +import warnings from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any @@ -1271,6 +1272,7 @@ def build(self) -> GuppyDemBuild: named_result_binding = "compiler_direct_scalar_partial" else: named_result_binding = "compiler_direct_scalar_complete" + _warn_on_idle_noise_residuals(dem) return GuppyDemBuild( dem=dem, circuit=circuit, @@ -1285,6 +1287,29 @@ def build(self) -> GuppyDemBuild: ) +def _warn_on_idle_noise_residuals(dem: DetectorErrorModel) -> None: + """Warn when an idle channel could not be represented exactly. + + A mutually exclusive Pauli channel is exactly representable as independent DEM + mechanisms only when each Pauli is at least as likely as the product of the other + two. Below that, independence forces a both-fire contribution the channel does not + have, and the builder emits the closest non-negative fit instead. The shortfall is + recorded on ``dem.idle_noise_residuals``; warn so it is not shipped unnoticed. + """ + residuals = dem.idle_noise_residuals + if not residuals: + return + largest = max(entry["magnitude"] for entry in residuals) + warnings.warn( + f"{len(residuals)} idle noise channel(s) were approximated: a mutually exclusive " + f"channel is not exactly representable as independent DEM mechanisms unless each " + f"Pauli is at least the product of the other two. The closest non-negative fit was " + f"used; largest residual {largest:.3e}. See dem.idle_noise_residuals for details.", + UserWarning, + stacklevel=3, + ) + + def build_dem_from_guppy( guppy: Any, *, @@ -1368,7 +1393,13 @@ def build_dem_from_guppy( p_meas: Measurement flip rate. p_prep: Preparation (reset) error rate. p_idle_linear: Optional total stochastic idle-noise rate linear in - duration. Uses the engines ``GeneralNoiseModel`` convention. + duration. Uses the engines ``GeneralNoiseModel`` categorical-Pauli + convention. The DEM groups non-empty propagated flip signatures + before converting distinct signatures to independent mechanisms. + If exact conversion would require a negative mechanism, the build + uses a non-negative boundary fit and reports its quantified + both-fire residual through ``dem.idle_noise_residuals`` and the + audited build's ``audit["idle_noise_residuals"]`` entry. p_idle_linear_model: Optional relative weights over ``"X"``, ``"Y"``, ``"Z"``, and ``"L"`` for ``p_idle_linear``. Weights must be finite, non-negative, and sum to 1.0 within ``1e-5``, including any @@ -1378,7 +1409,8 @@ def build_dem_from_guppy( p_idle_sin_squared: Optional stochastic sine-law idle rate. An axis multiplier ``m`` gives probability ``sin((p_idle_sin_squared * m) * t)^2``. By default X, Y, and Z - each use the full family rate. + each use the full family rate. These axis mechanisms remain + separate from the linear family. p_idle_sin_squared_model: Optional finite, non-negative relative-rate multipliers over ``"X"``, ``"Y"``, ``"Z"``, and ``"L"``. There is no sum constraint; the default is diff --git a/python/quantum-pecos/src/pecos/qec/dem_spec.py b/python/quantum-pecos/src/pecos/qec/dem_spec.py index e71fe9177..dc6d6120b 100644 --- a/python/quantum-pecos/src/pecos/qec/dem_spec.py +++ b/python/quantum-pecos/src/pecos/qec/dem_spec.py @@ -271,6 +271,7 @@ def audit(self) -> dict[str, Any]: "runtime_order_is_canonical": runtime_order == list(range(len(runtime_order))), "runtime_order_mismatch_count": sum(index != meas_id for index, meas_id in enumerate(runtime_order)), "measurement_ledger": [entry.to_dict() for entry in self.measurement_ledger], + "idle_noise_residuals": self.dem.idle_noise_residuals, } def evaluate_runtime_record(self, values: Sequence[int | bool]) -> tuple[list[int], int]: diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 3d09b0928..8c8731de0 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -178,7 +178,10 @@ class NoiseParameters: t2: T2 dephasing time (must satisfy t2 <= 2*t1). p_idle_linear: Optional total stochastic idle-noise rate linear in idle duration. By default, the total rate is split equally over X, Y, - and Z errors. + and Z errors. DEM construction groups non-empty propagated flip + signatures before converting distinct signatures to independent + mechanisms. Infeasible exact conversions use a non-negative fit + and expose their quantified residual on the DEM. p_idle_linear_model: Optional relative weights over ``"X"``, ``"Y"``, ``"Z"``, and ``"L"`` for ``p_idle_linear``. Weights must be finite, non-negative, and sum to 1.0; ``"L"`` must have zero weight because @@ -186,7 +189,8 @@ class NoiseParameters: p_idle_sin_squared: Optional stochastic sine-law idle rate. An axis multiplier ``m`` produces probability ``sin((p_idle_sin_squared * m) * duration)^2``. By default X, Y, - and Z each use multiplier 1.0. + and Z each use multiplier 1.0. These mechanisms remain separate + from the linear family. p_idle_sin_squared_model: Optional relative-rate multipliers over ``"X"``, ``"Y"``, ``"Z"``, and ``"L"`` for ``p_idle_sin_squared``. Values must be finite and non-negative; 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 e0c3bb09f..c3dee082d 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -304,6 +304,27 @@ def test_noise_model_structured_idle_family_matches_flat_axis_rates(entrypoint: ) assert grouped.to_string() == flat.to_string() + assert grouped.idle_noise_residuals == [] + assert flat.idle_noise_residuals == [] + + +def test_guppy_build_audit_surfaces_idle_conversion_residuals() -> None: + build = build_dem_from_guppy( + _structured_idle_noise_target, + num_qubits=2, + detectors=[Detector(rec[-2])], + observables=[Observable(rec[-1])], + p1=0.0, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p_idle_linear=0.03, + p_idle_linear_model={"X": 0.25, "Z": 0.75}, + idle_after_2q_duration=2.0, + ) + + assert build.audit["idle_noise_residuals"] == build.dem.idle_noise_residuals + assert build.audit["idle_noise_residuals"] == [] @pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) @@ -466,6 +487,7 @@ def test_structured_idle_pauli_models_accept_zero_leakage_weight( ) assert dem.num_contributions > 0 + assert dem.idle_noise_residuals == [] @pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) @@ -2172,3 +2194,39 @@ def test_surface_module_cache_collapses_unconstrained_budget_forms() -> None: # A genuinely-constrained budget is a separate cache entry. assert constrained is not unconstrained_none assert constrained["ancilla_budget"] == 2 + + +def test_idle_noise_residual_warning_fires_only_when_approximated() -> None: + """An approximated idle channel warns; an exact one stays silent. + + The residual is queryable on the DEM, but a field alone is easy to miss, so the + build also warns when it had to fall back to the closest non-negative fit. + """ + import warnings + from typing import ClassVar + + from pecos.qec.dem import _warn_on_idle_noise_residuals + + class _Exact: + idle_noise_residuals: ClassVar[list[dict[str, object]]] = [] + + class _Approximated: + idle_noise_residuals: ClassVar[list[dict[str, object]]] = [ + {"location_index": 3, "magnitude": 1.894e-05}, + {"location_index": 7, "magnitude": 2.1e-05}, + ] + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _warn_on_idle_noise_residuals(_Exact()) + assert caught == [] + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _warn_on_idle_noise_residuals(_Approximated()) + assert len(caught) == 1 + message = str(caught[0].message) + assert "2 idle noise channel(s) were approximated" in message + # The largest magnitude, not the first one encountered. + assert "2.100e-05" in message + assert "dem.idle_noise_residuals" in message From 99dbf7e00b78676364fabbbe7df34697ca67547f Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 5 Aug 2026 07:49:05 -0600 Subject: [PATCH 40/62] Use the paired idle family setters in the workflow example's simulator block --- docs/workflows/guppy-dem-decoding.md | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 78dcf892f..aacb91c4f 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -210,19 +210,16 @@ simulator does not need the runtime to emit idle gates: `with_idle_after_2q` adds an idle site on each two-qubit gate operand, the same placement the DEM pass uses. -The two sides express the idle families in different units, so the sine-law -rate has to be converted rather than copied. `NoiseParameters` takes it in -radians per time unit, while the simulator takes cycles per time unit and folds -in `coherent_to_incoherent_factor / 2`. Dividing by `factor / 2 * 2 * pi` -- -that is, `pi` at the default factor of one -- makes the two agree. At one, the -stochastic branch is the exact Pauli twirl of the coherent rotation. The linear -family needs no conversion, and its model dictionary is a normalized distribution -on both sides. +The idle families take the same rates and the same model dictionaries on both +sides, so stage 3's settings carry over verbatim -- no unit conversion. Each +family is named for its own law, so there is no mode flag to set either. + +The simulator still samples one linear event and then picks an axis, while the +DEM emits independent per-axis mechanisms; the DEM builder converts between the +two so both describe the same Pauli channel. ```python -import math - from pecos import general_noise, selene_engine, sim, stabilizer # The same gate and idle noise the DEM was built with. @@ -232,16 +229,11 @@ noise = ( .with_p2(0.02) .with_p_meas(0.02) .with_p_prep(0.02) - .with_p_idle_linear_rate(0.01) - .with_p_idle_linear_model({"X": 0.25, "Y": 0.25, "Z": 0.5}) - .with_p_idle_quadratic_coherent(False) - .with_p_idle_quadratic_rate(0.03 / math.pi) + .with_p_idle_linear(0.01, {"X": 0.25, "Y": 0.25, "Z": 0.5}) + .with_p_idle_sin_squared(0.03, {"Z": 1.0}) .with_idle_after_2q(1.0) ) -# The conversion above reproduces the DEM's sine-law probability exactly. -assert math.isclose(math.sin(0.03 / math.pi * math.pi) ** 2, math.sin(0.03) ** 2) - results = sim(rep_code_memory).classical(selene_engine()).quantum(stabilizer()).qubits(7).noise(noise).seed(42).run(500) columns = results.to_shot_map().to_dict() From daff4936eb6b187e441ad0cc613b0aeffa075037 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 5 Aug 2026 10:24:29 -0600 Subject: [PATCH 41/62] Convert the gate channels to independent DEM mechanisms at the flip-signature layer --- .../src/fault_tolerance/dem_builder.rs | 14 +- .../fault_tolerance/dem_builder/builder.rs | 281 +++++------- .../dem_builder/dem_sampler.rs | 142 +++++-- .../dem_builder/mem_builder.rs | 84 +++- .../src/fault_tolerance/dem_builder/types.rs | 402 ++++++++++++++---- .../tests/gate_channel_conversion_tests.rs | 364 ++++++++++++++++ crates/pecos-qec/tests/idle_noise_tests.rs | 41 +- docs/user-guide/dem-from-guppy.md | 15 +- .../src/fault_tolerance_bindings.rs | 11 +- python/quantum-pecos/src/pecos/qec/dem.py | 40 +- .../qec/test_decomposed_dem_invariants.py | 118 ++--- .../tests/qec/test_from_guppy_dem.py | 108 ++++- 12 files changed, 1185 insertions(+), 435 deletions(-) create mode 100644 crates/pecos-qec/tests/gate_channel_conversion_tests.rs diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs index 2c118bb1f..58e2d3bf4 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs @@ -100,11 +100,11 @@ pub use sampler::{ pub use types::{ ContributionEffectSummary, ContributionRenderRecord, ContributionRenderStrategy, ContributionRenderSummary, DecomposedFault, DemOutput, DetectorDef, DetectorErrorModel, - DirectSourceFamily, FaultContribution, FaultMechanism, FaultSourceType, IdleNoiseError, - IdleNoiseResidual, MeasurementCrosstalkDemMode, MeasurementCrosstalkTransitionModel, - MeasurementIdleNoiseResidual, MeasurementMechanism, MeasurementNoiseModel, NoiseConfig, - PAULI_1Q_ORDER, PAULI_2Q_ORDER, PauliProbs, PauliWeights, PecosDemMetadataError, - PerGateTypeNoise, ReplacementBranchApproximation, ReplacementBranchImpact, - TwoDetectorDirectRenderPolicy, combine_probabilities, omitted_two_qubit_gate_pauli_twirl, - record_offset_to_absolute_index, + DirectSourceFamily, FaultContribution, FaultMechanism, FaultSourceType, + MeasurementCrosstalkDemMode, MeasurementCrosstalkTransitionModel, MeasurementMechanism, + MeasurementNoiseChannelResidual, MeasurementNoiseModel, NoiseChannelError, NoiseChannelKind, + NoiseChannelResidual, NoiseConfig, PAULI_1Q_ORDER, PAULI_2Q_ORDER, PauliProbs, PauliWeights, + PecosDemMetadataError, PerGateTypeNoise, ReplacementBranchApproximation, + ReplacementBranchImpact, TwoDetectorDirectRenderPolicy, combine_probabilities, + omitted_two_qubit_gate_pauli_twirl, record_offset_to_absolute_index, }; 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 586793049..7d6040c75 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -17,9 +17,10 @@ use super::types::{ DemOutput, DetectorDef, DetectorErrorModel, DirectSourceComponents, DirectSourceFamily, - FaultMechanism, IdleChannelFamilies, IdleNoiseResidual, MeasurementCrosstalkDemMode, - NoiseConfig, PauliProbs, PerGateTypeNoise, ReplacementBranchApproximation, SourceMetadata, - fit_exclusive_idle_signatures, record_offset_to_absolute_index, validate_idle_probabilities, + FaultMechanism, IdleChannelFamilies, MeasurementCrosstalkDemMode, NoiseChannelKind, + NoiseChannelResidual, NoiseConfig, PauliProbs, PerGateTypeNoise, + ReplacementBranchApproximation, SourceMetadata, fit_exclusive_signatures, + record_offset_to_absolute_index, validate_exclusive_probabilities, validate_idle_probabilities, }; use crate::fault_tolerance::propagator::dag::DagSpacetimeLocation; use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Direction, Pauli, apply_gate}; @@ -429,7 +430,7 @@ impl<'a> DemBuilder<'a> { p1_total * weights.weight_for(&Z(0)), ]; } - let per = per_channel_probability(p1_total, 3); + let per = p1_total / 3.0; [per, per, per] } @@ -517,7 +518,7 @@ impl<'a> DemBuilder<'a> { p2_total * weight }); } - [per_channel_probability(self.noise.p2_rate_for_gate(loc1.gate_type), 15); 15] + [self.noise.p2_rate_for_gate(loc1.gate_type) / 15.0; 15] } /// Sets the number of measurements (used for record offset calculation). @@ -812,8 +813,8 @@ impl<'a> DemBuilder<'a> { /// 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. Returns - /// [`DemBuilderError::ConfigurationError`] for an invalid idle input or a - /// non-positive idle signature-channel eigenvalue. + /// [`DemBuilderError::ConfigurationError`] for an invalid noise input or a + /// non-positive signature-channel character. pub fn try_build(&self) -> Result { self.validate_measurement_count()?; self.validate_metadata_refs()?; @@ -835,7 +836,7 @@ impl<'a> DemBuilder<'a> { /// # Panics /// /// Panics if the configured replacement-branch approximation is invalid, - /// or if an idle input or signature channel is invalid. Use + /// or if a noise input or signature channel is invalid. Use /// [`Self::try_build`] to receive those failures as errors. #[must_use] pub fn build(&self) -> DetectorErrorModel { @@ -846,7 +847,7 @@ impl<'a> DemBuilder<'a> { self.validate_idle_noise() .expect("invalid DEM idle-noise configuration"); self.build_inner() - .expect("invalid DEM idle signature conversion") + .expect("invalid DEM signature conversion") } fn build_inner(&self) -> Result { @@ -1536,14 +1537,14 @@ impl<'a> DemBuilder<'a> { if !loc.before => { let rates = self.rates_1q_for_loc(loc); - if rates.iter().any(|r| *r > 0.0) { + if rates.iter().any(|r| *r != 0.0) { self.process_single_qubit_fault_source_tracked( loc_idx, rates, dem, meas_to_detectors, meas_to_observables, - ); + )?; } } GateType::Idle if !loc.before => { @@ -1567,7 +1568,7 @@ impl<'a> DemBuilder<'a> { let loc1 = &locations[loc1_idx]; let loc2 = &locations[loc2_idx]; let rates = self.rates_2q_for_locs(loc1, loc2); - if rates.iter().any(|r| *r > 0.0) { + if rates.iter().any(|r| *r != 0.0) { self.process_two_qubit_fault_source_tracked( loc1_idx, loc2_idx, @@ -1575,7 +1576,7 @@ impl<'a> DemBuilder<'a> { dem, meas_to_detectors, meas_to_observables, - ); + )?; } if self.noise.p2_replacement_approximation == ReplacementBranchApproximation::BranchImpact @@ -1918,15 +1919,22 @@ impl<'a> DemBuilder<'a> { } let context = format!("location {loc_idx} exclusive family {family_index}"); - let fit = fit_exclusive_idle_signatures(exclusive, FaultMechanism::xor, &context) + let fit = fit_exclusive_signatures(exclusive, FaultMechanism::xor, &context) .map_err(|error| DemBuilderError::ConfigurationError(error.to_string()))?; for (effect, probability) in fit.mechanisms { - Self::add_idle_signature_contribution(loc_idx, loc, effect, probability, dem); + Self::add_single_location_signature_contribution( + loc_idx, + loc, + effect, + probability, + dem, + ); } if let Some((effect, magnitude)) = fit.residual { - dem.add_idle_noise_residual(IdleNoiseResidual { + dem.add_idle_noise_residual(NoiseChannelResidual { location_index: u32::try_from(loc_idx) .expect("fault-location index must fit in residual metadata"), + channel_kind: NoiseChannelKind::Idle, effect, magnitude, }); @@ -1938,13 +1946,19 @@ impl<'a> DemBuilder<'a> { (y_effect.clone(), probabilities.py), (z_effect.clone(), probabilities.pz), ] { - Self::add_idle_signature_contribution(loc_idx, loc, effect, probability, dem); + Self::add_single_location_signature_contribution( + loc_idx, + loc, + effect, + probability, + dem, + ); } } Ok(()) } - fn add_idle_signature_contribution( + fn add_single_location_signature_contribution( loc_idx: usize, loc: &DagSpacetimeLocation, effect: FaultMechanism, @@ -1958,12 +1972,11 @@ impl<'a> DemBuilder<'a> { effect, probability, SourceMetadata::new(&[loc_idx], &[], &[loc.gate_type], &[loc.before]) - .with_direct_source_family(DirectSourceFamily::IdleSignature), + .with_direct_source_family(DirectSourceFamily::ExclusiveSignature), ); } - /// Processes a single-qubit gate fault with source tracking. - /// `rates` is `[rate_X, rate_Y, rate_Z]` -- zero entries are skipped. + /// Converts a categorical single-qubit gate channel at the propagated-signature layer. fn process_single_qubit_fault_source_tracked( &self, loc_idx: usize, @@ -1971,76 +1984,50 @@ impl<'a> DemBuilder<'a> { dem: &mut DetectorErrorModel, meas_to_detectors: &BTreeMap>, meas_to_observables: &BTreeMap>, - ) { - let [rate_x, rate_y, rate_z] = rates; - + ) -> Result<(), DemBuilderError> { + let loc = &self.influence_map.locations[loc_idx]; + let context = format!("one-qubit {} gate at location {loc_idx}", loc.gate_type); + validate_exclusive_probabilities(&rates, &context) + .map_err(|error| DemBuilderError::ConfigurationError(error.to_string()))?; let x_effect = self.compute_mechanism(loc_idx, Pauli::X, meas_to_detectors, meas_to_observables); + let y_effect = + self.compute_mechanism(loc_idx, Pauli::Y, meas_to_detectors, meas_to_observables); let z_effect = self.compute_mechanism(loc_idx, Pauli::Z, meas_to_detectors, meas_to_observables); + debug_assert_eq!(y_effect, x_effect.xor(&z_effect)); - // X error: direct source - if rate_x > 0.0 && !x_effect.is_empty() { - dem.add_direct_contribution_with_source( - x_effect.clone(), - rate_x, - SourceMetadata::new( - &[loc_idx], - &[Pauli::X], - &[self.influence_map.locations[loc_idx].gate_type], - &[self.influence_map.locations[loc_idx].before], - ), - ); + let mut exclusive = BTreeMap::new(); + for (effect, probability) in [x_effect, y_effect, z_effect].into_iter().zip(rates) { + if effect.is_empty() || probability == 0.0 { + continue; + } + *exclusive.entry(effect).or_insert(0.0) += probability; } - - // Z error: direct source - if rate_z > 0.0 && !z_effect.is_empty() { - dem.add_direct_contribution_with_source( - z_effect.clone(), - rate_z, - SourceMetadata::new( - &[loc_idx], - &[Pauli::Z], - &[self.influence_map.locations[loc_idx].gate_type], - &[self.influence_map.locations[loc_idx].before], - ), + let fit = fit_exclusive_signatures(exclusive, FaultMechanism::xor, &context) + .map_err(|error| DemBuilderError::ConfigurationError(error.to_string()))?; + for (effect, probability) in fit.mechanisms { + Self::add_single_location_signature_contribution( + loc_idx, + loc, + effect, + probability, + dem, ); } - - // Y error: Y = XZ, so effect is XOR of X and Z effects - let y_effect = x_effect.xor(&z_effect); - if rate_y > 0.0 && !y_effect.is_empty() { - if !x_effect.is_empty() && !z_effect.is_empty() { - dem.add_y_decomposed_contribution_with_source( - &x_effect, - &z_effect, - rate_y, - SourceMetadata::new( - &[loc_idx], - &[Pauli::Y], - &[self.influence_map.locations[loc_idx].gate_type], - &[self.influence_map.locations[loc_idx].before], - ), - ); - } else { - // One is empty, so Y has same effect as the non-empty one (direct source) - dem.add_direct_contribution_with_source( - y_effect, - rate_y, - SourceMetadata::new( - &[loc_idx], - &[Pauli::Y], - &[self.influence_map.locations[loc_idx].gate_type], - &[self.influence_map.locations[loc_idx].before], - ), - ); - } + if let Some((effect, magnitude)) = fit.residual { + dem.add_idle_noise_residual(NoiseChannelResidual { + location_index: u32::try_from(loc_idx) + .expect("fault-location index must fit in residual metadata"), + channel_kind: NoiseChannelKind::SingleQubitGate, + effect, + magnitude, + }); } + Ok(()) } - /// Processes a two-qubit gate fault with source tracking and intra-channel decomposition. - /// `rates` is the 15-entry array in `PAULI_2Q_ORDER` order -- zero entries - /// are skipped. + /// Converts a categorical two-qubit gate channel at the propagated-signature layer. fn process_two_qubit_fault_source_tracked( &self, loc1: usize, @@ -2049,31 +2036,59 @@ impl<'a> DemBuilder<'a> { dem: &mut DetectorErrorModel, meas_to_detectors: &BTreeMap>, meas_to_observables: &BTreeMap>, - ) { + ) -> Result<(), DemBuilderError> { let loc1_meta = &self.influence_map.locations[loc1]; let loc2_meta = &self.influence_map.locations[loc2]; + let context = format!( + "two-qubit {} gate at locations {loc1} and {loc2}", + loc1_meta.gate_type + ); + validate_exclusive_probabilities(&rates, &context) + .map_err(|error| DemBuilderError::ConfigurationError(error.to_string()))?; let effects = self.two_qubit_effect_table(loc1, loc2, meas_to_detectors, meas_to_observables); - // Process all 15 non-trivial Pauli combinations + let mut exclusive = BTreeMap::new(); for p1 in 0u8..4 { for p2 in 0u8..4 { if p1 == 0 && p2 == 0 { - continue; // Skip II + continue; } - - // Per-pair rate: index = 4*p1 + p2 - 1 (skipping II at idx 0). let flat = 4 * (p1 as usize) + (p2 as usize); - let prob = rates[flat - 1]; - if prob == 0.0 { + let probability = rates[flat - 1]; + let effect = effects[p1 as usize][p2 as usize].clone(); + if effect.is_empty() || probability == 0.0 { continue; } - Self::add_two_qubit_pauli_contribution( - loc1, loc2, p1, p2, prob, &effects, loc1_meta, loc2_meta, dem, None, - ); + *exclusive.entry(effect).or_insert(0.0) += probability; } } + let fit = fit_exclusive_signatures(exclusive, FaultMechanism::xor, &context) + .map_err(|error| DemBuilderError::ConfigurationError(error.to_string()))?; + for (effect, probability) in fit.mechanisms { + dem.add_direct_contribution_with_source( + effect, + probability, + SourceMetadata::new( + &[loc1, loc2], + &[], + &[loc1_meta.gate_type, loc2_meta.gate_type], + &[loc1_meta.before, loc2_meta.before], + ) + .with_direct_source_family(DirectSourceFamily::ExclusiveSignature), + ); + } + if let Some((effect, magnitude)) = fit.residual { + dem.add_idle_noise_residual(NoiseChannelResidual { + location_index: u32::try_from(loc1) + .expect("fault-location index must fit in residual metadata"), + channel_kind: NoiseChannelKind::TwoQubitGate, + effect, + magnitude, + }); + } + Ok(()) } fn two_qubit_effect_table( @@ -2585,39 +2600,6 @@ fn pauli_label_to_index(label: char) -> Option { } } -/// Computes the per-error probability for independent error channels. -/// -/// For a depolarizing channel with total error probability `p` split among `n` -/// independent Pauli channels, this computes the probability for each channel -/// such that the combined probability of any error occurring equals `p`. -/// -/// Formula: `p_each = 1 - (1-p)^(1/n)` -/// -/// This is derived from: `P(at least one error) = 1 - P(no errors) = 1 - (1-p_each)^n = p` -/// -/// For small `p`, this is approximately `p/n`, but the exact formula accounts -/// for the independence of error channels. -/// -/// # Arguments -/// -/// * `total_prob` - Total depolarizing probability (e.g., 0.02 for 2% error rate) -/// * `num_channels` - Number of independent error channels (3 for DEPOLARIZE1, 15 for DEPOLARIZE2) -/// -/// # Returns -/// -/// Per-channel error probability -#[inline] -fn per_channel_probability(total_prob: f64, num_channels: u32) -> f64 { - if total_prob <= 0.0 { - return 0.0; - } - if total_prob >= 1.0 { - return 1.0; - } - // p_each = 1 - (1-p)^(1/n) - 1.0 - (1.0 - total_prob).powf(1.0 / f64::from(num_channels)) -} - // ============================================================================ // Intra-Channel Decomposition // ============================================================================ @@ -4178,12 +4160,6 @@ mod tests { if location.num_alternatives == 0 { continue; } - let num_alternatives = f64::from( - u32::try_from(location.num_alternatives) - .expect("fault alternative count fits in u32"), - ); - let per_channel_probability = - 1.0 - location.no_fault_probability.powf(1.0 / num_alternatives); for fault in &location.faults { if fault.affected_detectors.is_empty() && fault.affected_observables.is_empty() { @@ -4200,7 +4176,7 @@ mod tests { .map(|&obs| u32::try_from(obs).unwrap()) .collect(); *by_effect.entry((detectors, observables)).or_insert(0.0) += - per_channel_probability; + fault.absolute_probability; } } by_effect @@ -4226,7 +4202,7 @@ mod tests { .collect() } - fn assert_catalog_dem_probabilities_match( + fn assert_catalog_dem_effects_match( catalog: &FaultCatalog, dem: &DetectorErrorModel, gate_type: GateType, @@ -4238,13 +4214,6 @@ mod tests { dem_probs.keys().collect::>(), "{gate_type:?} should produce the same non-empty effects in the fault catalog and DEM" ); - for (effect, catalog_probability) in catalog_probs { - let dem_probability = dem_probs[&effect]; - assert!( - (catalog_probability - dem_probability).abs() < 1e-12, - "{gate_type:?} effect {effect:?}: catalog probability {catalog_probability} != DEM probability {dem_probability}" - ); - } } for gate_type in [ @@ -4290,7 +4259,7 @@ mod tests { dem_has_source(&dem, gate_type), "DEM should track a source contribution for {gate_type:?}" ); - assert_catalog_dem_probabilities_match(&catalog, &dem, gate_type); + assert_catalog_dem_effects_match(&catalog, &dem, gate_type); } for gate_type in [ @@ -4338,7 +4307,7 @@ mod tests { dem_has_source(&dem, gate_type), "DEM should track a source contribution for {gate_type:?}" ); - assert_catalog_dem_probabilities_match(&catalog, &dem, gate_type); + assert_catalog_dem_effects_match(&catalog, &dem, gate_type); } } @@ -5308,38 +5277,6 @@ mod tests { assert!(vec.is_empty()); } - #[test] - fn test_per_channel_probability() { - // Test DEPOLARIZE1: p=0.01, n=3 - let p1 = per_channel_probability(0.01, 3); - // Should be 1 - (1-0.01)^(1/3) = 0.003344... - assert!((p1 - 0.003_344_506).abs() < 1e-6); - - // Verify: combining 3 channels gives back ~p - let combined = 1.0 - (1.0 - p1).powi(3); - assert!((combined - 0.01).abs() < 1e-10); - - // Test DEPOLARIZE2: p=0.02, n=15 - let p2 = per_channel_probability(0.02, 15); - // Should be 1 - (1-0.02)^(1/15) = 0.001346... - assert!((p2 - 0.001_345_941).abs() < 1e-6); - - // Verify: combining 15 channels gives back ~p - let combined2 = 1.0 - (1.0 - p2).powi(15); - assert!((combined2 - 0.02).abs() < 1e-10); - - // Edge cases - assert!((per_channel_probability(0.0, 3) - 0.0).abs() < f64::EPSILON); - assert!((per_channel_probability(1.0, 3) - 1.0).abs() < f64::EPSILON); - assert!((per_channel_probability(-0.1, 3) - 0.0).abs() < f64::EPSILON); - - // For small p, should be close to p/n - let small_p = per_channel_probability(0.001, 15); - let simple = 0.001 / 15.0; - // Difference should be < 0.1% for small p - assert!((small_p - simple).abs() / simple < 0.001); - } - /// Issue #325 regression: `from_circuit` once produced different DEMs for /// native `F`/`Fdg`/`SY`/`SYdg` versus their unitarily identical /// decompositions (86 mechanisms differed on the d=3 SZZ lowered 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 f4da76610..74dc0d00b 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 @@ -72,9 +72,10 @@ use std::collections::{BTreeMap, BTreeSet}; use wide::u64x4; use super::types::{ - FaultMechanism, IdleChannelFamilies, IdleNoiseResidual, NoiseConfig, PauliProbs, PauliWeights, - PerGateTypeNoise, ReplacementBranchApproximation, combine_probabilities, - fit_exclusive_idle_signatures, validate_idle_probabilities, + FaultMechanism, IdleChannelFamilies, NoiseChannelKind, NoiseChannelResidual, NoiseConfig, + PauliProbs, PauliWeights, PerGateTypeNoise, ReplacementBranchApproximation, + combine_probabilities, fit_exclusive_signatures, validate_exclusive_probabilities, + validate_idle_probabilities, }; // ============================================================================ @@ -268,8 +269,8 @@ pub struct SamplingEngine { num_detectors: usize, /// Number of DEM `L` outputs. num_dem_outputs: usize, - /// Quantified approximations introduced by idle signature conversion. - idle_noise_residuals: Vec, + /// Quantified approximations introduced by categorical signature conversion. + idle_noise_residuals: Vec, } const U32_BASE_AS_F64: f64 = 4_294_967_296.0; @@ -310,9 +311,9 @@ impl SamplingEngine { self.num_dem_outputs } - /// Returns quantified idle-channel approximations made while building. + /// Returns quantified categorical-channel approximations made while building. #[must_use] - pub fn idle_noise_residuals(&self) -> &[IdleNoiseResidual] { + pub fn idle_noise_residuals(&self) -> &[NoiseChannelResidual] { &self.idle_noise_residuals } @@ -448,8 +449,8 @@ impl SamplingEngine { /// /// # Panics /// - /// Panics if an idle-noise input or signature channel is invalid, or if an - /// idle gate event has no corresponding fault location. + /// Panics if a noise input or signature channel is invalid, or if a gate + /// event has no corresponding fault location. #[must_use] pub fn from_influence_map( influence_map: &DagFaultInfluenceMap, @@ -469,7 +470,7 @@ impl SamplingEngine { per_location_probs, &influence_map.locations, ); - if p <= 0.0 { + if p == 0.0 { continue; } @@ -542,7 +543,7 @@ impl SamplingEngine { *exclusive.entry(mechanism).or_insert(0.0) += probability; } let context = format!("location {loc_idx} exclusive family {family_index}"); - let fit = fit_exclusive_idle_signatures(exclusive, DemMechanism::xor, &context) + let fit = fit_exclusive_signatures(exclusive, DemMechanism::xor, &context) .unwrap_or_else(|error| { panic!("invalid DEM idle-noise configuration: {error}") }); @@ -555,9 +556,10 @@ impl SamplingEngine { .or_insert(probability); } if let Some((mechanism, magnitude)) = fit.residual { - idle_noise_residuals.push(IdleNoiseResidual { + idle_noise_residuals.push(NoiseChannelResidual { location_index: u32::try_from(loc_idx) .expect("fault-location index must fit in residual metadata"), + channel_kind: NoiseChannelKind::Idle, effect: mechanism.as_fault_mechanism(), magnitude, }); @@ -616,6 +618,24 @@ impl SamplingEngine { vec![per_event; events.len()] }; + let loc_idx = influence_map + .locations + .iter() + .position(|candidate| candidate.node == loc.node && candidate.before == loc.before) + .expect("gate event must have a fault location"); + let channel_kind = if n_qubits == 2 { + NoiseChannelKind::TwoQubitGate + } else { + NoiseChannelKind::SingleQubitGate + }; + let context = format!( + "{} {} channel at location {loc_idx}", + channel_kind.as_str(), + loc.gate_type + ); + validate_exclusive_probabilities(&event_weights, &context) + .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); + let mut exclusive = BTreeMap::new(); for (event, &event_prob) in events.iter().zip(&event_weights) { let det_indices: SmallVec<[u32; 4]> = event.detectors.iter().copied().collect(); let dem_output_indices: SmallVec<[u32; 2]> = event @@ -625,11 +645,27 @@ impl SamplingEngine { .collect(); let mech = DemMechanism::new(det_indices, dem_output_indices); - if !mech.is_empty() { - let entry = aggregated.entry(mech).or_insert(0.0); - *entry = combine_probabilities(*entry, event_prob); + if !mech.is_empty() && event_prob != 0.0 { + *exclusive.entry(mech).or_insert(0.0) += event_prob; } } + let fit = fit_exclusive_signatures(exclusive, DemMechanism::xor, &context) + .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); + for (mechanism, probability) in fit.mechanisms { + aggregated + .entry(mechanism) + .and_modify(|held| *held = combine_probabilities(*held, probability)) + .or_insert(probability); + } + if let Some((mechanism, magnitude)) = fit.residual { + idle_noise_residuals.push(NoiseChannelResidual { + location_index: u32::try_from(loc_idx) + .expect("fault-location index must fit in residual metadata"), + channel_kind, + effect: mechanism.as_fault_mechanism(), + magnitude, + }); + } } let num_detectors = influence_map.detectors.len(); @@ -2246,12 +2282,13 @@ impl<'a> SamplingEngineBuilder<'a> { if !loc.before => { let rates = self.rates_1q(loc.gate_type, &loc.qubits); - if rates.iter().any(|r| *r > 0.0) { + if rates.iter().any(|r| *r != 0.0) { self.process_depolarizing_fault_rates( loc_idx, rates, &mechanism_context, &mut aggregated, + &mut idle_noise_residuals, ); } } @@ -2278,8 +2315,8 @@ impl<'a> SamplingEngineBuilder<'a> { // Process two-qubit gates as pairs let has_any_2q_noise = self.per_gate.is_some() - || self.p2 > 0.0 - || self.p2_gate_rates.values().any(|rate| *rate > 0.0); + || self.p2 != 0.0 + || self.p2_gate_rates.values().any(|rate| *rate != 0.0); if has_any_2q_noise { for loc_indices in cx_groups.values() { for pair in loc_indices.chunks(2) { @@ -2299,13 +2336,14 @@ impl<'a> SamplingEngineBuilder<'a> { .copied() .collect(); let rates = self.rates_2q(gate_type, &pair_qubits); - if rates.iter().any(|r| *r > 0.0) { + if rates.iter().any(|r| *r != 0.0) { self.process_two_qubit_fault_rates( pair[0], pair[1], rates, &mechanism_context, &mut aggregated, + &mut idle_noise_residuals, ); } } @@ -2566,11 +2604,14 @@ impl<'a> SamplingEngineBuilder<'a> { rates: [f64; 3], context: &FaultMechanismContext<'_>, aggregated: &mut BTreeMap, + residuals: &mut Vec, ) { + let gate_type = self.influence_map.locations[loc_idx].gate_type; + let fit_context = format!("one-qubit {gate_type} gate at location {loc_idx}"); + validate_exclusive_probabilities(&rates, &fit_context) + .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); + let mut exclusive = BTreeMap::new(); for (pauli, &per_pauli_prob) in [Pauli::X, Pauli::Y, Pauli::Z].iter().zip(rates.iter()) { - if per_pauli_prob == 0.0 { - continue; - } let mechanism = self.compute_mechanism( loc_idx, *pauli, @@ -2578,10 +2619,27 @@ impl<'a> SamplingEngineBuilder<'a> { context.influence_observable_ids, context.num_tc_measurements, ); - if !mechanism.is_empty() { - let entry = aggregated.entry(mechanism).or_insert(0.0); - *entry = combine_probabilities(*entry, per_pauli_prob); + if mechanism.is_empty() || per_pauli_prob == 0.0 { + continue; } + *exclusive.entry(mechanism).or_insert(0.0) += per_pauli_prob; + } + let fit = fit_exclusive_signatures(exclusive, DemMechanism::xor, &fit_context) + .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); + for (mechanism, probability) in fit.mechanisms { + aggregated + .entry(mechanism) + .and_modify(|held| *held = combine_probabilities(*held, probability)) + .or_insert(probability); + } + if let Some((mechanism, magnitude)) = fit.residual { + residuals.push(NoiseChannelResidual { + location_index: u32::try_from(loc_idx) + .expect("fault-location index must fit in residual metadata"), + channel_kind: NoiseChannelKind::SingleQubitGate, + effect: mechanism.as_fault_mechanism(), + magnitude, + }); } } @@ -2591,7 +2649,7 @@ impl<'a> SamplingEngineBuilder<'a> { families: IdleChannelFamilies, context: &FaultMechanismContext<'_>, aggregated: &mut BTreeMap, - residuals: &mut Vec, + residuals: &mut Vec, ) { let x_mechanism = self.compute_mechanism( loc_idx, @@ -2641,15 +2699,16 @@ impl<'a> SamplingEngineBuilder<'a> { *exclusive.entry(mechanism).or_insert(0.0) += probability; } let fit_context = format!("location {loc_idx} exclusive family {family_index}"); - let fit = fit_exclusive_idle_signatures(exclusive, DemMechanism::xor, &fit_context) + let fit = fit_exclusive_signatures(exclusive, DemMechanism::xor, &fit_context) .unwrap_or_else(|error| panic!("invalid DEM idle-noise configuration: {error}")); for (mechanism, probability) in fit.mechanisms { add(mechanism, probability, aggregated); } if let Some((mechanism, magnitude)) = fit.residual { - residuals.push(IdleNoiseResidual { + residuals.push(NoiseChannelResidual { location_index: u32::try_from(loc_idx) .expect("fault-location index must fit in residual metadata"), + channel_kind: NoiseChannelKind::Idle, effect: mechanism.as_fault_mechanism(), magnitude, }); @@ -2675,7 +2734,12 @@ impl<'a> SamplingEngineBuilder<'a> { rates: [f64; 15], context: &FaultMechanismContext<'_>, aggregated: &mut BTreeMap, + residuals: &mut Vec, ) { + let gate_type = self.influence_map.locations[loc1].gate_type; + let fit_context = format!("two-qubit {gate_type} gate at locations {loc1} and {loc2}"); + validate_exclusive_probabilities(&rates, &fit_context) + .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); let paulis = [Pauli::I, Pauli::X, Pauli::Y, Pauli::Z]; let mut effects1: [Option; 4] = [None, None, None, None]; @@ -2698,7 +2762,7 @@ impl<'a> SamplingEngineBuilder<'a> { )); } - // Iterate (p1, p2) with global index = 4*p1 + p2 (skipping II at idx 0). + let mut exclusive = BTreeMap::new(); for &p1 in &paulis { for &p2 in &paulis { if p1 == Pauli::I && p2 == Pauli::I { @@ -2724,11 +2788,27 @@ impl<'a> SamplingEngineBuilder<'a> { xor_mechanisms(e1, e2) }; if !mechanism.is_empty() { - let entry = aggregated.entry(mechanism).or_insert(0.0); - *entry = combine_probabilities(*entry, prob); + *exclusive.entry(mechanism).or_insert(0.0) += prob; } } } + let fit = fit_exclusive_signatures(exclusive, DemMechanism::xor, &fit_context) + .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); + for (mechanism, probability) in fit.mechanisms { + aggregated + .entry(mechanism) + .and_modify(|held| *held = combine_probabilities(*held, probability)) + .or_insert(probability); + } + if let Some((mechanism, magnitude)) = fit.residual { + residuals.push(NoiseChannelResidual { + location_index: u32::try_from(loc1) + .expect("fault-location index must fit in residual metadata"), + channel_kind: NoiseChannelKind::TwoQubitGate, + effect: mechanism.as_fault_mechanism(), + magnitude, + }); + } } /// Compute the mechanism (detector/standard observable effects) for a fault. diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs index 6da564ab5..bf5571c17 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs @@ -18,8 +18,9 @@ //! sampling. use super::types::{ - IdleChannelFamilies, MeasurementIdleNoiseResidual, MeasurementMechanism, MeasurementNoiseModel, - NoiseConfig, fit_exclusive_idle_signatures, + IdleChannelFamilies, MeasurementMechanism, MeasurementNoiseChannelResidual, + MeasurementNoiseModel, NoiseChannelKind, NoiseConfig, fit_exclusive_signatures, + validate_exclusive_probabilities, }; use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Pauli}; use pecos_core::gate_type::GateType; @@ -71,7 +72,7 @@ impl<'a> MemBuilder<'a> { /// /// # Panics /// - /// Panics if an idle-noise input or signature channel is invalid. + /// Panics if a noise input or signature channel is invalid. #[must_use] pub fn build(&self) -> MeasurementNoiseModel { let num_measurements = self.influence_map.measurements.len(); @@ -131,7 +132,7 @@ impl<'a> MemBuilder<'a> { | GateType::RZ | GateType::U | GateType::R1XY - if self.noise.p1 > 0.0 && !loc.before => + if self.noise.p1 != 0.0 && !loc.before => { self.process_single_qubit_fault(loc_idx, &mut mem); } @@ -145,7 +146,7 @@ impl<'a> MemBuilder<'a> { panic!("invalid DEM idle-noise configuration: {error}") }); self.process_idle_fault(loc_idx, families, &mut mem); - } else if self.noise.p1 > 0.0 { + } else if self.noise.p1 != 0.0 { self.process_single_qubit_fault(loc_idx, &mut mem); } } @@ -153,7 +154,7 @@ impl<'a> MemBuilder<'a> { } } - if self.noise.p2 > 0.0 { + if self.noise.p2 != 0.0 { for loc_indices in two_qubit_groups.values() { for pair in loc_indices.chunks(2) { if pair.len() == 2 { @@ -208,9 +209,28 @@ impl<'a> MemBuilder<'a> { fn process_single_qubit_fault(&self, loc_idx: usize, mem: &mut MeasurementNoiseModel) { let prob = self.noise.p1 / 3.0; - for pauli in [Pauli::X, Pauli::Y, Pauli::Z] { - self.process_single_pauli_fault(loc_idx, pauli, prob, mem); + let probabilities = [prob; 3]; + let context = format!("one-qubit gate at location {loc_idx}"); + validate_exclusive_probabilities(&probabilities, &context) + .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); + let mut exclusive = std::collections::BTreeMap::new(); + for (pauli, probability) in [Pauli::X, Pauli::Y, Pauli::Z] + .into_iter() + .zip(probabilities) + { + let mechanism = self.compute_mechanism(loc_idx, pauli); + if mechanism.is_empty() || probability == 0.0 { + continue; + } + *exclusive.entry(mechanism).or_insert(0.0) += probability; } + Self::add_exclusive_signatures( + loc_idx, + NoiseChannelKind::SingleQubitGate, + exclusive, + &context, + mem, + ); } fn process_idle_fault( @@ -241,7 +261,7 @@ impl<'a> MemBuilder<'a> { } let context = format!("location {loc_idx} exclusive family {family_index}"); - let fit = fit_exclusive_idle_signatures( + let fit = fit_exclusive_signatures( exclusive, |left, right| xor_measurement_mechanisms(Some(left), Some(right)), &context, @@ -251,9 +271,10 @@ impl<'a> MemBuilder<'a> { mem.add_mechanism(mechanism, probability); } if let Some((mechanism, magnitude)) = fit.residual { - mem.add_idle_noise_residual(MeasurementIdleNoiseResidual { + mem.add_idle_noise_residual(MeasurementNoiseChannelResidual { location_index: u32::try_from(loc_idx) .expect("fault-location index must fit in residual metadata"), + channel_kind: NoiseChannelKind::Idle, mechanism, magnitude, }); @@ -274,6 +295,10 @@ impl<'a> MemBuilder<'a> { fn process_two_qubit_fault(&self, loc1: usize, loc2: usize, mem: &mut MeasurementNoiseModel) { let prob = self.noise.p2 / 15.0; + let probabilities = [prob; 15]; + let context = format!("two-qubit gate at locations {loc1} and {loc2}"); + validate_exclusive_probabilities(&probabilities, &context) + .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); let paulis = [Pauli::I, Pauli::X, Pauli::Y, Pauli::Z]; let mut effects1: [Option; 4] = [None, None, None, None]; @@ -284,6 +309,7 @@ impl<'a> MemBuilder<'a> { effects2[p.as_u8() as usize] = Some(self.compute_mechanism(loc2, p)); } + let mut exclusive = std::collections::BTreeMap::new(); for &p1 in &paulis { for &p2 in &paulis { if p1 == Pauli::I && p2 == Pauli::I { @@ -301,11 +327,45 @@ impl<'a> MemBuilder<'a> { ) }; - if !mechanism.is_empty() { - mem.add_mechanism(mechanism, prob); + if !mechanism.is_empty() && prob != 0.0 { + *exclusive.entry(mechanism).or_insert(0.0) += prob; } } } + Self::add_exclusive_signatures( + loc1, + NoiseChannelKind::TwoQubitGate, + exclusive, + &context, + mem, + ); + } + + fn add_exclusive_signatures( + loc_idx: usize, + channel_kind: NoiseChannelKind, + exclusive: std::collections::BTreeMap, + context: &str, + mem: &mut MeasurementNoiseModel, + ) { + let fit = fit_exclusive_signatures( + exclusive, + |left, right| xor_measurement_mechanisms(Some(left), Some(right)), + context, + ) + .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); + for (mechanism, probability) in fit.mechanisms { + mem.add_mechanism(mechanism, probability); + } + if let Some((mechanism, magnitude)) = fit.residual { + mem.add_idle_noise_residual(MeasurementNoiseChannelResidual { + location_index: u32::try_from(loc_idx) + .expect("fault-location index must fit in residual metadata"), + channel_kind, + mechanism, + magnitude, + }); + } } fn compute_mechanism(&self, loc_idx: usize, pauli: Pauli) -> MeasurementMechanism { diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index ae5d863fb..366c38033 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -113,9 +113,9 @@ pub enum FaultSourceType { /// rendered DEM behavior. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DirectSourceFamily { - /// Independent mechanism obtained from an idle location's flip signature. + /// Independent mechanism obtained from a categorical channel's flip signature. /// No Pauli label is attached because signature aliases are merged first. - IdleSignature, + ExclusiveSignature, /// Single-location direct source without a Y Pauli label. SingleLocation, @@ -167,7 +167,7 @@ pub struct FaultContribution { /// Original Pauli channel at each tracked location. /// - /// This is empty for idle-signature mechanisms, which are defined only + /// This is empty for exclusive-signature mechanisms, which are defined only /// after equal Pauli effects have been merged. pub paulis: SmallVec<[Pauli; 2]>, @@ -328,7 +328,7 @@ impl FaultContribution { source.location_indices.len() == source.paulis.len() || (source.paulis.is_empty() && source.direct_source_family_override - == Some(DirectSourceFamily::IdleSignature)) + == Some(DirectSourceFamily::ExclusiveSignature)) ); debug_assert_eq!(source.location_indices.len(), source.gate_types.len()); debug_assert_eq!(source.location_indices.len(), source.before_flags.len()); @@ -2677,39 +2677,64 @@ pub(crate) struct IdleChannelFamilies { pub(crate) independent: SmallVec<[PauliProbs; 2]>, } -/// An invalid dedicated idle-noise configuration for DEM construction. +/// An invalid noise-channel configuration for DEM construction. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct IdleNoiseError { +pub struct NoiseChannelError { message: String, } -impl IdleNoiseError { +impl NoiseChannelError { fn new(message: String) -> Self { Self { message } } } -impl fmt::Display for IdleNoiseError { +impl fmt::Display for NoiseChannelError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.message) } } -impl std::error::Error for IdleNoiseError {} +impl std::error::Error for NoiseChannelError {} + +/// Kind of categorical noise channel converted to independent DEM mechanisms. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum NoiseChannelKind { + /// Dedicated idle-gate noise. + Idle, + /// A single-qubit gate Pauli channel. + SingleQubitGate, + /// A two-qubit gate Pauli channel. + TwoQubitGate, +} -/// The non-negative boundary fit used for an infeasible idle signature channel. +impl NoiseChannelKind { + /// Stable user-facing label used by residual diagnostics. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::SingleQubitGate => "one-qubit gate", + Self::TwoQubitGate => "two-qubit gate", + } + } +} + +/// The non-negative boundary fit used for an infeasible signature channel. /// -/// The fitted independent mechanisms preserve the two unaffected exclusive -/// signature probabilities exactly. `magnitude` is the unavoidable excess on -/// `effect` (and the equal deficit on the identity outcome) caused when both -/// retained mechanisms fire. +/// `magnitude` is the total-variation distance between the categorical target +/// channel and the emitted independent channel. For the two-dimensional +/// boundary used by idle and gate channels, `effect` receives that same excess +/// probability and identity has the matching deficit. #[derive(Debug, Clone, PartialEq)] -pub struct IdleNoiseResidual { - /// Fault-location index whose idle channel required approximation. +pub struct NoiseChannelResidual { + /// Fault-location index whose channel required approximation. pub location_index: u32, - /// Flip signature receiving the unavoidable excess probability. + /// Kind of channel that required approximation. + pub channel_kind: NoiseChannelKind, + /// Flip signature with the largest non-identity discrepancy. pub effect: FaultMechanism, - /// Absolute probability transferred from identity to `effect`. + /// Total-variation distance from the requested categorical channel. pub magnitude: f64, } @@ -2719,10 +2744,28 @@ pub(crate) struct IndependentSignatureFit { pub(crate) residual: Option<(Signature, f64)>, } +pub(crate) fn validate_exclusive_probabilities( + probabilities: &[f64], + context: &str, +) -> Result<(), NoiseChannelError> { + let total: f64 = probabilities.iter().sum(); + if probabilities + .iter() + .any(|probability| !probability.is_finite() || !(0.0..=1.0).contains(probability)) + || !total.is_finite() + || !(0.0..=1.0).contains(&total) + { + return Err(NoiseChannelError::new(format!( + "invalid {context} categorical probabilities {probabilities:?} with total {total}; every probability and their total must be finite and lie in [0, 1]" + ))); + } + Ok(()) +} + pub(crate) fn validate_idle_probabilities( probabilities: PauliProbs, context: &str, -) -> Result<(), IdleNoiseError> { +) -> Result<(), NoiseChannelError> { let identity = 1.0 - probabilities.total(); if !probabilities.px.is_finite() || !probabilities.py.is_finite() @@ -2733,7 +2776,7 @@ pub(crate) fn validate_idle_probabilities( || !(0.0..=1.0).contains(&probabilities.pz) || !(0.0..=1.0).contains(&identity) { - return Err(IdleNoiseError::new(format!( + return Err(NoiseChannelError::new(format!( "invalid {context} idle channel probabilities [I={identity}, X={}, Y={}, Z={}]; every probability must be finite and lie in [0, 1]", probabilities.px, probabilities.py, probabilities.pz ))); @@ -2744,12 +2787,12 @@ pub(crate) fn validate_idle_probabilities( fn validate_independent_idle_probabilities( probabilities: PauliProbs, context: &str, -) -> Result<(), IdleNoiseError> { +) -> Result<(), NoiseChannelError> { if [probabilities.px, probabilities.py, probabilities.pz] .into_iter() .any(|probability| !probability.is_finite() || !(0.0..=1.0).contains(&probability)) { - return Err(IdleNoiseError::new(format!( + return Err(NoiseChannelError::new(format!( "invalid {context} idle mechanism probabilities [X={}, Y={}, Z={}]; every probability must be finite and lie in [0, 1]", probabilities.px, probabilities.py, probabilities.pz ))); @@ -2761,17 +2804,15 @@ fn validate_independent_idle_probabilities( /// independent Bernoulli mechanisms. /// /// With zero or one signature the categorical channel is already an exact DEM. -/// Otherwise the Pauli-channel eigenvalue formula gives the exact independent -/// rates. If exactly one rate is negative, its non-negative boundary is used: -/// that mechanism is omitted and the other two rates are uniquely chosen to -/// preserve their target signature probabilities. The only remaining error is -/// the both-fire probability transferred from identity to the omitted XOR -/// signature, returned as `residual`. -pub(crate) fn fit_exclusive_idle_signatures( +/// The distinct signatures are assigned deterministic coordinates in their +/// GF(2) span. The target channel's characters are transformed into independent +/// mechanism rates with a Walsh-Hadamard solve. The original closed form is +/// retained for dimension two so existing idle DEM numbers remain byte-exact. +pub(crate) fn fit_exclusive_signatures( exclusive: BTreeMap, xor: Xor, context: &str, -) -> Result, IdleNoiseError> +) -> Result, NoiseChannelError> where Signature: Clone + Ord, Xor: Fn(&Signature, &Signature) -> Signature, @@ -2783,8 +2824,8 @@ where || !total.is_finite() || !(0.0..=1.0).contains(&total) { - return Err(IdleNoiseError::new(format!( - "invalid {context} idle signature probabilities with total {total}; every probability and their total must be finite and lie in [0, 1]" + return Err(NoiseChannelError::new(format!( + "invalid {context} signature probabilities with total {total}; every probability and their total must be finite and lie in [0, 1]" ))); } @@ -2795,7 +2836,172 @@ where }); } - debug_assert!(exclusive.len() <= 3); + let mut coordinates = BTreeMap::::new(); + let mut dimension = 0usize; + for signature in exclusive.keys() { + if coordinates.contains_key(signature) { + continue; + } + let coordinate = 1usize << dimension; + let additions = coordinates + .iter() + .map(|(held, &held_coordinate)| (xor(signature, held), coordinate | held_coordinate)) + .collect::>(); + coordinates.insert(signature.clone(), coordinate); + coordinates.extend(additions); + dimension += 1; + } + + if dimension == 2 { + return fit_exclusive_dimension_two(&exclusive, xor, context); + } + + let size = 1usize << dimension; + debug_assert_eq!(coordinates.len(), size - 1); + let mut signatures = vec![None; size]; + for (signature, coordinate) in &coordinates { + signatures[*coordinate] = Some(signature.clone()); + } + + let mut target = vec![0.0; size]; + target[0] = 1.0 - total; + for (signature, probability) in &exclusive { + target[coordinates[signature]] = *probability; + } + + let mut characters = vec![0.0; size]; + for (dual, character) in characters.iter_mut().enumerate() { + *character = target + .iter() + .enumerate() + .map(|(coordinate, probability)| { + if (dual & coordinate).count_ones().is_multiple_of(2) { + *probability + } else { + -*probability + } + }) + .sum(); + } + if characters + .iter() + .any(|character| !character.is_finite() || *character <= 0.0) + { + return Err(NoiseChannelError::new(format!( + "invalid {context} signature channel: characters {characters:?} must all be positive" + ))); + } + + let log_characters = characters + .iter() + .map(|character| character.ln()) + .collect::>(); + let size_f64 = f64::from(u32::try_from(size).expect("signature-space size must fit in u32")); + let inverse_scale = -2.0 / size_f64; + let mut probabilities = vec![0.0; size]; + for (coordinate, probability) in probabilities.iter_mut().enumerate().skip(1) { + let transform: f64 = log_characters + .iter() + .enumerate() + .map(|(dual, log_character)| { + if (dual & coordinate).count_ones().is_multiple_of(2) { + *log_character + } else { + -*log_character + } + }) + .sum(); + let log_term = inverse_scale * transform; + *probability = (1.0 - log_term.exp()) / 2.0; + } + + if probabilities[1..] + .iter() + .all(|probability| probability.is_finite() && (0.0..=0.5).contains(probability)) + { + let mechanisms = (1..size) + .filter(|coordinate| probabilities[*coordinate] > 0.0) + .map(|coordinate| { + ( + signatures[coordinate] + .clone() + .expect("every nonzero span coordinate has a signature"), + probabilities[coordinate], + ) + }) + .collect(); + return Ok(IndependentSignatureFit { + mechanisms, + residual: None, + }); + } + + for probability in &mut probabilities[1..] { + *probability = if probability.is_finite() { + probability.clamp(0.0, 0.5) + } else if probability.is_sign_negative() { + 0.0 + } else { + 0.5 + }; + } + let mut fitted = vec![0.0; size]; + fitted[0] = 1.0; + for (coordinate, &probability) in probabilities.iter().enumerate().skip(1) { + if probability == 0.0 { + continue; + } + let previous = fitted.clone(); + for effect in 0..size { + fitted[effect] = previous[effect] * (1.0 - probability) + + previous[effect ^ coordinate] * probability; + } + } + let magnitude = fitted + .iter() + .zip(&target) + .map(|(actual, expected)| (actual - expected).abs()) + .sum::() + / 2.0; + let representative = (1..size) + .max_by(|left, right| { + (fitted[*left] - target[*left]) + .abs() + .total_cmp(&(fitted[*right] - target[*right]).abs()) + }) + .expect("a nontrivial span has a nonzero coordinate"); + let mechanisms = (1..size) + .filter(|coordinate| probabilities[*coordinate] > 0.0) + .map(|coordinate| { + ( + signatures[coordinate] + .clone() + .expect("every nonzero span coordinate has a signature"), + probabilities[coordinate], + ) + }) + .collect(); + Ok(IndependentSignatureFit { + mechanisms, + residual: Some(( + signatures[representative] + .clone() + .expect("the representative coordinate has a signature"), + magnitude, + )), + }) +} + +fn fit_exclusive_dimension_two( + exclusive: &BTreeMap, + xor: Xor, + context: &str, +) -> Result, NoiseChannelError> +where + Signature: Clone + Ord, + Xor: Fn(&Signature, &Signature) -> Signature, +{ + let total: f64 = exclusive.values().sum(); let mut signatures: Vec = exclusive.keys().cloned().collect(); let mut target: Vec = exclusive.values().copied().collect(); if signatures.len() == 2 { @@ -2815,8 +3021,8 @@ where .iter() .any(|eigenvalue| !eigenvalue.is_finite() || *eigenvalue <= 0.0) { - return Err(IdleNoiseError::new(format!( - "invalid {context} idle signature channel: eigenvalues [{}, {}, {}] must all be positive", + return Err(NoiseChannelError::new(format!( + "invalid {context} signature channel: characters [{}, {}, {}] must all be positive", eigenvalues[0], eigenvalues[1], eigenvalues[2] ))); } @@ -2826,20 +3032,12 @@ where (1.0 - (eigenvalues[0] * eigenvalues[2] / eigenvalues[1]).sqrt()) / 2.0, (1.0 - (eigenvalues[0] * eigenvalues[1] / eigenvalues[2]).sqrt()) / 2.0, ]; - if exact - .iter() - .any(|probability| !probability.is_finite() || *probability > 0.5) - { - return Err(IdleNoiseError::new(format!( - "invalid {context} idle signature conversion probabilities [{}, {}, {}]", - exact[0], exact[1], exact[2] - ))); - } - let negative: Vec = exact .iter() .enumerate() - .filter_map(|(index, probability)| (*probability < 0.0).then_some(index)) + .filter_map(|(index, probability)| { + (!probability.is_finite() || *probability < 0.0).then_some(index) + }) .collect(); if negative.is_empty() { let mechanisms = signatures @@ -3293,7 +3491,7 @@ impl NoiseConfig { self } - fn validate_idle_rates(family: &str, rates: PauliProbs) -> Result<(), IdleNoiseError> { + fn validate_idle_rates(family: &str, rates: PauliProbs) -> Result<(), NoiseChannelError> { if !rates.px.is_finite() || !rates.py.is_finite() || !rates.pz.is_finite() @@ -3301,7 +3499,7 @@ impl NoiseConfig { || rates.py < 0.0 || rates.pz < 0.0 { - return Err(IdleNoiseError::new(format!( + return Err(NoiseChannelError::new(format!( "invalid {family} idle rate/model [X={}, Y={}, Z={}]; rates must be finite and non-negative", rates.px, rates.py, rates.pz ))); @@ -3309,28 +3507,28 @@ impl NoiseConfig { Ok(()) } - fn base_idle_pauli_probs(&self, duration: f64) -> Result { + fn base_idle_pauli_probs(&self, duration: f64) -> Result { if !duration.is_finite() || duration < 0.0 { - return Err(IdleNoiseError::new(format!( + return Err(NoiseChannelError::new(format!( "invalid idle duration {duration}; duration must be finite and non-negative" ))); } if !self.p_idle.is_finite() || self.p_idle < 0.0 { - return Err(IdleNoiseError::new(format!( + return Err(NoiseChannelError::new(format!( "invalid uniform idle rate {}; rates must be finite and non-negative", self.p_idle ))); } let probabilities = if let (Some(t1), Some(t2)) = (self.t1, self.t2) { if !t1.is_finite() || !t2.is_finite() || t1 <= 0.0 || t2 <= 0.0 || t2 > 2.0 * t1 { - return Err(IdleNoiseError::new(format!( + return Err(NoiseChannelError::new(format!( "invalid idle T1/T2 values [T1={t1}, T2={t2}]; both must be finite and positive, with T2 <= 2*T1" ))); } PauliProbs::from_t1_t2(duration, t1, t2) } else { if self.t1.is_some() || self.t2.is_some() { - return Err(IdleNoiseError::new( + return Err(NoiseChannelError::new( "invalid idle T1/T2 configuration; T1 and T2 must be supplied together" .to_string(), )); @@ -3344,7 +3542,7 @@ impl NoiseConfig { pub(crate) fn try_idle_channel_families( &self, duration: f64, - ) -> Result { + ) -> Result { let base = self.base_idle_pauli_probs(duration)?; let linear_rates = PauliProbs { px: self.p_idle_x_linear_rate, @@ -3408,7 +3606,10 @@ impl NoiseConfig { /// /// Returns an error for a negative/non-finite rate or duration, or when a /// configured family produces an out-of-range probability. - pub fn try_idle_memory_pauli_probs(&self, duration: f64) -> Result { + pub fn try_idle_memory_pauli_probs( + &self, + duration: f64, + ) -> Result { let families = self.try_idle_channel_families(duration)?; let mut channel = PauliProbs::default(); let base_is_present = self.base_idle_pauli_probs(duration)?.total() > 0.0; @@ -3505,7 +3706,7 @@ impl NoiseConfig { /// /// Returns an error for a negative/non-finite rate or duration, an invalid /// T1/T2 pair, or an out-of-range categorical probability. - pub fn try_idle_pauli_probs(&self, duration: f64) -> Result { + pub fn try_idle_pauli_probs(&self, duration: f64) -> Result { let families = self.try_idle_channel_families(duration)?; let mut channel = PauliProbs::default(); for exclusive in families.exclusive { @@ -4281,14 +4482,16 @@ impl fmt::Debug for MeasurementMechanism { } } -/// A quantified idle-channel approximation in raw-measurement space. +/// A quantified categorical-channel approximation in raw-measurement space. #[derive(Debug, Clone, PartialEq)] -pub struct MeasurementIdleNoiseResidual { - /// Fault-location index whose idle channel required approximation. +pub struct MeasurementNoiseChannelResidual { + /// Fault-location index whose channel required approximation. pub location_index: u32, - /// Raw-measurement flip signature receiving the excess probability. + /// Kind of channel that required approximation. + pub channel_kind: NoiseChannelKind, + /// Raw-measurement flip signature with the largest discrepancy. pub mechanism: MeasurementMechanism, - /// Absolute probability transferred from identity to `mechanism`. + /// Total-variation distance from the requested categorical channel. pub magnitude: f64, } @@ -4301,8 +4504,8 @@ pub struct MeasurementNoiseModel { pub num_measurements: usize, /// Optional mapping from influence-map index to original circuit order. pub im_to_tc_order: Option>, - /// Quantified approximations introduced by idle signature conversion. - pub idle_noise_residuals: Vec, + /// Quantified approximations introduced by categorical signature conversion. + pub idle_noise_residuals: Vec, } impl MeasurementNoiseModel { @@ -4348,7 +4551,7 @@ impl MeasurementNoiseModel { .or_insert(probability); } - pub(crate) fn add_idle_noise_residual(&mut self, residual: MeasurementIdleNoiseResidual) { + pub(crate) fn add_idle_noise_residual(&mut self, residual: MeasurementNoiseChannelResidual) { self.idle_noise_residuals.push(residual); } @@ -4532,8 +4735,8 @@ pub struct DetectorErrorModel { /// component effects are non-empty and graphlike (≤2 detectors). /// Used to determine output format: ≥2 → 3 forms, 1 → 2 forms, 0 → 1 form. graphlike_decomposable_counts: BTreeMap<(u32, u32), u32>, - /// Quantified approximations introduced by infeasible idle signature channels. - idle_noise_residuals: Vec, + /// Quantified approximations introduced by infeasible categorical signature channels. + idle_noise_residuals: Vec, } /// Structured DEM mechanism tuple: `(probability, detector_ids, observable_ids)`. @@ -4651,18 +4854,18 @@ impl DetectorErrorModel { self.contributions.len() } - /// Returns every quantified idle-channel approximation made during build. + /// Returns every quantified categorical-channel approximation made during build. /// - /// Each record identifies the concrete flip signature that receives the - /// unavoidable both-fire excess and its matching probability magnitude. - /// An empty slice means idle conversion was exact at every location. + /// Each record identifies the channel kind, a representative concrete flip + /// signature, and the channel's total-variation residual magnitude. + /// An empty slice means every categorical conversion was exact. #[inline] #[must_use] - pub fn idle_noise_residuals(&self) -> &[IdleNoiseResidual] { + pub fn idle_noise_residuals(&self) -> &[NoiseChannelResidual] { &self.idle_noise_residuals } - pub(crate) fn add_idle_noise_residual(&mut self, residual: IdleNoiseResidual) { + pub(crate) fn add_idle_noise_residual(&mut self, residual: NoiseChannelResidual) { self.idle_noise_residuals.push(residual); } @@ -5098,7 +5301,7 @@ impl DetectorErrorModel { fn direct_source_family_label(family: DirectSourceFamily) -> &'static str { match family { - DirectSourceFamily::IdleSignature => "IdleSignature", + DirectSourceFamily::ExclusiveSignature => "ExclusiveSignature", DirectSourceFamily::SingleLocation => "SingleLocation", DirectSourceFamily::SingleLocationY => "SingleLocationY", DirectSourceFamily::TwoLocationPlainY => "TwoLocationPlainY", @@ -6827,6 +7030,59 @@ fn trim_trailing_zeros(s: &str) -> String { #[cfg(test)] mod tests { + + /// The single-qubit gate channel needs conversion too, at its own scale. + /// + /// Three Paulis at `p1/3` with distinct signatures. The expected value was computed + /// independently from the Pauli-channel characters, not from this implementation. + #[test] + fn exclusive_fit_converts_the_single_qubit_gate_channel() { + let per_pauli = 0.002 / 3.0; + let mut exclusive = std::collections::BTreeMap::new(); + exclusive.insert(1u8, per_pauli); + exclusive.insert(2u8, per_pauli); + exclusive.insert(3u8, per_pauli); + + let fit = super::fit_exclusive_signatures(exclusive, |a: &u8, b: &u8| a ^ b, "test") + .expect("uniform three-signature channel is exactly representable"); + + assert!(fit.residual.is_none()); + for probability in fit.mechanisms.values() { + assert!( + (probability - 6.671_117_046_932e-4).abs() < 1e-15, + "got {probability}, expected the converted 6.671117046932e-4 rather than \ + the unconverted {per_pauli}", + ); + } + } + + /// The exclusive->independent fit must convert, not pass probabilities through. + /// + /// Three distinct signatures each carrying `4 * p2/15 = 5.333e-3` (the twelve + /// detectable two-qubit Paulis merging four-to-one). Independent mechanisms also + /// fire together, so each must be raised to 5.362e-3 for the composed channel to + /// equal the requested one. The expected value was computed independently from the + /// Pauli-channel characters, not from this implementation. + #[test] + fn exclusive_fit_raises_probabilities_to_offset_joint_firing() { + let group = 4.0 * 0.02 / 15.0; + let mut exclusive = std::collections::BTreeMap::new(); + exclusive.insert(1u8, group); + exclusive.insert(2u8, group); + exclusive.insert(3u8, group); + + let fit = super::fit_exclusive_signatures(exclusive, |a: &u8, b: &u8| a ^ b, "test") + .expect("uniform three-signature channel is exactly representable"); + + assert!(fit.residual.is_none(), "channel is exactly representable"); + for (signature, probability) in &fit.mechanisms { + assert!( + (probability - 5.362_085_292_012e-3).abs() < 1e-12, + "signature {signature} got {probability}, expected the converted 5.362085292012e-3 \ + rather than the unconverted {group}", + ); + } + } use super::*; #[test] diff --git a/crates/pecos-qec/tests/gate_channel_conversion_tests.rs b/crates/pecos-qec/tests/gate_channel_conversion_tests.rs new file mode 100644 index 000000000..d764d1e00 --- /dev/null +++ b/crates/pecos-qec/tests/gate_channel_conversion_tests.rs @@ -0,0 +1,364 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Regression tests for categorical gate-Pauli channels converted to independent +//! DEM mechanisms after propagation to concrete flip signatures. + +use pecos_core::QubitId; +use pecos_qec::fault_tolerance::dem_builder::{ + DemBuilder, DetectorErrorModel, DirectSourceFamily, FaultMechanism, MeasurementNoiseModel, + MemBuilder, NoiseChannelKind, NoiseConfig, PerGateTypeNoise, combine_probabilities, +}; +use pecos_qec::fault_tolerance::propagator::{DagFaultInfluenceMap, DagSpacetimeLocation}; +use pecos_quantum::GateType; + +const FOUR_DETECTORS: &str = r#"[ + {"id": 0, "records": [-4]}, + {"id": 1, "records": [-3]}, + {"id": 2, "records": [-2]}, + {"id": 3, "records": [-1]} +]"#; + +fn synthetic_one_qubit_influence( + gate_type: GateType, + before: bool, + x: &[u32], + y: &[u32], + z: &[u32], +) -> DagFaultInfluenceMap { + let mut influence = DagFaultInfluenceMap::with_capacity(1); + influence.locations.push(DagSpacetimeLocation { + node: 0, + qubits: vec![QubitId::from(0usize)], + before, + gate_type, + idle_duration: 0.0, + }); + influence.influences.detectors_x.extend(x.iter().copied()); + influence.influences.detectors_y.extend(y.iter().copied()); + influence.influences.detectors_z.extend(z.iter().copied()); + influence.influences.finish_location(); + influence.measurements = (0..4).map(|index| (index, index, 0)).collect(); + influence +} + +fn synthetic_two_qubit_influence() -> DagFaultInfluenceMap { + let mut influence = DagFaultInfluenceMap::with_capacity(2); + for (qubit, x, y, z) in [ + (0, &[0][..], &[0, 1][..], &[1][..]), + (1, &[2][..], &[2, 3][..], &[3][..]), + ] { + influence.locations.push(DagSpacetimeLocation { + node: 0, + qubits: vec![QubitId::from(qubit)], + before: false, + gate_type: GateType::CX, + idle_duration: 0.0, + }); + influence.influences.detectors_x.extend(x.iter().copied()); + influence.influences.detectors_y.extend(y.iter().copied()); + influence.influences.detectors_z.extend(z.iter().copied()); + influence.influences.finish_location(); + } + influence.measurements = (0..4).map(|index| (index, index, 0)).collect(); + influence +} + +fn independent_distribution(model: &MeasurementNoiseModel, dimension: usize) -> Vec { + let mechanisms = model.mechanisms.iter().map(|(mechanism, &probability)| { + let mask = mechanism + .measurements + .iter() + .fold(0usize, |mask, &index| mask ^ (1usize << index)); + (mask, probability) + }); + compose_independent_mechanisms(mechanisms, dimension) +} + +fn fault_mechanism_mask(mechanism: &FaultMechanism) -> usize { + mechanism + .detectors + .iter() + .fold(0usize, |mask, &index| mask ^ (1usize << index)) +} + +fn compose_independent_mechanisms( + mechanisms: impl IntoIterator, + dimension: usize, +) -> Vec { + let size = 1usize << dimension; + let mut distribution = vec![0.0; size]; + distribution[0] = 1.0; + for (mask, probability) in mechanisms { + let previous = distribution.clone(); + for effect in 0..size { + distribution[effect] = + previous[effect] * (1.0 - probability) + previous[effect ^ mask] * probability; + } + } + distribution +} + +fn build_synthetic_dem(influence: &DagFaultInfluenceMap, noise: NoiseConfig) -> DetectorErrorModel { + DemBuilder::new(influence) + .with_noise_config(noise) + .with_detectors_json(FOUR_DETECTORS) + .expect("valid detector metadata") + .try_build() + .expect("valid categorical channel") +} + +fn build_synthetic_per_gate_dem( + influence: &DagFaultInfluenceMap, + gate_type: GateType, + rates: [f64; 3], +) -> DetectorErrorModel { + let noise = PerGateTypeNoise::from_base_noise(NoiseConfig::new(0.0, 0.0, 0.0, 0.0)) + .with_1q_rates(gate_type, rates); + DemBuilder::new(influence) + .with_per_gate_noise(noise) + .with_detectors_json(FOUR_DETECTORS) + .expect("valid detector metadata") + .try_build() + .expect("valid categorical channel") +} + +fn gate_signature_mechanisms(dem: &DetectorErrorModel) -> Vec<(FaultMechanism, f64)> { + dem.contribution_render_records() + .into_iter() + .map(|record| { + let contribution = record.contribution; + assert!(contribution.paulis.is_empty()); + assert_eq!( + contribution.direct_source_family, + Some(DirectSourceFamily::ExclusiveSignature) + ); + (contribution.effect, contribution.probability) + }) + .collect() +} + +#[test] +fn single_qubit_gate_mechanisms_compose_to_the_three_pauli_channel() { + let p1 = 0.002; + let influence = synthetic_one_qubit_influence(GateType::H, false, &[0], &[0, 1], &[1]); + let model = MemBuilder::new(&influence) + .with_noise_config(NoiseConfig::new(p1, 0.0, 0.0, 0.0)) + .build(); + let distribution = independent_distribution(&model, 2); + let target = p1 / 3.0; + + assert_eq!(model.mechanisms.len(), 3); + for &probability in model.mechanisms.values() { + assert!((probability - 0.000_667_111_704_693_190_7).abs() < 1e-15); + } + assert!((distribution[0] - (1.0 - p1)).abs() < 1e-12); + for effect_probability in &distribution[1..] { + assert!((effect_probability - target).abs() < 1e-12); + } + + let dem = build_synthetic_dem(&influence, NoiseConfig::new(p1, 0.0, 0.0, 0.0)); + let mechanisms = gate_signature_mechanisms(&dem); + assert_eq!(mechanisms.len(), 3); + assert!(dem.idle_noise_residuals().is_empty()); + let dem_distribution = compose_independent_mechanisms( + mechanisms + .iter() + .map(|(effect, probability)| (fault_mechanism_mask(effect), *probability)), + 2, + ); + for (actual, expected) in dem_distribution + .iter() + .zip([1.0 - p1, target, target, target]) + { + assert!( + (actual - expected).abs() < 1e-12, + "distribution={dem_distribution:?}, mechanisms={mechanisms:?}" + ); + } +} + +#[test] +fn two_qubit_gate_mechanisms_compose_to_the_fifteen_pauli_channel() { + let p2 = 0.02; + let influence = synthetic_two_qubit_influence(); + let model = MemBuilder::new(&influence) + .with_noise_config(NoiseConfig::new(0.0, p2, 0.0, 0.0)) + .build(); + let distribution = independent_distribution(&model, 4); + let target = p2 / 15.0; + + assert_eq!(model.mechanisms.len(), 15); + for &probability in model.mechanisms.values() { + assert!((probability - 0.001_345_946_290_707_722_4).abs() < 1e-15); + } + assert!((distribution[0] - (1.0 - p2)).abs() < 1e-12); + for effect_probability in &distribution[1..] { + assert!((effect_probability - target).abs() < 1e-12); + } + + let dem = build_synthetic_dem(&influence, NoiseConfig::new(0.0, p2, 0.0, 0.0)); + let mechanisms = gate_signature_mechanisms(&dem); + assert_eq!(mechanisms.len(), 15); + assert!(dem.idle_noise_residuals().is_empty()); + let dem_distribution = compose_independent_mechanisms( + mechanisms + .iter() + .map(|(effect, probability)| (fault_mechanism_mask(effect), *probability)), + 4, + ); + assert!( + (dem_distribution[0] - (1.0 - p2)).abs() < 1e-12, + "distribution={dem_distribution:?}, mechanisms={mechanisms:?}" + ); + for effect_probability in &dem_distribution[1..] { + assert!((effect_probability - target).abs() < 1e-12); + } +} + +#[test] +fn equal_gate_signatures_sum_before_independent_merging() { + let influence = synthetic_one_qubit_influence(GateType::H, false, &[0], &[], &[0]); + let dem = build_synthetic_per_gate_dem(&influence, GateType::H, [0.2, 0.0, 0.3]); + let mechanisms = gate_signature_mechanisms(&dem); + + assert_eq!(mechanisms.len(), 1); + assert_eq!(mechanisms[0].1.to_bits(), 0.5_f64.to_bits()); + assert_ne!( + mechanisms[0].1.to_bits(), + combine_probabilities(0.2, 0.3).to_bits() + ); + assert!(dem.idle_noise_residuals().is_empty()); +} + +#[test] +fn vanishing_gate_signatures_drop_without_changing_survivors() { + let influence = synthetic_one_qubit_influence(GateType::H, false, &[], &[1], &[1]); + let dem = build_synthetic_per_gate_dem(&influence, GateType::H, [0.2, 0.3, 0.4]); + let mechanisms = gate_signature_mechanisms(&dem); + + assert_eq!(mechanisms.len(), 1); + assert!((mechanisms[0].1 - 0.7).abs() < 1e-15); + assert!(dem.idle_noise_residuals().is_empty()); +} + +#[test] +fn prep_and_measurement_channels_remain_single_exact_mechanisms() { + for (gate_type, before, noise, expected) in [ + ( + GateType::PZ, + false, + NoiseConfig::new(0.0, 0.0, 0.0, 0.25), + 0.25_f64, + ), + ( + GateType::MZ, + true, + NoiseConfig::new(0.0, 0.0, 0.375, 0.0), + 0.375_f64, + ), + ] { + let influence = synthetic_one_qubit_influence(gate_type, before, &[0], &[], &[]); + let model = MemBuilder::new(&influence) + .with_noise_config(noise.clone()) + .build(); + assert_eq!(model.mechanisms.len(), 1); + assert_eq!( + model + .mechanisms + .values() + .next() + .expect("single prep or measurement mechanism") + .to_bits(), + expected.to_bits() + ); + assert!(model.idle_noise_residuals.is_empty()); + + let dem = build_synthetic_dem(&influence, noise); + let records = dem.contribution_render_records(); + assert_eq!(records.len(), 1); + assert_eq!( + records[0].contribution.probability.to_bits(), + expected.to_bits() + ); + assert!(dem.idle_noise_residuals().is_empty()); + } +} + +#[test] +fn infeasible_gate_channel_reports_kind_and_queryable_magnitude() { + let influence = synthetic_one_qubit_influence(GateType::H, false, &[0], &[0, 1], &[1]); + let dem = build_synthetic_per_gate_dem(&influence, GateType::H, [0.0075, 0.0, 0.0225]); + + let [residual] = dem.idle_noise_residuals() else { + panic!("the infeasible gate channel must report one residual") + }; + assert_eq!(residual.channel_kind, NoiseChannelKind::SingleQubitGate); + assert_eq!(fault_mechanism_mask(&residual.effect), 0b11); + assert!(residual.magnitude > 0.0); + let mechanisms = gate_signature_mechanisms(&dem); + let distribution = compose_independent_mechanisms( + mechanisms + .iter() + .map(|(effect, probability)| (fault_mechanism_mask(effect), *probability)), + 2, + ); + assert!((distribution[0b11] - residual.magnitude).abs() < 1e-12); +} + +#[test] +fn broken_gate_probabilities_and_nonpositive_characters_are_hard_errors() { + let influence = synthetic_one_qubit_influence(GateType::H, false, &[0], &[0, 1], &[1]); + let build = |rates| { + DemBuilder::new(&influence) + .with_per_gate_noise( + PerGateTypeNoise::from_base_noise(NoiseConfig::new(0.0, 0.0, 0.0, 0.0)) + .with_1q_rates(GateType::H, rates), + ) + .with_detectors_json(FOUR_DETECTORS) + .expect("valid detector metadata") + .try_build() + }; + + let probability_error = build([0.6, 0.6, 0.0]) + .expect_err("a categorical total above one must be rejected") + .to_string(); + assert!(probability_error.contains("one-qubit H gate")); + assert!(probability_error.contains("total 1.2")); + assert!(probability_error.contains("must be finite and lie in [0, 1]")); + + for rates in [[-0.1, 0.1, 0.0], [f64::NAN, 0.0, 0.0]] { + let probability_error = build(rates) + .expect_err("non-finite and out-of-range probabilities must be rejected") + .to_string(); + assert!(probability_error.contains("one-qubit H gate")); + assert!(probability_error.contains("must be finite and lie in [0, 1]")); + } + + let character_error = build([0.25, 0.0, 0.25]) + .expect_err("a zero signature character must be rejected") + .to_string(); + assert!(character_error.contains("one-qubit H gate")); + assert!(character_error.contains("characters")); + assert!(character_error.contains("must all be positive")); +} + +#[test] +fn identical_gate_configuration_produces_byte_identical_dem_text() { + let influence = synthetic_two_qubit_influence(); + let build = + || build_synthetic_dem(&influence, NoiseConfig::new(0.0, 0.02, 0.0, 0.0)).to_string(); + let expected = build(); + for _ in 0..16 { + assert_eq!(build().as_bytes(), expected.as_bytes()); + } +} diff --git a/crates/pecos-qec/tests/idle_noise_tests.rs b/crates/pecos-qec/tests/idle_noise_tests.rs index c1886242c..61bf920d8 100644 --- a/crates/pecos-qec/tests/idle_noise_tests.rs +++ b/crates/pecos-qec/tests/idle_noise_tests.rs @@ -625,17 +625,17 @@ fn linear_and_sine_idle_families_emit_separate_contributions() { } #[test] -fn nonpositive_signature_channel_eigenvalue_returns_specific_error() { +fn nonpositive_signature_channel_character_returns_specific_error() { let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); let error = build_synthetic_idle_dem( &influence, NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_pauli_linear_rates(0.25, 0.0, 0.25), ) - .expect_err("zero signature-channel eigenvalues are broken input"); + .expect_err("zero signature-channel characters are broken input"); assert!(error.contains("DEM builder configuration error")); assert!(error.contains("location")); - assert!(error.contains("eigenvalues")); + assert!(error.contains("characters")); assert!(error.contains("must all be positive")); } @@ -681,24 +681,35 @@ fn negative_idle_duration_is_rejected_instead_of_clamped() { #[test] fn identical_idle_configuration_produces_byte_identical_dem_text() { - let dag = build_idle_then_measure(3); - let influence = DagFaultAnalyzer::new(&dag).build_influence_map(); - let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) - .set_idle_pauli_linear_rates(1.0e-5, 2.0e-5, 3.0e-5) - .set_idle_pauli_quadratic_sine_rates(0.001, 0.002, 0.003); + let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); + let noise = + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_pauli_linear_rates(0.002, 0.003, 0.005); let build = || { - DemBuilder::new(&influence) - .with_noise_config(noise.clone()) - .with_detectors_json( - r#"[{"id": 0, "records": [-3]}, {"id": 1, "records": [-2]}, {"id": 2, "records": [-1]}]"#, - ) - .expect("valid detector metadata") - .try_build() + build_synthetic_idle_dem(&influence, noise.clone()) .expect("valid deterministic DEM") .to_string() }; let expected = build(); + assert_eq!( + expected, + "detector D0\ndetector D1\nerror(0.002001) D0\nerror(0.003011) D0 D1\nerror(0.005019) D1", + "this full dimension-two DEM text is pinned to commit 79e8aa833", + ); + let dem = build_synthetic_idle_dem(&influence, noise.clone()).expect("valid pinned DEM"); + let probabilities = idle_signature_contributions(&dem) + .into_iter() + .map(|(_, probability)| probability.to_bits()) + .collect::>(); + assert_eq!( + probabilities, + [ + 0.002_000_955_040_586_616_f64.to_bits(), + 0.003_011_095_091_214_222_f64.to_bits(), + 0.005_019_131_070_643_723_f64.to_bits(), + ], + "dimension-two idle mechanism bits are pinned to commit 79e8aa833", + ); for _ in 0..16 { assert_eq!(build().as_bytes(), expected.as_bytes()); } diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index b54ac0375..e7645dd7d 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -324,12 +324,15 @@ incoherent-conversion factor, and cycles-to-radians), so builder inputs are not directly interchangeable with these parameters. Every residual is readable from `dem.idle_noise_residuals` as a dictionary -containing `location_index`, the concrete `detectors`/`dem_outputs`/ -`tracked_paulis` signature, and `magnitude`. The magnitude is the unavoidable -both-fire excess on that signature and the equal deficit on the identity -outcome. Audited Guppy builds also copy this list to -`dem_build.audit["idle_noise_residuals"]`. An empty list certifies that all idle -signature conversions were exact. +containing `channel_kind`, `location_index`, the concrete +`detectors`/`dem_outputs`/`tracked_paulis` signature, and `magnitude`. Gate and +idle categorical Pauli channels share this list. The magnitude is the +total-variation distance between the requested categorical channel and the +emitted independent mechanisms; in the two-dimensional boundary case it is +also the excess on the reported signature and the matching identity deficit. +Audited Guppy builds copy this list to +`dem_build.audit["idle_noise_residuals"]`. An empty list certifies that all +categorical signature conversions were exact. The per-axis `p_idle_{x,y,z}_linear_rate`, `p_idle_{x,y,z}_quadratic_rate`, and diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 3f4de7b23..4dd4ac056 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -1468,7 +1468,7 @@ fn contribution_record_to_pydict( dict.set_item("before_flags", contribution.source_before_flags.to_vec())?; if let Some(family) = contribution.direct_source_family { let family_label = match family { - RustDirectSourceFamily::IdleSignature => "IdleSignature", + RustDirectSourceFamily::ExclusiveSignature => "ExclusiveSignature", RustDirectSourceFamily::SingleLocation => "SingleLocation", RustDirectSourceFamily::SingleLocationY => "SingleLocationY", RustDirectSourceFamily::TwoLocationPlainY => "TwoLocationPlainY", @@ -1759,11 +1759,11 @@ impl PyDetectorErrorModel { self.inner.num_contributions() } - /// Quantified residuals from infeasible idle exclusive-to-independent conversions. + /// Quantified residuals from infeasible categorical-to-independent conversions. /// - /// Each dictionary reports the idle fault location, the concrete flip - /// signature receiving excess probability, and the unavoidable both-fire - /// magnitude. An empty list means every idle conversion was exact. + /// Each dictionary reports the channel kind, fault location, representative + /// flip signature, and total-variation magnitude. An empty list means every + /// categorical conversion was exact. #[getter] fn idle_noise_residuals(&self, py: Python<'_>) -> PyResult>> { self.inner @@ -1772,6 +1772,7 @@ impl PyDetectorErrorModel { .map(|residual| { let dict = pyo3::types::PyDict::new(py); dict.set_item("location_index", residual.location_index)?; + dict.set_item("channel_kind", residual.channel_kind.as_str())?; dict.set_item("detectors", residual.effect.detectors.to_vec())?; dict.set_item("dem_outputs", residual.effect.dem_outputs.to_vec())?; dict.set_item("tracked_paulis", residual.effect.tracked_paulis.to_vec())?; diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index ea7e76d80..0967a2e6b 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -446,11 +446,13 @@ def from_guppy( point's defaults. In particular, ``NoiseParameters`` defaults such as ``p1=0.0`` apply instead of this function's ``p1=0.001``. Mixing ``noise`` with any flat noise keyword is rejected. - p1: Single-qubit gate Pauli error rate. + p1: Single-qubit gate Pauli error rate. The categorical Pauli + channel is converted after equal propagated signatures merge. p1_weights: Optional relative probabilities over single-qubit Pauli error labels ``"X"``, ``"Y"``, and ``"Z"``. Values must sum to 1.0; ``p1`` remains the total single-qubit error rate. - p2: Two-qubit gate depolarizing rate. + p2: Two-qubit gate depolarizing rate. Its 15 categorical branches + are converted together after propagation. p2_weights: Optional relative probabilities over two-qubit Pauli error labels. Plain labels such as ``"XX"`` are post-gate Pauli branches; labels prefixed by ``"*"`` such as ``"*XX"`` @@ -1272,7 +1274,7 @@ def build(self) -> GuppyDemBuild: named_result_binding = "compiler_direct_scalar_partial" else: named_result_binding = "compiler_direct_scalar_complete" - _warn_on_idle_noise_residuals(dem) + _warn_on_noise_channel_residuals(dem) return GuppyDemBuild( dem=dem, circuit=circuit, @@ -1287,24 +1289,22 @@ def build(self) -> GuppyDemBuild: ) -def _warn_on_idle_noise_residuals(dem: DetectorErrorModel) -> None: - """Warn when an idle channel could not be represented exactly. - - A mutually exclusive Pauli channel is exactly representable as independent DEM - mechanisms only when each Pauli is at least as likely as the product of the other - two. Below that, independence forces a both-fire contribution the channel does not - have, and the builder emits the closest non-negative fit instead. The shortfall is - recorded on ``dem.idle_noise_residuals``; warn so it is not shipped unnoticed. - """ +def _warn_on_noise_channel_residuals(dem: DetectorErrorModel) -> None: + """Warn when a categorical Pauli channel could not be represented exactly.""" residuals = dem.idle_noise_residuals if not residuals: return - largest = max(entry["magnitude"] for entry in residuals) + by_kind: dict[str, list[float]] = {} + for entry in residuals: + kind = str(entry["channel_kind"]) + by_kind.setdefault(kind, []).append(float(entry["magnitude"])) + kinds = ", ".join( + f"{len(magnitudes)} {kind} (largest {max(magnitudes):.3e})" for kind, magnitudes in sorted(by_kind.items()) + ) warnings.warn( - f"{len(residuals)} idle noise channel(s) were approximated: a mutually exclusive " - f"channel is not exactly representable as independent DEM mechanisms unless each " - f"Pauli is at least the product of the other two. The closest non-negative fit was " - f"used; largest residual {largest:.3e}. See dem.idle_noise_residuals for details.", + f"{len(residuals)} categorical noise channel(s) were approximated: {kinds}. " + "A non-negative boundary fit was emitted; magnitudes are total-variation " + "distances from the requested channels. See dem.idle_noise_residuals for details.", UserWarning, stacklevel=3, ) @@ -1382,10 +1382,12 @@ def build_dem_from_guppy( defaults. In particular, ``NoiseParameters`` defaults such as ``p1=0.0`` apply instead of this function's ``p1=0.001``. Mixing ``noise`` with any flat noise keyword is rejected. - p1: Single-qubit gate Pauli error rate. + p1: Single-qubit gate Pauli error rate. The categorical Pauli channel + is converted after equal propagated signatures merge. p1_weights: Optional relative probabilities over single-qubit Pauli error labels ``"X"``, ``"Y"``, and ``"Z"``. - p2: Two-qubit gate depolarizing rate. + p2: Two-qubit gate depolarizing rate. Its 15 categorical branches are + converted together after propagation. p2_weights: Optional relative probabilities over two-qubit Pauli error labels, including starred replacement branches. p2_replacement_approximation: Approximation used for starred diff --git a/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py b/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py index ec3d99dd9..619382971 100644 --- a/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py +++ b/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py @@ -85,39 +85,6 @@ def singleton_l0_edges(direct_targets: set[tuple[tuple[int, ...], tuple[int, ... return {dets[0] for dets, logs in direct_targets if len(dets) == 1 and len(logs) == 1} -def xor_lists(left: list[int], right: list[int]) -> list[int]: - """XOR two integer lists interpreted as parity sets.""" - out = set(left) - for value in right: - if value in out: - out.remove(value) - else: - out.add(value) - return sorted(out) - - -def xor_effect_rows(left: dict[str, list[int]], right: dict[str, list[int]]) -> tuple[list[int], list[int]]: - """XOR two structured detector/DEM-output rows.""" - return ( - xor_lists(left["detectors"], right["detectors"]), - xor_lists(left["dem_outputs"], right["dem_outputs"]), - ) - - -def xor_source_components(row: dict[str, object]) -> tuple[list[int], list[int]]: - """XOR a structured row's source component effects.""" - dets: list[int] = [] - outputs: list[int] = [] - for part_dets, part_outputs in zip( - row["source_component_detectors"], - row["source_component_dem_outputs"], - strict=True, - ): - dets = xor_lists(dets, list(part_dets)) - outputs = xor_lists(outputs, list(part_outputs)) - return dets, outputs - - def parse_dem_error_probabilities(dem_str: str) -> dict[str, float]: """Map DEM target strings to their stated error probabilities.""" out: dict[str, float] = {} @@ -490,11 +457,18 @@ def test_structured_source_tracking_bindings_are_self_consistent(basis: str) -> total_probability = sum(float(row["probability"]) for row in contributions) direct_rows = [row for row in contributions if row["source_type"] in DIRECT_SOURCE_TYPES] y_rows = [row for row in contributions if row["source_type"] == "YDecomposed"] + signature_rows = [row for row in contributions if row["direct_source_family"] == "ExclusiveSignature"] assert all(row["location_indices"] for row in contributions) - assert all(row["pauli_labels"] for row in contributions) + assert signature_rows + assert all(not row["pauli_labels"] for row in signature_rows) + assert all(row["pauli_labels"] or row["direct_source_family"] == "ExclusiveSignature" for row in contributions) assert all("gate_type_labels" in row for row in contributions) assert all("before_flags" in row for row in contributions) - assert all(len(row["location_indices"]) == len(row["pauli_labels"]) for row in contributions) + assert all( + len(row["location_indices"]) == len(row["pauli_labels"]) + or (not row["pauli_labels"] and row["direct_source_family"] == "ExclusiveSignature") + for row in contributions + ) assert all(len(row["location_indices"]) == len(row["gate_type_labels"]) for row in contributions) assert all(len(row["location_indices"]) == len(row["before_flags"]) for row in contributions) assert all(all(label in {"I", "X", "Y", "Z"} for label in row["pauli_labels"]) for row in contributions) @@ -511,75 +485,54 @@ def test_structured_source_tracking_bindings_are_self_consistent(basis: str) -> @pytest.mark.parametrize("basis", ["X", "Z"]) -def test_structured_source_component_rows_xor_back_to_effect(basis: str) -> None: - """Source component rows should XOR back to their parent effect.""" +def test_exclusive_signature_rows_do_not_claim_source_frame_components(basis: str) -> None: + """Converted signature mechanisms must not claim an original Pauli decomposition.""" dem = build_source_tracked_dem(distance=3, basis=basis, rounds=20) rows = [] for summary in dem.contribution_effect_summaries(): for row in dem.contributions_for_effect(summary["detectors"], summary["dem_outputs"]): - if "source_component_detectors" not in row: + if row.get("direct_source_family") != "ExclusiveSignature": continue rows.append((summary, row)) assert rows - - for summary, row in rows[:100]: - dets, outputs = xor_source_components(row) - assert dets == summary["detectors"] - assert outputs == summary["dem_outputs"] + assert all("source_component_detectors" not in row for _, row in rows) + assert all("source_component_dem_outputs" not in row for _, row in rows) @pytest.mark.parametrize("basis", ["X", "Z"]) -def test_structured_direct_component_rows_xor_back_to_effect(basis: str) -> None: - """Stored direct components should reconstruct the parent effect via XOR.""" +def test_exclusive_signature_rows_do_not_claim_legacy_direct_components(basis: str) -> None: + """Converted signature mechanisms must not expose fabricated two-location components.""" dem = build_source_tracked_dem(distance=3, basis=basis, rounds=20) rows = [] for summary in dem.contribution_effect_summaries(): for row in dem.contributions_for_effect(summary["detectors"], summary["dem_outputs"]): - if row["source_type"] not in DIRECT_SOURCE_TYPES: - continue - if "component_1_detectors" not in row or "component_2_detectors" not in row: + if row.get("direct_source_family") != "ExclusiveSignature": continue rows.append((summary, row)) assert rows - - for summary, row in rows[:100]: - left = { - "detectors": row["component_1_detectors"], - "dem_outputs": row["component_1_dem_outputs"], - } - right = { - "detectors": row["component_2_detectors"], - "dem_outputs": row["component_2_dem_outputs"], - } - dets, logs = xor_effect_rows(left, right) - assert dets == summary["detectors"] - assert logs == summary["dem_outputs"] + assert all("component_1_detectors" not in row for _, row in rows) + assert all("component_2_detectors" not in row for _, row in rows) @pytest.mark.parametrize("basis", ["X", "Z"]) -def test_structured_one_sided_direct_component_rows_are_exposed(basis: str) -> None: - """One-sided direct components should remain visible in the structured bindings.""" +def test_exclusive_signature_rows_stay_direct_without_one_sided_subtypes(basis: str) -> None: + """Converted gate signatures are direct mechanisms, including aliased effects.""" dem = build_source_tracked_dem(distance=3, basis=basis, rounds=20) rows = [] for summary in dem.contribution_effect_summaries(): for row in dem.contributions_for_effect(summary["detectors"], summary["dem_outputs"]): - if row["source_type"] != "DirectOneSidedComponent": + if row.get("direct_source_family") != "ExclusiveSignature": continue rows.append((summary, row)) assert rows - - for summary, row in rows[:100]: - assert "source_component_detectors" in row - assert "source_component_dem_outputs" in row - direct_dets, direct_logs = xor_source_components(row) - assert direct_dets == summary["detectors"] - assert direct_logs == summary["dem_outputs"] + assert all(row["source_type"] == "Direct" for _, row in rows) + assert all(len(row["location_indices"]) in {1, 2} for _, row in rows) @pytest.mark.parametrize("basis", ["X", "Z"]) @@ -597,9 +550,9 @@ def test_structured_direct_source_families_are_exposed_for_direct_rows(basis: st assert rows assert all("direct_source_family" in row for row in rows) - assert any(row["direct_source_family"] == "SingleLocationY" for row in rows) - assert any(row["direct_source_family"] == "TwoLocationComponent" for row in rows) - assert any(row["source_type"] == "DirectOneSidedComponent" for row in rows) + assert any(row["direct_source_family"] == "ExclusiveSignature" for row in rows) + assert {row["direct_source_family"] for row in rows} <= {"ExclusiveSignature", "SingleLocation"} + assert all(row["source_type"] == "Direct" for row in rows) @pytest.mark.parametrize("basis", ["X", "Z"]) @@ -649,7 +602,8 @@ def test_structured_render_summaries_reproduce_decomposed_regrouping(basis: str) assert probability == pytest.approx(decomposed_by_targets[targets], abs=5e-7) assert all("source_type_counts" in row for row in render_summaries) - assert any("DirectOneSidedComponent" in row["source_type_counts"] for row in render_summaries) + assert any("ExclusiveSignature" in row["direct_source_family_counts"] for row in render_summaries) + assert all("DirectOneSidedComponent" not in row["source_type_counts"] for row in render_summaries) @pytest.mark.parametrize("basis", ["X", "Z"]) @@ -664,7 +618,8 @@ def test_structured_render_records_reproduce_render_summaries(basis: str) -> Non assert len(render_records) == dem.num_contributions assert all("rendered_targets" in row for row in render_records) assert all("render_strategy" in row for row in render_records) - assert any("recorded_component_targets" in row for row in render_records) + assert any(row.get("direct_source_family") == "ExclusiveSignature" for row in render_records) + assert all("recorded_component_targets" not in row for row in render_records) regrouped: dict[tuple[tuple[int, ...], tuple[int, ...], str], dict[str, object]] = {} for row in render_records: @@ -747,8 +702,8 @@ def test_structured_keep_direct_policy_matches_default_render_outputs(basis: str @pytest.mark.parametrize("basis", ["X", "Z"]) -def test_structured_recorded_component_policy_exposes_alternative_records(basis: str) -> None: - """Recorded-component policy should expose alternate render strategies and targets.""" +def test_structured_recorded_component_policy_leaves_signature_rows_direct(basis: str) -> None: + """Recorded-component policy cannot invent components for converted signatures.""" dem = build_source_tracked_dem(distance=3, basis=basis, rounds=20) default_records = dem.contribution_render_records() @@ -757,8 +712,5 @@ def test_structured_recorded_component_policy_exposes_alternative_records(basis: ) assert len(policy_records) == len(default_records) - assert any(row["render_strategy"] == "RecordedComponents" for row in policy_records) - assert any( - policy_row["rendered_targets"] != default_row["rendered_targets"] - for default_row, policy_row in zip(default_records, policy_records, strict=False) - ) + assert policy_records == default_records + assert all(row["render_strategy"] != "RecordedComponents" for row in policy_records) 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 c3dee082d..4aceaee43 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -276,10 +276,17 @@ def test_noise_model_matches_flat_pauli_weights(entrypoint: str) -> None: "p_prep": 0.013, } - grouped = _noise_model_entrypoint_dem(entrypoint, noise=NoiseParameters(**noise_kwargs)) - flat = _noise_model_entrypoint_dem(entrypoint, **noise_kwargs) + with pytest.warns(UserWarning, match=r"two-qubit gate \(largest 1\.184e-05\)"): + grouped = _noise_model_entrypoint_dem(entrypoint, noise=NoiseParameters(**noise_kwargs)) + with pytest.warns(UserWarning, match=r"two-qubit gate \(largest 1\.184e-05\)"): + flat = _noise_model_entrypoint_dem(entrypoint, **noise_kwargs) assert grouped.to_string() == flat.to_string() + assert grouped.idle_noise_residuals == flat.idle_noise_residuals + assert len(grouped.idle_noise_residuals) == 1 + residual = grouped.idle_noise_residuals[0] + assert residual["channel_kind"] == "two-qubit gate" + assert residual["magnitude"] == pytest.approx(1.1843041548472428e-05) @pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) @@ -2196,37 +2203,114 @@ def test_surface_module_cache_collapses_unconstrained_budget_forms() -> None: assert constrained["ancilla_budget"] == 2 -def test_idle_noise_residual_warning_fires_only_when_approximated() -> None: - """An approximated idle channel warns; an exact one stays silent. +def test_noise_channel_residual_warning_names_kinds_and_magnitudes() -> None: + """Approximated idle and gate channels warn; exact channels stay silent. The residual is queryable on the DEM, but a field alone is easy to miss, so the - build also warns when it had to fall back to the closest non-negative fit. + build also warns when it emits the non-negative boundary fit. """ import warnings from typing import ClassVar - from pecos.qec.dem import _warn_on_idle_noise_residuals + from pecos.qec.dem import _warn_on_noise_channel_residuals class _Exact: idle_noise_residuals: ClassVar[list[dict[str, object]]] = [] class _Approximated: idle_noise_residuals: ClassVar[list[dict[str, object]]] = [ - {"location_index": 3, "magnitude": 1.894e-05}, - {"location_index": 7, "magnitude": 2.1e-05}, + {"location_index": 3, "channel_kind": "idle", "magnitude": 1.894e-05}, + { + "location_index": 7, + "channel_kind": "one-qubit gate", + "magnitude": 2.1e-05, + }, ] with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") - _warn_on_idle_noise_residuals(_Exact()) + _warn_on_noise_channel_residuals(_Exact()) assert caught == [] with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") - _warn_on_idle_noise_residuals(_Approximated()) + _warn_on_noise_channel_residuals(_Approximated()) assert len(caught) == 1 message = str(caught[0].message) - assert "2 idle noise channel(s) were approximated" in message - # The largest magnitude, not the first one encountered. + assert "2 categorical noise channel(s) were approximated" in message + assert "1 idle (largest 1.894e-05)" in message + assert "1 one-qubit gate (largest 2.100e-05)" in message assert "2.100e-05" in message + assert "total-variation distances" in message assert "dem.idle_noise_residuals" in message + + +@guppy +def _two_qubit_gate_channel_program() -> None: + """One CX with a detector on each measurement, for gate-channel conversion checks.""" + a, b = qubit(), qubit() + cx(a, b) + result("m0", measure(a)) + result("m1", measure(b)) + + +def test_two_qubit_gate_channel_is_converted_not_emitted_naively() -> None: + """The p2 channel is mutually exclusive, so its DEM mechanisms need conversion. + + Fifteen two-qubit Paulis land on three distinct flip signatures here: the three + Z-type Paulis are invisible to Z-basis measurement and drop out, and the other + twelve merge four-to-one. Within a group the probabilities ADD (the channel picks + one Pauli), giving 4 * p2/15 = 5.333e-3 per signature. Emitting that directly would + be wrong, because independent mechanisms also fire together; the converted value is + 5.362e-3, computed independently from the Pauli-channel characters. + """ + build = ( + DetectorErrorModel.builder() + .with_program(_two_qubit_gate_channel_program) + .with_qubits(2) + .with_detectors([Detector("m0")]) + .with_observables([Observable("m1")]) + .with_noise(NoiseParameters().with_p2(0.02)) + .build() + ) + text = build.dem.to_string() + + # The converted probability, not the summed-but-unconverted 0.005333. + assert text.count("error(0.005362)") == 3, text + assert "0.005333" not in text, text + + # Fifteen Paulis, three surviving signatures: the Z-type ones are undetectable. + assert text.count("error(") == 3, text + + # An exactly representable channel takes no approximation. + assert build.dem.idle_noise_residuals == [] + + +@guppy +def _prep_and_measure_program() -> None: + """Prepare and measure one qubit, for the prep/measurement exactness check.""" + q = qubit() + result("m0", measure(q)) + + +def test_prep_and_measurement_channels_stay_exact() -> None: + """Prep and measurement are single Bernoulli events, so they need no conversion. + + Each emits one Pauli at the full probability rather than a set of mutually + exclusive ones, so there is nothing to compose and nothing to approximate. This + pins that the gate/idle conversion work did not sweep them in. + """ + for setter, probability in (("with_p_prep", 0.02), ("with_p_meas", 0.02)): + noise = getattr(NoiseParameters(), setter)(probability) + build = ( + DetectorErrorModel.builder() + .with_program(_prep_and_measure_program) + .with_qubits(1) + .with_detectors([Detector("m0")]) + .with_observables([]) + .with_noise(noise) + .build() + ) + text = build.dem.to_string() + assert f"error({probability})" in text, f"{setter}: {text}" + assert build.dem.idle_noise_residuals == [], setter From 696dbc550b194a8c92bd17034172b44c06c151c9 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 5 Aug 2026 13:23:14 -0600 Subject: [PATCH 42/62] Never silently drop injected noise gates in pecos-neo runners --- exp/pecos-neo/src/extensible/batch.rs | 76 ++++- exp/pecos-neo/src/noise.rs | 38 +++ exp/pecos-neo/src/noise/builder.rs | 7 +- exp/pecos-neo/src/noise/composer.rs | 125 ++++++- exp/pecos-neo/src/noise/composite/action.rs | 54 +++- exp/pecos-neo/src/noise/composite/builder.rs | 7 +- exp/pecos-neo/src/noise/composite/channel.rs | 14 +- exp/pecos-neo/src/noise/composite/compiled.rs | 43 ++- .../src/noise/composite/primitive.rs | 71 +++- exp/pecos-neo/src/noise/general_builder.rs | 7 +- exp/pecos-neo/src/noise/idle.rs | 17 +- exp/pecos-neo/src/program.rs | 5 + exp/pecos-neo/src/runner.rs | 306 +++++++++++++----- .../src/sampling/importance_runner.rs | 255 ++++++++++++++- exp/pecos-neo/src/sampling/path.rs | 28 +- exp/pecos-neo/src/tool/simulation.rs | 150 ++++++++- 16 files changed, 1082 insertions(+), 121 deletions(-) diff --git a/exp/pecos-neo/src/extensible/batch.rs b/exp/pecos-neo/src/extensible/batch.rs index bada20e53..dae356765 100644 --- a/exp/pecos-neo/src/extensible/batch.rs +++ b/exp/pecos-neo/src/extensible/batch.rs @@ -290,7 +290,9 @@ impl BatchedCircuit { (Batch::OutputResult { results }, ResolvedOp::OutputResult { result }) => { results.push(*result); } - _ => {} // Should not happen if can_extend is correct + _ => unreachable!( + "BatchedCircuit invariant violated: can_extend accepted an incompatible operation" + ), } } @@ -345,6 +347,11 @@ pub trait BatchExecutor { ); /// Execute the full batched circuit. + /// + /// # Panics + /// + /// Panics if the default executor encounters a multi-angle operation it + /// cannot represent instead of silently dropping it. fn execute_batched(&mut self, circuit: &BatchedCircuit) -> Self::MeasurementResults where Self::MeasurementResults: Default, @@ -371,8 +378,14 @@ pub trait BatchExecutor { self.execute_single_qubit(*gate_id, &[qubits[0]]); } else if qubits.len() == 2 && angles.is_empty() { self.execute_two_qubit(*gate_id, &[(qubits[0], qubits[1])]); + } else { + panic!( + "BatchExecutor cannot execute multi-angle gate {gate_id:?} with \ + {} qubit(s) and {} angle(s)", + qubits.len(), + angles.len() + ); } - // Other cases would need more specific handling } } Batch::Prep { basis, qubits } => { @@ -512,6 +525,65 @@ mod tests { use super::super::gates; use super::*; + struct NoopExecutor; + + impl SimpleExecutor for NoopExecutor { + type MeasurementResults = Vec; + + fn execute_gate(&mut self, _gate_id: GateId, _qubits: &[QubitId], _angles: &[Angle64]) {} + + fn execute_prep(&mut self, _basis: super::super::PrepBasis, _qubit: QubitId) {} + + fn execute_measure( + &mut self, + _basis: super::super::MeasBasis, + _qubit: QubitId, + _result: super::super::ResultId, + _results: &mut Self::MeasurementResults, + ) { + } + + fn get_result( + &self, + _result: super::super::ResultId, + _results: &Self::MeasurementResults, + ) -> bool { + false + } + } + + #[test] + #[should_panic(expected = "BatchedCircuit invariant violated")] + fn incompatible_extension_panics() { + let mut batch = Batch::SingleQubit { + gate_id: gates::H, + qubits: vec![QubitId(0)], + }; + let prep = ResolvedOp::Prep { + qubit: QubitId(1), + basis: super::super::PrepBasis::Z, + }; + + BatchedCircuit::extend_batch(&mut batch, &prep); + } + + #[test] + #[should_panic(expected = "BatchExecutor cannot execute multi-angle gate")] + fn unsupported_multi_angle_batch_panics() { + let circuit = BatchedCircuit { + batches: vec![Batch::MultiAngle { + gate_id: gates::U, + ops: vec![( + smallvec::smallvec![QubitId(0)], + smallvec::smallvec![Angle64::ZERO, Angle64::ZERO], + )], + }], + result_count: 0, + }; + + let _ = NoopExecutor.execute_batched(&circuit); + } + #[test] fn test_batch_from_resolved_groups_same_gates() { let resolved = ResolvedCircuit::new(vec![ diff --git a/exp/pecos-neo/src/noise.rs b/exp/pecos-neo/src/noise.rs index 4bd490a01..49da95097 100644 --- a/exp/pecos-neo/src/noise.rs +++ b/exp/pecos-neo/src/noise.rs @@ -536,6 +536,33 @@ pub enum NoiseResponse { Multiple(Vec), } +/// A gate-execution capability required by a configured noise mechanism. +/// +/// Noise channels use this metadata to let runners reject incompatible +/// configurations before the first shot. The runtime gate dispatcher remains +/// a defensive backstop for custom channels that do not declare a requirement. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NoiseGateRequirement { + /// Gate type the noise mechanism can inject. + pub gate_type: GateType, + /// Setter or constructor that enabled the mechanism. + pub configured_by: &'static str, + /// Concrete configuration change that makes the pairing valid. + pub fix: &'static str, +} + +impl NoiseGateRequirement { + /// Create a gate-execution requirement for a noise mechanism. + #[must_use] + pub const fn new(gate_type: GateType, configured_by: &'static str, fix: &'static str) -> Self { + Self { + gate_type, + configured_by, + fix, + } + } +} + impl NoiseResponse { /// Create a response that injects a single gate. #[must_use] @@ -661,6 +688,17 @@ pub trait NoiseChannel: Send + Sync { 0 } + /// Report injected gates whose runner support must be validated. + /// + /// The default is empty for channels that emit no gates or only gates every + /// target runner supports. Custom channels that can emit rotations or other + /// runner-dependent gates should override this so mismatches fail during + /// configuration; runtime dispatch will panic if an undeclared unsupported + /// gate is reached. + fn gate_requirements(&self) -> SmallVec<[NoiseGateRequirement; 2]> { + SmallVec::new() + } + /// Clone this channel into a boxed trait object. /// /// Required for cloning `ComposableNoiseModel` to support parallel execution. diff --git a/exp/pecos-neo/src/noise/builder.rs b/exp/pecos-neo/src/noise/builder.rs index 5e0ce669d..258ef239f 100644 --- a/exp/pecos-neo/src/noise/builder.rs +++ b/exp/pecos-neo/src/noise/builder.rs @@ -596,7 +596,12 @@ impl NoiseModelBuilder { coherent_to_incoherent_factor: self.p_idle_coherent_factor, idle_after_2q: self.idle_after_2q, }; - model = model.add_channel(channel); + model = model.add_channel_configured_by( + channel, + "NoiseModelBuilder::with_coherent_idle(..)", + "supply a rotation executor with CircuitRunner::rotations(), or switch to a \ + stochastic idle family by removing with_coherent_idle(..)", + ); } // Add leakage channel (if scale differs from default of 1.0) diff --git a/exp/pecos-neo/src/noise/composer.rs b/exp/pecos-neo/src/noise/composer.rs index a7ec9cd4b..5176f23ea 100644 --- a/exp/pecos-neo/src/noise/composer.rs +++ b/exp/pecos-neo/src/noise/composer.rs @@ -22,7 +22,8 @@ use super::context::NoiseContext; use super::idle::IdleChannel; use super::plugin::{ContextObserver, EventHandler, NoiseModelConfig, NoisePlugin}; -use super::{NoiseChannel, NoiseEvent, NoiseResponse}; +use super::{NoiseChannel, NoiseEvent, NoiseGateRequirement, NoiseResponse}; +use crate::command::GateType; use pecos_core::{QubitId, TimeScale}; use pecos_random::PecosRng; @@ -59,6 +60,9 @@ pub struct ComposableNoiseModel { /// Noise channels that produce noise responses. channels: Vec>, + /// Runner capabilities required by configured gate-injection mechanisms. + gate_requirements: Vec, + /// Observers that react to context state changes. observers: Vec>, @@ -76,6 +80,7 @@ impl std::fmt::Debug for ComposableNoiseModel { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ComposableNoiseModel") .field("time_scale", &self.time_scale) + .field("gate_requirements", &self.gate_requirements) .field("event_handler_count", &self.event_handlers.len()) .field( "event_handler_names", @@ -109,6 +114,7 @@ impl ComposableNoiseModel { Self { event_handlers: Vec::new(), channels: Vec::new(), + gate_requirements: Vec::new(), observers: Vec::new(), context: NoiseContext::new(), time_scale: None, @@ -186,6 +192,12 @@ impl ComposableNoiseModel { // Transfer registered components from config to model self.event_handlers.extend(config.event_handlers); + self.gate_requirements.extend( + config + .channels + .iter() + .flat_map(|channel| channel.gate_requirements()), + ); self.channels.extend(config.channels); self.observers.extend(config.observers); @@ -201,6 +213,26 @@ impl ComposableNoiseModel { /// For plugin-based configuration, use `add_plugin()` instead. #[must_use] pub fn add_channel(mut self, channel: impl NoiseChannel + 'static) -> Self { + self.gate_requirements.extend(channel.gate_requirements()); + self.channels.push(Box::new(channel)); + self + } + + /// Add a channel while recording the builder setter that configured it. + pub(crate) fn add_channel_configured_by( + mut self, + channel: impl NoiseChannel + 'static, + configured_by: &'static str, + fix: &'static str, + ) -> Self { + self.gate_requirements + .extend(channel.gate_requirements().into_iter().map(|requirement| { + NoiseGateRequirement { + configured_by, + fix, + ..requirement + } + })); self.channels.push(Box::new(channel)); self } @@ -211,10 +243,53 @@ impl ComposableNoiseModel { /// or other source. For most cases, use [`Self::add_channel`] instead. #[must_use] pub fn add_boxed_channel(mut self, channel: Box) -> Self { + self.gate_requirements.extend(channel.gate_requirements()); self.channels.push(channel); self } + /// Validate gate-injection requirements against a runner configuration. + /// + /// # Errors + /// + /// Returns a diagnostic naming the configuring setter, incompatible runner, + /// and concrete fix when an injected gate is unsupported. + pub(crate) fn validate_runner_gate_support( + &self, + runner: &str, + has_rotation_support: bool, + ) -> Result<(), String> { + for requirement in &self.gate_requirements { + let gate_type = requirement.gate_type; + if supports_clifford_noise_gate(gate_type) + || (has_rotation_support && supports_rotation_noise_gate(gate_type)) + { + continue; + } + + let needs_rotation = supports_rotation_noise_gate(gate_type); + let fix = if runner == "ImportanceSamplingRunner" && needs_rotation { + "switch to a stochastic noise mechanism (for coherent idle configuration, use \ + the stochastic idle family with with_p_idle_coherent(false)); \ + ImportanceSamplingRunner does not provide a rotation executor" + } else { + requirement.fix + }; + let limitation = if needs_rotation { + "has no rotation executor and cannot represent that noise gate" + } else { + "cannot execute that injected gate with any supported executor" + }; + return Err(format!( + "{} configures a noise mechanism that can inject {gate_type:?}, but {runner} \ + {limitation}; {fix}.", + requirement.configured_by + )); + } + + Ok(()) + } + /// Add an event handler directly to the model. /// /// For plugin-based configuration, use `add_plugin()` instead. @@ -476,6 +551,53 @@ impl ComposableNoiseModel { } } +fn supports_clifford_noise_gate(gate_type: GateType) -> bool { + matches!( + gate_type, + GateType::I + | GateType::X + | GateType::Y + | GateType::Z + | GateType::H + | GateType::F + | GateType::Fdg + | GateType::SX + | GateType::SXdg + | GateType::SY + | GateType::SYdg + | GateType::SZ + | GateType::SZdg + | GateType::CX + | GateType::CY + | GateType::CZ + | GateType::SZZ + | GateType::SZZdg + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg + | GateType::SWAP + ) +} + +fn supports_rotation_noise_gate(gate_type: GateType) -> bool { + matches!( + gate_type, + GateType::T + | GateType::Tdg + | GateType::RX + | GateType::RY + | GateType::RZ + | GateType::U + | GateType::R1XY + | GateType::CRZ + | GateType::RXX + | GateType::RYY + | GateType::RZZ + | GateType::CCX + ) +} + // ============================================================================ // From implementations for ergonomic noise model construction // ============================================================================ @@ -485,6 +607,7 @@ impl Clone for ComposableNoiseModel { Self { event_handlers: self.event_handlers.iter().map(|h| h.clone_box()).collect(), channels: self.channels.iter().map(|c| c.clone_box()).collect(), + gate_requirements: self.gate_requirements.clone(), observers: self.observers.iter().map(|o| o.clone_box()).collect(), context: self.context.clone(), time_scale: self.time_scale, diff --git a/exp/pecos-neo/src/noise/composite/action.rs b/exp/pecos-neo/src/noise/composite/action.rs index 3b04a24b1..5e8723bea 100644 --- a/exp/pecos-neo/src/noise/composite/action.rs +++ b/exp/pecos-neo/src/noise/composite/action.rs @@ -17,7 +17,7 @@ use super::response::CompositeResponse; use crate::command::{GateCommand, GateType}; -use crate::noise::NoiseContext; +use crate::noise::{NoiseContext, NoiseGateRequirement}; use pecos_core::QubitId; use pecos_random::PecosRng; use rand::RngExt; @@ -38,6 +38,23 @@ pub trait GateAction: Send + Sync { /// Human-readable name for visualization. fn name(&self) -> &'static str; + + /// Runner capabilities required by gates this action can inject. + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + smallvec::SmallVec::new() + } +} + +pub(super) fn injected_gate_requirement( + gate_type: GateType, + configured_by: &'static str, +) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + smallvec![NoiseGateRequirement::new( + gate_type, + configured_by, + "supply a rotation executor with CircuitRunner::rotations() when the gate is a supported \ + rotation, or replace the gate-injection action with a stochastic Pauli action", + )] } /// No-op action - does nothing. @@ -168,6 +185,10 @@ impl GateAction for Inject { fn name(&self) -> &'static str { "inject" } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + injected_gate_requirement(self.gate_type, "Inject::new(..)") + } } /// Pauli weights for random Pauli sampling. @@ -1099,6 +1120,10 @@ impl GateAction for InjectCoherentRZ { fn name(&self) -> &'static str { "coherent_rz" } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + injected_gate_requirement(GateType::RZ, "InjectCoherentRZ::new(..)") + } } // --- Amplitude Damping (T1 Relaxation) --- @@ -1288,6 +1313,10 @@ impl GateAction for CoherentRotation { fn name(&self) -> &'static str { "coherent_rotation" } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + injected_gate_requirement(self.gate_type, "CoherentRotation::new(..)") + } } /// Over-rotation error: adds a fraction of the gate's angle as extra rotation. @@ -1361,6 +1390,10 @@ impl GateAction for OverRotation { fn name(&self) -> &'static str { "over_rotation" } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + injected_gate_requirement(self.gate_type, "OverRotation::new(..)") + } } // --- Correlated Phase Errors (ZZ Dephasing) --- @@ -1431,6 +1464,15 @@ impl GateAction for ZZDephasing { fn name(&self) -> &'static str { "zz_dephasing" } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + let mut requirements = injected_gate_requirement(GateType::RZ, "ZZDephasing::new(..)"); + requirements.extend(injected_gate_requirement( + GateType::RZZ, + "ZZDephasing::new(..)", + )); + requirements + } } /// ZZ dephasing with rate (angle = rate * duration). @@ -1481,6 +1523,16 @@ impl GateAction for ZZDephasingRate { fn name(&self) -> &'static str { "zz_dephasing_rate" } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + let mut requirements = + injected_gate_requirement(GateType::RZ, "ZZDephasing::from_rate(..)"); + requirements.extend(injected_gate_requirement( + GateType::RZZ, + "ZZDephasing::from_rate(..)", + )); + requirements + } } // --- Preparation Errors --- diff --git a/exp/pecos-neo/src/noise/composite/builder.rs b/exp/pecos-neo/src/noise/composite/builder.rs index eeb09cbd3..08a4a738b 100644 --- a/exp/pecos-neo/src/noise/composite/builder.rs +++ b/exp/pecos-neo/src/noise/composite/builder.rs @@ -879,7 +879,12 @@ impl CompositeNoiseModelBuilder { coherent_to_incoherent_factor: self.p_idle_coherent_to_incoherent_factor, idle_after_2q: self.idle_after_2q, }; - model = model.add_channel(channel); + model = model.add_channel_configured_by( + channel, + "CompositeNoiseModelBuilder::with_p_idle_coherent(true)", + "supply a rotation executor with CircuitRunner::rotations(), or switch to the \ + stochastic idle family with with_p_idle_coherent(false)", + ); } // Before-gate channel for skip logic (if leakage is enabled) diff --git a/exp/pecos-neo/src/noise/composite/channel.rs b/exp/pecos-neo/src/noise/composite/channel.rs index aeb3edc6f..2f66c2159 100644 --- a/exp/pecos-neo/src/noise/composite/channel.rs +++ b/exp/pecos-neo/src/noise/composite/channel.rs @@ -19,7 +19,7 @@ use super::Primitive; use super::batch::GeometricSampler; use super::response::CompositeResponse; -use crate::noise::{NoiseChannel, NoiseContext, NoiseEvent, NoiseResponse}; +use crate::noise::{NoiseChannel, NoiseContext, NoiseEvent, NoiseGateRequirement, NoiseResponse}; use pecos_core::QubitId; use pecos_random::PecosRng; use smallvec::smallvec; @@ -436,6 +436,10 @@ impl NoiseChannel for CompositeChannel

{ self.priority } + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.primitive.gate_requirements() + } + fn clone_box(&self) -> Box { Box::new(self.clone()) } @@ -907,6 +911,10 @@ impl NoiseChannel for BatchCompositeChannel

{ self.priority } + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.primitive.gate_requirements() + } + fn clone_box(&self) -> Box { Box::new(self.clone()) } @@ -1155,6 +1163,10 @@ impl NoiseChannel for CompositeCrosstalkChannel< self.priority } + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.primitive.gate_requirements() + } + fn clone_box(&self) -> Box { Box::new(self.clone()) } diff --git a/exp/pecos-neo/src/noise/composite/compiled.rs b/exp/pecos-neo/src/noise/composite/compiled.rs index dcfc9b013..35a0d9e94 100644 --- a/exp/pecos-neo/src/noise/composite/compiled.rs +++ b/exp/pecos-neo/src/noise/composite/compiled.rs @@ -34,7 +34,8 @@ use super::action::PauliWeights; use super::response::CompositeResponse; use crate::command::{GateCommand, GateType}; use crate::noise::{ - NoiseContext, SingleQubitEmissionWeights, TwoQubitEmissionWeights, TwoQubitPauliWeights, + NoiseContext, NoiseGateRequirement, SingleQubitEmissionWeights, TwoQubitEmissionWeights, + TwoQubitPauliWeights, }; use pecos_core::QubitId; use pecos_random::PecosRng; @@ -84,6 +85,16 @@ pub enum CompiledAction { } impl CompiledAction { + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + match self { + Self::Inject(gate_type) => { + super::action::injected_gate_requirement(*gate_type, "CompiledAction::Inject(..)") + } + Self::Custom(primitive) => primitive.gate_requirements(), + _ => smallvec::SmallVec::new(), + } + } + /// Apply this action. #[inline] pub fn apply( @@ -206,6 +217,32 @@ pub enum CompiledPrimitive { } impl CompiledPrimitive { + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + match self { + Self::Action(action) => action.gate_requirements(), + Self::Prob { inner, .. } => inner.gate_requirements(), + Self::When { + then_branch, + else_branch, + .. + } => { + let mut requirements = then_branch.gate_requirements(); + requirements.extend(else_branch.gate_requirements()); + requirements + } + Self::Sample { branches, .. } => branches + .iter() + .flat_map(|(_, primitive)| primitive.gate_requirements()) + .collect(), + Self::Seq(primitives) => primitives + .iter() + .flat_map(CompiledPrimitive::gate_requirements) + .collect(), + Self::Custom(primitive) => primitive.gate_requirements(), + Self::SkipIf(_) => smallvec::SmallVec::new(), + } + } + /// Apply this primitive. #[allow(clippy::missing_panics_doc)] // internal invariant: Sample always has branches #[inline] @@ -302,6 +339,10 @@ impl Primitive for CompiledPrimitive { fn clone_box(&self) -> Box { Box::new(self.clone()) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + CompiledPrimitive::gate_requirements(self) + } } // ============================================================================ diff --git a/exp/pecos-neo/src/noise/composite/primitive.rs b/exp/pecos-neo/src/noise/composite/primitive.rs index 3b7d24c98..ab566029b 100644 --- a/exp/pecos-neo/src/noise/composite/primitive.rs +++ b/exp/pecos-neo/src/noise/composite/primitive.rs @@ -20,7 +20,7 @@ use std::fmt::Write as _; use super::action::GateAction; use super::condition::Condition; use super::response::CompositeResponse; -use crate::noise::NoiseContext; +use crate::noise::{NoiseContext, NoiseGateRequirement}; use pecos_core::QubitId; use pecos_random::PecosRng; use rand::RngExt; @@ -44,6 +44,11 @@ pub trait Primitive: Send + Sync { /// Clone this primitive into a boxed trait object. fn clone_box(&self) -> Box; + /// Runner capabilities required by gates this primitive can inject. + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + smallvec::SmallVec::new() + } + /// Multi-line tree representation for debugging. /// /// Returns a tree-formatted string showing the structure of composed primitives. @@ -334,6 +339,12 @@ impl Primitive for TwoStage { stage2: self.stage2.clone_box(), }) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + let mut requirements = self.stage1.gate_requirements(); + requirements.extend(self.stage2.gate_requirements()); + requirements + } } impl Primitive for Box { @@ -383,6 +394,10 @@ impl Primitive for Box { fn clone_box(&self) -> Box { (**self).clone_box() } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + (**self).gate_requirements() + } } // Implement Primitive for all GateActions @@ -403,6 +418,10 @@ impl Primitive for A { fn clone_box(&self) -> Box { Box::new(self.clone()) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + GateAction::gate_requirements(self) + } } /// Probability gate: with probability p, execute inner primitive. @@ -482,6 +501,10 @@ impl Primitive for Prob

{ inner: self.inner.clone_box(), }) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.inner.gate_requirements() + } } /// Dynamic probability gate: compute probability from gate context. @@ -576,6 +599,10 @@ where inner: self.inner.clone_box(), }) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.inner.gate_requirements() + } } /// Linear time-dependent probability: p = rate * duration. @@ -648,6 +675,10 @@ impl Primitive for ProbLinear

{ inner: self.inner.clone_box(), }) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.inner.gate_requirements() + } } /// Quadratic time-dependent dephasing: p = sin(rate * duration)^2. @@ -739,6 +770,10 @@ impl Primitive for ProbQuadratic

{ inner: self.inner.clone_box(), }) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.inner.gate_requirements() + } } /// Conditional: if condition is true, execute `then_branch`, else `else_branch`. @@ -809,6 +844,12 @@ impl Primitive for W else_branch: self.else_branch.clone_box(), }) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + let mut requirements = self.then_branch.gate_requirements(); + requirements.extend(self.else_branch.gate_requirements()); + requirements + } } /// Weighted sample: choose one branch based on weights. @@ -914,6 +955,13 @@ impl Primitive for Sample

{ .collect(), )) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.branches + .iter() + .flat_map(|(_, primitive)| primitive.gate_requirements()) + .collect() + } } /// Sequential: execute all primitives in order, combine responses. @@ -988,6 +1036,13 @@ impl Primitive for Seq

{ self.primitives.iter().map(Primitive::clone_box).collect(), )) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.primitives + .iter() + .flat_map(Primitive::gate_requirements) + .collect() + } } /// Sequential execution of heterogeneous primitives using trait objects. @@ -1063,6 +1118,13 @@ impl Primitive for BoxSeq { fn clone_box(&self) -> Box { Box::new(self.clone()) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.primitives + .iter() + .flat_map(Primitive::gate_requirements) + .collect() + } } /// Early exit: if condition is true, return `SkipGate` response. @@ -1258,6 +1320,13 @@ impl Primitive for BoxSample { fn clone_box(&self) -> Box { Box::new(self.clone()) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.branches + .iter() + .flat_map(|(_, primitive)| primitive.gate_requirements()) + .collect() + } } /// Convenience functions for creating primitives. diff --git a/exp/pecos-neo/src/noise/general_builder.rs b/exp/pecos-neo/src/noise/general_builder.rs index b4ccfe4a2..296775961 100644 --- a/exp/pecos-neo/src/noise/general_builder.rs +++ b/exp/pecos-neo/src/noise/general_builder.rs @@ -771,7 +771,12 @@ impl GeneralNoiseModelBuilder { coherent_to_incoherent_factor: self.p_idle_coherent_to_incoherent_factor, idle_after_2q: self.idle_after_2q, }; - model = model.add_channel(channel); + model = model.add_channel_configured_by( + channel, + "GeneralNoiseModelBuilder::with_p_idle_coherent(true)", + "supply a rotation executor with CircuitRunner::rotations(), or switch to the \ + stochastic idle family with with_p_idle_coherent(false)", + ); } // Custom channels (composite or traditional) diff --git a/exp/pecos-neo/src/noise/idle.rs b/exp/pecos-neo/src/noise/idle.rs index f6f151663..0abd94463 100644 --- a/exp/pecos-neo/src/noise/idle.rs +++ b/exp/pecos-neo/src/noise/idle.rs @@ -53,7 +53,9 @@ //! - **Incoherent**: Stochastic Z error with probability = sin(rate * duration / 2)^2. //! This is the exact Pauli twirl of the coherent RZ rotation. -use super::{NoiseChannel, NoiseContext, NoiseEvent, NoiseResponse, PauliWeights}; +use super::{ + NoiseChannel, NoiseContext, NoiseEvent, NoiseGateRequirement, NoiseResponse, PauliWeights, +}; use crate::command::{GateCommand, GateType}; use pecos_core::{Angle64, TimeUnits}; use pecos_random::PecosRng; @@ -388,6 +390,19 @@ impl NoiseChannel for IdleChannel { "IdleChannel" } + fn gate_requirements(&self) -> SmallVec<[NoiseGateRequirement; 2]> { + if self.coherent_dephasing && self.quadratic_rate > 0.0 { + smallvec::smallvec![NoiseGateRequirement::new( + GateType::RZ, + "IdleChannel::with_coherent_dephasing(true)", + "supply a rotation executor with CircuitRunner::rotations(), or switch to the \ + stochastic idle family with IdleChannel::with_coherent_dephasing(false)", + )] + } else { + SmallVec::new() + } + } + fn clone_box(&self) -> Box { Box::new(self.clone()) } diff --git a/exp/pecos-neo/src/program.rs b/exp/pecos-neo/src/program.rs index 4580db75f..cf38824aa 100644 --- a/exp/pecos-neo/src/program.rs +++ b/exp/pecos-neo/src/program.rs @@ -161,6 +161,11 @@ impl ProgramRunner { } /// Set the noise model. + /// + /// # Panics + /// + /// Panics during configuration if the model can inject rotations but the + /// underlying [`CircuitRunner`] has no rotation executor. #[must_use] pub fn with_noise(mut self, noise: ComposableNoiseModel) -> Self { self.runner = self.runner.with_noise(noise); diff --git a/exp/pecos-neo/src/runner.rs b/exp/pecos-neo/src/runner.rs index e16b73b30..4a3fed3ea 100644 --- a/exp/pecos-neo/src/runner.rs +++ b/exp/pecos-neo/src/runner.rs @@ -794,8 +794,16 @@ impl CircuitRunner { /// Set the noise model. /// /// Gate definitions are automatically propagated to the noise model's context. + /// + /// # Panics + /// + /// Panics during configuration if the noise model declares a rotation-gate + /// emission but this runner has no rotation executor. #[must_use] pub fn with_noise(mut self, mut noise: ComposableNoiseModel) -> Self { + noise + .validate_runner_gate_support("CircuitRunner", self.rotation_executor.is_some()) + .unwrap_or_else(|message| panic!("{message}")); noise = noise.with_gate_definitions(self.definitions.clone()); self.noise = Some(noise); self @@ -1181,6 +1189,11 @@ impl CircuitRunner { /// Emits the event to the noise model, applies the response to state, /// and returns the response. Useful for idle noise between manually-applied /// gates, testing noise models, or custom execution loops. + /// + /// # Panics + /// + /// Panics if an undeclared noise mechanism injects a gate the runner cannot + /// execute. Declared requirements are rejected by [`Self::with_noise`]. pub fn apply_noise(&mut self, state: &mut S, event: &NoiseEvent<'_>) -> NoiseResponse { let Some(ref mut noise) = self.noise else { return NoiseResponse::None; @@ -2227,96 +2240,34 @@ impl CircuitRunner { /// Execute a noise gate (injected error). /// - /// Handles Pauli gates directly. For non-Pauli gates (rotations, Cliffords), - /// delegates to the rotation executor if available, otherwise skips. + /// # Panics + /// + /// Panics if neither the Clifford simulator nor the configured rotation + /// executor can execute the injected gate. Configuration validation should + /// make this unreachable for declared noise mechanisms. fn execute_noise_gate(&self, sim: &mut S, gate: &GateCommand) { let qubits = gate.qubits.as_slice(); - match gate.gate_type { - GateType::X => { - sim.x(qubits); - } - GateType::Y => { - sim.y(qubits); - } - GateType::Z => { - sim.z(qubits); - } - GateType::H => { - sim.h(qubits); - } - GateType::F => { - sim.f(qubits); - } - GateType::Fdg => { - sim.fdg(qubits); - } - GateType::SX => { - sim.sx(qubits); - } - GateType::SXdg => { - sim.sxdg(qubits); - } - GateType::SY => { - sim.sy(qubits); - } - GateType::SYdg => { - sim.sydg(qubits); - } - GateType::SZ => { - sim.sz(qubits); - } - GateType::SZdg => { - sim.szdg(qubits); - } - GateType::CX => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.cx(&pairs); - } - GateType::CY => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.cy(&pairs); - } - GateType::CZ => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.cz(&pairs); - } - GateType::SXX => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.sxx(&pairs); - } - GateType::SXXdg => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.sxxdg(&pairs); - } - GateType::SYY => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.syy(&pairs); - } - GateType::SYYdg => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.syydg(&pairs); - } - GateType::SZZ => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.szz(&pairs); - } - GateType::SZZdg => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.szzdg(&pairs); - } - GateType::SWAP => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.swap(&pairs); - } - // Non-Clifford gates: delegate to rotation executor - other => { - if let Some(executor) = self.rotation_executor { - executor(sim, GateId::from(other), gate.angles.as_slice(), qubits); - } - // If no rotation executor, silently skip (noise channel injected - // a gate the simulator can't handle). - } - } + let arity = gate.gate_type.quantum_arity(); + assert!( + !qubits.is_empty() && qubits.len().is_multiple_of(arity), + "CircuitRunner invariant violated: injected noise gate {:?} has {} target(s), which \ + is not a nonzero multiple of its arity {arity}", + gate.gate_type, + qubits.len() + ); + let gate_id = GateId::from(gate.gate_type); + let executed = (gate.gate_type != GateType::Idle + && Self::try_execute_clifford(sim, gate_id, qubits)) + || self + .rotation_executor + .is_some_and(|executor| executor(sim, gate_id, gate.angles.as_slice(), qubits)); + + assert!( + executed, + "CircuitRunner invariant violated: injected noise gate {:?} could not be executed; \ + configuration validation should have rejected the emitting noise mechanism", + gate.gate_type + ); } } @@ -2501,6 +2452,7 @@ mod tests { use super::*; use crate::command::CommandBuilder; use crate::extensible::{GateCategory, GateSpec, OpBuilder, gates}; + use crate::noise::GeneralNoiseModelBuilder; use crate::noise::single_qubit::SingleQubitChannel; use num_complex::Complex64; use pecos_core::clifford::Clifford; @@ -2760,6 +2712,184 @@ mod tests { assert_eq!(outcomes.len(), 1); } + #[test] + fn coherent_idle_without_rotation_support_fails_during_configuration() { + let noise = GeneralNoiseModelBuilder::new() + .with_p_idle_quadratic(std::f64::consts::PI) + .with_p_idle_coherent(true) + .build(); + + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = CircuitRunner::::new().with_noise(noise); + })) + .expect_err("coherent idle noise must require rotation support"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .expect("panic payload should be a string"); + + assert!(message.contains("with_p_idle_coherent(true)"), "{message}"); + assert!(message.contains("CircuitRunner"), "{message}"); + assert!(message.contains("CircuitRunner::rotations()"), "{message}"); + assert!(message.contains("stochastic idle"), "{message}"); + } + + #[test] + fn every_coherent_idle_entry_point_names_its_configuring_setter() { + use crate::noise::composite::CompositeNoiseModelBuilder; + use crate::noise::{IdleChannel, NoiseModelBuilder}; + + let cases = [ + ( + "NoiseModelBuilder::with_coherent_idle(..)", + NoiseModelBuilder::new() + .with_idle_noise(0.0, 0.25) + .with_coherent_idle(1.0) + .build(), + ), + ( + "CompositeNoiseModelBuilder::with_p_idle_coherent(true)", + CompositeNoiseModelBuilder::new() + .with_p_idle_quadratic(0.25) + .with_p_idle_coherent(true) + .build(), + ), + ( + "IdleChannel::with_coherent_dephasing(true)", + ComposableNoiseModel::new().add_channel( + IdleChannel { + quadratic_rate: 0.25, + ..IdleChannel::default() + } + .with_coherent_dephasing(true), + ), + ), + ]; + + for (setter, noise) in cases { + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = CircuitRunner::::new().with_noise(noise); + })) + .expect_err("coherent idle noise must require rotation support"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .expect("panic payload should be a string"); + + assert!(message.contains(setter), "{message}"); + assert!(message.contains("CircuitRunner::rotations()"), "{message}"); + } + } + + #[test] + fn coherent_idle_with_rotation_support_applies_rotation() { + let commands = CommandBuilder::new() + .pz(&[0]) + .h(&[0]) + .idle(&[0], 1u64) + .h(&[0]) + .mz(&[0]) + .build(); + let noise = GeneralNoiseModelBuilder::new() + .with_p_idle_quadratic(std::f64::consts::PI) + .with_p_idle_coherent(true) + .build(); + + let mut state = StateVec::new(1); + let mut runner = CircuitRunner::::rotations() + .with_noise(noise) + .with_seed(42); + let outcomes = runner.apply_circuit(&mut state, &commands).unwrap(); + + let outcome = outcomes.get(QubitId(0)).unwrap(); + assert!(outcome.outcome, "RZ(pi) must turn |+> into |->"); + assert!(outcome.is_deterministic); + } + + #[test] + fn composite_coherent_rotation_is_validated_during_configuration() { + use crate::noise::composite::prelude::{CompositeChannelBuilder, coherent_rz, prob}; + + let noise = ComposableNoiseModel::new().add_channel(CompositeChannelBuilder::idle( + "coherent_idle", + prob(1.0, coherent_rz(0.25)), + )); + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = CircuitRunner::::new().with_noise(noise); + })) + .expect_err("composite coherent rotation must require rotation support"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .expect("panic payload should be a string"); + + assert!(message.contains("InjectCoherentRZ::new(..)"), "{message}"); + assert!(message.contains("CircuitRunner"), "{message}"); + assert!(message.contains("CircuitRunner::rotations()"), "{message}"); + assert!(message.contains("stochastic Pauli"), "{message}"); + } + + #[test] + fn composite_unsupported_gate_is_rejected_with_or_without_rotations() { + use crate::noise::composite::prelude::{CompositeChannelBuilder, inject}; + + let noise = ComposableNoiseModel::new().add_channel(CompositeChannelBuilder::idle( + "unsupported_injection", + inject(GateType::PZ), + )); + + for with_rotations in [false, true] { + let runner = if with_rotations { + CircuitRunner::::rotations() + } else { + CircuitRunner::::new() + }; + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = runner.with_noise(noise.clone()); + })) + .expect_err("PZ cannot be executed as an injected noise gate"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .expect("panic payload should be a string"); + + assert!(message.contains("Inject::new(..)"), "{message}"); + assert!(message.contains("PZ"), "{message}"); + assert!(message.contains("CircuitRunner"), "{message}"); + assert!(message.contains("stochastic Pauli"), "{message}"); + } + } + + #[test] + #[should_panic(expected = "CircuitRunner invariant violated: injected noise gate PZ")] + fn unsupported_noise_gate_panics_in_circuit_runner() { + let mut state = SparseStab::new(1); + CircuitRunner::::new() + .execute_noise_gate(&mut state, &GateCommand::pz(QubitId(0))); + } + + #[test] + #[should_panic(expected = "CircuitRunner invariant violated: injected noise gate PZ")] + fn unsupported_noise_gate_panics_with_rotation_executor() { + let mut state = StateVec::new(1); + CircuitRunner::::rotations() + .execute_noise_gate(&mut state, &GateCommand::pz(QubitId(0))); + } + + #[test] + #[should_panic(expected = "CircuitRunner invariant violated: injected noise gate CX has 1")] + fn malformed_multi_qubit_noise_gate_panics_in_circuit_runner() { + let mut state = SparseStab::new(1); + CircuitRunner::::new().execute_noise_gate( + &mut state, + &GateCommand::new(GateType::CX, smallvec::smallvec![QubitId(0)]), + ); + } + #[test] fn test_with_gate_definitions() { use crate::extensible::{GateCategory, GateDefinitions}; diff --git a/exp/pecos-neo/src/sampling/importance_runner.rs b/exp/pecos-neo/src/sampling/importance_runner.rs index bcf1bf036..7dce245e9 100644 --- a/exp/pecos-neo/src/sampling/importance_runner.rs +++ b/exp/pecos-neo/src/sampling/importance_runner.rs @@ -202,8 +202,16 @@ impl ImportanceSamplingRunner { /// /// This noise model is used for structural noise effects (like leakage tracking). /// The error rates are overridden by the importance sampling configuration. + /// + /// # Panics + /// + /// Panics during configuration if the noise model declares a rotation-gate + /// emission, which this Clifford-only runner cannot execute. #[must_use] pub fn with_noise(mut self, noise: ComposableNoiseModel) -> Self { + noise + .validate_runner_gate_support("ImportanceSamplingRunner", false) + .unwrap_or_else(|message| panic!("{message}")); self.noise = Some(noise); self } @@ -289,6 +297,12 @@ impl ImportanceSamplingRunner { /// Run a single shot with importance sampling. /// /// Returns the measurement outcomes along with the importance weight. + /// + /// # Panics + /// + /// Panics if the circuit or an injected noise response contains a gate + /// that `ImportanceSamplingRunner` cannot execute. Declared noise + /// requirements are validated by [`Self::with_noise`]. pub fn run_shot(&mut self, commands: &CommandQueue) -> ImportanceSampledShot { // Reset for new shot self.weight = SampleWeight::one(); @@ -318,6 +332,10 @@ impl ImportanceSamplingRunner { /// /// **Performance**: Resets the simulator (8-12x faster than clone for large qubit counts) /// before running the circuit. + /// + /// # Panics + /// + /// Panics under the same conditions as [`Self::run_shot`]. pub fn run_shot_fresh(&mut self, commands: &CommandQueue) -> ImportanceSampledShot { // Reset simulator to |0⟩^n state (much faster than clone) self.simulator.reset(); @@ -364,7 +382,11 @@ impl ImportanceSamplingRunner { // Gate execution with importance-weighted noise _ => { - self.execute_clifford_gate(command); + assert!( + self.execute_clifford_gate(command), + "ImportanceSamplingRunner cannot execute circuit gate {:?}", + command.gate_type + ); self.apply_importance_sampled_gate_noise(command); } } @@ -590,21 +612,27 @@ impl ImportanceSamplingRunner { } /// Execute a noise gate. + /// + /// # Panics + /// + /// Panics if the simulator cannot execute the injected gate. Configuration + /// validation should make this unreachable for declared noise mechanisms. fn execute_noise_gate(&mut self, gate: &GateCommand) { - let qubits: Vec = gate.qubits.iter().copied().collect(); - - match gate.gate_type { - GateType::X => { - self.simulator.x(&qubits); - } - GateType::Y => { - self.simulator.y(&qubits); - } - GateType::Z => { - self.simulator.z(&qubits); - } - _ => {} - } + let arity = gate.gate_type.quantum_arity(); + assert!( + !gate.qubits.is_empty() && gate.qubits.len().is_multiple_of(arity), + "ImportanceSamplingRunner invariant violated: injected noise gate {:?} has {} \ + target(s), which is not a nonzero multiple of its arity {arity}", + gate.gate_type, + gate.qubits.len() + ); + assert!( + self.execute_clifford_gate(gate), + "ImportanceSamplingRunner invariant violated: injected noise gate {:?} could not be \ + executed; configuration validation should have rejected the emitting noise \ + mechanism", + gate.gate_type + ); } /// Execute Clifford gates. @@ -782,6 +810,11 @@ where /// 1. If deterministic (stabilizer eigenstate): return fixed outcome, no weight change /// 2. If non-deterministic (50/50): sample from biased proposal, force that outcome, /// update weight by P(outcome)/Q(outcome) = `0.5/bias_prob` + /// + /// # Panics + /// + /// Panics if the circuit or an injected noise response contains a gate + /// that `ImportanceSamplingRunner` cannot execute. pub fn run_shot_biased(&mut self, commands: &CommandQueue) -> ImportanceSampledShot { // Reset for new shot self.weight = SampleWeight::one(); @@ -843,7 +876,11 @@ where // Gate execution with importance-weighted noise (same as unbiased) _ => { - self.execute_clifford_gate(command); + assert!( + self.execute_clifford_gate(command), + "ImportanceSamplingRunner cannot execute circuit gate {:?}", + command.gate_type + ); self.apply_importance_sampled_gate_noise(command); } } @@ -885,9 +922,87 @@ where mod tests { use super::*; use crate::command::CommandBuilder; + use crate::noise::{NoiseChannel, NoiseContext}; use crate::sampling::weight::WeightedStatistics; use pecos_simulators::SparseStab; + #[derive(Clone)] + struct AfterPreparationGateChannel(GateType); + + impl NoiseChannel for AfterPreparationGateChannel { + fn responds_to(&self, event: &NoiseEvent<'_>) -> bool { + matches!(event, NoiseEvent::AfterPreparation { .. }) + } + + fn apply( + &self, + event: &NoiseEvent<'_>, + _ctx: &mut NoiseContext, + _rng: &mut PecosRng, + ) -> NoiseResponse { + let NoiseEvent::AfterPreparation { qubits } = event else { + return NoiseResponse::None; + }; + NoiseResponse::inject_gate(GateCommand::new(self.0, smallvec::smallvec![qubits[0]])) + } + + fn name(&self) -> &'static str { + "AfterPreparationGateChannel" + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + } + + #[derive(Clone)] + struct SeededPauliAfterPreparation; + + impl NoiseChannel for SeededPauliAfterPreparation { + fn responds_to(&self, event: &NoiseEvent<'_>) -> bool { + matches!(event, NoiseEvent::AfterPreparation { .. }) + } + + fn apply( + &self, + event: &NoiseEvent<'_>, + _ctx: &mut NoiseContext, + rng: &mut PecosRng, + ) -> NoiseResponse { + let NoiseEvent::AfterPreparation { qubits } = event else { + return NoiseResponse::None; + }; + let gate_type = match rng.random_range(0..3) { + 0 => GateType::X, + 1 => GateType::Y, + _ => GateType::Z, + }; + NoiseResponse::inject_gate(GateCommand::new(gate_type, smallvec::smallvec![qubits[0]])) + } + + fn name(&self) -> &'static str { + "SeededPauliAfterPreparation" + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + } + + fn outcome_bytes(outcomes: &MeasurementOutcomes) -> Vec { + outcomes + .iter() + .flat_map(|outcome| { + [ + u8::try_from(outcome.qubit.0).expect("test qubit fits in u8"), + u8::from(outcome.outcome), + u8::from(outcome.is_deterministic), + u8::from(outcome.is_leaked), + ] + }) + .collect() + } + #[test] fn test_importance_runner_basic() { let commands = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); @@ -937,6 +1052,114 @@ mod tests { assert!((result.weight.weight() - 1.0).abs() < 1e-10); } + #[test] + fn injected_h_reaches_importance_simulator() { + let commands = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); + let noise = + ComposableNoiseModel::new().add_channel(AfterPreparationGateChannel(GateType::H)); + let mut runner = ImportanceSamplingRunner::new(SparseStab::new(1)) + .with_noise(noise) + .with_seed(42); + + let result = runner.run_shot(&commands); + let outcome = result.outcomes.get(QubitId(0)).unwrap(); + + assert!(!outcome.outcome); + assert!( + outcome.is_deterministic, + "injected H followed by circuit H must return the qubit to |0>" + ); + } + + #[test] + fn coherent_idle_is_rejected_by_importance_runner_during_configuration() { + let noise = crate::noise::GeneralNoiseModelBuilder::new() + .with_p_idle_quadratic(0.25) + .with_p_idle_coherent(true) + .build(); + + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = ImportanceSamplingRunner::new(SparseStab::new(1)).with_noise(noise); + })) + .expect_err("importance sampling cannot execute coherent idle rotations"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .expect("panic payload should be a string"); + + assert!(message.contains("with_p_idle_coherent(true)"), "{message}"); + assert!(message.contains("ImportanceSamplingRunner"), "{message}"); + assert!(message.contains("stochastic idle"), "{message}"); + assert!( + message.contains("does not provide a rotation executor"), + "{message}" + ); + } + + #[test] + fn seeded_xyz_noise_output_is_byte_identical_on_both_runners() { + let qubits = [0, 1, 2, 3, 4, 5, 6, 7]; + let commands = CommandBuilder::new().pz(&qubits).mz(&qubits).build(); + let noise = || ComposableNoiseModel::new().add_channel(SeededPauliAfterPreparation); + + let mut state = SparseStab::new(qubits.len()); + let mut circuit_runner = crate::runner::CircuitRunner::::new() + .with_noise(noise()) + .with_seed(0x436_437); + let circuit_bytes = + outcome_bytes(&circuit_runner.apply_circuit(&mut state, &commands).unwrap()); + + let mut importance_runner = ImportanceSamplingRunner::new(SparseStab::new(qubits.len())) + .with_noise(noise()) + .with_seed(0x436_437); + let importance_bytes = outcome_bytes(&importance_runner.run_shot(&commands).outcomes); + + let baseline = vec![ + 0, 1, 1, 0, 1, 1, 1, 0, 2, 1, 1, 0, 3, 1, 1, 0, 4, 1, 1, 0, 5, 0, 1, 0, 6, 1, 1, 0, 7, + 1, 1, 0, + ]; + assert_eq!(circuit_bytes, baseline); + assert_eq!(importance_bytes, baseline); + } + + #[test] + #[should_panic( + expected = "ImportanceSamplingRunner invariant violated: injected noise gate PZ" + )] + fn unsupported_noise_gate_panics_in_importance_runner() { + let mut runner = ImportanceSamplingRunner::new(SparseStab::new(1)); + runner.execute_noise_gate(&GateCommand::pz(QubitId(0))); + } + + #[test] + #[should_panic(expected = "ImportanceSamplingRunner cannot execute circuit gate T")] + fn unsupported_circuit_gate_panics_in_importance_runner() { + let commands = CommandBuilder::new().pz(&[0]).t(&[0]).build(); + let mut runner = ImportanceSamplingRunner::new(SparseStab::new(1)); + let _ = runner.run_shot(&commands); + } + + #[test] + #[should_panic(expected = "ImportanceSamplingRunner cannot execute circuit gate T")] + fn unsupported_circuit_gate_panics_in_biased_importance_runner() { + let commands = CommandBuilder::new().pz(&[0]).t(&[0]).build(); + let mut runner = ImportanceSamplingRunner::new(SparseStab::new(1)); + let _ = runner.run_shot_biased(&commands); + } + + #[test] + #[should_panic( + expected = "ImportanceSamplingRunner invariant violated: injected noise gate CX has 1" + )] + fn malformed_multi_qubit_noise_gate_panics_in_importance_runner() { + let mut runner = ImportanceSamplingRunner::new(SparseStab::new(1)); + runner.execute_noise_gate(&GateCommand::new( + GateType::CX, + smallvec::smallvec![QubitId(0)], + )); + } + #[test] fn test_importance_sampling_estimates_correct_rate() { // This test verifies that importance sampling produces diff --git a/exp/pecos-neo/src/sampling/path.rs b/exp/pecos-neo/src/sampling/path.rs index d6f3cc705..5e4515a7f 100644 --- a/exp/pecos-neo/src/sampling/path.rs +++ b/exp/pecos-neo/src/sampling/path.rs @@ -390,6 +390,11 @@ impl PathExplorer { /// /// This executes the program normally (with random measurement outcomes) /// while recording which outcomes occurred. + /// + /// # Panics + /// + /// Panics if the circuit contains a gate the Clifford simulator cannot + /// execute. pub fn run_and_record(&mut self, commands: &CommandQueue) -> PathRecordedResult { self.simulator.reset(); let mut outcomes = MeasurementOutcomes::new(); @@ -410,6 +415,11 @@ impl PathExplorer { /// /// Returns the outcomes and the actual path taken (which may differ /// from the input if some measurements were deterministic). + /// + /// # Panics + /// + /// Panics if the circuit contains a gate the Clifford simulator cannot + /// execute. pub fn run_with_path( &mut self, commands: &CommandQueue, @@ -609,7 +619,9 @@ impl PathExplorer { qubits.chunks_exact(2).map(|c| (c[0], c[1])).collect(); self.simulator.swap(&pairs); } - _ => {} + unsupported => panic!( + "PathExplorer cannot execute circuit gate {unsupported:?}; use a Clifford gate" + ), } } } @@ -684,6 +696,20 @@ mod tests { use crate::command::CommandBuilder; use pecos_simulators::SparseStab; + #[test] + #[should_panic(expected = "PathExplorer cannot execute circuit gate T")] + fn unsupported_gate_is_not_silently_dropped() { + let commands = CommandBuilder::new() + .gate(GateCommand::new( + GateType::T, + smallvec::smallvec![QubitId(0)], + )) + .build(); + let mut explorer = PathExplorer::new(SparseStab::new(1)); + + let _ = explorer.run_and_record(&commands); + } + #[test] fn test_measurement_path_basic() { let mut path = MeasurementPath::new(); diff --git a/exp/pecos-neo/src/tool/simulation.rs b/exp/pecos-neo/src/tool/simulation.rs index 01bcbb8a5..d0d2f7554 100644 --- a/exp/pecos-neo/src/tool/simulation.rs +++ b/exp/pecos-neo/src/tool/simulation.rs @@ -347,6 +347,11 @@ pub trait SimulatorFactory: Send + Sync { "custom backend" } + /// Whether runners created by this factory include a rotation executor. + fn has_rotation_support(&self) -> bool { + false + } + /// Create a program runner for the given number of qubits. /// /// Called once during simulation startup. The returned runner handles @@ -559,6 +564,10 @@ where + 'static, F: Fn(usize) -> S + Send + Sync, { + fn has_rotation_support(&self) -> bool { + true + } + fn create_runner( &self, num_qubits: usize, @@ -2604,6 +2613,33 @@ impl SimNeoBuilder { _ => {} } + if let Some(noise) = &self.noise { + let (runner, has_rotation_support) = match &sampling { + Sampling::ImportanceSampling { .. } => ("ImportanceSamplingRunner", false), + Sampling::SubsetSimulation { .. } => ("CircuitRunner", false), + Sampling::MonteCarlo { .. } => match &quantum_backend { + QuantumBackend::SparseStab | QuantumBackend::Stabilizer => { + ("CircuitRunner", false) + } + QuantumBackend::StateVec => ("CircuitRunner", true), + QuantumBackend::Custom(factory) => { + (factory.diagnostic_label(), factory.has_rotation_support()) + } + QuantumBackend::AdaptedQuantumEngine(_) => { + // Noise was rejected above for this backend. + ("QuantumEngineBuilder backend", false) + } + }, + Sampling::PathEnumeration { .. } => { + // Noise is rejected by path-enumeration validation below. + ("PathExplorer", false) + } + }; + noise + .validate_runner_gate_support(runner, has_rotation_support) + .unwrap_or_else(|message| panic!("{message}")); + } + let parallel_plan = match &sampling { Sampling::MonteCarlo { workers, .. } if *workers > 1 => { let plan = build_parallel_execution_plan( @@ -2647,6 +2683,7 @@ impl SimNeoBuilder { Some(StaticCircuitSpec { circuit, num_qubits, + noise: self.noise.clone(), }) } else { None @@ -2689,6 +2726,7 @@ impl SimNeoBuilder { Some(StaticCircuitSpec { circuit, num_qubits, + noise: None, }) } _ => None, @@ -3447,10 +3485,14 @@ fn is_sim_startup(resources: &mut Resources) { // Consume QuantumBackendResource (IS always uses SparseStab internally) let _ = resources.remove::(); - // Also consume NoiseResource if present (IS uses its own boosted noise) - let _ = resources.try_remove::(); + let noise = resources + .try_remove::() + .map(|resource| resource.0); - let runner = build_importance_runner(&is_config, num_qubits); + let mut runner = build_importance_runner(&is_config, num_qubits); + if let Some(noise) = noise { + runner = runner.with_noise(noise); + } resources.insert(ISShotState { runner, @@ -3551,6 +3593,7 @@ struct SubsetRunSpec { struct StaticCircuitSpec { circuit: CommandQueue, num_qubits: usize, + noise: Option, } /// Native backend used by the internal parallel runner factory. @@ -4015,6 +4058,9 @@ impl Simulation { } let mut runner = build_importance_runner(is_config, spec.num_qubits); + if let Some(noise) = spec.noise.clone() { + runner = runner.with_noise(noise); + } let start = start_indices[worker_id]; for shot_index in start..start + worker_shots { if let Some(base_seed) = base_seed { @@ -4296,11 +4342,46 @@ fn distribute_shots(num_shots: usize, num_workers: usize) -> Vec { #[allow(clippy::cast_precision_loss)] // statistical tests use count as f64 mod tests { use super::*; - use crate::command::CommandBuilder; - use crate::noise::{ComposableNoiseModel, SingleQubitChannel}; + use crate::command::{CommandBuilder, GateCommand, GateType}; + use crate::noise::{ + ComposableNoiseModel, GeneralNoiseModelBuilder, NoiseChannel, NoiseContext, NoiseEvent, + NoiseResponse, SingleQubitChannel, + }; use crate::program::ConditionalProgram; use pecos_core::QubitId; + #[derive(Clone)] + struct AfterPreparationHChannel; + + impl NoiseChannel for AfterPreparationHChannel { + fn responds_to(&self, event: &NoiseEvent<'_>) -> bool { + matches!(event, NoiseEvent::AfterPreparation { .. }) + } + + fn apply( + &self, + event: &NoiseEvent<'_>, + _ctx: &mut NoiseContext, + _rng: &mut PecosRng, + ) -> NoiseResponse { + let NoiseEvent::AfterPreparation { qubits } = event else { + return NoiseResponse::None; + }; + NoiseResponse::inject_gate(GateCommand::new( + GateType::H, + smallvec::smallvec![qubits[0]], + )) + } + + fn name(&self) -> &'static str { + "AfterPreparationHChannel" + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + } + #[test] fn test_sim_neo_basic() { let circuit = CommandBuilder::new() @@ -6102,6 +6183,65 @@ mod tests { assert_eq!(weights.len(), 100); } + #[test] + fn importance_sampling_keeps_configured_noise_in_sequential_and_parallel_runs() { + let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); + + for workers in [1, 2] { + let noise = ComposableNoiseModel::new().add_channel(AfterPreparationHChannel); + let results = sim_neo(circuit.clone()) + .auto() + .noise(noise) + .sampling( + importance_sampling(4) + .with_uniform_error(0.0) + .workers(workers), + ) + .seed(42) + .run(); + + for outcomes in &results.outcomes { + let outcome = outcomes.get(QubitId(0)).unwrap(); + assert!(!outcome.outcome); + assert!( + outcome.is_deterministic, + "configured H noise was lost with {workers} worker(s)" + ); + } + } + } + + #[test] + fn coherent_idle_mismatch_fails_while_building_sim_neo() { + let circuit = CommandBuilder::new().pz(&[0]).mz(&[0]).build(); + let noise = GeneralNoiseModelBuilder::new() + .with_p_idle_quadratic(0.25) + .with_p_idle_coherent(true) + .build(); + + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = sim_neo(circuit) + .auto() + .noise(noise) + .sampling(importance_sampling(1)) + .build(); + })) + .expect_err("the noise/runner mismatch must fail during build"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .expect("panic payload should be a string"); + + assert!(message.contains("with_p_idle_coherent(true)"), "{message}"); + assert!(message.contains("ImportanceSamplingRunner"), "{message}"); + assert!(message.contains("stochastic idle"), "{message}"); + assert!( + message.contains("does not provide a rotation executor"), + "{message}" + ); + } + #[test] fn test_sim_neo_importance_sampling_uniform() { // Test the convenience method for uniform error rates From be2b86f6685cd056de8de2196a0edb3ef7bdf35b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 5 Aug 2026 15:48:36 -0600 Subject: [PATCH 43/62] Make the idle families the only public way to configure DEM idle noise --- docs/development/from-guppy-dem-handoff.md | 2 +- docs/user-guide/dem-from-guppy.md | 30 +- python/quantum-pecos/src/pecos/qec/dem.py | 21 +- .../src/pecos/qec/surface/decode.py | 333 ++++++++---------- .../pecos/test_noise_builder_setter_names.py | 17 + .../qec/surface/fixtures/idle_z_linear.dem | 37 ++ .../surface/fixtures/idle_z_sin_squared.dem | 37 ++ .../qec/surface/test_idle_noise_families.py | 84 ++--- .../qec/surface/test_noise_parameters.py | 64 ++-- .../qec/surface/test_pauli_twirl_handoff.py | 8 +- .../qec/surface/test_szz_interaction_basis.py | 4 +- .../tests/qec/test_from_guppy_dem.py | 34 +- .../tests/qec/test_guppy_dem_builder.py | 5 +- 13 files changed, 390 insertions(+), 286 deletions(-) create mode 100644 python/quantum-pecos/tests/qec/surface/fixtures/idle_z_linear.dem create mode 100644 python/quantum-pecos/tests/qec/surface/fixtures/idle_z_sin_squared.dem diff --git a/docs/development/from-guppy-dem-handoff.md b/docs/development/from-guppy-dem-handoff.md index c3b5fb957..52138258c 100644 --- a/docs/development/from-guppy-dem-handoff.md +++ b/docs/development/from-guppy-dem-handoff.md @@ -179,7 +179,7 @@ line. - Runtime-produced `Idle` gates are preserved in the QIS operation trace and replayed into QEC circuits as `TimeUnits` with the convention `1 TimeUnit = 1 ns`. They only affect DEMs when an idle-noise parameter such - as `p_idle_linear`, `t1/t2`, `p_idle_linear_rate`, or `p_idle_quadratic_rate` is set. + as an idle family, `t1`, or `t2` is set. - Keep fail-closed regression coverage for entirely raw traces, transformed scalar results, and aggregate arrays. Generated adapters may expose direct scalar sideband tags while retaining aggregate results for researcher-facing diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index e7645dd7d..491cdc241 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -200,7 +200,7 @@ different DEM. Both Guppy DEM entry points accept either the existing flat noise keywords or one `NoiseParameters` instance containing the complete noise configuration. `NoiseParameters` is available from the `pecos` top level, and supports both -its original dataclass constructor and immutable `with_` chaining. +its dataclass constructor and immutable family/setter chaining. The grouped and flat forms below are equivalent. Do not mix them in one call: even explicitly passing a flat parameter at its default value conflicts with `noise`. When `noise` is present, its defaults fully replace the entry point's @@ -221,10 +221,6 @@ noise = ( .with_p_idle_linear(0.01, {"X": 0.25, "Y": 0.25, "Z": 0.5}) .with_p_idle_sin_squared(0.03, {"Z": 1.0}) ) - -# The families translate into canonical per-axis rates. -assert noise.p_idle_z_linear_rate == 0.005 -assert noise.p_idle_z_quadratic_sine_rate == 0.03 ``` @@ -334,12 +330,24 @@ Audited Guppy builds copy this list to `dem_build.audit["idle_noise_residuals"]`. An empty list certifies that all categorical signature conversions were exact. -The per-axis `p_idle_{x,y,z}_linear_rate`, -`p_idle_{x,y,z}_quadratic_rate`, and -`p_idle_{x,y,z}_quadratic_sine_rate` parameters remain available as low-level -knobs. The bare Z-only aliases `p_idle_linear_rate`, -`p_idle_quadratic_rate`, and `p_idle_quadratic_sine_rate` are deprecated; use -the structured interface or the explicitly named `p_idle_z_*` equivalent. +The families are the only public way to configure these idle channels on +`NoiseParameters`. They translate into underscore-prefixed canonical per-axis +fields internally; those fields are implementation details consumed by the +Rust DEM boundary, not public constructor arguments or fluent setters. + +Migration from the removed setters is mechanical: + +| Removed | Replacement | +|---|---| +| `with_p_idle_z_linear_rate(r)` | `with_p_idle_linear(r, {"Z": 1.0})` | +| `with_p_idle_x_quadratic_sine_rate(r)` | `with_p_idle_sin_squared(r, {"X": 1.0})` | +| `with_p_idle_linear_rate(r)` | `with_p_idle_linear(r, {"Z": 1.0})` | + +The last row is intentionally Z-only: despite its axis-free name, +`NoiseParameters.with_p_idle_linear_rate` configured only the Z channel. The +identically named setter on `general_noise()` is different and remains live: it +configures a total linear rate split according to its model. Copying a numeric +value between those old interfaces therefore did not preserve the channel. The default Selene runtime does not emit idle gates. These parameters and `t1`/`t2` therefore have no locations to attach to unless the runtime supplies diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 0967a2e6b..4852bc967 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -97,6 +97,22 @@ "p_idle_y_quadratic_sine_rate", "p_idle_z_quadratic_sine_rate", ) +_NOISE_PARAMETER_INTERNAL_IDLE_FIELDS = frozenset( + { + "p_idle_linear_rate", + "p_idle_quadratic_rate", + "p_idle_x_linear_rate", + "p_idle_y_linear_rate", + "p_idle_z_linear_rate", + "p_idle_x_quadratic_rate", + "p_idle_y_quadratic_rate", + "p_idle_z_quadratic_rate", + "p_idle_quadratic_sine_rate", + "p_idle_x_quadratic_sine_rate", + "p_idle_y_quadratic_sine_rate", + "p_idle_z_quadratic_sine_rate", + }, +) class _NoiseKeywordDefault: @@ -154,7 +170,10 @@ def _resolve_guppy_noise(noise: NoiseParameters | None, call_arguments: Mapping[ msg = f"NoiseParameters.{field} is not supported by the Guppy DEM entry points; {guidance}" raise ValueError(msg) - expanded = {name: getattr(noise, name) for name in _GUPPY_NOISE_KEYWORDS} + expanded = { + name: getattr(noise, f"_{name}" if name in _NOISE_PARAMETER_INTERNAL_IDLE_FIELDS else name) + for name in _GUPPY_NOISE_KEYWORDS + } for weights_name in ("p1_weights", "p2_weights"): if expanded[weights_name] is not None: expanded[weights_name] = dict(expanded[weights_name]) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 8c8731de0..d9ece88c0 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -202,22 +202,35 @@ class NoiseParameters: p_idle_coherent_model: Optional relative-rate multipliers over ``"RX"``, ``"RY"``, and ``"RZ"`` for ``p_idle_coherent``. Values must be finite and non-negative. - p_idle_linear_rate: Legacy alias for stochastic Z-memory rate linear in idle duration. - p_idle_quadratic_rate: Legacy alias for stochastic Z-memory rate quadratic in idle duration. - p_idle_x_linear_rate: Stochastic X-memory rate linear in idle duration. - p_idle_y_linear_rate: Stochastic Y-memory rate linear in idle duration. - p_idle_z_linear_rate: Stochastic Z-memory rate linear in idle duration. - p_idle_x_quadratic_rate: Stochastic X-memory rate quadratic in idle duration. - p_idle_y_quadratic_rate: Stochastic Y-memory rate quadratic in idle duration. - p_idle_z_quadratic_rate: Stochastic Z-memory rate quadratic in idle duration. - p_idle_quadratic_sine_rate: Legacy alias for stochastic Z-memory rate - with probability ``sin(rate * duration)^2``. - p_idle_x_quadratic_sine_rate: Stochastic X-memory sine-law rate. - p_idle_y_quadratic_sine_rate: Stochastic Y-memory sine-law rate. - p_idle_z_quadratic_sine_rate: Stochastic Z-memory sine-law rate. - - Structured family inputs are normalized to the corresponding per-axis - fields during construction; the family fields are then cleared. + _p_idle_linear_rate: Internal legacy canonical scalar for stochastic + Z-memory noise linear in idle duration. + _p_idle_quadratic_rate: Internal legacy canonical scalar for stochastic + Z-memory noise quadratic in idle duration. + _p_idle_x_linear_rate: Internal canonical X-memory rate linear in idle duration. + _p_idle_y_linear_rate: Internal canonical Y-memory rate linear in idle duration. + _p_idle_z_linear_rate: Internal canonical Z-memory rate linear in idle duration. + _p_idle_x_quadratic_rate: Internal canonical X-memory rate quadratic in idle duration. + _p_idle_y_quadratic_rate: Internal canonical Y-memory rate quadratic in idle duration. + _p_idle_z_quadratic_rate: Internal canonical Z-memory rate quadratic in idle duration. + _p_idle_quadratic_sine_rate: Internal legacy canonical scalar for stochastic + Z-memory noise with probability ``sin(rate * duration)^2``. + _p_idle_x_quadratic_sine_rate: Internal canonical X-memory sine-law rate. + _p_idle_y_quadratic_sine_rate: Internal canonical Y-memory sine-law rate. + _p_idle_z_quadratic_sine_rate: Internal canonical Z-memory sine-law rate. + + The internal canonical scalar fields are not user configuration. The + three structured family setters normalize into them during construction, + then clear the family fields. Migrate removed setters mechanically: + + - ``with_p_idle_z_linear_rate(r)`` becomes + ``with_p_idle_linear(r, {"Z": 1.0})``. + - ``with_p_idle_x_quadratic_sine_rate(r)`` becomes + ``with_p_idle_sin_squared(r, {"X": 1.0})``. + - ``with_p_idle_linear_rate(r)`` becomes + ``with_p_idle_linear(r, {"Z": 1.0})``. Despite its axis-free name, + the removed setter was Z-only. The identically named + ``general_noise()`` setter instead configures a total rate split by a + model, so values must not be copied between the two interfaces. Runtime idle units: For ``traced_qis`` DEMs, runtime idles are replayed as nanosecond @@ -237,18 +250,18 @@ class NoiseParameters: p_idle: float | None = None t1: float | None = None t2: float | None = None - p_idle_linear_rate: float | None = None - p_idle_quadratic_rate: float | None = None - p_idle_x_linear_rate: float | None = None - p_idle_y_linear_rate: float | None = None - p_idle_z_linear_rate: float | None = None - p_idle_x_quadratic_rate: float | None = None - p_idle_y_quadratic_rate: float | None = None - p_idle_z_quadratic_rate: float | None = None - p_idle_quadratic_sine_rate: float | None = None - p_idle_x_quadratic_sine_rate: float | None = None - p_idle_y_quadratic_sine_rate: float | None = None - p_idle_z_quadratic_sine_rate: float | None = None + _p_idle_linear_rate: float | None = None + _p_idle_quadratic_rate: float | None = None + _p_idle_x_linear_rate: float | None = None + _p_idle_y_linear_rate: float | None = None + _p_idle_z_linear_rate: float | None = None + _p_idle_x_quadratic_rate: float | None = None + _p_idle_y_quadratic_rate: float | None = None + _p_idle_z_quadratic_rate: float | None = None + _p_idle_quadratic_sine_rate: float | None = None + _p_idle_x_quadratic_sine_rate: float | None = None + _p_idle_y_quadratic_sine_rate: float | None = None + _p_idle_z_quadratic_sine_rate: float | None = None p_idle_linear: float | None = None p_idle_linear_model: Mapping[str, float] | None = None p_idle_sin_squared: float | None = None @@ -265,12 +278,12 @@ def __post_init__(self) -> None: if self.p2_szzdg is not None: self.p2_szzdg = _validate_probability("p2_szzdg", self.p2_szzdg) ( - self.p_idle_x_linear_rate, - self.p_idle_y_linear_rate, - self.p_idle_z_linear_rate, - self.p_idle_x_quadratic_sine_rate, - self.p_idle_y_quadratic_sine_rate, - self.p_idle_z_quadratic_sine_rate, + self._p_idle_x_linear_rate, + self._p_idle_y_linear_rate, + self._p_idle_z_linear_rate, + self._p_idle_x_quadratic_sine_rate, + self._p_idle_y_quadratic_sine_rate, + self._p_idle_z_quadratic_sine_rate, ) = _translate_structured_idle_noise( p_idle_linear=self.p_idle_linear, p_idle_linear_model=self.p_idle_linear_model, @@ -278,15 +291,15 @@ def __post_init__(self) -> None: p_idle_sin_squared_model=self.p_idle_sin_squared_model, p_idle_coherent=self.p_idle_coherent, p_idle_coherent_model=self.p_idle_coherent_model, - p_idle_linear_rate=self.p_idle_linear_rate, - p_idle_quadratic_rate=self.p_idle_quadratic_rate, - p_idle_x_linear_rate=self.p_idle_x_linear_rate, - p_idle_y_linear_rate=self.p_idle_y_linear_rate, - p_idle_z_linear_rate=self.p_idle_z_linear_rate, - p_idle_quadratic_sine_rate=self.p_idle_quadratic_sine_rate, - p_idle_x_quadratic_sine_rate=self.p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate=self.p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate=self.p_idle_z_quadratic_sine_rate, + p_idle_linear_rate=self._p_idle_linear_rate, + p_idle_quadratic_rate=self._p_idle_quadratic_rate, + p_idle_x_linear_rate=self._p_idle_x_linear_rate, + p_idle_y_linear_rate=self._p_idle_y_linear_rate, + p_idle_z_linear_rate=self._p_idle_z_linear_rate, + p_idle_quadratic_sine_rate=self._p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=self._p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=self._p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=self._p_idle_z_quadratic_sine_rate, ) self.p_idle_linear = None self.p_idle_linear_model = None @@ -346,66 +359,6 @@ def with_t2(self, t2: float | None) -> NoiseParameters: """Return a copy with ``t2`` set to the given value.""" return replace(self, t2=t2) - def with_p_idle_linear_rate(self, p_idle_linear_rate: float | None) -> NoiseParameters: - """Return a copy with ``p_idle_linear_rate`` set to the given value.""" - return replace(self, p_idle_linear_rate=p_idle_linear_rate) - - def with_p_idle_quadratic_rate(self, p_idle_quadratic_rate: float | None) -> NoiseParameters: - """Return a copy with ``p_idle_quadratic_rate`` set to the given value.""" - return replace(self, p_idle_quadratic_rate=p_idle_quadratic_rate) - - def with_p_idle_x_linear_rate(self, p_idle_x_linear_rate: float | None) -> NoiseParameters: - """Return a copy with ``p_idle_x_linear_rate`` set to the given value.""" - return replace(self, p_idle_x_linear_rate=p_idle_x_linear_rate) - - def with_p_idle_y_linear_rate(self, p_idle_y_linear_rate: float | None) -> NoiseParameters: - """Return a copy with ``p_idle_y_linear_rate`` set to the given value.""" - return replace(self, p_idle_y_linear_rate=p_idle_y_linear_rate) - - def with_p_idle_z_linear_rate(self, p_idle_z_linear_rate: float | None) -> NoiseParameters: - """Return a copy with ``p_idle_z_linear_rate`` set to the given value.""" - return replace(self, p_idle_z_linear_rate=p_idle_z_linear_rate) - - def with_p_idle_x_quadratic_rate(self, p_idle_x_quadratic_rate: float | None) -> NoiseParameters: - """Return a copy with ``p_idle_x_quadratic_rate`` set to the given value.""" - return replace(self, p_idle_x_quadratic_rate=p_idle_x_quadratic_rate) - - def with_p_idle_y_quadratic_rate(self, p_idle_y_quadratic_rate: float | None) -> NoiseParameters: - """Return a copy with ``p_idle_y_quadratic_rate`` set to the given value.""" - return replace(self, p_idle_y_quadratic_rate=p_idle_y_quadratic_rate) - - def with_p_idle_z_quadratic_rate(self, p_idle_z_quadratic_rate: float | None) -> NoiseParameters: - """Return a copy with ``p_idle_z_quadratic_rate`` set to the given value.""" - return replace(self, p_idle_z_quadratic_rate=p_idle_z_quadratic_rate) - - def with_p_idle_quadratic_sine_rate( - self, - p_idle_quadratic_sine_rate: float | None, - ) -> NoiseParameters: - """Return a copy with ``p_idle_quadratic_sine_rate`` set to the given value.""" - return replace(self, p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate) - - def with_p_idle_x_quadratic_sine_rate( - self, - p_idle_x_quadratic_sine_rate: float | None, - ) -> NoiseParameters: - """Return a copy with ``p_idle_x_quadratic_sine_rate`` set to the given value.""" - return replace(self, p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate) - - def with_p_idle_y_quadratic_sine_rate( - self, - p_idle_y_quadratic_sine_rate: float | None, - ) -> NoiseParameters: - """Return a copy with ``p_idle_y_quadratic_sine_rate`` set to the given value.""" - return replace(self, p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate) - - def with_p_idle_z_quadratic_sine_rate( - self, - p_idle_z_quadratic_sine_rate: float | None, - ) -> NoiseParameters: - """Return a copy with ``p_idle_z_quadratic_sine_rate`` set to the given value.""" - return replace(self, p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate) - # The idle families take their rate and model together: a model without a # rate is inert and rejected, and __post_init__ translates a family into the # canonical per-axis fields and then clears it -- so setting the two halves @@ -437,33 +390,35 @@ def with_p_idle_coherent( @property def effective_p_idle_z_linear_rate(self) -> float | None: - """Z-axis linear idle rate, accepting the legacy alias.""" - return self.p_idle_z_linear_rate if self.p_idle_z_linear_rate is not None else self.p_idle_linear_rate + """Return the internal Z-axis linear idle rate, accepting the legacy scalar.""" + return self._p_idle_z_linear_rate if self._p_idle_z_linear_rate is not None else self._p_idle_linear_rate @property def effective_p_idle_z_quadratic_rate(self) -> float | None: - """Z-axis quadratic idle rate, accepting the legacy alias.""" - return self.p_idle_z_quadratic_rate if self.p_idle_z_quadratic_rate is not None else self.p_idle_quadratic_rate + """Return the internal Z-axis quadratic idle rate, accepting the legacy scalar.""" + return ( + self._p_idle_z_quadratic_rate if self._p_idle_z_quadratic_rate is not None else self._p_idle_quadratic_rate + ) @property def effective_p_idle_z_quadratic_sine_rate(self) -> float | None: - """Z-axis sine-law quadratic idle rate, accepting the legacy alias.""" - if self.p_idle_z_quadratic_sine_rate is not None: - return self.p_idle_z_quadratic_sine_rate - return self.p_idle_quadratic_sine_rate + """Return the internal Z-axis sine-law rate, accepting the legacy scalar.""" + if self._p_idle_z_quadratic_sine_rate is not None: + return self._p_idle_z_quadratic_sine_rate + return self._p_idle_quadratic_sine_rate @property def idle_memory_rates(self) -> tuple[float | None, ...]: """All dedicated Pauli idle-memory rates that require explicit idles.""" return ( - self.p_idle_x_linear_rate, - self.p_idle_y_linear_rate, + self._p_idle_x_linear_rate, + self._p_idle_y_linear_rate, self.effective_p_idle_z_linear_rate, - self.p_idle_x_quadratic_rate, - self.p_idle_y_quadratic_rate, + self._p_idle_x_quadratic_rate, + self._p_idle_y_quadratic_rate, self.effective_p_idle_z_quadratic_rate, - self.p_idle_x_quadratic_sine_rate, - self.p_idle_y_quadratic_sine_rate, + self._p_idle_x_quadratic_sine_rate, + self._p_idle_y_quadratic_sine_rate, self.effective_p_idle_z_quadratic_sine_rate, ) @@ -500,18 +455,18 @@ def for_runtime_idle_time_units( p_idle=_convert_optional_rate(self.p_idle, units), t1=_convert_optional_time(self.t1, units), t2=_convert_optional_time(self.t2, units), - p_idle_linear_rate=_convert_optional_rate(self.p_idle_linear_rate, units), - p_idle_x_linear_rate=_convert_optional_rate(self.p_idle_x_linear_rate, units), - p_idle_y_linear_rate=_convert_optional_rate(self.p_idle_y_linear_rate, units), - p_idle_z_linear_rate=_convert_optional_rate(self.p_idle_z_linear_rate, units), - p_idle_quadratic_rate=_convert_optional_rate(self.p_idle_quadratic_rate, units_squared), - p_idle_x_quadratic_rate=_convert_optional_rate(self.p_idle_x_quadratic_rate, units_squared), - p_idle_y_quadratic_rate=_convert_optional_rate(self.p_idle_y_quadratic_rate, units_squared), - p_idle_z_quadratic_rate=_convert_optional_rate(self.p_idle_z_quadratic_rate, units_squared), - p_idle_quadratic_sine_rate=_convert_optional_rate(self.p_idle_quadratic_sine_rate, units), - p_idle_x_quadratic_sine_rate=_convert_optional_rate(self.p_idle_x_quadratic_sine_rate, units), - p_idle_y_quadratic_sine_rate=_convert_optional_rate(self.p_idle_y_quadratic_sine_rate, units), - p_idle_z_quadratic_sine_rate=_convert_optional_rate(self.p_idle_z_quadratic_sine_rate, units), + _p_idle_linear_rate=_convert_optional_rate(self._p_idle_linear_rate, units), + _p_idle_x_linear_rate=_convert_optional_rate(self._p_idle_x_linear_rate, units), + _p_idle_y_linear_rate=_convert_optional_rate(self._p_idle_y_linear_rate, units), + _p_idle_z_linear_rate=_convert_optional_rate(self._p_idle_z_linear_rate, units), + _p_idle_quadratic_rate=_convert_optional_rate(self._p_idle_quadratic_rate, units_squared), + _p_idle_x_quadratic_rate=_convert_optional_rate(self._p_idle_x_quadratic_rate, units_squared), + _p_idle_y_quadratic_rate=_convert_optional_rate(self._p_idle_y_quadratic_rate, units_squared), + _p_idle_z_quadratic_rate=_convert_optional_rate(self._p_idle_z_quadratic_rate, units_squared), + _p_idle_quadratic_sine_rate=_convert_optional_rate(self._p_idle_quadratic_sine_rate, units), + _p_idle_x_quadratic_sine_rate=_convert_optional_rate(self._p_idle_x_quadratic_sine_rate, units), + _p_idle_y_quadratic_sine_rate=_convert_optional_rate(self._p_idle_y_quadratic_sine_rate, units), + _p_idle_z_quadratic_sine_rate=_convert_optional_rate(self._p_idle_z_quadratic_sine_rate, units), ) @staticmethod @@ -1523,18 +1478,18 @@ def _noise_uses_dedicated_idle_noise(noise: NoiseParameters) -> bool: p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2, - p_idle_linear_rate=noise.p_idle_linear_rate, - p_idle_quadratic_rate=noise.p_idle_quadratic_rate, - p_idle_x_linear_rate=noise.p_idle_x_linear_rate, - p_idle_y_linear_rate=noise.p_idle_y_linear_rate, - p_idle_z_linear_rate=noise.p_idle_z_linear_rate, - p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, - p_idle_quadratic_sine_rate=noise.p_idle_quadratic_sine_rate, - p_idle_x_quadratic_sine_rate=noise.p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate=noise.p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate=noise.p_idle_z_quadratic_sine_rate, + p_idle_linear_rate=noise._p_idle_linear_rate, + p_idle_quadratic_rate=noise._p_idle_quadratic_rate, + p_idle_x_linear_rate=noise._p_idle_x_linear_rate, + p_idle_y_linear_rate=noise._p_idle_y_linear_rate, + p_idle_z_linear_rate=noise._p_idle_z_linear_rate, + p_idle_x_quadratic_rate=noise._p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=noise._p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=noise._p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=noise._p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=noise._p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=noise._p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=noise._p_idle_z_quadratic_sine_rate, ) @@ -1597,18 +1552,18 @@ def _with_noise_compat( "p_idle": noise.p_idle, "t1": noise.t1, "t2": noise.t2, - "p_idle_linear_rate": noise.p_idle_linear_rate, - "p_idle_quadratic_rate": noise.p_idle_quadratic_rate, - "p_idle_x_linear_rate": noise.p_idle_x_linear_rate, - "p_idle_y_linear_rate": noise.p_idle_y_linear_rate, - "p_idle_z_linear_rate": noise.p_idle_z_linear_rate, - "p_idle_x_quadratic_rate": noise.p_idle_x_quadratic_rate, - "p_idle_y_quadratic_rate": noise.p_idle_y_quadratic_rate, - "p_idle_z_quadratic_rate": noise.p_idle_z_quadratic_rate, - "p_idle_quadratic_sine_rate": noise.p_idle_quadratic_sine_rate, - "p_idle_x_quadratic_sine_rate": noise.p_idle_x_quadratic_sine_rate, - "p_idle_y_quadratic_sine_rate": noise.p_idle_y_quadratic_sine_rate, - "p_idle_z_quadratic_sine_rate": noise.p_idle_z_quadratic_sine_rate, + "p_idle_linear_rate": noise._p_idle_linear_rate, + "p_idle_quadratic_rate": noise._p_idle_quadratic_rate, + "p_idle_x_linear_rate": noise._p_idle_x_linear_rate, + "p_idle_y_linear_rate": noise._p_idle_y_linear_rate, + "p_idle_z_linear_rate": noise._p_idle_z_linear_rate, + "p_idle_x_quadratic_rate": noise._p_idle_x_quadratic_rate, + "p_idle_y_quadratic_rate": noise._p_idle_y_quadratic_rate, + "p_idle_z_quadratic_rate": noise._p_idle_z_quadratic_rate, + "p_idle_quadratic_sine_rate": noise._p_idle_quadratic_sine_rate, + "p_idle_x_quadratic_sine_rate": noise._p_idle_x_quadratic_sine_rate, + "p_idle_y_quadratic_sine_rate": noise._p_idle_y_quadratic_sine_rate, + "p_idle_z_quadratic_sine_rate": noise._p_idle_z_quadratic_sine_rate, "p1_weights": _p1_weights_dict(noise.p1_weights), "p2_weights": _p2_weights_dict(noise.p2_weights), } @@ -1943,18 +1898,18 @@ def _cached_surface_native_dem_string( p_idle=p_idle, t1=t1, t2=t2, - p_idle_linear_rate=p_idle_linear_rate, - p_idle_quadratic_rate=p_idle_quadratic_rate, - p_idle_x_linear_rate=p_idle_x_linear_rate, - p_idle_y_linear_rate=p_idle_y_linear_rate, - p_idle_z_linear_rate=p_idle_z_linear_rate, - p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, - p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, - p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, + _p_idle_linear_rate=p_idle_linear_rate, + _p_idle_quadratic_rate=p_idle_quadratic_rate, + _p_idle_x_linear_rate=p_idle_x_linear_rate, + _p_idle_y_linear_rate=p_idle_y_linear_rate, + _p_idle_z_linear_rate=p_idle_z_linear_rate, + _p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, + _p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, + _p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, + _p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, + _p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, + _p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, + _p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, ), decompose_errors=decompose_errors, dem_decomposition=dem_decomposition, @@ -2160,18 +2115,18 @@ def generate_circuit_level_dem_from_builder( "p_idle": noise.p_idle, "t1": noise.t1, "t2": noise.t2, - "p_idle_linear_rate": noise.p_idle_linear_rate, - "p_idle_quadratic_rate": noise.p_idle_quadratic_rate, - "p_idle_x_linear_rate": noise.p_idle_x_linear_rate, - "p_idle_y_linear_rate": noise.p_idle_y_linear_rate, - "p_idle_z_linear_rate": noise.p_idle_z_linear_rate, - "p_idle_x_quadratic_rate": noise.p_idle_x_quadratic_rate, - "p_idle_y_quadratic_rate": noise.p_idle_y_quadratic_rate, - "p_idle_z_quadratic_rate": noise.p_idle_z_quadratic_rate, - "p_idle_quadratic_sine_rate": noise.p_idle_quadratic_sine_rate, - "p_idle_x_quadratic_sine_rate": noise.p_idle_x_quadratic_sine_rate, - "p_idle_y_quadratic_sine_rate": noise.p_idle_y_quadratic_sine_rate, - "p_idle_z_quadratic_sine_rate": noise.p_idle_z_quadratic_sine_rate, + "p_idle_linear_rate": noise._p_idle_linear_rate, + "p_idle_quadratic_rate": noise._p_idle_quadratic_rate, + "p_idle_x_linear_rate": noise._p_idle_x_linear_rate, + "p_idle_y_linear_rate": noise._p_idle_y_linear_rate, + "p_idle_z_linear_rate": noise._p_idle_z_linear_rate, + "p_idle_x_quadratic_rate": noise._p_idle_x_quadratic_rate, + "p_idle_y_quadratic_rate": noise._p_idle_y_quadratic_rate, + "p_idle_z_quadratic_rate": noise._p_idle_z_quadratic_rate, + "p_idle_quadratic_sine_rate": noise._p_idle_quadratic_sine_rate, + "p_idle_x_quadratic_sine_rate": noise._p_idle_x_quadratic_sine_rate, + "p_idle_y_quadratic_sine_rate": noise._p_idle_y_quadratic_sine_rate, + "p_idle_z_quadratic_sine_rate": noise._p_idle_z_quadratic_sine_rate, "twirl": twirl, "interaction_basis": interaction_basis, "check_plan": resolved_plan.plan_id, @@ -4142,18 +4097,18 @@ def build_native_sampler( p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2, - p_idle_linear_rate=noise.p_idle_linear_rate, - p_idle_quadratic_rate=noise.p_idle_quadratic_rate, - p_idle_x_linear_rate=noise.p_idle_x_linear_rate, - p_idle_y_linear_rate=noise.p_idle_y_linear_rate, - p_idle_z_linear_rate=noise.p_idle_z_linear_rate, - p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, - p_idle_quadratic_sine_rate=noise.p_idle_quadratic_sine_rate, - p_idle_x_quadratic_sine_rate=noise.p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate=noise.p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate=noise.p_idle_z_quadratic_sine_rate, + p_idle_linear_rate=noise._p_idle_linear_rate, + p_idle_quadratic_rate=noise._p_idle_quadratic_rate, + p_idle_x_linear_rate=noise._p_idle_x_linear_rate, + p_idle_y_linear_rate=noise._p_idle_y_linear_rate, + p_idle_z_linear_rate=noise._p_idle_z_linear_rate, + p_idle_x_quadratic_rate=noise._p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=noise._p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=noise._p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=noise._p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=noise._p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=noise._p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=noise._p_idle_z_quadratic_sine_rate, twirl=twirl, interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, diff --git a/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py b/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py index cadfdaa67..748fa8c5d 100644 --- a/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py +++ b/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py @@ -187,6 +187,23 @@ def test_idle_family_setters_are_chainable() -> None: assert builder.with_p_idle_quadratic_coherent(False) is not None +def test_general_noise_linear_rate_setter_keeps_total_rate_family_semantics() -> None: + """The live engines spelling remains a total rate split by its model.""" + + uniform_model = {"X": 1.0 / 3.0, "Y": 1.0 / 3.0, "Z": 1.0 / 3.0} + otherwise_noiseless = ( + general_noise().with_p_prep(0.0).with_p1(0.0).with_p2(0.0).with_p_meas(0.0).with_idle_after_2q(1.0) + ) + legacy_spelling = otherwise_noiseless.with_p_idle_linear_rate(1.0) + total_rate_family = otherwise_noiseless.with_p_idle_linear(1.0, uniform_model) + z_only_family = otherwise_noiseless.with_p_idle_linear(1.0, {"Z": 1.0}) + + legacy_results = _run_after_2q_noise(legacy_spelling, shots=64, seed=1234) + assert legacy_results == _run_after_2q_noise(total_rate_family, shots=64, seed=1234) + assert _run_after_2q_noise(z_only_family, shots=64, seed=1234) == [0] * 64 + assert legacy_results != [0] * 64 + + def test_retired_coherent_bool_switch_is_not_an_alias() -> None: """The old one-bool call cannot silently become a zero/one coherent-family rate.""" with pytest.raises(TypeError, match=r"coherent idling rate.*not bool"): diff --git a/python/quantum-pecos/tests/qec/surface/fixtures/idle_z_linear.dem b/python/quantum-pecos/tests/qec/surface/fixtures/idle_z_linear.dem new file mode 100644 index 000000000..ff7532bf8 --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/fixtures/idle_z_linear.dem @@ -0,0 +1,37 @@ +detector(0, 0, 0) D0 +detector(1, 0, 0) D1 +detector(2, 0, 0) D2 +detector(3, 0, 0) D3 +detector(0, 0, 1) D4 +detector(1, 0, 1) D5 +detector(2, 0, 1) D6 +detector(3, 0, 1) D7 +detector(4, 1, 0) D8 +detector(5, 1, 0) D9 +detector(6, 1, 0) D10 +detector(7, 1, 0) D11 +detector(4, 1, 1) D12 +detector(5, 1, 1) D13 +detector(6, 1, 1) D14 +detector(7, 1, 1) D15 +detector(4, 1, 2) D16 +detector(5, 1, 2) D17 +detector(6, 1, 2) D18 +detector(7, 1, 2) D19 +logical_observable L0 +error(0.023502) D0 +error(0.014821) D0 D1 +error(0.005982) D0 D4 +error(0.037626) D1 +error(0.011892) D1 D2 +error(0.040401) D2 +error(0.017732) D2 D3 +error(0.026361) D3 +error(0.005982) D3 D7 +error(0.023502) D4 +error(0.014821) D4 D5 +error(0.032028) D5 +error(0.011892) D5 D6 +error(0.032028) D6 +error(0.014821) D6 D7 +error(0.023502) D7 diff --git a/python/quantum-pecos/tests/qec/surface/fixtures/idle_z_sin_squared.dem b/python/quantum-pecos/tests/qec/surface/fixtures/idle_z_sin_squared.dem new file mode 100644 index 000000000..5ec1e1b8c --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/fixtures/idle_z_sin_squared.dem @@ -0,0 +1,37 @@ +detector(0, 0, 0) D0 +detector(1, 0, 0) D1 +detector(2, 0, 0) D2 +detector(3, 0, 0) D3 +detector(0, 0, 1) D4 +detector(1, 0, 1) D5 +detector(2, 0, 1) D6 +detector(3, 0, 1) D7 +detector(4, 1, 0) D8 +detector(5, 1, 0) D9 +detector(6, 1, 0) D10 +detector(7, 1, 0) D11 +detector(4, 1, 1) D12 +detector(5, 1, 1) D13 +detector(6, 1, 1) D14 +detector(7, 1, 1) D15 +detector(4, 1, 2) D16 +detector(5, 1, 2) D17 +detector(6, 1, 2) D18 +detector(7, 1, 2) D19 +logical_observable L0 +error(0.007153) D0 +error(0.004482) D0 D1 +error(0.001798) D0 D4 +error(0.011571) D1 +error(0.003589) D1 D2 +error(0.01245) D2 +error(0.005374) D2 D3 +error(0.00804) D3 +error(0.001798) D3 D7 +error(0.007153) D4 +error(0.004482) D4 D5 +error(0.009808) D5 +error(0.003589) D5 D6 +error(0.009808) D6 +error(0.004482) D6 D7 +error(0.007153) D7 diff --git a/python/quantum-pecos/tests/qec/surface/test_idle_noise_families.py b/python/quantum-pecos/tests/qec/surface/test_idle_noise_families.py index 20f947c33..ad3a9916d 100644 --- a/python/quantum-pecos/tests/qec/surface/test_idle_noise_families.py +++ b/python/quantum-pecos/tests/qec/surface/test_idle_noise_families.py @@ -1,13 +1,17 @@ from __future__ import annotations +from pathlib import Path + import pytest from pecos.qec.surface import NoiseParameters, SurfacePatch, TwirlConfig from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder +_FIXTURES = Path(__file__).with_name("fixtures") + -def _native_surface_dem(noise: NoiseParameters) -> bytes: +def _native_surface_dem(noise: NoiseParameters) -> str: patch = SurfacePatch.create(distance=3) - dem = generate_circuit_level_dem_from_builder( + return generate_circuit_level_dem_from_builder( patch, num_rounds=2, noise=noise, @@ -15,36 +19,49 @@ def _native_surface_dem(noise: NoiseParameters) -> bytes: decompose_errors=True, twirl=TwirlConfig(), ) - return dem.encode() -def test_linear_family_matches_per_axis_native_surface_dem() -> None: +def _pre_change_dem_fixture(name: str) -> str: + return (_FIXTURES / name).read_text().removesuffix("\n") + + +def test_z_linear_family_matches_removed_z_only_setter_dem_fixture() -> None: rate = 0.003 - structured = _native_surface_dem(NoiseParameters(p_idle_linear=rate)) - primitive = _native_surface_dem( - NoiseParameters( - p_idle_x_linear_rate=rate / 3.0, - p_idle_y_linear_rate=rate / 3.0, - p_idle_z_linear_rate=rate / 3.0, - ), + actual = _native_surface_dem( + NoiseParameters().with_p_idle_linear(rate, {"Z": 1.0}), ) - assert structured == primitive + assert actual == _pre_change_dem_fixture("idle_z_linear.dem") -def test_sin_squared_family_matches_per_axis_native_surface_dem() -> None: +def test_z_sin_squared_family_matches_removed_z_only_setter_dem_fixture() -> None: rate = 0.03 - structured = _native_surface_dem( - NoiseParameters( - p_idle_sin_squared=rate, - p_idle_sin_squared_model={"Z": 1.0}, - ), + actual = _native_surface_dem(NoiseParameters().with_p_idle_sin_squared(rate, {"Z": 1.0})) + + assert actual == _pre_change_dem_fixture("idle_z_sin_squared.dem") + + +def test_linear_family_default_is_symmetric_end_to_end() -> None: + rate = 0.003 + implicit = NoiseParameters().with_p_idle_linear(rate) + explicit = NoiseParameters().with_p_idle_linear( + rate, + {"X": 1.0 / 3.0, "Y": 1.0 / 3.0, "Z": 1.0 / 3.0}, ) - primitive = _native_surface_dem(NoiseParameters(p_idle_z_quadratic_sine_rate=rate)) - assert structured == primitive + assert implicit.idle_memory_rates[:3] == pytest.approx((rate / 3.0,) * 3) + assert _native_surface_dem(implicit) == _native_surface_dem(explicit) + + +def test_sin_squared_family_default_is_symmetric_end_to_end() -> None: + rate = 0.03 + implicit = NoiseParameters().with_p_idle_sin_squared(rate) + explicit = NoiseParameters().with_p_idle_sin_squared(rate, {"X": 1.0, "Y": 1.0, "Z": 1.0}) + + assert implicit.idle_memory_rates[6:] == pytest.approx((rate,) * 3) + assert _native_surface_dem(implicit) == _native_surface_dem(explicit) def test_structured_families_survive_runtime_idle_unit_conversion() -> None: @@ -56,12 +73,8 @@ def test_structured_families_survive_runtime_idle_unit_conversion() -> None: converted = noise.for_runtime_idle_time_units(time_units_per_second=10.0) - assert converted.p_idle_x_linear_rate == pytest.approx(0.01) - assert converted.p_idle_y_linear_rate == pytest.approx(0.01) - assert converted.p_idle_z_linear_rate == pytest.approx(0.01) - assert converted.p_idle_x_quadratic_sine_rate is None - assert converted.p_idle_y_quadratic_sine_rate is None - assert converted.p_idle_z_quadratic_sine_rate == pytest.approx(0.02) + assert converted.idle_memory_rates[:3] == pytest.approx((0.01, 0.01, 0.01)) + assert converted.idle_memory_rates[6:] == (None, None, pytest.approx(0.02)) assert converted.p_idle_linear is None assert converted.p_idle_linear_model is None assert converted.p_idle_sin_squared is None @@ -73,8 +86,8 @@ def test_structured_families_survive_runtime_idle_unit_conversion() -> None: @pytest.mark.parametrize( "kwargs", [ - {"p_idle_linear": 0.01, "p_idle_x_linear_rate": 0.02}, - {"p_idle_sin_squared": 0.01, "p_idle_y_quadratic_sine_rate": 0.02}, + {"p_idle_linear": 0.01, "_p_idle_x_linear_rate": 0.02}, + {"p_idle_sin_squared": 0.01, "_p_idle_y_quadratic_sine_rate": 0.02}, ], ) def test_structured_family_conflicts_with_corresponding_primitive(kwargs: dict[str, object]) -> None: @@ -82,19 +95,6 @@ def test_structured_family_conflicts_with_corresponding_primitive(kwargs: dict[s NoiseParameters(**kwargs) -@pytest.mark.parametrize( - "field", - [ - "p_idle_linear_rate", - "p_idle_quadratic_rate", - "p_idle_quadratic_sine_rate", - ], -) -def test_bare_z_only_alias_warns_through_noise_model(field: str) -> None: - with pytest.warns(DeprecationWarning, match=field): - NoiseParameters(**{field: 0.01}) - - def test_idle_memory_rates_include_translated_family_values() -> None: noise = NoiseParameters( p_idle_linear=0.3, @@ -109,4 +109,4 @@ def test_idle_memory_rates_include_translated_family_values() -> None: def test_nonzero_coherent_family_is_rejected_by_standard_dem_model() -> None: with pytest.raises(ValueError, match="cannot represent coherent idle noise"): - NoiseParameters(p_idle_coherent=0.01) + NoiseParameters().with_p_idle_coherent(0.01) diff --git a/python/quantum-pecos/tests/qec/surface/test_noise_parameters.py b/python/quantum-pecos/tests/qec/surface/test_noise_parameters.py index 6b717ffe1..545ee6ada 100644 --- a/python/quantum-pecos/tests/qec/surface/test_noise_parameters.py +++ b/python/quantum-pecos/tests/qec/surface/test_noise_parameters.py @@ -5,7 +5,6 @@ from __future__ import annotations -import warnings from dataclasses import fields import pecos.qec.surface as surface @@ -72,16 +71,54 @@ def test_fluent_chain_matches_constructor_and_dem() -> None: "p_idle_sin_squared_model", "p_idle_coherent_model", } +_INTERNAL_IDLE_FIELDS = { + "_p_idle_linear_rate", + "_p_idle_quadratic_rate", + "_p_idle_x_linear_rate", + "_p_idle_y_linear_rate", + "_p_idle_z_linear_rate", + "_p_idle_x_quadratic_rate", + "_p_idle_y_quadratic_rate", + "_p_idle_z_quadratic_rate", + "_p_idle_quadratic_sine_rate", + "_p_idle_x_quadratic_sine_rate", + "_p_idle_y_quadratic_sine_rate", + "_p_idle_z_quadratic_sine_rate", +} +_REMOVED_IDLE_SETTERS = tuple(f"with_{name.removeprefix('_')}" for name in sorted(_INTERNAL_IDLE_FIELDS)) def test_every_field_has_a_mechanical_fluent_setter() -> None: field_names = {field.name for field in fields(NoiseParameters)} + public_field_names = field_names - _INTERNAL_IDLE_FIELDS assert len(field_names) == 30 - for field_name in field_names - _FAMILY_MODEL_FIELDS: + assert len(public_field_names) == 18 + for field_name in public_field_names - _FAMILY_MODEL_FIELDS: assert callable(getattr(NoiseParameters, f"with_{field_name}")), field_name +def test_per_axis_and_legacy_idle_fields_are_internal() -> None: + noise = NoiseParameters() + + for internal_name in _INTERNAL_IDLE_FIELDS: + assert hasattr(noise, internal_name), internal_name + assert not hasattr(noise, internal_name.removeprefix("_")), internal_name + + +def test_per_axis_and_legacy_idle_setters_are_removed() -> None: + noise = NoiseParameters() + + for setter_name in _REMOVED_IDLE_SETTERS: + assert not hasattr(noise, setter_name), setter_name + + +def test_per_axis_idle_constructor_keyword_fails_loudly() -> None: + kwargs = {"p_idle_z_linear_rate": 0.01} + with pytest.raises(TypeError, match=r"unexpected keyword argument 'p_idle_z_linear_rate'"): + NoiseParameters(**kwargs) + + def test_family_models_are_set_through_their_rate_setter() -> None: import inspect @@ -105,9 +142,7 @@ def test_structured_family_survives_fluent_chain_and_runtime_conversion() -> Non converted = noise.for_runtime_idle_time_units(time_units_per_second=10.0) - assert converted.p_idle_x_linear_rate == pytest.approx(0.01) - assert converted.p_idle_y_linear_rate == pytest.approx(0.01) - assert converted.p_idle_z_linear_rate == pytest.approx(0.01) + assert converted.idle_memory_rates[:3] == pytest.approx((0.01, 0.01, 0.01)) assert converted.p_idle_linear is None assert converted.p_idle_linear_model is None @@ -118,8 +153,8 @@ def test_idle_family_rate_and_model_set_together() -> None: # would collide with the per-axis values the rate call just produced. noise = NoiseParameters().with_p_idle_linear(0.01, {"Z": 1.0}).with_p1(0.001) - assert noise.p_idle_z_linear_rate == pytest.approx(0.01) - assert noise.p_idle_x_linear_rate in (None, 0.0) + assert noise.idle_memory_rates[2] == pytest.approx(0.01) + assert noise.idle_memory_rates[0] in (None, 0.0) assert noise.p_idle_linear is None assert noise.p1 == pytest.approx(0.001) @@ -139,9 +174,9 @@ def test_each_idle_family_round_trips_through_runtime_conversion() -> None: linear = NoiseParameters().with_p_idle_linear(0.3, {"Z": 1.0}) sine = NoiseParameters().with_p_idle_sin_squared(0.2, {"X": 1.0}) - assert linear.for_runtime_idle_time_units(time_units_per_second=10.0).p_idle_z_linear_rate == pytest.approx(0.03) + assert linear.for_runtime_idle_time_units(time_units_per_second=10.0).idle_memory_rates[2] == pytest.approx(0.03) converted_sine = sine.for_runtime_idle_time_units(time_units_per_second=10.0) - assert converted_sine.p_idle_x_quadratic_sine_rate == pytest.approx(0.02) + assert converted_sine.idle_memory_rates[6] == pytest.approx(0.02) def test_deprecated_alias_warns_and_returns_noise_parameters() -> None: @@ -163,17 +198,6 @@ def test_public_import_paths_refer_to_the_same_class() -> None: assert SurfaceNoiseParameters is NoiseParameters -def test_legacy_idle_alias_setter_warns_but_family_setter_does_not() -> None: - with pytest.warns(DeprecationWarning, match="p_idle_linear_rate"): - NoiseParameters().with_p_idle_linear_rate(0.01) - - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - NoiseParameters().with_p_idle_linear(0.01) - - assert not caught - - def test_chaining_order_does_not_matter() -> None: first = NoiseParameters().with_p1(0.001).with_p2(0.01).with_p_meas(0.002).with_p_prep(0.003) second = NoiseParameters().with_p_prep(0.003).with_p_meas(0.002).with_p2(0.01).with_p1(0.001) diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py index 4cb72740d..d4cac2d2d 100644 --- a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py @@ -315,7 +315,7 @@ def test_abstract_twirl_builders_reject_unsupported_config( def test_twirl_sine_law_idle_noise_builds_dem_and_sampler() -> None: patch = SurfacePatch.create(distance=3) - noise = NoiseParameters(p_idle_x_quadratic_sine_rate=0.03) + noise = NoiseParameters().with_p_idle_sin_squared(0.03, {"X": 1.0}) twirl = TwirlConfig() dem = generate_circuit_level_dem_from_builder( @@ -348,9 +348,9 @@ def test_twirl_sine_law_idle_noise_builds_dem_and_sampler() -> None: ("depolarizing", NoiseParameters(p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001)), ("uniform_idle", NoiseParameters(p_idle=0.002)), ("t1_t2", NoiseParameters(t1=1000.0, t2=800.0)), - ("linear_idle", NoiseParameters(p_idle_z_linear_rate=0.001)), - ("quadratic_idle", NoiseParameters(p_idle_z_quadratic_rate=0.01)), - ("sine_law_idle", NoiseParameters(p_idle_x_quadratic_sine_rate=0.03)), + ("linear_idle", NoiseParameters().with_p_idle_linear(0.001, {"Z": 1.0})), + ("z_sine_law_idle", NoiseParameters().with_p_idle_sin_squared(0.01, {"Z": 1.0})), + ("x_sine_law_idle", NoiseParameters().with_p_idle_sin_squared(0.03, {"X": 1.0})), ], ) def test_twirling_does_not_change_canonical_dem( diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py index f6580fce7..e34ecb99b 100644 --- a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -1096,7 +1096,7 @@ def test_szz_native_dem_accepts_idle_with_physical_prefix_lowering() -> None: def test_szz_idle_dem_uses_lowered_prefix_topology(basis: str) -> None: patch = SurfacePatch.create(distance=3) patch_key = _surface_patch_cache_key(patch) - noise = NoiseParameters(p_idle_z_linear_rate=0.01) + noise = NoiseParameters().with_p_idle_linear(0.01, {"Z": 1.0}) actual = generate_circuit_level_dem_from_builder( patch, @@ -1178,7 +1178,7 @@ def test_szz_virtual_prefix_ticks_do_not_contribute_idle_dem() -> None: patch, num_rounds=1, basis="Z", - noise=NoiseParameters(p_idle_z_linear_rate=0.01), + noise=NoiseParameters().with_p_idle_linear(0.01, {"Z": 1.0}), interaction_basis="szz", decompose_errors=False, ) 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 4aceaee43..082cd93dd 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -1021,17 +1021,19 @@ def test_lowered_replay_converts_runtime_idle_seconds_to_nanosecond_time_units() def test_noise_model_converts_runtime_idle_rates_from_seconds_to_dem_time_units() -> None: - noise = NoiseParameters( - p1=0.001, - p2=0.002, - p_meas=0.003, - p_prep=0.004, - p_idle=9.0, - t1=1.5, - t2=2.5, - p_idle_z_linear_rate=3.0, - p_idle_x_quadratic_rate=4.0, - p_idle_z_quadratic_sine_rate=5.0, + noise = ( + NoiseParameters( + p1=0.001, + p2=0.002, + p_meas=0.003, + p_prep=0.004, + p_idle=9.0, + t1=1.5, + t2=2.5, + _p_idle_x_quadratic_rate=4.0, + ) + .with_p_idle_linear(3.0, {"Z": 1.0}) + .with_p_idle_sin_squared(5.0, {"Z": 1.0}) ) converted = noise.for_runtime_idle_time_units() @@ -1043,14 +1045,16 @@ def test_noise_model_converts_runtime_idle_rates_from_seconds_to_dem_time_units( assert converted.p_idle == pytest.approx(9.0 / RUNTIME_IDLE_TIME_UNITS_PER_SECOND) assert converted.t1 == pytest.approx(1.5 * RUNTIME_IDLE_TIME_UNITS_PER_SECOND) assert converted.t2 == pytest.approx(2.5 * RUNTIME_IDLE_TIME_UNITS_PER_SECOND) - assert converted.p_idle_z_linear_rate == pytest.approx(3.0 / RUNTIME_IDLE_TIME_UNITS_PER_SECOND) - assert converted.p_idle_x_quadratic_rate == pytest.approx(4.0 / (RUNTIME_IDLE_TIME_UNITS_PER_SECOND**2)) - assert converted.p_idle_z_quadratic_sine_rate == pytest.approx(5.0 / RUNTIME_IDLE_TIME_UNITS_PER_SECOND) + assert converted.idle_memory_rates[2] == pytest.approx(3.0 / RUNTIME_IDLE_TIME_UNITS_PER_SECOND) + assert converted.idle_memory_rates[3] == pytest.approx(4.0 / (RUNTIME_IDLE_TIME_UNITS_PER_SECOND**2)) + assert converted.idle_memory_rates[8] == pytest.approx(5.0 / RUNTIME_IDLE_TIME_UNITS_PER_SECOND) def test_noise_model_rejects_invalid_runtime_idle_time_unit_scale() -> None: with pytest.raises(ValueError, match="time_units_per_second"): - NoiseParameters(p_idle_z_linear_rate=1.0).for_runtime_idle_time_units(time_units_per_second=0.0) + NoiseParameters().with_p_idle_linear(1.0, {"Z": 1.0}).for_runtime_idle_time_units( + time_units_per_second=0.0, + ) def test_lowered_replay_preserves_gate_metadata() -> None: diff --git a/python/quantum-pecos/tests/qec/test_guppy_dem_builder.py b/python/quantum-pecos/tests/qec/test_guppy_dem_builder.py index a0b76c193..9359932ed 100644 --- a/python/quantum-pecos/tests/qec/test_guppy_dem_builder.py +++ b/python/quantum-pecos/tests/qec/test_guppy_dem_builder.py @@ -42,7 +42,10 @@ def _tagged_two_qubit_program() -> None: def test_builder_matches_both_wrappers_with_noise_and_inserted_idles() -> None: - noise = NoiseParameters(p1=0.0, p2=0.01, p_meas=0.02, p_prep=0.0, p_idle_z_linear_rate=0.03) + noise = NoiseParameters(p1=0.0, p2=0.01, p_meas=0.02, p_prep=0.0).with_p_idle_linear( + 0.03, + {"Z": 1.0}, + ) via_json_builder = ( DetectorErrorModel.builder() .with_program(_tagged_two_qubit_program) From 50e45886c7af5ff312c659477cf02a35ba197aa9 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 5 Aug 2026 18:50:00 -0600 Subject: [PATCH 44/62] Store DEM idle noise as rate-plus-weight-map families in the Rust config --- .../src/fault_tolerance/dem_builder.rs | 2 +- .../src/fault_tolerance/dem_builder/types.rs | 265 ++++++++---------- crates/pecos-qec/src/lib.rs | 2 +- crates/pecos-qec/tests/idle_noise_tests.rs | 223 +++++++++++++-- .../src/fault_tolerance_bindings.rs | 71 +++-- .../tests/qec/test_from_guppy_dem.py | 28 ++ 6 files changed, 380 insertions(+), 211 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs index 58e2d3bf4..8d58da4f4 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs @@ -100,7 +100,7 @@ pub use sampler::{ pub use types::{ ContributionEffectSummary, ContributionRenderRecord, ContributionRenderStrategy, ContributionRenderSummary, DecomposedFault, DemOutput, DetectorDef, DetectorErrorModel, - DirectSourceFamily, FaultContribution, FaultMechanism, FaultSourceType, + DirectSourceFamily, FaultContribution, FaultMechanism, FaultSourceType, IdleNoiseFamily, MeasurementCrosstalkDemMode, MeasurementCrosstalkTransitionModel, MeasurementMechanism, MeasurementNoiseChannelResidual, MeasurementNoiseModel, NoiseChannelError, NoiseChannelKind, NoiseChannelResidual, NoiseConfig, PAULI_1Q_ORDER, PAULI_2Q_ORDER, PauliProbs, PauliWeights, diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index 366c38033..9001b7ef7 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -2460,6 +2460,27 @@ pub fn omitted_two_qubit_gate_pauli_twirl( Some(entries.iter().copied().collect()) } +/// Rate and per-Pauli weights for one dedicated idle-noise family. +/// +/// `weights` may contain only `"X"`, `"Y"`, and `"Z"`. An empty map means +/// equal unit weight on all three axes. A zero `rate` disables the family +/// regardless of the map contents. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct IdleNoiseFamily { + /// Common family rate. + pub rate: f64, + /// Per-axis relative-rate weights. + pub weights: BTreeMap, +} + +impl IdleNoiseFamily { + /// Creates an idle-noise family from a common rate and per-axis weights. + #[must_use] + pub fn new(rate: f64, weights: BTreeMap) -> Self { + Self { rate, weights } + } +} + /// Noise model configuration for circuit-level fault analysis. #[derive(Debug, Clone)] pub struct NoiseConfig { @@ -2519,46 +2540,21 @@ pub struct NoiseConfig { /// /// This is the EEG H-type noise model for idle gates. Default is 0.0. pub idle_rz: f64, - /// Stochastic Z-memory error rate linear in idle duration. + /// Categorical Pauli-memory family linear in idle duration. /// - /// This mirrors PECOS engine idle memory noise in a DEM-compatible Pauli - /// channel: each explicit `Idle(duration, q)` contributes an independent - /// Z fault with probability `p_idle_linear_rate * duration`. + /// Axis `P` has probability `rate * weights[P] * duration`. The family is + /// converted to independent DEM mechanisms only after equal propagated + /// signatures have been collected. + pub p_idle_linear: IdleNoiseFamily, + /// Independent Pauli-memory family quadratic in idle duration. /// - /// This is the legacy Z-axis alias for `p_idle_z_linear_rate`. - pub p_idle_linear_rate: f64, - /// Stochastic Z-memory error rate for the quadratic idle term. + /// The common rate has units of inverse time squared. Axis `P` has + /// probability `rate * weights[P] * duration^2`. + pub p_idle_quadratic: IdleNoiseFamily, + /// Independent sine-squared Pauli-memory family. /// - /// Each explicit `Idle(duration, q)` contributes a Z-fault probability - /// term `p_idle_quadratic_rate * duration^2`. - /// - /// This is the legacy Z-axis alias for `p_idle_z_quadratic_rate`. - pub p_idle_quadratic_rate: f64, - /// Stochastic Z-memory sine-law rate for the quadratic idle term. - /// - /// Each explicit `Idle(duration, q)` contributes a Z-fault probability - /// term `sin(p_idle_quadratic_sine_rate * duration)^2`. This preserves - /// the small-duration quadratic behavior of coherent dephasing models - /// without changing the coefficient-style `p_idle_quadratic_rate` API. - /// - /// This is the legacy Z-axis alias for `p_idle_z_quadratic_sine_rate`. - pub p_idle_quadratic_sine_rate: f64, - /// Stochastic X-memory error rate linear in idle duration. - /// - /// Together with the Y and Z rates, this defines one categorical Pauli - /// channel. DEM construction converts that channel only after propagating - /// the Paulis to concrete detector/observable flip signatures. - pub p_idle_x_linear_rate: f64, - /// Stochastic Y-memory error rate linear in idle duration. - pub p_idle_y_linear_rate: f64, - /// Stochastic X-memory error rate quadratic in idle duration. - pub p_idle_x_quadratic_rate: f64, - /// Stochastic Y-memory error rate quadratic in idle duration. - pub p_idle_y_quadratic_rate: f64, - /// Stochastic X-memory sine-law rate for the quadratic idle term. - pub p_idle_x_quadratic_sine_rate: f64, - /// Stochastic Y-memory sine-law rate for the quadratic idle term. - pub p_idle_y_quadratic_sine_rate: f64, + /// Axis `P` has probability `sin(rate * weights[P] * duration)^2`. + pub p_idle_quadratic_sine: IdleNoiseFamily, /// Per-payload local measurement-crosstalk event rate. /// /// This rate is multiplied by the selected hidden-measurement transition @@ -3144,15 +3140,9 @@ impl Default for NoiseConfig { p2_weights: None, p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, - p_idle_linear_rate: 0.0, - p_idle_quadratic_rate: 0.0, - p_idle_quadratic_sine_rate: 0.0, - p_idle_x_linear_rate: 0.0, - p_idle_y_linear_rate: 0.0, - p_idle_x_quadratic_rate: 0.0, - p_idle_y_quadratic_rate: 0.0, - p_idle_x_quadratic_sine_rate: 0.0, - p_idle_y_quadratic_sine_rate: 0.0, + p_idle_linear: IdleNoiseFamily::default(), + p_idle_quadratic: IdleNoiseFamily::default(), + p_idle_quadratic_sine: IdleNoiseFamily::default(), p_meas_crosstalk_local: 0.0, p_meas_crosstalk_global: 0.0, p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), @@ -3179,15 +3169,9 @@ impl NoiseConfig { p2_weights: None, p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, - p_idle_linear_rate: 0.0, - p_idle_quadratic_rate: 0.0, - p_idle_quadratic_sine_rate: 0.0, - p_idle_x_linear_rate: 0.0, - p_idle_y_linear_rate: 0.0, - p_idle_x_quadratic_rate: 0.0, - p_idle_y_quadratic_rate: 0.0, - p_idle_x_quadratic_sine_rate: 0.0, - p_idle_y_quadratic_sine_rate: 0.0, + p_idle_linear: IdleNoiseFamily::default(), + p_idle_quadratic: IdleNoiseFamily::default(), + p_idle_quadratic_sine: IdleNoiseFamily::default(), p_meas_crosstalk_local: 0.0, p_meas_crosstalk_global: 0.0, p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), @@ -3212,15 +3196,9 @@ impl NoiseConfig { p2_weights: None, p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, - p_idle_linear_rate: 0.0, - p_idle_quadratic_rate: 0.0, - p_idle_quadratic_sine_rate: 0.0, - p_idle_x_linear_rate: 0.0, - p_idle_y_linear_rate: 0.0, - p_idle_x_quadratic_rate: 0.0, - p_idle_y_quadratic_rate: 0.0, - p_idle_x_quadratic_sine_rate: 0.0, - p_idle_y_quadratic_sine_rate: 0.0, + p_idle_linear: IdleNoiseFamily::default(), + p_idle_quadratic: IdleNoiseFamily::default(), + p_idle_quadratic_sine: IdleNoiseFamily::default(), p_meas_crosstalk_local: 0.0, p_meas_crosstalk_global: 0.0, p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), @@ -3245,15 +3223,9 @@ impl NoiseConfig { p2_weights: None, p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, - p_idle_linear_rate: 0.0, - p_idle_quadratic_rate: 0.0, - p_idle_quadratic_sine_rate: 0.0, - p_idle_x_linear_rate: 0.0, - p_idle_y_linear_rate: 0.0, - p_idle_x_quadratic_rate: 0.0, - p_idle_y_quadratic_rate: 0.0, - p_idle_x_quadratic_sine_rate: 0.0, - p_idle_y_quadratic_sine_rate: 0.0, + p_idle_linear: IdleNoiseFamily::default(), + p_idle_quadratic: IdleNoiseFamily::default(), + p_idle_quadratic_sine: IdleNoiseFamily::default(), p_meas_crosstalk_local: 0.0, p_meas_crosstalk_global: 0.0, p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), @@ -3268,61 +3240,24 @@ impl NoiseConfig { self } - /// Sets the linear stochastic Z-memory rate for explicit idle gates. + /// Sets the categorical linear idle-noise family. #[must_use] - pub fn set_idle_linear_rate(mut self, rate: f64) -> Self { - self.p_idle_linear_rate = rate; + pub fn set_idle_linear(mut self, family: IdleNoiseFamily) -> Self { + self.p_idle_linear = family; self } - /// Sets the quadratic stochastic Z-memory rate for explicit idle gates. + /// Sets the independent coefficient-quadratic idle-noise family. #[must_use] - pub fn set_idle_quadratic_rate(mut self, rate: f64) -> Self { - self.p_idle_quadratic_rate = rate; + pub fn set_idle_quadratic(mut self, family: IdleNoiseFamily) -> Self { + self.p_idle_quadratic = family; self } - /// Sets the sine-law quadratic stochastic Z-memory rate for explicit idle gates. + /// Sets the independent sine-squared idle-noise family. #[must_use] - pub fn set_idle_quadratic_sine_rate(mut self, rate: f64) -> Self { - self.p_idle_quadratic_sine_rate = rate; - self - } - - /// Sets the linear stochastic Pauli-memory rates for explicit idle gates. - #[must_use] - pub fn set_idle_pauli_linear_rates(mut self, px_rate: f64, py_rate: f64, pz_rate: f64) -> Self { - self.p_idle_x_linear_rate = px_rate; - self.p_idle_y_linear_rate = py_rate; - self.p_idle_linear_rate = pz_rate; - self - } - - /// Sets the quadratic stochastic Pauli-memory rates for explicit idle gates. - #[must_use] - pub fn set_idle_pauli_quadratic_rates( - mut self, - px_rate: f64, - py_rate: f64, - pz_rate: f64, - ) -> Self { - self.p_idle_x_quadratic_rate = px_rate; - self.p_idle_y_quadratic_rate = py_rate; - self.p_idle_quadratic_rate = pz_rate; - self - } - - /// Sets the sine-law quadratic stochastic Pauli-memory rates for explicit idle gates. - #[must_use] - pub fn set_idle_pauli_quadratic_sine_rates( - mut self, - px_rate: f64, - py_rate: f64, - pz_rate: f64, - ) -> Self { - self.p_idle_x_quadratic_sine_rate = px_rate; - self.p_idle_y_quadratic_sine_rate = py_rate; - self.p_idle_quadratic_sine_rate = pz_rate; + pub fn set_idle_quadratic_sine(mut self, family: IdleNoiseFamily) -> Self { + self.p_idle_quadratic_sine = family; self } @@ -3507,6 +3442,64 @@ impl NoiseConfig { Ok(()) } + fn idle_family_rates( + family_name: &str, + family: &IdleNoiseFamily, + ) -> Result { + if family.rate == 0.0 { + return Ok(PauliProbs::default()); + } + + for key in family.weights.keys() { + if !matches!(key.as_str(), "X" | "Y" | "Z") { + return Err(NoiseChannelError::new(format!( + "invalid {family_name} idle rate/model key {key:?}; weights must use only X, Y, and Z" + ))); + } + } + let weights = if family.weights.is_empty() { + PauliProbs { + px: 1.0, + py: 1.0, + pz: 1.0, + } + } else { + PauliProbs { + px: family.weights.get("X").copied().unwrap_or(0.0), + py: family.weights.get("Y").copied().unwrap_or(0.0), + pz: family.weights.get("Z").copied().unwrap_or(0.0), + } + }; + let weighted_rate = |weight: f64| { + if weight == 0.0 { + 0.0 + } else { + family.rate * weight + } + }; + let rates = PauliProbs { + px: weighted_rate(weights.px), + py: weighted_rate(weights.py), + pz: weighted_rate(weights.pz), + }; + if !family.rate.is_finite() + || family.rate < 0.0 + || !weights.px.is_finite() + || weights.px < 0.0 + || !weights.py.is_finite() + || weights.py < 0.0 + || !weights.pz.is_finite() + || weights.pz < 0.0 + { + return Err(NoiseChannelError::new(format!( + "invalid {family_name} idle rate/model [X={}, Y={}, Z={}]; rates must be finite and non-negative", + rates.px, rates.py, rates.pz + ))); + } + Self::validate_idle_rates(family_name, rates)?; + Ok(rates) + } + fn base_idle_pauli_probs(&self, duration: f64) -> Result { if !duration.is_finite() || duration < 0.0 { return Err(NoiseChannelError::new(format!( @@ -3544,24 +3537,10 @@ impl NoiseConfig { duration: f64, ) -> Result { let base = self.base_idle_pauli_probs(duration)?; - let linear_rates = PauliProbs { - px: self.p_idle_x_linear_rate, - py: self.p_idle_y_linear_rate, - pz: self.p_idle_linear_rate, - }; - let quadratic_rates = PauliProbs { - px: self.p_idle_x_quadratic_rate, - py: self.p_idle_y_quadratic_rate, - pz: self.p_idle_quadratic_rate, - }; - let sine_rates = PauliProbs { - px: self.p_idle_x_quadratic_sine_rate, - py: self.p_idle_y_quadratic_sine_rate, - pz: self.p_idle_quadratic_sine_rate, - }; - Self::validate_idle_rates("linear", linear_rates)?; - Self::validate_idle_rates("coefficient-quadratic", quadratic_rates)?; - Self::validate_idle_rates("sine-squared", sine_rates)?; + let linear_rates = Self::idle_family_rates("linear", &self.p_idle_linear)?; + let quadratic_rates = + Self::idle_family_rates("coefficient-quadratic", &self.p_idle_quadratic)?; + let sine_rates = Self::idle_family_rates("sine-squared", &self.p_idle_quadratic_sine)?; let duration_squared = duration * duration; let linear = PauliProbs { @@ -3727,15 +3706,9 @@ impl NoiseConfig { self.p_idle != 0.0 || self.t1.is_some() || self.t2.is_some() - || self.p_idle_linear_rate != 0.0 - || self.p_idle_quadratic_rate != 0.0 - || self.p_idle_quadratic_sine_rate != 0.0 - || self.p_idle_x_linear_rate != 0.0 - || self.p_idle_y_linear_rate != 0.0 - || self.p_idle_x_quadratic_rate != 0.0 - || self.p_idle_y_quadratic_rate != 0.0 - || self.p_idle_x_quadratic_sine_rate != 0.0 - || self.p_idle_y_quadratic_sine_rate != 0.0 + || self.p_idle_linear.rate != 0.0 + || self.p_idle_quadratic.rate != 0.0 + || self.p_idle_quadratic_sine.rate != 0.0 } } diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index 50100c2e2..b57965835 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -82,7 +82,7 @@ pub use distance::{ }; pub use fault_tolerance::dem_builder::{ DecomposedFault, DemBuilder, DemBuilderError, DemOutput, DetectorDef, DetectorErrorModel, - FaultMechanism, NoiseConfig, PecosDemMetadataError, combine_probabilities, + FaultMechanism, IdleNoiseFamily, NoiseConfig, PecosDemMetadataError, combine_probabilities, }; pub use fault_tolerance::{ CorrectionResult, DecoderAnalysis, DemOutputKind, DemOutputMetadata, ErrorClass, diff --git a/crates/pecos-qec/tests/idle_noise_tests.rs b/crates/pecos-qec/tests/idle_noise_tests.rs index 61bf920d8..9dbbbdda7 100644 --- a/crates/pecos-qec/tests/idle_noise_tests.rs +++ b/crates/pecos-qec/tests/idle_noise_tests.rs @@ -17,8 +17,8 @@ use pecos_core::pauli::{X, Y, Z}; use pecos_core::{QubitId, TimeUnits}; use pecos_qec::fault_tolerance::dem_builder::{ - DemBuilder, DemSamplerBuilder, DetectorErrorModel, FaultMechanism, MemBuilder, NoiseConfig, - PauliProbs, PerGateTypeNoise, combine_probabilities, + DemBuilder, DemSamplerBuilder, DetectorErrorModel, FaultMechanism, IdleNoiseFamily, MemBuilder, + NoiseConfig, PauliProbs, PerGateTypeNoise, combine_probabilities, }; use pecos_qec::fault_tolerance::propagator::{ DagFaultAnalyzer, DagFaultInfluenceMap, DagSpacetimeLocation, Pauli, @@ -26,6 +26,27 @@ use pecos_qec::fault_tolerance::propagator::{ use pecos_quantum::{DagCircuit, GateType}; use std::collections::BTreeMap; +fn idle_family( + rate: f64, + weights: impl IntoIterator, +) -> IdleNoiseFamily { + IdleNoiseFamily::new( + rate, + weights + .into_iter() + .map(|(axis, weight)| (axis.to_string(), weight)) + .collect(), + ) +} + +fn z_idle_family(rate: f64) -> IdleNoiseFamily { + idle_family(rate, [("Z", 1.0)]) +} + +fn axis_rate_family(px: f64, py: f64, pz: f64) -> IdleNoiseFamily { + idle_family(1.0, [("X", px), ("Y", py), ("Z", pz)]) +} + fn build_idle_then_measure(num_idles: usize) -> DagCircuit { // Prep N qubits, idle each once, measure each. Very simple fixture // to isolate idle-gate contributions. @@ -367,7 +388,9 @@ fn linear_memory_z_noise_uses_idle_duration_in_dem() { let influence = analyzer.build_influence_map(); let dem = DemBuilder::new(&influence) - .with_noise_config(NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear_rate(1.0e-3)) + .with_noise_config( + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(z_idle_family(1.0e-3)), + ) .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) .unwrap() .build(); @@ -381,22 +404,22 @@ fn linear_memory_z_noise_uses_idle_duration_in_dem() { #[test] fn idle_memory_pauli_probabilities_match_linear_and_quadratic_model() { let linear = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) - .set_idle_linear_rate(1.0e-3) + .set_idle_linear(z_idle_family(1.0e-3)) .idle_pauli_probs(20.0); assert_eq!(linear.px.to_bits(), 0.0_f64.to_bits()); assert_eq!(linear.py.to_bits(), 0.0_f64.to_bits()); assert!((linear.pz - 0.02).abs() < 1e-15); let quadratic = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) - .set_idle_quadratic_rate(0.1) + .set_idle_quadratic(z_idle_family(0.1)) .idle_pauli_probs(2.0); assert_eq!(quadratic.px.to_bits(), 0.0_f64.to_bits()); assert_eq!(quadratic.py.to_bits(), 0.0_f64.to_bits()); assert!((quadratic.pz - 0.4).abs() < 1e-15); let pauli = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) - .set_idle_pauli_linear_rates(1.0e-3, 2.0e-3, 3.0e-3) - .set_idle_pauli_quadratic_rates(1.0e-4, 2.0e-4, 3.0e-4) + .set_idle_linear(axis_rate_family(1.0e-3, 2.0e-3, 3.0e-3)) + .set_idle_quadratic(axis_rate_family(1.0e-4, 2.0e-4, 3.0e-4)) .idle_memory_pauli_probs(10.0); let expected = compose_pauli_channels( [0.94, 0.01, 0.02, 0.03], @@ -414,14 +437,14 @@ fn idle_memory_pauli_probabilities_match_linear_and_quadratic_model() { #[test] fn idle_memory_pauli_probabilities_support_quadratic_sine_model() { let z_sine = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) - .set_idle_quadratic_sine_rate(0.2) + .set_idle_quadratic_sine(z_idle_family(0.2)) .idle_memory_pauli_probs(3.0); assert_eq!(z_sine.px.to_bits(), 0.0_f64.to_bits()); assert_eq!(z_sine.py.to_bits(), 0.0_f64.to_bits()); assert!((z_sine.pz - 0.6_f64.sin().powi(2)).abs() < 1e-15); let pauli_sine = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) - .set_idle_pauli_quadratic_sine_rates(0.1, 0.2, 0.3) + .set_idle_quadratic_sine(axis_rate_family(0.1, 0.2, 0.3)) .idle_memory_pauli_probs(2.0); let expected = compose_xyz_mechanisms(PauliProbs { px: 0.2_f64.sin().powi(2), @@ -433,10 +456,152 @@ fn idle_memory_pauli_probabilities_support_quadratic_sine_model() { assert!((pauli_sine.pz - expected[3]).abs() < 1e-15); } +#[test] +fn unset_idle_weight_map_is_symmetric() { + let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); + let implicit = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_idle_linear(IdleNoiseFamily::new(0.005, BTreeMap::new())); + let explicit = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_idle_linear(idle_family(0.005, [("X", 1.0), ("Y", 1.0), ("Z", 1.0)])); + + assert_eq!( + build_synthetic_idle_dem(&influence, implicit) + .expect("implicit symmetric family") + .to_string(), + build_synthetic_idle_dem(&influence, explicit) + .expect("explicit symmetric family") + .to_string(), + ); +} + +#[test] +fn single_axis_idle_map_matches_pinned_z_linear_dem() { + let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); + let noise = + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(idle_family(0.005, [("Z", 1.0)])); + + assert_eq!( + build_synthetic_idle_dem(&influence, noise) + .expect("single-axis Z family") + .to_string(), + "detector D0\ndetector D1\nerror(0.005) D1", + ); +} + +#[test] +fn zero_idle_family_rate_is_inactive_regardless_of_weights() { + let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); + let omitted = NoiseConfig::new(0.0, 0.0, 0.0, 0.0); + let configured = NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(IdleNoiseFamily::new( + 0.0, + BTreeMap::from([("invalid".to_string(), f64::NAN), ("X".to_string(), -1.0)]), + )); + + assert!(!configured.uses_dedicated_idle_noise()); + let probabilities = configured + .try_idle_memory_pauli_probs(1.0) + .expect("zero-rate family bypasses its map"); + assert_eq!(probabilities.px.to_bits(), 0.0_f64.to_bits()); + assert_eq!(probabilities.py.to_bits(), 0.0_f64.to_bits()); + assert_eq!(probabilities.pz.to_bits(), 0.0_f64.to_bits()); + assert_eq!( + build_synthetic_idle_dem(&influence, configured) + .expect("zero-rate family is inactive") + .to_string(), + build_synthetic_idle_dem(&influence, omitted) + .expect("omitted family") + .to_string(), + ); +} + +#[test] +fn idle_weight_map_insertion_order_does_not_affect_dem_text() { + let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); + let xyz = idle_family(0.005, [("X", 0.4), ("Y", 0.6), ("Z", 1.0)]); + let zyx = idle_family(0.005, [("Z", 1.0), ("Y", 0.6), ("X", 0.4)]); + + assert!(std::any::type_name_of_val(&xyz.weights).contains("BTreeMap")); + + assert_eq!( + build_synthetic_idle_dem( + &influence, + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(xyz), + ) + .expect("XYZ insertion order") + .to_string(), + build_synthetic_idle_dem( + &influence, + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(zyx), + ) + .expect("ZYX insertion order") + .to_string(), + ); +} + +#[test] +fn invalid_idle_family_rates_and_weights_keep_existing_errors() { + let cases = [ + ( + IdleNoiseFamily::new(-0.01, BTreeMap::from([("Z".to_string(), 1.0)])), + "invalid linear idle rate/model [X=0, Y=0, Z=-0.01]", + ), + ( + IdleNoiseFamily::new(f64::INFINITY, BTreeMap::from([("Z".to_string(), 1.0)])), + "invalid linear idle rate/model [X=0, Y=0, Z=inf]", + ), + ( + IdleNoiseFamily::new(0.01, BTreeMap::from([("X".to_string(), -1.0)])), + "invalid linear idle rate/model [X=-0.01, Y=0, Z=0]", + ), + ( + IdleNoiseFamily::new(0.01, BTreeMap::from([("X".to_string(), f64::NAN)])), + "invalid linear idle rate/model [X=NaN, Y=0, Z=0]", + ), + ( + IdleNoiseFamily::new( + -f64::MIN_POSITIVE, + BTreeMap::from([("X".to_string(), f64::MIN_POSITIVE)]), + ), + "invalid linear idle rate/model [X=-0, Y=0, Z=0]", + ), + ( + IdleNoiseFamily::new(f64::INFINITY, BTreeMap::from([("X".to_string(), 0.0)])), + "invalid linear idle rate/model [X=0, Y=0, Z=0]", + ), + ( + IdleNoiseFamily::new( + f64::MIN_POSITIVE, + BTreeMap::from([("X".to_string(), -f64::MIN_POSITIVE)]), + ), + "invalid linear idle rate/model [X=-0, Y=0, Z=0]", + ), + ]; + + for (family, expected) in cases { + let error = + build_tracked_idle_dem(NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(family)) + .expect_err("invalid family inputs must be rejected"); + assert!(error.contains(expected), "error={error:?}"); + assert!(error.contains("rates must be finite and non-negative")); + } +} + +#[test] +fn invalid_idle_weight_map_key_is_rejected() { + let family = IdleNoiseFamily::new(0.01, BTreeMap::from([("not-a-pauli".to_string(), 1.0)])); + let error = + build_tracked_idle_dem(NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(family)) + .expect_err("invalid family key must be rejected"); + + assert!(error.contains("invalid linear idle rate/model key \"not-a-pauli\"")); + assert!(error.contains("weights must use only X, Y, and Z")); +} + #[test] fn equal_idle_signatures_sum_exclusive_probabilities_before_dem_merging() { let influence = synthetic_idle_influence(&[0], &[], &[0]); - let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_pauli_linear_rates(0.25, 0.0, 0.75); + let noise = + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(axis_rate_family(0.25, 0.0, 0.75)); let dem = build_synthetic_idle_dem(&influence, noise) .expect("equal signatures need no independent conversion"); let contributions = idle_signature_contributions(&dem); @@ -452,7 +617,7 @@ fn equal_idle_signatures_sum_exclusive_probabilities_before_dem_merging() { let measurement_model = MemBuilder::new(&influence) .with_noise_config( - NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_pauli_linear_rates(0.25, 0.0, 0.75), + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(axis_rate_family(0.25, 0.0, 0.75)), ) .build(); assert_eq!(measurement_model.mechanisms.len(), 1); @@ -471,7 +636,8 @@ fn equal_idle_signatures_sum_exclusive_probabilities_before_dem_merging() { #[test] fn empty_idle_signature_is_dropped_before_conversion() { let influence = synthetic_idle_influence(&[], &[0], &[0]); - let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_pauli_linear_rates(0.25, 0.0, 0.75); + let noise = + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(axis_rate_family(0.25, 0.0, 0.75)); let dem = build_synthetic_idle_dem(&influence, noise) .expect("an undetectable X branch cannot obstruct the surviving Z branch"); let contributions = idle_signature_contributions(&dem); @@ -482,7 +648,7 @@ fn empty_idle_signature_is_dropped_before_conversion() { let measurement_model = MemBuilder::new(&influence) .with_noise_config( - NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_pauli_linear_rates(0.25, 0.0, 0.75), + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(axis_rate_family(0.25, 0.0, 0.75)), ) .build(); assert_eq!(measurement_model.mechanisms.len(), 1); @@ -503,7 +669,7 @@ fn biased_xz_idle_channel_builds_with_quantified_boundary_residual() { let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); let loc_idx = idle_location(&influence); let noise = - NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_pauli_linear_rates(0.0075, 0.0, 0.0225); + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(axis_rate_family(0.0075, 0.0, 0.0225)); let dem = build_synthetic_idle_dem(&influence, noise) .expect("ordinary biased X/Z idle noise must produce a usable DEM"); let mechanisms = idle_signature_contributions(&dem); @@ -546,7 +712,8 @@ fn three_distinct_idle_signatures_match_engines_pauli_channel() { let z_effect = raw_idle_signature(&influence, loc_idx, Pauli::Z); for [px, py, pz] in [[0.0025, 0.0025, 0.005], [0.002, 0.003, 0.005]] { - let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_pauli_linear_rates(px, py, pz); + let noise = + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(axis_rate_family(px, py, pz)); let dem = build_synthetic_idle_dem(&influence, noise) .expect("three-signature engines channel is exactly representable"); let distribution = independent_signature_distribution(&idle_signature_contributions(&dem)); @@ -601,8 +768,8 @@ fn linear_and_sine_idle_families_emit_separate_contributions() { let sine_rate: f64 = 0.2; let sine_probability = sine_rate.sin().powi(2); let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) - .set_idle_linear_rate(linear_probability) - .set_idle_quadratic_sine_rate(sine_rate); + .set_idle_linear(z_idle_family(linear_probability)) + .set_idle_quadratic_sine(z_idle_family(sine_rate)); let dem = build_tracked_idle_dem(noise).expect("valid composed-family DEM"); let mut z_probabilities = dem @@ -629,7 +796,7 @@ fn nonpositive_signature_channel_character_returns_specific_error() { let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); let error = build_synthetic_idle_dem( &influence, - NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_pauli_linear_rates(0.25, 0.0, 0.25), + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(axis_rate_family(0.25, 0.0, 0.25)), ) .expect_err("zero signature-channel characters are broken input"); @@ -641,9 +808,10 @@ fn nonpositive_signature_channel_character_returns_specific_error() { #[test] fn oversized_coefficient_quadratic_mechanism_returns_specific_error() { - let error = - build_tracked_idle_dem(NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_quadratic_rate(1.1)) - .expect_err("probabilities above one must not be clamped"); + let error = build_tracked_idle_dem( + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_quadratic(z_idle_family(1.1)), + ) + .expect_err("probabilities above one must not be clamped"); assert!(error.contains("coefficient-quadratic idle mechanism probabilities")); assert!(error.contains("Z=1.1")); @@ -652,9 +820,10 @@ fn oversized_coefficient_quadratic_mechanism_returns_specific_error() { #[test] fn negative_idle_rate_is_rejected_instead_of_clamped() { - let error = - build_tracked_idle_dem(NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear_rate(-0.01)) - .expect_err("negative rates must not be clamped"); + let error = build_tracked_idle_dem( + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(z_idle_family(-0.01)), + ) + .expect_err("negative rates must not be clamped"); assert!(error.contains("invalid linear idle rate/model [X=0, Y=0, Z=-0.01]")); assert!(error.contains("rates must be finite and non-negative")); @@ -667,7 +836,9 @@ fn negative_idle_duration_is_rejected_instead_of_clamped() { let loc_idx = idle_location(&influence); influence.locations[loc_idx].idle_duration = -1.0; let error = DemBuilder::new(&influence) - .with_noise_config(NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear_rate(0.01)) + .with_noise_config( + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(z_idle_family(0.01)), + ) .try_build() .expect_err("negative idle durations must not be clamped"); @@ -683,7 +854,7 @@ fn negative_idle_duration_is_rejected_instead_of_clamped() { fn identical_idle_configuration_produces_byte_identical_dem_text() { let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); let noise = - NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_pauli_linear_rates(0.002, 0.003, 0.005); + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(axis_rate_family(0.002, 0.003, 0.005)); let build = || { build_synthetic_idle_dem(&influence, noise.clone()) .expect("valid deterministic DEM") diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 4dd4ac056..cd1596af0 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -54,7 +54,7 @@ use pecos_qec::fault_tolerance::dem_builder::{ DemSampler as RustNewDemSampler, DemSamplerBuilder as RustNewDemSamplerBuilder, DetectorErrorModel as RustDetectorErrorModel, DirectSourceFamily as RustDirectSourceFamily, EquivalenceResult as RustEquivalenceResult, FaultContribution as RustFaultContribution, - FaultSourceType as RustFaultSourceType, MeasurementCrosstalkDemMode, + FaultSourceType as RustFaultSourceType, IdleNoiseFamily, MeasurementCrosstalkDemMode, MeasurementCrosstalkTransitionModel, NoiseConfig, PAULI_2Q_ORDER, ParsedDem as RustParsedDem, PauliWeights, ReplacementBranchApproximation, TwoDetectorDirectRenderPolicy as RustTwoDetectorDirectRenderPolicy, @@ -83,6 +83,20 @@ type PyDemFitResult = (Vec, Vec); /// Per-shot detector rows paired with per-shot observable/DEM-output rows. type PyDetectorObservableRows = (Vec>, Vec>); +fn idle_family_from_axis_rates(px: f64, py: f64, pz: f64) -> IdleNoiseFamily { + if px == 0.0 && py == 0.0 && pz == 0.0 { + return IdleNoiseFamily::default(); + } + IdleNoiseFamily::new( + 1.0, + BTreeMap::from([ + ("X".to_string(), px), + ("Y".to_string(), py), + ("Z".to_string(), pz), + ]), + ) +} + fn parse_p1_weights(weights: BTreeMap) -> PyResult { use pecos_core::pauli::{X, Y, Z}; @@ -383,42 +397,25 @@ fn apply_noise_options( if let Some(rz) = idle_rz { noise = noise.set_idle_rz(rz); } - if let Some(rate) = p_idle_linear_rate { - noise = noise.set_idle_linear_rate(rate); - } - if let Some(rate) = p_idle_quadratic_rate { - noise = noise.set_idle_quadratic_rate(rate); - } - if let Some(rate) = p_idle_x_linear_rate { - noise.p_idle_x_linear_rate = rate; - } - if let Some(rate) = p_idle_y_linear_rate { - noise.p_idle_y_linear_rate = rate; - } - if let Some(rate) = p_idle_z_linear_rate { - noise.p_idle_linear_rate = rate; - } - if let Some(rate) = p_idle_x_quadratic_rate { - noise.p_idle_x_quadratic_rate = rate; - } - if let Some(rate) = p_idle_y_quadratic_rate { - noise.p_idle_y_quadratic_rate = rate; - } - if let Some(rate) = p_idle_z_quadratic_rate { - noise.p_idle_quadratic_rate = rate; - } - if let Some(rate) = p_idle_quadratic_sine_rate { - noise = noise.set_idle_quadratic_sine_rate(rate); - } - if let Some(rate) = p_idle_x_quadratic_sine_rate { - noise.p_idle_x_quadratic_sine_rate = rate; - } - if let Some(rate) = p_idle_y_quadratic_sine_rate { - noise.p_idle_y_quadratic_sine_rate = rate; - } - if let Some(rate) = p_idle_z_quadratic_sine_rate { - noise.p_idle_quadratic_sine_rate = rate; - } + noise.p_idle_linear = idle_family_from_axis_rates( + p_idle_x_linear_rate.unwrap_or(0.0), + p_idle_y_linear_rate.unwrap_or(0.0), + p_idle_z_linear_rate.or(p_idle_linear_rate).unwrap_or(0.0), + ); + noise.p_idle_quadratic = idle_family_from_axis_rates( + p_idle_x_quadratic_rate.unwrap_or(0.0), + p_idle_y_quadratic_rate.unwrap_or(0.0), + p_idle_z_quadratic_rate + .or(p_idle_quadratic_rate) + .unwrap_or(0.0), + ); + noise.p_idle_quadratic_sine = idle_family_from_axis_rates( + p_idle_x_quadratic_sine_rate.unwrap_or(0.0), + p_idle_y_quadratic_sine_rate.unwrap_or(0.0), + p_idle_z_quadratic_sine_rate + .or(p_idle_quadratic_sine_rate) + .unwrap_or(0.0), + ); if let Some(weights) = p1_weights { noise = noise.set_p1_weights(parse_p1_weights(weights)?); } 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 082cd93dd..c7b3d3e08 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -231,6 +231,34 @@ def _structured_idle_dem(entrypoint: str, **kwargs): ).dem +def test_all_idle_laws_match_pre_rust_family_dem_bytes() -> None: + actual = _structured_idle_dem( + "from_guppy", + idle_after_2q_duration=2.0, + p_idle_x_linear_rate=0.002, + p_idle_y_linear_rate=0.003, + p_idle_z_linear_rate=0.005, + p_idle_x_quadratic_rate=0.0001, + p_idle_y_quadratic_rate=0.0002, + p_idle_z_quadratic_rate=0.0003, + p_idle_x_quadratic_sine_rate=0.01, + p_idle_y_quadratic_sine_rate=0.02, + p_idle_z_quadratic_sine_rate=0.03, + ).to_string() + + assert actual == "detector D0\nlogical_observable L0\nerror(0.013129) L0\nerror(0.019423) D0" + + +def test_z_linear_family_matches_pre_removed_axis_dem_bytes() -> None: + actual = _structured_idle_dem( + "from_guppy", + idle_after_2q_duration=2.0, + p_idle_z_linear_rate=0.005, + ).to_string() + + assert actual == "detector D0\nlogical_observable L0\nerror(0.01) D0" + + def test_guppy_dem_entrypoints_do_not_expose_p_idle_shorthand() -> None: assert "p_idle" not in inspect.signature(DetectorErrorModel.from_guppy).parameters assert "p_idle" not in inspect.signature(build_dem_from_guppy).parameters From 57d7855507ff141cfa1e62ceb727e32fe1ac9445 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 5 Aug 2026 19:55:50 -0600 Subject: [PATCH 45/62] Add a relative residual-warning threshold to the DEM builder --- .../fault_tolerance/dem_builder/builder.rs | 6 + .../dem_builder/dem_sampler.rs | 10 ++ .../src/fault_tolerance/dem_builder/types.rs | 55 +++++- .../tests/gate_channel_conversion_tests.rs | 5 + crates/pecos-qec/tests/idle_noise_tests.rs | 37 +++- docs/user-guide/dem-from-guppy.md | 26 ++- .../src/fault_tolerance_bindings.rs | 7 +- python/quantum-pecos/src/pecos/qec/dem.py | 61 ++++++- .../tests/qec/test_from_guppy_dem.py | 170 +++++++++++++++++- 9 files changed, 349 insertions(+), 28 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 7d6040c75..a1432edfd 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -1906,6 +1906,7 @@ impl<'a> DemBuilder<'a> { let loc = &self.influence_map.locations[loc_idx]; for (family_index, probabilities) in families.exclusive.into_iter().enumerate() { + let channel_weight = probabilities.total(); let mut exclusive = BTreeMap::new(); for (effect, probability) in [ (x_effect.clone(), probabilities.px), @@ -1937,6 +1938,7 @@ impl<'a> DemBuilder<'a> { channel_kind: NoiseChannelKind::Idle, effect, magnitude, + channel_weight, }); } } @@ -1989,6 +1991,7 @@ impl<'a> DemBuilder<'a> { let context = format!("one-qubit {} gate at location {loc_idx}", loc.gate_type); validate_exclusive_probabilities(&rates, &context) .map_err(|error| DemBuilderError::ConfigurationError(error.to_string()))?; + let channel_weight = rates.iter().sum(); let x_effect = self.compute_mechanism(loc_idx, Pauli::X, meas_to_detectors, meas_to_observables); let y_effect = @@ -2022,6 +2025,7 @@ impl<'a> DemBuilder<'a> { channel_kind: NoiseChannelKind::SingleQubitGate, effect, magnitude, + channel_weight, }); } Ok(()) @@ -2045,6 +2049,7 @@ impl<'a> DemBuilder<'a> { ); validate_exclusive_probabilities(&rates, &context) .map_err(|error| DemBuilderError::ConfigurationError(error.to_string()))?; + let channel_weight = rates.iter().sum(); let effects = self.two_qubit_effect_table(loc1, loc2, meas_to_detectors, meas_to_observables); @@ -2086,6 +2091,7 @@ impl<'a> DemBuilder<'a> { channel_kind: NoiseChannelKind::TwoQubitGate, effect, magnitude, + channel_weight, }); } Ok(()) 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 74dc0d00b..5fb3d82e2 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 @@ -531,6 +531,7 @@ impl SamplingEngine { .expect("idle gate location must have a fault location"); for (family_index, probabilities) in families.exclusive.into_iter().enumerate() { + let channel_weight = probabilities.total(); let mut exclusive = BTreeMap::new(); for (mechanism, probability) in [ (x.clone(), probabilities.px), @@ -562,6 +563,7 @@ impl SamplingEngine { channel_kind: NoiseChannelKind::Idle, effect: mechanism.as_fault_mechanism(), magnitude, + channel_weight, }); } } @@ -635,6 +637,7 @@ impl SamplingEngine { ); validate_exclusive_probabilities(&event_weights, &context) .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); + let channel_weight = event_weights.iter().sum(); let mut exclusive = BTreeMap::new(); for (event, &event_prob) in events.iter().zip(&event_weights) { let det_indices: SmallVec<[u32; 4]> = event.detectors.iter().copied().collect(); @@ -664,6 +667,7 @@ impl SamplingEngine { channel_kind, effect: mechanism.as_fault_mechanism(), magnitude, + channel_weight, }); } } @@ -2610,6 +2614,7 @@ impl<'a> SamplingEngineBuilder<'a> { let fit_context = format!("one-qubit {gate_type} gate at location {loc_idx}"); validate_exclusive_probabilities(&rates, &fit_context) .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); + let channel_weight = rates.iter().sum(); let mut exclusive = BTreeMap::new(); for (pauli, &per_pauli_prob) in [Pauli::X, Pauli::Y, Pauli::Z].iter().zip(rates.iter()) { let mechanism = self.compute_mechanism( @@ -2639,6 +2644,7 @@ impl<'a> SamplingEngineBuilder<'a> { channel_kind: NoiseChannelKind::SingleQubitGate, effect: mechanism.as_fault_mechanism(), magnitude, + channel_weight, }); } } @@ -2687,6 +2693,7 @@ impl<'a> SamplingEngineBuilder<'a> { }; for (family_index, probabilities) in families.exclusive.into_iter().enumerate() { + let channel_weight = probabilities.total(); let mut exclusive = BTreeMap::new(); for (mechanism, probability) in [ (x_mechanism.clone(), probabilities.px), @@ -2711,6 +2718,7 @@ impl<'a> SamplingEngineBuilder<'a> { channel_kind: NoiseChannelKind::Idle, effect: mechanism.as_fault_mechanism(), magnitude, + channel_weight, }); } } @@ -2740,6 +2748,7 @@ impl<'a> SamplingEngineBuilder<'a> { let fit_context = format!("two-qubit {gate_type} gate at locations {loc1} and {loc2}"); validate_exclusive_probabilities(&rates, &fit_context) .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); + let channel_weight = rates.iter().sum(); let paulis = [Pauli::I, Pauli::X, Pauli::Y, Pauli::Z]; let mut effects1: [Option; 4] = [None, None, None, None]; @@ -2807,6 +2816,7 @@ impl<'a> SamplingEngineBuilder<'a> { channel_kind: NoiseChannelKind::TwoQubitGate, effect: mechanism.as_fault_mechanism(), magnitude, + channel_weight, }); } } diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index 9001b7ef7..80959a07c 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -2732,6 +2732,25 @@ pub struct NoiseChannelResidual { pub effect: FaultMechanism, /// Total-variation distance from the requested categorical channel. pub magnitude: f64, + /// Total non-identity probability of the requested categorical channel. + pub channel_weight: f64, +} + +impl NoiseChannelResidual { + /// Returns the residual as a fraction of the requested channel's error weight. + /// + /// # Panics + /// + /// Panics if `channel_weight` is not finite and positive. Such a channel + /// cannot produce a residual, so this indicates a broken construction invariant. + #[must_use] + pub fn relative_magnitude(&self) -> f64 { + assert!( + self.channel_weight.is_finite() && self.channel_weight > 0.0, + "noise-channel residual invariant violated: channel_weight must be finite and positive" + ); + self.magnitude / self.channel_weight + } } #[derive(Debug, Clone)] @@ -4830,7 +4849,8 @@ impl DetectorErrorModel { /// Returns every quantified categorical-channel approximation made during build. /// /// Each record identifies the channel kind, a representative concrete flip - /// signature, and the channel's total-variation residual magnitude. + /// signature, the requested channel's total error weight, and the absolute + /// and relative total-variation residual magnitudes. /// An empty slice means every categorical conversion was exact. #[inline] #[must_use] @@ -7004,6 +7024,39 @@ fn trim_trailing_zeros(s: &str) -> String { #[cfg(test)] mod tests { + fn residual_with_channel_weight(channel_weight: f64) -> NoiseChannelResidual { + NoiseChannelResidual { + location_index: 0, + channel_kind: NoiseChannelKind::Idle, + effect: FaultMechanism::new(), + magnitude: 0.002, + channel_weight, + } + } + + #[test] + fn noise_channel_residual_reports_relative_magnitude() { + assert_eq!( + residual_with_channel_weight(0.02) + .relative_magnitude() + .to_bits(), + 0.1_f64.to_bits() + ); + } + + #[test] + fn noise_channel_residual_rejects_invalid_channel_weight() { + for channel_weight in [0.0, -0.01, f64::INFINITY, f64::NEG_INFINITY, f64::NAN] { + let result = std::panic::catch_unwind(|| { + residual_with_channel_weight(channel_weight).relative_magnitude() + }); + assert!( + result.is_err(), + "channel weight {channel_weight:?} must violate the residual invariant" + ); + } + } + /// The single-qubit gate channel needs conversion too, at its own scale. /// /// Three Paulis at `p1/3` with distinct signatures. The expected value was computed diff --git a/crates/pecos-qec/tests/gate_channel_conversion_tests.rs b/crates/pecos-qec/tests/gate_channel_conversion_tests.rs index d764d1e00..2c69b33a2 100644 --- a/crates/pecos-qec/tests/gate_channel_conversion_tests.rs +++ b/crates/pecos-qec/tests/gate_channel_conversion_tests.rs @@ -304,7 +304,12 @@ fn infeasible_gate_channel_reports_kind_and_queryable_magnitude() { }; assert_eq!(residual.channel_kind, NoiseChannelKind::SingleQubitGate); assert_eq!(fault_mechanism_mask(&residual.effect), 0b11); + assert_eq!(residual.channel_weight.to_bits(), 0.03_f64.to_bits()); assert!(residual.magnitude > 0.0); + assert_eq!( + residual.relative_magnitude().to_bits(), + (residual.magnitude / 0.03).to_bits() + ); let mechanisms = gate_signature_mechanisms(&dem); let distribution = compose_independent_mechanisms( mechanisms diff --git a/crates/pecos-qec/tests/idle_noise_tests.rs b/crates/pecos-qec/tests/idle_noise_tests.rs index 9dbbbdda7..6d0292010 100644 --- a/crates/pecos-qec/tests/idle_noise_tests.rs +++ b/crates/pecos-qec/tests/idle_noise_tests.rs @@ -18,10 +18,10 @@ use pecos_core::pauli::{X, Y, Z}; use pecos_core::{QubitId, TimeUnits}; use pecos_qec::fault_tolerance::dem_builder::{ DemBuilder, DemSamplerBuilder, DetectorErrorModel, FaultMechanism, IdleNoiseFamily, MemBuilder, - NoiseConfig, PauliProbs, PerGateTypeNoise, combine_probabilities, + NoiseConfig, PauliProbs, PerGateTypeNoise, SamplingEngine, combine_probabilities, }; use pecos_qec::fault_tolerance::propagator::{ - DagFaultAnalyzer, DagFaultInfluenceMap, DagSpacetimeLocation, Pauli, + DagFaultAnalyzer, DagFaultInfluenceMap, DagSpacetimeLocation, DetectorId, MeasurementId, Pauli, }; use pecos_quantum::{DagCircuit, GateType}; use std::collections::BTreeMap; @@ -670,6 +670,22 @@ fn biased_xz_idle_channel_builds_with_quantified_boundary_residual() { let loc_idx = idle_location(&influence); let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(axis_rate_family(0.0075, 0.0, 0.0225)); + let legacy_sampling_engine = SamplingEngine::from_influence_map(&influence, &[1.0], &noise); + let mut sampler_influence = influence.clone(); + sampler_influence + .detectors + .push(DetectorId::single(MeasurementId { + tick: 0, + qubit: 0, + basis: 0, + })); + let sampler = DemSamplerBuilder::new(&sampler_influence) + .with_noise_config(noise.clone()) + .with_detectors_json(r#"[{"id": 0, "records": [-2]}, {"id": 1, "records": [-1]}]"#) + .expect("valid detector metadata") + .build() + .expect("valid detector sampler"); + let sampler_dem = sampler.to_detector_error_model(); let dem = build_synthetic_idle_dem(&influence, noise) .expect("ordinary biased X/Z idle noise must produce a usable DEM"); let mechanisms = idle_signature_contributions(&dem); @@ -687,7 +703,24 @@ fn biased_xz_idle_channel_builds_with_quantified_boundary_residual() { panic!("the infeasible two-signature channel must report one residual") }; assert_eq!(residual.effect, y_effect); + assert_eq!(residual.channel_weight.to_bits(), 0.03_f64.to_bits()); assert!((distribution[&y_effect] - residual.magnitude).abs() < 1e-12); + for sampler_residuals in [ + legacy_sampling_engine.idle_noise_residuals(), + sampler_dem.idle_noise_residuals(), + ] { + let [sampler_residual] = sampler_residuals else { + panic!("each sampler path must retain the idle-channel residual") + }; + assert_eq!( + sampler_residual.channel_weight.to_bits(), + 0.03_f64.to_bits() + ); + assert_eq!( + sampler_residual.relative_magnitude().to_bits(), + residual.relative_magnitude().to_bits() + ); + } let qx = mechanisms .iter() .find_map(|(effect, probability)| (effect == &x_effect).then_some(*probability)) diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index 491cdc241..ede099c93 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -321,15 +321,29 @@ not directly interchangeable with these parameters. Every residual is readable from `dem.idle_noise_residuals` as a dictionary containing `channel_kind`, `location_index`, the concrete -`detectors`/`dem_outputs`/`tracked_paulis` signature, and `magnitude`. Gate and -idle categorical Pauli channels share this list. The magnitude is the -total-variation distance between the requested categorical channel and the -emitted independent mechanisms; in the two-dimensional boundary case it is -also the excess on the reported signature and the matching identity deficit. -Audited Guppy builds copy this list to +`detectors`/`dem_outputs`/`tracked_paulis` signature, `magnitude`, +`channel_weight`, and `relative_magnitude`. Gate and idle categorical Pauli +channels share this list. The channel weight is the sum of the requested +channel's non-identity probabilities before conversion, including branches +whose propagated signature is empty. The magnitude is the total-variation +distance between the requested categorical channel and the emitted independent +mechanisms, and `relative_magnitude = magnitude / channel_weight`; in the +two-dimensional boundary case the absolute magnitude is also the excess on the +reported signature and the matching identity deficit. Audited Guppy builds +copy this list to `dem_build.audit["idle_noise_residuals"]`. An empty list certifies that all categorical signature conversions were exact. +`DetectorErrorModel.builder().with_residual_warning_threshold(fraction)` sets +a relative physics tolerance. For example, `fraction=0.002` accepts an inexact +conversion whose total-variation residual is at most 0.2% of that requested +channel's total error weight. The default is zero, and a build warns when any +residual is greater than the accepted fraction. The threshold gates only that +warning: every exact figure remains in `dem.idle_noise_residuals` and the audit +entry regardless of the tolerance. To suppress warnings wholesale, use +`warnings.filterwarnings`; the builder setter encodes an accepted channel +approximation, not a blanket quiet mode. + The families are the only public way to configure these idle channels on `NoiseParameters`. They translate into underscore-prefixed canonical per-axis fields internally; those fields are implementation details consumed by the diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index cd1596af0..b1f0a1de6 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -1759,8 +1759,9 @@ impl PyDetectorErrorModel { /// Quantified residuals from infeasible categorical-to-independent conversions. /// /// Each dictionary reports the channel kind, fault location, representative - /// flip signature, and total-variation magnitude. An empty list means every - /// categorical conversion was exact. + /// flip signature, total-variation magnitude, requested channel weight, and + /// their relative magnitude. An empty list means every categorical conversion + /// was exact. #[getter] fn idle_noise_residuals(&self, py: Python<'_>) -> PyResult>> { self.inner @@ -1774,6 +1775,8 @@ impl PyDetectorErrorModel { dict.set_item("dem_outputs", residual.effect.dem_outputs.to_vec())?; dict.set_item("tracked_paulis", residual.effect.tracked_paulis.to_vec())?; dict.set_item("magnitude", residual.magnitude)?; + dict.set_item("channel_weight", residual.channel_weight)?; + dict.set_item("relative_magnitude", residual.relative_magnitude())?; Ok(dict.unbind()) }) .collect() diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 4852bc967..04594fc0c 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -950,6 +950,7 @@ class GuppyDemBuilder: "_program", "_qubits", "_require_hosted_operation_order", + "_residual_warning_threshold", "_runtime", "_seed", "_strip_traced_idles", @@ -969,6 +970,7 @@ def __init__(self) -> None: self._strip_traced_idles: Any = _UNSET self._runtime: Any = _UNSET self._seed: Any = _UNSET + self._residual_warning_threshold: Any = _UNSET self._require_hosted_operation_order: Any = _UNSET self._max_hosted_tick_separation: Any = _UNSET @@ -1073,6 +1075,39 @@ def with_seed(self, seed: int) -> Self: self._set_once("_seed", seed, "with_seed") return self + def with_residual_warning_threshold(self, fraction: float) -> Self: + """Accept channel-conversion residuals up to a relative physics tolerance. + + ``fraction`` is a fraction of each requested channel's total error + weight, not an absolute probability. At or below this tolerance the + channel remains an accepted inexact conversion, with the exact figures + retained in ``dem.idle_noise_residuals`` and the build audit. The default + is ``0.0``, so every nonzero residual warns. + + Use :func:`warnings.filterwarnings` when the intent is to silence a + warning category wholesale; this setter records a physics tolerance. + """ + try: + finite = math.isfinite(fraction) + except (TypeError, ValueError): + finite = False + if not finite or not isinstance(fraction, (int, float)) or fraction < 0.0: + msg = ( + "with_residual_warning_threshold() requires a finite fraction of " + "the channel's total error weight in [0.0, 1.0]; " + f"got {fraction!r}" + ) + raise ValueError(msg) + if fraction > 1.0: + msg = ( + "with_residual_warning_threshold() is a fraction of the channel's " + "total error weight in [0.0, 1.0], not an absolute probability; " + f"got {fraction!r}" + ) + raise ValueError(msg) + self._set_once("_residual_warning_threshold", float(fraction), "with_residual_warning_threshold") + return self + def with_require_hosted_operation_order(self, flag: bool) -> Self: """Choose whether hosted-operation ordering is validated.""" self._set_once("_require_hosted_operation_order", flag, "with_require_hosted_operation_order") @@ -1293,7 +1328,10 @@ def build(self) -> GuppyDemBuild: named_result_binding = "compiler_direct_scalar_partial" else: named_result_binding = "compiler_direct_scalar_complete" - _warn_on_noise_channel_residuals(dem) + residual_warning_threshold = ( + 0.0 if self._residual_warning_threshold is _UNSET else self._residual_warning_threshold + ) + _warn_on_noise_channel_residuals(dem, residual_warning_threshold) return GuppyDemBuild( dem=dem, circuit=circuit, @@ -1308,22 +1346,27 @@ def build(self) -> GuppyDemBuild: ) -def _warn_on_noise_channel_residuals(dem: DetectorErrorModel) -> None: - """Warn when a categorical Pauli channel could not be represented exactly.""" - residuals = dem.idle_noise_residuals +def _warn_on_noise_channel_residuals(dem: DetectorErrorModel, relative_threshold: float = 0.0) -> None: + """Warn about channel residuals above the accepted relative tolerance.""" + residuals = [entry for entry in dem.idle_noise_residuals if float(entry["relative_magnitude"]) > relative_threshold] if not residuals: return - by_kind: dict[str, list[float]] = {} + by_kind: dict[str, list[tuple[float, float]]] = {} for entry in residuals: kind = str(entry["channel_kind"]) - by_kind.setdefault(kind, []).append(float(entry["magnitude"])) + by_kind.setdefault(kind, []).append( + (float(entry["relative_magnitude"]), float(entry["magnitude"])), + ) kinds = ", ".join( - f"{len(magnitudes)} {kind} (largest {max(magnitudes):.3e})" for kind, magnitudes in sorted(by_kind.items()) + f"{len(magnitudes)} {kind} (largest relative {max(value[0] for value in magnitudes):.3e}; " + f"largest TV {max(value[1] for value in magnitudes):.3e})" + for kind, magnitudes in sorted(by_kind.items()) ) warnings.warn( f"{len(residuals)} categorical noise channel(s) were approximated: {kinds}. " - "A non-negative boundary fit was emitted; magnitudes are total-variation " - "distances from the requested channels. See dem.idle_noise_residuals for details.", + "A non-negative boundary fit was emitted; relative magnitudes are fractions " + "of each requested channel's total error weight, and TV magnitudes are " + "total-variation distances. See dem.idle_noise_residuals for details.", UserWarning, stacklevel=3, ) 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 c7b3d3e08..91f69d328 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -5,6 +5,7 @@ import inspect import json +import warnings from typing import ClassVar import pytest @@ -304,9 +305,9 @@ def test_noise_model_matches_flat_pauli_weights(entrypoint: str) -> None: "p_prep": 0.013, } - with pytest.warns(UserWarning, match=r"two-qubit gate \(largest 1\.184e-05\)"): + with pytest.warns(UserWarning, match=r"two-qubit gate .*largest TV 1\.184e-05"): grouped = _noise_model_entrypoint_dem(entrypoint, noise=NoiseParameters(**noise_kwargs)) - with pytest.warns(UserWarning, match=r"two-qubit gate \(largest 1\.184e-05\)"): + with pytest.warns(UserWarning, match=r"two-qubit gate .*largest TV 1\.184e-05"): flat = _noise_model_entrypoint_dem(entrypoint, **noise_kwargs) assert grouped.to_string() == flat.to_string() @@ -315,6 +316,11 @@ def test_noise_model_matches_flat_pauli_weights(entrypoint: str) -> None: residual = grouped.idle_noise_residuals[0] assert residual["channel_kind"] == "two-qubit gate" assert residual["magnitude"] == pytest.approx(1.1843041548472428e-05) + assert residual["channel_weight"] == pytest.approx(0.007) + assert residual["relative_magnitude"] == pytest.approx(0.001691863078353204) + assert residual["relative_magnitude"] == pytest.approx( + residual["magnitude"] / residual["channel_weight"], + ) @pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) @@ -2241,9 +2247,6 @@ def test_noise_channel_residual_warning_names_kinds_and_magnitudes() -> None: The residual is queryable on the DEM, but a field alone is easy to miss, so the build also warns when it emits the non-negative boundary fit. """ - import warnings - from typing import ClassVar - from pecos.qec.dem import _warn_on_noise_channel_residuals class _Exact: @@ -2251,11 +2254,19 @@ class _Exact: class _Approximated: idle_noise_residuals: ClassVar[list[dict[str, object]]] = [ - {"location_index": 3, "channel_kind": "idle", "magnitude": 1.894e-05}, + { + "location_index": 3, + "channel_kind": "idle", + "magnitude": 1.894e-05, + "channel_weight": 0.01, + "relative_magnitude": 1.894e-03, + }, { "location_index": 7, "channel_kind": "one-qubit gate", "magnitude": 2.1e-05, + "channel_weight": 0.1, + "relative_magnitude": 2.1e-04, }, ] @@ -2270,12 +2281,155 @@ class _Approximated: assert len(caught) == 1 message = str(caught[0].message) assert "2 categorical noise channel(s) were approximated" in message - assert "1 idle (largest 1.894e-05)" in message - assert "1 one-qubit gate (largest 2.100e-05)" in message + assert "1 idle (largest relative 1.894e-03; largest TV 1.894e-05)" in message + assert "1 one-qubit gate (largest relative 2.100e-04; largest TV 2.100e-05)" in message assert "2.100e-05" in message + assert "fractions of each requested channel's total error weight" in message assert "total-variation distances" in message assert "dem.idle_noise_residuals" in message + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _warn_on_noise_channel_residuals(_Approximated(), 0.001) + assert len(caught) == 1 + message = str(caught[0].message) + assert "1 categorical noise channel(s) were approximated" in message + assert "1 idle" in message + assert "one-qubit gate" not in message + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _warn_on_noise_channel_residuals(_Approximated(), 1.894e-03) + assert caught == [] + + +def _approximated_gate_build( + *, + p2: float = 0.007, + p2_weights: dict[str, float] | None = None, + residual_warning_threshold: float | None = None, +): + weights = {"IX": 0.4, "XI": 0.6} if p2_weights is None else p2_weights + builder = ( + DetectorErrorModel.builder() + .with_program(_structured_idle_noise_target) + .with_qubits(2) + .with_detectors([Detector(rec[-2])]) + .with_observables([Observable(rec[-1])]) + .with_noise( + NoiseParameters( + p1=0.0, + p2=p2, + p2_weights=weights, + p_meas=0.0, + p_prep=0.0, + ), + ) + ) + if residual_warning_threshold is not None: + builder.with_residual_warning_threshold(residual_warning_threshold) + return builder.build() + + +def test_residual_warning_threshold_defaults_to_zero() -> None: + with pytest.warns(UserWarning, match="1 categorical noise channel"): + build = _approximated_gate_build() + + assert len(build.dem.idle_noise_residuals) == 1 + + +def test_residual_warning_threshold_above_relative_magnitude_is_quiet() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + build = _approximated_gate_build(residual_warning_threshold=0.002) + + assert build.dem.idle_noise_residuals[0]["relative_magnitude"] < 0.002 + + +def test_residual_warning_threshold_below_relative_magnitude_still_warns() -> None: + with pytest.warns(UserWarning, match=r"largest relative 1\.692e-03"): + build = _approximated_gate_build(residual_warning_threshold=0.001) + + assert build.dem.idle_noise_residuals[0]["relative_magnitude"] > 0.001 + + +def test_residual_warning_threshold_never_filters_residual_data() -> None: + with pytest.warns(UserWarning, match="1 categorical noise channel"): + default_build = _approximated_gate_build() + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + tolerant_build = _approximated_gate_build(residual_warning_threshold=0.002) + + default_residuals = default_build.dem.idle_noise_residuals + tolerant_residuals = tolerant_build.dem.idle_noise_residuals + default_audit_residuals = default_build.audit["idle_noise_residuals"] + tolerant_audit_residuals = tolerant_build.audit["idle_noise_residuals"] + + assert len(default_residuals) == 1 + assert tolerant_residuals == default_residuals + assert tolerant_audit_residuals == default_audit_residuals + assert tolerant_audit_residuals == tolerant_residuals + assert default_audit_residuals == default_residuals + + def encode(residuals: list[dict[str, object]]) -> bytes: + return json.dumps( + residuals, + sort_keys=True, + separators=(",", ":"), + ).encode() + + assert encode(tolerant_residuals) == encode(default_residuals) + assert encode(tolerant_audit_residuals) == encode(default_audit_residuals) + + +def test_relative_residual_threshold_is_portable_across_channel_weights() -> None: + target_relative_magnitude = 0.0002502503129381573 + configurations = [ + (0.001, {"IX": 0.5, "XI": 0.5}), + (0.1, {"IX": 0.002257285529184556, "XI": 0.9977427144708154}), + ] + observed_weights = [] + observed_relative_magnitudes = [] + + for p2, p2_weights in configurations: + with pytest.warns(UserWarning, match=r"largest relative 2\.503e-04"): + warned_build = _approximated_gate_build( + p2=p2, + p2_weights=p2_weights, + residual_warning_threshold=0.0002, + ) + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + quiet_build = _approximated_gate_build( + p2=p2, + p2_weights=p2_weights, + residual_warning_threshold=0.0003, + ) + + assert quiet_build.dem.idle_noise_residuals == warned_build.dem.idle_noise_residuals + residual = quiet_build.dem.idle_noise_residuals[0] + observed_weights.append(residual["channel_weight"]) + observed_relative_magnitudes.append(residual["relative_magnitude"]) + + assert observed_weights == pytest.approx([0.001, 0.1]) + assert observed_relative_magnitudes == pytest.approx( + [target_relative_magnitude, target_relative_magnitude], + abs=1e-15, + ) + + +@pytest.mark.parametrize("fraction", [-0.1, float("nan"), float("inf"), float("-inf")]) +def test_residual_warning_threshold_rejects_invalid_fraction(fraction: float) -> None: + with pytest.raises(ValueError, match="fraction of the channel's total error weight"): + DetectorErrorModel.builder().with_residual_warning_threshold(fraction) + + +def test_residual_warning_threshold_rejects_values_above_one_as_absolute() -> None: + with pytest.raises(ValueError, match="not an absolute probability") as exc_info: + DetectorErrorModel.builder().with_residual_warning_threshold(1.01) + + assert "fraction of the channel's total error weight" in str(exc_info.value) + @guppy def _two_qubit_gate_channel_program() -> None: From dceed5d0e5782f99bccf12703e7a9f85eb959b6c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 5 Aug 2026 21:31:00 -0600 Subject: [PATCH 46/62] Derive T1/T2 idle noise from the first-order Pauli twirl --- .../docs/user-guides/noise-channels.md | 7 +- exp/pecos-neo/examples/noise_models.rs | 10 +- exp/pecos-neo/src/noise/composer.rs | 14 +- exp/pecos-neo/src/noise/composite/builder.rs | 44 ++-- exp/pecos-neo/src/noise/general_builder.rs | 64 +++-- exp/pecos-neo/src/noise/idle.rs | 218 +++++++++++++++++- exp/pecos-neo/tests/noise_comparison_test.rs | 26 +-- 7 files changed, 316 insertions(+), 67 deletions(-) diff --git a/exp/pecos-neo/docs/user-guides/noise-channels.md b/exp/pecos-neo/docs/user-guides/noise-channels.md index 9e1909e9d..8d5baa466 100644 --- a/exp/pecos-neo/docs/user-guides/noise-channels.md +++ b/exp/pecos-neo/docs/user-guides/noise-channels.md @@ -82,9 +82,14 @@ T1/T2 decay during idle periods. Rate scales with duration. IdleChannel::linear(0.0001) // Rate per time unit .with_linear_depolarizing() // Uniform X/Y/Z -IdleChannel::from_t1_t2(50e-6, 30e-6) // Physical T1/T2 times +// T1=50us and total T2=30us when one abstract time unit is 1ns +IdleChannel::from_t1_t2(50_000.0, 30_000.0) ``` +`from_t1_t2` uses the first-order Pauli twirl, requires total `T2 <= 2 * T1`, and is valid for +idle durations much shorter than both coherence times. Use +`ComposableNoiseModel::with_idle_t1_t2` with a `TimeScale` when supplying physical seconds. + ## Specialized Channels For more specific noise scenarios. diff --git a/exp/pecos-neo/examples/noise_models.rs b/exp/pecos-neo/examples/noise_models.rs index d936ee7a3..ff4c04ccf 100644 --- a/exp/pecos-neo/examples/noise_models.rs +++ b/exp/pecos-neo/examples/noise_models.rs @@ -256,13 +256,13 @@ fn example_builder_api() { fn example_idle_noise() { println!("--- Idle Noise (T1/T2 Decoherence) ---"); - // Circuit: prep |+⟩, idle, then H to detect Z errors, measure - // Z errors during idle will flip the measurement outcome + // Circuit: prep |+⟩, idle, then H to detect Pauli-twirled Y/Z errors, measure + // Y/Z errors during idle will flip the measurement outcome let commands = CommandBuilder::new() .pz(&[0]) .h(&[0]) // Prepare |+⟩ .idle(&[0], 1000) // Idle for 1000 time units - .h(&[0]) // Convert Z errors to bit flips + .h(&[0]) // Convert Y/Z phase changes to measurement flips .mz(&[0]) .build(); @@ -285,7 +285,7 @@ fn example_idle_noise() { state.reset(); let outcomes = runner.apply_circuit(&mut state, &commands).unwrap(); if outcomes.get_bit(QubitId(0)).unwrap_or(false) { - errors += 1; // Z error during idle caused bit flip + errors += 1; // Y/Z error during idle caused bit flip } } @@ -295,7 +295,7 @@ fn example_idle_noise() { " Measured decoherence error rate: {:.1}%", error_rate * 100.0 ); - println!(" (Linear/T1 contribution expected: ~10%)"); + println!(" (First-order total-T2 coherence error expected: ~10%)"); println!(); } diff --git a/exp/pecos-neo/src/noise/composer.rs b/exp/pecos-neo/src/noise/composer.rs index 5176f23ea..8e983cad6 100644 --- a/exp/pecos-neo/src/noise/composer.rs +++ b/exp/pecos-neo/src/noise/composer.rs @@ -310,14 +310,17 @@ impl ComposableNoiseModel { /// Add an idle channel with T1/T2 times in physical units. /// - /// Requires `with_time_scale()` to be called first. + /// Requires `with_time_scale()` to be called first. T2 is total transverse coherence time, + /// not pure-dephasing Tphi. The first-order Pauli-twirl mapping, physical bound, validity + /// domain, and numerical compatibility note are documented by [`IdleChannel::from_t1_t2`]. /// /// # Arguments /// * `t1_seconds` - T1 relaxation time in seconds - /// * `t2_seconds` - T2 dephasing time in seconds + /// * `t2_seconds` - Total T2 transverse coherence time in seconds /// /// # Panics - /// Panics if `with_time_scale()` has not been called. + /// Panics if `with_time_scale()` has not been called, if either time is non-finite or not + /// greater than zero, or if `t2_seconds > 2 * t1_seconds`. /// /// # Example /// ``` @@ -333,10 +336,7 @@ impl ComposableNoiseModel { let scale = self .time_scale .expect("with_time_scale() must be called before with_idle_t1_t2()"); - // Convert physical times to time units - let t1_units = scale.from_seconds(t1_seconds).as_f64(); - let t2_units = scale.from_seconds(t2_seconds).as_f64(); - let channel = IdleChannel::from_t1_t2(t1_units, t2_units); + let channel = IdleChannel::from_t1_t2_seconds(t1_seconds, t2_seconds, scale); self.add_channel(channel) } diff --git a/exp/pecos-neo/src/noise/composite/builder.rs b/exp/pecos-neo/src/noise/composite/builder.rs index 08a4a738b..ac8662a6e 100644 --- a/exp/pecos-neo/src/noise/composite/builder.rs +++ b/exp/pecos-neo/src/noise/composite/builder.rs @@ -707,15 +707,16 @@ impl CompositeNoiseModelBuilder { /// Set T1/T2 relaxation times in physical units (seconds). /// - /// This is a convenience method that converts physical T1/T2 times to - /// the internal rate parameters based on the configured time scale. - /// - /// - T1 (amplitude damping): Sets linear idle noise rate = 1/T1 - /// - T2 (dephasing): Sets quadratic idle noise rate = 1/T2^2 + /// This is a convenience method that converts physical T1/T2 times to the internal rate + /// parameters based on the configured time scale. T2 is total transverse coherence time, not + /// pure-dephasing Tphi. The first-order Pauli-twirl mapping, physical bound, validity domain, + /// and numerical compatibility note are documented by [`IdleChannel::from_t1_t2`]. The + /// convenience configures the linear family and leaves the quadratic family unused. /// /// # Panics /// - /// Panics if `with_time_scale()` has not been called first. + /// Panics if `with_time_scale()` has not been called first, if either time is non-finite or not + /// greater than zero, or if `t2_seconds > 2 * t1_seconds`. /// /// # Example /// @@ -734,13 +735,14 @@ impl CompositeNoiseModelBuilder { .time_scale .expect("with_time_scale() must be called before with_idle_t1_t2()"); - // Convert physical times to time units - let t1_units = scale.from_seconds(t1_seconds).as_f64(); - let t2_units = scale.from_seconds(t2_seconds).as_f64(); - - // Set rates: linear_rate = 1/T1, quadratic_rate = 1/T2^2 - self.p_idle_linear_rate = 1.0 / t1_units.max(1.0); - self.p_idle_quadratic_rate = 1.0 / (t2_units * t2_units).max(1.0); + let channel = IdleChannel::from_t1_t2_seconds(t1_seconds, t2_seconds, scale); + self.p_idle_linear_rate = channel.linear_rate; + self.p_idle_linear_pauli_weights = Some(PauliWeights::custom( + channel.linear_weights.x, + channel.linear_weights.y, + channel.linear_weights.z, + )); + self.p_idle_quadratic_rate = channel.quadratic_rate; self } @@ -2038,12 +2040,22 @@ mod tests { #[test] fn test_idle_t1_t2_configuration() { // T1=50us, T2=30us with nanosecond time units - let model = CompositeNoiseModelBuilder::new() + let builder = CompositeNoiseModelBuilder::new() .with_time_scale(TimeScale::NANOSECONDS) - .with_idle_t1_t2(50e-6, 30e-6) - .build(); + .with_idle_t1_t2(50e-6, 30e-6); + let expected = IdleChannel::from_t1_t2(50_000.0, 30_000.0); + let actual_weights = builder + .p_idle_linear_pauli_weights + .expect("T1/T2 convenience must set linear Pauli weights"); + + assert!((builder.p_idle_linear_rate - expected.linear_rate).abs() < f64::EPSILON); + assert!((actual_weights.x - expected.linear_weights.x).abs() < f64::EPSILON); + assert!((actual_weights.y - expected.linear_weights.y).abs() < f64::EPSILON); + assert!((actual_weights.z - expected.linear_weights.z).abs() < f64::EPSILON); + assert!((builder.p_idle_quadratic_rate - expected.quadratic_rate).abs() < f64::EPSILON); // Should have created an idle channel + let model = builder.build(); assert_eq!(model.channel_count(), 1); // Should have time scale set assert!(model.time_scale().is_some()); diff --git a/exp/pecos-neo/src/noise/general_builder.rs b/exp/pecos-neo/src/noise/general_builder.rs index 296775961..407397159 100644 --- a/exp/pecos-neo/src/noise/general_builder.rs +++ b/exp/pecos-neo/src/noise/general_builder.rs @@ -543,24 +543,29 @@ impl GeneralNoiseModelBuilder { /// Set T1/T2 relaxation times in physical units (seconds). /// - /// Requires `with_time_scale()` to be called first. + /// Requires `with_time_scale()` to be called first. T2 is total transverse coherence time, + /// not pure-dephasing Tphi. This uses the first-order Pauli-twirl mapping documented by + /// [`IdleChannel::from_t1_t2`], including its `T2 <= 2 * T1` bound and small-duration validity + /// domain. It configures only the linear idle family; the quadratic rate is zero. + /// + /// This mapping changes the numerical rates and Pauli weights produced by this convenience + /// from earlier PECOS versions. It produces the same configuration callers can write with the + /// linear-family rate and weight setters. /// /// # Panics - /// Panics if `with_time_scale()` has not been called. + /// Panics if `with_time_scale()` has not been called, if either time is non-finite or not + /// greater than zero, or if `t2_seconds > 2 * t1_seconds`. #[must_use] pub fn with_idle_t1_t2(mut self, t1_seconds: f64, t2_seconds: f64) -> Self { let scale = self .time_scale .expect("with_time_scale() must be called before with_idle_t1_t2()"); - // Convert physical times to time units - let t1_units = scale.from_seconds(t1_seconds).as_f64(); - let t2_units = scale.from_seconds(t2_seconds).as_f64(); - - // Set rates: linear_rate = 1/T1, quadratic_rate = 1/T2^2 - self.p_idle_linear_rate = 1.0 / t1_units.max(1.0); - self.p_idle_quadratic_rate = 1.0 / (t2_units * t2_units).max(1.0); - self.p_idle_quadratic_configured = true; + let channel = IdleChannel::from_t1_t2_seconds(t1_seconds, t2_seconds, scale); + self.p_idle_linear_rate = channel.linear_rate; + self.p_idle_linear_weights = channel.linear_weights; + self.p_idle_quadratic_rate = channel.quadratic_rate; + self.p_idle_quadratic_configured = false; self } @@ -1186,15 +1191,48 @@ mod tests { #[test] fn test_idle_t1_t2_configuration() { // T1=50us, T2=30us with nanosecond time units - let model = GeneralNoiseModelBuilder::new() + let builder = GeneralNoiseModelBuilder::new() .with_time_scale(TimeScale::NANOSECONDS) - .with_idle_t1_t2(50e-6, 30e-6) - .build(); + .with_idle_t1_t2(50e-6, 30e-6); + let expected = IdleChannel::from_t1_t2(50_000.0, 30_000.0); + + assert!((builder.p_idle_linear_rate - expected.linear_rate).abs() < f64::EPSILON); + assert!((builder.p_idle_linear_weights.x - expected.linear_weights.x).abs() < f64::EPSILON); + assert!((builder.p_idle_linear_weights.y - expected.linear_weights.y).abs() < f64::EPSILON); + assert!((builder.p_idle_linear_weights.z - expected.linear_weights.z).abs() < f64::EPSILON); + assert!((builder.p_idle_quadratic_rate - expected.quadratic_rate).abs() < f64::EPSILON); + assert!(!builder.p_idle_quadratic_configured); // Should have created an idle channel + let model = builder.build(); assert_eq!(model.channel_count(), 1); } + #[test] + fn idle_t1_t2_rejects_non_finite_seconds_before_time_scale_conversion() { + for (t1, t2, parameter) in [ + (f64::INFINITY, 1.0, "t1"), + (f64::NEG_INFINITY, 1.0, "t1"), + (f64::NAN, 1.0, "t1"), + (1.0, f64::INFINITY, "t2"), + (1.0, f64::NEG_INFINITY, "t2"), + (1.0, f64::NAN, "t2"), + ] { + let panic = std::panic::catch_unwind(|| { + let _ = GeneralNoiseModelBuilder::new() + .with_time_scale(TimeScale::NANOSECONDS) + .with_idle_t1_t2(t1, t2); + }) + .expect_err("non-finite physical time must panic"); + let message = panic_message(panic.as_ref()); + assert!(message.contains(parameter), "unexpected panic: {message}"); + assert!( + message.contains("finite and greater than zero"), + "unexpected panic: {message}" + ); + } + } + // ======================================================================== // Mixed Channel Tests // ======================================================================== diff --git a/exp/pecos-neo/src/noise/idle.rs b/exp/pecos-neo/src/noise/idle.rs index 0abd94463..0a5088271 100644 --- a/exp/pecos-neo/src/noise/idle.rs +++ b/exp/pecos-neo/src/noise/idle.rs @@ -19,7 +19,7 @@ //! ## When to use this vs `CompositeChannel` //! //! **Use `IdleChannel` when:** -//! - You want standard T1/T2 decay with linear/quadratic scaling +//! - You want the first-order T1/T2 Pauli-twirl convenience or other batched idle noise //! - Performance is critical (batched processing) //! //! **Use `CompositeChannel` when:** @@ -37,10 +37,10 @@ //! ## Noise Components //! //! - **Linear noise**: Stochastic errors with probability proportional to time. -//! Models T1-like relaxation. +//! Can model a first-order Pauli twirl of relaxation and dephasing. //! //! - **Quadratic noise**: Can be coherent (RZ rotations) or incoherent (stochastic Z). -//! Models T2-like dephasing. +//! Models phase rotation with an angle proportional to time. //! //! - **Sine-squared noise**: Independent stochastic X, Y, Z, or leakage events with //! per-axis probability `sin(rate * multiplier * duration)^2`. @@ -57,7 +57,7 @@ use super::{ NoiseChannel, NoiseContext, NoiseEvent, NoiseGateRequirement, NoiseResponse, PauliWeights, }; use crate::command::{GateCommand, GateType}; -use pecos_core::{Angle64, TimeUnits}; +use pecos_core::{Angle64, TimeScale, TimeUnits}; use pecos_random::PecosRng; use rand::RngExt; use smallvec::SmallVec; @@ -145,25 +145,92 @@ impl IdleChannel { } } - /// Create an idle noise channel with T1/T2 parameters in abstract time units. + /// Create an idle noise channel from T1/T2 parameters in abstract time units. + /// + /// `t2` is the total transverse coherence time reported by device datasheets, not the pure + /// dephasing time Tphi. This convenience applies the first-order Pauli twirl of combined + /// amplitude damping and dephasing: + /// + /// ```text + /// rX = rY = 1 / (4 * T1) + /// rZ = 1 / (2 * T2) - 1 / (4 * T1) + /// ``` + /// + /// The channel's linear rate is `rX + rY + rZ`, with normalized X/Y/Z weights derived from + /// those rates. Its quadratic rate is zero: total-T2 dephasing is linear in duration to first + /// order and does not use the quadratic family. Equivalently, callers can configure the same + /// channel with [`Self::linear`] and [`Self::with_linear_weights`]. + /// + /// This approximation retains terms through first order in the idle duration `t`; it is valid + /// for `t` much smaller than both T1 and T2, where the resulting linear error probability is + /// also much smaller than one. Physical total T2 must satisfy `T2 <= 2 * T1` so that `rZ` is + /// non-negative. + /// + /// This mapping changes the numerical rates and Pauli weights produced by this convenience + /// from earlier PECOS versions, which used a Z-only `1/T1` linear rate and a `1/T2^2` + /// quadratic rate. /// /// # Arguments - /// * `t1` - T1 relaxation time in time units - /// * `t2` - T2 dephasing time in time units + /// * `t1` - T1 relaxation time in abstract time units + /// * `t2` - Total T2 transverse coherence time in the same units + /// + /// # Panics + /// + /// Panics if either time is non-finite or not greater than zero, or if `t2 > 2 * t1`. #[must_use] pub fn from_t1_t2(t1: f64, t2: f64) -> Self { - // Approximate error rate from T1/T2 - // This is a simplified model - let linear_rate = 1.0 / t1.max(1.0); - let quadratic_rate = 1.0 / (t2 * t2).max(1.0); + Self::validate_t1_t2(t1, t2); + + let rate_x = 1.0 / (4.0 * t1); + let rate_y = rate_x; + let rate_z = 1.0 / (2.0 * t2) - rate_x; + let linear_rate = rate_x + rate_y + rate_z; Self { linear_rate, - quadratic_rate, + linear_weights: PauliWeights::custom( + rate_x / linear_rate, + rate_y / linear_rate, + rate_z / linear_rate, + ), + quadratic_rate: 0.0, ..Default::default() } } + /// Convert physical seconds with a time scale, preserving the constructor's validation. + pub(crate) fn from_t1_t2_seconds(t1_seconds: f64, t2_seconds: f64, scale: TimeScale) -> Self { + // Validate before TimeScale rounds into its unsigned integer representation, which would + // otherwise erase the sign and non-finite state of some invalid inputs. + Self::validate_t1_t2(t1_seconds, t2_seconds); + let t1 = scale.from_seconds(t1_seconds).as_f64(); + let t2 = scale.from_seconds(t2_seconds).as_f64(); + Self::from_t1_t2(t1, t2) + } + + fn validate_t1_t2(t1: f64, t2: f64) { + assert!( + t1.is_finite(), + "t1 must be finite and greater than zero, got {t1}" + ); + assert!( + t1 > 0.0, + "t1 must be finite and greater than zero, got {t1}" + ); + assert!( + t2.is_finite(), + "t2 must be finite and greater than zero, got {t2}" + ); + assert!( + t2 > 0.0, + "t2 must be finite and greater than zero, got {t2}" + ); + assert!( + t2 <= 2.0 * t1, + "total transverse coherence time must satisfy t2 <= 2 * t1, got t1={t1} and t2={t2}" + ); + } + /// Set whether to use coherent dephasing. #[must_use] pub fn with_coherent_dephasing(mut self, coherent: bool) -> Self { @@ -413,6 +480,39 @@ mod tests { use super::*; use pecos_core::QubitId; + fn panic_message(panic: &(dyn std::any::Any + Send)) -> String { + if let Some(message) = panic.downcast_ref::() { + message.clone() + } else if let Some(message) = panic.downcast_ref::<&str>() { + (*message).to_string() + } else { + "non-string panic".to_string() + } + } + + fn assert_close(actual: f64, expected: f64) { + assert!( + (actual - expected).abs() < f64::EPSILON, + "expected {expected}, got {actual}" + ); + } + + fn assert_same_configuration(actual: &IdleChannel, expected: &IdleChannel) { + assert_close(actual.linear_rate, expected.linear_rate); + assert_close(actual.linear_weights.x, expected.linear_weights.x); + assert_close(actual.linear_weights.y, expected.linear_weights.y); + assert_close(actual.linear_weights.z, expected.linear_weights.z); + assert_close(actual.sin_squared_rate, expected.sin_squared_rate); + assert_eq!(actual.sin_squared_model, expected.sin_squared_model); + assert_close(actual.quadratic_rate, expected.quadratic_rate); + assert_eq!(actual.coherent_dephasing, expected.coherent_dephasing); + assert_close( + actual.coherent_to_incoherent_factor, + expected.coherent_to_incoherent_factor, + ); + assert_close(actual.idle_after_2q, expected.idle_after_2q); + } + fn collect_gates(response: NoiseResponse) -> Vec { match response { NoiseResponse::InjectGates(gates) => (*gates).into_vec(), @@ -491,6 +591,100 @@ mod tests { assert!((p - 0.01).abs() < 1e-10); } + #[test] + fn t1_t2_short_times_are_not_clamped() { + let half_unit_t1 = IdleChannel::from_t1_t2(0.5, 1.0); + let one_unit_t1 = IdleChannel::from_t1_t2(1.0, 2.0); + + assert_close(half_unit_t1.linear_rate, 1.0); + assert_close(one_unit_t1.linear_rate, 0.5); + assert_close(half_unit_t1.linear_rate, 2.0 * one_unit_t1.linear_rate); + assert_close(half_unit_t1.linear_weights.x, 0.5); + assert_close(half_unit_t1.linear_weights.y, 0.5); + assert_close(half_unit_t1.linear_weights.z, 0.0); + } + + #[test] + fn t1_t2_rejects_non_positive_and_non_finite_times_by_parameter() { + for t1 in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let panic = std::panic::catch_unwind(|| IdleChannel::from_t1_t2(t1, 1.0)) + .expect_err("invalid t1 must panic"); + let message = panic_message(panic.as_ref()); + assert!(message.contains("t1"), "unexpected panic: {message}"); + assert!( + message.contains("finite and greater than zero"), + "unexpected panic: {message}" + ); + } + + for t2 in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let panic = std::panic::catch_unwind(|| IdleChannel::from_t1_t2(1.0, t2)) + .expect_err("invalid t2 must panic"); + let message = panic_message(panic.as_ref()); + assert!(message.contains("t2"), "unexpected panic: {message}"); + assert!( + message.contains("finite and greater than zero"), + "unexpected panic: {message}" + ); + } + } + + #[test] + fn total_t2_physical_bound_is_enforced_and_inclusive() { + let boundary = IdleChannel::from_t1_t2(1.0, 2.0); + assert_close(boundary.linear_weights.z, 0.0); + + let panic = std::panic::catch_unwind(|| IdleChannel::from_t1_t2(1.0, 2.1)) + .expect_err("T2 above the physical bound must panic"); + let message = panic_message(panic.as_ref()); + assert!( + message.contains("t2 <= 2 * t1"), + "unexpected panic: {message}" + ); + assert!(message.contains("t1=1"), "unexpected panic: {message}"); + assert!(message.contains("t2=2.1"), "unexpected panic: {message}"); + } + + #[test] + fn t1_t2_sanity_values_match_first_order_pauli_twirl() { + let channel = IdleChannel::from_t1_t2(50_000.0, 30_000.0); + + assert!((channel.linear_rate - 2.166_666_666_666_666_7e-5).abs() < f64::EPSILON); + assert!((channel.linear_weights.x - 3.0 / 13.0).abs() < f64::EPSILON); + assert!((channel.linear_weights.y - 3.0 / 13.0).abs() < f64::EPSILON); + assert!((channel.linear_weights.z - 7.0 / 13.0).abs() < f64::EPSILON); + assert_close(channel.quadratic_rate, 0.0); + } + + #[test] + fn t1_t2_uses_linear_t2_scaling_and_zero_quadratic_angle() { + let t2_30 = IdleChannel::from_t1_t2(50.0, 30.0); + let t2_60 = IdleChannel::from_t1_t2(50.0, 60.0); + + // theta_quadratic(t; T2) = 0. The first-order transverse Pauli error instead follows + // pY(t) + pZ(t) = t / (2 * T2). + assert_close(t2_30.quadratic_angle(3.0), 0.0); + assert_close(t2_30.quadratic_angle(6.0), 0.0); + assert_close(t2_60.quadratic_angle(3.0), 0.0); + + let transverse_probability = |channel: &IdleChannel, duration| { + channel.linear_probability(duration) + * (channel.linear_weights.y + channel.linear_weights.z) + }; + assert!((transverse_probability(&t2_30, 3.0) - 0.05).abs() < f64::EPSILON); + assert!((transverse_probability(&t2_30, 6.0) - 0.1).abs() < f64::EPSILON); + assert!((transverse_probability(&t2_60, 3.0) - 0.025).abs() < f64::EPSILON); + } + + #[test] + fn t1_t2_convenience_equals_hand_written_linear_family() { + let convenience = IdleChannel::from_t1_t2(50_000.0, 30_000.0); + let hand_written = IdleChannel::linear(2.166_666_666_666_666_7e-5) + .with_linear_weights(PauliWeights::custom(3.0 / 13.0, 3.0 / 13.0, 7.0 / 13.0)); + + assert_same_configuration(&convenience, &hand_written); + } + #[test] fn test_linear_with_custom_weights() { // X-biased linear noise diff --git a/exp/pecos-neo/tests/noise_comparison_test.rs b/exp/pecos-neo/tests/noise_comparison_test.rs index 70fe42818..5df126e2c 100644 --- a/exp/pecos-neo/tests/noise_comparison_test.rs +++ b/exp/pecos-neo/tests/noise_comparison_test.rs @@ -566,11 +566,11 @@ fn test_idle_noise_with_time_scale() { // Test that idle noise with TimeScale produces expected decoherence. // // Circuit: prep |0> → X (to get |1>) → H → idle → H → measure - // The H gates convert Z errors (dephasing) to bit flip errors for detection. + // The H gates convert Y/Z transverse-coherence errors to bit flips for detection. // With T1=10us, T2=5us, and 1us idle, we expect ~10% error rate. // - // Note: IdleChannel by default produces Z-only errors (dephasing model), - // so we use H-basis measurement to detect them. + // The Pauli twirl produces X, Y, and Z errors. H-basis measurement detects the transverse + // coherence errors Y and Z while X leaves |+> unchanged. use pecos_core::TimeScale; @@ -585,13 +585,13 @@ fn test_idle_noise_with_time_scale() { assert!(model.time_scale().is_some()); assert_eq!(model.channel_count(), 1); - // Circuit with idle - use H gates to make Z errors detectable - // H|+> = |0>, H|-> = |1>, so Z|+> = |-> gives different outcome after H + // Circuit with idle - use H gates to make the Y/Z phase-changing errors detectable. + // H|+> = |0>, H|-> = |1>, so Y|+> and Z|+> give |-> up to phase. let commands = CommandBuilder::new() .pz(&[0]) .h(&[0]) // Prepare |+> state - .idle(&[0], 1000) // 1000 ns idle = 1 us (Z errors here) - .h(&[0]) // Convert Z errors to bit flips + .idle(&[0], 1000) // 1000 ns idle = 1 us (Pauli-twirled errors here) + .h(&[0]) // Convert Y/Z phase changes to measurement flips .mz(&[0]) .build(); @@ -609,7 +609,7 @@ fn test_idle_noise_with_time_scale() { if let Some(bits) = outcomes.bitstring(&qubits) && bits[0] { - error_count += 1; // Z error during idle will cause |1> outcome + error_count += 1; // Y/Z error during idle will cause |1> outcome } } @@ -617,13 +617,13 @@ fn test_idle_noise_with_time_scale() { println!("Idle noise with TimeScale:"); println!(" T1=10us, T2=5us, idle=1us"); - println!(" Error rate: {error_rate:.1}% (expected ~10% from linear/T1 dephasing)"); + println!(" Error rate: {error_rate:.1}% (expected ~10% from total-T2 coherence)"); - // Analytic expectation: linear_rate = 1/T1 = 1e-4/ns, so 1000 ns idle - // gives p = 0.1 exactly (Z-only weights, detected via H basis). The - // quadratic T2 term contributes sin^2(4e-5) ~ 1.6e-9, negligible. + // Analytic first-order Pauli twirl: the total linear rate is 1.25e-4/ns with weights + // (0.2, 0.2, 0.6), and the quadratic rate is zero. In the H basis, Y and Z are detected, so + // 1000 ns gives 1000 * 1.25e-4 * (0.2 + 0.6) = 0.1. assert!( rate_matches_expected(error_rate, 10.0), - "Error rate {error_rate:.1}% should be within {K_SIGMA} sigma of the analytic 10% T1 dephasing rate" + "Error rate {error_rate:.1}% should be within {K_SIGMA} sigma of the analytic 10% total-T2 coherence rate" ); } From ab6201a5406e29504e1cef4264903aa5733477fe Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 5 Aug 2026 22:30:16 -0600 Subject: [PATCH 47/62] Remove the legacy engines idle setters in favour of the families --- crates/pecos-engines/src/noise/general.rs | 387 +++++++----------- .../src/noise/general/builder.rs | 214 +++------- .../src/noise/general/default.rs | 4 - crates/pecos-engines/tests/noise_test.rs | 12 +- .../examples/general_noise_builder.rs | 8 +- .../examples/general_noise_config.rs | 8 +- crates/pecos-qasm/src/config.rs | 64 ++- .../tests/general_noise_builder_test.rs | 8 +- .../general_noise_builder_test.rs.disabled | 8 +- .../general_noise_config_test.rs.disabled | 6 +- crates/pecos/tests/neo_emission_test.rs | 2 - .../tests/neo_equivalence_matrix_test.rs | 2 - crates/pecos/tests/neo_routing_test.rs | 3 +- docs/user-guide/dem-from-guppy.md | 39 +- docs/user-guide/noise-model-builders.md | 38 +- docs/user-guide/qasm-simulation.md | 8 +- .../python_examples/noise_builder_example.py | 2 +- .../surface/native_dem_threshold_sweep.py | 9 +- python/pecos-rslib/src/engine_builders.rs | 107 +---- .../tests/user_guide_qasm_simulation.rs | 8 +- .../tests/guppy/test_noise_models.py | 2 +- .../pecos/test_noise_builder_setter_names.py | 80 ++-- 22 files changed, 413 insertions(+), 606 deletions(-) diff --git a/crates/pecos-engines/src/noise/general.rs b/crates/pecos-engines/src/noise/general.rs index a7379b53a..39751a137 100644 --- a/crates/pecos-engines/src/noise/general.rs +++ b/crates/pecos-engines/src/noise/general.rs @@ -120,15 +120,6 @@ pub struct GeneralNoiseModel { /// 1.0 means all leakage events remain leakage events. leakage_scale: f64, - /// Whether to use coherent dephasing vs incoherent (stochastic) dephasing - /// - /// If true, dephasing is modeled as coherent phase rotations using RZ gates. - /// If false, dephasing is modeled as stochastic Z errors with quadratic scaling. - /// - /// In physical systems, coherent dephasing represents systematic phase evolution - /// such as frequency offsets. - p_idle_quadratic_coherent: bool, - /// The idle noise rate for linear dependency on time (seconds). /// /// This always applies stochastic noise @@ -142,12 +133,6 @@ pub struct GeneralNoiseModel { /// the input. p_idle_linear_model: SingleQubitWeightedSampler, - /// The idle noise rate for quadratic dependency on time (seconds). - /// - /// This will be a coherent noise channel unless `p_idle_quadratic_coherent` is set to false. - /// If it is false it will apply Z to each qubit quadratic dependency on time. - p_idle_quadratic_rate: f64, - /// DEM-style stochastic sine-squared idle rate in radians per time unit. p_idle_sin_squared_rate: f64, @@ -160,16 +145,6 @@ pub struct GeneralNoiseModel { /// Unnormalized RX/RY/RZ relative multipliers for the coherent idle family. p_idle_coherent_model: BTreeMap, - /// Scaling factor to convert coherent dephasing rates to incoherent rates. - /// - /// A factor of one gives the exact Pauli twirl of the coherent rotation. Values above one can - /// be used to deliberately inflate stochastic dephasing. - /// - /// # Panics - /// - /// Panics if the factor is not positive (less than or equal to 0.0). - p_idle_coherent_to_incoherent_factor: f64, - /// Probability of applying a fault during preparation (initialization) /// /// This parameter models faults that occur when initializing a qubit to |0⟩. In ion trap @@ -295,10 +270,9 @@ pub struct GeneralNoiseModel { /// Duration of the idle-noise site applied to each qubit after a two-qubit gate. /// /// A value of `0.0` disables these sites. For a nonzero duration, the sites receive the same - /// configured idle mechanisms as a real [`GateType::Idle`] operation: linear stochastic noise - /// from `p_idle_linear_rate` and `p_idle_linear_model`, quadratic dephasing from - /// `p_idle_quadratic_rate` honoring `p_idle_quadratic_coherent`, and the independent per-axis - /// sine-squared and coherent families. The duration is not itself an error probability. + /// configured idle families as a real [`GateType::Idle`] operation: linear stochastic noise, + /// independent per-axis sine-squared noise, and coherent rotations. The duration is not itself + /// an error probability. idle_after_2q: f64, /// Probability of flipping a 0 measurement to 1 @@ -599,12 +573,7 @@ impl GeneralNoiseModel { // decide whether to add the original gate based on error models match gate.gate_type { GateType::Idle => { - self.apply_idle_faults( - &gate, - self.p_idle_linear_rate, - self.p_idle_quadratic_rate, - &mut builder, - ); + self.apply_idle_faults(&gate, self.p_idle_linear_rate, &mut builder); } GateType::PZ => { for &q in &gate.qubits { @@ -830,23 +799,15 @@ impl GeneralNoiseModel { &mut self, gate: &Gate, linear_rate: f64, - quadratic_rate: f64, builder: &mut ByteMessageBuilder, ) { let qubits: Vec = gate.qubits.iter().map(|q| usize::from(*q)).collect(); - self.apply_idle_faults_for_duration( - linear_rate, - quadratic_rate, - gate.idle_duration(), - &qubits, - builder, - ); + self.apply_idle_faults_for_duration(linear_rate, gate.idle_duration(), &qubits, builder); } fn apply_idle_faults_for_duration( &mut self, linear_rate: f64, - quadratic_rate: f64, duration: f64, qubits: &[usize], builder: &mut ByteMessageBuilder, @@ -855,10 +816,6 @@ impl GeneralNoiseModel { self.apply_idle_linear_stochastic_noise(linear_rate, duration, qubits, builder); } - if quadratic_rate.abs() > f64::EPSILON { - self.apply_idle_quadratic_dephasing(quadratic_rate, duration, qubits, builder); - } - if self.p_idle_sin_squared_rate > f64::EPSILON && duration.abs() > f64::EPSILON { self.apply_idle_sin_squared(duration, qubits, builder); } @@ -893,56 +850,6 @@ impl GeneralNoiseModel { } } - /// Apply coherent dephasing noise to a gate - /// - /// This method implements coherent phase rotation (systematic Z-rotation) noise - /// that occurs during idle periods or during gates with a specified duration. - /// - /// In physical systems, coherent dephasing represents: - /// - Systematic phase errors due to energy level shifts - /// - Frequency offsets in control fields - /// - AC Stark shifts - /// - Other systematic Z-rotation errors - /// - /// # Parameters - /// * `builder` - The `ByteMessageBuilder` to add gate operations to - /// * `angle` - The time duration over which idling occurs times the rate per time - /// * `qubits` - The qubits that are potentially affected by the idling noise - fn apply_idle_quadratic_dephasing( - &mut self, - rate: f64, - duration: f64, - qubits: &[usize], - builder: &mut ByteMessageBuilder, - ) { - let mut angle = rate * duration; - - angle = if self.p_idle_quadratic_coherent { - angle - } else { - angle.sin().powi(2) - }; - - if angle.abs() > f64::EPSILON { - let mut noisy_qubits = vec![]; - - for qubit in qubits { - if !self.is_leaked(*qubit) - && (self.p_idle_quadratic_coherent || self.rng.occurs(angle)) - { - noisy_qubits.push(*qubit); - } - } - if !noisy_qubits.is_empty() { - if self.p_idle_quadratic_coherent { - builder.rz(Angle64::from_radians(angle), &noisy_qubits); - } else { - builder.z(&noisy_qubits); - } - } - } - } - /// Apply the DEM-style stochastic sine-squared family independently per axis. fn apply_idle_sin_squared( &mut self, @@ -1376,7 +1283,6 @@ impl GeneralNoiseModel { .collect::>(); self.apply_idle_faults_for_duration( self.p_idle_linear_rate, - self.p_idle_quadratic_rate, self.idle_after_2q, &gate_qubits, builder, @@ -1628,9 +1534,7 @@ mod tests { assert_float_eq(model.p1, 0.0); assert_float_eq(model.p2, 0.0); assert_float_eq(model.p_idle_linear_rate, 0.0); - assert_float_eq(model.p_idle_quadratic_rate, 0.0); assert_float_eq(model.p_idle_sin_squared_rate, 0.0); - assert!(!model.p_idle_quadratic_coherent); assert_float_eq(model.p_idle_coherent_rate, 0.0); assert_eq!( model.p_idle_coherent_model, @@ -1650,7 +1554,6 @@ mod tests { assert_float_eq(model.p_meas_crosstalk_global, 0.0); assert_float_eq(model.p_meas_crosstalk_local, 0.0); assert_float_eq(model.p_prep_crosstalk, 0.0); - assert_float_eq(model.p_idle_coherent_to_incoherent_factor, 1.0); assert_float_eq(model.p2_angle_a, 0.0); assert_float_eq(model.p2_angle_b, 1.0); assert_float_eq(model.p2_angle_c, 0.0); @@ -2960,20 +2863,24 @@ mod tests { fn after_2q_outputs( duration: f64, linear_rate: f64, - quadratic_rate: f64, - coherent: bool, + coherent_rate: f64, seed: u64, shots: usize, ) -> Vec> { let mut input_builder = ByteMessage::quantum_operations_builder(); input_builder.cx(&[(0, 1)]); let input = input_builder.build(); + let linear_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); + let coherent_model = BTreeMap::from([("RZ".to_string(), 1.0)]); let mut model = GeneralNoiseModel::builder() .with_p2(0.0) - .with_p_idle_linear_rate(linear_rate) - .with_p_idle_quadratic_rate(quadratic_rate) - .with_p_idle_quadratic_coherent(coherent) + .with_p_idle_linear(linear_rate, &linear_model) + .with_p_idle_coherent(coherent_rate, &coherent_model) .with_idle_after_2q(duration) .with_seed(seed) .build(); @@ -2999,8 +2906,8 @@ mod tests { #[test] fn idle_after_2q_duration_scales_linear_noise() { - let smaller = after_2q_outputs(0.1, 1.0, 0.0, false, 42, 1_000); - let larger = after_2q_outputs(1.0, 1.0, 0.0, false, 42, 1_000); + let smaller = after_2q_outputs(0.1, 1.0, 0.0, 42, 1_000); + let larger = after_2q_outputs(1.0, 1.0, 0.0, 42, 1_000); assert!( emitted_after_2q_noise_count(&larger) > emitted_after_2q_noise_count(&smaller), @@ -3009,13 +2916,13 @@ mod tests { } #[test] - fn idle_after_2q_applies_quadratic_dephasing() { - let outputs = after_2q_outputs(0.5, 0.0, 0.25, true, 42, 1); + fn idle_after_2q_applies_coherent_family() { + let outputs = after_2q_outputs(0.5, 0.0, 0.25, 42, 1); let rz_gate = outputs .iter() .flatten() .find(|gate| gate.gate_type == GateType::RZ) - .expect("quadratic coherent dephasing should emit an RZ gate"); + .expect("the coherent idle family should emit an RZ gate"); assert_eq!(rz_gate.qubits.len(), 2); assert!(rz_gate.qubits.contains(&QubitId(0))); @@ -3024,68 +2931,92 @@ mod tests { #[test] fn zero_idle_after_2q_duration_emits_no_idle_noise() { - let outputs = after_2q_outputs(0.0, 1.0, 0.0, false, 42, 100); + let outputs = after_2q_outputs(0.0, 1.0, 0.0, 42, 100); assert_eq!(emitted_after_2q_noise_count(&outputs), 0); } #[test] fn zero_idle_rates_emit_no_after_2q_idle_noise() { - let outputs = after_2q_outputs(1.0, 0.0, 0.0, false, 42, 100); + let outputs = after_2q_outputs(1.0, 0.0, 0.0, 42, 100); assert_eq!(emitted_after_2q_noise_count(&outputs), 0); } #[test] fn idle_after_2q_is_deterministic_for_same_seed() { - let first = after_2q_outputs(0.5, 0.4, 0.0, false, 42, 100); - let second = after_2q_outputs(0.5, 0.4, 0.0, false, 42, 100); + let first = after_2q_outputs(0.5, 0.4, 0.0, 42, 100); + let second = after_2q_outputs(0.5, 0.4, 0.0, 42, 100); assert_eq!(first, second); } + /// The documented `r * PI` migration is exact in the probability, not just in + /// sampled bytes. + /// + /// The byte-comparison sibling test only resolves conversion errors of a few + /// percent, because a seeded stochastic run can leave every draw on the same side + /// of its threshold. This compares the analytic probability instead, so a wrong + /// constant fails at machine precision rather than at 5%. #[test] - fn dem_sine_and_legacy_quadratic_have_identical_z_probability() { - let sine_rate = 0.03; - let duration = 10.0; - let expected_probability = 0.087_332_192_545_160_84; - let legacy_rate = sine_rate / std::f64::consts::PI; - let z_model = BTreeMap::from([("Z".to_string(), 1.0)]); + fn quadratic_migration_r_times_pi_is_exact_in_probability() { + let legacy_rate = 0.2_f64; + let duration = 0.75_f64; - let mut sine = GeneralNoiseModel::builder() - .with_seed(424) - .with_p_idle_linear_rate(0.0) - .with_p_idle_sin_squared(sine_rate, &z_model) - .build(); - let mut legacy = GeneralNoiseModel::builder() - .with_seed(424) - .with_p_idle_linear_rate(0.0) - .with_p_idle_quadratic_rate(legacy_rate) - .build(); + // What the legacy path produced: the builder scaled the cycles-per-time rate by + // factor/2 * 2*PI, with the factor at its final default of 1.0. + let legacy_effective_rate = legacy_rate * std::f64::consts::PI; + let legacy_probability = (legacy_effective_rate * duration).sin().powi(2); + + // What the documented migration produces through the family entry point. + let migrated_probability = GeneralNoiseModel::sin_squared_probability( + legacy_rate * std::f64::consts::PI, + 1.0, + duration, + ); - assert!((sine.p_idle_sin_squared_rate - sine_rate).abs() < f64::EPSILON); - assert!((legacy.p_idle_quadratic_rate - sine_rate).abs() < f64::EPSILON); - let coherent_angle = 2.0 * sine_rate * duration; - assert!(((coherent_angle / 2.0).sin().powi(2) - expected_probability).abs() < f64::EPSILON); assert!( - (GeneralNoiseModel::sin_squared_probability(sine_rate, 1.0, duration) - - (coherent_angle / 2.0).sin().powi(2)) - .abs() - < f64::EPSILON + (migrated_probability - legacy_probability).abs() < 1e-15, + "migration must be exact: got {migrated_probability}, expected {legacy_probability}", + ); + + // And it is genuinely sensitive: a 0.1% error in the constant is caught here. + let perturbed = GeneralNoiseModel::sin_squared_probability( + legacy_rate * std::f64::consts::PI * 1.001, + 1.0, + duration, + ); + assert!( + (perturbed - legacy_probability).abs() > 1e-9, + "a perturbed constant must be distinguishable", ); + } + + #[test] + fn quadratic_migration_r_times_pi_keeps_captured_legacy_bytes() { + let legacy_rate = 0.2; + let z_model = BTreeMap::from([("Z".to_string(), 1.0)]); + let mut model = GeneralNoiseModel::builder() + .with_seed(424) + .with_p_idle_sin_squared(legacy_rate * std::f64::consts::PI, &z_model) + .build(); let mut input_builder = ByteMessage::quantum_operations_builder(); - input_builder.idle(duration, &[0]); - let input = input_builder.build(); - let sine_outputs = (0..256) - .map(|_| sine.apply_noise_on_start(&input).unwrap().into_bytes()) - .collect::>(); - let legacy_outputs = (0..256) - .map(|_| legacy.apply_noise_on_start(&input).unwrap().into_bytes()) - .collect::>(); + for _ in 0..8 { + input_builder.idle(0.75, &[0, 1, 2, 3]); + } + let output = model + .apply_noise_on_start(&input_builder.build()) + .unwrap() + .into_bytes(); + let captured_legacy_bytes = vec![ + 83, 67, 69, 80, 1, 0, 0, 0, 5, 0, 0, 0, 100, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, + 0, 1, 0, 0, 0, 10, 0, 0, 0, 12, 0, 0, 0, 2, 2, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 10, 0, 0, + 0, 8, 0, 0, 0, 2, 1, 0, 0, 0, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 2, 0, 0, 0, + 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 0, 0, 0, 0, + ]; - assert_eq!(sine_outputs, legacy_outputs); - assert!(sine_outputs.iter().any(|output| output.len() > 16)); + assert_eq!(output, captured_legacy_bytes); } #[test] @@ -3102,13 +3033,18 @@ mod tests { #[test] fn same_seed_and_configuration_emit_identical_noise() { + let linear_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); let make_model = || { GeneralNoiseModel::builder() .with_seed(4_242) .with_p_prep(0.4) .with_p1(0.4) .with_p2(0.4) - .with_p_idle_linear_rate(0.4) + .with_p_idle_linear(0.4, &linear_model) .with_p1_emission_ratio(0.5) .with_p2_emission_ratio(0.5) .with_prep_leak_ratio(0.5) @@ -3137,7 +3073,6 @@ mod tests { fn x_weighted_sine_model_emits_x_not_z() { let x_model = BTreeMap::from([("X".to_string(), 1.0)]); let mut model = GeneralNoiseModel::builder() - .with_p_idle_linear_rate(0.0) .with_p_idle_sin_squared(std::f64::consts::FRAC_PI_2, &x_model) .build(); let mut input_builder = ByteMessage::quantum_operations_builder(); @@ -3323,6 +3258,34 @@ mod tests { assert_eq!(gates[2].angles, [Angle64::from_radians(0.25)].into()); } + #[test] + fn family_only_configuration_keeps_captured_pre_removal_bytes() { + let linear_model = BTreeMap::from([("Z".to_string(), 1.0)]); + let sine_model = BTreeMap::from([("X".to_string(), 1.0)]); + let coherent_model = BTreeMap::from([("RZ".to_string(), 2.0)]); + let mut model = GeneralNoiseModel::builder() + .with_seed(424) + .with_p_idle_linear(1.0, &linear_model) + .with_p_idle_sin_squared(std::f64::consts::FRAC_PI_2, &sine_model) + .with_p_idle_coherent(0.25, &coherent_model) + .build(); + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.idle(1.0, &[0, 1]); + + let output = model + .apply_noise_on_start(&input_builder.build()) + .unwrap() + .into_bytes(); + let captured_pre_removal_bytes = vec![ + 83, 67, 69, 80, 1, 0, 0, 0, 4, 0, 0, 0, 96, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, + 0, 0, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 1, 0, 0, 0, 10, 0, 0, 0, 12, 0, 0, + 0, 1, 2, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 10, 0, 0, 0, 20, 0, 0, 0, 32, 2, 1, 0, 0, 0, 0, + 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 63, + ]; + + assert_eq!(output, captured_pre_removal_bytes); + } + #[test] fn coherent_idle_skips_leaked_qubits() { let coherent_model = BTreeMap::from([("RX".to_string(), 1.0)]); @@ -3351,7 +3314,6 @@ mod tests { ("L".to_string(), 1.0), ]); let mut model = GeneralNoiseModel::builder() - .with_p_idle_linear_rate(0.0) .with_p_idle_sin_squared(std::f64::consts::FRAC_PI_2, &model_map) .build(); @@ -3406,62 +3368,6 @@ mod tests { } } - #[test] - fn sine_family_conflicts_with_both_legacy_quadratic_spellings() { - let z_model = BTreeMap::from([("Z".to_string(), 1.0)]); - let builders = [ - GeneralNoiseModel::builder() - .with_p_idle_quadratic_rate(0.1) - .with_p_idle_sin_squared(0.1, &z_model), - GeneralNoiseModel::builder() - .with_average_p_idle_quadratic_rate(0.1) - .with_p_idle_sin_squared(0.1, &z_model), - ]; - for builder in builders { - let error = builder.validate_configuration().unwrap_err(); - assert!(error.contains("with_p_idle_quadratic_rate")); - assert!(error.contains("with_average_p_idle_quadratic_rate")); - assert!(error.contains("radians per time unit")); - assert!(error.contains("cycles per time unit")); - assert!( - std::panic::catch_unwind(|| builder.build()).is_err(), - "the conflict must fail when the Rust model is built" - ); - } - } - - #[test] - fn sine_family_conflicts_with_coherent_legacy_path() { - let z_model = BTreeMap::from([("Z".to_string(), 1.0)]); - let builder = GeneralNoiseModel::builder() - .with_p_idle_sin_squared(0.1, &z_model) - .with_p_idle_quadratic_coherent(true); - let error = builder.validate_configuration().unwrap_err(); - assert!(error.contains("with_p_idle_sin_squared")); - assert!(error.contains("with_p_idle_quadratic_coherent(true)")); - assert!(error.contains("stochastic by definition")); - assert!( - std::panic::catch_unwind(|| builder.build()).is_err(), - "the conflict must fail when the Rust model is built" - ); - } - - #[test] - fn coherent_family_conflicts_with_legacy_quadratic_coherent_switch() { - let coherent_model = BTreeMap::from([("RZ".to_string(), 1.0)]); - let builder = GeneralNoiseModel::builder() - .with_p_idle_coherent(0.1, &coherent_model) - .with_p_idle_quadratic_coherent(true); - let error = builder.validate_configuration().unwrap_err(); - assert!(error.contains("with_p_idle_coherent")); - assert!(error.contains("with_p_idle_quadratic_coherent(true)")); - assert!(error.contains("both would emit coherent idle rotations")); - assert!( - std::panic::catch_unwind(|| builder.build()).is_err(), - "the conflict must fail when the Rust model is built" - ); - } - #[test] fn coherent_family_rejects_invalid_rates_axes_and_multipliers() { let cases = [ @@ -3490,11 +3396,9 @@ mod tests { ); let x_model = BTreeMap::from([("X".to_string(), 1.0)]); let mut zero_rate = GeneralNoiseModel::builder() - .with_p_idle_linear_rate(0.0) .with_p_idle_sin_squared(0.0, &x_model) .build(); let mut nonzero_rate = GeneralNoiseModel::builder() - .with_p_idle_linear_rate(0.0) .with_p_idle_sin_squared(std::f64::consts::FRAC_PI_2, &x_model) .build(); let mut duration_one = ByteMessage::quantum_operations_builder(); @@ -3562,7 +3466,6 @@ mod tests { let make_model = || { GeneralNoiseModel::builder() .with_seed(424) - .with_p_idle_linear_rate(0.0) .with_p_idle_sin_squared(0.6, &sine_model) .build() }; @@ -3583,22 +3486,23 @@ mod tests { } #[test] - fn legacy_quadratic_stochastic_path_keeps_its_pre_change_bytes() { + fn linear_and_sine_families_keep_their_pre_removal_bytes() { let mut input_builder = ByteMessage::quantum_operations_builder(); for _ in 0..8 { input_builder.idle(0.75, &[0, 1, 2, 3]); } let input = input_builder.build(); + let linear_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); + let sine_model = BTreeMap::from([("Z".to_string(), 1.0)]); let mut model = GeneralNoiseModel::builder() .with_seed(424) - .with_p_idle_linear_rate(0.35) - .with_p_idle_coherent_to_incoherent_factor(1.5) - .with_p_idle_quadratic_rate(0.07) - .with_p_idle_quadratic_coherent(false) + .with_p_idle_linear(0.35, &linear_model) + .with_p_idle_sin_squared(0.07 * 1.5 * std::f64::consts::PI, &sine_model) .build(); - assert!( - (model.p_idle_quadratic_rate - 0.07 * 1.5 * std::f64::consts::PI).abs() < f64::EPSILON - ); let output = model.apply_noise_on_start(&input).unwrap().into_bytes(); let expected = vec![ @@ -3614,22 +3518,23 @@ mod tests { } #[test] - fn legacy_quadratic_coherent_path_keeps_its_pre_change_bytes() { + fn linear_and_coherent_families_keep_their_pre_removal_bytes() { let mut input_builder = ByteMessage::quantum_operations_builder(); for _ in 0..8 { input_builder.idle(0.75, &[0, 1, 2, 3]); } let input = input_builder.build(); + let linear_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); + let coherent_model = BTreeMap::from([("RZ".to_string(), 1.0)]); let mut model = GeneralNoiseModel::builder() .with_seed(424) - .with_p_idle_linear_rate(0.35) - .with_p_idle_coherent_to_incoherent_factor(1.5) - .with_p_idle_quadratic_rate(0.07) - .with_p_idle_quadratic_coherent(true) + .with_p_idle_linear(0.35, &linear_model) + .with_p_idle_coherent(0.07 * 2.0 * std::f64::consts::PI, &coherent_model) .build(); - assert!( - (model.p_idle_quadratic_rate - 0.07 * 2.0 * std::f64::consts::PI).abs() < f64::EPSILON - ); let output = model.apply_noise_on_start(&input).unwrap().into_bytes(); let expected = vec![ @@ -3655,14 +3560,14 @@ mod tests { } #[test] - fn test_p_idle_quadratic_coherent() { + fn test_coherent_and_sine_squared_idle_families() { // Create a circuit builder let mut builder = ByteMessage::quantum_operations_builder(); - // Create a noise model with coherent dephasing + // Create a noise model with coherent dephasing. + let coherent_model = BTreeMap::from([("RZ".to_string(), 1.0)]); let mut model = GeneralNoiseModel::builder() - .with_p_idle_quadratic_coherent(true) - .with_p_idle_quadratic_rate(0.2) + .with_p_idle_coherent(0.2, &coherent_model) .build(); // Create an idle gate @@ -3676,7 +3581,7 @@ mod tests { }; // Apply idle faults - should use coherent dephasing (RZ gates) - model.apply_idle_faults(&gate, 0.0, model.p_idle_quadratic_rate, &mut builder); + model.apply_idle_faults(&gate, 0.0, &mut builder); // Get the message and verify it contains RZ gates let message = builder.build(); @@ -3700,12 +3605,7 @@ mod tests { channel: None, }; - model.apply_idle_faults( - &multi_qubit_gate, - 0.0, - model.p_idle_quadratic_rate, - &mut builder, - ); + model.apply_idle_faults(&multi_qubit_gate, 0.0, &mut builder); let message = builder.build(); let gates = message.quantum_ops().unwrap(); @@ -3744,16 +3644,17 @@ mod tests { "RZ gates should affect qubits 0, 1, 2" ); - // Now test with incoherent dephasing + // Now test with stochastic sine-squared dephasing. let mut builder = ByteMessage::quantum_operations_builder(); + let sine_model = BTreeMap::from([("Z".to_string(), 1.0)]); let mut model = GeneralNoiseModel::builder() - .with_p_idle_quadratic_coherent(false) + .with_p_idle_sin_squared(0.2, &sine_model) .with_seed(42) .build(); - // Apply idle faults with incoherent dephasing - model.apply_idle_faults(&gate, 0.0, model.p_idle_quadratic_rate, &mut builder); + // Apply idle faults with incoherent dephasing. + model.apply_idle_faults(&gate, 0.0, &mut builder); // The message may contain Z gates or be empty depending on random outcomes let message = builder.build(); diff --git a/crates/pecos-engines/src/noise/general/builder.rs b/crates/pecos-engines/src/noise/general/builder.rs index 29f293eb6..40088516a 100644 --- a/crates/pecos-engines/src/noise/general/builder.rs +++ b/crates/pecos-engines/src/noise/general/builder.rs @@ -36,13 +36,10 @@ pub struct GeneralNoiseModelBuilder { leakage_scale: Option, emission_scale: Option, // idle noise - p_idle_quadratic_coherent: Option, p_idle_linear_rate: Option, p_idle_linear_model: Option, - p_idle_quadratic_rate: Option, p_idle_sin_squared: Option<(f64, BTreeMap)>, p_idle_coherent: Option<(f64, BTreeMap)>, - p_idle_coherent_to_incoherent_factor: Option, idle_scale: Option, // prep noise p_prep: Option, @@ -101,11 +98,8 @@ impl GeneralNoiseModelBuilder { // idle noise p_idle_linear_rate: None, p_idle_linear_model: None, - p_idle_quadratic_rate: None, p_idle_sin_squared: None, - p_idle_quadratic_coherent: None, p_idle_coherent: None, - p_idle_coherent_to_incoherent_factor: None, idle_scale: None, // prep noise p_prep: None, @@ -158,8 +152,6 @@ impl GeneralNoiseModelBuilder { /// branches do not cause leakage. /// - `p1_seepage_prob = p2_seepage_prob = 0.5`: seepage is attempted only for qubits that are /// already leaked. - /// - `p_idle_coherent_to_incoherent_factor = 1.5`: stochastic quadratic idle dephasing is - /// inflated by 50% relative to the exact Pauli twirl. #[must_use] pub fn auto(mut self) -> Self { self.p_prep.get_or_insert(0.01); @@ -173,7 +165,6 @@ impl GeneralNoiseModelBuilder { self.p_prep_leak_ratio.get_or_insert(0.5); self.p1_seepage_prob.get_or_insert(0.5); self.p2_seepage_prob.get_or_insert(0.5); - self.p_idle_coherent_to_incoherent_factor.get_or_insert(1.5); self } @@ -211,10 +202,6 @@ impl GeneralNoiseModelBuilder { // idle noise // ----------------------------------------------------------------------------------------- - if let Some(coherent) = self.p_idle_quadratic_coherent { - model.p_idle_quadratic_coherent = coherent; - } - if let Some(p_idle_linear_rate) = self.p_idle_linear_rate { model.p_idle_linear_rate = p_idle_linear_rate; } @@ -223,10 +210,6 @@ impl GeneralNoiseModelBuilder { model.p_idle_linear_model = model_map; } - if let Some(p_idle_quadratic_rate) = self.p_idle_quadratic_rate { - model.p_idle_quadratic_rate = p_idle_quadratic_rate; - } - if let Some((rate, sine_model)) = self.p_idle_sin_squared.clone() { model.p_idle_sin_squared_rate = rate; model.p_idle_sin_squared_model = sine_model; @@ -237,10 +220,6 @@ impl GeneralNoiseModelBuilder { model.p_idle_coherent_model = coherent_model; } - if let Some(factor) = self.p_idle_coherent_to_incoherent_factor { - model.p_idle_coherent_to_incoherent_factor = factor; - } - // prep noise // ----------------------------------------------------------------------------------------- if let Some(p_prep) = self.p_prep { @@ -408,55 +387,16 @@ impl GeneralNoiseModelBuilder { // --- idle noise --- // - /// Set whether the legacy quadratic idle rate uses coherent dephasing. - /// - /// This changes only how [`Self::with_p_idle_quadratic_rate`] is interpreted. It does not - /// configure the independent coherent idle family; use [`Self::with_p_idle_coherent`] for - /// that. - #[must_use] - pub fn with_p_idle_quadratic_coherent(mut self, use_coherent: bool) -> Self { - self.p_idle_quadratic_coherent = Some(use_coherent); - self - } - - /// Set the idling noise error rate for the linear term - #[must_use] - pub fn with_p_idle_linear_rate(mut self, rate: f64) -> Self { - self.p_idle_linear_rate = Some(Self::validate_non_negative(rate, "linear idling rate")); - self - } - - // TODO: See if we should put a average scaling... - /// Set the average idling noise error rate per channel for the linear term - #[must_use] - pub fn with_average_p_idle_linear_rate(mut self, rate: f64) -> Self { - let rate: f64 = rate * 3.0 / 2.0; - self.p_idle_linear_rate = Some(rate); - self - } - - /// Set the stochastic model for idling that is linearly dependent on time - #[must_use] - pub fn with_p_idle_linear_model(mut self, model: &BTreeMap) -> Self { - self.p_idle_linear_model = Some(SingleQubitWeightedSampler::new(model)); - self - } - /// Set the DEM-style linear idle-noise family. /// /// `rate` is the total event rate per time unit. For an idle of duration `d`, one event is /// sampled with probability `rate * d`, then its X, Y, Z, or leakage axis is drawn from /// `model`. The model must therefore be a normalized distribution: this linear family splits - /// one total rate across its axes. This is exactly the pairing convenience - /// `with_p_idle_linear_rate(rate).with_p_idle_linear_model(model)`. + /// one total rate across its axes. /// /// In contrast, [`Self::with_p_idle_sin_squared`] takes radians per time unit and an /// unnormalized model because sine laws do not add linearly: each axis carries its own - /// independent rate. That setter applies no `2*pi` conversion and no - /// `coherent_to_incoherent_factor`, unlike [`Self::with_p_idle_quadratic_rate`]. With neutral - /// global and idle scales, `with_p_idle_quadratic_rate(r)` equals - /// `with_p_idle_sin_squared(r * factor/2 * 2*pi, {"Z": 1.0})`, or `r * pi` at the - /// default factor. + /// independent rate. That setter applies no unit conversion. /// /// All engines idle-noise families are off by default, so translating a DEM configuration only /// requires setting the requested families. @@ -466,42 +406,27 @@ impl GeneralNoiseModelBuilder { /// independent per-axis mechanisms. The difference is second order in the rates; this setter /// aligns the units and axis alphabet, not that sampling structure. #[must_use] - pub fn with_p_idle_linear(self, rate: f64, model: &BTreeMap) -> Self { - self.with_p_idle_linear_rate(rate) - .with_p_idle_linear_model(model) - } - - /// Set the idling noise error rate for the quadratic term - #[must_use] - pub fn with_p_idle_quadratic_rate(mut self, rate: f64) -> Self { - self.p_idle_quadratic_rate = Some(rate); - self - } - - /// Set the average idling noise error rate per channel for the quadratic term - #[must_use] - pub fn with_average_p_idle_quadratic_rate(mut self, rate: f64) -> Self { - let rate: f64 = rate * (3.0 / 2.0_f64).sqrt(); - self.p_idle_quadratic_rate = Some(rate); + pub fn with_p_idle_linear(mut self, rate: f64, model: &BTreeMap) -> Self { + self.p_idle_linear_rate = Some(Self::validate_non_negative(rate, "linear idling rate")); + self.p_idle_linear_model = Some(SingleQubitWeightedSampler::new(model)); self } /// Set the DEM-style stochastic sine-squared idle-noise family. /// - /// `rate` is in radians per time unit. No `2*pi` conversion and no - /// `coherent_to_incoherent_factor` is applied, unlike - /// [`Self::with_p_idle_quadratic_rate`]. For each axis P with multiplier `n_P` and an idle of - /// duration `d`, engines independently samples `P(P) = sin^2(rate * n_P * d)`. + /// `rate` is in radians per time unit. No unit conversion is applied. For each axis P with + /// multiplier `n_P` and an idle of duration `d`, engines independently samples + /// `P(P) = sin^2(rate * n_P * d)`. /// /// The model accepts X, Y, Z, and L and is intentionally unnormalized: sine laws do not add /// linearly, so every axis carries its own independent rate. By comparison, /// [`Self::with_p_idle_linear`] requires a normalized distribution because its one total /// linear event rate is split across axes. /// - /// With neutral global and idle scales, `with_p_idle_quadratic_rate(r)` equals - /// `with_p_idle_sin_squared(r * factor/2 * 2*pi, {"Z": 1.0})`, or `r * pi` at the - /// default factor. The legacy spelling is in cycles per time unit and also folds the factor - /// into its runtime rate; this setter is the radians-per-time-unit spelling. + /// The removed cycles-per-time spelling migrates exactly as follows at its former default + /// factor of one: + /// + /// `with_p_idle_quadratic_rate(r) == with_p_idle_sin_squared(r * PI, {"Z": 1.0})` /// /// All engines idle-noise families are off by default, so translating a DEM configuration only /// requires setting the requested families. @@ -525,11 +450,10 @@ impl GeneralNoiseModelBuilder { /// Set the DEM-style coherent idle-noise family. /// - /// `rate` is in radians per time unit. No `2*pi` conversion and no - /// `coherent_to_incoherent_factor` is applied, just as for - /// [`Self::with_p_idle_sin_squared`]. For each RX/RY/RZ generator with multiplier `n_P` and - /// an idle of duration `d`, engines applies a deterministic rotation with angle - /// `rate * n_P * d`; coherent evolution is not sampled and consumes no random draw. + /// `rate` is in radians per time unit. No unit conversion is applied, just as for + /// [`Self::with_p_idle_sin_squared`]. For each RX/RY/RZ generator with multiplier `n_P` and an + /// idle of duration `d`, engines applies a deterministic rotation with angle `rate * n_P * d`; + /// coherent evolution is not sampled and consumes no random draw. /// /// The model is intentionally unnormalized because its values are relative rate multipliers, /// not probabilities to be split from one total event rate. The symmetric default model is @@ -553,19 +477,6 @@ impl GeneralNoiseModelBuilder { self } - /// Set the coherent-to-incoherent conversion factor - /// - /// # Parameters - /// * `factor` - The conversion factor between coherent and incoherent noise - #[must_use] - pub fn with_p_idle_coherent_to_incoherent_factor(mut self, factor: f64) -> Self { - self.p_idle_coherent_to_incoherent_factor = Some(Self::validate_positive( - factor, - "Coherent-to-incoherent factor", - )); - self - } - /// Set the scaling factor for idle noise /// /// Controls the strength of errors that occur during idle periods or memory operations. @@ -792,15 +703,12 @@ impl GeneralNoiseModelBuilder { /// Set the duration of the idle-noise site applied to each qubit after a two-qubit gate. /// - /// A duration of `0.0` disables these sites. Nonzero sites receive all configured idle - /// mechanisms over the given duration: linear stochastic noise from `p_idle_linear_rate` and - /// `p_idle_linear_model`, quadratic dephasing from `p_idle_quadratic_rate` honoring - /// `p_idle_quadratic_coherent`, plus the independent per-axis sine-squared and coherent - /// families. + /// A duration of `0.0` disables these sites. Nonzero sites receive every configured linear, + /// sine-squared, and coherent idle family over the given duration. /// /// Anyone who previously wrote `with_p2_idle(0.01)` and no linear rate now gets no after-2q /// idle noise; the equivalent is - /// `with_p_idle_linear_rate(0.01).with_idle_after_2q(1.0)`. + /// `with_p_idle_linear(0.01, model).with_idle_after_2q(1.0)`. #[must_use] pub fn with_idle_after_2q(mut self, duration: f64) -> Self { self.idle_after_2q = Some(Self::validate_duration(duration)); @@ -958,28 +866,12 @@ impl GeneralNoiseModelBuilder { } } - /// Validate combinations whose interpretation would otherwise depend on silent precedence. + /// Validate cross-field configuration invariants. /// /// # Errors /// - /// Returns a description of the conflicting spellings and their incompatible semantics. + /// Returns a description of the first invalid combination. pub fn validate_configuration(&self) -> Result<(), &'static str> { - if self.p_idle_sin_squared.is_some() && self.p_idle_quadratic_rate.is_some() { - return Err("with_p_idle_sin_squared cannot be combined with \ - with_p_idle_quadratic_rate/with_average_p_idle_quadratic_rate: \ - with_p_idle_sin_squared uses radians per time unit, while the legacy quadratic \ - spellings use cycles per time unit and apply coherent_to_incoherent_factor"); - } - if self.p_idle_sin_squared.is_some() && self.p_idle_quadratic_coherent == Some(true) { - return Err("with_p_idle_sin_squared cannot be combined with \ - with_p_idle_quadratic_coherent(true): \ - with_p_idle_sin_squared is stochastic by definition, while \ - with_p_idle_quadratic_coherent(true) selects the legacy coherent path"); - } - if self.p_idle_coherent.is_some() && self.p_idle_quadratic_coherent == Some(true) { - return Err("with_p_idle_coherent cannot be combined with \ - with_p_idle_quadratic_coherent(true): both would emit coherent idle rotations"); - } Ok(()) } @@ -1085,7 +977,6 @@ impl GeneralNoiseModelBuilder { // since neo matches engines' gate-removing emission with the default uniform distribution. let optional_features_off = zero_or_unset(self.p_prep_leak_ratio) && zero_or_unset(self.p_idle_linear_rate) - && zero_or_unset(self.p_idle_quadratic_rate) && self.p_idle_sin_squared.is_none() && self.p_idle_coherent.is_none() && zero_or_unset(self.p_prep_crosstalk) @@ -1229,17 +1120,6 @@ impl GeneralNoiseModelBuilder { model.p2_emission_ratio *= emission_scale * scale; model.p2_emission_ratio = model.p2_emission_ratio.min(1.0); - model.p_idle_quadratic_rate *= (idle_scale * scale).sqrt(); - - // If we need to do incoherent noise instead of coherent - if !model.p_idle_quadratic_coherent { - // 0.5 to deal with the 0.5 in sin(rate x duration x 0.5)^2 - let factor = model.p_idle_coherent_to_incoherent_factor * 0.5; - model.p_idle_quadratic_rate *= factor; - } - // frequency is in units of 2pi so convert to radians - model.p_idle_quadratic_rate *= 2.0 * std::f64::consts::PI; - model.p_idle_linear_rate = model.p_idle_linear_rate * scale * idle_scale; } } @@ -1276,11 +1156,15 @@ mod tests { assert_float_eq(model.p_prep_leak_ratio, 0.5); assert_float_eq(model.p1_seepage_prob, 0.5); assert_float_eq(model.p2_seepage_prob, 0.5); - assert_float_eq(model.p_idle_coherent_to_incoherent_factor, 1.5); } #[test] fn explicit_setter_beats_auto_in_both_orders() { + let linear_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); let set_explicit = |builder: GeneralNoiseModelBuilder| { builder .with_p_prep(0.11) @@ -1288,13 +1172,12 @@ mod tests { .with_p_meas_1(0.13) .with_p1(0.14) .with_p2(0.15) - .with_p_idle_linear_rate(0.16) + .with_p_idle_linear(0.16, &linear_model) .with_p1_emission_ratio(0.17) .with_p2_emission_ratio(0.18) .with_prep_leak_ratio(0.19) .with_p1_seepage_prob(0.20) .with_p2_seepage_prob(0.21) - .with_p_idle_coherent_to_incoherent_factor(1.25) }; let models = [ set_explicit(GeneralNoiseModelBuilder::new().auto()).build(), @@ -1313,10 +1196,38 @@ mod tests { assert_float_eq(model.p_prep_leak_ratio, 0.19); assert_float_eq(model.p1_seepage_prob, 0.20); assert_float_eq(model.p2_seepage_prob, 0.21); - assert_float_eq(model.p_idle_coherent_to_incoherent_factor, 1.25); } } + #[test] + fn retired_idle_builder_state_and_setters_are_absent_from_rust_source() { + let source = include_str!("builder.rs"); + let removed_setters = [ + "with_p_idle_linear_rate", + "with_p_idle_linear_model", + "with_p_idle_quadratic_rate", + "with_p_idle_quadratic_coherent", + "with_p_idle_coherent_to_incoherent_factor", + "with_average_p_idle_linear_rate", + "with_average_p_idle_quadratic_rate", + ]; + + for setter in removed_setters { + assert!( + !source.contains(&format!("pub fn {setter}")), + "{setter} must not remain on GeneralNoiseModelBuilder" + ); + } + + let removed_factor = ["p_idle_coherent_to_", "incoherent_factor"].concat(); + assert!( + !source.contains(&format!("{removed_factor}: Option")) + && !source.contains(&format!("self.{removed_factor}")) + && !source.contains(&format!("model.{removed_factor}")), + "the orphaned coherent-to-incoherent factor must not remain in builder state or auto()" + ); + } + #[test] fn auto_does_not_overwrite_explicit_zero() { let model = GeneralNoiseModelBuilder::new().with_p2(0.0).auto().build(); @@ -1356,7 +1267,6 @@ mod tests { .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0) .simple_probabilities() .expect("plain Pauli config is simple"); @@ -1375,7 +1285,6 @@ mod tests { .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0) .simple_probabilities() .expect("neutral defaults are simple"); @@ -1393,8 +1302,7 @@ mod tests { .with_average_p2(0.4) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) - .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0); + .with_prep_leak_ratio(0.0); let simple = builder.simple_probabilities().expect("simple config"); let (p_prep, p_meas_0, p_meas_1, p1, p2, angle, p1_emission, p2_emission) = builder @@ -1419,7 +1327,6 @@ mod tests { .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0) .with_p_prep(0.0) .with_p_meas_0(0.0) .with_p_meas_1(0.0); @@ -1444,7 +1351,6 @@ mod tests { .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0) .with_p_prep(0.0) .with_p_meas_0(0.0) .with_p_meas_1(0.0); @@ -1478,8 +1384,7 @@ mod tests { .with_average_p2(0.4) .with_p1_emission_ratio(0.25) .with_p2_emission_ratio(0.75) - .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0); + .with_prep_leak_ratio(0.0); // Non-zero emission is OUTSIDE the strict simple subset... assert!(builder.simple_probabilities().is_none()); @@ -1501,8 +1406,7 @@ mod tests { .with_average_p1(0.2) .with_p1_emission_ratio(0.25) .with_emission_scale(2.0) - .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0); + .with_prep_leak_ratio(0.0); // The built model applies the scale (0.25 * 2.0 = 0.5)... let built = builder.clone().build(); diff --git a/crates/pecos-engines/src/noise/general/default.rs b/crates/pecos-engines/src/noise/general/default.rs index 638438f67..104ace8e6 100644 --- a/crates/pecos-engines/src/noise/general/default.rs +++ b/crates/pecos-engines/src/noise/general/default.rs @@ -81,10 +81,8 @@ impl Default for GeneralNoiseModel { // No-effect defaults Self { p_prep: 0.0, - p_idle_quadratic_coherent: false, p_idle_linear_rate: 0.0, p_idle_linear_model: SingleQubitWeightedSampler::new(&p1_pauli_model), - p_idle_quadratic_rate: 0.0, p_idle_sin_squared_rate: 0.0, p_idle_sin_squared_model: BTreeMap::new(), p_idle_coherent_rate: 0.0, @@ -116,8 +114,6 @@ impl Default for GeneralNoiseModel { p_meas_crosstalk_local: 0.0, p_meas_crosstalk_model: CrosstalkWeightedSampler::new(&p_meas_crosstalk_model), p_prep_crosstalk: 0.0, - - p_idle_coherent_to_incoherent_factor: 1.0, noiseless_gates: BTreeSet::new(), p_meas_max: p_meas_0.max(p_meas_1), leakage_scale: 1.0, diff --git a/crates/pecos-engines/tests/noise_test.rs b/crates/pecos-engines/tests/noise_test.rs index 65788347e..7136f485c 100644 --- a/crates/pecos-engines/tests/noise_test.rs +++ b/crates/pecos-engines/tests/noise_test.rs @@ -615,6 +615,8 @@ fn test_software_gates_not_affected_by_noise() { #[test] fn test_coherent_vs_incoherent_dephasing() { const NUM_SHOTS: usize = 2000; + let coherent_idle_model = BTreeMap::from([("RZ".to_string(), 1.0)]); + let sine_idle_model = BTreeMap::from([("Z".to_string(), 1.0)]); // Create two noise models with different dephasing types using the builder pattern let coherent_model = GeneralNoiseModel::builder() @@ -623,7 +625,7 @@ fn test_coherent_vs_incoherent_dephasing() { .with_p_meas_1(0.01) .with_average_p1(0.05) .with_average_p2(0.1) - .with_p_idle_quadratic_coherent(true) + .with_p_idle_coherent(0.2, &coherent_idle_model) .with_seed(42) .build(); @@ -636,8 +638,7 @@ fn test_coherent_vs_incoherent_dephasing() { .with_p_meas_1(0.01) .with_average_p1(0.05) .with_average_p2(0.1) - .with_p_idle_quadratic_coherent(false) - .with_p_idle_coherent_to_incoherent_factor(2.0) + .with_p_idle_sin_squared(0.1, &sine_idle_model) .with_seed(42) .build(); @@ -646,7 +647,7 @@ fn test_coherent_vs_incoherent_dephasing() { // Create a dephasing test circuit: // 1. Prepare |+⟩ state with H - // 2. Wait a bit (we'll use a Z gate for simplicity instead of a true idle) + // 2. Wait for one time unit // 3. Apply H to convert phase to population // 4. Measure @@ -656,8 +657,7 @@ fn test_coherent_vs_incoherent_dephasing() { // Prepare |+⟩ state builder.h(&[0]); - // Add Z gate (as a simplified way to introduce phase) - builder.z(&[0]); + builder.idle(1.0, &[0]); // Convert phase to population builder.h(&[0]); diff --git a/crates/pecos-qasm/examples/general_noise_builder.rs b/crates/pecos-qasm/examples/general_noise_builder.rs index f5ee53ad2..d0f34b385 100644 --- a/crates/pecos-qasm/examples/general_noise_builder.rs +++ b/crates/pecos-qasm/examples/general_noise_builder.rs @@ -105,6 +105,11 @@ fn main() -> Result<(), Box> { // Example 4: Full configuration with all parameters println!("Example 4: Full noise configuration"); + let idle_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); let full_noise = GeneralNoiseModel::builder() .with_seed(456) .with_scale(1.2) @@ -117,8 +122,7 @@ fn main() -> Result<(), Box> { .with_average_p2(0.008) .with_p_meas_0(0.001) .with_p_meas_1(0.003) - .with_p_idle_quadratic_coherent(false) - .with_p_idle_linear_rate(0.0001) + .with_p_idle_linear(0.0001, &idle_model) .with_noiseless_gate(GateType::H) .with_noiseless_gate(GateType::CX); diff --git a/crates/pecos-qasm/examples/general_noise_config.rs b/crates/pecos-qasm/examples/general_noise_config.rs index 3d86a9913..7e14b0d79 100644 --- a/crates/pecos-qasm/examples/general_noise_config.rs +++ b/crates/pecos-qasm/examples/general_noise_config.rs @@ -11,6 +11,7 @@ use pecos_engines::noise::{ use pecos_engines::sim_builder; use pecos_programs::Qasm; use pecos_qasm::qasm_engine; +use std::collections::BTreeMap; fn main() -> Result<(), Box> { let qasm = r#" @@ -25,13 +26,18 @@ fn main() -> Result<(), Box> { // Example 1: General noise model with detailed configuration println!("Example 1: GeneralNoiseModelBuilder with unified API"); + let idle_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); let general_noise = GeneralNoiseModel::builder() .with_p1(0.001) .with_p2(0.01) .with_p_prep(0.001) .with_p_meas_0(0.001) .with_p_meas_1(0.001) - .with_p_idle_linear_rate(0.0001) + .with_p_idle_linear(0.0001, &idle_model) .with_idle_after_2q(1.0) .with_seed(42); diff --git a/crates/pecos-qasm/src/config.rs b/crates/pecos-qasm/src/config.rs index bf17d8cd2..5bf9ee4a5 100644 --- a/crates/pecos-qasm/src/config.rs +++ b/crates/pecos-qasm/src/config.rs @@ -61,15 +61,17 @@ pub struct GeneralNoiseFields { // Idle noise parameters #[serde(skip_serializing_if = "Option::is_none")] - pub p_idle_quadratic_coherent: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub p_idle_linear_rate: Option, + pub p_idle_linear: Option, #[serde(skip_serializing_if = "Option::is_none")] pub p_idle_linear_model: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub p_idle_quadratic_rate: Option, + pub p_idle_sin_squared: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub p_idle_sin_squared_model: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub p_idle_coherent_to_incoherent_factor: Option, + pub p_idle_coherent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub p_idle_coherent_model: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub idle_scale: Option, @@ -116,7 +118,7 @@ pub struct GeneralNoiseFields { pub p2_pauli_model: Option>, /// Duration of the idle-noise sites applied to both qubits after a two-qubit gate. /// - /// The configured linear and quadratic idle mechanisms determine the noise at these sites. + /// The configured idle families determine the noise at these sites. #[serde(skip_serializing_if = "Option::is_none")] pub idle_after_2q: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -208,20 +210,42 @@ impl GeneralNoiseFields { /// Apply idle noise parameters to the builder fn apply_idle_params(&self, mut builder: GeneralNoiseModelBuilder) -> GeneralNoiseModelBuilder { - if let Some(v) = self.p_idle_quadratic_coherent { - builder = builder.with_p_idle_quadratic_coherent(v); - } - if let Some(v) = self.p_idle_linear_rate { - builder = builder.with_p_idle_linear_rate(v); - } - if let Some(model) = self.p_idle_linear_model.as_ref() { - builder = builder.with_p_idle_linear_model(model); - } - if let Some(v) = self.p_idle_quadratic_rate { - builder = builder.with_p_idle_quadratic_rate(v); - } - if let Some(v) = self.p_idle_coherent_to_incoherent_factor { - builder = builder.with_p_idle_coherent_to_incoherent_factor(v); + if let Some(rate) = self.p_idle_linear { + let default_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); + builder = builder.with_p_idle_linear( + rate, + self.p_idle_linear_model.as_ref().unwrap_or(&default_model), + ); + } + if let Some(rate) = self.p_idle_sin_squared { + let default_model = BTreeMap::from([ + ("X".to_string(), 1.0), + ("Y".to_string(), 1.0), + ("Z".to_string(), 1.0), + ]); + builder = builder.with_p_idle_sin_squared( + rate, + self.p_idle_sin_squared_model + .as_ref() + .unwrap_or(&default_model), + ); + } + if let Some(rate) = self.p_idle_coherent { + let default_model = BTreeMap::from([ + ("RX".to_string(), 1.0), + ("RY".to_string(), 1.0), + ("RZ".to_string(), 1.0), + ]); + builder = builder.with_p_idle_coherent( + rate, + self.p_idle_coherent_model + .as_ref() + .unwrap_or(&default_model), + ); } if let Some(v) = self.idle_scale { builder = builder.with_idle_scale(v); diff --git a/crates/pecos-qasm/tests/general_noise_builder_test.rs b/crates/pecos-qasm/tests/general_noise_builder_test.rs index 3979ed1ce..ccc2990ad 100644 --- a/crates/pecos-qasm/tests/general_noise_builder_test.rs +++ b/crates/pecos-qasm/tests/general_noise_builder_test.rs @@ -281,6 +281,11 @@ fn test_general_noise_builder_chaining_all_methods() { "#; // Test that all builder methods can be chained + let idle_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); let noise_builder = GeneralNoiseModel::builder() .with_seed(42) .with_scale(1.2) @@ -293,8 +298,7 @@ fn test_general_noise_builder_chaining_all_methods() { .with_average_p2(0.008) .with_p_meas_0(0.002) .with_p_meas_1(0.003) - .with_p_idle_quadratic_coherent(false) - .with_p_idle_linear_rate(0.0001) + .with_p_idle_linear(0.0001, &idle_model) .with_noiseless_gate(GateType::H) .with_noiseless_gate(GateType::CX); diff --git a/crates/pecos-qasm/tests/general_noise_builder_test.rs.disabled b/crates/pecos-qasm/tests/general_noise_builder_test.rs.disabled index cff8fda20..54a2385b5 100644 --- a/crates/pecos-qasm/tests/general_noise_builder_test.rs.disabled +++ b/crates/pecos-qasm/tests/general_noise_builder_test.rs.disabled @@ -272,6 +272,11 @@ fn test_general_noise_builder_chaining_all_methods() { "#; // Test that all builder methods can be chained + let idle_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); let noise_builder = GeneralNoiseModel::builder() .with_seed(42) .with_scale(1.2) @@ -284,8 +289,7 @@ fn test_general_noise_builder_chaining_all_methods() { .with_average_p2(0.008) .with_p_meas_0(0.002) .with_p_meas_1(0.003) - .with_p_idle_quadratic_coherent(false) - .with_p_idle_linear_rate(0.0001) + .with_p_idle_linear(0.0001, &idle_model) .with_noiseless_gate(GateType::H) .with_noiseless_gate(GateType::CX); diff --git a/crates/pecos-qasm/tests/general_noise_config_test.rs.disabled b/crates/pecos-qasm/tests/general_noise_config_test.rs.disabled index d9a6b2ad9..62abe09ce 100644 --- a/crates/pecos-qasm/tests/general_noise_config_test.rs.disabled +++ b/crates/pecos-qasm/tests/general_noise_config_test.rs.disabled @@ -68,8 +68,7 @@ fn test_general_noise_json_complex() { "ZY": 0.06, "ZZ": 0.04 }, - "p_idle_quadratic_coherent": false, - "p_idle_linear_rate": 0.0001, + "p_idle_linear": 0.0001, "leakage_scale": 0.5, "emission_scale": 0.8, "p2_angle_params": [1.0, 0.5, 1.2, 0.3], @@ -89,8 +88,7 @@ fn test_general_noise_json_complex() { ); assert!(fields.p1_pauli_model.is_some()); assert!(fields.p2_pauli_model.is_some()); - assert_eq!(fields.p_idle_quadratic_coherent, Some(false)); - assert_eq!(fields.p_idle_linear_rate, Some(0.0001)); + assert_eq!(fields.p_idle_linear, Some(0.0001)); assert_eq!(fields.leakage_scale, Some(0.5)); assert_eq!(fields.emission_scale, Some(0.8)); assert_eq!(fields.p2_angle_params, Some((1.0, 0.5, 1.2, 0.3))); diff --git a/crates/pecos/tests/neo_emission_test.rs b/crates/pecos/tests/neo_emission_test.rs index 6221220ff..be54e7399 100644 --- a/crates/pecos/tests/neo_emission_test.rs +++ b/crates/pecos/tests/neo_emission_test.rs @@ -71,7 +71,6 @@ fn emission_noise_1q() -> pecos_engines::noise::GeneralNoiseModelBuilder { .with_p_meas_0(0.0) .with_p_meas_1(0.0) .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0) } fn engines_zero_count() -> u64 { @@ -200,7 +199,6 @@ fn emission_noise_2q() -> pecos_engines::noise::GeneralNoiseModelBuilder { .with_p_meas_0(0.0) .with_p_meas_1(0.0) .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0) } fn engines_2q_zero_count() -> u64 { diff --git a/crates/pecos/tests/neo_equivalence_matrix_test.rs b/crates/pecos/tests/neo_equivalence_matrix_test.rs index 7af70edfd..8365f094f 100644 --- a/crates/pecos/tests/neo_equivalence_matrix_test.rs +++ b/crates/pecos/tests/neo_equivalence_matrix_test.rs @@ -184,7 +184,6 @@ impl NoiseCell { .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0) .with_p_prep(0.0) .with_p_meas_0(p_meas) .with_p_meas_1(p_meas), @@ -207,7 +206,6 @@ impl NoiseCell { .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0) .with_p_prep(0.0) .with_p_meas_0(0.0) .with_p_meas_1(0.0), diff --git a/crates/pecos/tests/neo_routing_test.rs b/crates/pecos/tests/neo_routing_test.rs index c81e5f482..c745f1cc1 100644 --- a/crates/pecos/tests/neo_routing_test.rs +++ b/crates/pecos/tests/neo_routing_test.rs @@ -342,8 +342,7 @@ fn neo_stack_rejects_nonunit_emission_scale() { .with_p_prep(0.0) .with_p_meas_0(0.0) .with_p_meas_1(0.0) - .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0); + .with_prep_leak_ratio(0.0); let err = sim(deterministic_conditional_qasm()) .stack(SimStack::Neo) .noise(general) diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index ede099c93..63a7a4f40 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -268,6 +268,14 @@ else: raise AssertionError("grouped and flat noise must not be mixed") ``` +`NoiseParameters.p2_szz` and `p2_szzdg` are gate-type total-rate overrides. +When either is unset, that gate inherits the shared `p2` rate, so omitting both +causes no DEM/simulator divergence. A non-default override is a documented API +gap: it is not expressible through the engines `general_noise()` builder. Neo +can represent the distinction with a `PerGatePauliChannel`, but the standard +Guppy DEM entry points reject explicit `p2_szz` or `p2_szzdg` values rather than +silently dropping them. + ## Idle Noise The recommended structured interface has three rate-and-model families. Every @@ -313,11 +321,10 @@ compatibility, but reject it at call time when its weight is nonzero. A zero `L` weight is silently accepted. Multi-qubit idle faults are outside the scope of these keyword arguments and will arrive through a typed channel interface. -These rates match the engines *runtime* application semantics -(`GeneralNoiseModel`'s internal fields); the engines `GeneralNoiseModelBuilder` -additionally rescales its public inputs (square-root scaling, an -incoherent-conversion factor, and cycles-to-radians), so builder inputs are -not directly interchangeable with these parameters. +These rates match the engines family setters and runtime application semantics. +The removed quadratic builder spelling was the exception: its input was in +cycles per time and was converted before the runtime saw it. Family sine rates +are in radians per time and are not converted. Every residual is readable from `dem.idle_noise_residuals` as a dictionary containing `channel_kind`, `location_index`, the concrete @@ -359,9 +366,25 @@ Migration from the removed setters is mechanical: The last row is intentionally Z-only: despite its axis-free name, `NoiseParameters.with_p_idle_linear_rate` configured only the Z channel. The -identically named setter on `general_noise()` is different and remains live: it -configures a total linear rate split according to its model. Copying a numeric -value between those old interfaces therefore did not preserve the channel. +identically named setter on `general_noise()` configured a total linear rate +split according to its model and has now also been removed. Migrate that +engines spelling to `with_p_idle_linear(r, model)`, passing the symmetric +`{"X": 1/3, "Y": 1/3, "Z": 1/3}` model if the old model setter was not used. +Copying a numeric value between the two old interfaces did not preserve the +channel. + +The engines quadratic migration has a unit conversion that must not be +omitted. At the removed path's default coherent-to-incoherent factor of `1.0`: + +```text +with_p_idle_quadratic_rate(r) == with_p_idle_sin_squared(r * PI, {"Z": 1.0}) +``` + +The left-hand rate was in cycles per time; the family rate is in radians per +time. The orphaned `with_p_idle_coherent_to_incoherent_factor` setter has no +replacement. The two `with_average_p_idle_*_rate` spellings were also removed: +a gate-channel average-error conversion does not define a duration-independent +idle-family rate, especially for the nonlinear sine-squared law. The default Selene runtime does not emit idle gates. These parameters and `t1`/`t2` therefore have no locations to attach to unless the runtime supplies diff --git a/docs/user-guide/noise-model-builders.md b/docs/user-guide/noise-model-builders.md index 05b5fe4c4..7df79fed9 100644 --- a/docs/user-guide/noise-model-builders.md +++ b/docs/user-guide/noise-model-builders.md @@ -145,11 +145,29 @@ Configure idle decoherence with any combination of these independent families: multipliers, not probabilities. Omitting the Python model uses `{"RX": 1.0, "RY": 1.0, "RZ": 1.0}`. -The legacy `with_p_idle_quadratic_rate(...)` path remains available. -`with_p_idle_quadratic_coherent(...)` selects whether that one legacy rate emits -coherent RZ rotations or a stochastic sine-squared Z twirl; it does not control -the independent coherent family. Combining that switch set to `True` with the -coherent family is rejected because both would emit idle rotations. +The unpaired legacy idle setters have been removed. Migrate them as follows: + +| Removed setter | Replacement | +|---|---| +| `with_p_idle_linear_rate(r)` | `with_p_idle_linear(r, model)`; use the symmetric `{"X": 1/3, "Y": 1/3, "Z": 1/3}` model if no model was previously set | +| `with_p_idle_linear_model(m)` | `with_p_idle_linear(r, m)`; the rate and normalized model are now configured together | +| `with_p_idle_quadratic_rate(r)` | `with_p_idle_sin_squared(r * PI, {"Z": 1.0})` | +| `with_p_idle_quadratic_coherent(true)` | `with_p_idle_coherent(rate, model)`; choose the coherent family instead of switching another law's mode | +| `with_p_idle_quadratic_coherent(false)` | `with_p_idle_sin_squared(rate, model)`; choose the stochastic family directly | +| `with_p_idle_coherent_to_incoherent_factor(f)` | No replacement; the factor only modified the removed quadratic-rate path | +| `with_average_p_idle_linear_rate(r)` / `with_average_p_idle_quadratic_rate(r)` | No replacement; a gate-channel average-error conversion is not duration independent for a rate-times-duration law | + +The old quadratic rate was in cycles per time and was converted before it +reached the runtime. Family rates are in radians per time and receive no such +conversion. At the removed path's default factor of `1.0`, the exact migration +is: + +```text +with_p_idle_quadratic_rate(r) == with_p_idle_sin_squared(r * PI, {"Z": 1.0}) +``` + +Copying `r` directly into the family setter changes the channel by a factor of +pi. Coherent evolution is not sampled and consumes no RNG draws. Whether it can be consumed depends on the downstream consumer: the standard DEM builder rejects @@ -161,16 +179,18 @@ To add the same kind of idle-noise site to both qubits after every two-qubit gate, set its duration with `with_idle_after_2q(...)`: ```python -noise = GeneralNoiseModelBuilder().with_p_idle_linear_rate(0.01).with_idle_after_2q(1.0) +noise = ( + GeneralNoiseModelBuilder().with_p_idle_linear(0.01, {"X": 1 / 3, "Y": 1 / 3, "Z": 1 / 3}).with_idle_after_2q(1.0) +) ``` The duration only chooses where and how long idling occurs. It is not a -standalone probability: all configured linear, sine-squared, coherent, and -legacy quadratic idle mechanisms apply at these sites just as they do at a +standalone probability: all configured linear, sine-squared, and coherent idle +families apply at these sites just as they do at a scheduled `Idle` gate. A duration of `0.0` disables the after-two-qubit sites. Consequently, code that previously used `with_p2_idle(0.01)` without a linear idle rate now produces no after-2q idle noise; the equivalent configuration is -`with_p_idle_linear_rate(0.01).with_idle_after_2q(1.0)`. +`with_p_idle_linear(0.01, {"X": 1/3, "Y": 1/3, "Z": 1/3}).with_idle_after_2q(1.0)`. ## Common Noise Model Examples diff --git a/docs/user-guide/qasm-simulation.md b/docs/user-guide/qasm-simulation.md index 52d72fac3..fa187bd1c 100644 --- a/docs/user-guide/qasm-simulation.md +++ b/docs/user-guide/qasm-simulation.md @@ -291,14 +291,20 @@ For research or to match specific hardware characteristics, you can create detai ```rust use pecos::noise::GeneralNoiseModelBuilder; + use std::collections::BTreeMap; + let idle_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); let noise = GeneralNoiseModelBuilder::new() .with_p_prep(0.001) // State prep error .with_p_meas_0(0.005) // Measurement error |0> → |1> .with_p_meas_1(0.01) // Measurement error |1> → |0> .with_p1(0.0001) // Single-qubit gate error .with_p2(0.01) // Two-qubit gate error - .with_p_idle_linear_rate(0.0001) // Idle noise rate + .with_p_idle_linear(0.0001, &idle_model) // Idle noise rate .with_idle_after_2q(1.0) // Idle duration after two-qubit gates .with_seed(42); // Deterministic noise diff --git a/examples/python_examples/noise_builder_example.py b/examples/python_examples/noise_builder_example.py index ee8279082..47aa4ac46 100755 --- a/examples/python_examples/noise_builder_example.py +++ b/examples/python_examples/noise_builder_example.py @@ -140,7 +140,7 @@ def ion_trap_noise() -> None: # Two-qubit gates are limiting factor .with_average_p2(0.003) # 0.3% error # Apply configured idle noise for one time unit after each two-qubit gate - .with_p_idle_linear_rate(0.0001) + .with_p_idle_linear(0.0001, {"X": 1 / 3, "Y": 1 / 3, "Z": 1 / 3}) .with_idle_after_2q(1.0) # Asymmetric measurement .with_p_meas_0(0.001) # Dark state error diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index 7acfba0a7..64ce3ad52 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -556,7 +556,7 @@ def _noise_model_description(args: argparse.Namespace) -> str: sim_noise_model = getattr(args, "sim_noise_model", "depolarizing") base = f"p1={p1s:.4g}*p, p2=p, p_meas={pms:.4g}*p, p_prep={pps:.4g}*p" if sim_noise_model == "general": - return f"general_noise runtime ({base}, leak2depolar=True, p_idle_quadratic_coherent=False)" + return f"general_noise runtime ({base}, leak2depolar=True)" return f"depolarizing runtime ({base})" @@ -954,7 +954,6 @@ def run_direct_selene_backend(*, simulator: object) -> dict[str, list[list[int]] backend_start = time.perf_counter() noise_start = time.perf_counter() if sim_noise_model == "general": - use_coherent_idle = False noise_model = ( pecos.general_noise() .with_p_prep(physical_error_rate * p_prep_scale) @@ -962,7 +961,6 @@ def run_direct_selene_backend(*, simulator: object) -> dict[str, list[list[int]] .with_p1(physical_error_rate * p1_scale) .with_p2(physical_error_rate) .with_leakage_scale(0.0) - .with_p_idle_quadratic_coherent(use_coherent_idle) .with_seed(seed) ) elif sim_noise_model == "depolarizing": @@ -3724,10 +3722,7 @@ def _parse_args() -> argparse.Namespace: "--sim-noise-model", choices=["depolarizing", "general"], default="depolarizing", - help=( - "Runtime noise model used by --sample-backend sim. The 'general' " - "option sets leak2depolar=True and p_idle_quadratic_coherent=False." - ), + help=("Runtime noise model used by --sample-backend sim. The 'general' option sets leak2depolar=True."), ) parser.add_argument( "--dem-mode", diff --git a/python/pecos-rslib/src/engine_builders.rs b/python/pecos-rslib/src/engine_builders.rs index 959aa1587..e1d053918 100644 --- a/python/pecos-rslib/src/engine_builders.rs +++ b/python/pecos-rslib/src/engine_builders.rs @@ -915,8 +915,6 @@ impl PyGeneralNoiseModelBuilder { /// branches cause no leakage. /// * ``p1_seepage_prob = p2_seepage_prob = 0.5`` applies only to qubits that are already /// leaked. - /// * ``p_idle_coherent_to_incoherent_factor = 1.5`` inflates stochastic quadratic idle - /// dephasing by 50% relative to the exact Pauli twirl. fn auto(&self) -> Self { Self { inner: self.inner.clone().auto(), @@ -1088,40 +1086,14 @@ impl PyGeneralNoiseModelBuilder { }) } - /// Set whether the legacy quadratic idle rate uses coherent dephasing. - /// - /// This switch affects only ``with_p_idle_quadratic_rate``. Use - /// ``with_p_idle_coherent`` to configure the independent coherent family. - fn with_p_idle_quadratic_coherent(&self, use_coherent: bool) -> PyResult { - Ok(Self { - inner: self - .inner - .clone() - .with_p_idle_quadratic_coherent(use_coherent), - }) - } - - /// Set the idling noise error rate for the linear term - fn with_p_idle_linear_rate(&self, rate: f64) -> PyResult { - Ok(Self { - inner: self.inner.clone().with_p_idle_linear_rate(rate), - }) - } - /// Set the DEM-style linear idle-noise family. /// /// ``rate`` is the total event rate per time unit. The X/Y/Z/L ``model`` must be a /// normalized distribution because this family splits one total linear rate across axes. - /// This is a pairing convenience over ``with_p_idle_linear_rate`` plus - /// ``with_p_idle_linear_model``. /// /// By contrast, ``with_p_idle_sin_squared`` uses radians per time unit and unnormalized /// relative multipliers because sine laws do not add linearly: each axis has its own - /// independent rate. It applies no ``2*pi`` conversion and no - /// ``coherent_to_incoherent_factor``, unlike ``with_p_idle_quadratic_rate``. With neutral - /// global and idle scales, ``with_p_idle_quadratic_rate(r)`` equals - /// ``with_p_idle_sin_squared(r * factor/2 * 2*pi, {"Z": 1.0})``, or ``r * pi`` at the - /// default factor. + /// independent rate. It applies no unit conversion. /// /// All engines idle-noise families are off by default, so translating a DEM configuration only /// requires setting the requested families. Engines keeps its existing linear sampling @@ -1138,28 +1110,23 @@ impl PyGeneralNoiseModelBuilder { }) } - /// Set the idling noise error rate for the quadratic term - fn with_p_idle_quadratic_rate(&self, rate: f64) -> PyResult { - Ok(Self { - inner: self.inner.clone().with_p_idle_quadratic_rate(rate), - }) - } - /// Set the DEM-style stochastic sine-squared idle-noise family. /// - /// ``rate`` is radians per time unit; no ``2*pi`` conversion and no - /// ``coherent_to_incoherent_factor`` is applied, unlike - /// ``with_p_idle_quadratic_rate``. For each X/Y/Z/L axis P, multiplier ``n_P``, and duration - /// ``d``, engines independently samples ``P(P) = sin^2(rate * n_P * d)``. + /// ``rate`` is radians per time unit and no unit conversion is applied. For each X/Y/Z/L axis + /// P, multiplier ``n_P``, and duration ``d``, engines independently samples + /// ``P(P) = sin^2(rate * n_P * d)``. /// /// The model is intentionally unnormalized because sine laws do not add linearly: each axis /// carries its own independent rate. ``with_p_idle_linear`` instead requires a normalized /// distribution because it splits one total linear rate across axes. /// - /// With neutral global and idle scales, ``with_p_idle_quadratic_rate(r)`` equals - /// ``with_p_idle_sin_squared(r * factor/2 * 2*pi, {"Z": 1.0})``, or ``r * pi`` at the - /// default factor. All engines idle-noise families are off by default, so translating a DEM - /// configuration only requires setting the requested families. + /// The removed cycles-per-time spelling migrates exactly as follows at its former default + /// factor of one: + /// + /// ``with_p_idle_quadratic_rate(r) == with_p_idle_sin_squared(r * PI, {"Z": 1.0})`` + /// + /// All engines idle-noise families are off by default, so translating a DEM configuration + /// only requires setting the requested families. /// /// Engines deliberately retains its existing linear sampling structure: one event followed /// by a categorical axis choice, versus the DEM's independent per-axis mechanisms. The @@ -1177,10 +1144,10 @@ impl PyGeneralNoiseModelBuilder { /// Set the DEM-style coherent idle-noise family. /// - /// ``rate`` is radians per time unit. No ``2*pi`` conversion and no - /// ``coherent_to_incoherent_factor`` is applied. For each RX/RY/RZ generator P, multiplier - /// ``n_P``, and duration ``d``, engines deterministically applies a rotation with angle - /// ``rate * n_P * d``. Coherent evolution is not sampled and consumes no random draw. + /// ``rate`` is radians per time unit and no unit conversion is applied. For each RX/RY/RZ + /// generator P, multiplier ``n_P``, and duration ``d``, engines deterministically applies a + /// rotation with angle ``rate * n_P * d``. Coherent evolution is not sampled and consumes no + /// random draw. /// /// The model is intentionally unnormalized because its values are relative rate multipliers, /// not probabilities to be split from one total event rate. It defaults to @@ -1219,42 +1186,6 @@ impl PyGeneralNoiseModelBuilder { }) } - /// Set the stochastic model for idling that is linearly dependent on time - fn with_p_idle_linear_model( - &self, - model: std::collections::BTreeMap, - ) -> PyResult { - use std::collections::BTreeMap; - let btree_map: BTreeMap = model.into_iter().collect(); - Ok(Self { - inner: self.inner.clone().with_p_idle_linear_model(&btree_map), - }) - } - - /// Set coherent to incoherent noise conversion factor - fn with_p_idle_coherent_to_incoherent_factor(&self, factor: f64) -> PyResult { - Ok(Self { - inner: self - .inner - .clone() - .with_p_idle_coherent_to_incoherent_factor(factor), - }) - } - - /// Set the average idling noise error rate per channel for the linear term - fn with_average_p_idle_linear_rate(&self, rate: f64) -> PyResult { - Ok(Self { - inner: self.inner.clone().with_average_p_idle_linear_rate(rate), - }) - } - - /// Set the average idling noise error rate per channel for the quadratic term - fn with_average_p_idle_quadratic_rate(&self, rate: f64) -> PyResult { - Ok(Self { - inner: self.inner.clone().with_average_p_idle_quadratic_rate(rate), - }) - } - /// Set idle scale factor fn with_idle_scale(&self, scale: f64) -> PyResult { Ok(Self { @@ -1366,14 +1297,12 @@ impl PyGeneralNoiseModelBuilder { /// Set the duration of the idle-noise site applied to each qubit after a two-qubit gate. /// /// A duration of `0.0` disables these sites. Nonzero sites receive all configured idle - /// mechanisms over the given duration: linear stochastic noise from `p_idle_linear_rate` and - /// `p_idle_linear_model`, quadratic dephasing from `p_idle_quadratic_rate` honoring - /// `p_idle_quadratic_coherent`, plus the independent per-axis sine-squared and coherent - /// families. + /// families over the given duration: linear stochastic noise, independent per-axis + /// sine-squared noise, and coherent rotations. /// /// Anyone who previously wrote `with_p2_idle(0.01)` and no linear rate now gets no after-2q /// idle noise; the equivalent is - /// `with_p_idle_linear_rate(0.01).with_idle_after_2q(1.0)`. + /// `with_p_idle_linear(0.01, {"Z": 1.0}).with_idle_after_2q(1.0)`. fn with_idle_after_2q(&self, duration: f64) -> PyResult { Ok(Self { inner: self.inner.clone().with_idle_after_2q(duration), diff --git a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs index 2be33dbd4..b4dece83b 100644 --- a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs +++ b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs @@ -170,6 +170,7 @@ fn test_user_guide_qasm_simulation_rust_5() -> Result<(), Box Result<(), Box → |1> .with_p_meas_1(0.01) // Measurement error |1> → |0> .with_p1(0.0001) // Single-qubit gate error .with_p2(0.01) // Two-qubit gate error - .with_p_idle_linear_rate(0.0001) // Idle noise rate + .with_p_idle_linear(0.0001, &idle_model) // Idle noise rate .with_idle_after_2q(1.0) // Idle duration after two-qubit gates .with_seed(42); // Deterministic noise diff --git a/python/quantum-pecos/tests/guppy/test_noise_models.py b/python/quantum-pecos/tests/guppy/test_noise_models.py index 677e59bb9..5bca21ed8 100644 --- a/python/quantum-pecos/tests/guppy/test_noise_models.py +++ b/python/quantum-pecos/tests/guppy/test_noise_models.py @@ -196,7 +196,7 @@ def test_general_noise_idle_after_2q_api() -> None: builder = general_noise() assert callable(builder.with_idle_after_2q) - assert builder.with_p_idle_linear_rate(0.01).with_idle_after_2q(1.0) is not None + assert builder.with_p_idle_linear(0.01, {"X": 1 / 3, "Y": 1 / 3, "Z": 1 / 3}).with_idle_after_2q(1.0) is not None assert not hasattr(builder, "with_p2_idle") diff --git a/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py b/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py index 748fa8c5d..75643f013 100644 --- a/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py +++ b/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py @@ -50,6 +50,18 @@ "with_average_p2_probability", ) +RETIRED_IDLE_SETTERS = ( + "with_p_idle_linear_rate", + "with_p_idle_linear_model", + "with_p_idle_quadratic_rate", + "with_p_idle_quadratic_coherent", + "with_p_idle_coherent_to_incoherent_factor", + "with_average_p_idle_linear_rate", + "with_average_p_idle_quadratic_rate", +) + +SYMMETRIC_LINEAR_MODEL = {"X": 1.0 / 3.0, "Y": 1.0 / 3.0, "Z": 1.0 / 3.0} + _AFTER_2Q_QASM = """ OPENQASM 2.0; include "qelib1.inc"; @@ -111,6 +123,13 @@ def test_suffixed_setters_are_gone(factory, _setters) -> None: assert not hasattr(builder, removed), f"{removed} should have been renamed away" +def test_retired_idle_setters_are_gone() -> None: + """The unpaired and unit-converting idle spellings are absent from pyo3.""" + builder = general_noise() + for removed in RETIRED_IDLE_SETTERS: + assert not hasattr(builder, removed), f"{removed} should have been retired" + + def test_average_setters_keep_their_conversion() -> None: """``with_average_p*`` survives the rename; it converts from average gate error.""" builder = general_noise() @@ -129,10 +148,22 @@ def deterministic_x() -> bool: return measure(q) auto_then_zeros = ( - general_noise().auto().with_p_prep(0.0).with_p1(0.0).with_p2(0.0).with_p_meas(0.0).with_p_idle_linear_rate(0.0) + general_noise() + .auto() + .with_p_prep(0.0) + .with_p1(0.0) + .with_p2(0.0) + .with_p_meas(0.0) + .with_p_idle_linear(0.0, SYMMETRIC_LINEAR_MODEL) ) zeros_then_auto = ( - general_noise().with_p_prep(0.0).with_p1(0.0).with_p2(0.0).with_p_meas(0.0).with_p_idle_linear_rate(0.0).auto() + general_noise() + .with_p_prep(0.0) + .with_p1(0.0) + .with_p2(0.0) + .with_p_meas(0.0) + .with_p_idle_linear(0.0, SYMMETRIC_LINEAR_MODEL) + .auto() ) for noise in (auto_then_zeros, zeros_then_auto): @@ -158,13 +189,12 @@ def deterministic_x() -> bool: .with_p_meas_1(0.01) .with_p1(0.001) .with_p2(0.01) - .with_p_idle_linear_rate(0.001) + .with_p_idle_linear(0.001, SYMMETRIC_LINEAR_MODEL) .with_p1_emission_ratio(0.5) .with_p2_emission_ratio(0.5) .with_prep_leak_ratio(0.5) .with_p1_seepage_prob(0.5) .with_p2_seepage_prob(0.5) - .with_p_idle_coherent_to_incoherent_factor(1.5) ) def run(noise) -> list[bool]: @@ -180,28 +210,11 @@ def run(noise) -> list[bool]: def test_idle_family_setters_are_chainable() -> None: - """All structured idle families and the renamed quadratic switch are fluent.""" + """All structured idle families are fluent.""" builder = general_noise().with_p_idle_linear(0.01, {"X": 0.5, "L": 0.5}) builder = builder.with_p_idle_sin_squared(0.02, {"X": 1.0, "Z": 2.0, "L": 0.25}) builder = builder.with_p_idle_coherent(0.03, {"RX": 1.0, "RZ": 2.0}) - assert builder.with_p_idle_quadratic_coherent(False) is not None - - -def test_general_noise_linear_rate_setter_keeps_total_rate_family_semantics() -> None: - """The live engines spelling remains a total rate split by its model.""" - - uniform_model = {"X": 1.0 / 3.0, "Y": 1.0 / 3.0, "Z": 1.0 / 3.0} - otherwise_noiseless = ( - general_noise().with_p_prep(0.0).with_p1(0.0).with_p2(0.0).with_p_meas(0.0).with_idle_after_2q(1.0) - ) - legacy_spelling = otherwise_noiseless.with_p_idle_linear_rate(1.0) - total_rate_family = otherwise_noiseless.with_p_idle_linear(1.0, uniform_model) - z_only_family = otherwise_noiseless.with_p_idle_linear(1.0, {"Z": 1.0}) - - legacy_results = _run_after_2q_noise(legacy_spelling, shots=64, seed=1234) - assert legacy_results == _run_after_2q_noise(total_rate_family, shots=64, seed=1234) - assert _run_after_2q_noise(z_only_family, shots=64, seed=1234) == [0] * 64 - assert legacy_results != [0] * 64 + assert builder is not None def test_retired_coherent_bool_switch_is_not_an_alias() -> None: @@ -242,27 +255,6 @@ def test_sine_idle_multipliers_are_not_normalized() -> None: assert _run_after_2q_noise(noise, 32) == [3] * 32 -def test_legacy_quadratic_and_sine_family_conflict_at_build() -> None: - """The pyo3 build route names both incompatible rate spellings and their units.""" - noise = general_noise().with_p_idle_quadratic_rate(0.01).with_p_idle_sin_squared(0.02, {"Z": 1.0}) - with pytest.raises(ValueError, match=r"with_p_idle_quadratic_rate.*radians.*cycles"): - _run_after_2q_noise(noise) - - -def test_sine_family_and_coherent_legacy_path_conflict_at_build() -> None: - """The stochastic family cannot silently ignore the legacy coherent switch.""" - noise = general_noise().with_p_idle_sin_squared(0.02, {"Z": 1.0}).with_p_idle_quadratic_coherent(True) - with pytest.raises(ValueError, match=r"with_p_idle_quadratic_coherent\(true\).*stochastic by definition"): - _run_after_2q_noise(noise) - - -def test_coherent_family_and_quadratic_coherent_path_conflict_at_build() -> None: - """The independent and legacy coherent paths cannot both emit rotations.""" - noise = general_noise().with_p_idle_coherent(0.02, {"RZ": 1.0}).with_p_idle_quadratic_coherent(True) - with pytest.raises(ValueError, match=r"with_p_idle_coherent.*with_p_idle_quadratic_coherent\(true\)"): - _run_after_2q_noise(noise) - - @pytest.mark.parametrize("model", [{"L": 1.0}, {"A": 1.0}]) def test_coherent_idle_family_rejects_non_rotation_keys(model: dict[str, float]) -> None: """Leakage and unknown generators are rejected instead of being treated as rotations.""" From 87532e8426bd39d9423e112f70f5e29eddeea52a Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 5 Aug 2026 23:23:26 -0600 Subject: [PATCH 48/62] Remove the remaining pure aliases from the depolarizing noise builders --- .../benches/modules/noise_models.rs | 8 +-- .../examples/compare_noise_models.rs | 4 +- .../src/noise/biased_depolarizing.rs | 34 +++++----- .../pecos-engines/src/noise/depolarizing.rs | 62 +++++++++++++------ .../tests/neo_equivalence_matrix_test.rs | 4 +- python/pecos-rslib/src/engine_builders.rs | 12 ---- .../tests/guppy/test_missing_coverage.py | 2 +- .../pecos/test_noise_builder_setter_names.py | 22 +++++++ 8 files changed, 89 insertions(+), 59 deletions(-) diff --git a/crates/benchmarks/benches/modules/noise_models.rs b/crates/benchmarks/benches/modules/noise_models.rs index 135c7d970..fb0f9b71a 100644 --- a/crates/benchmarks/benches/modules/noise_models.rs +++ b/crates/benchmarks/benches/modules/noise_models.rs @@ -99,8 +99,8 @@ fn bench_depolarizing_noise(c: &mut Criterion) { let mut noise = DepolarizingNoiseModel::builder() .with_p_prep(0.001) .with_p_meas(0.001) - .with_single_qubit_probability(0.0005) - .with_two_qubit_probability(0.002) + .with_p1(0.0005) + .with_p2(0.002) .with_seed(42) .build(); @@ -132,8 +132,8 @@ fn bench_depolarizing_noise(c: &mut Criterion) { noise = DepolarizingNoiseModel::builder() .with_p_prep(0.001) .with_p_meas(0.001) - .with_single_qubit_probability(0.0005) - .with_two_qubit_probability(0.002) + .with_p1(0.0005) + .with_p2(0.002) .with_seed(42) .build(); let result = noise.start(input.clone()).unwrap(); diff --git a/crates/pecos-engines/examples/compare_noise_models.rs b/crates/pecos-engines/examples/compare_noise_models.rs index c0f729254..36483b785 100644 --- a/crates/pecos-engines/examples/compare_noise_models.rs +++ b/crates/pecos-engines/examples/compare_noise_models.rs @@ -197,8 +197,8 @@ fn test_asymmetric_measurements() { let depolarizing_noise = DepolarizingNoiseModel::builder() .with_p_prep(p_prep) .with_p_meas(p_depolarizing) - .with_single_qubit_probability(p1) - .with_two_qubit_probability(0.0) + .with_p1(p1) + .with_p2(0.0) .with_seed(seed) .build(); let mut depolarizing_system = diff --git a/crates/pecos-engines/src/noise/biased_depolarizing.rs b/crates/pecos-engines/src/noise/biased_depolarizing.rs index c36fa3a8c..82ecfc211 100644 --- a/crates/pecos-engines/src/noise/biased_depolarizing.rs +++ b/crates/pecos-engines/src/noise/biased_depolarizing.rs @@ -44,8 +44,8 @@ use std::any::Any; /// .with_p_prep(0.01) /// .with_p_meas_0(0.02) /// .with_p_meas_1(0.03) -/// .with_single_qubit_probability(0.04) -/// .with_two_qubit_probability(0.05) +/// .with_p1(0.04) +/// .with_p2(0.05) /// .with_seed(42) /// .build(); /// @@ -512,7 +512,19 @@ impl RngManageable for BiasedDepolarizingNoiseModel { } } -/// Builder for creating biased depolarizing noise models +/// Builder for creating biased depolarizing noise models. +/// +/// The retired descriptive probability setters are intentionally unavailable: +/// +/// ```compile_fail +/// use pecos_engines::noise::BiasedDepolarizingNoiseModel; +/// let _ = BiasedDepolarizingNoiseModel::builder().with_single_qubit_probability(0.01); +/// ``` +/// +/// ```compile_fail +/// use pecos_engines::noise::BiasedDepolarizingNoiseModel; +/// let _ = BiasedDepolarizingNoiseModel::builder().with_two_qubit_probability(0.01); +/// ``` #[derive(Debug, Clone)] pub struct BiasedDepolarizingNoiseModelBuilder { p_prep: Option, @@ -587,14 +599,6 @@ impl BiasedDepolarizingNoiseModelBuilder { self } - /// Set the probability of error after single-qubit gates - /// - /// This is an alias for `with_p1` for API consistency. - #[must_use] - pub fn with_single_qubit_probability(self, probability: f64) -> Self { - self.with_p1(probability) - } - /// Set the probability of error after two-qubit gates #[must_use] pub fn with_p2(mut self, probability: f64) -> Self { @@ -602,14 +606,6 @@ impl BiasedDepolarizingNoiseModelBuilder { self } - /// Set the probability of error after two-qubit gates - /// - /// This is an alias for `with_p2` for API consistency. - #[must_use] - pub fn with_two_qubit_probability(self, probability: f64) -> Self { - self.with_p2(probability) - } - /// Set the seed for the random number generator #[must_use] pub fn with_seed(mut self, seed: u64) -> Self { diff --git a/crates/pecos-engines/src/noise/depolarizing.rs b/crates/pecos-engines/src/noise/depolarizing.rs index b91a67457..01cacf463 100644 --- a/crates/pecos-engines/src/noise/depolarizing.rs +++ b/crates/pecos-engines/src/noise/depolarizing.rs @@ -42,8 +42,8 @@ use std::any::Any; /// let noise_model = DepolarizingNoiseModel::builder() /// .with_p_prep(0.01) /// .with_p_meas(0.02) -/// .with_single_qubit_probability(0.03) -/// .with_two_qubit_probability(0.04) +/// .with_p1(0.03) +/// .with_p2(0.04) /// .with_seed(42) /// .build(); /// @@ -439,7 +439,19 @@ impl RngManageable for DepolarizingNoiseModel { } } -/// Builder for creating depolarizing noise models +/// Builder for creating depolarizing noise models. +/// +/// The retired descriptive probability setters are intentionally unavailable: +/// +/// ```compile_fail +/// use pecos_engines::noise::DepolarizingNoiseModel; +/// let _ = DepolarizingNoiseModel::builder().with_single_qubit_probability(0.01); +/// ``` +/// +/// ```compile_fail +/// use pecos_engines::noise::DepolarizingNoiseModel; +/// let _ = DepolarizingNoiseModel::builder().with_two_qubit_probability(0.01); +/// ``` #[derive(Debug, Clone)] pub struct DepolarizingNoiseModelBuilder { p_prep: Option, @@ -504,14 +516,6 @@ impl DepolarizingNoiseModelBuilder { self } - /// Set the probability of error after single-qubit gates - /// - /// This is an alias for `with_p1` for API consistency. - #[must_use] - pub fn with_single_qubit_probability(self, probability: f64) -> Self { - self.with_p1(probability) - } - /// Set the probability of error after two-qubit gates #[must_use] pub fn with_p2(mut self, probability: f64) -> Self { @@ -519,14 +523,6 @@ impl DepolarizingNoiseModelBuilder { self } - /// Set the probability of error after two-qubit gates - /// - /// This is an alias for `with_p2` for API consistency. - #[must_use] - pub fn with_two_qubit_probability(self, probability: f64) -> Self { - self.with_p2(probability) - } - /// Set the seed for the random number generator #[must_use] pub fn with_seed(mut self, seed: u64) -> Self { @@ -780,6 +776,34 @@ mod tests { assert!((p2 - 0.5).abs() < f64::EPSILON); } + #[test] + fn field_name_setters_match_pre_removal_alias_bytes() { + let mut noise = DepolarizingNoiseModel::builder() + .with_p_prep(0.0) + .with_p_meas(0.0) + .with_p1(1.0) + .with_p2(1.0) + .with_seed(0x5eed) + .build(); + + let mut builder = ByteMessage::quantum_operations_builder(); + builder.x(&[0]); + builder.cx(&[(0, 1)]); + + let EngineStage::NeedsProcessing(output) = noise.start(builder.build()).unwrap() else { + panic!("noise model unexpectedly completed"); + }; + assert_eq!( + output.as_bytes(), + [ + 83, 67, 69, 80, 1, 0, 0, 0, 4, 0, 0, 0, 84, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 1, 1, + 0, 0, 0, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 10, 0, 0, 0, 12, + 0, 0, 0, 50, 2, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, + 1, 0, 0, 0, + ] + ); + } + #[test] fn test_builder_with_probability() { // Create a noise model with the builder diff --git a/crates/pecos/tests/neo_equivalence_matrix_test.rs b/crates/pecos/tests/neo_equivalence_matrix_test.rs index 8365f094f..d4495fef5 100644 --- a/crates/pecos/tests/neo_equivalence_matrix_test.rs +++ b/crates/pecos/tests/neo_equivalence_matrix_test.rs @@ -219,8 +219,8 @@ impl NoiseCell { .with_p_prep(0.0) .with_p_meas_0(p_meas_0) .with_p_meas_1(p_meas_1) - .with_single_qubit_probability(0.0) - .with_two_qubit_probability(0.0), + .with_p1(0.0) + .with_p2(0.0), ) .shots(SHOTS) .run(), diff --git a/python/pecos-rslib/src/engine_builders.rs b/python/pecos-rslib/src/engine_builders.rs index e1d053918..828686676 100644 --- a/python/pecos-rslib/src/engine_builders.rs +++ b/python/pecos-rslib/src/engine_builders.rs @@ -1029,13 +1029,6 @@ impl PyGeneralNoiseModelBuilder { }) } - /// Set preparation error probability - fn with_preparation_probability(&self, p: f64) -> PyResult { - Ok(Self { - inner: self.inner.clone().with_p_prep(p), - }) - } - /// Set measurement error probability (asymmetric) fn with_measurement_probability(&self, p0: f64, p1: f64) -> PyResult { Ok(Self { @@ -1421,11 +1414,6 @@ impl PyDepolarizingNoiseModelBuilder { inner: self.inner.clone().with_seed(seed), }) } - - /// Set preparation error probability (alias for `with_p_prep`) - fn with_preparation_probability(&self, p: f64) -> PyResult { - self.with_p_prep(p) - } } /// Python wrapper for `BiasedDepolarizingNoiseModelBuilder` diff --git a/python/quantum-pecos/tests/guppy/test_missing_coverage.py b/python/quantum-pecos/tests/guppy/test_missing_coverage.py index cd1ec9fc7..b2cbbb992 100644 --- a/python/quantum-pecos/tests/guppy/test_missing_coverage.py +++ b/python/quantum-pecos/tests/guppy/test_missing_coverage.py @@ -152,7 +152,7 @@ def prep_measure_circuit() -> bool: # Custom noise: high prep error, low measurement error noise = ( general_noise() - .with_preparation_probability(0.2) # 20% preparation error + .with_p_prep(0.2) # 20% preparation error .with_measurement_probability( 0.01, 0.01, diff --git a/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py b/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py index 75643f013..5e8bea105 100644 --- a/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py +++ b/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py @@ -40,6 +40,9 @@ ] REMOVED_SETTERS = ( + "with_single_qubit_probability", + "with_two_qubit_probability", + "with_preparation_probability", "with_p1_probability", "with_p2_probability", "with_prep_probability", @@ -123,6 +126,25 @@ def test_suffixed_setters_are_gone(factory, _setters) -> None: assert not hasattr(builder, removed), f"{removed} should have been renamed away" +def test_asymmetric_measurement_probability_sets_both_rates() -> None: + """The surviving two-argument helper preserves distinct 0→1 and 1→0 rates.""" + qasm = """ +OPENQASM 2.0; +include "qelib1.inc"; +qreg q[2]; +creg c[2]; +x q[1]; +measure q -> c; +""" + program = Qasm.from_string(qasm) + + zero_only = sim(program).noise(general_noise().with_measurement_probability(1.0, 0.0)).run(8).to_dict() + one_only = sim(program).noise(general_noise().with_measurement_probability(0.0, 1.0)).run(8).to_dict() + + assert zero_only["c"] == [3] * 8 + assert one_only["c"] == [0] * 8 + + def test_retired_idle_setters_are_gone() -> None: """The unpaired and unit-converting idle spellings are absent from pyo3.""" builder = general_noise() From 803b6634315c188a3f55a4a62b0aaa65dc03bf28 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 00:21:33 -0600 Subject: [PATCH 49/62] Explain that the Selene runtime plugin produces the traced QIS stream and may emit idles --- docs/workflows/guppy-dem-decoding.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index aacb91c4f..4292ee8c8 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -109,9 +109,21 @@ observables = [Observable("m0")] ## 3. Generate the DEM with gate and idle noise -`idle_after_2q_duration=1.0` inserts an idle of that duration on both qubits -after every two-qubit gate; traced identity-like gates are stripped first by -default, so runtime-emitted idles are not double-counted. +`with_idle_after_2q(1.0)` inserts an idle of that duration on both qubits after +every two-qubit gate; traced identity-like gates are stripped first by default, +so runtime-emitted idles are not double-counted. + +That default matters because the trace need not be idle-free. `with_runtime(...)` +selects the Selene runtime plugin that lowers and unrolls the Guppy program into +the QIS trace this TickCircuit is built from — so the runtime does not decorate +the trace, it produces it. A runtime that models timing emits its own idle gates +as part of that lowering, reflecting real scheduling rather than the uniform +convention inserted here. Setting `with_idle_after_2q(...)` therefore +implies stripping first, so the two conventions cannot stack. To keep a runtime's +own idle placement instead, simply omit `with_idle_after_2q(...)` — stripping is +off unless insertion asked for it — and the idle-noise families apply to whatever +idles the runtime emitted. `with_strip_traced_idles(...)` overrides that pairing +in either direction when you want it stated explicitly. The linear family uses a custom Z-biased distribution, keeping smaller X and Y memory errors while making dephasing dominant; its weights are an additive From 1d7b55df13268f71ca0342c60b624aacbb717523 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 00:26:56 -0600 Subject: [PATCH 50/62] Document the Selene runtime plugin contract on with_runtime --- python/quantum-pecos/src/pecos/qec/dem.py | 24 ++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 04594fc0c..b66fbc3c3 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -1066,7 +1066,29 @@ def with_strip_traced_idles(self, flag: bool | None) -> Self: return self def with_runtime(self, runtime: object | None) -> Self: - """Set the Selene runtime used for the trace.""" + """Set the Selene runtime that lowers the program into the traced QIS stream. + + The runtime produces the trace rather than annotating it: the Guppy + program is lowered and unrolled through it, and the ``TickCircuit`` this + DEM is built from comes out the far side. A runtime that models timing + therefore emits its own ``Idle`` gates, which is why + :meth:`with_idle_after_2q` strips traced idles first by default rather + than stacking a second convention on top. + + Accepts four forms: + + - ``None`` selects the default runtime, preferring a freshly built + artifact and falling back to the installed plugin package. + - A name without path separators is treated as a built runtime library. + - A path-like value is loaded as a shared library. + - A runtime plugin object is duck-typed. It must expose + ``library_file``; ``get_init_args()`` and ``library_search_dirs`` are + used when present and default to empty otherwise. An object without + ``library_file`` raises :class:`TypeError` at configuration time. + + See ``pecos._engine_builders._configure_selene_runtime`` for the + dispatch. + """ self._set_once("_runtime", runtime, "with_runtime") return self From 14876e94ec8dace439a52947d7df36a49b844340 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 00:40:45 -0600 Subject: [PATCH 51/62] Document the idle double-counting caveat and the TickCircuit pass pipeline --- docs/workflows/guppy-dem-decoding.md | 30 ++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 4292ee8c8..89566b715 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -217,8 +217,8 @@ under a noisy simulator and score those shots against the same DEM. the same (detector events, observable flips) pairs a DEM sample carries, so either source can feed the decoders. -The gate noise below mirrors stage 3, including the idle families. The -simulator does not need the runtime to emit idle gates: `with_idle_after_2q` +The gate noise below mirrors stage 3, including the idle families. With the +default runtime, which emits no idle gates of its own, `with_idle_after_2q` adds an idle site on each two-qubit gate operand, the same placement the DEM pass uses. @@ -230,6 +230,32 @@ The simulator still samples one linear event and then picks an axis, while the DEM emits independent per-axis mechanisms; the DEM builder converts between the two so both describe the same Pauli channel. +A caveat if you supply a runtime plugin via `with_runtime(...)`. Unlike the DEM +builder, a noise model cannot remove gates -- it decorates a gate stream. So a +runtime that emits its own `Idle` gates gets idle noise applied to those *and* +at the after-2q sites, double-counting where the DEM counts once. To control the +idle convention explicitly, lower the program to a `TickCircuit` yourself and run +the passes before simulating: + + +```python +from pecos.tracing import trace_program_to_tick_circuit + +# The QIS trace lowers and unrolls the Guppy program; pass runtime=... to select +# a Selene runtime plugin, which may schedule idles of its own. +tick_circuit = trace_program_to_tick_circuit(rep_code_memory, 7) + +tick_circuit.remove_identity() # drop runtime-emitted idles +tick_circuit.insert_idle_after_two_qubit_gates(1.0) # apply one uniform convention + +# Same strip-then-insert order the DEM builder uses, so both surfaces see the +# same idle placement. +``` + +`sim()` does not yet accept a `TickCircuit` (PECOS #444), so for now this pass +pipeline is how you inspect and control the idle placement, while the run below +uses the default runtime -- which emits no idles, so nothing is double-counted. + ```python from pecos import general_noise, selene_engine, sim, stabilizer From 12f366864ee0a4503391cfb3768be845cc7a22a7 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 00:44:20 -0600 Subject: [PATCH 52/62] Present the two after-2q idle mechanisms as alternatives rather than a pipeline --- docs/workflows/guppy-dem-decoding.md | 34 +++++++++++++++++----------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 89566b715..cbdf9a4cd 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -230,12 +230,21 @@ The simulator still samples one linear event and then picks an axis, while the DEM emits independent per-axis mechanisms; the DEM builder converts between the two so both describe the same Pauli channel. +There are two ways to place idle sites after two-qubit gates, and they are +**alternatives, not steps**. Using both double-counts: + +- `general_noise().with_idle_after_2q(d)` -- the noise model applies idle faults + at each two-qubit gate's operands as it decorates the stream. This is what the + run below uses. +- `TickCircuit.insert_idle_after_two_qubit_gates(d)` -- a circuit pass that + inserts real `Idle` gates, which the noise model then treats like any other + idle. + A caveat if you supply a runtime plugin via `with_runtime(...)`. Unlike the DEM -builder, a noise model cannot remove gates -- it decorates a gate stream. So a -runtime that emits its own `Idle` gates gets idle noise applied to those *and* -at the after-2q sites, double-counting where the DEM counts once. To control the -idle convention explicitly, lower the program to a `TickCircuit` yourself and run -the passes before simulating: +builder, a noise model cannot remove gates -- it only decorates a gate stream. So +a runtime that emits its own `Idle` gates gets idle noise applied to those *and* +at the after-2q sites, double-counting where the DEM counts once. Lowering the +program yourself lets you strip first, exactly as the DEM builder does: ```python @@ -245,16 +254,15 @@ from pecos.tracing import trace_program_to_tick_circuit # a Selene runtime plugin, which may schedule idles of its own. tick_circuit = trace_program_to_tick_circuit(rep_code_memory, 7) -tick_circuit.remove_identity() # drop runtime-emitted idles -tick_circuit.insert_idle_after_two_qubit_gates(1.0) # apply one uniform convention - -# Same strip-then-insert order the DEM builder uses, so both surfaces see the -# same idle placement. +# Drop runtime-emitted idles so only one convention survives. Insertion is then +# either this pass OR the noise model's with_idle_after_2q -- never both. +tick_circuit.remove_identity() ``` -`sim()` does not yet accept a `TickCircuit` (PECOS #444), so for now this pass -pipeline is how you inspect and control the idle placement, while the run below -uses the default runtime -- which emits no idles, so nothing is double-counted. +`sim()` does not yet accept a `TickCircuit` (PECOS #444), so today this is how to +inspect the lowered circuit rather than a path into the simulator. The run below +uses the default runtime, which emits no idles, so `with_idle_after_2q` is the +only convention in play and nothing is double-counted. ```python From 2aa06a32de8c2ef88933f887ae7092f16364f797 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 00:46:09 -0600 Subject: [PATCH 53/62] Say what remove_identity actually strips in the workflow example --- docs/workflows/guppy-dem-decoding.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index cbdf9a4cd..c66899be0 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -254,8 +254,9 @@ from pecos.tracing import trace_program_to_tick_circuit # a Selene runtime plugin, which may schedule idles of its own. tick_circuit = trace_program_to_tick_circuit(rep_code_memory, 7) -# Drop runtime-emitted idles so only one convention survives. Insertion is then -# either this pass OR the noise model's with_idle_after_2q -- never both. +# remove_identity() drops everything that is identity by effect: I, Idle, and +# zero-angle rotations. That clears runtime-emitted idles so only one convention +# survives -- insertion is then this pass OR with_idle_after_2q, never both. tick_circuit.remove_identity() ``` From bfce2d72dc977985cde7879c56b46319ce09bb77 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 11:29:25 -0600 Subject: [PATCH 54/62] Name the decoder syndrome encoding explicitly and give the BP family DEM constructors --- docs/user-guide/decoders.md | 16 +- docs/user-guide/dem-from-guppy.md | 6 +- docs/workflows/guppy-dem-decoding.md | 8 +- .../surface/native_dem_threshold_sweep.py | 12 +- examples/surface_code_noisy_decoding.ipynb | 4 +- examples/surface_code_thresholds.ipynb | 19 +-- python/pecos-rslib/pecos_rslib.pyi | 51 +++++- python/pecos-rslib/src/decoder_bindings.rs | 127 +++++++++++++-- .../src/pecos/qec/surface/decode.py | 29 ++-- .../pecos/decoders/test_decoder_bindings.py | 30 ++-- .../tests/qec/test_decoder_surface_defects.py | 146 +++++++++++++++++- 11 files changed, 357 insertions(+), 91 deletions(-) diff --git a/docs/user-guide/decoders.md b/docs/user-guide/decoders.md index 25b7623fc..955c63f84 100644 --- a/docs/user-guide/decoders.md +++ b/docs/user-guide/decoders.md @@ -41,14 +41,20 @@ The following decoder APIs and supporting types are publicly re-exported from | `FusionBlossomDecoder` | Check matrix, standard-code parameters, or a manual graph | Pure-Rust minimum-weight perfect matching. | | `TesseractDecoder` | DEM text | Search-based decoder that accepts raw hyperedges. | | `DemAwareDecoder` | DEM text | Maps DEM mechanisms and observables onto BP-OSD and other check-matrix decoders. | -| `BpOsdBuilder` / `BpOsdDecoder` | `SparseMatrix` check matrix | Belief propagation with ordered-statistics post-processing. | -| `BpLsdBuilder` / `BpLsdDecoder` | `SparseMatrix` check matrix | Belief propagation with localized-statistics post-processing. | -| `MinSumBpBuilder` / `MinSumBpDecoder` | Dense check matrix and error priors | Min-sum belief propagation. | -| `RelayBpBuilder` / `RelayBpDecoder` | Dense check matrix and error priors | Relay belief propagation. | -| `UnionFindBuilder` / `UnionFindDecoder` | `SparseMatrix` check matrix | Union-find decoding with inversion or peeling. | +| `BpOsdBuilder` / `BpOsdDecoder` | `SparseMatrix` check matrix or DEM text | Belief propagation with ordered-statistics post-processing. | +| `BpLsdBuilder` / `BpLsdDecoder` | `SparseMatrix` check matrix or DEM text | Belief propagation with localized-statistics post-processing. | +| `MinSumBpBuilder` / `MinSumBpDecoder` | Dense check matrix and error priors, or DEM text | Min-sum belief propagation. | +| `RelayBpBuilder` / `RelayBpDecoder` | Dense check matrix and error priors, or DEM text | Relay belief propagation. | +| `UnionFindBuilder` / `UnionFindDecoder` | `SparseMatrix` check matrix or DEM text | Union-find decoding with inversion or peeling. | | `CheckMatrix` / `SparseMatrix` | Dense or coordinate-form matrix data | Matrix containers used by matching and LDPC decoder constructors. | | `MwpmResult` / `BpResult` / `TesseractResult` | Decoder output | Result objects for matching, belief-propagation, and Tesseract decoders. | +Python decoder inputs name their encoding explicitly: use +`decode_syndrome(...)` for a dense detector vector and +`decode_from_defects(...)` for sparse detector indices. The BP/LDPC classes' +`from_dem(...)` constructors return a `DemAwareDecoder` wrapper so their results +include `observables_mask` and their instances retain the DEM dimensions. + ### Rust Decoders The Rust API provides access to a broader set of decoders: diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index 63a7a4f40..c46d0bf1c 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -170,7 +170,7 @@ assert batch.num_shots == 1000 decoder = PyMatchingDecoder.from_dem(dem.to_string_decomposed()) errors = 0 for shot in range(batch.num_shots): - predicted = decoder.decode(batch.get_syndrome(shot)).correction[0] + predicted = decoder.decode_syndrome(batch.get_syndrome(shot)).correction[0] actual = batch.get_observable_mask(shot) & 1 errors += predicted != actual print(f"logical error rate: {errors / batch.num_shots:.4f}") @@ -580,7 +580,7 @@ shots rather than on three independently sampled experiments. ```python -from pecos.decoders import DemAwareDecoder, TesseractDecoder +from pecos.decoders import BpOsdDecoder, TesseractDecoder from pecos.guppy_gen import get_num_qubits, make_surface_code from pecos.qec import DetectorErrorModel from pecos.qec.surface import SurfacePatch @@ -622,7 +622,7 @@ tesseract = TesseractDecoder.from_dem(dem.to_string(), preset="fast") tesseract_result = tesseract.decode_syndrome(syndrome) assert tesseract_result.observables_mask >= 0 -bp_osd = DemAwareDecoder.from_dem(dem.to_string(), decoder_type="bp_osd") +bp_osd = BpOsdDecoder.from_dem(dem.to_string()) bp_osd_result = bp_osd.decode_syndrome(syndrome) assert bp_osd_result.observables_mask >= 0 ``` diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index c66899be0..06e2f838c 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -301,11 +301,11 @@ an `observables_mask` bitmask. ```python -from pecos.decoders import DemAwareDecoder, PyMatchingDecoder, TesseractDecoder +from pecos.decoders import BpOsdDecoder, PyMatchingDecoder, TesseractDecoder pymatching = PyMatchingDecoder.from_dem(terminal_graphlike_text) tesseract = TesseractDecoder.from_dem(source_graphlike_text, preset="fast") -bp_osd = DemAwareDecoder.from_dem(raw_text, decoder_type="bp_osd") +bp_osd = BpOsdDecoder.from_dem(raw_text) pymatching_errors = 0 tesseract_errors = 0 @@ -315,7 +315,7 @@ for shot in range(batch.num_shots): syndrome = batch.get_syndrome(shot) actual = batch.get_observable_mask(shot) & 1 - pymatching_errors += pymatching.decode(syndrome).correction[0] != actual + pymatching_errors += pymatching.decode_syndrome(syndrome).correction[0] != actual tesseract_errors += (tesseract.decode_syndrome(syndrome).observables_mask & 1) != actual bp_osd_errors += (bp_osd.decode_syndrome(syndrome).observables_mask & 1) != actual @@ -336,7 +336,7 @@ The simulated shots decode the same way, against the same decoders: ```python sim_errors = 0 for syndrome, observable_mask in sim_shots: - predicted = pymatching.decode(syndrome).correction[0] + predicted = pymatching.decode_syndrome(syndrome).correction[0] sim_errors += predicted != (observable_mask & 1) print(f"simulated shots, pymatching: {sim_errors}/{len(sim_shots)}") diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index 64ce3ad52..ff74f9ffd 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -588,15 +588,9 @@ def _create_dem_decoder(decoder_type: str, dem_str: str, *, tesseract_beam: int return PyMatchingDecoder.from_dem(dem_str) -def _decode_one_shot(dem_decoder: object, events_flat: list[int]) -> object: - """Decode one shot using whichever DEM decoder was created. - - Tesseract.decode() wants sparse indices; decode_syndrome() accepts dense vectors. - PyMatching.decode() accepts dense vectors directly. - """ - if hasattr(dem_decoder, "decode_syndrome"): - return dem_decoder.decode_syndrome(events_flat) - return dem_decoder.decode(events_flat) +def _decode_one_shot(dem_decoder: Any, events_flat: list[int]) -> object: + """Decode one dense syndrome using whichever DEM decoder was created.""" + return dem_decoder.decode_syndrome(events_flat) def _decode_all_shots( diff --git a/examples/surface_code_noisy_decoding.ipynb b/examples/surface_code_noisy_decoding.ipynb index 663b4a365..b078c2edc 100644 --- a/examples/surface_code_noisy_decoding.ipynb +++ b/examples/surface_code_noisy_decoding.ipynb @@ -1188,11 +1188,11 @@ "# - External tools: Save to file and load\n", "\n", "# Example decode with empty syndrome (no errors)\n", - "result = tesseract.decode([]) # No detectors fired\n", + "result = tesseract.decode_from_defects([]) # No detectors fired\n", "print(f\"Empty syndrome decode: observables_mask={result.observables_mask}, cost={result.cost}\")\n", "\n", "# Example decode with some detection events\n", - "result = tesseract.decode([0, 8]) # Detectors 0 and 8 fired\n", + "result = tesseract.decode_from_defects([0, 8]) # Detectors 0 and 8 fired\n", "print(f\"Detectors [0,8] fired: observables_mask={result.observables_mask}, cost={result.cost}\")" ] }, diff --git a/examples/surface_code_thresholds.ipynb b/examples/surface_code_thresholds.ipynb index 7a4cd36e4..16fa901e2 100644 --- a/examples/surface_code_thresholds.ipynb +++ b/examples/surface_code_thresholds.ipynb @@ -65,7 +65,7 @@ " generate_surface_code_dem,\n", ")\n", "from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder\n", - "from pecos_rslib.decoders import PyMatchingDecoder, TesseractDecoder" + "from pecos_rslib.decoders import BpOsdDecoder, FusionBlossomDecoder, PyMatchingDecoder, TesseractDecoder" ] }, { @@ -177,19 +177,19 @@ " true_flip = observable_flips[i, 0] if observable_flips.shape[1] > 0 else 0\n", "\n", " if decoder_type == \"pymatching\":\n", - " result = decoder.decode(events.astype(np.uint8).tolist())\n", + " result = decoder.decode_syndrome(events.astype(np.uint8).tolist())\n", " predicted_flip = result.correction[0] if len(result.correction) > 0 else 0\n", " elif decoder_type == \"fusion_blossom\":\n", - " result = decoder.decode(events.astype(np.uint8).tolist())\n", + " result = decoder.decode_syndrome(events.astype(np.uint8).tolist())\n", " predicted_flip = result.correction[0] if len(result.correction) > 0 else 0\n", " decoder.clear()\n", " elif decoder_type == \"tesseract\":\n", " detection_indices = [j for j, v in enumerate(events) if v]\n", - " result = decoder.decode(detection_indices)\n", + " result = decoder.decode_from_defects(detection_indices)\n", " predicted_flip = result.observables_mask & 1\n", " elif decoder_type == \"bp_osd\":\n", - " result = decoder.decode(events.astype(np.uint8).tolist())\n", - " predicted_flip = result.decoding[0] if len(result.decoding) > 0 else 0\n", + " result = decoder.decode_syndrome(events.astype(np.uint8).tolist())\n", + " predicted_flip = result.observables_mask & 1\n", " else:\n", " msg = f\"Unknown decoder type: {decoder_type}\"\n", " raise ValueError(msg)\n", @@ -215,13 +215,10 @@ " return PyMatchingDecoder.from_dem(dem_string)\n", "\n", " if decoder_type == \"fusion_blossom\":\n", - " # FusionBlossom doesn't have from_dem, use PyMatching as fallback\n", - " return PyMatchingDecoder.from_dem(dem_string)\n", + " return FusionBlossomDecoder.from_dem(dem_string)\n", "\n", " if decoder_type == \"bp_osd\":\n", - " # BP+OSD doesn't directly support DEM format\n", - " msg = \"BP+OSD from DEM not yet implemented\"\n", - " raise NotImplementedError(msg)\n", + " return BpOsdDecoder.from_dem(dem_string)\n", "\n", " msg = f\"Unknown decoder type: {decoder_type}\"\n", " raise ValueError(msg)" diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index 5a9e56dda..8bd6a0b7e 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -2097,7 +2097,7 @@ class decoders: ) -> decoders.PyMatchingDecoder: ... @staticmethod def from_check_matrix(check_matrix: decoders.CheckMatrix) -> decoders.PyMatchingDecoder: ... - def decode(self, syndrome: list[int]) -> decoders.MwpmResult: ... + def decode_syndrome(self, syndrome: list[int]) -> decoders.MwpmResult: ... def decode_batch( self, detection_events: list[list[int]], @@ -2113,7 +2113,14 @@ class decoders: check_matrix: decoders.CheckMatrix, weights: list[float] | None = ..., ) -> None: ... - def decode(self, syndrome: list[int]) -> decoders.MwpmResult: ... + @staticmethod + def from_dem(dem: str, correlated: bool = ...) -> decoders.FusionBlossomDecoder: ... + def decode_syndrome(self, syndrome: list[int]) -> decoders.MwpmResult: ... + def decode_from_defects( + self, + defects: list[int], + erasures: list[int] | None = ..., + ) -> decoders.MwpmResult: ... def __repr__(self) -> str: ... class BpOsdBuilder: @@ -2129,7 +2136,7 @@ class decoders: >>> from pecos_rslib.decoders import BpOsdBuilder, SparseMatrix >>> H = SparseMatrix([[1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 1]]) >>> decoder = BpOsdBuilder(H, error_rate=0.01).osd_method("osd_cs").osd_order(7).build() - >>> result = decoder.decode([0, 0, 0]) + >>> result = decoder.decode_syndrome([0, 0, 0]) """ def __init__(self, pcm: decoders.SparseMatrix, error_rate: float) -> None: ... @@ -2165,7 +2172,13 @@ class decoders: Created via ``BpOsdBuilder(...).build()``. """ - def decode(self, syndrome: list[int]) -> decoders.BpResult: ... + @staticmethod + def from_dem( + dem: str, + error_rate: float | None = ..., + max_iter: int = ..., + ) -> decoders.DemAwareDecoder: ... + def decode_syndrome(self, syndrome: list[int]) -> decoders.BpResult: ... def __repr__(self) -> str: ... class BpLsdBuilder: @@ -2213,6 +2226,12 @@ class decoders: Created via ``BpLsdBuilder(...).build()``. """ + @staticmethod + def from_dem( + dem: str, + error_rate: float | None = ..., + max_iter: int = ..., + ) -> decoders.DemAwareDecoder: ... def decode(self, syndrome: list[int]) -> decoders.BpResult: ... def __repr__(self) -> str: ... @@ -2228,7 +2247,7 @@ class decoders: >>> from pecos_rslib.decoders import UnionFindBuilder, SparseMatrix >>> H = SparseMatrix([[1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 1]]) >>> decoder = UnionFindBuilder(H).method("peeling").build() - >>> result = decoder.decode([0, 0, 0]) + >>> result = decoder.decode_syndrome([0, 0, 0]) """ def __init__(self, pcm: decoders.SparseMatrix) -> None: ... @@ -2248,7 +2267,13 @@ class decoders: Created via ``UnionFindBuilder(...).build()``. """ - def decode( + @staticmethod + def from_dem( + dem: str, + error_rate: float | None = ..., + max_iter: int = ..., + ) -> decoders.DemAwareDecoder: ... + def decode_syndrome( self, syndrome: list[int], llrs: list[float] | None = ..., @@ -2279,7 +2304,7 @@ class decoders: beam_climbing: bool | None = ..., verbose: bool = ..., ) -> decoders.TesseractDecoder: ... - def decode(self, detections: list[int]) -> decoders.TesseractResult: ... + def decode_from_defects(self, detections: list[int]) -> decoders.TesseractResult: ... def decode_syndrome(self, syndrome: list[int]) -> decoders.TesseractResult: ... def decode_batch( self, @@ -2377,6 +2402,12 @@ class decoders: Created via ``RelayBpBuilder(...).build()``. """ + @staticmethod + def from_dem( + dem: str, + error_rate: float | None = ..., + max_iter: int = ..., + ) -> decoders.DemAwareDecoder: ... def decode(self, syndrome: list[int]) -> decoders.BpResult: """Decode a syndrome vector. @@ -2445,6 +2476,12 @@ class decoders: Created via ``MinSumBpBuilder(...).build()``. """ + @staticmethod + def from_dem( + dem: str, + error_rate: float | None = ..., + max_iter: int = ..., + ) -> decoders.DemAwareDecoder: ... def decode(self, syndrome: list[int]) -> decoders.BpResult: """Decode a syndrome vector. diff --git a/python/pecos-rslib/src/decoder_bindings.rs b/python/pecos-rslib/src/decoder_bindings.rs index 656d989a2..7048790b7 100644 --- a/python/pecos-rslib/src/decoder_bindings.rs +++ b/python/pecos-rslib/src/decoder_bindings.rs @@ -40,6 +40,19 @@ use ndarray::{Array1, Array2}; use pyo3::prelude::*; +fn explicit_decode_attribute_error(class_name: &str, name: &str) -> PyErr { + if name == "decode" { + pyo3::exceptions::PyAttributeError::new_err(format!( + "{class_name} has no attribute 'decode'; use decode_syndrome(...) for a dense vector \ + or decode_from_defects(...) for sparse detector indices" + )) + } else { + pyo3::exceptions::PyAttributeError::new_err(format!( + "'{class_name}' object has no attribute '{name}'" + )) + } +} + // ============================================================================= // Common Result Types // ============================================================================= @@ -56,7 +69,7 @@ use pyo3::prelude::*; /// # Example /// /// ```python -/// result = decoder.decode(syndrome) +/// result = decoder.decode_syndrome(syndrome) /// if result.weight < threshold: /// apply_correction(result.correction) /// ``` @@ -334,7 +347,7 @@ impl PyCheckMatrix { /// /// ```python /// syndrome = [1, 0] # Detection events -/// result = decoder.decode(syndrome) +/// result = decoder.decode_syndrome(syndrome) /// print(f"Correction: {result.correction}, Weight: {result.weight}") /// ``` // Note: unsendable because contains FFI pointers (cxx UniquePtr) @@ -504,7 +517,7 @@ impl PyPyMatchingDecoder { /// Decode a syndrome to find the most likely error. /// - /// This mirrors `PyMatching`'s `Matching.decode()`. + /// This mirrors `PyMatching`'s `Matching.decode()` with an explicit dense encoding name. /// /// # Arguments /// @@ -518,10 +531,10 @@ impl PyPyMatchingDecoder { /// /// ```python /// syndrome = [1, 0, 1, 0] - /// result = decoder.decode(syndrome) + /// result = decoder.decode_syndrome(syndrome) /// correction = result.correction # Observable flips to apply /// ``` - fn decode(&mut self, syndrome: Vec) -> PyResult { + fn decode_syndrome(&mut self, syndrome: Vec) -> PyResult { self.inner .decode(&syndrome) .map(|result| PyMwpmResult { @@ -533,7 +546,7 @@ impl PyPyMatchingDecoder { /// Decode a batch of syndromes at once. /// - /// Much faster than calling `decode()` in a Python loop -- the entire batch + /// Much faster than calling `decode_syndrome()` in a Python loop -- the entire batch /// is processed in Rust with no per-shot Python overhead. /// /// # Arguments @@ -607,6 +620,10 @@ impl PyPyMatchingDecoder { self.inner.num_observables() ) } + + fn __getattr__(&self, name: &str) -> PyResult<()> { + Err(explicit_decode_attribute_error("PyMatchingDecoder", name)) + } } // ============================================================================= @@ -646,7 +663,7 @@ use pecos_decoders::{ /// # Decoding /// /// ```python -/// result = decoder.decode(syndrome) +/// result = decoder.decode_syndrome(syndrome) /// decoder.clear() # Reset for next shot (efficient reuse) /// ``` #[pyclass(name = "FusionBlossomDecoder", module = "pecos_rslib.decoders")] @@ -865,7 +882,7 @@ impl PyFusionBlossomDecoder { /// # Returns /// /// `MwpmResult` with correction and weight. - fn decode(&mut self, syndrome: Vec) -> PyResult { + fn decode_syndrome(&mut self, syndrome: Vec) -> PyResult { let arr = Array1::from_vec(syndrome); self.inner .decode(&arr.view()) @@ -929,6 +946,13 @@ impl PyFusionBlossomDecoder { self.inner.num_edges() ) } + + fn __getattr__(&self, name: &str) -> PyResult<()> { + Err(explicit_decode_attribute_error( + "FusionBlossomDecoder", + name, + )) + } } // ============================================================================= @@ -1077,7 +1101,7 @@ fn parse_osd_method(s: &str) -> PyResult { /// /// H = SparseMatrix([[1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 1]]) /// decoder = BpOsdBuilder(H, error_rate=0.1).osd_method("osd_cs").osd_order(7).build() -/// result = decoder.decode(syndrome) +/// result = decoder.decode_syndrome(syndrome) /// ``` #[pyclass(name = "BpOsdBuilder", module = "pecos_rslib.decoders")] pub struct PyBpOsdBuilder { @@ -1185,6 +1209,17 @@ pub struct PyBpOsdDecoder { #[pymethods] impl PyBpOsdDecoder { + /// Create a DEM-aware BP+OSD decoder from a Detector Error Model. + #[staticmethod] + #[pyo3(signature = (dem, error_rate=None, max_iter=100))] + fn from_dem( + dem: &str, + error_rate: Option, + max_iter: usize, + ) -> PyResult { + PyDemAwareDecoder::from_dem(dem, "bp_osd", error_rate, max_iter) + } + /// Decode a syndrome. /// /// # Arguments @@ -1194,7 +1229,7 @@ impl PyBpOsdDecoder { /// # Returns /// /// `BpResult` with decoding, convergence status, and iteration count. - fn decode(&mut self, syndrome: Vec) -> PyResult { + fn decode_syndrome(&mut self, syndrome: Vec) -> PyResult { let arr = Array1::from_vec(syndrome); self.inner .decode(&arr.view()) @@ -1210,6 +1245,10 @@ impl PyBpOsdDecoder { fn __repr__(&self) -> String { "BpOsdDecoder(...)".to_string() } + + fn __getattr__(&self, name: &str) -> PyResult<()> { + Err(explicit_decode_attribute_error("BpOsdDecoder", name)) + } } /// Builder for BP+LSD decoder. @@ -1324,6 +1363,17 @@ pub struct PyBpLsdDecoder { #[pymethods] impl PyBpLsdDecoder { + /// Create a DEM-aware BP+LSD decoder from a Detector Error Model. + #[staticmethod] + #[pyo3(signature = (dem, error_rate=None, max_iter=100))] + fn from_dem( + dem: &str, + error_rate: Option, + max_iter: usize, + ) -> PyResult { + PyDemAwareDecoder::from_dem(dem, "bp_lsd", error_rate, max_iter) + } + /// Decode a syndrome. fn decode(&mut self, syndrome: Vec) -> PyResult { let arr = Array1::from_vec(syndrome); @@ -1355,7 +1405,7 @@ impl PyBpLsdDecoder { /// /// H = SparseMatrix([[1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 1]]) /// decoder = UnionFindBuilder(H).method("peeling").build() -/// result = decoder.decode(syndrome) +/// result = decoder.decode_syndrome(syndrome) /// ``` #[pyclass(name = "UnionFindBuilder", module = "pecos_rslib.decoders")] pub struct PyUnionFindBuilder { @@ -1420,6 +1470,17 @@ pub struct PyUnionFindDecoder { #[pymethods] impl PyUnionFindDecoder { + /// Create a DEM-aware Union-Find decoder from a Detector Error Model. + #[staticmethod] + #[pyo3(signature = (dem, error_rate=None, max_iter=100))] + fn from_dem( + dem: &str, + error_rate: Option, + max_iter: usize, + ) -> PyResult { + PyDemAwareDecoder::from_dem(dem, "union_find", error_rate, max_iter) + } + /// Decode a syndrome. /// /// # Arguments @@ -1428,7 +1489,7 @@ impl PyUnionFindDecoder { /// * `llrs` - Optional log-likelihood ratios for soft information /// * `bits_per_step` - Bits to grow per step (0 = all at once) #[pyo3(signature = (syndrome, llrs=None, bits_per_step=0))] - fn decode( + fn decode_syndrome( &mut self, syndrome: Vec, llrs: Option>, @@ -1451,6 +1512,10 @@ impl PyUnionFindDecoder { fn __repr__(&self) -> String { "UnionFindDecoder(...)".to_string() } + + fn __getattr__(&self, name: &str) -> PyResult<()> { + Err(explicit_decode_attribute_error("UnionFindDecoder", name)) + } } // ============================================================================= @@ -1527,7 +1592,7 @@ impl PyTesseractResult { /// ```python /// # Detection events as list of detector indices that fired /// detection_indices = [0, 2] -/// result = decoder.decode(detection_indices) +/// result = decoder.decode_from_defects(detection_indices) /// print(f"Observable mask: {result.observables_mask}, Cost: {result.cost}") /// ``` #[pyclass(name = "TesseractDecoder", module = "pecos_rslib.decoders", unsendable)] @@ -1605,10 +1670,10 @@ impl PyTesseractDecoder { /// /// ```python /// # Detectors 0 and 2 fired - /// result = decoder.decode([0, 2]) + /// result = decoder.decode_from_defects([0, 2]) /// print(f"Observable prediction: {result.observable_bits(1)}") /// ``` - fn decode(&mut self, detections: Vec) -> PyResult { + fn decode_from_defects(&mut self, detections: Vec) -> PyResult { let detections_arr = ndarray::Array1::from_vec(detections); self.inner @@ -1638,7 +1703,7 @@ impl PyTesseractDecoder { .filter_map(|(i, &val)| if val != 0 { Some(i as u64) } else { None }) .collect(); - self.decode(detections) + self.decode_from_defects(detections) } /// Decode a batch of syndromes in parallel using multiple decoder instances. @@ -1743,6 +1808,10 @@ impl PyTesseractDecoder { self.inner.num_observables() ) } + + fn __getattr__(&self, name: &str) -> PyResult<()> { + Err(explicit_decode_attribute_error("TesseractDecoder", name)) + } } // ============================================================================= @@ -1995,6 +2064,17 @@ pub struct PyRelayBpDecoder { #[pymethods] impl PyRelayBpDecoder { + /// Create a DEM-aware Relay BP decoder from a Detector Error Model. + #[staticmethod] + #[pyo3(signature = (dem, error_rate=None, max_iter=100))] + fn from_dem( + dem: &str, + error_rate: Option, + max_iter: usize, + ) -> PyResult { + PyDemAwareDecoder::from_dem(dem, "relay_bp", error_rate, max_iter) + } + /// Decode a syndrome. /// /// # Arguments @@ -2158,6 +2238,17 @@ pub struct PyMinSumBpDecoder { #[pymethods] impl PyMinSumBpDecoder { + /// Create a DEM-aware min-sum BP decoder from a Detector Error Model. + #[staticmethod] + #[pyo3(signature = (dem, error_rate=None, max_iter=100))] + fn from_dem( + dem: &str, + error_rate: Option, + max_iter: usize, + ) -> PyResult { + PyDemAwareDecoder::from_dem(dem, "min_sum_bp", error_rate, max_iter) + } + /// Decode a syndrome. /// /// # Arguments @@ -2513,6 +2604,10 @@ impl PyDemAwareDecoder { self.dem_check_matrix.num_observables, ) } + + fn __getattr__(&self, name: &str) -> PyResult<()> { + Err(explicit_decode_attribute_error("DemAwareDecoder", name)) + } } // ============================================================================= diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index d9ece88c0..999ad4050 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -3017,7 +3017,7 @@ def decode_z_syndrome( if self.decoder_type == DecoderType.TESSERACT: # Tesseract takes sparse detection indices detection_indices = [i for i, v in enumerate(events_flat) if v != 0] - result = decoder.decode(detection_indices) + result = decoder.decode_from_defects(detection_indices) # Tesseract returns observables_mask, not per-qubit correction # We return a dummy correction and encode logical flip in first element num_data = self._get_z_check_matrix().shape[1] @@ -3026,7 +3026,7 @@ def decode_z_syndrome( correction[0] = 1 # Mark that logical was predicted flipped weight = result.cost else: - result = decoder.decode(events_flat.tolist()) + result = decoder.decode_syndrome(events_flat.tolist()) # For FusionBlossom, need to clear state for next decode if self.decoder_type == DecoderType.FUSION_BLOSSOM: @@ -3044,7 +3044,10 @@ def decode_z_syndrome( else: raw_syndrome = detection_events.ravel() - result = decoder.decode(raw_syndrome.astype(np.uint8).tolist()) + if self.decoder_type == DecoderType.BP_LSD: + result = decoder.decode(raw_syndrome.astype(np.uint8).tolist()) + else: + result = decoder.decode_syndrome(raw_syndrome.astype(np.uint8).tolist()) correction = np.array(result.decoding, dtype=np.uint8) weight = 0.0 if result.converged else 1.0 # LDPC doesn't have weight @@ -3076,7 +3079,7 @@ def decode_x_syndrome( if self.decoder_type == DecoderType.TESSERACT: # Tesseract takes sparse detection indices detection_indices = [i for i, v in enumerate(events_flat) if v != 0] - result = decoder.decode(detection_indices) + result = decoder.decode_from_defects(detection_indices) # Tesseract returns observables_mask, not per-qubit correction num_data = self._get_x_check_matrix().shape[1] correction = np.zeros(num_data, dtype=np.uint8) @@ -3084,7 +3087,7 @@ def decode_x_syndrome( correction[0] = 1 # Mark that logical was predicted flipped weight = result.cost else: - result = decoder.decode(events_flat.tolist()) + result = decoder.decode_syndrome(events_flat.tolist()) # For FusionBlossom, need to clear state for next decode if self.decoder_type == DecoderType.FUSION_BLOSSOM: @@ -3102,7 +3105,10 @@ def decode_x_syndrome( else: raw_syndrome = detection_events.ravel() - result = decoder.decode(raw_syndrome.astype(np.uint8).tolist()) + if self.decoder_type == DecoderType.BP_LSD: + result = decoder.decode(raw_syndrome.astype(np.uint8).tolist()) + else: + result = decoder.decode_syndrome(raw_syndrome.astype(np.uint8).tolist()) correction = np.array(result.decoding, dtype=np.uint8) weight = 0.0 if result.converged else 1.0 # LDPC doesn't have weight @@ -3287,11 +3293,11 @@ def decode_memory_z( if self.decoder_type == DecoderType.TESSERACT: detection_indices = [i for i, v in enumerate(events_flat) if v != 0] - result = decoder.decode(detection_indices) + result = decoder.decode_from_defects(detection_indices) predicted_obs = result.observables_mask & 1 weight = result.cost else: - result = decoder.decode(events_flat.tolist()) + result = decoder.decode_syndrome(events_flat.tolist()) predicted_obs = result.correction[0] if len(result.correction) > 0 else 0 weight = result.weight @@ -3383,11 +3389,11 @@ def decode_memory_x( if self.decoder_type == DecoderType.TESSERACT: detection_indices = [i for i, v in enumerate(events_flat) if v != 0] - result = decoder.decode(detection_indices) + result = decoder.decode_from_defects(detection_indices) predicted_obs = result.observables_mask & 1 weight = result.cost else: - result = decoder.decode(events_flat.tolist()) + result = decoder.decode_syndrome(events_flat.tolist()) predicted_obs = result.correction[0] if len(result.correction) > 0 else 0 weight = result.weight @@ -4259,8 +4265,7 @@ def demask_pauli_frame_records( raise ValueError(msg) if obs_arr.ndim != 2: msg = ( - f"raw_obs must be 2-D of shape (num_shots, num_observables); " - f"got ndim={obs_arr.ndim}, shape={obs_arr.shape}" + f"raw_obs must be 2-D of shape (num_shots, num_observables); got ndim={obs_arr.ndim}, shape={obs_arr.shape}" ) raise ValueError(msg) if masks_arr.ndim != 2: diff --git a/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py b/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py index 490e373fd..b61230552 100644 --- a/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py +++ b/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py @@ -33,7 +33,7 @@ def test_result_attributes(self) -> None: matrix = CheckMatrix.from_dense([[1, 1, 0], [0, 1, 1]]) decoder = PyMatchingDecoder.from_check_matrix(matrix) - result = decoder.decode([0, 0]) + result = decoder.decode_syndrome([0, 0]) # Check attributes exist assert hasattr(result, "correction") @@ -49,7 +49,7 @@ def test_result_to_list(self) -> None: matrix = CheckMatrix.from_dense([[1, 1, 0], [0, 1, 1]]) decoder = PyMatchingDecoder.from_check_matrix(matrix) - result = decoder.decode([0, 0]) + result = decoder.decode_syndrome([0, 0]) assert result.to_list() == result.correction @@ -59,7 +59,7 @@ def test_result_indexing(self) -> None: matrix = CheckMatrix.from_dense([[1, 1, 0], [0, 1, 1]]) decoder = PyMatchingDecoder.from_check_matrix(matrix) - result = decoder.decode([0, 0]) + result = decoder.decode_syndrome([0, 0]) assert len(result) == len(result.correction) if len(result) > 0: @@ -127,7 +127,7 @@ def test_decode_trivial(self) -> None: H = CheckMatrix.from_dense([[1, 1, 0], [0, 1, 1]]) decoder = PyMatchingDecoder.from_check_matrix(H) - result = decoder.decode([0, 0]) + result = decoder.decode_syndrome([0, 0]) # No errors - should have zero weight assert result.weight == 0.0 @@ -140,7 +140,7 @@ def test_decode_single_error(self) -> None: H = CheckMatrix.from_dense([[1, 1, 0], [0, 1, 1]]) decoder = PyMatchingDecoder.from_check_matrix(H) - result = decoder.decode([1, 1]) + result = decoder.decode_syndrome([1, 1]) assert result is not None assert result.weight > 0 @@ -164,7 +164,7 @@ def test_from_dem_with_correlations(self) -> None: dem = "error(0.1) D0 D1 ^ D2 L0" decoder = PyMatchingDecoder.from_dem_with_correlations(dem) - result = decoder.decode([0, 0, 0]) + result = decoder.decode_syndrome([0, 0, 0]) assert result.correction == [0] @@ -192,7 +192,7 @@ def test_decode_trivial(self) -> None: from pecos_rslib.decoders import FusionBlossomDecoder decoder = FusionBlossomDecoder.from_check_matrix([[1, 1, 0], [0, 1, 1]]) - result = decoder.decode([0, 0]) + result = decoder.decode_syndrome([0, 0]) assert result.weight == 0.0 @@ -216,7 +216,7 @@ def test_clear_for_reuse(self) -> None: # Decode multiple syndromes with clear for _ in range(3): - result = decoder.decode([0, 0]) + result = decoder.decode_syndrome([0, 0]) assert result is not None decoder.clear() @@ -230,7 +230,7 @@ def test_result_attributes(self) -> None: H = SparseMatrix([[1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 1]]) decoder = BpOsdBuilder(H, error_rate=0.01).build() - result = decoder.decode([0, 0, 0]) + result = decoder.decode_syndrome([0, 0, 0]) assert hasattr(result, "decoding") assert hasattr(result, "converged") @@ -289,7 +289,7 @@ def test_decode_trivial(self) -> None: H = SparseMatrix([[1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 1]]) decoder = BpOsdBuilder(H, error_rate=0.01).build() - result = decoder.decode([0, 0, 0]) + result = decoder.decode_syndrome([0, 0, 0]) assert result.converged def test_bp_methods(self) -> None: @@ -300,12 +300,12 @@ def test_bp_methods(self) -> None: # product_sum decoder1 = BpOsdBuilder(H, error_rate=0.01).bp_method("product_sum").build() - result1 = decoder1.decode([0, 0, 0]) + result1 = decoder1.decode_syndrome([0, 0, 0]) assert result1 is not None # minimum_sum decoder2 = BpOsdBuilder(H, error_rate=0.01).bp_method("minimum_sum").build() - result2 = decoder2.decode([0, 0, 0]) + result2 = decoder2.decode_syndrome([0, 0, 0]) assert result2 is not None @@ -351,7 +351,7 @@ def test_decode_trivial(self) -> None: H = SparseMatrix([[1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 1]]) decoder = UnionFindBuilder(H).build() - result = decoder.decode([0, 0, 0]) + result = decoder.decode_syndrome([0, 0, 0]) assert result is not None def test_methods(self) -> None: @@ -361,11 +361,11 @@ def test_methods(self) -> None: H = SparseMatrix([[1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 1]]) decoder_inv = UnionFindBuilder(H).method("inversion").build() - result_inv = decoder_inv.decode([0, 0, 0]) + result_inv = decoder_inv.decode_syndrome([0, 0, 0]) assert result_inv is not None decoder_peel = UnionFindBuilder(H).method("peeling").build() - result_peel = decoder_peel.decode([0, 0, 0]) + result_peel = decoder_peel.decode_syndrome([0, 0, 0]) assert result_peel is not None diff --git a/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py b/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py index 4841ba066..6759b131f 100644 --- a/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py +++ b/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py @@ -9,17 +9,28 @@ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the # specific language governing permissions and limitations under the License. -"""Decoder surface defects from issue #431. - -Covers the public re-export, the DEM constructor that existed in Rust but was -unreachable from Python, and the drift between the decoder string registry and -the DEM-requirement query. -""" +"""Regression tests for the decoder surface defects from issue #431.""" from __future__ import annotations import pytest -from pecos.decoders import DemAwareResult, FusionBlossomDecoder +from pecos.decoders import ( + MWPM2D, + BpLsdDecoder, + BpOsdBuilder, + BpOsdDecoder, + DemAwareDecoder, + DemAwareResult, + DummyDecoder, + FusionBlossomDecoder, + MinSumBpDecoder, + PyMatchingDecoder, + RelayBpDecoder, + SparseMatrix, + TesseractDecoder, + UnionFindBuilder, + UnionFindDecoder, +) from pecos_rslib.qec import decoder_dem_requirement _DEM = """error(0.1) D0 D1 L0 @@ -28,6 +39,23 @@ detector D1 logical_observable L0""" +_ENCODING_DEM = """detector D0 +detector D1 +logical_observable L0 +error(0.1) D0 +error(0.1) D1 L0 +""" + +_SYNDROMES = ([0, 0], [1, 0], [0, 1], [1, 1]) +_OBSERVABLE_MASKS_BEFORE = [0, 0, 1, 1] +_FAMILY_DECODERS = [ + (BpOsdDecoder, "bp_osd"), + (BpLsdDecoder, "bp_lsd"), + (UnionFindDecoder, "union_find"), + (RelayBpDecoder, "relay_bp"), + (MinSumBpDecoder, "min_sum_bp"), +] + def test_dem_aware_result_is_importable() -> None: # Decoding returns this type, so users must be able to name it. @@ -39,6 +67,110 @@ def test_fusion_blossom_builds_from_a_dem() -> None: assert FusionBlossomDecoder.from_dem(_DEM, correlated=True) is not None +def test_dense_and_sparse_names_disambiguate_the_same_list() -> None: + decoder = TesseractDecoder.from_dem(_ENCODING_DEM) + dense_result = decoder.decode_syndrome([1, 0]) + sparse_result = decoder.decode_from_defects([1, 0]) + + assert dense_result.observables_mask == 0 + assert dense_result.cost == pytest.approx(2.197224577336219) + assert not dense_result.low_confidence + assert sparse_result.observables_mask == 1 + assert sparse_result.cost == pytest.approx(4.394449154672438) + assert not sparse_result.low_confidence + + +def test_renamed_methods_preserve_captured_results() -> None: + pymatching = PyMatchingDecoder.from_dem(_ENCODING_DEM) + pymatching_result = pymatching.decode_syndrome([1, 0]) + assert pymatching_result.correction == [0] + assert pymatching_result.weight == pytest.approx(4.394449154672439) + + fusion_blossom = FusionBlossomDecoder.from_dem(_ENCODING_DEM) + fusion_blossom_result = fusion_blossom.decode_syndrome([1, 0]) + assert fusion_blossom_result.correction == [0] + assert fusion_blossom_result.weight == pytest.approx(2.196) + + parity_check_matrix = SparseMatrix([[1, 0], [0, 1]]) + bp_osd = BpOsdBuilder(parity_check_matrix, error_rate=0.1).build() + bp_osd_result = bp_osd.decode_syndrome([1, 0]) + assert (bp_osd_result.decoding, bp_osd_result.converged, bp_osd_result.iterations) == ([1, 0], True, 1) + + union_find = UnionFindBuilder(parity_check_matrix).build() + union_find_result = union_find.decode_syndrome([1, 0]) + assert (union_find_result.decoding, union_find_result.converged, union_find_result.iterations) == ([1, 0], True, 1) + + +def test_affected_decoder_classes_do_not_expose_bare_decode() -> None: + parity_check_matrix = SparseMatrix([[1, 0], [0, 1]]) + decoders = [ + PyMatchingDecoder.from_dem(_ENCODING_DEM), + TesseractDecoder.from_dem(_ENCODING_DEM), + FusionBlossomDecoder.from_dem(_ENCODING_DEM), + BpOsdBuilder(parity_check_matrix, error_rate=0.1).build(), + UnionFindBuilder(parity_check_matrix).build(), + ] + + for decoder in decoders: + assert not hasattr(decoder, "decode") + with pytest.raises(AttributeError, match=r"decode_syndrome.*decode_from_defects"): + decoder.decode() + + +@pytest.mark.parametrize(("decoder_class", "decoder_type"), _FAMILY_DECODERS) +def test_family_from_dem_matches_existing_wrapper(decoder_class: type, decoder_type: str) -> None: + named_decoder = decoder_class.from_dem(_ENCODING_DEM) + existing_decoder = DemAwareDecoder.from_dem(_ENCODING_DEM, decoder_type=decoder_type) + + named_results = [named_decoder.decode_syndrome(list(syndrome)) for syndrome in _SYNDROMES] + existing_results = [existing_decoder.decode_syndrome(list(syndrome)) for syndrome in _SYNDROMES] + + assert all(isinstance(result, DemAwareResult) for result in named_results) + named_masks = [result.observables_mask for result in named_results] + existing_masks = [result.observables_mask for result in existing_results] + assert named_masks == existing_masks == _OBSERVABLE_MASKS_BEFORE + assert isinstance(named_decoder, DemAwareDecoder) + assert named_decoder.num_detectors == existing_decoder.num_detectors == 2 + assert named_decoder.num_mechanisms == existing_decoder.num_mechanisms == 2 + assert named_decoder.num_observables == existing_decoder.num_observables == 1 + assert f"type={decoder_type}" in repr(named_decoder) + assert not hasattr(named_decoder, "decode") + + +@pytest.mark.parametrize(("decoder_class", "decoder_type"), _FAMILY_DECODERS) +def test_family_from_dem_forwards_configuration(decoder_class: type, decoder_type: str) -> None: + named_decoder = decoder_class.from_dem(_ENCODING_DEM, error_rate=0.2, max_iter=0) + existing_decoder = DemAwareDecoder.from_dem( + _ENCODING_DEM, + decoder_type=decoder_type, + error_rate=0.2, + max_iter=0, + ) + + named_result = named_decoder.decode_syndrome([0, 1]) + existing_result = existing_decoder.decode_syndrome([0, 1]) + assert ( + named_result.observables_mask, + named_result.converged, + named_result.iterations, + ) == ( + existing_result.observables_mask, + existing_result.converged, + existing_result.iterations, + ) + + +@pytest.mark.parametrize("decoder_type", [decoder_type for _, decoder_type in _FAMILY_DECODERS]) +def test_decoder_type_still_accepts_all_five_family_values(decoder_type: str) -> None: + decoder = DemAwareDecoder.from_dem(_ENCODING_DEM, decoder_type=decoder_type) + assert decoder.decode_syndrome([0, 1]).observables_mask == 1 + + +def test_legacy_measurement_protocol_decoders_keep_decode() -> None: + assert callable(MWPM2D.decode) + assert callable(DummyDecoder.decode) + + # Every name `create_observable_decoder` accepts must also classify here. Add to # both places when adding a decoder; the two lists drifted apart before. @pytest.mark.parametrize( From 9d133bfe7320cee7a0904e0839f058e6cbd7fcc0 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 14:26:36 -0600 Subject: [PATCH 55/62] Expose the decoders' tuning parameters on their DEM constructors --- crates/pecos-fusion-blossom/src/decoder.rs | 62 +- .../tests/fusion_blossom_tests.rs | 20 + crates/pecos-ldpc-decoders/src/decoders.rs | 65 +- .../tests/ldpc/integration_test.rs | 73 ++ .../include/pymatching_bridge.h | 3 + crates/pecos-pymatching/src/bridge.cpp | 14 + crates/pecos-pymatching/src/bridge.rs | 5 + crates/pecos-pymatching/src/decoder.rs | 70 ++ crates/pecos-relay-bp/src/config.rs | 17 + crates/pecos-relay-bp/src/decoder.rs | 64 ++ crates/pecos-tesseract/src/decoder.rs | 87 +++ docs/workflows/guppy-dem-decoding.md | 4 +- python/pecos-rslib/pecos_rslib.pyi | 138 +++- python/pecos-rslib/src/decoder_bindings.rs | 702 ++++++++++++++---- .../tests/qec/test_decoder_surface_defects.py | 96 ++- 15 files changed, 1236 insertions(+), 184 deletions(-) diff --git a/crates/pecos-fusion-blossom/src/decoder.rs b/crates/pecos-fusion-blossom/src/decoder.rs index 547e4d8b3..8d7c73f6c 100644 --- a/crates/pecos-fusion-blossom/src/decoder.rs +++ b/crates/pecos-fusion-blossom/src/decoder.rs @@ -282,6 +282,20 @@ impl FusionBlossomDecoder { /// /// Returns error if the graph is empty or construction fails. pub fn from_matching_graph(graph: &pecos_decoder_core::dem::DemMatchingGraph) -> Result { + Self::from_matching_graph_with_solver_type(graph, SolverType::Serial) + } + + /// Create decoder from a `DemMatchingGraph` with an explicit supported solver. + /// + /// # Errors + /// + /// Returns an error if the graph is invalid, construction fails, or the + /// parallel solver is requested without its required partition configuration. + pub fn from_matching_graph_with_solver_type( + graph: &pecos_decoder_core::dem::DemMatchingGraph, + solver_type: SolverType, + ) -> Result { + Self::validate_dem_solver_type(solver_type)?; // Matching decoders pack observable flips into a u64; reject >64-observable // DEMs as an error rather than overflow-panicking in the `1 << o` loop below. graph @@ -290,7 +304,8 @@ impl FusionBlossomDecoder { let config = FusionBlossomConfig { num_nodes: Some(graph.num_detectors), num_observables: graph.num_observables, - ..Default::default() + solver_type, + max_tree_size: None, }; let mut decoder = Self::new(config)?; for edge in &graph.edges { @@ -314,9 +329,19 @@ impl FusionBlossomDecoder { /// /// Returns error if the DEM is malformed. pub fn from_dem(dem: &str) -> Result { + Self::from_dem_with_solver_type(dem, SolverType::Serial) + } + + /// Create decoder from a DEM string with an explicit supported solver. + /// + /// # Errors + /// + /// Returns an error if the DEM is malformed or the parallel solver is + /// requested without its required partition configuration. + pub fn from_dem_with_solver_type(dem: &str, solver_type: SolverType) -> Result { let graph = pecos_decoder_core::dem::DemMatchingGraph::from_dem_str(dem) .map_err(|e| FusionBlossomError::Configuration(e.to_string()))?; - Self::from_matching_graph(&graph) + Self::from_matching_graph_with_solver_type(&graph, solver_type) } /// Parse a DEM string into a reusable structure for correlated FB construction. @@ -526,8 +551,22 @@ impl FusionBlossomDecoder { /// /// Returns error if the DEM is malformed. pub fn from_dem_correlated(dem: &str) -> Result { + Self::from_dem_correlated_with_solver_type(dem, SolverType::Serial) + } + + /// Create a correlated decoder from a DEM string with an explicit supported solver. + /// + /// # Errors + /// + /// Returns an error if the DEM is malformed or the parallel solver is + /// requested without its required partition configuration. + pub fn from_dem_correlated_with_solver_type( + dem: &str, + solver_type: SolverType, + ) -> Result { use pecos_decoder_core::dem::DemCheckMatrix; + Self::validate_dem_solver_type(solver_type)?; let dcm = DemCheckMatrix::from_dem_str(dem) .map_err(|e| FusionBlossomError::Configuration(e.to_string()))?; // Matching decoders pack observable flips into a u64; reject >64-observable @@ -538,7 +577,8 @@ impl FusionBlossomDecoder { let config = FusionBlossomConfig { num_nodes: Some(dcm.num_detectors), num_observables: dcm.num_observables, - ..Default::default() + solver_type, + max_tree_size: None, }; let mut decoder = Self::new(config)?; @@ -575,6 +615,16 @@ impl FusionBlossomDecoder { Ok(decoder) } + fn validate_dem_solver_type(solver_type: SolverType) -> Result<()> { + if solver_type == SolverType::Parallel { + return Err(FusionBlossomError::Configuration( + "solver_type 'parallel' requires a partition configuration, which the DEM constructor does not accept" + .to_string(), + )); + } + Ok(()) + } + /// Create decoder from a standard QEC code /// /// # Errors @@ -1093,6 +1143,12 @@ impl FusionBlossomDecoder { self.num_nodes } + /// Get the configured solver type. + #[must_use] + pub fn solver_type(&self) -> SolverType { + self.config.solver_type + } + /// Get number of edges #[must_use] pub fn num_edges(&self) -> usize { diff --git a/crates/pecos-fusion-blossom/tests/fusion_blossom_tests.rs b/crates/pecos-fusion-blossom/tests/fusion_blossom_tests.rs index 333c7ab8e..75fe51343 100644 --- a/crates/pecos-fusion-blossom/tests/fusion_blossom_tests.rs +++ b/crates/pecos-fusion-blossom/tests/fusion_blossom_tests.rs @@ -16,6 +16,26 @@ fn test_create_decoder() { assert!(decoder.is_ok()); } +#[test] +fn test_dem_solver_type_is_applied_and_parallel_fails_at_construction() { + let dem = "error(0.1) D0 D1 L0\ndetector D0\ndetector D1\nlogical_observable L0"; + + let legacy = FusionBlossomDecoder::from_dem_with_solver_type(dem, SolverType::Legacy).unwrap(); + assert_eq!(legacy.solver_type(), SolverType::Legacy); + + let correlated = + FusionBlossomDecoder::from_dem_correlated_with_solver_type(dem, SolverType::Legacy) + .unwrap(); + assert_eq!(correlated.solver_type(), SolverType::Legacy); + + let error = FusionBlossomDecoder::from_dem_with_solver_type(dem, SolverType::Parallel) + .err() + .unwrap() + .to_string(); + assert!(error.contains("solver_type")); + assert!(error.contains("partition configuration")); +} + #[test] fn test_add_edges() { let config = FusionBlossomConfig { diff --git a/crates/pecos-ldpc-decoders/src/decoders.rs b/crates/pecos-ldpc-decoders/src/decoders.rs index 76bd9f197..e5b4a74ff 100644 --- a/crates/pecos-ldpc-decoders/src/decoders.rs +++ b/crates/pecos-ldpc-decoders/src/decoders.rs @@ -39,6 +39,31 @@ fn prepare_channel_probs( } } +fn validate_bp_tuning( + max_iter: usize, + adaptive_max_iter: usize, + ms_scaling_factor: f64, + order: usize, + order_parameter: &str, +) -> Result<(i32, i32), LdpcError> { + if !ms_scaling_factor.is_finite() || ms_scaling_factor < 0.0 { + return Err(LdpcError::InvalidInput( + "ms_scaling_factor must be finite and non-negative".to_string(), + )); + } + let actual_max_iter = if max_iter == 0 { + adaptive_max_iter + } else { + max_iter + }; + let actual_max_iter = i32::try_from(actual_max_iter) + .map_err(|_| LdpcError::InvalidInput("max_iter must not exceed 2147483647".to_string()))?; + let order = i32::try_from(order).map_err(|_| { + LdpcError::InvalidInput(format!("{order_parameter} must not exceed 2147483647")) + })?; + Ok((actual_max_iter, order)) +} + /// BP+OSD Decoder pub struct BpOsdDecoder { inner: UniquePtr, @@ -96,15 +121,19 @@ impl BpOsdDecoder { "OSD decoding requires syndrome input. Please use InputVectorType::Syndrome when OSD is enabled.".to_string() )); } - // Prepare channel probabilities let channel_probs = prepare_channel_probs(pcm.cols, error_rate, error_channel)?; // Create sparse matrix representation for FFI let sparse_repr = pcm.to_ffi_repr(); - // Handle adaptive iterations (0 means use n as max_iter) - let actual_max_iter = if max_iter == 0 { pcm.cols } else { max_iter }; + let (actual_max_iter, osd_order) = validate_bp_tuning( + max_iter, + pcm.cols, + ms_scaling_factor, + osd_order, + "osd_order", + )?; // Default thread count to 1 if not specified let threads = omp_thread_count.unwrap_or(1); @@ -118,12 +147,12 @@ impl BpOsdDecoder { let inner = ffi::create_bp_osd_decoder( &sparse_repr, &channel_probs, - i32::try_from(actual_max_iter).unwrap_or(i32::MAX), + actual_max_iter, bp_method.to_ffi(), bp_schedule.to_ffi(), ms_scaling_factor, osd_method.to_ffi(), - i32::try_from(osd_order).unwrap_or(0), + osd_order, input_vector_type.to_ffi(), i32::try_from(threads).unwrap_or(1), schedule_order, @@ -336,15 +365,19 @@ impl BpLsdDecoder { .to_string(), )); } - // Prepare channel probabilities let channel_probs = prepare_channel_probs(pcm.cols, error_rate, error_channel)?; // Create sparse matrix representation for FFI let sparse_repr = pcm.to_ffi_repr(); - // Handle adaptive iterations (0 means use n as max_iter) - let actual_max_iter = if max_iter == 0 { pcm.cols } else { max_iter }; + let (actual_max_iter, lsd_order) = validate_bp_tuning( + max_iter, + pcm.cols, + ms_scaling_factor, + lsd_order, + "lsd_order", + )?; // Default thread count to 1 if not specified let threads = omp_thread_count.unwrap_or(1); @@ -358,12 +391,12 @@ impl BpLsdDecoder { let inner = ffi::create_bp_lsd_decoder( &sparse_repr, &channel_probs, - i32::try_from(actual_max_iter).unwrap_or(i32::MAX), + actual_max_iter, bp_method.to_ffi(), bp_schedule.to_ffi(), ms_scaling_factor, lsd_method.to_ffi(), - i32::try_from(lsd_order).unwrap_or(0), + lsd_order, i32::try_from(bits_per_step).unwrap_or(0), input_vector_type.to_ffi(), i32::try_from(threads).unwrap_or(1), @@ -853,6 +886,7 @@ impl FlipDecoder { /// Union Find Decoder pub struct UnionFindDecoder { inner: UniquePtr, + uf_method: UfMethod, } /// Union Find method @@ -890,7 +924,10 @@ impl UnionFindDecoder { let decoder = ffi::create_union_find_decoder(&pcm_repr, uf_method.to_ffi()) .map_err(|e| LdpcError::Ldpc(e.what().to_string()))?; - Ok(Self { inner: decoder }) + Ok(Self { + inner: decoder, + uf_method, + }) } /// Decode a syndrome using Union Find @@ -951,6 +988,12 @@ impl UnionFindDecoder { pub fn bit_count(&self) -> usize { usize::try_from(ffi::get_bit_count_uf(&self.inner)).unwrap_or(0) } + + /// Get the configured Union-Find method. + #[must_use] + pub fn method(&self) -> UfMethod { + self.uf_method + } } /// `BeliefFind` Decoder - Combines BP with Union Find diff --git a/crates/pecos-ldpc-decoders/tests/ldpc/integration_test.rs b/crates/pecos-ldpc-decoders/tests/ldpc/integration_test.rs index 86b0f6aff..7c0f030ee 100644 --- a/crates/pecos-ldpc-decoders/tests/ldpc/integration_test.rs +++ b/crates/pecos-ldpc-decoders/tests/ldpc/integration_test.rs @@ -36,6 +36,79 @@ fn test_sparse_matrix_creation() { assert_eq!(reconstructed, dense); } +#[test] +fn test_bp_tuning_validation_names_invalid_parameter() { + let pcm = repetition_code(3); + let too_large = usize::try_from(i32::MAX).unwrap() + 1; + let make_osd = |max_iter, scaling, osd_order| { + BpOsdDecoder::new( + &pcm, + Some(0.1), + None, + max_iter, + BpMethod::ProductSum, + BpSchedule::Parallel, + scaling, + OsdMethod::OsdCs, + osd_order, + InputVectorType::Syndrome, + None, + None, + None, + ) + }; + + assert!( + make_osd(too_large, 1.0, 0) + .err() + .unwrap() + .to_string() + .contains("max_iter") + ); + assert!( + make_osd(10, f64::NAN, 0) + .err() + .unwrap() + .to_string() + .contains("ms_scaling_factor") + ); + assert!( + make_osd(10, -0.1, 0) + .err() + .unwrap() + .to_string() + .contains("ms_scaling_factor") + ); + assert!( + make_osd(10, 1.0, too_large) + .err() + .unwrap() + .to_string() + .contains("osd_order") + ); + + let lsd_error = BpLsdDecoder::new( + &pcm, + Some(0.1), + None, + 10, + BpMethod::ProductSum, + BpSchedule::Parallel, + 1.0, + OsdMethod::OsdCs, + too_large, + 0, + InputVectorType::Syndrome, + None, + None, + None, + ) + .err() + .unwrap() + .to_string(); + assert!(lsd_error.contains("lsd_order")); +} + #[test] fn test_repetition_code_decoder() { let pcm = repetition_code(5); diff --git a/crates/pecos-pymatching/include/pymatching_bridge.h b/crates/pecos-pymatching/include/pymatching_bridge.h index a76c20163..c3adb0abe 100644 --- a/crates/pecos-pymatching/include/pymatching_bridge.h +++ b/crates/pecos-pymatching/include/pymatching_bridge.h @@ -39,6 +39,7 @@ class PyMatchingGraph { double weight, double error_probability, MergeStrategy merge_strategy); + void set_all_error_probabilities(double error_probability); // Graph queries size_t get_num_nodes() const; @@ -124,6 +125,8 @@ void add_boundary_edge( double weight, double error_probability, MergeStrategy merge_strategy); +void pymatching_set_all_error_probabilities( + PyMatchingGraph& graph, double error_probability); size_t pymatching_get_num_nodes(const PyMatchingGraph& graph); size_t pymatching_get_num_detectors(const PyMatchingGraph& graph); diff --git a/crates/pecos-pymatching/src/bridge.cpp b/crates/pecos-pymatching/src/bridge.cpp index 7bbef999d..40b66882b 100644 --- a/crates/pecos-pymatching/src/bridge.cpp +++ b/crates/pecos-pymatching/src/bridge.cpp @@ -185,6 +185,15 @@ void PyMatchingGraph::add_boundary_edge( } } +void PyMatchingGraph::set_all_error_probabilities(double error_probability) { + double weight = std::log((1 - error_probability) / error_probability); + for (auto& edge : pimpl_->user_graph_->edges) { + edge.weight = weight; + edge.error_probability = error_probability; + } + pimpl_->mwpm_.reset(); +} + // ===== Graph Queries ===== size_t PyMatchingGraph::get_num_nodes() const { @@ -714,6 +723,11 @@ void add_boundary_edge( graph.add_boundary_edge(node, observables, weight, error_probability, merge_strategy); } +void pymatching_set_all_error_probabilities( + PyMatchingGraph& graph, double error_probability) { + graph.set_all_error_probabilities(error_probability); +} + size_t pymatching_get_num_nodes(const PyMatchingGraph& graph) { return graph.get_num_nodes(); } diff --git a/crates/pecos-pymatching/src/bridge.rs b/crates/pecos-pymatching/src/bridge.rs index b2becdc86..61303d8f6 100644 --- a/crates/pecos-pymatching/src/bridge.rs +++ b/crates/pecos-pymatching/src/bridge.rs @@ -119,6 +119,11 @@ pub(crate) mod ffi { merge_strategy: MergeStrategy, ) -> Result<()>; + fn pymatching_set_all_error_probabilities( + graph: Pin<&mut PyMatchingGraph>, + error_probability: f64, + ); + // ===== Graph Queries ===== /// Get the number of nodes in the graph. diff --git a/crates/pecos-pymatching/src/decoder.rs b/crates/pecos-pymatching/src/decoder.rs index f63a67d6d..08c7b2af1 100644 --- a/crates/pecos-pymatching/src/decoder.rs +++ b/crates/pecos-pymatching/src/decoder.rs @@ -359,6 +359,15 @@ impl fmt::Display for PyMatchingDecoder { } impl PyMatchingDecoder { + fn validate_error_probability(error_probability: f64) -> Result<()> { + if !(0.0..1.0).contains(&error_probability) || error_probability == 0.0 { + return Err(PyMatchingError::Configuration( + "error_probability must be finite and strictly between 0 and 1".to_string(), + )); + } + Ok(()) + } + /// Normalize edge parameters to their default values fn normalize_edge_params( weight: Option, @@ -535,6 +544,36 @@ impl PyMatchingDecoder { Ok(Self { graph, config }) } + /// Create a decoder from a DEM and override every graph edge's error probability. + /// + /// The graph structure is preserved. Each matching weight is recomputed + /// from the supplied probability, matching the builder's probability semantics. + /// + /// # Errors + /// + /// Returns an error if the DEM is invalid, the probability is outside + /// `(0, 1)`, or an edge cannot be updated. + pub fn from_dem_with_error_probability( + dem_string: &str, + error_probability: f64, + ) -> Result { + let mut decoder = Self::from_dem(dem_string)?; + decoder.set_all_error_probabilities(error_probability)?; + Ok(decoder) + } + + /// Replace the error probability and derived matching weight on every edge. + /// + /// # Errors + /// + /// Returns an error if `error_probability` is outside `(0, 1)` or an edge + /// cannot be updated. + pub fn set_all_error_probabilities(&mut self, error_probability: f64) -> Result<()> { + Self::validate_error_probability(error_probability)?; + ffi::pymatching_set_all_error_probabilities(self.graph.pin_mut(), error_probability); + Ok(()) + } + /// Create a decoder from a DEM string with correlation support /// /// When `enable_correlations` is true, the decoder tracks edge correlations @@ -1772,6 +1811,37 @@ impl DecodingResultTrait for DecodingResult { mod config_tests { use super::*; + #[test] + fn test_dem_error_probability_override_reaches_every_edge() { + let dem = + "error(0.1) D0\nerror(0.2) D1 L0\ndetector D0\ndetector D1\nlogical_observable L0"; + let baseline = PyMatchingDecoder::from_dem(dem).unwrap().get_all_edges(); + let overridden = PyMatchingDecoder::from_dem_with_error_probability(dem, 0.35) + .unwrap() + .get_all_edges(); + + assert_eq!(baseline.len(), overridden.len()); + let expected_weight = ((1.0_f64 - 0.35) / 0.35).ln(); + for after in &overridden { + assert!((after.weight - expected_weight).abs() < f64::EPSILON); + assert!((after.error_probability - 0.35).abs() < f64::EPSILON); + } + + let error = PyMatchingDecoder::from_dem_with_error_probability(dem, f64::NAN) + .err() + .unwrap() + .to_string(); + assert!(error.contains("error_probability")); + + for endpoint in [0.0, 1.0] { + let error = PyMatchingDecoder::from_dem_with_error_probability(dem, endpoint) + .err() + .unwrap() + .to_string(); + assert!(error.contains("error_probability")); + } + } + #[test] fn test_check_matrix_config_api() { // Test the new config-based API diff --git a/crates/pecos-relay-bp/src/config.rs b/crates/pecos-relay-bp/src/config.rs index 8bec52bf9..12bfcc0d8 100644 --- a/crates/pecos-relay-bp/src/config.rs +++ b/crates/pecos-relay-bp/src/config.rs @@ -85,6 +85,23 @@ impl MinSumConfig { } } + /// Validate min-sum tuning parameters. + /// + /// # Errors + /// + /// Returns a configuration error if a scaling value is not finite or is negative. + pub fn validate(&self) -> crate::errors::Result<()> { + if self + .alpha + .is_some_and(|alpha| !alpha.is_finite() || alpha < 0.0) + { + return Err(crate::errors::RelayBpError::Configuration( + "alpha must be finite and non-negative".to_string(), + )); + } + Ok(()) + } + /// Convert to relay-bp's internal config type. /// /// This creates an `ndarray_016::Array1` (relay-bp's pinned ndarray 0.16), diff --git a/crates/pecos-relay-bp/src/decoder.rs b/crates/pecos-relay-bp/src/decoder.rs index 08dcab23d..6c88b6d0c 100644 --- a/crates/pecos-relay-bp/src/decoder.rs +++ b/crates/pecos-relay-bp/src/decoder.rs @@ -27,6 +27,8 @@ pub struct RelayBpDecoder { inner: relay_bp::bp::relay::RelayDecoder, num_checks: usize, num_bits: usize, + min_sum_config: MinSumConfig, + relay_config: RelayConfig, } impl RelayBpDecoder { @@ -40,6 +42,7 @@ impl RelayBpDecoder { min_sum_config: &MinSumConfig, relay_config: &RelayConfig, ) -> Result { + min_sum_config.validate()?; let num_checks = check_matrix.nrows(); let num_bits = check_matrix.ncols(); @@ -57,6 +60,8 @@ impl RelayBpDecoder { inner, num_checks, num_bits, + min_sum_config: min_sum_config.clone(), + relay_config: relay_config.clone(), }) } @@ -102,6 +107,24 @@ impl RelayBpDecoder { pub fn bit_count(&self) -> usize { self.num_bits } + + /// Get the maximum number of BP iterations. + #[must_use] + pub fn max_iter(&self) -> usize { + self.min_sum_config.max_iter + } + + /// Get the optional min-sum scaling factor. + #[must_use] + pub fn alpha(&self) -> Option { + self.min_sum_config.alpha + } + + /// Get the random seed used for relay parameter sampling. + #[must_use] + pub fn seed(&self) -> u64 { + self.relay_config.seed + } } /// Min-sum BP decoder @@ -112,6 +135,7 @@ pub struct MinSumBpDecoder { inner: relay_bp::bp::min_sum::MinSumBPDecoder, num_checks: usize, num_bits: usize, + config: MinSumConfig, } impl MinSumBpDecoder { @@ -121,6 +145,7 @@ impl MinSumBpDecoder { /// /// Returns [`RelayBpError::InvalidMatrix`] if the check matrix is invalid. pub fn new(check_matrix: &ArrayView2, config: &MinSumConfig) -> Result { + config.validate()?; let num_checks = check_matrix.nrows(); let num_bits = check_matrix.ncols(); @@ -133,6 +158,7 @@ impl MinSumBpDecoder { inner, num_checks, num_bits, + config: config.clone(), }) } @@ -178,6 +204,18 @@ impl MinSumBpDecoder { pub fn bit_count(&self) -> usize { self.num_bits } + + /// Get the maximum number of BP iterations. + #[must_use] + pub fn max_iter(&self) -> usize { + self.config.max_iter + } + + /// Get the optional min-sum scaling factor. + #[must_use] + pub fn alpha(&self) -> Option { + self.config.alpha + } } #[cfg(test)] @@ -204,6 +242,32 @@ mod tests { assert!(result.converged); } + #[test] + fn test_alpha_validation_names_parameter() { + let h = repetition_code_matrix(); + let mut config = MinSumConfig::new(vec![0.1, 0.1, 0.1]); + config.alpha = Some(f64::NAN); + + let error = MinSumBpDecoder::new(&h.view(), &config) + .err() + .unwrap() + .to_string(); + assert!(error.contains("alpha")); + + config.alpha = Some(-0.1); + let error = MinSumBpDecoder::new(&h.view(), &config) + .err() + .unwrap() + .to_string(); + assert!(error.contains("alpha")); + + let error = RelayBpDecoder::new(&h.view(), &config, &RelayConfig::default()) + .err() + .unwrap() + .to_string(); + assert!(error.contains("alpha")); + } + #[test] fn test_relay_decoder() { let h = repetition_code_matrix(); diff --git a/crates/pecos-tesseract/src/decoder.rs b/crates/pecos-tesseract/src/decoder.rs index 517f4d8fd..c2492bcaf 100644 --- a/crates/pecos-tesseract/src/decoder.rs +++ b/crates/pecos-tesseract/src/decoder.rs @@ -67,6 +67,31 @@ impl Default for TesseractConfig { } impl TesseractConfig { + /// Validate configuration values before passing them through FFI. + /// + /// # Errors + /// + /// Returns [`TesseractError::InvalidConfig`] when a numeric tuning parameter + /// is outside its supported range. + pub fn validate(&self) -> Result<(), TesseractError> { + if self.det_beam == 0 { + return Err(TesseractError::InvalidConfig( + "det_beam must be greater than 0".to_string(), + )); + } + if self.pqlimit == 0 { + return Err(TesseractError::InvalidConfig( + "pqlimit must be greater than 0".to_string(), + )); + } + if !self.det_penalty.is_finite() || self.det_penalty < 0.0 { + return Err(TesseractError::InvalidConfig( + "det_penalty must be finite and non-negative".to_string(), + )); + } + Ok(()) + } + /// Create a new configuration with optimized settings for performance #[must_use] pub fn fast() -> Self { @@ -176,6 +201,7 @@ impl TesseractDecoder { /// - The DEM contains unsupported error mechanisms /// - Memory allocation fails pub fn new(dem_string: &str, config: TesseractConfig) -> Result { + config.validate()?; let config_repr = config.to_ffi_repr(); let inner = ffi::create_tesseract_decoder(dem_string, &config_repr) @@ -448,4 +474,65 @@ mod tests { assert!(!config.beam_climbing); assert!(!config.no_revisit_dets); } + + #[test] + fn test_tesseract_config_validation_names_invalid_parameter() { + let mut config = TesseractConfig { + det_beam: 0, + ..TesseractConfig::default() + }; + assert!( + config + .validate() + .unwrap_err() + .to_string() + .contains("det_beam") + ); + + config = TesseractConfig { + pqlimit: 0, + ..TesseractConfig::default() + }; + assert!( + config + .validate() + .unwrap_err() + .to_string() + .contains("pqlimit") + ); + + config = TesseractConfig { + det_penalty: f64::NAN, + ..TesseractConfig::default() + }; + assert!( + config + .validate() + .unwrap_err() + .to_string() + .contains("det_penalty") + ); + + config = TesseractConfig { + det_penalty: -0.1, + ..TesseractConfig::default() + }; + assert!( + config + .validate() + .unwrap_err() + .to_string() + .contains("det_penalty") + ); + + config = TesseractConfig { + pqlimit: 0, + ..TesseractConfig::default() + }; + let error = TesseractDecoder::new("error(0.1) D0\ndetector D0", config) + .err() + .unwrap() + .to_string(); + assert!(error.contains("pqlimit")); + } } diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 06e2f838c..e33534fc8 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -304,8 +304,8 @@ an `observables_mask` bitmask. from pecos.decoders import BpOsdDecoder, PyMatchingDecoder, TesseractDecoder pymatching = PyMatchingDecoder.from_dem(terminal_graphlike_text) -tesseract = TesseractDecoder.from_dem(source_graphlike_text, preset="fast") -bp_osd = BpOsdDecoder.from_dem(raw_text) +tesseract = TesseractDecoder.from_dem(source_graphlike_text, preset="fast", pqlimit=50_000) +bp_osd = BpOsdDecoder.from_dem(raw_text, max_iter=10, osd_order=1) pymatching_errors = 0 tesseract_errors = 0 diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index 8bd6a0b7e..c7c4a016d 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -2089,7 +2089,19 @@ class decoders: def __init__(self, num_nodes: int, num_observables: int = ...) -> None: ... @staticmethod - def from_dem(dem: str) -> decoders.PyMatchingDecoder: ... + def from_dem( + dem: str, + error_probability: float | None = ..., + ) -> decoders.PyMatchingDecoder: + """Build from a detector error model. + + Args: + dem: Detector error model text; its graph dimensions remain structural. + error_probability: Replaces every edge probability and its derived matching weight; better + calibration can improve accuracy without changing asymptotic runtime or memory. + """ + ... + @staticmethod def from_dem_with_correlations( dem: str, @@ -2114,7 +2126,21 @@ class decoders: weights: list[float] | None = ..., ) -> None: ... @staticmethod - def from_dem(dem: str, correlated: bool = ...) -> decoders.FusionBlossomDecoder: ... + def from_dem( + dem: str, + correlated: bool = ..., + solver_type: str | None = ..., + ) -> decoders.FusionBlossomDecoder: + """Build from a detector error model. + + Args: + dem: Detector error model text; node and observable counts are always derived from it. + correlated: Preserves decomposed correlations for accuracy at additional construction/runtime cost. + solver_type: ``"serial"`` is generally faster; ``"legacy"`` supports more graph shapes. + ``None`` preserves the serial default. Parallel requires an unavailable partition configuration. + """ + ... + def decode_syndrome(self, syndrome: list[int]) -> decoders.MwpmResult: ... def decode_from_defects( self, @@ -2176,8 +2202,26 @@ class decoders: def from_dem( dem: str, error_rate: float | None = ..., - max_iter: int = ..., - ) -> decoders.DemAwareDecoder: ... + max_iter: int | None = ..., + bp_schedule: str | None = ..., + ms_scaling_factor: float | None = ..., + osd_order: int | None = ..., + random_schedule_seed: int | None = ..., + ) -> decoders.DemAwareDecoder: + """Build BP+OSD from a detector error model. + + Args: + dem: Detector error model text; check-matrix dimensions are derived from it. + error_rate: Uniform prior override; mismatch can reduce accuracy with little runtime effect. + max_iter: BP iteration cap; larger values may improve convergence but increase runtime. + bp_schedule: Update order; serial may converge sooner while parallel favors throughput. + ms_scaling_factor: Selects minimum-sum BP and sets its correction factor; tuning can improve + accuracy at negligible runtime cost. ``None`` preserves product-sum BP. + osd_order: Combination-sweep order; larger values can improve accuracy at steep runtime cost. + random_schedule_seed: Makes randomized scheduling reproducible without changing its runtime bound. + """ + ... + def decode_syndrome(self, syndrome: list[int]) -> decoders.BpResult: ... def __repr__(self) -> str: ... @@ -2230,8 +2274,24 @@ class decoders: def from_dem( dem: str, error_rate: float | None = ..., - max_iter: int = ..., - ) -> decoders.DemAwareDecoder: ... + max_iter: int | None = ..., + bp_schedule: str | None = ..., + ms_scaling_factor: float | None = ..., + random_schedule_seed: int | None = ..., + ) -> decoders.DemAwareDecoder: + """Build BP+LSD from a detector error model. + + Args: + dem: Detector error model text; check-matrix dimensions are derived from it. + error_rate: Uniform prior override; mismatch can reduce accuracy with little runtime effect. + max_iter: BP iteration cap; larger values may improve convergence but increase runtime. + bp_schedule: Update order; serial may converge sooner while parallel favors throughput. + ms_scaling_factor: Selects minimum-sum BP and sets its correction factor; tuning can improve + accuracy at negligible runtime cost. ``None`` preserves product-sum BP. + random_schedule_seed: Makes randomized scheduling reproducible without changing its runtime bound. + """ + ... + def decode(self, syndrome: list[int]) -> decoders.BpResult: ... def __repr__(self) -> str: ... @@ -2270,9 +2330,16 @@ class decoders: @staticmethod def from_dem( dem: str, - error_rate: float | None = ..., - max_iter: int = ..., - ) -> decoders.DemAwareDecoder: ... + method: str | None = ..., + ) -> decoders.DemAwareDecoder: + """Build Union-Find from a detector error model. + + Args: + dem: Detector error model text; check-matrix dimensions are derived from it. + method: ``"peeling"`` is faster on compatible LDPC matrices; ``"inversion"`` is more general. + """ + ... + def decode_syndrome( self, syndrome: list[int], @@ -2302,8 +2369,25 @@ class decoders: preset: str = ..., det_beam: int | None = ..., beam_climbing: bool | None = ..., - verbose: bool = ..., - ) -> decoders.TesseractDecoder: ... + verbose: bool | None = ..., + no_revisit_dets: bool | None = ..., + pqlimit: int | None = ..., + det_penalty: float | None = ..., + ) -> decoders.TesseractDecoder: + """Build Tesseract from a detector error model and optional preset overrides. + + Args: + dem: Detector error model text; detector and observable counts are derived from it. + preset: Baseline accuracy/runtime profile: ``"default"``, ``"fast"``, or ``"accurate"``. + det_beam: Larger detector beams can improve accuracy at increased runtime and memory cost. + beam_climbing: Enables a faster search heuristic that can alter the accuracy/runtime balance. + verbose: Enables diagnostic output without changing accuracy or memory use. + no_revisit_dets: Avoids revisits for lower runtime, with a possible accuracy cost. + pqlimit: Priority-queue cap; smaller values bound memory at a possible accuracy cost. + det_penalty: Larger penalties prune search more aggressively for speed at possible accuracy cost. + """ + ... + def decode_from_defects(self, detections: list[int]) -> decoders.TesseractResult: ... def decode_syndrome(self, syndrome: list[int]) -> decoders.TesseractResult: ... def decode_batch( @@ -2406,8 +2490,21 @@ class decoders: def from_dem( dem: str, error_rate: float | None = ..., - max_iter: int = ..., - ) -> decoders.DemAwareDecoder: ... + max_iter: int | None = ..., + alpha: float | None = ..., + seed: int | None = ..., + ) -> decoders.DemAwareDecoder: + """Build Relay BP from a detector error model. + + Args: + dem: Detector error model text; check-matrix dimensions are derived from it. + error_rate: Uniform prior override; mismatch can reduce accuracy with little runtime effect. + max_iter: BP iteration cap; larger values may improve convergence but increase runtime. + alpha: Min-sum scaling factor; tuning can improve accuracy at negligible runtime cost. + seed: Makes relay sampling reproducible without increasing its runtime bound. + """ + ... + def decode(self, syndrome: list[int]) -> decoders.BpResult: """Decode a syndrome vector. @@ -2480,8 +2577,19 @@ class decoders: def from_dem( dem: str, error_rate: float | None = ..., - max_iter: int = ..., - ) -> decoders.DemAwareDecoder: ... + max_iter: int | None = ..., + alpha: float | None = ..., + ) -> decoders.DemAwareDecoder: + """Build min-sum BP from a detector error model. + + Args: + dem: Detector error model text; check-matrix dimensions are derived from it. + error_rate: Uniform prior override; mismatch can reduce accuracy with little runtime effect. + max_iter: BP iteration cap; larger values may improve convergence but increase runtime. + alpha: Min-sum scaling factor; tuning can improve accuracy at negligible runtime cost. + """ + ... + def decode(self, syndrome: list[int]) -> decoders.BpResult: """Decode a syndrome vector. diff --git a/python/pecos-rslib/src/decoder_bindings.rs b/python/pecos-rslib/src/decoder_bindings.rs index 7048790b7..75bbe79fe 100644 --- a/python/pecos-rslib/src/decoder_bindings.rs +++ b/python/pecos-rslib/src/decoder_bindings.rs @@ -440,6 +440,8 @@ impl PyPyMatchingDecoder { /// # Arguments /// /// * `dem` - Detector error model string in Stim format + /// * `error_probability` - Replace every edge probability and its derived matching weight; + /// closer calibration can improve decoding accuracy without changing asymptotic runtime /// /// # Example /// @@ -448,8 +450,14 @@ impl PyPyMatchingDecoder { /// decoder = PyMatchingDecoder.from_dem(dem) /// ``` #[staticmethod] - fn from_dem(dem: &str) -> PyResult { - RustPyMatchingDecoder::from_dem(dem) + #[pyo3(signature = (dem, error_probability=None))] + fn from_dem(dem: &str, error_probability: Option) -> PyResult { + let inner = if let Some(error_probability) = error_probability { + RustPyMatchingDecoder::from_dem_with_error_probability(dem, error_probability) + } else { + RustPyMatchingDecoder::from_dem(dem) + }; + inner .map(|inner| Self { inner }) .map_err(|e| PyErr::new::(e.to_string())) } @@ -725,6 +733,8 @@ impl PyFusionBlossomDecoder { /// /// * `dem` - Detector error model string in Stim format /// * `correlated` - Exploit X-Z correlations from decomposed mechanisms + /// * `solver_type` - "serial" is usually faster while "legacy" can handle + /// more graph shapes; neither changes the DEM-derived memory footprint /// /// # Example /// @@ -732,12 +742,26 @@ impl PyFusionBlossomDecoder { /// decoder = FusionBlossomDecoder.from_dem(dem_string) /// ``` #[staticmethod] - #[pyo3(signature = (dem, correlated=false))] - fn from_dem(dem: &str, correlated: bool) -> PyResult { + #[pyo3(signature = (dem, correlated=false, solver_type=None))] + fn from_dem(dem: &str, correlated: bool, solver_type: Option<&str>) -> PyResult { + let solver_type = match solver_type.unwrap_or("serial") { + "legacy" => RustSolverType::Legacy, + "serial" => RustSolverType::Serial, + "parallel" => { + return Err(PyErr::new::( + "solver_type 'parallel' requires a partition configuration, which from_dem does not accept", + )); + } + _ => { + return Err(PyErr::new::( + "solver_type must be 'legacy' or 'serial'", + )); + } + }; let inner = if correlated { - RustFusionBlossomDecoder::from_dem_correlated(dem) + RustFusionBlossomDecoder::from_dem_correlated_with_solver_type(dem, solver_type) } else { - RustFusionBlossomDecoder::from_dem(dem) + RustFusionBlossomDecoder::from_dem_with_solver_type(dem, solver_type) }; inner .map(|inner| Self { inner }) @@ -1071,12 +1095,76 @@ fn parse_bp_schedule(s: &str) -> PyResult { match s { "parallel" => Ok(RustBpSchedule::Parallel), "serial" => Ok(RustBpSchedule::Serial), + "serial_relative" => Ok(RustBpSchedule::SerialRelative), + _ => Err(PyErr::new::( + "bp_schedule must be 'parallel', 'serial', or 'serial_relative'", + )), + } +} + +fn parse_uf_method(s: &str) -> PyResult { + match s { + "inversion" => Ok(RustUfMethod::Inversion), + "peeling" => Ok(RustUfMethod::Peeling), _ => Err(PyErr::new::( - "schedule must be 'parallel' or 'serial'", + "method must be 'inversion' or 'peeling'", )), } } +fn optional_usize(value: Option, parameter: &str) -> PyResult> { + value + .map(|value| { + usize::try_from(value).map_err(|_| { + PyErr::new::(format!( + "{parameter} must be a non-negative integer no greater than {}", + usize::MAX + )) + }) + }) + .transpose() +} + +fn optional_u16(value: Option, parameter: &str) -> PyResult> { + value + .map(|value| { + u16::try_from(value).map_err(|_| { + PyErr::new::(format!( + "{parameter} must be an integer between 0 and {}", + u16::MAX + )) + }) + }) + .transpose() +} + +fn optional_i32(value: Option, parameter: &str) -> PyResult> { + value + .map(|value| { + i32::try_from(value).map_err(|_| { + PyErr::new::(format!( + "{parameter} must be an integer between {} and {}", + i32::MIN, + i32::MAX + )) + }) + }) + .transpose() +} + +fn optional_u64(value: Option, parameter: &str) -> PyResult> { + value + .map(|value| { + u64::try_from(value).map_err(|_| { + PyErr::new::(format!( + "{parameter} must be an integer between 0 and {}", + u64::MAX + )) + }) + }) + .transpose() +} + /// Parse an OSD method string into the Rust enum. fn parse_osd_method(s: &str) -> PyResult { match s { @@ -1210,14 +1298,38 @@ pub struct PyBpOsdDecoder { #[pymethods] impl PyBpOsdDecoder { /// Create a DEM-aware BP+OSD decoder from a Detector Error Model. + /// + /// * `error_rate` - Uniform prior override; model mismatch can reduce accuracy, with little runtime effect + /// * `max_iter` - BP iteration cap; larger values can improve convergence but increase runtime + /// * `bp_schedule` - Update ordering; serial may converge sooner while parallel favors throughput + /// * `ms_scaling_factor` - Select minimum-sum BP and set its correction factor; + /// tuning can improve accuracy with negligible runtime cost + /// * `osd_order` - Combination-sweep order; larger values can improve accuracy at steep runtime cost + /// * `random_schedule_seed` - Reproducible randomized scheduling; changes exploration, not its runtime bound #[staticmethod] - #[pyo3(signature = (dem, error_rate=None, max_iter=100))] + #[pyo3(signature = (dem, error_rate=None, max_iter=None, bp_schedule=None, ms_scaling_factor=None, osd_order=None, random_schedule_seed=None))] fn from_dem( dem: &str, error_rate: Option, - max_iter: usize, + max_iter: Option, + bp_schedule: Option<&str>, + ms_scaling_factor: Option, + osd_order: Option, + random_schedule_seed: Option, ) -> PyResult { - PyDemAwareDecoder::from_dem(dem, "bp_osd", error_rate, max_iter) + PyDemAwareDecoder::from_dem_with_overrides( + dem, + "bp_osd", + error_rate, + DemDecoderOverrides { + max_iter: optional_usize(max_iter, "max_iter")?, + bp_schedule: bp_schedule.map(parse_bp_schedule).transpose()?, + ms_scaling_factor, + osd_order: optional_usize(osd_order, "osd_order")?, + random_schedule_seed: optional_i32(random_schedule_seed, "random_schedule_seed")?, + ..Default::default() + }, + ) } /// Decode a syndrome. @@ -1364,14 +1476,35 @@ pub struct PyBpLsdDecoder { #[pymethods] impl PyBpLsdDecoder { /// Create a DEM-aware BP+LSD decoder from a Detector Error Model. + /// + /// * `error_rate` - Uniform prior override; model mismatch can reduce accuracy, with little runtime effect + /// * `max_iter` - BP iteration cap; larger values can improve convergence but increase runtime + /// * `bp_schedule` - Update ordering; serial may converge sooner while parallel favors throughput + /// * `ms_scaling_factor` - Select minimum-sum BP and set its correction factor; + /// tuning can improve accuracy with negligible runtime cost + /// * `random_schedule_seed` - Reproducible randomized scheduling; changes exploration, not its runtime bound #[staticmethod] - #[pyo3(signature = (dem, error_rate=None, max_iter=100))] + #[pyo3(signature = (dem, error_rate=None, max_iter=None, bp_schedule=None, ms_scaling_factor=None, random_schedule_seed=None))] fn from_dem( dem: &str, error_rate: Option, - max_iter: usize, + max_iter: Option, + bp_schedule: Option<&str>, + ms_scaling_factor: Option, + random_schedule_seed: Option, ) -> PyResult { - PyDemAwareDecoder::from_dem(dem, "bp_lsd", error_rate, max_iter) + PyDemAwareDecoder::from_dem_with_overrides( + dem, + "bp_lsd", + error_rate, + DemDecoderOverrides { + max_iter: optional_usize(max_iter, "max_iter")?, + bp_schedule: bp_schedule.map(parse_bp_schedule).transpose()?, + ms_scaling_factor, + random_schedule_seed: optional_i32(random_schedule_seed, "random_schedule_seed")?, + ..Default::default() + }, + ) } /// Decode a syndrome. @@ -1471,14 +1604,20 @@ pub struct PyUnionFindDecoder { #[pymethods] impl PyUnionFindDecoder { /// Create a DEM-aware Union-Find decoder from a Detector Error Model. + /// + /// * `method` - "peeling" is faster on compatible LDPC matrices; "inversion" is more general #[staticmethod] - #[pyo3(signature = (dem, error_rate=None, max_iter=100))] - fn from_dem( - dem: &str, - error_rate: Option, - max_iter: usize, - ) -> PyResult { - PyDemAwareDecoder::from_dem(dem, "union_find", error_rate, max_iter) + #[pyo3(signature = (dem, method=None))] + fn from_dem(dem: &str, method: Option<&str>) -> PyResult { + PyDemAwareDecoder::from_dem_with_overrides( + dem, + "union_find", + None, + DemDecoderOverrides { + uf_method: method.map(parse_uf_method).transpose()?, + ..Default::default() + }, + ) } /// Decode a syndrome. @@ -1612,7 +1751,10 @@ impl PyTesseractDecoder { /// * `preset` - Configuration preset: "default", "fast", or "accurate" /// * `det_beam` - Detector beam size (default: `u16::MAX` for infinite) /// * `beam_climbing` - Enable beam climbing heuristic - /// * `verbose` - Enable verbose output + /// * `verbose` - Enable verbose output; no accuracy/runtime tradeoff when disabled + /// * `no_revisit_dets` - Avoid revisiting detectors, reducing runtime at possible accuracy cost + /// * `pqlimit` - Priority queue entry cap; smaller values bound memory at possible accuracy cost + /// * `det_penalty` - Search penalty for adding detectors; larger values prune more aggressively /// /// # Example /// @@ -1623,18 +1765,28 @@ impl PyTesseractDecoder { /// decoder = TesseractDecoder.from_dem(dem, preset="fast") /// ``` #[staticmethod] - #[pyo3(signature = (dem, preset="default", det_beam=None, beam_climbing=None, verbose=false))] + #[pyo3(signature = (dem, preset="default", det_beam=None, beam_climbing=None, verbose=None, no_revisit_dets=None, pqlimit=None, det_penalty=None))] fn from_dem( dem: &str, preset: &str, - det_beam: Option, + det_beam: Option, beam_climbing: Option, - verbose: bool, + verbose: Option, + no_revisit_dets: Option, + pqlimit: Option, + det_penalty: Option, ) -> PyResult { + let det_beam = optional_u16(det_beam, "det_beam")?; + let pqlimit = optional_usize(pqlimit, "pqlimit")?; let mut config = match preset { "fast" => RustTesseractConfig::fast(), "accurate" => RustTesseractConfig::accurate(), - _ => RustTesseractConfig::default(), + "default" => RustTesseractConfig::default(), + _ => { + return Err(PyErr::new::( + "preset must be 'default', 'fast', or 'accurate'", + )); + } }; // Override with explicit parameters @@ -1644,7 +1796,18 @@ impl PyTesseractDecoder { if let Some(climbing) = beam_climbing { config.beam_climbing = climbing; } - config.verbose = verbose; + if let Some(verbose) = verbose { + config.verbose = verbose; + } + if let Some(no_revisit_dets) = no_revisit_dets { + config.no_revisit_dets = no_revisit_dets; + } + if let Some(pqlimit) = pqlimit { + config.pqlimit = pqlimit; + } + if let Some(det_penalty) = det_penalty { + config.det_penalty = det_penalty; + } let dem_string = dem.to_string(); RustTesseractDecoder::new(dem, config.clone()) @@ -2065,14 +2228,31 @@ pub struct PyRelayBpDecoder { #[pymethods] impl PyRelayBpDecoder { /// Create a DEM-aware Relay BP decoder from a Detector Error Model. + /// + /// * `error_rate` - Uniform prior override; model mismatch can reduce accuracy, with little runtime effect + /// * `max_iter` - BP iteration cap; larger values can improve convergence but increase runtime + /// * `alpha` - Min-sum scaling factor; tuning can improve accuracy with negligible runtime cost + /// * `seed` - Reproducible relay sampling; changes exploration without increasing its runtime bound #[staticmethod] - #[pyo3(signature = (dem, error_rate=None, max_iter=100))] + #[pyo3(signature = (dem, error_rate=None, max_iter=None, alpha=None, seed=None))] fn from_dem( dem: &str, error_rate: Option, - max_iter: usize, + max_iter: Option, + alpha: Option, + seed: Option, ) -> PyResult { - PyDemAwareDecoder::from_dem(dem, "relay_bp", error_rate, max_iter) + PyDemAwareDecoder::from_dem_with_overrides( + dem, + "relay_bp", + error_rate, + DemDecoderOverrides { + max_iter: optional_usize(max_iter, "max_iter")?, + alpha, + relay_seed: optional_u64(seed, "seed")?, + ..Default::default() + }, + ) } /// Decode a syndrome. @@ -2239,14 +2419,28 @@ pub struct PyMinSumBpDecoder { #[pymethods] impl PyMinSumBpDecoder { /// Create a DEM-aware min-sum BP decoder from a Detector Error Model. + /// + /// * `error_rate` - Uniform prior override; model mismatch can reduce accuracy, with little runtime effect + /// * `max_iter` - BP iteration cap; larger values can improve convergence but increase runtime + /// * `alpha` - Min-sum scaling factor; tuning can improve accuracy with negligible runtime cost #[staticmethod] - #[pyo3(signature = (dem, error_rate=None, max_iter=100))] + #[pyo3(signature = (dem, error_rate=None, max_iter=None, alpha=None))] fn from_dem( dem: &str, error_rate: Option, - max_iter: usize, + max_iter: Option, + alpha: Option, ) -> PyResult { - PyDemAwareDecoder::from_dem(dem, "min_sum_bp", error_rate, max_iter) + PyDemAwareDecoder::from_dem_with_overrides( + dem, + "min_sum_bp", + error_rate, + DemDecoderOverrides { + max_iter: optional_usize(max_iter, "max_iter")?, + alpha, + ..Default::default() + }, + ) } /// Decode a syndrome. @@ -2306,6 +2500,18 @@ enum InnerDecoder { MinSumBp(Box), } +#[derive(Clone, Copy, Default)] +struct DemDecoderOverrides { + max_iter: Option, + bp_schedule: Option, + ms_scaling_factor: Option, + osd_order: Option, + random_schedule_seed: Option, + uf_method: Option, + alpha: Option, + relay_seed: Option, +} + /// DEM-aware decoder that wraps a check-matrix decoder. /// /// Parses a DEM string, extracts the check matrix and observable matrix, @@ -2327,6 +2533,145 @@ pub struct PyDemAwareDecoder { dem_check_matrix: DemCheckMatrix, } +impl PyDemAwareDecoder { + fn from_dem_with_overrides( + dem: &str, + decoder_type: &str, + error_rate: Option, + overrides: DemDecoderOverrides, + ) -> PyResult { + const DEFAULT_MAX_ITER: usize = 100; + + let dcm = DemCheckMatrix::from_dem_str(dem) + .map_err(|e| PyErr::new::(e.to_string()))?; + + if dcm.num_mechanisms == 0 { + return Err(PyErr::new::( + "DEM contains no error mechanisms", + )); + } + + // Error priors: use per-mechanism probabilities from DEM, or uniform override. + let priors: Vec = if let Some(p) = error_rate { + vec![p; dcm.num_mechanisms] + } else { + dcm.error_priors.clone() + }; + let max_iter = overrides.max_iter.unwrap_or(DEFAULT_MAX_ITER); + + // The check matrix shape and observable map are structural properties of + // the DEM and are deliberately never accepted as caller overrides. + let sparse_h = RustSparseMatrix::from_dense(&dcm.check_matrix.view()); + + let inner = match decoder_type { + "bp_osd" => { + let osd_order = overrides.osd_order.unwrap_or(0); + let osd_method = if overrides.osd_order.is_some_and(|order| order > 0) { + RustOsdMethod::OsdCs + } else { + RustOsdMethod::Osd0 + }; + let bp_method = if overrides.ms_scaling_factor.is_some() { + RustBpMethod::MinimumSum + } else { + RustBpMethod::ProductSum + }; + let decoder = RustBpOsdDecoder::new( + &sparse_h, + None, + Some(&priors), + max_iter, + bp_method, + overrides.bp_schedule.unwrap_or(RustBpSchedule::Parallel), + overrides.ms_scaling_factor.unwrap_or(1.0), + osd_method, + osd_order, + RustInputVectorType::Syndrome, + None, + None, + overrides.random_schedule_seed, + ) + .map_err(|e| PyErr::new::(e.to_string()))?; + InnerDecoder::BpOsd(decoder) + } + "bp_lsd" => { + let bp_method = if overrides.ms_scaling_factor.is_some() { + RustBpMethod::MinimumSum + } else { + RustBpMethod::ProductSum + }; + let decoder = RustBpLsdDecoder::new( + &sparse_h, + None, + Some(&priors), + max_iter, + bp_method, + overrides.bp_schedule.unwrap_or(RustBpSchedule::Parallel), + overrides.ms_scaling_factor.unwrap_or(1.0), + RustOsdMethod::Off, + 0, + 0, + RustInputVectorType::Syndrome, + None, + None, + overrides.random_schedule_seed, + ) + .map_err(|e| PyErr::new::(e.to_string()))?; + InnerDecoder::BpLsd(decoder) + } + "union_find" => { + let decoder = RustUnionFindDecoder::new( + &sparse_h, + overrides.uf_method.unwrap_or(RustUfMethod::Inversion), + ) + .map_err(|e| PyErr::new::(e.to_string()))?; + InnerDecoder::UnionFind(decoder) + } + "relay_bp" => { + use pecos_decoders::RelayBpBuilder as RustRelayBpBuilderT; + let h_view = dcm.check_matrix.view(); + let mut builder = RustRelayBpBuilderT::new(&h_view) + .error_priors(&priors) + .max_iter(max_iter); + if let Some(alpha) = overrides.alpha { + builder = builder.alpha(Some(alpha)); + } + if let Some(seed) = overrides.relay_seed { + builder = builder.seed(seed); + } + let decoder = builder.build().map_err(|e| { + PyErr::new::(e.to_string()) + })?; + InnerDecoder::RelayBp(Box::new(decoder)) + } + "min_sum_bp" => { + use pecos_decoders::MinSumBpBuilder as RustMinSumBpBuilderT; + let h_view = dcm.check_matrix.view(); + let mut builder = RustMinSumBpBuilderT::new(&h_view) + .error_priors(&priors) + .max_iter(max_iter); + if let Some(alpha) = overrides.alpha { + builder = builder.alpha(Some(alpha)); + } + let decoder = builder.build().map_err(|e| { + PyErr::new::(e.to_string()) + })?; + InnerDecoder::MinSumBp(Box::new(decoder)) + } + _ => { + return Err(PyErr::new::(format!( + "Unknown decoder type: {decoder_type}. Supported: bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp" + ))); + } + }; + + Ok(Self { + inner, + dem_check_matrix: dcm, + }) + } +} + /// Result from a DEM-aware decoder. #[pyclass( name = "DemAwareResult", @@ -2408,109 +2753,15 @@ impl PyDemAwareDecoder { error_rate: Option, max_iter: usize, ) -> PyResult { - let dcm = DemCheckMatrix::from_dem_str(dem) - .map_err(|e| PyErr::new::(e.to_string()))?; - - if dcm.num_mechanisms == 0 { - return Err(PyErr::new::( - "DEM contains no error mechanisms", - )); - } - - // Error priors: use per-mechanism probabilities from DEM, or uniform override - let priors: Vec = if let Some(p) = error_rate { - vec![p; dcm.num_mechanisms] - } else { - dcm.error_priors.clone() - }; - - // Build the check matrix in the two formats decoders need: - // SparseMatrix for LDPC decoders, Array2 view for Relay/MinSum. - let sparse_h = RustSparseMatrix::from_dense(&dcm.check_matrix.view()); - - let inner = match decoder_type { - "bp_osd" => { - let decoder = RustBpOsdDecoder::new( - &sparse_h, - None, // error_rate - Some(&priors), // error_channel - max_iter, - RustBpMethod::ProductSum, - RustBpSchedule::Parallel, - 1.0, // ms_scaling_factor - RustOsdMethod::Osd0, - 0, // osd_order - RustInputVectorType::Syndrome, - None, - None, - None, - ) - .map_err(|e| PyErr::new::(e.to_string()))?; - InnerDecoder::BpOsd(decoder) - } - "bp_lsd" => { - let decoder = RustBpLsdDecoder::new( - &sparse_h, - None, // error_rate - Some(&priors), // error_channel - max_iter, - RustBpMethod::ProductSum, - RustBpSchedule::Parallel, - 1.0, // ms_scaling_factor - RustOsdMethod::Off, // lsd_method (LSD-0) - 0, // lsd_order - 0, // bits_per_step - RustInputVectorType::Syndrome, - None, - None, - None, - ) - .map_err(|e| PyErr::new::(e.to_string()))?; - InnerDecoder::BpLsd(decoder) - } - "union_find" => { - let decoder = RustUnionFindDecoder::new(&sparse_h, RustUfMethod::Inversion) - .map_err(|e| { - PyErr::new::(e.to_string()) - })?; - InnerDecoder::UnionFind(decoder) - } - "relay_bp" => { - use pecos_decoders::RelayBpBuilder as RustRelayBpBuilderT; - let h_view = dcm.check_matrix.view(); - let decoder = RustRelayBpBuilderT::new(&h_view) - .error_priors(&priors) - .max_iter(max_iter) - .build() - .map_err(|e| { - PyErr::new::(e.to_string()) - })?; - InnerDecoder::RelayBp(Box::new(decoder)) - } - "min_sum_bp" => { - use pecos_decoders::MinSumBpBuilder as RustMinSumBpBuilderT; - let h_view = dcm.check_matrix.view(); - let decoder = RustMinSumBpBuilderT::new(&h_view) - .error_priors(&priors) - .max_iter(max_iter) - .build() - .map_err(|e| { - PyErr::new::(e.to_string()) - })?; - InnerDecoder::MinSumBp(Box::new(decoder)) - } - _ => { - return Err(PyErr::new::(format!( - "Unknown decoder type: {decoder_type}. \ - Supported: bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp" - ))); - } - }; - - Ok(Self { - inner, - dem_check_matrix: dcm, - }) + Self::from_dem_with_overrides( + dem, + decoder_type, + error_rate, + DemDecoderOverrides { + max_iter: Some(max_iter), + ..Default::default() + }, + ) } /// Decode a dense syndrome vector. @@ -2663,3 +2914,184 @@ pub fn register_decoders_module(parent_module: &Bound<'_, PyModule>) -> PyResult Ok(()) } + +#[cfg(test)] +mod dem_tuning_tests { + use super::*; + + const DEM: &str = + "detector D0\ndetector D1\nlogical_observable L0\nerror(0.1) D0\nerror(0.1) D1 L0\n"; + + #[test] + fn tesseract_overrides_reach_config_and_win_over_preset() { + let decoder = PyTesseractDecoder::from_dem( + DEM, + "fast", + None, + None, + None, + Some(false), + Some(12_345), + Some(0.25), + ) + .unwrap(); + + assert!(!decoder.config.no_revisit_dets); + assert_eq!(decoder.config.pqlimit, 12_345); + assert!((decoder.config.det_penalty - 0.25).abs() < f64::EPSILON); + assert_eq!(decoder.config.det_beam, 5); + } + + #[test] + fn bp_osd_overrides_reach_inner_decoder() { + let decoder = PyBpOsdDecoder::from_dem( + DEM, + None, + Some(17), + Some("serial"), + Some(0.75), + Some(2), + Some(42), + ) + .unwrap(); + let InnerDecoder::BpOsd(inner) = decoder.inner else { + panic!("expected BP+OSD inner decoder"); + }; + + assert_eq!(inner.max_iter(), 17); + assert_eq!(inner.bp_method(), RustBpMethod::MinimumSum); + assert_eq!(inner.bp_schedule(), RustBpSchedule::Serial); + assert!((inner.ms_scaling_factor() - 0.75).abs() < f64::EPSILON); + assert_eq!(inner.osd_order(), 2); + assert_eq!(inner.osd_method(), RustOsdMethod::OsdCs); + assert_eq!(inner.random_schedule_seed(), 42); + } + + #[test] + fn bp_lsd_overrides_reach_inner_decoder() { + let decoder = PyBpLsdDecoder::from_dem( + DEM, + None, + Some(19), + Some("serial_relative"), + Some(0.625), + Some(24), + ) + .unwrap(); + let InnerDecoder::BpLsd(inner) = decoder.inner else { + panic!("expected BP+LSD inner decoder"); + }; + + assert_eq!(inner.max_iter(), 19); + assert_eq!(inner.bp_method(), RustBpMethod::MinimumSum); + assert_eq!(inner.bp_schedule(), RustBpSchedule::SerialRelative); + assert!((inner.ms_scaling_factor() - 0.625).abs() < f64::EPSILON); + assert_eq!(inner.random_schedule_seed(), 24); + } + + #[test] + fn union_find_override_reaches_inner_decoder() { + let decoder = PyUnionFindDecoder::from_dem(DEM, Some("peeling")).unwrap(); + let InnerDecoder::UnionFind(inner) = decoder.inner else { + panic!("expected Union-Find inner decoder"); + }; + + assert_eq!(inner.method(), RustUfMethod::Peeling); + } + + #[test] + fn relay_bp_overrides_reach_inner_decoder() { + let decoder = PyRelayBpDecoder::from_dem(DEM, None, Some(23), Some(0.8), Some(91)).unwrap(); + let InnerDecoder::RelayBp(inner) = decoder.inner else { + panic!("expected Relay BP inner decoder"); + }; + + assert_eq!(inner.max_iter(), 23); + assert_eq!(inner.alpha(), Some(0.8)); + assert_eq!(inner.seed(), 91); + } + + #[test] + fn min_sum_bp_overrides_reach_inner_decoder() { + let decoder = PyMinSumBpDecoder::from_dem(DEM, None, Some(29), Some(0.7)).unwrap(); + let InnerDecoder::MinSumBp(inner) = decoder.inner else { + panic!("expected min-sum BP inner decoder"); + }; + + assert_eq!(inner.max_iter(), 29); + assert_eq!(inner.alpha(), Some(0.7)); + } + + #[test] + fn bp_family_none_overrides_preserve_dem_defaults() { + let bp_osd = PyBpOsdDecoder::from_dem(DEM, None, None, None, None, None, None).unwrap(); + let InnerDecoder::BpOsd(bp_osd) = bp_osd.inner else { + panic!("expected BP+OSD inner decoder"); + }; + assert_eq!(bp_osd.max_iter(), 100); + assert_eq!(bp_osd.bp_method(), RustBpMethod::ProductSum); + assert_eq!(bp_osd.bp_schedule(), RustBpSchedule::Parallel); + assert!((bp_osd.ms_scaling_factor() - 1.0).abs() < f64::EPSILON); + assert_eq!(bp_osd.osd_order(), 0); + assert_eq!(bp_osd.random_schedule_seed(), -1); + + let relay = PyRelayBpDecoder::from_dem(DEM, None, None, None, None).unwrap(); + let InnerDecoder::RelayBp(relay) = relay.inner else { + panic!("expected Relay BP inner decoder"); + }; + assert_eq!(relay.max_iter(), 100); + assert_eq!(relay.alpha(), None); + assert_eq!(relay.seed(), 0); + } + + #[test] + fn textual_guards_name_the_parameter() { + pyo3::Python::initialize(); + + let preset_error = + PyTesseractDecoder::from_dem(DEM, "quick", None, None, None, None, None, None) + .err() + .unwrap(); + assert!(preset_error.to_string().contains("preset")); + + let schedule_error = PyBpLsdDecoder::from_dem(DEM, None, None, Some("random"), None, None) + .err() + .unwrap(); + assert!(schedule_error.to_string().contains("bp_schedule")); + + let method_error = PyUnionFindDecoder::from_dem(DEM, Some("fast")) + .err() + .unwrap(); + assert!(method_error.to_string().contains("method")); + + let solver_error = PyFusionBlossomDecoder::from_dem(DEM, false, Some("parallel")) + .err() + .unwrap(); + let message = solver_error.to_string(); + assert!(message.contains("solver_type")); + assert!(message.contains("partition configuration")); + + let solver_error = PyFusionBlossomDecoder::from_dem(DEM, false, Some("fast")) + .err() + .unwrap(); + assert!(solver_error.to_string().contains("solver_type")); + + for (error, parameter) in [ + ( + optional_usize(Some(-1), "max_iter").unwrap_err(), + "max_iter", + ), + ( + optional_u16(Some(65_536), "det_beam").unwrap_err(), + "det_beam", + ), + ( + optional_i32(Some(i64::from(i32::MAX) + 1), "random_schedule_seed").unwrap_err(), + "random_schedule_seed", + ), + (optional_u64(Some(-1), "seed").unwrap_err(), "seed"), + ] { + assert!(error.to_string().contains(parameter)); + } + } +} diff --git a/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py b/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py index 6759b131f..099e2e74e 100644 --- a/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py +++ b/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py @@ -13,6 +13,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import pytest from pecos.decoders import ( MWPM2D, @@ -33,6 +35,9 @@ ) from pecos_rslib.qec import decoder_dem_requirement +if TYPE_CHECKING: + from collections.abc import Callable + _DEM = """error(0.1) D0 D1 L0 error(0.1) D1 L0 detector D0 @@ -48,6 +53,7 @@ _SYNDROMES = ([0, 0], [1, 0], [0, 1], [1, 1]) _OBSERVABLE_MASKS_BEFORE = [0, 0, 1, 1] +_FAMILY_RESULTS_BEFORE = [(0, True, 1), (0, True, 1), (1, True, 1), (1, True, 1)] _FAMILY_DECODERS = [ (BpOsdDecoder, "bp_osd"), (BpLsdDecoder, "bp_lsd"), @@ -55,6 +61,7 @@ (RelayBpDecoder, "relay_bp"), (MinSumBpDecoder, "min_sum_bp"), ] +_ITERATIVE_FAMILY_DECODERS = [BpOsdDecoder, BpLsdDecoder, RelayBpDecoder, MinSumBpDecoder] def test_dem_aware_result_is_importable() -> None: @@ -129,6 +136,9 @@ def test_family_from_dem_matches_existing_wrapper(decoder_class: type, decoder_t named_masks = [result.observables_mask for result in named_results] existing_masks = [result.observables_mask for result in existing_results] assert named_masks == existing_masks == _OBSERVABLE_MASKS_BEFORE + assert [(result.observables_mask, result.converged, result.iterations) for result in named_results] == ( + _FAMILY_RESULTS_BEFORE + ) assert isinstance(named_decoder, DemAwareDecoder) assert named_decoder.num_detectors == existing_decoder.num_detectors == 2 assert named_decoder.num_mechanisms == existing_decoder.num_mechanisms == 2 @@ -137,27 +147,77 @@ def test_family_from_dem_matches_existing_wrapper(decoder_class: type, decoder_t assert not hasattr(named_decoder, "decode") -@pytest.mark.parametrize(("decoder_class", "decoder_type"), _FAMILY_DECODERS) -def test_family_from_dem_forwards_configuration(decoder_class: type, decoder_type: str) -> None: - named_decoder = decoder_class.from_dem(_ENCODING_DEM, error_rate=0.2, max_iter=0) - existing_decoder = DemAwareDecoder.from_dem( +@pytest.mark.parametrize("decoder_class", _ITERATIVE_FAMILY_DECODERS) +def test_iterative_family_from_dem_accepts_tuning(decoder_class: type) -> None: + named_decoder = decoder_class.from_dem(_ENCODING_DEM, error_rate=0.2, max_iter=1) + named_result = named_decoder.decode_syndrome([0, 1]) + assert named_result.observables_mask == 1 + + +def test_each_family_accepts_only_its_real_tuning_surface() -> None: + assert BpOsdDecoder.from_dem( _ENCODING_DEM, - decoder_type=decoder_type, - error_rate=0.2, - max_iter=0, + max_iter=5, + bp_schedule="serial", + ms_scaling_factor=0.75, + osd_order=1, + random_schedule_seed=42, ) - - named_result = named_decoder.decode_syndrome([0, 1]) - existing_result = existing_decoder.decode_syndrome([0, 1]) - assert ( - named_result.observables_mask, - named_result.converged, - named_result.iterations, - ) == ( - existing_result.observables_mask, - existing_result.converged, - existing_result.iterations, + assert BpLsdDecoder.from_dem( + _ENCODING_DEM, + max_iter=5, + bp_schedule="serial_relative", + ms_scaling_factor=0.625, + random_schedule_seed=43, ) + assert UnionFindDecoder.from_dem(_ENCODING_DEM, method="peeling") + assert RelayBpDecoder.from_dem(_ENCODING_DEM, max_iter=5, alpha=0.8, seed=44) + assert MinSumBpDecoder.from_dem(_ENCODING_DEM, max_iter=5, alpha=0.7) + + +@pytest.mark.parametrize( + ("build", "parameter"), + [ + (lambda: TesseractDecoder.from_dem(_ENCODING_DEM, preset="quick"), "preset"), + (lambda: TesseractDecoder.from_dem(_ENCODING_DEM, det_beam=0), "det_beam"), + (lambda: TesseractDecoder.from_dem(_ENCODING_DEM, det_beam=2**16), "det_beam"), + (lambda: TesseractDecoder.from_dem(_ENCODING_DEM, pqlimit=-1), "pqlimit"), + (lambda: TesseractDecoder.from_dem(_ENCODING_DEM, pqlimit=0), "pqlimit"), + (lambda: TesseractDecoder.from_dem(_ENCODING_DEM, det_penalty=-0.1), "det_penalty"), + (lambda: TesseractDecoder.from_dem(_ENCODING_DEM, det_penalty=float("nan")), "det_penalty"), + (lambda: BpOsdDecoder.from_dem(_ENCODING_DEM, max_iter=-1), "max_iter"), + (lambda: BpOsdDecoder.from_dem(_ENCODING_DEM, max_iter=2**31), "max_iter"), + (lambda: BpOsdDecoder.from_dem(_ENCODING_DEM, bp_schedule="random"), "bp_schedule"), + (lambda: BpOsdDecoder.from_dem(_ENCODING_DEM, ms_scaling_factor=-0.1), "ms_scaling_factor"), + (lambda: BpOsdDecoder.from_dem(_ENCODING_DEM, ms_scaling_factor=float("nan")), "ms_scaling_factor"), + (lambda: BpOsdDecoder.from_dem(_ENCODING_DEM, osd_order=-1), "osd_order"), + (lambda: BpOsdDecoder.from_dem(_ENCODING_DEM, osd_order=2**31), "osd_order"), + ( + lambda: BpOsdDecoder.from_dem(_ENCODING_DEM, random_schedule_seed=2**31), + "random_schedule_seed", + ), + (lambda: BpLsdDecoder.from_dem(_ENCODING_DEM, max_iter=-1), "max_iter"), + ( + lambda: BpLsdDecoder.from_dem(_ENCODING_DEM, random_schedule_seed=-(2**31) - 1), + "random_schedule_seed", + ), + (lambda: UnionFindDecoder.from_dem(_ENCODING_DEM, method="fast"), "method"), + (lambda: RelayBpDecoder.from_dem(_ENCODING_DEM, max_iter=-1), "max_iter"), + (lambda: RelayBpDecoder.from_dem(_ENCODING_DEM, alpha=-0.1), "alpha"), + (lambda: RelayBpDecoder.from_dem(_ENCODING_DEM, alpha=float("nan")), "alpha"), + (lambda: RelayBpDecoder.from_dem(_ENCODING_DEM, seed=-1), "seed"), + (lambda: MinSumBpDecoder.from_dem(_ENCODING_DEM, max_iter=-1), "max_iter"), + (lambda: MinSumBpDecoder.from_dem(_ENCODING_DEM, alpha=-0.1), "alpha"), + (lambda: PyMatchingDecoder.from_dem(_ENCODING_DEM, error_probability=0.0), "error_probability"), + (lambda: PyMatchingDecoder.from_dem(_ENCODING_DEM, error_probability=1.0), "error_probability"), + (lambda: PyMatchingDecoder.from_dem(_ENCODING_DEM, error_probability=1.1), "error_probability"), + (lambda: FusionBlossomDecoder.from_dem(_ENCODING_DEM, solver_type="parallel"), "solver_type"), + (lambda: FusionBlossomDecoder.from_dem(_ENCODING_DEM, solver_type="fast"), "solver_type"), + ], +) +def test_invalid_dem_tuning_names_parameter(build: Callable[[], object], parameter: str) -> None: + with pytest.raises((ValueError, RuntimeError), match=parameter): + build() @pytest.mark.parametrize("decoder_type", [decoder_type for _, decoder_type in _FAMILY_DECODERS]) From 0432a4a6c587161c1457c3470be422af67f0f575 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 15:27:22 -0600 Subject: [PATCH 56/62] Make the decoder config assembly testable in Rust with per-parameter guards --- python/pecos-rslib/src/decoder_bindings.rs | 989 +++++++++++++++++---- 1 file changed, 792 insertions(+), 197 deletions(-) diff --git a/python/pecos-rslib/src/decoder_bindings.rs b/python/pecos-rslib/src/decoder_bindings.rs index 75bbe79fe..e55153a4a 100644 --- a/python/pecos-rslib/src/decoder_bindings.rs +++ b/python/pecos-rslib/src/decoder_bindings.rs @@ -196,6 +196,19 @@ use pecos_decoders::{ PyMatchingConfig as RustPyMatchingConfig, PyMatchingDecoder as RustPyMatchingDecoder, }; +#[derive(Debug, Clone, Copy, Default, PartialEq)] +struct PyMatchingDemConfig { + error_probability: Option, +} + +fn pymatching_config(error_probability: Option) -> PyMatchingDemConfig { + let mut config = PyMatchingDemConfig::default(); + if let Some(error_probability) = error_probability { + config.error_probability = Some(error_probability); + } + config +} + /// Sparse check matrix for MWPM decoders. /// /// Represents a parity check matrix H where each column corresponds to an error @@ -452,7 +465,8 @@ impl PyPyMatchingDecoder { #[staticmethod] #[pyo3(signature = (dem, error_probability=None))] fn from_dem(dem: &str, error_probability: Option) -> PyResult { - let inner = if let Some(error_probability) = error_probability { + let config = pymatching_config(error_probability); + let inner = if let Some(error_probability) = config.error_probability { RustPyMatchingDecoder::from_dem_with_error_probability(dem, error_probability) } else { RustPyMatchingDecoder::from_dem(dem) @@ -644,6 +658,34 @@ use pecos_decoders::{ StandardCode as RustStandardCode, SyndromeData as RustSyndromeData, }; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct FusionBlossomDemConfig { + correlated: bool, + solver_type: RustSolverType, +} + +fn fusion_blossom_config( + correlated: bool, + solver_type: Option<&str>, +) -> Result { + let mut config = FusionBlossomDemConfig { + correlated, + solver_type: RustSolverType::Serial, + }; + match solver_type.unwrap_or("serial") { + "legacy" => config.solver_type = RustSolverType::Legacy, + "serial" => {} + "parallel" => { + return Err( + "solver_type 'parallel' requires a partition configuration, which from_dem does not accept" + .to_string(), + ); + } + _ => return Err("solver_type must be 'legacy' or 'serial'".to_string()), + } + Ok(config) +} + /// Fusion Blossom MWPM decoder. /// /// Pure Rust implementation of minimum-weight perfect matching. @@ -744,24 +786,12 @@ impl PyFusionBlossomDecoder { #[staticmethod] #[pyo3(signature = (dem, correlated=false, solver_type=None))] fn from_dem(dem: &str, correlated: bool, solver_type: Option<&str>) -> PyResult { - let solver_type = match solver_type.unwrap_or("serial") { - "legacy" => RustSolverType::Legacy, - "serial" => RustSolverType::Serial, - "parallel" => { - return Err(PyErr::new::( - "solver_type 'parallel' requires a partition configuration, which from_dem does not accept", - )); - } - _ => { - return Err(PyErr::new::( - "solver_type must be 'legacy' or 'serial'", - )); - } - }; - let inner = if correlated { - RustFusionBlossomDecoder::from_dem_correlated_with_solver_type(dem, solver_type) + let config = fusion_blossom_config(correlated, solver_type) + .map_err(PyErr::new::)?; + let inner = if config.correlated { + RustFusionBlossomDecoder::from_dem_correlated_with_solver_type(dem, config.solver_type) } else { - RustFusionBlossomDecoder::from_dem_with_solver_type(dem, solver_type) + RustFusionBlossomDecoder::from_dem_with_solver_type(dem, config.solver_type) }; inner .map(|inner| Self { inner }) @@ -1092,23 +1122,23 @@ fn parse_bp_method(s: &str) -> PyResult { /// Parse a BP schedule string into the Rust enum. fn parse_bp_schedule(s: &str) -> PyResult { + bp_schedule(s).map_err(PyErr::new::) +} + +fn bp_schedule(s: &str) -> Result { match s { "parallel" => Ok(RustBpSchedule::Parallel), "serial" => Ok(RustBpSchedule::Serial), "serial_relative" => Ok(RustBpSchedule::SerialRelative), - _ => Err(PyErr::new::( - "bp_schedule must be 'parallel', 'serial', or 'serial_relative'", - )), + _ => Err("bp_schedule must be 'parallel', 'serial', or 'serial_relative'".to_string()), } } -fn parse_uf_method(s: &str) -> PyResult { +fn uf_method(s: &str) -> Result { match s { "inversion" => Ok(RustUfMethod::Inversion), "peeling" => Ok(RustUfMethod::Peeling), - _ => Err(PyErr::new::( - "method must be 'inversion' or 'peeling'", - )), + _ => Err("method must be 'inversion' or 'peeling'".to_string()), } } @@ -1165,6 +1195,216 @@ fn optional_u64(value: Option, parameter: &str) -> PyResult> { .transpose() } +const DEFAULT_DEM_MAX_ITER: usize = 100; + +#[derive(Debug, Clone, Copy, PartialEq)] +struct BpOsdDemConfig { + error_rate: Option, + max_iter: usize, + bp_method: RustBpMethod, + bp_schedule: RustBpSchedule, + ms_scaling_factor: f64, + osd_method: RustOsdMethod, + osd_order: usize, + random_schedule_seed: Option, +} + +impl Default for BpOsdDemConfig { + fn default() -> Self { + Self { + error_rate: None, + max_iter: DEFAULT_DEM_MAX_ITER, + bp_method: RustBpMethod::ProductSum, + bp_schedule: RustBpSchedule::Parallel, + ms_scaling_factor: 1.0, + osd_method: RustOsdMethod::Osd0, + osd_order: 0, + random_schedule_seed: None, + } + } +} + +fn bp_osd_config( + error_rate: Option, + max_iter: Option, + bp_schedule: Option<&str>, + ms_scaling_factor: Option, + osd_order: Option, + random_schedule_seed: Option, +) -> Result { + let mut config = BpOsdDemConfig::default(); + if let Some(error_rate) = error_rate { + config.error_rate = Some(error_rate); + } + if let Some(max_iter) = max_iter { + config.max_iter = max_iter; + } + if let Some(bp_schedule) = bp_schedule { + config.bp_schedule = self::bp_schedule(bp_schedule)?; + } + if let Some(ms_scaling_factor) = ms_scaling_factor { + config.bp_method = RustBpMethod::MinimumSum; + config.ms_scaling_factor = ms_scaling_factor; + } + if let Some(osd_order) = osd_order { + if osd_order > 0 { + config.osd_method = RustOsdMethod::OsdCs; + } + config.osd_order = osd_order; + } + if let Some(random_schedule_seed) = random_schedule_seed { + config.random_schedule_seed = Some(random_schedule_seed); + } + Ok(config) +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct BpLsdDemConfig { + error_rate: Option, + max_iter: usize, + bp_method: RustBpMethod, + bp_schedule: RustBpSchedule, + ms_scaling_factor: f64, + random_schedule_seed: Option, +} + +impl Default for BpLsdDemConfig { + fn default() -> Self { + Self { + error_rate: None, + max_iter: DEFAULT_DEM_MAX_ITER, + bp_method: RustBpMethod::ProductSum, + bp_schedule: RustBpSchedule::Parallel, + ms_scaling_factor: 1.0, + random_schedule_seed: None, + } + } +} + +fn bp_lsd_config( + error_rate: Option, + max_iter: Option, + bp_schedule: Option<&str>, + ms_scaling_factor: Option, + random_schedule_seed: Option, +) -> Result { + let mut config = BpLsdDemConfig::default(); + if let Some(error_rate) = error_rate { + config.error_rate = Some(error_rate); + } + if let Some(max_iter) = max_iter { + config.max_iter = max_iter; + } + if let Some(bp_schedule) = bp_schedule { + config.bp_schedule = self::bp_schedule(bp_schedule)?; + } + if let Some(ms_scaling_factor) = ms_scaling_factor { + config.bp_method = RustBpMethod::MinimumSum; + config.ms_scaling_factor = ms_scaling_factor; + } + if let Some(random_schedule_seed) = random_schedule_seed { + config.random_schedule_seed = Some(random_schedule_seed); + } + Ok(config) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct UnionFindDemConfig { + method: RustUfMethod, +} + +impl Default for UnionFindDemConfig { + fn default() -> Self { + Self { + method: RustUfMethod::Inversion, + } + } +} + +fn union_find_config(method: Option<&str>) -> Result { + let mut config = UnionFindDemConfig::default(); + if let Some(method) = method { + config.method = uf_method(method)?; + } + Ok(config) +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct RelayBpDemConfig { + error_rate: Option, + max_iter: usize, + alpha: Option, + seed: u64, +} + +impl Default for RelayBpDemConfig { + fn default() -> Self { + Self { + error_rate: None, + max_iter: DEFAULT_DEM_MAX_ITER, + alpha: None, + seed: 0, + } + } +} + +fn relay_bp_config( + error_rate: Option, + max_iter: Option, + alpha: Option, + seed: Option, +) -> RelayBpDemConfig { + let mut config = RelayBpDemConfig::default(); + if let Some(error_rate) = error_rate { + config.error_rate = Some(error_rate); + } + if let Some(max_iter) = max_iter { + config.max_iter = max_iter; + } + if let Some(alpha) = alpha { + config.alpha = Some(alpha); + } + if let Some(seed) = seed { + config.seed = seed; + } + config +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct MinSumBpDemConfig { + error_rate: Option, + max_iter: usize, + alpha: Option, +} + +impl Default for MinSumBpDemConfig { + fn default() -> Self { + Self { + error_rate: None, + max_iter: DEFAULT_DEM_MAX_ITER, + alpha: None, + } + } +} + +fn min_sum_bp_config( + error_rate: Option, + max_iter: Option, + alpha: Option, +) -> MinSumBpDemConfig { + let mut config = MinSumBpDemConfig::default(); + if let Some(error_rate) = error_rate { + config.error_rate = Some(error_rate); + } + if let Some(max_iter) = max_iter { + config.max_iter = max_iter; + } + if let Some(alpha) = alpha { + config.alpha = Some(alpha); + } + config +} + /// Parse an OSD method string into the Rust enum. fn parse_osd_method(s: &str) -> PyResult { match s { @@ -1317,19 +1557,16 @@ impl PyBpOsdDecoder { osd_order: Option, random_schedule_seed: Option, ) -> PyResult { - PyDemAwareDecoder::from_dem_with_overrides( - dem, - "bp_osd", + let config = bp_osd_config( error_rate, - DemDecoderOverrides { - max_iter: optional_usize(max_iter, "max_iter")?, - bp_schedule: bp_schedule.map(parse_bp_schedule).transpose()?, - ms_scaling_factor, - osd_order: optional_usize(osd_order, "osd_order")?, - random_schedule_seed: optional_i32(random_schedule_seed, "random_schedule_seed")?, - ..Default::default() - }, + optional_usize(max_iter, "max_iter")?, + bp_schedule, + ms_scaling_factor, + optional_usize(osd_order, "osd_order")?, + optional_i32(random_schedule_seed, "random_schedule_seed")?, ) + .map_err(PyErr::new::)?; + PyDemAwareDecoder::from_dem_with_config(dem, DemDecoderConfig::BpOsd(config)) } /// Decode a syndrome. @@ -1493,18 +1730,15 @@ impl PyBpLsdDecoder { ms_scaling_factor: Option, random_schedule_seed: Option, ) -> PyResult { - PyDemAwareDecoder::from_dem_with_overrides( - dem, - "bp_lsd", + let config = bp_lsd_config( error_rate, - DemDecoderOverrides { - max_iter: optional_usize(max_iter, "max_iter")?, - bp_schedule: bp_schedule.map(parse_bp_schedule).transpose()?, - ms_scaling_factor, - random_schedule_seed: optional_i32(random_schedule_seed, "random_schedule_seed")?, - ..Default::default() - }, + optional_usize(max_iter, "max_iter")?, + bp_schedule, + ms_scaling_factor, + optional_i32(random_schedule_seed, "random_schedule_seed")?, ) + .map_err(PyErr::new::)?; + PyDemAwareDecoder::from_dem_with_config(dem, DemDecoderConfig::BpLsd(config)) } /// Decode a syndrome. @@ -1609,15 +1843,9 @@ impl PyUnionFindDecoder { #[staticmethod] #[pyo3(signature = (dem, method=None))] fn from_dem(dem: &str, method: Option<&str>) -> PyResult { - PyDemAwareDecoder::from_dem_with_overrides( - dem, - "union_find", - None, - DemDecoderOverrides { - uf_method: method.map(parse_uf_method).transpose()?, - ..Default::default() - }, - ) + let config = + union_find_config(method).map_err(PyErr::new::)?; + PyDemAwareDecoder::from_dem_with_config(dem, DemDecoderConfig::UnionFind(config)) } /// Decode a syndrome. @@ -1665,6 +1893,44 @@ use pecos_decoders::{ TesseractConfig as RustTesseractConfig, TesseractDecoder as RustTesseractDecoder, }; +fn tesseract_config( + preset: &str, + det_beam: Option, + beam_climbing: Option, + verbose: Option, + no_revisit_dets: Option, + pqlimit: Option, + det_penalty: Option, +) -> Result { + let mut config = match preset { + "fast" => RustTesseractConfig::fast(), + "accurate" => RustTesseractConfig::accurate(), + "default" => RustTesseractConfig::default(), + _ => return Err("preset must be 'default', 'fast', or 'accurate'".to_string()), + }; + + if let Some(det_beam) = det_beam { + config.det_beam = det_beam; + } + if let Some(beam_climbing) = beam_climbing { + config.beam_climbing = beam_climbing; + } + if let Some(verbose) = verbose { + config.verbose = verbose; + } + if let Some(no_revisit_dets) = no_revisit_dets { + config.no_revisit_dets = no_revisit_dets; + } + if let Some(pqlimit) = pqlimit { + config.pqlimit = pqlimit; + } + if let Some(det_penalty) = det_penalty { + config.det_penalty = det_penalty; + } + + Ok(config) +} + /// Result from Tesseract decoder. /// /// # Attributes @@ -1778,36 +2044,16 @@ impl PyTesseractDecoder { ) -> PyResult { let det_beam = optional_u16(det_beam, "det_beam")?; let pqlimit = optional_usize(pqlimit, "pqlimit")?; - let mut config = match preset { - "fast" => RustTesseractConfig::fast(), - "accurate" => RustTesseractConfig::accurate(), - "default" => RustTesseractConfig::default(), - _ => { - return Err(PyErr::new::( - "preset must be 'default', 'fast', or 'accurate'", - )); - } - }; - - // Override with explicit parameters - if let Some(beam) = det_beam { - config.det_beam = beam; - } - if let Some(climbing) = beam_climbing { - config.beam_climbing = climbing; - } - if let Some(verbose) = verbose { - config.verbose = verbose; - } - if let Some(no_revisit_dets) = no_revisit_dets { - config.no_revisit_dets = no_revisit_dets; - } - if let Some(pqlimit) = pqlimit { - config.pqlimit = pqlimit; - } - if let Some(det_penalty) = det_penalty { - config.det_penalty = det_penalty; - } + let config = tesseract_config( + preset, + det_beam, + beam_climbing, + verbose, + no_revisit_dets, + pqlimit, + det_penalty, + ) + .map_err(PyErr::new::)?; let dem_string = dem.to_string(); RustTesseractDecoder::new(dem, config.clone()) @@ -2242,17 +2488,13 @@ impl PyRelayBpDecoder { alpha: Option, seed: Option, ) -> PyResult { - PyDemAwareDecoder::from_dem_with_overrides( - dem, - "relay_bp", + let config = relay_bp_config( error_rate, - DemDecoderOverrides { - max_iter: optional_usize(max_iter, "max_iter")?, - alpha, - relay_seed: optional_u64(seed, "seed")?, - ..Default::default() - }, - ) + optional_usize(max_iter, "max_iter")?, + alpha, + optional_u64(seed, "seed")?, + ); + PyDemAwareDecoder::from_dem_with_config(dem, DemDecoderConfig::RelayBp(config)) } /// Decode a syndrome. @@ -2431,16 +2673,8 @@ impl PyMinSumBpDecoder { max_iter: Option, alpha: Option, ) -> PyResult { - PyDemAwareDecoder::from_dem_with_overrides( - dem, - "min_sum_bp", - error_rate, - DemDecoderOverrides { - max_iter: optional_usize(max_iter, "max_iter")?, - alpha, - ..Default::default() - }, - ) + let config = min_sum_bp_config(error_rate, optional_usize(max_iter, "max_iter")?, alpha); + PyDemAwareDecoder::from_dem_with_config(dem, DemDecoderConfig::MinSumBp(config)) } /// Decode a syndrome. @@ -2500,16 +2734,25 @@ enum InnerDecoder { MinSumBp(Box), } -#[derive(Clone, Copy, Default)] -struct DemDecoderOverrides { - max_iter: Option, - bp_schedule: Option, - ms_scaling_factor: Option, - osd_order: Option, - random_schedule_seed: Option, - uf_method: Option, - alpha: Option, - relay_seed: Option, +#[derive(Debug, Clone, Copy, PartialEq)] +enum DemDecoderConfig { + BpOsd(BpOsdDemConfig), + BpLsd(BpLsdDemConfig), + UnionFind(UnionFindDemConfig), + RelayBp(RelayBpDemConfig), + MinSumBp(MinSumBpDemConfig), +} + +impl DemDecoderConfig { + fn error_rate(self) -> Option { + match self { + Self::BpOsd(config) => config.error_rate, + Self::BpLsd(config) => config.error_rate, + Self::UnionFind(_) => None, + Self::RelayBp(config) => config.error_rate, + Self::MinSumBp(config) => config.error_rate, + } + } } /// DEM-aware decoder that wraps a check-matrix decoder. @@ -2534,14 +2777,7 @@ pub struct PyDemAwareDecoder { } impl PyDemAwareDecoder { - fn from_dem_with_overrides( - dem: &str, - decoder_type: &str, - error_rate: Option, - overrides: DemDecoderOverrides, - ) -> PyResult { - const DEFAULT_MAX_ITER: usize = 100; - + fn parse_dem(dem: &str) -> PyResult { let dcm = DemCheckMatrix::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; @@ -2551,118 +2787,102 @@ impl PyDemAwareDecoder { )); } + Ok(dcm) + } + + fn from_dem_with_config(dem: &str, config: DemDecoderConfig) -> PyResult { + let dcm = Self::parse_dem(dem)?; + Self::from_dem_check_matrix_with_config(dcm, config) + } + + fn from_dem_check_matrix_with_config( + dcm: DemCheckMatrix, + config: DemDecoderConfig, + ) -> PyResult { // Error priors: use per-mechanism probabilities from DEM, or uniform override. - let priors: Vec = if let Some(p) = error_rate { + let priors: Vec = if let Some(p) = config.error_rate() { vec![p; dcm.num_mechanisms] } else { dcm.error_priors.clone() }; - let max_iter = overrides.max_iter.unwrap_or(DEFAULT_MAX_ITER); // The check matrix shape and observable map are structural properties of // the DEM and are deliberately never accepted as caller overrides. let sparse_h = RustSparseMatrix::from_dense(&dcm.check_matrix.view()); - let inner = match decoder_type { - "bp_osd" => { - let osd_order = overrides.osd_order.unwrap_or(0); - let osd_method = if overrides.osd_order.is_some_and(|order| order > 0) { - RustOsdMethod::OsdCs - } else { - RustOsdMethod::Osd0 - }; - let bp_method = if overrides.ms_scaling_factor.is_some() { - RustBpMethod::MinimumSum - } else { - RustBpMethod::ProductSum - }; + let inner = match config { + DemDecoderConfig::BpOsd(config) => { let decoder = RustBpOsdDecoder::new( &sparse_h, None, Some(&priors), - max_iter, - bp_method, - overrides.bp_schedule.unwrap_or(RustBpSchedule::Parallel), - overrides.ms_scaling_factor.unwrap_or(1.0), - osd_method, - osd_order, + config.max_iter, + config.bp_method, + config.bp_schedule, + config.ms_scaling_factor, + config.osd_method, + config.osd_order, RustInputVectorType::Syndrome, None, None, - overrides.random_schedule_seed, + config.random_schedule_seed, ) .map_err(|e| PyErr::new::(e.to_string()))?; InnerDecoder::BpOsd(decoder) } - "bp_lsd" => { - let bp_method = if overrides.ms_scaling_factor.is_some() { - RustBpMethod::MinimumSum - } else { - RustBpMethod::ProductSum - }; + DemDecoderConfig::BpLsd(config) => { let decoder = RustBpLsdDecoder::new( &sparse_h, None, Some(&priors), - max_iter, - bp_method, - overrides.bp_schedule.unwrap_or(RustBpSchedule::Parallel), - overrides.ms_scaling_factor.unwrap_or(1.0), + config.max_iter, + config.bp_method, + config.bp_schedule, + config.ms_scaling_factor, RustOsdMethod::Off, 0, 0, RustInputVectorType::Syndrome, None, None, - overrides.random_schedule_seed, + config.random_schedule_seed, ) .map_err(|e| PyErr::new::(e.to_string()))?; InnerDecoder::BpLsd(decoder) } - "union_find" => { - let decoder = RustUnionFindDecoder::new( - &sparse_h, - overrides.uf_method.unwrap_or(RustUfMethod::Inversion), - ) - .map_err(|e| PyErr::new::(e.to_string()))?; + DemDecoderConfig::UnionFind(config) => { + let decoder = RustUnionFindDecoder::new(&sparse_h, config.method).map_err(|e| { + PyErr::new::(e.to_string()) + })?; InnerDecoder::UnionFind(decoder) } - "relay_bp" => { + DemDecoderConfig::RelayBp(config) => { use pecos_decoders::RelayBpBuilder as RustRelayBpBuilderT; let h_view = dcm.check_matrix.view(); - let mut builder = RustRelayBpBuilderT::new(&h_view) + let decoder = RustRelayBpBuilderT::new(&h_view) .error_priors(&priors) - .max_iter(max_iter); - if let Some(alpha) = overrides.alpha { - builder = builder.alpha(Some(alpha)); - } - if let Some(seed) = overrides.relay_seed { - builder = builder.seed(seed); - } - let decoder = builder.build().map_err(|e| { - PyErr::new::(e.to_string()) - })?; + .max_iter(config.max_iter) + .alpha(config.alpha) + .seed(config.seed) + .build() + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; InnerDecoder::RelayBp(Box::new(decoder)) } - "min_sum_bp" => { + DemDecoderConfig::MinSumBp(config) => { use pecos_decoders::MinSumBpBuilder as RustMinSumBpBuilderT; let h_view = dcm.check_matrix.view(); - let mut builder = RustMinSumBpBuilderT::new(&h_view) + let decoder = RustMinSumBpBuilderT::new(&h_view) .error_priors(&priors) - .max_iter(max_iter); - if let Some(alpha) = overrides.alpha { - builder = builder.alpha(Some(alpha)); - } - let decoder = builder.build().map_err(|e| { - PyErr::new::(e.to_string()) - })?; + .max_iter(config.max_iter) + .alpha(config.alpha) + .build() + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; InnerDecoder::MinSumBp(Box::new(decoder)) } - _ => { - return Err(PyErr::new::(format!( - "Unknown decoder type: {decoder_type}. Supported: bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp" - ))); - } }; Ok(Self { @@ -2753,15 +2973,30 @@ impl PyDemAwareDecoder { error_rate: Option, max_iter: usize, ) -> PyResult { - Self::from_dem_with_overrides( - dem, - decoder_type, - error_rate, - DemDecoderOverrides { - max_iter: Some(max_iter), - ..Default::default() - }, - ) + let dcm = Self::parse_dem(dem)?; + let config = match decoder_type { + "bp_osd" => DemDecoderConfig::BpOsd( + bp_osd_config(error_rate, Some(max_iter), None, None, None, None) + .map_err(PyErr::new::)?, + ), + "bp_lsd" => DemDecoderConfig::BpLsd( + bp_lsd_config(error_rate, Some(max_iter), None, None, None) + .map_err(PyErr::new::)?, + ), + "union_find" => DemDecoderConfig::UnionFind(UnionFindDemConfig::default()), + "relay_bp" => { + DemDecoderConfig::RelayBp(relay_bp_config(error_rate, Some(max_iter), None, None)) + } + "min_sum_bp" => { + DemDecoderConfig::MinSumBp(min_sum_bp_config(error_rate, Some(max_iter), None)) + } + _ => { + return Err(PyErr::new::(format!( + "Unknown decoder type: {decoder_type}. Supported: bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp" + ))); + } + }; + Self::from_dem_check_matrix_with_config(dcm, config) } /// Decode a dense syndrome vector. @@ -2922,6 +3157,366 @@ mod dem_tuning_tests { const DEM: &str = "detector D0\ndetector D1\nlogical_observable L0\nerror(0.1) D0\nerror(0.1) D1 L0\n"; + #[test] + fn pymatching_error_probability_override_reaches_config() { + let config = pymatching_config(Some(0.123)); + + assert_eq!(config.error_probability, Some(0.123)); + } + + #[test] + fn pymatching_omitted_override_preserves_default() { + let config = pymatching_config(None); + + assert_eq!(config.error_probability, None); + } + + #[test] + fn fusion_blossom_solver_type_override_reaches_config() { + let config = fusion_blossom_config(true, Some("legacy")).unwrap(); + + assert!(config.correlated); + assert_eq!(config.solver_type, RustSolverType::Legacy); + } + + #[test] + fn fusion_blossom_omitted_override_preserves_default() { + let config = fusion_blossom_config(false, None).unwrap(); + + assert!(!config.correlated); + assert_eq!(config.solver_type, RustSolverType::Serial); + } + + #[test] + fn fusion_blossom_parallel_solver_names_parameter() { + let error = fusion_blossom_config(false, Some("parallel")).unwrap_err(); + + assert!(error.contains("solver_type")); + assert!(error.contains("partition configuration")); + } + + #[test] + fn fusion_blossom_unknown_solver_names_parameter() { + let error = fusion_blossom_config(false, Some("fast")).unwrap_err(); + + assert!(error.contains("solver_type")); + } + + #[test] + fn tesseract_default_preset_has_documented_fields() { + let config = tesseract_config("default", None, None, None, None, None, None).unwrap(); + + assert_eq!(config.det_beam, u16::MAX); + assert!(!config.beam_climbing); + assert!(config.no_revisit_dets); + assert!(!config.verbose); + assert_eq!(config.pqlimit, 200_000); + assert_eq!(config.det_penalty.to_bits(), 0.0_f64.to_bits()); + } + + #[test] + fn tesseract_fast_preset_has_documented_fields() { + let config = tesseract_config("fast", None, None, None, None, None, None).unwrap(); + + assert_eq!(config.det_beam, 5); + assert!(config.beam_climbing); + assert!(config.no_revisit_dets); + assert!(!config.verbose); + assert_eq!(config.pqlimit, 200_000); + assert_eq!(config.det_penalty.to_bits(), 0.1_f64.to_bits()); + } + + #[test] + fn tesseract_accurate_preset_has_documented_fields() { + let config = tesseract_config("accurate", None, None, None, None, None, None).unwrap(); + + assert_eq!(config.det_beam, u16::MAX); + assert!(!config.beam_climbing); + assert!(!config.no_revisit_dets); + assert!(!config.verbose); + assert_eq!(config.pqlimit, 1_000_000); + assert_eq!(config.det_penalty.to_bits(), 0.0_f64.to_bits()); + } + + #[test] + fn tesseract_det_beam_override_reaches_config() { + let config = tesseract_config("default", Some(17), None, None, None, None, None).unwrap(); + + assert_eq!(config.det_beam, 17); + } + + #[test] + fn tesseract_beam_climbing_override_reaches_config() { + let config = + tesseract_config("accurate", None, Some(true), None, None, None, None).unwrap(); + + assert!(config.beam_climbing); + } + + #[test] + fn tesseract_verbose_override_reaches_config() { + let config = tesseract_config("default", None, None, Some(true), None, None, None).unwrap(); + + assert!(config.verbose); + } + + #[test] + fn tesseract_no_revisit_dets_override_reaches_config() { + let config = tesseract_config("fast", None, None, None, Some(false), None, None).unwrap(); + + assert!(!config.no_revisit_dets); + } + + #[test] + fn tesseract_pqlimit_override_reaches_config() { + let config = tesseract_config("fast", None, None, None, None, Some(345_678), None).unwrap(); + + assert_eq!(config.pqlimit, 345_678); + } + + #[test] + fn tesseract_det_penalty_override_reaches_config() { + let config = tesseract_config("fast", None, None, None, None, None, Some(0.25)).unwrap(); + + assert_eq!(config.det_penalty.to_bits(), 0.25_f64.to_bits()); + } + + #[test] + fn tesseract_override_wins_over_preset() { + let config = tesseract_config("fast", Some(19), None, None, None, None, None).unwrap(); + + assert_eq!(config.det_beam, 19); + assert!(config.beam_climbing); + } + + #[test] + fn tesseract_omitted_override_preserves_preset() { + let config = tesseract_config("fast", None, None, None, None, None, None).unwrap(); + + assert_eq!(config.det_beam, 5); + assert!(config.beam_climbing); + assert!(config.no_revisit_dets); + assert!(!config.verbose); + assert_eq!(config.pqlimit, 200_000); + assert_eq!(config.det_penalty.to_bits(), 0.1_f64.to_bits()); + } + + #[test] + fn tesseract_unknown_preset_names_parameter() { + let error = tesseract_config("quick", None, None, None, None, None, None).unwrap_err(); + + assert!(error.contains("preset")); + } + + #[test] + fn bp_osd_error_rate_override_reaches_config() { + let config = bp_osd_config(Some(0.123), None, None, None, None, None).unwrap(); + + assert_eq!(config.error_rate, Some(0.123)); + } + + #[test] + fn bp_osd_max_iter_override_reaches_config() { + let config = bp_osd_config(None, Some(17), None, None, None, None).unwrap(); + + assert_eq!(config.max_iter, 17); + } + + #[test] + fn bp_osd_bp_schedule_override_reaches_config() { + let config = bp_osd_config(None, None, Some("serial_relative"), None, None, None).unwrap(); + + assert_eq!(config.bp_schedule, RustBpSchedule::SerialRelative); + } + + #[test] + fn bp_osd_ms_scaling_factor_override_reaches_config() { + let config = bp_osd_config(None, None, None, Some(0.625), None, None).unwrap(); + + assert_eq!(config.bp_method, RustBpMethod::MinimumSum); + assert_eq!(config.ms_scaling_factor.to_bits(), 0.625_f64.to_bits()); + } + + #[test] + fn bp_osd_osd_order_override_reaches_config() { + let config = bp_osd_config(None, None, None, None, Some(2), None).unwrap(); + + assert_eq!(config.osd_method, RustOsdMethod::OsdCs); + assert_eq!(config.osd_order, 2); + } + + #[test] + fn bp_osd_random_schedule_seed_override_reaches_config() { + let config = bp_osd_config(None, None, None, None, None, Some(42)).unwrap(); + + assert_eq!(config.random_schedule_seed, Some(42)); + } + + #[test] + fn bp_osd_omitted_overrides_preserve_defaults() { + let config = bp_osd_config(None, None, None, None, None, None).unwrap(); + + assert_eq!(config.error_rate, None); + assert_eq!(config.max_iter, 100); + assert_eq!(config.bp_method, RustBpMethod::ProductSum); + assert_eq!(config.bp_schedule, RustBpSchedule::Parallel); + assert_eq!(config.ms_scaling_factor.to_bits(), 1.0_f64.to_bits()); + assert_eq!(config.osd_method, RustOsdMethod::Osd0); + assert_eq!(config.osd_order, 0); + assert_eq!(config.random_schedule_seed, None); + } + + #[test] + fn bp_osd_unknown_schedule_names_parameter() { + let error = bp_osd_config(None, None, Some("random"), None, None, None).unwrap_err(); + + assert!(error.contains("bp_schedule")); + } + + #[test] + fn bp_lsd_error_rate_override_reaches_config() { + let config = bp_lsd_config(Some(0.234), None, None, None, None).unwrap(); + + assert_eq!(config.error_rate, Some(0.234)); + } + + #[test] + fn bp_lsd_max_iter_override_reaches_config() { + let config = bp_lsd_config(None, Some(19), None, None, None).unwrap(); + + assert_eq!(config.max_iter, 19); + } + + #[test] + fn bp_lsd_bp_schedule_override_reaches_config() { + let config = bp_lsd_config(None, None, Some("serial_relative"), None, None).unwrap(); + + assert_eq!(config.bp_schedule, RustBpSchedule::SerialRelative); + } + + #[test] + fn bp_lsd_ms_scaling_factor_override_reaches_config() { + let config = bp_lsd_config(None, None, None, Some(0.75), None).unwrap(); + + assert_eq!(config.bp_method, RustBpMethod::MinimumSum); + assert_eq!(config.ms_scaling_factor.to_bits(), 0.75_f64.to_bits()); + } + + #[test] + fn bp_lsd_random_schedule_seed_override_reaches_config() { + let config = bp_lsd_config(None, None, None, None, Some(24)).unwrap(); + + assert_eq!(config.random_schedule_seed, Some(24)); + } + + #[test] + fn bp_lsd_omitted_overrides_preserve_defaults() { + let config = bp_lsd_config(None, None, None, None, None).unwrap(); + + assert_eq!(config.error_rate, None); + assert_eq!(config.max_iter, 100); + assert_eq!(config.bp_method, RustBpMethod::ProductSum); + assert_eq!(config.bp_schedule, RustBpSchedule::Parallel); + assert_eq!(config.ms_scaling_factor.to_bits(), 1.0_f64.to_bits()); + assert_eq!(config.random_schedule_seed, None); + } + + #[test] + fn bp_lsd_unknown_schedule_names_parameter() { + let error = bp_lsd_config(None, None, Some("random"), None, None).unwrap_err(); + + assert!(error.contains("bp_schedule")); + } + + #[test] + fn union_find_method_override_reaches_config() { + let config = union_find_config(Some("peeling")).unwrap(); + + assert_eq!(config.method, RustUfMethod::Peeling); + } + + #[test] + fn union_find_omitted_override_preserves_default() { + let config = union_find_config(None).unwrap(); + + assert_eq!(config.method, RustUfMethod::Inversion); + } + + #[test] + fn union_find_unknown_method_names_parameter() { + let error = union_find_config(Some("fast")).unwrap_err(); + + assert!(error.contains("method")); + } + + #[test] + fn relay_bp_error_rate_override_reaches_config() { + let config = relay_bp_config(Some(0.345), None, None, None); + + assert_eq!(config.error_rate, Some(0.345)); + } + + #[test] + fn relay_bp_max_iter_override_reaches_config() { + let config = relay_bp_config(None, Some(23), None, None); + + assert_eq!(config.max_iter, 23); + } + + #[test] + fn relay_bp_alpha_override_reaches_config() { + let config = relay_bp_config(None, None, Some(0.8), None); + + assert_eq!(config.alpha, Some(0.8)); + } + + #[test] + fn relay_bp_seed_override_reaches_config() { + let config = relay_bp_config(None, None, None, Some(91)); + + assert_eq!(config.seed, 91); + } + + #[test] + fn relay_bp_omitted_overrides_preserve_defaults() { + let config = relay_bp_config(None, None, None, None); + + assert_eq!(config.error_rate, None); + assert_eq!(config.max_iter, 100); + assert_eq!(config.alpha, None); + assert_eq!(config.seed, 0); + } + + #[test] + fn min_sum_bp_error_rate_override_reaches_config() { + let config = min_sum_bp_config(Some(0.456), None, None); + + assert_eq!(config.error_rate, Some(0.456)); + } + + #[test] + fn min_sum_bp_max_iter_override_reaches_config() { + let config = min_sum_bp_config(None, Some(29), None); + + assert_eq!(config.max_iter, 29); + } + + #[test] + fn min_sum_bp_alpha_override_reaches_config() { + let config = min_sum_bp_config(None, None, Some(0.7)); + + assert_eq!(config.alpha, Some(0.7)); + } + + #[test] + fn min_sum_bp_omitted_overrides_preserve_defaults() { + let config = min_sum_bp_config(None, None, None); + + assert_eq!(config.error_rate, None); + assert_eq!(config.max_iter, 100); + assert_eq!(config.alpha, None); + } + #[test] fn tesseract_overrides_reach_config_and_win_over_preset() { let decoder = PyTesseractDecoder::from_dem( From a05ba587b99da0dd9a2fe1f9d5ac62fec27275aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ryan-Anderson?= <70174051+qciaran@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:45:37 -0600 Subject: [PATCH 57/62] feat(qec): infer DEM annotations from Guppy outputs (#449) Co-authored-by: ciaranra --- crates/pecos-qis-ffi-types/src/operations.rs | 3 +- crates/pecos-qis-ffi/src/ffi.rs | 150 +++++++ crates/pecos-qis-ffi/src/lib.rs | 41 +- crates/pecos-qis/src/ccengine.rs | 120 ++++- crates/pecos-qis/src/executor.rs | 33 ++ crates/pecos-qis/src/qis_interface.rs | 28 ++ crates/pecos-qis/src/runtime.rs | 18 + crates/pecos-qis/src/selene_runtime.rs | 136 +++++- docs/user-guide/dem-from-guppy.md | 17 +- docs/user-guide/inferred-guppy-dem.md | 390 ++++++++++++++++ mkdocs.yml | 1 + python/pecos-rslib/src/sim.rs | 10 +- .../src/pecos/_qis_trace_replay.py | 22 +- .../quantum-pecos/src/pecos/qec/__init__.py | 6 + .../src/pecos/qec/guppy_output_dem.py | 424 ++++++++++++++++++ python/quantum-pecos/src/pecos/tracing.py | 45 +- .../test_selene_interface_integration.py | 99 ++++ .../tests/pecos/test_selene_sim_parity.py | 21 + .../quantum-pecos/tests/pecos/test_tracing.py | 16 +- .../tests/qec/test_guppy_output_dem.py | 122 +++++ 20 files changed, 1640 insertions(+), 62 deletions(-) create mode 100644 docs/user-guide/inferred-guppy-dem.md create mode 100644 python/quantum-pecos/src/pecos/qec/guppy_output_dem.py create mode 100644 python/quantum-pecos/tests/qec/test_guppy_output_dem.py diff --git a/crates/pecos-qis-ffi-types/src/operations.rs b/crates/pecos-qis-ffi-types/src/operations.rs index 7cfc5f604..5cc443b61 100644 --- a/crates/pecos-qis-ffi-types/src/operations.rs +++ b/crates/pecos-qis-ffi-types/src/operations.rs @@ -101,7 +101,8 @@ pub enum QuantumOp { RZZ(f64, usize, usize), // Measurement - Measure(usize, usize), // qubit, result_id + Measure(usize, usize), // qubit, result_id + MeasureLeaked(usize, usize), // qubit, result_id; outcome is 0, 1, or 2 // Reset Reset(usize), diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index 0e5c955b4..db7838b46 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -745,6 +745,21 @@ pub unsafe extern "C" fn ___lazy_measure(qubit: i64) -> i64 { }) } +/// Lazy leakage-aware measurement function (Selene/HUGR-LLVM style). +/// +/// # Safety +/// The same requirements as [`___lazy_measure`] apply. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ___lazy_measure_leaked(qubit: i64) -> i64 { + let qubit_id = i64_to_usize(qubit); + with_interface(|interface| { + let result_id = interface.allocate_result(); + interface.queue_operation(Operation::AllocateResult { id: result_id }); + interface.queue_operation(QuantumOp::MeasureLeaked(qubit_id, result_id).into()); + i64::try_from(result_id).expect("Result ID too large for i64") + }) +} + /// Read a future boolean value (Guppy/HUGR-LLVM style) /// /// This function retrieves a measurement result from a future/deferred measurement. @@ -831,6 +846,38 @@ pub unsafe extern "C" fn ___read_future_bool(future_id: i64) -> bool { } } +/// Read an integer-valued measurement future (Selene/HUGR-LLVM style). +/// +/// Leakage-aware Guppy measurements use an unsigned future with outcomes 0, 1, +/// or 2 (leaked). +/// +/// # Safety +/// The same requirements as [`___read_future_bool`] apply. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ___read_future_uint(future_id: i64) -> u64 { + log::debug!("___read_future_uint called with future_id={future_id}"); + let result_id = i64_to_usize(future_id); + + if crate::is_dynamic_mode_active() { + if let Some(result) = crate::get_measurement_outcome(result_id as u64) { + record_result_read(result_id); + return result; + } + if crate::wait_for_result_ready(result_id as u64, 30_000) { + let result = crate::get_measurement_outcome(result_id as u64); + if result.is_some() { + record_result_read(result_id); + } + return result.unwrap_or(0); + } + } + + // Static collection cannot synthesize a leak. Reuse the Boolean collection + // behavior so bounded probing and repeat-until-success termination remain + // consistent with ordinary measurements. + u64::from(unsafe { ___read_future_bool(future_id) }) +} + /// Reset the collection mode read counter. /// /// This should be called at the start of each new execution to reset the loop @@ -1012,6 +1059,48 @@ pub unsafe extern "C" fn print_bool(label_ptr: *const u8, label_len: i64, value: } } +/// Record an integer result whose value is in the detector-compatible 0/1 domain. +/// +/// Guppy permits integer literals in ``result(...)`` calls. PECOS named results +/// are currently Boolean, but cultivation programs use integer zero for a +/// detector known to be satisfied. Preserve those 0/1 outputs and reject other +/// integers instead of silently coercing arbitrary values. +/// +/// # Safety +/// `label_ptr` must reference a tket2 string with at least `label_len + 1` +/// bytes, as for [`print_bool`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn print_int(label_ptr: *const u8, label_len: i64, value: i64) { + let Ok(label_len_usize) = usize::try_from(label_len) else { + log::error!("print_int: invalid label length {label_len}"); + return; + }; + let data_ptr = unsafe { label_ptr.add(1) }; + let label_slice = unsafe { std::slice::from_raw_parts(data_ptr, label_len_usize) }; + let Ok(label) = std::str::from_utf8(label_slice) else { + log::error!("print_int: invalid UTF-8 in label"); + return; + }; + let name = label.strip_prefix("USER:INT:").unwrap_or(label); + let value = match value { + 0 => false, + 1 => true, + _ => { + log::error!( + "print_int: named result '{name}' has value {value}; PECOS currently supports only Boolean 0/1 named results" + ); + return; + } + }; + + if let Some(ctx) = crate::get_execution_context() { + // SAFETY: The registered context is valid for the execution duration. + unsafe { &*ctx }.store_named_bool(name, value); + } else { + log::warn!("print_int: no execution context for '{name}' = {value}"); + } +} + /// Dense 1D array struct matching the LLVM ABI from tket2 /// /// This struct is passed by pointer from LLVM-compiled code. @@ -1962,6 +2051,67 @@ mod tests { }); } + #[test] + fn test_lazy_measure_leaked_uses_an_allocated_measurement_result() { + setup_test(); + let result_id = unsafe { ___lazy_measure_leaked(0) }; + + assert_eq!(result_id, 0); + with_interface(|iface| { + assert_eq!(iface.operations.len(), 2); + assert_eq!(iface.operations[0], Operation::AllocateResult { id: 0 }); + assert_eq!( + iface.operations[1], + Operation::Quantum(QuantumOp::MeasureLeaked(0, 0)) + ); + }); + } + + #[test] + fn test_read_future_uint_preserves_the_ideal_measurement_value() { + setup_test(); + with_interface(|iface| iface.store_result(4, true)); + + assert_eq!(unsafe { ___read_future_uint(4) }, 1); + } + + #[test] + fn test_read_future_uint_preserves_leakage_outcome() { + setup_test(); + let ctx = crate::pecos_create_execution_context(); + let context = unsafe { &*ctx }; + context + .dynamic_mode_active + .store(true, std::sync::atomic::Ordering::SeqCst); + unsafe { crate::pecos_register_execution_context(ctx) }; + crate::pecos_set_measurement_outcome(4, 2); + + assert_eq!(unsafe { ___read_future_uint(4) }, 2); + + unsafe { + crate::pecos_register_execution_context(std::ptr::null_mut()); + crate::pecos_destroy_execution_context(ctx); + } + } + + #[test] + fn test_print_int_records_boolean_detector_literals() { + let ctx = crate::pecos_create_execution_context(); + unsafe { crate::pecos_register_execution_context(ctx) }; + let label = b"\x08DETECTOR"; + + unsafe { print_int(label.as_ptr(), 8, 0) }; + assert_eq!( + unsafe { &*ctx }.get_named_results()["DETECTOR"], + vec![false] + ); + + unsafe { + crate::pecos_register_execution_context(std::ptr::null_mut()); + crate::pecos_destroy_execution_context(ctx); + } + } + #[test] fn test_read_future_bool_with_stored_result() { setup_test(); diff --git a/crates/pecos-qis-ffi/src/lib.rs b/crates/pecos-qis-ffi/src/lib.rs index 47a1a5d2a..9d8657124 100644 --- a/crates/pecos-qis-ffi/src/lib.rs +++ b/crates/pecos-qis-ffi/src/lib.rs @@ -62,8 +62,10 @@ pub struct ExecutionContext { pub sync_condvar: Condvar, /// Storage for pending operations (shared between threads) pub pending_ops: Mutex>, - /// Storage for measurement results (shared between threads) - pub measurement_results: Mutex>>, + /// Storage for measurement outcomes (shared between threads). + /// + /// Ordinary measurements use 0/1. Leakage-aware measurements may also use 2. + pub measurement_results: Mutex>>, /// Storage for named results from `print_bool`/`print_bool_arr` (e.g., "synx", "final") pub named_results: Mutex>>, /// Runtime provenance for each `result(...)` output call. @@ -650,7 +652,7 @@ pub fn is_dynamic_mode_active() -> bool { /// This is used by the worker thread to get results set by the main thread. /// Returns None if no execution context is registered. #[must_use] -pub fn get_measurement_result(result_id: u64) -> Option { +pub fn get_measurement_outcome(result_id: u64) -> Option { let ctx = get_execution_context()?; let result_index = usize::try_from(result_id).ok()?; // SAFETY: Context is valid for duration of execution @@ -664,6 +666,23 @@ pub fn get_measurement_result(result_id: u64) -> Option { } } +/// Get a Boolean measurement result from the execution context. +/// +/// Returns `None` for a leakage outcome instead of silently treating 2 as true. +#[must_use] +pub fn get_measurement_result(result_id: u64) -> Option { + match get_measurement_outcome(result_id)? { + 0 => Some(false), + 1 => Some(true), + value => { + log::error!( + "get_measurement_result: result_id={result_id} has non-Boolean outcome {value}" + ); + None + } + } +} + /// Set a measurement result via FFI (called by main thread after simulation) /// /// This stores in the execution context so worker thread can access it. @@ -673,6 +692,20 @@ pub fn get_measurement_result(result_id: u64) -> Option { /// This function is safe to call from any thread. #[unsafe(no_mangle)] pub extern "C" fn pecos_set_measurement_result(result_id: u64, value: bool) { + pecos_set_measurement_outcome(result_id, u64::from(value)); +} + +/// Set an integer-valued measurement outcome via FFI. +/// +/// Ordinary measurement results are 0/1; leakage-aware results may also be 2. +#[unsafe(no_mangle)] +pub extern "C" fn pecos_set_measurement_outcome(result_id: u64, value: u64) { + if value > 2 { + log::error!( + "pecos_set_measurement_outcome: invalid outcome {value} for result_id={result_id}" + ); + return; + } log::debug!("pecos_set_measurement_result: result_id={result_id}, value={value}"); if let Some(ctx) = get_execution_context() { let Ok(result_index) = usize::try_from(result_id) else { @@ -919,7 +952,7 @@ mod tests { context.waiting_for_result.store(42, Ordering::SeqCst); if let Ok(mut results) = context.measurement_results.lock() { results.resize(1, None); - results[0] = Some(true); + results[0] = Some(1); } if let Ok(mut ops) = context.pending_ops.lock() { ops.push(Operation::AllocateQubit { id: 0 }); diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index e8e6a0922..fcb419448 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -65,6 +65,12 @@ pub struct OperationTraceChunk { pub lowered_quantum_ops: Vec, pub lowered_quantum_ops_complete: bool, pub named_result_traces: Vec, + /// Physical measurement outcomes keyed by stable QIS result id. + /// + /// This is populated only on the terminal ``trace_complete`` chunk. It + /// lets consumers certify aggregate named-result provenance without + /// relying on when the compiled program happened to read each future. + pub measurement_results: BTreeMap, } /// Shared in-memory store for traced QIS operation batches. @@ -271,7 +277,7 @@ pub struct QisEngine { measurement_mapping: Vec, /// Stored measurement results for `get_results()` - measurement_results: BTreeMap, + measurement_results: BTreeMap, /// RNG for generating per-shot seeds rng: PecosRng, @@ -321,26 +327,22 @@ pub struct QisEngine { } impl QisEngine { - fn parse_measurement_outcomes(message: &ByteMessage) -> Result, PecosError> { + fn parse_measurement_outcomes(message: &ByteMessage) -> Result, PecosError> { message .outcomes() - .map(|outcomes| outcomes.into_iter().map(|value| value as usize).collect()) + .map(|outcomes| outcomes.into_iter().collect()) .map_err(|e| PecosError::Generic(format!("Failed to parse measurements: {e}"))) } - fn map_measurements( - measurement_mapping: &[usize], - measurements: &[usize], - ) -> Vec<(usize, bool)> { + fn map_measurements(measurement_mapping: &[usize], measurements: &[u32]) -> Vec<(usize, u32)> { measurement_mapping .iter() .copied() .zip(measurements.iter().copied()) - .map(|(result_id, value)| (result_id, value != 0)) .collect() } - fn store_measurement_updates(&mut self, updates: &[(usize, bool)]) { + fn store_measurement_updates(&mut self, updates: &[(usize, u32)]) { for &(result_id, value) in updates { self.measurement_results.insert(result_id, value); debug!("QisEngine: Stored measurement result_id={result_id}, value={value}"); @@ -349,14 +351,14 @@ impl QisEngine { fn provide_measurement_updates_to_runtime( &mut self, - updates: &[(usize, bool)], + updates: &[(usize, u32)], ) -> Result<(), PecosError> { if updates.is_empty() { return Ok(()); } - let measurement_map: BTreeMap = updates.iter().copied().collect(); + let measurement_map: BTreeMap = updates.iter().copied().collect(); self.runtime - .provide_measurements(measurement_map) + .provide_measurement_outcomes(measurement_map) .map_err(|e| PecosError::Generic(format!("Failed to provide measurements: {e}"))) } @@ -703,6 +705,11 @@ impl QisEngine { builder.mz(&[self.mapped_qubit(*qubit, qop)?]); Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } + QuantumOp::MeasureLeaked(qubit, result_id) => { + self.measurement_mapping.push(*result_id); + builder.measure_leakages(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); + } QuantumOp::ZZ(qubit1, qubit2) => { builder.szz(&[( self.mapped_qubit(*qubit1, qop)?, @@ -850,6 +857,11 @@ impl QisEngine { builder.mz(&[qubit]); gate_metadata.push(metadata); } + QuantumOp::MeasureLeaked(qubit, result_id) => { + self.measurement_mapping.push(result_id); + builder.measure_leakages(&[qubit]); + gate_metadata.push(metadata); + } QuantumOp::ZZ(qubit1, qubit2) => { builder.szz(&[(qubit1, qubit2)]); gate_metadata.push(metadata); @@ -984,7 +996,7 @@ impl QisEngine { .iter() .map(|q| usize::from(*q)) .collect::>(); - let measurement_result_ids = if gate_type == "MZ" { + let measurement_result_ids = if matches!(gate_type.as_str(), "MZ" | "MeasureLeaked") { let end = measurement_cursor + qubits.len(); if end > measurement_mapping.len() { return Err( @@ -1070,6 +1082,11 @@ impl QisEngine { lowered_quantum_ops: lowered_trace, lowered_quantum_ops_complete, named_result_traces: Vec::new(), + measurement_results: if stage == "trace_complete" { + self.measurement_results.clone() + } else { + BTreeMap::new() + }, }; if let Some(ref collector) = self.operation_trace_collector { @@ -1140,6 +1157,7 @@ impl QisEngine { lowered_quantum_ops: Vec::new(), lowered_quantum_ops_complete: true, named_result_traces: named_result_traces.to_vec(), + measurement_results: BTreeMap::new(), }; if let Some(ref collector) = self.operation_trace_collector { @@ -1278,7 +1296,7 @@ impl QisEngine { } /// Set a measurement result for the running program - fn set_dynamic_result(&mut self, result_id: u64, value: bool) -> Result<(), PecosError> { + fn set_dynamic_result(&mut self, result_id: u64, value: u32) -> Result<(), PecosError> { let state = self .dynamic_state .as_ref() @@ -1289,7 +1307,7 @@ impl QisEngine { .ok_or_else(|| PecosError::Generic("No sync handle available".to_string()))?; handle - .set_measurement_result(result_id, value) + .set_measurement_outcome(result_id, u64::from(value)) .map_err(|e| PecosError::Generic(format!("Failed to set measurement result: {e}")))?; debug!("Set dynamic result: {result_id} = {value}"); Ok(()) @@ -1442,7 +1460,7 @@ impl QisEngine { /// from stale state, and no later gate can certify that trace. fn provide_measurements_terminal( &mut self, - updates: &[(usize, bool)], + updates: &[(usize, u32)], ) -> Result<(), PecosError> { match self.provide_measurement_updates_to_runtime(updates) { Ok(()) => Ok(()), @@ -1656,14 +1674,11 @@ impl ClassicalEngine for QisEngine { // results (from result() calls) are consistent. if !has_named_results { for (result_id, value) in &self.measurement_results { - shot.data.insert( - format!("measurement_{result_id}"), - Data::U32(u32::from(*value)), - ); + shot.data + .insert(format!("measurement_{result_id}"), Data::U32(*value)); debug!( "QisEngine: Added to shot: measurement_{} = {}", - result_id, - i32::from(*value) + result_id, value ); } } @@ -2063,6 +2078,67 @@ mod tests { in_memory[0].lowered_quantum_ops[3].measurement_result_ids, vec![7] ); + assert!(in_memory[0].measurement_results.is_empty()); + drop(in_memory); + + engine.measurement_results.insert(7, 1); + engine.trace_complete_chunk(); + let in_memory = collector.lock().expect("collector lock"); + assert_eq!(in_memory[1].stage, "trace_complete"); + assert_eq!(in_memory[1].measurement_results, BTreeMap::from([(7, 1)])); + } + + #[test] + fn test_direct_lowering_preserves_leakage_measurement() { + let mut engine = QisEngine::with_runtime(Box::new(DummyRuntime::default())); + let ops = vec![ + Operation::AllocateQubit { id: 0 }, + QuantumOp::MeasureLeaked(0, 8).into(), + ]; + + let lowered = engine + .lower_operations_to_commands(&ops) + .expect("lower leakage-aware measurement"); + let quantum_ops = lowered + .commands + .quantum_ops() + .expect("parse quantum operations"); + + assert_eq!(quantum_ops.len(), 2); + assert_eq!( + quantum_ops[1].gate_type, + pecos_core::gate_type::GateType::MeasureLeaked + ); + assert_eq!(engine.measurement_mapping, vec![8]); + } + + #[test] + fn test_general_noise_returns_two_for_lowered_leakage_measurement() { + use pecos_engines::QuantumSystem; + use pecos_engines::noise::general::GeneralNoiseModel; + use pecos_engines::quantum::StateVecEngine; + + let mut emission_model = BTreeMap::new(); + emission_model.insert("L".to_string(), 1.0); + let noise = GeneralNoiseModel::builder() + .with_p1(1.0) + .with_p1_emission_ratio(1.0) + .with_p1_emission_model(&emission_model) + .build(); + let mut system = QuantumSystem::new(Box::new(noise), Box::new(StateVecEngine::new(1))); + let mut builder = ByteMessage::quantum_operations_builder(); + builder.pz(&[0]); + builder.r1xy( + Angle64::from_radians(std::f64::consts::FRAC_PI_2), + Angle64::from_radians(3.0 * std::f64::consts::FRAC_PI_2), + &[0], + ); + builder.rz(Angle64::HALF_TURN, &[0]); + builder.measure_leakages(&[0]); + + let result = system.process(builder.build()).expect("simulate leakage"); + + assert_eq!(result.outcomes().expect("parse outcome"), vec![2]); } #[test] diff --git a/crates/pecos-qis/src/executor.rs b/crates/pecos-qis/src/executor.rs index 3bd3fe45e..f767419e5 100644 --- a/crates/pecos-qis/src/executor.rs +++ b/crates/pecos-qis/src/executor.rs @@ -378,6 +378,7 @@ enum ExecutionEntryPoint<'a> { } type WaitForNeedResultFn = unsafe extern "C" fn(u64) -> u64; type SetMeasurementResultFn = unsafe extern "C" fn(u64, bool); +type SetMeasurementOutcomeFn = unsafe extern "C" fn(u64, u64); type SignalResultReadyFn = unsafe extern "C" fn(); type AbortExecutionFn = unsafe extern "C" fn(); type GetNamedResultsJsonFn = unsafe extern "C" fn() -> *mut std::ffi::c_char; @@ -437,6 +438,20 @@ impl DynamicSyncHandle for HeliosSyncHandle { Ok(()) } + fn set_measurement_outcome(&self, result_id: u64, value: u64) -> Result<(), InterfaceError> { + let lib = Self::get_lib()?; + let set_fn: Symbol = unsafe { + lib.get(b"pecos_set_measurement_outcome\0").map_err(|e| { + InterfaceError::ExecutionError(format!( + "Failed to find pecos_set_measurement_outcome: {e}" + )) + })? + }; + unsafe { set_fn(result_id, value) }; + debug!("HeliosSyncHandle: Set measurement outcome {result_id} = {value}"); + Ok(()) + } + fn signal_result_ready(&self) -> Result<(), InterfaceError> { let lib = Self::get_lib()?; let signal_fn: Symbol = unsafe { @@ -2472,6 +2487,24 @@ impl QisInterface for QisHeliosInterface { Ok(()) } + fn set_measurement_outcome( + &mut self, + result_id: u64, + value: u64, + ) -> Result<(), InterfaceError> { + let lib = Self::get_qis_ffi_lib_singleton()?; + let set_fn: Symbol = unsafe { + lib.get(b"pecos_set_measurement_outcome\0").map_err(|e| { + InterfaceError::ExecutionError(format!( + "Failed to find pecos_set_measurement_outcome: {e}" + )) + })? + }; + unsafe { set_fn(result_id, value) }; + debug!("Set measurement outcome via FFI: {result_id} = {value}"); + Ok(()) + } + fn signal_result_ready(&mut self) -> Result<(), InterfaceError> { // Get the process-wide QIS FFI library singleton let lib = Self::get_qis_ffi_lib_singleton()?; diff --git a/crates/pecos-qis/src/qis_interface.rs b/crates/pecos-qis/src/qis_interface.rs index bb39af454..ec354bb8e 100644 --- a/crates/pecos-qis/src/qis_interface.rs +++ b/crates/pecos-qis/src/qis_interface.rs @@ -182,6 +182,23 @@ pub trait QisInterface: Send + Sync { )) } + /// Set an integer-valued measurement outcome for the running program. + /// + /// Existing interfaces remain Boolean-only by default. + fn set_measurement_outcome( + &mut self, + result_id: u64, + value: u64, + ) -> Result<(), InterfaceError> { + match value { + 0 => self.set_measurement_result(result_id, false), + 1 => self.set_measurement_result(result_id, true), + _ => Err(InterfaceError::Other(format!( + "dynamic interface does not support leakage outcome {value}" + ))), + } + } + /// Signal that the measurement result is ready /// /// This wakes up the blocked program to continue execution. @@ -259,6 +276,17 @@ pub trait DynamicSyncHandle: Send + Sync { /// Returns an error if the FFI call fails or no execution context is registered. fn set_measurement_result(&self, result_id: u64, value: bool) -> Result<(), InterfaceError>; + /// Set an integer-valued measurement outcome for the running program. + fn set_measurement_outcome(&self, result_id: u64, value: u64) -> Result<(), InterfaceError> { + match value { + 0 => self.set_measurement_result(result_id, false), + 1 => self.set_measurement_result(result_id, true), + _ => Err(InterfaceError::Other(format!( + "dynamic interface does not support leakage outcome {value}" + ))), + } + } + /// Signal that the measurement result is ready /// /// # Errors diff --git a/crates/pecos-qis/src/runtime.rs b/crates/pecos-qis/src/runtime.rs index 5696c9c13..5651ab621 100644 --- a/crates/pecos-qis/src/runtime.rs +++ b/crates/pecos-qis/src/runtime.rs @@ -146,6 +146,24 @@ pub trait QisRuntime: Send + Sync + dyn_clone::DynClone { /// Returns an error if the measurements cannot be provided. fn provide_measurements(&mut self, measurements: BTreeMap) -> Result<()>; + /// Provide integer-valued measurement outcomes back to the runtime. + /// + /// The default keeps existing Boolean runtimes compatible and rejects a + /// leakage outcome instead of silently converting 2 to true. + fn provide_measurement_outcomes(&mut self, outcomes: BTreeMap) -> Result<()> { + let measurements = outcomes + .into_iter() + .map(|(result_id, value)| match value { + 0 => Ok((result_id, false)), + 1 => Ok((result_id, true)), + _ => Err(RuntimeError::ExecutionError(format!( + "runtime does not support leakage outcome {value} for result {result_id}" + ))), + }) + .collect::>>()?; + self.provide_measurements(measurements) + } + /// Get the current classical state (for debugging/inspection) fn get_classical_state(&self) -> &ClassicalState; diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index 9346f5cbe..f968465d6 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -265,6 +265,9 @@ pub struct SeleneRuntime { /// Reverse lookup for measurement operations emitted by the runtime plugin. runtime_to_program_results: BTreeMap, + /// Program results produced by leakage-aware measurements. + leakage_results: BTreeSet, + /// End timestamp of the last scheduled physical operation per runtime qubit. last_gate_time_end_nanos: Vec, @@ -308,6 +311,7 @@ impl SeleneRuntime { program_to_runtime_qubits: BTreeMap::new(), program_to_runtime_results: BTreeMap::new(), runtime_to_program_results: BTreeMap::new(), + leakage_results: BTreeSet::new(), last_gate_time_end_nanos: Vec::new(), pending_shot_start: None, active_shot: None, @@ -484,6 +488,7 @@ impl SeleneRuntime { self.program_to_runtime_qubits.clear(); self.program_to_runtime_results.clear(); self.runtime_to_program_results.clear(); + self.leakage_results.clear(); self.last_gate_time_end_nanos.clear(); Ok(()) } @@ -855,6 +860,44 @@ impl SeleneRuntime { self.force_runtime_result(runtime_result) } + fn call_runtime_measure_leaked( + &mut self, + runtime_qubit: u64, + program_result: usize, + ) -> Result<()> { + let lib = self + .library + .as_ref() + .ok_or_else(|| RuntimeError::FfiError("Selene runtime is not loaded".to_string()))?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; + + let runtime_result = unsafe { + let measure_fn = lib + .get:: i32>( + b"selene_runtime_measure_leaked", + ) + .map_err(|e| { + RuntimeError::FfiError(format!("Missing leakage measurement function: {e}")) + })?; + let mut runtime_result = 0; + let errno = measure_fn(instance, runtime_qubit, &raw mut runtime_result); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "measure_leaked failed with errno {errno}" + ))); + } + runtime_result + }; + + self.program_to_runtime_results + .insert(program_result, runtime_result); + self.runtime_to_program_results + .insert(runtime_result, program_result); + self.force_runtime_result(runtime_result) + } + fn force_runtime_result(&self, runtime_result: u64) -> Result<()> { let lib = self .library @@ -989,6 +1032,9 @@ impl SeleneRuntime { QuantumOp::RZZ(*theta, map(*qubit_1)?, map(*qubit_2)?) } QuantumOp::Measure(qubit, result_id) => QuantumOp::Measure(map(*qubit)?, *result_id), + QuantumOp::MeasureLeaked(qubit, result_id) => { + QuantumOp::MeasureLeaked(map(*qubit)?, *result_id) + } QuantumOp::Reset(qubit) => QuantumOp::Reset(map(*qubit)?), }) } @@ -1018,6 +1064,13 @@ impl SeleneRuntime { self.program_to_runtime_qubits.remove(qubit); self.runtime_qfree(runtime_qubit)?; } + QuantumOp::MeasureLeaked(qubit, result_id) => { + self.leakage_results.insert(*result_id); + let runtime_qubit = self.runtime_qubit_for_program(*qubit)?; + self.call_runtime_measure_leaked(runtime_qubit, *result_id)?; + self.program_to_runtime_qubits.remove(qubit); + self.runtime_qfree(runtime_qubit)?; + } QuantumOp::Reset(qubit) => { let runtime_qubit = self.runtime_qubit_for_program(*qubit)?; self.call_runtime_reset(runtime_qubit)?; @@ -1298,6 +1351,10 @@ impl SeleneRuntime { QuantumOp::Measure(source_qubit, source_result), QuantumOp::Measure(lowered_qubit, lowered_result), ) => source_qubit == lowered_qubit && source_result == lowered_result, + ( + QuantumOp::MeasureLeaked(source_qubit, source_result), + QuantumOp::MeasureLeaked(lowered_qubit, lowered_result), + ) => source_qubit == lowered_qubit && source_result == lowered_result, _ => false, } } @@ -1332,6 +1389,7 @@ impl SeleneRuntime { | QuantumOp::RXY(_, _, qubit) | QuantumOp::Idle(_, qubit) | QuantumOp::Measure(qubit, _) + | QuantumOp::MeasureLeaked(qubit, _) | QuantumOp::Reset(qubit) => { qubits.insert(*qubit); } @@ -1470,15 +1528,30 @@ impl SeleneRuntime { RuntimeScheduledOp::Measure { qubit_id, result_id, + } => { + let qubit = self.runtime_qubit_to_usize(qubit_id)?; + let program_result = self.runtime_result_to_program_result(result_id)?; + self.push_idle_before(&mut lowered_ops, qubit, start_time)?; + if self.leakage_results.contains(&program_result) { + // The pinned runtime ABI allocates both Boolean and + // leakage-aware futures through `runtime_measure`. + // Restore the source result kind after scheduling so + // PECOS executes MeasureLeaked and can produce 2. + lowered_ops.push(QuantumOp::MeasureLeaked(qubit, program_result)); + } else { + lowered_ops.push(QuantumOp::Measure(qubit, program_result)); + } + self.mark_gate_end(qubit, end_time); } - | RuntimeScheduledOp::MeasureLeaked { + RuntimeScheduledOp::MeasureLeaked { qubit_id, result_id, } => { let qubit = self.runtime_qubit_to_usize(qubit_id)?; let program_result = self.runtime_result_to_program_result(result_id)?; + self.leakage_results.insert(program_result); self.push_idle_before(&mut lowered_ops, qubit, start_time)?; - lowered_ops.push(QuantumOp::Measure(qubit, program_result)); + lowered_ops.push(QuantumOp::MeasureLeaked(qubit, program_result)); self.mark_gate_end(qubit, end_time); } RuntimeScheduledOp::Reset { qubit_id } => { @@ -1578,6 +1651,7 @@ impl Clone for SeleneRuntime { program_to_runtime_qubits: self.program_to_runtime_qubits.clone(), program_to_runtime_results: self.program_to_runtime_results.clone(), runtime_to_program_results: self.runtime_to_program_results.clone(), + leakage_results: self.leakage_results.clone(), last_gate_time_end_nanos: self.last_gate_time_end_nanos.clone(), pending_shot_start: self.pending_shot_start, active_shot: self.active_shot, @@ -1650,8 +1724,11 @@ fn operation_capacity_with_mode( } fn include_quantum_result_capacity(qop: &QuantumOp, num_results: &mut usize) { - if let QuantumOp::Measure(_, result) = qop { - include_result(num_results, *result); + match qop { + QuantumOp::Measure(_, result) | QuantumOp::MeasureLeaked(_, result) => { + include_result(num_results, *result); + } + _ => {} } } @@ -1686,7 +1763,7 @@ fn include_quantum_op_capacity(qop: &QuantumOp, num_qubits: &mut usize, num_resu include_qubit(num_qubits, *qubit_2); include_qubit(num_qubits, *qubit_3); } - QuantumOp::Measure(qubit, result) => { + QuantumOp::Measure(qubit, result) | QuantumOp::MeasureLeaked(qubit, result) => { include_qubit(num_qubits, *qubit); include_result(num_results, *result); } @@ -1877,6 +1954,15 @@ impl QisRuntime for SeleneRuntime { } fn provide_measurements(&mut self, measurements: BTreeMap) -> Result<()> { + self.provide_measurement_outcomes( + measurements + .into_iter() + .map(|(result_id, value)| (result_id, u32::from(value))) + .collect(), + ) + } + + fn provide_measurement_outcomes(&mut self, measurements: BTreeMap) -> Result<()> { debug!( "Received {} measurement results, num_results={}, allocated_results={:?}", measurements.len(), @@ -1890,18 +1976,44 @@ impl QisRuntime for SeleneRuntime { "Measurement result {} = {} (num_results={})", result_id, value, self.num_results ); - self.state.measurements.insert(*result_id, *value); + if *value <= 1 { + self.state.measurements.insert(*result_id, *value == 1); + } if let Some(runtime_result_id) = self.program_to_runtime_results.get(result_id) { if let Some(lib) = &self.library && let Some(instance) = self.instance { unsafe { - if let Ok(set_result_fn) = + if self.leakage_results.contains(result_id) { + if let Ok(set_result_fn) = + lib.get:: i32>( + b"selene_runtime_set_u64_result", + ) + { + let errno = + set_result_fn(instance, *runtime_result_id, u64::from(*value)); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "selene_runtime_set_u64_result failed with errno {errno} \ + for result {result_id}" + ))); + } + } + } else if let Ok(set_result_fn) = lib.get:: i32>( b"selene_runtime_set_bool_result", ) { + let bool_value = match *value { + 0 => false, + 1 => true, + _ => { + return Err(RuntimeError::ExecutionError(format!( + "ordinary measurement result {result_id} has non-Boolean outcome {value}" + ))); + } + }; // A delivery FAILURE is fatal: the scheduler // would otherwise proceed on stale/default state // while the QIS worker advances on the real bit, @@ -1909,7 +2021,7 @@ impl QisRuntime for SeleneRuntime { // An ABSENT symbol stays legal -- a runtime that // never conditions on results has no delivery to // fail. - let errno = set_result_fn(instance, *runtime_result_id, *value); + let errno = set_result_fn(instance, *runtime_result_id, bool_value); if errno != 0 { return Err(RuntimeError::FfiError(format!( "selene_runtime_set_bool_result failed with errno {errno} \ @@ -1925,8 +2037,10 @@ impl QisRuntime for SeleneRuntime { ); } - if let Some(interface) = &mut self.interface { - interface.store_result(*result_id, *value); + if let Some(interface) = &mut self.interface + && *value <= 1 + { + interface.store_result(*result_id, *value == 1); } } @@ -1999,6 +2113,7 @@ impl QisRuntime for SeleneRuntime { self.program_to_runtime_qubits.clear(); self.program_to_runtime_results.clear(); self.runtime_to_program_results.clear(); + self.leakage_results.clear(); self.last_gate_time_end_nanos.clear(); self.pending_shot_start = Some((shot_id, seed)); self.apply_pending_shot_start()?; @@ -2057,6 +2172,7 @@ impl QisRuntime for SeleneRuntime { self.program_to_runtime_qubits.clear(); self.program_to_runtime_results.clear(); self.runtime_to_program_results.clear(); + self.leakage_results.clear(); self.last_gate_time_end_nanos.clear(); self.pending_shot_start = None; self.active_shot = None; diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index c46d0bf1c..558de5ac7 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -2,8 +2,21 @@ This guide covers `DetectorErrorModel.from_guppy`, which builds a circuit-level detector error model (DEM) from a Guppy program by tracing it -through the Selene QIS engine. This is the recommended way to get a DEM for -a logical circuit you intend to run on a Selene-compatible runtime. +through the Selene QIS engine. Use this entry point when detector and +observable definitions are already available as records, measurement IDs, or +scalar measurement-result tags. + +If the program has only computed detector and observable **values**, choose the +inferred-output workflow instead: + +| Available information | Entry point | +| --- | --- | +| Static parity definitions | `DetectorErrorModel.from_guppy` (this guide) | +| Raw measurements plus computed detector/observable outputs | [`infer_guppy_dem_annotations`](inferred-guppy-dem.md) | +| An annotated `TickCircuit` | `DetectorErrorModel.from_circuit` | + +The inferred-output workflow leaves an existing Guppy program unchanged and +recovers the missing static definitions from its `result()` outputs. ## What You'll Learn diff --git a/docs/user-guide/inferred-guppy-dem.md b/docs/user-guide/inferred-guppy-dem.md new file mode 100644 index 000000000..ceddbfe60 --- /dev/null +++ b/docs/user-guide/inferred-guppy-dem.md @@ -0,0 +1,390 @@ +# Inferring a DEM from Guppy Outputs + +Use `infer_guppy_dem_annotations` when a Guppy program already emits raw +physical measurements, detector bits, and logical-observable bits with +`result()`, but does not separately expose the measurement parities that +define those detectors and observables. The program does not need to be +edited: PECOS infers the parities, binds them to the runtime QIS trace, and +builds the detector error model (DEM) with native PECOS fault propagation. +Stim is not used. + +## Choose the right entry point + +| What the application already has | Use | +| --- | --- | +| Raw measurements plus detector and observable **values** computed by Guppy | `infer_guppy_dem_annotations` (this guide) | +| Audited detector and observable **definitions** using records, measurement IDs, or scalar result tags | [`DetectorErrorModel.from_guppy`](dem-from-guppy.md) | +| An annotated `TickCircuit` | `DetectorErrorModel.from_circuit` | + +The inferred-output workflow is especially useful for generated Guppy that +collects measurements into arrays or computes round-to-round parities inside +the program. + +## Program contract + +Before calling the tool, check all of the following: + +1. Every physical measurement is emitted exactly once through one or more + `result(raw_tag, ...)` calls. Do not omit initialization, syndrome, flag, + postselection, or final-readout measurements. +2. Detector and observable outputs are Boolean XOR parities of those raw + measurements. AND, OR, nonlinear expressions, constant-one offsets, and + outputs independent of every measurement are rejected. +3. Measurement values may affect classical parity calculations, but must not + change the quantum gate schedule. Measurement-dependent quantum branches + and repeated-until-success loops need a different analysis. +4. The supplied `num_qubits` is large enough to run the program through the + selected Selene runtime. + +Tag names are case-sensitive. The defaults are `"raw measurements"`, +`"DETECTOR"`, and `"obs"`; all are configurable. Repeated calls with the same +tag are concatenated in execution order. An array-valued call contributes its +elements in array order. + +## Quick start + +This two-measurement example is the smallest complete workflow. Strict +provenance determines the physical identity of each raw output automatically. + + +```python +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import measure, qubit + +from pecos.qec import infer_guppy_dem_annotations + + +@guppy +def parity_readout() -> None: + m0 = measure(qubit()) + m1 = measure(qubit()) + + # Emit each physical result exactly once under the raw tag. + result("raw measurements", m0) + result("raw measurements", m1) + + # These are computed values, not additional measurements. + result("DETECTOR", m0 ^ m1) + result("obs", m0) + + +inferred = infer_guppy_dem_annotations( + parity_readout, + num_qubits=2, + seed=7, +) + +assert inferred.raw_measurement_ids == (0, 1) +assert inferred.detector_supports == ((0, 1),) +assert inferred.observable_supports == ((0,),) +assert inferred.raw_binding in { + "runtime_result_ids", + "probe_correlated_result_ids", +} + +dem = inferred.build_dem( + p1=0.001, + p2=0.005, + p_meas=0.005, + p_prep=0.001, +) +assert dem.num_detectors == 1 +assert dem.num_observables == 1 +print(dem.to_string()) +``` + +The two accepted `raw_binding` values are both identity-preserving. A compiler +may retain a directly tagged measurement ID, or it may erase the ID while +duplicating the value into raw and computed outputs; strict probe correlation +handles either lowering. + +The returned supports contain QIS `MeasId` values. PECOS writes equivalent +`meas_ids` entries into `inferred.detectors_json` and +`inferred.observables_json`, attaches them to `inferred.circuit`, and passes +the annotated circuit to `DetectorErrorModel.from_circuit` when `build_dem` +is called. + +For an existing program, the integration itself is only this call: + + +```python +inferred = infer_guppy_dem_annotations( + existing_guppy_program, + num_qubits=program_qubit_count, + raw_tag="raw measurements", + detector_tag="DETECTOR", + observable_tags=("obs",), +) +dem = inferred.build_dem(p1=0.001, p2=0.005, p_meas=0.005, p_prep=0.001) +``` + +No separate trace-capture call is required. The function runs the coin-toss +probes and captures the QIS trace internally. + +## Example: rounds and aggregate arrays + +Real memory experiments commonly keep earlier syndromes, emit measurements in +arrays, and form final-boundary detectors from the last syndrome and data +readout. This two-data-qubit repetition memory demonstrates that pattern with +four physical measurements. + + +```python +from guppylang import guppy +from guppylang.std.builtins import array, result +from guppylang.std.quantum import cx, measure, qubit + +from pecos.qec import infer_guppy_dem_annotations + + +@guppy +def repetition_memory() -> None: + d0, d1 = qubit(), qubit() + + a0 = qubit() + cx(d0, a0) + cx(d1, a0) + s0 = measure(a0) + result("DETECTOR", s0) + + a1 = qubit() + cx(d0, a1) + cx(d1, a1) + s1 = measure(a1) + result("DETECTOR", s0 ^ s1) + + m0, m1 = measure(d0), measure(d1) + result("DETECTOR", s1 ^ m0 ^ m1) + result("obs", m0) + + # Raw arrays can be emitted after the parities have been computed. + result("raw measurements", array(s0, s1)) + result("raw measurements", array(m0, m1)) + + +inferred = infer_guppy_dem_annotations( + repetition_memory, + num_qubits=4, + probe_shots=64, + provenance_shots=32, + validation_rows=16, + seed=11, +) + +assert inferred.raw_measurement_ids == (0, 1, 2, 3) +assert inferred.detector_supports == ( + (0,), + (0, 1), + (1, 2, 3), +) +assert inferred.observable_supports == ((2,),) +assert inferred.raw_binding == "probe_correlated_result_ids" + +dem = inferred.build_dem(p1=0.001, p2=0.005, p_meas=0.005, p_prep=0.001) +assert dem.num_detectors == 3 +assert dem.num_observables == 1 +``` + +Array indexing, copying, and aggregation can erase element-level result IDs in +the compiled path. In strict mode, PECOS recovers them by correlating each raw +output column with result-ID-keyed physical outcomes over independent +coin-toss traces. `probe_correlated_result_ids` records that stronger binding. + +## Example: raw outputs in a different order + +Do not assume raw-array order is physical measurement order. Strict provenance +tracks the identity of each element even when an array is reordered. + + +```python +from guppylang import guppy +from guppylang.std.builtins import array, result +from guppylang.std.quantum import measure, qubit + +from pecos.qec import infer_guppy_dem_annotations + + +@guppy +def reordered_readout() -> None: + m0 = measure(qubit()) + m1 = measure(qubit()) + m2 = measure(qubit()) + result("DETECTOR", m2 ^ m0) + result("obs", m1) + result("raw measurements", array(m2, m0, m1)) + + +inferred = infer_guppy_dem_annotations( + reordered_readout, + num_qubits=3, + probe_shots=32, + provenance_shots=24, + validation_rows=8, + seed=13, +) + +# Array order is m2, m0, m1; the IDs preserve physical identity. +assert inferred.raw_measurement_ids == (2, 0, 1) +assert inferred.detector_supports == ((2, 0),) +assert inferred.observable_supports == ((1,),) +assert inferred.raw_binding == "probe_correlated_result_ids" +``` + +This distinction matters whenever an intermediate array is assembled in an +order that differs from the runtime measurement record. + +## Example: custom tags and several observables + +Pass every logical-result tag in the desired DEM observable order. An +array-valued observable expands into one DEM observable per element. + + +```python +from guppylang import guppy +from guppylang.std.builtins import array, result +from guppylang.std.quantum import measure, qubit + +from pecos.qec import infer_guppy_dem_annotations + + +@guppy +def tagged_readout() -> None: + m0 = measure(qubit()) + m1 = measure(qubit()) + m2 = measure(qubit()) + result("physical", array(m0, m1, m2)) + result("events", m0 ^ m1) + result("logical_z", m0) + result("logical_x", array(m1, m2)) + + +inferred = infer_guppy_dem_annotations( + tagged_readout, + num_qubits=3, + raw_tag="physical", + detector_tag="events", + observable_tags=("logical_z", "logical_x"), + probe_shots=32, + provenance_shots=24, + validation_rows=8, + seed=17, +) + +assert inferred.detector_supports == ((0, 1),) +assert inferred.observable_supports == ((0,), (1,), (2,)) +assert inferred.observable_labels == ( + ("logical_z", 0), + ("logical_x", 0), + ("logical_x", 1), +) +``` + +## Measurement identity modes + +Leave `require_raw_provenance=True`, the default, whenever possible: + +| `raw_binding` | Meaning | +| --- | --- | +| `runtime_result_ids` | Every raw output retained its QIS measurement ID directly. | +| `probe_correlated_result_ids` | PECOS recovered a unique complete ID mapping from independent probe signatures. | +| `assumed_canonical_result_order` | Strict identity was disabled and PECOS used positional order. | + +Correlation fails loudly if the quantum schedule changes, a raw element is a +computed value instead of a direct measurement, a measurement is omitted or +duplicated, or two physical signatures collide. Increasing +`provenance_shots` resolves a rare signature collision; it cannot repair an +incomplete or computed raw record. + +The weak fallback is explicit: + + +```python +inferred = infer_guppy_dem_annotations( + program, + num_qubits=7, + require_raw_provenance=False, +) +assert inferred.raw_binding == "assumed_canonical_result_order" +``` + +Use it only when the application independently guarantees that the +concatenated raw values occur exactly once each in physical measurement order. +It checks the measurement count, but it cannot detect a permutation. + +## Parameters and outputs + +`infer_guppy_dem_annotations` accepts: + +| Argument | Default | Purpose | +| --- | --- | --- | +| `program` | required | Compiled or compilable Guppy entry point. | +| `num_qubits` | required keyword | Runtime qubit capacity. | +| `raw_tag` | `"raw measurements"` | Tag containing every physical measurement exactly once. | +| `detector_tag` | `"DETECTOR"` | Tag containing all computed detection-event bits. | +| `observable_tags` | `("obs",)` | Logical result tags, in DEM observable order. | +| `probe_shots` | `256` | Coin-toss rows used to infer and validate affine parities. | +| `provenance_shots` | `32` | Rows used to correlate array elements with QIS IDs. | +| `validation_rows` | `32` | Probe rows reserved for parity validation. | +| `seed` | `0` | Reproducible trace and probe seed. | +| `runtime` | `None` | Selene runtime selection forwarded to PECOS. | +| `require_raw_provenance` | `True` | Require a complete identity-preserving raw-measurement binding. | + +The result is an `InferredGuppyDemAnnotations` with: + +| Attribute | Contents | +| --- | --- | +| `circuit` | Runtime-lowered `TickCircuit` with detector and observable metadata attached. | +| `detectors_json`, `observables_json` | Serialized definitions using QIS `meas_ids`. | +| `raw_measurement_ids` | One physical ID per raw output element, in emitted order. | +| `detector_supports`, `observable_supports` | Inferred parity supports expressed as physical IDs. | +| `observable_labels` | `(tag, element_index)` for each DEM observable. | +| `raw_binding` | Identity mode from the table above. | +| `probe_shots` | Number of parity-inference rows used. | +| `build_dem(**noise)` | Build a PECOS `DetectorErrorModel` from the annotated circuit. | + +`probe_shots` must provide at least one row per raw measurement, one affine +constant column, and the requested validation rows. For larger experiments, +increase it if PECOS reports insufficient GF(2) rank. + +## Failure guide + +| Error or symptom | Meaning | Action | +| --- | --- | --- | +| `missing required tag(s)` | A configured tag was never emitted. | Check spelling and case, or pass the matching tag arguments. | +| `must expose every physical measurement exactly once` | The raw stream omitted or duplicated a measurement. | Include initialization, syndrome, postselection, and final-readout measurements exactly once. | +| `not a direct physical measurement` | A raw element is computed from measurements. | Emit the original measurement value; keep computed values under detector/observable tags. | +| `signatures are ambiguous` | Too few provenance probes caused an identity collision. | Increase `provenance_shots` or change `seed`. | +| `not affine` or `constant-one` | An output is not a representable XOR parity. | Replace nonlinear/offset post-processing with explicit parity outputs, or provide audited static definitions. | +| `quantum operation schedule changed` | A measurement changed which quantum operations ran. | Build separate justified models per static path; do not use a single inferred DEM. | +| Native fault propagation rejects a gate such as `T` | The traced circuit is outside PECOS's Pauli/Clifford propagation support. | Supply a separately justified Clifford model or use a different analysis. | + +## Soundness and leakage boundaries + +The prototype establishes two empirical facts: + +1. Emitted detector and observable bits fit unique affine GF(2) parities of + the raw physical measurements, including independent validation rows. +2. The raw measurements bind to one runtime-lowered QIS circuit trace. + +It is an empirical certificate, not a compiler proof over every possible +classical-control path. The probability of accidental probe-signature +collisions decreases exponentially with the number of probes. + +The QIS tracer safely preserves leakage-aware measurement outcomes `0`, `1`, +and `2`, including Guppy's `is_leaked()` check. Parity inference itself is +Boolean and `build_dem` performs Pauli fault propagation, so the returned DEM +represents the accepted no-leakage path. It does **not** model leakage rate, +postselection probability, or rejected-shot behavior. A leakage check that +changes later quantum operations also violates the static-schedule contract. + +Repeated-until-success loops are outside this workflow because different +attempt counts produce different quantum schedules. Non-Clifford protocols, +including magic-state preparation or cultivation containing native `T` gates, +may have detector parities that can be inferred, but PECOS will reject DEM +construction unless the traced circuit has a separately justified supported +fault-propagation model. + +When definitions are already known rather than computed only as outputs, use +the audited typed workflow in [Detector Error Models from Guppy +Programs](dem-from-guppy.md). diff --git a/mkdocs.yml b/mkdocs.yml index ffcc311b0..cad2211f5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,6 +72,7 @@ nav: - QEC Geometry: user-guide/qec-geometry.md - QEC with Guppy: user-guide/qec-guppy.md - Detector Error Models from Guppy: user-guide/dem-from-guppy.md + - Inferring DEM Annotations from Guppy Outputs: user-guide/inferred-guppy-dem.md - Decoders: user-guide/decoders.md - Graph API: user-guide/graph-api.md - Circuit Representation: user-guide/circuit-representation.md diff --git a/python/pecos-rslib/src/sim.rs b/python/pecos-rslib/src/sim.rs index 382c2762c..f3fc3b393 100644 --- a/python/pecos-rslib/src/sim.rs +++ b/python/pecos-rslib/src/sim.rs @@ -654,7 +654,8 @@ impl PySimBuilder { /// This is the preferred programmatic tracing path for QIS-control simulations. /// It collects the structured trace in memory first, and any JSON dumping /// configured via `trace_operations(...)` becomes an optional mirror/export. - fn capture_operation_trace(&self, py: Python<'_>) -> PyResult> { + #[pyo3(signature = (shots=1))] + fn capture_operation_trace(&self, py: Python<'_>, shots: usize) -> PyResult> { use crate::engine_builders::{ PyBiasedDepolarizingNoiseModelBuilder, PyDepolarizingNoiseModelBuilder, PyGeneralNoiseModelBuilder, @@ -778,7 +779,12 @@ impl PySimBuilder { }; } - sim_builder.run(1).map_err(|e| { + if shots == 0 { + return Err(PyValueError::new_err( + "capture_operation_trace shots must be greater than zero", + )); + } + sim_builder.run(shots).map_err(|e| { PyRuntimeError::new_err(format!("Trace capture simulation failed: {e}")) })?; diff --git a/python/quantum-pecos/src/pecos/_qis_trace_replay.py b/python/quantum-pecos/src/pecos/_qis_trace_replay.py index ac5cc9f25..a82514f13 100644 --- a/python/quantum-pecos/src/pecos/_qis_trace_replay.py +++ b/python/quantum-pecos/src/pecos/_qis_trace_replay.py @@ -196,7 +196,7 @@ def tuple_args(payload: object, op_name: str, arity: int) -> tuple[Any, ...]: float(theta), [(mapped_slot(int(qubit_a), op_name), mapped_slot(int(qubit_b), op_name))], ) - elif op_name == "Measure": + elif op_name in {"Measure", "MeasureLeaked"}: program_id, result_id = tuple_args(payload, op_name, 2) measurement_qubit = mapped_slot(int(program_id), op_name) if _should_add_global_measurement_crosstalk_payload( @@ -321,9 +321,12 @@ def _replay_lowered_qis_trace_into_tick_circuit( a tick --- matching the parallel structure of the abstract circuit. MeasIds flow from runtime-lowered measurement provenance: - ``lowered_quantum_ops`` MZ entries must carry ``measurement_result_ids``. - This avoids inferring lowered measurement IDs from raw QIS operation order, - which is not stable under runtime scheduling or transport. + ``lowered_quantum_ops`` MZ and MeasureLeaked entries must carry + ``measurement_result_ids``. MeasureLeaked is replayed as the Boolean MZ + component of the accepted no-leakage path; leakage probability remains + outside the Pauli circuit/DEM model. This avoids inferring lowered + measurement IDs from raw QIS operation order, which is not stable under + runtime scheduling or transport. """ measurement_crosstalk_topology = _validate_measurement_crosstalk_topology( measurement_crosstalk_topology, @@ -372,10 +375,10 @@ def _replay_lowered_qis_trace_into_tick_circuit( msg = f"Lowered Idle gate expected one duration param, got {params!r}" raise ValueError(msg) tick.idle(_runtime_idle_seconds_to_time_units(params[0]), qubits) - elif gate_type == "MZ": + elif gate_type in {"MZ", "MeasureLeaked"}: if not isinstance(gate.get("measurement_result_ids"), list): msg = ( - "Lowered MZ trace is missing measurement_result_ids; " + f"Lowered {gate_type} trace is missing measurement_result_ids; " "rebuild PECOS so runtime-lowered measurements carry " "their result-id provenance instead of relying on " "operation-order inference." @@ -387,7 +390,10 @@ def _replay_lowered_qis_trace_into_tick_circuit( gate_type, ) if len(meas_ids) != len(qubits): - msg = f"Lowered MZ gate carries {len(meas_ids)} measurement_result_ids for {len(qubits)} qubit(s)" + msg = ( + f"Lowered {gate_type} gate carries {len(meas_ids)} " + f"measurement_result_ids for {len(qubits)} qubit(s)" + ) raise ValueError(msg) if _should_add_global_measurement_crosstalk_payload( measurement_crosstalk_topology, @@ -645,6 +651,8 @@ def source_measurement_ids_from_operation_trace(chunks: list[dict[str, Any]]) -> if not isinstance(quantum, Mapping): continue measure = quantum.get("Measure") + if measure is None: + measure = quantum.get("MeasureLeaked") if not isinstance(measure, Sequence) or isinstance(measure, (str, bytes)) or len(measure) != 2: continue result_id = measure[1] diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index 63792404d..090a9c1bd 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -93,6 +93,10 @@ PauliType, StabilizerCheck, ) +from pecos.qec.guppy_output_dem import ( + InferredGuppyDemAnnotations, + infer_guppy_dem_annotations, +) from pecos.qec.protocols import ( InnerCodeGeometry, MSDProtocol, @@ -139,12 +143,14 @@ "ParsedDem", "GuppyDemBuild", "GuppyDemBuilder", + "InferredGuppyDemAnnotations", "Observable", "assert_dems_equivalent", "compare_dems_exact", "compare_dems_statistical", "verify_dem_equivalence", "build_dem_from_guppy", + "infer_guppy_dem_annotations", "rec", "result_ref", "surface_memory_dem_spec", diff --git a/python/quantum-pecos/src/pecos/qec/guppy_output_dem.py b/python/quantum-pecos/src/pecos/qec/guppy_output_dem.py new file mode 100644 index 000000000..42d16462d --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/guppy_output_dem.py @@ -0,0 +1,424 @@ +"""Prototype DEM annotations inferred from Guppy parity outputs. + +This module is deliberately code-agnostic. It treats a Guppy program as the +owner of its detector/observable post-processing and learns the corresponding +affine GF(2) functions from PECOS coin-toss executions. A QIS trace then binds +the program's raw-measurement output to stable ``MeasId`` values. + +The inference is empirical, not a compiler proof. It therefore validates the +learned functions on additional independent rows and fails unless the raw tag +covers every physical measurement with a unique result-ID binding. +""" + +# Dynamic Guppy/runtime values and local fail-loud messages are intentional at +# this experimental Python boundary. +# ruff: noqa: ANN401, EM101, EM102, TRY003 + +from __future__ import annotations + +import json +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + + +def _bit(value: Any, *, context: str) -> int: + if isinstance(value, bool): + return int(value) + if isinstance(value, int) and value in (0, 1): + return value + raise ValueError(f"{context} must be bool, 0, or 1; got {value!r}") + + +def _rows(values: Sequence[Any], *, tag: str) -> list[list[int]]: + rows: list[list[int]] = [] + width: int | None = None + for shot, value in enumerate(values): + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + row = [_bit(item, context=f"result {tag!r}, shot {shot}") for item in value] + else: + row = [_bit(value, context=f"result {tag!r}, shot {shot}")] + if not row: + raise ValueError(f"result {tag!r}, shot {shot} is empty") + if width is None: + width = len(row) + elif len(row) != width: + raise ValueError(f"result {tag!r} has inconsistent shot widths: {width} and {len(row)}") + rows.append(row) + if not rows: + raise ValueError(f"result {tag!r} contains no shots") + return rows + + +def _infer_affine_columns( + inputs: Sequence[Sequence[int]], + outputs: Sequence[Sequence[int]], + *, + validation_rows: int, +) -> tuple[tuple[int, tuple[int, ...]], ...]: + """Infer all output columns as ``constant XOR selected inputs``.""" + if len(inputs) != len(outputs): + raise ValueError("raw and derived output records contain different shot counts") + input_width = len(inputs[0]) + output_width = len(outputs[0]) + if any(len(row) != input_width for row in inputs): + raise ValueError("raw measurement records have inconsistent widths") + if any(len(row) != output_width for row in outputs): + raise ValueError("derived output records have inconsistent widths") + + variable_count = input_width + 1 # affine constant followed by raw bits + minimum_rows = variable_count + validation_rows + if len(inputs) < minimum_rows: + raise ValueError( + f"affine inference needs at least {minimum_rows} shots for {input_width} raw measurements " + f"and {validation_rows} validation rows; got {len(inputs)}", + ) + + matrix = [ + [1, *(_bit(value, context="raw measurement") for value in raw), *derived] + for raw, derived in zip(inputs, outputs, strict=True) + ] + pivot_rows: dict[int, int] = {} + next_row = 0 + for column in range(variable_count): + pivot = next((row for row in range(next_row, len(matrix)) if matrix[row][column]), None) + if pivot is None: + continue + matrix[next_row], matrix[pivot] = matrix[pivot], matrix[next_row] + for row in range(len(matrix)): + if row != next_row and matrix[row][column]: + matrix[row] = [left ^ right for left, right in zip(matrix[row], matrix[next_row], strict=True)] + pivot_rows[column] = next_row + next_row += 1 + + if len(pivot_rows) != variable_count: + raise ValueError( + f"coin-toss probes have GF(2) rank {len(pivot_rows)}; need {variable_count}. " + "Increase probe_shots or change the seed.", + ) + for row in matrix: + if not any(row[:variable_count]) and any(row[variable_count:]): + raise ValueError("derived Guppy outputs are not affine parities of the raw measurement record") + + inferred: list[tuple[int, tuple[int, ...]]] = [] + for output in range(output_width): + coefficients = tuple(matrix[pivot_rows[column]][variable_count + output] for column in range(variable_count)) + constant = coefficients[0] + support = tuple(index for index, coefficient in enumerate(coefficients[1:]) if coefficient) + inferred.append((constant, support)) + + for shot, (raw, derived) in enumerate(zip(inputs, outputs, strict=True)): + for output, (constant, support) in enumerate(inferred): + predicted = constant + for index in support: + predicted ^= raw[index] + if predicted != derived[output]: + raise ValueError( + f"derived output {output} is not affine in the raw measurements (failed at shot {shot})", + ) + return tuple(inferred) + + +def _named_trace_items(trace: Sequence[Mapping[str, Any]]) -> list[Mapping[str, Any]]: + return [item for chunk in trace for item in (chunk.get("named_result_traces") or []) if isinstance(item, Mapping)] + + +def _trace_shots(trace: Sequence[Mapping[str, Any]]) -> list[list[Mapping[str, Any]]]: + shots: dict[tuple[int, int], list[Mapping[str, Any]]] = {} + for chunk in trace: + engine_id = chunk.get("engine_trace_id") + shot_index = chunk.get("shot_index") + if isinstance(engine_id, bool) or not isinstance(engine_id, int): + raise TypeError("QIS provenance trace is missing a valid engine_trace_id") + if isinstance(shot_index, bool) or not isinstance(shot_index, int): + raise TypeError("QIS provenance trace is missing a valid shot_index") + shots.setdefault((engine_id, shot_index), []).append(chunk) + return [sorted(chunks, key=lambda item: int(item.get("chunk_index", -1))) for _, chunks in sorted(shots.items())] + + +def _lowered_schedule(shot: Sequence[Mapping[str, Any]]) -> tuple[str, ...]: + return tuple( + json.dumps(gate, sort_keys=True, separators=(",", ":")) + for chunk in shot + for gate in (chunk.get("lowered_quantum_ops") or []) + if isinstance(gate, Mapping) + ) + + +def _correlate_raw_measurement_ids( + trace: Sequence[Mapping[str, Any]], + *, + raw_tag: str, + source_ids: Sequence[int], +) -> list[int]: + """Recover aggregate raw-output identity by independent probe signatures.""" + shots = _trace_shots(trace) + if len(shots) < 2: + raise ValueError("measurement provenance correlation needs at least two trace shots") + expected_schedule = _lowered_schedule(shots[0]) + raw_rows: list[list[int]] = [] + physical_rows: list[list[int]] = [] + for shot_number, shot in enumerate(shots): + if _lowered_schedule(shot) != expected_schedule: + raise ValueError( + f"quantum operation schedule changed during provenance probing at shot {shot_number}; " + "a single static DEM cannot represent this program", + ) + raw_row = [ + _bit(value, context=f"raw result tag {raw_tag!r}, provenance shot {shot_number}") + for item in _named_trace_items(shot) + if item.get("name") == raw_tag + for value in (item.get("values") or []) + ] + terminal = [chunk for chunk in shot if chunk.get("stage") == "trace_complete"] + if len(terminal) != 1: + raise ValueError(f"provenance shot {shot_number} has {len(terminal)} terminal trace chunks") + raw_results = terminal[0].get("measurement_results") + if not isinstance(raw_results, Mapping): + raise TypeError("QIS terminal trace lacks result-ID keyed measurement outcomes") + try: + outcomes = { + int(result_id): _bit(value, context="QIS measurement outcome") + for result_id, value in raw_results.items() + } + except (TypeError, ValueError) as error: + raise ValueError("QIS terminal trace contains invalid measurement outcomes") from error + if set(outcomes) != set(source_ids): + raise ValueError( + f"provenance shot {shot_number} measurement ids differ from the source trace: " + f"shot={sorted(outcomes)[:12]}, source={list(source_ids)[:12]}", + ) + if len(raw_row) != len(source_ids): + raise ValueError( + f"result tag {raw_tag!r} emits {len(raw_row)} values during provenance probing, " + f"but the QIS trace has {len(source_ids)} measurements", + ) + raw_rows.append(raw_row) + physical_rows.append([outcomes[result_id] for result_id in source_ids]) + + physical_signatures: dict[tuple[int, ...], list[int]] = {} + for column, result_id in enumerate(source_ids): + signature = tuple(row[column] for row in physical_rows) + physical_signatures.setdefault(signature, []).append(result_id) + collisions = [ids for ids in physical_signatures.values() if len(ids) != 1] + if collisions: + raise ValueError( + "physical measurement signatures are ambiguous during provenance probing; " + f"increase provenance_shots (first collision: {collisions[0][:8]})", + ) + + raw_ids: list[int] = [] + for column in range(len(source_ids)): + signature = tuple(row[column] for row in raw_rows) + matches = physical_signatures.get(signature) + if matches is None: + raise ValueError( + f"raw output element {column} is not a direct physical measurement across provenance probes", + ) + raw_ids.append(matches[0]) + if len(set(raw_ids)) != len(source_ids) or set(raw_ids) != set(source_ids): + raise ValueError( + f"result tag {raw_tag!r} does not expose every physical measurement exactly once; " + f"correlated ids={raw_ids[:12]}, source ids={list(source_ids)[:12]}", + ) + return raw_ids + + +@dataclass(frozen=True, slots=True) +class InferredGuppyDemAnnotations: + """An annotated QIS trace and its inferred detector/observable schema.""" + + circuit: Any + detectors_json: str + observables_json: str + raw_measurement_ids: tuple[int, ...] + detector_supports: tuple[tuple[int, ...], ...] + observable_supports: tuple[tuple[int, ...], ...] + observable_labels: tuple[tuple[str, int], ...] + probe_shots: int + raw_binding: str + + def build_dem(self, **noise: Any) -> Any: + """Build a PECOS DEM from the annotated trace, without Stim.""" + from pecos.qec.dem import DetectorErrorModel # noqa: PLC0415 + + return DetectorErrorModel.from_circuit(self.circuit, **noise) + + +def infer_guppy_dem_annotations( + program: object, + *, + num_qubits: int, + raw_tag: str = "raw measurements", + detector_tag: str = "DETECTOR", + observable_tags: Sequence[str] = ("obs",), + probe_shots: int = 256, + provenance_shots: int = 32, + validation_rows: int = 32, + seed: int = 0, + runtime: object | None = None, + require_raw_provenance: bool = True, +) -> InferredGuppyDemAnnotations: + """Infer parity annotations from an untouched Guppy program. + + The program must emit every physical measurement, in QIS measurement + through one or more ``result(raw_tag, ...)`` calls. Detector and + observable outputs may be computed XOR expressions. Coin-toss execution + makes the physical results independent GF(2) variables; Gaussian + elimination recovers each emitted parity and extra rows validate it. + + This prototype is suitable only when measurement values do not alter the + quantum operation schedule. PECOS captures one QIS path for the returned + circuit; the affine checks certify classical parity processing, not static + quantum control flow. ``require_raw_provenance`` defaults to true. Its + opt-in false setting assumes raw output order equals QIS measurement order + and records that weaker binding in the returned object and circuit. When + direct runtime result IDs are unavailable, the default mode correlates raw + output columns with result-ID keyed physical outcomes across independent + trace probes and requires a unique complete bijection. + """ + import pecos_rslib # noqa: PLC0415 + + import pecos # noqa: PLC0415 + from pecos._traced_circuit import normalize_traced_tick_circuit # noqa: PLC0415 + from pecos.tracing import ( # noqa: PLC0415 + _capture_qis_operation_traces, + capture_qis_operation_trace, + qis_operation_trace_to_tick_circuit, + ) + + if not observable_tags: + raise ValueError("observable_tags must contain at least one result tag") + if probe_shots <= 0 or provenance_shots < 2 or validation_rows < 1: + raise ValueError("probe_shots must be positive, provenance_shots at least 2, and validation_rows at least 1") + + trace = capture_qis_operation_trace(program, num_qubits, seed=seed, runtime=runtime) + circuit = qis_operation_trace_to_tick_circuit(trace) + normalize_traced_tick_circuit(circuit, context="infer_guppy_dem_annotations") + + raw_ids: list[int] = [] + raw_value_count = 0 + provenance_complete = True + for item in _named_trace_items(trace): + if item.get("name") != raw_tag: + continue + values = item.get("values") + result_ids = item.get("result_ids") + if not isinstance(values, list): + raise TypeError(f"raw result tag {raw_tag!r} has an invalid runtime trace value list") + raw_value_count += len(values) + if not isinstance(result_ids, list) or len(values) != len(result_ids): + provenance_complete = False + continue + if any(isinstance(value, bool) or not isinstance(value, int) or value < 0 for value in result_ids): + raise ValueError(f"raw result tag {raw_tag!r} contains an invalid measurement id") + raw_ids.extend(result_ids) + + source_ids_json = circuit.get_meta("qis_source_measurement_ids") + source_ids = json.loads(source_ids_json) if source_ids_json else [] + if provenance_complete and (len(set(raw_ids)) != len(source_ids) or set(raw_ids) != set(source_ids)): + raise ValueError( + f"result tag {raw_tag!r} must expose every physical measurement exactly once; " + f"tag ids={raw_ids[:12]}, source ids={source_ids[:12]}", + ) + if not provenance_complete: + if require_raw_provenance: + provenance_trace = _capture_qis_operation_traces( + program, + num_qubits, + shots=provenance_shots, + seed=seed, + runtime=runtime, + ) + raw_ids = _correlate_raw_measurement_ids( + provenance_trace, + raw_tag=raw_tag, + source_ids=source_ids, + ) + raw_binding = "probe_correlated_result_ids" + else: + if raw_value_count != len(source_ids): + raise ValueError( + f"result tag {raw_tag!r} emits {raw_value_count} traced values, but the QIS trace has " + f"{len(source_ids)} measurements", + ) + raw_ids = list(source_ids) + raw_binding = "assumed_canonical_result_order" + else: + raw_binding = "runtime_result_ids" + + results = ( + pecos.sim(program) + .classical(pecos.selene_engine(runtime)) + .quantum(pecos_rslib.coin_toss()) + .qubits(num_qubits) + .seed(seed) + .run(probe_shots) + .to_dict() + ) + missing = [tag for tag in (raw_tag, detector_tag, *observable_tags) if tag not in results] + if missing: + raise ValueError(f"Guppy results are missing required tag(s): {missing}") + + raw_rows = _rows(results[raw_tag], tag=raw_tag) + if len(raw_rows[0]) != len(raw_ids): + raise ValueError( + f"result tag {raw_tag!r} emits {len(raw_rows[0])} values per shot, " + f"but the QIS trace has {len(raw_ids)} measurements", + ) + detector_rows = _rows(results[detector_tag], tag=detector_tag) + observable_parts = [(tag, _rows(results[tag], tag=tag)) for tag in observable_tags] + observable_rows = [[value for _, rows in observable_parts for value in rows[shot]] for shot in range(probe_shots)] + observable_labels = tuple((tag, element) for tag, rows in observable_parts for element in range(len(rows[0]))) + + detector_affine = _infer_affine_columns(raw_rows, detector_rows, validation_rows=validation_rows) + observable_affine = _infer_affine_columns(raw_rows, observable_rows, validation_rows=validation_rows) + nonzero_offsets = [ + *(f"detector {index}" for index, (constant, _) in enumerate(detector_affine) if constant), + *(f"observable {index}" for index, (constant, _) in enumerate(observable_affine) if constant), + ] + if nonzero_offsets: + raise ValueError( + "DEM annotations cannot represent affine constant-one outputs: " + ", ".join(nonzero_offsets[:8]), + ) + + detector_supports = tuple(tuple(raw_ids[index] for index in support) for _, support in detector_affine) + observable_supports = tuple(tuple(raw_ids[index] for index in support) for _, support in observable_affine) + if any(not support for support in (*detector_supports, *observable_supports)): + raise ValueError("detector and observable outputs must depend on at least one physical measurement") + + detectors = [ + {"id": index, "meas_ids": list(support), "inferred_from_result_tag": detector_tag} + for index, support in enumerate(detector_supports) + ] + observables = [ + { + "id": index, + "meas_ids": list(support), + "inferred_from_result_tag": tag, + "result_element": element, + } + for index, (support, (tag, element)) in enumerate(zip(observable_supports, observable_labels, strict=True)) + ] + detectors_json = json.dumps(detectors, separators=(",", ":")) + observables_json = json.dumps(observables, separators=(",", ":")) + circuit.set_meta("detectors", detectors_json) + circuit.set_meta("observables", observables_json) + circuit.set_meta("num_measurements", str(len(raw_ids))) + circuit.set_meta("guppy_dem_annotation_method", "coin_toss_affine_inference_v2") + circuit.set_meta("guppy_raw_measurement_binding", raw_binding) + + return InferredGuppyDemAnnotations( + circuit=circuit, + detectors_json=detectors_json, + observables_json=observables_json, + raw_measurement_ids=tuple(raw_ids), + detector_supports=detector_supports, + observable_supports=observable_supports, + observable_labels=observable_labels, + probe_shots=probe_shots, + raw_binding=raw_binding, + ) + + +__all__ = ["InferredGuppyDemAnnotations", "infer_guppy_dem_annotations"] diff --git a/python/quantum-pecos/src/pecos/tracing.py b/python/quantum-pecos/src/pecos/tracing.py index 23d47965e..f7004f949 100644 --- a/python/quantum-pecos/src/pecos/tracing.py +++ b/python/quantum-pecos/src/pecos/tracing.py @@ -37,6 +37,32 @@ ) +def _capture_qis_operation_traces( + program: object, + num_qubits: int, + *, + shots: int, + seed: int = 0, + runtime: object | None = None, +) -> list[dict[str, Any]]: + """Capture one or more QIS trace shots for internal certification.""" + if shots <= 0: + msg = "trace shots must be greater than zero" + raise ValueError(msg) + import pecos_rslib # noqa: PLC0415 + + import pecos # noqa: PLC0415 + + sim_builder = ( + pecos.sim(program) + .classical(pecos.selene_engine(runtime)) + .quantum(pecos_rslib.coin_toss()) + .qubits(num_qubits) + .seed(seed) + ) + return list(sim_builder.capture_operation_trace(shots)) + + def capture_qis_operation_trace( program: object, num_qubits: int, @@ -63,20 +89,13 @@ def capture_qis_operation_trace( Returns: The structured operation-trace chunks for one completed shot. """ - import pecos_rslib # noqa: PLC0415 - - import pecos # noqa: PLC0415 - - # Trace capture records runtime-lowered operations and provenance. Use a - # permissive backend because no quantum-state evolution is needed here. - sim_builder = ( - pecos.sim(program) - .classical(pecos.selene_engine(runtime)) - .quantum(pecos_rslib.coin_toss()) - .qubits(num_qubits) - .seed(seed) + return _capture_qis_operation_traces( + program, + num_qubits, + shots=1, + seed=seed, + runtime=runtime, ) - return list(sim_builder.capture_operation_trace()) def _qis_operation_trace_to_tick_circuit( diff --git a/python/quantum-pecos/tests/pecos/test_selene_interface_integration.py b/python/quantum-pecos/tests/pecos/test_selene_interface_integration.py index d8b7bd5ce..5d2510747 100644 --- a/python/quantum-pecos/tests/pecos/test_selene_interface_integration.py +++ b/python/quantum-pecos/tests/pecos/test_selene_interface_integration.py @@ -270,6 +270,105 @@ def test_selene_engine_uses_plugin_when_cargo_target_is_empty(tmp_path: Path) -> _run_selene_cwd_probe(tmp_path, empty_cargo_target) +def test_qis_trace_capture_supports_leakage_measurement_futures(tmp_path: Path) -> None: + """The Helios leakage symbols must resolve instead of calling address zero.""" + probe = tmp_path / "measure_leaked_trace_probe.py" + probe.write_text( + textwrap.dedent( + """ + import pecos + from guppylang import guppy + from guppylang.std.builtins import result + from guppylang.std.qsystem import measure_leaked + from guppylang.std.quantum import qubit + + @guppy + def measure_leakage() -> None: + measured = measure_leaked(qubit()) + result("not leaked", not measured.is_leaked()) + measured.discard() + result("constant detector", 0) + + trace = pecos.capture_qis_operation_trace(measure_leakage, 1, seed=17) + assert trace + assert any( + item.get("name") == "not leaked" + for chunk in trace + for item in chunk.get("named_result_traces", []) + ) + assert any( + item.get("name") == "constant detector" and item.get("values") == [False] + for chunk in trace + for item in chunk.get("named_result_traces", []) + ) + """, + ), + encoding="utf-8", + ) + + completed = subprocess.run( + [sys.executable, str(probe)], + cwd=tmp_path, + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr + + +def test_guppy_leakage_outcome_round_trips_through_qis(tmp_path: Path) -> None: + """A PECOS leakage outcome of 2 must reach Guppy's unsigned future intact.""" + probe = tmp_path / "measure_leaked_outcome_probe.py" + probe.write_text( + textwrap.dedent( + """ + import pecos + from guppylang import guppy + from guppylang.std.builtins import result + from guppylang.std.qsystem import measure_leaked + from guppylang.std.quantum import qubit, z + + @guppy + def measure_forced_leakage() -> None: + q = qubit() + z(q) + measured = measure_leaked(q) + result("leaked", measured.is_leaked()) + measured.discard() + + noise = ( + pecos.general_noise() + .with_p1(1.0) + .with_p1_emission_ratio(1.0) + .with_p1_emission_model({"L": 1.0}) + .with_leakage_scale(1.0) + ) + results = ( + pecos.sim(measure_forced_leakage) + .classical(pecos.selene_engine()) + .qubits(1) + .quantum(pecos.state_vector()) + .noise(noise) + .seed(19) + .run(4) + .to_dict() + ) + assert all(results["leaked"]), results + """, + ), + encoding="utf-8", + ) + + completed = subprocess.run( + [sys.executable, str(probe)], + cwd=tmp_path, + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr + + def test_sim_guppy_reuses_physical_slot_after_measurement() -> None: """Test that a recycled physical slot is reinitialized when Guppy reallocates a qubit.""" import pecos diff --git a/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py b/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py index 988c2f1f2..df49f3d35 100644 --- a/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py +++ b/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py @@ -200,6 +200,27 @@ def test_capture_operation_trace_includes_named_result_provenance() -> None: assert len(named_trace["result_ids"]) == len(named_trace["values"]) +def test_capture_operation_trace_includes_result_id_keyed_outcomes_per_shot() -> None: + """Aggregate-output provenance can correlate values with physical IDs.""" + import pecos + import pecos_rslib + + _require_selene_runtime() + trace = ( + pecos.sim(make_tiny_x_syndrome_memory(1)) + .classical(pecos.selene_engine()) + .quantum(pecos_rslib.coin_toss()) + .qubits(2) + .seed(321) + .capture_operation_trace(3) + ) + + terminal = [chunk for chunk in trace if chunk.get("stage") == "trace_complete"] + assert len(terminal) == 3 + assert {chunk["shot_index"] for chunk in terminal} == {1, 2, 3} + assert all(set(chunk["measurement_results"]) == {"0", "1"} for chunk in terminal) + + def _collect_selene_named_results( instance: object, *, diff --git a/python/quantum-pecos/tests/pecos/test_tracing.py b/python/quantum-pecos/tests/pecos/test_tracing.py index ca3171a54..5194e65f5 100644 --- a/python/quantum-pecos/tests/pecos/test_tracing.py +++ b/python/quantum-pecos/tests/pecos/test_tracing.py @@ -106,6 +106,18 @@ def fake_capture( assert replayed.get_meta("qis_source_measurement_ids") == "[7]" +def test_leakage_measurement_replays_as_accepted_path_mz() -> None: + trace = _completed_trace() + trace[0]["operations"][-1] = {"Quantum": {"MeasureLeaked": [0, 7]}} + trace[0]["lowered_quantum_ops"][-1]["gate_type"] = "MeasureLeaked" + + replayed = pecos.qis_operation_trace_to_tick_circuit(trace) + + assert "MZ" in _gate_names(replayed) + assert "MeasureLeaked" not in _gate_names(replayed) + assert replayed.get_meta("qis_source_measurement_ids") == "[7]" + + def test_qis_operation_trace_conversion_rejects_an_incomplete_trace() -> None: with pytest.raises(ValueError, match="terminal trace_complete"): pecos.qis_operation_trace_to_tick_circuit(_completed_trace()[:-1]) @@ -225,7 +237,8 @@ def seed(self, seed): calls.append(("seed", seed)) return self - def capture_operation_trace(self): + def capture_operation_trace(self, shots): + calls.append(("shots", shots)) return iter(trace) program = object() @@ -240,6 +253,7 @@ def capture_operation_trace(self): ("quantum", "trace-backend"), ("qubits", 3), ("seed", 11), + ("shots", 1), ] diff --git a/python/quantum-pecos/tests/qec/test_guppy_output_dem.py b/python/quantum-pecos/tests/qec/test_guppy_output_dem.py new file mode 100644 index 000000000..6f88ea292 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_guppy_output_dem.py @@ -0,0 +1,122 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Tests for parity annotations learned from Guppy result outputs.""" + +from __future__ import annotations + +import json + +import pytest +from guppylang import guppy +from guppylang.std.builtins import array, result +from guppylang.std.quantum import measure, qubit +from pecos.qec import infer_guppy_dem_annotations + + +@guppy +def _measure_three_into_array() -> array[bool, 3]: + q0 = qubit() + q1 = qubit() + q2 = qubit() + return array(measure(q0), measure(q1), measure(q2)) + + +@guppy +def _computed_parity_outputs() -> None: + measurements = _measure_three_into_array() + m0 = measurements[0] + m1 = measurements[1] + m2 = measurements[2] + result("DETECTOR", m0 ^ m1) + result("DETECTOR", m1 ^ m2) + result("raw measurements", measurements) + result("obs", m0 ^ m2) + + +@guppy +def _raw_results_incomplete() -> None: + q0 = qubit() + q1 = qubit() + m0 = measure(q0) + result("raw measurements", m0) + m1 = measure(q1) + result("DETECTOR", m0 ^ m1) + result("obs", m0) + + +@guppy +def _reordered_raw_array() -> None: + m0 = measure(qubit()) + m1 = measure(qubit()) + m2 = measure(qubit()) + result("DETECTOR", m2 ^ m0) + result("raw measurements", array(m2, m0, m1)) + result("obs", m1) + + +def test_infers_computed_detector_and_observable_parities_and_builds_dem() -> None: + inferred = infer_guppy_dem_annotations( + _computed_parity_outputs, + num_qubits=3, + probe_shots=64, + validation_rows=16, + seed=7, + require_raw_provenance=False, + ) + + assert inferred.raw_measurement_ids == (0, 1, 2) + assert inferred.detector_supports == ((0, 1), (1, 2)) + assert inferred.observable_supports == ((0, 2),) + assert inferred.observable_labels == (("obs", 0),) + assert inferred.raw_binding == "assumed_canonical_result_order" + assert json.loads(inferred.detectors_json) == [ + {"id": 0, "meas_ids": [0, 1], "inferred_from_result_tag": "DETECTOR"}, + {"id": 1, "meas_ids": [1, 2], "inferred_from_result_tag": "DETECTOR"}, + ] + + dem = inferred.build_dem(p1=0.0, p2=0.0, p_meas=0.1, p_prep=0.0) + assert dem.num_detectors == 2 + assert dem.num_observables == 1 + assert "D0" in dem.to_string() + + +def test_computed_array_provenance_is_correlated_to_qis_result_ids() -> None: + inferred = infer_guppy_dem_annotations( + _computed_parity_outputs, + num_qubits=3, + probe_shots=32, + provenance_shots=16, + validation_rows=8, + seed=7, + ) + + assert inferred.raw_measurement_ids == (0, 1, 2) + assert inferred.raw_binding == "probe_correlated_result_ids" + + +def test_correlated_provenance_preserves_reordered_raw_identity() -> None: + inferred = infer_guppy_dem_annotations( + _reordered_raw_array, + num_qubits=3, + probe_shots=32, + provenance_shots=16, + validation_rows=8, + seed=13, + ) + + assert inferred.raw_measurement_ids == (2, 0, 1) + assert inferred.detector_supports == ((2, 0),) + assert inferred.observable_supports == ((1,),) + assert inferred.raw_binding == "probe_correlated_result_ids" + + +def test_raw_tag_must_cover_canonical_qis_measurement_order() -> None: + with pytest.raises(ValueError, match="emits 1 values during provenance probing"): + infer_guppy_dem_annotations( + _raw_results_incomplete, + num_qubits=2, + probe_shots=32, + validation_rows=8, + seed=3, + ) From c60c3ab11ba1b9bf31a06359c7e298f028d1ac10 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 21:31:27 -0600 Subject: [PATCH 58/62] Fix clippy lints introduced by the DEM-annotation merge --- crates/pecos-qis-ffi/src/ffi.rs | 3 +++ crates/pecos-qis/src/ccengine.rs | 5 +---- crates/pecos-qis/src/qis_interface.rs | 8 ++++++++ crates/pecos-qis/src/runtime.rs | 4 ++++ crates/pecos-qis/src/selene_runtime.rs | 4 ++-- 5 files changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index db7838b46..d476cbe38 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -749,6 +749,9 @@ pub unsafe extern "C" fn ___lazy_measure(qubit: i64) -> i64 { /// /// # Safety /// The same requirements as [`___lazy_measure`] apply. +/// +/// # Panics +/// Panics if the allocated result ID is too large to fit in i64. #[unsafe(no_mangle)] pub unsafe extern "C" fn ___lazy_measure_leaked(qubit: i64) -> i64 { let qubit_id = i64_to_usize(qubit); diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index fcb419448..a45b6a3b2 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -1676,10 +1676,7 @@ impl ClassicalEngine for QisEngine { for (result_id, value) in &self.measurement_results { shot.data .insert(format!("measurement_{result_id}"), Data::U32(*value)); - debug!( - "QisEngine: Added to shot: measurement_{} = {}", - result_id, value - ); + debug!("QisEngine: Added to shot: measurement_{result_id} = {value}"); } } diff --git a/crates/pecos-qis/src/qis_interface.rs b/crates/pecos-qis/src/qis_interface.rs index ec354bb8e..0ef5b0b2e 100644 --- a/crates/pecos-qis/src/qis_interface.rs +++ b/crates/pecos-qis/src/qis_interface.rs @@ -185,6 +185,10 @@ pub trait QisInterface: Send + Sync { /// Set an integer-valued measurement outcome for the running program. /// /// Existing interfaces remain Boolean-only by default. + /// + /// # Errors + /// Returns an error if `value` is not 0 or 1, since a Boolean-only interface + /// cannot represent a leakage outcome, or if setting the result itself fails. fn set_measurement_outcome( &mut self, result_id: u64, @@ -277,6 +281,10 @@ pub trait DynamicSyncHandle: Send + Sync { fn set_measurement_result(&self, result_id: u64, value: bool) -> Result<(), InterfaceError>; /// Set an integer-valued measurement outcome for the running program. + /// + /// # Errors + /// Returns an error if `value` is not 0 or 1, since a Boolean-only interface + /// cannot represent a leakage outcome, or if setting the result itself fails. fn set_measurement_outcome(&self, result_id: u64, value: u64) -> Result<(), InterfaceError> { match value { 0 => self.set_measurement_result(result_id, false), diff --git a/crates/pecos-qis/src/runtime.rs b/crates/pecos-qis/src/runtime.rs index 5651ab621..d169a1ced 100644 --- a/crates/pecos-qis/src/runtime.rs +++ b/crates/pecos-qis/src/runtime.rs @@ -150,6 +150,10 @@ pub trait QisRuntime: Send + Sync + dyn_clone::DynClone { /// /// The default keeps existing Boolean runtimes compatible and rejects a /// leakage outcome instead of silently converting 2 to true. + /// + /// # Errors + /// Returns an error if any outcome is not 0 or 1, since a Boolean runtime cannot + /// represent a leakage outcome, or if providing the measurements themselves fails. fn provide_measurement_outcomes(&mut self, outcomes: BTreeMap) -> Result<()> { let measurements = outcomes .into_iter() diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index f968465d6..a872cebf4 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -1350,8 +1350,8 @@ impl SeleneRuntime { ( QuantumOp::Measure(source_qubit, source_result), QuantumOp::Measure(lowered_qubit, lowered_result), - ) => source_qubit == lowered_qubit && source_result == lowered_result, - ( + ) + | ( QuantumOp::MeasureLeaked(source_qubit, source_result), QuantumOp::MeasureLeaked(lowered_qubit, lowered_result), ) => source_qubit == lowered_qubit && source_result == lowered_result, From 909a095251dd45ca09b709fd60609d4f58094bf2 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 21:31:34 -0600 Subject: [PATCH 59/62] Add a uniform ObservableFlips API across decoder results and sample batches --- docs/workflows/guppy-dem-decoding.md | 27 +- python/pecos-rslib/src/decoder_bindings.rs | 42 ++- .../src/fault_tolerance_bindings.rs | 69 ++--- python/pecos-rslib/src/lib.rs | 1 + .../src/observable_flips_bindings.rs | 271 ++++++++++++++++++ .../src/pecos/decoders/__init__.py | 2 + .../tests/qec/test_observable_flips.py | 215 ++++++++++++++ 7 files changed, 569 insertions(+), 58 deletions(-) create mode 100644 python/pecos-rslib/src/observable_flips_bindings.rs create mode 100644 python/quantum-pecos/tests/qec/test_observable_flips.py diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index e33534fc8..d7560f171 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -295,13 +295,14 @@ Each decoder is constructed from the DEM text form it accepts, then asked for a prediction per shot. A shot counts as a logical error when the predicted observable flip disagrees with the flip the sample actually carried. -The three decoders expose slightly different call shapes today: PyMatching -returns a per-observable `correction` vector, while Tesseract and BP+OSD return -an `observables_mask` bitmask. +The result types reconcile their underlying shapes through `observable_flips`. +PyMatching's per-observable `correction` vector and Tesseract and BP+OSD's +`observables_mask` bitmasks remain available when the underlying representation +is useful. ```python -from pecos.decoders import BpOsdDecoder, PyMatchingDecoder, TesseractDecoder +from pecos.decoders import BpOsdDecoder, ObservableFlips, PyMatchingDecoder, TesseractDecoder pymatching = PyMatchingDecoder.from_dem(terminal_graphlike_text) tesseract = TesseractDecoder.from_dem(source_graphlike_text, preset="fast", pqlimit=50_000) @@ -313,11 +314,11 @@ bp_osd_errors = 0 for shot in range(batch.num_shots): syndrome = batch.get_syndrome(shot) - actual = batch.get_observable_mask(shot) & 1 + actual = batch.observable_flips(shot) - pymatching_errors += pymatching.decode_syndrome(syndrome).correction[0] != actual - tesseract_errors += (tesseract.decode_syndrome(syndrome).observables_mask & 1) != actual - bp_osd_errors += (bp_osd.decode_syndrome(syndrome).observables_mask & 1) != actual + pymatching_errors += pymatching.decode_syndrome(syndrome).observable_flips != actual + tesseract_errors += tesseract.decode_syndrome(syndrome).observable_flips != actual + bp_osd_errors += bp_osd.decode_syndrome(syndrome).observable_flips != actual shots = batch.num_shots assert 0 < pymatching_errors < shots @@ -330,14 +331,20 @@ print(f"tesseract {tesseract_errors:5} {tesseract_errors / shots:.4%}") print(f"bp_osd {bp_osd_errors:5} {bp_osd_errors / shots:.4%}") ``` +With one observable, any-observable and per-observable error rates coincide. +With several, `predicted != actual` counts any-observable failures, while +`predicted[i] != actual[i]` counts failures for observable `i`; say which rate +you mean. + The simulated shots decode the same way, against the same decoders: ```python sim_errors = 0 for syndrome, observable_mask in sim_shots: - predicted = pymatching.decode_syndrome(syndrome).correction[0] - sim_errors += predicted != (observable_mask & 1) + predicted = pymatching.decode_syndrome(syndrome).observable_flips + actual = ObservableFlips.from_mask(observable_mask, dem.num_observables) + sim_errors += predicted != actual print(f"simulated shots, pymatching: {sim_errors}/{len(sim_shots)}") ``` diff --git a/python/pecos-rslib/src/decoder_bindings.rs b/python/pecos-rslib/src/decoder_bindings.rs index e55153a4a..10a64c958 100644 --- a/python/pecos-rslib/src/decoder_bindings.rs +++ b/python/pecos-rslib/src/decoder_bindings.rs @@ -40,6 +40,8 @@ use ndarray::{Array1, Array2}; use pyo3::prelude::*; +use crate::observable_flips_bindings::PyObservableFlips; + fn explicit_decode_attribute_error(class_name: &str, name: &str) -> PyErr { if name == "decode" { pyo3::exceptions::PyAttributeError::new_err(format!( @@ -95,6 +97,12 @@ impl PyMwpmResult { self.correction_data.iter().map(|&x| i32::from(x)).collect() } + /// The decoded observable flips with their intrinsic observable count. + #[getter] + fn observable_flips(&self) -> PyObservableFlips { + PyObservableFlips::from_u8_bits(&self.correction_data) + } + /// Get the correction as a list (alias for correction attribute). /// /// This mirrors `PyMatching`'s `decode()` return value. @@ -125,10 +133,13 @@ impl PyMwpmResult { /// /// # Attributes /// -/// * `decoding` - The decoded error vector +/// * `decoding` - The decoded error vector, indexed by error mechanism, not observable /// * `converged` - Whether BP converged before max iterations /// * `iterations` - Number of BP iterations performed /// +/// Per-observable results come from the decoders' `from_dem` constructors, +/// which return `DemAwareResult` from `decode_syndrome`. +/// /// # Example /// /// ```python @@ -156,6 +167,10 @@ pub struct PyBpResult { #[pymethods] impl PyBpResult { /// The decoded error vector as a Python list. + /// + /// This vector is indexed by error mechanism, not by observable. + /// Per-observable results come from the `from_dem` constructors, whose + /// `decode_syndrome` method returns `DemAwareResult`. #[getter] fn decoding(&self) -> Vec { self.decoding_data.iter().map(|&x| i32::from(x)).collect() @@ -1951,10 +1966,20 @@ pub struct PyTesseractResult { cost: f64, #[pyo3(get)] low_confidence: bool, + num_observables: usize, } #[pymethods] impl PyTesseractResult { + /// The decoded observable flips with the decoder's observable count. + #[getter] + fn observable_flips(&self) -> PyObservableFlips { + PyObservableFlips::from_mask_value( + pecos_decoder_core::obs_mask::ObsMask::from_u64(self.observables_mask), + self.num_observables, + ) + } + /// Get the observable predictions as a list of bits. fn observable_bits(&self, num_observables: usize) -> Vec { (0..num_observables) @@ -2084,6 +2109,7 @@ impl PyTesseractDecoder { /// ``` fn decode_from_defects(&mut self, detections: Vec) -> PyResult { let detections_arr = ndarray::Array1::from_vec(detections); + let num_observables = self.inner.num_observables(); self.inner .decode_detections(&detections_arr.view()) @@ -2091,6 +2117,7 @@ impl PyTesseractDecoder { observables_mask: result.observables_mask, cost: result.cost, low_confidence: result.low_confidence, + num_observables, }) .map_err(|e| PyErr::new::(e.to_string())) } @@ -2146,6 +2173,7 @@ impl PyTesseractDecoder { let dem_str = &self.dem_string; let config = &self.config; + let num_observables = self.inner.num_observables(); let results: Result, _> = pool.install(|| { syndromes @@ -2181,6 +2209,7 @@ impl PyTesseractDecoder { observables_mask: r.observables_mask, cost: r.cost, low_confidence: r.low_confidence, + num_observables, }) .map_err(|e| e.to_string()) }) @@ -2908,6 +2937,7 @@ pub struct PyDemAwareResult { /// Number of BP iterations used. #[pyo3(get)] pub iterations: usize, + num_observables: usize, } impl PyDemAwareResult { @@ -2936,7 +2966,13 @@ impl PyDemAwareResult { /// observables yield exactly the value the previous `u64` field held. #[getter] fn observables_mask(&self, py: Python<'_>) -> PyResult> { - crate::fault_tolerance_bindings::obsmask_to_py(py, &self.observables) + crate::observable_flips_bindings::obsmask_to_py(py, &self.observables) + } + + /// The decoded observable flips with the decoder's observable count. + #[getter] + fn observable_flips(&self) -> PyObservableFlips { + PyObservableFlips::from_mask_value(self.observables.clone(), self.num_observables) } fn __repr__(&self) -> String { @@ -3053,6 +3089,7 @@ impl PyDemAwareDecoder { observables, converged, iterations, + num_observables: self.dem_check_matrix.num_observables, }) } @@ -3106,6 +3143,7 @@ pub fn register_decoders_module(parent_module: &Bound<'_, PyModule>) -> PyResult let decoders_module = PyModule::new(py, "decoders")?; // Common result types + decoders_module.add_class::()?; decoders_module.add_class::()?; decoders_module.add_class::()?; diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index b1f0a1de6..da41818d1 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -71,6 +71,8 @@ use pecos_quantum::DagCircuit; use pecos_quantum::QubitId; use pyo3::Py; use pyo3::prelude::*; + +use crate::observable_flips_bindings::{PyObservableFlips, obsmask_to_py, py_to_obsmask}; use std::collections::BTreeMap; use std::str::FromStr; @@ -3596,6 +3598,12 @@ impl PySampleBatch { self.num_shots } + /// Number of observables in this batch. + #[getter] + fn num_observables(&self) -> usize { + self.obs_columns.len() + } + /// Get the syndrome for shot `i` as a list of u8 values. fn get_syndrome(&self, i: usize) -> PyResult> { if i >= self.num_shots { @@ -3633,6 +3641,20 @@ impl PySampleBatch { obsmask_to_py(py, &self.extract_obs_mask_wide(i)) } + /// Observable flips for shot `i`, with the batch's observable count. + fn observable_flips(&self, i: usize) -> PyResult { + if i >= self.num_shots { + return Err(PyErr::new::(format!( + "Shot index {i} out of range (num_shots={})", + self.num_shots + ))); + } + Ok(PyObservableFlips::from_mask_value( + self.extract_obs_mask_wide(i), + self.obs_columns.len(), + )) + } + /// Decode all samples with the given decoder type and return the error count. /// /// This runs entirely in Rust -- no per-shot Python crossing. @@ -5613,52 +5635,6 @@ impl PyCssUfDecoder { /// ... "`fusion_blossom_serial`", /// ... ) /// >>> obs = decoder.decode(syndrome) -/// Convert a wide observable mask to a Python integer (arbitrary precision). -/// -/// `<= 64` observables become a plain `int` from the single `u64` (identical to -/// the historical return); `> 64` observables become a big `int` built from the -/// mask's little-endian words, with no truncation. -pub(crate) fn obsmask_to_py( - py: Python<'_>, - mask: &pecos_decoder_core::obs_mask::ObsMask, -) -> PyResult> { - if let Some(v) = mask.to_u64() { - return Ok(v.into_pyobject(py)?.into_any().unbind()); - } - let mut bytes = Vec::with_capacity(mask.words().len() * 8); - for &word in mask.words() { - bytes.extend_from_slice(&word.to_le_bytes()); - } - let py_bytes = pyo3::types::PyBytes::new(py, &bytes); - let int_type = py.get_type::(); - Ok(int_type - .call_method1("from_bytes", (py_bytes, "little"))? - .unbind()) -} - -/// Convert a Python integer (arbitrary precision) to a wide observable mask. -/// -/// Inverse of [`obsmask_to_py`]: reads the int's little-endian bytes and packs -/// them into `u64` words, so observable indices >= 64 are preserved. -fn py_to_obsmask( - value: &pyo3::Bound<'_, pyo3::PyAny>, -) -> PyResult { - let bit_length: usize = value.call_method0("bit_length")?.extract()?; - let nbytes = bit_length.div_ceil(8).max(1); - let bytes: Vec = value - .call_method1("to_bytes", (nbytes, "little"))? - .extract()?; - let words: Vec = bytes - .chunks(8) - .map(|chunk| { - let mut buf = [0u8; 8]; - buf[..chunk.len()].copy_from_slice(chunk); - u64::from_le_bytes(buf) - }) - .collect(); - Ok(pecos_decoder_core::obs_mask::ObsMask::from_words(&words)) -} - #[pyclass(name = "LogicalSubgraphDecoder", module = "pecos_rslib.qec")] pub struct PyLogicalSubgraphDecoder { inner: pecos_decoder_core::logical_subgraph::LogicalSubgraphDecoder, @@ -7042,6 +7018,7 @@ fn decoder_dem_requirement(decoder_type: &str) -> PyResult { pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { let qec = PyModule::new(m.py(), "qec")?; + qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; diff --git a/python/pecos-rslib/src/lib.rs b/python/pecos-rslib/src/lib.rs index 0de139841..293d60860 100644 --- a/python/pecos-rslib/src/lib.rs +++ b/python/pecos-rslib/src/lib.rs @@ -50,6 +50,7 @@ mod gate_registry_bindings; mod graph_bindings; mod namespace_modules; mod num_bindings; +mod observable_flips_bindings; mod pauli_bindings; mod pauli_prop_bindings; mod pauli_sequence_bindings; diff --git a/python/pecos-rslib/src/observable_flips_bindings.rs b/python/pecos-rslib/src/observable_flips_bindings.rs new file mode 100644 index 000000000..55e7620d3 --- /dev/null +++ b/python/pecos-rslib/src/observable_flips_bindings.rs @@ -0,0 +1,271 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Shared Python value type for logical-observable flip predictions and ground truth. + +use pecos_decoder_core::obs_mask::ObsMask; +use pyo3::prelude::*; +use pyo3::types::{PyBytes, PyInt, PyList}; + +/// Logical-observable flips with an explicit observable count. +#[pyclass( + name = "ObservableFlips", + module = "pecos_rslib.decoders", + frozen, + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyObservableFlips { + mask: ObsMask, + num_observables: usize, +} + +impl PyObservableFlips { + pub(crate) fn from_mask_value(mask: ObsMask, num_observables: usize) -> Self { + debug_assert!(mask.iter_set_bits().all(|index| index < num_observables)); + Self { + mask, + num_observables, + } + } + + pub(crate) fn from_u8_bits(bits: &[u8]) -> Self { + let mut mask = ObsMask::new(); + for (index, &bit) in bits.iter().enumerate() { + if bit != 0 { + mask.set(index); + } + } + Self::from_mask_value(mask, bits.len()) + } + + fn normalize_index(&self, index: isize) -> PyResult { + let normalized = if index < 0 { + self.num_observables.checked_add_signed(index) + } else { + usize::try_from(index).ok() + }; + normalized + .filter(|&i| i < self.num_observables) + .ok_or_else(|| { + pyo3::exceptions::PyIndexError::new_err(format!( + "Observable index {index} out of range (num_observables={})", + self.num_observables + )) + }) + } + + fn validate_mask( + mask: &ObsMask, + mask_display: &str, + num_observables: usize, + ) -> Result<(), String> { + if let Some(index) = mask + .iter_set_bits() + .filter(|&index| index >= num_observables) + .max() + { + return Err(format!( + "mask={mask_display} has bit {index} set at or above \ + num_observables={num_observables}" + )); + } + Ok(()) + } + + fn value_eq(&self, other: &Self) -> bool { + self.num_observables == other.num_observables && self.mask == other.mask + } +} + +#[pymethods] +impl PyObservableFlips { + fn __len__(&self) -> usize { + self.num_observables + } + + fn __getitem__(&self, index: isize) -> PyResult { + self.normalize_index(index).map(|i| self.mask.get(i)) + } + + fn __iter__(&self, py: Python<'_>) -> PyResult> { + let bits = (0..self.num_observables).map(|index| self.mask.get(index)); + Ok(PyList::new(py, bits)?.call_method0("__iter__")?.unbind()) + } + + fn __eq__(&self, other: &Bound<'_, PyAny>, py: Python<'_>) -> PyResult> { + let Ok(other) = other.extract::>() else { + return Ok(py.NotImplemented()); + }; + Ok(self + .value_eq(&other) + .into_pyobject(py)? + .to_owned() + .into_any() + .unbind()) + } + + fn indices(&self) -> Vec { + self.mask.iter_set_bits().collect() + } + + #[getter] + fn mask(&self, py: Python<'_>) -> PyResult> { + obsmask_to_py(py, &self.mask) + } + + fn __repr__(&self, py: Python<'_>) -> PyResult { + let mask = obsmask_to_py(py, &self.mask)?; + Ok(format!( + "ObservableFlips(num_observables={}, mask={})", + self.num_observables, + mask.bind(py).str()?.to_str()? + )) + } + + #[staticmethod] + fn from_mask(mask: &Bound<'_, PyAny>, num_observables: usize) -> PyResult { + let mask = as_index(mask)?; + let mask_display = mask.str()?.to_str()?.to_owned(); + if mask.lt(0)? { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "mask={mask_display} is negative; an observable mask is unsigned" + ))); + } + let mask_value = py_to_obsmask(&mask)?; + Self::validate_mask(&mask_value, &mask_display, num_observables) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + Ok(Self::from_mask_value(mask_value, num_observables)) + } + + #[staticmethod] + fn from_bits(bits: &Bound<'_, PyAny>) -> PyResult { + let mut mask = ObsMask::new(); + let mut len = 0usize; + for (index, item) in bits.try_iter()?.enumerate() { + if bit_value(&item?, index)? { + mask.set(index); + } + len = index + 1; + } + Ok(Self::from_mask_value(mask, len)) + } +} + +/// Normalize an integer-like Python object to a true `int` via `__index__`. +/// +/// This is the protocol Python itself uses wherever an integer is required, so +/// `bool` and NumPy integer scalars are accepted on the same footing as `int`. +/// It matters here because the existing accessors this type bridges from -- +/// `MwpmResult.correction` and `TesseractResult.observable_bits` -- hand back +/// integers, and observable masks routinely arrive as NumPy scalars. +fn as_index<'py>(value: &Bound<'py, PyAny>) -> PyResult> { + match value.call_method0("__index__") { + Ok(index) => Ok(index), + // A missing `__index__` means "not an integer", which Python reports as a + // TypeError. A `__index__` that exists and raises is a real error: pass it on. + Err(err) if err.is_instance_of::(value.py()) => { + let type_name = value + .get_type() + .name() + .map_or_else(|_| "object".to_owned(), |name| name.to_string()); + Err(pyo3::exceptions::PyTypeError::new_err(format!( + "'{type_name}' object cannot be interpreted as an integer" + ))) + } + Err(err) => Err(err), + } +} + +/// Read one entry of a `from_bits` iterable. +/// +/// Booleans (including NumPy booleans) are taken directly; anything integer-like +/// must be exactly 0 or 1. Truthiness is deliberately not used -- a non-bit value +/// is an error, not something to coerce. +fn bit_value(item: &Bound<'_, PyAny>, index: usize) -> PyResult { + if let Ok(bit) = item.extract::() { + return Ok(bit); + } + match as_index(item)?.extract::()? { + 0 => Ok(false), + 1 => Ok(true), + value => Err(pyo3::exceptions::PyValueError::new_err(format!( + "bit at index {index} must be 0 or 1, got {value}" + ))), + } +} + +/// Convert a wide observable mask to a Python integer (arbitrary precision). +pub(crate) fn obsmask_to_py(py: Python<'_>, mask: &ObsMask) -> PyResult> { + if let Some(value) = mask.to_u64() { + return Ok(value.into_pyobject(py)?.into_any().unbind()); + } + let mut bytes = Vec::with_capacity(mask.words().len() * 8); + for &word in mask.words() { + bytes.extend_from_slice(&word.to_le_bytes()); + } + let py_bytes = PyBytes::new(py, &bytes); + Ok(py + .get_type::() + .call_method1("from_bytes", (py_bytes, "little"))? + .unbind()) +} + +/// Convert a Python integer (arbitrary precision) to a wide observable mask. +pub(crate) fn py_to_obsmask(value: &Bound<'_, PyAny>) -> PyResult { + let bit_length: usize = value.call_method0("bit_length")?.extract()?; + let nbytes = bit_length.div_ceil(8).max(1); + let bytes: Vec = value + .call_method1("to_bytes", (nbytes, "little"))? + .extract()?; + let words = bytes + .chunks(8) + .map(|chunk| { + let mut buf = [0u8; 8]; + buf[..chunk.len()].copy_from_slice(chunk); + u64::from_le_bytes(buf) + }) + .collect::>(); + Ok(ObsMask::from_words(&words)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn index_normalization_checks_both_ends() { + let flips = PyObservableFlips::from_u8_bits(&[1, 0]); + + assert_eq!(flips.normalize_index(0).unwrap(), 0); + assert_eq!(flips.normalize_index(-1).unwrap(), 1); + assert!(flips.normalize_index(2).is_err()); + assert!(flips.normalize_index(-3).is_err()); + } + + #[test] + fn equality_includes_length() { + let short = PyObservableFlips::from_mask_value(ObsMask::from_u64(1), 1); + let long = PyObservableFlips::from_mask_value(ObsMask::from_u64(1), 2); + + assert!(!short.value_eq(&long)); + } + + #[test] + fn mask_validation_rejects_bits_outside_length() { + let mask = ObsMask::from_u64(4); + let error = PyObservableFlips::validate_mask(&mask, "4", 2).unwrap_err(); + + assert!(error.contains("mask=4")); + assert!(error.contains("num_observables=2")); + } +} diff --git a/python/quantum-pecos/src/pecos/decoders/__init__.py b/python/quantum-pecos/src/pecos/decoders/__init__.py index 2748fb254..cf2637c5a 100644 --- a/python/quantum-pecos/src/pecos/decoders/__init__.py +++ b/python/quantum-pecos/src/pecos/decoders/__init__.py @@ -29,6 +29,7 @@ MinSumBpBuilder, MinSumBpDecoder, MwpmResult, + ObservableFlips, PyMatchingDecoder, RelayBpBuilder, RelayBpDecoder, @@ -57,6 +58,7 @@ "MinSumBpBuilder", "MinSumBpDecoder", "MwpmResult", + "ObservableFlips", "PyMatchingDecoder", "RelayBpBuilder", "RelayBpDecoder", diff --git a/python/quantum-pecos/tests/qec/test_observable_flips.py b/python/quantum-pecos/tests/qec/test_observable_flips.py new file mode 100644 index 000000000..3fb2491fb --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_observable_flips.py @@ -0,0 +1,215 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. + +"""Uniform observable-flip values across decoder predictions and sampled truth.""" + +from __future__ import annotations + +import pytest +from pecos_rslib.decoders import ( + BpOsdBuilder, + BpOsdDecoder, + DemAwareDecoder, + ObservableFlips, + PyMatchingDecoder, + SparseMatrix, + TesseractDecoder, +) +from pecos_rslib.qec import ( + ObservableFlips as QecObservableFlips, +) +from pecos_rslib.qec import ( + SampleBatch, +) + +_ONE_OBSERVABLE_DEM = """detector D0 +detector D1 +logical_observable L0 +error(0.1) D0 +error(0.1) D1 L0 +""" + + +def _wide_dem(num_observables: int = 71) -> str: + lines = ["error(0.1) D0 L0", "error(0.1) D1 L70", "detector D0", "detector D1"] + lines += [f"logical_observable L{index}" for index in range(num_observables)] + return "\n".join(lines) + + +def test_same_observable_flips_type_is_exported_from_both_namespaces() -> None: + assert QecObservableFlips is ObservableFlips + + +def test_indexing_iteration_indices_mask_and_repr() -> None: + flips = ObservableFlips.from_mask(0b101, 3) + + assert len(flips) == 3 + assert [flips[index] for index in range(len(flips))] == [True, False, True] + assert flips[-1] is True + assert flips[-2] is False + assert flips[-3] is True + assert list(flips) == [True, False, True] + assert flips.indices() == [0, 2] + assert flips.mask == 0b101 + assert repr(flips) == "ObservableFlips(num_observables=3, mask=5)" + + for index in (3, -4): + with pytest.raises(IndexError) as error: + _ = flips[index] + assert str(index) in str(error.value) + assert "num_observables=3" in str(error.value) + + +def test_equality_requires_same_type_bits_and_length() -> None: + flips = ObservableFlips.from_mask(1, 2) + + assert flips == ObservableFlips.from_bits([True, False]) + assert flips != ObservableFlips.from_bits([False, True]) + assert flips != ObservableFlips.from_mask(1, 3) + assert flips.__eq__([True, False]) is NotImplemented + assert flips.__eq__(1) is NotImplemented + assert (flips == [True, False]) is False + assert (flips == 1) is False + + +def test_from_mask_rejects_high_bits_and_round_trips() -> None: + with pytest.raises(ValueError, match=rf"mask={1 << 5}.*num_observables=2"): + ObservableFlips.from_mask(1 << 5, 2) + + mask = (1 << 70) | (1 << 2) + flips = ObservableFlips.from_mask(mask, 71) + assert flips.mask == mask + assert flips[70] is True + assert flips[69] is False + + +def test_constructors_accept_integer_like_values() -> None: + """The accessors this type bridges from hand back ints, and masks arrive as NumPy scalars. + + Both constructors go through ``__index__``, so ``int``, ``bool`` and NumPy + integer scalars are all accepted on the same footing. + """ + numpy = pytest.importorskip("numpy") + + # ``MwpmResult.correction`` and ``TesseractResult.observable_bits`` return list[int]. + assert ObservableFlips.from_bits([1, 0, 1]) == ObservableFlips.from_mask(0b101, 3) + assert ObservableFlips.from_bits([True, 0, 1]) == ObservableFlips.from_mask(0b101, 3) + assert ObservableFlips.from_bits(list(numpy.array([1, 0, 1]))) == ObservableFlips.from_mask(0b101, 3) + assert ObservableFlips.from_bits([numpy.True_, numpy.False_]) == ObservableFlips.from_mask(0b01, 2) + + assert ObservableFlips.from_mask(numpy.uint64(5), 3) == ObservableFlips.from_mask(5, 3) + assert ObservableFlips.from_mask(numpy.int64(5), 3) == ObservableFlips.from_mask(5, 3) + + +def test_constructors_reject_non_integers_and_non_bits() -> None: + # Truthiness is never used: a non-bit integer is an error, not something to coerce. + with pytest.raises(ValueError, match="bit at index 1 must be 0 or 1, got 2"): + ObservableFlips.from_bits([1, 2, 0]) + + # A missing __index__ is "not an integer", which Python reports as TypeError. + with pytest.raises(TypeError, match="cannot be interpreted as an integer"): + ObservableFlips.from_bits(["a"]) + with pytest.raises(TypeError, match="cannot be interpreted as an integer"): + ObservableFlips.from_mask("x", 3) + + with pytest.raises(ValueError, match="mask=-1 is negative"): + ObservableFlips.from_mask(-1, 3) + + +def test_from_bits_accepts_an_iterable_and_indices_agree_with_getitem() -> None: + bits = [False, True, False, True] + flips = ObservableFlips.from_bits(bit for bit in bits) + + assert list(flips) == bits + assert flips.indices() == [index for index in range(len(flips)) if flips[index]] + + +def test_sample_batch_observable_metadata_and_shot_bounds() -> None: + batch = SampleBatch([[0], [1]], [0, 3]) + + assert batch.num_shots == 2 + assert batch.num_observables == 2 + assert batch.observable_flips(1) == ObservableFlips.from_mask(3, 2) + with pytest.raises(IndexError, match=r"Shot index 2.*num_shots=2"): + batch.observable_flips(2) + + +def test_uniform_loop_preserves_one_observable_error_counts() -> None: + syndromes = [[0, 0], [1, 0], [0, 1], [1, 1]] + batch = SampleBatch(syndromes, [0, 1, 0, 1]) + decoders = { + "pymatching": PyMatchingDecoder.from_dem(_ONE_OBSERVABLE_DEM), + "tesseract": TesseractDecoder.from_dem(_ONE_OBSERVABLE_DEM), + "bp_osd": BpOsdDecoder.from_dem(_ONE_OBSERVABLE_DEM), + } + old_counts = dict.fromkeys(decoders, 0) + new_counts = dict.fromkeys(decoders, 0) + + for shot in range(batch.num_shots): + syndrome = batch.get_syndrome(shot) + actual_mask = batch.get_observable_mask(shot) & 1 + actual_flips = batch.observable_flips(shot) + results = {name: decoder.decode_syndrome(syndrome) for name, decoder in decoders.items()} + + old_counts["pymatching"] += results["pymatching"].correction[0] != actual_mask + old_counts["tesseract"] += results["tesseract"].observables_mask & 1 != actual_mask + old_counts["bp_osd"] += results["bp_osd"].observables_mask & 1 != actual_mask + for name, result in results.items(): + new_counts[name] += result.observable_flips != actual_flips + + assert old_counts == new_counts == {"pymatching": 2, "tesseract": 2, "bp_osd": 2} + + +def test_any_observable_and_per_observable_counts_are_distinct() -> None: + batch = SampleBatch([[], []], [0, 3]) + predictions = [ObservableFlips.from_mask(1, 2), ObservableFlips.from_mask(1, 2)] + any_observable_errors = 0 + per_observable_errors = [0] * batch.num_observables + + for shot, predicted in enumerate(predictions): + actual = batch.observable_flips(shot) + any_observable_errors += predicted != actual + for index in range(batch.num_observables): + per_observable_errors[index] += predicted[index] != actual[index] + + assert any_observable_errors == 2 + assert per_observable_errors == [1, 1] + + +def test_wide_observables_are_not_truncated_end_to_end() -> None: + dem = _wide_dem() + syndrome = [0, 1] + batch = SampleBatch([syndrome], [1 << 70]) + actual = batch.observable_flips(0) + decoders = [ + PyMatchingDecoder.from_dem(dem), + DemAwareDecoder.from_dem(dem, decoder_type="bp_osd"), + ] + + assert batch.num_observables == 71 + assert actual.mask == 1 << 70 + assert actual[70] is True + assert actual[6] is False + for decoder in decoders: + predicted = decoder.decode_syndrome(syndrome).observable_flips + assert predicted == actual + assert len(predicted) == 71 + assert predicted.mask == 1 << 70 + assert predicted[70] is True + assert predicted[6] is False + + +def test_bp_result_does_not_fabricate_observable_flips() -> None: + decoder = BpOsdBuilder(SparseMatrix([[1]]), error_rate=0.1).build() + result = decoder.decode_syndrome([0]) + + assert not hasattr(result, "observable_flips") From 5f0a45835e201934c838f422d1b9e5c8fe7a98d7 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 8 Aug 2026 17:49:09 -0600 Subject: [PATCH 60/62] Apply blackdoc formatting to the type stub --- python/pecos-rslib/pecos_rslib.pyi | 1 - 1 file changed, 1 deletion(-) diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index ba1909bf7..216901c59 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -2057,7 +2057,6 @@ class ObservableFlips: @staticmethod def from_bits(bits: Iterable[SupportsIndex]) -> ObservableFlips: ... - class qec: """Fault-tolerance and detector-error-model submodule.""" From 31f7ca3aeb44d60958aba675d920743f7b4ec98d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 8 Aug 2026 20:26:34 -0600 Subject: [PATCH 61/62] Collapse the observable-flip API onto ObservableFlips and remove the superseded spellings --- docs/user-guide/decoders.md | 2 +- docs/user-guide/dem-from-guppy.md | 8 +- docs/workflows/guppy-dem-decoding.md | 9 +-- .../surface/dem_decomposition_diagnostics.py | 2 +- examples/surface/ml_lookup_decoder.py | 4 +- .../surface/native_dem_threshold_sweep.py | 10 +-- examples/surface_code_noisy_decoding.ipynb | 8 +- examples/surface_code_thresholds.ipynb | 8 +- python/pecos-rslib/pecos_rslib.pyi | 10 --- python/pecos-rslib/src/decoder_bindings.rs | 74 +++++-------------- .../src/fault_tolerance_bindings.rs | 62 ---------------- .../src/observable_flips_bindings.rs | 5 +- .../src/pecos/qec/surface/decode.py | 22 +++--- .../pecos/decoders/test_decoder_bindings.py | 18 ++--- ...test_logical_subgraph_region_comparison.py | 2 +- .../tests/qec/test_decoder_surface_defects.py | 18 ++--- .../tests/qec/test_dem_aware_decoder_width.py | 4 +- .../tests/qec/test_from_guppy_dem.py | 2 +- .../tests/qec/test_observable_flips.py | 62 +++++++++++++--- .../tests/qec/test_sample_batch.py | 12 +-- .../qec/test_traced_qis_slow_integration.py | 2 +- .../tests/qec/test_wide_observables.py | 14 ++-- scripts/compare_meas_sampling_pipeline.py | 2 +- 23 files changed, 144 insertions(+), 216 deletions(-) diff --git a/docs/user-guide/decoders.md b/docs/user-guide/decoders.md index 955c63f84..177a58a20 100644 --- a/docs/user-guide/decoders.md +++ b/docs/user-guide/decoders.md @@ -53,7 +53,7 @@ Python decoder inputs name their encoding explicitly: use `decode_syndrome(...)` for a dense detector vector and `decode_from_defects(...)` for sparse detector indices. The BP/LDPC classes' `from_dem(...)` constructors return a `DemAwareDecoder` wrapper so their results -include `observables_mask` and their instances retain the DEM dimensions. +include `observable_flips` and their instances retain the DEM dimensions. ### Rust Decoders diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index fec2d350e..1a9156683 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -183,8 +183,8 @@ assert batch.num_shots == 1000 decoder = PyMatchingDecoder.from_dem(dem.to_string_decomposed()) errors = 0 for shot in range(batch.num_shots): - predicted = decoder.decode_syndrome(batch.get_syndrome(shot)).correction[0] - actual = batch.get_observable_mask(shot) & 1 + predicted = decoder.decode_syndrome(batch.get_syndrome(shot)).observable_flips[0] + actual = batch.get_observable_flips(shot)[0] errors += predicted != actual print(f"logical error rate: {errors / batch.num_shots:.4f}") ``` @@ -677,11 +677,11 @@ print(error_counts) syndrome = batch.get_syndrome(0) tesseract = TesseractDecoder.from_dem(dem.to_string(), preset="fast") tesseract_result = tesseract.decode_syndrome(syndrome) -assert tesseract_result.observables_mask >= 0 +assert tesseract_result.observable_flips.mask >= 0 bp_osd = BpOsdDecoder.from_dem(dem.to_string()) bp_osd_result = bp_osd.decode_syndrome(syndrome) -assert bp_osd_result.observables_mask >= 0 +assert bp_osd_result.observable_flips.mask >= 0 ``` For direct PyMatching construction, use the diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md index 1364de519..275264c02 100644 --- a/docs/workflows/guppy-dem-decoding.md +++ b/docs/workflows/guppy-dem-decoding.md @@ -193,7 +193,7 @@ assert all("error(" in text for text in (raw_text, terminal_graphlike_text, sour `to_sampler()` draws detector events and observable flips directly from the error model, without simulating the circuit. `get_syndrome()` returns one shot's -detector bits and `get_observable_mask()` the actual logical flips those shots +detector bits and `get_observable_flips()` the actual logical flips that shot incurred — the ground truth that decoder predictions are scored against. @@ -204,7 +204,7 @@ batch = sampler.sample_batch(2000, seed=1) assert batch.num_shots == 2000 for shot in range(2): syndrome = batch.get_syndrome(shot) - observable_mask = batch.get_observable_mask(shot) + observable_mask = batch.get_observable_flips(shot).mask assert len(syndrome) == dem.num_detectors print(f"shot {shot}: syndrome={syndrome}, observable_mask={observable_mask}") ``` @@ -296,9 +296,8 @@ prediction per shot. A shot counts as a logical error when the predicted observable flip disagrees with the flip the sample actually carried. The result types reconcile their underlying shapes through `observable_flips`. -PyMatching's per-observable `correction` vector and Tesseract and BP+OSD's -`observables_mask` bitmasks remain available when the underlying representation -is useful. +Its sequence interface exposes per-observable booleans, while `.mask` exposes +the same flips as an arbitrary-precision integer. ```python diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py index d7938f8ef..ead3721f6 100644 --- a/examples/surface/dem_decomposition_diagnostics.py +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -377,7 +377,7 @@ def tesseract_predictions(dem_text: str, detection_events: np.ndarray, *, beam: det_beam=beam, ) results = decoder.decode_batch([row.tolist() for row in detection_events]) - return np.array([int(result.observables_mask & 1) for result in results], dtype=np.uint8) + return np.array([int(result.observable_flips[0]) for result in results], dtype=np.uint8) def pymatching_predictions(dem_text: str, detection_events: np.ndarray, *, correlated: bool) -> np.ndarray: diff --git a/examples/surface/ml_lookup_decoder.py b/examples/surface/ml_lookup_decoder.py index 5e32bbba9..9f866d104 100644 --- a/examples/surface/ml_lookup_decoder.py +++ b/examples/surface/ml_lookup_decoder.py @@ -30,7 +30,7 @@ def build_lookup_table(batch, num_detectors: int) -> dict[tuple[int, ...], int]: for i in range(batch.num_shots): syn = batch.get_syndrome(i) - obs = batch.get_observable_mask(i) + obs = batch.get_observable_flips(i).mask # Convert syndrome to tuple of fired detector indices fired = tuple(d for d in range(min(num_detectors, len(syn))) if syn[d]) @@ -50,7 +50,7 @@ def decode_with_lookup(batch, table: dict, num_detectors: int) -> tuple[int, int errors = 0 for i in range(batch.num_shots): syn = batch.get_syndrome(i) - obs_true = batch.get_observable_mask(i) + obs_true = batch.get_observable_flips(i).mask fired = tuple(d for d in range(min(num_detectors, len(syn))) if syn[d]) predicted = table.get(fired, 0) # default: no correction diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index ff74f9ffd..6fc3de401 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -283,13 +283,9 @@ def _backend_runtime_label(sample_backend: str, native_circuit_source: str = "ab raise ValueError(msg) -def _predicted_observable_flip(result: object) -> int: +def _predicted_observable_flip(result: Any) -> int: """Extract the predicted logical observable flip from a DEM decoder result.""" - observables_mask = getattr(result, "observables_mask", None) - if observables_mask is not None: - return int(observables_mask & 1) - correction = getattr(result, "correction", []) - return int(correction[0]) if len(correction) > 0 else 0 + return int(result.observable_flips[0]) def _format_rate(value: float | None) -> str: @@ -633,7 +629,7 @@ def _decode_all_shots( batch_results = dem_decoder.decode_batch(syndromes) num_errors = 0 for shot_idx, result in enumerate(batch_results): - predicted_flip = int(result.observables_mask & 1) + predicted_flip = int(result.observable_flips[0]) num_errors += int(predicted_flip != true_flips[shot_idx]) return num_errors diff --git a/examples/surface_code_noisy_decoding.ipynb b/examples/surface_code_noisy_decoding.ipynb index b078c2edc..dd1fa193b 100644 --- a/examples/surface_code_noisy_decoding.ipynb +++ b/examples/surface_code_noisy_decoding.ipynb @@ -1150,8 +1150,8 @@ "\n", "Tesseract decoder: TesseractDecoder(detectors=120, errors=1679, observables=1)\n", "\n", - "Empty syndrome decode: observables_mask=0, cost=0.0\n", - "Detectors [0,8] fired: observables_mask=0, cost=7.4810164852089\n" + "Empty syndrome decode: observable_flips.mask=0, cost=0.0\n", + "Detectors [0,8] fired: observable_flips.mask=0, cost=7.4810164852089\n" ] } ], @@ -1189,11 +1189,11 @@ "\n", "# Example decode with empty syndrome (no errors)\n", "result = tesseract.decode_from_defects([]) # No detectors fired\n", - "print(f\"Empty syndrome decode: observables_mask={result.observables_mask}, cost={result.cost}\")\n", + "print(f\"Empty syndrome decode: observable_flips.mask={result.observable_flips.mask}, cost={result.cost}\")\n", "\n", "# Example decode with some detection events\n", "result = tesseract.decode_from_defects([0, 8]) # Detectors 0 and 8 fired\n", - "print(f\"Detectors [0,8] fired: observables_mask={result.observables_mask}, cost={result.cost}\")" + "print(f\"Detectors [0,8] fired: observable_flips.mask={result.observable_flips.mask}, cost={result.cost}\")" ] }, { diff --git a/examples/surface_code_thresholds.ipynb b/examples/surface_code_thresholds.ipynb index 16fa901e2..a5e28938a 100644 --- a/examples/surface_code_thresholds.ipynb +++ b/examples/surface_code_thresholds.ipynb @@ -178,18 +178,18 @@ "\n", " if decoder_type == \"pymatching\":\n", " result = decoder.decode_syndrome(events.astype(np.uint8).tolist())\n", - " predicted_flip = result.correction[0] if len(result.correction) > 0 else 0\n", + " predicted_flip = result.observable_flips[0] if len(result.observable_flips) > 0 else 0\n", " elif decoder_type == \"fusion_blossom\":\n", " result = decoder.decode_syndrome(events.astype(np.uint8).tolist())\n", - " predicted_flip = result.correction[0] if len(result.correction) > 0 else 0\n", + " predicted_flip = result.observable_flips[0] if len(result.observable_flips) > 0 else 0\n", " decoder.clear()\n", " elif decoder_type == \"tesseract\":\n", " detection_indices = [j for j, v in enumerate(events) if v]\n", " result = decoder.decode_from_defects(detection_indices)\n", - " predicted_flip = result.observables_mask & 1\n", + " predicted_flip = result.observable_flips.mask & 1\n", " elif decoder_type == \"bp_osd\":\n", " result = decoder.decode_syndrome(events.astype(np.uint8).tolist())\n", - " predicted_flip = result.observables_mask & 1\n", + " predicted_flip = result.observable_flips.mask & 1\n", " else:\n", " msg = f\"Unknown decoder type: {decoder_type}\"\n", " raise ValueError(msg)\n", diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index 216901c59..e22878eb2 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -2213,8 +2213,6 @@ class qec: @property def num_observables(self) -> int: ... def get_syndrome(self, i: int) -> list[int]: ... - def get_observable_mask(self, i: int) -> int: ... - def get_observable_mask_wide(self, i: int) -> int: ... def get_observable_flips(self, i: int) -> ObservableFlips: ... def detector_events(self) -> list[list[bool]]: ... def observable_flips(self) -> list[list[bool]]: ... @@ -2580,7 +2578,6 @@ class decoders: def converged(self) -> bool: ... @property def iterations(self) -> int: ... - def to_list(self) -> list[int]: ... def __repr__(self) -> str: ... def __len__(self) -> int: ... def __getitem__(self, idx: int) -> int: ... @@ -2608,8 +2605,6 @@ class decoders: class MwpmResult: """Result from MWPM decoders.""" - @property - def correction(self) -> list[int]: ... @property def observable_flips(self) -> ObservableFlips: ... def __repr__(self) -> str: ... @@ -2884,12 +2879,9 @@ class decoders: @property def observable_flips(self) -> ObservableFlips: ... @property - def observables_mask(self) -> int: ... - @property def cost(self) -> float: ... @property def low_confidence(self) -> bool: ... - def observable_bits(self, num_observables: int) -> list[int]: ... def __repr__(self) -> str: ... class TesseractDecoder: @@ -2935,8 +2927,6 @@ class decoders: @property def observable_flips(self) -> ObservableFlips: ... @property - def observables_mask(self) -> int: ... - @property def converged(self) -> bool: ... @property def iterations(self) -> int: ... diff --git a/python/pecos-rslib/src/decoder_bindings.rs b/python/pecos-rslib/src/decoder_bindings.rs index 10a64c958..252676726 100644 --- a/python/pecos-rslib/src/decoder_bindings.rs +++ b/python/pecos-rslib/src/decoder_bindings.rs @@ -65,7 +65,7 @@ fn explicit_decode_attribute_error(class_name: &str, name: &str) -> PyErr { /// /// # Attributes /// -/// * `correction` - The decoded correction/observable flip (list of 0/1 for each observable) +/// * `observable_flips` - The decoded observable flips /// * `weight` - Total weight of the matching (lower is better) /// /// # Example @@ -73,7 +73,7 @@ fn explicit_decode_attribute_error(class_name: &str, name: &str) -> PyErr { /// ```python /// result = decoder.decode_syndrome(syndrome) /// if result.weight < threshold: -/// apply_correction(result.correction) +/// apply_correction(result.observable_flips) /// ``` #[pyclass( name = "MwpmResult", @@ -91,28 +91,15 @@ pub struct PyMwpmResult { #[pymethods] impl PyMwpmResult { - /// The decoded correction (observable flips) as a Python list. - #[getter] - fn correction(&self) -> Vec { - self.correction_data.iter().map(|&x| i32::from(x)).collect() - } - /// The decoded observable flips with their intrinsic observable count. #[getter] fn observable_flips(&self) -> PyObservableFlips { PyObservableFlips::from_u8_bits(&self.correction_data) } - /// Get the correction as a list (alias for correction attribute). - /// - /// This mirrors `PyMatching`'s `decode()` return value. - fn to_list(&self) -> Vec { - self.correction() - } - fn __repr__(&self) -> String { format!( - "MwpmResult(correction={:?}, weight={:.4})", + "MwpmResult(observable_flips={:?}, weight={:.4})", self.correction_data, self.weight ) } @@ -176,11 +163,6 @@ impl PyBpResult { self.decoding_data.iter().map(|&x| i32::from(x)).collect() } - /// Get the decoding as a list. - fn to_list(&self) -> Vec { - self.decoding() - } - fn __repr__(&self) -> String { format!( "BpResult(converged={}, iterations={}, decoding_len={})", @@ -376,7 +358,7 @@ impl PyCheckMatrix { /// ```python /// syndrome = [1, 0] # Detection events /// result = decoder.decode_syndrome(syndrome) -/// print(f"Correction: {result.correction}, Weight: {result.weight}") +/// print(f"Observable flips: {list(result.observable_flips)}, Weight: {result.weight}") /// ``` // Note: unsendable because contains FFI pointers (cxx UniquePtr) #[pyclass( @@ -562,14 +544,14 @@ impl PyPyMatchingDecoder { /// /// # Returns /// - /// `MwpmResult` with correction vector and matching weight. + /// `MwpmResult` with observable flips and matching weight. /// /// # Example /// /// ```python /// syndrome = [1, 0, 1, 0] /// result = decoder.decode_syndrome(syndrome) - /// correction = result.correction # Observable flips to apply + /// observable_flips = result.observable_flips /// ``` fn decode_syndrome(&mut self, syndrome: Vec) -> PyResult { self.inner @@ -594,8 +576,8 @@ impl PyPyMatchingDecoder { /// # Returns /// /// List of observable predictions (one per shot), where each prediction - /// is a list of 0/1 values (one per observable). Use `observables_mask` - /// property on each element or just check index 0 for single-observable codes. + /// is a list of 0/1 values (one per observable). Check index 0 for + /// single-observable codes. /// /// # Example /// @@ -950,7 +932,7 @@ impl PyFusionBlossomDecoder { /// /// # Returns /// - /// `MwpmResult` with correction and weight. + /// `MwpmResult` with observable flips and weight. fn decode_syndrome(&mut self, syndrome: Vec) -> PyResult { let arr = Array1::from_vec(syndrome); self.inner @@ -1950,7 +1932,7 @@ fn tesseract_config( /// /// # Attributes /// -/// * `observables_mask` - Bitwise XOR of observables affected by predicted errors +/// * `observable_flips` - Observables affected by predicted errors /// * `cost` - Total cost of the solution /// * `low_confidence` - Whether this is a low-confidence prediction #[pyclass( @@ -1960,7 +1942,6 @@ fn tesseract_config( )] #[derive(Clone)] pub struct PyTesseractResult { - #[pyo3(get)] observables_mask: u64, #[pyo3(get)] cost: f64, @@ -1980,17 +1961,10 @@ impl PyTesseractResult { ) } - /// Get the observable predictions as a list of bits. - fn observable_bits(&self, num_observables: usize) -> Vec { - (0..num_observables) - .map(|i| ((self.observables_mask >> i) & 1) as i32) - .collect() - } - fn __repr__(&self) -> String { format!( - "TesseractResult(observables_mask={}, cost={:.4}, low_confidence={})", - self.observables_mask, self.cost, self.low_confidence + "TesseractResult(observable_flips=ObservableFlips(num_observables={}, mask={}), cost={:.4}, low_confidence={})", + self.num_observables, self.observables_mask, self.cost, self.low_confidence ) } } @@ -2023,7 +1997,7 @@ impl PyTesseractResult { /// # Detection events as list of detector indices that fired /// detection_indices = [0, 2] /// result = decoder.decode_from_defects(detection_indices) -/// print(f"Observable mask: {result.observables_mask}, Cost: {result.cost}") +/// print(f"Observable mask: {result.observable_flips.mask}, Cost: {result.cost}") /// ``` #[pyclass(name = "TesseractDecoder", module = "pecos_rslib.decoders", unsendable)] pub struct PyTesseractDecoder { @@ -2098,14 +2072,14 @@ impl PyTesseractDecoder { /// /// # Returns /// - /// `TesseractResult` with observables mask, cost, and confidence info. + /// `TesseractResult` with observable flips, cost, and confidence info. /// /// # Example /// /// ```python /// # Detectors 0 and 2 fired /// result = decoder.decode_from_defects([0, 2]) - /// print(f"Observable prediction: {result.observable_bits(1)}") + /// print(f"Observable prediction: {list(result.observable_flips)}") /// ``` fn decode_from_defects(&mut self, detections: Vec) -> PyResult { let detections_arr = ndarray::Array1::from_vec(detections); @@ -2788,7 +2762,7 @@ impl DemDecoderConfig { /// /// Parses a DEM string, extracts the check matrix and observable matrix, /// creates the inner decoder, and provides `decode_syndrome()` that returns -/// an `observables_mask` -- the same interface as `PyMatching` and Tesseract. +/// `observable_flips` -- the same interface as `PyMatching` and Tesseract. /// /// # Example /// @@ -2797,7 +2771,7 @@ impl DemDecoderConfig { /// /// decoder = DemAwareDecoder.from_dem(dem_string, decoder_type="bp_osd") /// result = decoder.decode_syndrome([0, 1, 1, 0]) -/// print(f"Observable prediction: {result.observables_mask}") +/// print(f"Observable prediction: {result.observable_flips}") /// ``` #[pyclass(name = "DemAwareDecoder", module = "pecos_rslib.decoders", unsendable)] pub struct PyDemAwareDecoder { @@ -2960,15 +2934,6 @@ impl PyDemAwareResult { #[pymethods] impl PyDemAwareResult { - /// Bitmask of predicted observable flips. - /// - /// A Python integer of arbitrary precision: DEMs with at most 64 - /// observables yield exactly the value the previous `u64` field held. - #[getter] - fn observables_mask(&self, py: Python<'_>) -> PyResult> { - crate::observable_flips_bindings::obsmask_to_py(py, &self.observables) - } - /// The decoded observable flips with the decoder's observable count. #[getter] fn observable_flips(&self) -> PyObservableFlips { @@ -2977,7 +2942,8 @@ impl PyDemAwareResult { fn __repr__(&self) -> String { format!( - "DemAwareResult(observables_mask={}, converged={}, iterations={})", + "DemAwareResult(observable_flips=ObservableFlips(num_observables={}, mask={}), converged={}, iterations={})", + self.num_observables, self.mask_display(), self.converged, self.iterations @@ -3043,7 +3009,7 @@ impl PyDemAwareDecoder { /// /// # Returns /// - /// `DemAwareResult` with `observables_mask`, `converged`, and `iterations`. + /// `DemAwareResult` with `observable_flips`, `converged`, and `iterations`. fn decode_syndrome(&mut self, syndrome: Vec) -> PyResult { let arr = Array1::from_vec(syndrome); let (decoding, converged, iterations) = match &mut self.inner { diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 0d5ad9f47..491b030ab 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -3445,23 +3445,6 @@ impl PySampleBatch { } } - /// Reject a batch that cannot be represented by the legacy `u64` observable - /// APIs (more than 64 observable columns). Callers with >64 observables must - /// use the wide `LogicalSubgraphDecoder` decode/decode_count paths, which - /// return arbitrary-precision Python ints. Call this up front in every - /// `u64`-returning public method before [`Self::extract_obs_mask`]. - fn ensure_narrow_observables(&self) -> PyResult<()> { - if self.obs_columns.len() > 64 { - return Err(pyo3::exceptions::PyValueError::new_err(format!( - "SampleBatch has {} observable columns, exceeding the 64-observable limit of \ - this u64-based API; use the wide LogicalSubgraphDecoder decode/decode_count \ - paths (arbitrary-precision int) for more than 64 observables", - self.obs_columns.len() - ))); - } - Ok(()) - } - /// Reject raw-measurement batches before treating their rows as syndromes. fn ensure_detector_events(&self) -> PyResult<()> { if self.raw_measurements { @@ -3472,27 +3455,6 @@ impl PySampleBatch { Ok(()) } - /// Extract observable mask for one shot (`u64`; observables 0..=63 only). - /// - /// The caller must have rejected wide batches via - /// [`Self::ensure_narrow_observables`] first; with >64 observable columns the - /// `1u64 << obs_idx` below would overflow. - fn extract_obs_mask(&self, shot: usize) -> u64 { - debug_assert!( - self.obs_columns.len() <= 64, - "extract_obs_mask requires <=64 observable columns; call ensure_narrow_observables first" - ); - let word_idx = shot / 64; - let bit_mask = 1u64 << (shot % 64); - let mut mask = 0u64; - for (obs_idx, col) in self.obs_columns.iter().enumerate() { - if col[word_idx] & bit_mask != 0 { - mask |= 1u64 << obs_idx; - } - } - mask - } - /// Extract the observable mask for one shot as a wide [`ObsMask`], with no /// 64-observable cap (the columnar storage already supports >64 columns). fn extract_obs_mask_wide(&self, shot: usize) -> pecos_decoder_core::obs_mask::ObsMask { @@ -3726,30 +3688,6 @@ impl PySampleBatch { Ok(buf) } - /// Get the expected observable mask for shot `i` (`u64`; <=64 observables). - fn get_observable_mask(&self, i: usize) -> PyResult { - self.ensure_narrow_observables()?; - if i >= self.num_shots { - return Err(PyErr::new::(format!( - "Shot index {i} out of range (num_shots={})", - self.num_shots - ))); - } - Ok(self.extract_obs_mask(i)) - } - - /// Observable mask for shot `i` as a Python ``int`` (arbitrary precision, so - /// more than 64 observables are not truncated). - fn get_observable_mask_wide(&self, py: Python<'_>, i: usize) -> PyResult> { - if i >= self.num_shots { - return Err(PyErr::new::(format!( - "Shot index {i} out of range (num_shots={})", - self.num_shots - ))); - } - obsmask_to_py(py, &self.extract_obs_mask_wide(i)) - } - /// Return all detector events as shots-major boolean lists. /// /// The result has shape (`num_shots`, `num_detectors`). diff --git a/python/pecos-rslib/src/observable_flips_bindings.rs b/python/pecos-rslib/src/observable_flips_bindings.rs index 55e7620d3..f4c65ce1e 100644 --- a/python/pecos-rslib/src/observable_flips_bindings.rs +++ b/python/pecos-rslib/src/observable_flips_bindings.rs @@ -165,9 +165,8 @@ impl PyObservableFlips { /// /// This is the protocol Python itself uses wherever an integer is required, so /// `bool` and NumPy integer scalars are accepted on the same footing as `int`. -/// It matters here because the existing accessors this type bridges from -- -/// `MwpmResult.correction` and `TesseractResult.observable_bits` -- hand back -/// integers, and observable masks routinely arrive as NumPy scalars. +/// This also accepts values returned by integer-oriented libraries, while +/// observable masks routinely arrive as NumPy scalars. fn as_index<'py>(value: &Bound<'py, PyAny>) -> PyResult> { match value.call_method0("__index__") { Ok(index) => Ok(index), diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index f3674ca66..d6dc65c63 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -3018,11 +3018,11 @@ def decode_z_syndrome( # Tesseract takes sparse detection indices detection_indices = [i for i, v in enumerate(events_flat) if v != 0] result = decoder.decode_from_defects(detection_indices) - # Tesseract returns observables_mask, not per-qubit correction + # Tesseract returns observable flips, not per-qubit correction # We return a dummy correction and encode logical flip in first element num_data = self._get_z_check_matrix().shape[1] correction = np.zeros(num_data, dtype=np.uint8) - if result.observables_mask & 1: # L0 flipped + if len(result.observable_flips) > 0 and result.observable_flips[0]: # L0 flipped correction[0] = 1 # Mark that logical was predicted flipped weight = result.cost else: @@ -3032,7 +3032,7 @@ def decode_z_syndrome( if self.decoder_type == DecoderType.FUSION_BLOSSOM: decoder.clear() - correction = np.array(result.correction, dtype=np.uint8) + correction = np.array(list(result.observable_flips), dtype=np.uint8) weight = result.weight else: # LDPC: use raw syndrome (last round) @@ -3080,10 +3080,10 @@ def decode_x_syndrome( # Tesseract takes sparse detection indices detection_indices = [i for i, v in enumerate(events_flat) if v != 0] result = decoder.decode_from_defects(detection_indices) - # Tesseract returns observables_mask, not per-qubit correction + # Tesseract returns observable flips, not per-qubit correction num_data = self._get_x_check_matrix().shape[1] correction = np.zeros(num_data, dtype=np.uint8) - if result.observables_mask & 1: # L0 flipped + if len(result.observable_flips) > 0 and result.observable_flips[0]: # L0 flipped correction[0] = 1 # Mark that logical was predicted flipped weight = result.cost else: @@ -3093,7 +3093,7 @@ def decode_x_syndrome( if self.decoder_type == DecoderType.FUSION_BLOSSOM: decoder.clear() - correction = np.array(result.correction, dtype=np.uint8) + correction = np.array(list(result.observable_flips), dtype=np.uint8) weight = result.weight else: # LDPC: use raw syndrome (last round) @@ -3294,11 +3294,11 @@ def decode_memory_z( if self.decoder_type == DecoderType.TESSERACT: detection_indices = [i for i, v in enumerate(events_flat) if v != 0] result = decoder.decode_from_defects(detection_indices) - predicted_obs = result.observables_mask & 1 + predicted_obs = result.observable_flips[0] if len(result.observable_flips) > 0 else 0 weight = result.cost else: result = decoder.decode_syndrome(events_flat.tolist()) - predicted_obs = result.correction[0] if len(result.correction) > 0 else 0 + predicted_obs = result.observable_flips[0] if len(result.observable_flips) > 0 else 0 weight = result.weight corrected_parity = (final_parity + predicted_obs) % 2 @@ -3390,11 +3390,11 @@ def decode_memory_x( if self.decoder_type == DecoderType.TESSERACT: detection_indices = [i for i, v in enumerate(events_flat) if v != 0] result = decoder.decode_from_defects(detection_indices) - predicted_obs = result.observables_mask & 1 + predicted_obs = result.observable_flips[0] if len(result.observable_flips) > 0 else 0 weight = result.cost else: result = decoder.decode_syndrome(events_flat.tolist()) - predicted_obs = result.correction[0] if len(result.correction) > 0 else 0 + predicted_obs = result.observable_flips[0] if len(result.observable_flips) > 0 else 0 weight = result.weight corrected_parity = (final_parity + predicted_obs) % 2 @@ -3664,7 +3664,7 @@ def surface_code_memory( max_hosted_tick_separation=max_hosted_tick_separation, ) batch = ParsedDem.from_string(dem).to_dem_sampler().sample_batch(shots, seed) - num_raw_errors = sum(1 for shot in range(shots) if batch.get_observable_mask(shot) != 0) + num_raw_errors = sum(1 for shot in range(shots) if batch.get_observable_flips(shot).mask != 0) num_logical_errors = batch.decode_count(dem, decoder_type) if decode else num_raw_errors return SimulationResult( diff --git a/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py b/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py index b61230552..979165f9e 100644 --- a/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py +++ b/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py @@ -29,29 +29,29 @@ class TestMwpmResult: def test_result_attributes(self) -> None: """Test that MwpmResult has the expected attributes.""" - from pecos_rslib.decoders import CheckMatrix, PyMatchingDecoder + from pecos_rslib.decoders import CheckMatrix, ObservableFlips, PyMatchingDecoder matrix = CheckMatrix.from_dense([[1, 1, 0], [0, 1, 1]]) decoder = PyMatchingDecoder.from_check_matrix(matrix) result = decoder.decode_syndrome([0, 0]) # Check attributes exist - assert hasattr(result, "correction") + assert hasattr(result, "observable_flips") assert hasattr(result, "weight") # Check types - assert isinstance(result.correction, list) + assert isinstance(result.observable_flips, ObservableFlips) assert isinstance(result.weight, float) - def test_result_to_list(self) -> None: - """Test MwpmResult.to_list() method.""" + def test_observable_flips_materializes_to_list(self) -> None: + """Test materializing MwpmResult.observable_flips as a list.""" from pecos_rslib.decoders import CheckMatrix, PyMatchingDecoder matrix = CheckMatrix.from_dense([[1, 1, 0], [0, 1, 1]]) decoder = PyMatchingDecoder.from_check_matrix(matrix) result = decoder.decode_syndrome([0, 0]) - assert result.to_list() == result.correction + assert list(result.observable_flips) == [bool(value) for value in result] def test_result_indexing(self) -> None: """Test MwpmResult supports indexing like a list.""" @@ -61,9 +61,9 @@ def test_result_indexing(self) -> None: decoder = PyMatchingDecoder.from_check_matrix(matrix) result = decoder.decode_syndrome([0, 0]) - assert len(result) == len(result.correction) + assert len(result) == len(result.observable_flips) if len(result) > 0: - assert result[0] == result.correction[0] + assert bool(result[0]) == result.observable_flips[0] class TestCheckMatrix: @@ -165,7 +165,7 @@ def test_from_dem_with_correlations(self) -> None: decoder = PyMatchingDecoder.from_dem_with_correlations(dem) result = decoder.decode_syndrome([0, 0, 0]) - assert result.correction == [0] + assert list(result.observable_flips) == [False] class TestFusionBlossomDecoder: diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py index cc06ef5c7..ea7640276 100644 --- a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py @@ -404,7 +404,7 @@ def test_decode_each_matches_decode_count(): batch = ParsedDem.from_string(dem).to_dem_sampler().sample_batch(n, seed=5) preds = batch.decode_each(dem, "pecos_uf:bp") assert len(preds) == n - wrong = sum(1 for i, p in enumerate(preds) if p != batch.get_observable_mask(i)) + wrong = sum(1 for i, p in enumerate(preds) if p != batch.get_observable_flips(i).mask) assert wrong == batch.decode_count(dem, "pecos_uf:bp") diff --git a/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py b/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py index 099e2e74e..d9a10f796 100644 --- a/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py +++ b/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py @@ -79,10 +79,10 @@ def test_dense_and_sparse_names_disambiguate_the_same_list() -> None: dense_result = decoder.decode_syndrome([1, 0]) sparse_result = decoder.decode_from_defects([1, 0]) - assert dense_result.observables_mask == 0 + assert dense_result.observable_flips.mask == 0 assert dense_result.cost == pytest.approx(2.197224577336219) assert not dense_result.low_confidence - assert sparse_result.observables_mask == 1 + assert sparse_result.observable_flips.mask == 1 assert sparse_result.cost == pytest.approx(4.394449154672438) assert not sparse_result.low_confidence @@ -90,12 +90,12 @@ def test_dense_and_sparse_names_disambiguate_the_same_list() -> None: def test_renamed_methods_preserve_captured_results() -> None: pymatching = PyMatchingDecoder.from_dem(_ENCODING_DEM) pymatching_result = pymatching.decode_syndrome([1, 0]) - assert pymatching_result.correction == [0] + assert list(pymatching_result.observable_flips) == [False] assert pymatching_result.weight == pytest.approx(4.394449154672439) fusion_blossom = FusionBlossomDecoder.from_dem(_ENCODING_DEM) fusion_blossom_result = fusion_blossom.decode_syndrome([1, 0]) - assert fusion_blossom_result.correction == [0] + assert list(fusion_blossom_result.observable_flips) == [False] assert fusion_blossom_result.weight == pytest.approx(2.196) parity_check_matrix = SparseMatrix([[1, 0], [0, 1]]) @@ -133,10 +133,10 @@ def test_family_from_dem_matches_existing_wrapper(decoder_class: type, decoder_t existing_results = [existing_decoder.decode_syndrome(list(syndrome)) for syndrome in _SYNDROMES] assert all(isinstance(result, DemAwareResult) for result in named_results) - named_masks = [result.observables_mask for result in named_results] - existing_masks = [result.observables_mask for result in existing_results] + named_masks = [result.observable_flips.mask for result in named_results] + existing_masks = [result.observable_flips.mask for result in existing_results] assert named_masks == existing_masks == _OBSERVABLE_MASKS_BEFORE - assert [(result.observables_mask, result.converged, result.iterations) for result in named_results] == ( + assert [(result.observable_flips.mask, result.converged, result.iterations) for result in named_results] == ( _FAMILY_RESULTS_BEFORE ) assert isinstance(named_decoder, DemAwareDecoder) @@ -151,7 +151,7 @@ def test_family_from_dem_matches_existing_wrapper(decoder_class: type, decoder_t def test_iterative_family_from_dem_accepts_tuning(decoder_class: type) -> None: named_decoder = decoder_class.from_dem(_ENCODING_DEM, error_rate=0.2, max_iter=1) named_result = named_decoder.decode_syndrome([0, 1]) - assert named_result.observables_mask == 1 + assert named_result.observable_flips.mask == 1 def test_each_family_accepts_only_its_real_tuning_surface() -> None: @@ -223,7 +223,7 @@ def test_invalid_dem_tuning_names_parameter(build: Callable[[], object], paramet @pytest.mark.parametrize("decoder_type", [decoder_type for _, decoder_type in _FAMILY_DECODERS]) def test_decoder_type_still_accepts_all_five_family_values(decoder_type: str) -> None: decoder = DemAwareDecoder.from_dem(_ENCODING_DEM, decoder_type=decoder_type) - assert decoder.decode_syndrome([0, 1]).observables_mask == 1 + assert decoder.decode_syndrome([0, 1]).observable_flips.mask == 1 def test_legacy_measurement_protocol_decoders_keep_decode() -> None: diff --git a/python/quantum-pecos/tests/qec/test_dem_aware_decoder_width.py b/python/quantum-pecos/tests/qec/test_dem_aware_decoder_width.py index 43fc14c5d..c9ddf21b4 100644 --- a/python/quantum-pecos/tests/qec/test_dem_aware_decoder_width.py +++ b/python/quantum-pecos/tests/qec/test_dem_aware_decoder_width.py @@ -34,7 +34,7 @@ def wide_decoder() -> DemAwareDecoder: def test_observable_past_64_sets_its_own_bit(wide_decoder: DemAwareDecoder) -> None: - mask = wide_decoder.decode_syndrome([0, 1]).observables_mask + mask = wide_decoder.decode_syndrome([0, 1]).observable_flips.mask assert mask >> _WIDE_OBSERVABLE & 1, "observable 70 must set bit 70" assert not mask >> _WRAPPED_BIT & 1, "observable 70 must not wrap onto bit 6" @@ -44,7 +44,7 @@ def test_observable_past_64_sets_its_own_bit(wide_decoder: DemAwareDecoder) -> N def test_narrow_observables_are_unchanged(wide_decoder: DemAwareDecoder) -> None: # Values that fit in 64 bits must stay exactly what the previous u64 # field held, so widening is not a behavior change for existing users. - assert wide_decoder.decode_syndrome([1, 0]).observables_mask == 1 + assert wide_decoder.decode_syndrome([1, 0]).observable_flips.mask == 1 def test_repr_reports_wide_masks(wide_decoder: DemAwareDecoder) -> 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 efdf74b18..986bea8ab 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -1892,7 +1892,7 @@ def test_constrained_from_guppy_dem_is_consumable_by_pecos_native_decoder() -> N # 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 + assert batch.get_observable_flips(0).mask >> dem.num_observables == 0 # PECOS-native Rust-backed matching decoder: DEM is consumable by # the actual downstream decoder surface. diff --git a/python/quantum-pecos/tests/qec/test_observable_flips.py b/python/quantum-pecos/tests/qec/test_observable_flips.py index 60c1d8e90..980a4be95 100644 --- a/python/quantum-pecos/tests/qec/test_observable_flips.py +++ b/python/quantum-pecos/tests/qec/test_observable_flips.py @@ -100,7 +100,7 @@ def test_constructors_accept_integer_like_values() -> None: """ numpy = pytest.importorskip("numpy") - # ``MwpmResult.correction`` and ``TesseractResult.observable_bits`` return list[int]. + # Integer-oriented callers can still construct flips without first converting to bool. assert ObservableFlips.from_bits([1, 0, 1]) == ObservableFlips.from_mask(0b101, 3) assert ObservableFlips.from_bits([True, 0, 1]) == ObservableFlips.from_mask(0b101, 3) assert ObservableFlips.from_bits(list(numpy.array([1, 0, 1]))) == ObservableFlips.from_mask(0b101, 3) @@ -143,6 +143,54 @@ def test_sample_batch_observable_metadata_and_shot_bounds() -> None: batch.get_observable_flips(2) +def test_removed_members_are_absent_and_replacements_match_captured_values() -> None: + syndrome = [1, 1] + mwpm_result = PyMatchingDecoder.from_dem(_ONE_OBSERVABLE_DEM).decode_syndrome(syndrome) + tesseract_result = TesseractDecoder.from_dem(_ONE_OBSERVABLE_DEM).decode_syndrome(syndrome) + dem_aware_result = DemAwareDecoder.from_dem( + _ONE_OBSERVABLE_DEM, + decoder_type="bp_osd", + ).decode_syndrome(syndrome) + bp_result = BpOsdBuilder(SparseMatrix([[1]]), error_rate=0.1).build().decode_syndrome([0]) + batch = SampleBatch([[0], [1]], [0, 3]) + + removed_members = [ + (mwpm_result, "correction"), + (mwpm_result, "to_list"), + (bp_result, "to_list"), + (tesseract_result, "observables_mask"), + (tesseract_result, "observable_bits"), + (dem_aware_result, "observables_mask"), + (batch, "get_observable_mask"), + (batch, "get_observable_mask_wide"), + ] + for result, member in removed_members: + assert not hasattr(result, member), f"{type(result).__name__}.{member} still exists" + + replacements = { + "MwpmResult.correction": list(mwpm_result.observable_flips), + "MwpmResult.to_list()": list(mwpm_result.observable_flips), + "BpResult.to_list()": bp_result.decoding, + "TesseractResult.observables_mask": tesseract_result.observable_flips.mask, + "TesseractResult.observable_bits(1)": list(tesseract_result.observable_flips), + "DemAwareResult.observables_mask": dem_aware_result.observable_flips.mask, + "SampleBatch.get_observable_mask(1)": batch.get_observable_flips(1).mask, + "SampleBatch.get_observable_mask_wide(1)": batch.get_observable_flips(1).mask, + } + assert replacements == { + "MwpmResult.correction": [True], + "MwpmResult.to_list()": [True], + "BpResult.to_list()": [0], + "TesseractResult.observables_mask": 1, + "TesseractResult.observable_bits(1)": [True], + "DemAwareResult.observables_mask": 1, + "SampleBatch.get_observable_mask(1)": 3, + "SampleBatch.get_observable_mask_wide(1)": 3, + } + assert not hasattr(bp_result, "observable_flips") + assert bp_result.decoding == [0] + + def test_uniform_loop_preserves_one_observable_error_counts() -> None: syndromes = [[0, 0], [1, 0], [0, 1], [1, 1]] batch = SampleBatch(syndromes, [0, 1, 0, 1]) @@ -151,22 +199,17 @@ def test_uniform_loop_preserves_one_observable_error_counts() -> None: "tesseract": TesseractDecoder.from_dem(_ONE_OBSERVABLE_DEM), "bp_osd": BpOsdDecoder.from_dem(_ONE_OBSERVABLE_DEM), } - old_counts = dict.fromkeys(decoders, 0) - new_counts = dict.fromkeys(decoders, 0) + error_counts = dict.fromkeys(decoders, 0) for shot in range(batch.num_shots): syndrome = batch.get_syndrome(shot) - actual_mask = batch.get_observable_mask(shot) & 1 actual_flips = batch.get_observable_flips(shot) results = {name: decoder.decode_syndrome(syndrome) for name, decoder in decoders.items()} - old_counts["pymatching"] += results["pymatching"].correction[0] != actual_mask - old_counts["tesseract"] += results["tesseract"].observables_mask & 1 != actual_mask - old_counts["bp_osd"] += results["bp_osd"].observables_mask & 1 != actual_mask for name, result in results.items(): - new_counts[name] += result.observable_flips != actual_flips + error_counts[name] += result.observable_flips != actual_flips - assert old_counts == new_counts == {"pymatching": 2, "tesseract": 2, "bp_osd": 2} + assert error_counts == {"pymatching": 2, "tesseract": 2, "bp_osd": 2} def test_any_observable_and_per_observable_counts_are_distinct() -> None: @@ -213,3 +256,4 @@ def test_bp_result_does_not_fabricate_observable_flips() -> None: result = decoder.decode_syndrome([0]) assert not hasattr(result, "observable_flips") + assert result.decoding == [0] diff --git a/python/quantum-pecos/tests/qec/test_sample_batch.py b/python/quantum-pecos/tests/qec/test_sample_batch.py index 7edc66f0f..c0ee81867 100644 --- a/python/quantum-pecos/tests/qec/test_sample_batch.py +++ b/python/quantum-pecos/tests/qec/test_sample_batch.py @@ -13,10 +13,10 @@ def test_round_trip_get_syndrome(self): assert list(batch.get_syndrome(0)) == [1, 0] assert list(batch.get_syndrome(1)) == [0, 1] - def test_round_trip_get_observable_mask(self): + def test_round_trip_get_observable_flips(self): batch = SampleBatch([[1, 0], [0, 1]], [1, 0]) - assert batch.get_observable_mask(0) == 1 - assert batch.get_observable_mask(1) == 0 + assert batch.get_observable_flips(0).mask == 1 + assert batch.get_observable_flips(1).mask == 0 def test_num_shots(self): batch = SampleBatch([[0, 0], [1, 1], [0, 1]], [0, 0, 0]) @@ -92,7 +92,7 @@ def test_bulk_accessors_match_per_shot_accessors(self, d3_setup): assert len(observable_flips) == batch.num_shots for shot in range(batch.num_shots): assert detector_events[shot] == [bool(value) for value in batch.get_syndrome(shot)] - mask = batch.get_observable_mask(shot) + mask = batch.get_observable_flips(shot).mask assert observable_flips[shot] == [ bool(mask & (1 << observable)) for observable in range(sampler.num_observables) ] @@ -114,10 +114,10 @@ def test_get_syndrome_shape(self, d3_setup): syn = batch.get_syndrome(0) assert len(syn) == sampler.num_detectors - def test_get_observable_mask_type(self, d3_setup): + def test_get_observable_flips_mask_type(self, d3_setup): sampler, _ = d3_setup batch = sampler.sample_batch(10, seed=42) - mask = batch.get_observable_mask(0) + mask = batch.get_observable_flips(0).mask assert isinstance(mask, int) def test_decode_count(self, d3_setup): diff --git a/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py b/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py index bdc4c2f0c..19a18ee42 100644 --- a/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py +++ b/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py @@ -104,7 +104,7 @@ def _decode_native_dem_samples(circuit, noise_args, matching, shots, seed): syndrome[det_index] = sampled_syndrome[det_index] predicted = matching.decode(syndrome) predicted_mask = sum(int(bit) << index for index, bit in enumerate(predicted)) - errors += predicted_mask != batch.get_observable_mask(shot_index) + errors += predicted_mask != batch.get_observable_flips(shot_index).mask return errors diff --git a/python/quantum-pecos/tests/qec/test_wide_observables.py b/python/quantum-pecos/tests/qec/test_wide_observables.py index 5cd4bb791..72c6b34d6 100644 --- a/python/quantum-pecos/tests/qec/test_wide_observables.py +++ b/python/quantum-pecos/tests/qec/test_wide_observables.py @@ -97,23 +97,19 @@ def test_observable_flips_matches_wide_per_shot_masks() -> None: observable_flips = batch.observable_flips() assert all(len(row) == n for row in observable_flips) for shot, row in enumerate(observable_flips): - mask = batch.get_observable_mask_wide(shot) + mask = batch.get_observable_flips(shot).mask assert row == [bool(mask & (1 << observable)) for observable in range(n)] -def test_u64_observable_getter_rejects_wide_batch() -> None: - # get_observable_mask returns a u64 and cannot represent observable >= 64, so - # it rejects a wide batch; get_observable_mask_wide returns the full Python - # int. (The decode methods, by contrast, compare wide ObsMasks and do not - # reject -- see below.) +def test_observable_flips_mask_supports_the_formerly_rejected_wide_batch() -> None: + # The removed u64 getter rejected this batch. ObservableFlips carries the + # full arbitrary-precision Python mask instead. n = 65 _dem, _ = _wide_dem(n) syn = [0] * n wide = SampleBatch([syn, syn], [1 << 64, 1 << 64]) - with pytest.raises(ValueError, match="64-observable"): - wide.get_observable_mask(0) - assert wide.get_observable_mask_wide(0) == 1 << 64 + assert wide.get_observable_flips(0).mask == 1 << 64 def test_sample_batch_decode_count_batch_handles_wide_dem() -> None: diff --git a/scripts/compare_meas_sampling_pipeline.py b/scripts/compare_meas_sampling_pipeline.py index 7cac295b3..f1501f649 100644 --- a/scripts/compare_meas_sampling_pipeline.py +++ b/scripts/compare_meas_sampling_pipeline.py @@ -133,7 +133,7 @@ def run_native_sampler(tc, noise_args, matching, shots, seed): predicted = matching.decode(syndrome) pred_mask = sum(int(v) << j for j, v in enumerate(predicted)) - if pred_mask != batch.get_observable_mask(i): + if pred_mask != batch.get_observable_flips(i).mask: errors += 1 t_decode = time.perf_counter() - t0 From 1d8c87b2a9b3e2f35bab39b43b794902daf1176a Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 8 Aug 2026 23:58:01 -0600 Subject: [PATCH 62/62] Fix sampler observable width for declared-unused logicals and document the boundary fit trade-off --- .../src/fault_tolerance/dem_builder/types.rs | 62 +++++++++++++++++++ .../src/fault_tolerance_bindings.rs | 18 ++++-- .../tests/qec/test_observable_flips.py | 37 +++++++++++ 3 files changed, 111 insertions(+), 6 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index 80959a07c..f9ac04ef2 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -2823,6 +2823,26 @@ fn validate_independent_idle_probabilities( /// GF(2) span. The target channel's characters are transformed into independent /// mechanism rates with a Walsh-Hadamard solve. The original closed form is /// retained for dimension two so existing idle DEM numbers remain byte-exact. +/// +/// # The boundary fit is not total-variation minimal, deliberately +/// +/// When an exact rate comes out negative the channel is not representable by +/// independent mechanisms. That mechanism is then dropped and the remaining two +/// are solved so the two *retained* target probabilities are reproduced +/// **exactly**; the mismatch lands on the omitted signature and on the identity, +/// and is reported as the residual. +/// +/// This is not the closest fit under total variation, and that is a choice, not +/// an oversight. For `(pI, pX, pY, pZ) = (0.65, 0.15, 0, 0.20)` this fit gives +/// `(0.60, 0.15, 0.05, 0.20)` at a TV distance of `0.05`, while the TV-minimal +/// fit is roughly `(0.65, 0.1147, 0.0353, 0.20)` at `0.0353`. The TV-minimal fit +/// is closer overall only by silently moving `pX` away from the 0.15 the caller +/// asked for -- a 24% change in the requested X rate. Preserving the requested +/// per-Pauli probabilities exactly is worth more here than a smaller aggregate +/// distance, because those probabilities are the thing the user specified. +/// +/// The reported residual is the true total-variation distance to the fit that is +/// actually emitted. It is not a claim of minimality. pub(crate) fn fit_exclusive_signatures( exclusive: BTreeMap, xor: Xor, @@ -7024,6 +7044,48 @@ fn trim_trailing_zeros(s: &str) -> String { #[cfg(test)] mod tests { + /// The boundary fit trades total-variation distance for exact preservation of + /// the requested per-Pauli probabilities. Pin both halves of that trade so it + /// cannot be "optimized" into a TV-minimal fit without a deliberate decision. + /// + /// Channel: `(pI, pX, pY, pZ) = (0.65, 0.15, 0, 0.20)`, whose exact Y rate is + /// negative, so the fit takes the boundary path. + #[test] + fn boundary_fit_preserves_requested_rates_rather_than_minimizing_total_variation() { + // Emitted fit, from the closed form: aX = 0.20, aZ = 0.25, aY dropped. + let (a_x, a_z) = (0.20_f64, 0.25_f64); + // Y's signature is X xor Z, so both firing reads as Y. + let emitted = [ + (1.0 - a_x) * (1.0 - a_z), // I + a_x * (1.0 - a_z), // X + a_x * a_z, // Y + (1.0 - a_x) * a_z, // Z + ]; + let target = [0.65, 0.15, 0.0, 0.20]; + let tv = |d: [f64; 4]| { + 0.5 * d + .iter() + .zip(target) + .map(|(a, b)| (a - b).abs()) + .sum::() + }; + + // The requested X and Z probabilities survive exactly. This is the point. + assert!((emitted[1] - target[1]).abs() < 1e-12); + assert!((emitted[3] - target[3]).abs() < 1e-12); + + // And it is knowingly not the closest fit: the TV-minimal one sits nearer + // but only by moving pX off the requested 0.15. + let tv_minimal = [ + 0.65, + 0.114_705_882_352_941_2, + 0.035_294_117_647_058_82, + 0.20, + ]; + assert!(tv(tv_minimal) < tv(emitted)); + assert!((tv_minimal[1] - target[1]).abs() > 0.03); + } + fn residual_with_channel_weight(channel_weight: f64) -> NoiseChannelResidual { NoiseChannelResidual { location_index: 0, diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 491b030ab..f0d2d68ef 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4309,9 +4309,18 @@ impl PyDemSampler { fn from_dem_string(dem_string: &str) -> PyResult { use pecos_qec::fault_tolerance::dem_builder::SamplingEngine; + // Detector and observable counts come from the canonical parser, which also + // honours bare `detector D` and `logical_observable L` declarations. + // Deriving them from `error(...)` lines alone undercounts: Stim emits + // `logical_observable Lk` precisely for logicals that no mechanism flips, and + // dropping those made the sampler's width disagree with the decoders' -- which + // silently turned every shot into a logical error when the widths were compared. + let (num_detectors, num_observables) = + pecos_decoder_core::dem::utils::parse_dem_metadata(dem_string).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("invalid DEM: {e}")) + })?; + let mut mechanisms = Vec::new(); - let mut max_det = 0u32; - let mut max_obs = 0u32; for line in dem_string.lines() { let line = line.trim(); @@ -4338,13 +4347,11 @@ impl PyDemSampler { pyo3::exceptions::PyValueError::new_err(format!("bad detector: {e}")) })?; dets.push(id); - max_det = max_det.max(id + 1); } else if let Some(l) = tok.strip_prefix('L') { let id: u32 = l.parse().map_err(|e| { pyo3::exceptions::PyValueError::new_err(format!("bad observable: {e}")) })?; obs.push(id); - max_obs = max_obs.max(id + 1); } } if prob > 0.0 { @@ -4352,8 +4359,7 @@ impl PyDemSampler { } } - let engine = - SamplingEngine::from_mechanisms(mechanisms, max_det as usize, max_obs as usize); + let engine = SamplingEngine::from_mechanisms(mechanisms, num_detectors, num_observables); let inner = RustNewDemSampler::from_engine(engine); Ok(Self { inner }) } diff --git a/python/quantum-pecos/tests/qec/test_observable_flips.py b/python/quantum-pecos/tests/qec/test_observable_flips.py index 980a4be95..aeb9bec1c 100644 --- a/python/quantum-pecos/tests/qec/test_observable_flips.py +++ b/python/quantum-pecos/tests/qec/test_observable_flips.py @@ -228,6 +228,43 @@ def test_any_observable_and_per_observable_counts_are_distinct() -> None: assert per_observable_errors == [1, 1] +def test_sampler_and_decoder_agree_on_width_for_declared_unused_observables() -> None: + """A declared observable that no mechanism flips must still count toward the width. + + Stim emits ``logical_observable Lk`` precisely for logicals nothing flips. If the + sampler derives its width from ``error(...)`` lines alone it reports a narrower + batch than the decoder, and because ``ObservableFlips`` equality includes length, + every shot then compares unequal -- a silent 100% logical error rate. + """ + from pecos_rslib.qec import DemSampler + + dem = "error(0.001) D0 L0\ndetector D0\nlogical_observable L0\nlogical_observable L1" + sampler = DemSampler.from_dem_string(dem) + batch = sampler.sample_batch(200, seed=1) + decoder = PyMatchingDecoder.from_dem(dem) + + predicted_width = len(decoder.decode_syndrome([0]).observable_flips) + assert batch.num_observables == 2 + assert predicted_width == 2 + + errors = sum( + decoder.decode_syndrome(batch.get_syndrome(shot)).observable_flips != batch.get_observable_flips(shot) + for shot in range(batch.num_shots) + ) + assert errors < batch.num_shots + + +def test_sampler_counts_declared_detectors_and_observables() -> None: + """The same undercount applied to bare ``detector D`` declarations.""" + from pecos_rslib.qec import DemSampler + + dem = "error(0.001) D0 L0\ndetector D0\ndetector D5\nlogical_observable L0\nlogical_observable L1" + sampler = DemSampler.from_dem_string(dem) + + assert sampler.num_detectors == 6 + assert sampler.num_observables == 2 + + def test_wide_observables_are_not_truncated_end_to_end() -> None: dem = _wide_dem() syndrome = [0, 1]