From e6f3a5ffcc4d419724c0e5b85f6d2e7c7fc8d5ed Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 21 May 2026 14:23:42 -0600 Subject: [PATCH 001/150] Document and fix from_guppy DEM handoff. --- docs/development/from-guppy-dem-handoff.md | 116 ++++++++++++++++++ .../quantum-pecos/src/pecos/qec/__init__.py | 8 +- python/quantum-pecos/src/pecos/qec/dem.py | 27 ++-- .../guppy/test_surface_ancilla_budget.py | 52 ++++++++ 4 files changed, 181 insertions(+), 22 deletions(-) create mode 100644 docs/development/from-guppy-dem-handoff.md create mode 100644 python/quantum-pecos/tests/guppy/test_surface_ancilla_budget.py diff --git a/docs/development/from-guppy-dem-handoff.md b/docs/development/from-guppy-dem-handoff.md new file mode 100644 index 000000000..6d3b076c8 --- /dev/null +++ b/docs/development/from-guppy-dem-handoff.md @@ -0,0 +1,116 @@ +# `DetectorErrorModel.from_guppy` Handoff + +This note is for future work on the DEM polish path. It captures the current +local fix and the validation target for constrained-ancilla surface-code DEMs. + +## Context + +The `dem-polish` work adds a Python-level `DetectorErrorModel.from_guppy(...)` +entry point. The intended shape is: + +1. Build any Guppy program, including constrained surface-code memory circuits. +2. Trace it through Selene/QIS into a `TickCircuit`. +3. Attach caller-provided detector and observable metadata. +4. Build the native PECOS DEM from that traced circuit. + +This should support calls like: + +```python +from pecos.guppy import get_num_qubits, make_surface_code +from pecos.qec import DetectorErrorModel + +program = make_surface_code( + distance=9, + num_rounds=18, + basis="Z", + ancilla_budget=17, +) + +dem = DetectorErrorModel.from_guppy( + program, + num_qubits=get_num_qubits(9, ancilla_budget=17), + detectors_json=detectors_json, + observables_json=observables_json, + num_measurements=num_measurements, + p1=p, + p2=p, + p_meas=p, + p_prep=p, +) +``` + +## Import-Time Issue + +`pecos_rslib.qec.DetectorErrorModel` is not currently subclassable from Python. +Defining: + +```python +class DetectorErrorModel(_RustDetectorErrorModel): + ... +``` + +causes `import pecos` to fail with: + +```text +TypeError: type 'pecos_rslib.qec.DetectorErrorModel' is not an acceptable base type +``` + +The current local fix is to re-export the Rust class directly and attach the +Python convenience constructor: + +```python +DetectorErrorModel = _RustDetectorErrorModel +DetectorErrorModel.from_guppy = classmethod(...) +``` + +This keeps the public API as `pecos.qec.DetectorErrorModel.from_guppy(...)` +while preserving the Rust class identity for objects returned by +`from_circuit(...)` and `from_guppy(...)`. + +## Constrained-Ancilla Surface DEM Target + +The key surface-code use case is Helios-sized rotated surface code memory: + +- `distance=9` +- `ancilla_budget=17` +- `num_qubits=98` +- both X and Z memory bases +- DEMs built from the traced Guppy/Selene/QIS path + +Important checks: + +```bash +uv run python -c "from pecos.guppy import make_surface_code, get_num_qubits; from pecos.qec import DetectorErrorModel; print(get_num_qubits(9, ancilla_budget=17)); print(hasattr(DetectorErrorModel, 'from_guppy')); make_surface_code(distance=9, num_rounds=18, basis='Z', ancilla_budget=17); print('ok')" +``` + +Expected output includes: + +```text +98 +True +ok +``` + +In the downstream `surface-memory-helios` repo, this smoke test currently +generates a constrained d=9 traced DEM: + +```bash +uv run python -c "from surface_memory_helios import surface_memory_dem; dem = surface_memory_dem(distance=9, rounds=18, basis='Z', p=0.01, decoder='pymatching', dem_source='traced_qis', ancilla_budget=17); print(len(dem.splitlines())); print(dem.splitlines()[0])" +``` + +The latest local run produced 37,066 DEM lines and a detector metadata first +line. + +## Follow-Up Guidance + +- Prefer the generic `from_guppy(...)` abstraction for future DEM construction + rather than adding more surface-specific tracing plumbing. +- Keep the surface helper path compatible with constrained ancilla budgets: + pass `ancilla_budget` into both `make_surface_code(...)` and + `get_num_qubits(...)` when tracing surface Guppy. +- Avoid reintroducing any `circuit_source="traced_qis"` rejection for + `ancilla_budget`; constrained Guppy programs are valid and traceable. +- Ensure PyMatching users can get decomposed DEM text from the `from_guppy(...)` + result, e.g. via `to_string_decomposed()`. +- Keep or add regression coverage for constrained d=9, `ancilla_budget=17` + through the Guppy/from_guppy route. diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index 7aac871c1..9b959d6a5 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -72,10 +72,10 @@ generate_488_layout, ) -# DetectorErrorModel is re-exported from pecos.qec.dem: a thin Python subclass -# of the Rust class that adds the from_guppy convenience constructor (the -# Guppy/Selene trace pipeline is Python-only, so it cannot live in the Rust -# extension without a dependency cycle). +# DetectorErrorModel is re-exported from pecos.qec.dem: the Rust class with a +# 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 from pecos.qec.generic import ( CheckSchedule, diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 1adf274c2..7707fc64d 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -6,8 +6,9 @@ ``pecos.qec.surface.decode``). To keep the convenient ``DetectorErrorModel.from_guppy(...)`` call site without making the low-level Rust extension import the high-level Python package (a dependency cycle), this -module defines a thin Python subclass that adds :meth:`from_guppy` and is -re-exported as the public ``pecos.qec.DetectorErrorModel``. +module attaches a Python :meth:`from_guppy` classmethod to the Rust-backed +``pecos_rslib.qec.DetectorErrorModel`` and re-exports that class as the public +``pecos.qec.DetectorErrorModel``. This wrapper is intentionally thin: it traces the Guppy program into a ``TickCircuit``, optionally compiles the program to a HUGR (only when @@ -40,22 +41,8 @@ from pecos_rslib.qec import DetectorErrorModel as _RustDetectorErrorModel -class DetectorErrorModel(_RustDetectorErrorModel): - """Detector error model with a Guppy/QIS-trace convenience constructor. - - Identical to :class:`pecos_rslib.qec.DetectorErrorModel` except for the - added :meth:`from_guppy` classmethod. - - Identity caveat: the inherited Rust factory classmethods - (``from_circuit``, ``from_pecos_metadata_json``, and ``from_guppy``, which - delegates to ``from_circuit``) construct and return the *Rust base* class - ``pecos_rslib.qec.DetectorErrorModel`` -- they do not return instances of - this Python subclass. Consequently ``isinstance(obj, DetectorErrorModel)`` - is ``False`` for objects produced by those constructors even though every - method works identically. Do not use ``isinstance`` against this public - subclass to recognize DEMs; check the Rust base type instead. (No PECOS - code relies on such an ``isinstance``; this is a public-API caveat only.) - """ +class _DetectorErrorModelMixin: + """Namespace for the Python Guppy/QIS-trace convenience constructor.""" __slots__ = () @@ -251,3 +238,7 @@ def _result_tags_present(detectors_json: str, observables_json: str) -> bool: extraction, loop-guard, resolution, and validation are all done in Rust. """ return '"result_tags"' in (detectors_json or "") or '"result_tags"' in (observables_json or "") + + +DetectorErrorModel = _RustDetectorErrorModel +DetectorErrorModel.from_guppy = classmethod(_DetectorErrorModelMixin.__dict__["from_guppy"].__func__) diff --git a/python/quantum-pecos/tests/guppy/test_surface_ancilla_budget.py b/python/quantum-pecos/tests/guppy/test_surface_ancilla_budget.py new file mode 100644 index 000000000..424f2efdf --- /dev/null +++ b/python/quantum-pecos/tests/guppy/test_surface_ancilla_budget.py @@ -0,0 +1,52 @@ +"""Tests for constrained-ancilla surface-code Guppy generation.""" + +from __future__ import annotations + +import pytest + + +def test_surface_qubit_count_respects_ancilla_budget() -> None: + """The optional budget caps peak live ancillas without changing data qubits.""" + from pecos.guppy import get_num_qubits + + assert get_num_qubits(7) == 97 + assert get_num_qubits(9) == 161 + assert get_num_qubits(9, ancilla_budget=17) == 98 + assert get_num_qubits(9, ancilla_budget=999) == 161 + + +def test_surface_ancilla_budget_must_be_positive() -> None: + """Reject nonsensical budgets early.""" + from pecos.guppy import get_num_qubits + + with pytest.raises(ValueError, match="ancilla_budget must be >= 1"): + get_num_qubits(3, ancilla_budget=0) + + +def test_constrained_ancilla_surface_code_compiles_to_hugr() -> None: + """A budgeted surface memory experiment should still be valid Guppy/HUGR.""" + from pecos.compilation_pipeline import compile_guppy_to_hugr + from pecos.guppy import make_surface_code + + program = make_surface_code(distance=3, num_rounds=1, basis="Z", ancilla_budget=2) + hugr = compile_guppy_to_hugr(program) + + assert len(hugr) > 0 + + +def test_constrained_ancilla_surface_code_traces_to_native_tick_circuit() -> None: + """Budgeted Guppy surface programs should work through traced-QIS DEM plumbing.""" + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + + patch = SurfacePatch.create(distance=3) + circuit = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=1, + basis="Z", + ancilla_budget=2, + circuit_source="traced_qis", + ) + + assert circuit.get_meta("ancilla_budget") == "2" + assert int(circuit.get_meta("num_measurements")) > 0 From 20f549b1721da6650b63c43802235de45e211b19 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 28 May 2026 23:53:53 -0600 Subject: [PATCH 002/150] Enable generic Selene runtime plugins in PECOS. --- crates/pecos-engines/src/classical.rs | 12 + crates/pecos-engines/src/sim_builder.rs | 4 +- crates/pecos-qis-ffi-types/src/operations.rs | 3 + crates/pecos-qis/src/ccengine.rs | 53 +- crates/pecos-qis/src/runtime.rs | 34 +- crates/pecos-qis/src/selene_runtime.rs | 906 +++++++++++++++++- python/pecos-rslib/src/engine_builders.rs | 68 +- .../src/pecos/_engine_builders.py | 77 +- .../test_selene_interface_integration.py | 21 + 9 files changed, 1091 insertions(+), 87 deletions(-) diff --git a/crates/pecos-engines/src/classical.rs b/crates/pecos-engines/src/classical.rs index 8204ca878..3c6dc3375 100644 --- a/crates/pecos-engines/src/classical.rs +++ b/crates/pecos-engines/src/classical.rs @@ -10,6 +10,14 @@ use std::any::Any; pub trait ClassicalEngine: Engine + DynClone + Send + Sync { fn num_qubits(&self) -> usize; + /// Provide a qubit-count hint from higher-level simulation configuration. + /// + /// Most classical engines can ignore this. Dynamic runtimes may need it + /// before program execution discovers allocations. + fn set_num_qubits_hint(&mut self, _num_qubits: usize) { + // Default implementation does nothing. + } + /// Generate a `ByteMessage` containing the next batch of quantum commands to execute. /// An empty message indicates no more commands are available. /// @@ -98,6 +106,10 @@ impl ClassicalEngine for Box { (**self).num_qubits() } + fn set_num_qubits_hint(&mut self, num_qubits: usize) { + (**self).set_num_qubits_hint(num_qubits); + } + fn generate_commands(&mut self) -> Result { (**self).generate_commands() } diff --git a/crates/pecos-engines/src/sim_builder.rs b/crates/pecos-engines/src/sim_builder.rs index a858e94ef..dc9a5caba 100644 --- a/crates/pecos-engines/src/sim_builder.rs +++ b/crates/pecos-engines/src/sim_builder.rs @@ -265,7 +265,7 @@ impl SimBuilder { use crate::quantum::SparseStabEngine; // Build classical engine (required) - let classical_engine = match self.classical_builder { + let mut classical_engine = match self.classical_builder { Some(builder) => builder.build_boxed()?, None => { return Err(PecosError::Input( @@ -284,6 +284,8 @@ impl SimBuilder { ) })?; + classical_engine.set_num_qubits_hint(num_qubits); + // Build quantum engine (require explicit qubit specification) let quantum_engine = if let Some(mut builder) = self.quantum_builder { // Set qubits on the quantum engine builder if explicitly specified diff --git a/crates/pecos-qis-ffi-types/src/operations.rs b/crates/pecos-qis-ffi-types/src/operations.rs index 8374db844..1ded1d33b 100644 --- a/crates/pecos-qis-ffi-types/src/operations.rs +++ b/crates/pecos-qis-ffi-types/src/operations.rs @@ -49,6 +49,9 @@ pub enum QuantumOp { // Hardware-native gates (for Selene compatibility) RXY(f64, f64, usize), // theta, phi, qubit + // Idle period in seconds for time-based noise models + Idle(f64, usize), // duration_seconds, qubit + // Two-qubit gates CX(usize, usize), CY(usize, usize), diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index 5c85fe079..c1c88bc13 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -197,6 +197,9 @@ pub struct QisEngine { /// qubit handles — use `active_qubit_slots.len()` for that. num_physical_slots: usize, + /// Optional device-size hint supplied by the top-level simulation builder. + num_qubits_hint: Option, + /// Mapping from program-level qubit handles to physical simulator slots. active_qubit_slots: BTreeMap, @@ -320,6 +323,7 @@ impl QisEngine { runtime, current_operations: None, num_physical_slots: 0, + num_qubits_hint: None, active_qubit_slots: BTreeMap::new(), free_qubit_slots: BTreeSet::new(), seen_program_qubits: BTreeSet::new(), @@ -412,6 +416,7 @@ impl QisEngine { runtime, current_operations: None, num_physical_slots: 0, + num_qubits_hint: None, active_qubit_slots: BTreeMap::new(), free_qubit_slots: BTreeSet::new(), seen_program_qubits: BTreeSet::new(), @@ -598,6 +603,9 @@ impl QisEngine { &[self.mapped_qubit(*qubit, qop)?], ); } + QuantumOp::Idle(duration, qubit) => { + builder.idle(*duration, &[self.mapped_qubit(*qubit, qop)?]); + } QuantumOp::CX(control, target) => { builder.cx(&[( self.mapped_qubit(*control, qop)?, @@ -643,6 +651,26 @@ impl QisEngine { message } + /// Convert freshly collected dynamic operations into a `ByteMessage`. + /// + /// Selene runtime plugins can opt in to lowering so their scheduler sees + /// the same operation stream that Selene would receive. Other runtimes keep + /// using PECOS's direct QIS lowering path. + fn lower_operations_to_bytemessage( + &mut self, + ops: &[Operation], + ) -> Result { + if self.runtime.supports_operation_lowering() { + let lowered_ops = self + .runtime + .lower_operations(ops) + .map_err(|e| PecosError::Generic(format!("Runtime lowering error: {e}")))?; + return self.quantum_ops_to_bytemessage(lowered_ops); + } + + self.operations_to_bytemessage(ops) + } + /// Convert already-materialized quantum ops into a `ByteMessage`. /// /// This path is used by runtimes that already present qubit ids in the fixed @@ -698,6 +726,9 @@ impl QisEngine { &[qubit], ); } + QuantumOp::Idle(duration, qubit) => { + builder.idle(duration, &[qubit]); + } QuantumOp::CX(control, target) => { builder.cx(&[(control, target)]); } @@ -760,6 +791,7 @@ impl Clone for QisEngine { runtime: dyn_clone::clone_box(&*self.runtime), current_operations: self.current_operations.clone(), num_physical_slots: self.num_physical_slots, + num_qubits_hint: self.num_qubits_hint, active_qubit_slots: self.active_qubit_slots.clone(), free_qubit_slots: self.free_qubit_slots.clone(), seen_program_qubits: self.seen_program_qubits.clone(), @@ -1120,11 +1152,20 @@ impl ClassicalEngine for QisEngine { // return the physical-slot high-water mark instead. The runtime can // report its own baseline (e.g. from `allocated_qubits` metadata) and // we take the larger of the two. - let num_qubits = self.runtime.num_qubits().max(self.num_physical_slots); + let num_qubits = self + .runtime + .num_qubits() + .max(self.num_physical_slots) + .max(self.num_qubits_hint.unwrap_or(0)); debug!("QisEngine: num_qubits() returning {num_qubits}"); num_qubits } + fn set_num_qubits_hint(&mut self, num_qubits: usize) { + self.num_qubits_hint = Some(num_qubits); + self.runtime.set_num_qubits(num_qubits); + } + fn set_seed(&mut self, seed: u64) { // Seed the RNG for generating per-shot seeds self.rng = PecosRng::seed_from_u64(seed); @@ -1346,7 +1387,7 @@ impl ControlEngine for QisEngine { // Track how many operations we're sending for simulation self.simulated_op_count = ops.len(); if !ops.is_empty() { - let commands = self.operations_to_bytemessage(&ops)?; + let commands = self.lower_operations_to_bytemessage(&ops)?; self.trace_operations_chunk( "pending_start", &ops, @@ -1365,7 +1406,7 @@ impl ControlEngine for QisEngine { if !self.pending_dynamic_ops.is_empty() { let final_ops = std::mem::take(&mut self.pending_dynamic_ops); if !final_ops.is_empty() { - let commands = self.operations_to_bytemessage(&final_ops)?; + let commands = self.lower_operations_to_bytemessage(&final_ops)?; self.trace_operations_chunk("pending_final", &final_ops, None, Some(&commands)); return Ok(EngineStage::NeedsProcessing(commands)); } @@ -1411,7 +1452,7 @@ impl ControlEngine for QisEngine { if !self.pending_dynamic_ops.is_empty() { let final_ops = std::mem::take(&mut self.pending_dynamic_ops); if !final_ops.is_empty() { - let commands = self.operations_to_bytemessage(&final_ops)?; + let commands = self.lower_operations_to_bytemessage(&final_ops)?; self.trace_operations_chunk("pending_final", &final_ops, None, Some(&commands)); return Ok(EngineStage::NeedsProcessing(commands)); } @@ -1455,7 +1496,7 @@ impl ControlEngine for QisEngine { if let Some(ops) = self.get_dynamic_operations() { self.simulated_op_count += ops.len(); if !ops.is_empty() { - let commands = self.operations_to_bytemessage(&ops)?; + let commands = self.lower_operations_to_bytemessage(&ops)?; self.trace_operations_chunk( "pending_continue", &ops, @@ -1475,7 +1516,7 @@ impl ControlEngine for QisEngine { if !self.pending_dynamic_ops.is_empty() { let final_ops = std::mem::take(&mut self.pending_dynamic_ops); if !final_ops.is_empty() { - let commands = self.operations_to_bytemessage(&final_ops)?; + let commands = self.lower_operations_to_bytemessage(&final_ops)?; self.trace_operations_chunk("pending_final", &final_ops, None, Some(&commands)); return Ok(EngineStage::NeedsProcessing(commands)); } diff --git a/crates/pecos-qis/src/runtime.rs b/crates/pecos-qis/src/runtime.rs index 2f5b30330..961e16ede 100644 --- a/crates/pecos-qis/src/runtime.rs +++ b/crates/pecos-qis/src/runtime.rs @@ -11,7 +11,7 @@ //! doesn't perform quantum simulation but manages program execution flow. use log::trace; -use pecos_qis_ffi_types::{OperationCollector, QuantumOp}; +use pecos_qis_ffi_types::{Operation, OperationCollector, QuantumOp}; use std::collections::BTreeMap; /// Result type for runtime operations @@ -201,6 +201,14 @@ pub trait QisRuntime: Send + Sync + dyn_clone::DynClone { /// Get the number of qubits used by the program fn num_qubits(&self) -> usize; + /// Provide an external qubit-count hint before runtime initialization. + /// + /// Dynamic programs discover qubits while the program runs, but some + /// runtime plugins need the total device size during initialization. + fn set_num_qubits(&mut self, _num_qubits: usize) { + // Default implementation does nothing. + } + /// Set the maximum number of operations to batch /// /// This allows tuning the trade-off between runtime overhead and @@ -210,6 +218,30 @@ pub trait QisRuntime: Send + Sync + dyn_clone::DynClone { let _ = size; } + /// Whether this runtime can lower freshly collected program operations. + /// + /// Dynamic QIS execution yields PECOS `Operation`s from the running program. + /// Most runtimes leave those operations for the engine to lower directly. + /// Selene runtime plugins opt in here so the operation stream flows through + /// the runtime scheduler before it reaches the PECOS quantum/noise stack. + fn supports_operation_lowering(&self) -> bool { + false + } + + /// Lower freshly collected program operations through the runtime. + /// + /// Implementations that return `true` from `supports_operation_lowering` + /// should accept the given operations, drain any runtime-ready scheduled + /// operations, and return them as PECOS quantum operations. + /// + /// # Errors + /// Returns an error if the runtime cannot accept or lower the operations. + fn lower_operations(&mut self, _operations: &[Operation]) -> Result> { + Err(RuntimeError::ExecutionError( + "runtime does not support operation lowering".to_string(), + )) + } + /// Check if the runtime needs to re-execute with known measurements /// /// This is set to true after measurements are provided for programs diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index 913c37c9a..a8643f0a7 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -7,11 +7,176 @@ use crate::runtime::{ClassicalState, QisRuntime, Result, RuntimeError, Shot}; use log::{debug, trace}; use pecos_qis_ffi_types::{Operation, OperationCollector, QuantumOp}; use std::collections::BTreeMap; -use std::ffi::c_void; +use std::ffi::{CString, c_void}; use std::mem::ManuallyDrop; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; +type RuntimeInstance = *mut c_void; +type RuntimeGetOperationInstance = *mut c_void; + +#[derive(Debug, Clone)] +enum RuntimeScheduledOp { + Rxy { + qubit_id: u64, + theta: f64, + phi: f64, + }, + Rz { + qubit_id: u64, + theta: f64, + }, + Rzz { + qubit_id_1: u64, + qubit_id_2: u64, + theta: f64, + }, + Measure { + qubit_id: u64, + result_id: u64, + }, + MeasureLeaked { + qubit_id: u64, + result_id: u64, + }, + Reset { + qubit_id: u64, + }, + Custom, +} + +#[derive(Debug, Default)] +struct RuntimeOperationBatch { + start_time_nanos: u64, + duration_nanos: u64, + invoked: bool, + operations: Vec, +} + +impl RuntimeOperationBatch { + fn end_time_nanos(&self) -> u64 { + self.start_time_nanos.saturating_add(self.duration_nanos) + } +} + +#[repr(C)] +struct SeleneRuntimeGetOperationInterface { + rzz_fn: extern "C" fn(RuntimeGetOperationInstance, u64, u64, f64), + rxy_fn: extern "C" fn(RuntimeGetOperationInstance, u64, f64, f64), + rz_fn: extern "C" fn(RuntimeGetOperationInstance, u64, f64), + measure_fn: extern "C" fn(RuntimeGetOperationInstance, u64, u64), + measure_leaked_fn: extern "C" fn(RuntimeGetOperationInstance, u64, u64), + reset_fn: extern "C" fn(RuntimeGetOperationInstance, u64), + custom_fn: extern "C" fn(RuntimeGetOperationInstance, usize, *const c_void, usize), + set_batch_time_fn: extern "C" fn(RuntimeGetOperationInstance, u64, u64), +} + +extern "C" fn runtime_batch_rxy( + instance: RuntimeGetOperationInstance, + qubit_id: u64, + theta: f64, + phi: f64, +) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch.operations.push(RuntimeScheduledOp::Rxy { + qubit_id, + theta, + phi, + }); + batch.invoked = true; +} + +extern "C" fn runtime_batch_rz(instance: RuntimeGetOperationInstance, qubit_id: u64, theta: f64) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch + .operations + .push(RuntimeScheduledOp::Rz { qubit_id, theta }); + batch.invoked = true; +} + +extern "C" fn runtime_batch_rzz( + instance: RuntimeGetOperationInstance, + qubit_id_1: u64, + qubit_id_2: u64, + theta: f64, +) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch.operations.push(RuntimeScheduledOp::Rzz { + qubit_id_1, + qubit_id_2, + theta, + }); + batch.invoked = true; +} + +extern "C" fn runtime_batch_measure( + instance: RuntimeGetOperationInstance, + qubit_id: u64, + result_id: u64, +) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch.operations.push(RuntimeScheduledOp::Measure { + qubit_id, + result_id, + }); + batch.invoked = true; +} + +extern "C" fn runtime_batch_measure_leaked( + instance: RuntimeGetOperationInstance, + qubit_id: u64, + result_id: u64, +) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch.operations.push(RuntimeScheduledOp::MeasureLeaked { + qubit_id, + result_id, + }); + batch.invoked = true; +} + +extern "C" fn runtime_batch_reset(instance: RuntimeGetOperationInstance, qubit_id: u64) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch + .operations + .push(RuntimeScheduledOp::Reset { qubit_id }); + batch.invoked = true; +} + +extern "C" fn runtime_batch_custom( + instance: RuntimeGetOperationInstance, + _tag: usize, + _data: *const c_void, + _data_len: usize, +) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch.operations.push(RuntimeScheduledOp::Custom); + batch.invoked = true; +} + +extern "C" fn runtime_batch_set_time( + instance: RuntimeGetOperationInstance, + start_time_nanos: u64, + duration_nanos: u64, +) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch.start_time_nanos = start_time_nanos; + batch.duration_nanos = duration_nanos; + batch.invoked = true; +} + +static RUNTIME_OPERATION_CALLBACKS: SeleneRuntimeGetOperationInterface = + SeleneRuntimeGetOperationInterface { + rzz_fn: runtime_batch_rzz, + rxy_fn: runtime_batch_rxy, + rz_fn: runtime_batch_rz, + measure_fn: runtime_batch_measure, + measure_leaked_fn: runtime_batch_measure_leaked, + reset_fn: runtime_batch_reset, + custom_fn: runtime_batch_custom, + set_batch_time_fn: runtime_batch_set_time, + }; + /// Selene runtime implementation /// /// The `library` field is wrapped in `ManuallyDrop` to prevent calling `dlclose()` @@ -22,6 +187,12 @@ pub struct SeleneRuntime { /// Path to the Selene .so file plugin_path: String, + /// Runtime-plugin init arguments passed to `selene_runtime_init`. + init_args: Vec, + + /// Additional dynamic library search directories needed by the plugin. + library_search_dirs: Vec, + /// Loaded library (if any) /// Wrapped in `ManuallyDrop` to prevent `dlclose()` during process exit. #[allow(dead_code)] @@ -58,6 +229,18 @@ pub struct SeleneRuntime { /// Track measurement result IDs that have been seen but not yet resolved pending_measurements: Vec, + + /// Program qubit handles mapped onto runtime qubit handles returned by qalloc. + program_to_runtime_qubits: BTreeMap, + + /// Program result IDs mapped onto runtime future IDs returned by measure. + program_to_runtime_results: BTreeMap, + + /// Reverse lookup for measurement operations emitted by the runtime plugin. + runtime_to_program_results: BTreeMap, + + /// End timestamp of the last scheduled physical operation per runtime qubit. + last_gate_time_end_nanos: Vec, } // SAFETY: SeleneRuntime owns its instance pointer exclusively. @@ -72,6 +255,8 @@ impl SeleneRuntime { pub fn new(plugin_path: impl AsRef) -> Self { Self { plugin_path: plugin_path.as_ref().to_string_lossy().to_string(), + init_args: Vec::new(), + library_search_dirs: Vec::new(), library: None, instance: None, state: ClassicalState::default(), @@ -83,9 +268,29 @@ impl SeleneRuntime { current_op_index: 0, needs_reexecution: false, pending_measurements: Vec::new(), + program_to_runtime_qubits: BTreeMap::new(), + program_to_runtime_results: BTreeMap::new(), + runtime_to_program_results: BTreeMap::new(), + last_gate_time_end_nanos: Vec::new(), } } + /// Create a runtime from the generic Selene runtime-plugin shape. + /// + /// `init_args` are passed directly to the plugin's `selene_runtime_init` + /// argc/argv pair. `library_search_dirs` are prepended to the platform + /// dynamic-library search path before loading the plugin. + pub fn with_plugin_config( + plugin_path: impl AsRef, + init_args: Vec, + library_search_dirs: Vec, + ) -> Self { + let mut runtime = Self::new(plugin_path); + runtime.init_args = init_args; + runtime.library_search_dirs = library_search_dirs; + runtime + } + /// Check if this runtime needs re-execution with known measurements /// /// This is set to true after measurements are provided for programs @@ -132,9 +337,14 @@ impl SeleneRuntime { return Ok(()); } + self.apply_library_search_dirs()?; + debug!( - "Loading Selene plugin from {} with {} qubits and {} results", - self.plugin_path, self.num_qubits, self.num_results + "Loading Selene plugin from {} with {} qubits, {} results, and {} init args", + self.plugin_path, + self.num_qubits, + self.num_results, + self.init_args.len() ); unsafe { @@ -150,13 +360,31 @@ impl SeleneRuntime { .get(b"selene_runtime_init") .map_err(|e| RuntimeError::FfiError(format!("Missing init function: {e}")))?; + let c_args = self + .init_args + .iter() + .map(|arg| { + CString::new(arg.as_str()).map_err(|_| { + RuntimeError::FfiError(format!( + "Selene runtime init argument contains NUL byte: {arg:?}" + )) + }) + }) + .collect::>>()?; + let arg_ptrs = c_args.iter().map(|arg| arg.as_ptr()).collect::>(); + let argv = if arg_ptrs.is_empty() { + std::ptr::null() + } else { + arg_ptrs.as_ptr() + }; + let mut instance: *mut c_void = std::ptr::null_mut(); let errno = init_fn( &raw mut instance, self.num_qubits as u64, - 0, // start time - 0, // argc - std::ptr::null(), // argv + 0, // start time + arg_ptrs.len() as u32, + argv, ); if errno != 0 { @@ -172,6 +400,35 @@ impl SeleneRuntime { Ok(()) } + fn apply_library_search_dirs(&self) -> Result<()> { + if self.library_search_dirs.is_empty() { + return Ok(()); + } + + let env_key = if cfg!(target_os = "windows") { + "PATH" + } else if cfg!(target_os = "macos") { + "DYLD_LIBRARY_PATH" + } else { + "LD_LIBRARY_PATH" + }; + + let existing = std::env::var_os(env_key).unwrap_or_default(); + let mut paths = self.library_search_dirs.clone(); + paths.extend(std::env::split_paths(&existing)); + let joined = std::env::join_paths(paths).map_err(|e| { + RuntimeError::FfiError(format!("Invalid Selene runtime library search path: {e}")) + })?; + + // SAFETY: This mirrors Selene's plugin runtime environment setup. The + // mutation happens immediately before loading the selected runtime. + unsafe { + std::env::set_var(env_key, joined); + } + + Ok(()) + } + /// Process operations from the interface sequentially /// /// This method now breaks at measurement operations to allow the quantum @@ -260,6 +517,512 @@ impl SeleneRuntime { Ok(Some(self.operations_buffer.clone())) } } + + fn runtime_qubit_for_program(&mut self, program_qubit: usize) -> Result { + if let Some(&runtime_qubit) = self.program_to_runtime_qubits.get(&program_qubit) { + return Ok(runtime_qubit); + } + + self.load_plugin()?; + let runtime_qubit = self.runtime_qalloc()?; + self.program_to_runtime_qubits + .insert(program_qubit, runtime_qubit); + self.num_qubits = self.num_qubits.max(program_qubit + 1); + Ok(runtime_qubit) + } + + fn runtime_qalloc(&self) -> 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()) + })?; + + unsafe { + let qalloc_fn = lib + .get:: i32>( + b"selene_runtime_qalloc", + ) + .map_err(|e| RuntimeError::FfiError(format!("Missing qalloc function: {e}")))?; + let mut runtime_qubit = 0; + let errno = qalloc_fn(instance, &raw mut runtime_qubit); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "qalloc failed with errno {errno}" + ))); + } + if runtime_qubit == u64::MAX { + return Err(RuntimeError::ExecutionError( + "Selene runtime failed to allocate a qubit".to_string(), + )); + } + Ok(runtime_qubit) + } + } + + fn runtime_qfree(&self, runtime_qubit: u64) -> Result<()> { + let Some(lib) = &self.library else { + return Ok(()); + }; + let Some(instance) = self.instance else { + return Ok(()); + }; + + unsafe { + let qfree_fn = lib + .get:: i32>(b"selene_runtime_qfree") + .map_err(|e| RuntimeError::FfiError(format!("Missing qfree function: {e}")))?; + let errno = qfree_fn(instance, runtime_qubit); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "qfree failed with errno {errno}" + ))); + } + } + + Ok(()) + } + + fn call_runtime_rxy(&self, runtime_qubit: u64, theta: f64, phi: f64) -> 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()) + })?; + + unsafe { + let rxy_fn = lib + .get:: i32>( + b"selene_runtime_rxy_gate", + ) + .map_err(|e| RuntimeError::FfiError(format!("Missing rxy function: {e}")))?; + let errno = rxy_fn(instance, runtime_qubit, theta, phi); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "rxy failed with errno {errno}" + ))); + } + } + + Ok(()) + } + + fn call_runtime_rz(&self, runtime_qubit: u64, theta: f64) -> 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()) + })?; + + unsafe { + let rz_fn = lib + .get:: i32>( + b"selene_runtime_rz_gate", + ) + .map_err(|e| RuntimeError::FfiError(format!("Missing rz function: {e}")))?; + let errno = rz_fn(instance, runtime_qubit, theta); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "rz failed with errno {errno}" + ))); + } + } + + Ok(()) + } + + fn call_runtime_rzz( + &self, + runtime_qubit_1: u64, + runtime_qubit_2: u64, + theta: f64, + ) -> 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()) + })?; + + unsafe { + let rzz_fn = lib + .get:: i32>( + b"selene_runtime_rzz_gate", + ) + .map_err(|e| RuntimeError::FfiError(format!("Missing rzz function: {e}")))?; + let errno = rzz_fn(instance, runtime_qubit_1, runtime_qubit_2, theta); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "rzz failed with errno {errno}" + ))); + } + } + + Ok(()) + } + + fn call_runtime_reset(&self, runtime_qubit: u64) -> 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()) + })?; + + unsafe { + let reset_fn = lib + .get:: i32>(b"selene_runtime_reset") + .map_err(|e| RuntimeError::FfiError(format!("Missing reset function: {e}")))?; + let errno = reset_fn(instance, runtime_qubit); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "reset failed with errno {errno}" + ))); + } + } + + Ok(()) + } + + fn call_runtime_measure(&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", + ) + .map_err(|e| RuntimeError::FfiError(format!("Missing measure 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 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 + .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()) + })?; + + unsafe { + let force_fn = lib + .get:: i32>( + b"selene_runtime_force_result", + ) + .map_err(|e| { + RuntimeError::FfiError(format!("Missing force_result function: {e}")) + })?; + let errno = force_fn(instance, runtime_result); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "force_result failed with errno {errno}" + ))); + } + } + + Ok(()) + } + + fn submit_operation_to_runtime( + &mut self, + op: &Operation, + lowered_ops: &mut Vec, + ) -> Result<()> { + match op { + Operation::AllocateQubit { id } => { + let _ = self.runtime_qubit_for_program(*id)?; + } + Operation::AllocateResult { id } => { + self.num_results = self.num_results.max(id + 1); + } + Operation::ReleaseQubit { id } => { + if let Some(runtime_qubit) = self.program_to_runtime_qubits.remove(id) { + self.runtime_qfree(runtime_qubit)?; + } + } + Operation::RecordOutput { .. } | Operation::Barrier => {} + Operation::Quantum(qop) => self.submit_quantum_op_to_runtime(qop, lowered_ops)?, + } + + Ok(()) + } + + fn submit_quantum_op_to_runtime( + &mut self, + qop: &QuantumOp, + lowered_ops: &mut Vec, + ) -> Result<()> { + match qop { + QuantumOp::RXY(theta, phi, qubit) => { + let runtime_qubit = self.runtime_qubit_for_program(*qubit)?; + self.call_runtime_rxy(runtime_qubit, *theta, *phi)?; + } + QuantumOp::RZ(theta, qubit) => { + let runtime_qubit = self.runtime_qubit_for_program(*qubit)?; + self.call_runtime_rz(runtime_qubit, *theta)?; + } + QuantumOp::RZZ(theta, qubit_1, qubit_2) => { + let runtime_qubit_1 = self.runtime_qubit_for_program(*qubit_1)?; + let runtime_qubit_2 = self.runtime_qubit_for_program(*qubit_2)?; + self.call_runtime_rzz(runtime_qubit_1, runtime_qubit_2, *theta)?; + } + QuantumOp::Measure(qubit, result_id) => { + let runtime_qubit = self.runtime_qubit_for_program(*qubit)?; + self.call_runtime_measure(runtime_qubit, *result_id)?; + } + QuantumOp::Reset(qubit) => { + let runtime_qubit = self.runtime_qubit_for_program(*qubit)?; + self.call_runtime_reset(runtime_qubit)?; + } + _ => { + lowered_ops.extend(self.drain_runtime_operations()?); + lowered_ops.push(self.map_passthrough_op_to_runtime_qubits(qop)?); + } + } + + Ok(()) + } + + fn map_passthrough_op_to_runtime_qubits(&mut self, qop: &QuantumOp) -> Result { + let mut map = |qubit: usize| -> Result { + let runtime_qubit = self.runtime_qubit_for_program(qubit)?; + usize::try_from(runtime_qubit).map_err(|_| { + RuntimeError::ExecutionError(format!( + "Runtime qubit id {runtime_qubit} does not fit in usize" + )) + }) + }; + + Ok(match qop { + QuantumOp::H(qubit) => QuantumOp::H(map(*qubit)?), + QuantumOp::X(qubit) => QuantumOp::X(map(*qubit)?), + QuantumOp::Y(qubit) => QuantumOp::Y(map(*qubit)?), + QuantumOp::Z(qubit) => QuantumOp::Z(map(*qubit)?), + QuantumOp::S(qubit) => QuantumOp::S(map(*qubit)?), + QuantumOp::Sdg(qubit) => QuantumOp::Sdg(map(*qubit)?), + QuantumOp::T(qubit) => QuantumOp::T(map(*qubit)?), + QuantumOp::Tdg(qubit) => QuantumOp::Tdg(map(*qubit)?), + QuantumOp::RX(theta, qubit) => QuantumOp::RX(*theta, map(*qubit)?), + QuantumOp::RY(theta, qubit) => QuantumOp::RY(*theta, map(*qubit)?), + QuantumOp::CX(control, target) => QuantumOp::CX(map(*control)?, map(*target)?), + QuantumOp::CY(control, target) => QuantumOp::CY(map(*control)?, map(*target)?), + QuantumOp::CZ(control, target) => QuantumOp::CZ(map(*control)?, map(*target)?), + QuantumOp::CH(control, target) => QuantumOp::CH(map(*control)?, map(*target)?), + QuantumOp::CRZ(theta, control, target) => { + QuantumOp::CRZ(*theta, map(*control)?, map(*target)?) + } + QuantumOp::CCX(control_1, control_2, target) => { + QuantumOp::CCX(map(*control_1)?, map(*control_2)?, map(*target)?) + } + QuantumOp::ZZ(qubit_1, qubit_2) => QuantumOp::ZZ(map(*qubit_1)?, map(*qubit_2)?), + QuantumOp::Idle(duration, qubit) => QuantumOp::Idle(*duration, map(*qubit)?), + QuantumOp::RXY(..) + | QuantumOp::RZ(..) + | QuantumOp::RZZ(..) + | QuantumOp::Measure(..) + | QuantumOp::Reset(..) => qop.clone(), + }) + } + + fn drain_runtime_operations(&mut self) -> Result> { + self.load_plugin()?; + let mut lowered_ops = Vec::new(); + + loop { + let mut batch = RuntimeOperationBatch::default(); + let errno = { + 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()) + })?; + + unsafe { + let get_next_fn = lib + .get:: i32>(b"selene_runtime_get_next_operations") + .map_err(|e| { + RuntimeError::FfiError(format!( + "Missing get_next_operations function: {e}" + )) + })?; + get_next_fn( + instance, + (&raw mut batch).cast::(), + &raw const RUNTIME_OPERATION_CALLBACKS, + ) + } + }; + + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "get_next_operations failed with errno {errno}" + ))); + } + + if !batch.invoked { + break; + } + + lowered_ops.extend(self.convert_runtime_batch(batch)?); + } + + Ok(lowered_ops) + } + + fn convert_runtime_batch(&mut self, batch: RuntimeOperationBatch) -> Result> { + let mut lowered_ops = Vec::new(); + let start_time = batch.start_time_nanos; + let end_time = batch.end_time_nanos(); + + for op in batch.operations { + match op { + RuntimeScheduledOp::Rxy { + qubit_id, + theta, + phi, + } => { + let qubit = self.runtime_qubit_to_usize(qubit_id)?; + self.push_idle_before(&mut lowered_ops, qubit, start_time)?; + lowered_ops.push(QuantumOp::RXY(theta, phi, qubit)); + self.mark_gate_end(qubit, end_time); + } + RuntimeScheduledOp::Rz { qubit_id, theta } => { + let qubit = self.runtime_qubit_to_usize(qubit_id)?; + self.push_idle_before(&mut lowered_ops, qubit, start_time)?; + lowered_ops.push(QuantumOp::RZ(theta, qubit)); + self.mark_gate_end(qubit, end_time); + } + RuntimeScheduledOp::Rzz { + qubit_id_1, + qubit_id_2, + theta, + } => { + let qubit_1 = self.runtime_qubit_to_usize(qubit_id_1)?; + let qubit_2 = self.runtime_qubit_to_usize(qubit_id_2)?; + self.push_idle_before(&mut lowered_ops, qubit_1, start_time)?; + self.push_idle_before(&mut lowered_ops, qubit_2, start_time)?; + lowered_ops.push(QuantumOp::RZZ(theta, qubit_1, qubit_2)); + self.mark_gate_end(qubit_1, end_time); + self.mark_gate_end(qubit_2, end_time); + } + RuntimeScheduledOp::Measure { + qubit_id, + result_id, + } + | 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.push_idle_before(&mut lowered_ops, qubit, start_time)?; + lowered_ops.push(QuantumOp::Measure(qubit, program_result)); + self.mark_gate_end(qubit, end_time); + } + RuntimeScheduledOp::Reset { qubit_id } => { + let qubit = self.runtime_qubit_to_usize(qubit_id)?; + lowered_ops.push(QuantumOp::Reset(qubit)); + self.mark_gate_end(qubit, end_time); + } + RuntimeScheduledOp::Custom => {} + } + } + + Ok(lowered_ops) + } + + fn runtime_qubit_to_usize(&mut self, runtime_qubit: u64) -> Result { + let qubit = usize::try_from(runtime_qubit).map_err(|_| { + RuntimeError::ExecutionError(format!( + "Runtime qubit id {runtime_qubit} does not fit in usize" + )) + })?; + self.ensure_timing_slot(qubit); + Ok(qubit) + } + + fn runtime_result_to_program_result(&self, runtime_result: u64) -> Result { + if let Some(&program_result) = self.runtime_to_program_results.get(&runtime_result) { + return Ok(program_result); + } + + usize::try_from(runtime_result).map_err(|_| { + RuntimeError::ExecutionError(format!( + "Runtime result id {runtime_result} does not fit in usize" + )) + }) + } + + fn ensure_timing_slot(&mut self, qubit: usize) { + if self.last_gate_time_end_nanos.len() <= qubit { + self.last_gate_time_end_nanos.resize(qubit + 1, 0); + } + } + + fn push_idle_before( + &mut self, + lowered_ops: &mut Vec, + qubit: usize, + start_time_nanos: u64, + ) -> Result<()> { + self.ensure_timing_slot(qubit); + let last_gate_end = self.last_gate_time_end_nanos[qubit]; + if last_gate_end > start_time_nanos { + return Err(RuntimeError::ExecutionError(format!( + "Runtime operation on qubit {qubit} starts before its previous operation ended: {start_time_nanos} < {last_gate_end}" + ))); + } + + let idle_time = start_time_nanos - last_gate_end; + if idle_time > 0 { + lowered_ops.push(QuantumOp::Idle(nanoseconds_to_seconds(idle_time), qubit)); + } + + Ok(()) + } + + fn mark_gate_end(&mut self, qubit: usize, end_time_nanos: u64) { + self.ensure_timing_slot(qubit); + self.last_gate_time_end_nanos[qubit] = end_time_nanos; + } +} + +fn nanoseconds_to_seconds(nanoseconds: u64) -> f64 { + std::time::Duration::from_nanos(nanoseconds).as_secs_f64() } impl Clone for SeleneRuntime { @@ -268,6 +1031,8 @@ impl Clone for SeleneRuntime { // The library itself can't be cloned, so we'll reload if needed Self { plugin_path: self.plugin_path.clone(), + init_args: self.init_args.clone(), + library_search_dirs: self.library_search_dirs.clone(), library: None, // Will be reloaded on demand instance: None, // Will be recreated on demand state: self.state.clone(), @@ -279,6 +1044,10 @@ impl Clone for SeleneRuntime { current_op_index: self.current_op_index, needs_reexecution: self.needs_reexecution, pending_measurements: self.pending_measurements.clone(), + 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(), + last_gate_time_end_nanos: self.last_gate_time_end_nanos.clone(), } } } @@ -325,6 +1094,22 @@ impl QisRuntime for SeleneRuntime { self.process_interface_ops() } + fn supports_operation_lowering(&self) -> bool { + true + } + + fn lower_operations(&mut self, operations: &[Operation]) -> Result> { + self.load_plugin()?; + let mut lowered_ops = Vec::new(); + + for op in operations { + self.submit_operation_to_runtime(op, &mut lowered_ops)?; + } + + lowered_ops.extend(self.drain_runtime_operations()?); + Ok(lowered_ops) + } + fn provide_measurements(&mut self, measurements: BTreeMap) -> Result<()> { debug!( "Received {} measurement results, num_results={}, allocated_results={:?}", @@ -341,45 +1126,33 @@ impl QisRuntime for SeleneRuntime { ); self.state.measurements.insert(*result_id, *value); - // For Selene runtime: Only pass measurements that were explicitly allocated - // The Selene runtime doesn't support dynamic result allocation, so we must - // check if this result was known at compile time - if let Some(interface) = &mut self.interface { - if interface.allocated_results.contains(result_id) { - // This result was explicitly allocated, try to pass to Selene runtime - if let Some(lib) = &self.library - && let Some(instance) = self.instance - { - unsafe { - if let Ok(set_result_fn) = - lib.get:: i32>( - b"selene_runtime_set_bool_result", - ) - { - let errno = set_result_fn(instance, *result_id as u64, *value); - if errno != 0 { - // Unexpected error - log it at trace level since this is normal - // for programs that don't explicitly allocate all result slots - log::trace!( - "Selene runtime returned error {errno} for result {result_id}" - ); - } + 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) = + lib.get:: i32>( + b"selene_runtime_set_bool_result", + ) + { + let errno = set_result_fn(instance, *runtime_result_id, *value); + if errno != 0 { + log::trace!( + "Selene runtime returned error {errno} for result {result_id}" + ); } } } - } else { - // Result wasn't explicitly allocated - this is normal for LLVM programs - // that use implicit result IDs in measurements - log::trace!( - "Measurement result {result_id} was not explicitly allocated, storing locally only" - ); } + } else { + log::trace!( + "Measurement result {result_id} was not allocated by the Selene runtime, storing locally only" + ); + } - // Update the interface with the measurement result + if let Some(interface) = &mut self.interface { interface.store_result(*result_id, *value); - } else { - // No interface loaded - just store locally - log::trace!("No interface loaded, storing measurement {result_id} locally"); } } @@ -422,6 +1195,10 @@ impl QisRuntime for SeleneRuntime { self.num_qubits } + fn set_num_qubits(&mut self, num_qubits: usize) { + self.num_qubits = self.num_qubits.max(num_qubits); + } + fn set_batch_size(&mut self, size: usize) { self.batch_size = size; } @@ -468,6 +1245,10 @@ impl QisRuntime for SeleneRuntime { self.current_op_index = 0; self.needs_reexecution = false; self.pending_measurements.clear(); + self.program_to_runtime_qubits.clear(); + self.program_to_runtime_results.clear(); + self.runtime_to_program_results.clear(); + self.last_gate_time_end_nanos.clear(); Ok(()) } @@ -477,12 +1258,10 @@ impl QisRuntime for SeleneRuntime { && let Some(instance) = self.instance { unsafe { - if let Ok(shot_end_fn) = lib - .get:: i32>( - b"selene_runtime_shot_end", - ) + if let Ok(shot_end_fn) = + lib.get:: i32>(b"selene_runtime_shot_end") { - let _ = shot_end_fn(instance, 0, 0); + let _ = shot_end_fn(instance); } } } @@ -515,6 +1294,10 @@ impl QisRuntime for SeleneRuntime { self.library = None; self.state = ClassicalState::default(); self.current_op_index = 0; + self.program_to_runtime_qubits.clear(); + self.program_to_runtime_results.clear(); + self.runtime_to_program_results.clear(); + self.last_gate_time_end_nanos.clear(); Ok(()) } @@ -556,4 +1339,37 @@ mod tests { assert_eq!(runtime.num_qubits(), 0); assert!(runtime.is_complete()); } + + #[test] + fn test_selene_runtime_plugin_config_clones() { + let runtime = SeleneRuntime::with_plugin_config( + "/path/to/selene.so", + vec!["--duration-ns-rxy=10".to_string()], + vec![PathBuf::from("/path/to/lib")], + ); + let cloned = runtime.clone(); + assert_eq!(cloned.init_args, ["--duration-ns-rxy=10"]); + assert_eq!(cloned.library_search_dirs, [PathBuf::from("/path/to/lib")]); + } + + #[test] + fn test_runtime_batch_timing_inserts_idle() { + let mut runtime = SeleneRuntime::new("/path/to/selene.so"); + let batch = RuntimeOperationBatch { + start_time_nanos: 20, + duration_nanos: 5, + invoked: true, + operations: vec![RuntimeScheduledOp::Rxy { + qubit_id: 0, + theta: 1.0, + phi: 0.5, + }], + }; + + let ops = runtime.convert_runtime_batch(batch).unwrap(); + assert_eq!( + ops, + vec![QuantumOp::Idle(20e-9, 0), QuantumOp::RXY(1.0, 0.5, 0)] + ); + } } diff --git a/python/pecos-rslib/src/engine_builders.rs b/python/pecos-rslib/src/engine_builders.rs index f18ef6271..2585b87bd 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 std::path::PathBuf; use std::sync::{Arc, Mutex}; // Import existing shot result types @@ -93,6 +94,7 @@ impl PyQasmEngineBuilder { #[derive(Clone)] pub struct PyQisEngineBuilder { pub(crate) inner: RustQisEngineBuilder, + runtime_configured: bool, } #[pymethods] @@ -101,6 +103,7 @@ impl PyQisEngineBuilder { fn new() -> Self { Self { inner: pecos_qis::qis_engine(), + runtime_configured: false, } } @@ -139,14 +142,42 @@ impl PyQisEngineBuilder { Ok(self.clone()) } - /// Use Selene simple runtime - fn selene_runtime(&mut self) -> PyResult { - let runtime = pecos_qis::selene_simple_runtime().map_err(|e| { + /// Use a Selene runtime built into the current PECOS/Cargo target. + #[pyo3(signature = (runtime_name = None))] + fn selene_runtime(&mut self, runtime_name: Option<&str>) -> PyResult { + let runtime = match runtime_name { + None | Some("selene_simple_runtime") => pecos_qis::selene_simple_runtime(), + Some(name) => pecos_qis::selene_runtime_auto(name), + } + .map_err(|e| { PyErr::new::(format!( "Failed to load Selene runtime: {e}" )) })?; self.inner = self.inner.clone().runtime(runtime); + self.runtime_configured = true; + Ok(self.clone()) + } + + /// Use a generic Selene runtime plugin by its shared library and plugin arguments. + #[pyo3(signature = (library_file, init_args = None, library_search_dirs = None))] + fn selene_runtime_plugin( + &mut self, + library_file: &str, + init_args: Option>, + library_search_dirs: Option>, + ) -> PyResult { + let runtime = pecos_qis::SeleneRuntime::with_plugin_config( + library_file, + init_args.unwrap_or_default(), + library_search_dirs + .unwrap_or_default() + .into_iter() + .map(PathBuf::from) + .collect(), + ); + self.inner = self.inner.clone().runtime(runtime); + self.runtime_configured = true; Ok(self.clone()) } @@ -163,14 +194,18 @@ impl PyQisEngineBuilder { .clone() .interface(pecos_qis::helios_interface_builder()); - // Always set Selene runtime to work with Helios interface - log::debug!("Setting Selene runtime for Helios interface"); - let runtime = pecos_qis::selene_simple_runtime().map_err(|e| { - PyErr::new::(format!( - "Failed to load Selene runtime: {e}" - )) - })?; - self.inner = self.inner.clone().runtime(runtime); + if !self.runtime_configured { + log::debug!( + "No runtime configured; setting default Selene runtime for Helios interface" + ); + let runtime = pecos_qis::selene_simple_runtime().map_err(|e| { + PyErr::new::(format!( + "Failed to load Selene runtime: {e}" + )) + })?; + self.inner = self.inner.clone().runtime(runtime); + self.runtime_configured = true; + } log::debug!("Helios interface and Selene runtime configured"); Ok(self.clone()) @@ -745,19 +780,26 @@ pub fn qasm_engine() -> PyQasmEngineBuilder { pub fn qis_engine() -> PyQisEngineBuilder { PyQisEngineBuilder { inner: pecos_qis::qis_engine(), + runtime_configured: false, } } /// Create a Selene-backed QIS Control Engine builder. #[pyfunction] -pub fn selene_engine() -> PyResult { - let runtime = pecos_qis::selene_simple_runtime().map_err(|e| { +#[pyo3(signature = (runtime_name = None))] +pub fn selene_engine(runtime_name: Option<&str>) -> PyResult { + let runtime = match runtime_name { + None | Some("selene_simple_runtime") => pecos_qis::selene_simple_runtime(), + Some(name) => pecos_qis::selene_runtime_auto(name), + } + .map_err(|e| { PyErr::new::(format!( "Failed to load Selene runtime: {e}" )) })?; Ok(PyQisEngineBuilder { inner: pecos_qis::qis_engine().runtime(runtime), + runtime_configured: true, }) } diff --git a/python/quantum-pecos/src/pecos/_engine_builders.py b/python/quantum-pecos/src/pecos/_engine_builders.py index f8f1e3047..3c0115e24 100644 --- a/python/quantum-pecos/src/pecos/_engine_builders.py +++ b/python/quantum-pecos/src/pecos/_engine_builders.py @@ -17,6 +17,8 @@ from __future__ import annotations +from os import PathLike, fspath +from pathlib import Path from typing import TYPE_CHECKING import pecos_rslib @@ -155,23 +157,21 @@ def program(self, program: Qis | Hugr | CompiledQis) -> Self: self._builder = self._builder.program(program) return self - def selene_runtime(self, runtime_name: str | None = None) -> Self: - """Use Selene simple runtime. + def selene_runtime(self, runtime: object | None = None) -> Self: + """Use a Selene runtime. Args: - runtime_name: Optional runtime name. ``None`` and - ``"selene_simple_runtime"`` both select the default runtime. + runtime: Optional runtime selector. ``None`` selects the default + ``selene_simple_runtime``. A string without path separators is + treated as a built Selene runtime library name. A path-like + value, or any Selene runtime plugin object exposing + ``library_file``, ``get_init_args()``, and optional + ``library_search_dirs``, is passed through generically. Returns: Self for method chaining. """ - if runtime_name not in (None, "selene_simple_runtime"): - msg = ( - "Python QisEngineBuilder.selene_runtime(runtime_name=...) only " - "supports the default 'selene_simple_runtime' wrapper today." - ) - raise NotImplementedError(msg) - self._builder = self._builder.selene_runtime() + self._builder = _configure_selene_runtime(self._builder, runtime) return self def interface(self, builder: object) -> Self: @@ -231,23 +231,58 @@ def qis_engine() -> QisEngineBuilder: return QisEngineBuilder() -def selene_engine(runtime_name: str | None = None) -> QisEngineBuilder: +def _looks_like_library_path(value: str) -> bool: + path = Path(value) + return ( + path.exists() + or "/" in value + or "\\" in value + or path.suffix in {".so", ".dylib", ".dll"} + ) + + +def _configure_selene_runtime(builder: object, runtime: object | None) -> object: + if runtime is None: + return builder.selene_runtime() + + if isinstance(runtime, str): + if _looks_like_library_path(runtime): + return builder.selene_runtime_plugin(runtime) + return builder.selene_runtime(runtime) + + if isinstance(runtime, PathLike): + return builder.selene_runtime_plugin(fspath(runtime)) + + library_file = getattr(runtime, "library_file", None) + if library_file is None: + msg = ( + "Selene runtime must be None, a built runtime name, a shared-library path, " + "or a Selene runtime plugin object with a 'library_file' property." + ) + raise TypeError(msg) + + get_init_args = getattr(runtime, "get_init_args", None) + init_args = list(get_init_args()) if callable(get_init_args) else [] + library_search_dirs = [fspath(path) for path in getattr(runtime, "library_search_dirs", [])] + return builder.selene_runtime_plugin( + fspath(library_file), + [str(arg) for arg in init_args], + library_search_dirs, + ) + + +def selene_engine(runtime: object | None = None) -> QisEngineBuilder: """Create a Selene-backed QIS engine builder. Args: - runtime_name: Optional built Selene runtime library name. - When omitted, the default simple Selene runtime is used. + runtime: Optional runtime selector. ``None`` selects the default + ``selene_simple_runtime``. A built runtime name, shared-library + path, or generic Selene runtime plugin object may also be supplied. Returns: QisEngineBuilder: A builder for Selene-backed QIS/HUGR simulations. """ - if runtime_name not in (None, "selene_simple_runtime"): - msg = ( - "Python selene_engine(runtime_name=...) is not currently supported by the wrapper. " - "Use the default runtime or call into pecos_rslib directly for custom runtime names." - ) - raise NotImplementedError(msg) - return QisEngineBuilder().selene_runtime().interface(pecos_rslib.qis_helios_interface()) + return QisEngineBuilder().selene_runtime(runtime).interface(pecos_rslib.qis_helios_interface()) __all__ = [ 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 bb555b916..0973f9ff7 100644 --- a/python/quantum-pecos/tests/pecos/test_selene_interface_integration.py +++ b/python/quantum-pecos/tests/pecos/test_selene_interface_integration.py @@ -137,6 +137,27 @@ def test_selene_engine_python_exports() -> None: assert isinstance(named_builder, pecos.QisEngineBuilder) +def test_selene_engine_accepts_generic_runtime_plugin_shape() -> None: + """A downstream Selene runtime plugin object is sufficient; PECOS does not need to know its package.""" + from pathlib import Path + + import pecos + + class RuntimePlugin: + def __init__(self) -> None: + self.library_file = Path("libcustom_selene_runtime.so") + self.library_search_dirs = [Path("custom-selene-libs")] + + def get_init_args(self) -> list[str]: + return ["--hardware-profile=custom"] + + builder = pecos.qis_engine().selene_runtime(RuntimePlugin()) + assert isinstance(builder, pecos.QisEngineBuilder) + + engine_builder = pecos.selene_engine(RuntimePlugin()) + assert isinstance(engine_builder, pecos.QisEngineBuilder) + + def test_sim_guppy_can_use_selene_engine_via_qis_path() -> None: """Test that sim(Guppy(...)).classical(selene_engine()) routes HUGR through the QIS path.""" import pecos From e9b1a0b32b86f07e655ef6114b86efaf71bbdfcf Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 29 May 2026 07:36:02 -0600 Subject: [PATCH 003/150] Rename observable-subgraph decoder to logical-subgraph, add >64-observable and unclassified-detector guards, and fix D0-only DEM parse --- .../pecos-decoder-core/src/ghost_protocol.rs | 10 +- crates/pecos-decoder-core/src/lib.rs | 4 +- .../src/logical_algorithm.rs | 36 ++-- ...rvable_subgraph.rs => logical_subgraph.rs} | 191 ++++++++++++++---- .../committed.rs} | 26 +-- .../windowed.rs} | 42 ++-- crates/pecos-decoders/src/lib.rs | 8 +- docs/user-guide/cmake-setup.md | 4 +- examples/surface/brickwork_sweep.py | 34 ++-- examples/surface/coherent_noise_sweep.py | 6 +- .../src/fault_tolerance_bindings.rs | 88 ++++---- .../src/pecos/qec/surface/logical_circuit.py | 16 +- .../tests/qec/surface/test_circuit_fuzz.py | 54 ++--- 13 files changed, 315 insertions(+), 204 deletions(-) rename crates/pecos-decoder-core/src/{observable_subgraph.rs => logical_subgraph.rs} (83%) rename crates/pecos-decoder-core/src/{committed_osd.rs => logical_subgraph/committed.rs} (86%) rename crates/pecos-decoder-core/src/{windowed_osd.rs => logical_subgraph/windowed.rs} (88%) diff --git a/crates/pecos-decoder-core/src/ghost_protocol.rs b/crates/pecos-decoder-core/src/ghost_protocol.rs index 44cba87cc..99cca51d3 100644 --- a/crates/pecos-decoder-core/src/ghost_protocol.rs +++ b/crates/pecos-decoder-core/src/ghost_protocol.rs @@ -135,9 +135,9 @@ pub struct GhostMessage { #[must_use] pub fn extract_ghost_edges_from_dem( dem_str: &str, - stab_coords: &crate::observable_subgraph::StabCoords, + stab_coords: &crate::logical_subgraph::StabCoords, ) -> Vec { - use crate::observable_subgraph::classify_detector; + use crate::logical_subgraph::classify_detector; use std::collections::BTreeMap; // Parse detector coordinates @@ -263,7 +263,7 @@ mod tests { #[test] fn test_extract_ghost_edges_from_synthetic_dem() { - use crate::observable_subgraph::QubitStabCoords; + use crate::logical_subgraph::QubitStabCoords; // Two qubits: qubit 0 has X-stab at (1,1) and Z-stab at (3,1), // qubit 1 has X-stab at (7,1) and Z-stab at (9,1). @@ -309,7 +309,7 @@ mod tests { #[test] fn test_extract_no_ghost_edges_graphlike_dem() { - use crate::observable_subgraph::QubitStabCoords; + use crate::logical_subgraph::QubitStabCoords; let stab_coords = vec![QubitStabCoords { x_positions: vec![(1.0, 1.0)], @@ -329,7 +329,7 @@ mod tests { #[test] fn test_extract_three_same_qubit_no_ghost() { - use crate::observable_subgraph::QubitStabCoords; + use crate::logical_subgraph::QubitStabCoords; let stab_coords = vec![QubitStabCoords { x_positions: vec![(1.0, 1.0), (1.0, 3.0)], diff --git a/crates/pecos-decoder-core/src/lib.rs b/crates/pecos-decoder-core/src/lib.rs index 6ac5e2bba..acbb2c244 100644 --- a/crates/pecos-decoder-core/src/lib.rs +++ b/crates/pecos-decoder-core/src/lib.rs @@ -25,7 +25,6 @@ pub mod adaptive; pub mod advanced; pub mod bp_matching; -pub mod committed_osd; pub mod config; pub mod correlated_decoder; pub mod correlated_reweighting; @@ -40,7 +39,7 @@ pub mod k_mwpm; pub mod logical_algorithm; pub mod matrix; pub mod multi_decoder; -pub mod observable_subgraph; +pub mod logical_subgraph; pub mod pauli_frame; pub mod perturbed; pub mod preprocessor; @@ -48,7 +47,6 @@ pub mod results; pub mod streaming; pub mod telemetry; pub mod two_pass_decoder; -pub mod windowed_osd; use ndarray::ArrayView1; diff --git a/crates/pecos-decoder-core/src/logical_algorithm.rs b/crates/pecos-decoder-core/src/logical_algorithm.rs index cb97a7113..9c366e209 100644 --- a/crates/pecos-decoder-core/src/logical_algorithm.rs +++ b/crates/pecos-decoder-core/src/logical_algorithm.rs @@ -18,8 +18,8 @@ //! //! # Decoding Modes //! -//! - **Full-circuit**: Uses the full DEM's OSD for maximum accuracy. -//! Equivalent to `ObservableSubgraphDecoder` on the full circuit. +//! - **Full-circuit**: Uses the full DEM's logical-subgraph decoder for maximum accuracy. +//! Equivalent to `LogicalSubgraphDecoder` on the full circuit. //! - **Per-segment** (future streaming): Each segment decoded independently //! with buffer overlap at gate boundaries. @@ -91,17 +91,17 @@ pub struct AlgorithmDescriptor { /// Decoder for logical quantum algorithms. /// -/// Wraps a full-circuit decoder (OSD) with segment metadata. The +/// Wraps a full-circuit decoder (logical-subgraph decoder) with segment metadata. The /// segment structure enables: /// - Tracking which gates occur at which point in the circuit /// - Pauli frame propagation for T-gate/measurement corrections /// - Future streaming mode with per-segment windowed decoding /// /// In the current implementation, `decode_shot` delegates to the -/// full-circuit OSD for maximum accuracy. The segment structure is +/// full-circuit logical-subgraph decoder for maximum accuracy. The segment structure is /// metadata for frame tracking and streaming (step 5). pub struct LogicalAlgorithmDecoder { - /// Full-circuit decoder (OSD on the complete DEM). + /// Full-circuit decoder (logical-subgraph decoder on the complete DEM). full_decoder: Box, /// Segment metadata for streaming/frame tracking. segments: Vec, @@ -114,7 +114,7 @@ pub struct LogicalAlgorithmDecoder { impl LogicalAlgorithmDecoder { /// Build from a full-circuit decoder and algorithm descriptor. /// - /// The `full_decoder` is typically an `ObservableSubgraphDecoder` + /// The `full_decoder` is typically an `LogicalSubgraphDecoder` /// built from the full circuit DEM. #[must_use] pub fn new( @@ -213,7 +213,7 @@ impl ObservableDecoder for LogicalAlgorithmDecoder { /// Streaming wrapper for `LogicalAlgorithmDecoder`. /// -/// Buffers syndrome data round-by-round. The full-circuit OSD decodes +/// Buffers syndrome data round-by-round. The full-circuit logical-subgraph decoder decodes /// the entire accumulated syndrome at `flush()` for maximum accuracy. /// /// The segment structure tracks which rounds belong to which segment. @@ -257,7 +257,7 @@ impl ObservableDecoder for LogicalAlgorithmDecoder { /// assert_eq!(obs, 1); /// ``` pub struct StreamingLogicalDecoder { - /// The underlying batch decoder (full-circuit OSD). + /// The underlying batch decoder (full-circuit logical-subgraph decoder). inner: LogicalAlgorithmDecoder, /// Accumulated syndrome buffer (full circuit size). syndrome: Vec, @@ -305,7 +305,7 @@ impl StreamingLogicalDecoder { self.rounds_fed += 1; } - /// Decode the accumulated syndrome using the full-circuit OSD. + /// Decode the accumulated syndrome using the full-circuit logical-subgraph decoder. /// /// Returns the observable correction mask. This is the final /// correction to apply to raw measurement outcomes. @@ -395,7 +395,7 @@ use crate::decode_budget::{DecodeBudget, DecodeStrategy, DetectorRegion}; /// /// - **Offline** (ion trap / simulation): `FullCircuitStrategy` — buffer /// everything, decode at end. Maximum accuracy. -/// - **Streaming** (neutral atom): `CommittedOsdStrategy` — decode and +/// - **Streaming** (neutral atom): `CommittedLogicalSubgraphStrategy` — decode and /// commit at segment boundaries. Bounded memory. /// - **Real-time** (superconducting): windowed UF with ghost protocol /// (future). @@ -457,7 +457,7 @@ impl LogicalCircuitDecoder { /// Decode a full shot (batch mode). /// - /// For offline/ion trap budgets: equivalent to full-circuit OSD. + /// For offline/ion trap budgets: equivalent to full-circuit logical-subgraph decoder. /// For streaming budgets: decodes and commits each segment. pub fn decode_shot(&mut self, full_syndrome: &[u8]) -> Result { self.reset(); @@ -561,7 +561,7 @@ pub struct FullCircuitStrategy { } impl FullCircuitStrategy { - /// Wrap any `ObservableDecoder` (typically OSD). + /// Wrap any `ObservableDecoder` (typically logical-subgraph decoder). #[must_use] pub fn new(decoder: Box) -> Self { Self { inner: decoder } @@ -588,18 +588,18 @@ impl DecodeStrategy for FullCircuitStrategy { } // ============================================================================ -// Strategy: Windowed OSD (neutral atom / medium budget) +// Strategy: Windowed logical-subgraph decoding (neutral atom / medium budget) // ============================================================================ -/// Windowed OSD strategy: per-observable subgraph windowed decoding. +/// Windowed logical-subgraph strategy: per-logical-operator subgraph windowed decoding. /// /// Each observable's subgraph is graphlike (no hyperedges). A windowed /// decoder (sandwich or plain PM) runs inside each subgraph with bounded /// latency. The full matching graph is pre-built; only syndrome routing /// and per-window matching are per-shot work. /// -/// This achieves bounded-latency streaming with OSD-level accuracy. -pub struct WindowedOsdStrategy { +/// This achieves bounded-latency streaming with logical-subgraph decoder-level accuracy. +pub struct WindowedLogicalSubgraphStrategy { /// Per-subgraph decoders (windowed or plain). subgraph_decoders: Vec>, /// Per-subgraph detector maps: `subgraph_detector_maps`[i][local] = global. @@ -610,7 +610,7 @@ pub struct WindowedOsdStrategy { _num_observables: usize, } -impl WindowedOsdStrategy { +impl WindowedLogicalSubgraphStrategy { /// Build from pre-extracted subgraph DEMs and detector maps. /// /// `subgraph_dems`: per-observable DEM strings (graphlike). @@ -644,7 +644,7 @@ impl WindowedOsdStrategy { } } -impl DecodeStrategy for WindowedOsdStrategy { +impl DecodeStrategy for WindowedLogicalSubgraphStrategy { fn decode(&mut self, syndrome: &[u8]) -> Result { let mut obs_mask = 0u64; diff --git a/crates/pecos-decoder-core/src/observable_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs similarity index 83% rename from crates/pecos-decoder-core/src/observable_subgraph.rs rename to crates/pecos-decoder-core/src/logical_subgraph.rs index 31ce1480a..c343d069b 100644 --- a/crates/pecos-decoder-core/src/observable_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -10,7 +10,7 @@ // or implied. See the License for the specific language governing permissions and limitations under // the License. -//! Per-observable subgraph decoder for transversal gates. +//! Per-logical-operator subgraph decoder for transversal gates. //! //! Based on the insight (proved independently by Serra-Peralta et al. //! arXiv:2505.13599 and Cain et al. arXiv:2505.13587) that per-observable @@ -25,7 +25,7 @@ //! to identify which (qubit, `stab_type`) groups form its observing region //! 3. Extract a sub-DEM restricted to those detectors //! 4. Run any MWPM-compatible decoder on each subgraph independently -//! 5. Combine per-observable corrections +//! 5. Combine per-logical-operator corrections //! //! # Observing Region //! @@ -36,6 +36,9 @@ //! - ALL detectors in those groups form the observing region //! - This preserves the graphlike property of each subgraph +pub mod committed; +pub mod windowed; + use std::collections::{BTreeMap, BTreeSet}; use crate::ObservableDecoder; @@ -137,9 +140,9 @@ impl SparseDem { if !det_set.remove(&d) { det_set.insert(d); } + has_any_detector = true; if d > max_detector { max_detector = d; - has_any_detector = true; } } } else if let Some(l_str) = token.strip_prefix('L') @@ -184,9 +187,9 @@ impl SparseDem { } if let Some(d) = parse_u32_fast(&line_bytes[start..i]) { dets.push(d); + has_any_detector = true; if d > max_detector { max_detector = d; - has_any_detector = true; } } } else if line_bytes[i] == b'L' { @@ -239,9 +242,9 @@ impl SparseDem { } if let Some(d) = parse_u32_fast(&bytes[start..pos]) { detector_coords.insert(d as usize, coords); + has_any_detector = true; if d > max_detector { max_detector = d; - has_any_detector = true; } } } @@ -346,7 +349,7 @@ pub fn classify_detector(x: f64, y: f64, stab_coords: &StabCoords) -> Option; -pub fn partition_dem_by_observable( +pub fn partition_dem_by_logical( dem_str: &str, stab_coords: &StabCoords, -) -> Result, DecoderError> { - partition_dem_by_observable_windowed(dem_str, stab_coords, None) +) -> Result, DecoderError> { + partition_dem_by_logical_windowed(dem_str, stab_coords, None) } -pub fn partition_dem_by_observable_windowed( +pub fn partition_dem_by_logical_windowed( dem_str: &str, stab_coords: &StabCoords, max_time_radius: MaxTimeRadius, -) -> Result, DecoderError> { +) -> Result, DecoderError> { // Single-pass sparse DEM parsing: mechanisms + detector coordinates. let sdem = SparseDem::from_dem_str(dem_str)?; let coord_map = &sdem.detector_coords; + // Observable flips are packed into a u64 mask (bit i = observable i), so + // more than 64 observables would silently overflow the shift. Fail loud. + if sdem.num_observables > 64 { + return Err(DecoderError::InvalidConfiguration(format!( + "LogicalSubgraphDecoder packs observable flips into a u64 mask and \ + supports at most 64 observables, but the DEM declares {}", + sdem.num_observables, + ))); + } + + // Detectors that appear in an error mechanism are the only ones that affect + // decoding. Any such detector that fails to classify would be silently + // dropped from every observing region and corrupt the decode, so treat that + // as a configuration error rather than a silent fallback. Detectors that are + // declared but never used (numbering gaps, unused ancillas) are ignored. + let mut used = vec![false; sdem.num_detectors]; + for (_, dets, _) in &sdem.mechanisms { + for &d in dets { + if (d as usize) < sdem.num_detectors { + used[d as usize] = true; + } + } + } + // Classify each detector into a (qubit, stab_type) group. let mut det_group: Vec> = vec![None; sdem.num_detectors]; let mut group_detectors: BTreeMap> = BTreeMap::new(); + let mut unclassified: Vec = Vec::new(); for (d, group_slot) in det_group.iter_mut().enumerate().take(sdem.num_detectors) { - if let Some(coords) = coord_map.get(&d) - && coords.len() >= 2 - { - let (x, y) = (coords[0], coords[1]); - if let Some(group) = classify_detector(x, y, stab_coords) { - *group_slot = Some(group); - group_detectors.entry(group).or_default().insert(d); + let group = match coord_map.get(&d) { + Some(coords) if coords.len() >= 2 => { + classify_detector(coords[0], coords[1], stab_coords) + } + _ => None, + }; + match group { + Some(g) => { + *group_slot = Some(g); + group_detectors.entry(g).or_default().insert(d); } + None if used[d] => unclassified.push(d), + None => {} } } + if !unclassified.is_empty() { + let shown: Vec = unclassified + .iter() + .take(5) + .map(|&d| match coord_map.get(&d) { + Some(c) => format!("D{d} at {c:?}"), + None => format!("D{d} (no coordinates)"), + }) + .collect(); + return Err(DecoderError::InvalidConfiguration(format!( + "{} detector(s) used in error mechanisms could not be classified against \ + stab_coords (missing coordinates, or a coordinate scale / completeness \ + mismatch). Examples: {}", + unclassified.len(), + shown.join(", "), + ))); + } + // For each observable, find its observing region. let mut subgraphs = Vec::with_capacity(sdem.num_observables); @@ -463,7 +516,7 @@ pub fn partition_dem_by_observable_windowed( } if region_detectors.is_empty() { - subgraphs.push(ObservableSubgraph { + subgraphs.push(LogicalSubgraph { observable_idx: obs_idx, detector_map: Vec::new(), inverse_map: vec![None; sdem.num_detectors], @@ -532,7 +585,7 @@ pub fn partition_dem_by_observable_windowed( let num_sub = detector_map.len(); let edges = DemMatchingGraph::merge_parallel_edges(edges); - subgraphs.push(ObservableSubgraph { + subgraphs.push(LogicalSubgraph { observable_idx: obs_idx, detector_map, inverse_map, @@ -553,19 +606,19 @@ pub fn partition_dem_by_observable_windowed( // Decoder // ============================================================================ -/// Per-observable subgraph decoder. +/// Per-logical-operator subgraph decoder. /// /// Wraps a factory function that creates per-subgraph inner decoders. /// Any `ObservableDecoder` works as the inner decoder (UF, Fusion Blossom, /// perturbed ensemble, etc.). -pub struct ObservableSubgraphDecoder { - subgraphs: Vec, +pub struct LogicalSubgraphDecoder { + subgraphs: Vec, decoders: Vec>, num_observables: usize, sub_syndromes: Vec>, } -impl ObservableSubgraphDecoder { +impl LogicalSubgraphDecoder { /// Build from a DEM string, stabilizer coordinates, and inner decoder factory. /// /// # Errors @@ -595,7 +648,7 @@ impl ObservableSubgraphDecoder { &DemMatchingGraph, ) -> Result, DecoderError>, { - let subgraphs = partition_dem_by_observable_windowed(dem, stab_coords, max_time_radius)?; + let subgraphs = partition_dem_by_logical_windowed(dem, stab_coords, max_time_radius)?; let num_observables = subgraphs.len(); let mut decoders = Vec::with_capacity(subgraphs.len()); @@ -621,7 +674,7 @@ impl ObservableSubgraphDecoder { /// Access a subgraph. #[must_use] - pub fn subgraph(&self, obs_idx: usize) -> Option<&ObservableSubgraph> { + pub fn subgraph(&self, obs_idx: usize) -> Option<&LogicalSubgraph> { self.subgraphs.get(obs_idx) } @@ -688,7 +741,7 @@ impl ObservableSubgraphDecoder { } } -impl ObservableDecoder for ObservableSubgraphDecoder { +impl ObservableDecoder for LogicalSubgraphDecoder { fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { let mut obs_mask = 0u64; @@ -723,13 +776,13 @@ impl ObservableDecoder for ObservableSubgraphDecoder { } } -/// Parallel per-observable subgraph decoder using rayon. -pub struct ParallelObservableSubgraphDecoder { - subgraphs: Vec, +/// Parallel per-logical-operator subgraph decoder using rayon. +pub struct ParallelLogicalSubgraphDecoder { + subgraphs: Vec, decoders: Vec>>, } -impl ParallelObservableSubgraphDecoder { +impl ParallelLogicalSubgraphDecoder { /// Build from a DEM string, stabilizer coordinates, and inner decoder factory. /// /// # Errors @@ -743,7 +796,7 @@ impl ParallelObservableSubgraphDecoder { where F: FnMut(&DemMatchingGraph) -> Result, DecoderError>, { - let subgraphs = partition_dem_by_observable(dem, stab_coords)?; + let subgraphs = partition_dem_by_logical(dem, stab_coords)?; let mut decoders = Vec::with_capacity(subgraphs.len()); for sg in &subgraphs { @@ -876,7 +929,7 @@ mod tests { "error(0.01) D0 L0\n", // boundary edge → D0 is (qubit 0, X) ); let sc = simple_stab_coords(); - let sgs = partition_dem_by_observable(dem, &sc).unwrap(); + let sgs = partition_dem_by_logical(dem, &sc).unwrap(); assert_eq!(sgs.len(), 1); // Boundary edge D0 L0 → D0 is qubit 0 X-type. // Observing region = all qubit-0 X-type detectors = {D0}. @@ -900,7 +953,7 @@ mod tests { "error(0.01) D2 D3\n", // D2-D3 edge ); let sc = simple_stab_coords(); - let sgs = partition_dem_by_observable(dem, &sc).unwrap(); + let sgs = partition_dem_by_logical(dem, &sc).unwrap(); assert_eq!(sgs.len(), 2); assert_eq!(sgs[0].detector_map, vec![0]); // qubit 0 X-type only assert_eq!(sgs[1].detector_map, vec![2]); // qubit 1 X-type only @@ -915,7 +968,7 @@ mod tests { "error(0.01) D1 L1\n", ); let sc = simple_stab_coords(); - let mut dec = ObservableSubgraphDecoder::from_dem(dem, &sc, |_| { + let mut dec = LogicalSubgraphDecoder::from_dem(dem, &sc, |_| { Ok(Box::new(FixedDecoder(1)) as Box) }) .unwrap(); @@ -929,6 +982,66 @@ mod tests { assert_eq!(obs, 0b10); } + #[test] + fn test_unclassified_used_detector_errors() { + // D1 is used in a mechanism but sits at coords matching no stabilizer. + let dem = concat!( + "detector(1, 0, 0) D0\n", + "detector(99, 99, 0) D1\n", + "error(0.01) D0 L0\n", + "error(0.01) D0 D1\n", // D1 used here, but won't classify + ); + let sc = simple_stab_coords(); + let err = partition_dem_by_logical(dem, &sc).unwrap_err(); + assert!( + matches!(err, DecoderError::InvalidConfiguration(_)), + "expected InvalidConfiguration, got {err:?}" + ); + } + + #[test] + fn test_unclassified_unused_detector_ignored() { + // D1 has bad coords but is never used in a mechanism -> ignored, no error. + let dem = concat!( + "detector(1, 0, 0) D0\n", + "detector(99, 99, 0) D1\n", + "error(0.01) D0 L0\n", + ); + let sc = simple_stab_coords(); + assert!(partition_dem_by_logical(dem, &sc).is_ok()); + } + + #[test] + fn test_single_detector_d0() { + // Regression: a DEM whose only detector is D0 must parse with + // num_detectors == 1 (not 0). D0 used to never set has_any_detector + // because `0 > max_detector` is false, yielding an empty det_group + // and an out-of-bounds panic downstream. + let dem = concat!( + "detector(1, 0, 0) D0\n", // qubit 0 X + "error(0.01) D0 L0\n", // boundary edge on D0 + ); + let sc = simple_stab_coords(); + let sgs = partition_dem_by_logical(dem, &sc).unwrap(); + assert_eq!(sgs.len(), 1); + assert_eq!(sgs[0].detector_map, vec![0]); + } + + #[test] + fn test_too_many_observables_errors() { + // 65 observables exceeds the u64 mask capacity. + let mut dem = String::from("detector(1, 0, 0) D0\n"); + for l in 0..65 { + dem.push_str(&format!("error(0.01) D0 L{l}\n")); + } + let sc = simple_stab_coords(); + let err = partition_dem_by_logical(&dem, &sc).unwrap_err(); + assert!( + matches!(err, DecoderError::InvalidConfiguration(_)), + "expected InvalidConfiguration, got {err:?}" + ); + } + #[test] fn test_parallel_decoder() { let dem = concat!( @@ -938,7 +1051,7 @@ mod tests { "error(0.01) D1 L1\n", ); let sc = simple_stab_coords(); - let dec = ParallelObservableSubgraphDecoder::from_dem(dem, &sc, |_| { + let dec = ParallelLogicalSubgraphDecoder::from_dem(dem, &sc, |_| { Ok(Box::new(NullDecoder) as Box) }) .unwrap(); diff --git a/crates/pecos-decoder-core/src/committed_osd.rs b/crates/pecos-decoder-core/src/logical_subgraph/committed.rs similarity index 86% rename from crates/pecos-decoder-core/src/committed_osd.rs rename to crates/pecos-decoder-core/src/logical_subgraph/committed.rs index 8157cd6a8..4397ebc40 100644 --- a/crates/pecos-decoder-core/src/committed_osd.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph/committed.rs @@ -10,9 +10,9 @@ // or implied. See the License for the specific language governing permissions and limitations under // the License. -//! OSD with software commitment for streaming decoding. +//! logical-subgraph decoder with software commitment for streaming decoding. //! -//! Wraps an `ObservableSubgraphDecoder` with per-detector commitment +//! Wraps an `LogicalSubgraphDecoder` with per-detector commitment //! tracking. Committed detectors are masked during future decodes, //! implementing the "software commitment" concept from Cain et al. //! (arXiv:2505.13587). @@ -23,7 +23,7 @@ use crate::ObservableDecoder; use crate::decode_budget::{DecodeStrategy, DetectorRegion}; use crate::errors::DecoderError; -use crate::observable_subgraph::ObservableSubgraphDecoder; +use crate::logical_subgraph::LogicalSubgraphDecoder; /// Observable subgraph decoder with software commitment. /// @@ -34,9 +34,9 @@ use crate::observable_subgraph::ObservableSubgraphDecoder; /// /// The total correction is `committed_obs ^ active_obs`: the XOR /// of committed corrections and the latest active decode. -pub struct CommittedOsdDecoder { - /// The underlying OSD (unchanged). - inner: ObservableSubgraphDecoder, +pub struct CommittedLogicalSubgraphDecoder { + /// The underlying logical-subgraph decoder (unchanged). + inner: LogicalSubgraphDecoder, /// Per-detector commitment state. True = committed. committed: Vec, /// Accumulated observable correction from committed regions. @@ -47,10 +47,10 @@ pub struct CommittedOsdDecoder { masked_syndrome: Vec, } -impl CommittedOsdDecoder { - /// Wrap an existing OSD with commitment tracking. +impl CommittedLogicalSubgraphDecoder { + /// Wrap an existing logical-subgraph decoder with commitment tracking. #[must_use] - pub fn new(inner: ObservableSubgraphDecoder, num_detectors: usize) -> Self { + pub fn new(inner: LogicalSubgraphDecoder, num_detectors: usize) -> Self { Self { inner, committed: vec![false; num_detectors], @@ -63,7 +63,7 @@ impl CommittedOsdDecoder { /// Decode only uncommitted detectors. /// /// Committed detectors are masked to 0 before passing to the - /// inner OSD. Returns the correction for the active (uncommitted) + /// inner logical-subgraph decoder. Returns the correction for the active (uncommitted) /// region. pub fn decode_active(&mut self, syndrome: &[u8]) -> Result { // Build masked syndrome: zero out committed detectors @@ -123,7 +123,7 @@ impl CommittedOsdDecoder { } } -impl ObservableDecoder for CommittedOsdDecoder { +impl ObservableDecoder for CommittedLogicalSubgraphDecoder { fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { // Full decode: committed XOR active let active = self.decode_active(syndrome)?; @@ -131,7 +131,7 @@ impl ObservableDecoder for CommittedOsdDecoder { } } -impl DecodeStrategy for CommittedOsdDecoder { +impl DecodeStrategy for CommittedLogicalSubgraphDecoder { fn decode(&mut self, syndrome: &[u8]) -> Result { self.decode_active(syndrome) } @@ -150,7 +150,7 @@ impl DecodeStrategy for CommittedOsdDecoder { } fn reset(&mut self) { - CommittedOsdDecoder::reset(self); + CommittedLogicalSubgraphDecoder::reset(self); } } diff --git a/crates/pecos-decoder-core/src/windowed_osd.rs b/crates/pecos-decoder-core/src/logical_subgraph/windowed.rs similarity index 88% rename from crates/pecos-decoder-core/src/windowed_osd.rs rename to crates/pecos-decoder-core/src/logical_subgraph/windowed.rs index 00e575ffe..2bc561611 100644 --- a/crates/pecos-decoder-core/src/windowed_osd.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph/windowed.rs @@ -12,7 +12,7 @@ //! Windowed observable subgraph decoder. //! -//! Splits a DEM into time windows, runs per-observable subgraph decoding +//! Splits a DEM into time windows, runs per-logical-operator subgraph decoding //! within each window. This prevents the observing region from spanning //! the full circuit at deep depths, maintaining decoding accuracy. //! @@ -27,26 +27,26 @@ use std::collections::BTreeMap; use crate::ObservableDecoder; use crate::dem::{DemCheckMatrix, DemMatchingGraph, MatchingEdge, parse_detector_coords}; use crate::errors::DecoderError; -use crate::observable_subgraph::{ObservableSubgraphDecoder, StabCoords}; +use crate::logical_subgraph::{LogicalSubgraphDecoder, StabCoords}; -/// Configuration for windowed OSD. +/// Configuration for windowed logical-subgraph decoder. #[derive(Debug, Clone)] -pub struct WindowedOsdConfig { +pub struct WindowedLogicalSubgraphConfig { /// Core window size in time steps. pub step: usize, /// Buffer size on each side (0 = non-overlapping). pub buffer: usize, } -impl Default for WindowedOsdConfig { +impl Default for WindowedLogicalSubgraphConfig { fn default() -> Self { Self { step: 8, buffer: 4 } } } -/// A single time window with its own OSD. -pub struct OsdWindow { - decoder: ObservableSubgraphDecoder, +/// A single time window with its own logical-subgraph decoder. +pub struct LogicalSubgraphWindow { + decoder: LogicalSubgraphDecoder, /// Maps local detector index → global detector index. local_to_global: Vec, num_local: usize, @@ -56,17 +56,17 @@ pub struct OsdWindow { /// Windowed observable subgraph decoder. /// -/// Splits the DEM into time windows, each decoded with its own OSD. +/// Splits the DEM into time windows, each decoded with its own logical-subgraph decoder. /// The observing region within each window is naturally bounded, /// preventing the scaling degradation seen at deep circuits. -pub struct WindowedOsdDecoder { - pub windows: Vec, +pub struct WindowedLogicalSubgraphDecoder { + pub windows: Vec, _num_detectors: usize, /// Reusable window syndrome buffer window_syn: Vec, } -impl WindowedOsdDecoder { +impl WindowedLogicalSubgraphDecoder { /// Build from a DEM string with time-based windowing. /// /// # Errors @@ -75,7 +75,7 @@ impl WindowedOsdDecoder { pub fn from_dem( dem: &str, stab_coords: &StabCoords, - config: &WindowedOsdConfig, + config: &WindowedLogicalSubgraphConfig, mut inner_factory: F, ) -> Result where @@ -101,11 +101,11 @@ impl WindowedOsdDecoder { let max_t = det_time.values().copied().fold(f64::NEG_INFINITY, f64::max); if max_t <= min_t { - // Single time step or empty — just use full OSD + // Single time step or empty — just use full logical-subgraph decoder let full_osd = - ObservableSubgraphDecoder::from_dem(dem, stab_coords, &mut inner_factory)?; + LogicalSubgraphDecoder::from_dem(dem, stab_coords, &mut inner_factory)?; return Ok(Self { - windows: vec![OsdWindow { + windows: vec![LogicalSubgraphWindow { decoder: full_osd, local_to_global: (0..num_detectors).collect(), num_local: num_detectors, @@ -216,7 +216,7 @@ impl WindowedOsdDecoder { }; // Build sub-DEM string with detector coordinate declarations. - // The OSD needs these to classify detectors by (qubit, stab_type). + // The logical-subgraph decoder needs these to classify detectors by (qubit, stab_type). let mut sub_dem_lines = Vec::new(); for (local_id, &global_id) in local_to_global.iter().enumerate() { // Find this detector's coordinates from the parsed coords @@ -228,11 +228,11 @@ impl WindowedOsdDecoder { sub_dem_lines.push(graph_to_dem_string(&sub_graph)); let sub_dem = sub_dem_lines.join("\n"); - // Build OSD for this window using the sub-DEM + // Build logical-subgraph decoder for this window using the sub-DEM let window_osd = - ObservableSubgraphDecoder::from_dem(&sub_dem, stab_coords, &mut inner_factory)?; + LogicalSubgraphDecoder::from_dem(&sub_dem, stab_coords, &mut inner_factory)?; - windows.push(OsdWindow { + windows.push(LogicalSubgraphWindow { decoder: window_osd, local_to_global, num_local, @@ -250,7 +250,7 @@ impl WindowedOsdDecoder { } } -impl ObservableDecoder for WindowedOsdDecoder { +impl ObservableDecoder for WindowedLogicalSubgraphDecoder { fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { let mut obs_mask = 0u64; diff --git a/crates/pecos-decoders/src/lib.rs b/crates/pecos-decoders/src/lib.rs index 5c9906a45..02a9797c9 100644 --- a/crates/pecos-decoders/src/lib.rs +++ b/crates/pecos-decoders/src/lib.rs @@ -21,10 +21,10 @@ pub use pecos_decoder_core::{ }; // Re-export observable subgraph decoder (for transversal gates) -pub use pecos_decoder_core::observable_subgraph::{ - DetectorGroup, ObservableSubgraph, ObservableSubgraphDecoder, - ParallelObservableSubgraphDecoder, QubitStabCoords, StabCoords, StabType, - partition_dem_by_observable, +pub use pecos_decoder_core::logical_subgraph::{ + DetectorGroup, LogicalSubgraph, LogicalSubgraphDecoder, + ParallelLogicalSubgraphDecoder, QubitStabCoords, StabCoords, StabType, + partition_dem_by_logical, }; // Re-export LDPC decoders when feature is enabled diff --git a/docs/user-guide/cmake-setup.md b/docs/user-guide/cmake-setup.md index bbd1c5061..443e0dcd6 100644 --- a/docs/user-guide/cmake-setup.md +++ b/docs/user-guide/cmake-setup.md @@ -94,10 +94,10 @@ Optional decoders: `pecos python build` will detect cmake automatically and pass `--features mwpf` to maturin. To check the decoder from Python: ```python -from pecos_rslib.qec import ObservableSubgraphDecoder # MWPF-capable decoder +from pecos_rslib.qec import LogicalSubgraphDecoder # MWPF-capable decoder # Construct with a real DEM + stabilizer coords: -# decoder = ObservableSubgraphDecoder(dem_str, stab_coords, inner_decoder="mwpf") +# decoder = LogicalSubgraphDecoder(dem_str, stab_coords, inner_decoder="mwpf") ``` Set `PECOS_BUILD_MWPF=0` to force MWPF off even when cmake is present (useful for reproducing the lean build locally). `PECOS_BUILD_MWPF=1` forces it on, which is what CI sets. diff --git a/examples/surface/brickwork_sweep.py b/examples/surface/brickwork_sweep.py index 88fe9f01e..55d501e3a 100644 --- a/examples/surface/brickwork_sweep.py +++ b/examples/surface/brickwork_sweep.py @@ -8,7 +8,7 @@ are unambiguous. Decoder names: - observable_subgraph:INNER -- OSD with inner decoder (pymatching, pecos_uf:fast, etc.) + logical_subgraph:INNER -- logical-subgraph decoder with inner decoder (pymatching, pecos_uf:fast, etc.) logical_circuit:BUDGET:INNER -- LogicalCircuitDecoder (unlimited, windowed, 10ms, etc.) logical_algorithm:INNER -- LogicalAlgorithmDecoder (full-circuit, no budget) @@ -16,13 +16,13 @@ uv run python examples/surface/brickwork_sweep.py \ --distances 3 5 --widths 2 3 4 --depths 1 2 3 \ --error-rates 0.001 0.002 \ - --decoders observable_subgraph:pymatching \ + --decoders logical_subgraph:pymatching \ --shots 5000 --output-dir /tmp/brickwork_sweep uv run python examples/surface/brickwork_sweep.py \ --distances 3 5 --widths 2 3 --depths 1 2 \ --error-rates 0.001 \ - --decoders observable_subgraph:pymatching logical_circuit:windowed:pymatching \ + --decoders logical_subgraph:pymatching logical_circuit:windowed:pymatching \ --shots 2000 --save-html --open """ @@ -159,7 +159,7 @@ def run_sweep( ) -> BrickworkShard: """Run the full brickwork sweep.""" from pecos.qec.surface import SurfacePatch - from pecos_rslib.qec import ObservableSubgraphDecoder, ParsedDem + from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem config = { "distances": distances, @@ -230,19 +230,19 @@ def run_sweep( desc = b.build_algorithm_descriptor(p1=p, p2=p, p_meas=p, p_prep=p) algo = LogicalAlgorithmDecoder(desc, inner) errors = algo.decode_count(batch) - elif decoder_name.startswith("observable_subgraph"): + elif decoder_name.startswith("logical_subgraph"): parts = decoder_name.split(":", 1) inner = parts[1] if len(parts) > 1 else "pymatching" - osd = ObservableSubgraphDecoder(dem_str, sc, inner) + decoder = LogicalSubgraphDecoder(dem_str, sc, inner) # Use parallel decode for large shot counts if shots >= 5000: - errors = osd.decode_count_parallel(batch, dem_str, sc, inner) + errors = decoder.decode_count_parallel(batch, dem_str, sc, inner) else: - errors = osd.decode_count(batch) + errors = decoder.decode_count(batch) else: msg = ( f"Unknown decoder: '{decoder_name}'. " - f"Supported: observable_subgraph:INNER, " + f"Supported: logical_subgraph:INNER, " f"logical_circuit:BUDGET:INNER, " f"logical_algorithm:INNER" ) @@ -430,9 +430,9 @@ def write_html_report(shard: BrickworkShard, path: Path, coherent_results=None) "

Budget strategies

", "

The decoder framework selects a strategy based on the user-specified reaction time budget:

", "
    ", - "
  • unlimited — Full-circuit OSD. Maximum accuracy. " + "
  • unlimited — Full-circuit logical-subgraph decoder. Maximum accuracy. " "Appropriate for Clifford circuits or offline analysis.
  • ", - "
  • windowed / 10ms — Windowed OSD with " + "
  • windowed / 10ms — Windowed logical-subgraph decoder with " "overlap buffers inside each per-observable subgraph. Bounded latency, full " "accuracy with sufficient overlap.
  • ", "
  • 100us / 1us — Tight budget. Windowed " @@ -592,7 +592,7 @@ def main(): help="Override --depths: set depth=2^((d+1)/2) per distance", ) parser.add_argument("--error-rates", type=float, nargs="+", default=[0.001]) - parser.add_argument("--decoders", nargs="+", default=["observable_subgraph:pymatching"]) + parser.add_argument("--decoders", nargs="+", default=["logical_subgraph:pymatching"]) parser.add_argument("--shots", type=int, default=5000) parser.add_argument("--seed", type=int, default=42) parser.add_argument("--rounds-per-layer", type=int, default=2) @@ -680,7 +680,7 @@ def main(): # T-injection circuits if args.include_t_injection or args.t_injection_only: from pecos.qec.surface import SurfacePatch - from pecos_rslib.qec import ObservableSubgraphDecoder, ParsedDem + from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem print("\n--- T-Injection Circuits ---") for d in args.distances: @@ -721,15 +721,15 @@ def main(): desc = b.build_algorithm_descriptor(p1=p, p2=p, p_meas=p, p_prep=p) algo = LogicalAlgorithmDecoder(desc, inner) errors = algo.decode_count(batch) - elif decoder_name.startswith("observable_subgraph"): + elif decoder_name.startswith("logical_subgraph"): parts = decoder_name.split(":", 1) inner = parts[1] if len(parts) > 1 else "pymatching" - osd = ObservableSubgraphDecoder(dem_str, sc, inner) - errors = osd.decode_count(batch) + decoder = LogicalSubgraphDecoder(dem_str, sc, inner) + errors = decoder.decode_count(batch) else: msg = ( f"Unknown decoder: '{decoder_name}'. " - f"Supported: observable_subgraph:INNER, " + f"Supported: logical_subgraph:INNER, " f"logical_circuit:BUDGET:INNER, " f"logical_algorithm:INNER" ) diff --git a/examples/surface/coherent_noise_sweep.py b/examples/surface/coherent_noise_sweep.py index 1995878c6..63c89a56c 100644 --- a/examples/surface/coherent_noise_sweep.py +++ b/examples/surface/coherent_noise_sweep.py @@ -68,7 +68,7 @@ def run_sweep( ) -> CoherentNoiseSweep: """Run a coherent noise sweep using sim_neo.""" from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch - from pecos_rslib.qec import ObservableSubgraphDecoder + from pecos_rslib.qec import LogicalSubgraphDecoder from pecos_rslib_exp import depolarizing, sim_neo, stab_mps, statevec patch = SurfacePatch.create(distance=distance) @@ -82,7 +82,7 @@ def run_sweep( num_meas = int(tc.get_meta("num_measurements")) dem_str = b.build_dem(p1=p_depol, p2=p_depol, p_meas=p_depol, p_prep=p_depol) sc = b.stab_coords() - osd = ObservableSubgraphDecoder(dem_str, sc, "pymatching") + decoder = LogicalSubgraphDecoder(dem_str, sc, "pymatching") sweep = CoherentNoiseSweep( distance=distance, @@ -133,7 +133,7 @@ def run_sweep( val ^= meas[idx] if val: obs_mask |= 1 << obs["id"] - pred = osd.decode([int(x) for x in det_events]) + pred = decoder.decode([int(x) for x in det_events]) if pred != obs_mask: errors += 1 decode_time = time.perf_counter() - t0 diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index c443778b2..400e23215 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -2556,19 +2556,19 @@ fn create_observable_decoder( } Ok(Box::new(EnsembleDecoder::new(members))) } - // Per-observable subgraph decoder: requires stab_coords from Python. + // Per-logical-operator subgraph decoder: requires stab_coords from Python. // This is NOT callable from the string-based create_observable_decoder API. - // Use the Python ObservableSubgraphDecoder class directly instead. - s if s == "observable_subgraph" || s.starts_with("observable_subgraph:") => { + // Use the Python LogicalSubgraphDecoder class directly instead. + s if s == "logical_subgraph" || s.starts_with("logical_subgraph:") => { Err(PyErr::new::( - "observable_subgraph decoder requires stab_coords. \ - Use pecos_rslib.qec.ObservableSubgraphDecoder class directly.", + "logical_subgraph decoder requires stab_coords. \ + Use pecos_rslib.qec.LogicalSubgraphDecoder class directly.", )) } _ => Err(PyErr::new::(format!( "Unsupported decoder_type: {decoder_type}. \ Supported: pymatching, tesseract, mwpf, pecos_uf (or pecos_uf:fast/balanced/accurate), \ - observable_subgraph, ensemble:d1,d2,..., bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp." + logical_subgraph, ensemble:d1,d2,..., bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp." ))), } } @@ -4410,9 +4410,9 @@ impl PyCssUfDecoder { // Observable Subgraph Decoder (Python class) // ============================================================================= -/// Per-observable subgraph decoder for transversal gates. +/// Per-logical-operator subgraph decoder for transversal gates. /// -/// Partitions a DEM into per-observable graphlike subgraphs using +/// Partitions a DEM into per-logical-operator graphlike subgraphs using /// stabilizer coordinate information, then decodes each independently. /// /// Args: @@ -4422,19 +4422,19 @@ impl PyCssUfDecoder { /// `inner_decoder`: Inner decoder type string (default "`pecos_uf:fast`"). /// /// Example: -/// >>> decoder = `ObservableSubgraphDecoder`( +/// >>> decoder = `LogicalSubgraphDecoder`( /// ... `dem_str`, /// ... [{"X": [(1,0), (3,1)], "Z": [(0,3), (1,1)]}], /// ... "`pecos_uf:fast`", /// ... ) /// >>> obs = decoder.decode(syndrome) -#[pyclass(name = "ObservableSubgraphDecoder", module = "pecos_rslib.qec")] -pub struct PyObservableSubgraphDecoder { - inner: pecos_decoder_core::observable_subgraph::ObservableSubgraphDecoder, +#[pyclass(name = "LogicalSubgraphDecoder", module = "pecos_rslib.qec")] +pub struct PyLogicalSubgraphDecoder { + inner: pecos_decoder_core::logical_subgraph::LogicalSubgraphDecoder, } #[pymethods] -impl PyObservableSubgraphDecoder { +impl PyLogicalSubgraphDecoder { #[new] #[pyo3(signature = (dem, stab_coords, inner_decoder="pecos_uf:fast", max_time_radius=None))] fn new( @@ -4443,7 +4443,7 @@ impl PyObservableSubgraphDecoder { inner_decoder: &str, max_time_radius: Option, ) -> PyResult { - use pecos_decoder_core::observable_subgraph::{ObservableSubgraphDecoder, QubitStabCoords}; + use pecos_decoder_core::logical_subgraph::{LogicalSubgraphDecoder, QubitStabCoords}; // Parse stab_coords from Python dicts let mut rust_stab_coords = Vec::with_capacity(stab_coords.len()); @@ -4462,7 +4462,7 @@ impl PyObservableSubgraphDecoder { }); } - let inner = ObservableSubgraphDecoder::from_dem_windowed( + let inner = LogicalSubgraphDecoder::from_dem_windowed( dem, &rust_stab_coords, max_time_radius, @@ -4551,7 +4551,7 @@ impl PyObservableSubgraphDecoder { num_workers: Option, max_time_radius: Option, ) -> PyResult { - use pecos_decoder_core::observable_subgraph::{ObservableSubgraphDecoder, QubitStabCoords}; + use pecos_decoder_core::logical_subgraph::{LogicalSubgraphDecoder, QubitStabCoords}; use rayon::prelude::*; // Parse stab_coords @@ -4598,7 +4598,7 @@ impl PyObservableSubgraphDecoder { .par_chunks(chunk_size.max(1)) .map(|chunk| { // Build a fresh decoder for this worker - let mut dec = ObservableSubgraphDecoder::from_dem_windowed( + let mut dec = LogicalSubgraphDecoder::from_dem_windowed( &dem_str, &sc, max_time_radius, @@ -4655,7 +4655,7 @@ impl PyObservableSubgraphDecoder { stab_coords: Vec>, ) -> PyResult<(usize, usize)> { use pecos_decoder_core::ghost_protocol::extract_ghost_edges_from_dem; - use pecos_decoder_core::observable_subgraph::QubitStabCoords; + use pecos_decoder_core::logical_subgraph::QubitStabCoords; let mut sc = Vec::with_capacity(stab_coords.len()); for dict in &stab_coords { @@ -4705,12 +4705,12 @@ impl PyObservableSubgraphDecoder { } // ============================================================================= -// Windowed OSD Decoder (Python class) +// Windowed logical-subgraph decoding Decoder (Python class) // ============================================================================= /// Windowed observable subgraph decoder for deep circuits. /// -/// Splits the DEM into time windows, runs OSD within each window. +/// Splits the DEM into time windows, runs logical-subgraph decoder within each window. /// Prevents the observing region from spanning the full circuit. /// /// Args: @@ -4719,13 +4719,13 @@ impl PyObservableSubgraphDecoder { /// `inner_decoder`: Inner MWPM decoder type. /// step: Core window size in time steps. /// buffer: Buffer size on each side (0 = non-overlapping). -#[pyclass(name = "WindowedOsdDecoder", module = "pecos_rslib.qec")] -pub struct PyWindowedOsdDecoder { - inner: pecos_decoder_core::windowed_osd::WindowedOsdDecoder, +#[pyclass(name = "WindowedLogicalSubgraphDecoder", module = "pecos_rslib.qec")] +pub struct PyWindowedLogicalSubgraphDecoder { + inner: pecos_decoder_core::logical_subgraph::windowed::WindowedLogicalSubgraphDecoder, } #[pymethods] -impl PyWindowedOsdDecoder { +impl PyWindowedLogicalSubgraphDecoder { #[new] #[pyo3(signature = (dem, stab_coords, inner_decoder="pymatching", step=8, buffer=4))] fn new( @@ -4735,8 +4735,8 @@ impl PyWindowedOsdDecoder { step: usize, buffer: usize, ) -> PyResult { - use pecos_decoder_core::observable_subgraph::QubitStabCoords; - use pecos_decoder_core::windowed_osd::{WindowedOsdConfig, WindowedOsdDecoder}; + use pecos_decoder_core::logical_subgraph::QubitStabCoords; + use pecos_decoder_core::logical_subgraph::windowed::{WindowedLogicalSubgraphConfig, WindowedLogicalSubgraphDecoder}; let mut sc = Vec::with_capacity(stab_coords.len()); for dict in &stab_coords { @@ -4754,9 +4754,9 @@ impl PyWindowedOsdDecoder { }); } - let config = WindowedOsdConfig { step, buffer }; + let config = WindowedLogicalSubgraphConfig { step, buffer }; - let inner = WindowedOsdDecoder::from_dem(dem, &sc, &config, |subgraph| { + let inner = WindowedLogicalSubgraphDecoder::from_dem(dem, &sc, &config, |subgraph| { let sub_dem = subgraph_to_dem_string(subgraph); let d = create_observable_decoder(&sub_dem, inner_decoder) .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string()))?; @@ -4801,7 +4801,7 @@ impl PyWindowedOsdDecoder { // Logical Algorithm Decoder (Python class) // ============================================================================= -/// Decoder for logical quantum algorithms with per-segment OSD and +/// Decoder for logical quantum algorithms with per-segment logical-subgraph decoder and /// Pauli frame propagation at transversal gate boundaries. /// /// Built from a descriptor dict produced by @@ -4820,7 +4820,7 @@ impl PyLogicalAlgorithmDecoder { /// /// Args: /// descriptor: Dict from ``LogicalCircuitBuilder.build_algorithm_descriptor()``. - /// `inner_decoder`: Decoder type string for each segment's OSD inner decoder. + /// `inner_decoder`: Decoder type string for each segment's logical-subgraph decoder inner decoder. #[new] #[pyo3(signature = (descriptor, inner_decoder="pymatching"))] fn new( @@ -4830,9 +4830,9 @@ impl PyLogicalAlgorithmDecoder { use pecos_decoder_core::logical_algorithm::{ AlgorithmDescriptor, BoundaryGate, LogicalAlgorithmDecoder, SegmentDescriptor, }; - use pecos_decoder_core::observable_subgraph::{ObservableSubgraphDecoder, QubitStabCoords}; + use pecos_decoder_core::logical_subgraph::{LogicalSubgraphDecoder, QubitStabCoords}; - // Parse full DEM and stab_coords for full-circuit OSD + // Parse full DEM and stab_coords for full-circuit logical-subgraph decoder let full_dem: String = descriptor .get_item("full_dem")? .ok_or_else(|| PyErr::new::("full_dem"))? @@ -4874,8 +4874,8 @@ impl PyLogicalAlgorithmDecoder { let inner_str = inner_decoder.to_string(); - // Build full-circuit OSD from the full DEM - let full_osd = ObservableSubgraphDecoder::from_dem(&full_dem, &rust_sc, |subgraph| { + // Build full-circuit logical-subgraph decoder from the full DEM + let full_osd = LogicalSubgraphDecoder::from_dem(&full_dem, &rust_sc, |subgraph| { let sub_dem = subgraph_to_dem_string(subgraph); let d = create_observable_decoder(&sub_dem, &inner_str) .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string()))?; @@ -5044,8 +5044,8 @@ impl PyLogicalAlgorithmDecoder { /// Budget-aware decoder for logical quantum circuits. /// /// Selects decode strategy based on available reaction time: -/// - ``"unlimited"``: full-circuit OSD (Clifford circuits, offline) -/// - ``"windowed"``: default windowed OSD (~1ms reaction time) +/// - ``"unlimited"``: full-circuit logical-subgraph decoder (Clifford circuits, offline) +/// - ``"windowed"``: default windowed logical-subgraph decoder (~1ms reaction time) /// - ``"10ms"``, ``"1000us"``, etc.: explicit reaction time budget /// /// The reaction time is the time available at feed-forward decision @@ -5076,7 +5076,7 @@ impl PyLogicalCircuitDecoder { AlgorithmDescriptor, BoundaryGate, FullCircuitStrategy, LogicalCircuitDecoder, SegmentDescriptor, }; - use pecos_decoder_core::observable_subgraph::{ObservableSubgraphDecoder, QubitStabCoords}; + use pecos_decoder_core::logical_subgraph::{LogicalSubgraphDecoder, QubitStabCoords}; // Parse full DEM let full_dem: String = descriptor @@ -5118,7 +5118,7 @@ impl PyLogicalCircuitDecoder { let num_qubits = rust_sc.len(); let inner_str = inner_decoder.to_string(); - let full_osd = ObservableSubgraphDecoder::from_dem(&full_dem, &rust_sc, |subgraph| { + let full_osd = LogicalSubgraphDecoder::from_dem(&full_dem, &rust_sc, |subgraph| { let sub_dem = subgraph_to_dem_string(subgraph); let d = create_observable_decoder(&sub_dem, &inner_str) .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string()))?; @@ -5236,12 +5236,12 @@ impl PyLogicalCircuitDecoder { // Select strategy based on budget. let strategy: Box = if decode_budget.is_unlimited() { - // Unlimited: full-circuit OSD (maximum accuracy) + // Unlimited: full-circuit logical-subgraph decoder (maximum accuracy) Box::new(FullCircuitStrategy::new(Box::new(full_osd))) } else { // Windowed: per-subgraph sandwich decoding. - // Extract per-subgraph DEMs and detector maps from the full OSD. - use pecos_decoder_core::logical_algorithm::WindowedOsdStrategy; + // Extract per-subgraph DEMs and detector maps from the full logical-subgraph decoder. + use pecos_decoder_core::logical_algorithm::WindowedLogicalSubgraphStrategy; let mut sub_dems = Vec::new(); let mut det_maps = Vec::new(); @@ -5262,7 +5262,7 @@ impl PyLogicalCircuitDecoder { format!("windowed:step={d},buf=0") }; - let wosd = WindowedOsdStrategy::new(sub_dems, det_maps, |dem_str| { + let wosd = WindowedLogicalSubgraphStrategy::new(sub_dems, det_maps, |dem_str| { let dec = create_observable_decoder(dem_str, &windowed_str) .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string()))?; Ok(Box::new(SendWrapper(dec)) @@ -5561,8 +5561,8 @@ 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::()?; qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; diff --git a/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py b/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py index c5d10a619..c443f389c 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py +++ b/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py @@ -527,7 +527,7 @@ def stab_coords(self) -> list[dict[str, list[tuple[float, float]]]]: with keys "X" and "Z" mapping to ancilla (x, y) positions. These coordinates match the detector annotations in the Stim circuit. - Used as input to ``ObservableSubgraphDecoder``. + Used as input to ``LogicalSubgraphDecoder``. """ result = [] for ps in self._patches.values(): @@ -609,10 +609,10 @@ def build_sampler_and_decoder( """Build a DemSampler and OSD decoder without any string round-trip. Returns: - Tuple of (DemSampler, ObservableSubgraphDecoder, dem_str). + Tuple of (DemSampler, LogicalSubgraphDecoder, dem_str). dem_str is also returned for compatibility with existing code. """ - from pecos_rslib.qec import DagFaultAnalyzer, DemBuilder, ObservableSubgraphDecoder + from pecos_rslib.qec import DagFaultAnalyzer, DemBuilder, LogicalSubgraphDecoder tc = self.to_tick_circuit() dc = tc.to_dag_circuit() @@ -642,7 +642,7 @@ def build_sampler_and_decoder( dem_str = str(dem) sc = self.stab_coords() - decoder = ObservableSubgraphDecoder(dem_str, sc, inner_decoder) + decoder = LogicalSubgraphDecoder(dem_str, sc, inner_decoder) return sampler, decoder, dem_str @@ -860,7 +860,7 @@ def build_decoder( inner_decoder: str = "fusion_blossom_serial", use_stim_dem: bool = True, ) -> tuple[object, object]: - """Build an ObservableSubgraphDecoder for this circuit. + """Build an LogicalSubgraphDecoder for this circuit. Args: p1: Single-qubit depolarizing error rate. @@ -872,10 +872,10 @@ def build_decoder( mechanisms). If False, use PECOS-native DEM pipeline. Returns: - Tuple of (stim.Circuit, ObservableSubgraphDecoder). + Tuple of (stim.Circuit, LogicalSubgraphDecoder). """ import stim - from pecos_rslib.qec import ObservableSubgraphDecoder + from pecos_rslib.qec import LogicalSubgraphDecoder stim_str = self.to_stim(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep) circuit = stim.Circuit(stim_str) @@ -887,7 +887,7 @@ def build_decoder( dem_str = self.build_dem(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep) sc = self.stab_coords() - decoder = ObservableSubgraphDecoder(dem_str, sc, inner_decoder) + decoder = LogicalSubgraphDecoder(dem_str, sc, inner_decoder) return circuit, decoder diff --git a/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py b/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py index 976829c83..b05fbce1d 100644 --- a/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py +++ b/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py @@ -594,9 +594,9 @@ def test_pecos_dem_decode_memory(self, patch): # At d=3 p=0.001, LER should be very low assert ler < 0.05, f"LER too high: {ler}" - def test_observable_subgraph_decoder(self, patch, nq): - """OSD with PECOS DEM on CX circuit.""" - from pecos_rslib.qec import ObservableSubgraphDecoder, ParsedDem + def test_logical_subgraph_decoder(self, patch, nq): + """logical-subgraph decoder with PECOS DEM on CX circuit.""" + from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem b = LogicalCircuitBuilder() b.add_patch(patch, "C", qubit_offset=0) @@ -607,10 +607,10 @@ def test_observable_subgraph_decoder(self, patch, nq): dem_str = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) sc = b.stab_coords() - osd = ObservableSubgraphDecoder(dem_str, sc, "pecos_uf:fast") - assert osd.num_observables() == 2 + decoder = LogicalSubgraphDecoder(dem_str, sc, "pecos_uf:fast") + assert decoder.num_observables() == 2 - sizes = osd.subgraph_sizes() + sizes = decoder.subgraph_sizes() for s in sizes: assert s > 0, "Empty subgraph" @@ -812,17 +812,17 @@ def test_noisy_random_composition(self, patch, nq, seed): # --------------------------------------------------------------------------- -# OSD accuracy comparison +# logical-subgraph decoder accuracy comparison # --------------------------------------------------------------------------- -class TestOSDAccuracy: +class TestLogicalSubgraphAccuracy: """Compare observable subgraph decoder accuracy against baseline.""" - def test_osd_better_than_naive_on_cx(self, patch, nq): - """OSD should outperform naive decomposed MWPM on CX circuits.""" + def test_logical_subgraph_better_than_naive_on_cx(self, patch, nq): + """logical-subgraph decoder should outperform naive decomposed MWPM on CX circuits.""" import stim - from pecos_rslib.qec import ObservableSubgraphDecoder, ParsedDem + from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem b = LogicalCircuitBuilder() b.add_patch(patch, "C", qubit_offset=0) @@ -846,31 +846,31 @@ def test_osd_better_than_naive_on_cx(self, patch, nq): naive_errors = batch_naive.decode_count(str(dem_decomp), "pecos_uf:fast") naive_ler = naive_errors / 20000 - # OSD with FB + # logical-subgraph decoder with FB sc = b.stab_coords() - osd = ObservableSubgraphDecoder(dem_str, sc, "fusion_blossom_serial") - osd_errors = sum( + decoder = LogicalSubgraphDecoder(dem_str, sc, "fusion_blossom_serial") + lsd_errors = sum( 1 for i in range(20000) - if osd.decode(det_events[i].tolist()) != sum((1 << j) for j in range(obs_flips.shape[1]) if obs_flips[i, j]) + if decoder.decode(det_events[i].tolist()) != sum((1 << j) for j in range(obs_flips.shape[1]) if obs_flips[i, j]) ) - osd_ler = osd_errors / 20000 + lsd_ler = lsd_errors / 20000 - # OSD should be at least as good (usually much better) - assert osd_ler <= naive_ler * 1.5 + 0.001, f"OSD ({osd_ler:.5f}) much worse than naive ({naive_ler:.5f})" + # logical-subgraph decoder should be at least as good (usually much better) + assert lsd_ler <= naive_ler * 1.5 + 0.001, f"logical-subgraph decoder ({lsd_ler:.5f}) much worse than naive ({naive_ler:.5f})" # --------------------------------------------------------------------------- -# PECOS-native DEM with OSD decoder on CX +# PECOS-native DEM with logical-subgraph decoder decoder on CX # --------------------------------------------------------------------------- -class TestPecosDemWithOSD: +class TestPecosDemWithLogicalSubgraph: """Test PECOS-native DEM pipeline with observable subgraph decoder.""" - def test_pecos_dem_osd_cx(self, patch, nq): - """PECOS DEM → OSD decoder on CX circuit.""" - from pecos_rslib.qec import ObservableSubgraphDecoder, ParsedDem + def test_pecos_dem_logical_subgraph_cx(self, patch, nq): + """PECOS DEM → logical-subgraph decoder decoder on CX circuit.""" + from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem b = LogicalCircuitBuilder() b.add_patch(patch, "C", qubit_offset=0) @@ -885,17 +885,17 @@ def test_pecos_dem_osd_cx(self, patch, nq): errors = [line for line in dem_str.split("\n") if line.startswith("error(")] assert len(errors) > 0 - # Build OSD decoder from PECOS DEM + # Build logical-subgraph decoder decoder from PECOS DEM sc = b.stab_coords() - osd = ObservableSubgraphDecoder(dem_str, sc, "pecos_uf:fast") - assert osd.num_observables() == 2 + decoder = LogicalSubgraphDecoder(dem_str, sc, "pecos_uf:fast") + assert decoder.num_observables() == 2 # Sample and decode parsed = ParsedDem.from_string(dem_str) batch = parsed.to_dem_sampler().generate_samples(5000, seed=42) errors = batch.decode_count(dem_str, "pecos_uf:fast") ler = errors / 5000 - assert ler < 0.1, f"PECOS DEM + OSD CX LER too high: {ler}" + assert ler < 0.1, f"PECOS DEM + logical-subgraph decoder CX LER too high: {ler}" # --------------------------------------------------------------------------- From a2e18fad01fc8a535686d67d64116be2c4030244 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Fri, 29 May 2026 23:20:10 -0600 Subject: [PATCH 004/150] Preserve runtime idle gates through QEC DEM generation. --- .../fault_tolerance/dem_builder/builder.rs | 50 +++++-- .../dem_builder/dem_sampler.rs | 12 +- .../dem_builder/mem_builder.rs | 3 +- .../fault_tolerance/dem_builder/sampler.rs | 4 +- .../src/fault_tolerance/dem_builder/types.rs | 66 ++++++++- .../src/fault_tolerance/influence_builder.rs | 6 +- .../src/fault_tolerance/lookup_decoder.rs | 6 +- .../src/fault_tolerance/propagator/dag.rs | 67 +++++++-- crates/pecos-qec/tests/idle_noise_tests.rs | 59 ++++++++ crates/pecos-qis/src/ccengine.rs | 77 +++++++++- .../src/fault_tolerance_bindings.rs | 131 +++++++++++++----- python/quantum-pecos/src/pecos/qec/dem.py | 16 +++ .../src/pecos/qec/surface/circuit_builder.py | 17 ++- .../src/pecos/qec/surface/decode.py | 96 ++++++++++++- .../tests/qec/test_from_guppy_dem.py | 70 ++++++++++ 15 files changed, 593 insertions(+), 87 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 b6ce524d5..d6dd9a5e4 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -163,7 +163,23 @@ impl<'a> DemBuilder<'a> { p_meas: f64, p_prep: f64, ) -> Result { - build_dem_from_circuit(circuit, p1, p2, p_meas, p_prep) + build_dem_from_circuit(circuit, NoiseConfig::new(p1, p2, p_meas, p_prep)) + } + + /// Try to build a `DetectorErrorModel` directly from a `DagCircuit` and + /// full noise configuration. + /// + /// Reads detector/DEM output definitions from circuit metadata and returns + /// parser errors instead of dropping malformed metadata. + /// + /// # Errors + /// + /// Returns an error if detector or observable metadata is malformed. + pub fn try_from_circuit_with_noise_config( + circuit: &pecos_quantum::DagCircuit, + noise: NoiseConfig, + ) -> Result { + build_dem_from_circuit(circuit, noise) } /// Build a `DetectorErrorModel` from a `TickCircuit` and noise. @@ -202,7 +218,24 @@ impl<'a> DemBuilder<'a> { p_prep: f64, ) -> Result { let dag = pecos_quantum::DagCircuit::from(circuit); - build_dem_from_circuit(&dag, p1, p2, p_meas, p_prep) + build_dem_from_circuit(&dag, NoiseConfig::new(p1, p2, p_meas, p_prep)) + } + + /// Try to build a `DetectorErrorModel` from a `TickCircuit` and full noise + /// configuration. + /// + /// Converts to `DagCircuit` internally and returns parser errors instead + /// of dropping malformed metadata. + /// + /// # Errors + /// + /// Returns an error if detector or observable metadata is malformed. + pub fn try_from_tick_circuit_with_noise_config( + circuit: &pecos_quantum::TickCircuit, + noise: NoiseConfig, + ) -> Result { + let dag = pecos_quantum::DagCircuit::from(circuit); + build_dem_from_circuit(&dag, noise) } /// Creates a new DEM builder from a fault influence map. @@ -306,8 +339,7 @@ impl<'a> DemBuilder<'a> { return rates; } if pg.base.uses_dedicated_idle_noise() { - #[allow(clippy::cast_precision_loss)] - let duration = loc.idle_duration.max(1) as f64; + let duration = loc.idle_duration.max(0.0); let probs = pg.base.idle_pauli_probs(duration); return [probs.px, probs.py, probs.pz]; } @@ -315,8 +347,7 @@ impl<'a> DemBuilder<'a> { } if self.noise.uses_dedicated_idle_noise() { - #[allow(clippy::cast_precision_loss)] - let duration = loc.idle_duration.max(1) as f64; + let duration = loc.idle_duration.max(0.0); let probs = self.noise.idle_pauli_probs(duration); return [probs.px, probs.py, probs.pz]; } @@ -1757,10 +1788,7 @@ fn extract_measurement_refs( /// Reads detector/DEM output definitions from circuit metadata attributes. fn build_dem_from_circuit( circuit: &pecos_quantum::DagCircuit, - p1: f64, - p2: f64, - p_meas: f64, - p_prep: f64, + noise: NoiseConfig, ) -> Result { use crate::fault_tolerance::influence_builder::InfluenceBuilder; use crate::fault_tolerance::propagator::DagFaultAnalyzer; @@ -1796,7 +1824,7 @@ fn build_dem_from_circuit( } }); - let builder = DemBuilder::new(&influence_map).with_noise(p1, p2, p_meas, p_prep); + let builder = DemBuilder::new(&influence_map).with_noise_config(noise); let builder = if let Some(ref dj) = det_json { builder.with_detectors_json(dj)? 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 72c102c43..4b11d4559 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 @@ -423,10 +423,8 @@ impl SamplingEngine { .locations .iter() .find(|l| l.node == loc.node && l.before == loc.before) - .map_or(1, |l| l.idle_duration.max(1)); - // Duration values are small integers; precision loss is not a concern. - #[allow(clippy::cast_precision_loss)] - Some(noise.idle_pauli_probs(duration as f64)) + .map_or(0.0, |l| l.idle_duration.max(0.0)); + Some(noise.idle_pauli_probs(duration)) } else { None }; @@ -2298,8 +2296,7 @@ impl<'a> SamplingEngineBuilder<'a> { return rates; } if pg.base.uses_dedicated_idle_noise() { - #[allow(clippy::cast_precision_loss)] - let duration = loc.idle_duration.max(1) as f64; + let duration = loc.idle_duration.max(0.0); let probs = pg.base.idle_pauli_probs(duration); return [probs.px, probs.py, probs.pz]; } @@ -2309,8 +2306,7 @@ impl<'a> SamplingEngineBuilder<'a> { if let Some(noise) = &self.idle_noise && noise.uses_dedicated_idle_noise() { - #[allow(clippy::cast_precision_loss)] - let duration = loc.idle_duration.max(1) as f64; + let duration = loc.idle_duration.max(0.0); let probs = noise.idle_pauli_probs(duration); return [probs.px, probs.py, probs.pz]; } 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 85176ec4c..545f709a0 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 @@ -128,8 +128,7 @@ impl<'a> MemBuilder<'a> { } GateType::Idle if !loc.before => { if self.noise.uses_dedicated_idle_noise() { - #[allow(clippy::cast_precision_loss)] - let duration = loc.idle_duration.max(1) as f64; + 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); 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 53d29c1c5..f0e1516b7 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -1451,9 +1451,7 @@ pub(crate) fn compute_location_probs_from_noise( | GateType::RZZ => noise.p2, GateType::Idle => { if noise.uses_dedicated_idle_noise() { - // Duration values are small integers; precision loss is not a concern. - #[allow(clippy::cast_precision_loss)] - let duration = loc.idle_duration.max(1) as f64; + let duration = loc.idle_duration.max(0.0); 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 3deda7745..48a695879 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -1541,6 +1541,18 @@ 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. + /// + /// 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`. + pub p_idle_linear_rate: f64, + /// Stochastic Z-memory error rate for the quadratic idle term. + /// + /// This follows the incoherent PECOS engine convention for quadratic idle + /// dephasing: each explicit `Idle(duration, q)` contributes an independent + /// Z fault with probability `sin(p_idle_quadratic_rate * duration)^2`. + pub p_idle_quadratic_rate: f64, } /// Per-Pauli error probabilities for a single qubit. @@ -1606,6 +1618,8 @@ impl Default for NoiseConfig { p1_weights: None, p2_weights: None, idle_rz: 0.0, + p_idle_linear_rate: 0.0, + p_idle_quadratic_rate: 0.0, } } } @@ -1625,6 +1639,8 @@ impl NoiseConfig { p1_weights: None, p2_weights: None, idle_rz: 0.0, + p_idle_linear_rate: 0.0, + p_idle_quadratic_rate: 0.0, } } @@ -1642,6 +1658,8 @@ impl NoiseConfig { p1_weights: None, p2_weights: None, idle_rz: 0.0, + p_idle_linear_rate: 0.0, + p_idle_quadratic_rate: 0.0, } } @@ -1659,6 +1677,8 @@ impl NoiseConfig { p1_weights: None, p2_weights: None, idle_rz: 0.0, + p_idle_linear_rate: 0.0, + p_idle_quadratic_rate: 0.0, } } @@ -1669,6 +1689,20 @@ impl NoiseConfig { self } + /// 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 + } + + /// 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; + self + } + /// Sets T1/T2 relaxation times for idle noise. /// /// When set, idle gates use the Pauli-twirled T1/T2 model instead of @@ -1747,17 +1781,40 @@ impl NoiseConfig { self } + fn compose_z_fault(probs: PauliProbs, z_probability: f64) -> PauliProbs { + let z_probability = z_probability.clamp(0.0, 1.0); + if z_probability <= f64::EPSILON { + return probs; + } + + let p_identity = (1.0 - probs.total()).max(0.0); + PauliProbs { + px: probs.px * (1.0 - z_probability) + probs.py * z_probability, + py: probs.py * (1.0 - z_probability) + probs.px * z_probability, + pz: probs.pz * (1.0 - z_probability) + p_identity * z_probability, + } + } + + fn idle_memory_z_probability(&self, duration: f64) -> f64 { + let duration = duration.max(0.0); + let linear = (self.p_idle_linear_rate * duration).clamp(0.0, 1.0); + let quadratic = (self.p_idle_quadratic_rate * duration).sin().powi(2); + + linear + quadratic - 2.0 * linear * quadratic + } + /// 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`. #[must_use] pub fn idle_pauli_probs(&self, duration: f64) -> PauliProbs { - if let (Some(t1), Some(t2)) = (self.t1, self.t2) { + 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_z_fault(probs, self.idle_memory_z_probability(duration)) } /// Returns true when idle locations use the dedicated idle-noise model. @@ -1765,7 +1822,10 @@ 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 > 0.0 + || matches!((self.t1, self.t2), (Some(_), Some(_))) + || self.p_idle_linear_rate > 0.0 + || self.p_idle_quadratic_rate.abs() > f64::EPSILON } } diff --git a/crates/pecos-qec/src/fault_tolerance/influence_builder.rs b/crates/pecos-qec/src/fault_tolerance/influence_builder.rs index aa77d8e71..6820ca72c 100644 --- a/crates/pecos-qec/src/fault_tolerance/influence_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/influence_builder.rs @@ -498,16 +498,12 @@ impl<'a> InfluenceBuilder<'a> { // Measurement: before. All others: after. let before = is_measurement; for &q in &qubits { - // idle_duration() returns a non-negative integer stored as f64; - // truncation and sign loss are not a concern. - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - let idle_duration = gate.idle_duration() as u64; locations.push(DagSpacetimeLocation { node, qubits: vec![q], before, gate_type: gate.gate_type, - idle_duration, + idle_duration: gate.idle_duration(), }); } } diff --git a/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs b/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs index 50d828465..e6bc7eba3 100644 --- a/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs +++ b/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs @@ -102,10 +102,8 @@ impl LookupDecoder { .locations .iter() .find(|l| l.node == loc.node && l.before == loc.before) - .map_or(1, |l| l.idle_duration.max(1)); - // Duration values are small integers; precision loss is not a concern. - #[allow(clippy::cast_precision_loss)] - Some(noise.idle_pauli_probs(duration as f64)) + .map_or(0.0, |l| l.idle_duration.max(0.0)); + Some(noise.idle_pauli_probs(duration)) } else { None }; diff --git a/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs b/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs index 99d164f04..74ab1c3d9 100644 --- a/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs +++ b/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs @@ -79,7 +79,9 @@ use pecos_core::{PauliString, QuarterPhase, QubitId}; use pecos_quantum::DagCircuit; use pecos_simulators::PauliProp; use smallvec::SmallVec; +use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet, BinaryHeap}; +use std::hash::{Hash, Hasher}; /// Reusable work buffers for propagation, avoiding per-call allocation. pub struct PropagationBuffers { @@ -113,6 +115,8 @@ pub struct FaultLocations { pub before: Vec, /// Gate type at each location. pub gate_types: Vec, + /// Idle duration at each location. 0.0 for non-idle gates. + pub idle_durations: Vec, /// Reverse index: node -> list of location IDs at that node. pub node_to_locations: Vec>, } @@ -132,6 +136,7 @@ impl FaultLocations { qubits: Vec::with_capacity(num_locations), before: Vec::with_capacity(num_locations), gate_types: Vec::with_capacity(num_locations), + idle_durations: Vec::with_capacity(num_locations), node_to_locations: vec![SmallVec::new(); max_node + 1], } } @@ -157,12 +162,14 @@ impl FaultLocations { qubits: SmallVec<[usize; 2]>, before: bool, gate_type: GateType, + idle_duration: f64, ) -> usize { let loc_id = self.nodes.len(); self.nodes.push(node); self.qubits.push(qubits); self.before.push(before); self.gate_types.push(gate_type); + self.idle_durations.push(idle_duration); // Update reverse index if node < self.node_to_locations.len() { @@ -206,7 +213,7 @@ impl FaultLocations { qubits: self.qubits[i].iter().map(|&q| QubitId::from(q)).collect(), before: self.before[i], gate_type: self.gate_types[i], - idle_duration: 0, + idle_duration: self.idle_durations[i], }) .collect() } @@ -220,7 +227,7 @@ impl FaultLocations { /// /// Unlike `SpacetimeLocation` which uses tick indices, this uses DAG node indices /// for more efficient sparse propagation. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone)] pub struct DagSpacetimeLocation { /// The node index in the DAG. pub node: usize, @@ -230,8 +237,47 @@ pub struct DagSpacetimeLocation { pub before: bool, /// The type of gate at this location. pub gate_type: GateType, - /// Duration for idle gates (in abstract time units). 0 for non-idle gates. - pub idle_duration: u64, + /// Duration for idle gates. 0.0 for non-idle gates. + pub idle_duration: f64, +} + +impl PartialEq for DagSpacetimeLocation { + fn eq(&self, other: &Self) -> bool { + self.node == other.node + && self.qubits == other.qubits + && self.before == other.before + && self.gate_type == other.gate_type + && self.idle_duration.to_bits() == other.idle_duration.to_bits() + } +} + +impl Eq for DagSpacetimeLocation {} + +impl PartialOrd for DagSpacetimeLocation { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for DagSpacetimeLocation { + fn cmp(&self, other: &Self) -> Ordering { + self.node + .cmp(&other.node) + .then_with(|| self.qubits.cmp(&other.qubits)) + .then_with(|| self.before.cmp(&other.before)) + .then_with(|| self.gate_type.cmp(&other.gate_type)) + .then_with(|| self.idle_duration.total_cmp(&other.idle_duration)) + } +} + +impl Hash for DagSpacetimeLocation { + fn hash(&self, state: &mut H) { + self.node.hash(state); + self.qubits.hash(state); + self.before.hash(state); + self.gate_type.hash(state); + self.idle_duration.to_bits().hash(state); + } } // ============================================================================ @@ -1785,9 +1831,14 @@ impl<'a> DagFaultAnalyzer<'a> { // Idle gates on non-active qubits provide the missing "before" // coverage that would otherwise require before-gate locations. let before = is_measurement; + let idle_duration = if gate.gate_type == GateType::Idle { + gate.idle_duration() + } else { + 0.0 + }; for &q in &qubits { let single_qubit: SmallVec<[usize; 2]> = smallvec::smallvec![q]; - locations.push(node, single_qubit, before, gate.gate_type); + locations.push(node, single_qubit, before, gate.gate_type, idle_duration); } } } @@ -2645,14 +2696,14 @@ mod tests { qubits: vec![QubitId::from(0)], before: true, gate_type: GateType::H, - idle_duration: 0, + idle_duration: 0.0, }; let loc2 = DagSpacetimeLocation { node: 1, qubits: vec![QubitId::from(0)], before: true, gate_type: GateType::H, - idle_duration: 0, + idle_duration: 0.0, }; assert!(loc1 < loc2); } @@ -2764,7 +2815,7 @@ mod tests { qubits: vec![QubitId(0)], before: false, gate_type: GateType::H, - idle_duration: 0, + idle_duration: 0.0, }); map.dem_output_metadata = vec![ DemOutputMetadata::tracked_pauli(pecos_core::PauliString::xs(&[0])), diff --git a/crates/pecos-qec/tests/idle_noise_tests.rs b/crates/pecos-qec/tests/idle_noise_tests.rs index 6eebe49d0..dfb7fa2a3 100644 --- a/crates/pecos-qec/tests/idle_noise_tests.rs +++ b/crates/pecos-qec/tests/idle_noise_tests.rs @@ -37,6 +37,16 @@ fn build_idle_then_measure(num_idles: usize) -> DagCircuit { dag } +fn build_nanosecond_idle_x_basis_measure() -> DagCircuit { + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + dag.h(&[0]); + dag.idle(TimeUnits::new(20), &[0]); + dag.h(&[0]); + dag.mz(&[0]); + dag +} + #[test] fn idle_locations_contribute_mechanisms_when_rates_set() { let dag = build_idle_then_measure(2); @@ -179,6 +189,55 @@ fn explicit_uniform_idle_noise_is_noisy() { ); } +#[test] +fn nanosecond_timeunit_idle_duration_is_preserved_in_fault_locations() { + let dag = build_nanosecond_idle_x_basis_measure(); + let influence = DagFaultAnalyzer::new(&dag).build_influence_map(); + + let idle = influence + .locations + .iter() + .find(|loc| loc.gate_type == GateType::Idle) + .expect("idle location"); + + assert!((idle.idle_duration - 20.0).abs() < f64::EPSILON); +} + +#[test] +fn linear_memory_z_noise_uses_idle_duration_in_dem() { + let dag = build_nanosecond_idle_x_basis_measure(); + let analyzer = DagFaultAnalyzer::new(&dag); + 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_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build(); + + assert!( + dem.num_contributions() > 0, + "linear Z-memory noise on an idle should produce DEM contributions", + ); +} + +#[test] +fn idle_memory_z_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) + .idle_pauli_probs(20.0); + assert_eq!(linear.px, 0.0); + assert_eq!(linear.py, 0.0); + 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.5) + .idle_pauli_probs(2.0); + assert_eq!(quadratic.px, 0.0); + assert_eq!(quadratic.py, 0.0); + assert!((quadratic.pz - 1.0_f64.sin().powi(2)).abs() < 1e-15); +} + #[test] fn dem_builder_scalar_p1_does_not_attach_to_idle() { let dag = build_idle_then_measure(1); diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index c1c88bc13..f448c9080 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -1597,6 +1597,7 @@ mod tests { let ops = vec![ Operation::AllocateQubit { id: 0 }, QuantumOp::H(0).into(), + QuantumOp::Idle(20e-9, 0).into(), QuantumOp::Measure(0, 7).into(), ]; let commands = engine @@ -1619,17 +1620,89 @@ mod tests { assert_eq!(value["shot_index"], 1); assert_eq!(value["waiting_for_result_id"], 7); assert_eq!(value["current_shot_seed"], 123); - assert_eq!(value["num_operations"], 3); + assert_eq!(value["num_operations"], 4); assert_eq!(value["operations"][0]["AllocateQubit"]["id"], 0); assert_eq!(value["operations"][1]["Quantum"]["H"], 0); + assert_eq!(value["operations"][2]["Quantum"]["Idle"][0], 20e-9); assert_eq!(value["lowered_quantum_ops"][0]["gate_type"], "PZ"); assert_eq!(value["lowered_quantum_ops"][1]["gate_type"], "H"); - assert_eq!(value["lowered_quantum_ops"][2]["gate_type"], "MZ"); + assert_eq!(value["lowered_quantum_ops"][2]["gate_type"], "Idle"); + assert_eq!(value["lowered_quantum_ops"][2]["params"][0], 20e-9); + assert_eq!(value["lowered_quantum_ops"][3]["gate_type"], "MZ"); let in_memory = collector.lock().expect("collector lock"); assert_eq!(in_memory.len(), 1); assert_eq!(in_memory[0].stage, "unit_test"); assert_eq!(in_memory[0].lowered_quantum_ops[0].gate_type, "PZ"); + assert_eq!(in_memory[0].lowered_quantum_ops[2].gate_type, "Idle"); + assert_eq!(in_memory[0].lowered_quantum_ops[2].params, vec![20e-9]); + } + + #[derive(Clone, Default)] + struct IdleLoweringRuntime { + state: ClassicalState, + } + + impl QisRuntime for IdleLoweringRuntime { + fn load_interface(&mut self, _interface: OperationList) -> RuntimeResult<()> { + Ok(()) + } + + fn execute_until_quantum(&mut self) -> RuntimeResult>> { + Ok(None) + } + + fn provide_measurements( + &mut self, + _measurements: BTreeMap, + ) -> RuntimeResult<()> { + Ok(()) + } + + fn get_classical_state(&self) -> &ClassicalState { + &self.state + } + + fn get_classical_state_mut(&mut self) -> &mut ClassicalState { + &mut self.state + } + + fn is_complete(&self) -> bool { + true + } + + fn num_qubits(&self) -> usize { + 1 + } + + fn supports_operation_lowering(&self) -> bool { + true + } + + fn lower_operations(&mut self, _operations: &[Operation]) -> RuntimeResult> { + Ok(vec![QuantumOp::Idle(20e-9, 0), QuantumOp::H(0)]) + } + } + + #[test] + fn test_operation_trace_chunk_includes_runtime_lowered_idles() { + let mut engine = QisEngine::with_runtime(Box::new(IdleLoweringRuntime::default())); + let collector: OperationTraceStore = Arc::new(Mutex::new(Vec::new())); + engine.set_operation_trace_collector(collector.clone()); + engine.begin_trace_shot(); + + let ops = vec![QuantumOp::H(0).into()]; + let commands = engine + .lower_operations_to_bytemessage(&ops) + .expect("runtime lower ops to bytemessage"); + engine.trace_operations_chunk("unit_test", &ops, None, Some(&commands)); + + let in_memory = collector.lock().expect("collector lock"); + assert_eq!(in_memory.len(), 1); + assert_eq!(in_memory[0].lowered_quantum_ops[0].gate_type, "Idle"); + assert_eq!(in_memory[0].lowered_quantum_ops[0].params, vec![20e-9]); + assert_eq!(in_memory[0].lowered_quantum_ops[0].qubits, vec![0]); + assert_eq!(in_memory[0].lowered_quantum_ops[1].gate_type, "H"); } #[test] diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index c443778b2..049e6ce91 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -70,6 +70,31 @@ use pyo3::prelude::*; type PyDemMechanismTuple = (f64, Vec, Vec); type PyDemFitResult = (Vec, Vec); +fn apply_idle_noise_options( + mut noise: NoiseConfig, + p_idle: Option, + t1: Option, + t2: Option, + idle_rz: Option, + p_idle_linear_rate: Option, + p_idle_quadratic_rate: Option, +) -> NoiseConfig { + 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); + } + 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); + } + noise +} + // Adapter for decoder factories that require `Send + Sync` trait objects. // Decoder implementations own their state; Python access remains GIL-mediated. struct SendWrapper(Box); @@ -952,26 +977,42 @@ impl PyDetectorErrorModel { /// >>> print(dem.to_string()) /// >>> sampler = dem.to_sampler() #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] + #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &pyo3::Bound<'_, pyo3::PyAny>, p1: f64, p2: f64, p_meas: f64, p_prep: f64, + p_idle: Option, + t1: Option, + t2: Option, + idle_rz: Option, + p_idle_linear_rate: Option, + p_idle_quadratic_rate: Option, ) -> PyResult { use pecos_qec::fault_tolerance::dem_builder::DemBuilder; + let noise = apply_idle_noise_options( + NoiseConfig::new(p1, p2, p_meas, p_prep), + p_idle, + t1, + t2, + idle_rz, + p_idle_linear_rate, + p_idle_quadratic_rate, + ); if let Ok(dag) = circuit.extract::>() { - let inner = DemBuilder::try_from_circuit(&dag.inner, p1, p2, p_meas, p_prep) + let inner = DemBuilder::try_from_circuit_with_noise_config(&dag.inner, noise) .map_err(|err| pyo3::exceptions::PyValueError::new_err(err.to_string()))?; Ok(Self { inner }) } else if let Ok(tc) = circuit.extract::>() { - let inner = DemBuilder::try_from_tick_circuit(&tc.inner, p1, p2, p_meas, p_prep) + let inner = DemBuilder::try_from_tick_circuit_with_noise_config(&tc.inner, noise) .map_err(|err| pyo3::exceptions::PyValueError::new_err(err.to_string()))?; Ok(Self { inner }) } else { @@ -1270,7 +1311,7 @@ impl PyDemBuilder { /// /// Returns: /// Self for method chaining. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -1282,16 +1323,18 @@ impl PyDemBuilder { t1: Option, t2: Option, idle_rz: Option, + p_idle_linear_rate: Option, + p_idle_quadratic_rate: Option, ) -> PyRefMut<'_, Self> { - let mut noise = NoiseConfig::new(p1, p2, p_meas, p_prep); - 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); - } - if let Some(rz) = idle_rz { - noise = noise.set_idle_rz(rz); - } - slf.noise = noise; + slf.noise = apply_idle_noise_options( + NoiseConfig::new(p1, p2, p_meas, p_prep), + p_idle, + t1, + t2, + idle_rz, + p_idle_linear_rate, + p_idle_quadratic_rate, + ); slf } @@ -3135,7 +3178,8 @@ impl PyDemSampler { /// >>> sampler = DemSampler.from_circuit(dag, p1=0.001, p2=0.01) /// >>> sampler = DemSampler.from_circuit(tc, p2=0.01) # TickCircuit also works #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, idle_rz=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] + #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &Bound<'_, pyo3::PyAny>, p1: f64, @@ -3143,13 +3187,21 @@ impl PyDemSampler { p_meas: f64, p_prep: f64, p_idle: Option, + t1: Option, + t2: Option, idle_rz: Option, + p_idle_linear_rate: Option, + p_idle_quadratic_rate: Option, ) -> PyResult { - let mut noise = NoiseConfig::new(p1, p2, p_meas, p_prep); - noise.p_idle = p_idle.unwrap_or(0.0); - if let Some(rz) = idle_rz { - noise = noise.set_idle_rz(rz); - } + let noise = apply_idle_noise_options( + NoiseConfig::new(p1, p2, p_meas, p_prep), + p_idle, + t1, + t2, + idle_rz, + p_idle_linear_rate, + p_idle_quadratic_rate, + ); // Accept both DagCircuit and TickCircuit if let Ok(dag) = @@ -3259,7 +3311,7 @@ impl PyDemSampler { /// /// The `observables` argument defines observables. #[staticmethod] - #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None))] + #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] #[allow(clippy::too_many_arguments)] fn with_detectors( influence_map: &PyDagFaultInfluenceMap, @@ -3272,12 +3324,19 @@ impl PyDemSampler { p_idle: Option, t1: Option, t2: Option, + idle_rz: Option, + p_idle_linear_rate: Option, + p_idle_quadratic_rate: Option, ) -> PyResult { - let mut noise = NoiseConfig::new(p1, p2, p_meas, p_prep); - 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); - } + let noise = apply_idle_noise_options( + NoiseConfig::new(p1, p2, p_meas, p_prep), + p_idle, + t1, + t2, + idle_rz, + p_idle_linear_rate, + p_idle_quadratic_rate, + ); let inner = RustNewDemSamplerBuilder::new(&influence_map.inner) .with_noise_config(noise) .with_detectors(detectors, observables) @@ -3730,7 +3789,7 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -3742,16 +3801,18 @@ impl PyDemSamplerBuilder { t1: Option, t2: Option, idle_rz: Option, + p_idle_linear_rate: Option, + p_idle_quadratic_rate: Option, ) -> PyRefMut<'_, Self> { - let mut noise = NoiseConfig::new(p1, p2, p_meas, p_prep); - 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); - } - if let Some(rz) = idle_rz { - noise = noise.set_idle_rz(rz); - } - slf.noise = noise; + slf.noise = apply_idle_noise_options( + NoiseConfig::new(p1, p2, p_meas, p_prep), + p_idle, + t1, + t2, + idle_rz, + p_idle_linear_rate, + p_idle_quadratic_rate, + ); slf } diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 1adf274c2..febc93e89 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -72,6 +72,11 @@ def from_guppy( p2: float = 0.01, p_meas: float = 0.001, p_prep: float = 0.001, + 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, seed: int = 0, ) -> _RustDetectorErrorModel: """Build a circuit-level DEM from a Guppy program by tracing it. @@ -142,6 +147,12 @@ def from_guppy( p2: Two-qubit gate depolarizing rate. 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 stochastic Z-memory rate linear in idle duration. + p_idle_quadratic_rate: Optional stochastic Z-memory rate using + ``sin(rate * duration) ** 2``. seed: Seed for the ideal trace run. Returns: @@ -241,6 +252,11 @@ def from_guppy( p2=p2, p_meas=p_meas, p_prep=p_prep, + p_idle=p_idle, + t1=t1, + t2=t2, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, ) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index b50b51718..5690f528a 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -2142,6 +2142,8 @@ def generate_dem_from_tick_circuit( 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, decompose_errors: bool = True, maximal_decomposition: bool = False, ) -> str: @@ -2173,6 +2175,9 @@ def generate_dem_from_tick_circuit( The caller is responsible for inserting idle gates where needed. t1: Optional T1 relaxation time for explicit idle gates. t2: Optional T2 dephasing time for explicit idle gates. + p_idle_linear_rate: Optional stochastic Z-memory rate linear in idle duration. + p_idle_quadratic_rate: Optional stochastic Z-memory rate using + ``sin(rate * duration) ** 2``. decompose_errors: If True (default), decompose hyperedge errors into graphlike components using the `^` separator. Set to False to output raw hyperedges. Ignored if maximal_decomposition=True. @@ -2207,7 +2212,17 @@ def generate_dem_from_tick_circuit( # Build DEM using Rust DemBuilder builder = DemBuilder(influence_map) - builder.with_noise(p1, p2, p_meas, p_prep, p_idle=p_idle, t1=t1, t2=t2) + builder.with_noise( + p1, + p2, + p_meas, + p_prep, + p_idle=p_idle, + t1=t1, + t2=t2, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, + ) builder.with_num_measurements(num_measurements) builder.with_measurement_order(measurement_order) builder.with_detectors_json(detectors_json) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index e65994390..3864b8adc 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -92,6 +92,9 @@ 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_rate: Stochastic Z-memory rate linear in idle duration. + p_idle_quadratic_rate: Stochastic Z-memory rate using + ``sin(rate * duration) ** 2``. """ p1: float = 0.0 @@ -101,6 +104,8 @@ class NoiseModel: 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 @staticmethod def uniform(physical_error_rate: float) -> NoiseModel: @@ -117,6 +122,8 @@ def is_noiseless(self) -> bool: and self.p_meas == 0.0 and self.p_prep == 0.0 and (self.p_idle is None or self.p_idle == 0.0) + and (self.p_idle_linear_rate is None or self.p_idle_linear_rate == 0.0) + and (self.p_idle_quadratic_rate is None or self.p_idle_quadratic_rate == 0.0) ) @property @@ -125,6 +132,10 @@ def physical_error_rate(self) -> float: rates = [self.p1, self.p2, self.p_meas, self.p_prep] if self.p_idle is not None: rates.append(self.p_idle) + if self.p_idle_linear_rate is not None: + rates.append(self.p_idle_linear_rate) + if self.p_idle_quadratic_rate is not None: + rates.append(self.p_idle_quadratic_rate) return max(rates) @@ -406,6 +417,22 @@ def _copy_surface_tick_circuit_metadata(source_tc: Any, target_tc: Any) -> None: target_tc.set_meta(key, value) +def _runtime_idle_seconds_to_time_units(duration_seconds: float) -> Any: + """Convert runtime idle seconds into PECOS nanosecond time units.""" + import math + + from pecos_rslib import TimeUnits + + if not math.isfinite(duration_seconds) or duration_seconds < 0.0: + msg = f"Idle duration must be finite and non-negative, got {duration_seconds!r}" + raise ValueError(msg) + + units = round(duration_seconds * 1_000_000_000.0) + if duration_seconds > 0.0: + units = max(1, units) + return TimeUnits(units) + + def _replay_qis_trace_into_tick_circuit(operations: list[dict[str, Any]]) -> Any: """Replay traced QIS operations into a PECOS TickCircuit.""" import heapq @@ -502,6 +529,12 @@ def tuple_args(payload: Any, op_name: str, arity: int) -> tuple[Any, ...]: elif op_name == "RXY": theta, phi, program_id = tuple_args(payload, op_name, 3) tick.r1xy(float(theta), float(phi), [mapped_slot(int(program_id), op_name)]) + elif op_name == "Idle": + duration, program_id = tuple_args(payload, op_name, 2) + tick.idle( + _runtime_idle_seconds_to_time_units(float(duration)), + [mapped_slot(int(program_id), op_name)], + ) elif op_name == "CX": control, target = tuple_args(payload, op_name, 2) tick.cx([(mapped_slot(int(control), op_name), mapped_slot(int(target), op_name))]) @@ -614,6 +647,7 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> gate_type = str(gate["gate_type"]) qubits = [int(q) for q in gate.get("qubits", [])] angles = [float(theta) for theta in gate.get("angles", [])] + params = [float(param) for param in gate.get("params", [])] tick = tick_circuit.tick() if gate_type == "H": @@ -634,6 +668,11 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> tick.tdg(qubits) elif gate_type == "PZ": tick.pz(qubits) + elif gate_type == "Idle": + if len(params) != 1: + 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": end = meas_cursor + len(qubits) if end > len(meas_ids_in_order): @@ -977,14 +1016,27 @@ def _uses_dedicated_idle_noise( p_idle: float | None, t1: float | None, t2: float | None, + p_idle_linear_rate: float | None = None, + p_idle_quadratic_rate: float | None = None, ) -> bool: """Return True when noise parameters require explicit idle locations.""" - return (p_idle is not None and p_idle > 0.0) or (t1 is not None and t2 is not None) + return ( + (p_idle is not None and p_idle > 0.0) + or (t1 is not None and t2 is not None) + or (p_idle_linear_rate is not None and p_idle_linear_rate > 0.0) + or (p_idle_quadratic_rate is not None and p_idle_quadratic_rate != 0.0) + ) def _noise_uses_dedicated_idle_noise(noise: NoiseModel) -> bool: """Return True when this noise model requires explicit idle locations.""" - return _uses_dedicated_idle_noise(p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2) + return _uses_dedicated_idle_noise( + 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, + ) @cache @@ -1047,7 +1099,17 @@ def _dem_string_from_cached_surface_topology( dem = ( DemBuilder(topology.influence_map) - .with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_prep, p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2) + .with_noise( + noise.p1, + noise.p2, + noise.p_meas, + noise.p_prep, + 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, + ) .with_num_measurements(topology.num_measurements) .with_measurement_order(list(topology.measurement_order)) .with_detectors_json(topology.detectors_json) @@ -1072,9 +1134,17 @@ def _cached_surface_native_dem_string( 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, ) -> str: """Cache native DEM strings across callers for one topology + noise tuple.""" - include_idle_gates = _uses_dedicated_idle_noise(p_idle=p_idle, t1=t1, t2=t2) + include_idle_gates = _uses_dedicated_idle_noise( + p_idle=p_idle, + t1=t1, + t2=t2, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, + ) topology = _cached_surface_native_topology( patch_key, num_rounds, @@ -1085,7 +1155,17 @@ def _cached_surface_native_dem_string( ) return _dem_string_from_cached_surface_topology( topology, - NoiseModel(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep, p_idle=p_idle, t1=t1, t2=t2), + NoiseModel( + p1=p1, + p2=p2, + p_meas=p_meas, + p_prep=p_prep, + p_idle=p_idle, + t1=t1, + t2=t2, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, + ), decompose_errors=decompose_errors, ) @@ -1134,6 +1214,8 @@ def _build_native_sampler_from_cached_surface_topology( 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, ) # Remap sampling_model for NativeSampler dispatch sampling_model = "influence_dem" @@ -1215,6 +1297,8 @@ 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, ) @@ -2881,6 +2965,8 @@ 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, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() return NativeSampler( 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 5d493b5fc..7c58aaaa7 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -63,6 +63,16 @@ def _flat_mz_ids(tc) -> list[int]: return ids +def _flat_idle_gates(tc) -> list[tuple[list[int], float]]: + dag = tc.to_dag_circuit() + idles: list[tuple[list[int], float]] = [] + for node_id in dag.nodes(): + gate = dag.gate(node_id) + if gate is not None and gate.gate_type.name == "Idle": + idles.append((list(gate.qubits), float(gate.params[0]))) + return idles + + def test_from_guppy_meas_ids_are_normalized_to_records() -> None: assert _dem_text(detectors_json='[{"id":0,"meas_ids":[0]}]') == _dem_text( detectors_json='[{"id":0,"records":[-1]}]', @@ -149,6 +159,54 @@ def test_lowered_replay_fails_on_measurement_count_mismatch() -> None: _replay_lowered_qis_trace_into_tick_circuit(chunks) +def test_lowered_replay_preserves_runtime_idles() -> None: + chunks = [ + { + "operations": [{"Quantum": {"H": 0}}], + "lowered_quantum_ops": [ + {"gate_type": "Idle", "qubits": [0], "angles": [], "params": [20e-9]}, + {"gate_type": "H", "qubits": [0], "angles": [], "params": []}, + ], + }, + ] + + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + + assert _flat_idle_gates(tc) == [([0], 20.0)] + + +def test_lowered_runtime_idles_can_drive_memory_noise_dem() -> None: + from pecos.qec import DetectorErrorModel + + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 0]}}], + "lowered_quantum_ops": [ + {"gate_type": "PZ", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "H", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "Idle", "qubits": [0], "angles": [], "params": [20e-9]}, + {"gate_type": "H", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "MZ", "qubits": [0], "angles": [], "params": []}, + ], + }, + ] + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + tc.set_meta("detectors", '[{"id": 0, "records": [-1]}]') + tc.set_meta("observables", "[]") + tc.set_meta("num_measurements", "1") + + dem = DetectorErrorModel.from_circuit( + tc, + p1=0.0, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p_idle_linear_rate=1.0e-3, + ) + + assert dem.num_contributions > 0 + + def test_reject_partially_lowered_trace_passes_on_uniformly_lowered() -> None: """A trace where every quantum-carrying chunk is also lowered is accepted (this is the real Selene shape; the byte-identical regressions exercise it @@ -217,6 +275,18 @@ def test_non_lowered_replay_preserves_non_sequential_result_ids() -> None: assert _flat_mz_ids(tc) == [77, 3] +def test_non_lowered_replay_preserves_idle_ops() -> None: + operations = [ + {"AllocateQubit": {"id": 10}}, + {"Quantum": {"Idle": [20e-9, 10]}}, + {"Quantum": {"H": 10}}, + ] + + tc = _replay_qis_trace_into_tick_circuit(operations) + + assert _flat_idle_gates(tc) == [([0], 20.0)] + + def test_from_guppy_surface_code_is_byte_identical_to_reference() -> None: """Regression: from_guppy(make_surface_code(...)) must work and match the traced_qis reference DEM. A reverted dynamic-control guard had broken this From c2cbfe18fc4434252d97fd5ed154ec347a392037 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 30 May 2026 10:00:59 -0600 Subject: [PATCH 005/150] Allow traced Guppy DEMs to use custom Selene runtimes. --- docs/development/from-guppy-dem-handoff.md | 8 ++ python/quantum-pecos/src/pecos/qec/dem.py | 6 +- .../src/pecos/qec/surface/decode.py | 75 +++++++++++++++++-- 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/docs/development/from-guppy-dem-handoff.md b/docs/development/from-guppy-dem-handoff.md index 6d3b076c8..5dff0bfa5 100644 --- a/docs/development/from-guppy-dem-handoff.md +++ b/docs/development/from-guppy-dem-handoff.md @@ -105,6 +105,14 @@ line. - Prefer the generic `from_guppy(...)` abstraction for future DEM construction rather than adding more surface-specific tracing plumbing. +- Runtime plugins are intentionally generic: pass a Selene runtime plugin object + through `pecos.selene_engine(runtime)` or the higher-level + `runtime=...` arguments on traced Guppy/DEM helpers. Anduril should live in + downstream experiment projects, not as a PECOS dependency. +- 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. - Keep the surface helper path compatible with constrained ancilla budgets: pass `ancilla_budget` into both `make_surface_code(...)` and `get_num_qubits(...)` when tracing surface Guppy. diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index c1e083013..19776ab0d 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -64,6 +64,7 @@ def from_guppy( t2: float | None = None, p_idle_linear_rate: float | None = None, p_idle_quadratic_rate: float | None = None, + runtime: object | None = None, seed: int = 0, ) -> _RustDetectorErrorModel: """Build a circuit-level DEM from a Guppy program by tracing it. @@ -140,6 +141,9 @@ def from_guppy( p_idle_linear_rate: Optional stochastic Z-memory rate linear in idle duration. p_idle_quadratic_rate: Optional stochastic Z-memory rate using ``sin(rate * duration) ** 2``. + runtime: Optional Selene runtime selector/plugin. ``None`` selects + the default Selene runtime. Runtime plugin objects are passed + through to ``pecos.selene_engine(runtime)``. seed: Seed for the ideal trace run. Returns: @@ -201,7 +205,7 @@ def from_guppy( ) raise ValueError(msg) from exc - tc = trace_guppy_into_tick_circuit(guppy, num_qubits, seed=seed) + tc = trace_guppy_into_tick_circuit(guppy, num_qubits, seed=seed, runtime=runtime) # Compilation passes required for traced QIS circuits before fault # analysis: normalize parameterized Clifford rotations to named gates diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 3864b8adc..dc14115ce 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -776,7 +776,13 @@ def _reject_partially_lowered_trace(chunks: list[dict[str, Any]]) -> None: raise ValueError(msg) -def trace_guppy_into_tick_circuit(program: Any, num_qubits: int, *, seed: int = 0) -> Any: +def trace_guppy_into_tick_circuit( + program: Any, + num_qubits: int, + *, + seed: int = 0, + runtime: object | None = None, +) -> Any: """Trace a Guppy/QIS program's lowered Selene op stream into a ``TickCircuit``. Runs ``program`` under the Selene QIS engine with operation tracing enabled @@ -797,6 +803,9 @@ def trace_guppy_into_tick_circuit(program: Any, num_qubits: int, *, seed: int = num_qubits: Number of qubits to allocate. QIS/HUGR programs require an explicit qubit count for trace capture. seed: Seed for the (ideal) trace run. + runtime: Optional Selene runtime selector/plugin. ``None`` selects the + default Selene runtime. Runtime plugin objects are passed through to + ``pecos.selene_engine(runtime)``. Returns: A ``TickCircuit`` with no detector/observable metadata attached; the @@ -805,7 +814,11 @@ def trace_guppy_into_tick_circuit(program: Any, num_qubits: int, *, seed: int = import pecos sim_builder = ( - pecos.sim(program).classical(pecos.selene_engine()).quantum(pecos.stabilizer()).qubits(num_qubits).seed(seed) + pecos.sim(program) + .classical(pecos.selene_engine(runtime)) + .quantum(pecos.stabilizer()) + .qubits(num_qubits) + .seed(seed) ) chunks = list(sim_builder.capture_operation_trace()) @@ -831,6 +844,7 @@ def _generate_traced_surface_tick_circuit( basis: str, *, ancilla_budget: int | None = None, + runtime: object | None = None, ) -> Any: """Trace the lowered ideal Selene/QIS op stream and replay it into a TickCircuit. @@ -859,6 +873,7 @@ def _generate_traced_surface_tick_circuit( program, get_num_qubits(patch=patch, ancilla_budget=ancilla_budget), seed=0, + runtime=runtime, ) @@ -869,6 +884,7 @@ def _build_surface_tick_circuit_for_native_model( *, ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", + runtime: object | None = None, ) -> Any: """Build the TickCircuit used by the native DEM and sampler paths.""" from pecos.qec.surface.circuit_builder import ( @@ -895,6 +911,7 @@ def _build_surface_tick_circuit_for_native_model( num_rounds, basis, ancilla_budget=ancilla_budget, + runtime=runtime, ) # Coarse sanity check: the traced and abstract circuits must agree on the # sequence of *measured qubit indices*. This catches gross drift (a dropped @@ -935,6 +952,7 @@ def build_memory_circuit( basis: str = "Z", ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", + runtime: object | None = None, ) -> Any: """Build the standard surface-code memory ``TickCircuit``. @@ -951,6 +969,8 @@ def build_memory_circuit( ancilla_budget: Optional cap on simultaneously live ancillas. circuit_source: ``"abstract"`` for the native surface builder or ``"traced_qis"`` for the lowered traced QIS gate stream. + runtime: Optional Selene runtime selector/plugin used when + ``circuit_source="traced_qis"``. Returns: A Rust-backed ``TickCircuit`` with detector and observable metadata. @@ -981,6 +1001,7 @@ def build_memory_circuit( basis, ancilla_budget=ancilla_budget, circuit_source=circuit_source, + runtime=runtime, ) @@ -1039,16 +1060,17 @@ def _noise_uses_dedicated_idle_noise(noise: NoiseModel) -> bool: ) -@cache -def _cached_surface_native_topology( +def _surface_native_topology( patch_key: tuple[int, int, str, bool], num_rounds: int, basis: str, ancilla_budget: int | None, circuit_source: Literal["abstract", "traced_qis"], include_idle_gates: bool, + *, + runtime: object | None = None, ) -> _CachedNativeSurfaceTopology: - """Cache topology-only native analysis shared across noise parameters.""" + """Build topology-only native analysis shared across noise parameters.""" import json from pecos.qec import DagFaultAnalyzer @@ -1061,6 +1083,7 @@ def _cached_surface_native_topology( basis, ancilla_budget=ancilla_budget, circuit_source=circuit_source, + runtime=runtime, ) if include_idle_gates: # Insert idle gates only when the requested noise model includes a @@ -1088,6 +1111,26 @@ def _cached_surface_native_topology( ) +@cache +def _cached_surface_native_topology( + patch_key: tuple[int, int, str, bool], + num_rounds: int, + basis: str, + ancilla_budget: int | None, + circuit_source: Literal["abstract", "traced_qis"], + include_idle_gates: bool, +) -> _CachedNativeSurfaceTopology: + """Cache topology-only native analysis shared across noise parameters.""" + return _surface_native_topology( + patch_key, + num_rounds, + basis, + ancilla_budget, + circuit_source, + include_idle_gates, + ) + + def _dem_string_from_cached_surface_topology( topology: _CachedNativeSurfaceTopology, noise: NoiseModel, @@ -1242,6 +1285,7 @@ def generate_circuit_level_dem_from_builder( decompose_errors: bool = False, ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", + runtime: object | None = None, ) -> str: """Generate circuit-level DEM using PECOS native fault propagation. @@ -1270,6 +1314,10 @@ def generate_circuit_level_dem_from_builder( ``"traced_qis"`` traces the lowered ideal Selene/QIS gate stream and replays that exact gate list into a TickCircuit before running native PECOS fault analysis. + runtime: Optional Selene runtime selector/plugin used when + ``circuit_source="traced_qis"``. Custom runtime topologies are not + kept in PECOS's in-process topology cache because plugin objects + can carry private mutable state. Returns: DEM string in standard format @@ -1283,6 +1331,23 @@ def generate_circuit_level_dem_from_builder( """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) patch_key = _surface_patch_cache_key(patch) + include_idle_gates = _noise_uses_dedicated_idle_noise(noise) + if runtime is not None: + topology = _surface_native_topology( + patch_key, + num_rounds, + basis.upper(), + ancilla_budget, + circuit_source, + include_idle_gates, + runtime=runtime, + ) + return _dem_string_from_cached_surface_topology( + topology, + noise, + decompose_errors=decompose_errors, + ) + return _cached_surface_native_dem_string( patch_key, num_rounds, From 85cdcfa5621faf799b43d33066637a6ef3968a7e Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 30 May 2026 10:15:51 -0600 Subject: [PATCH 006/150] Keep runtime plugin handoff wording generic. --- docs/development/from-guppy-dem-handoff.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/development/from-guppy-dem-handoff.md b/docs/development/from-guppy-dem-handoff.md index 5dff0bfa5..54766dbff 100644 --- a/docs/development/from-guppy-dem-handoff.md +++ b/docs/development/from-guppy-dem-handoff.md @@ -105,10 +105,11 @@ line. - Prefer the generic `from_guppy(...)` abstraction for future DEM construction rather than adding more surface-specific tracing plumbing. -- Runtime plugins are intentionally generic: pass a Selene runtime plugin object - through `pecos.selene_engine(runtime)` or the higher-level - `runtime=...` arguments on traced Guppy/DEM helpers. Anduril should live in - downstream experiment projects, not as a PECOS dependency. +- Runtime plugins are intentionally generic: pass any Selene-compatible runtime + plugin object through `pecos.selene_engine(runtime)` or the higher-level + `runtime=...` arguments on traced Guppy/DEM helpers. PECOS should depend only + on the public shape of those plugin objects; experiment-specific runtimes and + package sources belong in downstream projects. - 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 From 0962b2074e86604043a912f828de2bced2cc652d Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 30 May 2026 18:39:09 -0600 Subject: [PATCH 007/150] Add weighted Pauli DEM noise support --- .../src/fault_tolerance/dem_builder/types.rs | 128 ++++++++++-- crates/pecos-qec/tests/idle_noise_tests.rs | 14 +- .../src/fault_tolerance_bindings.rs | 196 ++++++++++++++++-- python/quantum-pecos/src/pecos/qec/dem.py | 33 ++- .../src/pecos/qec/surface/circuit_builder.py | 72 ++++++- .../src/pecos/qec/surface/decode.py | 142 ++++++++++++- .../tests/qec/test_from_guppy_dem.py | 81 ++++++++ 7 files changed, 601 insertions(+), 65 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 48a695879..e23e3d68d 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -1546,13 +1546,24 @@ pub struct NoiseConfig { /// 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`. + /// + /// 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. /// - /// This follows the incoherent PECOS engine convention for quadratic idle - /// dephasing: each explicit `Idle(duration, q)` contributes an independent - /// Z fault with probability `sin(p_idle_quadratic_rate * duration)^2`. + /// 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 X-memory error rate linear in idle duration. + 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, } /// Per-Pauli error probabilities for a single qubit. @@ -1620,6 +1631,10 @@ impl Default for NoiseConfig { idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_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, } } } @@ -1641,6 +1656,10 @@ impl NoiseConfig { idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_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, } } @@ -1660,6 +1679,10 @@ impl NoiseConfig { idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_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, } } @@ -1679,6 +1702,10 @@ impl NoiseConfig { idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_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, } } @@ -1699,7 +1726,30 @@ impl NoiseConfig { /// 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; + self.p_idle_quadratic_rate = rate.max(0.0); + 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 + } + + /// 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.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 } @@ -1781,28 +1831,64 @@ impl NoiseConfig { self } - fn compose_z_fault(probs: PauliProbs, z_probability: f64) -> PauliProbs { - let z_probability = z_probability.clamp(0.0, 1.0); - if z_probability <= f64::EPSILON { + fn idle_memory_probability(linear_rate: f64, quadratic_rate: f64, duration: f64) -> f64 { + let duration = duration.max(0.0); + (linear_rate.max(0.0) * duration + quadratic_rate.max(0.0) * duration * duration) + .clamp(0.0, 1.0) + } + + /// Dedicated idle-memory Pauli probabilities for `Idle(duration, q)`. + #[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, + duration, + ), + py: Self::idle_memory_probability( + self.p_idle_y_linear_rate, + self.p_idle_y_quadratic_rate, + duration, + ), + pz: Self::idle_memory_probability( + self.p_idle_linear_rate, + self.p_idle_quadratic_rate, + duration, + ), + }; + let total = probs.total(); + if total > 1.0 { + probs.px /= total; + probs.py /= total; + probs.pz /= total; + } + probs + } + + fn compose_pauli_channel(probs: PauliProbs, channel: PauliProbs) -> PauliProbs { + if channel.total() <= f64::EPSILON { return probs; } let p_identity = (1.0 - probs.total()).max(0.0); + let c_identity = (1.0 - channel.total()).max(0.0); PauliProbs { - px: probs.px * (1.0 - z_probability) + probs.py * z_probability, - py: probs.py * (1.0 - z_probability) + probs.px * z_probability, - pz: probs.pz * (1.0 - z_probability) + p_identity * z_probability, + px: p_identity * channel.px + + probs.px * c_identity + + probs.py * channel.pz + + probs.pz * channel.py, + py: p_identity * channel.py + + probs.py * c_identity + + probs.px * channel.pz + + probs.pz * channel.px, + pz: p_identity * channel.pz + + probs.pz * c_identity + + probs.px * channel.py + + probs.py * channel.px, } } - fn idle_memory_z_probability(&self, duration: f64) -> f64 { - let duration = duration.max(0.0); - let linear = (self.p_idle_linear_rate * duration).clamp(0.0, 1.0); - let quadratic = (self.p_idle_quadratic_rate * duration).sin().powi(2); - - linear + quadratic - 2.0 * linear * quadratic - } - /// Compute per-Pauli idle noise probabilities for a given duration. /// /// If T1/T2 are set, uses the Pauli-twirled model (biased noise). @@ -1814,7 +1900,7 @@ impl NoiseConfig { } else { PauliProbs::depolarizing((self.p_idle * duration).min(1.0)) }; - Self::compose_z_fault(probs, self.idle_memory_z_probability(duration)) + Self::compose_pauli_channel(probs, self.idle_memory_pauli_probs(duration)) } /// Returns true when idle locations use the dedicated idle-noise model. @@ -1826,6 +1912,10 @@ impl NoiseConfig { || 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_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 } } diff --git a/crates/pecos-qec/tests/idle_noise_tests.rs b/crates/pecos-qec/tests/idle_noise_tests.rs index dfb7fa2a3..4f195d58d 100644 --- a/crates/pecos-qec/tests/idle_noise_tests.rs +++ b/crates/pecos-qec/tests/idle_noise_tests.rs @@ -222,7 +222,7 @@ fn linear_memory_z_noise_uses_idle_duration_in_dem() { } #[test] -fn idle_memory_z_probabilities_match_linear_and_quadratic_model() { +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) .idle_pauli_probs(20.0); @@ -231,11 +231,19 @@ fn idle_memory_z_probabilities_match_linear_and_quadratic_model() { 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.5) + .set_idle_quadratic_rate(0.1) .idle_pauli_probs(2.0); assert_eq!(quadratic.px, 0.0); assert_eq!(quadratic.py, 0.0); - assert!((quadratic.pz - 1.0_f64.sin().powi(2)).abs() < 1e-15); + 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) + .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); } #[test] diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 049e6ce91..cd5d354fe 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -51,7 +51,8 @@ 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, NoiseConfig, ParsedDem as RustParsedDem, + FaultSourceType as RustFaultSourceType, NoiseConfig, PAULI_2Q_ORDER, + ParsedDem as RustParsedDem, PauliWeights, TwoDetectorDirectRenderPolicy as RustTwoDetectorDirectRenderPolicy, compare_dems_exact as rust_compare_dems_exact, compare_dems_statistical as rust_compare_dems_statistical, @@ -66,11 +67,62 @@ use pecos_quantum::DagCircuit; use pecos_quantum::QubitId; use pyo3::Py; use pyo3::prelude::*; +use std::collections::BTreeMap; type PyDemMechanismTuple = (f64, Vec, Vec); type PyDemFitResult = (Vec, Vec); -fn apply_idle_noise_options( +fn parse_p2_weights(weights: BTreeMap) -> PyResult { + use pecos_core::pauli::{X, Y, Z}; + + let mut entries = Vec::with_capacity(weights.len()); + let mut sum = 0.0; + for (label, weight) in weights { + let label = label.trim().to_ascii_uppercase(); + if !PAULI_2Q_ORDER.contains(&label.as_str()) { + let msg = format!( + "p2_weights keys must be one of {:?}, got {label:?}", + PAULI_2Q_ORDER + ); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + if !weight.is_finite() || weight < 0.0 { + let msg = + format!("p2_weights[{label:?}] must be finite and non-negative, got {weight}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + let mut pauli = None; + for (qubit, ch) in label.chars().enumerate() { + let term = match ch { + 'I' => None, + 'X' => Some(X(qubit)), + 'Y' => Some(Y(qubit)), + 'Z' => Some(Z(qubit)), + _ => unreachable!("validated p2_weights label contains only I/X/Y/Z"), + }; + pauli = match (pauli, term) { + (None, None) => None, + (Some(existing), None) => Some(existing), + (None, Some(term)) => Some(term), + (Some(existing), Some(term)) => Some(existing & term), + }; + } + let Some(pauli) = pauli else { + return Err(pyo3::exceptions::PyValueError::new_err( + "p2_weights cannot contain the identity pair 'II'", + )); + }; + sum += weight; + entries.push((pauli, weight)); + } + if (sum - 1.0).abs() >= 1.0e-6 { + let msg = format!("p2_weights relative probabilities must sum to 1.0, got {sum}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + Ok(PauliWeights::new(entries)) +} + +fn apply_noise_options( mut noise: NoiseConfig, p_idle: Option, t1: Option, @@ -78,7 +130,14 @@ fn apply_idle_noise_options( idle_rz: Option, p_idle_linear_rate: Option, p_idle_quadratic_rate: Option, -) -> NoiseConfig { + p_idle_x_linear_rate: Option, + p_idle_y_linear_rate: Option, + p_idle_z_linear_rate: Option, + p_idle_x_quadratic_rate: Option, + p_idle_y_quadratic_rate: Option, + p_idle_z_quadratic_rate: Option, + p2_weights: Option>, +) -> PyResult { 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); @@ -92,7 +151,28 @@ fn apply_idle_noise_options( if let Some(rate) = p_idle_quadratic_rate { noise = noise.set_idle_quadratic_rate(rate); } - noise + if let Some(rate) = p_idle_x_linear_rate { + noise.p_idle_x_linear_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_y_linear_rate { + noise.p_idle_y_linear_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_z_linear_rate { + noise.p_idle_linear_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_x_quadratic_rate { + noise.p_idle_x_quadratic_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_y_quadratic_rate { + noise.p_idle_y_quadratic_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_z_quadratic_rate { + noise.p_idle_quadratic_rate = rate.max(0.0); + } + if let Some(weights) = p2_weights { + noise = noise.set_p2_weights(parse_p2_weights(weights)?); + } + Ok(noise) } // Adapter for decoder factories that require `Send + Sync` trait objects. @@ -977,7 +1057,7 @@ impl PyDetectorErrorModel { /// >>> print(dem.to_string()) /// >>> sampler = dem.to_sampler() #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &pyo3::Bound<'_, pyo3::PyAny>, @@ -991,10 +1071,17 @@ impl PyDetectorErrorModel { idle_rz: Option, p_idle_linear_rate: Option, p_idle_quadratic_rate: Option, + p_idle_x_linear_rate: Option, + p_idle_y_linear_rate: Option, + p_idle_z_linear_rate: Option, + p_idle_x_quadratic_rate: Option, + p_idle_y_quadratic_rate: Option, + p_idle_z_quadratic_rate: Option, + p2_weights: Option>, ) -> PyResult { use pecos_qec::fault_tolerance::dem_builder::DemBuilder; - let noise = apply_idle_noise_options( + let noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), p_idle, t1, @@ -1002,7 +1089,14 @@ impl PyDetectorErrorModel { idle_rz, 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, + p2_weights, + )?; if let Ok(dag) = circuit.extract::>() { @@ -1311,7 +1405,7 @@ impl PyDemBuilder { /// /// Returns: /// Self for method chaining. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -1325,8 +1419,15 @@ impl PyDemBuilder { idle_rz: Option, p_idle_linear_rate: Option, p_idle_quadratic_rate: Option, - ) -> PyRefMut<'_, Self> { - slf.noise = apply_idle_noise_options( + p_idle_x_linear_rate: Option, + p_idle_y_linear_rate: Option, + p_idle_z_linear_rate: Option, + p_idle_x_quadratic_rate: Option, + p_idle_y_quadratic_rate: Option, + p_idle_z_quadratic_rate: Option, + p2_weights: Option>, + ) -> PyResult> { + slf.noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), p_idle, t1, @@ -1334,8 +1435,15 @@ impl PyDemBuilder { idle_rz, p_idle_linear_rate, p_idle_quadratic_rate, - ); - slf + 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, + p2_weights, + )?; + Ok(slf) } /// Set the detector definitions from JSON. @@ -3178,7 +3286,7 @@ impl PyDemSampler { /// >>> sampler = DemSampler.from_circuit(dag, p1=0.001, p2=0.01) /// >>> sampler = DemSampler.from_circuit(tc, p2=0.01) # TickCircuit also works #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &Bound<'_, pyo3::PyAny>, @@ -3192,8 +3300,15 @@ impl PyDemSampler { idle_rz: Option, p_idle_linear_rate: Option, p_idle_quadratic_rate: Option, + p_idle_x_linear_rate: Option, + p_idle_y_linear_rate: Option, + p_idle_z_linear_rate: Option, + p_idle_x_quadratic_rate: Option, + p_idle_y_quadratic_rate: Option, + p_idle_z_quadratic_rate: Option, + p2_weights: Option>, ) -> PyResult { - let noise = apply_idle_noise_options( + let noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), p_idle, t1, @@ -3201,7 +3316,14 @@ impl PyDemSampler { idle_rz, 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, + p2_weights, + )?; // Accept both DagCircuit and TickCircuit if let Ok(dag) = @@ -3311,7 +3433,7 @@ impl PyDemSampler { /// /// The `observables` argument defines observables. #[staticmethod] - #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] + #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] #[allow(clippy::too_many_arguments)] fn with_detectors( influence_map: &PyDagFaultInfluenceMap, @@ -3327,8 +3449,15 @@ impl PyDemSampler { idle_rz: Option, p_idle_linear_rate: Option, p_idle_quadratic_rate: Option, + p_idle_x_linear_rate: Option, + p_idle_y_linear_rate: Option, + p_idle_z_linear_rate: Option, + p_idle_x_quadratic_rate: Option, + p_idle_y_quadratic_rate: Option, + p_idle_z_quadratic_rate: Option, + p2_weights: Option>, ) -> PyResult { - let noise = apply_idle_noise_options( + let noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), p_idle, t1, @@ -3336,7 +3465,14 @@ impl PyDemSampler { idle_rz, 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, + p2_weights, + )?; let inner = RustNewDemSamplerBuilder::new(&influence_map.inner) .with_noise_config(noise) .with_detectors(detectors, observables) @@ -3789,7 +3925,7 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -3803,8 +3939,15 @@ impl PyDemSamplerBuilder { idle_rz: Option, p_idle_linear_rate: Option, p_idle_quadratic_rate: Option, - ) -> PyRefMut<'_, Self> { - slf.noise = apply_idle_noise_options( + p_idle_x_linear_rate: Option, + p_idle_y_linear_rate: Option, + p_idle_z_linear_rate: Option, + p_idle_x_quadratic_rate: Option, + p_idle_y_quadratic_rate: Option, + p_idle_z_quadratic_rate: Option, + p2_weights: Option>, + ) -> PyResult> { + slf.noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), p_idle, t1, @@ -3812,8 +3955,15 @@ impl PyDemSamplerBuilder { idle_rz, p_idle_linear_rate, p_idle_quadratic_rate, - ); - slf + 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, + p2_weights, + )?; + Ok(slf) } /// Set detector definitions from JSON. diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 19776ab0d..e268bb9db 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -36,10 +36,13 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from pecos_rslib.qec import DetectorErrorModel as _RustDetectorErrorModel +P2Weights = Mapping[str, float] + class _DetectorErrorModelMixin: """Namespace for the Python Guppy/QIS-trace convenience constructor.""" @@ -57,6 +60,7 @@ def from_guppy( num_measurements: int | None = None, p1: float = 0.001, p2: float = 0.01, + p2_weights: P2Weights | None = None, p_meas: float = 0.001, p_prep: float = 0.001, p_idle: float | None = None, @@ -64,6 +68,12 @@ def from_guppy( 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, runtime: object | None = None, seed: int = 0, ) -> _RustDetectorErrorModel: @@ -133,14 +143,24 @@ def from_guppy( circuit; if given, it must match the traced count. p1: Single-qubit gate depolarizing rate. p2: Two-qubit gate depolarizing rate. + p2_weights: Optional relative probabilities over the 15 non-identity + two-qubit Pauli errors (``IX`` through ``ZZ``). Values must sum + to 1.0; ``p2`` remains the total two-qubit error rate. 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 stochastic Z-memory rate linear in idle duration. - p_idle_quadratic_rate: Optional stochastic Z-memory rate using - ``sin(rate * duration) ** 2``. + 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. runtime: Optional Selene runtime selector/plugin. ``None`` selects the default Selene runtime. Runtime plugin objects are passed through to ``pecos.selene_engine(runtime)``. @@ -241,6 +261,7 @@ def from_guppy( tc, p1=p1, p2=p2, + p2_weights=p2_weights, p_meas=p_meas, p_prep=p_prep, p_idle=p_idle, @@ -248,6 +269,12 @@ def from_guppy( 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, ) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 5690f528a..bf5989773 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -21,6 +21,9 @@ from enum import Enum, auto from typing import TYPE_CHECKING, TypedDict +if TYPE_CHECKING: + from collections.abc import Mapping + # `_batched_stabilizers` and `_normalize_ancilla_budget` are imported from # the shared `_ancilla_batching` helper so this builder and the Guppy # emitter (`pecos.guppy.surface`) compute identical batches by @@ -1723,6 +1726,7 @@ def generate_dem_from_tick_circuit_via_pauli_frame( *, p1: float = 0.01, p2: float = 0.01, + p2_weights: Mapping[str, float] | None = None, p_meas: float = 0.01, p_prep: float = 0.01, ) -> str: @@ -1739,6 +1743,9 @@ def generate_dem_from_tick_circuit_via_pauli_frame( tc: TickCircuit with detector/observable metadata p1: Single-qubit depolarizing error rate p2: Two-qubit depolarizing error rate + p2_weights: Optional relative probabilities over the 15 non-identity + two-qubit Pauli errors (``IX`` through ``ZZ``). Values must sum to + 1.0; ``p2`` remains the total two-qubit error rate. p_meas: Measurement error rate p_prep: Initialization (prep) error rate @@ -1910,9 +1917,34 @@ def simulate_error( # Single-qubit Paulis for depolarizing noise single_paulis = ["X", "Y", "Z"] # Two-qubit Paulis (non-identity on at least one qubit) - two_paulis = [ - (p1, p2) for p1 in ["I", "X", "Y", "Z"] for p2 in ["I", "X", "Y", "Z"] if not (p1 == "I" and p2 == "I") - ] + two_pauli_labels = tuple( + f"{p_ctrl}{p_targ}" + for p_ctrl in ("I", "X", "Y", "Z") + for p_targ in ("I", "X", "Y", "Z") + if not (p_ctrl == "I" and p_targ == "I") + ) + if p2_weights is None: + two_paulis = tuple((label[0], label[1], 1.0 / 15.0) for label in two_pauli_labels) + else: + from math import isfinite + + weights = {str(label).upper(): float(weight) for label, weight in p2_weights.items()} + unknown_labels = sorted(set(weights) - set(two_pauli_labels)) + if unknown_labels: + message = f"p2_weights contains invalid Pauli labels: {unknown_labels}" + raise ValueError(message) + if any(not isfinite(weight) or weight < 0.0 for weight in weights.values()): + message = "p2_weights values must be finite and non-negative" + raise ValueError(message) + weight_sum = sum(weights.values()) + if abs(weight_sum - 1.0) >= 1.0e-6: + message = f"p2_weights relative probabilities must sum to 1.0, got {weight_sum}" + raise ValueError(message) + two_paulis = tuple( + (label[0], label[1], weight) + for label, weight in sorted(weights.items()) + if weight > 0.0 + ) # Process each gate as a potential error location for op_idx, (_tick_idx, gate_name, qubits, meas_idx) in enumerate(circuit_ops): @@ -1936,7 +1968,7 @@ def simulate_error( elif gate_name == "CX" and p2 > 0: # Two-qubit gate error: depolarizing (each Pauli pair with prob p2/15) ctrl, targ = qubits[0], qubits[1] - for p_ctrl, p_targ in two_paulis: + for p_ctrl, p_targ, relative_probability in two_paulis: frame = {} if p_ctrl != "I": frame[ctrl] = p_ctrl @@ -1945,7 +1977,7 @@ def simulate_error( dets, obs = simulate_error(op_idx + 1, frame) if dets or obs: key = (frozenset(dets), frozenset(obs)) - error_mechanisms[key] += p2 / 15 + error_mechanisms[key] += p2 * relative_probability elif gate_name == "MZ" and p_meas > 0: # Measurement error: bit flip (affects this measurement directly) @@ -2137,6 +2169,7 @@ def generate_dem_from_tick_circuit( *, p1: float = 0.01, p2: float = 0.01, + p2_weights: Mapping[str, float] | None = None, p_meas: float = 0.01, p_prep: float = 0.01, p_idle: float | None = None, @@ -2144,6 +2177,12 @@ def generate_dem_from_tick_circuit( 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, decompose_errors: bool = True, maximal_decomposition: bool = False, ) -> str: @@ -2169,15 +2208,25 @@ def generate_dem_from_tick_circuit( tc: TickCircuit with detector/observable metadata (required) p1: Single-qubit depolarizing error rate p2: Two-qubit depolarizing error rate + p2_weights: Optional relative probabilities over the 15 non-identity + two-qubit Pauli errors (``IX`` through ``ZZ``). Values must sum to + 1.0; ``p2`` remains the total two-qubit error rate. p_meas: Measurement error rate p_prep: Initialization (prep) error rate p_idle: Optional idle noise rate per explicit idle-gate time unit. The caller is responsible for inserting idle gates where needed. t1: Optional T1 relaxation time for explicit idle gates. t2: Optional T2 dephasing time for explicit idle gates. - p_idle_linear_rate: Optional stochastic Z-memory rate linear in idle duration. - p_idle_quadratic_rate: Optional stochastic Z-memory rate using - ``sin(rate * duration) ** 2``. + 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. decompose_errors: If True (default), decompose hyperedge errors into graphlike components using the `^` separator. Set to False to output raw hyperedges. Ignored if maximal_decomposition=True. @@ -2217,11 +2266,18 @@ def generate_dem_from_tick_circuit( p2, p_meas, p_prep, + p2_weights=p2_weights, 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, ) builder.with_num_measurements(num_measurements) builder.with_measurement_order(measurement_order) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index dc14115ce..693bc758f 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -43,6 +43,7 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence from dataclasses import dataclass from enum import Enum from functools import cache @@ -57,6 +58,9 @@ from pecos.qec.surface.patch import Stabilizer, SurfacePatch +P2Weights = Mapping[str, float] | Sequence[tuple[str, float]] + + def _validate_probability(name: str, value: float) -> float: """Return ``value`` as a float after validating it is a probability.""" probability = float(value) @@ -87,18 +91,27 @@ class NoiseModel: Attributes: p1: Single-qubit gate error rate. p2: Two-qubit gate error rate. + p2_weights: Optional relative probabilities over the 15 non-identity + two-qubit Pauli errors (``IX`` through ``ZZ``). Values must sum to + 1.0; ``p2`` remains the total two-qubit error rate. p_meas: Measurement error rate. p_prep: Initialization error rate. 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_rate: Stochastic Z-memory rate linear in idle duration. - p_idle_quadratic_rate: Stochastic Z-memory rate using - ``sin(rate * duration) ** 2``. + 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. """ p1: float = 0.0 p2: float = 0.0 + p2_weights: P2Weights | None = None p_meas: float = 0.0 p_prep: float = 0.0 p_idle: float | None = None @@ -106,6 +119,38 @@ class NoiseModel: 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 + + def __post_init__(self) -> None: + """Normalize cache-sensitive inputs after dataclass initialization.""" + self.p2_weights = _normalize_p2_weights(self.p2_weights) + + @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 + + @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 + + @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.effective_p_idle_z_linear_rate, + self.p_idle_x_quadratic_rate, + self.p_idle_y_quadratic_rate, + self.effective_p_idle_z_quadratic_rate, + ) @staticmethod def uniform(physical_error_rate: float) -> NoiseModel: @@ -122,8 +167,7 @@ def is_noiseless(self) -> bool: and self.p_meas == 0.0 and self.p_prep == 0.0 and (self.p_idle is None or self.p_idle == 0.0) - and (self.p_idle_linear_rate is None or self.p_idle_linear_rate == 0.0) - and (self.p_idle_quadratic_rate is None or self.p_idle_quadratic_rate == 0.0) + and all(rate is None or rate == 0.0 for rate in self.idle_memory_rates) ) @property @@ -132,13 +176,22 @@ def physical_error_rate(self) -> float: rates = [self.p1, self.p2, self.p_meas, self.p_prep] if self.p_idle is not None: rates.append(self.p_idle) - if self.p_idle_linear_rate is not None: - rates.append(self.p_idle_linear_rate) - if self.p_idle_quadratic_rate is not None: - rates.append(self.p_idle_quadratic_rate) + rates.extend(rate for rate in self.idle_memory_rates if rate is not None) return max(rates) +def _normalize_p2_weights(p2_weights: P2Weights | None) -> tuple[tuple[str, float], ...] | None: + if p2_weights is None: + return None + items = p2_weights.items() if isinstance(p2_weights, Mapping) else p2_weights + return tuple(sorted((str(label).upper(), float(weight)) for label, weight in items)) + + +def _p2_weights_dict(p2_weights: P2Weights | None) -> dict[str, float] | None: + normalized = _normalize_p2_weights(p2_weights) + return None if normalized is None else dict(normalized) + + @dataclass class DecodingResult: """Result from decoding a single shot.""" @@ -1039,6 +1092,12 @@ def _uses_dedicated_idle_noise( t2: float | 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, ) -> bool: """Return True when noise parameters require explicit idle locations.""" return ( @@ -1046,6 +1105,17 @@ def _uses_dedicated_idle_noise( or (t1 is not None and t2 is not None) or (p_idle_linear_rate is not None and p_idle_linear_rate > 0.0) or (p_idle_quadratic_rate is not None and p_idle_quadratic_rate != 0.0) + or any( + rate is not None and rate > 0.0 + for rate in ( + 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, + ) + ) ) @@ -1057,6 +1127,12 @@ def _noise_uses_dedicated_idle_noise(noise: NoiseModel) -> bool: 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, ) @@ -1152,6 +1228,13 @@ def _dem_string_from_cached_surface_topology( 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, + p2_weights=_p2_weights_dict(noise.p2_weights), ) .with_num_measurements(topology.num_measurements) .with_measurement_order(list(topology.measurement_order)) @@ -1174,11 +1257,18 @@ def _cached_surface_native_dem_string( p_meas: float, p_prep: float, decompose_errors: bool, + p2_weights: tuple[tuple[str, float], ...] | None = None, 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, ) -> str: """Cache native DEM strings across callers for one topology + noise tuple.""" include_idle_gates = _uses_dedicated_idle_noise( @@ -1187,6 +1277,12 @@ def _cached_surface_native_dem_string( 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, ) topology = _cached_surface_native_topology( patch_key, @@ -1201,6 +1297,7 @@ def _cached_surface_native_dem_string( NoiseModel( p1=p1, p2=p2, + p2_weights=p2_weights, p_meas=p_meas, p_prep=p_prep, p_idle=p_idle, @@ -1208,6 +1305,12 @@ def _cached_surface_native_dem_string( 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, ), decompose_errors=decompose_errors, ) @@ -1259,6 +1362,13 @@ def _build_native_sampler_from_cached_surface_topology( 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, + p2_weights=_p2_weights_dict(noise.p2_weights), ) # Remap sampling_model for NativeSampler dispatch sampling_model = "influence_dem" @@ -1359,11 +1469,18 @@ def generate_circuit_level_dem_from_builder( noise.p_meas, noise.p_prep, decompose_errors=decompose_errors, + p2_weights=noise.p2_weights, 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, ) @@ -3027,11 +3144,18 @@ def build_native_sampler( noise.p_meas, noise.p_prep, decompose_errors=True, + p2_weights=noise.p2_weights, 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, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() return NativeSampler( 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 7c58aaaa7..70b81b569 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -207,6 +207,87 @@ def test_lowered_runtime_idles_can_drive_memory_noise_dem() -> None: assert dem.num_contributions > 0 +def test_lowered_runtime_idles_accept_axis_memory_noise_dem() -> None: + from pecos.qec import DetectorErrorModel + + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 0]}}], + "lowered_quantum_ops": [ + {"gate_type": "PZ", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "Idle", "qubits": [0], "angles": [], "params": [10e-9]}, + {"gate_type": "MZ", "qubits": [0], "angles": [], "params": []}, + ], + }, + ] + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + tc.set_meta("detectors", '[{"id": 0, "records": [-1]}]') + tc.set_meta("observables", "[]") + tc.set_meta("num_measurements", "1") + + dem = DetectorErrorModel.from_circuit( + tc, + p1=0.0, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p_idle_x_linear_rate=1.0e-3, + p_idle_y_quadratic_rate=1.0e-4, + ) + + assert dem.num_contributions > 0 + + +def test_from_circuit_accepts_biased_p2_weights() -> None: + from pecos.qec import DetectorErrorModel + + chunks = [ + { + "operations": [{"Quantum": {"Measure": [1, 0]}}], + "lowered_quantum_ops": [ + {"gate_type": "PZ", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "PZ", "qubits": [1], "angles": [], "params": []}, + {"gate_type": "CX", "qubits": [0, 1], "angles": [], "params": []}, + {"gate_type": "MZ", "qubits": [1], "angles": [], "params": []}, + ], + }, + ] + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + tc.set_meta("detectors", '[{"id": 0, "records": [-1]}]') + tc.set_meta("observables", "[]") + tc.set_meta("num_measurements", "1") + pauli_labels = ( + "IX", + "IY", + "IZ", + "XI", + "XX", + "XY", + "XZ", + "YI", + "YX", + "YY", + "YZ", + "ZI", + "ZX", + "ZY", + "ZZ", + ) + weights = dict.fromkeys(pauli_labels, 0.0) + weights["IX"] = 1.0 + + dem = DetectorErrorModel.from_circuit( + tc, + p1=0.0, + p2=0.01, + p2_weights=weights, + p_meas=0.0, + p_prep=0.0, + ) + + assert dem.num_contributions > 0 + + def test_reject_partially_lowered_trace_passes_on_uniformly_lowered() -> None: """A trace where every quantum-carrying chunk is also lowered is accepted (this is the real Selene shape; the byte-identical regressions exercise it From 1a9944731f263c6aad6058fb6ff43c7da592eab5 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 30 May 2026 19:56:37 -0600 Subject: [PATCH 008/150] Support runtime-remapped surface DEM metadata --- crates/pecos-core/src/clifford_simplify.rs | 29 ++++ .../src/pecos/qec/surface/decode.py | 136 ++++++++++++++++-- .../tests/qec/test_from_guppy_dem.py | 35 +++++ 3 files changed, 185 insertions(+), 15 deletions(-) diff --git a/crates/pecos-core/src/clifford_simplify.rs b/crates/pecos-core/src/clifford_simplify.rs index e3caf97e2..c8725f137 100644 --- a/crates/pecos-core/src/clifford_simplify.rs +++ b/crates/pecos-core/src/clifford_simplify.rs @@ -11,6 +11,11 @@ use crate::gate_type::GateType; /// Type alias -- all comparisons use 64-bit fixed-point angles. type A64 = Angle; +/// Numerical lowering pipelines can produce angles that are a few fixed-point +/// units away from canonical Clifford quarter-turns. Snap only within a tiny +/// tolerance so genuine non-Clifford rotations still fail loudly. +const R1XY_CLIFFORD_EPSILON_TURNS: f64 = 1e-9; + /// Eighth-turn (pi/4): `QUARTER_TURN` / 2. fn eighth_turn() -> A64 { A64::QUARTER_TURN / 2u64 @@ -94,10 +99,12 @@ pub fn try_simplify_rotation(gate: GateType, angle: A64) -> Option { /// quarter-turn sqrt gates. #[must_use] pub fn try_simplify_r1xy(theta: A64, phi: A64) -> Option { + let theta = snap_r1xy_clifford_angle(theta)?; if theta == A64::ZERO { return Some(GateType::I); } + let phi = snap_r1xy_clifford_angle(phi)?; match phi { A64::ZERO => simplify_rx(theta), A64::HALF_TURN => simplify_rx(-theta), @@ -111,6 +118,17 @@ pub fn try_simplify_r1xy(theta: A64, phi: A64) -> Option { // Internal helpers // ------------------------------------------------------------------------- +fn snap_r1xy_clifford_angle(angle: A64) -> Option { + [ + A64::ZERO, + A64::QUARTER_TURN, + A64::HALF_TURN, + A64::THREE_QUARTERS_TURN, + ] + .into_iter() + .find(|target| angle.abs_diff_eq_turns(target, R1XY_CLIFFORD_EPSILON_TURNS)) +} + /// Negate an angle. fn neg(a: A64) -> A64 { -a @@ -454,6 +472,17 @@ mod tests { ); } + #[test] + fn r1xy_near_quarter_turn_sqrt_gates() { + assert_eq!( + try_simplify_r1xy( + Angle64::from_turns(0.25 + 1e-12), + Angle64::from_turns(0.75 - 1e-12), + ), + Some(GateType::SYdg) + ); + } + #[test] fn r1xy_three_quarter_turn_sqrt_dagger_gates() { // theta=3pi/2, phi=0: SXdg diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 693bc758f..4b5ab9258 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -453,8 +453,16 @@ def det_id(round_: int, stab_idx: int) -> int: return "\n".join(lines) -def _copy_surface_tick_circuit_metadata(source_tc: Any, target_tc: Any) -> None: +def _copy_surface_tick_circuit_metadata( + source_tc: Any, + target_tc: Any, + *, + measurement_index_remap: dict[int, int] | None = None, +) -> None: """Copy the surface-level metadata needed by the native DEM/sampler builders.""" + num_measurements_text = source_tc.get_meta("num_measurements") + num_measurements = int(num_measurements_text) if num_measurements_text is not None else None + for key in ( "basis", "detectors", @@ -467,9 +475,89 @@ def _copy_surface_tick_circuit_metadata(source_tc: Any, target_tc: Any) -> None: ): value = source_tc.get_meta(key) if value is not None: + if measurement_index_remap is not None and key in ( + "detectors", + "observables", + "detector_descriptors", + "observable_descriptors", + ): + if num_measurements is None: + msg = "Cannot remap surface metadata without num_measurements" + raise ValueError(msg) + value = _remap_surface_record_metadata_json( + value, + measurement_index_remap=measurement_index_remap, + num_measurements=num_measurements, + ) target_tc.set_meta(key, value) +def _measurement_index_remap_for_orders( + abstract_measurement_order: list[int], + traced_measurement_order: list[int], +) -> dict[int, int]: + """Map abstract record indices to runtime-traced record indices. + + The detector metadata is generated from the abstract surface schedule, but + a runtime may legally reorder measurement operations while preserving the + same measured qubit occurrences. This helper binds each measurement by + ``(qubit, occurrence_count_for_that_qubit)`` so metadata can follow a pure + scheduling reorder without accepting dropped/extra/wrong measurements. + """ + from collections import Counter, defaultdict + + if len(abstract_measurement_order) != len(traced_measurement_order) or Counter( + abstract_measurement_order, + ) != Counter(traced_measurement_order): + msg = ( + "Traced and abstract surface circuits disagree on the measured-qubit " + "multiset; refusing to remap detector/observable metadata" + ) + raise ValueError(msg) + + traced_occurrences: dict[tuple[int, int], int] = {} + traced_counts: defaultdict[int, int] = defaultdict(int) + for traced_index, qubit in enumerate(traced_measurement_order): + occurrence = traced_counts[qubit] + traced_occurrences[(qubit, occurrence)] = traced_index + traced_counts[qubit] += 1 + + remap: dict[int, int] = {} + abstract_counts: defaultdict[int, int] = defaultdict(int) + for abstract_index, qubit in enumerate(abstract_measurement_order): + occurrence = abstract_counts[qubit] + remap[abstract_index] = traced_occurrences[(qubit, occurrence)] + abstract_counts[qubit] += 1 + + return remap + + +def _remap_surface_record_metadata_json( + metadata_json: str, + *, + measurement_index_remap: dict[int, int], + num_measurements: int, +) -> str: + """Remap negative ``records`` offsets inside detector/observable metadata.""" + import json + + entries = json.loads(metadata_json) + for entry in entries: + records = entry.get("records") + if records is None: + continue + remapped_records = [] + for record in records: + abstract_index = num_measurements + int(record) + if abstract_index not in measurement_index_remap: + msg = f"Surface metadata record {record!r} is out of range for remapping" + raise ValueError(msg) + traced_index = measurement_index_remap[abstract_index] + remapped_records.append(traced_index - num_measurements) + entry["records"] = remapped_records + return json.dumps(entries) + + def _runtime_idle_seconds_to_time_units(duration_seconds: float) -> Any: """Convert runtime idle seconds into PECOS nanosecond time units.""" import math @@ -966,13 +1054,15 @@ def _build_surface_tick_circuit_for_native_model( ancilla_budget=ancilla_budget, runtime=runtime, ) - # Coarse sanity check: the traced and abstract circuits must agree on the - # sequence of *measured qubit indices*. This catches gross drift (a dropped - # or added measurement, a wrong-qubit measurement, a different schedule - # shape). It is NOT an identity-level check: `_extract_measurement_order` - # returns physical qubit indices, and under ancilla reuse the same physical - # qubit appears in many measurements -- so two different stabilizer - # orderings can produce an identical qubit-index sequence and pass here. + # Coarse sanity check: the traced and abstract circuits must either agree + # on measured-qubit order or be remappable by measured-qubit occurrence. + # This catches gross drift (a dropped/added/wrong-qubit measurement) while + # allowing runtimes that preserve stabilizer identity but schedule + # measurements in a different order. It is NOT an identity-level check: + # `_extract_measurement_order` returns physical qubit indices, and under + # ancilla reuse the same physical qubit appears in many measurements -- so + # two different stabilizer orderings can produce an identical qubit-index + # sequence and pass here. # There is no independent stabilizer-identity oracle in the stack today: # the detector/observable record offsets are the production binding (not a # validator), and the byte-identical traced-vs-traced DEM regression shares @@ -983,16 +1073,32 @@ def _build_surface_tick_circuit_for_native_model( # replayed TickCircuit does not currently carry (future work). traced_measurement_order = _extract_measurement_order(traced_tc) abstract_measurement_order = _extract_measurement_order(abstract_tc) + measurement_index_remap = None if traced_measurement_order != abstract_measurement_order: - msg = ( - "Traced and abstract surface circuits disagree on the measured-qubit " - "sequence (a dropped/added/wrong-qubit measurement or a different " - "schedule shape); refusing to build a native DEM/sampler from a " - "circuit that does not match the abstract detector/observable metadata" + try: + measurement_index_remap = _measurement_index_remap_for_orders( + abstract_measurement_order, + traced_measurement_order, + ) + except ValueError as exc: + msg = ( + "Traced and abstract surface circuits disagree on the measured-qubit " + "sequence (a dropped/added/wrong-qubit measurement or a different " + "schedule shape); refusing to build a native DEM/sampler from a " + "circuit that does not match the abstract detector/observable metadata" + ) + raise ValueError(msg) from exc + + if measurement_index_remap is None: + _copy_surface_tick_circuit_metadata(abstract_tc, traced_tc) + else: + _copy_surface_tick_circuit_metadata( + abstract_tc, + traced_tc, + measurement_index_remap=measurement_index_remap, ) - raise ValueError(msg) + traced_tc.set_meta("surface_metadata_record_binding", "runtime_measurement_order") - _copy_surface_tick_circuit_metadata(abstract_tc, traced_tc) traced_tc.set_meta("circuit_source", circuit_source) return traced_tc 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 70b81b569..8dc266600 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,8 @@ """Regression tests for the Guppy-to-DEM convenience path.""" +import json + import pytest from guppylang import guppy from guppylang.std.builtins import result @@ -12,7 +14,9 @@ from pecos.qec.surface import SurfacePatch from pecos.qec.surface.decode import ( _build_surface_tick_circuit_for_native_model, + _measurement_index_remap_for_orders, _reject_partially_lowered_trace, + _remap_surface_record_metadata_json, _replay_lowered_qis_trace_into_tick_circuit, _replay_qis_trace_into_tick_circuit, ) @@ -733,6 +737,37 @@ def test_copy_surface_metadata_propagates_descriptors() -> None: assert len(obs_desc) > 0 +def test_surface_metadata_records_remap_to_runtime_measurement_order() -> None: + remap = _measurement_index_remap_for_orders( + [0, 1, 0, 2], + [1, 0, 2, 0], + ) + assert remap == {0: 1, 1: 0, 2: 3, 3: 2} + + metadata = json.dumps( + [ + {"id": 0, "records": [-4, -2]}, + {"id": 1, "records": [-3]}, + ], + ) + remapped = json.loads( + _remap_surface_record_metadata_json( + metadata, + measurement_index_remap=remap, + num_measurements=4, + ), + ) + assert remapped == [ + {"id": 0, "records": [-3, -1]}, + {"id": 1, "records": [-4]}, + ] + + +def test_surface_metadata_record_remap_rejects_measurement_drift() -> None: + with pytest.raises(ValueError, match="measured-qubit multiset"): + _measurement_index_remap_for_orders([0, 1, 0], [0, 1, 2]) + + def test_surface_module_cache_collapses_unconstrained_budget_forms() -> None: """``get_surface_code_module`` keys its cache on the *effective* budget (``normalize_ancilla_budget(d*d-1, budget)``), so ``ancilla_budget=None`` From bdb85094100f7e8b0b2bf56ef5d992b7484d3748 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 30 May 2026 23:34:24 -0600 Subject: [PATCH 009/150] Replace logical-subgraph byte-level DEM parser with shared line-based dem::SparseDem (profiled equal) --- crates/pecos-decoder-core/src/dem.rs | 133 ++++++++++ .../src/logical_subgraph.rs | 236 +----------------- 2 files changed, 134 insertions(+), 235 deletions(-) diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index 324a21e50..ef69f25af 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -200,6 +200,139 @@ pub mod utils { } } +/// Sparse parse of a DEM: the error mechanisms plus detector coordinates, +/// without the dense matrices of [`DemCheckMatrix`]. +/// +/// Each `error(p) ...` line becomes one `(probability, detector_ids, +/// observable_ids)` entry. Decomposed mechanisms (`D0 ^ D1`) are XOR-combined; +/// graphlike mechanisms keep their DEM token order. `detector(x, y, t) D_i` +/// declarations are collected into `detector_coords`. +/// +/// Parsing runs once at decoder construction (never in a decode hot loop), so +/// this is plain line-based parsing — a byte-level variant was profiled and +/// gave no measurable speedup over this. +/// +/// # Example +/// +/// ``` +/// use pecos_decoder_core::dem::SparseDem; +/// +/// let dem = "detector(1, 0, 0) D0\nerror(0.01) D0 D1 L0\nerror(0.02) D1"; +/// let sdem = SparseDem::from_dem_str(dem).unwrap(); +/// assert_eq!(sdem.num_detectors, 2); +/// assert_eq!(sdem.num_observables, 1); +/// assert_eq!(sdem.mechanisms.len(), 2); +/// ``` +#[derive(Debug, Clone)] +pub struct SparseDem { + /// Per-mechanism: `(probability, detector_ids, observable_ids)`. + pub mechanisms: Vec<(f64, Vec, Vec)>, + /// Detector id → coordinates (spatial + time), from `detector(...)` lines. + pub detector_coords: std::collections::BTreeMap>, + /// Number of detectors: max detector id + 1, across both mechanisms and + /// `detector(...)` declarations (0 if none). + pub num_detectors: usize, + /// Number of observables: max observable id + 1 (0 if none). + pub num_observables: usize, +} + +impl SparseDem { + /// Parse a DEM string into its sparse mechanism + coordinate form. + /// + /// # Errors + /// + /// Returns [`DecoderError`] if an `error(...)` line is malformed. + pub fn from_dem_str(dem: &str) -> Result { + let mut mechanisms: Vec<(f64, Vec, Vec)> = Vec::new(); + let mut detector_coords = std::collections::BTreeMap::new(); + let mut max_detector: Option = None; + let mut max_observable: Option = None; + + for line in dem.lines() { + let line = line.trim(); + + if let Some(rest) = line.strip_prefix("error(") { + let close = rest.find(')').ok_or_else(|| { + DecoderError::InvalidConfiguration("Missing ) in error line".into()) + })?; + let probability: f64 = rest[..close].parse().map_err(|_| { + DecoderError::InvalidConfiguration(format!( + "Invalid probability: {}", + &rest[..close] + )) + })?; + let tokens = &rest[close + 1..]; + + let (detectors, observables) = if tokens.contains('^') { + // Decomposed mechanism: XOR-combine components into sorted sets. + let mut det_set = std::collections::BTreeSet::new(); + let mut obs_set = std::collections::BTreeSet::new(); + for token in tokens.split('^').flat_map(str::split_whitespace) { + if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) + { + if !det_set.remove(&d) { + det_set.insert(d); + } + max_detector = Some(max_detector.map_or(d, |m| m.max(d))); + } else if let Some(l) = + token.strip_prefix('L').and_then(|s| s.parse::().ok()) + { + if !obs_set.remove(&l) { + obs_set.insert(l); + } + max_observable = Some(max_observable.map_or(l, |m| m.max(l))); + } + } + ( + det_set.into_iter().collect(), + obs_set.into_iter().collect(), + ) + } else { + // Graphlike mechanism: keep DEM token order. + let mut detectors = Vec::new(); + let mut observables = Vec::new(); + for token in tokens.split_whitespace() { + if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) + { + detectors.push(d); + max_detector = Some(max_detector.map_or(d, |m| m.max(d))); + } else if let Some(l) = + token.strip_prefix('L').and_then(|s| s.parse::().ok()) + { + observables.push(l); + max_observable = Some(max_observable.map_or(l, |m| m.max(l))); + } + } + (detectors, observables) + }; + + mechanisms.push((probability, detectors, observables)); + } else if let Some(rest) = line.strip_prefix("detector(") { + let Some(close) = rest.find(')') else { + continue; + }; + let coords: Vec = rest[..close] + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + for token in rest[close + 1..].split_whitespace() { + if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) { + detector_coords.insert(d as usize, coords.clone()); + max_detector = Some(max_detector.map_or(d, |m| m.max(d))); + } + } + } + } + + Ok(Self { + mechanisms, + detector_coords, + num_detectors: max_detector.map_or(0, |m| m as usize + 1), + num_observables: max_observable.map_or(0, |m| m as usize + 1), + }) + } +} + /// Check matrix representation extracted from a Detector Error Model. /// /// Converts a DEM string into the matrices needed by check-matrix-based diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index c343d069b..1ceb299d7 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -42,243 +42,9 @@ pub mod windowed; use std::collections::{BTreeMap, BTreeSet}; use crate::ObservableDecoder; -use crate::dem::{DemMatchingGraph, MatchingEdge}; +use crate::dem::{DemMatchingGraph, MatchingEdge, SparseDem}; use crate::errors::DecoderError; -/// Sparse representation of a parsed DEM, avoiding the dense matrix -/// allocation of [`DemCheckMatrix`]. Also collects detector coordinates -/// in a single pass to avoid re-scanning the DEM string. -struct SparseDem { - /// Per-mechanism: (probability, `detector_ids`, `observable_ids`). - mechanisms: Vec<(f64, Vec, Vec)>, - /// Detector id → coordinates (spatial + time). - detector_coords: BTreeMap>, - num_detectors: usize, - num_observables: usize, -} - -/// Parse ASCII digits into u32. Faster than `str::parse` for the common case. -#[inline] -fn parse_u32_fast(s: &[u8]) -> Option { - if s.is_empty() { - return None; - } - let mut n: u32 = 0; - for &b in s { - if !b.is_ascii_digit() { - return None; - } - n = n.wrapping_mul(10).wrapping_add(u32::from(b - b'0')); - } - Some(n) -} - -impl SparseDem { - fn from_dem_str(dem: &str) -> Result { - // Estimate capacity: ~1 mechanism per 55 bytes of DEM string. - let est_mechs = dem.len() / 55; - let mut mechanisms = Vec::with_capacity(est_mechs); - let mut detector_coords = BTreeMap::new(); - let mut max_detector: u32 = 0; - let mut max_observable: u32 = 0; - let mut has_any_detector = false; - - let bytes = dem.as_bytes(); - let mut pos = 0; - let len = bytes.len(); - - while pos < len { - // Skip to start of line content (skip whitespace/newlines) - while pos < len - && (bytes[pos] == b' ' - || bytes[pos] == b'\n' - || bytes[pos] == b'\r' - || bytes[pos] == b'\t') - { - pos += 1; - } - if pos >= len { - break; - } - - if bytes[pos] == b'e' && pos + 6 < len && &bytes[pos..pos + 6] == b"error(" { - // Parse error line at byte level. - pos += 6; - // Find closing paren — probability string - let prob_start = pos; - while pos < len && bytes[pos] != b')' { - pos += 1; - } - if pos >= len { - return Err(DecoderError::InvalidConfiguration( - "Missing ) in error line".into(), - )); - } - let prob: f64 = std::str::from_utf8(&bytes[prob_start..pos]) - .unwrap_or("0") - .parse() - .map_err(|_| DecoderError::InvalidConfiguration("Bad probability".into()))?; - pos += 1; // skip ')' - - // Scan for ^ to decide fast vs slow path - let line_start = pos; - while pos < len && bytes[pos] != b'\n' { - pos += 1; - } - let line_end = pos; - let line_bytes = &bytes[line_start..line_end]; - - if line_bytes.contains(&b'^') { - // Slow path: XOR decomposition - let line_str = std::str::from_utf8(line_bytes).unwrap_or(""); - let mut det_set = BTreeSet::new(); - let mut obs_set = BTreeSet::new(); - for component in line_str.split('^') { - for token in component.split_whitespace() { - if let Some(d_str) = token.strip_prefix('D') { - if let Some(d) = parse_u32_fast(d_str.as_bytes()) { - if !det_set.remove(&d) { - det_set.insert(d); - } - has_any_detector = true; - if d > max_detector { - max_detector = d; - } - } - } else if let Some(l_str) = token.strip_prefix('L') - && let Some(l) = parse_u32_fast(l_str.as_bytes()) - { - if !obs_set.remove(&l) { - obs_set.insert(l); - } - if l > max_observable { - max_observable = l; - } - } - } - } - mechanisms.push(( - prob, - det_set.into_iter().collect(), - obs_set.into_iter().collect(), - )); - } else { - // Fast path: no XOR. Parse tokens directly into Vecs. - let mut dets = Vec::with_capacity(3); - let mut obs = Vec::with_capacity(1); - let mut i = 0; - while i < line_bytes.len() { - // Skip whitespace - while i < line_bytes.len() && line_bytes[i] == b' ' { - i += 1; - } - if i >= line_bytes.len() { - break; - } - - if line_bytes[i] == b'D' { - i += 1; - let start = i; - while i < line_bytes.len() - && line_bytes[i] >= b'0' - && line_bytes[i] <= b'9' - { - i += 1; - } - if let Some(d) = parse_u32_fast(&line_bytes[start..i]) { - dets.push(d); - has_any_detector = true; - if d > max_detector { - max_detector = d; - } - } - } else if line_bytes[i] == b'L' { - i += 1; - let start = i; - while i < line_bytes.len() - && line_bytes[i] >= b'0' - && line_bytes[i] <= b'9' - { - i += 1; - } - if let Some(l) = parse_u32_fast(&line_bytes[start..i]) { - obs.push(l); - if l > max_observable { - max_observable = l; - } - } - } else { - // Skip unknown token - while i < line_bytes.len() && line_bytes[i] != b' ' { - i += 1; - } - } - } - mechanisms.push((prob, dets, obs)); - } - } else if bytes[pos] == b'd' && pos + 9 < len && &bytes[pos..pos + 9] == b"detector(" { - // Parse detector coordinate declaration. - pos += 9; - let coord_start = pos; - while pos < len && bytes[pos] != b')' { - pos += 1; - } - if pos < len { - let coord_str = std::str::from_utf8(&bytes[coord_start..pos]).unwrap_or(""); - let coords: Vec = coord_str - .split(',') - .filter_map(|s| s.trim().parse().ok()) - .collect(); - pos += 1; // skip ')' - // Find detector ID: "D123" - while pos < len && bytes[pos] == b' ' { - pos += 1; - } - if pos < len && bytes[pos] == b'D' { - pos += 1; - let start = pos; - while pos < len && bytes[pos] >= b'0' && bytes[pos] <= b'9' { - pos += 1; - } - if let Some(d) = parse_u32_fast(&bytes[start..pos]) { - detector_coords.insert(d as usize, coords); - has_any_detector = true; - if d > max_detector { - max_detector = d; - } - } - } - } - // Skip rest of line - while pos < len && bytes[pos] != b'\n' { - pos += 1; - } - } else { - // Skip unknown line - while pos < len && bytes[pos] != b'\n' { - pos += 1; - } - } - } - - let has_any_obs = max_observable > 0 || mechanisms.iter().any(|(_, _, o)| !o.is_empty()); - Ok(Self { - mechanisms, - detector_coords, - num_detectors: if has_any_detector { - max_detector as usize + 1 - } else { - 0 - }, - num_observables: if has_any_obs { - max_observable as usize + 1 - } else { - 0 - }, - }) - } -} - // ============================================================================ // Stabilizer coordinate mapping // ============================================================================ From 1288e6eebd2a1038079a726cdfd17db777111bdc Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 30 May 2026 23:37:42 -0600 Subject: [PATCH 010/150] Rename test locals lsd_* to subgraph_* to avoid clash with BP-LSD acronym --- python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py b/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py index b05fbce1d..88bd2c0a9 100644 --- a/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py +++ b/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py @@ -849,15 +849,15 @@ def test_logical_subgraph_better_than_naive_on_cx(self, patch, nq): # logical-subgraph decoder with FB sc = b.stab_coords() decoder = LogicalSubgraphDecoder(dem_str, sc, "fusion_blossom_serial") - lsd_errors = sum( + subgraph_errors = sum( 1 for i in range(20000) if decoder.decode(det_events[i].tolist()) != sum((1 << j) for j in range(obs_flips.shape[1]) if obs_flips[i, j]) ) - lsd_ler = lsd_errors / 20000 + subgraph_ler = subgraph_errors / 20000 # logical-subgraph decoder should be at least as good (usually much better) - assert lsd_ler <= naive_ler * 1.5 + 0.001, f"logical-subgraph decoder ({lsd_ler:.5f}) much worse than naive ({naive_ler:.5f})" + assert subgraph_ler <= naive_ler * 1.5 + 0.001, f"logical-subgraph decoder ({subgraph_ler:.5f}) much worse than naive ({naive_ler:.5f})" # --------------------------------------------------------------------------- From 3095ed3b6eef18712efb6d4ce0b3b58ef83b9a54 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 31 May 2026 10:13:12 -0600 Subject: [PATCH 011/150] Count logical_observable declarations in DEM parsers so unflipped observables aren't dropped --- crates/pecos-decoder-core/src/dem.rs | 61 +++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index ef69f25af..ce0adf31c 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -148,6 +148,17 @@ pub mod utils { } } } + "logical_observable" => { + // Declared observable with no flipping mechanism (Stim emits + // these for deterministic logicals) still counts. + for part in &parts[1..] { + if let Some(l_str) = part.strip_prefix('L') + && let Ok(l) = l_str.parse::() + { + observables.insert(l); + } + } + } _ => {} } } @@ -321,6 +332,16 @@ impl SparseDem { max_detector = Some(max_detector.map_or(d, |m| m.max(d))); } } + } else if let Some(rest) = line.strip_prefix("logical_observable") { + // Stim emits `logical_observable Lk` for observables that no + // error mechanism flips (deterministic / unflipped logicals). + // Honour the declared count so a trailing unflipped observable + // is not silently dropped from `num_observables`. + for token in rest.split_whitespace() { + if let Some(l) = token.strip_prefix('L').and_then(|s| s.parse::().ok()) { + max_observable = Some(max_observable.map_or(l, |m| m.max(l))); + } + } } } @@ -392,8 +413,17 @@ impl DemCheckMatrix { if line.is_empty() || line.starts_with('#') { continue; } + if let Some(rest) = line.strip_prefix("logical_observable") { + // Count declared observables that no mechanism flips. + for token in rest.split_whitespace() { + if let Some(l) = token.strip_prefix('L').and_then(|s| s.parse::().ok()) { + max_observable = Some(max_observable.map_or(l, |m| m.max(l))); + } + } + continue; + } if !line.starts_with("error(") { - // Skip non-error lines (detector, logical_observable, etc.) + // Skip non-error lines (detector, etc.) continue; } @@ -574,6 +604,15 @@ impl DemMatchingGraph { for line in dem.lines() { let line = line.trim(); + if let Some(rest) = line.strip_prefix("logical_observable") { + // Count declared observables that no mechanism flips. + for token in rest.split_whitespace() { + if let Some(l) = token.strip_prefix('L').and_then(|s| s.parse::().ok()) { + max_observable = Some(max_observable.map_or(l, |m| m.max(l))); + } + } + continue; + } if line.is_empty() || line.starts_with('#') || !line.starts_with("error(") { continue; } @@ -999,6 +1038,26 @@ mod tests { assert_eq!(observables, 2); // L0 and L1 } + #[test] + fn test_logical_observable_declaration_counts() { + // L1 has no flipping mechanism; Stim emits `logical_observable L1`. + // All parsers must still count it so the trailing observable is not + // silently dropped. + let dem = "error(0.01) D0 L0\ndetector(0, 0, 0) D0\nlogical_observable L1\n"; + + let sdem = SparseDem::from_dem_str(dem).unwrap(); + assert_eq!(sdem.num_observables, 2, "SparseDem must count L1"); + + let dcm = DemCheckMatrix::from_dem_str(dem).unwrap(); + assert_eq!(dcm.num_observables, 2, "DemCheckMatrix must count L1"); + + let graph = DemMatchingGraph::from_dem_str(dem).unwrap(); + assert_eq!(graph.num_observables, 2, "DemMatchingGraph must count L1"); + + let (_dets, obs) = utils::parse_dem_metadata(dem).unwrap(); + assert_eq!(obs, 2, "parse_dem_metadata must count L1"); + } + #[test] fn test_dem_check_matrix_basic() { let dem = "error(0.01) D0 D1 L0\nerror(0.02) D1 D2\nerror(0.03) D0 D2 L0"; From dae55dc66d00a0543a1fcdb468c3e9c0a90ff6bb Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 31 May 2026 10:13:13 -0600 Subject: [PATCH 012/150] Split logical-subgraph membership source from subgraph extractor and add lomatching differential test --- .../src/logical_subgraph.rs | 233 +++++++++++++++--- .../src/fault_tolerance_bindings.rs | 9 + ...test_logical_subgraph_lomatching_parity.py | 124 ++++++++++ 3 files changed, 336 insertions(+), 30 deletions(-) create mode 100644 python/quantum-pecos/tests/qec/surface/test_logical_subgraph_lomatching_parity.py diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index 1ceb299d7..6f39e550d 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -10,12 +10,25 @@ // or implied. See the License for the specific language governing permissions and limitations under // the License. -//! Per-logical-operator subgraph decoder for transversal gates. +//! Logical-operator subgraph decoder for transversal gates. //! -//! Based on the insight (proved independently by Serra-Peralta et al. -//! arXiv:2505.13599 and Cain et al. arXiv:2505.13587) that per-observable -//! subgraphs of a transversal-gate DEM are always graphlike — even when -//! the full DEM contains weight-3+ hyperedges. +//! This decodes a *logical operator's subgraph of the DEM* — not the full DEM. +//! In the Heisenberg picture a detector error model records how errors meet +//! evolving operators: stabilizers evolve into **detectors**, logical operators +//! evolve into **observables**. This decoder groups detectors by their +//! stabilizer (X/Z, per qubit) and restricts the DEM to one logical operator at +//! a time — the errors that can flip it. Each logical operator is effectively +//! its own channel through the circuit (e.g. a transversal CX propagates +//! `XI → XX`, `IZ → ZZ`), so the per-operator problems decouple. +//! +//! The payoff (proved independently by Serra-Peralta et al. arXiv:2505.13599 +//! and Cain et al. arXiv:2505.13587): restricted to one logical operator, the +//! subgraph is always graphlike — only 1-2 detector edges, matchable — even +//! when the full DEM contains weight-3+ hyperedges. So any MWPM-style decoder +//! handles each piece, and the results combine. +//! +//! Naming: abbreviate this "LS decoder" if needed — never "LSD", which collides +//! with BP-LSD (Localised Statistics Decoding) elsewhere in the workspace. //! //! # Algorithm //! @@ -164,19 +177,43 @@ pub fn partition_dem_by_logical_windowed( stab_coords: &StabCoords, max_time_radius: MaxTimeRadius, ) -> Result, DecoderError> { - // Single-pass sparse DEM parsing: mechanisms + detector coordinates. + // Region source (coordinate classification) -> shared subgraph extractor. let sdem = SparseDem::from_dem_str(dem_str)?; - let coord_map = &sdem.detector_coords; + let membership = coordinate_membership_from_dem(&sdem, stab_coords, max_time_radius)?; + subgraphs_from_membership(&sdem, &membership) +} - // Observable flips are packed into a u64 mask (bit i = observable i), so - // more than 64 observables would silently overflow the shift. Fail loud. - if sdem.num_observables > 64 { - return Err(DecoderError::InvalidConfiguration(format!( - "LogicalSubgraphDecoder packs observable flips into a u64 mask and \ - supports at most 64 observables, but the DEM declares {}", - sdem.num_observables, - ))); - } +/// Per-observable detector membership: entry `k` is the sorted full-DEM detector +/// ids in logical observable `k`'s observing region. +/// +/// This is the seam between a *region source* and the *subgraph extractor* +/// ([`subgraphs_from_membership`]). The coordinate region source lives here +/// ([`coordinate_membership_from_dem`]); a back-propagation source can live in a +/// higher crate (e.g. `pecos-qec`) — it only needs to produce this same +/// membership and hand it down, which preserves the `pecos-qec -> +/// pecos-decoder-core` dependency direction. +pub type ObservingRegions = Vec>; + +/// Region source: derive each observable's detector membership from the DEM's +/// spatial stabilizer coordinates and boundary edges. +/// +/// This is the algorithm `lomatching` ships for vertex selection +/// (`get_detector_indices_for_subgraphs`): find the 1-detector mechanisms that +/// flip each observable, bucket their detectors into `(qubit, stab_type)` groups +/// by coordinate, and include every detector of those groups at the boundary +/// times. `max_time_radius` widens the per-time inclusion (default `None` = +/// exact boundary times, matching lomatching). +/// +/// # Errors +/// +/// Returns an error if a detector used in an error mechanism has coordinates +/// that match no stabilizer position in `stab_coords`. +pub fn coordinate_membership_from_dem( + sdem: &SparseDem, + stab_coords: &StabCoords, + max_time_radius: MaxTimeRadius, +) -> Result { + let coord_map = &sdem.detector_coords; // Detectors that appear in an error mechanism are the only ones that affect // decoding. Any such detector that fails to classify would be silently @@ -232,12 +269,12 @@ pub fn partition_dem_by_logical_windowed( ))); } - // For each observable, find its observing region. - let mut subgraphs = Vec::with_capacity(sdem.num_observables); + // For each observable, collect its observing region. + let mut membership: ObservingRegions = Vec::with_capacity(sdem.num_observables); for obs_idx in 0..sdem.num_observables { - // Step 1: Find boundary edges — 1-detector mechanisms that flip - // this observable. Collect (group, time) from each boundary detector. + // Step 1: Find boundary edges — 1-detector mechanisms that flip this + // observable. Collect (group, time) from each boundary detector. let mut group_times: BTreeMap> = BTreeMap::new(); for (_, dets, obs) in &sdem.mechanisms { @@ -256,11 +293,11 @@ pub fn partition_dem_by_logical_windowed( } } - // Step 2: For each (group, time) boundary edge, include ALL - // detectors of that group at that time. This matches lomatching's - // per-time-step approach: detectors are included only at times - // where boundary edges exist, not across the full time range. - // With max_time_radius, extend each boundary time by ±radius. + // Step 2: For each (group, time) boundary edge, include ALL detectors of + // that group at that time. This matches lomatching's per-time-step + // approach: detectors are included only at times where boundary edges + // exist, not across the full time range. With max_time_radius, extend + // each boundary time by ±radius. let mut region_detectors = BTreeSet::new(); for (group, times) in &group_times { if let Some(dets) = group_detectors.get(group) { @@ -281,7 +318,38 @@ pub fn partition_dem_by_logical_windowed( } } - if region_detectors.is_empty() { + membership.push(region_detectors.into_iter().collect()); + } + + Ok(membership) +} + +/// Subgraph extractor: turn per-observable detector membership into decodable +/// graphlike subgraphs. The membership may come from any region source — the +/// coordinate one ([`coordinate_membership_from_dem`]) or a back-propagation +/// builder in a higher crate. +/// +/// # Errors +/// +/// Returns an error if `membership` has more than 64 entries: observable flips +/// are packed into a u64 mask (bit `i` = observable `i`), so any region source +/// feeding this extractor inherits the 64-observable limit. +pub fn subgraphs_from_membership( + sdem: &SparseDem, + membership: &[Vec], +) -> Result, DecoderError> { + if membership.len() > 64 { + return Err(DecoderError::InvalidConfiguration(format!( + "LogicalSubgraphDecoder packs observable flips into a u64 mask and \ + supports at most 64 observables, but got membership for {}", + membership.len(), + ))); + } + + let mut subgraphs = Vec::with_capacity(membership.len()); + + for (obs_idx, detectors) in membership.iter().enumerate() { + if detectors.is_empty() { subgraphs.push(LogicalSubgraph { observable_idx: obs_idx, detector_map: Vec::new(), @@ -297,14 +365,15 @@ pub fn partition_dem_by_logical_windowed( continue; } - // Step 3: Build detector mapping. - let detector_map: Vec = region_detectors.into_iter().collect(); + // Build detector mapping (full DEM id -> subgraph-local index). + let detector_map: Vec = detectors.clone(); let mut inverse_map = vec![None; sdem.num_detectors]; for (sub_idx, &full_idx) in detector_map.iter().enumerate() { inverse_map[full_idx] = Some(sub_idx); } - // Step 4: Extract edges for this subgraph. + // Extract edges for this subgraph by projecting mechanisms onto the + // membership detectors. let mut edges = Vec::new(); let mut skipped = 0; @@ -313,7 +382,6 @@ pub fn partition_dem_by_logical_windowed( continue; } - // Map mechanism detectors to subgraph indices. let sub_dets: Vec = dets .iter() .filter_map(|&d| inverse_map[d as usize].map(|s| s as u32)) @@ -444,6 +512,18 @@ impl LogicalSubgraphDecoder { self.subgraphs.get(obs_idx) } + /// Per-observable observing regions: entry `k` is the sorted full-DEM + /// detector ids in observable `k`'s subgraph. This is the membership the + /// region source produced — exposed for differential testing against + /// reference implementations (e.g. lomatching). + #[must_use] + pub fn observing_regions(&self) -> ObservingRegions { + self.subgraphs + .iter() + .map(|sg| sg.detector_map.clone()) + .collect() + } + /// Batch decode multiple syndromes, returning error count. /// /// For each subgraph, extracts all sub-syndromes into a flat buffer @@ -706,6 +786,99 @@ mod tests { assert_eq!(sgs[0].detector_map, vec![0]); } + #[test] + fn test_membership_seam_matches_partition() { + // coordinate_membership_from_dem + subgraphs_from_membership must equal + // the all-in-one partition (the seam is behaviour-preserving). + let dem = concat!( + "detector(1, 0, 0) D0\n", + "detector(0, 1, 0) D1\n", + "detector(3, 0, 0) D2\n", + "detector(2, 1, 0) D3\n", + "error(0.01) D0 L0\n", + "error(0.01) D0 D1\n", + "error(0.01) D2 L1\n", + "error(0.01) D2 D3\n", + ); + let sc = simple_stab_coords(); + + let sdem = SparseDem::from_dem_str(dem).unwrap(); + let membership = coordinate_membership_from_dem(&sdem, &sc, None).unwrap(); + assert_eq!(membership, vec![vec![0usize], vec![2usize]]); + + let via_seam = subgraphs_from_membership(&sdem, &membership).unwrap(); + let direct = partition_dem_by_logical(dem, &sc).unwrap(); + assert_eq!(via_seam.len(), direct.len()); + for (a, b) in via_seam.iter().zip(direct.iter()) { + assert_eq!(a.detector_map, b.detector_map); + } + } + + #[test] + fn test_subgraphs_from_external_membership() { + // A region source (e.g. a future back-propagation builder) can hand in + // its own membership and the extractor builds the subgraphs from it. + let dem = concat!( + "detector(1, 0, 0) D0\n", + "detector(1, 0, 1) D1\n", + "error(0.01) D0 L0\n", + "error(0.01) D0 D1\n", + ); + let sdem = SparseDem::from_dem_str(dem).unwrap(); + // Membership the coordinate path would NOT produce on its own (both times). + let membership = vec![vec![0usize, 1usize]]; + let sgs = subgraphs_from_membership(&sdem, &membership).unwrap(); + assert_eq!(sgs.len(), 1); + assert_eq!(sgs[0].detector_map, vec![0, 1]); + + // >64 observable membership is rejected by the extractor regardless of source. + let big: Vec> = (0..65).map(|_| Vec::new()).collect(); + assert!(matches!( + subgraphs_from_membership(&sdem, &big), + Err(DecoderError::InvalidConfiguration(_)) + )); + } + + #[test] + fn test_membership_exact_time_vs_radius() { + // D0 and D1 are both qubit-0 X at times 0 and 1. The boundary edge is at + // time 0. Default (None) includes only the boundary time; a radius pulls + // in the neighbouring time step. + let dem = concat!( + "detector(1, 0, 0) D0\n", // qubit 0 X, time 0 + "detector(1, 0, 1) D1\n", // qubit 0 X, time 1 + "error(0.01) D0 L0\n", // boundary at time 0 + "error(0.01) D0 D1\n", + ); + let sc = simple_stab_coords(); + let sdem = SparseDem::from_dem_str(dem).unwrap(); + + let exact = coordinate_membership_from_dem(&sdem, &sc, None).unwrap(); + assert_eq!(exact, vec![vec![0usize]], "None = exact boundary time only"); + + let widened = coordinate_membership_from_dem(&sdem, &sc, Some(1)).unwrap(); + assert_eq!(widened, vec![vec![0usize, 1usize]], "radius pulls in time 1"); + } + + #[test] + fn test_decomposed_mechanism_partitions() { + // A `^`-decomposed mechanism is XOR-combined into one 2-detector + // mechanism, so it is NOT a boundary edge and does not seed the region; + // the single-detector `D0 L0` does. Partition must still succeed. + let dem = concat!( + "detector(1, 0, 0) D0\n", + "detector(0, 1, 0) D1\n", + "error(0.01) D0 L0\n", // boundary → qubit 0 X + "error(0.02) D0 ^ D1 L0\n", // decomposed; XOR -> {D0, D1} + ); + let sc = simple_stab_coords(); + let sdem = SparseDem::from_dem_str(dem).unwrap(); + // The `^` line parsed as one mechanism with both detectors. + assert_eq!(sdem.mechanisms.len(), 2); + let membership = coordinate_membership_from_dem(&sdem, &sc, None).unwrap(); + assert_eq!(membership, vec![vec![0usize]]); // D1 (qubit-0 Z) excluded + } + #[test] fn test_partition_two_qubits() { let dem = concat!( diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 400e23215..4c3e38b79 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4634,6 +4634,15 @@ impl PyLogicalSubgraphDecoder { .collect() } + /// Per-observable observing regions: a list (one entry per observable) of + /// sorted full-DEM detector ids in that observable's subgraph. + /// + /// Exposed for differential testing against reference implementations such + /// as `lomatching.get_detector_indices_for_subgraphs`. + fn observing_regions(&self) -> Vec> { + self.inner.observing_regions() + } + /// Diagnostics: (`num_edges`, `skipped_hyperedges`) for each subgraph. fn subgraph_diagnostics(&self) -> Vec<(usize, usize)> { (0..self.inner.num_observables()) diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_lomatching_parity.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_lomatching_parity.py new file mode 100644 index 000000000..4975b2ca9 --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_lomatching_parity.py @@ -0,0 +1,124 @@ +# 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. + +"""Differential test: PECOS LogicalSubgraphDecoder observing-region selection +vs the reference algorithm shipped by lomatching. + +PECOS's `coordinate_membership_from_dem` (exposed via +`LogicalSubgraphDecoder.observing_regions`) is the same boundary-edge + +stabilizer-coordinate vertex selection that lomatching ships for its MWPM +subgraphs (Serra-Peralta et al., arXiv:2505.13599). The papers justify the +construction via Clifford back-propagation, but the shipping decoder uses this +coordinate path -- so PECOS should agree with it detector-for-detector. + +The oracle below is a faithful port of +``lomatching.util.get_detector_indices_for_subgraphs`` +(``~/Repos/lomatching/lomatching/util.py:255-353``), depending only on stim so +the full lomatching decoder stack (numba/galois/ldpc/pymatching) is not required. +""" + +from __future__ import annotations + +import pytest +from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch +from pecos_rslib.qec import LogicalSubgraphDecoder + +stim = pytest.importorskip("stim", reason="stim is the differential-test oracle dependency") + + +def _lomatching_reference_membership(dem_str: str, stab_coords) -> list[list[int]]: + """Faithful port of lomatching's ``get_detector_indices_for_subgraphs``. + + Returns, per observable (sorted by observable index), the sorted detector + ids in that observable's observing region. Reference: + Serra-Peralta et al. arXiv:2505.13599; lomatching/util.py:255-353. + """ + dem = stim.DetectorErrorModel(dem_str).flattened() + + det_to_coords = { + d: tuple(map(float, c)) for d, c in dem.get_detector_coordinates().items() + } + coords_to_det = {c: d for d, c in det_to_coords.items()} + + # spatial coord (all but the trailing time element) -> (logical qubit, stab type) + coords_to_stab: dict[tuple, tuple[int, str]] = {} + for l_ind, qubit in enumerate(stab_coords): + for stab_type in ("X", "Z"): + for coord in qubit[stab_type]: + coords_to_stab[tuple(map(float, coord))] = (l_ind, stab_type) + + # boundary edges = single-detector error mechanisms that flip an observable + bd_edges_obs: dict[int, list[int]] = {o: [] for o in range(dem.num_observables)} + for instr in dem: + if instr.type != "error": + continue + dets = [t.val for t in instr.targets_copy() if t.is_relative_detector_id()] + if len(dets) != 1: + continue + for o in (t.val for t in instr.targets_copy() if t.is_logical_observable_id()): + bd_edges_obs[o] += dets + + # (logical qubit, stab type, time) seeds for each observable + lst_obs: dict[int, set[tuple[int, str, float]]] = { + o: set() for o in range(dem.num_observables) + } + for obs, dets in bd_edges_obs.items(): + for det in dets: + coords = det_to_coords[det] + l_ind, stab = coords_to_stab[coords[:-1]] + lst_obs[obs].add((l_ind, stab, coords[-1])) + + # include every detector of those (qubit, stab, time) groups + membership: list[list[int]] = [] + for obs in sorted(lst_obs): + inds: list[int] = [] + for l_ind, stab, time in lst_obs[obs]: + for c in stab_coords[l_ind][stab]: + coord = (*map(float, c), time) + inds.append(coords_to_det[coord]) + membership.append(sorted(inds)) + return membership + + +def _assert_parity(dem_str: str, stab_coords) -> None: + decoder = LogicalSubgraphDecoder(dem_str, stab_coords, "pecos_uf:fast") + pecos = [sorted(r) for r in decoder.observing_regions()] + reference = _lomatching_reference_membership(dem_str, stab_coords) + + assert len(pecos) == len(reference), ( + f"observable count mismatch: PECOS {len(pecos)} vs lomatching {len(reference)}" + ) + for obs, (p, r) in enumerate(zip(pecos, reference, strict=True)): + assert p == r, f"observable {obs} membership differs:\n PECOS={p}\n lomatching={r}" + + +def test_parity_memory_z(): + """Single-patch Z memory: PECOS regions == lomatching regions.""" + b = LogicalCircuitBuilder() + b.add_patch(SurfacePatch.create(distance=3), "A") + b.add_memory("A", 3, "Z") + dem_str = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + _assert_parity(dem_str, b.stab_coords()) + + +def test_parity_transversal_cx(): + """Two-patch transversal CX (the hyperedge case): regions must agree.""" + patch = SurfacePatch.create(distance=3) + nq = patch.geometry.num_data + patch.geometry.num_ancilla + b = LogicalCircuitBuilder() + b.add_patch(patch, "C", qubit_offset=0) + b.add_patch(patch, "T", qubit_offset=nq) + b.add_memory(["C", "T"], 3, "Z") + b.add_transversal_cx("C", "T") + b.add_memory(["C", "T"], 3, "Z") + dem_str = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + _assert_parity(dem_str, b.stab_coords()) From 96f95dfa5f0f9503548c4e097624e706023945aa Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 31 May 2026 10:22:02 -0600 Subject: [PATCH 013/150] Reject non-flattened DEMs (repeat / shift_detectors) in line-based parsers instead of silently mis-parsing --- crates/pecos-decoder-core/src/dem.rs | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index ce0adf31c..acf4003e3 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -262,6 +262,19 @@ impl SparseDem { for line in dem.lines() { let line = line.trim(); + // This is a flat, single-pass parser: it does not expand `repeat` + // blocks or apply `shift_detectors`. Silently mis-parsing those would + // corrupt detector ids, so refuse them and tell the caller to flatten + // (e.g. stim's `DetectorErrorModel.flattened()`). + if line.starts_with("repeat") || line.starts_with("shift_detectors") { + return Err(DecoderError::InvalidConfiguration( + "SparseDem requires a flattened DEM: `repeat` / `shift_detectors` \ + are not supported. Flatten the DEM first (e.g. stim's \ + DetectorErrorModel.flattened())." + .into(), + )); + } + if let Some(rest) = line.strip_prefix("error(") { let close = rest.find(')').ok_or_else(|| { DecoderError::InvalidConfiguration("Missing ) in error line".into()) @@ -422,6 +435,13 @@ impl DemCheckMatrix { } continue; } + if line.starts_with("repeat") || line.starts_with("shift_detectors") { + return Err(DecoderError::InvalidConfiguration( + "DemCheckMatrix requires a flattened DEM: `repeat` / \ + `shift_detectors` are not supported. Flatten the DEM first." + .into(), + )); + } if !line.starts_with("error(") { // Skip non-error lines (detector, etc.) continue; @@ -613,6 +633,13 @@ impl DemMatchingGraph { } continue; } + if line.starts_with("repeat") || line.starts_with("shift_detectors") { + return Err(DecoderError::InvalidConfiguration( + "DemMatchingGraph requires a flattened DEM: `repeat` / \ + `shift_detectors` are not supported. Flatten the DEM first." + .into(), + )); + } if line.is_empty() || line.starts_with('#') || !line.starts_with("error(") { continue; } @@ -1058,6 +1085,19 @@ mod tests { assert_eq!(obs, 2, "parse_dem_metadata must count L1"); } + #[test] + fn test_non_flattened_dem_rejected() { + // repeat blocks and shift_detectors would corrupt detector ids if parsed + // line-by-line; all parsers must refuse rather than silently mis-parse. + let repeat_dem = "repeat 3 {\n error(0.01) D0 L0\n shift_detectors 1\n}\n"; + assert!(SparseDem::from_dem_str(repeat_dem).is_err()); + assert!(DemCheckMatrix::from_dem_str(repeat_dem).is_err()); + assert!(DemMatchingGraph::from_dem_str(repeat_dem).is_err()); + + let shift_dem = "error(0.01) D0 L0\nshift_detectors 1\nerror(0.01) D0 L0\n"; + assert!(SparseDem::from_dem_str(shift_dem).is_err()); + } + #[test] fn test_dem_check_matrix_basic() { let dem = "error(0.01) D0 D1 L0\nerror(0.02) D1 D2\nerror(0.03) D0 D2 L0"; From 6fd2ce4c12867faf751e5abbe13d12b5f2c2ecce Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 31 May 2026 11:19:44 -0600 Subject: [PATCH 014/150] Add LogicalSubgraphDecoder.from_membership and compare coordinate vs back-prop observing regions (back-prop decodes ~25x worse) --- .../src/logical_subgraph.rs | 42 ++++++ .../src/fault_tolerance_bindings.rs | 28 ++++ ...test_logical_subgraph_region_comparison.py | 121 ++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index 6f39e550d..47b28d026 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -500,6 +500,48 @@ impl LogicalSubgraphDecoder { }) } + /// Build from a precomputed per-observable detector membership instead of + /// from `stab_coords`. + /// + /// The membership may come from ANY region source — the coordinate path + /// ([`coordinate_membership_from_dem`]) or a back-propagation / detecting- + /// region source. This is the entry point for comparing alternative + /// observing-region constructions (e.g. the paper's back-propagation region + /// vs the coordinate group-fill) on the same DEM and decoders. + /// + /// # Errors + /// + /// Returns an error if the DEM is malformed, the membership has more than 64 + /// entries, or the factory fails. + pub fn from_membership( + dem: &str, + membership: &[Vec], + mut factory: F, + ) -> Result + where + F: FnMut( + &DemMatchingGraph, + ) -> Result, DecoderError>, + { + let sdem = SparseDem::from_dem_str(dem)?; + let subgraphs = subgraphs_from_membership(&sdem, membership)?; + let num_observables = subgraphs.len(); + + let mut decoders = Vec::with_capacity(subgraphs.len()); + let mut sub_syndromes = Vec::with_capacity(subgraphs.len()); + for sg in &subgraphs { + decoders.push(factory(&sg.graph)?); + sub_syndromes.push(vec![0u8; sg.detector_map.len()]); + } + + Ok(Self { + subgraphs, + decoders, + num_observables, + sub_syndromes, + }) + } + /// Number of observables. #[must_use] pub fn num_observables(&self) -> usize { diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 4c3e38b79..c52592a2e 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4479,6 +4479,34 @@ impl PyLogicalSubgraphDecoder { Ok(Self { inner }) } + /// Build from a precomputed per-observable detector membership instead of + /// from `stab_coords`. + /// + /// `membership` is a list (one entry per observable) of full-DEM detector + /// ids. This lets callers supply an alternative observing-region + /// construction (e.g. the paper's back-propagation / detecting-region set) + /// and decode with the same machinery for direct comparison. + #[staticmethod] + #[pyo3(signature = (dem, membership, inner_decoder="pecos_uf:fast"))] + fn from_membership( + dem: &str, + membership: Vec>, + inner_decoder: &str, + ) -> PyResult { + use pecos_decoder_core::logical_subgraph::LogicalSubgraphDecoder; + + let inner = LogicalSubgraphDecoder::from_membership(dem, &membership, |subgraph| { + let sub_dem = subgraph_to_dem_string(subgraph); + let decoder = create_observable_decoder(&sub_dem, inner_decoder) + .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string()))?; + Ok(Box::new(SendWrapper(decoder)) + as Box) + }) + .map_err(|e| PyErr::new::(e.to_string()))?; + + Ok(Self { inner }) + } + /// Decode a syndrome and return observable flip predictions. fn decode(&mut self, syndrome: Vec) -> PyResult { use pecos_decoder_core::ObservableDecoder; 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 new file mode 100644 index 000000000..0a7ae476a --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py @@ -0,0 +1,121 @@ +# 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. + +"""Compare observing-region constructions for the logical-subgraph decoder. + +The decoder's accuracy depends on which detectors land in each observable's +subgraph. Two constructions: + +- **coordinate group-fill** (the shipping path, == lomatching's + ``get_detector_indices_for_subgraphs``): boundary edges seed + ``(qubit, stab_type, time)`` groups, then ALL detectors of those groups are + included. +- **back-propagation / co-flipped** (the papers' derivation, arXiv:2505.13587): + detectors that share a fault with the observable -- i.e. lie in its + detecting region. Computed here directly from the DEM ``L`` targets. + +`LogicalSubgraphDecoder.from_membership` lets both feed the same decoder, so we +can compare. Finding (recorded in +``pecos-docs/design/logical-subgraph-backprop-region-builder.md``): the raw +back-prop set decodes much WORSE despite being larger, because it lacks the +group-fill structure that makes each subgraph cleanly matchable. +""" + +from __future__ import annotations + +import pytest +from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch +from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem + + +def _coflip_membership_from_dem(dem_str: str, num_observables: int) -> list[list[int]]: + """Back-propagation / co-flipped observing region, read straight off the DEM. + + For each observable O, the detectors of every error mechanism that flips O + (an error in their shared support flips both -- O's detecting region). + """ + regions: list[set[int]] = [set() for _ in range(num_observables)] + for raw_line in dem_str.splitlines(): + line = raw_line.strip() + if not line.startswith("error("): + continue + tokens = line[line.index(")") + 1 :].split() + dets = [int(t[1:]) for t in tokens if t.startswith("D")] + obs = [int(t[1:]) for t in tokens if t.startswith("L")] + for o in obs: + regions[o].update(dets) + return [sorted(r) for r in regions] + + +def _cx_circuit(): + patch = SurfacePatch.create(distance=3) + nq = patch.geometry.num_data + patch.geometry.num_ancilla + b = LogicalCircuitBuilder() + b.add_patch(patch, "C", qubit_offset=0) + b.add_patch(patch, "T", qubit_offset=nq) + b.add_memory(["C", "T"], 3, "Z") + b.add_transversal_cx("C", "T") + b.add_memory(["C", "T"], 3, "Z") + return b + + +def test_from_membership_reproduces_coordinate_path(): + """Feeding the coordinate membership through from_membership reproduces the + normal coordinate decoder exactly (the seam is behaviour-preserving).""" + b = _cx_circuit() + dem = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + sc = b.stab_coords() + + coord = LogicalSubgraphDecoder(dem, sc, "pecos_uf:fast") + rebuilt = LogicalSubgraphDecoder.from_membership( + dem, + coord.observing_regions(), + "pecos_uf:fast", + ) + + assert rebuilt.subgraph_sizes() == coord.subgraph_sizes() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(2000, seed=3) + assert rebuilt.decode_count(batch) == coord.decode_count(batch) + + +def test_coordinate_region_beats_raw_backprop_region(): + """The coordinate group-fill region decodes far better than the raw + back-prop / co-flipped detector set -- group-fill is essential, not + cosmetic.""" + b = _cx_circuit() + dem = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + sc = b.stab_coords() + + n = 20000 + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=11) + + coord = LogicalSubgraphDecoder(dem, sc, "pecos_uf:fast") + coflip_membership = _coflip_membership_from_dem(dem, coord.num_observables()) + backprop = LogicalSubgraphDecoder.from_membership(dem, coflip_membership, "pecos_uf:fast") + + coord_ler = coord.decode_count(batch) / n + backprop_ler = backprop.decode_count(batch) / n + + # The coordinate group-fill region is dramatically better. The gap is large + # and stable (~20x at d=3, p=0.001); assert a conservative margin. + assert coord_ler < backprop_ler, ( + f"expected coordinate region to beat raw back-prop: " + f"coord={coord_ler:.5f} backprop={backprop_ler:.5f}" + ) + assert coord_ler * 5 < backprop_ler, ( + f"expected a large gap (group-fill essential): " + f"coord={coord_ler:.5f} backprop={backprop_ler:.5f}" + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "-s"]) From 62357753fbdef33d37e50b69154054551ad63784 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 11:52:27 -0600 Subject: [PATCH 015/150] Support replacement two-qubit DEM weights --- .../src/fault_tolerance/dem_builder.rs | 3 +- .../fault_tolerance/dem_builder/builder.rs | 7 +- .../src/fault_tolerance/dem_builder/types.rs | 300 +++++++++++++++++- .../src/fault_tolerance_bindings.rs | 79 ++++- python/quantum-pecos/src/pecos/qec/dem.py | 15 +- .../src/pecos/qec/surface/decode.py | 20 +- 6 files changed, 400 insertions(+), 24 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs index a6af42108..82abce66a 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs @@ -100,6 +100,7 @@ pub use types::{ ContributionRenderSummary, DecomposedFault, DemOutput, DetectorDef, DetectorErrorModel, DirectSourceFamily, FaultContribution, FaultMechanism, FaultSourceType, MeasurementMechanism, MeasurementNoiseModel, NoiseConfig, PAULI_1Q_ORDER, PAULI_2Q_ORDER, PauliProbs, PauliWeights, - PecosDemMetadataError, PerGateTypeNoise, TwoDetectorDirectRenderPolicy, combine_probabilities, + PecosDemMetadataError, PerGateTypeNoise, ReplacementBranchApproximation, + 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 d6dd9a5e4..1b9ae02e4 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -378,7 +378,12 @@ impl<'a> DemBuilder<'a> { let flat = idx + 1; let p1 = flat / 4; let p2 = flat % 4; - self.noise.p2 * weights.weight_for(&pauli_pair_for_weight(p1, p2)) + self.noise.p2 + * weights.two_qubit_weight_for( + loc1.gate_type, + &pauli_pair_for_weight(p1, p2), + self.noise.p2_replacement_approximation, + ) }); } [per_channel_probability(self.noise.p2, 15); 15] 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 e23e3d68d..664e381d9 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -1410,8 +1410,23 @@ impl std::error::Error for PecosDemMetadataError {} /// ``` #[derive(Debug, Clone)] pub struct PauliWeights { - /// (`PauliString`, weight) pairs. Weights must sum to ~1.0. + /// Post-gate (`PauliString`, weight) pairs. entries: Vec<(pecos_core::PauliString, f64)>, + /// Replacement (`PauliString`, weight) pairs. These omit the ideal gate before + /// applying the stored Pauli. + replacement_entries: Vec<(pecos_core::PauliString, f64)>, +} + +/// Approximation used for replacement two-qubit fault branches. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ReplacementBranchApproximation { + /// Ignore the omitted ideal gate and treat replacement entries like post-gate + /// Pauli entries. Useful as a baseline comparison. + IgnoreGateRemoval, + /// Convolve replacement entries with the Pauli twirl of the omitted ideal + /// gate's dagger. This is the default approximation for starred entries. + #[default] + PauliTwirlOmittedGate, } impl PauliWeights { @@ -1423,16 +1438,40 @@ impl PauliWeights { /// /// Panics if weights don't sum to ~1.0 or if any weight is negative. pub fn new(entries: impl IntoIterator) -> Self { + Self::with_replacement(entries, std::iter::empty()) + } + + /// Create from post-gate and replacement branch entries. + /// + /// Replacement entries model branches where the ideal two-qubit gate is omitted + /// and the entry Pauli is applied instead. The combined post-gate and replacement + /// branch weights must sum to ~1.0. + /// + /// # Panics + /// + /// Panics if weights don't sum to ~1.0 or if any weight is negative. + pub fn with_replacement( + entries: impl IntoIterator, + replacement_entries: impl IntoIterator, + ) -> Self { let entries: Vec<_> = entries.into_iter().collect(); - let sum: f64 = entries.iter().map(|(_, w)| w).sum(); + let replacement_entries: Vec<_> = replacement_entries.into_iter().collect(); + let sum: f64 = entries + .iter() + .chain(replacement_entries.iter()) + .map(|(_, w)| w) + .sum(); assert!( (sum - 1.0).abs() < 1e-6, "PauliWeights must sum to 1.0, got {sum}" ); - for (ps, w) in &entries { + for (ps, w) in entries.iter().chain(replacement_entries.iter()) { assert!(*w >= 0.0, "Weight for {ps} must be non-negative, got {w}"); } - Self { entries } + Self { + entries, + replacement_entries, + } } /// Uniform weights for single-qubit gates: X, Y, Z each with 1/3. @@ -1441,6 +1480,7 @@ impl PauliWeights { use pecos_core::pauli::{X, Y, Z}; Self { entries: vec![(X(0), 1.0 / 3.0), (Y(0), 1.0 / 3.0), (Z(0), 1.0 / 3.0)], + replacement_entries: Vec::new(), } } @@ -1467,6 +1507,7 @@ impl PauliWeights { (Z(0) & Y(1), w), (Z(0) & Z(1), w), ], + replacement_entries: Vec::new(), } } @@ -1484,11 +1525,76 @@ impl PauliWeights { .map_or(0.0, |(_, w)| *w) } + /// Look up the effective two-qubit Pauli weight for a specific gate. + /// + /// Plain entries contribute directly. Replacement entries first convolve with + /// the Pauli twirl of the omitted gate, so `*II` on `SZZ` contributes half + /// `II` and half `ZZ`, while `*XX` on `SZZ` contributes half `XX` and half + /// `YY`. The identity component is intentionally not returned by callers that + /// query only non-identity Pauli labels. + #[must_use] + pub fn two_qubit_weight_for( + &self, + gate_type: GateType, + pauli: &pecos_core::PauliString, + approximation: ReplacementBranchApproximation, + ) -> f64 { + let Ok(query_label) = two_qubit_pauli_label(pauli) else { + return 0.0; + }; + let direct = self + .entries + .iter() + .filter_map(|(ps, weight)| { + (two_qubit_pauli_label(ps).ok()?.as_str() == query_label).then_some(*weight) + }) + .sum::(); + + if approximation == ReplacementBranchApproximation::IgnoreGateRemoval { + return direct + + self + .replacement_entries + .iter() + .filter_map(|(ps, weight)| { + (two_qubit_pauli_label(ps).ok()?.as_str() == query_label).then_some(*weight) + }) + .sum::(); + } + + let Some(twirl) = omitted_two_qubit_gate_pauli_twirl(gate_type) else { + return direct; + }; + direct + + self + .replacement_entries + .iter() + .filter_map(|(ps, weight)| { + let replacement_label = two_qubit_pauli_label(ps).ok()?; + Some( + twirl + .iter() + .filter_map(|(twirl_label, twirl_weight)| { + (multiply_two_qubit_pauli_labels(&replacement_label, twirl_label) + == query_label) + .then_some(weight * twirl_weight) + }) + .sum::(), + ) + }) + .sum::() + } + /// Get all entries as `(PauliString, weight)` pairs. #[must_use] pub fn entries(&self) -> &[(pecos_core::PauliString, f64)] { &self.entries } + + /// Get replacement entries as `(PauliString, weight)` pairs. + #[must_use] + pub fn replacement_entries(&self) -> &[(pecos_core::PauliString, f64)] { + &self.replacement_entries + } } impl From<[(pecos_core::PauliString, f64); N]> for PauliWeights { @@ -1497,6 +1603,37 @@ impl From<[(pecos_core::PauliString, f64); N]> for PauliWeights } } +/// Return the Pauli-twirled channel for omitting a supported two-qubit Clifford gate. +/// +/// Some physical error models have *replacement* fault branches: when the branch +/// fires, the intended gate is not applied and the branch operation is applied +/// instead. A DEM built in the ideal-circuit frame can approximate that missing +/// operation by convolving the replacement branch with the Pauli twirl of the +/// omitted gate's inverse. Clifford gates and their adjoints have the same +/// Pauli-twirl probabilities, so this helper returns the distribution in terms +/// of two-qubit Pauli labels, including `"II"` when present. +/// +/// This helper is intentionally parameter-free and device-agnostic. Callers +/// remain responsible for deciding which fault branches are replacement +/// branches, how leakage symbols are projected, and how branch probabilities are +/// scaled. +#[must_use] +pub fn omitted_two_qubit_gate_pauli_twirl( + gate_type: GateType, +) -> Option> { + let entries: &[(&str, f64)] = match gate_type { + GateType::CX => &[("II", 0.25), ("IX", 0.25), ("ZI", 0.25), ("ZX", 0.25)], + GateType::CY => &[("II", 0.25), ("IY", 0.25), ("ZI", 0.25), ("ZY", 0.25)], + GateType::CZ => &[("II", 0.25), ("IZ", 0.25), ("ZI", 0.25), ("ZZ", 0.25)], + GateType::SWAP => &[("II", 0.25), ("XX", 0.25), ("YY", 0.25), ("ZZ", 0.25)], + GateType::SXX | GateType::SXXdg => &[("II", 0.5), ("XX", 0.5)], + GateType::SYY | GateType::SYYdg => &[("II", 0.5), ("YY", 0.5)], + GateType::SZZ | GateType::SZZdg => &[("II", 0.5), ("ZZ", 0.5)], + _ => return None, + }; + Some(entries.iter().copied().collect()) +} + /// Noise model configuration for circuit-level fault analysis. #[derive(Debug, Clone)] pub struct NoiseConfig { @@ -1532,6 +1669,8 @@ pub struct NoiseConfig { /// Maps each two-qubit Pauli fault to its relative probability. Must sum to ~1.0. /// Default (None) = uniform depolarizing. pub p2_weights: Option, + /// Approximation used for replacement two-qubit entries in `p2_weights`. + pub p2_replacement_approximation: ReplacementBranchApproximation, /// Coherent idle RZ rotation angle per time unit. /// /// When set (> 0), idle gates contribute a coherent Z rotation in addition @@ -1628,6 +1767,7 @@ impl Default for NoiseConfig { t2: None, p1_weights: None, p2_weights: None, + p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_rate: 0.0, @@ -1653,6 +1793,7 @@ impl NoiseConfig { t2: None, p1_weights: None, p2_weights: None, + p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_rate: 0.0, @@ -1676,6 +1817,7 @@ impl NoiseConfig { t2: None, p1_weights: None, p2_weights: None, + p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_rate: 0.0, @@ -1699,6 +1841,7 @@ impl NoiseConfig { t2: None, p1_weights: None, p2_weights: None, + p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_rate: 0.0, @@ -1802,6 +1945,16 @@ impl NoiseConfig { self } + /// Sets how replacement entries in `p2_weights` are approximated. + #[must_use] + pub fn set_p2_replacement_approximation( + mut self, + approximation: ReplacementBranchApproximation, + ) -> Self { + self.p2_replacement_approximation = approximation; + self + } + /// Sets idle noise from a coherent RZ rotation angle per time unit. /// /// Converts `idle_rz` (the angle theta of an RZ(theta) rotation applied @@ -1927,6 +2080,43 @@ fn pauli_pattern(ps: &pecos_core::PauliString) -> Vec { ps.paulis().iter().map(|&(p, _)| p).collect() } +fn two_qubit_pauli_label(ps: &pecos_core::PauliString) -> Result { + let mut chars = ['I', 'I']; + for &(pauli, qubit) in ps.paulis() { + let idx = qubit.index(); + if idx >= 2 { + return Err(format!( + "two-qubit Pauli weights only support qubit indices 0 and 1, got {idx}" + )); + } + chars[idx] = match pauli { + pecos_core::Pauli::I => 'I', + pecos_core::Pauli::X => 'X', + pecos_core::Pauli::Y => 'Y', + pecos_core::Pauli::Z => 'Z', + }; + } + Ok(chars.iter().collect()) +} + +fn multiply_two_qubit_pauli_labels(left: &str, right: &str) -> String { + left.chars() + .zip(right.chars()) + .map(|(a, b)| multiply_pauli_labels(a, b)) + .collect() +} + +fn multiply_pauli_labels(left: char, right: char) -> char { + match (left, right) { + ('I', p) | (p, 'I') => p, + ('X', 'X') | ('Y', 'Y') | ('Z', 'Z') => 'I', + ('X', 'Y') | ('Y', 'X') => 'Z', + ('X', 'Z') | ('Z', 'X') => 'Y', + ('Y', 'Z') | ('Z', 'Y') => 'X', + _ => unreachable!("validated Pauli labels contain only I/X/Y/Z"), + } +} + fn pecos_metadata_dem_output_value(target: &DemOutput) -> serde_json::Value { serde_json::json!({ "id": target.id, @@ -5494,4 +5684,106 @@ mod tests { Some(DirectSourceFamily::SingleLocationY) ); } + + #[test] + fn test_omitted_two_qubit_gate_pauli_twirl_for_spp_gates() { + let szz = omitted_two_qubit_gate_pauli_twirl(GateType::SZZ).expect("SZZ is supported"); + let szzdg = + omitted_two_qubit_gate_pauli_twirl(GateType::SZZdg).expect("SZZdg is supported"); + + assert_eq!(szz, BTreeMap::from([("II", 0.5), ("ZZ", 0.5)])); + assert_eq!(szzdg, szz); + } + + #[test] + fn test_omitted_two_qubit_gate_pauli_twirl_for_entanglers() { + assert_eq!( + omitted_two_qubit_gate_pauli_twirl(GateType::CX).expect("CX is supported"), + BTreeMap::from([("II", 0.25), ("IX", 0.25), ("ZI", 0.25), ("ZX", 0.25)]), + ); + assert_eq!( + omitted_two_qubit_gate_pauli_twirl(GateType::CZ).expect("CZ is supported"), + BTreeMap::from([("II", 0.25), ("IZ", 0.25), ("ZI", 0.25), ("ZZ", 0.25)]), + ); + assert_eq!( + omitted_two_qubit_gate_pauli_twirl(GateType::SWAP).expect("SWAP is supported"), + BTreeMap::from([("II", 0.25), ("XX", 0.25), ("YY", 0.25), ("ZZ", 0.25)]), + ); + assert!(omitted_two_qubit_gate_pauli_twirl(GateType::RZZ).is_none()); + } + + #[test] + fn test_two_qubit_replacement_weight_convolves_with_omitted_gate_twirl() { + use pecos_core::pauli::{X, Y, Z}; + + let weights = PauliWeights::with_replacement([(X(0) & X(1), 0.25)], [(X(0) & X(1), 0.75)]); + + assert!( + (weights.two_qubit_weight_for( + GateType::SZZ, + &(X(0) & X(1)), + ReplacementBranchApproximation::PauliTwirlOmittedGate, + ) - (0.25 + 0.75 * 0.5)) + .abs() + < 1e-12 + ); + assert!( + (weights.two_qubit_weight_for( + GateType::SZZ, + &(Y(0) & Y(1)), + ReplacementBranchApproximation::PauliTwirlOmittedGate, + ) - 0.75 * 0.5) + .abs() + < 1e-12 + ); + assert!( + (weights.two_qubit_weight_for( + GateType::SZZ, + &(X(0) & X(1)), + ReplacementBranchApproximation::IgnoreGateRemoval, + ) - 1.0) + .abs() + < 1e-12 + ); + + let replacement_omits_only = PauliWeights::with_replacement( + [], + [( + pecos_core::PauliString::with_phase_and_paulis( + pecos_core::QuarterPhase::PlusOne, + Vec::new(), + ), + 1.0, + )], + ); + assert!( + (replacement_omits_only.two_qubit_weight_for( + GateType::SZZ, + &(Z(0) & Z(1)), + ReplacementBranchApproximation::PauliTwirlOmittedGate, + ) - 0.5) + .abs() + < 1e-12 + ); + + let cx_replacement_identity = PauliWeights::with_replacement([], [(Z(0) & X(1), 1.0)]); + assert!( + (cx_replacement_identity.two_qubit_weight_for( + GateType::CX, + &(Z(0) & X(1)), + ReplacementBranchApproximation::PauliTwirlOmittedGate, + ) - 0.25) + .abs() + < 1e-12 + ); + assert!( + (cx_replacement_identity.two_qubit_weight_for( + GateType::CX, + &Z(0), + ReplacementBranchApproximation::PauliTwirlOmittedGate, + ) - 0.25) + .abs() + < 1e-12 + ); + } } diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index cd5d354fe..1c8f80aad 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -52,7 +52,7 @@ use pecos_qec::fault_tolerance::dem_builder::{ DetectorErrorModel as RustDetectorErrorModel, DirectSourceFamily as RustDirectSourceFamily, EquivalenceResult as RustEquivalenceResult, FaultContribution as RustFaultContribution, FaultSourceType as RustFaultSourceType, NoiseConfig, PAULI_2Q_ORDER, - ParsedDem as RustParsedDem, PauliWeights, + ParsedDem as RustParsedDem, PauliWeights, ReplacementBranchApproximation, TwoDetectorDirectRenderPolicy as RustTwoDetectorDirectRenderPolicy, compare_dems_exact as rust_compare_dems_exact, compare_dems_statistical as rust_compare_dems_statistical, @@ -76,12 +76,18 @@ fn parse_p2_weights(weights: BTreeMap) -> PyResult { use pecos_core::pauli::{X, Y, Z}; let mut entries = Vec::with_capacity(weights.len()); + let mut replacement_entries = Vec::new(); let mut sum = 0.0; for (label, weight) in weights { let label = label.trim().to_ascii_uppercase(); - if !PAULI_2Q_ORDER.contains(&label.as_str()) { + let (replacement, label) = match label.strip_prefix('*') { + Some(stripped) => (true, stripped.to_string()), + None => (false, label), + }; + let replacement_identity = replacement && label == "II"; + if !replacement_identity && !PAULI_2Q_ORDER.contains(&label.as_str()) { let msg = format!( - "p2_weights keys must be one of {:?}, got {label:?}", + "p2_weights keys must be one of {:?} or prefixed with '*' for replacement branches, got {label:?}", PAULI_2Q_ORDER ); return Err(pyo3::exceptions::PyValueError::new_err(msg)); @@ -107,19 +113,54 @@ fn parse_p2_weights(weights: BTreeMap) -> PyResult { (Some(existing), Some(term)) => Some(existing & term), }; } - let Some(pauli) = pauli else { + let pauli = if let Some(pauli) = pauli { + pauli + } else if replacement { + pecos_core::PauliString::with_phase_and_paulis( + pecos_core::QuarterPhase::PlusOne, + Vec::new(), + ) + } else { return Err(pyo3::exceptions::PyValueError::new_err( - "p2_weights cannot contain the identity pair 'II'", + "plain p2_weights cannot contain identity pair 'II'; use '*II' for a replacement branch that only omits the gate", )); }; sum += weight; - entries.push((pauli, weight)); + if replacement { + replacement_entries.push((pauli, weight)); + } else { + entries.push((pauli, weight)); + } } if (sum - 1.0).abs() >= 1.0e-6 { let msg = format!("p2_weights relative probabilities must sum to 1.0, got {sum}"); return Err(pyo3::exceptions::PyValueError::new_err(msg)); } - Ok(PauliWeights::new(entries)) + Ok(PauliWeights::with_replacement(entries, replacement_entries)) +} + +fn parse_replacement_approximation( + value: Option, +) -> PyResult { + let Some(value) = value else { + return Ok(ReplacementBranchApproximation::default()); + }; + match value + .trim() + .to_ascii_lowercase() + .replace(['-', ' '], "_") + .as_str() + { + "pauli_twirl_omitted_gate" | "pauli_twirl" | "twirl" => { + Ok(ReplacementBranchApproximation::PauliTwirlOmittedGate) + } + "ignore_gate_removal" | "ignore_removal" | "post_gate" | "postgate" => { + Ok(ReplacementBranchApproximation::IgnoreGateRemoval) + } + _ => Err(pyo3::exceptions::PyValueError::new_err( + "p2_replacement_approximation must be 'pauli_twirl_omitted_gate' or 'ignore_gate_removal'", + )), + } } fn apply_noise_options( @@ -137,6 +178,7 @@ fn apply_noise_options( p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, p2_weights: Option>, + p2_replacement_approximation: Option, ) -> PyResult { noise.p_idle = p_idle.unwrap_or(0.0); if let (Some(t1_val), Some(t2_val)) = (t1, t2) { @@ -172,6 +214,9 @@ fn apply_noise_options( if let Some(weights) = p2_weights { noise = noise.set_p2_weights(parse_p2_weights(weights)?); } + noise = noise.set_p2_replacement_approximation(parse_replacement_approximation( + p2_replacement_approximation, + )?); Ok(noise) } @@ -1057,7 +1102,7 @@ impl PyDetectorErrorModel { /// >>> print(dem.to_string()) /// >>> sampler = dem.to_sampler() #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &pyo3::Bound<'_, pyo3::PyAny>, @@ -1078,6 +1123,7 @@ impl PyDetectorErrorModel { p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, p2_weights: Option>, + p2_replacement_approximation: Option, ) -> PyResult { use pecos_qec::fault_tolerance::dem_builder::DemBuilder; @@ -1096,6 +1142,7 @@ impl PyDetectorErrorModel { p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, p2_weights, + p2_replacement_approximation, )?; if let Ok(dag) = circuit.extract::>() @@ -1405,7 +1452,7 @@ impl PyDemBuilder { /// /// Returns: /// Self for method chaining. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -1426,6 +1473,7 @@ impl PyDemBuilder { p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, p2_weights: Option>, + p2_replacement_approximation: Option, ) -> PyResult> { slf.noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -1442,6 +1490,7 @@ impl PyDemBuilder { p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, p2_weights, + p2_replacement_approximation, )?; Ok(slf) } @@ -3286,7 +3335,7 @@ impl PyDemSampler { /// >>> sampler = DemSampler.from_circuit(dag, p1=0.001, p2=0.01) /// >>> sampler = DemSampler.from_circuit(tc, p2=0.01) # TickCircuit also works #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &Bound<'_, pyo3::PyAny>, @@ -3307,6 +3356,7 @@ impl PyDemSampler { p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, p2_weights: Option>, + p2_replacement_approximation: Option, ) -> PyResult { let noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -3323,6 +3373,7 @@ impl PyDemSampler { p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, p2_weights, + p2_replacement_approximation, )?; // Accept both DagCircuit and TickCircuit @@ -3433,7 +3484,7 @@ impl PyDemSampler { /// /// The `observables` argument defines observables. #[staticmethod] - #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] + #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] #[allow(clippy::too_many_arguments)] fn with_detectors( influence_map: &PyDagFaultInfluenceMap, @@ -3456,6 +3507,7 @@ impl PyDemSampler { p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, p2_weights: Option>, + p2_replacement_approximation: Option, ) -> PyResult { let noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -3472,6 +3524,7 @@ impl PyDemSampler { p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, p2_weights, + p2_replacement_approximation, )?; let inner = RustNewDemSamplerBuilder::new(&influence_map.inner) .with_noise_config(noise) @@ -3925,7 +3978,7 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -3946,6 +3999,7 @@ impl PyDemSamplerBuilder { p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, p2_weights: Option>, + p2_replacement_approximation: Option, ) -> PyResult> { slf.noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -3962,6 +4016,7 @@ impl PyDemSamplerBuilder { p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, p2_weights, + p2_replacement_approximation, )?; Ok(slf) } diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index e268bb9db..12087cf76 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -61,6 +61,7 @@ def from_guppy( p1: float = 0.001, 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: float | None = None, @@ -143,9 +144,16 @@ def from_guppy( circuit; if given, it must match the traced count. p1: Single-qubit gate depolarizing rate. p2: Two-qubit gate depolarizing rate. - p2_weights: Optional relative probabilities over the 15 non-identity - two-qubit Pauli errors (``IX`` through ``ZZ``). Values must sum - to 1.0; ``p2`` remains the total two-qubit error rate. + 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"`` + are replacement branches that omit the ideal two-qubit gate + before applying the Pauli. Values must sum to 1.0; ``p2`` + remains the total two-qubit error rate. + p2_replacement_approximation: Approximation used for starred + replacement labels. ``"pauli_twirl_omitted_gate"`` convolves + with the omitted two-qubit gate's Pauli twirl; ``"ignore_gate_removal"`` + treats starred 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. @@ -262,6 +270,7 @@ def from_guppy( p1=p1, p2=p2, p2_weights=p2_weights, + p2_replacement_approximation=p2_replacement_approximation, p_meas=p_meas, p_prep=p_prep, p_idle=p_idle, diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 4b5ab9258..5d14d8933 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -91,9 +91,16 @@ class NoiseModel: Attributes: p1: Single-qubit gate error rate. p2: Two-qubit gate error rate. - p2_weights: Optional relative probabilities over the 15 non-identity - two-qubit Pauli errors (``IX`` through ``ZZ``). Values must sum to - 1.0; ``p2`` remains the total two-qubit error rate. + 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"`` are replacement + branches that omit the ideal two-qubit gate before applying the + Pauli. Values must sum to 1.0; ``p2`` remains the total two-qubit + error rate. + p2_replacement_approximation: Approximation used for starred + replacement labels. ``"pauli_twirl_omitted_gate"`` convolves with + the omitted two-qubit gate's Pauli twirl; ``"ignore_gate_removal"`` + treats starred entries like plain post-gate Pauli entries. p_meas: Measurement error rate. p_prep: Initialization error rate. p_idle: Idle noise rate per time unit (uniform depolarizing). @@ -112,6 +119,7 @@ class NoiseModel: p1: float = 0.0 p2: float = 0.0 p2_weights: P2Weights | None = None + p2_replacement_approximation: str | None = None p_meas: float = 0.0 p_prep: float = 0.0 p_idle: float | None = None @@ -1341,6 +1349,7 @@ def _dem_string_from_cached_surface_topology( p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, p2_weights=_p2_weights_dict(noise.p2_weights), + p2_replacement_approximation=noise.p2_replacement_approximation, ) .with_num_measurements(topology.num_measurements) .with_measurement_order(list(topology.measurement_order)) @@ -1364,6 +1373,7 @@ def _cached_surface_native_dem_string( p_prep: float, decompose_errors: bool, p2_weights: tuple[tuple[str, float], ...] | None = None, + p2_replacement_approximation: str | None = None, p_idle: float | None = None, t1: float | None = None, t2: float | None = None, @@ -1404,6 +1414,7 @@ def _cached_surface_native_dem_string( p1=p1, p2=p2, p2_weights=p2_weights, + p2_replacement_approximation=p2_replacement_approximation, p_meas=p_meas, p_prep=p_prep, p_idle=p_idle, @@ -1475,6 +1486,7 @@ def _build_native_sampler_from_cached_surface_topology( p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, p2_weights=_p2_weights_dict(noise.p2_weights), + p2_replacement_approximation=noise.p2_replacement_approximation, ) # Remap sampling_model for NativeSampler dispatch sampling_model = "influence_dem" @@ -1576,6 +1588,7 @@ def generate_circuit_level_dem_from_builder( noise.p_prep, decompose_errors=decompose_errors, p2_weights=noise.p2_weights, + p2_replacement_approximation=noise.p2_replacement_approximation, p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2, @@ -3251,6 +3264,7 @@ def build_native_sampler( noise.p_prep, decompose_errors=True, p2_weights=noise.p2_weights, + p2_replacement_approximation=noise.p2_replacement_approximation, p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2, From 454a60c9c704a3872be46f3b8421cac8639a929e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 31 May 2026 11:57:48 -0600 Subject: [PATCH 016/150] Add back-prop-seeded group-fill region comparison: strictly broader than coordinate and decodes worse --- ...test_logical_subgraph_region_comparison.py | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) 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 0a7ae476a..c10468e87 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 @@ -56,6 +56,60 @@ def _coflip_membership_from_dem(dem_str: str, num_observables: int) -> list[list return [sorted(r) for r in regions] +def _groupfill_membership(dem_str: str, stab_coords, *, seed_all: bool) -> list[list[int]]: + """Coordinate group-fill membership, parameterized by the seed rule. + + ``seed_all=False`` seeds only 1-detector boundary edges (the shipping + coordinate path / lomatching). ``seed_all=True`` seeds from every detector of + any O-flipping mechanism (the back-propagation / detecting-region crossings), + then group-fills the same way -- a strictly broader region. + """ + from collections import defaultdict + + det_coords: dict[int, tuple[float, ...]] = {} + mechs: list[tuple[list[int], list[int]]] = [] + for raw in dem_str.splitlines(): + ln = raw.strip() + if ln.startswith("detector("): + coords = tuple(float(x) for x in ln[ln.index("(") + 1 : ln.index(")")].split(",")) + for t in ln[ln.index(")") + 1 :].split(): + if t.startswith("D"): + det_coords[int(t[1:])] = coords + elif ln.startswith("error("): + toks = ln[ln.index(")") + 1 :].split() + mechs.append( + ([int(t[1:]) for t in toks if t.startswith("D")], + [int(t[1:]) for t in toks if t.startswith("L")]), + ) + + coords_to_stab: dict[tuple, tuple[int, str]] = {} + for li, q in enumerate(stab_coords): + for st in ("X", "Z"): + for c in q[st]: + coords_to_stab[tuple(map(float, c))] = (li, st) + + det_group: dict[int, tuple] = {} + group_dets: dict[tuple, list[int]] = defaultdict(list) + for d, c in det_coords.items(): + spatial, time = c[:-1], c[-1] + if spatial in coords_to_stab: + li, st = coords_to_stab[spatial] + det_group[d] = (li, st, time) + group_dets[(li, st, time)].append(d) + + nobs = 1 + max((o for _, obs in mechs for o in obs), default=-1) + seeds: list[set] = [set() for _ in range(nobs)] + for dets, obs in mechs: + if not obs: + continue + seed_dets = dets if (seed_all or len(dets) == 1) else [] + for o in obs: + for d in seed_dets: + if d in det_group: + seeds[o].add(det_group[d]) + return [sorted({d for g in seeds[o] for d in group_dets[g]}) for o in range(nobs)] + + def _cx_circuit(): patch = SurfacePatch.create(distance=3) nq = patch.geometry.num_data + patch.geometry.num_ancilla @@ -117,5 +171,46 @@ def test_coordinate_region_beats_raw_backprop_region(): ) +def test_coordinate_seeding_reproduces_shipping_path(): + """The group-fill helper with boundary-edge seeding reproduces the shipping + coordinate membership exactly (validates the helper used for the broader + back-prop comparison).""" + b = _cx_circuit() + dem = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + sc = b.stab_coords() + coord = LogicalSubgraphDecoder(dem, sc, "pecos_uf:fast") + helper = _groupfill_membership(dem, sc, seed_all=False) + assert helper == [sorted(r) for r in coord.observing_regions()] + + +def test_coordinate_beats_backprop_seeded_groupfill(): + """The faithful 'next step': seed the same group-fill from the operator's + back-propagation crossings (all O-flipping mechanism detectors) instead of + boundary edges. It is strictly broader than the coordinate region and + decodes worse -- confirming the boundary-edge seeding IS the right (faithful) + back-propagation region, and broadening hurts.""" + b = _cx_circuit() + dem = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + sc = b.stab_coords() + + coord_membership = _groupfill_membership(dem, sc, seed_all=False) + backprop_membership = _groupfill_membership(dem, sc, seed_all=True) + + # Coordinate region is a strict subset of the back-prop-seeded region. + for c, bp in zip(coord_membership, backprop_membership, strict=True): + assert set(c) <= set(bp) + assert sum(len(bp) for bp in backprop_membership) > sum(len(c) for c in coord_membership) + + n = 20000 + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=13) + coord = LogicalSubgraphDecoder.from_membership(dem, coord_membership, "pecos_uf:fast") + backprop = LogicalSubgraphDecoder.from_membership(dem, backprop_membership, "pecos_uf:fast") + coord_ler = coord.decode_count(batch) / n + backprop_ler = backprop.decode_count(batch) / n + assert coord_ler < backprop_ler, ( + f"coord={coord_ler:.5f} backprop-seeds={backprop_ler:.5f}" + ) + + if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) From 8835879ef1a6ba17d311f5c4c7512971eec5fb72 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 12:15:38 -0600 Subject: [PATCH 017/150] Add branch-impact DEM approximation --- .../fault_tolerance/dem_builder/builder.rs | 246 ++++++++++++------ .../dem_builder/dem_sampler.rs | 83 +++++- .../fault_tolerance/dem_builder/sampler.rs | 9 +- .../src/fault_tolerance/dem_builder/types.rs | 89 +++++-- .../src/fault_tolerance_bindings.rs | 5 +- python/quantum-pecos/src/pecos/qec/dem.py | 6 +- .../src/pecos/qec/surface/decode.py | 6 +- 7 files changed, 321 insertions(+), 123 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 1b9ae02e4..6f94ccfe7 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -17,7 +17,8 @@ use super::types::{ DemOutput, DetectorDef, DetectorErrorModel, DirectSourceComponents, FaultMechanism, - NoiseConfig, PerGateTypeNoise, SourceMetadata, record_offset_to_absolute_index, + NoiseConfig, PerGateTypeNoise, ReplacementBranchApproximation, SourceMetadata, + record_offset_to_absolute_index, }; use crate::fault_tolerance::propagator::dag::DagSpacetimeLocation; use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Pauli}; @@ -378,12 +379,19 @@ impl<'a> DemBuilder<'a> { let flat = idx + 1; let p1 = flat / 4; let p2 = flat % 4; - self.noise.p2 - * weights.two_qubit_weight_for( + let pauli = pauli_pair_for_weight(p1, p2); + let weight = if self.noise.p2_replacement_approximation + == ReplacementBranchApproximation::BranchImpact + { + weights.post_gate_two_qubit_weight_for(&pauli) + } else { + weights.two_qubit_weight_for( loc1.gate_type, - &pauli_pair_for_weight(p1, p2), + &pauli, self.noise.p2_replacement_approximation, ) + }; + self.noise.p2 * weight }); } [per_channel_probability(self.noise.p2, 15); 15] @@ -841,6 +849,17 @@ impl<'a> DemBuilder<'a> { meas_to_observables, ); } + if self.noise.p2_replacement_approximation + == ReplacementBranchApproximation::BranchImpact + { + self.process_two_qubit_replacement_branch_impacts_source_tracked( + pair[0], + pair[1], + dem, + meas_to_detectors, + meas_to_observables, + ); + } } } } @@ -867,6 +886,39 @@ impl<'a> DemBuilder<'a> { } } + /// Processes starred two-qubit replacement branches as explicit branch impacts. + fn process_two_qubit_replacement_branch_impacts_source_tracked( + &self, + loc1: usize, + loc2: usize, + dem: &mut DetectorErrorModel, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, + ) { + let Some(weights) = &self.noise.p2_weights else { + return; + }; + let loc1_meta = &self.influence_map.locations[loc1]; + let branch_weights = weights.replacement_branch_impact_weights(loc1_meta.gate_type); + if branch_weights.is_empty() { + return; + } + + let effects = + self.two_qubit_effect_table(loc1, loc2, meas_to_detectors, meas_to_observables); + let loc2_meta = &self.influence_map.locations[loc2]; + + for (label, relative_weight) in branch_weights { + let Some((p1, p2)) = two_qubit_label_to_pauli_indices(&label) else { + continue; + }; + let prob = self.noise.p2 * relative_weight; + self.add_two_qubit_pauli_contribution( + loc1, loc2, p1, p2, prob, &effects, loc1_meta, loc2_meta, dem, + ); + } + } + /// Processes a measurement fault with source tracking. fn process_meas_fault_source_tracked( &self, @@ -980,19 +1032,47 @@ impl<'a> DemBuilder<'a> { let loc1_meta = &self.influence_map.locations[loc1]; let loc2_meta = &self.influence_map.locations[loc2]; - // Compute base effects for X and Z on each qubit + let effects = + self.two_qubit_effect_table(loc1, loc2, meas_to_detectors, meas_to_observables); + + // Process all 15 non-trivial Pauli combinations + for p1 in 0u8..4 { + for p2 in 0u8..4 { + if p1 == 0 && p2 == 0 { + continue; // Skip II + } + + // 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 { + continue; + } + self.add_two_qubit_pauli_contribution( + loc1, loc2, p1, p2, prob, &effects, loc1_meta, loc2_meta, dem, + ); + } + } + } + + fn two_qubit_effect_table( + &self, + loc1: usize, + loc2: usize, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, + ) -> [[FaultMechanism; 4]; 4] { let x1 = self.compute_mechanism(loc1, Pauli::X, meas_to_detectors, meas_to_observables); let z1 = self.compute_mechanism(loc1, Pauli::Z, meas_to_detectors, meas_to_observables); let x2 = self.compute_mechanism(loc2, Pauli::X, meas_to_detectors, meas_to_observables); let z2 = self.compute_mechanism(loc2, Pauli::Z, meas_to_detectors, meas_to_observables); - // Build effect table for all 16 Pauli combinations let get_single_effect = |p: u8, x: &FaultMechanism, z: &FaultMechanism| -> FaultMechanism { match p { - 0 => FaultMechanism::new(), // I - 1 => x.clone(), // X - 2 => x.xor(z), // Y = X XOR Z - 3 => z.clone(), // Z + 0 => FaultMechanism::new(), + 1 => x.clone(), + 2 => x.xor(z), + 3 => z.clone(), _ => unreachable!("Pauli index must be 0-3"), } }; @@ -1005,81 +1085,66 @@ impl<'a> DemBuilder<'a> { effects[p1 as usize][p2 as usize] = e1.xor(&e2); } } + effects + } - // Process all 15 non-trivial Pauli combinations - for p1 in 0u8..4 { - for p2 in 0u8..4 { - if p1 == 0 && p2 == 0 { - continue; // Skip II - } - - let effect = &effects[p1 as usize][p2 as usize]; - if effect.is_empty() { - 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 { - continue; - } + #[allow(clippy::too_many_arguments)] + fn add_two_qubit_pauli_contribution( + &self, + loc1: usize, + loc2: usize, + p1: u8, + p2: u8, + prob: f64, + effects: &[[FaultMechanism; 4]; 4], + loc1_meta: &DagSpacetimeLocation, + loc2_meta: &DagSpacetimeLocation, + dem: &mut DetectorErrorModel, + ) { + let effect = &effects[p1 as usize][p2 as usize]; + if effect.is_empty() { + return; + } - // Get component effects (P1I and IP2) - let e1 = &effects[p1 as usize][0]; // P1 on qubit 1, I on qubit 2 - let e2 = &effects[0][p2 as usize]; // I on qubit 1, P2 on qubit 2 - - // Check if this is a "graphlike decomposable" source: - // - Combined effect has exactly 2 detectors and no dem_outputs - // - Both component effects are non-empty - // - Both component effects are graphlike (≤2 detectors) - let graphlike_decomposable = effect.num_detectors() == 2 - && effect.dem_outputs.is_empty() - && !e1.is_empty() - && !e2.is_empty() - && e1.num_detectors() <= 2 - && e2.num_detectors() <= 2; - if graphlike_decomposable { - dem.mark_graphlike_decomposable(effect.detectors[0], effect.detectors[1]); - } + let e1 = &effects[p1 as usize][0]; + let e2 = &effects[0][p2 as usize]; + + let graphlike_decomposable = effect.num_detectors() == 2 + && effect.dem_outputs.is_empty() + && !e1.is_empty() + && !e2.is_empty() + && e1.num_detectors() <= 2 + && e2.num_detectors() <= 2; + if graphlike_decomposable { + dem.mark_graphlike_decomposable(effect.detectors[0], effect.detectors[1]); + } - // Check for intra-channel decomposition (Y-containing cases) - if let Some((a1, a2, b1, b2)) = get_y_decomposition(p1, p2) { - // Y-containing channels can be decomposable if both their X and Z - // components have non-empty, distinct effects. Otherwise they - // produce the effect directly without decomposition. - let e_a = &effects[a1 as usize][a2 as usize]; - let e_b = &effects[b1 as usize][b2 as usize]; - - // Only truly decomposable if both components are non-empty and different. - // add_y_decomposed_contribution handles routing to Direct when appropriate. - dem.add_y_decomposed_contribution_with_source( - e_a, - e_b, - prob, - SourceMetadata::new( - &[loc1, loc2], - &[Pauli::from_u8(p1), Pauli::from_u8(p2)], - &[loc1_meta.gate_type, loc2_meta.gate_type], - &[loc1_meta.before, loc2_meta.before], - ), - ); - } else { - // Non-Y channel (XI, IX, ZI, IZ, XX, XZ, ZX, ZZ) - // These are always direct sources. - dem.add_direct_contribution_with_source_components( - effect.clone(), - prob, - SourceMetadata::new( - &[loc1, loc2], - &[Pauli::from_u8(p1), Pauli::from_u8(p2)], - &[loc1_meta.gate_type, loc2_meta.gate_type], - &[loc1_meta.before, loc2_meta.before], - ), - DirectSourceComponents::new(e1, e2), - ); - } - } + if let Some((a1, a2, b1, b2)) = get_y_decomposition(p1, p2) { + let e_a = &effects[a1 as usize][a2 as usize]; + let e_b = &effects[b1 as usize][b2 as usize]; + dem.add_y_decomposed_contribution_with_source( + e_a, + e_b, + prob, + SourceMetadata::new( + &[loc1, loc2], + &[Pauli::from_u8(p1), Pauli::from_u8(p2)], + &[loc1_meta.gate_type, loc2_meta.gate_type], + &[loc1_meta.before, loc2_meta.before], + ), + ); + } else { + dem.add_direct_contribution_with_source_components( + effect.clone(), + prob, + SourceMetadata::new( + &[loc1, loc2], + &[Pauli::from_u8(p1), Pauli::from_u8(p2)], + &[loc1_meta.gate_type, loc2_meta.gate_type], + &[loc1_meta.before, loc2_meta.before], + ), + DirectSourceComponents::new(e1, e2), + ); } } @@ -1304,6 +1369,23 @@ fn pauli_pair_for_weight(p1: usize, p2: usize) -> pecos_core::PauliString { pecos_core::PauliString::with_phase_and_paulis(pecos_core::QuarterPhase::PlusOne, paulis) } +fn two_qubit_label_to_pauli_indices(label: &str) -> Option<(u8, u8)> { + let mut chars = label.chars(); + let p1 = pauli_label_to_index(chars.next()?)?; + let p2 = pauli_label_to_index(chars.next()?)?; + chars.next().is_none().then_some((p1, p2)) +} + +fn pauli_label_to_index(label: char) -> Option { + match label { + 'I' => Some(0), + 'X' => Some(1), + 'Y' => Some(2), + 'Z' => Some(3), + _ => None, + } +} + /// Computes the per-error probability for independent error channels. /// /// For a depolarizing channel with total error probability `p` split among `n` 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 4b11d4559..ef72c41c9 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 @@ -71,7 +71,10 @@ use smallvec::SmallVec; use std::collections::{BTreeMap, BTreeSet}; use wide::u64x4; -use super::types::{NoiseConfig, PerGateTypeNoise, combine_probabilities}; +use super::types::{ + NoiseConfig, PauliWeights, PerGateTypeNoise, ReplacementBranchApproximation, + combine_probabilities, +}; // ============================================================================ // DEM Mechanism (used during building) @@ -461,7 +464,18 @@ impl SamplingEngine { // Custom per-Pauli weights: p * weight_for(pauli) events .iter() - .map(|event| p * weights.weight_for(&event.pauli)) + .map(|event| { + let weight = if n_qubits == 2 { + weights.two_qubit_weight_for( + loc.gate_type, + &event.pauli, + noise.p2_replacement_approximation, + ) + } else { + weights.weight_for(&event.pauli) + }; + p * weight + }) .collect() } else { // Default uniform: p / num_events @@ -1836,6 +1850,9 @@ pub(crate) struct SamplingEngineBuilder<'a> { p2: f64, p_meas: f64, p_prep: f64, + p1_weights: Option, + p2_weights: Option, + p2_replacement_approximation: ReplacementBranchApproximation, idle_noise: Option, detector_records: Vec>, observable_records: Vec>, @@ -1859,6 +1876,9 @@ impl<'a> SamplingEngineBuilder<'a> { p2: 0.01, p_meas: 0.01, p_prep: 0.01, + p1_weights: None, + p2_weights: None, + p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_noise: None, per_gate: None, detector_records: Vec::new(), @@ -1875,6 +1895,24 @@ impl<'a> SamplingEngineBuilder<'a> { self.p2 = p2; self.p_meas = p_meas; self.p_prep = p_prep; + self.p1_weights = None; + self.p2_weights = None; + self.p2_replacement_approximation = ReplacementBranchApproximation::default(); + self.idle_noise = None; + self + } + + /// Set the full noise model, including biased Pauli weights. + #[must_use] + pub fn with_noise_config(mut self, noise: NoiseConfig) -> Self { + self.p1 = noise.p1; + self.p2 = noise.p2; + self.p_meas = noise.p_meas; + self.p_prep = noise.p_prep; + self.p1_weights = noise.p1_weights.clone(); + self.p2_weights = noise.p2_weights.clone(); + self.p2_replacement_approximation = noise.p2_replacement_approximation; + self.idle_noise = noise.uses_dedicated_idle_noise().then_some(noise); self } @@ -2277,6 +2315,14 @@ impl<'a> SamplingEngineBuilder<'a> { ] } } else { + if let Some(weights) = &self.p1_weights { + use pecos_core::pauli::{X, Y, Z}; + return [ + self.p1 * weights.weight_for(&X(0)), + self.p1 * weights.weight_for(&Y(0)), + self.p1 * weights.weight_for(&Z(0)), + ]; + } [self.p1 / 3.0; 3] } } @@ -2324,6 +2370,19 @@ impl<'a> SamplingEngineBuilder<'a> { std::array::from_fn(|i| pg.rate_2q(gate, i)) } } else { + if let Some(weights) = &self.p2_weights { + return std::array::from_fn(|idx| { + let flat = idx + 1; + let p1 = flat / 4; + let p2 = flat % 4; + self.p2 + * weights.two_qubit_weight_for( + gate, + &pauli_pair_for_weight(p1, p2), + self.p2_replacement_approximation, + ) + }); + } [self.p2 / 15.0; 15] } } @@ -2532,6 +2591,26 @@ where } } +fn pauli_pair_for_weight(p1: usize, p2: usize) -> pecos_core::PauliString { + let mut paulis = Vec::new(); + let pauli_from_index = |idx| match idx { + 0 => pecos_core::Pauli::I, + 1 => pecos_core::Pauli::X, + 2 => pecos_core::Pauli::Y, + 3 => pecos_core::Pauli::Z, + _ => unreachable!("Pauli index must be 0-3"), + }; + let pa1 = pauli_from_index(p1); + let pa2 = pauli_from_index(p2); + if pa1 != pecos_core::Pauli::I { + paulis.push((pa1, pecos_core::QubitId::from(0usize))); + } + if pa2 != pecos_core::Pauli::I { + paulis.push((pa2, pecos_core::QubitId::from(1usize))); + } + pecos_core::PauliString::with_phase_and_paulis(pecos_core::QuarterPhase::PlusOne, paulis) +} + /// XORs two [`DemMechanism`]s (symmetric difference of detectors and standard observables). fn xor_mechanisms(a: Option<&DemMechanism>, b: Option<&DemMechanism>) -> DemMechanism { match (a, b) { 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 f0e1516b7..6bd954490 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -1351,19 +1351,12 @@ impl<'a> DemSamplerBuilder<'a> { } let mut builder = SamplingEngineBuilder::new(self.influence_map) - .with_noise( - self.noise.p1, - self.noise.p2, - self.noise.p_meas, - self.noise.p_prep, - ) + .with_noise_config(self.noise.clone()) .with_detector_records(detector_records) .with_observable_records(observable_records.clone()); if let Some(per_gate) = self.per_gate { builder = builder.with_per_gate_noise(per_gate); - } else if self.noise.uses_dedicated_idle_noise() { - builder = builder.with_idle_noise_config(self.noise.clone()); } if let Some(order) = self.measurement_order { 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 664e381d9..c9ed0a8a0 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -1427,6 +1427,10 @@ pub enum ReplacementBranchApproximation { /// gate's dagger. This is the default approximation for starred entries. #[default] PauliTwirlOmittedGate, + /// Evaluate replacement branches as their own fault branches after + /// convolving with the omitted gate's Pauli twirl, instead of folding them + /// into the ordinary post-gate two-qubit rate vector. + BranchImpact, } impl PauliWeights { @@ -1542,13 +1546,7 @@ impl PauliWeights { let Ok(query_label) = two_qubit_pauli_label(pauli) else { return 0.0; }; - let direct = self - .entries - .iter() - .filter_map(|(ps, weight)| { - (two_qubit_pauli_label(ps).ok()?.as_str() == query_label).then_some(*weight) - }) - .sum::(); + let direct = self.post_gate_two_qubit_weight_for(pauli); if approximation == ReplacementBranchApproximation::IgnoreGateRemoval { return direct @@ -1561,27 +1559,53 @@ impl PauliWeights { .sum::(); } - let Some(twirl) = omitted_two_qubit_gate_pauli_twirl(gate_type) else { - return direct; - }; direct + self - .replacement_entries - .iter() - .filter_map(|(ps, weight)| { - let replacement_label = two_qubit_pauli_label(ps).ok()?; - Some( - twirl - .iter() - .filter_map(|(twirl_label, twirl_weight)| { - (multiply_two_qubit_pauli_labels(&replacement_label, twirl_label) - == query_label) - .then_some(weight * twirl_weight) - }) - .sum::(), - ) - }) - .sum::() + .replacement_branch_impact_weights(gate_type) + .get(query_label.as_str()) + .copied() + .unwrap_or(0.0) + } + + /// Look up only the plain post-gate two-qubit Pauli weight. + #[must_use] + pub fn post_gate_two_qubit_weight_for(&self, pauli: &pecos_core::PauliString) -> f64 { + let Ok(query_label) = two_qubit_pauli_label(pauli) else { + return 0.0; + }; + self.entries + .iter() + .filter_map(|(ps, weight)| { + (two_qubit_pauli_label(ps).ok()?.as_str() == query_label).then_some(*weight) + }) + .sum::() + } + + /// Effective non-identity branch-impact weights from replacement entries. + /// + /// Each starred replacement entry is convolved with the Pauli twirl of the + /// omitted ideal gate. Identity effects are omitted because DEM builders + /// only emit branches that flip detectors or logical observables. + #[must_use] + pub fn replacement_branch_impact_weights(&self, gate_type: GateType) -> BTreeMap { + let Some(twirl) = omitted_two_qubit_gate_pauli_twirl(gate_type) else { + return BTreeMap::new(); + }; + let mut weights = BTreeMap::new(); + for (ps, replacement_weight) in &self.replacement_entries { + let Ok(replacement_label) = two_qubit_pauli_label(ps) else { + continue; + }; + for (twirl_label, twirl_weight) in &twirl { + let effective_label = + multiply_two_qubit_pauli_labels(&replacement_label, twirl_label); + if effective_label == "II" { + continue; + } + *weights.entry(effective_label).or_insert(0.0) += replacement_weight * twirl_weight; + } + } + weights } /// Get all entries as `(PauliString, weight)` pairs. @@ -5736,6 +5760,15 @@ mod tests { .abs() < 1e-12 ); + assert!( + (weights.two_qubit_weight_for( + GateType::SZZ, + &(Y(0) & Y(1)), + ReplacementBranchApproximation::BranchImpact, + ) - 0.75 * 0.5) + .abs() + < 1e-12 + ); assert!( (weights.two_qubit_weight_for( GateType::SZZ, @@ -5765,6 +5798,10 @@ mod tests { .abs() < 1e-12 ); + assert_eq!( + replacement_omits_only.replacement_branch_impact_weights(GateType::SZZ), + BTreeMap::from([("ZZ".to_string(), 0.5)]) + ); let cx_replacement_identity = PauliWeights::with_replacement([], [(Z(0) & X(1), 1.0)]); assert!( diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 1c8f80aad..9c8555ec1 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -154,11 +154,14 @@ fn parse_replacement_approximation( "pauli_twirl_omitted_gate" | "pauli_twirl" | "twirl" => { Ok(ReplacementBranchApproximation::PauliTwirlOmittedGate) } + "branch_impact" | "replacement_branch_impact" | "impact" => { + Ok(ReplacementBranchApproximation::BranchImpact) + } "ignore_gate_removal" | "ignore_removal" | "post_gate" | "postgate" => { Ok(ReplacementBranchApproximation::IgnoreGateRemoval) } _ => Err(pyo3::exceptions::PyValueError::new_err( - "p2_replacement_approximation must be 'pauli_twirl_omitted_gate' or 'ignore_gate_removal'", + "p2_replacement_approximation must be 'pauli_twirl_omitted_gate', 'branch_impact', or 'ignore_gate_removal'", )), } } diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 12087cf76..251ea8109 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -152,8 +152,10 @@ def from_guppy( remains the total two-qubit error rate. p2_replacement_approximation: Approximation used for starred replacement labels. ``"pauli_twirl_omitted_gate"`` convolves - with the omitted two-qubit gate's Pauli twirl; ``"ignore_gate_removal"`` - treats starred entries like plain post-gate Pauli entries. + with the omitted two-qubit gate's Pauli twirl; + ``"branch_impact"`` evaluates starred entries as replacement + branch impacts; ``"ignore_gate_removal"`` treats starred + 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. diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 5d14d8933..4a411242d 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -99,8 +99,10 @@ class NoiseModel: error rate. p2_replacement_approximation: Approximation used for starred replacement labels. ``"pauli_twirl_omitted_gate"`` convolves with - the omitted two-qubit gate's Pauli twirl; ``"ignore_gate_removal"`` - treats starred entries like plain post-gate Pauli entries. + the omitted two-qubit gate's Pauli twirl; ``"branch_impact"`` + evaluates starred entries as replacement branch impacts; + ``"ignore_gate_removal"`` treats starred entries like plain + post-gate Pauli entries. p_meas: Measurement error rate. p_prep: Initialization error rate. p_idle: Idle noise rate per time unit (uniform depolarizing). From 1c286156a426e75136cff77e418c6b28720085cc Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 31 May 2026 12:24:24 -0600 Subject: [PATCH 018/150] Default LogicalSubgraphDecoder inner to pecos_uf:bp (exact-MWPM LER, native, ~13-26% lower than pecos_uf:fast) --- .../src/fault_tolerance_bindings.rs | 9 ++++-- ...test_logical_subgraph_region_comparison.py | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index c52592a2e..39d877bcc 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4435,8 +4435,13 @@ pub struct PyLogicalSubgraphDecoder { #[pymethods] impl PyLogicalSubgraphDecoder { + // Default inner is `pecos_uf:bp` (native union-find + belief propagation): it + // reaches exact-MWPM LER -- ~13-26% lower than `pecos_uf:fast`, the gap + // growing with code distance -- at ~3x the speed of pymatching, with no + // external decoder dependency. See + // pecos-docs/design/logical-subgraph-backprop-region-builder.md. #[new] - #[pyo3(signature = (dem, stab_coords, inner_decoder="pecos_uf:fast", max_time_radius=None))] + #[pyo3(signature = (dem, stab_coords, inner_decoder="pecos_uf:bp", max_time_radius=None))] fn new( dem: &str, stab_coords: Vec>, @@ -4487,7 +4492,7 @@ impl PyLogicalSubgraphDecoder { /// construction (e.g. the paper's back-propagation / detecting-region set) /// and decode with the same machinery for direct comparison. #[staticmethod] - #[pyo3(signature = (dem, membership, inner_decoder="pecos_uf:fast"))] + #[pyo3(signature = (dem, membership, inner_decoder="pecos_uf:bp"))] fn from_membership( dem: &str, membership: Vec>, 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 c10468e87..e2a7edab2 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 @@ -212,5 +212,34 @@ def test_coordinate_beats_backprop_seeded_groupfill(): ) +def test_bp_inner_is_lower_ler_default(): + """The default inner decoder is the native UF+BP (`pecos_uf:bp`), which + reaches exact-MWPM accuracy and decodes with lower LER than the older + `pecos_uf:fast` union-find. The gap grows with distance; assert it at d=3.""" + b = _cx_circuit() + dem = b.build_dem(p1=0.002, p2=0.002, p_meas=0.002) + sc = b.stab_coords() + + n = 40000 + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=4) + + default = LogicalSubgraphDecoder(dem, sc) # no inner -> the new default + bp = LogicalSubgraphDecoder(dem, sc, "pecos_uf:bp") + fast = LogicalSubgraphDecoder(dem, sc, "pecos_uf:fast") + exact = LogicalSubgraphDecoder(dem, sc, "pymatching") + + default_ler = default.decode_count(batch) / n + bp_ler = bp.decode_count(batch) / n + fast_ler = fast.decode_count(batch) / n + exact_ler = exact.decode_count(batch) / n + + # The default IS pecos_uf:bp. + assert default_ler == bp_ler + # BP inner beats the old fast default... + assert bp_ler < fast_ler, f"bp={bp_ler:.5f} fast={fast_ler:.5f}" + # ...and matches exact MWPM (native UF+BP reaches the optimum on graphlike subgraphs). + assert abs(bp_ler - exact_ler) <= 0.0005, f"bp={bp_ler:.5f} exact={exact_ler:.5f}" + + if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) From 51acac95c4a54ee690b3309b5e66b392d555c7ed Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 12:49:00 -0600 Subject: [PATCH 019/150] Reserve exact replacement branch replay --- .../src/fault_tolerance/dem_builder.rs | 4 +- .../fault_tolerance/dem_builder/builder.rs | 117 +++++++-- .../dem_builder/dem_sampler.rs | 10 + .../src/fault_tolerance/dem_builder/types.rs | 231 ++++++++++++++---- .../src/fault_tolerance_bindings.rs | 9 +- python/quantum-pecos/src/pecos/qec/dem.py | 4 +- .../src/pecos/qec/surface/decode.py | 3 + 7 files changed, 307 insertions(+), 71 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs index 82abce66a..f96ef6633 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs @@ -101,6 +101,6 @@ pub use types::{ DirectSourceFamily, FaultContribution, FaultMechanism, FaultSourceType, MeasurementMechanism, MeasurementNoiseModel, NoiseConfig, PAULI_1Q_ORDER, PAULI_2Q_ORDER, PauliProbs, PauliWeights, PecosDemMetadataError, PerGateTypeNoise, ReplacementBranchApproximation, - TwoDetectorDirectRenderPolicy, combine_probabilities, omitted_two_qubit_gate_pauli_twirl, - record_offset_to_absolute_index, + 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 6f94ccfe7..575fe7fde 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -16,8 +16,8 @@ //! influence maps and detector/DEM-output metadata. use super::types::{ - DemOutput, DetectorDef, DetectorErrorModel, DirectSourceComponents, FaultMechanism, - NoiseConfig, PerGateTypeNoise, ReplacementBranchApproximation, SourceMetadata, + DemOutput, DetectorDef, DetectorErrorModel, DirectSourceComponents, DirectSourceFamily, + FaultMechanism, NoiseConfig, PerGateTypeNoise, ReplacementBranchApproximation, SourceMetadata, record_offset_to_absolute_index, }; use crate::fault_tolerance::propagator::dag::DagSpacetimeLocation; @@ -641,6 +641,7 @@ impl<'a> DemBuilder<'a> { pub fn try_build(&self) -> Result { self.validate_measurement_count()?; self.validate_metadata_refs()?; + self.validate_replacement_branch_approximation()?; Ok(self.build()) } @@ -655,6 +656,8 @@ impl<'a> DemBuilder<'a> { /// circuit-derived metadata must use [`Self::try_build`] instead. #[must_use] pub fn build(&self) -> DetectorErrorModel { + self.validate_replacement_branch_approximation() + .expect("invalid DEM replacement branch approximation"); let num_influence_dem_outputs = self .num_influence_dem_outputs() .max(self.influence_map.dem_output_metadata.len()); @@ -721,6 +724,24 @@ impl<'a> DemBuilder<'a> { dem } + fn validate_replacement_branch_approximation(&self) -> Result<(), DemBuilderError> { + let has_replacement_branches = self + .noise + .p2_weights + .as_ref() + .is_some_and(|weights| weights.has_replacement_entries()); + if self.noise.p2_replacement_approximation + == ReplacementBranchApproximation::ExactBranchReplay + && has_replacement_branches + { + return Err(DemBuilderError::ConfigurationError( + "exact_branch_replay for starred p2 replacement branches requires a circuit-aware exact branch provider; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations" + .to_string(), + )); + } + Ok(()) + } + fn num_influence_dem_outputs(&self) -> usize { self.influence_map .influences @@ -899,8 +920,8 @@ impl<'a> DemBuilder<'a> { return; }; let loc1_meta = &self.influence_map.locations[loc1]; - let branch_weights = weights.replacement_branch_impact_weights(loc1_meta.gate_type); - if branch_weights.is_empty() { + let branch_impacts = weights.replacement_branch_impacts(loc1_meta.gate_type); + if branch_impacts.is_empty() { return; } @@ -908,13 +929,22 @@ impl<'a> DemBuilder<'a> { self.two_qubit_effect_table(loc1, loc2, meas_to_detectors, meas_to_observables); let loc2_meta = &self.influence_map.locations[loc2]; - for (label, relative_weight) in branch_weights { - let Some((p1, p2)) = two_qubit_label_to_pauli_indices(&label) else { + for impact in branch_impacts { + let Some((p1, p2)) = two_qubit_label_to_pauli_indices(&impact.pauli_label) else { continue; }; - let prob = self.noise.p2 * relative_weight; + let prob = self.noise.p2 * impact.relative_probability; self.add_two_qubit_pauli_contribution( - loc1, loc2, p1, p2, prob, &effects, loc1_meta, loc2_meta, dem, + loc1, + loc2, + p1, + p2, + prob, + &effects, + loc1_meta, + loc2_meta, + dem, + Some(DirectSourceFamily::TwoLocationReplacementBranchImpact), ); } } @@ -1049,7 +1079,7 @@ impl<'a> DemBuilder<'a> { continue; } self.add_two_qubit_pauli_contribution( - loc1, loc2, p1, p2, prob, &effects, loc1_meta, loc2_meta, dem, + loc1, loc2, p1, p2, prob, &effects, loc1_meta, loc2_meta, dem, None, ); } } @@ -1100,6 +1130,7 @@ impl<'a> DemBuilder<'a> { loc1_meta: &DagSpacetimeLocation, loc2_meta: &DagSpacetimeLocation, dem: &mut DetectorErrorModel, + direct_source_family: Option, ) { let effect = &effects[p1 as usize][p2 as usize]; if effect.is_empty() { @@ -1119,30 +1150,40 @@ impl<'a> DemBuilder<'a> { dem.mark_graphlike_decomposable(effect.detectors[0], effect.detectors[1]); } + let source_locations = [loc1, loc2]; + let source_paulis = [Pauli::from_u8(p1), Pauli::from_u8(p2)]; + let source_gate_types = [loc1_meta.gate_type, loc2_meta.gate_type]; + let source_before_flags = [loc1_meta.before, loc2_meta.before]; + if let Some((a1, a2, b1, b2)) = get_y_decomposition(p1, p2) { let e_a = &effects[a1 as usize][a2 as usize]; let e_b = &effects[b1 as usize][b2 as usize]; - dem.add_y_decomposed_contribution_with_source( - e_a, - e_b, - prob, - SourceMetadata::new( - &[loc1, loc2], - &[Pauli::from_u8(p1), Pauli::from_u8(p2)], - &[loc1_meta.gate_type, loc2_meta.gate_type], - &[loc1_meta.before, loc2_meta.before], - ), + let mut source = SourceMetadata::new( + &source_locations, + &source_paulis, + &source_gate_types, + &source_before_flags, ); + if direct_source_family.is_some() { + source = source.with_replacement_branch(); + } + dem.add_y_decomposed_contribution_with_source(e_a, e_b, prob, source); } else { + let mut source = SourceMetadata::new( + &source_locations, + &source_paulis, + &source_gate_types, + &source_before_flags, + ); + if let Some(family) = direct_source_family { + source = source + .with_direct_source_family(family) + .with_replacement_branch(); + } dem.add_direct_contribution_with_source_components( effect.clone(), prob, - SourceMetadata::new( - &[loc1, loc2], - &[Pauli::from_u8(p1), Pauli::from_u8(p2)], - &[loc1_meta.gate_type, loc2_meta.gate_type], - &[loc1_meta.before, loc2_meta.before], - ), + source, DirectSourceComponents::new(e1, e2), ); } @@ -2129,12 +2170,15 @@ pub fn resolve_result_tags( pub enum DemBuilderError { /// JSON parsing error. ParseError(String), + /// Invalid DEM builder configuration. + ConfigurationError(String), } impl std::fmt::Display for DemBuilderError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::ParseError(msg) => write!(f, "DEM builder parse error: {msg}"), + Self::ConfigurationError(msg) => write!(f, "DEM builder configuration error: {msg}"), } } } @@ -2751,6 +2795,29 @@ mod tests { ); } + #[test] + fn test_try_build_rejects_exact_branch_replay_without_provider() { + use crate::fault_tolerance::dem_builder::PauliWeights; + use pecos_core::pauli::Z; + + let influence_map = DagFaultInfluenceMap::with_capacity(0); + let noise = NoiseConfig::new(0.0, 0.01, 0.0, 0.0) + .set_p2_weights(PauliWeights::with_replacement([], [(Z(0) & Z(1), 1.0)])) + .set_p2_replacement_approximation(ReplacementBranchApproximation::ExactBranchReplay); + + let err = DemBuilder::new(&influence_map) + .with_noise_config(noise) + .try_build() + .expect_err("exact branch replay must fail loud without an exact provider"); + + assert!(matches!(err, DemBuilderError::ConfigurationError(_))); + assert!( + err.to_string() + .contains("circuit-aware exact branch provider"), + "unexpected error: {err}", + ); + } + #[test] fn test_parse_accepts_dem_label_id_form() { let det = parse_detectors_json(r#"[{"id": "D0", "records": [-1]}]"#).unwrap(); 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 ef72c41c9..4fe4b1de7 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 @@ -2000,6 +2000,16 @@ impl<'a> SamplingEngineBuilder<'a> { /// Build the [`SamplingEngine`]. #[must_use] pub fn build(self) -> SamplingEngine { + if self.p2_replacement_approximation == ReplacementBranchApproximation::ExactBranchReplay + && self + .p2_weights + .as_ref() + .is_some_and(|weights| weights.has_replacement_entries()) + { + panic!( + "exact_branch_replay for starred p2 replacement branches requires a circuit-aware exact branch provider; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations" + ); + } let num_detectors = self.detector_records.len(); let influence_observable_ids = self.influence_map.observable_ids(); let num_influence_observables = self.influence_map.num_observables(); 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 c9ed0a8a0..2aea930f8 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -126,6 +126,9 @@ pub enum DirectSourceFamily { /// Two-location direct source where exactly one component is non-empty. TwoLocationOneSidedComponent, + /// Two-location direct source produced by a replacement-branch projection. + TwoLocationReplacementBranchImpact, + /// Fallback for other direct-source shapes. Other, } @@ -168,6 +171,10 @@ pub struct FaultContribution { /// currently recorded for direct two-qubit channel sources to aid decomposition /// analysis without changing emitted DEM behavior. pub direct_component_effects: Option<(FaultMechanism, FaultMechanism)>, + + /// True when this contribution came from a replacement branch rather than + /// ordinary post-gate Pauli noise. + pub replacement_branch: bool, } #[derive(Debug, Clone, Copy)] @@ -176,6 +183,8 @@ pub(crate) struct SourceMetadata<'a, Index> { paulis: &'a [Pauli], gate_types: &'a [GateType], before_flags: &'a [bool], + direct_source_family_override: Option, + replacement_branch: bool, } impl<'a, Index> SourceMetadata<'a, Index> { @@ -190,8 +199,20 @@ impl<'a, Index> SourceMetadata<'a, Index> { paulis, gate_types, before_flags, + direct_source_family_override: None, + replacement_branch: false, } } + + pub(crate) const fn with_direct_source_family(mut self, family: DirectSourceFamily) -> Self { + self.direct_source_family_override = Some(family); + self + } + + pub(crate) const fn with_replacement_branch(mut self) -> Self { + self.replacement_branch = true; + self + } } #[derive(Debug, Clone, Copy)] @@ -254,6 +275,7 @@ impl FaultContribution { source_before_flags: SmallVec::new(), direct_source_family: None, direct_component_effects: None, + replacement_branch: false, } } @@ -275,12 +297,11 @@ impl FaultContribution { paulis: source.paulis.iter().copied().collect(), source_gate_types: source.gate_types.iter().copied().collect(), source_before_flags: source.before_flags.iter().copied().collect(), - direct_source_family: Self::classify_direct_source_family( - source.location_indices, - source.paulis, - None, - ), + direct_source_family: source.direct_source_family_override.or_else(|| { + Self::classify_direct_source_family(source.location_indices, source.paulis, None) + }), direct_component_effects: None, + replacement_branch: source.replacement_branch, } } @@ -311,12 +332,15 @@ impl FaultContribution { paulis: source.paulis.iter().copied().collect(), source_gate_types: source.gate_types.iter().copied().collect(), source_before_flags: source.before_flags.iter().copied().collect(), - direct_source_family: Self::classify_direct_source_family( - source.location_indices, - source.paulis, - Some((components.first, components.second)), - ), + direct_source_family: source.direct_source_family_override.or_else(|| { + Self::classify_direct_source_family( + source.location_indices, + source.paulis, + Some((components.first, components.second)), + ) + }), direct_component_effects: Some((components.first.clone(), components.second.clone())), + replacement_branch: source.replacement_branch, } } @@ -346,6 +370,7 @@ impl FaultContribution { source_before_flags: SmallVec::new(), direct_source_family: None, direct_component_effects: None, + replacement_branch: false, } } @@ -376,6 +401,7 @@ impl FaultContribution { source_before_flags: source.before_flags.iter().copied().collect(), direct_source_family: None, direct_component_effects: None, + replacement_branch: source.replacement_branch, } } @@ -1164,6 +1190,21 @@ fn convert_location_indices(location_indices: &[usize]) -> SmallVec<[u32; 2]> { .collect() } +fn converted_source_metadata<'a>( + source: SourceMetadata<'a, usize>, + location_indices: &'a [u32], +) -> SourceMetadata<'a, u32> { + let mut converted = SourceMetadata::new( + location_indices, + source.paulis, + source.gate_types, + source.before_flags, + ); + converted.direct_source_family_override = source.direct_source_family_override; + converted.replacement_branch = source.replacement_branch; + converted +} + /// Converts a DEM measurement-record offset to an absolute measurement index. /// /// Negative offsets count backward from the end of the measurement record @@ -1427,10 +1468,42 @@ pub enum ReplacementBranchApproximation { /// gate's dagger. This is the default approximation for starred entries. #[default] PauliTwirlOmittedGate, - /// Evaluate replacement branches as their own fault branches after - /// convolving with the omitted gate's Pauli twirl, instead of folding them - /// into the ordinary post-gate two-qubit rate vector. + /// Evaluate Pauli-projected replacement branches as their own contribution + /// streams after convolving with the omitted gate's Pauli twirl, instead of + /// folding them into the ordinary post-gate two-qubit rate vector. + /// + /// This preserves replacement-branch provenance in contribution metadata, + /// but it is still a Pauli projection rather than exact non-Pauli branch + /// replay. BranchImpact, + /// Request exact replay of replacement branches against the circuit. + /// + /// This mode is intentionally fail-loud until a circuit-aware replay + /// provider is attached. A replacement branch can be a non-Pauli Clifford + /// effect in the ideal-circuit frame, which standard DEM rows cannot always + /// represent as one deterministic Pauli-like event. + ExactBranchReplay, +} + +/// A single Pauli-projected impact term produced by a replacement branch. +/// +/// This is intentionally an intermediate representation rather than a final +/// DEM contribution. It preserves the expanded branch term until the builder +/// can evaluate the term against detector/observable metadata. Today the +/// projection comes from the Pauli twirl of the omitted two-qubit Clifford +/// gate. A future exact replay implementation should attach the same +/// replacement-branch contribution metadata, but may need to bypass this +/// Pauli-projected shape when the branch is not representable as a Pauli fault. +#[derive(Debug, Clone, PartialEq)] +pub struct ReplacementBranchImpact { + /// Replacement branch Pauli label before omitted-gate projection. + pub replacement_pauli_label: String, + /// Pauli-twirl term for the omitted ideal gate. + pub omitted_gate_twirl_label: String, + /// Non-identity two-qubit Pauli label (`IX` through `ZZ`) to evaluate. + pub pauli_label: String, + /// Relative probability mass for this projected branch term. + pub relative_probability: f64, } impl PauliWeights { @@ -1561,10 +1634,11 @@ impl PauliWeights { direct + self - .replacement_branch_impact_weights(gate_type) - .get(query_label.as_str()) - .copied() - .unwrap_or(0.0) + .replacement_branch_impacts(gate_type) + .into_iter() + .filter(|impact| impact.pauli_label == query_label) + .map(|impact| impact.relative_probability) + .sum::() } /// Look up only the plain post-gate two-qubit Pauli weight. @@ -1581,17 +1655,20 @@ impl PauliWeights { .sum::() } - /// Effective non-identity branch-impact weights from replacement entries. + /// Non-identity branch-impact terms from replacement entries. /// /// Each starred replacement entry is convolved with the Pauli twirl of the - /// omitted ideal gate. Identity effects are omitted because DEM builders - /// only emit branches that flip detectors or logical observables. + /// omitted ideal gate. The returned terms are deliberately not aggregated: + /// the builder should evaluate each branch term as a separate contribution + /// before the DEM's normal contribution grouping combines equivalent + /// detector/logical effects. Identity effects are omitted because DEM + /// builders only emit branches that flip detectors or logical observables. #[must_use] - pub fn replacement_branch_impact_weights(&self, gate_type: GateType) -> BTreeMap { + pub fn replacement_branch_impacts(&self, gate_type: GateType) -> Vec { let Some(twirl) = omitted_two_qubit_gate_pauli_twirl(gate_type) else { - return BTreeMap::new(); + return Vec::new(); }; - let mut weights = BTreeMap::new(); + let mut impacts = Vec::new(); for (ps, replacement_weight) in &self.replacement_entries { let Ok(replacement_label) = two_qubit_pauli_label(ps) else { continue; @@ -1602,9 +1679,27 @@ impl PauliWeights { if effective_label == "II" { continue; } - *weights.entry(effective_label).or_insert(0.0) += replacement_weight * twirl_weight; + impacts.push(ReplacementBranchImpact { + replacement_pauli_label: replacement_label.clone(), + omitted_gate_twirl_label: (*twirl_label).to_string(), + pauli_label: effective_label, + relative_probability: replacement_weight * twirl_weight, + }); } } + impacts + } + + /// Effective non-identity branch-impact weights from replacement entries. + /// + /// This is a convenience aggregation for callers that do not need source + /// branch identity. + #[must_use] + pub fn replacement_branch_impact_weights(&self, gate_type: GateType) -> BTreeMap { + let mut weights = BTreeMap::new(); + for impact in self.replacement_branch_impacts(gate_type) { + *weights.entry(impact.pauli_label).or_insert(0.0) += impact.relative_probability; + } weights } @@ -1619,6 +1714,12 @@ impl PauliWeights { pub fn replacement_entries(&self) -> &[(pecos_core::PauliString, f64)] { &self.replacement_entries } + + /// Whether this weight table contains starred replacement branches. + #[must_use] + pub fn has_replacement_entries(&self) -> bool { + !self.replacement_entries.is_empty() + } } impl From<[(pecos_core::PauliString, f64); N]> for PauliWeights { @@ -3475,6 +3576,9 @@ impl DetectorErrorModel { DirectSourceFamily::TwoLocationPlainY => "TwoLocationPlainY", DirectSourceFamily::TwoLocationComponent => "TwoLocationComponent", DirectSourceFamily::TwoLocationOneSidedComponent => "TwoLocationOneSidedComponent", + DirectSourceFamily::TwoLocationReplacementBranchImpact => { + "TwoLocationReplacementBranchImpact" + } DirectSourceFamily::Other => "Other", } } @@ -3620,12 +3724,7 @@ impl DetectorErrorModel { .push(FaultContribution::direct_with_source( effect, probability, - SourceMetadata::new( - &location_indices, - source.paulis, - source.gate_types, - source.before_flags, - ), + converted_source_metadata(source, &location_indices), )); } @@ -3646,12 +3745,7 @@ impl DetectorErrorModel { .push(FaultContribution::direct_with_source_components( effect, probability, - SourceMetadata::new( - &location_indices, - source.paulis, - source.gate_types, - source.before_flags, - ), + converted_source_metadata(source, &location_indices), components, )); } @@ -3731,12 +3825,7 @@ impl DetectorErrorModel { x_effect, z_effect, probability, - SourceMetadata::new( - &location_indices, - source.paulis, - source.gate_types, - source.before_flags, - ), + converted_source_metadata(source, &location_indices), )); } @@ -5682,6 +5771,55 @@ mod tests { assert_eq!(contribution.source_before_flags.as_slice(), &[false, false]); } + #[test] + fn test_replacement_branch_source_metadata_is_preserved() { + let effect = FaultMechanism::from_unsorted([7, 11], std::iter::empty()); + let first = effect.clone(); + let second = FaultMechanism::new(); + + let contribution = FaultContribution::direct_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[3, 4], + &[Pauli::Z, Pauli::I], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ) + .with_direct_source_family(DirectSourceFamily::TwoLocationReplacementBranchImpact) + .with_replacement_branch(), + DirectSourceComponents::new(&first, &second), + ); + + assert!(contribution.replacement_branch); + assert_eq!( + contribution.direct_source_family, + Some(DirectSourceFamily::TwoLocationReplacementBranchImpact) + ); + + let mut dem = DetectorErrorModel::new(); + dem.add_direct_contribution_with_source_components( + FaultMechanism::from_unsorted([7, 11], std::iter::empty()), + 0.02, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::Z, Pauli::I], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ) + .with_direct_source_family(DirectSourceFamily::TwoLocationReplacementBranchImpact) + .with_replacement_branch(), + DirectSourceComponents::new(&first, &second), + ); + let contributions = dem.contributions_for_effect(&[7, 11], &[]); + assert_eq!(contributions.len(), 1); + assert!(contributions[0].replacement_branch); + assert_eq!( + contributions[0].direct_source_family, + Some(DirectSourceFamily::TwoLocationReplacementBranchImpact) + ); + } + #[test] fn test_add_y_decomposed_contribution_with_source_routes_metadata_to_direct() { let mut dem = DetectorErrorModel::new(); @@ -5802,6 +5940,15 @@ mod tests { replacement_omits_only.replacement_branch_impact_weights(GateType::SZZ), BTreeMap::from([("ZZ".to_string(), 0.5)]) ); + assert_eq!( + replacement_omits_only.replacement_branch_impacts(GateType::SZZ), + vec![ReplacementBranchImpact { + replacement_pauli_label: "II".to_string(), + omitted_gate_twirl_label: "ZZ".to_string(), + pauli_label: "ZZ".to_string(), + relative_probability: 0.5, + }], + ); let cx_replacement_identity = PauliWeights::with_replacement([], [(Z(0) & X(1), 1.0)]); assert!( diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 9c8555ec1..6e0efd483 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -157,11 +157,14 @@ fn parse_replacement_approximation( "branch_impact" | "replacement_branch_impact" | "impact" => { Ok(ReplacementBranchApproximation::BranchImpact) } + "exact_branch_replay" | "exact_replay" | "exact_branch" | "exact" => { + Ok(ReplacementBranchApproximation::ExactBranchReplay) + } "ignore_gate_removal" | "ignore_removal" | "post_gate" | "postgate" => { Ok(ReplacementBranchApproximation::IgnoreGateRemoval) } _ => Err(pyo3::exceptions::PyValueError::new_err( - "p2_replacement_approximation must be 'pauli_twirl_omitted_gate', 'branch_impact', or 'ignore_gate_removal'", + "p2_replacement_approximation must be 'pauli_twirl_omitted_gate', 'branch_impact', 'exact_branch_replay', or 'ignore_gate_removal'", )), } } @@ -1052,10 +1055,14 @@ fn contribution_record_to_pydict( RustDirectSourceFamily::TwoLocationPlainY => "TwoLocationPlainY", RustDirectSourceFamily::TwoLocationComponent => "TwoLocationComponent", RustDirectSourceFamily::TwoLocationOneSidedComponent => "TwoLocationOneSidedComponent", + RustDirectSourceFamily::TwoLocationReplacementBranchImpact => { + "TwoLocationReplacementBranchImpact" + } RustDirectSourceFamily::Other => "Other", }; dict.set_item("direct_source_family", family_label)?; } + dict.set_item("replacement_branch", contribution.replacement_branch)?; match contribution.source_type { RustFaultSourceType::Direct => { diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 251ea8109..e7ae7fd45 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -154,7 +154,9 @@ def from_guppy( replacement labels. ``"pauli_twirl_omitted_gate"`` convolves with the omitted two-qubit gate's Pauli twirl; ``"branch_impact"`` evaluates starred entries as replacement - branch impacts; ``"ignore_gate_removal"`` treats starred + branch impacts; ``"exact_branch_replay"`` is reserved for a + future circuit-aware exact replay provider and currently fails + loudly for starred entries; ``"ignore_gate_removal"`` treats starred entries like plain post-gate Pauli entries. p_meas: Measurement flip rate. p_prep: Preparation (reset) error rate. diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 4a411242d..cfb9a3e3f 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -101,6 +101,9 @@ class NoiseModel: replacement labels. ``"pauli_twirl_omitted_gate"`` convolves with the omitted two-qubit gate's Pauli twirl; ``"branch_impact"`` evaluates starred entries as replacement branch impacts; + ``"exact_branch_replay"`` is reserved for a future circuit-aware + exact replay provider and currently fails loudly for starred + entries; ``"ignore_gate_removal"`` treats starred entries like plain post-gate Pauli entries. p_meas: Measurement error rate. From 5fff6807de84526989f7e9a88a54f8315200d598 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 12:55:51 -0600 Subject: [PATCH 020/150] Start exact branch replay plumbing --- .../fault_tolerance/dem_builder/builder.rs | 199 +++++++++++++----- 1 file changed, 150 insertions(+), 49 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 575fe7fde..cb736f1dd 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -761,9 +761,6 @@ impl<'a> DemBuilder<'a> { ) { let locations = &self.influence_map.locations; - // Group CX locations by node for two-qubit gate processing - let mut cx_groups: BTreeMap> = BTreeMap::new(); - for (loc_idx, loc) in locations.iter().enumerate() { match loc.gate_type { GateType::PZ | GateType::QAlloc @@ -786,23 +783,7 @@ impl<'a> DemBuilder<'a> { meas_to_observables, ); } - GateType::CX - | GateType::CZ - | GateType::CY - | GateType::SZZ - | GateType::SZZdg - | GateType::SXX - | GateType::SXXdg - | GateType::SYY - | GateType::SYYdg - | GateType::SWAP - | GateType::RXX - | GateType::RYY - | GateType::RZZ - if !loc.before => - { - cx_groups.entry(loc.node).or_default().push(loc_idx); - } + gate_type if is_two_qubit_noise_gate(gate_type) && !loc.before => {} GateType::H | GateType::F | GateType::Fdg @@ -852,35 +833,30 @@ impl<'a> DemBuilder<'a> { } // Process two-qubit gates. - for (_, loc_indices) in cx_groups { - for pair in loc_indices.chunks(2) { - if pair.len() != 2 { - continue; - } - let loc1 = &locations[pair[0]]; - let loc2 = &locations[pair[1]]; - let rates = self.rates_2q_for_locs(loc1, loc2); - if rates.iter().any(|r| *r > 0.0) { - self.process_two_qubit_fault_source_tracked( - pair[0], - pair[1], - rates, - dem, - meas_to_detectors, - meas_to_observables, - ); - } - if self.noise.p2_replacement_approximation - == ReplacementBranchApproximation::BranchImpact - { - self.process_two_qubit_replacement_branch_impacts_source_tracked( - pair[0], - pair[1], - dem, - meas_to_detectors, - meas_to_observables, - ); - } + for [loc1_idx, loc2_idx] in two_qubit_after_location_pairs(locations) { + 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) { + self.process_two_qubit_fault_source_tracked( + loc1_idx, + loc2_idx, + rates, + dem, + meas_to_detectors, + meas_to_observables, + ); + } + if self.noise.p2_replacement_approximation + == ReplacementBranchApproximation::BranchImpact + { + self.process_two_qubit_replacement_branch_impacts_source_tracked( + loc1_idx, + loc2_idx, + dem, + meas_to_detectors, + meas_to_observables, + ); } } } @@ -1907,6 +1883,44 @@ fn extract_measurement_refs( Ok((records, meas_ids)) } +fn is_two_qubit_noise_gate(gate_type: GateType) -> bool { + matches!( + gate_type, + GateType::CX + | GateType::CZ + | GateType::CY + | GateType::SZZ + | GateType::SZZdg + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg + | GateType::SWAP + | GateType::RXX + | GateType::RYY + | GateType::RZZ + ) +} + +fn two_qubit_after_location_pairs(locations: &[DagSpacetimeLocation]) -> Vec<[usize; 2]> { + let mut groups: BTreeMap> = BTreeMap::new(); + for (loc_idx, loc) in locations.iter().enumerate() { + if is_two_qubit_noise_gate(loc.gate_type) && !loc.before { + groups.entry(loc.node).or_default().push(loc_idx); + } + } + + groups + .into_values() + .flat_map(|loc_indices| { + loc_indices + .chunks_exact(2) + .map(|pair| [pair[0], pair[1]]) + .collect::>() + }) + .collect() +} + // ============================================================================ // Convenience: build DEM from circuit (free function to handle lifetimes) // ============================================================================ @@ -1979,6 +1993,36 @@ fn build_dem_from_circuit( builder.try_build() } +/// Return a branch circuit where one ideal two-qubit gate has been omitted. +/// +/// Replacement-branch exact replay needs to evaluate "the hardware branch did +/// not apply this entangler" without disturbing the surrounding DAG wiring. +/// Replacing the selected node by batched identities preserves the node id, +/// qubit wires, and topological context while making the operation itself a +/// no-op on every qubit carried by the original gate. +#[cfg(test)] +fn circuit_with_omitted_two_qubit_gate( + circuit: &pecos_quantum::DagCircuit, + node: usize, +) -> Result { + let original = circuit.gate(node).ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "cannot omit gate at node {node}: no such gate node exists" + )) + })?; + if !original.gate_type.is_two_qubit() { + return Err(DemBuilderError::ConfigurationError(format!( + "cannot omit gate at node {node}: {:?} is not a two-qubit gate", + original.gate_type + ))); + } + + let mut branch = circuit.clone(); + let replacement = pecos_core::Gate::simple(GateType::I, original.qubits.clone()); + *branch.gate_mut(node).expect("gate existed before clone") = replacement; + Ok(branch) +} + fn observable_records_from_annotations( circuit: &pecos_quantum::DagCircuit, influence_map: &DagFaultInfluenceMap, @@ -2818,6 +2862,63 @@ mod tests { ); } + #[test] + fn test_circuit_with_omitted_two_qubit_gate_preserves_wiring() { + use pecos_core::{Gate, QubitId}; + use pecos_quantum::DagCircuit; + + let mut circuit = DagCircuit::new(); + let prep0 = circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + let prep1 = circuit.add_gate_auto_wire(Gate::pz(&[QubitId(1)])); + let entangler = circuit.add_gate_auto_wire(Gate::szz(&[(QubitId(0), QubitId(1))])); + let meas0 = circuit.add_gate_auto_wire(Gate::mz(&[QubitId(0)])); + let meas1 = circuit.add_gate_auto_wire(Gate::mz(&[QubitId(1)])); + + let branch = circuit_with_omitted_two_qubit_gate(&circuit, entangler) + .expect("two-qubit entangler can be omitted"); + + assert_eq!(circuit.gate(entangler).unwrap().gate_type, GateType::SZZ); + let replacement = branch.gate(entangler).unwrap(); + assert_eq!(replacement.gate_type, GateType::I); + assert_eq!(replacement.qubits.as_slice(), &[QubitId(0), QubitId(1)]); + + assert_eq!( + branch.predecessor_on_qubit(entangler, QubitId(0)), + Some(prep0) + ); + assert_eq!( + branch.predecessor_on_qubit(entangler, QubitId(1)), + Some(prep1) + ); + assert_eq!( + branch.successor_on_qubit(entangler, QubitId(0)), + Some(meas0) + ); + assert_eq!( + branch.successor_on_qubit(entangler, QubitId(1)), + Some(meas1) + ); + assert_eq!(branch.topological_order(), circuit.topological_order()); + } + + #[test] + fn test_circuit_with_omitted_two_qubit_gate_rejects_bad_nodes() { + use pecos_core::{Gate, QubitId}; + use pecos_quantum::DagCircuit; + + let mut circuit = DagCircuit::new(); + let prep = circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + + assert!(matches!( + circuit_with_omitted_two_qubit_gate(&circuit, prep), + Err(DemBuilderError::ConfigurationError(_)) + )); + assert!(matches!( + circuit_with_omitted_two_qubit_gate(&circuit, prep + 1), + Err(DemBuilderError::ConfigurationError(_)) + )); + } + #[test] fn test_parse_accepts_dem_label_id_form() { let det = parse_detectors_json(r#"[{"id": "D0", "records": [-1]}]"#).unwrap(); From de1ed63061e4491b340f8afeda5aabbe0ccda60a Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 13:02:35 -0600 Subject: [PATCH 021/150] Attach exact branch replay context --- .../fault_tolerance/dem_builder/builder.rs | 131 +++++++++++++++++- 1 file changed, 129 insertions(+), 2 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 cb736f1dd..b0bb813e9 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -112,6 +112,20 @@ pub struct DemBuilder<'a> { /// Optional measurement order: maps `TickCircuit` measurement index -> qubit. /// This allows proper mapping between record offsets and influence map indices. measurement_order: Option>, + /// Optional circuit context for future exact replacement-branch replay. + exact_branch_context: Option>, +} + +#[derive(Debug, Clone, Copy)] +struct ExactBranchReplayContext<'a> { + circuit: &'a pecos_quantum::DagCircuit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ExactBranchReplayRequest { + gate_node: usize, + gate_type: GateType, + loc_indices: [usize; 2], } impl<'a> DemBuilder<'a> { @@ -250,6 +264,7 @@ impl<'a> DemBuilder<'a> { observables: Vec::new(), num_measurements: influence_map.measurements.len(), measurement_order: None, + exact_branch_context: None, } } @@ -267,6 +282,11 @@ impl<'a> DemBuilder<'a> { self } + fn with_exact_branch_replay_context(mut self, circuit: &'a pecos_quantum::DagCircuit) -> Self { + self.exact_branch_context = Some(ExactBranchReplayContext { circuit }); + self + } + /// Attach per-gate-type per-Pauli noise. When present, overrides /// [`Self::with_noise`] scalars for gate types in the spec's maps. /// Mirrors @@ -734,6 +754,14 @@ impl<'a> DemBuilder<'a> { == ReplacementBranchApproximation::ExactBranchReplay && has_replacement_branches { + if let Some(context) = self.exact_branch_context { + let requests = + context.replacement_branch_requests(&self.influence_map.locations)?; + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay has circuit context and identified {} two-qubit replay request(s), but exact replacement-branch DEM emission is not implemented yet; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations", + requests.len() + ))); + } return Err(DemBuilderError::ConfigurationError( "exact_branch_replay for starred p2 replacement branches requires a circuit-aware exact branch provider; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations" .to_string(), @@ -1921,6 +1949,52 @@ fn two_qubit_after_location_pairs(locations: &[DagSpacetimeLocation]) -> Vec<[us .collect() } +impl ExactBranchReplayContext<'_> { + fn replacement_branch_requests( + &self, + locations: &[DagSpacetimeLocation], + ) -> Result, DemBuilderError> { + let mut requests = Vec::new(); + for [loc1_idx, loc2_idx] in two_qubit_after_location_pairs(locations) { + let loc1 = &locations[loc1_idx]; + let loc2 = &locations[loc2_idx]; + if loc1.node != loc2.node { + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay expected paired two-qubit locations to share a node, got {} and {}", + loc1.node, loc2.node + ))); + } + if loc1.gate_type != loc2.gate_type { + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay expected paired two-qubit locations at node {} to share a gate type, got {:?} and {:?}", + loc1.node, loc1.gate_type, loc2.gate_type + ))); + } + let branch = circuit_with_omitted_two_qubit_gate(self.circuit, loc1.node)?; + let replacement = branch + .gate(loc1.node) + .expect("omitted branch preserves the original gate node"); + if !loc1 + .qubits + .iter() + .chain(loc2.qubits.iter()) + .all(|qubit| replacement.qubits.contains(qubit)) + { + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay location qubits at node {} are not all present in the omitted branch gate", + loc1.node + ))); + } + requests.push(ExactBranchReplayRequest { + gate_node: loc1.node, + gate_type: loc1.gate_type, + loc_indices: [loc1_idx, loc2_idx], + }); + } + Ok(requests) + } +} + // ============================================================================ // Convenience: build DEM from circuit (free function to handle lifetimes) // ============================================================================ @@ -1966,7 +2040,9 @@ fn build_dem_from_circuit( } }); - let builder = DemBuilder::new(&influence_map).with_noise_config(noise); + let builder = DemBuilder::new(&influence_map) + .with_noise_config(noise) + .with_exact_branch_replay_context(circuit); let builder = if let Some(ref dj) = det_json { builder.with_detectors_json(dj)? @@ -2000,7 +2076,6 @@ fn build_dem_from_circuit( /// Replacing the selected node by batched identities preserves the node id, /// qubit wires, and topological context while making the operation itself a /// no-op on every qubit carried by the original gate. -#[cfg(test)] fn circuit_with_omitted_two_qubit_gate( circuit: &pecos_quantum::DagCircuit, node: usize, @@ -2862,6 +2937,29 @@ mod tests { ); } + #[test] + fn test_from_circuit_exact_branch_replay_attaches_context_but_still_rejects() { + use crate::fault_tolerance::dem_builder::PauliWeights; + use pecos_core::pauli::Z; + use pecos_quantum::DagCircuit; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0, 1]); + circuit.szz(&[(0, 1)]); + + let noise = NoiseConfig::new(0.0, 0.01, 0.0, 0.0) + .set_p2_weights(PauliWeights::with_replacement([], [(Z(0) & Z(1), 1.0)])) + .set_p2_replacement_approximation(ReplacementBranchApproximation::ExactBranchReplay); + + let err = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect_err("exact branch replay must remain fail-loud until emission is implemented"); + + assert!( + err.to_string().contains("has circuit context"), + "unexpected error: {err}", + ); + } + #[test] fn test_circuit_with_omitted_two_qubit_gate_preserves_wiring() { use pecos_core::{Gate, QubitId}; @@ -2901,6 +2999,35 @@ mod tests { assert_eq!(branch.topological_order(), circuit.topological_order()); } + #[test] + fn test_exact_branch_replay_context_collects_two_qubit_requests() { + use crate::fault_tolerance::propagator::DagFaultAnalyzer; + use pecos_core::{Gate, QubitId}; + use pecos_quantum::DagCircuit; + + let mut circuit = DagCircuit::new(); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(1)])); + let entangler = circuit.add_gate_auto_wire(Gate::szz(&[(QubitId(0), QubitId(1))])); + circuit.add_gate_auto_wire(Gate::mz(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::mz(&[QubitId(1)])); + + let influence_map = DagFaultAnalyzer::new(&circuit).build_influence_map(); + let requests = ExactBranchReplayContext { circuit: &circuit } + .replacement_branch_requests(&influence_map.locations) + .expect("two-qubit branch requests should be recoverable"); + + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].gate_node, entangler); + assert_eq!(requests[0].gate_type, GateType::SZZ); + let loc_qubits: Vec<_> = requests[0] + .loc_indices + .iter() + .flat_map(|&idx| influence_map.locations[idx].qubits.iter().copied()) + .collect(); + assert_eq!(loc_qubits, vec![QubitId(0), QubitId(1)]); + } + #[test] fn test_circuit_with_omitted_two_qubit_gate_rejects_bad_nodes() { use pecos_core::{Gate, QubitId}; From 78a21a5bdaff7924f848d77fd207883657e45c21 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 13:07:11 -0600 Subject: [PATCH 022/150] Validate omitted branch replay locations --- .../fault_tolerance/dem_builder/builder.rs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) 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 b0bb813e9..89d715fbf 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -402,6 +402,8 @@ impl<'a> DemBuilder<'a> { let pauli = pauli_pair_for_weight(p1, p2); let weight = if self.noise.p2_replacement_approximation == ReplacementBranchApproximation::BranchImpact + || self.noise.p2_replacement_approximation + == ReplacementBranchApproximation::ExactBranchReplay { weights.post_gate_two_qubit_weight_for(&pauli) } else { @@ -757,6 +759,10 @@ impl<'a> DemBuilder<'a> { if let Some(context) = self.exact_branch_context { let requests = context.replacement_branch_requests(&self.influence_map.locations)?; + for request in &requests { + let _branch_locs = context + .omitted_branch_location_pair(*request, &self.influence_map.locations)?; + } return Err(DemBuilderError::ConfigurationError(format!( "exact_branch_replay has circuit context and identified {} two-qubit replay request(s), but exact replacement-branch DEM emission is not implemented yet; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations", requests.len() @@ -1993,6 +1999,67 @@ impl ExactBranchReplayContext<'_> { } Ok(requests) } + + fn omitted_branch_location_pair( + &self, + request: ExactBranchReplayRequest, + original_locations: &[DagSpacetimeLocation], + ) -> Result<[usize; 2], DemBuilderError> { + use crate::fault_tolerance::propagator::DagFaultAnalyzer; + + let branch = circuit_with_omitted_two_qubit_gate(self.circuit, request.gate_node)?; + let branch_map = DagFaultAnalyzer::new(&branch).build_influence_map(); + identity_location_pair_for_request(request, original_locations, &branch_map.locations) + } +} + +fn identity_location_pair_for_request( + request: ExactBranchReplayRequest, + original_locations: &[DagSpacetimeLocation], + branch_locations: &[DagSpacetimeLocation], +) -> Result<[usize; 2], DemBuilderError> { + let [orig_loc1, orig_loc2] = request.loc_indices; + let expected_qubits = [ + *original_locations + .get(orig_loc1) + .and_then(|loc| loc.qubits.first()) + .ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "exact_branch_replay original location {orig_loc1} has no qubit" + )) + })?, + *original_locations + .get(orig_loc2) + .and_then(|loc| loc.qubits.first()) + .ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "exact_branch_replay original location {orig_loc2} has no qubit" + )) + })?, + ]; + + let mut pair = [usize::MAX; 2]; + for (branch_idx, branch_loc) in branch_locations.iter().enumerate() { + if branch_loc.node != request.gate_node + || branch_loc.before + || branch_loc.gate_type != GateType::I + { + continue; + } + if branch_loc.qubits.first() == Some(&expected_qubits[0]) { + pair[0] = branch_idx; + } else if branch_loc.qubits.first() == Some(&expected_qubits[1]) { + pair[1] = branch_idx; + } + } + + if pair.contains(&usize::MAX) { + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay could not find identity branch locations for omitted gate node {} on qubits {:?}", + request.gate_node, expected_qubits + ))); + } + Ok(pair) } // ============================================================================ @@ -3020,6 +3087,11 @@ mod tests { assert_eq!(requests.len(), 1); assert_eq!(requests[0].gate_node, entangler); assert_eq!(requests[0].gate_type, GateType::SZZ); + let branch_pair = (ExactBranchReplayContext { circuit: &circuit }) + .omitted_branch_location_pair(requests[0], &influence_map.locations) + .expect("omitted branch identity locations should be recoverable"); + assert_ne!(branch_pair[0], branch_pair[1]); + let loc_qubits: Vec<_> = requests[0] .loc_indices .iter() From 5e96dc929cc633c31540894c529fbacb3870be6a Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 15:01:41 -0600 Subject: [PATCH 023/150] Validate exact branch effects --- .../fault_tolerance/dem_builder/builder.rs | 353 +++++++++++++++++- .../src/fault_tolerance/dem_builder/types.rs | 6 + .../src/fault_tolerance/influence_builder.rs | 10 +- .../src/fault_tolerance_bindings.rs | 3 + 4 files changed, 356 insertions(+), 16 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 89d715fbf..07a1268d7 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -22,7 +22,9 @@ use super::types::{ }; use crate::fault_tolerance::propagator::dag::DagSpacetimeLocation; use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Pauli}; +use pecos_core::BitSet; use pecos_core::gate_type::GateType; +use pecos_simulators::symbolic_sparse_stab::MeasurementHistory; use smallvec::SmallVec; use std::collections::BTreeMap; @@ -128,6 +130,12 @@ struct ExactBranchReplayRequest { loc_indices: [usize; 2], } +#[derive(Debug, Clone, PartialEq, Eq)] +struct MeasurementParityExpression { + dependencies: BitSet, + flip: bool, +} + impl<'a> DemBuilder<'a> { /// Build a `DetectorErrorModel` directly from a circuit and noise. /// @@ -603,6 +611,38 @@ impl<'a> DemBuilder<'a> { .collect() } + fn measurement_indices_from_refs( + &self, + records: &[i32], + meas_ids: &[usize], + ) -> Result, DemBuilderError> { + if !records.is_empty() { + return records + .iter() + .map(|&rec| { + record_offset_to_absolute_index(self.num_measurements, rec).ok_or_else(|| { + DemBuilderError::ParseError(format!( + "record offset {rec} is out of range for a circuit with {} measurement(s)", + self.num_measurements + )) + }) + }) + .collect(); + } + + meas_ids + .iter() + .map(|&meas_id| { + self.resolve_meas_id_to_tc_index(meas_id).ok_or_else(|| { + DemBuilderError::ParseError(format!( + "meas_id {meas_id} is not present in the circuit's {} measurement(s)", + self.num_measurements + )) + }) + }) + .collect() + } + /// Validates metadata refs, then builds the Detector Error Model. /// /// This is the fail-loud entry point. Every path that ingests @@ -759,9 +799,21 @@ impl<'a> DemBuilder<'a> { if let Some(context) = self.exact_branch_context { let requests = context.replacement_branch_requests(&self.influence_map.locations)?; + let weights = self + .noise + .p2_weights + .as_ref() + .expect("replacement entries exist"); for request in &requests { let _branch_locs = context .omitted_branch_location_pair(*request, &self.influence_map.locations)?; + let _omitted_effect = + self.exact_omitted_branch_base_effect(context, *request)?; + for (replacement_pauli, _weight) in weights.replacement_entries() { + let label = two_qubit_label_for_replay(replacement_pauli)?; + let _replacement_effect = + self.exact_replacement_branch_effect(context, *request, &label)?; + } } return Err(DemBuilderError::ConfigurationError(format!( "exact_branch_replay has circuit context and identified {} two-qubit replay request(s), but exact replacement-branch DEM emission is not implemented yet; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations", @@ -776,6 +828,75 @@ impl<'a> DemBuilder<'a> { Ok(()) } + fn exact_omitted_branch_base_effect( + &self, + context: ExactBranchReplayContext<'_>, + request: ExactBranchReplayRequest, + ) -> Result { + let mut triggered_dets: SmallVec<[u32; 4]> = SmallVec::new(); + let mut triggered_obs: SmallVec<[u32; 2]> = SmallVec::new(); + + for detector in &self.detectors { + let indices = + self.measurement_indices_from_refs(&detector.records, &detector.meas_ids)?; + if context.omitted_branch_flips_measurement_parity(request, &indices)? { + xor_toggle_4(&mut triggered_dets, detector.id); + } + } + + for observable in &self.observables { + let indices = + self.measurement_indices_from_refs(&observable.records, &observable.meas_ids)?; + if context.omitted_branch_flips_measurement_parity(request, &indices)? { + xor_toggle_2(&mut triggered_obs, observable.id); + } + } + + triggered_dets.sort_unstable(); + triggered_obs.sort_unstable(); + Ok(FaultMechanism::from_sorted_with_tracked_paulis( + triggered_dets, + triggered_obs, + SmallVec::new(), + )) + } + + fn exact_replacement_branch_effect( + &self, + context: ExactBranchReplayContext<'_>, + request: ExactBranchReplayRequest, + replacement_pauli_label: &str, + ) -> Result { + use crate::fault_tolerance::propagator::DagFaultAnalyzer; + + let (p1, p2) = two_qubit_label_to_pauli_indices(replacement_pauli_label).ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "exact_branch_replay replacement Pauli label {replacement_pauli_label:?} is not a two-qubit Pauli label" + )) + })?; + let base_effect = self.exact_omitted_branch_base_effect(context, request)?; + if p1 == 0 && p2 == 0 { + return Ok(base_effect); + } + + let branch = circuit_with_omitted_two_qubit_gate(context.circuit, request.gate_node)?; + let branch_map = DagFaultAnalyzer::new(&branch).build_influence_map(); + let branch_locs = identity_location_pair_for_request( + request, + &self.influence_map.locations, + &branch_map.locations, + )?; + let (meas_to_detectors, meas_to_observables) = self.build_measurement_mappings(); + let branch_effects = Self::two_qubit_effect_table_for_map( + &branch_map, + branch_locs[0], + branch_locs[1], + &meas_to_detectors, + &meas_to_observables, + ); + Ok(base_effect.xor(&branch_effects[p1 as usize][p2 as usize])) + } + fn num_influence_dem_outputs(&self) -> usize { self.influence_map .influences @@ -1128,6 +1249,63 @@ impl<'a> DemBuilder<'a> { effects } + fn two_qubit_effect_table_for_map( + influence_map: &DagFaultInfluenceMap, + loc1: usize, + loc2: usize, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, + ) -> [[FaultMechanism; 4]; 4] { + let x1 = Self::compute_mechanism_for_map( + influence_map, + loc1, + Pauli::X, + meas_to_detectors, + meas_to_observables, + ); + let z1 = Self::compute_mechanism_for_map( + influence_map, + loc1, + Pauli::Z, + meas_to_detectors, + meas_to_observables, + ); + let x2 = Self::compute_mechanism_for_map( + influence_map, + loc2, + Pauli::X, + meas_to_detectors, + meas_to_observables, + ); + let z2 = Self::compute_mechanism_for_map( + influence_map, + loc2, + Pauli::Z, + meas_to_detectors, + meas_to_observables, + ); + + let get_single_effect = |p: u8, x: &FaultMechanism, z: &FaultMechanism| -> FaultMechanism { + match p { + 0 => FaultMechanism::new(), + 1 => x.clone(), + 2 => x.xor(z), + 3 => z.clone(), + _ => unreachable!("Pauli index must be 0-3"), + } + }; + + let mut effects: [[FaultMechanism; 4]; 4] = Default::default(); + for p1 in 0..4u8 { + for p2 in 0..4u8 { + let e1 = get_single_effect(p1, &x1, &z1); + let e2 = get_single_effect(p2, &x2, &z2); + effects[p1 as usize][p2 as usize] = e1.xor(&e2); + } + } + effects + } + #[allow(clippy::too_many_arguments)] fn add_two_qubit_pauli_contribution( &self, @@ -1327,27 +1505,35 @@ impl<'a> DemBuilder<'a> { pauli: Pauli, meas_to_detectors: &BTreeMap>, meas_to_observables: &BTreeMap>, + ) -> FaultMechanism { + Self::compute_mechanism_for_map( + self.influence_map, + loc_idx, + pauli, + meas_to_detectors, + meas_to_observables, + ) + } + + fn compute_mechanism_for_map( + influence_map: &DagFaultInfluenceMap, + loc_idx: usize, + pauli: Pauli, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, ) -> FaultMechanism { // Get the measurement indices that this fault flips - let rust_dets = self - .influence_map - .get_detector_indices(loc_idx, pauli.as_u8()); + let rust_dets = influence_map.get_detector_indices(loc_idx, pauli.as_u8()); // Convert to pre-defined detector IDs using XOR let mut triggered_dets: SmallVec<[u32; 4]> = SmallVec::new(); let mut triggered_obs: SmallVec<[u32; 2]> = SmallVec::new(); let mut triggered_tracked_paulis: SmallVec<[u32; 2]> = SmallVec::new(); - for dem_output_idx in self - .influence_map - .get_observable_indices(loc_idx, pauli.as_u8()) - { + for dem_output_idx in influence_map.get_observable_indices(loc_idx, pauli.as_u8()) { xor_toggle_2(&mut triggered_obs, dem_output_idx); } - for tracked_pauli_idx in self - .influence_map - .get_tracked_pauli_indices(loc_idx, pauli.as_u8()) - { + for tracked_pauli_idx in influence_map.get_tracked_pauli_indices(loc_idx, pauli.as_u8()) { xor_toggle_2(&mut triggered_tracked_paulis, tracked_pauli_idx); } @@ -1427,6 +1613,35 @@ fn two_qubit_label_to_pauli_indices(label: &str) -> Option<(u8, u8)> { chars.next().is_none().then_some((p1, p2)) } +fn two_qubit_label_for_replay(pauli: &pecos_core::PauliString) -> Result { + let mut label = String::with_capacity(2); + for qubit in [0, 1] { + label.push(pauli_index_to_label(match pauli.get(qubit) { + pecos_core::Pauli::I => 0, + pecos_core::Pauli::X => 1, + pecos_core::Pauli::Y => 2, + pecos_core::Pauli::Z => 3, + })); + } + if pauli.qubits().into_iter().any(|qubit| qubit > 1) { + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay replacement Pauli {} is not supported by the two-qubit replay path", + pauli.to_sparse_str() + ))); + } + Ok(label) +} + +fn pauli_index_to_label(index: u8) -> char { + match index { + 0 => 'I', + 1 => 'X', + 2 => 'Y', + 3 => 'Z', + _ => unreachable!("Pauli index must be 0-3"), + } +} + fn pauli_label_to_index(label: char) -> Option { match label { 'I' => Some(0), @@ -2011,6 +2226,51 @@ impl ExactBranchReplayContext<'_> { let branch_map = DagFaultAnalyzer::new(&branch).build_influence_map(); identity_location_pair_for_request(request, original_locations, &branch_map.locations) } + + fn omitted_branch_flips_measurement_parity( + &self, + request: ExactBranchReplayRequest, + measurement_indices: &[usize], + ) -> Result { + use crate::fault_tolerance::influence_builder::InfluenceBuilder; + + let ideal_info = InfluenceBuilder::new(self.circuit).run_symbolic_simulation(); + let branch = circuit_with_omitted_two_qubit_gate(self.circuit, request.gate_node)?; + let branch_info = InfluenceBuilder::new(&branch).run_symbolic_simulation(); + + let ideal = + measurement_parity_expression(&ideal_info.history, measurement_indices, "ideal")?; + let branch = + measurement_parity_expression(&branch_info.history, measurement_indices, "branch")?; + if ideal.dependencies != branch.dependencies { + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay omitted gate at node {} changes measurement dependencies for parity {:?}; this branch is not representable as a single deterministic DEM event", + request.gate_node, measurement_indices + ))); + } + Ok(ideal.flip ^ branch.flip) + } +} + +fn measurement_parity_expression( + history: &MeasurementHistory, + measurement_indices: &[usize], + history_label: &str, +) -> Result { + let mut dependencies = BitSet::new(); + let mut flip = false; + + for &measurement_idx in measurement_indices { + let result = history.get(measurement_idx).ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "exact_branch_replay {history_label} history has no measurement {measurement_idx}" + )) + })?; + dependencies.symmetric_difference_update(&result.outcome); + flip ^= result.flip; + } + + Ok(MeasurementParityExpression { dependencies, flip }) } fn identity_location_pair_for_request( @@ -3100,6 +3360,77 @@ mod tests { assert_eq!(loc_qubits, vec![QubitId(0), QubitId(1)]); } + #[test] + fn test_exact_branch_replay_base_effect_detects_deterministic_omitted_gate_flip() { + use crate::fault_tolerance::propagator::DagFaultAnalyzer; + use pecos_quantum::DagCircuit; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0, 1]); + circuit.x(&[0]); + let entangler = circuit.add_gate_auto_wire(pecos_core::Gate::cx(&[(0, 1)])); + circuit.mz(&[1]); + + let influence_map = DagFaultAnalyzer::new(&circuit).build_influence_map(); + let context = ExactBranchReplayContext { circuit: &circuit }; + let request = context + .replacement_branch_requests(&influence_map.locations) + .unwrap() + .into_iter() + .find(|request| request.gate_node == entangler) + .expect("CX request should be present"); + + let builder = DemBuilder::new(&influence_map) + .with_detectors_json(r#"[{"id":0,"records":[-1]}]"#) + .unwrap() + .with_num_measurements(1); + let effect = builder + .exact_omitted_branch_base_effect(context, request) + .expect("omitting this CX only flips a deterministic measurement parity"); + + assert_eq!(effect.detectors.as_slice(), &[0]); + assert!(effect.dem_outputs.is_empty()); + } + + #[test] + fn test_exact_branch_replay_replacement_pauli_combines_with_omitted_gate_effect() { + use crate::fault_tolerance::propagator::DagFaultAnalyzer; + use pecos_quantum::DagCircuit; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0, 1]); + circuit.x(&[0]); + let entangler = circuit.add_gate_auto_wire(pecos_core::Gate::cx(&[(0, 1)])); + circuit.mz(&[1]); + + let influence_map = DagFaultAnalyzer::new(&circuit).build_influence_map(); + let context = ExactBranchReplayContext { circuit: &circuit }; + let request = context + .replacement_branch_requests(&influence_map.locations) + .unwrap() + .into_iter() + .find(|request| request.gate_node == entangler) + .expect("CX request should be present"); + + let builder = DemBuilder::new(&influence_map) + .with_detectors_json(r#"[{"id":0,"records":[-1]}]"#) + .unwrap() + .with_num_measurements(1); + + let omitted_only = builder + .exact_replacement_branch_effect(context, request, "II") + .expect("omission-only branch should be deterministic here"); + assert_eq!(omitted_only.detectors.as_slice(), &[0]); + + let omitted_then_target_x = builder + .exact_replacement_branch_effect(context, request, "IX") + .expect("replacement X on the target should be deterministic here"); + assert!( + omitted_then_target_x.is_empty(), + "target X after omitted CX restores the ideal target measurement" + ); + } + #[test] fn test_circuit_with_omitted_two_qubit_gate_rejects_bad_nodes() { use pecos_core::{Gate, QubitId}; 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 2aea930f8..91cf82cce 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -129,6 +129,9 @@ pub enum DirectSourceFamily { /// Two-location direct source produced by a replacement-branch projection. TwoLocationReplacementBranchImpact, + /// Two-location direct source produced by exact replacement-branch replay. + TwoLocationExactReplacementBranch, + /// Fallback for other direct-source shapes. Other, } @@ -3579,6 +3582,9 @@ impl DetectorErrorModel { DirectSourceFamily::TwoLocationReplacementBranchImpact => { "TwoLocationReplacementBranchImpact" } + DirectSourceFamily::TwoLocationExactReplacementBranch => { + "TwoLocationExactReplacementBranch" + } DirectSourceFamily::Other => "Other", } } diff --git a/crates/pecos-qec/src/fault_tolerance/influence_builder.rs b/crates/pecos-qec/src/fault_tolerance/influence_builder.rs index 6820ca72c..bef7ed93c 100644 --- a/crates/pecos-qec/src/fault_tolerance/influence_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/influence_builder.rs @@ -250,7 +250,7 @@ impl<'a> InfluenceBuilder<'a> { } /// Run symbolic simulation to get measurement correlations. - fn run_symbolic_simulation(&self) -> MeasurementInfo { + pub(crate) fn run_symbolic_simulation(&self) -> MeasurementInfo { let topo_order = self.dag.topological_order(); // Determine number of qubits from the circuit @@ -862,11 +862,11 @@ impl<'a> InfluenceBuilder<'a> { } /// Information about measurements from symbolic simulation. -struct MeasurementInfo { - history: pecos_simulators::symbolic_sparse_stab::MeasurementHistory, - node_to_meas_idx: Vec>, +pub(crate) struct MeasurementInfo { + pub(crate) history: pecos_simulators::symbolic_sparse_stab::MeasurementHistory, + pub(crate) node_to_meas_idx: Vec>, #[allow(dead_code)] - num_measurements: usize, + pub(crate) num_measurements: usize, } /// Definition of a detector as XOR of measurements. diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 6e0efd483..81a8563b7 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -1058,6 +1058,9 @@ fn contribution_record_to_pydict( RustDirectSourceFamily::TwoLocationReplacementBranchImpact => { "TwoLocationReplacementBranchImpact" } + RustDirectSourceFamily::TwoLocationExactReplacementBranch => { + "TwoLocationExactReplacementBranch" + } RustDirectSourceFamily::Other => "Other", }; dict.set_item("direct_source_family", family_label)?; From b923f17372a7978bda780c1286cac17667871890 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 15:12:10 -0600 Subject: [PATCH 024/150] Emit exact replacement branch effects --- .../fault_tolerance/dem_builder/builder.rs | 167 ++++++++++++++++-- 1 file changed, 156 insertions(+), 11 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 07a1268d7..5882bfc42 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -815,10 +815,7 @@ impl<'a> DemBuilder<'a> { self.exact_replacement_branch_effect(context, *request, &label)?; } } - return Err(DemBuilderError::ConfigurationError(format!( - "exact_branch_replay has circuit context and identified {} two-qubit replay request(s), but exact replacement-branch DEM emission is not implemented yet; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations", - requests.len() - ))); + return Ok(()); } return Err(DemBuilderError::ConfigurationError( "exact_branch_replay for starred p2 replacement branches requires a circuit-aware exact branch provider; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations" @@ -1013,6 +1010,13 @@ impl<'a> DemBuilder<'a> { meas_to_observables, ); } + if self.noise.p2_replacement_approximation + == ReplacementBranchApproximation::ExactBranchReplay + { + self.process_two_qubit_exact_replacement_branches_source_tracked( + loc1_idx, loc2_idx, dem, + ); + } } } @@ -1080,6 +1084,66 @@ impl<'a> DemBuilder<'a> { } } + /// Processes starred two-qubit replacement branches by replaying the exact + /// omitted-gate branch against detector/observable metadata. + fn process_two_qubit_exact_replacement_branches_source_tracked( + &self, + loc1: usize, + loc2: usize, + dem: &mut DetectorErrorModel, + ) { + let Some(weights) = &self.noise.p2_weights else { + return; + }; + if !weights.has_replacement_entries() { + return; + } + + let context = self + .exact_branch_context + .expect("exact_branch_replay was validated with circuit context"); + let loc1_meta = &self.influence_map.locations[loc1]; + let loc2_meta = &self.influence_map.locations[loc2]; + let request = ExactBranchReplayRequest { + gate_node: loc1_meta.node, + gate_type: loc1_meta.gate_type, + loc_indices: [loc1, loc2], + }; + + for (replacement_pauli, relative_probability) in weights.replacement_entries() { + if *relative_probability <= 0.0 { + continue; + } + let label = two_qubit_label_for_replay(replacement_pauli) + .expect("exact_branch_replay replacement Pauli was validated"); + let (p1, p2) = two_qubit_label_to_pauli_indices(&label) + .expect("exact_branch_replay label must be a two-qubit Pauli"); + let effect = self + .exact_replacement_branch_effect(context, request, &label) + .expect("exact_branch_replay effect was validated"); + if effect.is_empty() { + continue; + } + + let source_locations = [loc1, loc2]; + let source_paulis = [Pauli::from_u8(p1), Pauli::from_u8(p2)]; + let source_gate_types = [loc1_meta.gate_type, loc2_meta.gate_type]; + let source_before_flags = [loc1_meta.before, loc2_meta.before]; + dem.add_direct_contribution_with_source( + effect, + self.noise.p2 * *relative_probability, + SourceMetadata::new( + &source_locations, + &source_paulis, + &source_gate_types, + &source_before_flags, + ) + .with_direct_source_family(DirectSourceFamily::TwoLocationExactReplacementBranch) + .with_replacement_branch(), + ); + } + } + /// Processes a measurement fault with source tracking. fn process_meas_fault_source_tracked( &self, @@ -3265,24 +3329,105 @@ mod tests { } #[test] - fn test_from_circuit_exact_branch_replay_attaches_context_but_still_rejects() { + fn test_from_circuit_exact_branch_replay_emits_omitted_gate_effect() { use crate::fault_tolerance::dem_builder::PauliWeights; - use pecos_core::pauli::Z; - use pecos_quantum::DagCircuit; + use pecos_core::PauliString; + use pecos_quantum::{Attribute, DagCircuit}; let mut circuit = DagCircuit::new(); circuit.pz(&[0, 1]); - circuit.szz(&[(0, 1)]); + circuit.x(&[0]); + circuit.cx(&[(0, 1)]); + circuit.mz(&[1]); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); let noise = NoiseConfig::new(0.0, 0.01, 0.0, 0.0) - .set_p2_weights(PauliWeights::with_replacement([], [(Z(0) & Z(1), 1.0)])) + .set_p2_weights(PauliWeights::with_replacement( + [], + [(PauliString::identity(), 1.0)], + )) + .set_p2_replacement_approximation(ReplacementBranchApproximation::ExactBranchReplay); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("exact branch replay should emit representable branch effects"); + + let contributions = dem.contributions_for_effect(&[0], &[]); + assert_eq!(contributions.len(), 1); + assert!((contributions[0].probability - 0.01).abs() < 1.0e-12); + assert!(contributions[0].replacement_branch); + assert_eq!( + contributions[0].direct_source_family, + Some(DirectSourceFamily::TwoLocationExactReplacementBranch) + ); + assert!( + contributions[0] + .paulis + .iter() + .all(|pauli| *pauli == Pauli::I), + "omission-only replacement branch should be recorded as *II" + ); + } + + #[test] + fn test_from_circuit_exact_branch_replay_skips_empty_replacement_effect() { + use crate::fault_tolerance::dem_builder::PauliWeights; + use pecos_core::pauli::X; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0, 1]); + circuit.x(&[0]); + circuit.cx(&[(0, 1)]); + circuit.mz(&[1]); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + + let noise = NoiseConfig::new(0.0, 0.01, 0.0, 0.0) + .set_p2_weights(PauliWeights::with_replacement([], [(X(1), 1.0)])) + .set_p2_replacement_approximation(ReplacementBranchApproximation::ExactBranchReplay); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("exact branch replay should allow empty representable effects"); + + assert_eq!(dem.num_contributions(), 0); + } + + #[test] + fn test_from_circuit_exact_branch_replay_rejects_dependency_changing_branch() { + use crate::fault_tolerance::dem_builder::PauliWeights; + use pecos_core::PauliString; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0, 1]); + circuit.h(&[0]); + circuit.cx(&[(0, 1)]); + circuit.mz(&[1]); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + + let noise = NoiseConfig::new(0.0, 0.01, 0.0, 0.0) + .set_p2_weights(PauliWeights::with_replacement( + [], + [(PauliString::identity(), 1.0)], + )) .set_p2_replacement_approximation(ReplacementBranchApproximation::ExactBranchReplay); let err = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) - .expect_err("exact branch replay must remain fail-loud until emission is implemented"); + .expect_err("dependency-changing replacement branches must stay fail-loud"); assert!( - err.to_string().contains("has circuit context"), + err.to_string().contains("not representable"), "unexpected error: {err}", ); } From b30b093bc4133a42903133c9f5753bbbf0102d53 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 31 May 2026 15:18:53 -0600 Subject: [PATCH 025/150] Guard LogicalSubgraphDecoder distance suppression (xfail-strict): documents the hyperedge-decomposition bug capping suppression past d~3 --- ...test_logical_subgraph_region_comparison.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) 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 e2a7edab2..460774fa4 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 @@ -241,5 +241,45 @@ def test_bp_inner_is_lower_ler_default(): assert abs(bp_ler - exact_ler) <= 0.0005, f"bp={bp_ler:.5f} exact={exact_ler:.5f}" +@pytest.mark.xfail( + strict=True, + reason=( + "KNOWN BUG: LogicalSubgraphDecoder does not achieve distance suppression past " + "d~3. It builds each subgraph by PROJECTING undecomposed hyperedges (Y / " + "correlated errors, ~50-70% of mechanisms) onto the region, instead of " + "decomposing them into matching-optimal graphlike edges (a 2-det edge plus " + "boundary edges) the way stim's decompose_errors / lomatching's " + "get_circuit_subgraph does. The observing region is correct (matches lomatching " + "exactly at d=3/5/7) but the edges are wrong, so MWPM cannot suppress. lomatching " + "MoMatching drives memory LER to 0 at d=7 on the same circuits; PECOS does not. " + "Fix = port matching-oriented hyperedge decomposition into subgraph construction. " + "See pecos-docs/design/logical-subgraph-backprop-region-builder.md. When the fix " + "lands this xfail flips to a pass (strict) -- remove the marker then." + ), +) +def test_distance_suppression_memory(): + """A fault-tolerant decoder must drive LER DOWN as code distance grows below + threshold. This guards against the hyperedge-decomposition bug regressing + further and turns green the moment the fix is in.""" + + def mem_ler(d, p, n, seed): + patch = SurfacePatch.create(d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", d, "Z") + dem = b.build_dem(p1=p, p2=p, p_meas=p) + sc = b.stab_coords() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) + return LogicalSubgraphDecoder(dem, sc).decode_count(batch) / n + + p, n = 0.001, 60000 + ler_d3 = mem_ler(3, p, n, seed=1) + ler_d5 = mem_ler(5, p, n, seed=1) + # Below threshold, d=5 must beat d=3 by a clear margin (lomatching: ~13x). + assert ler_d5 < ler_d3 * 0.7, ( + f"no distance suppression: d3={ler_d3:.5f} d5={ler_d5:.5f}" + ) + + if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) From 7030f5669a3afd70a2074f178faccc2f374daef4 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 16:37:59 -0600 Subject: [PATCH 026/150] Thread exact branch context through surface DEMs --- .../fault_tolerance/dem_builder/builder.rs | 6 +++++- .../src/fault_tolerance_bindings.rs | 20 +++++++++++++++++++ .../src/pecos/qec/surface/decode.py | 3 +++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 5882bfc42..c5f0175d7 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -290,7 +290,11 @@ impl<'a> DemBuilder<'a> { self } - fn with_exact_branch_replay_context(mut self, circuit: &'a pecos_quantum::DagCircuit) -> Self { + #[must_use] + pub fn with_exact_branch_replay_context( + mut self, + circuit: &'a pecos_quantum::DagCircuit, + ) -> Self { self.exact_branch_context = Some(ExactBranchReplayContext { circuit }); self } diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 81a8563b7..6942b5a40 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -1432,6 +1432,7 @@ pub struct PyDemBuilder { observables_json: Option, num_measurements: Option, measurement_order: Option>, + exact_branch_circuit: Option, } #[pymethods] @@ -1449,6 +1450,7 @@ impl PyDemBuilder { observables_json: None, num_measurements: None, measurement_order: None, + exact_branch_circuit: None, } } @@ -1564,6 +1566,20 @@ impl PyDemBuilder { slf } + /// Attach the original circuit for exact replacement-branch replay. + /// + /// This is only needed when using `p2_replacement_approximation="exact_branch_replay"` + /// with starred p2 replacement branches. The influence map still determines + /// ordinary Pauli propagation; the circuit context lets PECOS replay the + /// omitted-gate branch and fail loudly if it is not DEM-representable. + fn with_exact_branch_replay_circuit<'py>( + mut slf: PyRefMut<'py, Self>, + circuit: &crate::dag_circuit_bindings::PyDagCircuit, + ) -> PyRefMut<'py, Self> { + slf.exact_branch_circuit = Some(circuit.inner.clone()); + slf + } + /// Build the Detector Error Model. /// /// Returns: @@ -1577,6 +1593,10 @@ impl PyDemBuilder { let mut builder = RustDemBuilder::new(&self.influence_map).with_noise_config(self.noise.clone()); + if let Some(ref circuit) = self.exact_branch_circuit { + builder = builder.with_exact_branch_replay_context(circuit); + } + if let Some(num) = self.num_measurements { builder = builder.with_num_measurements(num); } diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index cfb9a3e3f..cd442e62c 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -220,6 +220,7 @@ class DecodingResult: class _CachedNativeSurfaceTopology: """Topology-only native model data reused across noise configurations.""" + dag_circuit: Any influence_map: Any detectors_json: str observables_json: str @@ -1296,6 +1297,7 @@ def _surface_native_topology( num_measurements = int(tc.get_meta("num_measurements") or str(len(measurement_order))) return _CachedNativeSurfaceTopology( + dag_circuit=dag, influence_map=influence_map, detectors_json=detectors_json, observables_json=observables_json, @@ -1356,6 +1358,7 @@ def _dem_string_from_cached_surface_topology( p2_weights=_p2_weights_dict(noise.p2_weights), p2_replacement_approximation=noise.p2_replacement_approximation, ) + .with_exact_branch_replay_circuit(topology.dag_circuit) .with_num_measurements(topology.num_measurements) .with_measurement_order(list(topology.measurement_order)) .with_detectors_json(topology.detectors_json) From e90ba04842ce03f3e14d6dd532c9744e7680d8ec Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 17:06:24 -0600 Subject: [PATCH 027/150] Document exact branch replay surface DEMs --- python/quantum-pecos/src/pecos/qec/surface/decode.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index cd442e62c..0754a16f7 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -101,9 +101,9 @@ class NoiseModel: replacement labels. ``"pauli_twirl_omitted_gate"`` convolves with the omitted two-qubit gate's Pauli twirl; ``"branch_impact"`` evaluates starred entries as replacement branch impacts; - ``"exact_branch_replay"`` is reserved for a future circuit-aware - exact replay provider and currently fails loudly for starred - entries; + ``"exact_branch_replay"`` uses the traced circuit context to replay + omitted-gate branches at concrete two-qubit gate locations and + fails loudly when a branch is not DEM-representable; ``"ignore_gate_removal"`` treats starred entries like plain post-gate Pauli entries. p_meas: Measurement error rate. From fc48370a9680cf3245c6c0876baf547489cc73ec Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 31 May 2026 17:56:42 -0600 Subject: [PATCH 028/150] Fix LogicalSubgraphDecoder distance suppression: default to exact-MWPM inner (fusion_blossom); native union-find inners don't suppress at d>=5 --- .../src/fault_tolerance_bindings.rs | 15 +-- ...test_logical_subgraph_region_comparison.py | 93 +++++++------------ 2 files changed, 43 insertions(+), 65 deletions(-) diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 39d877bcc..ef0ac6c0e 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4435,13 +4435,16 @@ pub struct PyLogicalSubgraphDecoder { #[pymethods] impl PyLogicalSubgraphDecoder { - // Default inner is `pecos_uf:bp` (native union-find + belief propagation): it - // reaches exact-MWPM LER -- ~13-26% lower than `pecos_uf:fast`, the gap - // growing with code distance -- at ~3x the speed of pymatching, with no - // external decoder dependency. See + // Default inner is `fusion_blossom_serial` (exact MWPM): it achieves + // distance suppression (LER drops with code distance), matching lomatching + // exactly (memory d=7 -> 0 logical errors). The native union-find inners + // (`pecos_uf:fast`, `pecos_uf:bp`) decode WELL at d=3 but DO NOT suppress at + // d>=5 -- a bug in the union-find decoder, not the subgraph construction + // (region + edge topology are correct, == lomatching). Correctness is + // non-negotiable here, so the default must be an exact MWPM inner. See // pecos-docs/design/logical-subgraph-backprop-region-builder.md. #[new] - #[pyo3(signature = (dem, stab_coords, inner_decoder="pecos_uf:bp", max_time_radius=None))] + #[pyo3(signature = (dem, stab_coords, inner_decoder="fusion_blossom_serial", max_time_radius=None))] fn new( dem: &str, stab_coords: Vec>, @@ -4492,7 +4495,7 @@ impl PyLogicalSubgraphDecoder { /// construction (e.g. the paper's back-propagation / detecting-region set) /// and decode with the same machinery for direct comparison. #[staticmethod] - #[pyo3(signature = (dem, membership, inner_decoder="pecos_uf:bp"))] + #[pyo3(signature = (dem, membership, inner_decoder="fusion_blossom_serial"))] fn from_membership( dem: &str, membership: Vec>, 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 460774fa4..11c26744f 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 @@ -212,72 +212,47 @@ def test_coordinate_beats_backprop_seeded_groupfill(): ) -def test_bp_inner_is_lower_ler_default(): - """The default inner decoder is the native UF+BP (`pecos_uf:bp`), which - reaches exact-MWPM accuracy and decodes with lower LER than the older - `pecos_uf:fast` union-find. The gap grows with distance; assert it at d=3.""" - b = _cx_circuit() - dem = b.build_dem(p1=0.002, p2=0.002, p_meas=0.002) +def _mem_ler(d, p, n, seed, inner=None): + patch = SurfacePatch.create(d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", d, "Z") + dem = b.build_dem(p1=p, p2=p, p_meas=p) sc = b.stab_coords() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) + dec = LogicalSubgraphDecoder(dem, sc) if inner is None else LogicalSubgraphDecoder(dem, sc, inner) + return dec.decode_count(batch) / n + - n = 40000 - batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=4) - - default = LogicalSubgraphDecoder(dem, sc) # no inner -> the new default - bp = LogicalSubgraphDecoder(dem, sc, "pecos_uf:bp") - fast = LogicalSubgraphDecoder(dem, sc, "pecos_uf:fast") - exact = LogicalSubgraphDecoder(dem, sc, "pymatching") - - default_ler = default.decode_count(batch) / n - bp_ler = bp.decode_count(batch) / n - fast_ler = fast.decode_count(batch) / n - exact_ler = exact.decode_count(batch) / n - - # The default IS pecos_uf:bp. - assert default_ler == bp_ler - # BP inner beats the old fast default... - assert bp_ler < fast_ler, f"bp={bp_ler:.5f} fast={fast_ler:.5f}" - # ...and matches exact MWPM (native UF+BP reaches the optimum on graphlike subgraphs). - assert abs(bp_ler - exact_ler) <= 0.0005, f"bp={bp_ler:.5f} exact={exact_ler:.5f}" - - -@pytest.mark.xfail( - strict=True, - reason=( - "KNOWN BUG: LogicalSubgraphDecoder does not achieve distance suppression past " - "d~3. It builds each subgraph by PROJECTING undecomposed hyperedges (Y / " - "correlated errors, ~50-70% of mechanisms) onto the region, instead of " - "decomposing them into matching-optimal graphlike edges (a 2-det edge plus " - "boundary edges) the way stim's decompose_errors / lomatching's " - "get_circuit_subgraph does. The observing region is correct (matches lomatching " - "exactly at d=3/5/7) but the edges are wrong, so MWPM cannot suppress. lomatching " - "MoMatching drives memory LER to 0 at d=7 on the same circuits; PECOS does not. " - "Fix = port matching-oriented hyperedge decomposition into subgraph construction. " - "See pecos-docs/design/logical-subgraph-backprop-region-builder.md. When the fix " - "lands this xfail flips to a pass (strict) -- remove the marker then." - ), -) def test_distance_suppression_memory(): """A fault-tolerant decoder must drive LER DOWN as code distance grows below - threshold. This guards against the hyperedge-decomposition bug regressing - further and turns green the moment the fix is in.""" - - def mem_ler(d, p, n, seed): - patch = SurfacePatch.create(d) - b = LogicalCircuitBuilder() - b.add_patch(patch, "A") - b.add_memory("A", d, "Z") - dem = b.build_dem(p1=p, p2=p, p_meas=p) - sc = b.stab_coords() - batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) - return LogicalSubgraphDecoder(dem, sc).decode_count(batch) / n - + threshold. The default inner (exact MWPM, fusion_blossom) suppresses, + matching lomatching (d=7 -> 0). Guards against the default reverting to a + non-suppressing inner.""" p, n = 0.001, 60000 - ler_d3 = mem_ler(3, p, n, seed=1) - ler_d5 = mem_ler(5, p, n, seed=1) + ler_d3 = _mem_ler(3, p, n, seed=1) + ler_d5 = _mem_ler(5, p, n, seed=1) # Below threshold, d=5 must beat d=3 by a clear margin (lomatching: ~13x). assert ler_d5 < ler_d3 * 0.7, ( - f"no distance suppression: d3={ler_d3:.5f} d5={ler_d5:.5f}" + f"no distance suppression with default inner: d3={ler_d3:.5f} d5={ler_d5:.5f}" + ) + + +def test_native_union_find_inner_does_not_suppress(): + """KNOWN BUG (separately tracked): PECOS's native union-find inner decoders + (`pecos_uf:*`) decode well at d=3 but do NOT achieve distance suppression at + d>=5 -- which is why the LogicalSubgraphDecoder default is exact MWPM, not + `pecos_uf`. The subgraph region + edge topology are correct (== lomatching); + the failure is in the UF decoder itself. This pins the bug; when UF is fixed + (d=5 LER < d=3), this assertion flips and the test should be updated. + See pecos-docs/design/logical-subgraph-backprop-region-builder.md.""" + p, n = 0.001, 60000 + uf_d3 = _mem_ler(3, p, n, seed=1, inner="pecos_uf:bp") + uf_d5 = _mem_ler(5, p, n, seed=1, inner="pecos_uf:bp") + # Currently the UF inner does NOT suppress: d=5 is not meaningfully below d=3. + assert uf_d5 >= uf_d3 * 0.7, ( + f"union-find inner now suppresses (d3={uf_d3:.5f} d5={uf_d5:.5f}) -- " + "UF bug appears fixed; update this test and reconsider the default inner." ) From 1cd1e2f6aae4bbc5ae9fdc40f1e111a27b7f50e7 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 23:20:11 -0600 Subject: [PATCH 029/150] Speed up exact branch DEM replay --- .../fault_tolerance/dem_builder/builder.rs | 183 +++++++++++++----- 1 file changed, 139 insertions(+), 44 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 c5f0175d7..20428e3e7 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -26,7 +26,9 @@ use pecos_core::BitSet; use pecos_core::gate_type::GateType; use pecos_simulators::symbolic_sparse_stab::MeasurementHistory; use smallvec::SmallVec; +use std::cell::RefCell; use std::collections::BTreeMap; +use std::rc::Rc; // ============================================================================ // JSON Parsing Types @@ -116,6 +118,10 @@ pub struct DemBuilder<'a> { measurement_order: Option>, /// Optional circuit context for future exact replacement-branch replay. exact_branch_context: Option>, + /// Ideal symbolic measurement history shared by exact branch replays. + exact_ideal_history_cache: RefCell>>, + /// Per-gate cache for exact replacement-branch replay effects. + exact_branch_cache: RefCell>, } #[derive(Debug, Clone, Copy)] @@ -123,6 +129,12 @@ struct ExactBranchReplayContext<'a> { circuit: &'a pecos_quantum::DagCircuit, } +#[derive(Debug, Clone)] +struct ExactBranchReplayAnalysis { + base_effect: FaultMechanism, + branch_effects: [[FaultMechanism; 4]; 4], +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ExactBranchReplayRequest { gate_node: usize, @@ -273,9 +285,15 @@ impl<'a> DemBuilder<'a> { num_measurements: influence_map.measurements.len(), measurement_order: None, exact_branch_context: None, + exact_ideal_history_cache: RefCell::new(None), + exact_branch_cache: RefCell::new(BTreeMap::new()), } } + fn clear_exact_branch_cache(&mut self) { + self.exact_branch_cache.get_mut().clear(); + } + /// Sets the noise configuration from individual parameters. #[must_use] pub fn with_noise(mut self, p1: f64, p2: f64, p_meas: f64, p_prep: f64) -> Self { @@ -296,6 +314,8 @@ impl<'a> DemBuilder<'a> { circuit: &'a pecos_quantum::DagCircuit, ) -> Self { self.exact_branch_context = Some(ExactBranchReplayContext { circuit }); + self.exact_ideal_history_cache.get_mut().take(); + self.clear_exact_branch_cache(); self } @@ -435,6 +455,7 @@ impl<'a> DemBuilder<'a> { #[must_use] pub fn with_num_measurements(mut self, num: usize) -> Self { self.num_measurements = num; + self.clear_exact_branch_cache(); self } @@ -459,6 +480,7 @@ impl<'a> DemBuilder<'a> { #[must_use] pub fn with_measurement_order(mut self, order: Vec) -> Self { self.measurement_order = Some(order); + self.clear_exact_branch_cache(); self } @@ -479,6 +501,7 @@ impl<'a> DemBuilder<'a> { /// Returns an error if the JSON is malformed. pub fn with_detectors_json(mut self, json: &str) -> Result { self.detectors = parse_detectors_json(json)?; + self.clear_exact_branch_cache(); Ok(self) } @@ -494,6 +517,7 @@ impl<'a> DemBuilder<'a> { /// Returns an error if the JSON is malformed. pub fn with_observables_json(mut self, json: &str) -> Result { self.observables = parse_observables_json(json)?; + self.clear_exact_branch_cache(); Ok(self) } @@ -510,6 +534,7 @@ impl<'a> DemBuilder<'a> { meas_ids: Vec::new(), }) .collect(); + self.clear_exact_branch_cache(); self } @@ -809,10 +834,6 @@ impl<'a> DemBuilder<'a> { .as_ref() .expect("replacement entries exist"); for request in &requests { - let _branch_locs = context - .omitted_branch_location_pair(*request, &self.influence_map.locations)?; - let _omitted_effect = - self.exact_omitted_branch_base_effect(context, *request)?; for (replacement_pauli, _weight) in weights.replacement_entries() { let label = two_qubit_label_for_replay(replacement_pauli)?; let _replacement_effect = @@ -829,18 +850,38 @@ impl<'a> DemBuilder<'a> { Ok(()) } + #[cfg(test)] fn exact_omitted_branch_base_effect( &self, context: ExactBranchReplayContext<'_>, request: ExactBranchReplayRequest, ) -> Result { + let branch = circuit_with_omitted_two_qubit_gate(context.circuit, request.gate_node)?; + self.exact_omitted_branch_base_effect_for_branch(context, request, &branch) + } + + fn exact_omitted_branch_base_effect_for_branch( + &self, + context: ExactBranchReplayContext<'_>, + request: ExactBranchReplayRequest, + branch: &pecos_quantum::DagCircuit, + ) -> Result { + use crate::fault_tolerance::influence_builder::InfluenceBuilder; + + let ideal_history = self.exact_ideal_measurement_history(context); + let branch_info = InfluenceBuilder::new(branch).run_symbolic_simulation(); let mut triggered_dets: SmallVec<[u32; 4]> = SmallVec::new(); let mut triggered_obs: SmallVec<[u32; 2]> = SmallVec::new(); for detector in &self.detectors { let indices = self.measurement_indices_from_refs(&detector.records, &detector.meas_ids)?; - if context.omitted_branch_flips_measurement_parity(request, &indices)? { + if omitted_branch_flips_measurement_parity_from_histories( + request, + ideal_history.as_ref(), + &branch_info.history, + &indices, + )? { xor_toggle_4(&mut triggered_dets, detector.id); } } @@ -848,7 +889,12 @@ impl<'a> DemBuilder<'a> { for observable in &self.observables { let indices = self.measurement_indices_from_refs(&observable.records, &observable.meas_ids)?; - if context.omitted_branch_flips_measurement_parity(request, &indices)? { + if omitted_branch_flips_measurement_parity_from_histories( + request, + ideal_history.as_ref(), + &branch_info.history, + &indices, + )? { xor_toggle_2(&mut triggered_obs, observable.id); } } @@ -862,25 +908,44 @@ impl<'a> DemBuilder<'a> { )) } - fn exact_replacement_branch_effect( + fn exact_ideal_measurement_history( + &self, + context: ExactBranchReplayContext<'_>, + ) -> Rc { + use crate::fault_tolerance::influence_builder::InfluenceBuilder; + + if let Some(cached) = self.exact_ideal_history_cache.borrow().as_ref().cloned() { + return cached; + } + + let history = Rc::new( + InfluenceBuilder::new(context.circuit) + .run_symbolic_simulation() + .history, + ); + *self.exact_ideal_history_cache.borrow_mut() = Some(history.clone()); + history + } + + fn exact_branch_analysis( &self, context: ExactBranchReplayContext<'_>, request: ExactBranchReplayRequest, - replacement_pauli_label: &str, - ) -> Result { + ) -> Result { use crate::fault_tolerance::propagator::DagFaultAnalyzer; - let (p1, p2) = two_qubit_label_to_pauli_indices(replacement_pauli_label).ok_or_else(|| { - DemBuilderError::ConfigurationError(format!( - "exact_branch_replay replacement Pauli label {replacement_pauli_label:?} is not a two-qubit Pauli label" - )) - })?; - let base_effect = self.exact_omitted_branch_base_effect(context, request)?; - if p1 == 0 && p2 == 0 { - return Ok(base_effect); + if let Some(cached) = self + .exact_branch_cache + .borrow() + .get(&request.gate_node) + .cloned() + { + return Ok(cached); } let branch = circuit_with_omitted_two_qubit_gate(context.circuit, request.gate_node)?; + let base_effect = + self.exact_omitted_branch_base_effect_for_branch(context, request, &branch)?; let branch_map = DagFaultAnalyzer::new(&branch).build_influence_map(); let branch_locs = identity_location_pair_for_request( request, @@ -895,7 +960,35 @@ impl<'a> DemBuilder<'a> { &meas_to_detectors, &meas_to_observables, ); - Ok(base_effect.xor(&branch_effects[p1 as usize][p2 as usize])) + let analysis = ExactBranchReplayAnalysis { + base_effect, + branch_effects, + }; + self.exact_branch_cache + .borrow_mut() + .insert(request.gate_node, analysis.clone()); + Ok(analysis) + } + + fn exact_replacement_branch_effect( + &self, + context: ExactBranchReplayContext<'_>, + request: ExactBranchReplayRequest, + replacement_pauli_label: &str, + ) -> Result { + let (p1, p2) = two_qubit_label_to_pauli_indices(replacement_pauli_label).ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "exact_branch_replay replacement Pauli label {replacement_pauli_label:?} is not a two-qubit Pauli label" + )) + })?; + let analysis = self.exact_branch_analysis(context, request)?; + if p1 == 0 && p2 == 0 { + return Ok(analysis.base_effect); + } + + Ok(analysis + .base_effect + .xor(&analysis.branch_effects[p1 as usize][p2 as usize])) } fn num_influence_dem_outputs(&self) -> usize { @@ -2259,10 +2352,12 @@ impl ExactBranchReplayContext<'_> { loc1.node, loc1.gate_type, loc2.gate_type ))); } - let branch = circuit_with_omitted_two_qubit_gate(self.circuit, loc1.node)?; - let replacement = branch - .gate(loc1.node) - .expect("omitted branch preserves the original gate node"); + let replacement = self.circuit.gate(loc1.node).ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "exact_branch_replay expected an original gate at node {}", + loc1.node + )) + })?; if !loc1 .qubits .iter() @@ -2283,6 +2378,7 @@ impl ExactBranchReplayContext<'_> { Ok(requests) } + #[cfg(test)] fn omitted_branch_location_pair( &self, request: ExactBranchReplayRequest, @@ -2294,30 +2390,23 @@ impl ExactBranchReplayContext<'_> { let branch_map = DagFaultAnalyzer::new(&branch).build_influence_map(); identity_location_pair_for_request(request, original_locations, &branch_map.locations) } +} - fn omitted_branch_flips_measurement_parity( - &self, - request: ExactBranchReplayRequest, - measurement_indices: &[usize], - ) -> Result { - use crate::fault_tolerance::influence_builder::InfluenceBuilder; - - let ideal_info = InfluenceBuilder::new(self.circuit).run_symbolic_simulation(); - let branch = circuit_with_omitted_two_qubit_gate(self.circuit, request.gate_node)?; - let branch_info = InfluenceBuilder::new(&branch).run_symbolic_simulation(); - - let ideal = - measurement_parity_expression(&ideal_info.history, measurement_indices, "ideal")?; - let branch = - measurement_parity_expression(&branch_info.history, measurement_indices, "branch")?; - if ideal.dependencies != branch.dependencies { - return Err(DemBuilderError::ConfigurationError(format!( - "exact_branch_replay omitted gate at node {} changes measurement dependencies for parity {:?}; this branch is not representable as a single deterministic DEM event", - request.gate_node, measurement_indices - ))); - } - Ok(ideal.flip ^ branch.flip) +fn omitted_branch_flips_measurement_parity_from_histories( + request: ExactBranchReplayRequest, + ideal_history: &MeasurementHistory, + branch_history: &MeasurementHistory, + measurement_indices: &[usize], +) -> Result { + let ideal = measurement_parity_expression(ideal_history, measurement_indices, "ideal")?; + let branch = measurement_parity_expression(branch_history, measurement_indices, "branch")?; + if ideal.dependencies != branch.dependencies { + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay omitted gate at node {} changes measurement dependencies for parity {:?}; this branch is not representable as a single deterministic DEM event", + request.gate_node, measurement_indices + ))); } + Ok(ideal.flip ^ branch.flip) } fn measurement_parity_expression( @@ -3570,6 +3659,8 @@ mod tests { .exact_replacement_branch_effect(context, request, "II") .expect("omission-only branch should be deterministic here"); assert_eq!(omitted_only.detectors.as_slice(), &[0]); + assert!(builder.exact_ideal_history_cache.borrow().is_some()); + assert_eq!(builder.exact_branch_cache.borrow().len(), 1); let omitted_then_target_x = builder .exact_replacement_branch_effect(context, request, "IX") @@ -3578,6 +3669,10 @@ mod tests { omitted_then_target_x.is_empty(), "target X after omitted CX restores the ideal target measurement" ); + assert_eq!(builder.exact_branch_cache.borrow().len(), 1); + + let builder = builder.with_detectors_json("[]").unwrap(); + assert!(builder.exact_branch_cache.borrow().is_empty()); } #[test] From b2b52735e02343461c1ce3cf9b98300717864546 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 3 Jun 2026 19:32:40 -0600 Subject: [PATCH 030/150] Fix UF predecoder to fall through for non-provably-optimal isolated/pair defects so it suppresses with distance, revert LogicalSubgraphDecoder default inner to pecos_uf:bp, add decode_each/_UfDebug diagnostics and windowed pin test --- crates/pecos-uf-decoder/src/decoder.rs | 113 ++++++++++-------- .../src/fault_tolerance_bindings.rs | 84 +++++++++++-- ...test_logical_subgraph_region_comparison.py | 84 ++++++++++--- 3 files changed, 210 insertions(+), 71 deletions(-) diff --git a/crates/pecos-uf-decoder/src/decoder.rs b/crates/pecos-uf-decoder/src/decoder.rs index 1ce43de0d..35e610f31 100644 --- a/crates/pecos-uf-decoder/src/decoder.rs +++ b/crates/pecos-uf-decoder/src/decoder.rs @@ -456,8 +456,9 @@ impl UfDecoder { let root = component[di]; if comp_size[root] == 1 { - // Isolated defect: match to boundary. - obs_mask ^= self.predecode_single(defect_list[di]); + // Isolated defect: provably optimal only if its lightest edge + // is a direct boundary edge; otherwise fall through (`?`). + obs_mask ^= self.predecode_single(defect_list[di])?; handled[di] = true; } else if comp_size[root] == 2 { // Find the other defect in this component. @@ -473,61 +474,56 @@ impl UfDecoder { let d0 = defect_list[di]; let d1 = defect_list[ni]; - // Find lightest direct edge and lightest boundary alternatives. - let mut direct_w = f64::INFINITY; - let mut direct_obs = 0u64; - for &(e, nbr) in self.adj(d0 as usize) { - if nbr == d1 && self.edges[e].weight < direct_w { - direct_w = self.edges[e].weight; - direct_obs = self.edges[e].obs_mask; + // Pairing the two defects directly is provably the global + // minimum-weight correction ONLY when the shared edge is the + // lightest incident edge of BOTH defects (a "mutually nearest" + // adjacent pair -- the signature of a single bulk fault): + // - any d0-d1 path costs >= each defect's lightest edge, so + // the direct edge is the cheapest pairing, and + // - any split to the boundary costs >= lb0 + lb1 = 2*direct_w + // > direct_w. + // In every other case the boundary route (possibly a + // logical-flipping bulk path) may be cheaper, so we fall through + // to the full decoder rather than guess from direct edges alone. + let e0 = self.adj(d0 as usize).first().copied(); + let e1 = self.adj(d1 as usize).first().copied(); + match (e0, e1) { + (Some((edge_idx, nbr0)), Some((_, nbr1))) if nbr0 == d1 && nbr1 == d0 => { + obs_mask ^= self.edges[edge_idx].obs_mask; + handled[di] = true; + handled[ni] = true; } + _ => return None, } - - let mut b0_w = f64::INFINITY; - let mut b0_obs = 0u64; - for &(e, nbr) in self.adj(d0 as usize) { - if nbr == boundary && self.edges[e].weight < b0_w { - b0_w = self.edges[e].weight; - b0_obs = self.edges[e].obs_mask; - } - } - - let mut b1_w = f64::INFINITY; - let mut b1_obs = 0u64; - for &(e, nbr) in self.adj(d1 as usize) { - if nbr == boundary && self.edges[e].weight < b1_w { - b1_w = self.edges[e].weight; - b1_obs = self.edges[e].obs_mask; - } - } - - // Pick min-weight correction. - if direct_w <= b0_w + b1_w { - obs_mask ^= direct_obs; - } else { - obs_mask ^= b0_obs ^ b1_obs; - } - - handled[di] = true; - handled[ni] = true; } } Some(obs_mask) } - /// Predecode: single defect matches to boundary. - fn predecode_single(&self, defect: u32) -> u64 { + /// Predecode an isolated single defect, if it is provably optimal. + /// + /// The optimal correction for an isolated defect is the minimum-weight path + /// to the boundary, whose weight is at least that of the defect's lightest + /// incident edge (adjacency is sorted by weight, so that is `adj[0]`). The + /// predecoder can therefore resolve it cheaply ONLY when the lightest edge + /// goes directly to the boundary -- then that single edge IS the optimal + /// path. Otherwise the optimal path routes through the bulk (and may flip an + /// observable that a direct boundary edge would miss), so we return `None` + /// and fall through to the full decoder. + /// + /// (Returning a direct boundary edge that is not the lightest -- or `0` when + /// no direct boundary edge exists -- was the historical bug that broke + /// distance suppression: e.g. a bulk defect whose min-weight correction is a + /// logical-flipping path was silently decoded as no-flip.) + fn predecode_single(&self, defect: u32) -> Option { let boundary = self.num_detectors as u32; - // Find the lightest boundary edge from this defect. - // Adjacency is sorted by weight, so iterate and pick first boundary edge. - for &(edge_idx, neighbor) in self.adj(defect as usize) { - if neighbor == boundary { - return self.edges[edge_idx].obs_mask; - } + let &(edge_idx, neighbor) = self.adj(defect as usize).first()?; + if neighbor == boundary { + Some(self.edges[edge_idx].obs_mask) + } else { + None } - // No boundary edge found (shouldn't happen for valid surface codes). - 0 } /// Returns true if a cluster (given by its root) still needs to grow. @@ -959,6 +955,29 @@ impl UfDecoder { self.edges.get(edge_idx).map_or(0, |e| e.obs_mask) } + /// Debug: decode forcing the full grow+peel path (bypassing the + /// predecoder), returning the observable mask and the correction edges as + /// `(node1, node2, obs_mask, weight)`. For diagnostics and tests. + pub fn decode_full_with_correction(&mut self, syndrome: &[u8]) -> (u64, Vec<(u32, u32, u64, f64)>) { + self.reset(); + for (i, &v) in syndrome.iter().enumerate() { + if v != 0 && i < self.num_detectors { + self.parity[i] = true; + self.is_defect[i] = true; + } + } + self.grow_clusters(); + let (obs, edge_idxs) = self.peel_correction_with_edges(); + let edges = edge_idxs + .iter() + .map(|&i| { + let e = &self.edges[i]; + (e.node1, e.node2, e.obs_mask, e.weight) + }) + .collect(); + (obs, edges) + } + /// Get node1 of an edge. #[must_use] pub fn edge_node1(&self, edge_idx: usize) -> u32 { diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index ef0ac6c0e..2ac86caae 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -2768,6 +2768,30 @@ impl PySampleBatch { Ok(errors) } + /// Decode every shot and return the predicted observable mask per shot. + /// + /// Mirrors `decode_count` but returns the raw per-shot predictions instead + /// of an aggregate error count, so callers can localize disagreements + /// against a reference decoder. + /// + /// Args: + /// dem: DEM string for the decoder. + /// `decoder_type`: Decoder type string. + /// + /// Returns: + /// List of predicted observable masks, one per shot. + #[pyo3(signature = (dem, decoder_type="pymatching"))] + fn decode_each(&self, dem: &str, decoder_type: &str) -> PyResult> { + let mut decoder = create_observable_decoder(dem, decoder_type)?; + let mut predictions = Vec::with_capacity(self.num_shots); + let mut syndrome = vec![0u8; self.num_detectors]; + for i in 0..self.num_shots { + self.extract_syndrome(i, &mut syndrome); + predictions.push(decoder.decode_to_observables(&syndrome).unwrap_or(u64::MAX)); + } + Ok(predictions) + } + /// Parallel decode: distributes samples across rayon workers. /// /// Each worker creates its own decoder instance. Faster for slow decoders. @@ -4308,6 +4332,46 @@ fn assert_dems_equivalent( } } +// ============================================================================= +// UF debug wrapper (diagnostics only) +// ============================================================================= + +/// Debug wrapper over the matching-graph Union-Find decoder. +/// +/// Exposes the full grow+peel correction (bypassing the predecoder) so tests +/// can inspect exactly which edges UF commits and compare against MWPM. +#[pyclass(name = "_UfDebug", module = "pecos_rslib.qec")] +pub struct PyUfDebug { + inner: pecos_decoders::UfDecoder, +} + +#[pymethods] +impl PyUfDebug { + #[new] + #[pyo3(signature = (dem, config="fast"))] + fn new(dem: &str, config: &str) -> PyResult { + let cfg = match config { + "fast" => pecos_decoders::UfDecoderConfig::fast(), + "balanced" => pecos_decoders::UfDecoderConfig::balanced(), + "windowed" => pecos_decoders::UfDecoderConfig::windowed(), + other => { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "unknown UF config: {other}" + ))); + } + }; + let inner = pecos_decoders::UfDecoder::from_dem(dem, cfg) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(Self { inner }) + } + + /// Decode forcing the full grow+peel path (no predecoder). Returns + /// `(obs_mask, [(node1, node2, obs_mask, weight), ...])`. + fn decode_full(&mut self, syndrome: Vec) -> (u64, Vec<(u32, u32, u64, f64)>) { + self.inner.decode_full_with_correction(&syndrome) + } +} + // ============================================================================= // CSS UF Decoder (UIUF) // ============================================================================= @@ -4435,16 +4499,17 @@ pub struct PyLogicalSubgraphDecoder { #[pymethods] impl PyLogicalSubgraphDecoder { - // Default inner is `fusion_blossom_serial` (exact MWPM): it achieves - // distance suppression (LER drops with code distance), matching lomatching - // exactly (memory d=7 -> 0 logical errors). The native union-find inners - // (`pecos_uf:fast`, `pecos_uf:bp`) decode WELL at d=3 but DO NOT suppress at - // d>=5 -- a bug in the union-find decoder, not the subgraph construction - // (region + edge topology are correct, == lomatching). Correctness is - // non-negotiable here, so the default must be an exact MWPM inner. See + // Default inner is `pecos_uf:bp` (native belief-propagation + union-find): + // dependency-free, fast, and it achieves distance suppression (LER drops with + // code distance), tracking exact MWPM closely. The native UF previously did + // NOT suppress at d>=5, so the default was temporarily exact MWPM + // (`fusion_blossom_serial`); that UF bug -- a predecoder that mis-decoded + // isolated defects whose min-weight correction is a bulk path to the boundary + // -- has been fixed (predecoder now falls through to the full grow+peel + // decoder unless provably optimal). See // pecos-docs/design/logical-subgraph-backprop-region-builder.md. #[new] - #[pyo3(signature = (dem, stab_coords, inner_decoder="fusion_blossom_serial", max_time_radius=None))] + #[pyo3(signature = (dem, stab_coords, inner_decoder="pecos_uf:bp", max_time_radius=None))] fn new( dem: &str, stab_coords: Vec>, @@ -4495,7 +4560,7 @@ impl PyLogicalSubgraphDecoder { /// construction (e.g. the paper's back-propagation / detecting-region set) /// and decode with the same machinery for direct comparison. #[staticmethod] - #[pyo3(signature = (dem, membership, inner_decoder="fusion_blossom_serial"))] + #[pyo3(signature = (dem, membership, inner_decoder="pecos_uf:bp"))] fn from_membership( dem: &str, membership: Vec>, @@ -5606,6 +5671,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/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 11c26744f..614aba204 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 @@ -34,7 +34,11 @@ import pytest from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch -from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem +from pecos_rslib.qec import ( + LogicalSubgraphDecoder, + ParsedDem, + WindowedLogicalSubgraphDecoder, +) def _coflip_membership_from_dem(dem_str: str, num_observables: int) -> list[list[int]]: @@ -226,9 +230,9 @@ def _mem_ler(d, p, n, seed, inner=None): def test_distance_suppression_memory(): """A fault-tolerant decoder must drive LER DOWN as code distance grows below - threshold. The default inner (exact MWPM, fusion_blossom) suppresses, - matching lomatching (d=7 -> 0). Guards against the default reverting to a - non-suppressing inner.""" + threshold. The default inner (`pecos_uf:bp`, native BP + union-find) + suppresses, tracking exact MWPM / lomatching (d=7 -> 0). Guards against the + default reverting to a non-suppressing inner.""" p, n = 0.001, 60000 ler_d3 = _mem_ler(3, p, n, seed=1) ler_d5 = _mem_ler(5, p, n, seed=1) @@ -238,21 +242,71 @@ def test_distance_suppression_memory(): ) -def test_native_union_find_inner_does_not_suppress(): - """KNOWN BUG (separately tracked): PECOS's native union-find inner decoders - (`pecos_uf:*`) decode well at d=3 but do NOT achieve distance suppression at - d>=5 -- which is why the LogicalSubgraphDecoder default is exact MWPM, not - `pecos_uf`. The subgraph region + edge topology are correct (== lomatching); - the failure is in the UF decoder itself. This pins the bug; when UF is fixed - (d=5 LER < d=3), this assertion flips and the test should be updated. +def test_native_union_find_inner_suppresses(): + """PECOS's native union-find inner decoders (`pecos_uf:*`) achieve distance + suppression, as a fault-tolerant decoder must. + + This previously did NOT hold: the UF predecoder mis-decoded isolated defects + whose minimum-weight correction is a bulk *path* to the boundary (it only + looked at direct boundary edges, returning a no-flip / wrong-edge result), + which broke suppression at d>=5. Fixed by making the predecoder fall through + to the full grow+peel decoder whenever its shortcut is not provably optimal + (see `predecode_single` / size-2 handling in pecos-uf-decoder). The full + decoder finds the logical-flipping path and suppresses. See pecos-docs/design/logical-subgraph-backprop-region-builder.md.""" p, n = 0.001, 60000 uf_d3 = _mem_ler(3, p, n, seed=1, inner="pecos_uf:bp") uf_d5 = _mem_ler(5, p, n, seed=1, inner="pecos_uf:bp") - # Currently the UF inner does NOT suppress: d=5 is not meaningfully below d=3. - assert uf_d5 >= uf_d3 * 0.7, ( - f"union-find inner now suppresses (d3={uf_d3:.5f} d5={uf_d5:.5f}) -- " - "UF bug appears fixed; update this test and reconsider the default inner." + # Below threshold, d=5 must beat d=3 by a clear margin. + assert uf_d5 < uf_d3 * 0.7, ( + f"union-find inner no longer suppresses: d3={uf_d3:.5f} d5={uf_d5:.5f}" + ) + + +def _windowed_mem_ler(d, rounds, p, n, seed, step, buffer): + patch = SurfacePatch.create(d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", rounds, "Z") + dem = b.build_dem(p1=p, p2=p, p_meas=p) + sc = b.stab_coords() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) + dec = WindowedLogicalSubgraphDecoder(dem, sc, "pymatching", step, buffer) + return dec.decode_count(batch) / n, dec.num_windows() + + +def test_windowed_logical_subgraph_does_not_suppress(): + """KNOWN BUG (separately tracked): the windowed logical-subgraph decoder + does NOT achieve distance suppression -- it ANTI-suppresses (LER grows with + d) and is ~40-100x worse than the non-windowed decoder on the same DEM and + same exact-MWPM inner. + + Root cause is a windowing-correctness bug, not the inner decoder: each window + independently decodes and XORs its *full-window* observable correction, with + NO core-commit (the ``_is_core`` field in windowed.rs is computed but never + used) and no handling for error chains that cross a window boundary. A + measurement-error chain spanning a boundary becomes two separate boundary + defects -- each window pairs it to its own boundary and spuriously flips the + observable. Overlap (``buffer>0``) makes it worse, not better, because + overlap-region corrections are XORed twice. + + The proper fix is a sliding-window core-commit scheme (decode with buffer + context, commit only core-region corrections), plus the time-like-snake + handling from arXiv:2505.13599 (synchronized resets / shortcut edges). Until + then, use the non-windowed ``LogicalSubgraphDecoder``. This pins the bug; + when windowing is fixed (windowed LER suppresses with d), this assertion + flips and the test should be updated. + See pecos-docs/design/logical-subgraph-backprop-region-builder.md.""" + p, n, rounds, step = 0.001, 80000, 24, 4 + ler_d3, nwin = _windowed_mem_ler(3, rounds, p, n, seed=1, step=step, buffer=0) + ler_d5, _ = _windowed_mem_ler(5, rounds, p, n, seed=1, step=step, buffer=0) + # Sanity: the circuit is deep enough to actually exercise windowing. + assert nwin > 1, f"probe degenerated to a single window (nwin={nwin})" + # Currently windowing does NOT suppress: d=5 is no better than d=3 (in fact + # worse). When this flips, the windowed path has been fixed. + assert ler_d5 >= ler_d3 * 0.7, ( + f"windowed logical-subgraph now suppresses (d3={ler_d3:.5f} " + f"d5={ler_d5:.5f}) -- windowing bug appears fixed; update this test." ) From 54456c296746f701ab3f494a847d279eca587fed Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 4 Jun 2026 06:52:10 -0600 Subject: [PATCH 031/150] Rewrite windowed logical-subgraph decoder to correct sliding-window core-commit (per-observable subgraph wrapped in OverlappingWindowedDecoder), delete the broken naive-XOR decoder-core version, and pin single-window-correctness + the known windowed-LOM limitation --- .../src/logical_subgraph.rs | 1 - .../src/logical_subgraph/windowed.rs | 294 ------------------ crates/pecos-decoders/src/lib.rs | 1 + crates/pecos-uf-decoder/src/lib.rs | 2 + .../src/logical_subgraph_windowed.rs | 179 +++++++++++ .../src/fault_tolerance_bindings.rs | 39 +-- ...test_logical_subgraph_region_comparison.py | 134 +++++--- 7 files changed, 295 insertions(+), 355 deletions(-) delete mode 100644 crates/pecos-decoder-core/src/logical_subgraph/windowed.rs create mode 100644 crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index 47b28d026..0d074b7a8 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -50,7 +50,6 @@ //! - This preserves the graphlike property of each subgraph pub mod committed; -pub mod windowed; use std::collections::{BTreeMap, BTreeSet}; diff --git a/crates/pecos-decoder-core/src/logical_subgraph/windowed.rs b/crates/pecos-decoder-core/src/logical_subgraph/windowed.rs deleted file mode 100644 index 2bc561611..000000000 --- a/crates/pecos-decoder-core/src/logical_subgraph/windowed.rs +++ /dev/null @@ -1,294 +0,0 @@ -// 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. - -//! Windowed observable subgraph decoder. -//! -//! Splits a DEM into time windows, runs per-logical-operator subgraph decoding -//! within each window. This prevents the observing region from spanning -//! the full circuit at deep depths, maintaining decoding accuracy. -//! -//! Window types: -//! - **Non-overlapping**: each detector belongs to exactly one window -//! - **Overlapping**: buffer zones extend beyond the core for matching context -//! -//! The observable correction from each window is XOR'd together. - -use std::collections::BTreeMap; - -use crate::ObservableDecoder; -use crate::dem::{DemCheckMatrix, DemMatchingGraph, MatchingEdge, parse_detector_coords}; -use crate::errors::DecoderError; -use crate::logical_subgraph::{LogicalSubgraphDecoder, StabCoords}; - -/// Configuration for windowed logical-subgraph decoder. -#[derive(Debug, Clone)] -pub struct WindowedLogicalSubgraphConfig { - /// Core window size in time steps. - pub step: usize, - /// Buffer size on each side (0 = non-overlapping). - pub buffer: usize, -} - -impl Default for WindowedLogicalSubgraphConfig { - fn default() -> Self { - Self { step: 8, buffer: 4 } - } -} - -/// A single time window with its own logical-subgraph decoder. -pub struct LogicalSubgraphWindow { - decoder: LogicalSubgraphDecoder, - /// Maps local detector index → global detector index. - local_to_global: Vec, - num_local: usize, - /// Which local detectors are in the core (vs buffer). - _is_core: Vec, -} - -/// Windowed observable subgraph decoder. -/// -/// Splits the DEM into time windows, each decoded with its own logical-subgraph decoder. -/// The observing region within each window is naturally bounded, -/// preventing the scaling degradation seen at deep circuits. -pub struct WindowedLogicalSubgraphDecoder { - pub windows: Vec, - _num_detectors: usize, - /// Reusable window syndrome buffer - window_syn: Vec, -} - -impl WindowedLogicalSubgraphDecoder { - /// Build from a DEM string with time-based windowing. - /// - /// # Errors - /// - /// Returns error if the DEM is malformed. - pub fn from_dem( - dem: &str, - stab_coords: &StabCoords, - config: &WindowedLogicalSubgraphConfig, - mut inner_factory: F, - ) -> Result - where - F: FnMut( - &DemMatchingGraph, - ) -> Result, DecoderError>, - { - // Parse detector coordinates to get time values - let coords = parse_detector_coords(dem); - let mut det_time: BTreeMap = BTreeMap::new(); - for dc in &coords { - if let Some(t) = dc.coords.last() { - det_time.insert(dc.id as usize, *t); - } - } - - let dcm = DemCheckMatrix::from_dem_str(dem) - .map_err(|e| DecoderError::InvalidGraph(e.to_string()))?; - let num_detectors = dcm.num_detectors; - - // Find time range - let min_t = det_time.values().copied().fold(f64::INFINITY, f64::min); - let max_t = det_time.values().copied().fold(f64::NEG_INFINITY, f64::max); - - if max_t <= min_t { - // Single time step or empty — just use full logical-subgraph decoder - let full_osd = - LogicalSubgraphDecoder::from_dem(dem, stab_coords, &mut inner_factory)?; - return Ok(Self { - windows: vec![LogicalSubgraphWindow { - decoder: full_osd, - local_to_global: (0..num_detectors).collect(), - num_local: num_detectors, - _is_core: vec![true; num_detectors], - }], - _num_detectors: num_detectors, - window_syn: vec![0u8; num_detectors], - }); - } - - let step = config.step as f64; - let buffer = config.buffer as f64; - let mut windows = Vec::new(); - let mut t_start = min_t; - let mut max_local = 0; - - while t_start <= max_t { - let core_end = (t_start + step).min(max_t + 1.0); - let win_start = (t_start - buffer).max(min_t); - let win_end = (core_end + buffer).min(max_t + 1.0); - - // Detectors in this window - let mut local_to_global = Vec::new(); - let mut is_core = Vec::new(); - - for d in 0..num_detectors { - if let Some(&t) = det_time.get(&d) - && t >= win_start - && t < win_end - { - local_to_global.push(d); - is_core.push(t >= t_start && t < core_end); - } - } - - if local_to_global.is_empty() { - t_start += step; - continue; - } - - let num_local = local_to_global.len(); - if num_local > max_local { - max_local = num_local; - } - - // Build sub-DEM for this window - let mut inverse = vec![None; num_detectors]; - for (local, &global) in local_to_global.iter().enumerate() { - inverse[global] = Some(local); - } - - let mut edges = Vec::new(); - let mut skipped = 0; - - for m in 0..dcm.num_mechanisms { - let p = dcm.error_priors[m]; - if p <= 0.0 { - continue; - } - - let sub_dets: Vec = (0..dcm.num_detectors) - .filter(|&d| dcm.check_matrix[[d, m]] != 0) - .filter_map(|d| inverse[d].map(|s| s as u32)) - .collect(); - - if sub_dets.is_empty() { - continue; - } - - let weight = if p < 1.0 { ((1.0 - p) / p).ln() } else { 0.0 }; - - // Observable: include if ANY observable is flipped - let mut observables = Vec::new(); - for o in 0..dcm.num_observables { - if dcm.observable_matrix[[o, m]] != 0 { - observables.push(o as u32); - } - } - - match sub_dets.len() { - 1 => edges.push(MatchingEdge { - node1: sub_dets[0], - node2: None, - weight, - observables, - probability: p, - fault_id: m, - }), - 2 => edges.push(MatchingEdge { - node1: sub_dets[0], - node2: Some(sub_dets[1]), - weight, - observables, - probability: p, - fault_id: m, - }), - _ => skipped += 1, - } - } - - let edges = DemMatchingGraph::merge_parallel_edges(edges); - let sub_graph = DemMatchingGraph { - edges, - num_detectors: num_local, - num_observables: dcm.num_observables, - skipped_hyperedges: skipped, - detector_coords: Vec::new(), - }; - - // Build sub-DEM string with detector coordinate declarations. - // The logical-subgraph decoder needs these to classify detectors by (qubit, stab_type). - let mut sub_dem_lines = Vec::new(); - for (local_id, &global_id) in local_to_global.iter().enumerate() { - // Find this detector's coordinates from the parsed coords - if let Some(dc) = coords.iter().find(|dc| dc.id as usize == global_id) { - let coord_str: Vec = dc.coords.iter().map(|c| format!("{c}")).collect(); - sub_dem_lines.push(format!("detector({}) D{local_id}", coord_str.join(", "))); - } - } - sub_dem_lines.push(graph_to_dem_string(&sub_graph)); - let sub_dem = sub_dem_lines.join("\n"); - - // Build logical-subgraph decoder for this window using the sub-DEM - let window_osd = - LogicalSubgraphDecoder::from_dem(&sub_dem, stab_coords, &mut inner_factory)?; - - windows.push(LogicalSubgraphWindow { - decoder: window_osd, - local_to_global, - num_local, - _is_core: is_core, - }); - - t_start += step; - } - - Ok(Self { - windows, - _num_detectors: num_detectors, - window_syn: vec![0u8; max_local], - }) - } -} - -impl ObservableDecoder for WindowedLogicalSubgraphDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { - let mut obs_mask = 0u64; - - for window in &mut self.windows { - // Extract window syndrome - let n = window.num_local; - for (local, &global) in window.local_to_global.iter().enumerate() { - self.window_syn[local] = if global < syndrome.len() { - syndrome[global] - } else { - 0 - }; - } - - // Decode this window - let window_obs = window - .decoder - .decode_to_observables(&self.window_syn[..n])?; - obs_mask ^= window_obs; - } - - Ok(obs_mask) - } -} - -fn graph_to_dem_string(graph: &DemMatchingGraph) -> String { - let mut lines = Vec::new(); - for edge in &graph.edges { - let p = edge.probability; - let mut targets = Vec::new(); - targets.push(format!("D{}", edge.node1)); - if let Some(n2) = edge.node2 { - targets.push(format!("D{n2}")); - } - for &obs in &edge.observables { - targets.push(format!("L{obs}")); - } - lines.push(format!("error({p}) {}", targets.join(" "))); - } - lines.join("\n") -} diff --git a/crates/pecos-decoders/src/lib.rs b/crates/pecos-decoders/src/lib.rs index 02a9797c9..1b7ad8b46 100644 --- a/crates/pecos-decoders/src/lib.rs +++ b/crates/pecos-decoders/src/lib.rs @@ -104,6 +104,7 @@ pub use pecos_uf_decoder::{ BpSchedule as UfBpSchedule, BpUfConfig, BpUfDecoder, CssUfDecoder, OverlappingWindowedDecoder, QubitEdgeMapping, SandwichWindowedDecoder, StreamingWindowedDecoder, UfDecoder, UfDecoderConfig, WindowedConfig, WindowedDecoder, + WindowedLogicalSubgraphDecoder, }; // Re-export Relay BP decoder when feature is enabled diff --git a/crates/pecos-uf-decoder/src/lib.rs b/crates/pecos-uf-decoder/src/lib.rs index 180bccf56..754be0c39 100644 --- a/crates/pecos-uf-decoder/src/lib.rs +++ b/crates/pecos-uf-decoder/src/lib.rs @@ -33,6 +33,7 @@ pub mod astar; pub mod bp_uf; pub mod css_decoder; pub mod decoder; +pub mod logical_subgraph_windowed; pub mod mini_bp; pub mod windowed; @@ -45,6 +46,7 @@ pub use astar::{AStarConfig, AStarDecoder}; pub use bp_uf::{BpSchedule, BpUfConfig, BpUfDecoder}; pub use css_decoder::{CssUfDecoder, QubitEdgeMapping}; pub use decoder::{UfDecoder, UfDecoderConfig}; +pub use logical_subgraph_windowed::WindowedLogicalSubgraphDecoder; pub use windowed::{ BeamSearchConfig, BeamSearchWindowedDecoder, OverlappingWindowedDecoder, SandwichWindowedDecoder, StreamingWindowedDecoder, WindowedConfig, WindowedDecoder, diff --git a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs new file mode 100644 index 000000000..ea88ed3c0 --- /dev/null +++ b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs @@ -0,0 +1,179 @@ +// 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. + +//! Windowed logical-subgraph decoder with correct sliding-window core-commit. +//! +//! The logical-subgraph decoder partitions a DEM per logical observable and +//! decodes each observable's subgraph independently (the coordinate observing +//! regions of Serra-Peralta et al., arXiv:2505.13599 / the `lomatching` +//! package). For deep circuits an observing region would span the whole circuit, +//! so we additionally window each subgraph in time. +//! +//! **Nesting: subgraph -> window.** Each per-observable subgraph is a clean +//! graphlike matching graph, so we wrap it in an +//! [`OverlappingWindowedDecoder`], which performs proper sliding-window +//! decoding: every window is decoded with a buffer for matching context, but +//! only correction edges whose BOTH endpoints lie in the window core are +//! committed (Tan et al., arXiv:2209.09219). The per-observable committed +//! observable flips are XORed. +//! +//! An earlier implementation windowed the full DEM first and then ran a subgraph +//! decoder per window, combining by a naive full-window observable XOR with no +//! core-commit. That double-counted error chains crossing a window boundary and +//! *anti-suppressed* (LER grew with code distance). The correct nesting here +//! reuses the tested core-commit machinery instead. + +use pecos_decoder_core::ObservableDecoder; +use pecos_decoder_core::dem::DemMatchingGraph; +use pecos_decoder_core::errors::DecoderError; +use pecos_decoder_core::logical_subgraph::{ + LogicalSubgraph, MaxTimeRadius, StabCoords, partition_dem_by_logical_windowed, +}; +use std::fmt::Write as _; + +use crate::decoder::{UfDecoder, UfDecoderConfig}; +use crate::windowed::{OverlappingWindowedDecoder, WindowedConfig}; + +/// One per-observable subgraph, windowed with sliding-window core-commit. +struct SubgraphWindowed { + /// Which full-DEM observable this subgraph decodes (the global bit index). + observable_idx: usize, + /// Subgraph-local detector index -> full-DEM detector index. + detector_map: Vec, + /// Number of subgraph-local detectors. + num_local: usize, + /// The time-windowed decoder over this subgraph (returns local bit 0). + decoder: OverlappingWindowedDecoder, +} + +/// Windowed logical-subgraph decoder. +/// +/// Partitions the DEM per observable, then windows each subgraph with an +/// [`OverlappingWindowedDecoder`] (sliding-window core-commit). Per-observable +/// committed observable flips are XORed into the final mask. +pub struct WindowedLogicalSubgraphDecoder { + subgraphs: Vec, + /// Reusable subgraph-local syndrome buffer (sized to the largest subgraph). + local_syn: Vec, +} + +impl WindowedLogicalSubgraphDecoder { + /// Build from a full DEM string and stabilizer coordinates. + /// + /// `max_time_radius` controls the per-observable observing region (see + /// [`partition_dem_by_logical_windowed`]); pass `None` for the full region + /// (the windowing then bounds the time extent instead). + /// + /// # Errors + /// + /// Returns `DecoderError` if the DEM is malformed or a subgraph decoder + /// fails to build. + pub fn from_dem( + dem: &str, + stab_coords: &StabCoords, + max_time_radius: MaxTimeRadius, + window_config: WindowedConfig, + ) -> Result { + let parts = partition_dem_by_logical_windowed(dem, stab_coords, max_time_radius)?; + + // Subgraph graphs do not carry detector coordinates, but the time-based + // windowing needs them. Pull them from the full DEM and inject (mapped + // to subgraph-local indices) when serializing each sub-DEM. + let full_coords = DemMatchingGraph::from_dem_str(dem)?.detector_coords; + + let mut subgraphs = Vec::with_capacity(parts.len()); + let mut max_local = 0usize; + for part in parts { + if part.detector_map.is_empty() { + // Observable with an empty region never flips: contributes 0. + continue; + } + let sub_dem = subgraph_to_dem_string(&part, &full_coords); + let decoder = OverlappingWindowedDecoder::from_dem(&sub_dem, window_config, |wdem| { + UfDecoder::from_dem(wdem, UfDecoderConfig::windowed()) + })?; + max_local = max_local.max(part.graph.num_detectors); + subgraphs.push(SubgraphWindowed { + observable_idx: part.observable_idx, + detector_map: part.detector_map, + num_local: part.graph.num_detectors, + decoder, + }); + } + + Ok(Self { + subgraphs, + local_syn: vec![0u8; max_local], + }) + } + + /// Number of per-observable subgraphs that actually decode (non-empty). + #[must_use] + pub fn num_subgraphs(&self) -> usize { + self.subgraphs.len() + } + + /// Total number of windows across all subgraphs. + #[must_use] + pub fn num_windows(&self) -> usize { + self.subgraphs.iter().map(|s| s.decoder.num_windows()).sum() + } +} + +impl ObservableDecoder for WindowedLogicalSubgraphDecoder { + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + let mut obs_mask = 0u64; + for sg in &mut self.subgraphs { + let n = sg.num_local; + for (local, &global) in sg.detector_map.iter().enumerate() { + self.local_syn[local] = if global < syndrome.len() { + syndrome[global] + } else { + 0 + }; + } + // The subgraph decodes a single observable as its local bit 0; map + // that back to this observable's global bit. + let sub_obs = sg.decoder.decode_to_observables(&self.local_syn[..n])?; + if sub_obs & 1 != 0 { + obs_mask |= 1u64 << sg.observable_idx; + } + } + Ok(obs_mask) + } +} + +/// Serialize a subgraph back to a DEM string: detector coordinate lines (mapped +/// from the full DEM via `detector_map`) plus error lines. The coordinate lines +/// let [`OverlappingWindowedDecoder::from_dem`] derive per-detector times for +/// the windowing. +fn subgraph_to_dem_string(part: &LogicalSubgraph, full_coords: &[Option>]) -> String { + let mut s = String::new(); + for (local, &global) in part.detector_map.iter().enumerate() { + if let Some(Some(coords)) = full_coords.get(global) { + let coord_str: Vec = coords.iter().map(|c| format!("{c}")).collect(); + let _ = writeln!(s, "detector({}) D{local}", coord_str.join(", ")); + } + } + for edge in &part.graph.edges { + let _ = write!(s, "error({})", edge.probability); + let _ = write!(s, " D{}", edge.node1); + if let Some(n2) = edge.node2 { + let _ = write!(s, " D{n2}"); + } + for &obs in &edge.observables { + let _ = write!(s, " L{obs}"); + } + let _ = writeln!(s); + } + s +} diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 2ac86caae..b66d4169c 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4483,13 +4483,14 @@ impl PyCssUfDecoder { /// dem: DEM string with detector coordinate declarations. /// `stab_coords`: List of dicts, one per logical qubit. Each dict has /// keys "X" and "Z" mapping to lists of (x, y) ancilla coordinates. -/// `inner_decoder`: Inner decoder type string (default "`pecos_uf:fast`"). +/// `inner_decoder`: Inner decoder type string (default "`pecos_uf:bp`", +/// native belief-propagation + union-find). /// /// Example: /// >>> decoder = `LogicalSubgraphDecoder`( /// ... `dem_str`, /// ... [{"X": [(1,0), (3,1)], "Z": [(0,3), (1,1)]}], -/// ... "`pecos_uf:fast`", +/// ... "`pecos_uf:bp`", /// ... ) /// >>> obs = decoder.decode(syndrome) #[pyclass(name = "LogicalSubgraphDecoder", module = "pecos_rslib.qec")] @@ -4823,30 +4824,33 @@ impl PyLogicalSubgraphDecoder { /// Splits the DEM into time windows, runs logical-subgraph decoder within each window. /// Prevents the observing region from spanning the full circuit. /// +/// Partitions the DEM per observable, then windows each subgraph with proper +/// sliding-window core-commit (only correction edges whose both endpoints lie +/// in a window's core are committed). The inner decoder is the native +/// edge-tracking union-find decoder, which core-commit requires. +/// /// Args: /// dem: DEM string. /// `stab_coords`: Stabilizer coordinates per logical qubit. -/// `inner_decoder`: Inner MWPM decoder type. /// step: Core window size in time steps. -/// buffer: Buffer size on each side (0 = non-overlapping). +/// buffer: Buffer size on each side for matching context (0 = +/// non-overlapping; recommend ~code distance). #[pyclass(name = "WindowedLogicalSubgraphDecoder", module = "pecos_rslib.qec")] pub struct PyWindowedLogicalSubgraphDecoder { - inner: pecos_decoder_core::logical_subgraph::windowed::WindowedLogicalSubgraphDecoder, + inner: pecos_decoders::WindowedLogicalSubgraphDecoder, } #[pymethods] impl PyWindowedLogicalSubgraphDecoder { #[new] - #[pyo3(signature = (dem, stab_coords, inner_decoder="pymatching", step=8, buffer=4))] + #[pyo3(signature = (dem, stab_coords, step=8, buffer=4))] fn new( dem: &str, stab_coords: Vec>, - inner_decoder: &str, step: usize, buffer: usize, ) -> PyResult { use pecos_decoder_core::logical_subgraph::QubitStabCoords; - use pecos_decoder_core::logical_subgraph::windowed::{WindowedLogicalSubgraphConfig, WindowedLogicalSubgraphDecoder}; let mut sc = Vec::with_capacity(stab_coords.len()); for dict in &stab_coords { @@ -4864,16 +4868,15 @@ impl PyWindowedLogicalSubgraphDecoder { }); } - let config = WindowedLogicalSubgraphConfig { step, buffer }; + let config = pecos_decoders::WindowedConfig { + step_size: step, + buffer_size: buffer, + ..Default::default() + }; - let inner = WindowedLogicalSubgraphDecoder::from_dem(dem, &sc, &config, |subgraph| { - let sub_dem = subgraph_to_dem_string(subgraph); - let d = create_observable_decoder(&sub_dem, inner_decoder) - .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string()))?; - Ok(Box::new(SendWrapper(d)) - as Box) - }) - .map_err(|e| PyErr::new::(e.to_string()))?; + let inner = + pecos_decoders::WindowedLogicalSubgraphDecoder::from_dem(dem, &sc, None, config) + .map_err(|e| PyErr::new::(e.to_string()))?; Ok(Self { inner }) } @@ -4903,7 +4906,7 @@ impl PyWindowedLogicalSubgraphDecoder { } fn num_windows(&self) -> usize { - self.inner.windows.len() + self.inner.num_windows() } } 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 614aba204..1e5f7607d 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 @@ -242,24 +242,29 @@ def test_distance_suppression_memory(): ) -def test_native_union_find_inner_suppresses(): - """PECOS's native union-find inner decoders (`pecos_uf:*`) achieve distance - suppression, as a fault-tolerant decoder must. - - This previously did NOT hold: the UF predecoder mis-decoded isolated defects - whose minimum-weight correction is a bulk *path* to the boundary (it only - looked at direct boundary edges, returning a no-flip / wrong-edge result), - which broke suppression at d>=5. Fixed by making the predecoder fall through - to the full grow+peel decoder whenever its shortcut is not provably optimal - (see `predecode_single` / size-2 handling in pecos-uf-decoder). The full - decoder finds the logical-flipping path and suppresses. +def test_default_inner_bp_uf_suppresses(): + """The default native inner decoder (`pecos_uf:bp`, belief-propagation + + union-find) achieves distance suppression, as a fault-tolerant decoder must. + + Context: the UF predecoder used to mis-decode isolated defects whose + minimum-weight correction is a bulk *path* to the boundary (it only looked at + direct boundary edges, returning a no-flip / wrong-edge result). That was a + catastrophic single-defect bug; fixed by making the predecoder fall through to + the full grow+peel decoder unless its shortcut is provably optimal (see + `predecode_single` / size-2 handling in pecos-uf-decoder). + + NOTE: this validates the *default* inner `pecos_uf:bp`, which suppresses + robustly (tracks exact MWPM). Pure `pecos_uf:fast` (no belief propagation) was + also improved by the predecoder fix but its full grow+peel heuristic does NOT + robustly suppress at depth -- a separate, lesser weakness, which is why the + default is `pecos_uf:bp`, not `pecos_uf:fast`. See pecos-docs/design/logical-subgraph-backprop-region-builder.md.""" p, n = 0.001, 60000 uf_d3 = _mem_ler(3, p, n, seed=1, inner="pecos_uf:bp") uf_d5 = _mem_ler(5, p, n, seed=1, inner="pecos_uf:bp") # Below threshold, d=5 must beat d=3 by a clear margin. assert uf_d5 < uf_d3 * 0.7, ( - f"union-find inner no longer suppresses: d3={uf_d3:.5f} d5={uf_d5:.5f}" + f"default bp+uf inner no longer suppresses: d3={uf_d3:.5f} d5={uf_d5:.5f}" ) @@ -271,42 +276,87 @@ def _windowed_mem_ler(d, rounds, p, n, seed, step, buffer): dem = b.build_dem(p1=p, p2=p, p_meas=p) sc = b.stab_coords() batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) - dec = WindowedLogicalSubgraphDecoder(dem, sc, "pymatching", step, buffer) + dec = WindowedLogicalSubgraphDecoder(dem, sc, step, buffer) return dec.decode_count(batch) / n, dec.num_windows() -def test_windowed_logical_subgraph_does_not_suppress(): - """KNOWN BUG (separately tracked): the windowed logical-subgraph decoder - does NOT achieve distance suppression -- it ANTI-suppresses (LER grows with - d) and is ~40-100x worse than the non-windowed decoder on the same DEM and - same exact-MWPM inner. - - Root cause is a windowing-correctness bug, not the inner decoder: each window - independently decodes and XORs its *full-window* observable correction, with - NO core-commit (the ``_is_core`` field in windowed.rs is computed but never - used) and no handling for error chains that cross a window boundary. A - measurement-error chain spanning a boundary becomes two separate boundary - defects -- each window pairs it to its own boundary and spuriously flips the - observable. Overlap (``buffer>0``) makes it worse, not better, because - overlap-region corrections are XORed twice. - - The proper fix is a sliding-window core-commit scheme (decode with buffer - context, commit only core-region corrections), plus the time-like-snake - handling from arXiv:2505.13599 (synchronized resets / shortcut edges). Until - then, use the non-windowed ``LogicalSubgraphDecoder``. This pins the bug; - when windowing is fixed (windowed LER suppresses with d), this assertion - flips and the test should be updated. +def _nonwindowed_mem_ler(d, rounds, p, n, seed, inner): + patch = SurfacePatch.create(d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", rounds, "Z") + dem = b.build_dem(p1=p, p2=p, p_meas=p) + sc = b.stab_coords() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) + return LogicalSubgraphDecoder(dem, sc, inner).decode_count(batch) / n + + +def test_windowed_logical_subgraph_single_window_matches_nonwindowed(): + """Correctness guarantee for the windowed decoder's construction: with a + window larger than the circuit depth (a single window, all-core), the + windowed logical-subgraph decoder reproduces the non-windowed decoder with + the same native union-find inner. + + This pins that the per-observable subgraph serialization, the local<->global + detector mapping, and the local-bit-0 -> global-observable-bit remapping are + all correct -- independently of the time-windowing behaviour exercised + below.""" + p, n, rounds = 0.001, 40000, 18 + for d in (3, 5): + win, nwin = _windowed_mem_ler(d, rounds, p, n, seed=7, step=10_000, buffer=0) + non = _nonwindowed_mem_ler(d, rounds, p, n, seed=7, inner="pecos_uf:fast") + assert nwin == 1, f"expected a single window, got {nwin}" + # Same decode up to negligible tie-breaking differences. + assert abs(win - non) <= max(0.0005, 0.15 * non), ( + f"single-window windowed != non-windowed at d={d}: win={win:.5f} non={non:.5f}" + ) + + +def test_windowed_logical_subgraph_known_limitation_no_full_suppression(): + """KNOWN LIMITATION (separately tracked): the windowed logical-subgraph + decoder does not yet achieve full distance suppression on memory. + + The decoder was rewritten to do proper sliding-window core-commit (each + per-observable subgraph is wrapped in an ``OverlappingWindowedDecoder``, which + commits only correction edges whose both endpoints lie in a window's core). + That removed the old double-counting bug -- a single window now reproduces the + non-windowed decoder exactly (see the test above), and multi-window LER is no + longer catastrophic (the old naive-XOR decoder anti-suppressed to ~10-25%). + + What remains is the *windowed logical-observable-matching* limitation + identified in Serra-Peralta et al. (arXiv:2505.13599, Sec. V): per-observable + windowing admits "time-like snake" error patterns that scale sublinearly in + d, so LER does not fully suppress without their additional machinery + (synchronized resets every Omega(d) and/or a two-step decoder with short-cut + edges). Standard *full-DEM* sliding-window decoding does not have this issue + (PECOS's ``windowed:`` decoder suppresses on a graphlike/Stim-decomposed DEM + -- it is not exercised here because the native PECOS DEM has undecomposed + hyperedges that the full-DEM windowed path's matching inner rejects). For a + single-observable memory prefer either the non-windowed + ``LogicalSubgraphDecoder`` or full-DEM windowing on a decomposed DEM. This + pins the limitation; implementing the anti-snake machinery is the remaining + work. See pecos-docs/design/logical-subgraph-backprop-region-builder.md.""" - p, n, rounds, step = 0.001, 80000, 24, 4 - ler_d3, nwin = _windowed_mem_ler(3, rounds, p, n, seed=1, step=step, buffer=0) - ler_d5, _ = _windowed_mem_ler(5, rounds, p, n, seed=1, step=step, buffer=0) - # Sanity: the circuit is deep enough to actually exercise windowing. + p, n, rounds = 0.001, 40000, 18 + ler_d3, nwin = _windowed_mem_ler(3, rounds, p, n, seed=7, step=3, buffer=3) + ler_d5, _ = _windowed_mem_ler(5, rounds, p, n, seed=7, step=5, buffer=5) + ler_d7, _ = _windowed_mem_ler(7, rounds, p, n, seed=7, step=7, buffer=7) + # The circuit is deep enough to actually exercise windowing. assert nwin > 1, f"probe degenerated to a single window (nwin={nwin})" - # Currently windowing does NOT suppress: d=5 is no better than d=3 (in fact - # worse). When this flips, the windowed path has been fixed. - assert ler_d5 >= ler_d3 * 0.7, ( + # Still does not fully suppress (the paper's windowed-LOM limitation): LER does + # not fall with distance (it in fact grows). When the anti-snake machinery + # lands and this suppresses, flip the assertions and update the test. + assert ler_d5 >= ler_d3 * 0.7 and ler_d7 >= ler_d5 * 0.7, ( f"windowed logical-subgraph now suppresses (d3={ler_d3:.5f} " - f"d5={ler_d5:.5f}) -- windowing bug appears fixed; update this test." + f"d5={ler_d5:.5f} d7={ler_d7:.5f}) -- anti-snake machinery appears to " + "have landed; update this test." + ) + # Guard against regressing to the old catastrophic anti-suppression (the + # naive-XOR decoder reached ~0.1-0.25 here); the core-commit rewrite keeps it + # well below that across distances. + assert max(ler_d3, ler_d5, ler_d7) < 0.1, ( + f"windowed LER regressed toward catastrophic: " + f"d3={ler_d3:.5f} d5={ler_d5:.5f} d7={ler_d7:.5f}" ) From 87306177872aa0d7331f85c9f68ad9a1b3e73248 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 4 Jun 2026 23:03:26 -0600 Subject: [PATCH 032/150] Add shared coord-preserving LogicalSubgraphWindowPlan and make the logical-circuit windowed-budget mode fail-loud: explicit full_fallback with effective_windowing/actual_num_windows/can_window introspection and a strict option that errors on an unmet bounded-latency budget --- .../src/logical_algorithm.rs | 8 +- .../src/logical_subgraph.rs | 17 ++ .../src/logical_subgraph/window_plan.rs | 230 ++++++++++++++++++ .../src/fault_tolerance_bindings.rs | 102 ++++++-- .../test_logical_circuit_decoder_windowing.py | 80 ++++++ 5 files changed, 414 insertions(+), 23 deletions(-) create mode 100644 crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs create mode 100644 python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py diff --git a/crates/pecos-decoder-core/src/logical_algorithm.rs b/crates/pecos-decoder-core/src/logical_algorithm.rs index 9c366e209..ed41d038f 100644 --- a/crates/pecos-decoder-core/src/logical_algorithm.rs +++ b/crates/pecos-decoder-core/src/logical_algorithm.rs @@ -680,7 +680,13 @@ impl DecodeStrategy for WindowedLogicalSubgraphStrategy { } fn commit(&mut self, _region: &DetectorRegion) -> Result { - // Commitment is handled internally by the windowed inner decoders + // NOTE (abstraction caveat): this strategy is currently a *batch* decoder + // exposed through the streaming `DecodeStrategy` trait. It decodes the + // whole syndrome in one `decode()` call; per-observable subgraph windowing + // (when enabled) is handled inside each inner decoder, not via incremental + // region commits. So `commit()` is intentionally a no-op and + // `committed_obs()` returns 0. Real streaming commit semantics are a + // follow-up (see the windowed logical-subgraph proper-solution design). Ok(0) } diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index 0d074b7a8..cc2061819 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -50,6 +50,7 @@ //! - This preserves the graphlike property of each subgraph pub mod committed; +pub mod window_plan; use std::collections::{BTreeMap, BTreeSet}; @@ -553,6 +554,22 @@ impl LogicalSubgraphDecoder { self.subgraphs.get(obs_idx) } + /// Build a coord-preserving per-observable window plan from these subgraphs + /// and the full-DEM detector coordinates (indexed by global detector id). + /// + /// Subgraph matching graphs drop detector coordinates, so the windowed + /// decoders need the full-DEM coords re-injected to time-window correctly. + /// The plan also reports whether real windowing would happen or it + /// degenerates to a single-window full decode (see + /// [`window_plan::LogicalSubgraphWindowPlan`]). + #[must_use] + pub fn window_plan( + &self, + full_coords: &[Option>], + ) -> window_plan::LogicalSubgraphWindowPlan { + window_plan::LogicalSubgraphWindowPlan::new(&self.subgraphs, full_coords) + } + /// Per-observable observing regions: entry `k` is the sorted full-DEM /// detector ids in observable `k`'s subgraph. This is the membership the /// region source produced — exposed for differential testing against diff --git a/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs b/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs new file mode 100644 index 000000000..abc0e596f --- /dev/null +++ b/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs @@ -0,0 +1,230 @@ +// 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. + +//! Coord-preserving per-observable subgraph/window plan with honest +//! windowing-mode introspection. +//! +//! Both windowed logical-subgraph decoders (the standalone +//! `WindowedLogicalSubgraphDecoder` in `pecos-uf-decoder`, and the streaming +//! `WindowedLogicalSubgraphStrategy` here) need the same inputs: per-observable +//! graphlike sub-DEMs that PRESERVE detector coordinates (so time-based +//! windowing has real times), the local↔global detector maps, and -- crucially +//! -- a way to report whether real time-windowing will actually happen or the +//! decode silently degenerates to a single full window. +//! +//! Subgraph matching graphs drop detector coordinates +//! ([`crate::logical_subgraph::subgraphs_from_membership`] sets +//! `detector_coords: Vec::new()`), so a sub-DEM serialized from the graph alone +//! has no `detector(...)` lines; any windowed inner then sees `total_t = 1` and +//! builds a single window. This plan injects the full-DEM coordinates (mapped to +//! subgraph-local indices) and exposes the resulting window structure, so +//! callers can FAIL LOUD instead of silently full-decoding behind a +//! bounded-latency API. +//! +//! This lives in `pecos-decoder-core` as shared data/modeling so both the +//! downstream UF decoder and the logical-circuit strategy consume one plan +//! (avoiding a `decoder-core -> pecos-uf-decoder` dependency). + +use crate::logical_subgraph::LogicalSubgraph; +use std::fmt::Write as _; + +/// Whether a windowed logical-subgraph decode actually time-windows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EffectiveWindowing { + /// Every per-observable subgraph fits in a single window: a full + /// (non-windowed) decode -- accurate, but unbounded latency. Selecting this + /// when bounded latency was requested is a silent fallback unless surfaced. + FullFallback, + /// At least one subgraph spans multiple time windows: real sliding-window + /// decoding (bounded latency, subject to the windowed-LOM accuracy limit). + RealWindowed, +} + +impl EffectiveWindowing { + /// Stable string label for APIs / tests. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + EffectiveWindowing::FullFallback => "full_fallback", + EffectiveWindowing::RealWindowed => "real_windowed", + } + } +} + +/// One per-observable subgraph with detector coordinates preserved. +pub struct PlanEntry { + /// Global observable (logical) index this subgraph decodes. + pub observable_idx: usize, + /// Subgraph-local detector index -> full-DEM detector index. + pub detector_map: Vec, + /// Coord-preserving sub-DEM: `detector(...)` lines + `error(...)` lines. + pub sub_dem: String, + /// Per-local-detector time (coordinate element 2; `0.0` if unknown). + pub detector_times: Vec, +} + +/// Coord-preserving per-observable subgraph/window plan. +pub struct LogicalSubgraphWindowPlan { + entries: Vec, +} + +impl LogicalSubgraphWindowPlan { + /// Build from per-observable subgraphs and the full-DEM detector + /// coordinates (indexed by global detector id). Empty-region observables + /// (no detectors) are skipped -- they never flip and contribute nothing. + #[must_use] + pub fn new(subgraphs: &[LogicalSubgraph], full_coords: &[Option>]) -> Self { + let mut entries = Vec::new(); + for sg in subgraphs { + if sg.detector_map.is_empty() { + continue; + } + let mut detector_times = Vec::with_capacity(sg.detector_map.len()); + let mut sub_dem = String::new(); + for (local, &global) in sg.detector_map.iter().enumerate() { + let coords = full_coords.get(global).and_then(|c| c.as_ref()); + let t = coords.and_then(|c| c.get(2).copied()).unwrap_or(0.0); + detector_times.push(t); + if let Some(c) = coords { + let cs: Vec = c.iter().map(|v| format!("{v}")).collect(); + let _ = writeln!(sub_dem, "detector({}) D{local}", cs.join(", ")); + } + } + for edge in &sg.graph.edges { + let _ = write!(sub_dem, "error({})", edge.probability); + let _ = write!(sub_dem, " D{}", edge.node1); + if let Some(n2) = edge.node2 { + let _ = write!(sub_dem, " D{n2}"); + } + for &obs in &edge.observables { + let _ = write!(sub_dem, " L{obs}"); + } + let _ = writeln!(sub_dem); + } + entries.push(PlanEntry { + observable_idx: sg.observable_idx, + detector_map: sg.detector_map.clone(), + sub_dem, + detector_times, + }); + } + Self { entries } + } + + /// Number of non-empty per-observable subgraphs in the plan. + #[must_use] + pub fn num_observables(&self) -> usize { + self.entries.len() + } + + /// The per-observable plan entries. + #[must_use] + pub fn entries(&self) -> &[PlanEntry] { + &self.entries + } + + /// Coord-preserving sub-DEM strings (one per non-empty observable). + #[must_use] + pub fn sub_dems(&self) -> Vec { + self.entries.iter().map(|e| e.sub_dem.clone()).collect() + } + + /// Local->global detector maps (one per non-empty observable). + #[must_use] + pub fn detector_maps(&self) -> Vec> { + self.entries.iter().map(|e| e.detector_map.clone()).collect() + } + + /// Number of time windows observable `i` would use at `step` rounds per + /// window. Mirrors the core-window loop in the sliding-window decoders + /// (`t_start` from 0 by `step` while `< total_t`, `total_t = max_time + 1`), + /// counting only windows that contain at least one detector. + #[must_use] + pub fn window_count(&self, i: usize, step: usize) -> usize { + self.entries + .get(i) + .map_or(0, |e| window_count_for_times(&e.detector_times, step)) + } + + /// Total windows across all observables at `step`. + #[must_use] + pub fn total_windows(&self, step: usize) -> usize { + (0..self.entries.len()) + .map(|i| self.window_count(i, step)) + .sum() + } + + /// Whether real time-windowing happens at `step`, or it degenerates to a + /// single-window full decode for every observable. + #[must_use] + pub fn effective_windowing(&self, step: usize) -> EffectiveWindowing { + if (0..self.entries.len()).any(|i| self.window_count(i, step) > 1) { + EffectiveWindowing::RealWindowed + } else { + EffectiveWindowing::FullFallback + } + } +} + +/// Count the time windows for a set of detector times at `step` rounds per +/// window, matching the sliding-window loop's core ranges. Only windows that +/// contain at least one detector are counted. With no coordinates (all times +/// `0.0`) this returns 1 -- the silent-fallback signal. +fn window_count_for_times(times: &[f64], step: usize) -> usize { + if times.is_empty() { + return 0; + } + let max_time = times.iter().copied().fold(0.0f64, f64::max); + let total_t = max_time + 1.0; + let step = step.max(1) as f64; + + let mut count = 0usize; + let mut t_start = 0.0f64; + while t_start < total_t { + let is_last = t_start + 2.0 * step > total_t; + let t_core_end = if is_last { total_t + 1.0 } else { t_start + step }; + if times.iter().any(|&t| t >= t_start && t < t_core_end) { + count += 1; + } + if is_last { + break; + } + t_start += step; + } + count +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coordless_times_are_single_window() { + // All detectors at time 0 (the subgraph-graph / no-coords case). + assert_eq!(window_count_for_times(&[0.0, 0.0, 0.0], 4), 1); + assert_eq!(window_count_for_times(&[0.0], 1), 1); + } + + #[test] + fn empty_times_are_zero_windows() { + assert_eq!(window_count_for_times(&[], 4), 0); + } + + #[test] + fn multi_round_times_window_by_step() { + // Times 0..=23 (24 rounds), step 4 -> several windows (> 1). + let times: Vec = (0..24).map(f64::from).collect(); + assert!(window_count_for_times(×, 4) > 1); + // A step covering the whole range -> a single window. + assert_eq!(window_count_for_times(×, 1000), 1); + } +} diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index b66d4169c..4d954ab30 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -5173,16 +5173,26 @@ impl PyLogicalAlgorithmDecoder { #[pyclass(name = "LogicalCircuitDecoder", module = "pecos_rslib.qec")] pub struct PyLogicalCircuitDecoder { inner: pecos_decoder_core::logical_algorithm::LogicalCircuitDecoder, + /// How the decode actually windows: "unlimited" (full circuit), + /// "full_fallback" (per-observable full decode behind a windowed budget), + /// or "real_windowed" (genuine sliding-window; not yet enabled). + effective_windowing: String, + /// Per-observable window count actually used (1 == full decode). + actual_num_windows: Vec, + /// Whether genuine time-windowing is *possible* for this circuit (deep + /// enough), independent of whether it is enabled. False for "unlimited". + can_window: bool, } #[pymethods] impl PyLogicalCircuitDecoder { #[new] - #[pyo3(signature = (descriptor, budget="unlimited", inner_decoder="pymatching"))] + #[pyo3(signature = (descriptor, budget="unlimited", inner_decoder="pymatching", strict=false))] fn new( descriptor: &pyo3::Bound<'_, pyo3::types::PyDict>, budget: &str, inner_decoder: &str, + strict: bool, ) -> PyResult { use pecos_decoder_core::decode_budget::DecodeBudget; use pecos_decoder_core::logical_algorithm::{ @@ -5347,36 +5357,54 @@ impl PyLogicalCircuitDecoder { }; // Select strategy based on budget. + let mut effective_windowing = String::from("unlimited"); + let mut actual_num_windows: Vec = Vec::new(); + let mut can_window = false; let strategy: Box = if decode_budget.is_unlimited() { // Unlimited: full-circuit logical-subgraph decoder (maximum accuracy) Box::new(FullCircuitStrategy::new(Box::new(full_osd))) } else { - // Windowed: per-subgraph sandwich decoding. - // Extract per-subgraph DEMs and detector maps from the full logical-subgraph decoder. + // A bounded-latency ("windowed") budget was requested. Genuine + // per-observable sliding-window LOM decoding does not yet + // suppress (the windowed-LOM time-like-snake limitation; needs + // the anti-snake machinery), so we do an EXPLICIT full-decode + // fallback per observable -- accurate, but NOT bounded latency -- + // and surface that honestly via `effective_windowing()` / + // `actual_num_windows()`. No silent fallback. `strict=True` turns + // the unmet latency budget into a hard error. use pecos_decoder_core::logical_algorithm::WindowedLogicalSubgraphStrategy; - - let mut sub_dems = Vec::new(); - let mut det_maps = Vec::new(); - for i in 0..full_osd.num_observables() { - if let Some(sg) = full_osd.subgraph(i) { - sub_dems.push(subgraph_to_dem_string(&sg.graph)); - det_maps.push(sg.detector_map.clone()); - } + use pecos_decoder_core::logical_subgraph::window_plan::EffectiveWindowing; + + // Coord-preserving window plan (reports whether real windowing is + // even possible for this circuit depth). + let full_coords = pecos_decoder_core::DemMatchingGraph::from_dem_str(&full_dem) + .map_err(|e| PyErr::new::(e.to_string()))? + .detector_coords; + let plan = full_osd.window_plan(&full_coords); + let step = decode_budget.code_distance.max(1); + can_window = plan.effective_windowing(step) == EffectiveWindowing::RealWindowed; + + if strict { + return Err(PyErr::new::( + "bounded-latency ('windowed') budget requested with strict=True, \ + but accurate windowed logical-subgraph decoding is not yet \ + available (windowed-LOM anti-snake machinery pending). It would \ + fall back to a full per-observable decode (unbounded latency). \ + Use budget='unlimited', or pass strict=False to accept the \ + full-decode fallback." + .to_string(), + )); } - let d = decode_budget.code_distance; - let buf = decode_budget.overlap_rounds.min(d * 2); // cap at 2d - let windowed_str = if buf > 0 { - format!("windowed:step={d},buf={buf},wmax=2.5") - } else { - // No overlap: use plain PM (faster, but accuracy limited - // to non-overlapping windowed matching) - format!("windowed:step={d},buf=0") - }; + let sub_dems = plan.sub_dems(); + let det_maps = plan.detector_maps(); + effective_windowing = String::from("full_fallback"); + actual_num_windows = vec![1usize; sub_dems.len()]; + let fallback_inner = inner_decoder.to_string(); let wosd = WindowedLogicalSubgraphStrategy::new(sub_dems, det_maps, |dem_str| { - let dec = create_observable_decoder(dem_str, &windowed_str) + let dec = create_observable_decoder(dem_str, &fallback_inner) .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string()))?; Ok(Box::new(SendWrapper(dec)) as Box) @@ -5387,7 +5415,37 @@ impl PyLogicalCircuitDecoder { }; let inner = LogicalCircuitDecoder::new(algo_desc, strategy, decode_budget, num_qubits); - Ok(Self { inner }) + Ok(Self { + inner, + effective_windowing, + actual_num_windows, + can_window, + }) + } + + /// How the decode actually windows: ``"unlimited"`` (full-circuit decode), + /// ``"full_fallback"`` (per-observable full decode behind a windowed + /// budget -- accurate but NOT bounded latency), or ``"real_windowed"`` + /// (genuine sliding-window; not yet enabled pending the windowed-LOM + /// anti-snake machinery). Lets callers/tests assert the effective mode + /// instead of trusting a silent fallback. + #[getter] + fn effective_windowing(&self) -> &str { + &self.effective_windowing + } + + /// Per-observable window count actually used (``1`` == full decode). Empty + /// for the unlimited budget. + #[getter] + fn actual_num_windows(&self) -> Vec { + self.actual_num_windows.clone() + } + + /// Whether genuine time-windowing is *possible* for this circuit (deep + /// enough), independent of whether it is enabled. ``False`` for unlimited. + #[getter] + fn can_window(&self) -> bool { + self.can_window } /// Decode a single syndrome. diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py b/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py new file mode 100644 index 000000000..fc5c35527 --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py @@ -0,0 +1,80 @@ +# 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. + +"""Honest windowing-mode reporting for the logical-circuit decoder (Layer 0). + +The windowed-budget path used to silently perform a single-window full decode +(the per-observable sub-DEMs were serialized without detector coordinates, so the +inner windowed decoder degenerated to one window) while the API advertised a +bounded-latency budget. This pins that the effective mode is now surfaced +explicitly: ``effective_windowing`` / ``actual_num_windows`` are introspectable, +``can_window`` distinguishes "real windowing is possible" from "real windowing is +enabled", and a ``strict`` request hard-errors instead of silently falling back. + +See pecos-docs/design/windowed-logical-subgraph-proper-solution.md. +""" + +from __future__ import annotations + +import pytest +from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch +from pecos_rslib.qec import LogicalCircuitDecoder + + +def _memory_descriptor(d: int, rounds: int) -> dict: + patch = SurfacePatch.create(d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", rounds, "Z") + return b.build_algorithm_descriptor(p1=0.001, p2=0.001, p_meas=0.001) + + +def test_unlimited_budget_reports_unlimited(): + dec = LogicalCircuitDecoder(_memory_descriptor(3, 9), budget="unlimited") + assert dec.effective_windowing == "unlimited" + assert dec.can_window is False + assert dec.actual_num_windows == [] + + +def test_windowed_budget_is_explicit_full_fallback_not_silent(): + """The windowed budget must NOT silently claim bounded latency: it reports a + full-decode fallback with one window per observable, while still signalling + that genuine windowing is possible for this (deep enough) circuit.""" + dec = LogicalCircuitDecoder(_memory_descriptor(3, 9), budget="windowed") + assert dec.effective_windowing == "full_fallback" + assert len(dec.actual_num_windows) >= 1 + assert all(n == 1 for n in dec.actual_num_windows) + # The circuit is deep enough that real windowing *could* happen (coords are + # preserved in the plan); it is just not enabled until the anti-snake work. + assert dec.can_window is True + + +def test_strict_windowed_budget_hard_errors(): + """With strict=True, an unmet bounded-latency budget is a hard error rather + than a silent full-decode fallback.""" + desc = _memory_descriptor(3, 9) + with pytest.raises(Exception, match="strict"): + LogicalCircuitDecoder(desc, budget="windowed", strict=True) + + +def test_windowed_full_fallback_still_decodes(): + """The full-fallback path is still a working decoder (accurate per-observable + decode), not a stub.""" + desc = _memory_descriptor(3, 9) + dec = LogicalCircuitDecoder(desc, budget="windowed") + ndet = sum(1 for ln in desc["full_dem"].splitlines() if ln.strip().startswith("detector(")) + # Zero syndrome -> zero correction. + assert dec.decode([0] * ndet) == 0 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From a671a33416cbca2c2c7f97885f2b3e49e984bf80 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 5 Jun 2026 11:29:32 -0600 Subject: [PATCH 033/150] Unify standalone WindowedLogicalSubgraphDecoder onto the shared LogicalSubgraphWindowPlan and delete its duplicate coord-injecting serializer (single serialization path; single-window==non-windowed correctness preserved) --- .../src/logical_subgraph_windowed.rs | 63 ++++++------------- 1 file changed, 18 insertions(+), 45 deletions(-) diff --git a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs index ea88ed3c0..5f84fbba1 100644 --- a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs +++ b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs @@ -35,10 +35,10 @@ use pecos_decoder_core::ObservableDecoder; use pecos_decoder_core::dem::DemMatchingGraph; use pecos_decoder_core::errors::DecoderError; +use pecos_decoder_core::logical_subgraph::window_plan::LogicalSubgraphWindowPlan; use pecos_decoder_core::logical_subgraph::{ - LogicalSubgraph, MaxTimeRadius, StabCoords, partition_dem_by_logical_windowed, + MaxTimeRadius, StabCoords, partition_dem_by_logical_windowed, }; -use std::fmt::Write as _; use crate::decoder::{UfDecoder, UfDecoderConfig}; use crate::windowed::{OverlappingWindowedDecoder, WindowedConfig}; @@ -85,27 +85,26 @@ impl WindowedLogicalSubgraphDecoder { ) -> Result { let parts = partition_dem_by_logical_windowed(dem, stab_coords, max_time_radius)?; - // Subgraph graphs do not carry detector coordinates, but the time-based - // windowing needs them. Pull them from the full DEM and inject (mapped - // to subgraph-local indices) when serializing each sub-DEM. + // Shared coord-preserving plan: subgraph graphs carry no detector + // coordinates, so the plan re-injects the full-DEM coords (mapped to + // subgraph-local indices) into each sub-DEM, giving the time-based + // windowing real detector times. Empty-region observables are dropped. let full_coords = DemMatchingGraph::from_dem_str(dem)?.detector_coords; + let plan = LogicalSubgraphWindowPlan::new(&parts, &full_coords); - let mut subgraphs = Vec::with_capacity(parts.len()); + let mut subgraphs = Vec::with_capacity(plan.num_observables()); let mut max_local = 0usize; - for part in parts { - if part.detector_map.is_empty() { - // Observable with an empty region never flips: contributes 0. - continue; - } - let sub_dem = subgraph_to_dem_string(&part, &full_coords); - let decoder = OverlappingWindowedDecoder::from_dem(&sub_dem, window_config, |wdem| { - UfDecoder::from_dem(wdem, UfDecoderConfig::windowed()) - })?; - max_local = max_local.max(part.graph.num_detectors); + for entry in plan.entries() { + let decoder = + OverlappingWindowedDecoder::from_dem(&entry.sub_dem, window_config, |wdem| { + UfDecoder::from_dem(wdem, UfDecoderConfig::windowed()) + })?; + let num_local = entry.detector_map.len(); + max_local = max_local.max(num_local); subgraphs.push(SubgraphWindowed { - observable_idx: part.observable_idx, - detector_map: part.detector_map, - num_local: part.graph.num_detectors, + observable_idx: entry.observable_idx, + detector_map: entry.detector_map.clone(), + num_local, decoder, }); } @@ -151,29 +150,3 @@ impl ObservableDecoder for WindowedLogicalSubgraphDecoder { Ok(obs_mask) } } - -/// Serialize a subgraph back to a DEM string: detector coordinate lines (mapped -/// from the full DEM via `detector_map`) plus error lines. The coordinate lines -/// let [`OverlappingWindowedDecoder::from_dem`] derive per-detector times for -/// the windowing. -fn subgraph_to_dem_string(part: &LogicalSubgraph, full_coords: &[Option>]) -> String { - let mut s = String::new(); - for (local, &global) in part.detector_map.iter().enumerate() { - if let Some(Some(coords)) = full_coords.get(global) { - let coord_str: Vec = coords.iter().map(|c| format!("{c}")).collect(); - let _ = writeln!(s, "detector({}) D{local}", coord_str.join(", ")); - } - } - for edge in &part.graph.edges { - let _ = write!(s, "error({})", edge.probability); - let _ = write!(s, " D{}", edge.node1); - if let Some(n2) = edge.node2 { - let _ = write!(s, " D{n2}"); - } - for &obs in &edge.observables { - let _ = write!(s, " L{obs}"); - } - let _ = writeln!(s); - } - s -} From ca52fd67a36a1077e1652c4faccf9f3d888db776 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 5 Jun 2026 20:51:33 -0600 Subject: [PATCH 034/150] Address deep review: fix WindowedLogicalSubgraphStrategy to flip the global observable bit not the list position (real multi-observable bug, + length/>64 guards + tests), propagate decode_each errors instead of a sentinel, gate strict on can_window, remove dead _UfDebug/decode_full_with_correction surface, single-source the windowing label, assert the predecoder positive-weight premise, and document window_count as an estimate --- .../src/logical_algorithm.rs | 94 ++++++++++++++++--- .../src/logical_subgraph/window_plan.rs | 17 +++- crates/pecos-uf-decoder/src/decoder.rs | 34 +++---- .../src/fault_tolerance_bindings.rs | 89 +++++++----------- ...test_logical_subgraph_region_comparison.py | 14 +++ 5 files changed, 155 insertions(+), 93 deletions(-) diff --git a/crates/pecos-decoder-core/src/logical_algorithm.rs b/crates/pecos-decoder-core/src/logical_algorithm.rs index ed41d038f..f516d2275 100644 --- a/crates/pecos-decoder-core/src/logical_algorithm.rs +++ b/crates/pecos-decoder-core/src/logical_algorithm.rs @@ -604,33 +604,61 @@ pub struct WindowedLogicalSubgraphStrategy { subgraph_decoders: Vec>, /// Per-subgraph detector maps: `subgraph_detector_maps`[i][local] = global. detector_maps: Vec>, + /// Global observable (logical) index each subgraph decodes. Required because + /// callers may pass only the non-empty subgraphs (empty-region observables + /// dropped), so the subgraph's list position is NOT its observable index. + observable_indices: Vec, /// Per-subgraph sub-syndrome buffers (reusable). sub_syndromes: Vec>, - /// Number of observables. - _num_observables: usize, } impl WindowedLogicalSubgraphStrategy { - /// Build from pre-extracted subgraph DEMs and detector maps. + /// Build from pre-extracted subgraph DEMs, detector maps, and the global + /// observable index each subgraph decodes. /// - /// `subgraph_dems`: per-observable DEM strings (graphlike). - /// `detector_maps`: per-observable local→global detector index maps. + /// `subgraph_dems`: per-subgraph DEM strings (graphlike). + /// `detector_maps`: per-subgraph local→global detector index maps. + /// `observable_indices`: the global observable bit each subgraph flips + /// (each subgraph reports its observable as local bit 0). MUST line up + /// with `subgraph_dems` — when empty-region observables are filtered out, + /// pass the surviving observables' true indices, not `0..n`. /// `factory`: creates the inner decoder for each subgraph DEM. + /// + /// # Errors + /// + /// Returns `DecoderError` if the factory fails, if the three input vectors + /// disagree in length, or if any observable index is >= 64 (the u64 + /// observable mask cannot hold it). pub fn new( subgraph_dems: Vec, detector_maps: Vec>, + observable_indices: Vec, mut factory: F, ) -> Result where F: FnMut(&str) -> Result, DecoderError>, { - let num_observables = subgraph_dems.len(); - let mut decoders = Vec::with_capacity(num_observables); - let mut sub_syndromes = Vec::with_capacity(num_observables); + let num = subgraph_dems.len(); + if detector_maps.len() != num || observable_indices.len() != num { + return Err(DecoderError::InvalidConfiguration(format!( + "WindowedLogicalSubgraphStrategy: mismatched inputs (dems={num}, \ + maps={}, obs={})", + detector_maps.len(), + observable_indices.len(), + ))); + } + if let Some(&bad) = observable_indices.iter().find(|&&o| o >= 64) { + return Err(DecoderError::InvalidConfiguration(format!( + "WindowedLogicalSubgraphStrategy: observable index {bad} >= 64 \ + exceeds the u64 observable-mask capacity" + ))); + } + let mut decoders = Vec::with_capacity(num); + let mut sub_syndromes = Vec::with_capacity(num); for (i, dem_str) in subgraph_dems.iter().enumerate() { let dec = factory(dem_str)?; - let n = detector_maps.get(i).map_or(0, std::vec::Vec::len); + let n = detector_maps[i].len(); sub_syndromes.push(vec![0u8; n]); decoders.push(dec); } @@ -638,8 +666,8 @@ impl WindowedLogicalSubgraphStrategy { Ok(Self { subgraph_decoders: decoders, detector_maps, + observable_indices, sub_syndromes, - _num_observables: num_observables, }) } } @@ -669,10 +697,12 @@ impl DecodeStrategy for WindowedLogicalSubgraphStrategy { }; } - // Decode this subgraph + // Decode this subgraph: it reports its observable as local bit 0; + // map that to the subgraph's *global* observable bit (not its list + // position `i`, which differs once empty observables are filtered). let sub_obs = dec.decode_to_observables(&buf[..n])?; if sub_obs & 1 != 0 { - obs_mask |= 1 << i; + obs_mask |= 1u64 << self.observable_indices[i]; } } @@ -726,6 +756,46 @@ mod tests { assert_eq!(dec.decode_shot(&[0, 1, 0, 1]).unwrap(), 0b01); } + #[test] + fn windowed_strategy_maps_to_global_observable_index() { + // Two surviving subgraphs whose true (global) observable indices are + // NON-contiguous -- as happens when earlier observables had empty + // regions and were filtered out. Each reports its observable as local + // bit 0; the strategy must flip the GLOBAL bit, not the list position. + // (The pre-fix `1 << i` would have produced bits {0,1} = 0b0011.) + let mut strat = WindowedLogicalSubgraphStrategy::new( + vec!["error(0.1) D0 L0".to_string(), "error(0.1) D0 L0".to_string()], + vec![vec![0usize], vec![1usize]], + vec![1usize, 3usize], + |_dem| Ok(Box::new(FixedDecoder(1)) as Box), + ) + .unwrap(); + let obs = strat.decode(&[1, 1]).unwrap(); + assert_eq!(obs, (1u64 << 1) | (1u64 << 3)); + } + + #[test] + fn windowed_strategy_rejects_observable_index_over_63() { + let r = WindowedLogicalSubgraphStrategy::new( + vec!["error(0.1) D0 L0".to_string()], + vec![vec![0usize]], + vec![64usize], + |_dem| Ok(Box::new(FixedDecoder(1)) as Box), + ); + assert!(r.is_err()); + } + + #[test] + fn windowed_strategy_rejects_mismatched_input_lengths() { + let r = WindowedLogicalSubgraphStrategy::new( + vec!["error(0.1) D0 L0".to_string()], + vec![vec![0usize], vec![1usize]], // 2 maps for 1 dem + vec![0usize], + |_dem| Ok(Box::new(FixedDecoder(1)) as Box), + ); + assert!(r.is_err()); + } + #[test] fn test_hadamard_frame() { let mut frame = 0b01u64; // X correction on bit 0 diff --git a/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs b/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs index abc0e596f..1159582fb 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs @@ -144,10 +144,19 @@ impl LogicalSubgraphWindowPlan { self.entries.iter().map(|e| e.detector_map.clone()).collect() } - /// Number of time windows observable `i` would use at `step` rounds per - /// window. Mirrors the core-window loop in the sliding-window decoders - /// (`t_start` from 0 by `step` while `< total_t`, `total_t = max_time + 1`), - /// counting only windows that contain at least one detector. + /// Estimated number of time windows observable `i` would use at `step` + /// rounds per window. + /// + /// This is an ESTIMATE, not a guaranteed match of the exact window count an + /// `OverlappingWindowedDecoder` builds: it counts core ranges that contain a + /// detector and ignores the buffer overlap, and it requires an explicit + /// `step` (the real decoder auto-derives `step` from the graph when none is + /// given). It is sufficient for the load-bearing use here -- the + /// [`Self::effective_windowing`] FullFallback-vs-RealWindowed *boolean*, + /// which depends only on `total_t` vs `step`, not on buffer details. Exact + /// counts should single-source the decoder's own loop (a Layer C item when + /// the windowing construction is revisited; see the proper-solution design + /// doc). #[must_use] pub fn window_count(&self, i: usize, step: usize) -> usize { self.entries diff --git a/crates/pecos-uf-decoder/src/decoder.rs b/crates/pecos-uf-decoder/src/decoder.rs index 35e610f31..dd57ee9bc 100644 --- a/crates/pecos-uf-decoder/src/decoder.rs +++ b/crates/pecos-uf-decoder/src/decoder.rs @@ -373,6 +373,17 @@ impl UfDecoder { /// predecoding them individually gives the same result as joint decoding. #[must_use] pub fn predecode_clusters(&self, syndrome: &[u8]) -> Option { + // Invariant the shortcut proofs rely on: edge weights are non-negative + // (true for `ln((1-p)/p)` when p < 0.5, i.e. real sub-threshold error + // priors). With a negative weight the "lightest edge is the min-weight + // correction" / "direct pair <= split" arguments break. Asserted in + // debug builds; if it ever fires, the predecoder must be disabled for + // that graph (the full decoder handles negative weights correctly). + debug_assert!( + self.edges.iter().all(|e| e.weight >= 0.0), + "predecoder requires non-negative edge weights (p < 0.5)" + ); + let boundary = self.num_detectors as u32; // Mark defects. @@ -955,29 +966,6 @@ impl UfDecoder { self.edges.get(edge_idx).map_or(0, |e| e.obs_mask) } - /// Debug: decode forcing the full grow+peel path (bypassing the - /// predecoder), returning the observable mask and the correction edges as - /// `(node1, node2, obs_mask, weight)`. For diagnostics and tests. - pub fn decode_full_with_correction(&mut self, syndrome: &[u8]) -> (u64, Vec<(u32, u32, u64, f64)>) { - self.reset(); - for (i, &v) in syndrome.iter().enumerate() { - if v != 0 && i < self.num_detectors { - self.parity[i] = true; - self.is_defect[i] = true; - } - } - self.grow_clusters(); - let (obs, edge_idxs) = self.peel_correction_with_edges(); - let edges = edge_idxs - .iter() - .map(|&i| { - let e = &self.edges[i]; - (e.node1, e.node2, e.obs_mask, e.weight) - }) - .collect(); - (obs, edges) - } - /// Get node1 of an edge. #[must_use] pub fn edge_node1(&self, edge_idx: usize) -> u32 { diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 4d954ab30..abdfa0c00 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -2787,7 +2787,12 @@ impl PySampleBatch { let mut syndrome = vec![0u8; self.num_detectors]; for i in 0..self.num_shots { self.extract_syndrome(i, &mut syndrome); - predictions.push(decoder.decode_to_observables(&syndrome).unwrap_or(u64::MAX)); + // Propagate a decode failure rather than masking it as a sentinel + // observable value (which would read as a spurious disagreement). + let predicted = decoder + .decode_to_observables(&syndrome) + .map_err(|e| PyErr::new::(e.to_string()))?; + predictions.push(predicted); } Ok(predictions) } @@ -4332,46 +4337,6 @@ fn assert_dems_equivalent( } } -// ============================================================================= -// UF debug wrapper (diagnostics only) -// ============================================================================= - -/// Debug wrapper over the matching-graph Union-Find decoder. -/// -/// Exposes the full grow+peel correction (bypassing the predecoder) so tests -/// can inspect exactly which edges UF commits and compare against MWPM. -#[pyclass(name = "_UfDebug", module = "pecos_rslib.qec")] -pub struct PyUfDebug { - inner: pecos_decoders::UfDecoder, -} - -#[pymethods] -impl PyUfDebug { - #[new] - #[pyo3(signature = (dem, config="fast"))] - fn new(dem: &str, config: &str) -> PyResult { - let cfg = match config { - "fast" => pecos_decoders::UfDecoderConfig::fast(), - "balanced" => pecos_decoders::UfDecoderConfig::balanced(), - "windowed" => pecos_decoders::UfDecoderConfig::windowed(), - other => { - return Err(pyo3::exceptions::PyValueError::new_err(format!( - "unknown UF config: {other}" - ))); - } - }; - let inner = pecos_decoders::UfDecoder::from_dem(dem, cfg) - .map_err(|e| PyErr::new::(e.to_string()))?; - Ok(Self { inner }) - } - - /// Decode forcing the full grow+peel path (no predecoder). Returns - /// `(obs_mask, [(node1, node2, obs_mask, weight), ...])`. - fn decode_full(&mut self, syndrome: Vec) -> (u64, Vec<(u32, u32, u64, f64)>) { - self.inner.decode_full_with_correction(&syndrome) - } -} - // ============================================================================= // CSS UF Decoder (UIUF) // ============================================================================= @@ -5385,30 +5350,47 @@ impl PyLogicalCircuitDecoder { let step = decode_budget.code_distance.max(1); can_window = plan.effective_windowing(step) == EffectiveWindowing::RealWindowed; - if strict { + // `strict` rejects only when genuine windowing was POSSIBLE (the + // circuit is deep enough) but is being skipped. When `!can_window` + // the circuit is a single window anyway, so a full decode IS the + // bounded-latency answer -- no degradation to reject. + if strict && can_window { return Err(PyErr::new::( "bounded-latency ('windowed') budget requested with strict=True, \ but accurate windowed logical-subgraph decoding is not yet \ - available (windowed-LOM anti-snake machinery pending). It would \ - fall back to a full per-observable decode (unbounded latency). \ - Use budget='unlimited', or pass strict=False to accept the \ - full-decode fallback." + available (windowed-LOM anti-snake machinery pending). This \ + circuit is deep enough to time-window (can_window=True), so a \ + full per-observable decode would forgo the requested latency \ + bound. Use budget='unlimited', or pass strict=False to accept \ + the full-decode fallback." .to_string(), )); } let sub_dems = plan.sub_dems(); let det_maps = plan.detector_maps(); - effective_windowing = String::from("full_fallback"); + let obs_indices: Vec = + plan.entries().iter().map(|e| e.observable_idx).collect(); + // The fallback runs a full (non-windowed) inner per observable, so + // the actual window count is 1 each by construction. (The Layer C + // real-windowed path must instead derive these from the windowed + // inners.) The label is single-sourced from the plan's enum. + effective_windowing = EffectiveWindowing::FullFallback.as_str().to_string(); actual_num_windows = vec![1usize; sub_dems.len()]; let fallback_inner = inner_decoder.to_string(); - let wosd = WindowedLogicalSubgraphStrategy::new(sub_dems, det_maps, |dem_str| { - let dec = create_observable_decoder(dem_str, &fallback_inner) - .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string()))?; - Ok(Box::new(SendWrapper(dec)) - as Box) - }) + let wosd = WindowedLogicalSubgraphStrategy::new( + sub_dems, + det_maps, + obs_indices, + |dem_str| { + let dec = create_observable_decoder(dem_str, &fallback_inner).map_err( + |e| pecos_decoders::DecoderError::InternalError(e.to_string()), + )?; + Ok(Box::new(SendWrapper(dec)) + as Box) + }, + ) .map_err(|e| PyErr::new::(e.to_string()))?; Box::new(wosd) @@ -5732,7 +5714,6 @@ 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/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 1e5f7607d..f05da9998 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 @@ -360,5 +360,19 @@ def test_windowed_logical_subgraph_known_limitation_no_full_suppression(): ) +def test_decode_each_matches_decode_count(): + """`SampleBatch.decode_each` returns per-shot predictions consistent with the + aggregate `decode_count` -- it is the per-shot primitive used to localize + where two decoders disagree.""" + b = _cx_circuit() + dem = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + n = 3000 + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(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)) + assert wrong == batch.decode_count(dem, "pecos_uf:bp") + + if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) From 90125b928ead76e0235c9c936856e722f9fbe0d8 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 5 Jun 2026 23:01:19 -0600 Subject: [PATCH 035/150] Second-review hygiene: harden parse_dem_metadata (max+1 observable count + reject repeat/shift_detectors), move predecoder positive-weight debug_assert to construction, add local <64 assert at the standalone windowed shift site, fix XORed clippy doc lint, and clarify actual_num_windows doc --- crates/pecos-decoder-core/src/dem.rs | 50 +++++++++++++------ crates/pecos-uf-decoder/src/decoder.rs | 21 ++++---- .../src/logical_subgraph_windowed.rs | 13 +++-- .../src/fault_tolerance_bindings.rs | 9 ++-- 4 files changed, 60 insertions(+), 33 deletions(-) diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index acf4003e3..c380ae578 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -103,7 +103,11 @@ pub mod utils { /// Returns [`DecoderError`] if the DEM format is invalid pub fn parse_dem_metadata(dem: &str) -> Result<(usize, usize), DecoderError> { let mut max_detector = None; - let mut observables = std::collections::BTreeSet::new(); + // Count as `max index + 1` (not distinct-id count) to match the other + // parsers (`SparseDem`, `DemCheckMatrix`, `DemMatchingGraph`) and to size + // index-addressed buffers correctly when ids are non-contiguous + // (e.g. only `L2` present -> 3 observables, not 1). + let mut max_observable: Option = None; for line in dem.lines() { let line = line.trim(); @@ -111,6 +115,16 @@ pub mod utils { continue; } + // This is a flat single-pass counter; reject loop/offset commands + // rather than miscounting them (same contract as the other parsers). + if line.starts_with("repeat") || line.starts_with("shift_detectors") { + return Err(DecoderError::InvalidConfiguration( + "parse_dem_metadata requires a flattened DEM: `repeat` / \ + `shift_detectors` are not supported. Flatten the DEM first." + .into(), + )); + } + let parts: Vec<&str> = line.split_whitespace().collect(); if parts.is_empty() { continue; @@ -124,8 +138,10 @@ pub mod utils { }; match command { - "error" => { - // Parse error line for detector and observable indices + // `error` and `logical_observable` both contribute observable + // ids; `logical_observable` declares deterministic logicals that + // Stim emits with no flipping mechanism but still count. + "error" | "logical_observable" => { for part in &parts[1..] { if let Some(d_str) = part.strip_prefix('D') { if let Ok(d) = d_str.parse::() { @@ -134,7 +150,7 @@ pub mod utils { } else if let Some(l_str) = part.strip_prefix('L') && let Ok(l) = l_str.parse::() { - observables.insert(l); + max_observable = Some(max_observable.map_or(l, |m: usize| m.max(l))); } } } @@ -148,23 +164,12 @@ pub mod utils { } } } - "logical_observable" => { - // Declared observable with no flipping mechanism (Stim emits - // these for deterministic logicals) still counts. - for part in &parts[1..] { - if let Some(l_str) = part.strip_prefix('L') - && let Ok(l) = l_str.parse::() - { - observables.insert(l); - } - } - } _ => {} } } let detector_count = max_detector.map_or(0, |m| m + 1); - let observable_count = observables.len(); + let observable_count = max_observable.map_or(0, |m| m + 1); Ok((detector_count, observable_count)) } @@ -1085,6 +1090,17 @@ mod tests { assert_eq!(obs, 2, "parse_dem_metadata must count L1"); } + #[test] + fn test_parser_observable_count_is_max_plus_one_for_noncontiguous_ids() { + // Only L2 present: index-addressed buffers need 3 slots, not 1. All + // parsers must agree on `max + 1`, not distinct-id count. + let dem = "error(0.01) D0 L2\ndetector(0,0,0) D0\n"; + assert_eq!(DemMatchingGraph::from_dem_str(dem).unwrap().num_observables, 3); + assert_eq!(DemCheckMatrix::from_dem_str(dem).unwrap().num_observables, 3); + let (_d, obs) = utils::parse_dem_metadata(dem).unwrap(); + assert_eq!(obs, 3, "parse_dem_metadata must agree (max+1, not distinct count)"); + } + #[test] fn test_non_flattened_dem_rejected() { // repeat blocks and shift_detectors would corrupt detector ids if parsed @@ -1093,9 +1109,11 @@ mod tests { assert!(SparseDem::from_dem_str(repeat_dem).is_err()); assert!(DemCheckMatrix::from_dem_str(repeat_dem).is_err()); assert!(DemMatchingGraph::from_dem_str(repeat_dem).is_err()); + assert!(utils::parse_dem_metadata(repeat_dem).is_err()); let shift_dem = "error(0.01) D0 L0\nshift_detectors 1\nerror(0.01) D0 L0\n"; assert!(SparseDem::from_dem_str(shift_dem).is_err()); + assert!(utils::parse_dem_metadata(shift_dem).is_err()); } #[test] diff --git a/crates/pecos-uf-decoder/src/decoder.rs b/crates/pecos-uf-decoder/src/decoder.rs index dd57ee9bc..5a1c4e29a 100644 --- a/crates/pecos-uf-decoder/src/decoder.rs +++ b/crates/pecos-uf-decoder/src/decoder.rs @@ -224,6 +224,16 @@ impl UfDecoder { temp_adj[n2 as usize].push((idx, n1)); } + // Construction-time invariant: edge weights are non-negative (true for + // `ln((1-p)/p)` when p < 0.5, i.e. real sub-threshold priors). The + // predecoder's shortcut proofs ("lightest edge is the min-weight + // correction", "direct pair <= boundary split") depend on it; a negative + // weight would silently break them. Checked once here, not per shot. + debug_assert!( + edges.iter().all(|e| e.weight >= 0.0), + "UfDecoder requires non-negative edge weights (error priors p < 0.5)" + ); + // Sort each node's adjacency by weight (lightest first). for adj in &mut temp_adj { adj.sort_by(|a, b| { @@ -373,17 +383,6 @@ impl UfDecoder { /// predecoding them individually gives the same result as joint decoding. #[must_use] pub fn predecode_clusters(&self, syndrome: &[u8]) -> Option { - // Invariant the shortcut proofs rely on: edge weights are non-negative - // (true for `ln((1-p)/p)` when p < 0.5, i.e. real sub-threshold error - // priors). With a negative weight the "lightest edge is the min-weight - // correction" / "direct pair <= split" arguments break. Asserted in - // debug builds; if it ever fires, the predecoder must be disabled for - // that graph (the full decoder handles negative weights correctly). - debug_assert!( - self.edges.iter().all(|e| e.weight >= 0.0), - "predecoder requires non-negative edge weights (p < 0.5)" - ); - let boundary = self.num_detectors as u32; // Mark defects. diff --git a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs index 5f84fbba1..2a41f3b35 100644 --- a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs +++ b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs @@ -24,7 +24,7 @@ //! decoding: every window is decoded with a buffer for matching context, but //! only correction edges whose BOTH endpoints lie in the window core are //! committed (Tan et al., arXiv:2209.09219). The per-observable committed -//! observable flips are XORed. +//! observable flips are XOR-combined. //! //! An earlier implementation windowed the full DEM first and then ran a subgraph //! decoder per window, combining by a naive full-window observable XOR with no @@ -59,7 +59,7 @@ struct SubgraphWindowed { /// /// Partitions the DEM per observable, then windows each subgraph with an /// [`OverlappingWindowedDecoder`] (sliding-window core-commit). Per-observable -/// committed observable flips are XORed into the final mask. +/// committed observable flips are XOR-combined into the final mask. pub struct WindowedLogicalSubgraphDecoder { subgraphs: Vec, /// Reusable subgraph-local syndrome buffer (sized to the largest subgraph). @@ -141,7 +141,14 @@ impl ObservableDecoder for WindowedLogicalSubgraphDecoder { }; } // The subgraph decodes a single observable as its local bit 0; map - // that back to this observable's global bit. + // that back to this observable's global bit. `observable_idx < 64` is + // guaranteed upstream (`subgraphs_from_membership` rejects >64 + // observables); assert it locally where the u64 shift consumes it. + debug_assert!( + sg.observable_idx < 64, + "observable index {} exceeds u64 observable-mask capacity", + sg.observable_idx + ); let sub_obs = sg.decoder.decode_to_observables(&self.local_syn[..n])?; if sub_obs & 1 != 0 { obs_mask |= 1u64 << sg.observable_idx; diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index abdfa0c00..ca3c29c76 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -5142,7 +5142,8 @@ pub struct PyLogicalCircuitDecoder { /// "full_fallback" (per-observable full decode behind a windowed budget), /// or "real_windowed" (genuine sliding-window; not yet enabled). effective_windowing: String, - /// Per-observable window count actually used (1 == full decode). + /// Window count actually used, one entry per non-empty subgraph (1 == full + /// decode); not indexed by global observable id. actual_num_windows: Vec, /// Whether genuine time-windowing is *possible* for this circuit (deep /// enough), independent of whether it is enabled. False for "unlimited". @@ -5416,8 +5417,10 @@ impl PyLogicalCircuitDecoder { &self.effective_windowing } - /// Per-observable window count actually used (``1`` == full decode). Empty - /// for the unlimited budget. + /// Window count actually used, one entry per *non-empty* subgraph in + /// surviving-subgraph order (empty-region observables are dropped, so this + /// is not indexed by global observable id). ``1`` == full decode. All ``1`` + /// in the current full-fallback path; empty for the unlimited budget. #[getter] fn actual_num_windows(&self) -> Vec { self.actual_num_windows.clone() From 66f377f5549bd04a1b967a647a33073a2dbcd5fd Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 7 Jun 2026 00:14:43 -0600 Subject: [PATCH 036/150] Fix 3 Codex-review blockers: emit real physical code distance in the algorithm descriptor (was a fake sqrt-of-patch-count that made can_window/strict dishonest), return DecoderError instead of panicking on out-of-range from_membership detector ids, and make all four DEM parsers agree on declared-but-unreferenced detectors (max+1); narrow the coordless subgraph_dems doc --- crates/pecos-decoder-core/src/dem.rs | 51 ++++++++++++++++++- .../src/logical_subgraph.rs | 17 +++++++ .../src/fault_tolerance_bindings.rs | 35 ++++++++++--- .../src/pecos/qec/surface/logical_circuit.py | 9 ++++ .../test_logical_circuit_decoder_windowing.py | 13 +++++ 5 files changed, 115 insertions(+), 10 deletions(-) diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index c380ae578..d7e351633 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -130,9 +130,15 @@ pub mod utils { continue; } - // Handle commands with probability parameters like "error(0.01)" + // Normalize commands that carry parenthesized parameters: + // `error(0.01) ...` and `detector(x,y,t) Dk` (Stim always parenthesizes + // detector coordinates, so a literal `parts[0] == "detector"` check + // would miss every real declaration and undercount detectors that are + // declared but never referenced by an error mechanism). let command = if parts[0].starts_with("error(") { "error" + } else if parts[0].starts_with("detector(") { + "detector" } else { parts[0] }; @@ -447,8 +453,22 @@ impl DemCheckMatrix { .into(), )); } + if let Some(rest) = line.strip_prefix("detector(") { + // Count the declared detector id, which may not be referenced by + // any error mechanism. All parsers agree on + // `max(declared, error-referenced) + 1`. + if let Some(close) = rest.find(')') { + for token in rest[close + 1..].split_whitespace() { + if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) + { + max_detector = Some(max_detector.map_or(d, |m| m.max(d))); + } + } + } + continue; + } if !line.starts_with("error(") { - // Skip non-error lines (detector, etc.) + // Skip other non-error lines (logical_observable handled above). continue; } @@ -645,6 +665,20 @@ impl DemMatchingGraph { .into(), )); } + if let Some(rest) = line.strip_prefix("detector(") { + // Count the declared detector id (may not be error-referenced) so + // `num_detectors` matches the other parsers and its coordinate is + // not later dropped from `detector_coords`. + if let Some(close) = rest.find(')') { + for token in rest[close + 1..].split_whitespace() { + if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) + { + max_detector = Some(max_detector.map_or(d, |m| m.max(d))); + } + } + } + continue; + } if line.is_empty() || line.starts_with('#') || !line.starts_with("error(") { continue; } @@ -1090,6 +1124,19 @@ mod tests { assert_eq!(obs, 2, "parse_dem_metadata must count L1"); } + #[test] + fn test_parsers_count_declared_but_unreferenced_detectors() { + // A detector declared via `detector(coords) Dk` but never referenced by + // an error mechanism must still count (max declared id + 1). All four + // parsers must agree; index-addressed buffers depend on it. + let dem = "detector(0, 0, 0) D2\nlogical_observable L0\n"; + assert_eq!(SparseDem::from_dem_str(dem).unwrap().num_detectors, 3); + assert_eq!(DemCheckMatrix::from_dem_str(dem).unwrap().num_detectors, 3); + assert_eq!(DemMatchingGraph::from_dem_str(dem).unwrap().num_detectors, 3); + let (dets, _obs) = utils::parse_dem_metadata(dem).unwrap(); + assert_eq!(dets, 3, "parse_dem_metadata must count declared detector D2"); + } + #[test] fn test_parser_observable_count_is_max_plus_one_for_noncontiguous_ids() { // Only L2 present: index-addressed buffers need 3 slots, not 1. All diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index cc2061819..52bbb728e 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -366,7 +366,17 @@ pub fn subgraphs_from_membership( } // Build detector mapping (full DEM id -> subgraph-local index). + // Validate caller-provided ids first: an out-of-range id would index + // past `inverse_map` and panic, so return a decoder error instead. let detector_map: Vec = detectors.clone(); + if let Some(&bad) = detector_map.iter().find(|&&d| d >= sdem.num_detectors) { + return Err(DecoderError::InvalidConfiguration(format!( + "subgraphs_from_membership: observable {obs_idx} membership references \ + detector {bad}, but the DEM has only {} detectors (D0..D{})", + sdem.num_detectors, + sdem.num_detectors.saturating_sub(1), + ))); + } let mut inverse_map = vec![None; sdem.num_detectors]; for (sub_idx, &full_idx) in detector_map.iter().enumerate() { inverse_map[full_idx] = Some(sub_idx); @@ -895,6 +905,13 @@ mod tests { subgraphs_from_membership(&sdem, &big), Err(DecoderError::InvalidConfiguration(_)) )); + + // An out-of-range membership detector id must error, not panic + // (the DEM has 2 detectors D0,D1; detector 5 is past `inverse_map`). + assert!(matches!( + subgraphs_from_membership(&sdem, &vec![vec![5usize]]), + Err(DecoderError::InvalidConfiguration(_)) + )); } #[test] diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index ca3c29c76..8b5261477 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4754,10 +4754,15 @@ impl PyLogicalSubgraphDecoder { Ok((edges.len(), num_qubits)) } - /// Get the per-subgraph DEM strings (graphlike, suitable for windowed decoding). - /// - /// Each string is a DEM with local detector IDs (0..N) that can be - /// passed to windowed or sandwich decoders. + /// Get the per-subgraph DEM strings (graphlike, local detector IDs 0..N). + /// + /// NOTE: these strings carry NO `detector(...)` coordinate lines (subgraph + /// graphs drop coordinates), so they are NOT suitable for *time-windowed* + /// decoding -- a windowed decoder would see no detector times and collapse to + /// a single window. For windowing, use the coord-preserving + /// `LogicalSubgraphWindowPlan` path (the `WindowedLogicalSubgraphDecoder` / + /// logical-circuit windowed budget already do). These strings are fine for + /// full (non-windowed) per-subgraph decoding. fn subgraph_dems(&self) -> Vec { (0..self.inner.num_observables()) .map(|i| { @@ -5290,10 +5295,24 @@ impl PyLogicalCircuitDecoder { // Select budget: "unlimited" for full-circuit, "windowed" for // bounded-latency, or a cycle time in microseconds like "1000us". - let mut distance = 0usize; - while distance.saturating_mul(distance) < num_qubits { - distance += 1; - } + // + // Use the REAL physical code distance from the descriptor (used for the + // windowing step / latency bound). `num_qubits = rust_sc.len()` is the + // number of logical patches, NOT a distance -- deriving distance from it + // (e.g. sqrt) is wrong (a single d=7 patch would yield distance 1 and + // make `can_window`/`strict` dishonest). Fall back to the old patch-count + // heuristic only for legacy descriptors that predate the `distance` field. + let distance: usize = descriptor + .get_item("distance")? + .and_then(|v| v.extract::().ok()) + .filter(|&d| d > 0) + .unwrap_or_else(|| { + let mut d = 0usize; + while d.saturating_mul(d) < num_qubits { + d += 1; + } + d.max(1) + }); let decode_budget = match budget { "unlimited" | "offline" => DecodeBudget::unlimited(), "windowed" => { diff --git a/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py b/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py index c443f389c..4c5f94c1f 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py +++ b/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py @@ -836,6 +836,14 @@ def build_algorithm_descriptor( seg_dems.append("\n".join(lines)) + # Physical code distance for latency/windowing decisions. With multiple + # patches use the minimum (the weakest bound governs latency). This is the + # real surface-code distance, NOT a count of logical patches. + distance = min( + (min(ps.patch.geometry.dx, ps.patch.geometry.dz) for ps in self._patches.values()), + default=0, + ) + return { "segments": [ { @@ -848,6 +856,7 @@ def build_algorithm_descriptor( "boundary_gates": boundary_gates, "num_observables": num_patches * 2, "full_dem": full_dem, + "distance": distance, } def build_decoder( diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py b/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py index fc5c35527..5f1dd9a79 100644 --- a/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py +++ b/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py @@ -66,6 +66,19 @@ def test_strict_windowed_budget_hard_errors(): LogicalCircuitDecoder(desc, budget="windowed", strict=True) +def test_strict_accepts_shallow_circuit_using_real_distance(): + """`can_window`/`strict` must use the REAL physical code distance from the + descriptor, not a fake distance derived from the patch count. A single d=5 + patch with only 2 rounds is one window at step=d=5, so strict=True must NOT + reject and can_window must be False. (The prior code derived distance=1 from + the 1-patch count and wrongly reported real windowing / rejected.)""" + desc = _memory_descriptor(5, 2) + assert desc["distance"] == 5 + dec = LogicalCircuitDecoder(desc, budget="windowed", strict=True) # must not raise + assert dec.can_window is False + assert dec.effective_windowing == "full_fallback" + + def test_windowed_full_fallback_still_decodes(): """The full-fallback path is still a working decoder (accurate per-observable decode), not a stub.""" From 03d4d02aacb72dc019d67e2857f8ad2780e8d695 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 7 Jun 2026 09:26:41 -0600 Subject: [PATCH 037/150] Prototype reliable_observables: derive decodable logical-observable combinations from reset structure via GF(2) null space of the reset/observable anticommutation matrix (port of lomatching get_reliable_observables); validated against the paper's Bell-state fragile example, memory, and a real PECOS to_stim circuit --- .../src/pecos/qec/reliable_observables.py | 198 ++++++++++++++++++ .../tests/qec/test_reliable_observables.py | 103 +++++++++ 2 files changed, 301 insertions(+) create mode 100644 python/quantum-pecos/src/pecos/qec/reliable_observables.py create mode 100644 python/quantum-pecos/tests/qec/test_reliable_observables.py diff --git a/python/quantum-pecos/src/pecos/qec/reliable_observables.py b/python/quantum-pecos/src/pecos/qec/reliable_observables.py new file mode 100644 index 000000000..86d6fb451 --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/reliable_observables.py @@ -0,0 +1,198 @@ +# 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. + +"""PROTOTYPE: reliable logical-observable combinations from reset structure. + +Port of lomatching's ``get_reliable_observables`` (Serra-Peralta et al., +arXiv:2505.13599, "logical observable matching"). A logical observable is +*fragile* when its back-propagated observing region anticommutes with a reset +stabilizer -- decoding it directly is unreliable (a weight-2 space-time +stabilizer flips its decoded outcome without a logical fault). The paper's fix +is to decode only an **independent generating set of reliable observables** and +infer fragile ones as products. + +This module computes that reliable set: build the anticommutation matrix +``A[reset, obs]`` (1 iff reset ``reset`` and observable ``obs`` anticommute) and +take its right null space over GF(2). Each null vector is a combination of raw +observables that commutes with *every* reset -- i.e. a reliable observable. + +Status: prototype / proof-of-concept. Dependency-free (own GF(2) null space, no +``galois``). The eventual production path would compute the null space via +pecos-num's GF(2) and feed the reliable set into the logical-subgraph decoder +front-end. See ``pecos-docs/design/lomatching-paper-additional-learnings.md``. +""" + +from __future__ import annotations + +import numpy as np + +try: + import stim +except ImportError as exc: # pragma: no cover - stim is an optional dep + msg = "reliable_observables requires the `stim` package" + raise ImportError(msg) from exc + +# Stim reset instruction names (Pauli basis is the last character; default Z). +_RESET_INSTRS = frozenset(["R", "RX", "RY", "RZ", "MR", "MRX", "MRY", "MRZ"]) + +# A Pauli region is {tick_index: stim.PauliString}. +PauliRegion = dict[int, "stim.PauliString"] + + +def reliable_observables(circuit: stim.Circuit) -> list[set[int]]: + """Return a complete basis of reliable observable combinations. + + Args: + circuit: A ``stim.Circuit`` whose observables are defined via + ``OBSERVABLE_INCLUDE``. Qubits must be explicitly reset, and a reset + must be the only operation on its qubit within its ``TICK`` (the + lomatching precondition). + + Returns: + One ``set[int]`` per basis element of the reliable space; each set is the + raw-observable indices whose XOR is a reliable observable. An empty list + means no nontrivial reliable combination exists. + """ + if not isinstance(circuit, stim.Circuit): + msg = f"`circuit` must be a stim.Circuit, got {type(circuit)}" + raise TypeError(msg) + + resets = _reset_pauli_regions(circuit) + num_obs = circuit.num_observables + obs_regions = {o: _observing_region(circuit, o) for o in range(num_obs)} + + # A[reset, obs] = 1 iff the reset and the observable's region anticommute. + a = np.zeros((len(resets), num_obs), dtype=np.uint8) + for obs_id, region in obs_regions.items(): + for reset_id, reset_region in resets.items(): + if _anticommute(reset_region, region): + a[reset_id, obs_id] = 1 + + return [set(np.nonzero(vec)[0].tolist()) for vec in _gf2_right_null_space(a)] + + +def is_reliable(circuit: stim.Circuit, observable: set[int] | int) -> bool: + """Whether a single observable (or XOR of observables) is reliable. + + A combination is reliable iff its region commutes with every reset. + """ + obs = {observable} if isinstance(observable, int) else set(observable) + resets = _reset_pauli_regions(circuit) + # Combine the regions of the chosen observables by tick-wise Pauli product. + combined: PauliRegion = {} + for o in obs: + for tick, ps in _observing_region(circuit, o).items(): + combined[tick] = combined[tick] * ps if tick in combined else ps + return all(not _anticommute(r, combined) for r in resets.values()) + + +# --------------------------------------------------------------------------- # +# Internals (faithful to lomatching's util.py) +# --------------------------------------------------------------------------- # + + +def _reset_pauli_regions(circuit: stim.Circuit) -> dict[int, PauliRegion]: + """Per-reset single-tick Pauli region: the reset's Pauli on its qubit.""" + flat = circuit.flattened() + n = flat.num_qubits + resets: dict[int, PauliRegion] = {} + reset_idx = 0 + tick = 0 + for instr in flat: + if instr.name == "TICK": + tick += 1 + continue + if instr.name not in _RESET_INSTRS: + continue + pauli = "Z" + if instr.name.endswith("X"): + pauli = "X" + elif instr.name.endswith("Y"): + pauli = "Y" + for target in instr.targets_copy(): + ps = stim.PauliString(n) + ps[target.value] = pauli + resets[reset_idx] = {tick: ps} + reset_idx += 1 + return resets + + +def _observing_region(circuit: stim.Circuit, observable: int) -> PauliRegion: + """Back-propagated observing region of one observable, via stim. + + Rewrites the circuit so only `observable` survives, renamed to L0, then uses + `stim.Circuit.detecting_regions` to get its {tick: PauliString} region. + """ + new_circuit = stim.Circuit() + for instr in circuit.flattened(): + if instr.name != "OBSERVABLE_INCLUDE": + new_circuit.append(instr) + continue + if instr.gate_args_copy()[0] != observable: + continue + new_circuit.append( + stim.CircuitInstruction( + name="OBSERVABLE_INCLUDE", gate_args=[0], targets=instr.targets_copy() + ) + ) + target = stim.DemTarget("L0") + regions = new_circuit.detecting_regions( + targets=[target], ignore_anticommutation_errors=True + ) + return regions.get(target, {}) + + +def _anticommute(region_a: PauliRegion, region_b: PauliRegion) -> bool: + """True iff two Pauli regions anticommute (odd number of anticommuting ticks).""" + anti = 0 + for tick in set(region_a).intersection(region_b): + if not region_a[tick].commutes(region_b[tick]): + anti += 1 + return anti % 2 == 1 + + +def _gf2_right_null_space(a: np.ndarray) -> list[np.ndarray]: + """Basis of {x : a @ x == 0 (mod 2)} over GF(2), via row reduction. + + Returns a list of 0/1 vectors of length ``a.shape[1]``. + """ + a = (np.asarray(a, dtype=np.uint8) % 2).copy() + rows, cols = a.shape + pivot_col_of_row: list[int] = [] + pivot_cols: set[int] = set() + r = 0 + for c in range(cols): + # find a pivot in column c at or below row r + piv = next((i for i in range(r, rows) if a[i, c]), None) + if piv is None: + continue + a[[r, piv]] = a[[piv, r]] + for i in range(rows): + if i != r and a[i, c]: + a[i] ^= a[r] + pivot_col_of_row.append(c) + pivot_cols.add(c) + r += 1 + if r == rows: + break + + free_cols = [c for c in range(cols) if c not in pivot_cols] + basis: list[np.ndarray] = [] + for f in free_cols: + x = np.zeros(cols, dtype=np.uint8) + x[f] = 1 + # back-substitute: pivot row i fixes its pivot col from the free col + for i, pc in enumerate(pivot_col_of_row): + if a[i, f]: + x[pc] = 1 + basis.append(x) + return basis diff --git a/python/quantum-pecos/tests/qec/test_reliable_observables.py b/python/quantum-pecos/tests/qec/test_reliable_observables.py new file mode 100644 index 000000000..6e7715625 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_reliable_observables.py @@ -0,0 +1,103 @@ +# 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. + +"""Validate the reliable-observable prototype against the paper's examples. + +Reproduces the fragile-observable example from Serra-Peralta et al. +(arXiv:2505.13599): two fragile observables whose product is reliable. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +stim = pytest.importorskip("stim") + +from pecos.qec.reliable_observables import ( # noqa: E402 + _gf2_right_null_space, + is_reliable, + reliable_observables, +) + + +def test_gf2_right_null_space(): + a = np.array([[1, 1, 0], [0, 1, 1]], dtype=np.uint8) + basis = _gf2_right_null_space(a) + # rank 2, 3 cols -> 1-d null space, spanned by [1,1,1]. + assert len(basis) == 1 + assert basis[0].tolist() == [1, 1, 1] + for v in basis: + assert np.all((a @ v) % 2 == 0) + + +def test_gf2_full_rank_has_trivial_null_space(): + a = np.eye(3, dtype=np.uint8) + assert _gf2_right_null_space(a) == [] + + +def _bell_circuit() -> stim.Circuit: + # Paper's fragile example (eq. Bell_state_fragile_observables): + # q0=|0>, q1=|+>, CX(1,0), measure both in Z. O0={M(q0)}, O1={M(q1)} are + # each fragile; their product is reliable (and deterministic). + c = stim.Circuit() + c.append("RZ", [0]) + c.append("RX", [1]) + c.append("TICK") + c.append("CX", [1, 0]) + c.append("TICK") + c.append("M", [0]) + c.append("M", [1]) + c.append("OBSERVABLE_INCLUDE", [stim.target_rec(-2)], 0) + c.append("OBSERVABLE_INCLUDE", [stim.target_rec(-1)], 1) + return c + + +def test_bell_fragile_observables_product_is_reliable(): + """The paper's headline example: O0, O1 fragile; O0*O1 reliable.""" + c = _bell_circuit() + assert reliable_observables(c) == [{0, 1}] + assert is_reliable(c, {0, 1}) + assert not is_reliable(c, 0) + assert not is_reliable(c, 1) + + +def test_memory_single_observable_is_reliable(): + c = stim.Circuit() + c.append("RZ", [0]) + c.append("TICK") + c.append("M", [0]) + c.append("OBSERVABLE_INCLUDE", [stim.target_rec(-1)], 0) + assert reliable_observables(c) == [{0}] + assert is_reliable(c, 0) + + +def test_runs_on_pecos_surface_memory_circuit(): + """End-to-end on a real PECOS-generated circuit: a surface-code Z memory has + a single reliable logical observable.""" + from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch + + patch = SurfacePatch.create(distance=3) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", 3, "Z") + circuit = stim.Circuit(b.to_stim(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0)) + assert circuit.num_observables >= 1 + rel = reliable_observables(circuit) + # Every raw observable should be reliable on its own for a plain memory. + for o in range(circuit.num_observables): + assert is_reliable(circuit, o), f"observable {o} unexpectedly fragile" + assert rel # non-empty reliable basis + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From 76554b3df4b63604914d727f1b607ad20f806aca Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 7 Jun 2026 11:21:17 -0600 Subject: [PATCH 038/150] Preserve runtime result provenance for traced surface DEMs --- crates/pecos-qis-ffi-types/src/lib.rs | 2 +- crates/pecos-qis-ffi-types/src/operations.rs | 11 + crates/pecos-qis-ffi/src/ffi.rs | 38 ++- crates/pecos-qis-ffi/src/lib.rs | 111 ++++++- crates/pecos-qis/src/ccengine.rs | 95 +++++- crates/pecos-qis/src/executor.rs | 50 +++ crates/pecos-qis/src/qis_interface.rs | 11 + .../src/pecos/qec/surface/decode.py | 306 +++++++++++++----- .../tests/pecos/test_selene_sim_parity.py | 24 ++ .../tests/qec/test_from_guppy_dem.py | 39 +++ 10 files changed, 593 insertions(+), 94 deletions(-) diff --git a/crates/pecos-qis-ffi-types/src/lib.rs b/crates/pecos-qis-ffi-types/src/lib.rs index 31a0f2f84..94cc36807 100644 --- a/crates/pecos-qis-ffi-types/src/lib.rs +++ b/crates/pecos-qis-ffi-types/src/lib.rs @@ -7,7 +7,7 @@ mod operations; -pub use operations::{Operation, QuantumOp}; +pub use operations::{NamedResultTrace, Operation, QuantumOp}; const DEFAULT_OPERATION_CAPACITY: usize = 1024; const DEFAULT_MEASUREMENT_CAPACITY: usize = 256; diff --git a/crates/pecos-qis-ffi-types/src/operations.rs b/crates/pecos-qis-ffi-types/src/operations.rs index 1ded1d33b..61cf38b11 100644 --- a/crates/pecos-qis-ffi-types/src/operations.rs +++ b/crates/pecos-qis-ffi-types/src/operations.rs @@ -3,6 +3,17 @@ //! This module defines the quantum operations that can be collected by the interface //! and later executed by a runtime. +/// Runtime provenance for a named `result(...)` output. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct NamedResultTrace { + /// Name passed to `result(name, value)`. + pub name: String, + /// Boolean values emitted for this result call. + pub values: Vec, + /// Runtime measurement result IDs read to produce `values`, in element order. + pub result_ids: Vec, +} + /// High-level quantum operations that include both QIS and control flow #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub enum Operation { diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index 9a2f3bfd4..120f53e3a 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -266,6 +266,13 @@ pub unsafe extern "C" fn __quantum__rt__result_allocate() -> i64 { // --- Result Retrieval --- +fn record_result_read(result_id: usize) { + if let Some(ctx) = crate::get_execution_context() { + // SAFETY: Context is valid for duration of execution. + unsafe { &*ctx }.record_result_read(result_id); + } +} + /// Get measurement result (returns 1 if result is One, 0 otherwise) /// /// This function supports dynamic circuits: if the result is not yet available and @@ -284,6 +291,7 @@ pub unsafe extern "C" fn __quantum__rt__result_get_one(result: i64) -> i32 { let existing_result = with_interface(|interface| interface.get_result(result_id)); if let Some(value) = existing_result { + record_result_read(result_id); return i32::from(value); } @@ -300,7 +308,10 @@ pub unsafe extern "C" fn __quantum__rt__result_get_one(result: i64) -> i32 { ); 0 }, - i32::from, + |value| { + record_result_read(result_id); + i32::from(value) + }, ) }) } else { @@ -473,6 +484,7 @@ pub unsafe extern "C" fn ___read_future_bool(future_id: i64) -> bool { log::debug!("___read_future_bool: existing_result={existing_result:?}"); if let Some(result) = existing_result { + record_result_read(result_id); return result; } @@ -484,6 +496,7 @@ pub unsafe extern "C" fn ___read_future_bool(future_id: i64) -> bool { log::debug!( "___read_future_bool: result already in context for result_id={result_id}: {result}" ); + record_result_read(result_id); return result; } @@ -498,6 +511,9 @@ pub unsafe extern "C" fn ___read_future_bool(future_id: i64) -> bool { // The main thread stores results there to cross the thread boundary let result = crate::get_measurement_result(result_id as u64); log::debug!("___read_future_bool: got result after waiting: {result:?}"); + if result.is_some() { + record_result_read(result_id); + } return result.unwrap_or(false); } log::debug!("___read_future_bool: timeout waiting for result"); @@ -1505,6 +1521,26 @@ mod tests { assert!(result); } + #[test] + fn test_named_result_trace_consumes_recorded_result_reads() { + let ctx = crate::ExecutionContext::new(); + + ctx.record_result_read(7); + ctx.store_named_bool("m", true); + ctx.record_result_read(8); + ctx.record_result_read(9); + ctx.store_named_array("arr", &[false, true]); + + let traces = ctx.get_named_result_traces(); + assert_eq!(traces.len(), 2); + assert_eq!(traces[0].name, "m"); + assert_eq!(traces[0].values, vec![true]); + assert_eq!(traces[0].result_ids, vec![7]); + assert_eq!(traces[1].name, "arr"); + assert_eq!(traces[1].values, vec![false, true]); + assert_eq!(traces[1].result_ids, vec![8, 9]); + } + #[test] fn test_read_future_bool_default() { setup_test(); diff --git a/crates/pecos-qis-ffi/src/lib.rs b/crates/pecos-qis-ffi/src/lib.rs index b772a8749..e83988691 100644 --- a/crates/pecos-qis-ffi/src/lib.rs +++ b/crates/pecos-qis-ffi/src/lib.rs @@ -66,6 +66,10 @@ pub struct ExecutionContext { pub measurement_results: Mutex>>, /// Storage for named results from `print_bool`/`print_bool_arr` (e.g., "synx", "final") pub named_results: Mutex>>, + /// Runtime provenance for each `result(...)` output call. + pub named_result_traces: Mutex>, + /// Result IDs read since the last named output consumed them. + pub pending_result_reads: Mutex>, } impl ExecutionContext { @@ -80,6 +84,8 @@ impl ExecutionContext { pending_ops: Mutex::new(Vec::new()), measurement_results: Mutex::new(Vec::new()), named_results: Mutex::new(BTreeMap::new()), + named_result_traces: Mutex::new(Vec::new()), + pending_result_reads: Mutex::new(Vec::new()), } } @@ -101,6 +107,53 @@ impl ExecutionContext { if let Ok(mut named) = self.named_results.lock() { named.clear(); } + if let Ok(mut traces) = self.named_result_traces.lock() { + traces.clear(); + } + if let Ok(mut reads) = self.pending_result_reads.lock() { + reads.clear(); + } + } + + /// Record that program execution read a runtime measurement result. + pub fn record_result_read(&self, result_id: usize) { + if let Ok(mut reads) = self.pending_result_reads.lock() { + reads.push(result_id); + } else { + log::error!("ExecutionContext::record_result_read failed to acquire lock"); + } + } + + fn take_result_reads(&self, count: usize) -> Vec { + if count == 0 { + return Vec::new(); + } + let Ok(mut reads) = self.pending_result_reads.lock() else { + log::error!("ExecutionContext::take_result_reads failed to acquire lock"); + return Vec::new(); + }; + if reads.len() < count { + log::warn!( + "Named result output expected {count} result read(s), but only {} were recorded", + reads.len() + ); + return Vec::new(); + } + reads.drain(..count).collect() + } + + fn store_named_result_trace(&self, name: &str, values: &[bool], result_ids: Vec) { + if let Ok(mut traces) = self.named_result_traces.lock() { + traces.push(NamedResultTrace { + name: name.to_string(), + values: values.to_vec(), + result_ids, + }); + } else { + log::error!( + "ExecutionContext::store_named_result_trace failed to acquire lock for '{name}'" + ); + } } /// Store a named result (single bool value) @@ -122,6 +175,8 @@ impl ExecutionContext { "ExecutionContext::store_named_bool: thread {thread_id:?} failed to acquire lock for '{name}'" ); } + let result_ids = self.take_result_reads(1); + self.store_named_result_trace(name, &[value], result_ids); } /// Store a named result array (multiple bool values) @@ -130,6 +185,8 @@ impl ExecutionContext { let entry = named.entry(name.to_string()).or_default(); entry.extend_from_slice(values); } + let result_ids = self.take_result_reads(values.len()); + self.store_named_result_trace(name, values, result_ids); } /// Get all named results (returns a clone) @@ -140,6 +197,15 @@ impl ExecutionContext { .map(|guard| guard.clone()) .unwrap_or_default() } + + /// Get all named result provenance records (returns a clone) + #[must_use] + pub fn get_named_result_traces(&self) -> Vec { + self.named_result_traces + .lock() + .map(|guard| guard.clone()) + .unwrap_or_default() + } } impl Default for ExecutionContext { @@ -201,7 +267,9 @@ fn get_execution_context() -> Option<*mut ExecutionContext> { } // Re-export all types from pecos-qis-ffi-types -pub use pecos_qis_ffi_types::{Operation, OperationCollector, OperationList, QuantumOp}; +pub use pecos_qis_ffi_types::{ + NamedResultTrace, Operation, OperationCollector, OperationList, QuantumOp, +}; /// Type alias for the quantum executor callback /// @@ -745,10 +813,49 @@ pub extern "C" fn pecos_get_named_results_json() -> *mut std::ffi::c_char { } } +/// Get named result runtime provenance from execution context as JSON. +/// +/// Returns a pointer to a heap-allocated null-terminated JSON string containing +/// records of `result(...)` calls with the measurement result IDs used to +/// produce each output value. +/// +/// The caller must free the returned string using `pecos_free_named_results_json`. +/// Returns null if no context is registered or traces are empty. +#[unsafe(no_mangle)] +pub extern "C" fn pecos_get_named_result_traces_json() -> *mut std::ffi::c_char { + let Some(ctx) = get_execution_context() else { + return std::ptr::null_mut(); + }; + + // SAFETY: Context is valid for duration of execution + let ctx = unsafe { &*ctx }; + let traces = ctx.get_named_result_traces(); + if traces.is_empty() { + return std::ptr::null_mut(); + } + + let json = match serde_json::to_string(&traces) { + Ok(s) => s, + Err(e) => { + log::error!("pecos_get_named_result_traces_json: serialization error: {e}"); + return std::ptr::null_mut(); + } + }; + + match std::ffi::CString::new(json) { + Ok(cstr) => cstr.into_raw(), + Err(e) => { + log::error!("pecos_get_named_result_traces_json: CString error: {e}"); + std::ptr::null_mut() + } + } +} + /// Free a JSON string allocated by `pecos_get_named_results_json` /// /// # Safety -/// The pointer must have been allocated by `pecos_get_named_results_json`. +/// The pointer must have been allocated by `pecos_get_named_results_json` or +/// `pecos_get_named_result_traces_json`. #[unsafe(no_mangle)] pub unsafe extern "C" fn pecos_free_named_results_json(ptr: *mut std::ffi::c_char) { if !ptr.is_null() { diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index f448c9080..1711e5831 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -23,7 +23,9 @@ use pecos_engines::shot_results::{Data, Shot}; use pecos_engines::{ ByteMessage, ByteMessageBuilder, ClassicalEngine, ControlEngine, Engine, EngineStage, }; -use pecos_qis_ffi_types::{Operation, OperationCollector as OperationList, QuantumOp}; +use pecos_qis_ffi_types::{ + NamedResultTrace, Operation, OperationCollector as OperationList, QuantumOp, +}; use pecos_random::PecosRng; use std::collections::{BTreeMap, BTreeSet}; use std::fs; @@ -58,6 +60,7 @@ pub struct OperationTraceChunk { pub num_operations: usize, pub operations: Vec, pub lowered_quantum_ops: Vec, + pub named_result_traces: Vec, } /// Shared in-memory store for traced QIS operation batches. @@ -892,6 +895,7 @@ impl QisEngine { num_operations: ops.len(), operations: ops.to_vec(), lowered_quantum_ops: lowered_trace, + named_result_traces: Vec::new(), }; if let Some(ref collector) = self.operation_trace_collector { @@ -931,6 +935,92 @@ impl QisEngine { } } + fn trace_named_result_traces_chunk(&mut self, named_result_traces: &[NamedResultTrace]) { + if named_result_traces.is_empty() + || (self.operation_trace_dir.is_none() && self.operation_trace_collector.is_none()) + { + return; + } + + let stage = "named_results"; + let file_name = format!( + "engine_{:04}_shot_{:06}_chunk_{:04}_{}.json", + self.trace_engine_id, self.trace_shot_index, self.trace_chunk_index, stage + ); + let chunk_index = self.trace_chunk_index; + self.trace_chunk_index = self + .trace_chunk_index + .checked_add(1) + .expect("trace_chunk_index overflow: too many chunks for a single trace shot"); + let chunk = OperationTraceChunk { + format: "pecos_qis_operation_trace_v1", + engine_trace_id: self.trace_engine_id, + shot_index: self.trace_shot_index, + chunk_index, + stage: stage.to_string(), + waiting_for_result_id: None, + current_shot_seed: self.current_shot_seed, + simulated_op_count: self.simulated_op_count, + num_operations: 0, + operations: Vec::new(), + lowered_quantum_ops: Vec::new(), + named_result_traces: named_result_traces.to_vec(), + }; + + if let Some(ref collector) = self.operation_trace_collector { + match collector.lock() { + Ok(mut guard) => guard.push(chunk.clone()), + Err(err) => warn!("Failed to store named result trace chunk in memory: {err}"), + } + } + + if let Some(ref trace_dir) = self.operation_trace_dir { + if let Err(err) = fs::create_dir_all(trace_dir) { + warn!( + "Failed to create operation trace directory {}: {err}", + trace_dir.display() + ); + return; + } + + let trace_path = trace_dir.join(file_name); + let serialized = match serde_json::to_string_pretty(&chunk) { + Ok(serialized) => serialized, + Err(err) => { + warn!( + "Failed to serialize named result trace chunk for {}: {err}", + trace_path.display() + ); + return; + } + }; + + if let Err(err) = fs::write(&trace_path, serialized) { + warn!( + "Failed to write named result trace chunk {}: {err}", + trace_path.display() + ); + } + } + } + + fn trace_named_result_traces_from_dynamic_handle(&mut self) { + let named_result_traces = if let Some(state) = &self.dynamic_state + && let Some(handle) = &state.sync_handle + { + match handle.get_named_result_traces() { + Ok(named_result_traces) => named_result_traces, + Err(e) => { + debug!("QisEngine: Failed to get named result traces: {e}"); + Vec::new() + } + } + } else { + Vec::new() + }; + self.trace_named_result_traces_chunk(&named_result_traces); + } + /// Start the LLVM program execution in a worker thread /// /// Uses a persistent worker thread to avoid TLS allocation issues from @@ -1411,6 +1501,7 @@ impl ControlEngine for QisEngine { return Ok(EngineStage::NeedsProcessing(commands)); } } + self.trace_named_result_traces_from_dynamic_handle(); let shot = self.get_results()?; return Ok(EngineStage::Complete(shot)); } @@ -1457,6 +1548,7 @@ impl ControlEngine for QisEngine { return Ok(EngineStage::NeedsProcessing(commands)); } } + self.trace_named_result_traces_from_dynamic_handle(); let shot = self.get_results()?; return Ok(EngineStage::Complete(shot)); } @@ -1521,6 +1613,7 @@ impl ControlEngine for QisEngine { return Ok(EngineStage::NeedsProcessing(commands)); } } + self.trace_named_result_traces_from_dynamic_handle(); let shot = self.get_results()?; return Ok(EngineStage::Complete(shot)); } diff --git a/crates/pecos-qis/src/executor.rs b/crates/pecos-qis/src/executor.rs index d1ba56774..2b1a76990 100644 --- a/crates/pecos-qis/src/executor.rs +++ b/crates/pecos-qis/src/executor.rs @@ -303,6 +303,7 @@ type SetMeasurementResultFn = unsafe extern "C" fn(u64, bool); type SignalResultReadyFn = unsafe extern "C" fn(); type AbortExecutionFn = unsafe extern "C" fn(); type GetNamedResultsJsonFn = unsafe extern "C" fn() -> *mut std::ffi::c_char; +type GetNamedResultTracesJsonFn = unsafe extern "C" fn() -> *mut std::ffi::c_char; type FreeNamedResultsJsonFn = unsafe extern "C" fn(*mut std::ffi::c_char); /// Synchronization handle for main thread communication with worker thread @@ -454,6 +455,55 @@ impl DynamicSyncHandle for HeliosSyncHandle { debug!("HeliosSyncHandle: Got {} named results", result.len()); Ok(result) } + + fn get_named_result_traces( + &self, + ) -> Result, InterfaceError> { + let lib = Self::get_lib()?; + + let get_fn: Symbol = unsafe { + lib.get(b"pecos_get_named_result_traces_json\0") + .map_err(|e| { + InterfaceError::ExecutionError(format!( + "Failed to find pecos_get_named_result_traces_json: {e}" + )) + })? + }; + + let ptr = unsafe { get_fn() }; + if ptr.is_null() { + return Ok(Vec::new()); + } + + let c_str = unsafe { std::ffi::CStr::from_ptr(ptr) }; + let json_str = c_str.to_str().map_err(|e| { + InterfaceError::ExecutionError(format!( + "Invalid UTF-8 in named result traces JSON: {e}" + )) + })?; + + let result: Vec = serde_json::from_str(json_str) + .map_err(|e| { + InterfaceError::ExecutionError(format!( + "Failed to parse named result traces JSON: {e}" + )) + })?; + + let free_fn: Symbol = unsafe { + lib.get(b"pecos_free_named_results_json\0").map_err(|e| { + InterfaceError::ExecutionError(format!( + "Failed to find pecos_free_named_results_json: {e}" + )) + })? + }; + unsafe { free_fn(ptr) }; + + debug!( + "HeliosSyncHandle: Got {} named result trace records", + result.len() + ); + Ok(result) + } } /// Derive the project target directory from the compile-time embedded Helios path. diff --git a/crates/pecos-qis/src/qis_interface.rs b/crates/pecos-qis/src/qis_interface.rs index 305add2e1..12c040a42 100644 --- a/crates/pecos-qis/src/qis_interface.rs +++ b/crates/pecos-qis/src/qis_interface.rs @@ -273,6 +273,17 @@ pub trait DynamicSyncHandle: Send + Sync { fn get_named_results( &self, ) -> Result>, InterfaceError>; + + /// Get named result provenance from the execution context. + /// + /// Returns one record per `result(...)` output call, including the runtime + /// measurement result IDs read to produce that output. + /// + /// # Errors + /// Returns an error if the FFI call fails or JSON parsing fails. + fn get_named_result_traces( + &self, + ) -> Result, InterfaceError>; } /// Box type for interface implementations diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 0754a16f7..8f474c42d 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -572,6 +572,93 @@ def _remap_surface_record_metadata_json( return json.dumps(entries) +def _surface_runtime_measurement_remap_from_result_traces( + patch: SurfacePatch, + num_rounds: int, + result_traces: list[dict[str, Any]], +) -> dict[int, int]: + """Map abstract surface measurement indices to runtime ``result_id``s. + + The generated surface Guppy emits scalar ``result("sx*/sz*:meas:N", bit)`` + calls for stabilizer measurements and one ``result("final", array(...))`` + call for data readout. Those tags survive runtime scheduling changes and + are the stable detector/observable anchor. Aggregate ``synx``/``synz`` tags + are deliberately ignored because they reread existing futures. + """ + import re + from collections import defaultdict + + syndrome_per_round = len(patch.geometry.x_stabilizers) + len(patch.geometry.z_stabilizers) + expected_syndrome_measurements = syndrome_per_round * num_rounds + expected_measurements = expected_syndrome_measurements + patch.geometry.num_data + + scalar_tag_re = re.compile(r"^s[xz]\d+:meas:(\d+)$") + occurrence_by_tag: defaultdict[str, int] = defaultdict(int) + remap: dict[int, int] = {} + final_seen = False + + for trace in result_traces: + name = trace.get("name") + result_ids = trace.get("result_ids") or [] + values = trace.get("values") or [] + if not isinstance(name, str): + continue + + match = scalar_tag_re.match(name) + if match is not None: + if len(result_ids) != 1 or len(values) != 1: + msg = f"Surface scalar result tag {name!r} must map to exactly one measurement result" + raise ValueError(msg) + meas_in_round = int(match.group(1)) + if not 0 <= meas_in_round < syndrome_per_round: + msg = ( + f"Surface scalar result tag {name!r} has per-round measurement index " + f"{meas_in_round}, outside [0, {syndrome_per_round})" + ) + raise ValueError(msg) + round_index = occurrence_by_tag[name] + occurrence_by_tag[name] += 1 + if round_index >= num_rounds: + msg = f"Surface scalar result tag {name!r} appears more than {num_rounds} round(s)" + raise ValueError(msg) + abstract_index = round_index * syndrome_per_round + meas_in_round + remap[abstract_index] = int(result_ids[0]) + elif name == "final": + if final_seen: + msg = "Surface traced result provenance has more than one final data result" + raise ValueError(msg) + final_seen = True + if len(result_ids) != patch.geometry.num_data or len(values) != patch.geometry.num_data: + msg = ( + "Surface final result tag must map to exactly " + f"{patch.geometry.num_data} data measurements" + ) + raise ValueError(msg) + for offset, result_id in enumerate(result_ids): + remap[expected_syndrome_measurements + offset] = int(result_id) + + if len(remap) != expected_measurements: + missing = sorted(set(range(expected_measurements)) - set(remap)) + msg = ( + "Runtime trace did not provide complete surface result-tag provenance: " + f"mapped {len(remap)}/{expected_measurements} measurements" + ) + if missing: + msg += f"; first missing abstract measurement index {missing[0]}" + raise ValueError(msg) + + runtime_ids = sorted(remap.values()) + if runtime_ids != list(range(expected_measurements)): + msg = ( + "Runtime result-tag provenance is not a dense measurement-id range " + f"0..{expected_measurements - 1}; got first/last " + f"{runtime_ids[:3]}...{runtime_ids[-3:]}" + ) + raise ValueError(msg) + + return remap + + def _runtime_idle_seconds_to_time_units(duration_seconds: float) -> Any: """Convert runtime idle seconds into PECOS nanosecond time units.""" import math @@ -931,6 +1018,58 @@ def _reject_partially_lowered_trace(chunks: list[dict[str, Any]]) -> None: raise ValueError(msg) +def _replay_qis_trace_chunks_into_tick_circuit(chunks: list[dict[str, Any]]) -> Any: + """Replay captured QIS operation trace chunks into a ``TickCircuit``.""" + if any(chunk.get("lowered_quantum_ops") for chunk in chunks): + _reject_partially_lowered_trace(chunks) + return _replay_lowered_qis_trace_into_tick_circuit(chunks) + + operations: list[dict[str, Any]] = [] + for chunk in chunks: + operations.extend(list(chunk.get("operations", []))) + return _replay_qis_trace_into_tick_circuit(operations) + + +def named_result_traces_from_operation_trace(chunks: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return runtime `result(...)` provenance records from operation trace chunks.""" + traces: list[dict[str, Any]] = [] + for chunk in chunks: + traces.extend(trace for trace in (chunk.get("named_result_traces") or []) if isinstance(trace, dict)) + return traces + + +def capture_guppy_operation_trace( + program: Any, + num_qubits: int, + *, + seed: int = 0, + runtime: object | None = None, +) -> list[dict[str, Any]]: + """Capture a Guppy/QIS program's Selene operation trace chunks.""" + import pecos + + sim_builder = ( + pecos.sim(program) + .classical(pecos.selene_engine(runtime)) + .quantum(pecos.stabilizer()) + .qubits(num_qubits) + .seed(seed) + ) + return list(sim_builder.capture_operation_trace()) + + +def trace_guppy_into_tick_circuit_with_result_traces( + program: Any, + num_qubits: int, + *, + seed: int = 0, + runtime: object | None = None, +) -> tuple[Any, list[dict[str, Any]]]: + """Trace a Guppy/QIS program into a ``TickCircuit`` plus result-tag provenance.""" + chunks = capture_guppy_operation_trace(program, num_qubits, seed=seed, runtime=runtime) + return _replay_qis_trace_chunks_into_tick_circuit(chunks), named_result_traces_from_operation_trace(chunks) + + def trace_guppy_into_tick_circuit( program: Any, num_qubits: int, @@ -966,31 +1105,8 @@ def trace_guppy_into_tick_circuit( A ``TickCircuit`` with no detector/observable metadata attached; the caller supplies that. """ - import pecos - - sim_builder = ( - pecos.sim(program) - .classical(pecos.selene_engine(runtime)) - .quantum(pecos.stabilizer()) - .qubits(num_qubits) - .seed(seed) - ) - chunks = list(sim_builder.capture_operation_trace()) - - # Selene lowers QIS gates into per-chunk `lowered_quantum_ops` (the gate - # shape actually executed; e.g. cx -> RZZ + rotations). When any chunk is - # lowered we replay from those, but first reject a mixed/partially-lowered - # trace that would silently drop a chunk's raw gates (see - # `_reject_partially_lowered_trace`). - if any(chunk.get("lowered_quantum_ops") for chunk in chunks): - _reject_partially_lowered_trace(chunks) - return _replay_lowered_qis_trace_into_tick_circuit(chunks) - - # No chunk was lowered: replay the uniformly-raw QIS operation stream. - operations: list[dict[str, Any]] = [] - for chunk in chunks: - operations.extend(list(chunk.get("operations", []))) - return _replay_qis_trace_into_tick_circuit(operations) + chunks = capture_guppy_operation_trace(program, num_qubits, seed=seed, runtime=runtime) + return _replay_qis_trace_chunks_into_tick_circuit(chunks) def _generate_traced_surface_tick_circuit( @@ -1015,6 +1131,25 @@ def _generate_traced_surface_tick_circuit( traced faithfully rather than silently substituting the default rotated patch of the same distance. """ + tc, _ = _generate_traced_surface_tick_circuit_with_result_traces( + patch, + num_rounds, + basis, + ancilla_budget=ancilla_budget, + runtime=runtime, + ) + return tc + + +def _generate_traced_surface_tick_circuit_with_result_traces( + patch: SurfacePatch, + num_rounds: int, + basis: str, + *, + ancilla_budget: int | None = None, + runtime: object | None = None, +) -> tuple[Any, list[dict[str, Any]]]: + """Trace a surface Guppy program into a ``TickCircuit`` plus result provenance.""" from pecos.guppy import get_num_qubits from pecos.guppy.surface import generate_memory_experiment @@ -1024,7 +1159,7 @@ def _generate_traced_surface_tick_circuit( basis, ancilla_budget=ancilla_budget, ) - return trace_guppy_into_tick_circuit( + return trace_guppy_into_tick_circuit_with_result_traces( program, get_num_qubits(patch=patch, ancilla_budget=ancilla_budget), seed=0, @@ -1061,57 +1196,43 @@ def _build_surface_tick_circuit_for_native_model( msg = f"Unknown circuit_source {circuit_source!r}" raise ValueError(msg) - traced_tc = _generate_traced_surface_tick_circuit( + traced_tc, result_traces = _generate_traced_surface_tick_circuit_with_result_traces( patch, num_rounds, basis, ancilla_budget=ancilla_budget, runtime=runtime, ) - # Coarse sanity check: the traced and abstract circuits must either agree - # on measured-qubit order or be remappable by measured-qubit occurrence. - # This catches gross drift (a dropped/added/wrong-qubit measurement) while - # allowing runtimes that preserve stabilizer identity but schedule - # measurements in a different order. It is NOT an identity-level check: - # `_extract_measurement_order` returns physical qubit indices, and under - # ancilla reuse the same physical qubit appears in many measurements -- so - # two different stabilizer orderings can produce an identical qubit-index - # sequence and pass here. - # There is no independent stabilizer-identity oracle in the stack today: - # the detector/observable record offsets are the production binding (not a - # validator), and the byte-identical traced-vs-traced DEM regression shares - # the same shared batching policy on both sides (so it cannot catch a - # policy bug). The current safeguards against identity drift are the shared - # `batched_stabilizers` source-of-truth and the source-level CX-emission - # pins; a true identity check here would need stabilizer provenance the - # replayed TickCircuit does not currently carry (future work). + + measurement_index_remap = _surface_runtime_measurement_remap_from_result_traces( + patch, + num_rounds, + result_traces, + ) + _copy_surface_tick_circuit_metadata( + abstract_tc, + traced_tc, + measurement_index_remap=measurement_index_remap, + ) + traced_tc.set_meta("surface_metadata_record_binding", "runtime_result_tags") + + # Coarse sanity check: detector metadata is bound by runtime result tags, + # but still reject traces with dropped/extra/wrong measured physical qubits. traced_measurement_order = _extract_measurement_order(traced_tc) abstract_measurement_order = _extract_measurement_order(abstract_tc) - measurement_index_remap = None - if traced_measurement_order != abstract_measurement_order: - try: - measurement_index_remap = _measurement_index_remap_for_orders( - abstract_measurement_order, - traced_measurement_order, - ) - except ValueError as exc: - msg = ( - "Traced and abstract surface circuits disagree on the measured-qubit " - "sequence (a dropped/added/wrong-qubit measurement or a different " - "schedule shape); refusing to build a native DEM/sampler from a " - "circuit that does not match the abstract detector/observable metadata" - ) - raise ValueError(msg) from exc - - if measurement_index_remap is None: - _copy_surface_tick_circuit_metadata(abstract_tc, traced_tc) - else: - _copy_surface_tick_circuit_metadata( - abstract_tc, - traced_tc, - measurement_index_remap=measurement_index_remap, + try: + _measurement_index_remap_for_orders( + abstract_measurement_order, + traced_measurement_order, ) - traced_tc.set_meta("surface_metadata_record_binding", "runtime_measurement_order") + except ValueError as exc: + msg = ( + "Traced and abstract surface circuits disagree on the measured-qubit " + "multiset (a dropped/added/wrong-qubit measurement); refusing to " + "build a native DEM/sampler from a circuit that does not match the " + "surface memory experiment" + ) + raise ValueError(msg) from exc traced_tc.set_meta("circuit_source", circuit_source) return traced_tc @@ -1337,28 +1458,35 @@ def _dem_string_from_cached_surface_topology( """Build a DEM string from cached topology and fresh noise parameters.""" from pecos.qec import DemBuilder + noise_kwargs = { + "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, + "p2_weights": _p2_weights_dict(noise.p2_weights), + } + if noise.p2_replacement_approximation is not None: + noise_kwargs["p2_replacement_approximation"] = noise.p2_replacement_approximation + + builder = DemBuilder(topology.influence_map).with_noise( + noise.p1, + noise.p2, + noise.p_meas, + noise.p_prep, + **noise_kwargs, + ) + if hasattr(builder, "with_exact_branch_replay_circuit"): + builder = builder.with_exact_branch_replay_circuit(topology.dag_circuit) + dem = ( - DemBuilder(topology.influence_map) - .with_noise( - noise.p1, - noise.p2, - noise.p_meas, - noise.p_prep, - 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, - p2_weights=_p2_weights_dict(noise.p2_weights), - p2_replacement_approximation=noise.p2_replacement_approximation, - ) - .with_exact_branch_replay_circuit(topology.dag_circuit) + builder .with_num_measurements(topology.num_measurements) .with_measurement_order(list(topology.measurement_order)) .with_detectors_json(topology.detectors_json) 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 1e7ec9cb4..3ec397f6d 100644 --- a/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py +++ b/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py @@ -178,6 +178,30 @@ def test_capture_operation_trace_returns_in_memory_batches() -> None: assert trace[0]["lowered_quantum_ops"] +def test_capture_operation_trace_includes_named_result_provenance() -> None: + """Trace capture must preserve result(...) -> measurement-id provenance.""" + import pecos + from pecos.qec.surface.decode import named_result_traces_from_operation_trace + + _require_selene_runtime() + + trace = ( + pecos.sim(make_tiny_x_syndrome_memory(1)) + .classical(pecos.selene_engine()) + .quantum(pecos.stabilizer()) + .qubits(2) + .seed(123) + .capture_operation_trace() + ) + + named_traces = named_result_traces_from_operation_trace(trace) + names = {trace["name"] for trace in named_traces} + assert {"synx", "final"} <= names + assert any(chunk.get("stage") == "named_results" for chunk in trace) + for named_trace in named_traces: + assert len(named_trace["result_ids"]) == len(named_trace["values"]) + + def _collect_selene_named_results( instance: object, *, 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 8dc266600..9f6a3b13a 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -19,6 +19,8 @@ _remap_surface_record_metadata_json, _replay_lowered_qis_trace_into_tick_circuit, _replay_qis_trace_into_tick_circuit, + _surface_runtime_measurement_remap_from_result_traces, + trace_guppy_into_tick_circuit_with_result_traces, ) @@ -763,6 +765,43 @@ def test_surface_metadata_records_remap_to_runtime_measurement_order() -> None: ] +def test_surface_metadata_records_remap_to_runtime_result_tags() -> None: + patch = SurfacePatch.create(distance=3) + program = make_surface_code(distance=3, num_rounds=2, basis="Z", ancilla_budget=2) + _, result_traces = trace_guppy_into_tick_circuit_with_result_traces( + program, + get_num_qubits(3, ancilla_budget=2), + seed=0, + ) + + remap = _surface_runtime_measurement_remap_from_result_traces( + patch, + 2, + result_traces, + ) + + assert len(remap) == 25 # 2 rounds * 8 stabilizers + 9 final data measurements + assert sorted(remap) == list(range(25)) + assert sorted(remap.values()) == list(range(25)) + + +def test_traced_surface_metadata_uses_runtime_result_tags() -> None: + patch = SurfacePatch.create(distance=3) + traced_tc = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=2, + basis="Z", + ancilla_budget=2, + circuit_source="traced_qis", + ) + + assert traced_tc.get_meta("surface_metadata_record_binding") == "runtime_result_tags" + assert traced_tc.get_meta("circuit_source") == "traced_qis" + assert int(traced_tc.get_meta("num_measurements")) == 25 + assert len(json.loads(traced_tc.get_meta("detectors"))) > 0 + assert len(json.loads(traced_tc.get_meta("observables"))) == 1 + + def test_surface_metadata_record_remap_rejects_measurement_drift() -> None: with pytest.raises(ValueError, match="measured-qubit multiset"): _measurement_index_remap_for_orders([0, 1, 0], [0, 1, 2]) From 3fd994268f6c909817cc26b14be0fd07240686b0 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 7 Jun 2026 12:12:14 -0600 Subject: [PATCH 039/150] Default LogicalSubgraphDecoder inner to fusion_blossom_serial (exact MWPM): measured more accurate AND faster than pecos_uf:bp at depth/multi-observable (d=7 transversal-CX 1.9s vs 12.5s, 2-5x lower LER), bundled; pecos_uf:bp kept as the native dependency-free option; rename test_default_inner_bp_uf_suppresses -> test_native_bp_uf_inner_suppresses --- .../src/fault_tolerance_bindings.rs | 38 ++++++++++++------- ...test_logical_subgraph_region_comparison.py | 24 ++++++------ 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 8b5261477..c3f5b5ea3 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4448,14 +4448,21 @@ impl PyCssUfDecoder { /// dem: DEM string with detector coordinate declarations. /// `stab_coords`: List of dicts, one per logical qubit. Each dict has /// keys "X" and "Z" mapping to lists of (x, y) ancilla coordinates. -/// `inner_decoder`: Inner decoder type string (default "`pecos_uf:bp`", -/// native belief-propagation + union-find). +/// `inner_decoder`: Inner decoder type string (default +/// "`fusion_blossom_serial`", exact MWPM -- accurate and fast across +/// distances, bundled). The best choice is circuit-dependent: +/// `pecos_uf:bp` (PECOS-native belief-propagation + union-find, +/// dependency-free) is competitive on memory and at small distance and is +/// the right pick when you want the pure-native path, but its grow+peel +/// matching is both LESS accurate and SLOWER at higher distance / +/// multi-observable circuits. `belief_matching` matches fusion's accuracy +/// but is slower. /// /// Example: /// >>> decoder = `LogicalSubgraphDecoder`( /// ... `dem_str`, /// ... [{"X": [(1,0), (3,1)], "Z": [(0,3), (1,1)]}], -/// ... "`pecos_uf:bp`", +/// ... "`fusion_blossom_serial`", /// ... ) /// >>> obs = decoder.decode(syndrome) #[pyclass(name = "LogicalSubgraphDecoder", module = "pecos_rslib.qec")] @@ -4465,17 +4472,20 @@ pub struct PyLogicalSubgraphDecoder { #[pymethods] impl PyLogicalSubgraphDecoder { - // Default inner is `pecos_uf:bp` (native belief-propagation + union-find): - // dependency-free, fast, and it achieves distance suppression (LER drops with - // code distance), tracking exact MWPM closely. The native UF previously did - // NOT suppress at d>=5, so the default was temporarily exact MWPM - // (`fusion_blossom_serial`); that UF bug -- a predecoder that mis-decoded - // isolated defects whose min-weight correction is a bulk path to the boundary - // -- has been fixed (predecoder now falls through to the full grow+peel - // decoder unless provably optimal). See - // pecos-docs/design/logical-subgraph-backprop-region-builder.md. + // Default inner is `fusion_blossom_serial` (exact MWPM, bundled). MEASURED + // accuracy/speed tradeoff (memory vs transversal algorithms, d=3..7, p=0.001) + // picks it: it is accurate AND fast across distances, whereas the native + // `pecos_uf:bp` -- competitive on memory and at d=3 -- is BOTH less accurate + // and slower at higher distance / multi-observable circuits (its grow+peel + // matching blows up at high d, e.g. d=7 transversal-CX: ~12.5s vs fusion + // ~1.9s, and ~2-5x higher LER at d=5/7). `pecos_uf:bp` remains the right pick + // for the pure-native, dependency-free path (and it does achieve distance + // suppression -- the predecoder bug that broke it at d>=5 is fixed). + // `belief_matching` matches fusion's accuracy but is slower. See + // pecos-docs/design/lomatching-paper-additional-learnings.md and + // logical-subgraph-backprop-region-builder.md. #[new] - #[pyo3(signature = (dem, stab_coords, inner_decoder="pecos_uf:bp", max_time_radius=None))] + #[pyo3(signature = (dem, stab_coords, inner_decoder="fusion_blossom_serial", max_time_radius=None))] fn new( dem: &str, stab_coords: Vec>, @@ -4526,7 +4536,7 @@ impl PyLogicalSubgraphDecoder { /// construction (e.g. the paper's back-propagation / detecting-region set) /// and decode with the same machinery for direct comparison. #[staticmethod] - #[pyo3(signature = (dem, membership, inner_decoder="pecos_uf:bp"))] + #[pyo3(signature = (dem, membership, inner_decoder="fusion_blossom_serial"))] fn from_membership( dem: &str, membership: Vec>, 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 f05da9998..980f75e05 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 @@ -230,9 +230,9 @@ def _mem_ler(d, p, n, seed, inner=None): def test_distance_suppression_memory(): """A fault-tolerant decoder must drive LER DOWN as code distance grows below - threshold. The default inner (`pecos_uf:bp`, native BP + union-find) - suppresses, tracking exact MWPM / lomatching (d=7 -> 0). Guards against the - default reverting to a non-suppressing inner.""" + threshold. The default inner (`fusion_blossom_serial`, exact MWPM) suppresses, + matching lomatching (d=7 -> 0). Guards against the default reverting to a + non-suppressing inner.""" p, n = 0.001, 60000 ler_d3 = _mem_ler(3, p, n, seed=1) ler_d5 = _mem_ler(5, p, n, seed=1) @@ -242,9 +242,12 @@ def test_distance_suppression_memory(): ) -def test_default_inner_bp_uf_suppresses(): - """The default native inner decoder (`pecos_uf:bp`, belief-propagation + - union-find) achieves distance suppression, as a fault-tolerant decoder must. +def test_native_bp_uf_inner_suppresses(): + """The native `pecos_uf:bp` inner (belief-propagation + union-find) achieves + distance suppression, as a fault-tolerant decoder must. (It is the + dependency-free native option; the decoder *default* is exact MWPM + `fusion_blossom_serial`, which is more accurate and faster at depth -- see + pecos-docs/design/lomatching-paper-additional-learnings.md.) Context: the UF predecoder used to mis-decode isolated defects whose minimum-weight correction is a bulk *path* to the boundary (it only looked at @@ -253,11 +256,10 @@ def test_default_inner_bp_uf_suppresses(): the full grow+peel decoder unless its shortcut is provably optimal (see `predecode_single` / size-2 handling in pecos-uf-decoder). - NOTE: this validates the *default* inner `pecos_uf:bp`, which suppresses - robustly (tracks exact MWPM). Pure `pecos_uf:fast` (no belief propagation) was - also improved by the predecoder fix but its full grow+peel heuristic does NOT - robustly suppress at depth -- a separate, lesser weakness, which is why the - default is `pecos_uf:bp`, not `pecos_uf:fast`. + NOTE: pure `pecos_uf:fast` (no belief propagation) was also improved by the + predecoder fix but its full grow+peel heuristic does NOT robustly suppress at + depth -- a separate, lesser weakness, which is why the native option is + `pecos_uf:bp`, not `pecos_uf:fast`. See pecos-docs/design/logical-subgraph-backprop-region-builder.md.""" p, n = 0.001, 60000 uf_d3 = _mem_ler(3, p, n, seed=1, inner="pecos_uf:bp") From 7d1244a996903b6943ee6a62ec8d33c20b57dafc Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 7 Jun 2026 15:01:39 -0600 Subject: [PATCH 040/150] Reuse constructor inner decoder in LogicalSubgraphDecoder.decode_count_parallel, add default-backend regressions, quarantine reliable_observables prototype --- .../src/fault_tolerance_bindings.rs | 62 ++++++++++++++----- .../src/pecos/qec/reliable_observables.py | 14 +++-- ...test_logical_subgraph_region_comparison.py | 39 ++++++++++++ 3 files changed, 95 insertions(+), 20 deletions(-) diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index c3f5b5ea3..e4ef8ef52 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4468,20 +4468,31 @@ impl PyCssUfDecoder { #[pyclass(name = "LogicalSubgraphDecoder", module = "pecos_rslib.qec")] pub struct PyLogicalSubgraphDecoder { inner: pecos_decoder_core::logical_subgraph::LogicalSubgraphDecoder, + /// The inner per-observable decoder backend selected at construction + /// (e.g. `"fusion_blossom_serial"`). `decode_count_parallel` reuses this so + /// the parallel workers match the serial path unless the caller overrides. + inner_decoder: String, } #[pymethods] impl PyLogicalSubgraphDecoder { - // Default inner is `fusion_blossom_serial` (exact MWPM, bundled). MEASURED - // accuracy/speed tradeoff (memory vs transversal algorithms, d=3..7, p=0.001) - // picks it: it is accurate AND fast across distances, whereas the native - // `pecos_uf:bp` -- competitive on memory and at d=3 -- is BOTH less accurate - // and slower at higher distance / multi-observable circuits (its grow+peel - // matching blows up at high d, e.g. d=7 transversal-CX: ~12.5s vs fusion - // ~1.9s, and ~2-5x higher LER at d=5/7). `pecos_uf:bp` remains the right pick - // for the pure-native, dependency-free path (and it does achieve distance - // suppression -- the predecoder bug that broke it at d>=5 is fixed). - // `belief_matching` matches fusion's accuracy but is slower. See + // Default inner is `fusion_blossom_serial`: a POLICY choice of accuracy-first + // exact MWPM, bundled by default. It is exact minimum-weight matching on each + // per-observable subgraph, so it cannot be beaten on that projected graph, and + // it ships with PECOS (no optional dependency to install). + // + // A spot benchmark (memory + transversal-CX, d=3..7, p=0.001, n=30k, SINGLE + // SEED) is consistent with this: fusion is at least as accurate as the native + // `pecos_uf:bp` everywhere and markedly faster at depth (e.g. d=7 transversal-CX + // ~1.9s vs ~12.5s). But that table is UNDER-POWERED to prove a long-term + // default -- single seed, single p, point LERs with overlapping confidence + // intervals. Treat it as supporting evidence, not proof; a proper threshold/CI + // sweep is a tracked benchmark TODO. The default rests on the policy above. + // + // `pecos_uf:bp` remains the right pick for the pure-native, dependency-free + // path (it does achieve distance suppression -- the predecoder bug that broke + // it at d>=5 is fixed). `belief_matching` matched fusion's accuracy in the spot + // benchmark but was slower. See // pecos-docs/design/lomatching-paper-additional-learnings.md and // logical-subgraph-backprop-region-builder.md. #[new] @@ -4525,7 +4536,10 @@ impl PyLogicalSubgraphDecoder { ) .map_err(|e| PyErr::new::(e.to_string()))?; - Ok(Self { inner }) + Ok(Self { + inner, + inner_decoder: inner_decoder.to_string(), + }) } /// Build from a precomputed per-observable detector membership instead of @@ -4553,7 +4567,10 @@ impl PyLogicalSubgraphDecoder { }) .map_err(|e| PyErr::new::(e.to_string()))?; - Ok(Self { inner }) + Ok(Self { + inner, + inner_decoder: inner_decoder.to_string(), + }) } /// Decode a syndrome and return observable flip predictions. @@ -4569,6 +4586,15 @@ impl PyLogicalSubgraphDecoder { self.inner.num_observables() } + /// The inner per-observable decoder backend selected at construction. + /// + /// `decode_count_parallel` reuses this unless the caller overrides it, so + /// the serial and parallel paths agree by default. + #[getter] + fn inner_decoder(&self) -> &str { + &self.inner_decoder + } + /// Decode a batch of syndromes and return observable predictions. /// /// Args: @@ -4617,14 +4643,16 @@ impl PyLogicalSubgraphDecoder { /// Decode a `SampleBatch` in parallel using rayon. /// /// Creates per-worker decoder instances to avoid lock contention. - /// Requires the DEM string and inner decoder type for reconstruction. - #[pyo3(signature = (batch, dem, stab_coords, inner_decoder="pymatching", num_workers=None, max_time_radius=None))] + /// Requires the DEM string for reconstruction. `inner_decoder` defaults to + /// the backend selected at construction (so the parallel path matches the + /// serial `decode_count` path); pass an explicit value only to override it. + #[pyo3(signature = (batch, dem, stab_coords, inner_decoder=None, num_workers=None, max_time_radius=None))] fn decode_count_parallel( &self, batch: &PySampleBatch, dem: &str, stab_coords: Vec>, - inner_decoder: &str, + inner_decoder: Option<&str>, num_workers: Option, max_time_radius: Option, ) -> PyResult { @@ -4649,7 +4677,9 @@ impl PyLogicalSubgraphDecoder { } let dem_str = dem.to_string(); - let inner_str = inner_decoder.to_string(); + // Reuse the backend chosen at construction unless the caller overrides, + // so parallel workers decode identically to the serial path. + let inner_str = inner_decoder.unwrap_or(self.inner_decoder.as_str()).to_string(); let n = batch.num_shots; // Materialize row-major data for parallel decode. diff --git a/python/quantum-pecos/src/pecos/qec/reliable_observables.py b/python/quantum-pecos/src/pecos/qec/reliable_observables.py index 86d6fb451..f8d9433b4 100644 --- a/python/quantum-pecos/src/pecos/qec/reliable_observables.py +++ b/python/quantum-pecos/src/pecos/qec/reliable_observables.py @@ -25,10 +25,16 @@ take its right null space over GF(2). Each null vector is a combination of raw observables that commutes with *every* reset -- i.e. a reliable observable. -Status: prototype / proof-of-concept. Dependency-free (own GF(2) null space, no -``galois``). The eventual production path would compute the null space via -pecos-num's GF(2) and feed the reliable set into the logical-subgraph decoder -front-end. See ``pecos-docs/design/lomatching-paper-additional-learnings.md``. +Status: prototype / proof-of-concept -- NOT part of the supported ``pecos.qec`` +API. It is deliberately *not* re-exported by ``pecos.qec.__init__`` (importing +``pecos.qec`` does not pull it in). QUARANTINE: this module imports ``stim`` at +module load and uses ``stim.Circuit.detecting_regions`` at runtime, which +violates the project rule that externals (stim, numpy, ...) are dev/test oracles, +never on a runtime path. Do NOT import this on any decode path. A production +version must compute observing regions natively (pecos-eeg / EEG-Heisenberg) and +the GF(2) null space via pecos-num. It is also currently a no-op on the circuits +PECOS emits today (every observable is already reliable). See +``pecos-docs/design/lomatching-paper-additional-learnings.md``. """ from __future__ import annotations 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 980f75e05..80f97001e 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 @@ -270,6 +270,45 @@ def test_native_bp_uf_inner_suppresses(): ) +def _mem_dem_batch(d, p, n, seed): + """Build a memory DEM + stab_coords + a sample batch (shared fixture for the + non-stochastic default/parallel regressions below).""" + patch = SurfacePatch.create(d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", d, "Z") + dem = b.build_dem(p1=p, p2=p, p_meas=p) + sc = b.stab_coords() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) + return dem, sc, batch + + +def test_default_inner_is_fusion_blossom_serial(): + """Pin the constructor default backend deterministically (no stochastic LER + margin): the default-built decoder reports ``fusion_blossom_serial`` as its + inner, and decodes identically to one built with that inner explicitly. + Guards the default-flip decision recorded in + pecos-docs/design/lomatching-paper-additional-learnings.md.""" + dem, sc, batch = _mem_dem_batch(5, p=0.003, n=4000, seed=7) + default = LogicalSubgraphDecoder(dem, sc) + explicit = LogicalSubgraphDecoder(dem, sc, "fusion_blossom_serial") + assert default.inner_decoder == "fusion_blossom_serial" + assert default.decode_count(batch) == explicit.decode_count(batch) + + +def test_decode_count_parallel_matches_serial_default(): + """Finding #1 regression: `decode_count_parallel` must reuse the inner + backend chosen at construction (not silently fall back to a different + default), so the parallel path agrees with the serial `decode_count` on the + same shots. Previously the parallel helper defaulted to `pymatching` + regardless of the constructor's inner.""" + dem, sc, batch = _mem_dem_batch(5, p=0.003, n=4000, seed=11) + dec = LogicalSubgraphDecoder(dem, sc) + serial = dec.decode_count(batch) + parallel = dec.decode_count_parallel(batch, dem, sc) + assert serial == parallel + + def _windowed_mem_ler(d, rounds, p, n, seed, step, buffer): patch = SurfacePatch.create(d) b = LogicalCircuitBuilder() From c90c883aa33259afc8005c95fc1a4deefd96608a Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 7 Jun 2026 18:44:51 -0600 Subject: [PATCH 041/150] Fix constrained ancilla DAG propagation --- .../src/fault_tolerance/propagator/dag.rs | 103 +++++++++++++++++- .../src/pecos/qec/surface/circuit_builder.py | 15 ++- .../tests/qec/surface/test_surface_decoder.py | 36 ++++++ .../qec/surface/test_surface_metadata.py | 9 +- 4 files changed, 153 insertions(+), 10 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs b/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs index 74ab1c3d9..8e11576d7 100644 --- a/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs +++ b/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs @@ -1891,8 +1891,11 @@ impl<'a> DagFaultAnalyzer<'a> { map.detectors.push(DetectorId::single(measurement_id)); } - // Use forest propagation: per-ancilla Phase 1/Phase 2 split. - let recorder = self.propagate_all_forest(); + // Use the generic per-measurement path for correctness with physical + // qubit reuse. The forest shortcut groups by measured qubit ID and is + // only valid when that physical qubit represents one fixed measurement + // stream, not a reusable ancilla slot. + let recorder = self.propagate_all_parallel(); // Convert buckets to SoA format (O(n) flattening) map.influences = recorder.into_soa(); @@ -2336,9 +2339,14 @@ impl<'a> DagFaultAnalyzer<'a> { } } - /// Parallel forest propagation: groups measurements by ancilla qubit, - /// propagates the latest measurement fully with capture, replays the - /// shared tail prefix for earlier measurements. + /// Parallel forest propagation for fixed ancilla streams. + /// + /// This groups measurements by physical ancilla qubit, propagates the latest + /// measurement fully with capture, and replays the shared tail prefix for + /// earlier measurements. It is an optimization for circuits where each + /// measured physical qubit represents one fixed logical measurement stream. + /// It must not be used for circuits that reuse one physical measurement + /// qubit for multiple logical checks. #[must_use] pub fn propagate_all_forest(&self) -> BucketRecorder { use rayon::prelude::*; @@ -2542,6 +2550,71 @@ mod tests { dag } + /// Reuses one physical ancilla slot for two different checks that share a data qubit. + fn reused_physical_ancilla_circuit() -> DagCircuit { + let mut dag = DagCircuit::new(); + for _ in 0..2 { + dag.qalloc(&[10]); + dag.cx(&[(0, 10)]); + dag.cx(&[(1, 10)]); + dag.mz_free(&[10]); + + dag.qalloc(&[10]); + dag.cx(&[(1, 10)]); + dag.cx(&[(2, 10)]); + dag.mz_free(&[10]); + } + dag + } + + fn build_parallel_map(analyzer: &DagFaultAnalyzer<'_>) -> DagFaultInfluenceMap { + build_map_with_influences(analyzer, analyzer.propagate_all_parallel().into_soa()) + } + + fn build_forest_map(analyzer: &DagFaultAnalyzer<'_>) -> DagFaultInfluenceMap { + build_map_with_influences(analyzer, analyzer.propagate_all_forest().into_soa()) + } + + fn build_map_with_influences( + analyzer: &DagFaultAnalyzer<'_>, + influences: InfluencesSoA, + ) -> DagFaultInfluenceMap { + let mut map = DagFaultInfluenceMap::with_capacity(analyzer.locations.len()); + map.locations = analyzer.locations.to_dag_spacetime_locations(); + + let (measurements, meas_ids) = analyzer.extract_measurements(); + map.measurements.clone_from(&measurements); + map.meas_ids = meas_ids; + + for &(node, qubit, basis) in &measurements { + map.detectors.push(DetectorId::single(MeasurementId { + tick: node, + qubit, + basis, + })); + } + + map.influences = influences; + map + } + + fn detector_fingerprint(map: &DagFaultInfluenceMap) -> Vec<(Vec, Vec)> { + vec![ + ( + map.influences.detectors_x.offsets.clone(), + map.influences.detectors_x.data.clone(), + ), + ( + map.influences.detectors_y.offsets.clone(), + map.influences.detectors_y.data.clone(), + ), + ( + map.influences.detectors_z.offsets.clone(), + map.influences.detectors_z.data.clone(), + ), + ] + } + /// Circuit with CZ gates for testing multi-qubit symmetric faults fn cz_syndrome_circuit() -> DagCircuit { let mut dag = DagCircuit::new(); @@ -2688,6 +2761,26 @@ mod tests { assert!(locations.len() >= 4); } + #[test] + fn test_build_influence_map_uses_generic_propagation_for_reused_physical_ancilla_slots() { + let dag = reused_physical_ancilla_circuit(); + let analyzer = DagFaultAnalyzer::new(&dag); + + let built = analyzer.build_influence_map(); + let parallel = build_parallel_map(&analyzer); + let forest = build_forest_map(&analyzer); + + assert_eq!( + detector_fingerprint(&built), + detector_fingerprint(¶llel) + ); + assert_ne!( + detector_fingerprint(&forest), + detector_fingerprint(¶llel), + "this regression circuit should distinguish physical-slot reuse from fixed-ancilla reuse" + ); + } + #[test] fn test_dag_spacetime_location_ordering() { // Verify that DagSpacetimeLocation has consistent ordering diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index bf5989773..b748a5eea 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -728,7 +728,12 @@ def render( circuit.cx([(op.qubits[0], op.qubits[1])]) elif op.op_type == OpType.MEASURE: - circuit.mz([op.qubits[0]]) + q = op.qubits[0] + if op.label.startswith(("sx", "sz")): + circuit.mz_free([q]) + allocated.discard(q) + else: + circuit.mz([q]) elif op.op_type == OpType.TICK: pass # DagCircuit doesn't have explicit ticks @@ -1038,7 +1043,11 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non elif op.op_type == OpType.MEASURE: q = op.qubits[0] - meas_refs = get_tick_for_qubits([q]).mz([q]) + if op.label.startswith(("sx", "sz")): + meas_refs = get_tick_for_qubits([q]).mz_free([q]) + allocated.discard(q) + else: + meas_refs = get_tick_for_qubits([q]).mz([q]) mark_qubits_used([q]) # Label helps identify measurement (e.g., "sx0", "sz0", "final[0]") meta = get_ancilla_gate_metadata(q, op.label) @@ -2091,7 +2100,7 @@ def _extract_measurement_order(tc: TickCircuit) -> list[int]: gates = tick.gate_batches() for gate in gates: gate_type = str(gate.gate_type) - if "MZ" in gate_type: + if "MZ" in gate_type or "MeasureFree" in gate_type: # Add each measured qubit to the order for qubit in gate.qubits: # Qubit might be an int or a QubitId object 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 0cdcd1486..88982a279 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -473,6 +473,42 @@ def test_constrained_budget_sampler_builds_for_all_models(self) -> None: ) assert sampler.num_detectors == expected_detectors + def test_constrained_budget_dem_remains_strictly_decodable(self) -> None: + """Constrained ancilla reuse should not produce ungraphlike DEM artifacts. + + This pins a regression where DAG fault propagation grouped measurements + by physical ancilla slot. That shortcut is invalid when the same slot is + reused for different stabilizers and produced high-degree mechanisms + that strict PyMatching could not parse. + """ + from pecos.qec import ParsedDem + + patch = SurfacePatch.create(distance=5) + params = { + "p1": 0.0, + "p2": 0.001, + "p_meas": 0.0, + "p_prep": 0.0, + "decompose_errors": True, + } + + for basis in ("X", "Z"): + tc = generate_tick_circuit_from_patch( + patch, + num_rounds=5, + basis=basis, + ancilla_budget=8, + ) + dem = generate_dem_from_tick_circuit(tc, **params) + sampler = ParsedDem.from_string(dem).to_dem_sampler() + + assert sampler.sample_decode_count( + dem, + 16, + decoder_type="pymatching", + seed=1234, + ) >= 0 + def test_traced_qis_traces_the_given_patch_not_its_distance(self) -> None: """A non-rotated patch must be traced from its OWN Guppy program, not the default rotated patch of the same distance. Before the patch- diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py b/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py index f5f7fa606..ec75274fe 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py @@ -199,19 +199,23 @@ def test_tick_circuit_exposes_observable_descriptors() -> None: def test_tick_circuit_exposes_measurement_order() -> None: - """Tick circuits should expose measurement order matching their MZ gates.""" + """Tick circuits should expose measurement order matching measurement gates.""" patch = SurfacePatch.create(distance=3) tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis="X") observed = get_measurement_order_from_tick_circuit(tc) expected: list[int] = [] + has_measure_free = False for tick_index in range(tc.num_ticks()): tick = tc.get_tick(tick_index) if tick is None: continue for gate in tick.gate_batches(): - if "MZ" not in str(gate.gate_type): + gate_type = str(gate.gate_type) + if "MeasureFree" in gate_type: + has_measure_free = True + if "MZ" not in gate_type and "MeasureFree" not in gate_type: continue for qubit in gate.qubits: if hasattr(qubit, "index"): @@ -219,6 +223,7 @@ def test_tick_circuit_exposes_measurement_order() -> None: else: expected.append(int(qubit)) + assert has_measure_free assert observed == expected assert len(observed) == int(tc.get_meta("num_measurements") or "0") From d3976652acf62f6205188b5b3bc85d9403e16a5a Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 7 Jun 2026 23:13:53 -0600 Subject: [PATCH 042/150] Add powered inner-decoder threshold/LER study establishing fusion_blossom_serial as the default with Jeffreys CIs --- examples/surface/inner_decoder_study.py | 445 ++++++++++++++++++ .../results/inner_decoder_study_report.md | 183 +++++++ .../results/inner_decoder_study_speed.jsonl | 8 + .../inner_decoder_study_suppress.jsonl | 216 +++++++++ .../inner_decoder_study_threshold.jsonl | 72 +++ .../src/fault_tolerance_bindings.rs | 37 +- 6 files changed, 945 insertions(+), 16 deletions(-) create mode 100644 examples/surface/inner_decoder_study.py create mode 100644 examples/surface/results/inner_decoder_study_report.md create mode 100644 examples/surface/results/inner_decoder_study_speed.jsonl create mode 100644 examples/surface/results/inner_decoder_study_suppress.jsonl create mode 100644 examples/surface/results/inner_decoder_study_threshold.jsonl diff --git a/examples/surface/inner_decoder_study.py b/examples/surface/inner_decoder_study.py new file mode 100644 index 000000000..c3baaaf2a --- /dev/null +++ b/examples/surface/inner_decoder_study.py @@ -0,0 +1,445 @@ +# 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. + +"""Rigorous inner-decoder study for ``LogicalSubgraphDecoder``. + +Answers, with statistics that can actually separate the candidates: + +* Fault tolerance / distance suppression -- does logical error rate (LER) fall + as code distance ``d`` grows below threshold, for each inner decoder? +* Threshold -- where do the per-distance LER curves cross (so above it more + distance hurts)? Estimated per inner. +* Lowest LER -- at fixed sub-threshold ``p``, which inner wins, and is the gap + statistically real (non-overlapping Jeffreys intervals)? +* Speed -- decoder build cost vs per-shot decode throughput, separated. + +Design choices that fix the under-powered earlier spot-check: + +* PAIRED comparison: one sampled batch per (family, d, p, seed) is decoded by + every inner, so decoder differences are not confounded by sampling noise. +* Sub-threshold ``p`` chosen so LER is large enough (~1e-3..1e-2) that 1e5 shots + yield hundreds of failures -> tight intervals that resolve 2x differences. +* Multiple seeds for the headline cells (batch-to-batch stability). +* Jeffreys (Bayesian Beta(k+1/2, n-k+1/2)) intervals -- the project's preferred + binomial CI -- computed via scipy as an analysis oracle (never a runtime dep). +* Build time (decoder construction) separated from decode time (decode_count). + +Results are appended as JSON lines to ``results/inner_decoder_study_.jsonl`` +so a run is resumable and analysable independently (see ``--phase analyze``). +""" + +from __future__ import annotations + +import argparse +import json +import math +import time +from dataclasses import asdict, dataclass +from pathlib import Path + +from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch +from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem + +# Candidate inner decoders for the library default. fusion_blossom_serial is the +# current default (exact MWPM, bundled); pecos_uf:bp is the native option; +# belief_matching is BP+MWPM; pymatching/tesseract are external baselines. +CANDIDATES = [ + "fusion_blossom_serial", + "pecos_uf:bp", + "belief_matching", + "pymatching", + "tesseract", +] + +RESULTS_DIR = Path(__file__).resolve().parent / "results" + + +@dataclass(frozen=True) +class Cell: + """One measured (family, d, p, seed, inner) point.""" + + family: str + distance: int + rounds: int + p: float + seed: int + inner: str + num_shots: int + num_errors: int + ler: float + build_seconds: float + decode_seconds: float + + +# --------------------------------------------------------------------------- # +# Circuit families +# --------------------------------------------------------------------------- # + + +def _memory_builder(d: int, rounds: int) -> LogicalCircuitBuilder: + patch = SurfacePatch.create(distance=d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", rounds, "Z") + return b + + +def _cx_builder(d: int, rounds: int) -> LogicalCircuitBuilder: + patch = SurfacePatch.create(distance=d) + nq = patch.geometry.num_data + patch.geometry.num_ancilla + b = LogicalCircuitBuilder() + b.add_patch(patch, "C", qubit_offset=0) + b.add_patch(patch, "T", qubit_offset=nq) + b.add_memory(["C", "T"], rounds, "Z") + b.add_transversal_cx("C", "T") + b.add_memory(["C", "T"], rounds, "Z") + return b + + +FAMILIES = {"memory": _memory_builder, "cx": _cx_builder} + + +# --------------------------------------------------------------------------- # +# Statistics (Jeffreys interval as an analysis oracle) +# --------------------------------------------------------------------------- # + + +def jeffreys_ci(k: int, n: int, alpha: float = 0.05) -> tuple[float, float]: + """Two-sided Jeffreys (Beta) credible interval for a binomial proportion. + + Posterior under the Jeffreys prior Beta(1/2, 1/2) is Beta(k+1/2, n-k+1/2). + Endpoints clamped to (0, 1) at k=0 / k=n per the standard convention. + """ + from scipy.stats import beta # analysis-only oracle, not a PECOS runtime dep + + lo = 0.0 if k == 0 else float(beta.ppf(alpha / 2.0, k + 0.5, n - k + 0.5)) + hi = 1.0 if k == n else float(beta.ppf(1.0 - alpha / 2.0, k + 0.5, n - k + 0.5)) + return lo, hi + + +def intervals_disjoint(a: Cell, b: Cell) -> bool: + """True if the two cells' Jeffreys 95% intervals do not overlap.""" + a_lo, a_hi = jeffreys_ci(a.num_errors, a.num_shots) + b_lo, b_hi = jeffreys_ci(b.num_errors, b.num_shots) + return a_hi < b_lo or b_hi < a_lo + + +# --------------------------------------------------------------------------- # +# Measurement +# --------------------------------------------------------------------------- # + + +def measure_cell(family: str, d: int, rounds: int, p: float, seed: int, inners: list[str], n: int) -> list[Cell]: + """Sample ONE batch and decode it with every inner (paired comparison).""" + builder = FAMILIES[family](d, rounds) + dem = builder.build_dem(p1=p, p2=p, p_meas=p) + sc = builder.stab_coords() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) + + cells: list[Cell] = [] + for inner in inners: + t0 = time.perf_counter() + dec = LogicalSubgraphDecoder(dem, sc, inner) + t1 = time.perf_counter() + wrong = dec.decode_count(batch) + t2 = time.perf_counter() + cells.append( + Cell( + family=family, + distance=d, + rounds=rounds, + p=p, + seed=seed, + inner=inner, + num_shots=n, + num_errors=wrong, + ler=wrong / n, + build_seconds=t1 - t0, + decode_seconds=t2 - t1, + ) + ) + return cells + + +def _append(path: Path, cells: list[Cell]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a") as fh: + for c in cells: + fh.write(json.dumps(asdict(c)) + "\n") + + +def _load(path: Path) -> list[Cell]: + if not path.exists(): + return [] + out = [] + for line in path.read_text().splitlines(): + if line.strip(): + out.append(Cell(**json.loads(line))) + return out + + +# --------------------------------------------------------------------------- # +# Phases +# --------------------------------------------------------------------------- # + + +def run_suppress(path: Path) -> None: + """Distance suppression + decoder ranking with resolving statistics. + + Sub-threshold p, large n, multiple seeds; memory (1 obs) and transversal-CX + (multi-obs, where the earlier spot-check saw fusion beat bp).""" + done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} + plan = [ + ("memory", [3, 5, 7], [0.002, 0.003, 0.005], CANDIDATES, [1, 2, 3], 100_000), + ("cx", [3, 5, 7], [0.002, 0.003, 0.005], ["fusion_blossom_serial", "pecos_uf:bp", "belief_matching"], [1, 2, 3], 50_000), + ] + for family, ds, ps, inners, seeds, n in plan: + for d in ds: + for p in ps: + for seed in seeds: + todo = [i for i in inners if (family, d, p, seed, i) not in done] + if not todo: + continue + t = time.perf_counter() + cells = measure_cell(family, d, d, p, seed, todo, n) + _append(path, cells) + best = min(cells, key=lambda c: c.ler) + print( + f"[suppress] {family:6s} d={d} p={p:.3f} seed={seed} n={n}: " + + " ".join(f"{c.inner.split(':')[0][:6]}={c.num_errors}" for c in cells) + + f" best={best.inner.split(':')[0]} ({time.perf_counter() - t:.1f}s)", + flush=True, + ) + + +def run_threshold(path: Path) -> None: + """Threshold sweep over p for the policy candidates -- locate the crossing.""" + done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} + inners = ["fusion_blossom_serial", "pecos_uf:bp", "pymatching"] + ps = [0.004, 0.005, 0.006, 0.007, 0.008, 0.009, 0.010, 0.012] + for d in [3, 5, 7]: + for p in ps: + todo = [i for i in inners if ("memory", d, p, 1, i) not in done] + if not todo: + continue + t = time.perf_counter() + cells = measure_cell("memory", d, d, p, 1, todo, 50_000) + _append(path, cells) + print( + f"[threshold] memory d={d} p={p:.3f}: " + + " ".join(f"{c.inner.split(':')[0][:6]}={c.ler:.4f}" for c in cells) + + f" ({time.perf_counter() - t:.1f}s)", + flush=True, + ) + + +def run_speed(path: Path) -> None: + """Per-shot decode throughput (build vs decode) at the costly d=7 point.""" + done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} + for family in ["memory", "cx"]: + inners = CANDIDATES if family == "memory" else ["fusion_blossom_serial", "pecos_uf:bp", "belief_matching"] + todo = [i for i in inners if (family, 7, 0.003, 1, i) not in done] + if not todo: + continue + cells = measure_cell(family, 7, 7, 0.003, 1, todo, 50_000) + _append(path, cells) + for c in cells: + us = c.decode_seconds / c.num_shots * 1e6 + print( + f"[speed] {family:6s} d=7 {c.inner:24s}: build={c.build_seconds * 1e3:7.1f}ms " + f"decode={c.decode_seconds:6.2f}s {us:8.1f}us/shot", + flush=True, + ) + + +# --------------------------------------------------------------------------- # +# Analysis +# --------------------------------------------------------------------------- # + + +def _pool(cells: list[Cell]) -> dict: + """Pool repeated seeds for the same (family,d,p,inner) into one binomial.""" + agg: dict[tuple, list[int]] = {} + for c in cells: + key = (c.family, c.distance, c.p, c.inner) + k, n = agg.setdefault(key, [0, 0]) + agg[key] = [k + c.num_errors, n + c.num_shots] + return agg + + +def analyze(out_dir: Path) -> str: + lines: list[str] = [] + + def w(s: str = "") -> None: + lines.append(s) + + sup = _load(out_dir / "inner_decoder_study_suppress.jsonl") + thr = _load(out_dir / "inner_decoder_study_threshold.jsonl") + spd = _load(out_dir / "inner_decoder_study_speed.jsonl") + + w("# Inner-decoder study results") + w() + w("LER with Jeffreys 95% intervals (Beta(k+1/2, n-k+1/2)); seeds pooled into one") + w("binomial per (family, d, p, inner). `k/n` = failures / shots.") + w() + + if sup: + agg = _pool(sup) + families = sorted({k[0] for k in agg}) + inners = [i for i in CANDIDATES if any(k[3] == i for k in agg)] + for fam in families: + ps = sorted({k[2] for k in agg if k[0] == fam}) + ds = sorted({k[1] for k in agg if k[0] == fam}) + w(f"## {fam}: distance suppression + ranking") + w() + for p in ps: + w(f"### p = {p}") + w() + w("| inner | " + " | ".join(f"d={d}" for d in ds) + " |") + w("|---|" + "---|" * len(ds)) + for inner in inners: + cells = [] + for d in ds: + kv = agg.get((fam, d, p, inner)) + cells.append(kv) + row = [inner] + for kv in cells: + if kv is None: + row.append("--") + continue + k, n = kv + lo, hi = jeffreys_ci(k, n) + row.append(f"{k}/{n} {k / n:.2e} [{lo:.1e},{hi:.1e}]") + w("| " + " | ".join(row) + " |") + w() + # Decision-relevant contrast per distance: the best inner vs the + # native pecos_uf:bp candidate (the MWPM-family members are + # accuracy-tied on these graphlike DEMs, so best-vs-2nd is + # uninformative -- best-vs-bp is the contrast that picks a default). + for d in ds: + present = [(i, agg[(fam, d, p, i)]) for i in inners if (fam, d, p, i) in agg] + if len(present) < 2: + continue + present.sort(key=lambda t: t[1][0] / t[1][1]) + bi, (bk, bn) = present[0] + bench = "pecos_uf:bp" + if (fam, d, p, bench) not in agg or bi == bench: + continue + rk, rn = agg[(fam, d, p, bench)] + blo, bhi = jeffreys_ci(bk, bn) + rlo, rhi = jeffreys_ci(rk, rn) + sep = "DISJOINT" if bhi < rlo else "overlap" + ratio = (rk / rn) / (bk / bn) if bk else float("inf") + w(f"- d={d}: best **{bi}** {bk / bn:.2e} vs {bench} {rk / rn:.2e} " + f"({ratio:.1f}x) -- Jeffreys intervals {sep}") + w() + # Suppression check + exponent per inner (pooled across seeds). + w(f"### {fam}: suppression exponent (LER ~ (p/p_th)^((d+1)/2))") + w() + for p in ps: + for inner in inners: + seq = [(d, agg[(fam, d, p, inner)]) for d in ds if (fam, d, p, inner) in agg] + seq = [(d, kv) for d, kv in seq if kv[0] > 0] # need nonzero to log + if len(seq) < 2: + continue + suppresses = all( + seq[i + 1][1][0] / seq[i + 1][1][1] < seq[i][1][0] / seq[i][1][1] + for i in range(len(seq) - 1) + ) + ratios = [ + (seq[i][1][0] / seq[i][1][1]) / (seq[i + 1][1][0] / seq[i + 1][1][1]) + for i in range(len(seq) - 1) + ] + tag = "suppresses" if suppresses else "NOT monotone" + w(f"- p={p} {inner}: {tag}; per-step LER ratio " + + ", ".join(f"{r:.1f}x" for r in ratios)) + w() + + if thr: + agg = _pool(thr) + inners = sorted({k[3] for k in agg}) + ds = sorted({k[1] for k in agg}) + ps = sorted({k[2] for k in agg}) + w("## memory: threshold crossing") + w() + for inner in inners: + w(f"### {inner}") + w() + w("| p | " + " | ".join(f"d={d}" for d in ds) + " |") + w("|---|" + "---|" * len(ds)) + for p in ps: + row = [f"{p:.3f}"] + for d in ds: + kv = agg.get(("memory", d, p, inner)) + row.append(f"{kv[0] / kv[1]:.2e}" if kv else "--") + w("| " + " | ".join(row) + " |") + # crossing estimate: smallest p where d=max no longer beats d=min + cross = None + d_lo, d_hi = ds[0], ds[-1] + for p in ps: + a = agg.get(("memory", d_lo, p, inner)) + b = agg.get(("memory", d_hi, p, inner)) + if a and b and b[0] / b[1] >= a[0] / a[1]: + cross = p + break + w() + w(f"- threshold estimate (d={d_hi} stops beating d={d_lo}): " + + (f"~{cross}" if cross else f"above {ps[-1]} (not reached)")) + w() + + if spd: + w("## speed (d=7, p=0.003, n per cell as sampled)") + w() + w("| family | inner | build ms | decode s | us/shot |") + w("|---|---|---:|---:|---:|") + for c in sorted(spd, key=lambda c: (c.family, c.decode_seconds)): + us = c.decode_seconds / c.num_shots * 1e6 + w(f"| {c.family} | {c.inner} | {c.build_seconds * 1e3:.1f} | " + f"{c.decode_seconds:.2f} | {us:.1f} |") + w() + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- # + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--phase", required=True, choices=["suppress", "threshold", "speed", "analyze", "smoke"]) + ap.add_argument("--out", type=Path, default=RESULTS_DIR) + args = ap.parse_args() + + if args.phase == "smoke": + cells = measure_cell("memory", 3, 3, 0.005, 1, ["fusion_blossom_serial", "pecos_uf:bp"], 2000) + for c in cells: + lo, hi = jeffreys_ci(c.num_errors, c.num_shots) + print(f"smoke {c.inner}: {c.num_errors}/{c.num_shots} ler={c.ler:.4f} " + f"CI=[{lo:.4f},{hi:.4f}] build={c.build_seconds * 1e3:.1f}ms decode={c.decode_seconds:.3f}s") + cx = measure_cell("cx", 3, 3, 0.005, 1, ["fusion_blossom_serial"], 2000) + print(f"smoke cx: {cx[0].num_errors}/{cx[0].num_shots} ler={cx[0].ler:.4f}") + return + + if args.phase == "analyze": + report = analyze(args.out) + print(report) + (args.out / "inner_decoder_study_report.md").write_text(report + "\n") + print(f"\n[written] {args.out / 'inner_decoder_study_report.md'}") + return + + path = args.out / f"inner_decoder_study_{args.phase}.jsonl" + {"suppress": run_suppress, "threshold": run_threshold, "speed": run_speed}[args.phase](path) + print(f"[done] {args.phase} -> {path}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/examples/surface/results/inner_decoder_study_report.md b/examples/surface/results/inner_decoder_study_report.md new file mode 100644 index 000000000..0925e703d --- /dev/null +++ b/examples/surface/results/inner_decoder_study_report.md @@ -0,0 +1,183 @@ +# Inner-decoder study results + +LER with Jeffreys 95% intervals (Beta(k+1/2, n-k+1/2)); seeds pooled into one +binomial per (family, d, p, inner). `k/n` = failures / shots. + +## cx: distance suppression + ranking + +### p = 0.002 + +| inner | d=3 | d=5 | d=7 | +|---|---|---|---| +| fusion_blossom_serial | 1039/150000 6.93e-03 [6.5e-03,7.4e-03] | 294/150000 1.96e-03 [1.7e-03,2.2e-03] | 59/150000 3.93e-04 [3.0e-04,5.0e-04] | +| pecos_uf:bp | 1069/150000 7.13e-03 [6.7e-03,7.6e-03] | 556/150000 3.71e-03 [3.4e-03,4.0e-03] | 159/150000 1.06e-03 [9.0e-04,1.2e-03] | +| belief_matching | 1042/150000 6.95e-03 [6.5e-03,7.4e-03] | 294/150000 1.96e-03 [1.7e-03,2.2e-03] | 59/150000 3.93e-04 [3.0e-04,5.0e-04] | +| pymatching | -- | -- | -- | +| tesseract | -- | -- | -- | + +- d=3: best **fusion_blossom_serial** 6.93e-03 vs pecos_uf:bp 7.13e-03 (1.0x) -- Jeffreys intervals overlap +- d=5: best **fusion_blossom_serial** 1.96e-03 vs pecos_uf:bp 3.71e-03 (1.9x) -- Jeffreys intervals DISJOINT +- d=7: best **fusion_blossom_serial** 3.93e-04 vs pecos_uf:bp 1.06e-03 (2.7x) -- Jeffreys intervals DISJOINT + +### p = 0.003 + +| inner | d=3 | d=5 | d=7 | +|---|---|---|---| +| fusion_blossom_serial | 2158/150000 1.44e-02 [1.4e-02,1.5e-02] | 1010/150000 6.73e-03 [6.3e-03,7.2e-03] | 333/150000 2.22e-03 [2.0e-03,2.5e-03] | +| pecos_uf:bp | 2217/150000 1.48e-02 [1.4e-02,1.5e-02] | 1641/150000 1.09e-02 [1.0e-02,1.1e-02] | 662/150000 4.41e-03 [4.1e-03,4.8e-03] | +| belief_matching | 2159/150000 1.44e-02 [1.4e-02,1.5e-02] | 1010/150000 6.73e-03 [6.3e-03,7.2e-03] | 333/150000 2.22e-03 [2.0e-03,2.5e-03] | +| pymatching | -- | -- | -- | +| tesseract | -- | -- | -- | + +- d=3: best **fusion_blossom_serial** 1.44e-02 vs pecos_uf:bp 1.48e-02 (1.0x) -- Jeffreys intervals overlap +- d=5: best **fusion_blossom_serial** 6.73e-03 vs pecos_uf:bp 1.09e-02 (1.6x) -- Jeffreys intervals DISJOINT +- d=7: best **fusion_blossom_serial** 2.22e-03 vs pecos_uf:bp 4.41e-03 (2.0x) -- Jeffreys intervals DISJOINT + +### p = 0.005 + +| inner | d=3 | d=5 | d=7 | +|---|---|---|---| +| fusion_blossom_serial | 5711/150000 3.81e-02 [3.7e-02,3.9e-02] | 4316/150000 2.88e-02 [2.8e-02,3.0e-02] | 2636/150000 1.76e-02 [1.7e-02,1.8e-02] | +| pecos_uf:bp | 5803/150000 3.87e-02 [3.8e-02,4.0e-02] | 5967/150000 3.98e-02 [3.9e-02,4.1e-02] | 4051/150000 2.70e-02 [2.6e-02,2.8e-02] | +| belief_matching | 5730/150000 3.82e-02 [3.7e-02,3.9e-02] | 4316/150000 2.88e-02 [2.8e-02,3.0e-02] | 2636/150000 1.76e-02 [1.7e-02,1.8e-02] | +| pymatching | -- | -- | -- | +| tesseract | -- | -- | -- | + +- d=3: best **fusion_blossom_serial** 3.81e-02 vs pecos_uf:bp 3.87e-02 (1.0x) -- Jeffreys intervals overlap +- d=5: best **fusion_blossom_serial** 2.88e-02 vs pecos_uf:bp 3.98e-02 (1.4x) -- Jeffreys intervals DISJOINT +- d=7: best **fusion_blossom_serial** 1.76e-02 vs pecos_uf:bp 2.70e-02 (1.5x) -- Jeffreys intervals DISJOINT + +### cx: suppression exponent (LER ~ (p/p_th)^((d+1)/2)) + +- p=0.002 fusion_blossom_serial: suppresses; per-step LER ratio 3.5x, 5.0x +- p=0.002 pecos_uf:bp: suppresses; per-step LER ratio 1.9x, 3.5x +- p=0.002 belief_matching: suppresses; per-step LER ratio 3.5x, 5.0x +- p=0.003 fusion_blossom_serial: suppresses; per-step LER ratio 2.1x, 3.0x +- p=0.003 pecos_uf:bp: suppresses; per-step LER ratio 1.4x, 2.5x +- p=0.003 belief_matching: suppresses; per-step LER ratio 2.1x, 3.0x +- p=0.005 fusion_blossom_serial: suppresses; per-step LER ratio 1.3x, 1.6x +- p=0.005 pecos_uf:bp: NOT monotone; per-step LER ratio 1.0x, 1.5x +- p=0.005 belief_matching: suppresses; per-step LER ratio 1.3x, 1.6x + +## memory: distance suppression + ranking + +### p = 0.002 + +| inner | d=3 | d=5 | d=7 | +|---|---|---|---| +| fusion_blossom_serial | 421/300000 1.40e-03 [1.3e-03,1.5e-03] | 111/300000 3.70e-04 [3.1e-04,4.4e-04] | 28/300000 9.33e-05 [6.3e-05,1.3e-04] | +| pecos_uf:bp | 425/300000 1.42e-03 [1.3e-03,1.6e-03] | 280/300000 9.33e-04 [8.3e-04,1.0e-03] | 62/300000 2.07e-04 [1.6e-04,2.6e-04] | +| belief_matching | 420/300000 1.40e-03 [1.3e-03,1.5e-03] | 111/300000 3.70e-04 [3.1e-04,4.4e-04] | 28/300000 9.33e-05 [6.3e-05,1.3e-04] | +| pymatching | 421/300000 1.40e-03 [1.3e-03,1.5e-03] | 111/300000 3.70e-04 [3.1e-04,4.4e-04] | 28/300000 9.33e-05 [6.3e-05,1.3e-04] | +| tesseract | 421/300000 1.40e-03 [1.3e-03,1.5e-03] | 111/300000 3.70e-04 [3.1e-04,4.4e-04] | 28/300000 9.33e-05 [6.3e-05,1.3e-04] | + +- d=3: best **belief_matching** 1.40e-03 vs pecos_uf:bp 1.42e-03 (1.0x) -- Jeffreys intervals overlap +- d=5: best **fusion_blossom_serial** 3.70e-04 vs pecos_uf:bp 9.33e-04 (2.5x) -- Jeffreys intervals DISJOINT +- d=7: best **fusion_blossom_serial** 9.33e-05 vs pecos_uf:bp 2.07e-04 (2.2x) -- Jeffreys intervals DISJOINT + +### p = 0.003 + +| inner | d=3 | d=5 | d=7 | +|---|---|---|---| +| fusion_blossom_serial | 934/300000 3.11e-03 [2.9e-03,3.3e-03] | 375/300000 1.25e-03 [1.1e-03,1.4e-03] | 129/300000 4.30e-04 [3.6e-04,5.1e-04] | +| pecos_uf:bp | 944/300000 3.15e-03 [3.0e-03,3.4e-03] | 738/300000 2.46e-03 [2.3e-03,2.6e-03] | 233/300000 7.77e-04 [6.8e-04,8.8e-04] | +| belief_matching | 933/300000 3.11e-03 [2.9e-03,3.3e-03] | 375/300000 1.25e-03 [1.1e-03,1.4e-03] | 129/300000 4.30e-04 [3.6e-04,5.1e-04] | +| pymatching | 934/300000 3.11e-03 [2.9e-03,3.3e-03] | 376/300000 1.25e-03 [1.1e-03,1.4e-03] | 130/300000 4.33e-04 [3.6e-04,5.1e-04] | +| tesseract | 934/300000 3.11e-03 [2.9e-03,3.3e-03] | 376/300000 1.25e-03 [1.1e-03,1.4e-03] | 131/300000 4.37e-04 [3.7e-04,5.2e-04] | + +- d=3: best **belief_matching** 3.11e-03 vs pecos_uf:bp 3.15e-03 (1.0x) -- Jeffreys intervals overlap +- d=5: best **fusion_blossom_serial** 1.25e-03 vs pecos_uf:bp 2.46e-03 (2.0x) -- Jeffreys intervals DISJOINT +- d=7: best **fusion_blossom_serial** 4.30e-04 vs pecos_uf:bp 7.77e-04 (1.8x) -- Jeffreys intervals DISJOINT + +### p = 0.005 + +| inner | d=3 | d=5 | d=7 | +|---|---|---|---| +| fusion_blossom_serial | 2418/300000 8.06e-03 [7.7e-03,8.4e-03] | 1616/300000 5.39e-03 [5.1e-03,5.7e-03] | 830/300000 2.77e-03 [2.6e-03,3.0e-03] | +| pecos_uf:bp | 2481/300000 8.27e-03 [8.0e-03,8.6e-03] | 2625/300000 8.75e-03 [8.4e-03,9.1e-03] | 1519/300000 5.06e-03 [4.8e-03,5.3e-03] | +| belief_matching | 2429/300000 8.10e-03 [7.8e-03,8.4e-03] | 1616/300000 5.39e-03 [5.1e-03,5.7e-03] | 830/300000 2.77e-03 [2.6e-03,3.0e-03] | +| pymatching | 2415/300000 8.05e-03 [7.7e-03,8.4e-03] | 1618/300000 5.39e-03 [5.1e-03,5.7e-03] | 828/300000 2.76e-03 [2.6e-03,3.0e-03] | +| tesseract | 2416/300000 8.05e-03 [7.7e-03,8.4e-03] | 1619/300000 5.40e-03 [5.1e-03,5.7e-03] | 833/300000 2.78e-03 [2.6e-03,3.0e-03] | + +- d=3: best **pymatching** 8.05e-03 vs pecos_uf:bp 8.27e-03 (1.0x) -- Jeffreys intervals overlap +- d=5: best **fusion_blossom_serial** 5.39e-03 vs pecos_uf:bp 8.75e-03 (1.6x) -- Jeffreys intervals DISJOINT +- d=7: best **pymatching** 2.76e-03 vs pecos_uf:bp 5.06e-03 (1.8x) -- Jeffreys intervals DISJOINT + +### memory: suppression exponent (LER ~ (p/p_th)^((d+1)/2)) + +- p=0.002 fusion_blossom_serial: suppresses; per-step LER ratio 3.8x, 4.0x +- p=0.002 pecos_uf:bp: suppresses; per-step LER ratio 1.5x, 4.5x +- p=0.002 belief_matching: suppresses; per-step LER ratio 3.8x, 4.0x +- p=0.002 pymatching: suppresses; per-step LER ratio 3.8x, 4.0x +- p=0.002 tesseract: suppresses; per-step LER ratio 3.8x, 4.0x +- p=0.003 fusion_blossom_serial: suppresses; per-step LER ratio 2.5x, 2.9x +- p=0.003 pecos_uf:bp: suppresses; per-step LER ratio 1.3x, 3.2x +- p=0.003 belief_matching: suppresses; per-step LER ratio 2.5x, 2.9x +- p=0.003 pymatching: suppresses; per-step LER ratio 2.5x, 2.9x +- p=0.003 tesseract: suppresses; per-step LER ratio 2.5x, 2.9x +- p=0.005 fusion_blossom_serial: suppresses; per-step LER ratio 1.5x, 1.9x +- p=0.005 pecos_uf:bp: NOT monotone; per-step LER ratio 0.9x, 1.7x +- p=0.005 belief_matching: suppresses; per-step LER ratio 1.5x, 1.9x +- p=0.005 pymatching: suppresses; per-step LER ratio 1.5x, 2.0x +- p=0.005 tesseract: suppresses; per-step LER ratio 1.5x, 1.9x + +## memory: threshold crossing + +### fusion_blossom_serial + +| p | d=3 | d=5 | d=7 | +|---|---|---|---| +| 0.004 | 4.82e-03 | 2.72e-03 | 1.44e-03 | +| 0.005 | 7.40e-03 | 5.16e-03 | 3.16e-03 | +| 0.006 | 1.13e-02 | 8.86e-03 | 5.90e-03 | +| 0.007 | 1.44e-02 | 1.36e-02 | 1.06e-02 | +| 0.008 | 1.87e-02 | 2.05e-02 | 1.68e-02 | +| 0.009 | 2.35e-02 | 2.77e-02 | 2.53e-02 | +| 0.010 | 2.85e-02 | 3.55e-02 | 3.69e-02 | +| 0.012 | 3.95e-02 | 5.50e-02 | 6.57e-02 | + +- threshold estimate (d=7 stops beating d=3): ~0.009 + +### pecos_uf:bp + +| p | d=3 | d=5 | d=7 | +|---|---|---|---| +| 0.004 | 4.88e-03 | 5.30e-03 | 2.52e-03 | +| 0.005 | 7.62e-03 | 9.02e-03 | 5.18e-03 | +| 0.006 | 1.14e-02 | 1.38e-02 | 9.64e-03 | +| 0.007 | 1.47e-02 | 1.96e-02 | 1.62e-02 | +| 0.008 | 1.94e-02 | 2.77e-02 | 2.50e-02 | +| 0.009 | 2.41e-02 | 3.69e-02 | 3.50e-02 | +| 0.010 | 2.92e-02 | 4.65e-02 | 5.00e-02 | +| 0.012 | 4.03e-02 | 6.90e-02 | 8.44e-02 | + +- threshold estimate (d=7 stops beating d=3): ~0.007 + +### pymatching + +| p | d=3 | d=5 | d=7 | +|---|---|---|---| +| 0.004 | 4.82e-03 | 2.74e-03 | 1.44e-03 | +| 0.005 | 7.40e-03 | 5.18e-03 | 3.16e-03 | +| 0.006 | 1.12e-02 | 8.86e-03 | 5.92e-03 | +| 0.007 | 1.44e-02 | 1.37e-02 | 1.06e-02 | +| 0.008 | 1.87e-02 | 2.04e-02 | 1.68e-02 | +| 0.009 | 2.36e-02 | 2.78e-02 | 2.53e-02 | +| 0.010 | 2.86e-02 | 3.55e-02 | 3.69e-02 | +| 0.012 | 3.94e-02 | 5.51e-02 | 6.58e-02 | + +- threshold estimate (d=7 stops beating d=3): ~0.009 + +## speed (d=7, p=0.003, n per cell as sampled) + +| family | inner | build ms | decode s | us/shot | +|---|---|---:|---:|---:| +| cx | fusion_blossom_serial | 55.1 | 61.22 | 1224.5 | +| cx | belief_matching | 234.4 | 140.59 | 2811.8 | +| cx | pecos_uf:bp | 228.1 | 356.84 | 7136.8 | +| memory | pymatching | 11.6 | 1.56 | 31.1 | +| memory | fusion_blossom_serial | 11.3 | 9.03 | 180.5 | +| memory | belief_matching | 27.7 | 24.13 | 482.5 | +| memory | tesseract | 16.9 | 44.44 | 888.8 | +| memory | pecos_uf:bp | 26.3 | 49.61 | 992.2 | + diff --git a/examples/surface/results/inner_decoder_study_speed.jsonl b/examples/surface/results/inner_decoder_study_speed.jsonl new file mode 100644 index 000000000..67662bee2 --- /dev/null +++ b/examples/surface/results/inner_decoder_study_speed.jsonl @@ -0,0 +1,8 @@ +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 27, "ler": 0.00054, "build_seconds": 0.011340677971020341, "decode_seconds": 9.026471441960894} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 47, "ler": 0.00094, "build_seconds": 0.026346575003117323, "decode_seconds": 49.61077240493614} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 27, "ler": 0.00054, "build_seconds": 0.027656523045152426, "decode_seconds": 24.126216560951434} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 27, "ler": 0.00054, "build_seconds": 0.011642899014987051, "decode_seconds": 1.5562470420263708} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "tesseract", "num_shots": 50000, "num_errors": 27, "ler": 0.00054, "build_seconds": 0.016912557068280876, "decode_seconds": 44.43782857491169} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 109, "ler": 0.00218, "build_seconds": 0.055101476958952844, "decode_seconds": 61.224118691985495} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 217, "ler": 0.00434, "build_seconds": 0.2280546340625733, "decode_seconds": 356.84065975493286} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 109, "ler": 0.00218, "build_seconds": 0.2343938680132851, "decode_seconds": 140.59061060997192} diff --git a/examples/surface/results/inner_decoder_study_suppress.jsonl b/examples/surface/results/inner_decoder_study_suppress.jsonl new file mode 100644 index 000000000..a5df4b383 --- /dev/null +++ b/examples/surface/results/inner_decoder_study_suppress.jsonl @@ -0,0 +1,216 @@ +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 151, "ler": 0.00151, "build_seconds": 0.0006421069847419858, "decode_seconds": 0.4497824680292979} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 149, "ler": 0.00149, "build_seconds": 0.0014974679797887802, "decode_seconds": 0.4129953379742801} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 146, "ler": 0.00146, "build_seconds": 0.0009097249712795019, "decode_seconds": 1.0400518350070342} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 151, "ler": 0.00151, "build_seconds": 0.000768975936807692, "decode_seconds": 0.30314706708304584} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 151, "ler": 0.00151, "build_seconds": 0.0011475470382720232, "decode_seconds": 3.57746625400614} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 135, "ler": 0.00135, "build_seconds": 0.0005435589700937271, "decode_seconds": 0.437479944084771} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 136, "ler": 0.00136, "build_seconds": 0.0008776090107858181, "decode_seconds": 0.395081341965124} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 137, "ler": 0.00137, "build_seconds": 0.0009364159777760506, "decode_seconds": 1.0369742000475526} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 135, "ler": 0.00135, "build_seconds": 0.0007549140136688948, "decode_seconds": 0.2959932100493461} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 135, "ler": 0.00135, "build_seconds": 0.0011641409946605563, "decode_seconds": 3.5544276010477915} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 135, "ler": 0.00135, "build_seconds": 0.0005864349659532309, "decode_seconds": 0.43750640004873276} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 140, "ler": 0.0014, "build_seconds": 0.0008039840031415224, "decode_seconds": 0.3960577129619196} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 137, "ler": 0.00137, "build_seconds": 0.0009138500317931175, "decode_seconds": 1.032312617986463} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 135, "ler": 0.00135, "build_seconds": 0.0007354009430855513, "decode_seconds": 0.28613320307340473} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 135, "ler": 0.00135, "build_seconds": 0.0010396430734544992, "decode_seconds": 3.470524953911081} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 297, "ler": 0.00297, "build_seconds": 0.0005425120471045375, "decode_seconds": 0.633861496928148} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 308, "ler": 0.00308, "build_seconds": 0.0007895899470895529, "decode_seconds": 0.5555906310910359} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 298, "ler": 0.00298, "build_seconds": 0.0008792480221018195, "decode_seconds": 1.4799694110406563} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 297, "ler": 0.00297, "build_seconds": 0.0007408609380945563, "decode_seconds": 0.33822497306391597} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 297, "ler": 0.00297, "build_seconds": 0.0010694570373743773, "decode_seconds": 4.372640290064737} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 305, "ler": 0.00305, "build_seconds": 0.0005225250497460365, "decode_seconds": 0.6251439020270482} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 300, "ler": 0.003, "build_seconds": 0.0007970969891175628, "decode_seconds": 0.5422393509652466} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 303, "ler": 0.00303, "build_seconds": 0.0009121129987761378, "decode_seconds": 1.4369446540949866} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 305, "ler": 0.00305, "build_seconds": 0.0007219089893624187, "decode_seconds": 0.33977618906646967} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 305, "ler": 0.00305, "build_seconds": 0.0010611469624564052, "decode_seconds": 4.464067224995233} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 332, "ler": 0.00332, "build_seconds": 0.0005535660311579704, "decode_seconds": 0.6583235840080306} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 336, "ler": 0.00336, "build_seconds": 0.0008649560622870922, "decode_seconds": 0.5674660198856145} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 332, "ler": 0.00332, "build_seconds": 0.0009156609885394573, "decode_seconds": 1.5248000029241666} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 332, "ler": 0.00332, "build_seconds": 0.0007595749339088798, "decode_seconds": 0.35373285110108554} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 332, "ler": 0.00332, "build_seconds": 0.0011283719213679433, "decode_seconds": 4.603742911014706} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 839, "ler": 0.00839, "build_seconds": 0.0005783980013802648, "decode_seconds": 1.0560591499088332} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 859, "ler": 0.00859, "build_seconds": 0.0008050329051911831, "decode_seconds": 0.8307753570843488} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 842, "ler": 0.00842, "build_seconds": 0.0008946330053731799, "decode_seconds": 2.2287630209466442} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 838, "ler": 0.00838, "build_seconds": 0.0007100310176610947, "decode_seconds": 0.4149702109862119} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 838, "ler": 0.00838, "build_seconds": 0.0010134490439668298, "decode_seconds": 6.129969451925717} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 819, "ler": 0.00819, "build_seconds": 0.0005436060018837452, "decode_seconds": 1.0267086579697207} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 836, "ler": 0.00836, "build_seconds": 0.0008376280311495066, "decode_seconds": 0.8533820679876953} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 827, "ler": 0.00827, "build_seconds": 0.0009307489963248372, "decode_seconds": 2.2911229039309546} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 818, "ler": 0.00818, "build_seconds": 0.000733512919396162, "decode_seconds": 0.42581556702498347} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 818, "ler": 0.00818, "build_seconds": 0.0010085488902404904, "decode_seconds": 6.200439296080731} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 760, "ler": 0.0076, "build_seconds": 0.0005461819237098098, "decode_seconds": 1.023707817075774} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 786, "ler": 0.00786, "build_seconds": 0.0008072979981079698, "decode_seconds": 0.8395827020285651} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 760, "ler": 0.0076, "build_seconds": 0.0008926040027290583, "decode_seconds": 2.319871476967819} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 759, "ler": 0.00759, "build_seconds": 0.0007133319741114974, "decode_seconds": 0.43179583409801126} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 760, "ler": 0.0076, "build_seconds": 0.0010716660181060433, "decode_seconds": 6.314711899962276} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 38, "ler": 0.00038, "build_seconds": 0.003559389035217464, "decode_seconds": 3.2722078029764816} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 86, "ler": 0.00086, "build_seconds": 0.0060471040196716785, "decode_seconds": 10.21703472896479} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 38, "ler": 0.00038, "build_seconds": 0.006321229040622711, "decode_seconds": 8.985465016914532} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 38, "ler": 0.00038, "build_seconds": 0.003913899068720639, "decode_seconds": 0.9927647470030934} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 38, "ler": 0.00038, "build_seconds": 0.005649265018291771, "decode_seconds": 17.831412710016593} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 32, "ler": 0.00032, "build_seconds": 0.0034006189089268446, "decode_seconds": 3.2840063450857997} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 88, "ler": 0.00088, "build_seconds": 0.005910877021960914, "decode_seconds": 10.341198201989755} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 32, "ler": 0.00032, "build_seconds": 0.006320501095615327, "decode_seconds": 9.032301379018463} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 32, "ler": 0.00032, "build_seconds": 0.0038215159438550472, "decode_seconds": 0.9886277749901637} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 32, "ler": 0.00032, "build_seconds": 0.005684125004336238, "decode_seconds": 17.790226757060736} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 41, "ler": 0.00041, "build_seconds": 0.0034657069481909275, "decode_seconds": 3.2388331450056285} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 106, "ler": 0.00106, "build_seconds": 0.005772155011072755, "decode_seconds": 10.084181787911803} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 41, "ler": 0.00041, "build_seconds": 0.006229009013622999, "decode_seconds": 9.035242390003987} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 41, "ler": 0.00041, "build_seconds": 0.0038418869953602552, "decode_seconds": 0.989809827064164} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 41, "ler": 0.00041, "build_seconds": 0.0056845389772206545, "decode_seconds": 17.67726430098992} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 112, "ler": 0.00112, "build_seconds": 0.0035957690561190248, "decode_seconds": 5.040996249997988} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 229, "ler": 0.00229, "build_seconds": 0.005812851013615727, "decode_seconds": 14.782907075015828} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 112, "ler": 0.00112, "build_seconds": 0.006486502010375261, "decode_seconds": 12.593983304919675} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 112, "ler": 0.00112, "build_seconds": 0.003774462966248393, "decode_seconds": 1.2669156150659546} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 112, "ler": 0.00112, "build_seconds": 0.0057538190158084035, "decode_seconds": 26.34577722591348} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 143, "ler": 0.00143, "build_seconds": 0.0033786309650167823, "decode_seconds": 5.02246251096949} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 270, "ler": 0.0027, "build_seconds": 0.005627467064186931, "decode_seconds": 14.253194133983925} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 143, "ler": 0.00143, "build_seconds": 0.00640254991594702, "decode_seconds": 12.500117123010568} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 144, "ler": 0.00144, "build_seconds": 0.0037747110472992063, "decode_seconds": 1.2520127579336986} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 144, "ler": 0.00144, "build_seconds": 0.005499309976585209, "decode_seconds": 25.5488203499699} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 120, "ler": 0.0012, "build_seconds": 0.003438101033680141, "decode_seconds": 4.9035661809612066} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 239, "ler": 0.00239, "build_seconds": 0.0058507469948381186, "decode_seconds": 14.442335027037188} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 120, "ler": 0.0012, "build_seconds": 0.006360596977174282, "decode_seconds": 12.397279483033344} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 120, "ler": 0.0012, "build_seconds": 0.0038107429863885045, "decode_seconds": 1.262787204934284} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 120, "ler": 0.0012, "build_seconds": 0.005702464026398957, "decode_seconds": 25.919863046961837} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 531, "ler": 0.00531, "build_seconds": 0.0036036160308867693, "decode_seconds": 9.057322234031744} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 878, "ler": 0.00878, "build_seconds": 0.006103449035435915, "decode_seconds": 21.981844869907945} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 531, "ler": 0.00531, "build_seconds": 0.006224778015166521, "decode_seconds": 18.28489999193698} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 531, "ler": 0.00531, "build_seconds": 0.0037976870080456138, "decode_seconds": 1.8186134689021856} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 531, "ler": 0.00531, "build_seconds": 0.005775927915237844, "decode_seconds": 42.00563556107227} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 576, "ler": 0.00576, "build_seconds": 0.0034860699670389295, "decode_seconds": 8.885354830999859} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 875, "ler": 0.00875, "build_seconds": 0.006064192042686045, "decode_seconds": 21.99228063202463} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 576, "ler": 0.00576, "build_seconds": 0.006546488031744957, "decode_seconds": 18.443165402975865} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 577, "ler": 0.00577, "build_seconds": 0.003976376028731465, "decode_seconds": 1.8343121459474787} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 578, "ler": 0.00578, "build_seconds": 0.005711263045668602, "decode_seconds": 42.36042152903974} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 509, "ler": 0.00509, "build_seconds": 0.003392826998606324, "decode_seconds": 8.768765309010632} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 872, "ler": 0.00872, "build_seconds": 0.005992374033667147, "decode_seconds": 21.8824789240025} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 509, "ler": 0.00509, "build_seconds": 0.006520363036543131, "decode_seconds": 18.35858091490809} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 510, "ler": 0.0051, "build_seconds": 0.003585992963053286, "decode_seconds": 1.739908825024031} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 510, "ler": 0.0051, "build_seconds": 0.005436684004962444, "decode_seconds": 42.01746710005682} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 2, "ler": 2e-05, "build_seconds": 0.01198889291845262, "decode_seconds": 12.00824419897981} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 14, "ler": 0.00014, "build_seconds": 0.0289649119367823, "decode_seconds": 83.20337125204969} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 2, "ler": 2e-05, "build_seconds": 0.028456886997446418, "decode_seconds": 41.73888665006962} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 2, "ler": 2e-05, "build_seconds": 0.012472887057811022, "decode_seconds": 2.5712421860080212} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 2, "ler": 2e-05, "build_seconds": 0.01786184194497764, "decode_seconds": 61.58867626695428} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 18, "ler": 0.00018, "build_seconds": 0.012055136961862445, "decode_seconds": 12.108281126944348} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 24, "ler": 0.00024, "build_seconds": 0.027866789023391902, "decode_seconds": 83.1739500790136} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 18, "ler": 0.00018, "build_seconds": 0.028778383042663336, "decode_seconds": 40.38781055691652} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 18, "ler": 0.00018, "build_seconds": 0.011742246919311583, "decode_seconds": 2.514463031082414} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 18, "ler": 0.00018, "build_seconds": 0.017679139971733093, "decode_seconds": 61.27467135200277} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 8, "ler": 8e-05, "build_seconds": 0.01150249200873077, "decode_seconds": 11.53979802003596} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 24, "ler": 0.00024, "build_seconds": 0.02652843203395605, "decode_seconds": 80.4659172029933} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 8, "ler": 8e-05, "build_seconds": 0.027839287999086082, "decode_seconds": 39.69481441995595} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 8, "ler": 8e-05, "build_seconds": 0.011705885990522802, "decode_seconds": 2.405015271040611} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 8, "ler": 8e-05, "build_seconds": 0.01666771702002734, "decode_seconds": 59.89127721195109} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 44, "ler": 0.00044, "build_seconds": 0.011185246054083109, "decode_seconds": 18.059501508949324} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 73, "ler": 0.00073, "build_seconds": 0.02570761798415333, "decode_seconds": 103.05704250000417} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 44, "ler": 0.00044, "build_seconds": 0.027981007006019354, "decode_seconds": 49.33750203391537} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 44, "ler": 0.00044, "build_seconds": 0.012218795018270612, "decode_seconds": 3.1121007680194452} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 45, "ler": 0.00045, "build_seconds": 0.017130585969425738, "decode_seconds": 88.9494686650578} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 45, "ler": 0.00045, "build_seconds": 0.011477769003249705, "decode_seconds": 17.931140725966543} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 82, "ler": 0.00082, "build_seconds": 0.025771027081646025, "decode_seconds": 100.77474934596103} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 45, "ler": 0.00045, "build_seconds": 0.02863633690867573, "decode_seconds": 49.05798025405966} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 46, "ler": 0.00046, "build_seconds": 0.011999138980172575, "decode_seconds": 3.068818107014522} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 45, "ler": 0.00045, "build_seconds": 0.016575157991610467, "decode_seconds": 88.56133615795989} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 40, "ler": 0.0004, "build_seconds": 0.011320184916257858, "decode_seconds": 18.141521485056728} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 78, "ler": 0.00078, "build_seconds": 0.02584795793518424, "decode_seconds": 101.07802409003489} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 40, "ler": 0.0004, "build_seconds": 0.029297174070961773, "decode_seconds": 48.16394220502116} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 40, "ler": 0.0004, "build_seconds": 0.011357437004335225, "decode_seconds": 3.0276183639653027} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 41, "ler": 0.00041, "build_seconds": 0.01649698195978999, "decode_seconds": 88.96721229003742} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 243, "ler": 0.00243, "build_seconds": 0.011295111034996808, "decode_seconds": 33.22787524398882} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 477, "ler": 0.00477, "build_seconds": 0.02625328896101564, "decode_seconds": 123.47734216309618} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 243, "ler": 0.00243, "build_seconds": 0.026949655963107944, "decode_seconds": 64.25777647399809} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 243, "ler": 0.00243, "build_seconds": 0.011809082003310323, "decode_seconds": 4.572257385938428} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 246, "ler": 0.00246, "build_seconds": 0.016887197038158774, "decode_seconds": 153.30489724094514} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 290, "ler": 0.0029, "build_seconds": 0.011330893030390143, "decode_seconds": 33.7523007580312} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 527, "ler": 0.00527, "build_seconds": 0.027414581971243024, "decode_seconds": 125.70785540400539} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 290, "ler": 0.0029, "build_seconds": 0.028182174894027412, "decode_seconds": 65.73475300311111} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 288, "ler": 0.00288, "build_seconds": 0.012029790901578963, "decode_seconds": 4.614306694013067} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 288, "ler": 0.00288, "build_seconds": 0.018439115956425667, "decode_seconds": 153.3892400059849} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 297, "ler": 0.00297, "build_seconds": 0.011190642951987684, "decode_seconds": 33.50160719000269} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 515, "ler": 0.00515, "build_seconds": 0.026636217022314668, "decode_seconds": 123.7905317270197} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 297, "ler": 0.00297, "build_seconds": 0.02799793507438153, "decode_seconds": 65.43267240794376} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 297, "ler": 0.00297, "build_seconds": 0.012005220050923526, "decode_seconds": 4.573971158941276} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 299, "ler": 0.00299, "build_seconds": 0.017136757029220462, "decode_seconds": 152.66755335300695} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 339, "ler": 0.00678, "build_seconds": 0.0027666680980473757, "decode_seconds": 1.2426382739795372} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 347, "ler": 0.00694, "build_seconds": 0.003978513996116817, "decode_seconds": 1.9058792720315978} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 341, "ler": 0.00682, "build_seconds": 0.0043051280081272125, "decode_seconds": 4.2842509569600224} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 361, "ler": 0.00722, "build_seconds": 0.0029481599340215325, "decode_seconds": 1.277993755065836} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 373, "ler": 0.00746, "build_seconds": 0.004023088025860488, "decode_seconds": 1.8912213450530544} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 362, "ler": 0.00724, "build_seconds": 0.0045119550777599216, "decode_seconds": 4.184288542019203} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 339, "ler": 0.00678, "build_seconds": 0.0025959149934351444, "decode_seconds": 1.2000236450694501} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 349, "ler": 0.00698, "build_seconds": 0.003879523021169007, "decode_seconds": 1.831096573965624} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 339, "ler": 0.00678, "build_seconds": 0.004237468005158007, "decode_seconds": 4.154158256016672} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 695, "ler": 0.0139, "build_seconds": 0.0026430010329931974, "decode_seconds": 1.7629588880809024} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 723, "ler": 0.01446, "build_seconds": 0.003869635984301567, "decode_seconds": 2.6145379460649565} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 694, "ler": 0.01388, "build_seconds": 0.004219696973450482, "decode_seconds": 5.9179590420098975} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 736, "ler": 0.01472, "build_seconds": 0.002717138035222888, "decode_seconds": 1.831788704963401} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 760, "ler": 0.0152, "build_seconds": 0.004061102983541787, "decode_seconds": 2.6863492289558053} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 739, "ler": 0.01478, "build_seconds": 0.004333141027018428, "decode_seconds": 6.091107173007913} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 727, "ler": 0.01454, "build_seconds": 0.0027309979777783155, "decode_seconds": 1.8216422849800438} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 734, "ler": 0.01468, "build_seconds": 0.003968781093135476, "decode_seconds": 2.7072006149683148} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 726, "ler": 0.01452, "build_seconds": 0.004328525043092668, "decode_seconds": 5.964680672972463} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1900, "ler": 0.038, "build_seconds": 0.0027084420435130596, "decode_seconds": 3.0450374559732154} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1933, "ler": 0.03866, "build_seconds": 0.004028124967589974, "decode_seconds": 4.224082892993465} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 1922, "ler": 0.03844, "build_seconds": 0.004390742047689855, "decode_seconds": 8.884535679011606} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1924, "ler": 0.03848, "build_seconds": 0.0029172690119594336, "decode_seconds": 3.0538512399652973} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1959, "ler": 0.03918, "build_seconds": 0.004021001048386097, "decode_seconds": 4.20720031298697} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 1936, "ler": 0.03872, "build_seconds": 0.0043352419743314385, "decode_seconds": 8.902371066971682} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1887, "ler": 0.03774, "build_seconds": 0.002712189918383956, "decode_seconds": 3.042711588088423} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1911, "ler": 0.03822, "build_seconds": 0.0040241440292447805, "decode_seconds": 4.225115368957631} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 1872, "ler": 0.03744, "build_seconds": 0.004651825060136616, "decode_seconds": 8.91394612903241} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 101, "ler": 0.00202, "build_seconds": 0.017809053068049252, "decode_seconds": 10.11937194596976} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 202, "ler": 0.00404, "build_seconds": 0.040036563063040376, "decode_seconds": 56.461156163946725} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 101, "ler": 0.00202, "build_seconds": 0.041228037094697356, "decode_seconds": 31.25634205795359} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 105, "ler": 0.0021, "build_seconds": 0.017188239027746022, "decode_seconds": 9.704132576938719} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 188, "ler": 0.00376, "build_seconds": 0.0385180430021137, "decode_seconds": 55.710626682965085} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 105, "ler": 0.0021, "build_seconds": 0.042297302978113294, "decode_seconds": 31.37941742199473} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 88, "ler": 0.00176, "build_seconds": 0.01705125393345952, "decode_seconds": 9.60310909000691} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 166, "ler": 0.00332, "build_seconds": 0.039060045033693314, "decode_seconds": 56.601782400975935} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 88, "ler": 0.00176, "build_seconds": 0.04300876404158771, "decode_seconds": 32.065633705002256} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 342, "ler": 0.00684, "build_seconds": 0.017805316019803286, "decode_seconds": 15.855886935023591} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 543, "ler": 0.01086, "build_seconds": 0.04020364605821669, "decode_seconds": 75.02097634994425} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 342, "ler": 0.00684, "build_seconds": 0.04101130703929812, "decode_seconds": 39.980677679996006} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 308, "ler": 0.00616, "build_seconds": 0.019196123001165688, "decode_seconds": 15.783406346919946} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 530, "ler": 0.0106, "build_seconds": 0.03991413407493383, "decode_seconds": 74.85742094798479} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 308, "ler": 0.00616, "build_seconds": 0.04262624797411263, "decode_seconds": 40.36505612905603} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 360, "ler": 0.0072, "build_seconds": 0.017643426079303026, "decode_seconds": 15.781059438944794} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 568, "ler": 0.01136, "build_seconds": 0.040128167951479554, "decode_seconds": 72.37126195908058} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 360, "ler": 0.0072, "build_seconds": 0.042749025975354016, "decode_seconds": 40.39476935600396} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1419, "ler": 0.02838, "build_seconds": 0.017737832968123257, "decode_seconds": 28.877137659001164} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1997, "ler": 0.03994, "build_seconds": 0.038523137918673456, "decode_seconds": 92.57560077100061} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 1419, "ler": 0.02838, "build_seconds": 0.04114609200041741, "decode_seconds": 54.23134226095863} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1495, "ler": 0.0299, "build_seconds": 0.018085039919242263, "decode_seconds": 29.113411615020595} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 2000, "ler": 0.04, "build_seconds": 0.04000170307699591, "decode_seconds": 95.11584063793998} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 1495, "ler": 0.0299, "build_seconds": 0.04291122790891677, "decode_seconds": 54.54225459403824} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1402, "ler": 0.02804, "build_seconds": 0.017674515023827553, "decode_seconds": 28.85841549898032} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1970, "ler": 0.0394, "build_seconds": 0.040045556030236185, "decode_seconds": 94.39712701388635} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 1402, "ler": 0.02804, "build_seconds": 0.04084432800300419, "decode_seconds": 54.54200694710016} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 17, "ler": 0.00034, "build_seconds": 0.05726063495967537, "decode_seconds": 37.58960096305236} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 51, "ler": 0.00102, "build_seconds": 0.2170823459746316, "decode_seconds": 316.8492327040294} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 17, "ler": 0.00034, "build_seconds": 0.23379869293421507, "decode_seconds": 115.71196917700581} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 21, "ler": 0.00042, "build_seconds": 0.05516764707863331, "decode_seconds": 37.62585578695871} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 58, "ler": 0.00116, "build_seconds": 0.2244842719519511, "decode_seconds": 319.665760110016} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 21, "ler": 0.00042, "build_seconds": 0.23318979307077825, "decode_seconds": 116.37066508294083} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 21, "ler": 0.00042, "build_seconds": 0.056982876965776086, "decode_seconds": 37.65939962002449} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 50, "ler": 0.001, "build_seconds": 0.22406468004919589, "decode_seconds": 317.6037418489577} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 21, "ler": 0.00042, "build_seconds": 0.2336903209798038, "decode_seconds": 114.94639900908805} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 109, "ler": 0.00218, "build_seconds": 0.0555530849378556, "decode_seconds": 60.70033219899051} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 217, "ler": 0.00434, "build_seconds": 0.22634913795627654, "decode_seconds": 360.4764091570396} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 109, "ler": 0.00218, "build_seconds": 0.23468924802727997, "decode_seconds": 139.93496759200934} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 122, "ler": 0.00244, "build_seconds": 0.06277166202198714, "decode_seconds": 60.998767197015695} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 215, "ler": 0.0043, "build_seconds": 0.22652721300255507, "decode_seconds": 359.99144811194856} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 122, "ler": 0.00244, "build_seconds": 0.2339047659188509, "decode_seconds": 139.8659792370163} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 102, "ler": 0.00204, "build_seconds": 0.05706561403349042, "decode_seconds": 61.76871059194673} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 230, "ler": 0.0046, "build_seconds": 0.22566778305917978, "decode_seconds": 358.26359176193364} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 102, "ler": 0.00204, "build_seconds": 0.23369164892937988, "decode_seconds": 140.8752816610504} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 860, "ler": 0.0172, "build_seconds": 0.05626340501476079, "decode_seconds": 116.39792538096663} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1320, "ler": 0.0264, "build_seconds": 0.22628114593680948, "decode_seconds": 381.43563097610604} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 860, "ler": 0.0172, "build_seconds": 0.2335918080061674, "decode_seconds": 195.28046238503885} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 878, "ler": 0.01756, "build_seconds": 0.055321692023426294, "decode_seconds": 116.20386328105815} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1370, "ler": 0.0274, "build_seconds": 0.222290173987858, "decode_seconds": 382.33848310704343} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 878, "ler": 0.01756, "build_seconds": 0.2357181420084089, "decode_seconds": 196.46588010096457} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 898, "ler": 0.01796, "build_seconds": 0.058892372995615005, "decode_seconds": 117.09821905102581} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1361, "ler": 0.02722, "build_seconds": 0.226338843931444, "decode_seconds": 382.2485167060513} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 898, "ler": 0.01796, "build_seconds": 0.23742589203175157, "decode_seconds": 197.39164293906651} diff --git a/examples/surface/results/inner_decoder_study_threshold.jsonl b/examples/surface/results/inner_decoder_study_threshold.jsonl new file mode 100644 index 000000000..29010b06b --- /dev/null +++ b/examples/surface/results/inner_decoder_study_threshold.jsonl @@ -0,0 +1,72 @@ +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.004, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 241, "ler": 0.00482, "build_seconds": 0.000553303980268538, "decode_seconds": 0.41356655303388834} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.004, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 244, "ler": 0.00488, "build_seconds": 0.0007363270269706845, "decode_seconds": 0.35552097705658525} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.004, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 241, "ler": 0.00482, "build_seconds": 0.0006616739556193352, "decode_seconds": 0.19352870399598032} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 370, "ler": 0.0074, "build_seconds": 0.0005259430035948753, "decode_seconds": 0.5087154989596456} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 381, "ler": 0.00762, "build_seconds": 0.0007031150162220001, "decode_seconds": 0.42045116203371435} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 370, "ler": 0.0074, "build_seconds": 0.0005987919867038727, "decode_seconds": 0.21348594094160944} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.006, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 566, "ler": 0.01132, "build_seconds": 0.0005060170078650117, "decode_seconds": 0.6070600069360808} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.006, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 571, "ler": 0.01142, "build_seconds": 0.0008120449492707849, "decode_seconds": 0.4770000349963084} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.006, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 561, "ler": 0.01122, "build_seconds": 0.0006387030007317662, "decode_seconds": 0.2350762509740889} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.007, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 720, "ler": 0.0144, "build_seconds": 0.0005051549524068832, "decode_seconds": 0.7224671969888732} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.007, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 736, "ler": 0.01472, "build_seconds": 0.0007587990257889032, "decode_seconds": 0.5536875569960102} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.007, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 720, "ler": 0.0144, "build_seconds": 0.0006870540091767907, "decode_seconds": 0.2616626820527017} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.008, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 935, "ler": 0.0187, "build_seconds": 0.0005344880046322942, "decode_seconds": 0.8156614169711247} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.008, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 972, "ler": 0.01944, "build_seconds": 0.0007926550460979342, "decode_seconds": 0.6286538590211421} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.008, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 936, "ler": 0.01872, "build_seconds": 0.0006048210198059678, "decode_seconds": 0.282362152938731} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.009, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1177, "ler": 0.02354, "build_seconds": 0.0005155650433152914, "decode_seconds": 0.9224442170234397} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.009, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1203, "ler": 0.02406, "build_seconds": 0.0007628179155290127, "decode_seconds": 0.681600435054861} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.009, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1181, "ler": 0.02362, "build_seconds": 0.0007024700753390789, "decode_seconds": 0.30697357503231615} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.01, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1427, "ler": 0.02854, "build_seconds": 0.0005175839178264141, "decode_seconds": 1.0248573770513758} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.01, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1458, "ler": 0.02916, "build_seconds": 0.0008263279451057315, "decode_seconds": 0.7313043309841305} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.01, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1432, "ler": 0.02864, "build_seconds": 0.0006042990135028958, "decode_seconds": 0.33015464805066586} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.012, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1973, "ler": 0.03946, "build_seconds": 0.0005137789994478226, "decode_seconds": 1.2339211829239503} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.012, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 2017, "ler": 0.04034, "build_seconds": 0.0007613759953528643, "decode_seconds": 0.8449526780750602} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.012, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1969, "ler": 0.03938, "build_seconds": 0.0006781750125810504, "decode_seconds": 0.38146582897752523} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 136, "ler": 0.00272, "build_seconds": 0.003294265945442021, "decode_seconds": 3.3930116021074355} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 265, "ler": 0.0053, "build_seconds": 0.005730609060265124, "decode_seconds": 9.166729047894478} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 137, "ler": 0.00274, "build_seconds": 0.0036312530282884836, "decode_seconds": 0.7465279749594629} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 258, "ler": 0.00516, "build_seconds": 0.0033381300745531917, "decode_seconds": 4.302522073034197} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 451, "ler": 0.00902, "build_seconds": 0.005688800010830164, "decode_seconds": 10.679329155012965} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 259, "ler": 0.00518, "build_seconds": 0.0035879879724234343, "decode_seconds": 0.8774489300558344} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 443, "ler": 0.00886, "build_seconds": 0.003356348956003785, "decode_seconds": 5.301479918998666} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 689, "ler": 0.01378, "build_seconds": 0.005740055930800736, "decode_seconds": 12.126563983038068} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 443, "ler": 0.00886, "build_seconds": 0.0035532600013539195, "decode_seconds": 1.0043239099904895} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.007, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 682, "ler": 0.01364, "build_seconds": 0.0032987690065056086, "decode_seconds": 6.343367288005538} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.007, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 978, "ler": 0.01956, "build_seconds": 0.005682545015588403, "decode_seconds": 13.27012179105077} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.007, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 684, "ler": 0.01368, "build_seconds": 0.003600129042752087, "decode_seconds": 1.148259557900019} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1023, "ler": 0.02046, "build_seconds": 0.0033241230994462967, "decode_seconds": 7.480666225892492} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1384, "ler": 0.02768, "build_seconds": 0.005689051002264023, "decode_seconds": 14.328783028991893} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1021, "ler": 0.02042, "build_seconds": 0.0036275250604376197, "decode_seconds": 1.2811408209381625} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.009, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1385, "ler": 0.0277, "build_seconds": 0.00330440909601748, "decode_seconds": 8.675665647955611} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.009, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1845, "ler": 0.0369, "build_seconds": 0.005962638999335468, "decode_seconds": 15.303825345006771} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.009, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1391, "ler": 0.02782, "build_seconds": 0.003699503024108708, "decode_seconds": 1.4179591269930825} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.01, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1776, "ler": 0.03552, "build_seconds": 0.0033197120064869523, "decode_seconds": 9.890155210043304} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.01, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 2327, "ler": 0.04654, "build_seconds": 0.005759961088187993, "decode_seconds": 15.909984825993888} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.01, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1773, "ler": 0.03546, "build_seconds": 0.0034103929065167904, "decode_seconds": 1.5061595120932907} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.012, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 2749, "ler": 0.05498, "build_seconds": 0.0032265339978039265, "decode_seconds": 12.273728820029646} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.012, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 3452, "ler": 0.06904, "build_seconds": 0.005713721038773656, "decode_seconds": 17.117338757030666} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.012, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 2753, "ler": 0.05506, "build_seconds": 0.0035725990310311317, "decode_seconds": 1.8775908190291375} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.004, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 72, "ler": 0.00144, "build_seconds": 0.01094578206539154, "decode_seconds": 12.749464866006747} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.004, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 126, "ler": 0.00252, "build_seconds": 0.026317083975300193, "decode_seconds": 58.79540476400871} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.004, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 72, "ler": 0.00144, "build_seconds": 0.011541299987584352, "decode_seconds": 1.924756177002564} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 158, "ler": 0.00316, "build_seconds": 0.011019375058822334, "decode_seconds": 16.652649068040773} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 259, "ler": 0.00518, "build_seconds": 0.026470276061445475, "decode_seconds": 63.40331910492387} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 158, "ler": 0.00316, "build_seconds": 0.011802835972048342, "decode_seconds": 2.3041705150390044} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.006, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 295, "ler": 0.0059, "build_seconds": 0.011194723076187074, "decode_seconds": 20.679654374951497} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.006, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 482, "ler": 0.00964, "build_seconds": 0.0258271771017462, "decode_seconds": 65.64840204094071} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.006, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 296, "ler": 0.00592, "build_seconds": 0.011505651054903865, "decode_seconds": 2.7034300840459764} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.007, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 532, "ler": 0.01064, "build_seconds": 0.011078166076913476, "decode_seconds": 25.844283557962626} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.007, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 812, "ler": 0.01624, "build_seconds": 0.025456094066612422, "decode_seconds": 68.38099301594775} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.007, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 532, "ler": 0.01064, "build_seconds": 0.01105475495569408, "decode_seconds": 2.9668737499741837} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 841, "ler": 0.01682, "build_seconds": 0.010775579023174942, "decode_seconds": 31.2043084789766} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1248, "ler": 0.02496, "build_seconds": 0.02808927302248776, "decode_seconds": 69.41384809394367} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 840, "ler": 0.0168, "build_seconds": 0.011174535960890353, "decode_seconds": 3.4064176470274106} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.009, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1267, "ler": 0.02534, "build_seconds": 0.010881343041546643, "decode_seconds": 36.750298622995615} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.009, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1748, "ler": 0.03496, "build_seconds": 0.02647972700651735, "decode_seconds": 72.80723491904791} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.009, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1267, "ler": 0.02534, "build_seconds": 0.011633733054623008, "decode_seconds": 3.9785961559973657} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.01, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1846, "ler": 0.03692, "build_seconds": 0.011132820975035429, "decode_seconds": 42.67777827405371} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.01, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 2501, "ler": 0.05002, "build_seconds": 0.026440824032761157, "decode_seconds": 72.88538435997907} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.01, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1846, "ler": 0.03692, "build_seconds": 0.012233932968229055, "decode_seconds": 4.604135178029537} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.012, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 3286, "ler": 0.06572, "build_seconds": 0.011454413994215429, "decode_seconds": 55.201751724933274} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.012, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 4218, "ler": 0.08436, "build_seconds": 0.02668835991062224, "decode_seconds": 74.66283174906857} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.012, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 3288, "ler": 0.06576, "build_seconds": 0.012077316991053522, "decode_seconds": 5.50001355400309} diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index e4ef8ef52..caa04793f 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4476,23 +4476,28 @@ pub struct PyLogicalSubgraphDecoder { #[pymethods] impl PyLogicalSubgraphDecoder { - // Default inner is `fusion_blossom_serial`: a POLICY choice of accuracy-first - // exact MWPM, bundled by default. It is exact minimum-weight matching on each - // per-observable subgraph, so it cannot be beaten on that projected graph, and - // it ships with PECOS (no optional dependency to install). + // Default inner is `fusion_blossom_serial`: exact MWPM on each per-observable + // subgraph, bundled (no optional dependency). This is now backed by a powered + // threshold/CI study (`examples/surface/inner_decoder_study.py`; memory + + // transversal-CX, d=3/5/7, 3 seeds pooled = 150-300k shots/cell, Jeffreys + // intervals), NOT just policy: + // * Accuracy: fusion is statistically tied with pymatching/belief_matching/ + // tesseract (these per-observable DEMs are graphlike, so exact MWPM is + // optimal) and STRICTLY beats `pecos_uf:bp` at every d>=5 cell -- 1.4-2.7x + // lower LER with DISJOINT Jeffreys intervals, both families. Tied at d=3. + // * Threshold: fusion ~0.9% vs `pecos_uf:bp` ~0.7% (bp also breaks down sooner). + // * Speed: at d=7 bp's grow+peel blows up (CX 7.1ms/shot vs fusion 1.2ms); + // "bp is the fast native one" is false at depth. + // Only `pymatching` is faster (~6x) but it is an EXTERNAL dep with zero accuracy + // or threshold gain, so it is the documented speed option, not the default. + // SCOPE: the study families are graphlike, so it does not distinguish fusion + // from hyperedge decoders (tesseract/mwpf) -- re-run with those if non-graphlike + // per-observable DEMs (biased/correlated noise) ever arise. See + // pecos-docs/design/inner-decoder-threshold-study.md. // - // A spot benchmark (memory + transversal-CX, d=3..7, p=0.001, n=30k, SINGLE - // SEED) is consistent with this: fusion is at least as accurate as the native - // `pecos_uf:bp` everywhere and markedly faster at depth (e.g. d=7 transversal-CX - // ~1.9s vs ~12.5s). But that table is UNDER-POWERED to prove a long-term - // default -- single seed, single p, point LERs with overlapping confidence - // intervals. Treat it as supporting evidence, not proof; a proper threshold/CI - // sweep is a tracked benchmark TODO. The default rests on the policy above. - // - // `pecos_uf:bp` remains the right pick for the pure-native, dependency-free - // path (it does achieve distance suppression -- the predecoder bug that broke - // it at d>=5 is fixed). `belief_matching` matched fusion's accuracy in the spot - // benchmark but was slower. See + // `pecos_uf:bp` remains the pure-native, dependency-free path (it does suppress + // with distance -- the predecoder bug that broke it at d>=5 is fixed -- just at + // a worse prefactor and lower threshold). See // pecos-docs/design/lomatching-paper-additional-learnings.md and // logical-subgraph-backprop-region-builder.md. #[new] From 4c5a546d7e2ec682d8a15812ca12df56339fcd88 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 8 Jun 2026 07:25:35 -0600 Subject: [PATCH 043/150] Add hyperedge-regime phase closing the threshold study's graphlike caveat: hyperedge-aware inners tie fusion shot-for-shot --- examples/surface/inner_decoder_study.py | 33 ++++++++++++++- .../inner_decoder_study_hyperedge.jsonl | 40 +++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 examples/surface/results/inner_decoder_study_hyperedge.jsonl diff --git a/examples/surface/inner_decoder_study.py b/examples/surface/inner_decoder_study.py index c3baaaf2a..bbe281f00 100644 --- a/examples/surface/inner_decoder_study.py +++ b/examples/surface/inner_decoder_study.py @@ -242,6 +242,35 @@ def run_threshold(path: Path) -> None: ) +def run_hyperedge(path: Path) -> None: + """Closes the graphlike-scope caveat: PECOS full DEMs are genuinely + non-graphlike (weight-8 hyperedges, ~70% of CX errors weight>=3), so test + whether hyperedge-aware inners beat plain MWPM on the per-observable-subgraph + path NEAR threshold (where the 2026-04-24 audit saw a 23% hyperedge effect on + the full memory DEM). If they merely tie, the LogicalSubgraphDecoder default + is optimal even in the hyperedge regime, not just on graphlike DEMs.""" + done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} + inners = ["fusion_blossom_serial", "pymatching", "tesseract", "belief_matching", "belief_matching_correlated"] + plan = [ + ("memory", 5, [0.006, 0.008], [1, 2], 30_000), + ("memory", 7, [0.008], [1, 2], 20_000), + ("cx", 5, [0.004], [1, 2], 30_000), + ] + for family, d, ps, seeds, n in plan: + for p in ps: + for seed in seeds: + todo = [i for i in inners if (family, d, p, seed, i) not in done] + if not todo: + continue + cells = measure_cell(family, d, d, p, seed, todo, n) + _append(path, cells) + print( + f"[hyperedge] {family:6s} d={d} p={p:.3f} seed={seed}: " + + " ".join(f"{c.inner.split('_')[0][:6]}={c.num_errors}" for c in cells), + flush=True, + ) + + def run_speed(path: Path) -> None: """Per-shot decode throughput (build vs decode) at the costly d=7 point.""" done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} @@ -415,7 +444,7 @@ def w(s: str = "") -> None: def main() -> None: ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("--phase", required=True, choices=["suppress", "threshold", "speed", "analyze", "smoke"]) + ap.add_argument("--phase", required=True, choices=["suppress", "threshold", "hyperedge", "speed", "analyze", "smoke"]) ap.add_argument("--out", type=Path, default=RESULTS_DIR) args = ap.parse_args() @@ -437,7 +466,7 @@ def main() -> None: return path = args.out / f"inner_decoder_study_{args.phase}.jsonl" - {"suppress": run_suppress, "threshold": run_threshold, "speed": run_speed}[args.phase](path) + {"suppress": run_suppress, "threshold": run_threshold, "hyperedge": run_hyperedge, "speed": run_speed}[args.phase](path) print(f"[done] {args.phase} -> {path}", flush=True) diff --git a/examples/surface/results/inner_decoder_study_hyperedge.jsonl b/examples/surface/results/inner_decoder_study_hyperedge.jsonl new file mode 100644 index 000000000..d8b94fe04 --- /dev/null +++ b/examples/surface/results/inner_decoder_study_hyperedge.jsonl @@ -0,0 +1,40 @@ +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 30000, "num_errors": 271, "ler": 0.009033333333333334, "build_seconds": 0.003566185012459755, "decode_seconds": 3.37455288390629} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "pymatching", "num_shots": 30000, "num_errors": 271, "ler": 0.009033333333333334, "build_seconds": 0.0038856130558997393, "decode_seconds": 0.6466185129247606} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "tesseract", "num_shots": 30000, "num_errors": 271, "ler": 0.009033333333333334, "build_seconds": 0.005911809974350035, "decode_seconds": 15.393886688980274} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "belief_matching", "num_shots": 30000, "num_errors": 271, "ler": 0.009033333333333334, "build_seconds": 0.0071361170848831534, "decode_seconds": 6.31418666895479} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "belief_matching_correlated", "num_shots": 30000, "num_errors": 271, "ler": 0.009033333333333334, "build_seconds": 0.0071890190010890365, "decode_seconds": 6.367211817996576} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 30000, "num_errors": 260, "ler": 0.008666666666666666, "build_seconds": 0.0032669759821146727, "decode_seconds": 3.3683419070439413} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 2, "inner": "pymatching", "num_shots": 30000, "num_errors": 260, "ler": 0.008666666666666666, "build_seconds": 0.0037438629660755396, "decode_seconds": 0.6292870710603893} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 2, "inner": "tesseract", "num_shots": 30000, "num_errors": 260, "ler": 0.008666666666666666, "build_seconds": 0.005560801015235484, "decode_seconds": 14.951133309979923} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 2, "inner": "belief_matching", "num_shots": 30000, "num_errors": 260, "ler": 0.008666666666666666, "build_seconds": 0.006299200002104044, "decode_seconds": 6.250429176958278} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 2, "inner": "belief_matching_correlated", "num_shots": 30000, "num_errors": 260, "ler": 0.008666666666666666, "build_seconds": 0.00739661802072078, "decode_seconds": 6.481872584903613} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 30000, "num_errors": 564, "ler": 0.0188, "build_seconds": 0.0032418479677289724, "decode_seconds": 4.569911486003548} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "pymatching", "num_shots": 30000, "num_errors": 564, "ler": 0.0188, "build_seconds": 0.003733533900231123, "decode_seconds": 0.7791196580510587} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "tesseract", "num_shots": 30000, "num_errors": 564, "ler": 0.0188, "build_seconds": 0.00560479296837002, "decode_seconds": 19.93792283802759} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "belief_matching", "num_shots": 30000, "num_errors": 564, "ler": 0.0188, "build_seconds": 0.0068061730125918984, "decode_seconds": 7.923268544021994} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "belief_matching_correlated", "num_shots": 30000, "num_errors": 564, "ler": 0.0188, "build_seconds": 0.007016985095106065, "decode_seconds": 7.727108254912309} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 30000, "num_errors": 587, "ler": 0.019566666666666666, "build_seconds": 0.0034840760054066777, "decode_seconds": 4.617438982008025} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 2, "inner": "pymatching", "num_shots": 30000, "num_errors": 588, "ler": 0.0196, "build_seconds": 0.003798080957494676, "decode_seconds": 0.7727671850007027} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 2, "inner": "tesseract", "num_shots": 30000, "num_errors": 589, "ler": 0.019633333333333332, "build_seconds": 0.005618234979920089, "decode_seconds": 20.43235256697517} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 2, "inner": "belief_matching", "num_shots": 30000, "num_errors": 587, "ler": 0.019566666666666666, "build_seconds": 0.006501637981273234, "decode_seconds": 8.125532574020326} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 2, "inner": "belief_matching_correlated", "num_shots": 30000, "num_errors": 587, "ler": 0.019566666666666666, "build_seconds": 0.007528997026383877, "decode_seconds": 8.493251777952537} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 20000, "num_errors": 371, "ler": 0.01855, "build_seconds": 0.012017961009405553, "decode_seconds": 13.148979397956282} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "pymatching", "num_shots": 20000, "num_errors": 374, "ler": 0.0187, "build_seconds": 0.01236077700741589, "decode_seconds": 1.443247208953835} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "tesseract", "num_shots": 20000, "num_errors": 377, "ler": 0.01885, "build_seconds": 0.017561979009769857, "decode_seconds": 58.6753382619936} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "belief_matching", "num_shots": 20000, "num_errors": 371, "ler": 0.01855, "build_seconds": 0.030035778996534646, "decode_seconds": 20.313708811998367} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "belief_matching_correlated", "num_shots": 20000, "num_errors": 371, "ler": 0.01855, "build_seconds": 0.03070830798242241, "decode_seconds": 20.43584023998119} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 20000, "num_errors": 333, "ler": 0.01665, "build_seconds": 0.011728838086128235, "decode_seconds": 12.78964776895009} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 2, "inner": "pymatching", "num_shots": 20000, "num_errors": 333, "ler": 0.01665, "build_seconds": 0.012101199012249708, "decode_seconds": 1.4187101080315188} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 2, "inner": "tesseract", "num_shots": 20000, "num_errors": 335, "ler": 0.01675, "build_seconds": 0.01718469092156738, "decode_seconds": 55.972942068008706} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 2, "inner": "belief_matching", "num_shots": 20000, "num_errors": 333, "ler": 0.01665, "build_seconds": 0.029899114975705743, "decode_seconds": 20.612918598926626} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 2, "inner": "belief_matching_correlated", "num_shots": 20000, "num_errors": 333, "ler": 0.01665, "build_seconds": 0.03118286095559597, "decode_seconds": 20.239125563995913} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 30000, "num_errors": 458, "ler": 0.015266666666666666, "build_seconds": 0.019938747980631888, "decode_seconds": 14.27238380908966} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "pymatching", "num_shots": 30000, "num_errors": 458, "ler": 0.015266666666666666, "build_seconds": 0.01992121199145913, "decode_seconds": 2.0521467130165547} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "tesseract", "num_shots": 30000, "num_errors": 458, "ler": 0.015266666666666666, "build_seconds": 0.030081987963058054, "decode_seconds": 58.16961134807207} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "belief_matching", "num_shots": 30000, "num_errors": 458, "ler": 0.015266666666666666, "build_seconds": 0.047197493026033044, "decode_seconds": 30.490634263958782} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "belief_matching_correlated", "num_shots": 30000, "num_errors": 458, "ler": 0.015266666666666666, "build_seconds": 0.04685803898610175, "decode_seconds": 30.193152333027683} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 30000, "num_errors": 438, "ler": 0.0146, "build_seconds": 0.018612609012052417, "decode_seconds": 13.599516084999777} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 2, "inner": "pymatching", "num_shots": 30000, "num_errors": 438, "ler": 0.0146, "build_seconds": 0.01944837300106883, "decode_seconds": 1.9902514149434865} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 2, "inner": "tesseract", "num_shots": 30000, "num_errors": 439, "ler": 0.014633333333333333, "build_seconds": 0.029277449008077383, "decode_seconds": 58.933918961905874} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 2, "inner": "belief_matching", "num_shots": 30000, "num_errors": 438, "ler": 0.0146, "build_seconds": 0.04867564095184207, "decode_seconds": 30.084009875077754} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 2, "inner": "belief_matching_correlated", "num_shots": 30000, "num_errors": 438, "ler": 0.0146, "build_seconds": 0.04650826100260019, "decode_seconds": 30.7477054920746} From 6da12ffefececb7cdbae5c2c8b0f84e8128e974a Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 00:31:57 -0600 Subject: [PATCH 044/150] Tighten surface DEM result handling --- .../src/fault_tolerance/dem_builder.rs | 10 +- .../fault_tolerance/dem_builder/builder.rs | 249 +- .../src/fault_tolerance/dem_builder/types.rs | 2102 +++++++++++++++-- python/pecos-rslib/src/decoder_bindings.rs | 16 +- .../src/fault_tolerance_bindings.rs | 48 +- .../quantum-pecos/src/pecos/guppy/surface.py | 129 +- python/quantum-pecos/src/pecos/qec/dem.py | 10 +- .../src/pecos/qec/surface/circuit_builder.py | 358 ++- .../src/pecos/qec/surface/decode.py | 370 ++- .../pecos/decoders/test_decoder_bindings.py | 10 + .../tests/qec/surface/test_surface_decoder.py | 102 +- .../qec/surface/test_surface_metadata.py | 40 +- .../tests/qec/test_from_guppy_dem.py | 90 +- .../qec/test_traced_qis_clifford_pipeline.py | 29 +- 14 files changed, 3244 insertions(+), 319 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs index f96ef6633..391dcd7b5 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs @@ -46,10 +46,12 @@ //! //! # Error Decomposition //! -//! When using decomposed DEM output, hyperedge errors (affecting 3+ -//! detectors) are decomposed into combinations of graphlike errors (affecting -//! 1-2 detectors). This is necessary for MWPM decoders which only work on -//! graphs, not hypergraphs. +//! When using decomposed DEM output, PECOS decomposes only through component +//! structure carried by the original fault source (for example `Y = X ^ Z` or +//! recorded per-location components for multi-qubit sources). Residual +//! hyperedges remain hyperedges. Graphlike decoders should consume this output +//! only after checking that no residual hyperedge components remain; hypergraph +//! decoders can consume the faithful hypergraph model directly. //! //! # Comparison with Python Implementation //! 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 20428e3e7..1125a0af9 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -1215,9 +1215,12 @@ impl<'a> DemBuilder<'a> { .expect("exact_branch_replay replacement Pauli was validated"); let (p1, p2) = two_qubit_label_to_pauli_indices(&label) .expect("exact_branch_replay label must be a two-qubit Pauli"); - let effect = self - .exact_replacement_branch_effect(context, request, &label) + let analysis = self + .exact_branch_analysis(context, request) .expect("exact_branch_replay effect was validated"); + let base_effect = analysis.base_effect.clone(); + let branch_pauli_effect = analysis.branch_effects[p1 as usize][p2 as usize].clone(); + let effect = base_effect.xor(&branch_pauli_effect); if effect.is_empty() { continue; } @@ -1226,7 +1229,7 @@ impl<'a> DemBuilder<'a> { let source_paulis = [Pauli::from_u8(p1), Pauli::from_u8(p2)]; let source_gate_types = [loc1_meta.gate_type, loc2_meta.gate_type]; let source_before_flags = [loc1_meta.before, loc2_meta.before]; - dem.add_direct_contribution_with_source( + dem.add_direct_contribution_with_source_components( effect, self.noise.p2 * *relative_probability, SourceMetadata::new( @@ -1237,6 +1240,7 @@ impl<'a> DemBuilder<'a> { ) .with_direct_source_family(DirectSourceFamily::TwoLocationExactReplacementBranch) .with_replacement_branch(), + DirectSourceComponents::new(&base_effect, &branch_pauli_effect), ); } } @@ -1504,6 +1508,26 @@ impl<'a> DemBuilder<'a> { let source_gate_types = [loc1_meta.gate_type, loc2_meta.gate_type]; let source_before_flags = [loc1_meta.before, loc2_meta.before]; + let source_frame_components = if direct_source_family.is_none() { + Self::two_qubit_clifford_source_frame_components(loc1_meta.gate_type, p1, p2, effects) + } else { + None + }; + if let Some(parts) = source_frame_components.as_ref() { + dem.add_direct_contribution_with_source_components( + effect.clone(), + prob, + SourceMetadata::new( + &source_locations, + &source_paulis, + &source_gate_types, + &source_before_flags, + ), + DirectSourceComponents::from_slice(parts.as_slice()), + ); + return; + } + if let Some((a1, a2, b1, b2)) = get_y_decomposition(p1, p2) { let e_a = &effects[a1 as usize][a2 as usize]; let e_b = &effects[b1 as usize][b2 as usize]; @@ -1529,6 +1553,7 @@ impl<'a> DemBuilder<'a> { .with_direct_source_family(family) .with_replacement_branch(); } + dem.add_direct_contribution_with_source_components( effect.clone(), prob, @@ -1538,6 +1563,47 @@ impl<'a> DemBuilder<'a> { } } + /// Builds exact source-frame components for ordinary post-gate Pauli noise + /// on supported two-qubit Clifford gates. + /// + /// A post-gate Pauli can be pulled back through the Clifford into a pre-gate + /// Pauli. Decomposing that pre-gate Pauli into X/Z generators often exposes + /// the graphlike source pieces that were hidden by the native gate frame. + /// Each generator is then pushed forward again and looked up in the existing + /// post-gate effect table, so the XOR of returned components is exactly the + /// original post-gate effect. + fn two_qubit_clifford_source_frame_components( + gate_type: GateType, + post_p1: u8, + post_p2: u8, + effects: &[[FaultMechanism; 4]; 4], + ) -> Option> { + let images = two_qubit_pre_generator_post_images(gate_type)?; + let (pre_p1, pre_p2) = invert_two_qubit_clifford_post_pauli(images, (post_p1, post_p2))?; + + let mut components = SmallVec::new(); + for image in two_qubit_pre_pauli_generator_images(images, pre_p1, pre_p2) { + toggle_source_component( + &mut components, + effects[image.0 as usize][image.1 as usize].clone(), + ); + } + + if components.is_empty() { + return None; + } + + #[cfg(debug_assertions)] + { + let combined = components + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); + debug_assert_eq!(combined, effects[post_p1 as usize][post_p2 as usize]); + } + + Some(components) + } + /// Builds mappings from measurement indices to detector/DEM-output IDs. /// /// When `measurement_order` is provided, this properly maps between @@ -1850,6 +1916,133 @@ fn per_channel_probability(total_prob: f64, num_channels: u32) -> f64 { // Intra-Channel Decomposition // ============================================================================ +type TwoQubitPauli = (u8, u8); +type TwoQubitGeneratorImages = [TwoQubitPauli; 4]; + +/// Returns post-gate images of the pre-gate generators +/// `[X1, Z1, X2, Z2]`, ignoring phase. +#[inline] +fn two_qubit_pre_generator_post_images(gate_type: GateType) -> Option { + match gate_type { + GateType::CX => Some([ + (1, 1), // X1 -> XX + (3, 0), // Z1 -> ZI + (0, 1), // X2 -> IX + (3, 3), // Z2 -> ZZ + ]), + GateType::CZ => Some([ + (1, 3), // X1 -> XZ + (3, 0), // Z1 -> ZI + (3, 1), // X2 -> ZX + (0, 3), // Z2 -> IZ + ]), + GateType::SZZ | GateType::SZZdg => Some([ + (2, 3), // X1 -> YZ + (3, 0), // Z1 -> ZI + (3, 2), // X2 -> ZY + (0, 3), // Z2 -> IZ + ]), + _ => None, + } +} + +#[inline] +fn invert_two_qubit_clifford_post_pauli( + images: TwoQubitGeneratorImages, + post: TwoQubitPauli, +) -> Option { + for pre_p1 in 0..4 { + for pre_p2 in 0..4 { + if forward_two_qubit_pauli(images, pre_p1, pre_p2) == post { + return Some((pre_p1, pre_p2)); + } + } + } + None +} + +fn two_qubit_pre_pauli_generator_images( + images: TwoQubitGeneratorImages, + pre_p1: u8, + pre_p2: u8, +) -> SmallVec<[TwoQubitPauli; 4]> { + let mut out = SmallVec::new(); + if pauli_has_x(pre_p1) { + out.push(images[0]); + } + if pauli_has_z(pre_p1) { + out.push(images[1]); + } + if pauli_has_x(pre_p2) { + out.push(images[2]); + } + if pauli_has_z(pre_p2) { + out.push(images[3]); + } + out +} + +#[inline] +fn forward_two_qubit_pauli( + images: TwoQubitGeneratorImages, + pre_p1: u8, + pre_p2: u8, +) -> TwoQubitPauli { + two_qubit_pre_pauli_generator_images(images, pre_p1, pre_p2) + .into_iter() + .fold((0, 0), xor_two_qubit_pauli) +} + +#[inline] +fn xor_two_qubit_pauli(a: TwoQubitPauli, b: TwoQubitPauli) -> TwoQubitPauli { + (xor_pauli(a.0, b.0), xor_pauli(a.1, b.1)) +} + +#[inline] +fn xor_pauli(a: u8, b: u8) -> u8 { + pauli_from_bits( + pauli_has_x(a) ^ pauli_has_x(b), + pauli_has_z(a) ^ pauli_has_z(b), + ) +} + +#[inline] +fn pauli_has_x(pauli: u8) -> bool { + matches!(pauli, 1 | 2) +} + +#[inline] +fn pauli_has_z(pauli: u8) -> bool { + matches!(pauli, 2 | 3) +} + +#[inline] +fn pauli_from_bits(has_x: bool, has_z: bool) -> u8 { + match (has_x, has_z) { + (false, false) => 0, + (true, false) => 1, + (true, true) => 2, + (false, true) => 3, + } +} + +fn toggle_source_component( + components: &mut SmallVec<[FaultMechanism; 4]>, + component: FaultMechanism, +) { + if component.is_empty() { + return; + } + if let Some(index) = components + .iter() + .position(|existing| existing == &component) + { + components.remove(index); + } else { + components.push(component); + } +} + /// Returns the intra-channel decomposition for Y-containing Pauli cases. /// /// For any two-qubit Pauli case (p1, p2) that contains Y, returns the @@ -2792,6 +2985,47 @@ impl std::error::Error for DemBuilderError {} mod tests { use super::*; + #[test] + fn test_szz_source_frame_components_pull_post_error_to_pre_generators() { + fn dets(indices: &[u32]) -> FaultMechanism { + FaultMechanism::from_unsorted(indices.iter().copied(), std::iter::empty()) + } + + let a = dets(&[0, 1]); + let b = dets(&[2]); + let c = dets(&[3, 4]); + + let mut effects: [[FaultMechanism; 4]; 4] = Default::default(); + effects[2][3] = a.clone(); // SZZ maps pre X1 to post YZ. + effects[3][0] = b.clone(); // SZZ maps pre Z1 to post ZI. + effects[0][3] = c.clone(); // SZZ maps pre Z2 to post IZ. + effects[1][0] = a.xor(&b).xor(&c); + + let parts = + DemBuilder::two_qubit_clifford_source_frame_components(GateType::SZZ, 1, 0, &effects) + .expect("post XI should pull back through SZZ to pre YZ"); + + assert_eq!(parts.len(), 3); + assert!(parts.contains(&a)); + assert!(parts.contains(&b)); + assert!(parts.contains(&c)); + assert_eq!( + parts + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)), + effects[1][0] + ); + + effects[3][0] = a.clone(); + effects[1][0] = c.clone(); + + let parts = + DemBuilder::two_qubit_clifford_source_frame_components(GateType::SZZ, 1, 0, &effects) + .expect("duplicate source components should cancel by XOR"); + + assert_eq!(parts.as_slice(), &[c]); + } + #[test] fn test_from_circuit_tracks_tracked_pauli() { use pecos_core::pauli::X; @@ -3463,6 +3697,15 @@ mod tests { .all(|pauli| *pauli == Pauli::I), "omission-only replacement branch should be recorded as *II" ); + let (base_effect, branch_pauli_effect) = contributions[0] + .direct_component_effects() + .expect("exact branch replay should preserve base/branch components"); + assert_eq!( + base_effect.xor(&branch_pauli_effect), + contributions[0].effect + ); + assert_eq!(base_effect.detectors.as_slice(), &[0]); + assert!(branch_pauli_effect.is_empty()); } #[test] 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 91cf82cce..866800dab 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -39,10 +39,11 @@ //! - [`DetectorErrorModel::to_string()`] - Non-decomposed format. Each //! mechanism is output once with its combined probability. //! -//! - [`DetectorErrorModel::to_string_decomposed()`] - Decomposed format. -//! Hyperedge errors (3+ detectors) are decomposed into graphlike components, -//! and 2-detector mechanisms may have multiple representations for decoder -//! compatibility. +//! - [`DetectorErrorModel::to_string_decomposed()`] - Source-decomposed format. +//! Faults are decomposed only using component structure carried by the source +//! contribution itself. Residual hyperedges stay hyperedges so hypergraph +//! decoders see the faithful model and graphlike decoders can reject them +//! loudly. //! //! Decomposed errors use the `^` separator to indicate XOR composition: //! @@ -50,15 +51,16 @@ //! error(0.01) D0 D1 ^ D2 D3 //! ``` //! -//! This indicates an error decomposed into two parts whose XOR equals the -//! original mechanism. +//! This indicates one correlated source event decomposed into two source +//! components whose XOR equals the original mechanism. Components may still be +//! hyperedges if the physical source component flips 3+ detectors. use pecos_core::PauliString; use pecos_core::gate_type::GateType; use rand::RngExt; use smallvec::SmallVec; use std::cmp::Ordering; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::fmt; use std::hash::{Hash, Hasher}; @@ -168,13 +170,20 @@ pub struct FaultContribution { /// Coarse direct-source family for read-only analysis. pub direct_source_family: Option, - /// Optional per-location component effects for direct multi-location sources. + /// Optional legacy per-location component effects for direct multi-location sources. /// /// These are builder-time component effects whose XOR equals `effect`. They are - /// currently recorded for direct two-qubit channel sources to aid decomposition - /// analysis without changing emitted DEM behavior. + /// kept for the original two-component diagnostics and Python bindings. pub direct_component_effects: Option<(FaultMechanism, FaultMechanism)>, + /// Optional source-frame component effects for direct multi-location sources. + /// + /// These are builder-time component effects whose XOR equals `effect`. Unlike + /// `direct_component_effects`, this can carry more than two pieces, which is + /// needed when a native two-qubit Clifford's exact source frame is generated + /// by multiple Pauli generators. + pub source_component_effects: Option>, + /// True when this contribution came from a replacement branch rather than /// ordinary post-gate Pauli noise. pub replacement_branch: bool, @@ -218,15 +227,26 @@ impl<'a, Index> SourceMetadata<'a, Index> { } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub(crate) struct DirectSourceComponents<'a> { - first: &'a FaultMechanism, - second: &'a FaultMechanism, + components: SmallVec<[&'a FaultMechanism; 4]>, } impl<'a> DirectSourceComponents<'a> { - pub(crate) const fn new(first: &'a FaultMechanism, second: &'a FaultMechanism) -> Self { - Self { first, second } + pub(crate) fn new(first: &'a FaultMechanism, second: &'a FaultMechanism) -> Self { + Self { + components: smallvec::smallvec![first, second], + } + } + + pub(crate) fn from_slice(components: &'a [FaultMechanism]) -> Self { + Self { + components: components.iter().collect(), + } + } + + fn as_slice(&self) -> &[&'a FaultMechanism] { + &self.components } } @@ -234,7 +254,7 @@ impl FaultContribution { fn classify_direct_source_family( location_indices: &[u32], paulis: &[Pauli], - direct_component_effects: Option<(&FaultMechanism, &FaultMechanism)>, + direct_component_effects: Option<&[&FaultMechanism]>, ) -> Option { if location_indices.is_empty() { return None; @@ -249,8 +269,12 @@ impl FaultContribution { DirectSourceFamily::SingleLocation }), 2 => { - if let Some((first, second)) = direct_component_effects { - if first.is_empty() ^ second.is_empty() { + if let Some(components) = direct_component_effects { + let non_empty = components + .iter() + .filter(|component| !component.is_empty()) + .count(); + if components.len() == 2 && non_empty == 1 { Some(DirectSourceFamily::TwoLocationOneSidedComponent) } else { Some(DirectSourceFamily::TwoLocationComponent) @@ -278,6 +302,7 @@ impl FaultContribution { source_before_flags: SmallVec::new(), direct_source_family: None, direct_component_effects: None, + source_component_effects: None, replacement_branch: false, } } @@ -304,6 +329,7 @@ impl FaultContribution { Self::classify_direct_source_family(source.location_indices, source.paulis, None) }), direct_component_effects: None, + source_component_effects: None, replacement_branch: source.replacement_branch, } } @@ -320,13 +346,26 @@ impl FaultContribution { debug_assert_eq!(source.location_indices.len(), source.paulis.len()); debug_assert_eq!(source.location_indices.len(), source.gate_types.len()); debug_assert_eq!(source.location_indices.len(), source.before_flags.len()); - let source_type = if (components.first == &effect && components.second.is_empty()) - || (components.second == &effect && components.first.is_empty()) - { + let component_refs = components.as_slice(); + let non_empty_components: SmallVec<[&FaultMechanism; 4]> = component_refs + .iter() + .copied() + .filter(|component| !component.is_empty()) + .collect(); + let source_type = if non_empty_components.len() == 1 && non_empty_components[0] == &effect { FaultSourceType::DirectOneSidedComponent } else { FaultSourceType::Direct }; + let direct_component_effects = if let [first, second] = component_refs { + Some(((*first).clone(), (*second).clone())) + } else { + None + }; + let source_component_effects = component_refs + .iter() + .map(|component| (*component).clone()) + .collect(); Self { effect, probability, @@ -339,10 +378,11 @@ impl FaultContribution { Self::classify_direct_source_family( source.location_indices, source.paulis, - Some((components.first, components.second)), + Some(component_refs), ) }), - direct_component_effects: Some((components.first.clone(), components.second.clone())), + direct_component_effects, + source_component_effects: Some(source_component_effects), replacement_branch: source.replacement_branch, } } @@ -373,6 +413,7 @@ impl FaultContribution { source_before_flags: SmallVec::new(), direct_source_family: None, direct_component_effects: None, + source_component_effects: None, replacement_branch: false, } } @@ -404,6 +445,7 @@ impl FaultContribution { source_before_flags: source.before_flags.iter().copied().collect(), direct_source_family: None, direct_component_effects: None, + source_component_effects: None, replacement_branch: source.replacement_branch, } } @@ -440,6 +482,12 @@ impl FaultContribution { pub fn direct_component_effects(&self) -> Option<(FaultMechanism, FaultMechanism)> { self.direct_component_effects.clone() } + + /// Returns source-frame component effects for a direct multi-location source. + #[must_use] + pub fn source_component_effects(&self) -> Option> { + self.source_component_effects.clone() + } } /// Aggregated source-tracked information for one unique effect. @@ -535,6 +583,22 @@ pub enum TwoDetectorDirectRenderPolicy { PreferRecordedComponents, } +/// Policy for decomposing hyperedges in decomposed DEM output. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum HyperedgeDecompositionRenderPolicy { + /// Preserve source-carried components exactly, even when a component is a + /// hyperedge. This is the faithful source-component view. + PreserveSourceComponents, + + /// Decompose hyperedge components using graphlike pieces learned from + /// source-carried components and full source-tracked alternatives. + SourceGraphlikeComponents, + + /// Historical compatibility mode: search graphlike full effects elsewhere + /// in the DEM. This is not source proof and is kept explicit. + GlobalGraphlikeSearch, +} + // ============================================================================ // Error Mechanism // ============================================================================ @@ -927,15 +991,122 @@ fn find_hyperedge_decomposition( GraphlikeDecompositionIndex::new(graphlike_set).find_hyperedge_decomposition(hyperedge) } +const GRAPH_PATH_BOUNDARY: GraphPathNode = GraphPathNode::Boundary; +const MAX_GRAPH_PATH_TERMINALS: usize = 12; +const MAX_GRAPH_PATH_LENGTH: usize = 16; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +enum GraphPathNode { + Boundary, + Detector(u32), +} + +#[derive(Debug, Clone)] +struct GraphPathEdge { + next: GraphPathNode, + mechanism: FaultMechanism, +} + +#[derive(Debug, Clone)] +struct GraphPathCandidate { + dem_outputs: SmallVec<[u32; 2]>, + parts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct GraphPathSearchCacheKey { + start: GraphPathNode, + end: GraphPathNode, + excluded_origin: Option, +} + +impl GraphPathSearchCacheKey { + fn new(a: GraphPathNode, b: GraphPathNode, excluded_origin: Option<&FaultMechanism>) -> Self { + let (start, end) = ordered_graph_path_pair(a, b); + Self { + start, + end, + excluded_origin: excluded_origin.cloned(), + } + } +} + +type GraphPathSearchCache = BTreeMap>; + +#[derive(Clone, Default)] +struct SourceGraphlikeClosure { + mechanisms: BTreeSet, + primitive: BTreeSet, + derived_origins: BTreeMap>, +} + +impl SourceGraphlikeClosure { + fn from_primitives(primitives: BTreeSet) -> Self { + Self { + mechanisms: primitives.clone(), + primitive: primitives, + derived_origins: BTreeMap::new(), + } + } + + fn len(&self) -> usize { + self.mechanisms.len() + } + + fn insert_derived(&mut self, mechanism: FaultMechanism, origin: &FaultMechanism) { + let is_new = self.mechanisms.insert(mechanism.clone()); + if is_new && !self.primitive.contains(&mechanism) { + self.derived_origins + .entry(mechanism) + .or_default() + .insert(origin.clone()); + } + } +} + struct GraphlikeDecompositionIndex { graphlike_set: BTreeSet, + primitive_graphlike_set: BTreeSet, + derived_origins: BTreeMap>, /// Indexed by detector ID; see `SingletonDecompositionIndex` for the same /// pattern and rationale. Detector IDs are dense `0..num_detectors`. candidates_by_detector: Vec>, + /// Maps one- and two-detector symptoms to a known graphlike mechanism with + /// those symptoms, including any associated logical frame changes. + symptoms: BTreeMap, Vec>, + /// Source-derived detector graph used for exact path decompositions. + /// + /// A one-detector mechanism is represented as an edge to `Boundary`; a + /// two-detector mechanism is represented as an ordinary detector edge. + /// Paths may introduce intermediate detector vertices only when they cancel + /// pairwise in the XOR of path components. + path_adjacency: BTreeMap>, + /// Pure logical graphlike components, keyed by their DEM-output mask. + pure_logical_components: BTreeMap, Vec>, } impl GraphlikeDecompositionIndex { fn new(graphlike_set: &BTreeSet) -> Self { + Self::from_parts( + graphlike_set.clone(), + graphlike_set.clone(), + BTreeMap::new(), + ) + } + + fn from_source_closure(closure: &SourceGraphlikeClosure) -> Self { + Self::from_parts( + closure.mechanisms.clone(), + closure.primitive.clone(), + closure.derived_origins.clone(), + ) + } + + fn from_parts( + graphlike_set: BTreeSet, + primitive_graphlike_set: BTreeSet, + derived_origins: BTreeMap>, + ) -> Self { let max_det = graphlike_set .iter() .flat_map(|c| c.detectors.iter().copied()) @@ -944,11 +1115,70 @@ impl GraphlikeDecompositionIndex { let mut candidates_by_detector: Vec> = max_det.map_or_else(Vec::new, |m| vec![Vec::new(); m as usize + 1]); - for candidate in graphlike_set { + for candidate in &graphlike_set { for &det in &candidate.detectors { candidates_by_detector[det as usize].push(candidate.clone()); } } + let mut symptoms: BTreeMap, Vec> = BTreeMap::new(); + for candidate in &graphlike_set { + if candidate.detectors.is_empty() || candidate.detectors.len() > 2 { + continue; + } + symptoms + .entry(candidate.detectors.clone()) + .or_default() + .push(candidate.clone()); + } + for values in symptoms.values_mut() { + values.sort_by(Self::compare_symptom_candidates); + values.dedup(); + } + let mut path_adjacency: BTreeMap> = BTreeMap::new(); + let mut pure_logical_components: BTreeMap, Vec> = + BTreeMap::new(); + for candidate in &graphlike_set { + if !candidate.is_graphlike() || candidate.is_standard_empty() { + continue; + } + match candidate.detectors.as_slice() { + [] => { + pure_logical_components + .entry(candidate.dem_outputs.clone()) + .or_default() + .push(candidate.clone()); + } + [det] => { + Self::add_path_edge( + &mut path_adjacency, + GRAPH_PATH_BOUNDARY, + GraphPathNode::Detector(*det), + candidate.clone(), + ); + } + [d0, d1] => { + Self::add_path_edge( + &mut path_adjacency, + GraphPathNode::Detector(*d0), + GraphPathNode::Detector(*d1), + candidate.clone(), + ); + } + _ => {} + } + } + for values in pure_logical_components.values_mut() { + values.sort_by(Self::compare_symptom_candidates); + values.dedup(); + } + for edges in path_adjacency.values_mut() { + edges.sort_by(|a, b| { + a.next + .cmp(&b.next) + .then_with(|| a.mechanism.cmp(&b.mechanism)) + }); + edges.dedup_by(|a, b| a.next == b.next && a.mechanism == b.mechanism); + } for values in &mut candidates_by_detector { values.sort_by(|a, b| { b.detectors @@ -959,13 +1189,94 @@ impl GraphlikeDecompositionIndex { } Self { graphlike_set: graphlike_set.clone(), + primitive_graphlike_set, + derived_origins, candidates_by_detector, + symptoms, + path_adjacency, + pure_logical_components, + } + } + + fn add_path_edge( + path_adjacency: &mut BTreeMap>, + a: GraphPathNode, + b: GraphPathNode, + mechanism: FaultMechanism, + ) { + path_adjacency.entry(a).or_default().push(GraphPathEdge { + next: b, + mechanism: mechanism.clone(), + }); + path_adjacency + .entry(b) + .or_default() + .push(GraphPathEdge { next: a, mechanism }); + } + + fn compare_symptom_candidates(a: &FaultMechanism, b: &FaultMechanism) -> Ordering { + a.dem_outputs + .len() + .cmp(&b.dem_outputs.len()) + .then_with(|| a.dem_outputs.cmp(&b.dem_outputs)) + .then_with(|| a.cmp(b)) + } + + fn candidate_allowed( + &self, + candidate: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, + ) -> bool { + let Some(origin) = excluded_origin else { + return true; + }; + if self.primitive_graphlike_set.contains(candidate) { + return true; } + self.derived_origins.get(candidate).is_none_or(|origins| { + origins + .iter() + .any(|candidate_origin| candidate_origin != origin) + }) + } + + fn first_allowed_symptom_candidate( + &self, + key: &SmallVec<[u32; 4]>, + excluded_origin: Option<&FaultMechanism>, + ) -> Option<&FaultMechanism> { + self.symptoms.get(key).and_then(|candidates| { + candidates + .iter() + .find(|candidate| self.candidate_allowed(candidate, excluded_origin)) + }) + } + + fn first_allowed_logical_component( + &self, + key: &SmallVec<[u32; 2]>, + excluded_origin: Option<&FaultMechanism>, + ) -> Option<&FaultMechanism> { + self.pure_logical_components + .get(key) + .and_then(|candidates| { + candidates + .iter() + .find(|candidate| self.candidate_allowed(candidate, excluded_origin)) + }) } fn find_hyperedge_decomposition( &self, hyperedge: &FaultMechanism, + ) -> Option> { + self.find_hyperedge_decomposition_for_origin(hyperedge, None) + } + + fn find_hyperedge_decomposition_for_origin( + &self, + hyperedge: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, ) -> Option> { // If already graphlike, no decomposition needed if hyperedge.is_graphlike() { @@ -983,13 +1294,341 @@ impl GraphlikeDecompositionIndex { }; let mut memo = BTreeMap::new(); - let result = self.search_decomposition(hyperedge, &mut memo); + let result = self.search_decomposition(hyperedge, excluded_origin, &mut memo); result.filter(|decomp| decomp_dets_valid(decomp)) } + fn find_hyperedge_decomposition_with_remnants_for_origin_cached( + &self, + hyperedge: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, + path_cache: &mut GraphPathSearchCache, + ) -> Option> { + if let Some(decomp) = + self.find_hyperedge_decomposition_for_origin(hyperedge, excluded_origin) + { + return Some(decomp); + } + + let remnant = self.find_remnant_decomposition(hyperedge, excluded_origin); + if remnant + .as_ref() + .is_some_and(|parts| self.remnant_decomposition_is_preferred(parts, excluded_origin)) + { + return remnant; + } + + self.find_graph_path_decomposition_with_cache(hyperedge, excluded_origin, path_cache) + .or(remnant) + } + + fn find_hyperedge_discovery_decomposition_for_origin( + &self, + hyperedge: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, + ) -> Option> { + self.find_hyperedge_decomposition_for_origin(hyperedge, excluded_origin) + .or_else(|| self.find_remnant_decomposition(hyperedge, excluded_origin)) + } + + fn remnant_decomposition_is_preferred( + &self, + parts: &[FaultMechanism], + excluded_origin: Option<&FaultMechanism>, + ) -> bool { + parts.iter().all(|part| { + part.is_graphlike() + && self.graphlike_set.contains(part) + && self.candidate_allowed(part, excluded_origin) + }) + } + + #[cfg(test)] + fn find_graph_path_decomposition( + &self, + hyperedge: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, + ) -> Option> { + let mut path_cache = GraphPathSearchCache::new(); + self.find_graph_path_decomposition_with_cache(hyperedge, excluded_origin, &mut path_cache) + } + + fn find_graph_path_decomposition_with_cache( + &self, + hyperedge: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, + path_cache: &mut GraphPathSearchCache, + ) -> Option> { + if hyperedge.is_graphlike() { + return Some(vec![hyperedge.clone()]); + } + if self.path_adjacency.is_empty() || hyperedge.detectors.len() > MAX_GRAPH_PATH_TERMINALS { + return None; + } + + let mut terminals: Vec = hyperedge + .detectors + .iter() + .copied() + .map(GraphPathNode::Detector) + .collect(); + if terminals.len() % 2 == 1 { + terminals.push(GRAPH_PATH_BOUNDARY); + } + + let full_mask = (1u16 << terminals.len()) - 1; + let mut pairing_state_cache: BTreeMap< + u16, + BTreeMap, Vec>, + > = BTreeMap::new(); + let states = self.graph_path_pairing_states( + full_mask, + &terminals, + excluded_origin, + path_cache, + &mut pairing_state_cache, + ); + let mut best: Option> = None; + + for (outputs, mut parts) in states { + let missing_outputs = symmetric_difference_2(&outputs, &hyperedge.dem_outputs); + if !missing_outputs.is_empty() { + let Some(logical_part) = + self.first_allowed_logical_component(&missing_outputs, excluded_origin) + else { + continue; + }; + parts.push(logical_part.clone()); + } + parts = parity_reduce_mechanisms(parts); + if parts.is_empty() { + continue; + } + let recomposed = parts + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); + if recomposed == *hyperedge && parts.iter().all(FaultMechanism::is_graphlike) { + match best.as_ref() { + Some(existing) if !prefer_graph_path_parts(&parts, existing) => {} + _ => best = Some(parts), + } + } + } + + best + } + + fn graph_path_pairing_states( + &self, + mask: u16, + terminals: &[GraphPathNode], + excluded_origin: Option<&FaultMechanism>, + path_cache: &mut GraphPathSearchCache, + pairing_state_cache: &mut BTreeMap, Vec>>, + ) -> BTreeMap, Vec> { + if let Some(cached) = pairing_state_cache.get(&mask) { + return cached.clone(); + } + + if mask == 0 { + let mut base = BTreeMap::new(); + base.insert(SmallVec::new(), Vec::new()); + pairing_state_cache.insert(mask, base.clone()); + return base; + } + + let first = mask.trailing_zeros() as usize; + let without_first = mask & !(1u16 << first); + let mut states: BTreeMap, Vec> = BTreeMap::new(); + + for second in (first + 1)..terminals.len() { + if without_first & (1u16 << second) == 0 { + continue; + } + + let tail_mask = without_first & !(1u16 << second); + let tail_states = self.graph_path_pairing_states( + tail_mask, + terminals, + excluded_origin, + path_cache, + pairing_state_cache, + ); + if tail_states.is_empty() { + continue; + } + + let candidates = path_cache + .entry(GraphPathSearchCacheKey::new( + terminals[first], + terminals[second], + excluded_origin, + )) + .or_insert_with(|| { + self.graph_path_candidates(terminals[first], terminals[second], excluded_origin) + }); + if candidates.is_empty() { + continue; + } + + for candidate in candidates.iter() { + for (tail_outputs, tail_parts) in &tail_states { + let outputs = symmetric_difference_2(&candidate.dem_outputs, tail_outputs); + let mut parts = Vec::with_capacity(candidate.parts.len() + tail_parts.len()); + parts.extend(candidate.parts.iter().cloned()); + parts.extend(tail_parts.iter().cloned()); + + match states.get(&outputs) { + Some(existing) if !prefer_graph_path_parts(&parts, existing) => {} + _ => { + states.insert(outputs, parts); + } + } + } + } + } + + pairing_state_cache.insert(mask, states.clone()); + states + } + + fn graph_path_candidates( + &self, + start: GraphPathNode, + end: GraphPathNode, + excluded_origin: Option<&FaultMechanism>, + ) -> Vec { + let mut queue = VecDeque::new(); + queue.push_back((start, SmallVec::<[u32; 2]>::new(), Vec::new())); + + let mut seen: BTreeSet<(GraphPathNode, SmallVec<[u32; 2]>)> = BTreeSet::new(); + seen.insert((start, SmallVec::new())); + let mut best_by_outputs: BTreeMap, Vec> = + BTreeMap::new(); + + while let Some((node, outputs, parts)) = queue.pop_front() { + if node == end && !parts.is_empty() { + best_by_outputs + .entry(outputs.clone()) + .and_modify(|existing| { + if prefer_graph_path_parts(&parts, existing) { + *existing = parts.clone(); + } + }) + .or_insert_with(|| parts.clone()); + } + if parts.len() >= MAX_GRAPH_PATH_LENGTH { + continue; + } + + let Some(edges) = self.path_adjacency.get(&node) else { + continue; + }; + for edge in edges { + if !self.candidate_allowed(&edge.mechanism, excluded_origin) { + continue; + } + let next_outputs = symmetric_difference_2(&outputs, &edge.mechanism.dem_outputs); + let state = (edge.next, next_outputs.clone()); + if !seen.insert(state) { + continue; + } + + let mut next_parts = Vec::with_capacity(parts.len() + 1); + next_parts.extend(parts.iter().cloned()); + next_parts.push(edge.mechanism.clone()); + queue.push_back((edge.next, next_outputs, next_parts)); + } + } + + let mut candidates: Vec<_> = best_by_outputs + .into_iter() + .map(|(dem_outputs, parts)| GraphPathCandidate { dem_outputs, parts }) + .collect(); + candidates.sort_by(|a, b| { + a.parts + .len() + .cmp(&b.parts.len()) + .then_with(|| a.dem_outputs.cmp(&b.dem_outputs)) + .then_with(|| a.parts.cmp(&b.parts)) + }); + candidates + } + + fn find_remnant_decomposition( + &self, + hyperedge: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, + ) -> Option> { + if hyperedge.is_graphlike() { + return Some(vec![hyperedge.clone()]); + } + + let mut done = BTreeSet::new(); + let mut remaining = hyperedge.clone(); + let mut parts = Vec::new(); + + for (i, &d0) in hyperedge.detectors.iter().enumerate() { + if done.contains(&d0) { + continue; + } + for &d1 in hyperedge.detectors.iter().skip(i + 1) { + if done.contains(&d1) { + continue; + } + let key = SmallVec::from_slice(&[d0, d1]); + let Some(candidate) = self.first_allowed_symptom_candidate(&key, excluded_origin) + else { + continue; + }; + done.insert(d0); + done.insert(d1); + remaining = remaining.xor(candidate); + parts.push(candidate.clone()); + break; + } + } + + for &det in &hyperedge.detectors { + if done.contains(&det) { + continue; + } + let key = SmallVec::from_slice(&[det]); + let Some(candidate) = self.first_allowed_symptom_candidate(&key, excluded_origin) + else { + continue; + }; + done.insert(det); + remaining = remaining.xor(candidate); + parts.push(candidate.clone()); + } + + let missed = hyperedge + .detectors + .iter() + .filter(|det| !done.contains(det)) + .count(); + if missed > 2 { + return None; + } + + if !remaining.is_standard_empty() { + parts.push(remaining); + } + let recomposed = parts + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); + if recomposed == *hyperedge && parts.iter().all(FaultMechanism::is_graphlike) { + Some(parts) + } else { + None + } + } + fn search_decomposition( &self, remaining: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, memo: &mut BTreeMap>>, ) -> Option> { if let Some(cached) = memo.get(remaining) { @@ -1002,7 +1641,10 @@ impl GraphlikeDecompositionIndex { return result; } - if remaining.is_graphlike() && self.graphlike_set.contains(remaining) { + if remaining.is_graphlike() + && self.graphlike_set.contains(remaining) + && self.candidate_allowed(remaining, excluded_origin) + { let result = Some(vec![remaining.clone()]); memo.insert(remaining.clone(), result.clone()); return result; @@ -1012,6 +1654,9 @@ impl GraphlikeDecompositionIndex { && let Some(candidates) = self.candidates_by_detector.get(pivot as usize) { for candidate in candidates { + if !self.candidate_allowed(candidate, excluded_origin) { + continue; + } if !candidate .detectors .iter() @@ -1030,7 +1675,7 @@ impl GraphlikeDecompositionIndex { continue; } - if let Some(suffix) = self.search_decomposition(&next, memo) { + if let Some(suffix) = self.search_decomposition(&next, excluded_origin, memo) { let mut combined = Vec::with_capacity(suffix.len() + 1); combined.push(candidate.clone()); combined.extend(suffix); @@ -1047,12 +1692,40 @@ impl GraphlikeDecompositionIndex { } } -/// Finds a decomposition of a graphlike effect into singleton detector components. -/// -/// This is used for "maximal" decomposition modes that prefer singleton -/// detector symptoms whenever the required singleton effects already exist as -/// standalone mechanisms in the DEM. -fn find_singleton_decomposition( +fn ordered_graph_path_pair(a: GraphPathNode, b: GraphPathNode) -> (GraphPathNode, GraphPathNode) { + if a <= b { (a, b) } else { (b, a) } +} + +fn prefer_graph_path_parts(candidate: &[FaultMechanism], existing: &[FaultMechanism]) -> bool { + candidate + .len() + .cmp(&existing.len()) + .then_with(|| candidate.cmp(existing)) + == Ordering::Less +} + +fn parity_reduce_mechanisms(parts: Vec) -> Vec { + let mut reduced = Vec::new(); + for part in parts { + if part.is_empty() { + continue; + } + if let Some(index) = reduced.iter().position(|existing| existing == &part) { + reduced.remove(index); + } else { + reduced.push(part); + } + } + reduced.sort(); + reduced +} + +/// Finds a decomposition of a graphlike effect into singleton detector components. +/// +/// This is used for "maximal" decomposition modes that prefer singleton +/// detector symptoms whenever the required singleton effects already exist as +/// standalone mechanisms in the DEM. +fn find_singleton_decomposition( effect: &FaultMechanism, index: &SingletonDecompositionIndex, ) -> Option> { @@ -3589,10 +4262,13 @@ impl DetectorErrorModel { } } - let graphlike_set = self.collect_graphlike_mechanisms(); + let graphlike_set = BTreeSet::new(); let graphlike_index = GraphlikeDecompositionIndex::new(&graphlike_set); - let mut rendered_targets_cache: BTreeMap<(FaultMechanism, FaultSourceType), String> = - BTreeMap::new(); + let mut rendered_targets_cache: BTreeMap< + (FaultMechanism, FaultSourceType, Option), + (String, ContributionRenderStrategy), + > = BTreeMap::new(); + let mut source_graphlike_path_cache = GraphPathSearchCache::new(); let mut by_render: BTreeMap<(FaultMechanism, String), Accumulator> = BTreeMap::new(); for contrib in &self.contributions { @@ -3604,7 +4280,10 @@ impl DetectorErrorModel { contrib, &graphlike_index, None, + None, two_detector_direct_policy, + HyperedgeDecompositionRenderPolicy::PreserveSourceComponents, + &mut source_graphlike_path_cache, &mut rendered_targets_cache, ); let acc = by_render @@ -3671,10 +4350,59 @@ impl DetectorErrorModel { &self, two_detector_direct_policy: TwoDetectorDirectRenderPolicy, ) -> Vec { - let graphlike_set = self.collect_graphlike_mechanisms(); + self.contribution_render_records_inner( + two_detector_direct_policy, + HyperedgeDecompositionRenderPolicy::PreserveSourceComponents, + ) + } + + /// Returns per-contribution render records for the source-informed + /// graphlike renderer. + #[must_use] + pub fn contribution_source_graphlike_render_records(&self) -> Vec { + self.contribution_render_records_inner( + TwoDetectorDirectRenderPolicy::KeepDirect, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents, + ) + } + + fn contribution_render_records_inner( + &self, + two_detector_direct_policy: TwoDetectorDirectRenderPolicy, + hyperedge_policy: HyperedgeDecompositionRenderPolicy, + ) -> Vec { + let graphlike_set = if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::GlobalGraphlikeSearch + ) { + self.collect_graphlike_mechanisms() + } else { + BTreeSet::new() + }; let graphlike_index = GraphlikeDecompositionIndex::new(&graphlike_set); - let mut rendered_targets_cache: BTreeMap<(FaultMechanism, FaultSourceType), String> = - BTreeMap::new(); + let source_graphlike_closure = if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents + ) { + self.collect_source_graphlike_closure() + } else { + SourceGraphlikeClosure::default() + }; + let source_graphlike_index = if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents + ) { + Some(GraphlikeDecompositionIndex::from_source_closure( + &source_graphlike_closure, + )) + } else { + None + }; + let mut rendered_targets_cache: BTreeMap< + (FaultMechanism, FaultSourceType, Option), + (String, ContributionRenderStrategy), + > = BTreeMap::new(); + let mut source_graphlike_path_cache = GraphPathSearchCache::new(); let mut records = Vec::new(); for contrib in &self.contributions { @@ -3686,8 +4414,11 @@ impl DetectorErrorModel { Self::contribution_render_details( contrib, &graphlike_index, + source_graphlike_index.as_ref(), None, two_detector_direct_policy, + hyperedge_policy, + &mut source_graphlike_path_cache, &mut rendered_targets_cache, ); records.push(ContributionRenderRecord { @@ -4057,23 +4788,178 @@ impl DetectorErrorModel { out } + fn source_component_parts(contrib: &FaultContribution) -> Vec { + if let Some((x_effect, z_effect)) = contrib.decomposition_components() { + return [x_effect, z_effect] + .into_iter() + .map(|part| part.standard_effect()) + .filter(|part| !part.is_empty()) + .collect(); + } + + if let Some(parts) = contrib.source_component_effects() { + return parts + .into_iter() + .map(|part| part.standard_effect()) + .filter(|part| !part.is_empty()) + .collect(); + } + + let effect = contrib.effect.standard_effect(); + if effect.is_empty() || contrib.location_indices.is_empty() { + Vec::new() + } else { + vec![effect] + } + } + + fn collect_source_graphlike_mechanisms(&self) -> BTreeSet { + let mut graphlike = BTreeSet::new(); + for contrib in &self.contributions { + let effect = contrib.effect.standard_effect(); + if !contrib.location_indices.is_empty() && effect.is_graphlike() { + graphlike.insert(effect); + } + for part in Self::source_component_parts(contrib) { + if part.is_graphlike() { + graphlike.insert(part); + } + } + } + graphlike + } + + fn collect_source_graphlike_closure(&self) -> SourceGraphlikeClosure { + let mut graphlike = + SourceGraphlikeClosure::from_primitives(self.collect_source_graphlike_mechanisms()); + + loop { + let before = graphlike.len(); + let index = GraphlikeDecompositionIndex::from_source_closure(&graphlike); + + for contrib in &self.contributions { + let mut parts = Self::source_component_parts(contrib); + let effect = contrib.effect.standard_effect(); + if !contrib.location_indices.is_empty() && !effect.is_empty() { + parts.push(effect); + } + + for part in parts { + let origin = part.clone(); + let decomposed = if part.is_graphlike() { + vec![part] + } else { + index + .find_hyperedge_discovery_decomposition_for_origin(&part, Some(&origin)) + .unwrap_or_else(|| vec![part]) + }; + + for piece in decomposed { + if piece.is_graphlike() && !piece.is_standard_empty() { + graphlike.insert_derived(piece, &origin); + } + } + } + } + + if graphlike.len() == before { + break; + } + } + + graphlike + } + + fn source_graphlike_decompose_parts( + parts: Vec, + source_graphlike_index: Option<&GraphlikeDecompositionIndex>, + singleton_set: Option<&SingletonDecompositionIndex>, + source_graphlike_path_cache: &mut GraphPathSearchCache, + ) -> Vec { + let mut out = Vec::new(); + for part in parts { + if part.is_empty() { + continue; + } + + let decomposed = if part.is_graphlike() { + vec![part] + } else if let Some(index) = source_graphlike_index { + index + .find_hyperedge_decomposition_with_remnants_for_origin_cached( + &part, + Some(&part), + source_graphlike_path_cache, + ) + .unwrap_or_else(|| vec![part]) + } else { + vec![part] + }; + + out.extend(Self::maybe_maximally_decompose_parts( + decomposed, + singleton_set, + )); + } + out + } + + fn source_graphlike_decompose_recorded_or_full_effect( + effect: &FaultMechanism, + parts: Vec, + source_graphlike_index: Option<&GraphlikeDecompositionIndex>, + singleton_set: Option<&SingletonDecompositionIndex>, + source_graphlike_path_cache: &mut GraphPathSearchCache, + ) -> Vec { + let recorded_parts = Self::source_graphlike_decompose_parts( + parts, + source_graphlike_index, + singleton_set, + source_graphlike_path_cache, + ); + if recorded_parts.iter().all(FaultMechanism::is_graphlike) { + return recorded_parts; + } + + if let Some(index) = source_graphlike_index { + if let Some(effect_parts) = index + .find_hyperedge_decomposition_with_remnants_for_origin_cached( + effect, + Some(effect), + source_graphlike_path_cache, + ) + { + if effect_parts.iter().all(FaultMechanism::is_graphlike) { + return Self::maybe_maximally_decompose_parts(effect_parts, singleton_set); + } + } + } + + recorded_parts + } + + fn format_decomposed_parts(parts: Vec) -> String { + Self::parity_reduce_decomposed_parts(parts) + .iter() + .map(format_mechanism_targets) + .filter(|targets| !targets.is_empty()) + .collect::>() + .join(" ^ ") + } + + fn parity_reduce_decomposed_parts(parts: Vec) -> Vec { + parity_reduce_mechanisms(parts) + } + fn recorded_component_targets( contrib: &FaultContribution, singleton_set: Option<&SingletonDecompositionIndex>, ) -> Option { - let (first, second) = contrib.direct_component_effects()?; - let targets = Self::maybe_maximally_decompose_parts( - [first, second] - .into_iter() - .filter(|part| !part.is_empty()) - .collect(), + let parts = contrib.source_component_effects()?; + let targets = Self::format_decomposed_parts(Self::maybe_maximally_decompose_parts( + parts.into_iter().filter(|part| !part.is_empty()).collect(), singleton_set, - ) - .iter() - .map(format_mechanism_targets) - .filter(|targets| !targets.is_empty()) - .collect::>() - .join(" ^ "); + )); if targets.is_empty() { None } else { @@ -4095,60 +4981,42 @@ impl DetectorErrorModel { fn contribution_render_details( contrib: &FaultContribution, graphlike_index: &GraphlikeDecompositionIndex, + source_graphlike_index: Option<&GraphlikeDecompositionIndex>, singleton_set: Option<&SingletonDecompositionIndex>, two_detector_direct_policy: TwoDetectorDirectRenderPolicy, - cache: &mut BTreeMap<(FaultMechanism, FaultSourceType), String>, + hyperedge_policy: HyperedgeDecompositionRenderPolicy, + source_graphlike_path_cache: &mut GraphPathSearchCache, + cache: &mut BTreeMap< + (FaultMechanism, FaultSourceType, Option), + (String, ContributionRenderStrategy), + >, ) -> (String, ContributionRenderStrategy, Option) { let recorded_component_targets = Self::recorded_component_targets(contrib, singleton_set); - let key = (contrib.effect.clone(), contrib.source_type.clone()); - if let Some(cached) = cache.get(&key) { - let strategy = if contrib.decomposition_components().is_some() { - ContributionRenderStrategy::SourceComponents - } else if contrib.effect.num_detectors() == 2 && contrib.effect.dem_outputs.is_empty() { - let direct_targets = - Self::two_detector_direct_targets(&contrib.effect, singleton_set); - if matches!( - two_detector_direct_policy, - TwoDetectorDirectRenderPolicy::PreferRecordedComponents - ) && recorded_component_targets.as_deref() == Some(cached.as_str()) - && cached != &direct_targets - { - ContributionRenderStrategy::RecordedComponents - } else { - ContributionRenderStrategy::TwoDetectorDirect - } - } else if contrib.effect.is_hyperedge() { - ContributionRenderStrategy::HyperedgeGraphlike - } else { - ContributionRenderStrategy::EffectDirect - }; - return (cached.clone(), strategy, recorded_component_targets); + let key = ( + contrib.effect.clone(), + contrib.source_type.clone(), + recorded_component_targets.clone(), + ); + if let Some((cached_targets, cached_strategy)) = cache.get(&key) { + return ( + cached_targets.clone(), + *cached_strategy, + recorded_component_targets, + ); } let effect = contrib.effect.standard_effect(); let (targets, strategy) = if let Some((x_effect, z_effect)) = contrib.decomposition_components() { - let x_graphlike = x_effect.is_empty() || x_effect.is_graphlike(); - let z_graphlike = z_effect.is_empty() || z_effect.is_graphlike(); - - if !x_effect.is_empty() && !z_effect.is_empty() && x_graphlike && z_graphlike { - let x_parts = - Self::maybe_maximally_decompose_parts(vec![x_effect.clone()], singleton_set); - let z_parts = - Self::maybe_maximally_decompose_parts(vec![z_effect.clone()], singleton_set); - let targets = x_parts - .iter() - .chain(z_parts.iter()) - .map(format_mechanism_targets) - .filter(|targets| !targets.is_empty()) - .collect::>() - .join(" ^ "); - let targets = if targets.is_empty() { - String::new() - } else { - targets - }; + if !x_effect.is_empty() && !z_effect.is_empty() { + let parts = Self::source_graphlike_decompose_parts( + vec![x_effect.clone(), z_effect.clone()], + source_graphlike_index, + singleton_set, + source_graphlike_path_cache, + ); + let targets = Self::format_decomposed_parts(parts); (targets, ContributionRenderStrategy::SourceComponents) } else if effect.num_detectors() == 2 && effect.dem_outputs.is_empty() { let direct_targets = Self::two_detector_direct_targets(&effect, singleton_set); @@ -4181,15 +5049,65 @@ impl DetectorErrorModel { ) } } else if effect.is_hyperedge() { - if let Some(decomp) = graphlike_index.find_hyperedge_decomposition(&effect) { - ( - Self::maybe_maximally_decompose_parts(decomp, singleton_set) - .iter() - .map(format_mechanism_targets) - .collect::>() - .join(" ^ "), - ContributionRenderStrategy::HyperedgeGraphlike, - ) + if let Some(parts) = contrib.source_component_effects() { + let targets = Self::format_decomposed_parts( + Self::source_graphlike_decompose_recorded_or_full_effect( + &effect, + parts.into_iter().collect(), + source_graphlike_index, + singleton_set, + source_graphlike_path_cache, + ), + ); + (targets, ContributionRenderStrategy::RecordedComponents) + } else if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents + ) { + if let Some(index) = source_graphlike_index { + if let Some(decomp) = index + .find_hyperedge_decomposition_with_remnants_for_origin_cached( + &effect, + Some(&effect), + source_graphlike_path_cache, + ) + { + ( + Self::format_decomposed_parts( + Self::maybe_maximally_decompose_parts(decomp, singleton_set), + ), + ContributionRenderStrategy::HyperedgeGraphlike, + ) + } else { + ( + format_mechanism_targets(&effect), + ContributionRenderStrategy::EffectDirect, + ) + } + } else { + ( + format_mechanism_targets(&effect), + ContributionRenderStrategy::EffectDirect, + ) + } + } else if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::GlobalGraphlikeSearch + ) { + if let Some(decomp) = graphlike_index.find_hyperedge_decomposition(&effect) { + ( + Self::format_decomposed_parts(Self::maybe_maximally_decompose_parts( + decomp, + singleton_set, + )), + ContributionRenderStrategy::HyperedgeGraphlike, + ) + } else { + ( + format_mechanism_targets(&effect), + ContributionRenderStrategy::EffectDirect, + ) + } } else { ( format_mechanism_targets(&effect), @@ -4198,11 +5116,10 @@ impl DetectorErrorModel { } } else { ( - Self::maybe_maximally_decompose_parts(vec![effect.clone()], singleton_set) - .iter() - .map(format_mechanism_targets) - .collect::>() - .join(" ^ "), + Self::format_decomposed_parts(Self::maybe_maximally_decompose_parts( + vec![effect.clone()], + singleton_set, + )), ContributionRenderStrategy::EffectDirect, ) } @@ -4237,15 +5154,66 @@ impl DetectorErrorModel { ) } } else if effect.is_hyperedge() { - if let Some(decomp) = graphlike_index.find_hyperedge_decomposition(&effect) { - ( - Self::maybe_maximally_decompose_parts(decomp, singleton_set) - .iter() - .map(format_mechanism_targets) - .collect::>() - .join(" ^ "), - ContributionRenderStrategy::HyperedgeGraphlike, - ) + if let Some(parts) = contrib.source_component_effects() { + let targets = Self::format_decomposed_parts( + Self::source_graphlike_decompose_recorded_or_full_effect( + &effect, + parts.into_iter().collect(), + source_graphlike_index, + singleton_set, + source_graphlike_path_cache, + ), + ); + (targets, ContributionRenderStrategy::RecordedComponents) + } else if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents + ) { + if let Some(index) = source_graphlike_index { + if let Some(decomp) = index + .find_hyperedge_decomposition_with_remnants_for_origin_cached( + &effect, + Some(&effect), + source_graphlike_path_cache, + ) + { + ( + Self::format_decomposed_parts(Self::maybe_maximally_decompose_parts( + decomp, + singleton_set, + )), + ContributionRenderStrategy::HyperedgeGraphlike, + ) + } else { + ( + format_mechanism_targets(&effect), + ContributionRenderStrategy::EffectDirect, + ) + } + } else { + ( + format_mechanism_targets(&effect), + ContributionRenderStrategy::EffectDirect, + ) + } + } else if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::GlobalGraphlikeSearch + ) { + if let Some(decomp) = graphlike_index.find_hyperedge_decomposition(&effect) { + ( + Self::format_decomposed_parts(Self::maybe_maximally_decompose_parts( + decomp, + singleton_set, + )), + ContributionRenderStrategy::HyperedgeGraphlike, + ) + } else { + ( + format_mechanism_targets(&effect), + ContributionRenderStrategy::EffectDirect, + ) + } } else { ( format_mechanism_targets(&effect), @@ -4254,31 +5222,39 @@ impl DetectorErrorModel { } } else { ( - Self::maybe_maximally_decompose_parts(vec![effect.clone()], singleton_set) - .iter() - .map(format_mechanism_targets) - .collect::>() - .join(" ^ "), + Self::format_decomposed_parts(Self::maybe_maximally_decompose_parts( + vec![effect.clone()], + singleton_set, + )), ContributionRenderStrategy::EffectDirect, ) }; - cache.insert(key, targets.clone()); + cache.insert(key, (targets.clone(), strategy)); (targets, strategy, recorded_component_targets) } fn contribution_targets( contrib: &FaultContribution, graphlike_index: &GraphlikeDecompositionIndex, + source_graphlike_index: Option<&GraphlikeDecompositionIndex>, singleton_set: Option<&SingletonDecompositionIndex>, two_detector_direct_policy: TwoDetectorDirectRenderPolicy, - cache: &mut BTreeMap<(FaultMechanism, FaultSourceType), String>, + hyperedge_policy: HyperedgeDecompositionRenderPolicy, + source_graphlike_path_cache: &mut GraphPathSearchCache, + cache: &mut BTreeMap< + (FaultMechanism, FaultSourceType, Option), + (String, ContributionRenderStrategy), + >, ) -> String { Self::contribution_render_details( contrib, graphlike_index, + source_graphlike_index, singleton_set, two_detector_direct_policy, + hyperedge_policy, + source_graphlike_path_cache, cache, ) .0 @@ -4303,15 +5279,32 @@ impl DetectorErrorModel { /// because the edge is already graphlike and extra L0 terms can change /// decoder behavior without adding new information. /// - /// Hyperedges (3+ detectors) are decomposed into graphlike forms when - /// possible. Mechanisms with up to 2 detectors are already graphlike even + /// Hyperedges (3+ detectors) are decomposed only when source-tracked + /// component structure justifies the split. Residual hyperedges remain + /// hyperedges. Mechanisms with up to 2 detectors are already graphlike even /// when they carry multiple DEM outputs. #[must_use] fn to_string_decomposed_inner( &self, maximal_decomposition: bool, two_detector_direct_policy: TwoDetectorDirectRenderPolicy, + hyperedge_policy: HyperedgeDecompositionRenderPolicy, ) -> String { + let profile_enabled = std::env::var_os("PECOS_DEM_RENDER_PROFILE").is_some(); + let profile_start = std::time::Instant::now(); + let mut profile_last = profile_start; + let profile_step = |label: &str, last: &mut std::time::Instant| { + if profile_enabled { + let now = std::time::Instant::now(); + eprintln!( + "[pecos-dem-render] {label}: step={:.3}s total={:.3}s", + now.duration_since(*last).as_secs_f64(), + now.duration_since(profile_start).as_secs_f64(), + ); + *last = now; + } + }; + let mut lines = Vec::new(); // Add detector coordinate annotations @@ -4327,40 +5320,91 @@ impl DetectorErrorModel { for obs in &self.observables { lines.push(format!("logical_observable L{}", obs.id)); } + profile_step("annotations", &mut profile_last); - let graphlike_set = self.collect_graphlike_mechanisms(); + let graphlike_set = if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::GlobalGraphlikeSearch + ) { + self.collect_graphlike_mechanisms() + } else { + BTreeSet::new() + }; let graphlike_index = GraphlikeDecompositionIndex::new(&graphlike_set); + profile_step("global_graphlike_index", &mut profile_last); + let source_graphlike_closure = if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents + ) { + self.collect_source_graphlike_closure() + } else { + SourceGraphlikeClosure::default() + }; + if profile_enabled { + eprintln!( + "[pecos-dem-render] source_graphlike_closure_size={}", + source_graphlike_closure.len() + ); + } + profile_step("source_graphlike_closure", &mut profile_last); + let source_graphlike_index = if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents + ) { + Some(GraphlikeDecompositionIndex::from_source_closure( + &source_graphlike_closure, + )) + } else { + None + }; + profile_step("source_graphlike_index", &mut profile_last); let singleton_set = maximal_decomposition.then(|| self.collect_singleton_index()); + profile_step("singleton_index", &mut profile_last); let mut by_targets: BTreeMap = BTreeMap::new(); - let mut rendered_targets_cache: BTreeMap<(FaultMechanism, FaultSourceType), String> = - BTreeMap::new(); - - let mut add_targets = |targets: String, probability: f64| { - if targets.is_empty() || probability <= 0.0 { - return; - } - by_targets - .entry(targets) - .and_modify(|p| *p = combine_independent_probs(*p, probability)) - .or_insert(probability); - }; + let mut rendered_targets_cache: BTreeMap< + (FaultMechanism, FaultSourceType, Option), + (String, ContributionRenderStrategy), + > = BTreeMap::new(); + let mut source_graphlike_path_cache = GraphPathSearchCache::new(); // Process each tracked contribution individually, then regroup identical // decomposed outputs. Rewriting each error class before merging keeps // source-aware decompositions stable. + let mut rendered_contribs = 0usize; for contrib in &self.contributions { if contrib.effect.is_empty() || contrib.probability <= 0.0 { continue; } + rendered_contribs += 1; let targets = Self::contribution_targets( contrib, &graphlike_index, + source_graphlike_index.as_ref(), singleton_set.as_ref(), two_detector_direct_policy, + hyperedge_policy, + &mut source_graphlike_path_cache, &mut rendered_targets_cache, ); - add_targets(targets, contrib.probability); + if !targets.is_empty() && contrib.probability > 0.0 { + by_targets + .entry(targets) + .and_modify(|p| *p = combine_independent_probs(*p, contrib.probability)) + .or_insert(contrib.probability); + } + if profile_enabled && rendered_contribs % 5000 == 0 { + let now = std::time::Instant::now(); + eprintln!( + "[pecos-dem-render] rendered_contributions={} render_cache={} path_cache={} target_buckets={} total={:.3}s", + rendered_contribs, + rendered_targets_cache.len(), + source_graphlike_path_cache.len(), + by_targets.len(), + now.duration_since(profile_start).as_secs_f64(), + ); + } } + profile_step("render_contributions", &mut profile_last); for (targets, total_prob) in by_targets { if !targets.is_empty() && total_prob > 0.0 { @@ -4371,13 +5415,65 @@ impl DetectorErrorModel { )); } } + profile_step("format_output", &mut profile_last); lines.join("\n") } #[must_use] pub fn to_string_decomposed(&self) -> String { - self.to_string_decomposed_inner(false, TwoDetectorDirectRenderPolicy::KeepDirect) + self.to_string_decomposed_inner( + false, + TwoDetectorDirectRenderPolicy::KeepDirect, + HyperedgeDecompositionRenderPolicy::PreserveSourceComponents, + ) + } + + /// Converts the DEM to a source-decomposed string. + /// + /// This is an explicit alias for [`Self::to_string_decomposed`]. The + /// renderer uses only decomposition structure attached to the original + /// fault source (for example Y=X^Z components and recorded per-location + /// components for multi-qubit sources). Hyperedges without a + /// source-legitimate decomposition remain hyperedges. + #[must_use] + pub fn to_string_source_decomposed(&self) -> String { + self.to_string_decomposed() + } + + /// Converts the DEM to a source-informed graphlike decomposition. + /// + /// This renderer first uses source-carried component structure (for + /// example Y=X^Z and recorded per-location components). If a source + /// component is still a hyperedge, it follows Stim's intra/inter-channel + /// decomposition strategy to a fixed point: use graphlike mechanisms that + /// appear as source-carried components or full source-tracked alternatives + /// in this DEM, then introduce graphlike remnants when needed and make + /// those available to later decompositions. Residual hyperedges remain + /// hyperedges if this source-informed graphlike closure cannot explain them. + #[must_use] + pub fn to_string_source_graphlike_decomposed(&self) -> String { + self.to_string_decomposed_inner( + false, + TwoDetectorDirectRenderPolicy::KeepDirect, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents, + ) + } + + /// Converts the DEM to a graphlike-search decomposed string. + /// + /// This keeps the historical compatibility behavior that may decompose a + /// residual hyperedge by searching for graphlike mechanisms elsewhere in + /// the DEM. It is intentionally separate from the source-decomposed path: + /// use it only for representation experiments, not as proof that the source + /// mechanism itself has graphlike components. + #[must_use] + pub fn to_string_graphlike_search_decomposed(&self) -> String { + self.to_string_decomposed_inner( + false, + TwoDetectorDirectRenderPolicy::KeepDirect, + HyperedgeDecompositionRenderPolicy::GlobalGraphlikeSearch, + ) } /// Converts the DEM to decomposed format with an explicit direct-2det @@ -4387,7 +5483,11 @@ impl DetectorErrorModel { &self, two_detector_direct_policy: TwoDetectorDirectRenderPolicy, ) -> String { - self.to_string_decomposed_inner(false, two_detector_direct_policy) + self.to_string_decomposed_inner( + false, + two_detector_direct_policy, + HyperedgeDecompositionRenderPolicy::PreserveSourceComponents, + ) } /// Converts the DEM to a maximally decomposed graphlike form when possible. @@ -4402,7 +5502,11 @@ impl DetectorErrorModel { /// resulting matching graph. #[must_use] pub fn to_string_decomposed_maximally(&self) -> String { - self.to_string_decomposed_inner(true, TwoDetectorDirectRenderPolicy::KeepDirect) + self.to_string_decomposed_inner( + true, + TwoDetectorDirectRenderPolicy::KeepDirect, + HyperedgeDecompositionRenderPolicy::PreserveSourceComponents, + ) } /// Converts the DEM to a maximally decomposed graphlike form with an @@ -4412,7 +5516,11 @@ impl DetectorErrorModel { &self, two_detector_direct_policy: TwoDetectorDirectRenderPolicy, ) -> String { - self.to_string_decomposed_inner(true, two_detector_direct_policy) + self.to_string_decomposed_inner( + true, + two_detector_direct_policy, + HyperedgeDecompositionRenderPolicy::PreserveSourceComponents, + ) } /// Collects all graphlike mechanisms from contributions. @@ -5738,6 +6846,710 @@ mod tests { assert!(matches!(contribution.source_type, FaultSourceType::Direct)); } + #[test] + fn test_decomposed_render_uses_recorded_graphlike_components_for_direct_hyperedge() { + let first = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let second = FaultMechanism::from_unsorted([2, 3], std::iter::empty()); + let effect = first.xor(&second); + let mut dem = DetectorErrorModel::new(); + + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::X, Pauli::Z], + &[GateType::CX, GateType::CX], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + + let decomposed = dem.to_string_decomposed(); + assert!(decomposed.contains("error(0.01) D0 D1 ^ D2 D3")); + + let records = dem.contribution_render_records(); + assert_eq!(records.len(), 1); + assert_eq!( + records[0].render_strategy, + ContributionRenderStrategy::RecordedComponents + ); + assert_eq!( + records[0].recorded_component_targets.as_deref(), + Some("D0 D1 ^ D2 D3") + ); + } + + #[test] + fn test_decomposed_render_preserves_recorded_hyperedge_components() { + let first = FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()); + let second = FaultMechanism::from_unsorted([3, 4], std::iter::empty()); + let effect = first.xor(&second); + let mut dem = DetectorErrorModel::new(); + + dem.add_direct_contribution( + FaultMechanism::from_unsorted([0, 1], std::iter::empty()), + 0.001, + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([2], std::iter::empty()), + 0.001, + ); + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::X, Pauli::Z], + &[GateType::CX, GateType::CX], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + + let records = dem.contribution_render_records(); + let record = records + .iter() + .find(|record| (record.contribution.probability - 0.01).abs() < 1e-12) + .expect("expected recorded-component contribution"); + + assert_eq!( + record.render_strategy, + ContributionRenderStrategy::RecordedComponents + ); + assert_eq!(record.rendered_targets, "D0 D1 D2 ^ D3 D4"); + } + + #[test] + fn test_decomposed_render_cache_distinguishes_recorded_components() { + let effect = FaultMechanism::from_unsorted([0, 1, 2, 3], std::iter::empty()); + let a_first = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let a_second = FaultMechanism::from_unsorted([2, 3], std::iter::empty()); + let b_first = FaultMechanism::from_unsorted([0, 2], std::iter::empty()); + let b_second = FaultMechanism::from_unsorted([1, 3], std::iter::empty()); + let mut dem = DetectorErrorModel::new(); + + dem.add_direct_contribution_with_source_components( + effect.clone(), + 0.01, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::X, Pauli::Z], + &[GateType::CX, GateType::CX], + &[false, false], + ), + DirectSourceComponents::new(&a_first, &a_second), + ); + dem.add_direct_contribution_with_source_components( + effect, + 0.02, + SourceMetadata::new( + &[5usize, 6usize], + &[Pauli::Z, Pauli::X], + &[GateType::CX, GateType::CX], + &[false, false], + ), + DirectSourceComponents::new(&b_first, &b_second), + ); + + let records = dem.contribution_render_records(); + assert_eq!(records.len(), 2); + assert_eq!(records[0].rendered_targets, "D0 D1 ^ D2 D3"); + assert_eq!(records[1].rendered_targets, "D0 D2 ^ D1 D3"); + assert_eq!( + records[0].render_strategy, + ContributionRenderStrategy::RecordedComponents + ); + assert_eq!( + records[1].render_strategy, + ContributionRenderStrategy::RecordedComponents + ); + + let decomposed = dem.to_string_decomposed(); + assert!(decomposed.contains("error(0.01) D0 D1 ^ D2 D3")); + assert!(decomposed.contains("error(0.02) D0 D2 ^ D1 D3")); + } + + #[test] + fn test_decomposed_render_uses_recorded_hyperedge_components_without_global_search() { + let first = FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()); + let second = FaultMechanism::from_unsorted([3], std::iter::empty()); + let effect = first.xor(&second); + let mut dem = DetectorErrorModel::new(); + + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::X, Pauli::Z], + &[GateType::CX, GateType::CX], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + + let decomposed = dem.to_string_decomposed(); + assert!(decomposed.contains("error(0.01) D0 D1 D2 ^ D3")); + + let records = dem.contribution_render_records(); + assert_eq!(records.len(), 1); + assert_eq!( + records[0].render_strategy, + ContributionRenderStrategy::RecordedComponents + ); + assert_eq!( + records[0].recorded_component_targets.as_deref(), + Some("D0 D1 D2 ^ D3") + ); + } + + #[test] + fn test_graphlike_search_decomposed_is_explicit_compatibility_path() { + let mut dem = DetectorErrorModel::new(); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([0, 1], std::iter::empty()), + 0.001, + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([2], std::iter::empty()), + 0.001, + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()), + 0.01, + ); + + let source_decomposed = dem.to_string_decomposed(); + assert!(source_decomposed.contains("error(0.01) D0 D1 D2")); + assert!(!source_decomposed.contains("error(0.01) D0 D1 ^ D2")); + + let graphlike_search = dem.to_string_graphlike_search_decomposed(); + assert!(graphlike_search.contains("error(0.01) D0 D1 ^ D2")); + } + + #[test] + fn test_source_graphlike_decomposed_uses_source_component_graphlike_pieces() { + let first = FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()); + let second = FaultMechanism::from_unsorted([3], std::iter::empty()); + let effect = first.xor(&second); + let mut dem = DetectorErrorModel::new(); + + // These graphlike pieces are not arbitrary full effects; they are + // carried as source components on tracked contributions. + dem.add_direct_contribution_with_source_components( + FaultMechanism::from_unsorted([0, 1], std::iter::empty()), + 0.001, + SourceMetadata::new( + &[10usize, 11usize], + &[Pauli::X, Pauli::I], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + DirectSourceComponents::new( + &FaultMechanism::from_unsorted([0, 1], std::iter::empty()), + &FaultMechanism::new(), + ), + ); + dem.add_direct_contribution_with_source_components( + FaultMechanism::from_unsorted([2], std::iter::empty()), + 0.001, + SourceMetadata::new( + &[12usize, 13usize], + &[Pauli::Z, Pauli::I], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + DirectSourceComponents::new( + &FaultMechanism::from_unsorted([2], std::iter::empty()), + &FaultMechanism::new(), + ), + ); + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::X, Pauli::Z], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + + let source_preserved = dem.to_string_source_decomposed(); + assert!(source_preserved.contains("error(0.01) D0 D1 D2 ^ D3")); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + assert!(source_graphlike.contains("error(0.01) D0 D1 ^ D2 ^ D3")); + } + + #[test] + fn test_source_graphlike_decomposed_rejects_global_only_decomposition() { + let mut dem = DetectorErrorModel::new(); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([0, 1], std::iter::empty()), + 0.001, + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([2], std::iter::empty()), + 0.001, + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()), + 0.01, + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + assert!(source_graphlike.contains("error(0.01) D0 D1 D2")); + assert!(!source_graphlike.contains("error(0.01) D0 D1 ^ D2")); + + let graphlike_search = dem.to_string_graphlike_search_decomposed(); + assert!(graphlike_search.contains("error(0.01) D0 D1 ^ D2")); + } + + #[test] + fn test_source_graphlike_decomposed_uses_source_remnant_edges() { + let mut dem = DetectorErrorModel::new(); + + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([1, 2], std::iter::empty()), + 0.001, + SourceMetadata::new(&[10usize], &[Pauli::X], &[GateType::H], &[false]), + ); + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([0, 1, 2], [0]), + 0.01, + SourceMetadata::new(&[11usize], &[Pauli::X], &[GateType::PZ], &[false]), + ); + + let source_preserved = dem.to_string_source_decomposed(); + assert!(source_preserved.contains("error(0.01) D0 D1 D2 L0")); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + assert!( + source_graphlike.contains("error(0.01) D1 D2 ^ D0 L0") + || source_graphlike.contains("error(0.01) D0 L0 ^ D1 D2") + ); + } + + #[test] + fn test_source_graphlike_decomposed_uses_exact_detector_graph_paths() { + let mut dem = DetectorErrorModel::new(); + let graphlike_parts = [ + FaultMechanism::from_unsorted([10, 13], std::iter::empty()), + FaultMechanism::from_unsorted([13, 40], std::iter::empty()), + FaultMechanism::from_unsorted([651, 655], std::iter::empty()), + FaultMechanism::from_unsorted([658, 661], std::iter::empty()), + FaultMechanism::from_unsorted([661, 664], std::iter::empty()), + FaultMechanism::from_unsorted([659], std::iter::empty()), + ]; + for (idx, part) in graphlike_parts.iter().enumerate() { + dem.add_direct_contribution_with_source( + part.clone(), + 0.001, + SourceMetadata::new(&[idx], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + let effect = graphlike_parts + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); + assert_eq!( + effect, + FaultMechanism::from_unsorted([10, 40, 651, 655, 658, 659, 664], std::iter::empty(),) + ); + dem.add_direct_contribution_with_source( + effect, + 0.01, + SourceMetadata::new(&[99usize], &[Pauli::X], &[GateType::SZZ], &[false]), + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected source graphlike decomposition for target hyperedge"); + for expected in [ + "D10 D13", + "D13 D40", + "D651 D655", + "D658 D661", + "D659", + "D661 D664", + ] { + assert!( + target_line.contains(expected), + "missing {expected} in {target_line}", + ); + } + assert!( + !target_line.contains("D10 D40 D651"), + "target hyperedge should be split through exact graph paths: {target_line}", + ); + } + + #[test] + fn test_source_graphlike_path_decomposition_matches_logical_frame() { + let mut dem = DetectorErrorModel::new(); + let first = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let second = FaultMechanism::from_unsorted([1, 2], [0]); + let third = FaultMechanism::from_unsorted([3], std::iter::empty()); + for (idx, part) in [&first, &second, &third].into_iter().enumerate() { + dem.add_direct_contribution_with_source( + part.clone(), + 0.001, + SourceMetadata::new(&[idx], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + let effect = first.xor(&second).xor(&third); + assert_eq!(effect, FaultMechanism::from_unsorted([0, 2, 3], [0])); + dem.add_direct_contribution_with_source( + effect, + 0.01, + SourceMetadata::new(&[9usize], &[Pauli::X], &[GateType::SZZ], &[false]), + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected source graphlike decomposition for logical target"); + assert!(target_line.contains("D0 D1")); + assert!(target_line.contains("D1 D2 L0")); + assert!(target_line.contains("D3")); + assert!(!target_line.contains("D0 D2 D3 L0")); + } + + #[test] + fn test_source_graphlike_path_decomposition_handles_boundary_cluster() { + let mut dem = DetectorErrorModel::new(); + let graphlike_parts = [ + FaultMechanism::from_unsorted([14, 40], std::iter::empty()), + FaultMechanism::from_unsorted([40, 44], std::iter::empty()), + FaultMechanism::from_unsorted([654], [0]), + FaultMechanism::from_unsorted([662], std::iter::empty()), + FaultMechanism::from_unsorted([663, 666], std::iter::empty()), + FaultMechanism::from_unsorted([668, 671], std::iter::empty()), + FaultMechanism::from_unsorted([671], std::iter::empty()), + ]; + for (idx, part) in graphlike_parts.iter().enumerate() { + dem.add_direct_contribution_with_source( + part.clone(), + 0.001, + SourceMetadata::new(&[idx], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + let effect = graphlike_parts + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); + assert_eq!( + effect, + FaultMechanism::from_unsorted([14, 44, 654, 662, 663, 666, 668], [0]) + ); + dem.add_direct_contribution_with_source( + effect, + 0.01, + SourceMetadata::new(&[99usize], &[Pauli::X], &[GateType::SZZ], &[false]), + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected source graphlike decomposition for boundary cluster"); + for expected in [ + "D14 D40", + "D40 D44", + "D654 L0", + "D662", + "D663 D666", + "D668 D671", + "D671", + ] { + assert!( + target_line.contains(expected), + "missing {expected} in {target_line}", + ); + } + assert!(!target_line.contains("D14 D44 D654")); + } + + #[test] + fn test_source_graphlike_closure_learns_full_source_alternatives() { + let mut dem = DetectorErrorModel::new(); + + let hidden_pair = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let first = FaultMechanism::from_unsorted([2, 3, 4], std::iter::empty()); + let second = FaultMechanism::from_unsorted([0, 1, 2, 3, 4], std::iter::empty()); + assert_eq!(first.xor(&second), hidden_pair); + + dem.add_direct_contribution_with_source_components( + hidden_pair, + 0.001, + SourceMetadata::new( + &[20usize, 21usize], + &[Pauli::X, Pauli::Z], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([6], std::iter::empty()), + 0.001, + SourceMetadata::new(&[22usize], &[Pauli::X], &[GateType::H], &[false]), + ); + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([0, 1, 6], std::iter::empty()), + 0.01, + SourceMetadata::new(&[23usize], &[Pauli::X], &[GateType::PZ], &[false]), + ); + + let source_preserved = dem.to_string_source_decomposed(); + assert!(source_preserved.contains("error(0.01) D0 D1 D6")); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + assert!(source_graphlike.contains("error(0.01) D0 D1 ^ D6")); + } + + #[test] + fn test_source_graphlike_closure_promotes_remnant_edges_for_later_paths() { + let mut dem = DetectorErrorModel::new(); + + for (loc, effect) in [ + ( + 10usize, + FaultMechanism::from_unsorted([100, 101], std::iter::empty()), + ), + ( + 11usize, + FaultMechanism::from_unsorted([102, 103], std::iter::empty()), + ), + ( + 12usize, + FaultMechanism::from_unsorted([200, 201], std::iter::empty()), + ), + ] { + dem.add_direct_contribution_with_source( + effect, + 0.001, + SourceMetadata::new(&[loc], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + // These two source mechanisms introduce graphlike remnant edges D2-D28 + // and D28-D32. A later source mechanism needs both as a detector-graph + // path from D2 to D32. + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([2, 28, 100, 101], std::iter::empty()), + 0.002, + SourceMetadata::new(&[20usize], &[Pauli::X], &[GateType::SX], &[false]), + ); + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([28, 32, 102, 103], std::iter::empty()), + 0.002, + SourceMetadata::new(&[21usize], &[Pauli::X], &[GateType::SX], &[false]), + ); + + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([2, 32, 200, 201], std::iter::empty()), + 0.01, + SourceMetadata::new(&[22usize], &[Pauli::Z], &[GateType::SZZ], &[false]), + ); + + let closure = dem.collect_source_graphlike_closure(); + assert!( + closure + .mechanisms + .contains(&FaultMechanism::from_unsorted([2, 28], std::iter::empty(),)) + ); + assert!( + closure + .mechanisms + .contains(&FaultMechanism::from_unsorted([28, 32], std::iter::empty(),)) + ); + let target = FaultMechanism::from_unsorted([2, 32, 200, 201], std::iter::empty()); + let index = GraphlikeDecompositionIndex::from_source_closure(&closure); + let self_remnant = FaultMechanism::from_unsorted([2, 32], std::iter::empty()); + assert!( + !index.candidate_allowed(&self_remnant, Some(&target)), + "self remnant provenance: primitive={} origins={:?}", + closure.primitive.contains(&self_remnant), + closure.derived_origins.get(&self_remnant), + ); + let graph_path = index + .find_graph_path_decomposition(&target, Some(&target)) + .expect("expected promoted remnant edges to produce a graph path"); + assert!( + graph_path.contains(&FaultMechanism::from_unsorted([2, 28], std::iter::empty(),)), + "{graph_path:?}", + ); + assert!( + graph_path.contains(&FaultMechanism::from_unsorted([28, 32], std::iter::empty(),)), + "{graph_path:?}", + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected later hyperedge to decompose through promoted remnant edges"); + for expected in ["D2 D28", "D28 D32", "D200 D201"] { + assert!( + target_line.contains(expected), + "missing {expected} in {target_line}", + ); + } + assert!(!target_line.contains("D2 D32 D200 D201")); + } + + #[test] + fn test_source_graphlike_recorded_components_can_use_full_effect_decomposition() { + let mut dem = DetectorErrorModel::new(); + + for (loc, effect) in [ + ( + 30usize, + FaultMechanism::from_unsorted([0, 3], std::iter::empty()), + ), + ( + 31usize, + FaultMechanism::from_unsorted([1, 4], std::iter::empty()), + ), + ( + 32usize, + FaultMechanism::from_unsorted([2, 5], std::iter::empty()), + ), + ] { + dem.add_direct_contribution_with_source( + effect, + 0.001, + SourceMetadata::new(&[loc], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + let first = FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()); + let second = FaultMechanism::from_unsorted([3, 4, 5], std::iter::empty()); + let effect = first.xor(&second); + + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[40usize, 41usize], + &[Pauli::X, Pauli::X], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + + let source_preserved = dem.to_string_source_decomposed(); + assert!(source_preserved.contains("error(0.01) D0 D1 D2 ^ D3 D4 D5")); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + assert!(source_graphlike.contains("D0 D3")); + assert!(source_graphlike.contains("D1 D4")); + assert!(source_graphlike.contains("D2 D5")); + assert!(!source_graphlike.contains("error(0.01) D0 D1 D2 ^ D3 D4 D5")); + } + + #[test] + fn test_decomposed_render_cancels_duplicate_components_by_parity() { + let mut dem = DetectorErrorModel::new(); + let repeated = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let survivor = FaultMechanism::from_unsorted([2, 3], std::iter::empty()); + let effect = survivor.clone(); + + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[50usize, 51usize, 52usize], + &[Pauli::X, Pauli::X, Pauli::Z], + &[GateType::SZZ, GateType::SZZ, GateType::SZZ], + &[false, false, false], + ), + DirectSourceComponents::from_slice(&[repeated.clone(), repeated, survivor]), + ); + + let source_decomposed = dem.to_string_source_decomposed(); + assert!(source_decomposed.contains("error(0.01) D2 D3")); + assert!(!source_decomposed.contains("D0 D1")); + } + + #[test] + fn test_spp_source_components_preserve_recorded_hypergraph_structure() { + for gate_type in [GateType::SZZ, GateType::SZZdg] { + let first = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let second = FaultMechanism::from_unsorted([2, 3, 4], std::iter::empty()); + let effect = first.xor(&second); + let mut dem = DetectorErrorModel::new(); + + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::X, Pauli::Z], + &[gate_type, gate_type], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + + let source_decomposed = dem.to_string_source_decomposed(); + assert!(source_decomposed.contains("error(0.01) D0 D1 ^ D2 D3 D4")); + + let records = dem.contribution_render_records(); + assert_eq!(records.len(), 1); + assert_eq!( + records[0].render_strategy, + ContributionRenderStrategy::RecordedComponents + ); + assert_eq!( + records[0].contribution.source_gate_types.as_slice(), + &[gate_type, gate_type] + ); + } + } + + #[test] + fn test_spp_y_source_decomposition_preserves_hyperedge_branch() { + let x_effect = FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()); + let z_effect = FaultMechanism::from_unsorted([3, 4], std::iter::empty()); + let mut dem = DetectorErrorModel::new(); + + dem.add_y_decomposed_contribution_with_source( + &x_effect, + &z_effect, + 0.02, + SourceMetadata::new( + &[5usize, 6usize], + &[Pauli::Y, Pauli::I], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + ); + + let source_decomposed = dem.to_string_source_decomposed(); + assert!(source_decomposed.contains("error(0.02) D0 D1 D2 ^ D3 D4")); + + let records = dem.contribution_render_records(); + assert_eq!(records.len(), 1); + assert_eq!( + records[0].render_strategy, + ContributionRenderStrategy::SourceComponents + ); + assert_eq!( + records[0].contribution.source_gate_types.as_slice(), + &[GateType::SZZ, GateType::SZZ] + ); + } + #[test] fn test_direct_with_source_components_marks_one_sided_component_sources() { let effect = FaultMechanism::from_unsorted([7, 11], std::iter::empty()); diff --git a/python/pecos-rslib/src/decoder_bindings.rs b/python/pecos-rslib/src/decoder_bindings.rs index bf9d27879..ac9f51ac5 100644 --- a/python/pecos-rslib/src/decoder_bindings.rs +++ b/python/pecos-rslib/src/decoder_bindings.rs @@ -321,7 +321,7 @@ impl PyCheckMatrix { /// H = [[1, 1, 0], [0, 1, 1]] /// decoder = PyMatchingDecoder.from_check_matrix(CheckMatrix.from_dense(H)) /// -/// # From Stim detector error model +/// # From detector error model /// decoder = PyMatchingDecoder.from_dem(dem_string) /// /// # Manual graph construction (like PyMatching's add_edge) @@ -420,7 +420,7 @@ impl PyPyMatchingDecoder { .map_err(|e| PyErr::new::(e.to_string())) } - /// Create decoder from a Stim Detector Error Model. + /// Create decoder from a Detector Error Model. /// /// This mirrors `PyMatching`'s `Matching.from_detector_error_model()`. /// @@ -441,6 +441,18 @@ impl PyPyMatchingDecoder { .map_err(|e| PyErr::new::(e.to_string())) } + /// Create decoder from a Detector Error Model with correlation support. + /// + /// When enabled, PyMatching preserves DEM decomposition correlations while + /// constructing and decoding the matching graph. + #[staticmethod] + #[pyo3(signature = (dem, enable_correlations=true))] + fn from_dem_with_correlations(dem: &str, enable_correlations: bool) -> PyResult { + RustPyMatchingDecoder::from_dem_with_correlations(dem, enable_correlations) + .map(|inner| Self { inner }) + .map_err(|e| PyErr::new::(e.to_string())) + } + /// Add an edge between two detector nodes. /// /// This mirrors `PyMatching`'s `Matching.add_edge()`. diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 6942b5a40..95d9051b5 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -1227,11 +1227,11 @@ impl PyDetectorErrorModel { self.inner.to_string() } - /// Convert the DEM to a string with decomposed representations. + /// Convert the DEM to a string with source-decomposed representations. /// - /// For 2-detector mechanisms, outputs multiple equivalent representations - /// including L0 cancellation forms where available. Hyperedge errors - /// (affecting 3+ detectors) are decomposed into graphlike components. + /// Faults are decomposed only using component structure attached to the + /// original source contribution. Residual hyperedges remain hyperedges + /// instead of being rewritten by an ambient graphlike search. /// /// Returns: /// A string in DEM format with decomposed representations. @@ -1239,6 +1239,33 @@ impl PyDetectorErrorModel { self.inner.to_string_decomposed() } + /// Convert the DEM to source-decomposed text. + /// + /// Only decomposition components attached to the original fault source are + /// used. Residual hyperedges remain hyperedges instead of being rewritten + /// by an ambient graphlike search. + fn to_string_source_decomposed(&self) -> String { + self.inner.to_string_source_decomposed() + } + + /// Convert the DEM to a source-informed graphlike decomposition. + /// + /// Source-carried components are recursively decomposed only using + /// graphlike pieces that are themselves source-carried components in this + /// DEM. Residual hyperedges remain hyperedges. + fn to_string_source_graphlike_decomposed(&self) -> String { + self.inner.to_string_source_graphlike_decomposed() + } + + /// Convert the DEM using the explicit historical graphlike-search renderer. + /// + /// This may decompose residual hyperedges by searching for graphlike + /// mechanisms elsewhere in the DEM, so it should be treated as a + /// compatibility/diagnostic representation rather than source proof. + fn to_string_graphlike_search_decomposed(&self) -> String { + self.inner.to_string_graphlike_search_decomposed() + } + /// Convert the DEM to a string with an explicit direct-2det render policy. fn to_string_decomposed_with_two_detector_direct_policy( &self, @@ -1342,6 +1369,19 @@ impl PyDetectorErrorModel { .collect() } + /// Returns per-contribution render records for the source-informed + /// graphlike renderer. + fn contribution_source_graphlike_render_records( + &self, + py: Python<'_>, + ) -> PyResult>> { + self.inner + .contribution_source_graphlike_render_records() + .into_iter() + .map(|record| contribution_render_record_to_pydict(py, record, &self.inner)) + .collect() + } + /// Returns per-contribution render records under an explicit direct-2det /// render policy. fn contribution_render_records_with_two_detector_direct_policy( diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 3307f294c..9bd615176 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -67,7 +67,8 @@ def generate_guppy_source( 4-round CX schedule restricted to that batch's stabilizers, measure, then move to the next batch (which allocates fresh qubits whose physical slots are reused by Selene's lowering). - The same per-stabilizer ``result("...:meas:N", …)`` calls fire + Per-stabilizer counted-round ``result("...:meas:N", …)`` calls + and prep-boundary ``result("...:init:meas:N", …)`` calls fire in the abstract's batched measurement order, keeping detector record offsets transferable between abstract and traced paths. @@ -289,6 +290,128 @@ def generate_guppy_source( ], ) + def append_init_syndrome_function(function_name: str, stab_type: str) -> None: + """Append a basis-prep syndrome-establishment helper.""" + if stab_type == "X": + stabs = list(geom.x_stabilizers) + return_type = f"array[bool, {num_x_stab}]" + return_calls = ", ".join(f"sx{s.index}" for s in stabs) + doc = "Establish initial X stabilizer signs after Z-basis data prep." + else: + stabs = list(geom.z_stabilizers) + return_type = f"array[bool, {num_z_stab}]" + return_calls = ", ".join(f"sz{s.index}" for s in stabs) + doc = "Establish initial Z stabilizer signs after X-basis data prep." + + lines.extend( + [ + "", + "", + "@guppy", + f"def {function_name}(surf: SurfaceCode_{dx}x{dz}) -> {return_type}:", + f' """{doc}"""', + ], + ) + + if not constrained: + if stab_type == "X": + lines.extend(f" ax{stab.index} = qubit()" for stab in stabs) + lines.append("") + lines.append(" # Hadamard on X ancillas") + lines.extend(f" h(ax{stab.index})" for stab in stabs) + else: + lines.extend(f" az{stab.index} = qubit()" for stab in stabs) + + for rnd_idx, rnd_gates in enumerate(rounds): + filtered = [(t, i, q) for t, i, q in rnd_gates if t == stab_type] + if not filtered: + continue + lines.append("") + lines.append(f" # Round {rnd_idx + 1}") + for _stab_type, stab_idx, data_q in filtered: + if stab_type == "X": + lines.append(f" cx(ax{stab_idx}, surf.data[{data_q}])") + else: + lines.append(f" cx(surf.data[{data_q}], az{stab_idx})") + + if stab_type == "X": + lines.append("") + lines.append(" # Hadamard on X ancillas") + lines.extend(f" h(ax{stab.index})" for stab in stabs) + + lines.append("") + lines.append(" # Measure init ancillas") + for idx, stab in enumerate(stabs): + if stab_type == "X": + lines.append(f" sx{stab.index} = measure(ax{stab.index})") + lines.append(f' result("sx{stab.index}:init:meas:{idx}", sx{stab.index})') + else: + lines.append(f" sz{stab.index} = measure(az{stab.index})") + lines.append(f' result("sz{stab.index}:init:meas:{idx}", sz{stab.index})') + else: + batches = batched_stabilizers(patch, effective_budget) + idx = 0 + for batch_idx, batch in enumerate(batches): + init_batch = [(t, i) for t, i in batch if t == stab_type] + if not init_batch: + continue + lines.append("") + lines.append(f" # Batch {batch_idx + 1}/{len(batches)} of {stab_type} stabilizers") + + batch_anc_var: dict[tuple[str, int], str] = {} + for pos, (selected_type, stab_idx) in enumerate(init_batch): + var = f"_init_a_b{batch_idx}_p{pos}" + batch_anc_var[(selected_type, stab_idx)] = var + lines.append(f" {var} = qubit()") + + if stab_type == "X": + lines.append(" # Hadamard on X ancillas in this batch") + for selected_type, stab_idx in init_batch: + lines.append(f" h({batch_anc_var[(selected_type, stab_idx)]})") + + batch_keys = set(batch_anc_var.keys()) + for rnd_idx, rnd_gates in enumerate(rounds): + rnd_in_batch = [ + (selected_type, stab_idx, data_q) + for selected_type, stab_idx, data_q in rnd_gates + if (selected_type, stab_idx) in batch_keys + ] + if not rnd_in_batch: + continue + lines.append("") + lines.append(f" # Batch {batch_idx + 1} round {rnd_idx + 1}") + for selected_type, stab_idx, data_q in rnd_in_batch: + anc = batch_anc_var[(selected_type, stab_idx)] + if selected_type == "X": + lines.append(f" cx({anc}, surf.data[{data_q}])") + else: + lines.append(f" cx(surf.data[{data_q}], {anc})") + + if stab_type == "X": + lines.append("") + lines.append(" # Hadamard on X ancillas in this batch") + for selected_type, stab_idx in init_batch: + lines.append(f" h({batch_anc_var[(selected_type, stab_idx)]})") + + lines.append("") + lines.append(f" # Measure init batch {batch_idx + 1} ancillas") + for selected_type, stab_idx in init_batch: + anc = batch_anc_var[(selected_type, stab_idx)] + syn_var = f"sx{stab_idx}" if selected_type == "X" else f"sz{stab_idx}" + lines.append(f" {syn_var} = measure({anc})") + lines.append(f' result("{syn_var}:init:meas:{idx}", {syn_var})') + idx += 1 + + lines.extend( + [ + "", + f" return array({return_calls})", + ], + ) + + append_init_syndrome_function("init_z_basis", "X") + append_init_syndrome_function("init_x_basis", "Z") + # Generate measurement lines.extend( [ @@ -359,6 +482,8 @@ def generate_guppy_source( " def memory_z() -> None:", f' """Z-basis memory experiment for dx={dx}, dz={dz}."""', " surf = prep_z_basis()", + " init_syn = init_z_basis(surf)", + ' result("init_synx", init_syn)', "", " for _t in range(comptime(num_rounds)):", " syn = syndrome_extraction(surf)", @@ -379,6 +504,8 @@ def generate_guppy_source( " def memory_x() -> None:", f' """X-basis memory experiment for dx={dx}, dz={dz}."""', " surf = prep_x_basis()", + " init_syn = init_x_basis(surf)", + ' result("init_synz", init_syn)', "", " for _t in range(comptime(num_rounds)):", " syn = syndrome_extraction(surf)", diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index e7ae7fd45..e4f1a9a46 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -210,6 +210,7 @@ def from_guppy( scalar ``result(tag, measure(q))`` in straight-line programs; the runtime-loop case (per-occurrence binding) remains deferred. """ + from pecos.qec.surface.circuit_builder import normalize_traced_qis_tick_circuit from pecos.qec.surface.decode import trace_guppy_into_tick_circuit # Tag-referenced detectors require the compiled HUGR (to recover the @@ -240,11 +241,10 @@ def from_guppy( tc = trace_guppy_into_tick_circuit(guppy, num_qubits, seed=seed, runtime=runtime) # Compilation passes required for traced QIS circuits before fault - # analysis: normalize parameterized Clifford rotations to named gates - # and stamp stable MeasIds onto measurement gates. After this every - # MZ carries the stable id the Rust builder resolves meas_ids against. - tc.lower_clifford_rotations() - tc.assign_missing_meas_ids() + # 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_qis_tick_circuit(tc, context="DetectorErrorModel.from_guppy") # Resolve `result_tags` -> record offsets via Rust (sound HUGR # extraction + runtime-loop guard via static-vs-traced measurement diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index b748a5eea..e7e4e5862 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -161,8 +161,9 @@ def build_surface_code_circuit( This generates the circuit structure matching the Guppy implementation: 1. prep_{basis}_basis: Allocate and prepare data qubits - 2. syndrome_extraction x num_rounds: Syndrome extraction with fresh ancillas - 3. measure_{basis}_basis: Final data qubit measurement + 2. init syndrome establishment for the random-sign stabilizer family + 3. syndrome_extraction x num_rounds: Syndrome extraction with fresh ancillas + 4. measure_{basis}_basis: Final data qubit measurement Args: patch: Surface code patch with geometry @@ -240,6 +241,152 @@ def z_anc_q(stab_idx: int) -> int: ops.append(SurfaceCircuitStep(OpType.TICK)) + # ========================================================================= + # init_{basis}_basis syndrome establishment + # ========================================================================= + # Data prep fixes only the stabilizers matching the memory basis. Measure + # the complementary stabilizer family once to establish its random signs; + # this is logical state prep and is intentionally not counted in + # `num_rounds`. + init_stabilizer_type = "X" if basis.upper() == "Z" else "Z" + ops.append( + SurfaceCircuitStep( + OpType.COMMENT, + label=f"init_{init_stabilizer_type.lower()}_syndrome", + ), + ) + if effective_ancilla_budget == total_ancilla: + init_stabilizers = geom.x_stabilizers if init_stabilizer_type == "X" else geom.z_stabilizers + init_anc_q = x_anc_q if init_stabilizer_type == "X" else z_anc_q + + ops.extend( + SurfaceCircuitStep( + OpType.ALLOC, + [init_anc_q(s.index)], + f"a{init_stabilizer_type.lower()}{s.index}", + ) + for s in init_stabilizers + ) + + if init_stabilizer_type == "X": + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on X ancillas")) + ops.extend(SurfaceCircuitStep(OpType.H, [x_anc_q(s.index)], f"ax{s.index}") for s in init_stabilizers) + + ops.append(SurfaceCircuitStep(OpType.TICK)) + + for rnd_idx, cx_round in enumerate(cnot_rounds): + ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"CX round {rnd_idx + 1}")) + for stab_type, stab_idx, data_idx in cx_round: + if stab_type != init_stabilizer_type: + continue + if stab_type == "X": + ops.append( + SurfaceCircuitStep( + OpType.CX, + [x_anc_q(stab_idx), data_q(data_idx)], + f"X{stab_idx}", + ), + ) + else: + ops.append( + SurfaceCircuitStep( + OpType.CX, + [data_q(data_idx), z_anc_q(stab_idx)], + f"Z{stab_idx}", + ), + ) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + if init_stabilizer_type == "X": + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on X ancillas")) + ops.extend(SurfaceCircuitStep(OpType.H, [x_anc_q(s.index)], f"ax{s.index}") for s in init_stabilizers) + + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Measure ancillas")) + init_label_prefix = "sx" if init_stabilizer_type == "X" else "sz" + ops.extend( + SurfaceCircuitStep( + OpType.MEASURE, + [init_anc_q(s.index)], + f"{init_label_prefix}{s.index}", + ) + for s in init_stabilizers + ) + + ops.append(SurfaceCircuitStep(OpType.TICK)) + else: + stabilizer_batches = _batched_stabilizers(patch, effective_ancilla_budget) + for batch in stabilizer_batches: + init_batch = [(stab_type, stab_idx) for stab_type, stab_idx in batch if stab_type == init_stabilizer_type] + if not init_batch: + continue + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Prepare ancillas")) + batch_ancillas = { + (stab_type, stab_idx): x_anc_q(stab_idx) if stab_type == "X" else z_anc_q(stab_idx) + for stab_type, stab_idx in init_batch + } + + for stab_type, stab_idx in init_batch: + ops.append( + SurfaceCircuitStep( + OpType.ALLOC, + [batch_ancillas[(stab_type, stab_idx)]], + f"a{stab_type.lower()}{stab_idx}", + ), + ) + + if init_stabilizer_type == "X": + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on X ancillas")) + ops.extend( + SurfaceCircuitStep(OpType.H, [batch_ancillas[("X", stab_idx)]], f"ax{stab_idx}") + for _stab_type, stab_idx in init_batch + ) + + ops.append(SurfaceCircuitStep(OpType.TICK)) + + for rnd_idx, cx_round in enumerate(cnot_rounds): + ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"CX round {rnd_idx + 1}")) + for stab_type, stab_idx, data_idx in cx_round: + ancilla_q = batch_ancillas.get((stab_type, stab_idx)) + if ancilla_q is None: + continue + if stab_type == "X": + ops.append( + SurfaceCircuitStep( + OpType.CX, + [ancilla_q, data_q(data_idx)], + f"X{stab_idx}", + ), + ) + else: + ops.append( + SurfaceCircuitStep( + OpType.CX, + [data_q(data_idx), ancilla_q], + f"Z{stab_idx}", + ), + ) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + if init_stabilizer_type == "X": + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on X ancillas")) + ops.extend( + SurfaceCircuitStep(OpType.H, [batch_ancillas[("X", stab_idx)]], f"ax{stab_idx}") + for _stab_type, stab_idx in init_batch + ) + + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Measure ancillas")) + for stab_type, stab_idx in init_batch: + measure_label = f"sx{stab_idx}" if stab_type == "X" else f"sz{stab_idx}" + ops.append( + SurfaceCircuitStep( + OpType.MEASURE, + [batch_ancillas[(stab_type, stab_idx)]], + measure_label, + ), + ) + + ops.append(SurfaceCircuitStep(OpType.TICK)) + # ========================================================================= # syndrome_extraction (called num_rounds times) # ========================================================================= @@ -569,10 +716,11 @@ def render( lines.append("") lines.append("# Detectors") - # Determine which stabilizer types are deterministic in round 0 - # Z-basis: Z stabilizers are deterministic (eigenvalue +1 on |0>) - # X-basis: X stabilizers are deterministic (eigenvalue +1 on |+>) + # Data prep fixes stabilizers matching the memory basis. The + # complementary family is random but has an explicit init + # measurement, which round 0 compares against. deterministic_type_round0 = "Z" if basis.upper() == "Z" else "X" + init_baseline_type = "X" if basis.upper() == "Z" else "Z" # Syndrome detectors for X stabilizers for rnd in range(num_rounds): @@ -583,12 +731,16 @@ def render( curr_offset = meas_count - curr_idx if rnd == 0: - # Only X stabilizers have deterministic round-0 detectors in X-basis - if deterministic_type_round0 == "X": + if init_baseline_type == "X": + init_idx = stab_meas_record[("X", s.index, -1)] + init_offset = meas_count - init_idx + lines.append( + f"DETECTOR({s.index}, 0, {rnd}) rec[{-curr_offset}] rec[{-init_offset}]", + ) + elif deterministic_type_round0 == "X": lines.append( f"DETECTOR({s.index}, 0, {rnd}) rec[{-curr_offset}]", ) - # In Z-basis, X stabilizers are random in round 0, skip single-record detector else: # Compare consecutive rounds (always valid) prev_idx = stab_meas_record[("X", s.index, rnd - 1)] @@ -607,12 +759,16 @@ def render( det_x = num_x_anc + s.index if rnd == 0: - # Only Z stabilizers have deterministic round-0 detectors in Z-basis - if deterministic_type_round0 == "Z": + if init_baseline_type == "Z": + init_idx = stab_meas_record[("Z", s.index, -1)] + init_offset = meas_count - init_idx + lines.append( + f"DETECTOR({det_x}, 1, {rnd}) rec[{-curr_offset}] rec[{-init_offset}]", + ) + elif deterministic_type_round0 == "Z": lines.append( f"DETECTOR({det_x}, 1, {rnd}) rec[{-curr_offset}]", ) - # In X-basis, Z stabilizers are random in round 0, skip single-record detector else: # Compare consecutive rounds (always valid) prev_idx = stab_meas_record[("Z", s.index, rnd - 1)] @@ -634,13 +790,15 @@ def render( for s in stabilizers: data_rec_offsets = [meas_count - (final_meas_start + dq) for dq in s.data_qubits] - last_syn_idx = stab_meas_record[(stab_type, s.index, num_rounds - 1)] - syn_offset = meas_count - last_syn_idx - rec_str = " ".join(f"rec[{-off}]" for off in data_rec_offsets) + record_offsets = [*data_rec_offsets] + if num_rounds > 0: + last_syn_idx = stab_meas_record[(stab_type, s.index, num_rounds - 1)] + record_offsets.append(meas_count - last_syn_idx) + rec_str = " ".join(f"rec[{-off}]" for off in record_offsets) det_x = s.index if stab_type == "X" else num_x_anc + s.index det_y = 0 if stab_type == "X" else 1 lines.append( - f"DETECTOR({det_x}, {det_y}, {num_rounds}) {rec_str} rec[{-syn_offset}]", + f"DETECTOR({det_x}, {det_y}, {num_rounds}) {rec_str}", ) # Logical observable @@ -930,6 +1088,15 @@ def mark_qubits_used(qubits: list[int]) -> None: """Mark qubits as used in current tick.""" qubits_in_current_tick.update(qubits) + def is_syndrome_context(phase: str, round_index: int) -> bool: + """Return whether the current context belongs to syndrome extraction.""" + if round_index >= 0 or phase.startswith("init_syndrome"): + return True + return round_index == -1 and ( + phase in {"syndrome_h_pre", "syndrome_h_post", "measure_ancilla"} + or phase.startswith("cx_round_") + ) + def gate_metadata(meta: dict | None = None) -> dict: """Build metadata for the current gate context. @@ -939,7 +1106,7 @@ def gate_metadata(meta: dict | None = None) -> dict: context: dict[str, object] = { "phase": current_phase, } - if current_round >= 0: + if is_syndrome_context(current_phase, current_round): context["syndrome_round"] = current_round if current_cx_round > 0: context["cx_round"] = current_cx_round @@ -966,11 +1133,19 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non current_round = int(op.label.split()[-1]) - 1 current_phase = "syndrome_prep" current_cx_round = 0 + elif "init_" in op.label and "syndrome" in op.label: + current_round = -1 + current_phase = "init_syndrome_prep" + current_cx_round = 0 elif "Prepare ancillas" in op.label: - current_phase = "syndrome_prep" + current_phase = "init_syndrome_prep" if current_round < 0 else "syndrome_prep" current_cx_round = 0 elif "Hadamard on X ancillas" in op.label: - current_phase = "syndrome_h_pre" if current_phase == "syndrome_prep" else "syndrome_h_post" + current_phase = ( + "syndrome_h_pre" + if current_phase in {"syndrome_prep", "init_syndrome_prep"} + else "syndrome_h_post" + ) elif "CX round" in op.label: current_cx_round = int(op.label.split()[-1]) current_phase = f"cx_round_{current_cx_round}" @@ -1080,7 +1255,7 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non for tick_idx, tick_meta in all_tick_metadata.items(): # Set tick-level metadata circuit.set_tick_meta(tick_idx, "phase", tick_meta["phase"]) - if tick_meta["round"] >= 0: + if is_syndrome_context(str(tick_meta["phase"]), int(tick_meta["round"])): circuit.set_tick_meta(tick_idx, "syndrome_round", tick_meta["round"]) if tick_meta["cx_round"] > 0: circuit.set_tick_meta(tick_idx, "cx_round", tick_meta["cx_round"]) @@ -1090,6 +1265,7 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non geom = patch.geometry num_x_anc = len(geom.x_stabilizers) deterministic_type_round0 = "Z" if basis.upper() == "Z" else "X" + init_baseline_type = "X" if basis.upper() == "Z" else "Z" detectors = [] detector_id = 0 @@ -1103,7 +1279,18 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non curr_offset = meas_count - curr_idx if rnd == 0: - if deterministic_type_round0 == "X": + if init_baseline_type == "X": + init_idx = stab_meas_record[("X", s.index, -1)] + init_offset = meas_count - init_idx + detectors.append( + { + "id": detector_id, + "coords": [s.index, 0, rnd], + "records": [-curr_offset, -init_offset], + }, + ) + detector_id += 1 + elif deterministic_type_round0 == "X": detectors.append( { "id": detector_id, @@ -1134,7 +1321,18 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non det_x = num_x_anc + s.index if rnd == 0: - if deterministic_type_round0 == "Z": + if init_baseline_type == "Z": + init_idx = stab_meas_record[("Z", s.index, -1)] + init_offset = meas_count - init_idx + detectors.append( + { + "id": detector_id, + "coords": [det_x, 1, rnd], + "records": [-curr_offset, -init_offset], + }, + ) + detector_id += 1 + elif deterministic_type_round0 == "Z": detectors.append( { "id": detector_id, @@ -1167,15 +1365,17 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non for s in stabilizers: data_rec_offsets = [-(meas_count - (final_meas_start + dq)) for dq in s.data_qubits] - last_syn_idx = stab_meas_record[(stab_type, s.index, num_rounds - 1)] - syn_offset = -(meas_count - last_syn_idx) + records = [*data_rec_offsets] + if num_rounds > 0: + last_syn_idx = stab_meas_record[(stab_type, s.index, num_rounds - 1)] + records.append(-(meas_count - last_syn_idx)) det_x = s.index if stab_type == "X" else num_x_anc + s.index det_y = 0 if stab_type == "X" else 1 detectors.append( { "id": detector_id, "coords": [det_x, det_y, num_rounds], - "records": [*data_rec_offsets, syn_offset], + "records": records, }, ) detector_id += 1 @@ -1204,6 +1404,7 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non stab_meas_refs, final_meas_refs_by_qubit, deterministic_type_round0, + init_baseline_type, ) circuit.set_meta("basis", basis.upper()) circuit.set_meta("ancilla_budget", str(allocation.total - len(allocation.data_qubits))) @@ -1219,6 +1420,7 @@ def _add_typed_annotations( stab_meas_refs: dict, final_meas_refs_by_qubit: dict, deterministic_type_round0: str, + init_baseline_type: str, ) -> None: """Add typed PauliAnnotation detectors and observables to the circuit. @@ -1232,7 +1434,10 @@ def _add_typed_annotations( if curr_refs is None: continue if rnd == 0: - if deterministic_type_round0 == "X": + if init_baseline_type == "X": + init_refs = stab_meas_refs.get(("X", s.index, -1), []) + circuit.detector(init_refs + curr_refs, label=f"Sx{s.index}_r{rnd}") + elif deterministic_type_round0 == "X": circuit.detector(curr_refs, label=f"Sx{s.index}_r{rnd}") else: prev_refs = stab_meas_refs.get(("X", s.index, rnd - 1), []) @@ -1245,7 +1450,10 @@ def _add_typed_annotations( if curr_refs is None: continue if rnd == 0: - if deterministic_type_round0 == "Z": + if init_baseline_type == "Z": + init_refs = stab_meas_refs.get(("Z", s.index, -1), []) + circuit.detector(init_refs + curr_refs, label=f"Sz{s.index}_r{rnd}") + elif deterministic_type_round0 == "Z": circuit.detector(curr_refs, label=f"Sz{s.index}_r{rnd}") else: prev_refs = stab_meas_refs.get(("Z", s.index, rnd - 1), []) @@ -1409,6 +1617,101 @@ def generate_tick_circuit_from_patch( return renderer.render(ops, allocation, patch, num_rounds, basis) +def normalize_traced_qis_tick_circuit( + tick_circuit: object, + *, + context: str = "traced-QIS DEM construction", +) -> None: + """Normalize a traced-QIS TickCircuit before DEM/DAG analysis. + + Selene/QIS traces may contain parameterized Clifford rotations such as + ``RZZ(pi/2)``. Fault analysis and replacement-branch noise models operate + on the named Clifford gates (``SZZ`` / ``SZZdg``), so callers should run + this helper at every traced-QIS boundary before converting to a DAG. + """ + _call_required_tick_circuit_method(tick_circuit, "lower_clifford_rotations", context) + _call_required_tick_circuit_method(tick_circuit, "assign_missing_meas_ids", context) + assert_traced_qis_tick_circuit_dem_ready(tick_circuit, context=context) + + +def assert_traced_qis_tick_circuit_dem_ready( + tick_circuit: object, + *, + context: str = "traced-QIS DEM construction", +) -> None: + """Fail loudly if raw traced-QIS rotations survived normalization.""" + offenders = _raw_traced_qis_rzz_gates(tick_circuit, context=context) + if not offenders: + return + + preview = "; ".join(offenders[:5]) + suffix = f"; ... {len(offenders) - 5} more" if len(offenders) > 5 else "" + msg = ( + f"{context}: traced-QIS circuit still contains raw RZZ gates after Clifford " + "normalization. DEM/DAG analysis expects Clifford RZZ(pi/2) and " + "RZZ(-pi/2) gates to be lowered to SZZ/SZZdg before noise attachment " + "and fault propagation. Call normalize_traced_qis_tick_circuit(...) " + "before to_dag_circuit(), or extend lower_clifford_rotations() for the " + f"runtime-emitted angle. First offending gates: {preview}{suffix}" + ) + raise ValueError(msg) + + +def _call_required_tick_circuit_method(tick_circuit: object, method_name: str, context: str) -> None: + method = getattr(tick_circuit, method_name, None) + if not callable(method): + msg = f"{context}: expected a TickCircuit with callable {method_name}()." + raise TypeError(msg) + method() + + +def _raw_traced_qis_rzz_gates(tick_circuit: object, *, context: str) -> list[str]: + try: + num_ticks = int(tick_circuit.num_ticks()) # type: ignore[attr-defined] + except AttributeError as exc: + msg = f"{context}: expected a TickCircuit with num_ticks() before DEM/DAG analysis." + raise TypeError(msg) from exc + + offenders: list[str] = [] + for tick_index in range(num_ticks): + try: + tick = tick_circuit.get_tick(tick_index) # type: ignore[attr-defined] + except AttributeError as exc: + msg = f"{context}: expected a TickCircuit with get_tick() before DEM/DAG analysis." + raise TypeError(msg) from exc + try: + gate_batches = tick.gate_batches() + except AttributeError as exc: + msg = f"{context}: expected TickCircuit ticks with gate_batches() before DEM/DAG analysis." + raise TypeError(msg) from exc + for gate_index, gate in enumerate(gate_batches): + if _gate_type_name(gate) != "RZZ": + continue + qubits = [int(q) for q in getattr(gate, "qubits", [])] + offenders.append( + f"tick={tick_index} gate={gate_index} qubits={qubits} angles={_gate_angles_for_message(gate)}", + ) + return offenders + + +def _gate_type_name(gate: object) -> str: + gate_type = getattr(gate, "gate_type", "") + return str(getattr(gate_type, "name", str(gate_type).rsplit(".", maxsplit=1)[-1])) + + +def _gate_angles_for_message(gate: object) -> list[str]: + angles = getattr(gate, "angles", None) + if angles is None: + angles = getattr(gate, "params", []) + formatted = [] + for angle in angles: + try: + formatted.append(repr(float(angle))) + except (TypeError, ValueError): + formatted.append(repr(angle)) + return formatted + + def get_detector_descriptors_from_tick_circuit( tick_circuit: TickCircuit, patch: SurfacePatch, @@ -2299,6 +2602,9 @@ def generate_dem_from_tick_circuit( if maximal_decomposition: return _maximally_decompose_graphlike_dem(dem.to_string_decomposed()) if decompose_errors: + source_graphlike = getattr(dem, "to_string_source_graphlike_decomposed", None) + if source_graphlike is not None: + return source_graphlike() return dem.to_string_decomposed() return dem.to_string() diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 8f474c42d..46a05888e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -573,90 +573,189 @@ def _remap_surface_record_metadata_json( def _surface_runtime_measurement_remap_from_result_traces( - patch: SurfacePatch, - num_rounds: int, + abstract_tc: Any, result_traces: list[dict[str, Any]], ) -> dict[int, int]: """Map abstract surface measurement indices to runtime ``result_id``s. - The generated surface Guppy emits scalar ``result("sx*/sz*:meas:N", bit)`` - calls for stabilizer measurements and one ``result("final", array(...))`` - call for data readout. Those tags survive runtime scheduling changes and - are the stable detector/observable anchor. Aggregate ``synx``/``synz`` tags - are deliberately ignored because they reread existing futures. + The generated surface Guppy emits scalar counted-round + ``result("sx*/sz*:meas:N", bit)`` tags, prep-boundary + ``result("sx*/sz*:init:meas:N", bit)`` tags, and one + ``result("final", array(...))`` call for data readout. The abstract + TickCircuit labels each measurement with the result tag it should bind to. + Those tags survive runtime scheduling changes and are the stable + detector/observable anchor. """ - import re - from collections import defaultdict - - syndrome_per_round = len(patch.geometry.x_stabilizers) + len(patch.geometry.z_stabilizers) - expected_syndrome_measurements = syndrome_per_round * num_rounds - expected_measurements = expected_syndrome_measurements + patch.geometry.num_data + num_measurements = int(abstract_tc.get_meta("num_measurements")) + scalar_trace_ids, array_trace_ids = _index_surface_result_trace_ids(result_traces) + abstract_refs = _surface_abstract_measurement_result_refs(abstract_tc) + if len(abstract_refs) != num_measurements: + msg = f"expected {num_measurements} abstract measurement refs, got {len(abstract_refs)}" + raise ValueError(msg) - scalar_tag_re = re.compile(r"^s[xz]\d+:meas:(\d+)$") - occurrence_by_tag: defaultdict[str, int] = defaultdict(int) + occurrence_by_tag: dict[str, int] = {} remap: dict[int, int] = {} - final_seen = False + for abstract_index, ref in enumerate(abstract_refs): + if ref[0] == "scalar": + _, name = ref + occurrence = occurrence_by_tag.get(name, 0) + occurrence_by_tag[name] = occurrence + 1 + try: + remap[abstract_index] = scalar_trace_ids[name][occurrence] + except (KeyError, IndexError) as exc: + msg = f"result tag {name!r} occurrence {occurrence} is missing from the runtime trace" + raise ValueError(msg) from exc + else: + _, name, element = ref + try: + remap[abstract_index] = array_trace_ids[name][0][element] + except (KeyError, IndexError) as exc: + msg = f"result tag {name!r}[{element}] is missing from the runtime trace" + raise ValueError(msg) from exc + runtime_ids = sorted(remap.values()) + if runtime_ids != list(range(num_measurements)): + msg = ( + "Runtime result-tag provenance is not a dense measurement-id range " + f"0..{num_measurements - 1}; got first/last " + f"{runtime_ids[:3]}...{runtime_ids[-3:]}" + ) + raise ValueError(msg) + return remap + + +def _index_surface_result_trace_ids( + result_traces: Sequence[Mapping[str, Any]], +) -> tuple[dict[str, list[int]], dict[str, list[list[int]]]]: + """Index runtime named-result provenance by tag name.""" + scalar_trace_ids: dict[str, list[int]] = {} + array_trace_ids: dict[str, list[list[int]]] = {} for trace in result_traces: name = trace.get("name") - result_ids = trace.get("result_ids") or [] - values = trace.get("values") or [] - if not isinstance(name, str): + values = trace.get("values") + result_ids = trace.get("result_ids") + if not isinstance(name, str) or not isinstance(values, list) or not isinstance(result_ids, list): continue + if len(values) != len(result_ids): + msg = ( + f"runtime result tag {name!r} has {len(values)} value(s) but " + f"{len(result_ids)} result id(s); cannot bind surface metadata" + ) + raise ValueError(msg) + ids = [int(result_id) for result_id in result_ids] + is_scalar_syndrome_tag = name.startswith(("sx", "sz")) and ":meas:" in name + if is_scalar_syndrome_tag and len(ids) == 1: + scalar_trace_ids.setdefault(name, []).append(ids[0]) + else: + array_trace_ids.setdefault(name, []).append(ids) + if not scalar_trace_ids and not array_trace_ids: + msg = "runtime trace does not contain named_result_traces; rebuild PECOS with result-tag provenance support" + raise ValueError(msg) + return scalar_trace_ids, array_trace_ids - match = scalar_tag_re.match(name) - if match is not None: - if len(result_ids) != 1 or len(values) != 1: - msg = f"Surface scalar result tag {name!r} must map to exactly one measurement result" - raise ValueError(msg) - meas_in_round = int(match.group(1)) - if not 0 <= meas_in_round < syndrome_per_round: - msg = ( - f"Surface scalar result tag {name!r} has per-round measurement index " - f"{meas_in_round}, outside [0, {syndrome_per_round})" - ) - raise ValueError(msg) - round_index = occurrence_by_tag[name] - occurrence_by_tag[name] += 1 - if round_index >= num_rounds: - msg = f"Surface scalar result tag {name!r} appears more than {num_rounds} round(s)" - raise ValueError(msg) - abstract_index = round_index * syndrome_per_round + meas_in_round - remap[abstract_index] = int(result_ids[0]) - elif name == "final": - if final_seen: - msg = "Surface traced result provenance has more than one final data result" - raise ValueError(msg) - final_seen = True - if len(result_ids) != patch.geometry.num_data or len(values) != patch.geometry.num_data: + +def _surface_abstract_measurement_result_refs(abstract_tc: Any) -> list[tuple[str, str] | tuple[str, str, int]]: + """Return the result-tag reference for each abstract surface measurement.""" + refs: list[tuple[str, str] | tuple[str, str, int]] = [] + syndrome_measure_index_by_round: dict[int, int] = {} + measurement_gate_types = {"MZ", "MeasureFree"} + for tick_index in range(abstract_tc.num_ticks()): + tick = abstract_tc.get_tick(tick_index) + if tick is None: + continue + for gate_index, gate in enumerate(tick.gate_batches()): + gate_type = str(getattr(gate, "gate_type", "")).rsplit(".", maxsplit=1)[-1] + if gate_type not in measurement_gate_types: + continue + label = str(abstract_tc.get_gate_meta(tick_index, gate_index, "label") or "") + if label.startswith(("sx", "sz")): + round_value = abstract_tc.get_gate_meta(tick_index, gate_index, "syndrome_round") + if round_value is None: + msg = f"surface syndrome measurement {label!r} is missing syndrome_round metadata" + raise ValueError(msg) + round_index = int(round_value) + measurement_index = syndrome_measure_index_by_round.get(round_index, 0) + syndrome_measure_index_by_round[round_index] = measurement_index + 1 + phase = "init:meas" if round_index < 0 else "meas" + refs.append(("scalar", f"{label}:{phase}:{measurement_index}")) + continue + if label.startswith("final[") and label.endswith("]"): + refs.append(("array", "final", int(label.removeprefix("final[").removesuffix("]")))) + continue + msg = f"surface measurement is missing a result-tag-compatible label: {label!r}" + raise ValueError(msg) + return refs + + +def _extract_measurement_meas_ids(tc: Any) -> list[int]: + """Return stable measurement ids in TickCircuit execution order.""" + ids: list[int] = [] + for tick_idx in range(tc.num_ticks()): + tick = tc.get_tick(tick_idx) + if tick is None: + continue + for gate in tick.gate_batches(): + gate_type = str(getattr(gate, "gate_type", "")).rsplit(".", maxsplit=1)[-1] + if gate_type not in {"MZ", "MeasureFree"}: + continue + qubits = list(getattr(gate, "qubits", [])) + meas_ids = list(getattr(gate, "meas_ids", [])) + if len(meas_ids) != len(qubits): msg = ( - "Surface final result tag must map to exactly " - f"{patch.geometry.num_data} data measurements" + f"traced measurement gate {gate_type} in tick {tick_idx} carries " + f"{len(meas_ids)} MeasId(s) for {len(qubits)} qubit(s)" ) raise ValueError(msg) - for offset, result_id in enumerate(result_ids): - remap[expected_syndrome_measurements + offset] = int(result_id) + ids.extend(int(meas_id) for meas_id in meas_ids) + return ids + - if len(remap) != expected_measurements: - missing = sorted(set(range(expected_measurements)) - set(remap)) +def _validate_result_tag_remap_against_traced_measurements( + traced_tc: Any, + measurement_index_remap: Mapping[int, int], + *, + expected_measurements: int, +) -> None: + """Fail loudly unless result-tag bindings exactly cover traced MeasIds.""" + expected_abstract_indices = list(range(expected_measurements)) + actual_abstract_indices = sorted(measurement_index_remap) + if actual_abstract_indices != expected_abstract_indices: msg = ( - "Runtime trace did not provide complete surface result-tag provenance: " - f"mapped {len(remap)}/{expected_measurements} measurements" + "runtime result-tag remap does not cover every abstract measurement; " + f"expected indices {expected_abstract_indices[:3]}...{expected_abstract_indices[-3:]}, " + f"got {actual_abstract_indices[:3]}...{actual_abstract_indices[-3:]}" ) - if missing: - msg += f"; first missing abstract measurement index {missing[0]}" raise ValueError(msg) - runtime_ids = sorted(remap.values()) - if runtime_ids != list(range(expected_measurements)): + traced_meas_ids = _extract_measurement_meas_ids(traced_tc) + if len(traced_meas_ids) != expected_measurements: msg = ( - "Runtime result-tag provenance is not a dense measurement-id range " - f"0..{expected_measurements - 1}; got first/last " - f"{runtime_ids[:3]}...{runtime_ids[-3:]}" + "traced circuit contains " + f"{len(traced_meas_ids)} measured MeasId(s), but result-tag metadata " + f"expects {expected_measurements}" + ) + raise ValueError(msg) + if len(set(traced_meas_ids)) != len(traced_meas_ids): + duplicates = sorted( + meas_id + for meas_id in set(traced_meas_ids) + if traced_meas_ids.count(meas_id) > 1 ) + msg = f"traced circuit contains duplicate measured MeasId(s): {duplicates[:8]}" raise ValueError(msg) - return remap + expected_meas_ids = sorted(int(meas_id) for meas_id in measurement_index_remap.values()) + actual_meas_ids = sorted(traced_meas_ids) + if actual_meas_ids != expected_meas_ids: + expected_set = set(expected_meas_ids) + actual_set = set(actual_meas_ids) + missing = sorted(expected_set - actual_set) + extra = sorted(actual_set - expected_set) + msg = ( + "runtime result-tag bindings do not exactly match the traced circuit's " + f"measured MeasIds; missing={missing[:8]}, extra={extra[:8]}" + ) + raise ValueError(msg) def _runtime_idle_seconds_to_time_units(duration_seconds: float) -> Any: @@ -1177,10 +1276,7 @@ def _build_surface_tick_circuit_for_native_model( runtime: object | None = None, ) -> Any: """Build the TickCircuit used by the native DEM and sampler paths.""" - from pecos.qec.surface.circuit_builder import ( - _extract_measurement_order, - generate_tick_circuit_from_patch, - ) + from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch abstract_tc = generate_tick_circuit_from_patch( patch, @@ -1204,10 +1300,11 @@ def _build_surface_tick_circuit_for_native_model( runtime=runtime, ) - measurement_index_remap = _surface_runtime_measurement_remap_from_result_traces( - patch, - num_rounds, - result_traces, + measurement_index_remap = _surface_runtime_measurement_remap_from_result_traces(abstract_tc, result_traces) + _validate_result_tag_remap_against_traced_measurements( + traced_tc, + measurement_index_remap, + expected_measurements=int(abstract_tc.get_meta("num_measurements")), ) _copy_surface_tick_circuit_metadata( abstract_tc, @@ -1216,24 +1313,6 @@ def _build_surface_tick_circuit_for_native_model( ) traced_tc.set_meta("surface_metadata_record_binding", "runtime_result_tags") - # Coarse sanity check: detector metadata is bound by runtime result tags, - # but still reject traces with dropped/extra/wrong measured physical qubits. - traced_measurement_order = _extract_measurement_order(traced_tc) - abstract_measurement_order = _extract_measurement_order(abstract_tc) - try: - _measurement_index_remap_for_orders( - abstract_measurement_order, - traced_measurement_order, - ) - except ValueError as exc: - msg = ( - "Traced and abstract surface circuits disagree on the measured-qubit " - "multiset (a dropped/added/wrong-qubit measurement); refusing to " - "build a native DEM/sampler from a circuit that does not match the " - "surface memory experiment" - ) - raise ValueError(msg) from exc - traced_tc.set_meta("circuit_source", circuit_source) return traced_tc @@ -1277,8 +1356,8 @@ def build_memory_circuit( """ from pecos.qec.surface.patch import SurfacePatch - if rounds < 1: - msg = f"rounds must be >= 1, got {rounds}" + if rounds < 0: + msg = f"rounds must be >= 0, got {rounds}" raise ValueError(msg) if patch is None: if distance is None: @@ -1391,7 +1470,10 @@ def _surface_native_topology( import json from pecos.qec import DagFaultAnalyzer - from pecos.qec.surface.circuit_builder import _extract_measurement_order + from pecos.qec.surface.circuit_builder import ( + _extract_measurement_order, + normalize_traced_qis_tick_circuit, + ) patch = _cached_surface_patch(patch_key) tc = _build_surface_tick_circuit_for_native_model( @@ -1402,6 +1484,11 @@ def _surface_native_topology( circuit_source=circuit_source, runtime=runtime, ) + if circuit_source == "traced_qis": + # Keep this surface helper aligned with DetectorErrorModel.from_guppy: + # traced QIS emits parameterized Clifford rotations, while DEM + # replacement-branch approximations operate on named Clifford gates. + normalize_traced_qis_tick_circuit(tc, context="surface traced-QIS native topology") if include_idle_gates: # Insert idle gates only when the requested noise model includes a # dedicated idle channel. Otherwise inserted idle gates receive ordinary @@ -1493,7 +1580,12 @@ def _dem_string_from_cached_surface_topology( .with_observables_json(topology.observables_json) .build_with_source_tracking() ) - return dem.to_string_decomposed() if decompose_errors else dem.to_string() + if not decompose_errors: + return dem.to_string() + source_graphlike = getattr(dem, "to_string_source_graphlike_decomposed", None) + if source_graphlike is not None: + return source_graphlike() + return dem.to_string_decomposed() @cache @@ -2646,15 +2738,18 @@ def _compute_dem_detection_events_z( synx_list: list[NDArray[np.uint8]], synz_list: list[NDArray[np.uint8]], final: NDArray[np.uint8], + *, + init_synx: NDArray[np.uint8] | None = None, ) -> NDArray[np.uint8]: """Compute full detection events for Z-basis DEM-based decoding. The circuit-level DEM defines detectors in this order: - 1. X stabilizer detectors for rounds 1..num_rounds-1 - (X stabs are non-deterministic at round 0 for Z-basis) + 1. X stabilizer detectors for rounds 0..num_rounds-1 + (round 0 compares against the init X-syndrome baseline when present) 2. Z stabilizer detectors for rounds 0..num_rounds-1 (round 0 is deterministic for Z-basis) - 3. Final round detectors: last Z syndrome vs final data parity + 3. Final detectors: last known Z syndrome vs final data parity + (for r=0, the known Z syndrome is the deterministic prep sign) Args: synx_list: X syndrome arrays, one per round @@ -2667,22 +2762,36 @@ def _compute_dem_detection_events_z( geom = self.patch.geometry synx = np.array(synx_list, dtype=np.uint8) synz = np.array(synz_list, dtype=np.uint8) + if self.num_rounds > 0 and init_synx is None: + msg = ( + "Z-basis circuit-level DEM decoding requires init_synx, the prep-baseline " + "X syndrome measured before counted syndrome-extraction rounds." + ) + raise ValueError(msg) events: list[int] = [] - # 1. X stabilizer detection events (rounds 1 to num_rounds-1) - for r in range(1, self.num_rounds): - events.extend((synx[r] ^ synx[r - 1]).tolist()) + if self.num_rounds > 0: + assert init_synx is not None + init_synx_array = np.array(init_synx, dtype=np.uint8) + if init_synx_array.shape != synx[0].shape: + msg = f"init_synx has shape {init_synx_array.shape}, expected {synx[0].shape}" + raise ValueError(msg) + + # 1. X stabilizer detection events + events.extend((synx[0] ^ init_synx_array).tolist()) + for r in range(1, self.num_rounds): + events.extend((synx[r] ^ synx[r - 1]).tolist()) - # 2. Z stabilizer detection events (all rounds) - events.extend(synz[0].tolist()) # round 0: compare to expected 0 - for r in range(1, self.num_rounds): - events.extend((synz[r] ^ synz[r - 1]).tolist()) + # 2. Z stabilizer detection events (all rounds) + events.extend(synz[0].tolist()) # round 0: compare to expected 0 + for r in range(1, self.num_rounds): + events.extend((synz[r] ^ synz[r - 1]).tolist()) - # 3. Final round: parity of final data on each Z stabilizer XOR last syndrome + # 3. Final readout: final Z parity XOR the last known Z syndrome. for stab in geom.z_stabilizers: data_parity = sum(int(final[q]) for q in stab.data_qubits) % 2 - last_syn = int(synz[-1][stab.index]) + last_syn = int(synz[-1][stab.index]) if self.num_rounds > 0 else 0 events.append((data_parity ^ last_syn) & 1) return np.array(events, dtype=np.uint8) @@ -2692,15 +2801,18 @@ def _compute_dem_detection_events_x( synx_list: list[NDArray[np.uint8]], synz_list: list[NDArray[np.uint8]], final: NDArray[np.uint8], + *, + init_synz: NDArray[np.uint8] | None = None, ) -> NDArray[np.uint8]: """Compute full detection events for X-basis DEM-based decoding. The circuit-level DEM defines detectors in this order: 1. X stabilizer detectors for rounds 0..num_rounds-1 (X stabs are deterministic at round 0 for X-basis) - 2. Z stabilizer detectors for rounds 1..num_rounds-1 - (Z stabs are non-deterministic at round 0 for X-basis) - 3. Final round detectors: last X syndrome vs final data parity + 2. Z stabilizer detectors for rounds 0..num_rounds-1 + (round 0 compares against the init Z-syndrome baseline when present) + 3. Final detectors: last known X syndrome vs final data parity + (for r=0, the known X syndrome is the deterministic prep sign) Args: synx_list: X syndrome arrays, one per round @@ -2713,22 +2825,36 @@ def _compute_dem_detection_events_x( geom = self.patch.geometry synx = np.array(synx_list, dtype=np.uint8) synz = np.array(synz_list, dtype=np.uint8) + if self.num_rounds > 0 and init_synz is None: + msg = ( + "X-basis circuit-level DEM decoding requires init_synz, the prep-baseline " + "Z syndrome measured before counted syndrome-extraction rounds." + ) + raise ValueError(msg) events: list[int] = [] - # 1. X stabilizer detection events (all rounds) - events.extend(synx[0].tolist()) # round 0: compare to expected 0 - for r in range(1, self.num_rounds): - events.extend((synx[r] ^ synx[r - 1]).tolist()) + if self.num_rounds > 0: + assert init_synz is not None + init_synz_array = np.array(init_synz, dtype=np.uint8) + if init_synz_array.shape != synz[0].shape: + msg = f"init_synz has shape {init_synz_array.shape}, expected {synz[0].shape}" + raise ValueError(msg) + + # 1. X stabilizer detection events (all rounds) + events.extend(synx[0].tolist()) # round 0: compare to expected 0 + for r in range(1, self.num_rounds): + events.extend((synx[r] ^ synx[r - 1]).tolist()) - # 2. Z stabilizer detection events (rounds 1 to num_rounds-1) - for r in range(1, self.num_rounds): - events.extend((synz[r] ^ synz[r - 1]).tolist()) + # 2. Z stabilizer detection events + events.extend((synz[0] ^ init_synz_array).tolist()) + for r in range(1, self.num_rounds): + events.extend((synz[r] ^ synz[r - 1]).tolist()) - # 3. Final round: parity of final data on each X stabilizer XOR last syndrome + # 3. Final readout: final X parity XOR the last known X syndrome. for stab in geom.x_stabilizers: data_parity = sum(int(final[q]) for q in stab.data_qubits) % 2 - last_syn = int(synx[-1][stab.index]) + last_syn = int(synx[-1][stab.index]) if self.num_rounds > 0 else 0 events.append((data_parity ^ last_syn) & 1) return np.array(events, dtype=np.uint8) @@ -2738,6 +2864,8 @@ def decode_memory_z( synx_list: list[NDArray[np.uint8]], synz_list: list[NDArray[np.uint8]], final: NDArray[np.uint8], + *, + init_synx: NDArray[np.uint8] | None = None, ) -> tuple[bool, DecodingResult]: """Decode a Z-basis memory experiment. @@ -2759,6 +2887,8 @@ def decode_memory_z( synx_list: List of X syndrome arrays, one per round synz_list: List of Z syndrome arrays, one per round final: Final data qubit measurements + init_synx: Optional prep-baseline X syndrome for the random + stabilizer signs established before counted Z-memory rounds. Returns: (is_logical_error, decoding_result) @@ -2772,7 +2902,7 @@ def decode_memory_z( DecoderType.PYMATCHING, DecoderType.TESSERACT, ): - events = self._compute_dem_detection_events_z(synx_list, synz_list, final) + events = self._compute_dem_detection_events_z(synx_list, synz_list, final, init_synx=init_synx) events_flat = events.ravel().astype(np.uint8) decoder = self._get_z_decoder() @@ -2833,6 +2963,8 @@ def decode_memory_x( synx_list: list[NDArray[np.uint8]], synz_list: list[NDArray[np.uint8]], final: NDArray[np.uint8], + *, + init_synz: NDArray[np.uint8] | None = None, ) -> tuple[bool, DecodingResult]: """Decode an X-basis memory experiment. @@ -2854,6 +2986,8 @@ def decode_memory_x( synx_list: List of X syndrome arrays, one per round synz_list: List of Z syndrome arrays, one per round final: Final data qubit measurements + init_synz: Optional prep-baseline Z syndrome for the random + stabilizer signs established before counted X-memory rounds. Returns: (is_logical_error, decoding_result) @@ -2867,7 +3001,7 @@ def decode_memory_x( DecoderType.PYMATCHING, DecoderType.TESSERACT, ): - events = self._compute_dem_detection_events_x(synx_list, synz_list, final) + events = self._compute_dem_detection_events_x(synx_list, synz_list, final, init_synz=init_synz) events_flat = events.ravel().astype(np.uint8) decoder = self._get_x_decoder() @@ -3082,8 +3216,8 @@ def surface_code_memory( msg = f"shots must be >= 0, got {shots}" raise ValueError(msg) num_rounds = distance if rounds is None else rounds - if num_rounds < 1: - msg = f"rounds must be >= 1, got {num_rounds}" + if num_rounds < 0: + msg = f"rounds must be >= 0, got {num_rounds}" raise ValueError(msg) noise_model = _memory_noise_model(physical_error_rate, noise_model) 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 0d1f815a7..490e373fd 100644 --- a/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py +++ b/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py @@ -157,6 +157,16 @@ def test_manual_graph_construction(self) -> None: assert decoder.num_nodes >= 3 assert decoder.num_edges >= 4 + def test_from_dem_with_correlations(self) -> None: + """Test construction from DEM with decomposition correlations enabled.""" + from pecos_rslib.decoders import PyMatchingDecoder + + dem = "error(0.1) D0 D1 ^ D2 L0" + decoder = PyMatchingDecoder.from_dem_with_correlations(dem) + + result = decoder.decode([0, 0, 0]) + assert result.correction == [0] + class TestFusionBlossomDecoder: """Tests for FusionBlossomDecoder (mirrors fusion_blossom).""" 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 88982a279..4df6535ab 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -249,8 +249,14 @@ def test_decode_trivial_syndrome_z(self) -> None: synx_list = [np.zeros(num_x_stab, dtype=np.uint8)] synz_list = [np.zeros(num_z_stab, dtype=np.uint8)] final = np.zeros(patch.num_data, dtype=np.uint8) + init_synx = np.zeros(num_x_stab, dtype=np.uint8) - is_error, _result = decoder.decode_memory_z(synx_list, synz_list, final) + is_error, _result = decoder.decode_memory_z( + synx_list, + synz_list, + final, + init_synx=init_synx, + ) # No errors should be detected assert not is_error @@ -267,12 +273,80 @@ def test_decode_trivial_syndrome_x(self) -> None: synx_list = [np.zeros(num_x_stab, dtype=np.uint8)] synz_list = [np.zeros(num_z_stab, dtype=np.uint8)] final = np.zeros(patch.num_data, dtype=np.uint8) + init_synz = np.zeros(num_z_stab, dtype=np.uint8) - is_error, _result = decoder.decode_memory_x(synx_list, synz_list, final) + is_error, _result = decoder.decode_memory_x( + synx_list, + synz_list, + final, + init_synz=init_synz, + ) # No errors should be detected assert not is_error + def test_dem_detection_events_require_prep_baseline(self) -> None: + """Circuit-level DEM event construction should fail loudly without prep baselines.""" + patch = SurfacePatch.create(distance=3) + decoder = SurfaceDecoder(patch, num_rounds=2) + num_x_stab = len(patch.geometry.x_stabilizers) + num_z_stab = len(patch.geometry.z_stabilizers) + synx_list = [np.zeros(num_x_stab, dtype=np.uint8) for _ in range(2)] + synz_list = [np.zeros(num_z_stab, dtype=np.uint8) for _ in range(2)] + final = np.zeros(patch.num_data, dtype=np.uint8) + + with pytest.raises(ValueError, match="requires init_synx"): + decoder._compute_dem_detection_events_z(synx_list, synz_list, final) + with pytest.raises(ValueError, match="requires init_synz"): + decoder._compute_dem_detection_events_x(synx_list, synz_list, final) + + def test_dem_detection_events_count_only_syndrome_rounds_as_duration(self) -> None: + """Prep baselines and destructive readout should not add counted syndrome rounds.""" + patch = SurfacePatch.create(distance=3) + rounds = 2 + decoder = SurfaceDecoder(patch, num_rounds=rounds) + num_x_stab = len(patch.geometry.x_stabilizers) + num_z_stab = len(patch.geometry.z_stabilizers) + synx_list = [np.zeros(num_x_stab, dtype=np.uint8) for _ in range(rounds)] + synz_list = [np.zeros(num_z_stab, dtype=np.uint8) for _ in range(rounds)] + final = np.zeros(patch.num_data, dtype=np.uint8) + + z_events = decoder._compute_dem_detection_events_z( + synx_list, + synz_list, + final, + init_synx=np.zeros(num_x_stab, dtype=np.uint8), + ) + x_events = decoder._compute_dem_detection_events_x( + synx_list, + synz_list, + final, + init_synz=np.zeros(num_z_stab, dtype=np.uint8), + ) + + assert z_events.shape == (rounds * (num_x_stab + num_z_stab) + num_z_stab,) + assert x_events.shape == (rounds * (num_x_stab + num_z_stab) + num_x_stab,) + + def test_zero_round_dem_events_use_readout_against_prep_boundary(self) -> None: + """r=0 still has terminal detectors from final readout versus prep signs.""" + patch = SurfacePatch.create(distance=3) + decoder = SurfaceDecoder(patch, num_rounds=0) + num_x_stab = len(patch.geometry.x_stabilizers) + num_z_stab = len(patch.geometry.z_stabilizers) + + z_final = np.zeros(patch.num_data, dtype=np.uint8) + z_final[patch.geometry.z_stabilizers[0].data_qubits[0]] = 1 + x_final = np.zeros(patch.num_data, dtype=np.uint8) + x_final[patch.geometry.x_stabilizers[0].data_qubits[0]] = 1 + + z_events = decoder._compute_dem_detection_events_z([], [], z_final) + x_events = decoder._compute_dem_detection_events_x([], [], x_final) + + assert z_events.shape == (num_z_stab,) + assert x_events.shape == (num_x_stab,) + assert z_events[0] == 1 + assert x_events[0] == 1 + class TestDemGeneration: """Tests for DEM generation functions.""" @@ -528,6 +602,30 @@ def traced_dem(*, rotated: bool) -> str: assert traced_dem(rotated=True) != traced_dem(rotated=False) + def test_traced_qis_native_topology_lowers_clifford_rotations(self) -> None: + """Surface native topology should match from_guppy's traced-QIS normalization.""" + from pecos.qec.surface.decode import _surface_native_topology, _surface_patch_cache_key + + _require_selene_runtime() + + patch = SurfacePatch.create(distance=3) + topology = _surface_native_topology( + _surface_patch_cache_key(patch), + 2, + "Z", + None, + "traced_qis", + False, + ) + gate_names = { + topology.dag_circuit.gate(node).gate_type.name + for node in topology.dag_circuit.nodes() + if topology.dag_circuit.gate(node) is not None + } + + assert "RZZ" not in gate_names + assert "SZZ" in gate_names + def test_guppy_module_cache_keys_on_full_patch_identity(self) -> None: """Rotated and non-rotated patches of the same dx/dz/budget must NOT share a cached Guppy module (they generate different circuits).""" diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py b/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py index ec75274fe..e52803d90 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py @@ -178,6 +178,40 @@ def test_tick_circuit_exposes_detector_descriptors() -> None: assert final_x["coords"] == [0, 0, 2] +@pytest.mark.parametrize( + ("basis", "baseline_kind", "detector_y"), + [("Z", "X", 0), ("X", "Z", 1)], +) +def test_tick_circuit_uses_explicit_prep_syndrome_baseline( + basis: str, + baseline_kind: str, + detector_y: int, +) -> None: + """Round-0 random-sign detectors should compare against prep syndrome measurements.""" + patch = SurfacePatch.create(distance=3) + tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis=basis) + detectors = json.loads(tc.get_meta("detectors") or "[]") + num_measurements = int(tc.get_meta("num_measurements") or "0") + init_count = len(patch.x_stabilizers if baseline_kind == "X" else patch.z_stabilizers) + + assert num_measurements == init_count + 2 * patch.num_ancilla + patch.num_data + + init_tick_rounds = [ + tc.get_tick_meta(tick_index, "syndrome_round") + for tick_index in range(tc.num_ticks()) + if tc.get_tick_meta(tick_index, "syndrome_round") == -1 + ] + assert init_tick_rounds + + first_random_detector = next( + det for det in detectors if det["coords"][1] == detector_y and det["coords"][2] == 0 + ) + assert len(first_random_detector["records"]) == 2 + record_indices = [num_measurements + int(record) for record in first_random_detector["records"]] + assert record_indices[0] >= init_count + assert record_indices[1] < init_count + + def test_tick_circuit_exposes_observable_descriptors() -> None: """Tick circuits should publish observable descriptors derived from logical metadata.""" patch = SurfacePatch.create(distance=3) @@ -243,9 +277,11 @@ def test_tick_circuit_respects_ancilla_budget_in_measurement_order() -> None: batched_order = get_measurement_order_from_tick_circuit(batched_tc) num_ancilla = patch.geometry.num_ancilla - full_ancilla_measures = full_order[:num_ancilla] - batched_ancilla_measures = batched_order[:num_ancilla] + init_count = len(patch.x_stabilizers) + full_ancilla_measures = full_order[init_count : init_count + num_ancilla] + batched_ancilla_measures = batched_order[init_count : init_count + num_ancilla] + assert len(set(full_order[:init_count])) == init_count assert len(set(full_ancilla_measures)) == num_ancilla assert len(set(batched_ancilla_measures)) == 2 assert max(batched_ancilla_measures) == patch.num_data + 1 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 9f6a3b13a..455b26348 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -12,14 +12,17 @@ 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 from pecos.qec.surface.decode import ( _build_surface_tick_circuit_for_native_model, + _extract_measurement_meas_ids, _measurement_index_remap_for_orders, _reject_partially_lowered_trace, _remap_surface_record_metadata_json, _replay_lowered_qis_trace_into_tick_circuit, _replay_qis_trace_into_tick_circuit, _surface_runtime_measurement_remap_from_result_traces, + _validate_result_tag_remap_against_traced_measurements, trace_guppy_into_tick_circuit_with_result_traces, ) @@ -767,6 +770,12 @@ def test_surface_metadata_records_remap_to_runtime_measurement_order() -> None: def test_surface_metadata_records_remap_to_runtime_result_tags() -> None: patch = SurfacePatch.create(distance=3) + abstract_tc = generate_tick_circuit_from_patch( + patch, + num_rounds=2, + basis="Z", + ancilla_budget=2, + ) program = make_surface_code(distance=3, num_rounds=2, basis="Z", ancilla_budget=2) _, result_traces = trace_guppy_into_tick_circuit_with_result_traces( program, @@ -775,14 +784,83 @@ def test_surface_metadata_records_remap_to_runtime_result_tags() -> None: ) remap = _surface_runtime_measurement_remap_from_result_traces( - patch, - 2, + abstract_tc, result_traces, ) - assert len(remap) == 25 # 2 rounds * 8 stabilizers + 9 final data measurements - assert sorted(remap) == list(range(25)) - assert sorted(remap.values()) == list(range(25)) + assert len(remap) == 29 # 4 prep X stabilizers + 2 rounds * 8 stabilizers + 9 final data measurements + assert sorted(remap) == list(range(29)) + assert sorted(remap.values()) == list(range(29)) + + +def test_result_tag_remap_validation_accepts_exact_traced_meas_ids() -> None: + from pecos_rslib.quantum import TickCircuit + + tc = TickCircuit() + tc.tick().mz_with_ids([0, 1], [10, 3]) + + remap = {0: 3, 1: 10} + + assert _extract_measurement_meas_ids(tc) == [10, 3] + _validate_result_tag_remap_against_traced_measurements( + tc, + remap, + expected_measurements=2, + ) + + +def test_result_tag_remap_validation_rejects_duplicate_traced_meas_ids() -> None: + from pecos_rslib.quantum import TickCircuit + + tc = TickCircuit() + tc.tick().mz_with_ids([0, 1], [7, 7]) + + with pytest.raises(ValueError, match="duplicate measured MeasId"): + _validate_result_tag_remap_against_traced_measurements( + tc, + {0: 7, 1: 8}, + expected_measurements=2, + ) + + +def test_result_tag_remap_validation_rejects_unbound_traced_meas_ids() -> None: + from pecos_rslib.quantum import TickCircuit + + tc = TickCircuit() + tc.tick().mz_with_ids([0, 1], [0, 2]) + + with pytest.raises(ValueError, match="do not exactly match"): + _validate_result_tag_remap_against_traced_measurements( + tc, + {0: 0, 1: 1}, + expected_measurements=2, + ) + + +def test_result_tag_remap_validation_rejects_unstamped_measurements() -> None: + class FakeGate: + gate_type = "MZ" + qubits = [0] + meas_ids: list[int] = [] + + class FakeTick: + def gate_batches(self): + return [FakeGate()] + + class FakeCircuit: + def num_ticks(self) -> int: + return 1 + + def get_tick(self, tick_idx: int): + assert tick_idx == 0 + return FakeTick() + + with pytest.raises(ValueError, match="carries 0 MeasId"): + _validate_result_tag_remap_against_traced_measurements( + FakeCircuit(), + {0: 0}, + expected_measurements=1, + ) def test_traced_surface_metadata_uses_runtime_result_tags() -> None: @@ -797,7 +875,7 @@ def test_traced_surface_metadata_uses_runtime_result_tags() -> None: assert traced_tc.get_meta("surface_metadata_record_binding") == "runtime_result_tags" assert traced_tc.get_meta("circuit_source") == "traced_qis" - assert int(traced_tc.get_meta("num_measurements")) == 25 + assert int(traced_tc.get_meta("num_measurements")) == 29 assert len(json.loads(traced_tc.get_meta("detectors"))) > 0 assert len(json.loads(traced_tc.get_meta("observables"))) == 1 diff --git a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py index 47b0ca312..7cc55b476 100644 --- a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py +++ b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py @@ -3,9 +3,13 @@ """Smoke tests for the traced-QIS surface-code route after Clifford lowering.""" +import math import random +import pytest + from pecos.qec.surface import SurfacePatch +from pecos.qec.surface.circuit_builder import normalize_traced_qis_tick_circuit from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model from pecos.quantum import TickCircuit from pecos_rslib_exp import ( @@ -79,7 +83,7 @@ def build_lowered_traced_qis_surface_code(rounds=3): patch = SurfacePatch.create(distance=3) tc = _build_surface_tick_circuit_for_native_model(patch, rounds, "Z", circuit_source="traced_qis") - tc.lower_clifford_rotations() + normalize_traced_qis_tick_circuit(tc, context="test traced-QIS surface code") return tc @@ -213,6 +217,29 @@ def test_lowered_traced_qis_pipeline_sampling_and_catalog_smoke(): assert len(first_fault.faults) == 1 +def test_normalize_traced_qis_tick_circuit_lowers_clifford_rzz(): + tc = TickCircuit() + tc.tick().rzz(math.pi / 2, [(0, 1)]) + + normalize_traced_qis_tick_circuit(tc, context="test Clifford RZZ normalization") + + gate_names = [ + gate.gate_type.name + for tick_index in range(tc.num_ticks()) + for gate in tc.get_tick(tick_index).gate_batches() + ] + assert "RZZ" not in gate_names + assert "SZZ" in gate_names + + +def test_normalize_traced_qis_tick_circuit_rejects_raw_rzz_after_lowering(): + tc = TickCircuit() + tc.tick().rzz(math.pi / 4, [(0, 1)]) + + with pytest.raises(ValueError, match="still contains raw RZZ"): + normalize_traced_qis_tick_circuit(tc, context="test non-Clifford RZZ normalization") + + def test_explicit_python_gate_names_map_to_rust_clifford_gates(): tc = build_explicit_clifford_gate_circuit() noise = depolarizing().p1(0.03).p2(0.15).p_meas(0).p_prep(0) From 16d459673a63068f3cd4f53fe953e8843cd1c4ce Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 15:34:29 -0600 Subject: [PATCH 045/150] Preserve runtime surface DEM metadata through traced QIS --- .../src/fault_tolerance/dem_builder/types.rs | 296 ++++++++++++++---- crates/pecos-qis/src/ccengine.rs | 84 ++++- .../src/fault_tolerance_bindings.rs | 10 + .../src/pecos/qec/surface/circuit_builder.py | 98 ++++-- .../src/pecos/qec/surface/decode.py | 166 +++++----- .../tests/qec/test_from_guppy_dem.py | 215 +++++++++++-- 6 files changed, 656 insertions(+), 213 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 866800dab..e629bfe59 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -1081,8 +1081,6 @@ struct GraphlikeDecompositionIndex { /// Paths may introduce intermediate detector vertices only when they cancel /// pairwise in the XOR of path components. path_adjacency: BTreeMap>, - /// Pure logical graphlike components, keyed by their DEM-output mask. - pure_logical_components: BTreeMap, Vec>, } impl GraphlikeDecompositionIndex { @@ -1135,19 +1133,12 @@ impl GraphlikeDecompositionIndex { values.dedup(); } let mut path_adjacency: BTreeMap> = BTreeMap::new(); - let mut pure_logical_components: BTreeMap, Vec> = - BTreeMap::new(); for candidate in &graphlike_set { - if !candidate.is_graphlike() || candidate.is_standard_empty() { + if !is_detectable_graphlike_component(candidate) || candidate.is_standard_empty() { continue; } match candidate.detectors.as_slice() { - [] => { - pure_logical_components - .entry(candidate.dem_outputs.clone()) - .or_default() - .push(candidate.clone()); - } + [] => {} [det] => { Self::add_path_edge( &mut path_adjacency, @@ -1167,10 +1158,6 @@ impl GraphlikeDecompositionIndex { _ => {} } } - for values in pure_logical_components.values_mut() { - values.sort_by(Self::compare_symptom_candidates); - values.dedup(); - } for edges in path_adjacency.values_mut() { edges.sort_by(|a, b| { a.next @@ -1194,7 +1181,6 @@ impl GraphlikeDecompositionIndex { candidates_by_detector, symptoms, path_adjacency, - pure_logical_components, } } @@ -1252,20 +1238,6 @@ impl GraphlikeDecompositionIndex { }) } - fn first_allowed_logical_component( - &self, - key: &SmallVec<[u32; 2]>, - excluded_origin: Option<&FaultMechanism>, - ) -> Option<&FaultMechanism> { - self.pure_logical_components - .get(key) - .and_then(|candidates| { - candidates - .iter() - .find(|candidate| self.candidate_allowed(candidate, excluded_origin)) - }) - } - fn find_hyperedge_decomposition( &self, hyperedge: &FaultMechanism, @@ -1279,7 +1251,7 @@ impl GraphlikeDecompositionIndex { excluded_origin: Option<&FaultMechanism>, ) -> Option> { // If already graphlike, no decomposition needed - if hyperedge.is_graphlike() { + if is_detectable_graphlike_component(hyperedge) { return Some(vec![hyperedge.clone()]); } @@ -1337,7 +1309,7 @@ impl GraphlikeDecompositionIndex { excluded_origin: Option<&FaultMechanism>, ) -> bool { parts.iter().all(|part| { - part.is_graphlike() + is_detectable_graphlike_component(part) && self.graphlike_set.contains(part) && self.candidate_allowed(part, excluded_origin) }) @@ -1359,7 +1331,7 @@ impl GraphlikeDecompositionIndex { excluded_origin: Option<&FaultMechanism>, path_cache: &mut GraphPathSearchCache, ) -> Option> { - if hyperedge.is_graphlike() { + if is_detectable_graphlike_component(hyperedge) { return Some(vec![hyperedge.clone()]); } if self.path_adjacency.is_empty() || hyperedge.detectors.len() > MAX_GRAPH_PATH_TERMINALS { @@ -1393,12 +1365,13 @@ impl GraphlikeDecompositionIndex { for (outputs, mut parts) in states { let missing_outputs = symmetric_difference_2(&outputs, &hyperedge.dem_outputs); if !missing_outputs.is_empty() { - let Some(logical_part) = - self.first_allowed_logical_component(&missing_outputs, excluded_origin) - else { - continue; - }; - parts.push(logical_part.clone()); + // Do not repair logical-frame parity by appending a pure `L` + // component. Although this preserves the full XOR effect, it + // creates an undetectable decomposition component (`L0 ^ D1 D2`) + // that graphlike decoders cannot attach to a syndrome edge. + // A valid graph-path decomposition must carry observable parity + // on one of its detector/boundary components. + continue; } parts = parity_reduce_mechanisms(parts); if parts.is_empty() { @@ -1407,7 +1380,7 @@ impl GraphlikeDecompositionIndex { let recomposed = parts .iter() .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); - if recomposed == *hyperedge && parts.iter().all(FaultMechanism::is_graphlike) { + if recomposed == *hyperedge && parts_are_detectable_graphlike(&parts) { match best.as_ref() { Some(existing) if !prefer_graph_path_parts(&parts, existing) => {} _ => best = Some(parts), @@ -1560,7 +1533,7 @@ impl GraphlikeDecompositionIndex { hyperedge: &FaultMechanism, excluded_origin: Option<&FaultMechanism>, ) -> Option> { - if hyperedge.is_graphlike() { + if is_detectable_graphlike_component(hyperedge) { return Some(vec![hyperedge.clone()]); } @@ -1618,7 +1591,7 @@ impl GraphlikeDecompositionIndex { let recomposed = parts .iter() .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); - if recomposed == *hyperedge && parts.iter().all(FaultMechanism::is_graphlike) { + if recomposed == *hyperedge && parts_are_detectable_graphlike(&parts) { Some(parts) } else { None @@ -1641,7 +1614,7 @@ impl GraphlikeDecompositionIndex { return result; } - if remaining.is_graphlike() + if is_detectable_graphlike_component(remaining) && self.graphlike_set.contains(remaining) && self.candidate_allowed(remaining, excluded_origin) { @@ -1720,6 +1693,22 @@ fn parity_reduce_mechanisms(parts: Vec) -> Vec { reduced } +fn is_pure_logical_mechanism(part: &FaultMechanism) -> bool { + part.detectors.is_empty() && !part.dem_outputs.is_empty() +} + +fn is_detectable_graphlike_component(part: &FaultMechanism) -> bool { + part.num_detectors() <= 2 && !is_pure_logical_mechanism(part) +} + +fn parts_have_pure_logical_component(parts: &[FaultMechanism]) -> bool { + parts.iter().any(is_pure_logical_mechanism) +} + +fn parts_are_detectable_graphlike(parts: &[FaultMechanism]) -> bool { + parts.iter().all(is_detectable_graphlike_component) +} + /// Finds a decomposition of a graphlike effect into singleton detector components. /// /// This is used for "maximal" decomposition modes that prefer singleton @@ -1733,6 +1722,9 @@ fn find_singleton_decomposition( return Some(Vec::new()); } if effect.num_detectors() <= 1 { + if is_pure_logical_mechanism(effect) { + return None; + } return Some(vec![effect.clone()]); } if index.is_empty() { @@ -4776,7 +4768,7 @@ impl DetectorErrorModel { let mut out = Vec::new(); for part in parts { - if part.is_graphlike() { + if is_detectable_graphlike_component(&part) { out.extend(Self::maximally_decompose_graphlike_effect( &part, singleton_set, @@ -4788,6 +4780,32 @@ impl DetectorErrorModel { out } + fn source_graphlike_decompose_full_effect_or_raw( + effect: &FaultMechanism, + source_graphlike_index: Option<&GraphlikeDecompositionIndex>, + singleton_set: Option<&SingletonDecompositionIndex>, + source_graphlike_path_cache: &mut GraphPathSearchCache, + ) -> Vec { + if is_detectable_graphlike_component(effect) { + return Self::maybe_maximally_decompose_parts(vec![effect.clone()], singleton_set); + } + + if let Some(index) = source_graphlike_index { + if let Some(parts) = index.find_hyperedge_decomposition_with_remnants_for_origin_cached( + effect, + Some(effect), + source_graphlike_path_cache, + ) { + let parts = Self::maybe_maximally_decompose_parts(parts, singleton_set); + if parts_are_detectable_graphlike(&parts) { + return parts; + } + } + } + + vec![effect.clone()] + } + fn source_component_parts(contrib: &FaultContribution) -> Vec { if let Some((x_effect, z_effect)) = contrib.decomposition_components() { return [x_effect, z_effect] @@ -4817,11 +4835,11 @@ impl DetectorErrorModel { let mut graphlike = BTreeSet::new(); for contrib in &self.contributions { let effect = contrib.effect.standard_effect(); - if !contrib.location_indices.is_empty() && effect.is_graphlike() { + if !contrib.location_indices.is_empty() && is_detectable_graphlike_component(&effect) { graphlike.insert(effect); } for part in Self::source_component_parts(contrib) { - if part.is_graphlike() { + if is_detectable_graphlike_component(&part) { graphlike.insert(part); } } @@ -4846,7 +4864,7 @@ impl DetectorErrorModel { for part in parts { let origin = part.clone(); - let decomposed = if part.is_graphlike() { + let decomposed = if is_detectable_graphlike_component(&part) { vec![part] } else { index @@ -4855,7 +4873,7 @@ impl DetectorErrorModel { }; for piece in decomposed { - if piece.is_graphlike() && !piece.is_standard_empty() { + if is_detectable_graphlike_component(&piece) && !piece.is_standard_empty() { graphlike.insert_derived(piece, &origin); } } @@ -4882,7 +4900,7 @@ impl DetectorErrorModel { continue; } - let decomposed = if part.is_graphlike() { + let decomposed = if is_detectable_graphlike_component(&part) { vec![part] } else if let Some(index) = source_graphlike_index { index @@ -4917,22 +4935,19 @@ impl DetectorErrorModel { singleton_set, source_graphlike_path_cache, ); - if recorded_parts.iter().all(FaultMechanism::is_graphlike) { + let has_pure_logical_component = parts_have_pure_logical_component(&recorded_parts); + if !has_pure_logical_component && parts_are_detectable_graphlike(&recorded_parts) { return recorded_parts; } - if let Some(index) = source_graphlike_index { - if let Some(effect_parts) = index - .find_hyperedge_decomposition_with_remnants_for_origin_cached( - effect, - Some(effect), - source_graphlike_path_cache, - ) - { - if effect_parts.iter().all(FaultMechanism::is_graphlike) { - return Self::maybe_maximally_decompose_parts(effect_parts, singleton_set); - } - } + let full_effect_parts = Self::source_graphlike_decompose_full_effect_or_raw( + effect, + source_graphlike_index, + singleton_set, + source_graphlike_path_cache, + ); + if has_pure_logical_component || parts_are_detectable_graphlike(&full_effect_parts) { + return full_effect_parts; } recorded_parts @@ -5010,12 +5025,22 @@ impl DetectorErrorModel { contrib.decomposition_components() { if !x_effect.is_empty() && !z_effect.is_empty() { - let parts = Self::source_graphlike_decompose_parts( + let source_parts = Self::source_graphlike_decompose_parts( vec![x_effect.clone(), z_effect.clone()], source_graphlike_index, singleton_set, source_graphlike_path_cache, ); + let parts = if parts_have_pure_logical_component(&source_parts) { + Self::source_graphlike_decompose_full_effect_or_raw( + &effect, + source_graphlike_index, + singleton_set, + source_graphlike_path_cache, + ) + } else { + source_parts + }; let targets = Self::format_decomposed_parts(parts); (targets, ContributionRenderStrategy::SourceComponents) } else if effect.num_detectors() == 2 && effect.dem_outputs.is_empty() { @@ -5531,7 +5556,7 @@ impl DetectorErrorModel { let mut graphlike = BTreeSet::new(); for contrib in &self.contributions { let standard = contrib.effect.standard_effect(); - if !standard.is_standard_empty() && standard.is_graphlike() { + if !standard.is_standard_empty() && is_detectable_graphlike_component(&standard) { graphlike.insert(standard); } } @@ -7222,6 +7247,149 @@ mod tests { assert!(!target_line.contains("D0 D2 D3 L0")); } + #[test] + fn test_source_graphlike_path_decomposition_rejects_pure_logical_fixup() { + let mut dem = DetectorErrorModel::new(); + let first = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let second = FaultMechanism::from_unsorted([1, 2], std::iter::empty()); + let third = FaultMechanism::from_unsorted([3], std::iter::empty()); + let pure_logical = FaultMechanism::from_unsorted(std::iter::empty(), [0]); + for (idx, part) in [&first, &second, &third, &pure_logical] + .into_iter() + .enumerate() + { + dem.add_direct_contribution_with_source( + part.clone(), + 0.001, + SourceMetadata::new(&[idx], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + let effect = first.xor(&second).xor(&third).xor(&pure_logical); + assert_eq!(effect, FaultMechanism::from_unsorted([0, 2, 3], [0])); + dem.add_direct_contribution_with_source( + effect, + 0.01, + SourceMetadata::new(&[9usize], &[Pauli::X], &[GateType::SZZ], &[false]), + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected target error line"); + assert!(target_line.contains("L0")); + let (_, targets) = target_line + .split_once(") ") + .expect("expected error line target separator"); + for component in targets.split(" ^ ") { + let has_detector = component + .split_whitespace() + .any(|target| target.starts_with('D')); + let has_logical = component + .split_whitespace() + .any(|target| target.starts_with('L')); + assert!( + has_detector || !has_logical, + "logical component must be detector-bearing in {target_line}", + ); + } + } + + #[test] + fn test_source_graphlike_decomposition_rejects_pure_logical_singleton_piece() { + let mut dem = DetectorErrorModel::new(); + let first = FaultMechanism::from_unsorted([1], std::iter::empty()); + let second = FaultMechanism::from_unsorted([2], std::iter::empty()); + let third = FaultMechanism::from_unsorted([121], std::iter::empty()); + let pure_logical = FaultMechanism::from_unsorted(std::iter::empty(), [0]); + for (idx, part) in [&first, &second, &third, &pure_logical] + .into_iter() + .enumerate() + { + dem.add_direct_contribution_with_source( + part.clone(), + 0.001, + SourceMetadata::new(&[idx], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + let effect = first.xor(&second).xor(&third).xor(&pure_logical); + assert_eq!(effect, FaultMechanism::from_unsorted([1, 2, 121], [0])); + dem.add_direct_contribution_with_source( + effect, + 0.01, + SourceMetadata::new(&[20usize], &[Pauli::X], &[GateType::PZ], &[false]), + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected target error line"); + assert!( + !target_line.contains("L0 ^"), + "pure logical must not be emitted as a decomposed component: {target_line}", + ); + assert!( + !target_line.contains("^ L0"), + "pure logical must not be emitted as a decomposed component: {target_line}", + ); + assert!( + target_line.contains("D1 D2 D121 L0"), + "effect should remain as the raw source hyperedge when no detector-bearing graphlike decomposition exists: {target_line}", + ); + } + + #[test] + fn test_source_graphlike_recorded_components_reject_pure_logical_component() { + let mut dem = DetectorErrorModel::new(); + let pure_logical = FaultMechanism::from_unsorted(std::iter::empty(), [0]); + let first = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let second = FaultMechanism::from_unsorted([1, 2], [0]); + let components = [pure_logical.clone(), first.clone(), second.clone()]; + let effect = components + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); + assert_eq!( + effect, + FaultMechanism::from_unsorted([0, 2], std::iter::empty()) + ); + + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[10usize, 11usize], + &[Pauli::X, Pauli::Z], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + DirectSourceComponents::from_slice(&components), + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected target error line"); + let (_, targets) = target_line + .split_once(") ") + .expect("expected error line target separator"); + for component in targets.split(" ^ ") { + let has_detector = component + .split_whitespace() + .any(|target| target.starts_with('D')); + let has_logical = component + .split_whitespace() + .any(|target| target.starts_with('L')); + assert!( + has_detector || !has_logical, + "logical component must be detector-bearing in {target_line}", + ); + } + } + #[test] fn test_source_graphlike_path_decomposition_handles_boundary_cluster() { let mut dem = DetectorErrorModel::new(); diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index 1711e5831..377ea7a64 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -44,6 +44,7 @@ pub struct LoweredQuantumGateTrace { pub angles: Vec, pub params: Vec, pub qubits: Vec, + pub measurement_result_ids: Vec, } /// One traced batch of QIS operations and their lowered simulator commands. @@ -834,25 +835,57 @@ impl QisEngine { self.trace_chunk_index = 0; } - fn lowered_quantum_ops_trace(commands: &ByteMessage) -> Vec { + fn lowered_quantum_ops_trace( + commands: &ByteMessage, + measurement_mapping: &[usize], + ) -> Vec { match commands.quantum_ops() { - Ok(gates) => gates - .iter() - .map(|gate| LoweredQuantumGateTrace { - gate_type: gate.gate_type.to_string(), - angles: gate - .angles - .iter() - .map(Angle64::to_radians) - .collect::>(), - params: gate.params.iter().copied().collect::>(), - qubits: gate + Ok(gates) => { + let mut measurement_cursor = 0usize; + let mut traces = Vec::with_capacity(gates.len()); + for gate in gates { + let gate_type = gate.gate_type.to_string(); + let qubits = gate .qubits .iter() .map(|q| usize::from(*q)) - .collect::>(), - }) - .collect::>(), + .collect::>(); + let measurement_result_ids = if gate_type == "MZ" { + let end = measurement_cursor + qubits.len(); + if end > measurement_mapping.len() { + warn!( + "Lowered operation trace has more measured qubits than result-id mappings" + ); + Vec::new() + } else { + let ids = measurement_mapping[measurement_cursor..end].to_vec(); + measurement_cursor = end; + ids + } + } else { + Vec::new() + }; + traces.push(LoweredQuantumGateTrace { + gate_type, + angles: gate + .angles + .iter() + .map(Angle64::to_radians) + .collect::>(), + params: gate.params.iter().copied().collect::>(), + qubits, + measurement_result_ids, + }); + } + if measurement_cursor != measurement_mapping.len() { + warn!( + "Lowered operation trace consumed {} measurement mapping(s), but {} were present", + measurement_cursor, + measurement_mapping.len() + ); + } + traces + } Err(err) => { warn!("Failed to parse lowered quantum ops for tracing: {err}"); Vec::new() @@ -872,7 +905,7 @@ impl QisEngine { } let lowered_trace = lowered_quantum_ops - .map(Self::lowered_quantum_ops_trace) + .map(|commands| Self::lowered_quantum_ops_trace(commands, &self.measurement_mapping)) .unwrap_or_default(); let file_name = format!( "engine_{:04}_shot_{:06}_chunk_{:04}_{}.json", @@ -1722,6 +1755,10 @@ mod tests { assert_eq!(value["lowered_quantum_ops"][2]["gate_type"], "Idle"); assert_eq!(value["lowered_quantum_ops"][2]["params"][0], 20e-9); assert_eq!(value["lowered_quantum_ops"][3]["gate_type"], "MZ"); + assert_eq!( + value["lowered_quantum_ops"][3]["measurement_result_ids"], + serde_json::json!([7]) + ); let in_memory = collector.lock().expect("collector lock"); assert_eq!(in_memory.len(), 1); @@ -1729,6 +1766,10 @@ mod tests { assert_eq!(in_memory[0].lowered_quantum_ops[0].gate_type, "PZ"); assert_eq!(in_memory[0].lowered_quantum_ops[2].gate_type, "Idle"); assert_eq!(in_memory[0].lowered_quantum_ops[2].params, vec![20e-9]); + assert_eq!( + in_memory[0].lowered_quantum_ops[3].measurement_result_ids, + vec![7] + ); } #[derive(Clone, Default)] @@ -1773,7 +1814,11 @@ mod tests { } fn lower_operations(&mut self, _operations: &[Operation]) -> RuntimeResult> { - Ok(vec![QuantumOp::Idle(20e-9, 0), QuantumOp::H(0)]) + Ok(vec![ + QuantumOp::Idle(20e-9, 0), + QuantumOp::H(0), + QuantumOp::Measure(0, 17), + ]) } } @@ -1796,6 +1841,11 @@ mod tests { assert_eq!(in_memory[0].lowered_quantum_ops[0].params, vec![20e-9]); assert_eq!(in_memory[0].lowered_quantum_ops[0].qubits, vec![0]); assert_eq!(in_memory[0].lowered_quantum_ops[1].gate_type, "H"); + assert_eq!(in_memory[0].lowered_quantum_ops[2].gate_type, "MZ"); + assert_eq!( + in_memory[0].lowered_quantum_ops[2].measurement_result_ids, + vec![17] + ); } #[test] diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 95d9051b5..fa6c36b52 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -485,6 +485,16 @@ impl PyDagFaultInfluenceMap { self.inner.has_observable_flips(loc_idx, pauli) } + /// Replace this map's non-detector DEM outputs with another map's outputs. + /// + /// This is the Python equivalent of the canonical Rust DEM builder's + /// annotation merge: detector influence from `DagFaultAnalyzer` is kept, + /// while observable/tracked-Pauli outputs from `InfluenceBuilder` are used + /// for DEM output propagation. + fn merge_dem_outputs_from(&mut self, other: &PyDagFaultInfluenceMap) { + self.inner.merge_dem_outputs_from(&other.inner); + } + /// Check if a fault at the given location flips any tracked Pauli. /// /// Args: diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index e7e4e5862..1eb5245d9 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -913,13 +913,20 @@ class TickCircuitRenderer(CircuitRenderer): are stored as circuit metadata and preserved when converting to DagCircuit. """ - def __init__(self, *, add_detectors: bool = True) -> None: + def __init__( + self, + *, + add_detectors: bool = True, + add_typed_annotations: bool = True, + ) -> None: """Initialize TickCircuit renderer. Args: - add_detectors: Whether to add detector annotations as metadata + add_detectors: Whether to add detector/observable metadata. + add_typed_annotations: Whether to also add typed Pauli annotations. """ self.add_detectors = add_detectors + self.add_typed_annotations = add_typed_annotations def render( self, @@ -1093,8 +1100,7 @@ def is_syndrome_context(phase: str, round_index: int) -> bool: if round_index >= 0 or phase.startswith("init_syndrome"): return True return round_index == -1 and ( - phase in {"syndrome_h_pre", "syndrome_h_post", "measure_ancilla"} - or phase.startswith("cx_round_") + phase in {"syndrome_h_pre", "syndrome_h_post", "measure_ancilla"} or phase.startswith("cx_round_") ) def gate_metadata(meta: dict | None = None) -> dict: @@ -1395,17 +1401,23 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non circuit.set_meta("num_measurements", str(meas_count)) circuit.set_meta("num_detectors", str(len(detectors))) - # Also add typed PauliAnnotation annotations (new path) - self._add_typed_annotations( - circuit, - geom, - num_rounds, - basis, - stab_meas_refs, - final_meas_refs_by_qubit, - deterministic_type_round0, - init_baseline_type, - ) + # Also add typed PauliAnnotation annotations (new path) when the + # caller wants direct Pauli annotations in addition to legacy + # measurement-record metadata. Native surface DEM construction uses + # the JSON metadata below and disables these annotations to avoid + # mixing two independent observable sources in the same influence + # map. + if self.add_typed_annotations: + self._add_typed_annotations( + circuit, + geom, + num_rounds, + basis, + stab_meas_refs, + final_meas_refs_by_qubit, + deterministic_type_round0, + init_baseline_type, + ) circuit.set_meta("basis", basis.upper()) circuit.set_meta("ancilla_budget", str(allocation.total - len(allocation.data_qubits))) @@ -1585,6 +1597,7 @@ def generate_tick_circuit_from_patch( basis: str = "Z", *, add_detectors: bool = True, + add_typed_annotations: bool = True, ancilla_budget: int | None = None, ) -> TickCircuit: """Generate PECOS TickCircuit from SurfacePatch. @@ -1606,14 +1619,18 @@ def generate_tick_circuit_from_patch( patch: Surface code patch num_rounds: Number of syndrome rounds basis: 'Z' or 'X' - add_detectors: Whether to add detector annotations as metadata + add_detectors: Whether to add detector/observable metadata. + add_typed_annotations: Whether to also add typed Pauli annotations. ancilla_budget: Optional cap on simultaneously live ancillas Returns: PECOS TickCircuit instance """ ops, allocation = build_surface_code_circuit(patch, num_rounds, basis, ancilla_budget) - renderer = TickCircuitRenderer(add_detectors=add_detectors) + renderer = TickCircuitRenderer( + add_detectors=add_detectors, + add_typed_annotations=add_typed_annotations, + ) return renderer.render(ops, allocation, patch, num_rounds, basis) @@ -2252,11 +2269,7 @@ def simulate_error( if abs(weight_sum - 1.0) >= 1.0e-6: message = f"p2_weights relative probabilities must sum to 1.0, got {weight_sum}" raise ValueError(message) - two_paulis = tuple( - (label[0], label[1], weight) - for label, weight in sorted(weights.items()) - if weight > 0.0 - ) + two_paulis = tuple((label[0], label[1], weight) for label, weight in sorted(weights.items()) if weight > 0.0) # Process each gate as a potential error location for op_idx, (_tick_idx, gate_name, qubits, meas_idx) in enumerate(circuit_ops): @@ -2476,6 +2489,38 @@ def _maximally_decompose_graphlike_dem(dem_text: str) -> str: return "\n".join(rewritten_lines) +def _build_canonical_dem_influence_map(dag: DagCircuit): + """Build the influence map used by the canonical Rust DEM builder. + + `DagFaultAnalyzer` supplies the detector influence map. Observable and + tracked-Pauli annotations need positional propagation from + `InfluenceBuilder`; merging those non-detector outputs keeps this legacy + surface helper aligned with `DetectorErrorModel.from_circuit`. + """ + from pecos.qec import DagFaultAnalyzer, InfluenceBuilder + + analyzer = DagFaultAnalyzer(dag) + influence_map = analyzer.build_influence_map() + annotation_builder = InfluenceBuilder(dag) + annotation_builder.with_circuit_annotations() + annotation_map = annotation_builder.build() + influence_map.merge_dem_outputs_from(annotation_map) + return influence_map + + +def _metadata_uses_record_offsets(*metadata_jsons: str | None) -> bool: + """Return whether detector/observable metadata uses positional records.""" + import json + + for metadata_json in metadata_jsons: + if not metadata_json: + continue + for entry in json.loads(metadata_json): + if entry.get("records"): + return True + return False + + def generate_dem_from_tick_circuit( tc: TickCircuit, *, @@ -2549,7 +2594,7 @@ def generate_dem_from_tick_circuit( Returns: DEM string in Stim-compatible format """ - from pecos.qec import DagFaultAnalyzer, DemBuilder + from pecos.qec import DemBuilder # Get detector and observable metadata detectors_json = tc.get_meta("detectors") @@ -2565,11 +2610,11 @@ def generate_dem_from_tick_circuit( # This allows proper mapping between record offsets (TickCircuit order) and # influence map indices (DAG topological order). measurement_order = _extract_measurement_order(tc) + metadata_uses_records = _metadata_uses_record_offsets(detectors_json, observables_json) # Convert TickCircuit to DagCircuit and build influence map dag = tc.to_dag_circuit() - analyzer = DagFaultAnalyzer(dag) - influence_map = analyzer.build_influence_map() + influence_map = _build_canonical_dem_influence_map(dag) # Build DEM using Rust DemBuilder builder = DemBuilder(influence_map) @@ -2592,7 +2637,8 @@ def generate_dem_from_tick_circuit( p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, ) builder.with_num_measurements(num_measurements) - builder.with_measurement_order(measurement_order) + if metadata_uses_records: + builder.with_measurement_order(measurement_order) builder.with_detectors_json(detectors_json) if observables_json: builder.with_observables_json(observables_json) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 46a05888e..d72dda77e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -552,23 +552,38 @@ def _remap_surface_record_metadata_json( measurement_index_remap: dict[int, int], num_measurements: int, ) -> str: - """Remap negative ``records`` offsets inside detector/observable metadata.""" + """Bind abstract measurement refs to runtime-stable ``meas_ids``. + + ``measurement_index_remap`` maps abstract measurement indices to the + stable result ids emitted by the runtime trace. Those ids are not + positional record offsets, so remapped runtime metadata must use + ``meas_ids`` and must drop stale ``records``. + """ import json entries = json.loads(metadata_json) for entry in entries: - records = entry.get("records") - if records is None: + records = entry.pop("records", None) + if records is not None: + abstract_indices = [] + for record in records: + abstract_index = num_measurements + int(record) + if abstract_index not in measurement_index_remap: + msg = f"Surface metadata record {record!r} is out of range for remapping" + raise ValueError(msg) + abstract_indices.append(abstract_index) + elif "meas_ids" in entry: + abstract_indices = [int(meas_id) for meas_id in entry["meas_ids"]] + else: continue - remapped_records = [] - for record in records: - abstract_index = num_measurements + int(record) + + remapped_meas_ids = [] + for abstract_index in abstract_indices: if abstract_index not in measurement_index_remap: - msg = f"Surface metadata record {record!r} is out of range for remapping" + msg = f"Surface metadata meas_id {abstract_index!r} is out of range for remapping" raise ValueError(msg) - traced_index = measurement_index_remap[abstract_index] - remapped_records.append(traced_index - num_measurements) - entry["records"] = remapped_records + remapped_meas_ids.append(int(measurement_index_remap[abstract_index])) + entry["meas_ids"] = remapped_meas_ids return json.dumps(entries) @@ -736,11 +751,7 @@ def _validate_result_tag_remap_against_traced_measurements( ) raise ValueError(msg) if len(set(traced_meas_ids)) != len(traced_meas_ids): - duplicates = sorted( - meas_id - for meas_id in set(traced_meas_ids) - if traced_meas_ids.count(meas_id) > 1 - ) + duplicates = sorted(meas_id for meas_id in set(traced_meas_ids) if traced_meas_ids.count(meas_id) > 1) msg = f"traced circuit contains duplicate measured MeasId(s): {duplicates[:8]}" raise ValueError(msg) @@ -958,31 +969,15 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> tick, then compact (ASAP schedule) so that gates on disjoint qubits share a tick --- matching the parallel structure of the abstract circuit. - MeasIds flow from the QIS measurement result slot: Quantum.Measure carries - ``[qubit, result_id]``, and those IDs are stamped on MZ gates via - mz_with_ids(). + 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. """ from pecos_rslib.quantum import TickCircuit tick_circuit = TickCircuit() - # Pass 1: the ordered MeasIds, read directly from each Measure op. A - # ``Quantum.Measure`` op carries ``[qubit, result_id]`` where ``result_id`` - # is the QIS result slot the runtime allocated for it (== the MeasId we - # stamp). Using it directly needs no AllocateResult/Measure pairing - # heuristic and no interleave assumption -- batched - # allocate-allocate-measure-measure (a valid QIS pattern) works the same - # as interleaved. (The order of Measure ops here matches the order of MZ - # gates in ``lowered_quantum_ops``, consumed in pass 2.) - meas_ids_in_order: list[int] = [] - for chunk in chunks: - for op in chunk.get("operations") or []: - quantum = dict(op).get("Quantum") - if isinstance(quantum, dict) and "Measure" in quantum: - meas_ids_in_order.append(int(quantum["Measure"][1])) - - # Pass 2: replay gates, stamping MeasIds on MZ gates in global trace order. - meas_cursor = 0 for chunk in chunks: for gate in chunk.get("lowered_quantum_ops") or []: gate_type = str(gate["gate_type"]) @@ -1015,16 +1010,19 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> raise ValueError(msg) tick.idle(_runtime_idle_seconds_to_time_units(params[0]), qubits) elif gate_type == "MZ": - end = meas_cursor + len(qubits) - if end > len(meas_ids_in_order): + meas_ids = gate.get("measurement_result_ids") + if not isinstance(meas_ids, list): msg = ( - "More measured qubits than result(...)-anchored " - "MeasIds in the traced program; a measurement is " - "missing its result(...) call." + "Lowered MZ trace is missing measurement_result_ids; " + "rebuild PECOS so runtime-lowered measurements carry " + "their result-id provenance instead of relying on " + "operation-order inference." ) raise ValueError(msg) - tick.mz_with_ids(qubits, meas_ids_in_order[meas_cursor:end]) - meas_cursor = end + if len(meas_ids) != len(qubits): + msg = f"Lowered MZ gate carries {len(meas_ids)} measurement_result_ids for {len(qubits)} qubit(s)" + raise ValueError(msg) + tick.mz_with_ids(qubits, [int(meas_id) for meas_id in meas_ids]) elif gate_type == "RX": tick.rx(angles[0], qubits) elif gate_type == "RY": @@ -1055,14 +1053,6 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> msg = f"Unsupported lowered traced gate {gate_type!r}" raise ValueError(msg) - if meas_cursor != len(meas_ids_in_order): - msg = ( - f"Traced program has {len(meas_ids_in_order)} result(...)-anchored " - f"measurements but only {meas_cursor} measured qubit(s) in the " - "lowered gate stream; result()/measurement mismatch." - ) - raise ValueError(msg) - # Compact: ASAP-schedule gates into minimal ticks tick_circuit.compact_ticks() @@ -1283,6 +1273,7 @@ def _build_surface_tick_circuit_for_native_model( num_rounds, basis, ancilla_budget=ancilla_budget, + add_typed_annotations=False, ) if circuit_source == "abstract": @@ -1469,9 +1460,10 @@ def _surface_native_topology( """Build topology-only native analysis shared across noise parameters.""" import json - from pecos.qec import DagFaultAnalyzer from pecos.qec.surface.circuit_builder import ( + _build_canonical_dem_influence_map, _extract_measurement_order, + _metadata_uses_record_offsets, normalize_traced_qis_tick_circuit, ) @@ -1496,12 +1488,13 @@ def _surface_native_topology( tc.fill_idle_gates() dag = tc.to_dag_circuit() - analyzer = DagFaultAnalyzer(dag) - influence_map = analyzer.build_influence_map() + influence_map = _build_canonical_dem_influence_map(dag) detectors_json = tc.get_meta("detectors") or "[]" observables_json = tc.get_meta("observables") or "[]" - measurement_order = tuple(_extract_measurement_order(tc)) + measurement_order = ( + tuple(_extract_measurement_order(tc)) if _metadata_uses_record_offsets(detectors_json, observables_json) else () + ) num_measurements = int(tc.get_meta("num_measurements") or str(len(measurement_order))) return _CachedNativeSurfaceTopology( @@ -1572,12 +1565,14 @@ def _dem_string_from_cached_surface_topology( if hasattr(builder, "with_exact_branch_replay_circuit"): builder = builder.with_exact_branch_replay_circuit(topology.dag_circuit) + builder = builder.with_num_measurements(topology.num_measurements) + if topology.measurement_order: + builder = builder.with_measurement_order(list(topology.measurement_order)) dem = ( - builder - .with_num_measurements(topology.num_measurements) - .with_measurement_order(list(topology.measurement_order)) - .with_detectors_json(topology.detectors_json) - .with_observables_json(topology.observables_json) + builder.with_detectors_json(topology.detectors_json) + .with_observables_json( + topology.observables_json, + ) .build_with_source_tracking() ) if not decompose_errors: @@ -1690,32 +1685,35 @@ def _build_native_sampler_from_cached_surface_topology( ) sampler = ParsedDem.from_string(dem_str).to_dem_sampler() elif sampling_model in ("influence_dem", "mnm"): - import json - - det_records = [d["records"] for d in json.loads(topology.detectors_json)] - obs_records = [o["records"] for o in json.loads(topology.observables_json)] if topology.observables_json else [] - sampler = DemSampler.with_detectors( - topology.influence_map, - det_records, - obs_records, - noise.p1, - noise.p2, - noise.p_meas, - noise.p_prep, - p_idle=noise.p_idle, - t1=noise.t1, - t2=noise.t2, - 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, - p2_weights=_p2_weights_dict(noise.p2_weights), - p2_replacement_approximation=noise.p2_replacement_approximation, + from pecos.qec import DemSamplerBuilder + + sampler_builder = ( + DemSamplerBuilder(topology.influence_map) + .with_noise( + noise.p1, + noise.p2, + noise.p_meas, + noise.p_prep, + 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, + p2_weights=_p2_weights_dict(noise.p2_weights), + p2_replacement_approximation=noise.p2_replacement_approximation, + ) + .with_detectors_json(topology.detectors_json) + .with_observables_json(topology.observables_json) ) + if topology.measurement_order: + sampler_builder = sampler_builder.with_measurement_order(list(topology.measurement_order)) + sampler = sampler_builder.build() # Remap sampling_model for NativeSampler dispatch sampling_model = "influence_dem" else: 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 455b26348..7364ff563 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -11,10 +11,11 @@ from guppylang.std.quantum import h, measure, qubit, x from pecos.guppy import get_num_qubits, make_surface_code from pecos.qec import DetectorErrorModel -from pecos.qec.surface import SurfacePatch +from pecos.qec.surface import NoiseModel, SurfacePatch from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch from pecos.qec.surface.decode import ( _build_surface_tick_circuit_for_native_model, + _copy_surface_tick_circuit_metadata, _extract_measurement_meas_ids, _measurement_index_remap_for_orders, _reject_partially_lowered_trace, @@ -23,6 +24,7 @@ _replay_qis_trace_into_tick_circuit, _surface_runtime_measurement_remap_from_result_traces, _validate_result_tag_remap_against_traced_measurements, + generate_circuit_level_dem_from_builder, trace_guppy_into_tick_circuit_with_result_traces, ) @@ -145,26 +147,40 @@ def test_lowered_replay_uses_measure_result_ids_directly() -> None: {"Quantum": {"Measure": [1, 42]}}, ], "lowered_quantum_ops": [ - {"gate_type": "MZ", "qubits": [0], "angles": []}, - {"gate_type": "MZ", "qubits": [1], "angles": []}, + {"gate_type": "MZ", "qubits": [0], "angles": [], "measurement_result_ids": [42]}, + {"gate_type": "MZ", "qubits": [1], "angles": [], "measurement_result_ids": [99]}, ], }, ] tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) - assert _flat_mz_ids(tc) == [99, 42] + assert _flat_mz_ids(tc) == [42, 99] def test_lowered_replay_fails_on_measurement_count_mismatch() -> None: chunks = [ { "operations": [{"Quantum": {"Measure": [0, 7]}}], - "lowered_quantum_ops": [{"gate_type": "MZ", "qubits": [0, 1], "angles": []}], + "lowered_quantum_ops": [ + {"gate_type": "MZ", "qubits": [0, 1], "angles": [], "measurement_result_ids": [7]}, + ], + }, + ] + + with pytest.raises(ValueError, match="carries 1 measurement_result_ids for 2"): + _replay_lowered_qis_trace_into_tick_circuit(chunks) + + +def test_lowered_replay_fails_on_missing_measurement_result_ids() -> None: + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 7]}}], + "lowered_quantum_ops": [{"gate_type": "MZ", "qubits": [0], "angles": []}], }, ] - with pytest.raises(ValueError, match="More measured qubits"): + with pytest.raises(ValueError, match="missing measurement_result_ids"): _replay_lowered_qis_trace_into_tick_circuit(chunks) @@ -195,7 +211,7 @@ def test_lowered_runtime_idles_can_drive_memory_noise_dem() -> None: {"gate_type": "H", "qubits": [0], "angles": [], "params": []}, {"gate_type": "Idle", "qubits": [0], "angles": [], "params": [20e-9]}, {"gate_type": "H", "qubits": [0], "angles": [], "params": []}, - {"gate_type": "MZ", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "MZ", "qubits": [0], "angles": [], "params": [], "measurement_result_ids": [0]}, ], }, ] @@ -225,7 +241,7 @@ def test_lowered_runtime_idles_accept_axis_memory_noise_dem() -> None: "lowered_quantum_ops": [ {"gate_type": "PZ", "qubits": [0], "angles": [], "params": []}, {"gate_type": "Idle", "qubits": [0], "angles": [], "params": [10e-9]}, - {"gate_type": "MZ", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "MZ", "qubits": [0], "angles": [], "params": [], "measurement_result_ids": [0]}, ], }, ] @@ -257,7 +273,7 @@ def test_from_circuit_accepts_biased_p2_weights() -> None: {"gate_type": "PZ", "qubits": [0], "angles": [], "params": []}, {"gate_type": "PZ", "qubits": [1], "angles": [], "params": []}, {"gate_type": "CX", "qubits": [0, 1], "angles": [], "params": []}, - {"gate_type": "MZ", "qubits": [1], "angles": [], "params": []}, + {"gate_type": "MZ", "qubits": [1], "angles": [], "params": [], "measurement_result_ids": [0]}, ], }, ] @@ -305,7 +321,7 @@ def test_reject_partially_lowered_trace_passes_on_uniformly_lowered() -> None: chunks = [ { "operations": [{"Quantum": {"Measure": [0, 7]}}], - "lowered_quantum_ops": [{"gate_type": "MZ", "qubits": [0], "angles": []}], + "lowered_quantum_ops": [{"gate_type": "MZ", "qubits": [0], "angles": [], "measurement_result_ids": [7]}], }, { # allocation/output bookkeeping only; legitimately has no lowered ops "operations": [{"AllocateResult": {"id": 7}}, {"RecordOutput": {"id": 7}}], @@ -509,17 +525,18 @@ def test_from_guppy_constrained_surface_dem_byte_identical( noise=noise, ) assert got == ref_dem, ( - f"constrained surface from_guppy not byte-identical for " - f"d={d}, budget={budget}, basis={basis}, rounds={rounds}" + f"constrained surface from_guppy not byte-identical for d={d}, budget={budget}, basis={basis}, rounds={rounds}" ) def test_constrained_surface_traced_metadata_matches_abstract() -> None: - """The traced TickCircuit's surface metadata is copied verbatim from the - abstract reference. Specifically pins that - ``_copy_surface_tick_circuit_metadata`` propagates ``ancilla_budget`` - (the new key added when the constrained codegen landed) alongside the - existing detectors/observables/counts.""" + """Traced surface metadata preserves structure but binds via MeasIds. + + Runtime traces may reorder measurements, so detector/observable metadata + cannot be copied as positional ``records``. It should preserve the same + detector/observable IDs and descriptors while replacing abstract records + with runtime-stable ``meas_ids``. + """ patch = SurfacePatch.create(distance=3) abstract_tc = _build_surface_tick_circuit_for_native_model( patch, @@ -537,8 +554,6 @@ def test_constrained_surface_traced_metadata_matches_abstract() -> None: ) for key in ( "basis", - "detectors", - "observables", "num_measurements", "num_detectors", "ancilla_budget", @@ -546,10 +561,109 @@ def test_constrained_surface_traced_metadata_matches_abstract() -> None: a = abstract_tc.get_meta(key) b = traced_tc.get_meta(key) assert a == b, f"metadata mismatch on key {key!r}: abstract={a!r}, traced={b!r}" + + for key in ("detectors", "observables"): + abstract_entries = json.loads(abstract_tc.get_meta(key) or "[]") + traced_entries = json.loads(traced_tc.get_meta(key) or "[]") + assert len(abstract_entries) == len(traced_entries) + for abstract_entry, traced_entry in zip(abstract_entries, traced_entries, strict=True): + assert "records" in abstract_entry + assert "records" not in traced_entry + assert "meas_ids" in traced_entry + assert {k: v for k, v in abstract_entry.items() if k != "records"} == { + k: v for k, v in traced_entry.items() if k != "meas_ids" + } # ancilla_budget specifically must be the requested budget (stored as a string by set_meta). assert traced_tc.get_meta("ancilla_budget") == "2" +@pytest.mark.parametrize("basis", ["X", "Z"]) +def test_native_abstract_surface_dem_uses_record_metadata_only_for_r0(basis: str) -> None: + """Native abstract DEM construction must not mix typed Pauli annotations + with legacy record metadata. + + Public abstract circuits keep typed annotations by default, but the native + surface DEM helper consumes JSON record metadata. Mixing both sources makes + r=0 prep/readout DEMs carry a detectorless logical source that is absent + from the traced-QIS metadata path. + """ + patch = SurfacePatch.create(distance=3) + public_tc = generate_tick_circuit_from_patch(patch, num_rounds=0, basis=basis) + native_tc = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=0, + basis=basis, + circuit_source="abstract", + ) + + assert public_tc.annotations() + assert native_tc.annotations() == [] + 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) + for decompose_errors in (False, True): + dem_text = generate_circuit_level_dem_from_builder( + patch, + num_rounds=0, + noise=noise, + basis=basis, + circuit_source="abstract", + decompose_errors=decompose_errors, + ) + detectorless_logical_errors = [ + line + for line in dem_text.splitlines() + if line.startswith("error") and "L" in line and "D" not in line + ] + assert detectorless_logical_errors == [] + + +@pytest.mark.parametrize( + ("distance", "ancilla_budget"), + [ + (3, None), + (3, 2), + (9, None), + (9, 17), + ], +) +@pytest.mark.parametrize("basis", ["X", "Z"]) +@pytest.mark.parametrize("rounds", [0, 1, 3]) +def test_surface_memory_round_count_contract( + distance: int, + ancilla_budget: int | None, + basis: str, + rounds: int, +) -> None: + """Surface memory circuits count only full X/Z syndrome rounds as ``r``. + + Logical SPAM is outside ``r``: the prep phase measures only the random-sign + stabilizer family (X checks for Z-basis memory, Z checks for X-basis + memory), and readout measures all data qubits. Restricted and unrestricted + ancilla schedules must preserve that experiment contract. + """ + patch = SurfacePatch.create(distance=distance) + geom = patch.geometry + num_x_checks = len(geom.x_stabilizers) + num_z_checks = len(geom.z_stabilizers) + init_checks = num_z_checks if basis == "X" else num_x_checks + final_check_detectors = num_x_checks if basis == "X" else num_z_checks + expected_measurements = init_checks + rounds * (num_x_checks + num_z_checks) + geom.num_data + expected_detectors = rounds * (num_x_checks + num_z_checks) + final_check_detectors + + tc = generate_tick_circuit_from_patch( + patch, + num_rounds=rounds, + basis=basis, + ancilla_budget=ancilla_budget, + ) + + assert int(tc.get_meta("num_measurements")) == expected_measurements + assert len(json.loads(tc.get_meta("detectors") or "[]")) == expected_detectors + assert json.loads(tc.get_meta("observables") or "[]") + + @pytest.mark.parametrize(("d", "budget"), [(3, 1), (3, 2), (5, 3)]) def test_constrained_surface_lowered_qubit_stream_within_budget(d: int, budget: int) -> None: """The lowered-trace physical qubit IDs must stay within the budgeted @@ -742,7 +856,7 @@ def test_copy_surface_metadata_propagates_descriptors() -> None: assert len(obs_desc) > 0 -def test_surface_metadata_records_remap_to_runtime_measurement_order() -> None: +def test_surface_metadata_records_bind_to_runtime_meas_ids() -> None: remap = _measurement_index_remap_for_orders( [0, 1, 0, 2], [1, 0, 2, 0], @@ -763,10 +877,20 @@ def test_surface_metadata_records_remap_to_runtime_measurement_order() -> None: ), ) assert remapped == [ - {"id": 0, "records": [-3, -1]}, - {"id": 1, "records": [-4]}, + {"id": 0, "meas_ids": [1, 3]}, + {"id": 1, "meas_ids": [0]}, ] + existing_meas_ids = json.dumps([{"id": 2, "meas_ids": [0, 3]}]) + rebound = json.loads( + _remap_surface_record_metadata_json( + existing_meas_ids, + measurement_index_remap=remap, + num_measurements=4, + ), + ) + assert rebound == [{"id": 2, "meas_ids": [1, 2]}] + def test_surface_metadata_records_remap_to_runtime_result_tags() -> None: patch = SurfacePatch.create(distance=3) @@ -793,6 +917,53 @@ def test_surface_metadata_records_remap_to_runtime_result_tags() -> None: assert sorted(remap.values()) == list(range(29)) +def test_runtime_result_tags_bind_metadata_when_lowered_measurements_reorder() -> None: + from pecos_rslib.quantum import TickCircuit + + patch = SurfacePatch.create(distance=3) + abstract_tc = generate_tick_circuit_from_patch(patch, num_rounds=0, basis="Z") + result_traces = [ + {"name": "sx0:init:meas:0", "values": [False], "result_ids": [0]}, + {"name": "sx1:init:meas:1", "values": [False], "result_ids": [1]}, + {"name": "sx2:init:meas:2", "values": [False], "result_ids": [2]}, + {"name": "sx3:init:meas:3", "values": [False], "result_ids": [3]}, + { + "name": "final", + "values": [False] * 9, + # Semantic data-qubit order, independent of runtime MZ order. + "result_ids": list(range(4, 13)), + }, + ] + remap = _surface_runtime_measurement_remap_from_result_traces(abstract_tc, result_traces) + + traced_tc = TickCircuit() + traced_tc.tick().mz_with_ids([9, 10, 11, 12], [0, 1, 2, 3]) + traced_tc.tick().mz_with_ids( + [5, 0, 4, 1, 8, 3, 7, 6, 2], + [9, 4, 8, 5, 12, 7, 11, 10, 6], + ) + + assert _extract_measurement_meas_ids(traced_tc) != list(range(13)) + _validate_result_tag_remap_against_traced_measurements( + traced_tc, + remap, + expected_measurements=13, + ) + + _copy_surface_tick_circuit_metadata( + abstract_tc, + traced_tc, + measurement_index_remap=remap, + ) + detectors = json.loads(traced_tc.get_meta("detectors")) + observables = json.loads(traced_tc.get_meta("observables")) + assert all("records" not in entry for entry in detectors + observables) + assert all("meas_ids" in entry for entry in detectors + observables) + + logical_z_qubits = list(patch.geometry.logical_z.data_qubits) + assert observables == [{"id": 0, "meas_ids": [4 + q for q in logical_z_qubits]}] + + def test_result_tag_remap_validation_accepts_exact_traced_meas_ids() -> None: from pecos_rslib.quantum import TickCircuit From b50419b352f52db10e7978f2dae1655f9cd18188 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 18:20:15 -0600 Subject: [PATCH 046/150] Expose measurement crosstalk noise bindings --- python/pecos-rslib/src/engine_builders.rs | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/python/pecos-rslib/src/engine_builders.rs b/python/pecos-rslib/src/engine_builders.rs index 2585b87bd..9fd76a2ca 100644 --- a/python/pecos-rslib/src/engine_builders.rs +++ b/python/pecos-rslib/src/engine_builders.rs @@ -1218,6 +1218,32 @@ impl PyGeneralNoiseModelBuilder { }) } + /// Set the probability of global crosstalk during measurement operations + fn with_p_meas_crosstalk_global(&self, prob: f64) -> PyResult { + Ok(Self { + inner: self.inner.clone().with_p_meas_crosstalk_global(prob), + }) + } + + /// Set the probability of local crosstalk during measurement operations + fn with_p_meas_crosstalk_local(&self, prob: f64) -> PyResult { + Ok(Self { + inner: self.inner.clone().with_p_meas_crosstalk_local(prob), + }) + } + + /// Set the transition model for measurement crosstalk + fn with_p_meas_crosstalk_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_meas_crosstalk_model(&btree_map), + }) + } + /// Set the scaling factor for measurement errors fn with_meas_scale(&self, scale: f64) -> PyResult { Ok(Self { From 5c4c38806e097b798c37ea8b63f9b0ecff010927 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 18:37:54 -0600 Subject: [PATCH 047/150] Document measurement crosstalk DEM semantics --- docs/development/measurement-crosstalk-dem.md | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 docs/development/measurement-crosstalk-dem.md diff --git a/docs/development/measurement-crosstalk-dem.md b/docs/development/measurement-crosstalk-dem.md new file mode 100644 index 000000000..947395b7f --- /dev/null +++ b/docs/development/measurement-crosstalk-dem.md @@ -0,0 +1,69 @@ +# Measurement Crosstalk DEM Semantics + +This note records the intended long-term semantics for adding measurement-crosstalk +sources to PECOS detector error model generation. The goal is to keep simulator +noise, traced-QIS circuits, raw hypergraph DEMs, and decomposed decoder inputs +consistent without silently substituting an unrelated scalar noise model. + +## Runtime Source Model + +The general noise model represents measurement crosstalk with payload gates: + +- `MeasCrosstalkLocalPayload` identifies local victim qubits. +- `MeasCrosstalkGlobalPayload` identifies excluded active measurement qubits; the + victims are the live prepared qubits not listed in the payload. + +For each candidate victim, the simulator independently samples a crosstalk event +with the local or global payload probability. When an event occurs, the simulator +performs a hidden `MZ` on the victim and samples a transition from the configured +model: + +- `0->0` and `1->1` leave the victim unchanged. +- `0->1` and `1->0` apply `X`. +- `0->L` and `1->L` leak the victim; with `leak2depolar`, leakage is replaced by + an explicit depolarized Pauli/no-op branch in the simulator. + +The transition is conditioned on the hidden measurement outcome, so the exact +channel is not always a fixed Pauli channel independent of circuit state. + +## DEM Requirements + +A crosstalk DEM implementation should satisfy these constraints: + +- Preserve crosstalk as a first-class source family in source metadata. +- Use the actual payload placement from the traced/lowered circuit. +- Derive global-payload victims from the live prepared qubit set at that point in + the circuit, not from static qubit count alone. +- For exact mode, replay each hidden-measurement/transition branch against the + same detector and observable metadata used by the ideal circuit. +- Fail loudly if a crosstalk branch changes measurement dependencies in a way that + cannot be represented as a detector/observable flip against the ideal record. +- Keep any Pauli-twirled or averaged treatment explicit and opt-in; it must not be + used under a name that implies exact crosstalk DEM support. + +## Implementation Plan + +1. Extend `NoiseConfig` with measurement-crosstalk local/global probabilities, + transition weights, and an explicit approximation mode. +2. Add crosstalk payload source extraction in the circuit-aware DEM path. +3. Reuse the exact branch replay machinery where possible: compute the ideal + measurement parity expressions once, then evaluate branch effects by replaying + hidden `MZ` plus the transition action at each payload victim. +4. Emit raw hypergraph DEM contributions with crosstalk source metadata. +5. Extend source-level decomposition so graph-like decoder inputs preserve the + same crosstalk source identity and fail loudly on irreducible branch effects. +6. Thread the new options through Python bindings and surface helper APIs. +7. Keep coverage diagnostics reporting crosstalk as omitted until one of these DEM + modes is explicitly enabled and tested. + +## Minimal Tests + +The first implementation tests should cover: + +- Local payload with a single deterministic victim where `0->1` produces the same + detector flip as an `X` at that spacetime point. +- Local payload with `0->0`/`1->1` only, producing no DEM contribution. +- Global payload victim selection excluding the active measured qubits. +- `leak2depolar` transition expansion into explicit Pauli/no-op branches. +- Fail-loud behavior when hidden measurement changes detector dependencies rather + than only flipping deterministic parities. From 6c1caf78ef95f685de8dfac6251b3ad74dfbad94 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 18:56:02 -0600 Subject: [PATCH 048/150] Add exact deterministic local crosstalk DEM support --- .../src/fault_tolerance/dem_builder.rs | 3 +- .../fault_tolerance/dem_builder/builder.rs | 403 +++++++++++++++++- .../src/fault_tolerance/dem_builder/types.rs | 142 ++++++ docs/development/measurement-crosstalk-dem.md | 34 +- 4 files changed, 570 insertions(+), 12 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs index 391dcd7b5..01a6649dd 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs @@ -100,7 +100,8 @@ pub use sampler::{ pub use types::{ ContributionEffectSummary, ContributionRenderRecord, ContributionRenderStrategy, ContributionRenderSummary, DecomposedFault, DemOutput, DetectorDef, DetectorErrorModel, - DirectSourceFamily, FaultContribution, FaultMechanism, FaultSourceType, MeasurementMechanism, + DirectSourceFamily, FaultContribution, FaultMechanism, FaultSourceType, + MeasurementCrosstalkDemMode, MeasurementCrosstalkTransitionModel, MeasurementMechanism, MeasurementNoiseModel, NoiseConfig, PAULI_1Q_ORDER, PAULI_2Q_ORDER, PauliProbs, PauliWeights, PecosDemMetadataError, PerGateTypeNoise, ReplacementBranchApproximation, ReplacementBranchImpact, TwoDetectorDirectRenderPolicy, combine_probabilities, 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 1125a0af9..8afc2fc71 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -17,14 +17,16 @@ use super::types::{ DemOutput, DetectorDef, DetectorErrorModel, DirectSourceComponents, DirectSourceFamily, - FaultMechanism, NoiseConfig, PerGateTypeNoise, ReplacementBranchApproximation, SourceMetadata, - record_offset_to_absolute_index, + FaultMechanism, MeasurementCrosstalkDemMode, NoiseConfig, PerGateTypeNoise, + ReplacementBranchApproximation, SourceMetadata, record_offset_to_absolute_index, }; use crate::fault_tolerance::propagator::dag::DagSpacetimeLocation; use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Pauli}; use pecos_core::BitSet; use pecos_core::gate_type::GateType; -use pecos_simulators::symbolic_sparse_stab::MeasurementHistory; +use pecos_simulators::{ + SymbolicMeasurementResult, SymbolicSparseStab, symbolic_sparse_stab::MeasurementHistory, +}; use smallvec::SmallVec; use std::cell::RefCell; use std::collections::BTreeMap; @@ -733,6 +735,7 @@ impl<'a> DemBuilder<'a> { self.validate_measurement_count()?; self.validate_metadata_refs()?; self.validate_replacement_branch_approximation()?; + self.validate_measurement_crosstalk_dem_mode()?; Ok(self.build()) } @@ -749,6 +752,8 @@ impl<'a> DemBuilder<'a> { 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"); let num_influence_dem_outputs = self .num_influence_dem_outputs() .max(self.influence_map.dem_output_metadata.len()); @@ -850,6 +855,258 @@ impl<'a> DemBuilder<'a> { Ok(()) } + fn validate_measurement_crosstalk_dem_mode(&self) -> Result<(), DemBuilderError> { + if self.noise.measurement_crosstalk_dem_mode == MeasurementCrosstalkDemMode::Omitted { + return Ok(()); + } + + let has_local_payloads = self + .influence_map + .locations + .iter() + .any(|loc| !loc.before && loc.gate_type == GateType::MeasCrosstalkLocalPayload); + let has_global_payloads = self + .influence_map + .locations + .iter() + .any(|loc| !loc.before && loc.gate_type == GateType::MeasCrosstalkGlobalPayload); + + if !self.noise.p_meas_crosstalk_model.is_valid() { + return Err(DemBuilderError::ConfigurationError( + "measurement crosstalk transition probabilities must be finite, non-negative, and have each hidden-outcome row sum <= 1" + .to_string(), + )); + } + + if has_global_payloads && self.noise.p_meas_crosstalk_global > 0.0 { + return Err(DemBuilderError::ConfigurationError( + "exact deterministic measurement crosstalk DEM replay does not yet support global payloads" + .to_string(), + )); + } + + if self.noise.p_meas_crosstalk_local <= 0.0 || !has_local_payloads { + return Ok(()); + } + + if self.noise.p_meas_crosstalk_model.has_leakage() { + return Err(DemBuilderError::ConfigurationError( + "exact deterministic measurement crosstalk DEM replay does not yet support leakage transitions" + .to_string(), + )); + } + + let Some(context) = self.exact_branch_context else { + return Err(DemBuilderError::ConfigurationError( + "exact deterministic measurement crosstalk DEM replay requires a circuit-aware builder context" + .to_string(), + )); + }; + + for (loc_idx, loc) in self.influence_map.locations.iter().enumerate() { + if loc.before || loc.gate_type != GateType::MeasCrosstalkLocalPayload { + continue; + } + let result = self.hidden_mz_result_before_crosstalk_payload(context, loc)?; + if !result.is_deterministic || !result.outcome.is_empty() { + return Err(DemBuilderError::ConfigurationError(format!( + "exact deterministic measurement crosstalk DEM replay requires a state-independent hidden MZ result at location {loc_idx} (node {}, qubit {:?}); got deterministic={}, dependencies={:?}", + loc.node, + loc.qubits.first(), + result.is_deterministic, + result.outcome + ))); + } + } + + Ok(()) + } + + fn hidden_mz_result_before_crosstalk_payload( + &self, + context: ExactBranchReplayContext<'_>, + loc: &DagSpacetimeLocation, + ) -> Result { + let qubit = loc.qubits.first().copied().ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "measurement crosstalk payload at node {} has no victim qubit", + loc.node + )) + })?; + let qubit_index = qubit.index(); + let topo_order = context.circuit.topological_order(); + let max_qubit = topo_order + .iter() + .filter_map(|&node| context.circuit.gate(node)) + .flat_map(|gate| gate.qubits.iter()) + .map(pecos_core::QubitId::index) + .max() + .unwrap_or(qubit_index); + let mut sim = SymbolicSparseStab::new(max_qubit.max(qubit_index) + 1); + + for node in topo_order { + if node == loc.node { + return sim.mz(&[qubit_index]).into_iter().next().ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "failed to synthesize hidden MZ for measurement crosstalk payload at node {}", + loc.node + )) + }); + } + + if let Some(gate) = context.circuit.gate(node) { + let qubits: Vec = + gate.qubits.iter().map(pecos_core::QubitId::index).collect(); + Self::apply_symbolic_gate_for_crosstalk_hidden_mz( + &mut sim, + node, + gate.gate_type, + &qubits, + )?; + } + } + + Err(DemBuilderError::ConfigurationError(format!( + "measurement crosstalk payload node {} was not found in the replay circuit", + loc.node + ))) + } + + fn apply_symbolic_gate_for_crosstalk_hidden_mz( + sim: &mut SymbolicSparseStab, + node: usize, + gate_type: GateType, + qubits: &[usize], + ) -> Result<(), DemBuilderError> { + let require = |n: usize| -> Result<(), DemBuilderError> { + if qubits.len() < n { + return Err(DemBuilderError::ConfigurationError(format!( + "measurement crosstalk replay expected gate {:?} at node {} to have at least {} qubit(s), got {}", + gate_type, + node, + n, + qubits.len() + ))); + } + Ok(()) + }; + + match gate_type { + GateType::H => { + require(1)?; + sim.h(&[qubits[0]]); + } + GateType::F => { + require(1)?; + sim.sx(&[qubits[0]]); + sim.sz(&[qubits[0]]); + } + GateType::Fdg => { + require(1)?; + sim.szdg(&[qubits[0]]); + sim.sxdg(&[qubits[0]]); + } + GateType::SX => { + require(1)?; + sim.sx(&[qubits[0]]); + } + GateType::SXdg => { + require(1)?; + sim.sxdg(&[qubits[0]]); + } + GateType::SY => { + require(1)?; + sim.sy(&[qubits[0]]); + } + GateType::SYdg => { + require(1)?; + sim.sydg(&[qubits[0]]); + } + GateType::SZ => { + require(1)?; + sim.sz(&[qubits[0]]); + } + GateType::SZdg => { + require(1)?; + sim.szdg(&[qubits[0]]); + } + GateType::X => { + require(1)?; + sim.x(&[qubits[0]]); + } + GateType::Y => { + require(1)?; + sim.y(&[qubits[0]]); + } + GateType::Z => { + require(1)?; + sim.z(&[qubits[0]]); + } + GateType::CX => { + require(2)?; + sim.cx(&[(qubits[0], qubits[1])]); + } + GateType::CY => { + require(2)?; + sim.cy(&[(qubits[0], qubits[1])]); + } + GateType::CZ => { + require(2)?; + sim.cz(&[(qubits[0], qubits[1])]); + } + GateType::SXX => { + require(2)?; + sim.sxx(&[(qubits[0], qubits[1])]); + } + GateType::SXXdg => { + require(2)?; + sim.sxxdg(&[(qubits[0], qubits[1])]); + } + GateType::SYY => { + require(2)?; + sim.syy(&[(qubits[0], qubits[1])]); + } + GateType::SYYdg => { + require(2)?; + sim.syydg(&[(qubits[0], qubits[1])]); + } + GateType::SZZ => { + require(2)?; + sim.szz(&[(qubits[0], qubits[1])]); + } + GateType::SZZdg => { + require(2)?; + sim.szzdg(&[(qubits[0], qubits[1])]); + } + GateType::SWAP => { + require(2)?; + sim.swap(&[(qubits[0], qubits[1])]); + } + GateType::MZ | GateType::MeasureFree => { + require(1)?; + sim.mz(&[qubits[0]]); + } + GateType::PZ | GateType::QAlloc => { + require(1)?; + sim.pz(qubits[0]); + } + GateType::I + | GateType::Idle + | GateType::QFree + | GateType::MeasCrosstalkGlobalPayload + | GateType::MeasCrosstalkLocalPayload + | GateType::TrackedPauliMeta => {} + _ => { + return Err(DemBuilderError::ConfigurationError(format!( + "measurement crosstalk exact deterministic replay does not support gate {:?} before payload node {}", + gate_type, node + ))); + } + } + + Ok(()) + } + #[cfg(test)] fn exact_omitted_branch_base_effect( &self, @@ -1032,6 +1289,19 @@ impl<'a> DemBuilder<'a> { meas_to_observables, ); } + GateType::MeasCrosstalkLocalPayload + if !loc.before + && self.noise.measurement_crosstalk_dem_mode + == MeasurementCrosstalkDemMode::ExactDeterministic + && self.noise.p_meas_crosstalk_local > 0.0 => + { + self.process_measurement_crosstalk_local_source_tracked( + loc_idx, + dem, + meas_to_detectors, + meas_to_observables, + ); + } gate_type if is_two_qubit_noise_gate(gate_type) && !loc.before => {} GateType::H | GateType::F @@ -1267,6 +1537,44 @@ impl<'a> DemBuilder<'a> { } } + /// Processes local measurement-crosstalk payloads when hidden outcomes are + /// deterministic and state-independent. + fn process_measurement_crosstalk_local_source_tracked( + &self, + loc_idx: usize, + dem: &mut DetectorErrorModel, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, + ) { + let context = self + .exact_branch_context + .expect("measurement crosstalk exact deterministic mode was validated with context"); + let loc = &self.influence_map.locations[loc_idx]; + let hidden = self + .hidden_mz_result_before_crosstalk_payload(context, loc) + .expect("measurement crosstalk exact deterministic hidden result was validated"); + let transition_probability = if hidden.flip { + self.noise.p_meas_crosstalk_model.p_1_to_0 + } else { + self.noise.p_meas_crosstalk_model.p_0_to_1 + }; + let p = self.noise.p_meas_crosstalk_local * transition_probability; + if p <= 0.0 { + return; + } + + let mechanism = + self.compute_mechanism(loc_idx, Pauli::X, meas_to_detectors, meas_to_observables); + if !mechanism.is_empty() { + dem.add_direct_contribution_with_source( + mechanism, + p, + SourceMetadata::new(&[loc_idx], &[Pauli::X], &[loc.gate_type], &[loc.before]) + .with_direct_source_family(DirectSourceFamily::MeasurementCrosstalk), + ); + } + } + /// 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( @@ -3708,6 +4016,95 @@ mod tests { assert!(branch_pauli_effect.is_empty()); } + fn single_qubit_local_crosstalk_circuit(pre_payload_h: bool) -> pecos_quantum::DagCircuit { + use pecos_core::{Gate, QubitId}; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + if pre_payload_h { + circuit.add_gate_auto_wire(Gate::h(&[QubitId(0)])); + } + circuit.add_gate_auto_wire(Gate::meas_crosstalk_local_payload(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::mz(&[QubitId(0)])); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + circuit + } + + #[test] + fn test_exact_deterministic_local_measurement_crosstalk_emits_dem_source() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_local_crosstalk_circuit(false); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_local_rate(0.25) + .set_measurement_crosstalk_transition_model( + MeasurementCrosstalkTransitionModel::bit_flip(0.4, 0.0), + ) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("deterministic local measurement crosstalk should be representable"); + + let contributions = dem.contributions_for_effect(&[0], &[]); + assert_eq!(contributions.len(), 1); + assert!((contributions[0].probability - 0.1).abs() < 1.0e-12); + assert_eq!( + contributions[0].direct_source_family, + Some(DirectSourceFamily::MeasurementCrosstalk) + ); + assert_eq!( + contributions[0].source_gate_types.as_slice(), + &[GateType::MeasCrosstalkLocalPayload] + ); + assert_eq!(contributions[0].paulis.as_slice(), &[Pauli::X]); + } + + #[test] + fn test_exact_deterministic_local_measurement_crosstalk_identity_transition_is_empty() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_local_crosstalk_circuit(false); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_local_rate(0.25) + .set_measurement_crosstalk_transition_model( + MeasurementCrosstalkTransitionModel::bit_flip(0.0, 0.0), + ) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("identity local measurement crosstalk should be representable"); + + assert_eq!(dem.num_contributions(), 0); + } + + #[test] + fn test_exact_deterministic_local_measurement_crosstalk_rejects_hidden_randomness() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_local_crosstalk_circuit(true); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_local_rate(0.25) + .set_measurement_crosstalk_transition_model( + MeasurementCrosstalkTransitionModel::bit_flip(0.4, 0.0), + ) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let err = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect_err("nondeterministic hidden measurement must fail loudly"); + + assert!(matches!(err, DemBuilderError::ConfigurationError(_))); + assert!( + err.to_string() + .contains("state-independent hidden MZ result"), + "unexpected error: {err}" + ); + } + #[test] fn test_from_circuit_exact_branch_replay_skips_empty_replacement_effect() { use crate::fault_tolerance::dem_builder::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 e629bfe59..d637d19ed 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -134,6 +134,9 @@ pub enum DirectSourceFamily { /// Two-location direct source produced by exact replacement-branch replay. TwoLocationExactReplacementBranch, + /// Single-location direct source produced by measurement-crosstalk replay. + MeasurementCrosstalk, + /// Fallback for other direct-source shapes. Other, } @@ -2496,6 +2499,97 @@ pub struct NoiseConfig { pub p_idle_x_quadratic_rate: f64, /// Stochastic Y-memory error rate quadratic in idle duration. pub p_idle_y_quadratic_rate: f64, + /// Per-payload local measurement-crosstalk event rate. + /// + /// This rate is multiplied by the selected hidden-measurement transition + /// probability from [`MeasurementCrosstalkTransitionModel`] when crosstalk + /// DEM replay is enabled. + pub p_meas_crosstalk_local: f64, + /// Per-payload global measurement-crosstalk event rate. + /// + /// Global payload DEM replay is intentionally not implemented yet because + /// the source semantics need to be represented explicitly. If exact + /// crosstalk DEM replay is requested with a positive global rate, the DEM + /// builder fails loudly. + pub p_meas_crosstalk_global: f64, + /// Hidden-measurement transition probabilities used by measurement + /// crosstalk DEM replay. + pub p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel, + /// Policy for converting measurement-crosstalk payloads into DEM sources. + pub measurement_crosstalk_dem_mode: MeasurementCrosstalkDemMode, +} + +/// Policy for converting runtime measurement-crosstalk payloads into DEMs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MeasurementCrosstalkDemMode { + /// Ignore measurement-crosstalk payloads when constructing DEMs. + #[default] + Omitted, + /// Replay payloads exactly when the hidden measurement outcome is + /// deterministic and state-independent. + ExactDeterministic, +} + +/// Hidden-measurement transition probabilities for local measurement crosstalk. +/// +/// The no-op probabilities are implicit: +/// `p(0->0) = 1 - p_0_to_1 - p_0_to_leak` and +/// `p(1->1) = 1 - p_1_to_0 - p_1_to_leak`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MeasurementCrosstalkTransitionModel { + /// Probability that a hidden 0 result flips to 1. + pub p_0_to_1: f64, + /// Probability that a hidden 0 result leaks. + pub p_0_to_leak: f64, + /// Probability that a hidden 1 result flips to 0. + pub p_1_to_0: f64, + /// Probability that a hidden 1 result leaks. + pub p_1_to_leak: f64, +} + +impl MeasurementCrosstalkTransitionModel { + /// Creates a no-leakage measurement-crosstalk transition model. + #[must_use] + pub const fn bit_flip(p_0_to_1: f64, p_1_to_0: f64) -> Self { + Self { + p_0_to_1, + p_0_to_leak: 0.0, + p_1_to_0, + p_1_to_leak: 0.0, + } + } + + /// Returns true if all transition probabilities are finite and valid. + #[must_use] + pub fn is_valid(&self) -> bool { + self.p_0_to_1.is_finite() + && self.p_0_to_leak.is_finite() + && self.p_1_to_0.is_finite() + && self.p_1_to_leak.is_finite() + && self.p_0_to_1 >= 0.0 + && self.p_0_to_leak >= 0.0 + && self.p_1_to_0 >= 0.0 + && self.p_1_to_leak >= 0.0 + && self.p_0_to_1 + self.p_0_to_leak <= 1.0 + f64::EPSILON + && self.p_1_to_0 + self.p_1_to_leak <= 1.0 + f64::EPSILON + } + + /// Returns true when any leakage transition probability is non-zero. + #[must_use] + pub fn has_leakage(&self) -> bool { + self.p_0_to_leak > 0.0 || self.p_1_to_leak > 0.0 + } +} + +impl Default for MeasurementCrosstalkTransitionModel { + fn default() -> Self { + Self { + p_0_to_1: 0.0, + p_0_to_leak: 0.0, + p_1_to_0: 0.0, + p_1_to_leak: 0.0, + } + } } /// Per-Pauli error probabilities for a single qubit. @@ -2568,6 +2662,10 @@ impl Default for NoiseConfig { p_idle_y_linear_rate: 0.0, p_idle_x_quadratic_rate: 0.0, p_idle_y_quadratic_rate: 0.0, + p_meas_crosstalk_local: 0.0, + p_meas_crosstalk_global: 0.0, + p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), + measurement_crosstalk_dem_mode: MeasurementCrosstalkDemMode::default(), } } } @@ -2594,6 +2692,10 @@ impl NoiseConfig { p_idle_y_linear_rate: 0.0, p_idle_x_quadratic_rate: 0.0, p_idle_y_quadratic_rate: 0.0, + p_meas_crosstalk_local: 0.0, + p_meas_crosstalk_global: 0.0, + p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), + measurement_crosstalk_dem_mode: MeasurementCrosstalkDemMode::default(), } } @@ -2618,6 +2720,10 @@ impl NoiseConfig { p_idle_y_linear_rate: 0.0, p_idle_x_quadratic_rate: 0.0, p_idle_y_quadratic_rate: 0.0, + p_meas_crosstalk_local: 0.0, + p_meas_crosstalk_global: 0.0, + p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), + measurement_crosstalk_dem_mode: MeasurementCrosstalkDemMode::default(), } } @@ -2642,6 +2748,10 @@ impl NoiseConfig { p_idle_y_linear_rate: 0.0, p_idle_x_quadratic_rate: 0.0, p_idle_y_quadratic_rate: 0.0, + p_meas_crosstalk_local: 0.0, + p_meas_crosstalk_global: 0.0, + p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), + measurement_crosstalk_dem_mode: MeasurementCrosstalkDemMode::default(), } } @@ -2748,6 +2858,37 @@ impl NoiseConfig { self } + /// Sets the local measurement-crosstalk payload event rate. + #[must_use] + pub fn set_measurement_crosstalk_local_rate(mut self, rate: f64) -> Self { + self.p_meas_crosstalk_local = rate.max(0.0); + self + } + + /// Sets the global measurement-crosstalk payload event rate. + #[must_use] + pub fn set_measurement_crosstalk_global_rate(mut self, rate: f64) -> Self { + self.p_meas_crosstalk_global = rate.max(0.0); + self + } + + /// Sets the hidden-measurement transition model for crosstalk DEM replay. + #[must_use] + pub fn set_measurement_crosstalk_transition_model( + mut self, + model: MeasurementCrosstalkTransitionModel, + ) -> Self { + self.p_meas_crosstalk_model = model; + self + } + + /// Sets the policy for converting measurement-crosstalk payloads into DEMs. + #[must_use] + pub fn set_measurement_crosstalk_dem_mode(mut self, mode: MeasurementCrosstalkDemMode) -> Self { + self.measurement_crosstalk_dem_mode = mode; + self + } + /// Sets idle noise from a coherent RZ rotation angle per time unit. /// /// Converts `idle_rz` (the angle theta of an RZ(theta) rotation applied @@ -4250,6 +4391,7 @@ impl DetectorErrorModel { DirectSourceFamily::TwoLocationExactReplacementBranch => { "TwoLocationExactReplacementBranch" } + DirectSourceFamily::MeasurementCrosstalk => "MeasurementCrosstalk", DirectSourceFamily::Other => "Other", } } diff --git a/docs/development/measurement-crosstalk-dem.md b/docs/development/measurement-crosstalk-dem.md index 947395b7f..fc50d5c3b 100644 --- a/docs/development/measurement-crosstalk-dem.md +++ b/docs/development/measurement-crosstalk-dem.md @@ -41,20 +41,38 @@ A crosstalk DEM implementation should satisfy these constraints: - Keep any Pauli-twirled or averaged treatment explicit and opt-in; it must not be used under a name that implies exact crosstalk DEM support. +## Implemented Mode + +`NoiseConfig` now exposes `MeasurementCrosstalkDemMode::ExactDeterministic` +for the local-payload subset. In this mode the circuit-aware DEM builder: + +- Replays the ideal Clifford circuit up to each `MeasCrosstalkLocalPayload`. +- Synthesizes the hidden `MZ` on the payload victim. +- Requires that hidden result to be deterministic and state-independent. +- Emits an `X`-equivalent DEM source with `DirectSourceFamily::MeasurementCrosstalk` + for `0->1` or `1->0` transitions. +- Emits no contribution for implicit `0->0` or `1->1` transitions. +- Fails loudly if global payloads, leakage transitions, missing circuit context, + unsupported pre-payload gates, or nondeterministic hidden outcomes are present. + +This mode is intentionally narrow: it is exact for the deterministic local cases +it accepts, and it rejects cases that still need a branch-level representation. + ## Implementation Plan -1. Extend `NoiseConfig` with measurement-crosstalk local/global probabilities, - transition weights, and an explicit approximation mode. -2. Add crosstalk payload source extraction in the circuit-aware DEM path. +1. Extend the exact crosstalk DEM path beyond deterministic local bit-flip + transitions. +2. Add global-payload victim selection from the live prepared qubit set. 3. Reuse the exact branch replay machinery where possible: compute the ideal measurement parity expressions once, then evaluate branch effects by replaying hidden `MZ` plus the transition action at each payload victim. -4. Emit raw hypergraph DEM contributions with crosstalk source metadata. -5. Extend source-level decomposition so graph-like decoder inputs preserve the +4. Add `leak2depolar` transition expansion into explicit Pauli/no-op branches. +5. Emit raw hypergraph DEM contributions with crosstalk source metadata. +6. Extend source-level decomposition so graph-like decoder inputs preserve the same crosstalk source identity and fail loudly on irreducible branch effects. -6. Thread the new options through Python bindings and surface helper APIs. -7. Keep coverage diagnostics reporting crosstalk as omitted until one of these DEM - modes is explicitly enabled and tested. +7. Thread the new options through Python bindings and surface helper APIs. +8. Keep coverage diagnostics reporting unsupported crosstalk branches as omitted + until the relevant DEM modes are explicitly enabled and tested. ## Minimal Tests From 96425bb9aa251f873f05a2e5b9cc385090095819 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 19:41:57 -0600 Subject: [PATCH 049/150] Expose measurement crosstalk DEM options to Python --- .../src/fault_tolerance_bindings.rs | 130 +++++++++++++++++- 1 file changed, 123 insertions(+), 7 deletions(-) diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index fa6c36b52..021316745 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -51,8 +51,9 @@ 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, NoiseConfig, PAULI_2Q_ORDER, - ParsedDem as RustParsedDem, PauliWeights, ReplacementBranchApproximation, + FaultSourceType as RustFaultSourceType, MeasurementCrosstalkDemMode, + MeasurementCrosstalkTransitionModel, NoiseConfig, PAULI_2Q_ORDER, ParsedDem as RustParsedDem, + PauliWeights, ReplacementBranchApproximation, TwoDetectorDirectRenderPolicy as RustTwoDetectorDirectRenderPolicy, compare_dems_exact as rust_compare_dems_exact, compare_dems_statistical as rust_compare_dems_statistical, @@ -169,6 +170,64 @@ fn parse_replacement_approximation( } } +fn parse_measurement_crosstalk_dem_mode( + value: Option, +) -> PyResult { + let Some(value) = value else { + return Ok(MeasurementCrosstalkDemMode::default()); + }; + match value + .trim() + .to_ascii_lowercase() + .replace(['-', ' '], "_") + .as_str() + { + "omitted" | "omit" | "none" | "off" => Ok(MeasurementCrosstalkDemMode::Omitted), + "exact_deterministic" | "exact" | "deterministic" => { + Ok(MeasurementCrosstalkDemMode::ExactDeterministic) + } + _ => Err(pyo3::exceptions::PyValueError::new_err( + "measurement_crosstalk_dem_mode must be 'omitted' or 'exact_deterministic'", + )), + } +} + +fn parse_measurement_crosstalk_transition_model( + value: Option>, +) -> PyResult { + let Some(value) = value else { + return Ok(MeasurementCrosstalkTransitionModel::default()); + }; + let mut model = MeasurementCrosstalkTransitionModel::default(); + for (key, probability) in value { + if !probability.is_finite() || probability < 0.0 { + let msg = format!( + "measurement crosstalk transition probability for {key:?} must be finite and non-negative" + ); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + match key.trim().to_ascii_uppercase().replace(' ', "").as_str() { + "0->0" | "1->1" => {} + "0->1" => model.p_0_to_1 = probability, + "0->L" => model.p_0_to_leak = probability, + "1->0" => model.p_1_to_0 = probability, + "1->L" => model.p_1_to_leak = probability, + _ => { + let msg = format!( + "unsupported measurement crosstalk transition key {key:?}; expected 0->0, 0->1, 0->L, 1->0, 1->1, or 1->L" + ); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + } + } + if !model.is_valid() { + return Err(pyo3::exceptions::PyValueError::new_err( + "measurement crosstalk transition rows must sum to <= 1", + )); + } + Ok(model) +} + fn apply_noise_options( mut noise: NoiseConfig, p_idle: Option, @@ -185,6 +244,10 @@ fn apply_noise_options( p_idle_z_quadratic_rate: Option, p2_weights: Option>, p2_replacement_approximation: Option, + p_meas_crosstalk_local: Option, + p_meas_crosstalk_global: Option, + p_meas_crosstalk_model: Option>, + measurement_crosstalk_dem_mode: Option, ) -> PyResult { noise.p_idle = p_idle.unwrap_or(0.0); if let (Some(t1_val), Some(t2_val)) = (t1, t2) { @@ -223,6 +286,18 @@ fn apply_noise_options( noise = noise.set_p2_replacement_approximation(parse_replacement_approximation( p2_replacement_approximation, )?); + if let Some(rate) = p_meas_crosstalk_local { + noise = noise.set_measurement_crosstalk_local_rate(rate); + } + if let Some(rate) = p_meas_crosstalk_global { + noise = noise.set_measurement_crosstalk_global_rate(rate); + } + noise = noise.set_measurement_crosstalk_transition_model( + parse_measurement_crosstalk_transition_model(p_meas_crosstalk_model)?, + ); + noise = noise.set_measurement_crosstalk_dem_mode(parse_measurement_crosstalk_dem_mode( + measurement_crosstalk_dem_mode, + )?); Ok(noise) } @@ -1071,6 +1146,7 @@ fn contribution_record_to_pydict( RustDirectSourceFamily::TwoLocationExactReplacementBranch => { "TwoLocationExactReplacementBranch" } + RustDirectSourceFamily::MeasurementCrosstalk => "MeasurementCrosstalk", RustDirectSourceFamily::Other => "Other", }; dict.set_item("direct_source_family", family_label)?; @@ -1125,7 +1201,7 @@ impl PyDetectorErrorModel { /// >>> print(dem.to_string()) /// >>> sampler = dem.to_sampler() #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &pyo3::Bound<'_, pyo3::PyAny>, @@ -1147,6 +1223,10 @@ impl PyDetectorErrorModel { p_idle_z_quadratic_rate: Option, p2_weights: Option>, p2_replacement_approximation: Option, + p_meas_crosstalk_local: Option, + p_meas_crosstalk_global: Option, + p_meas_crosstalk_model: Option>, + measurement_crosstalk_dem_mode: Option, ) -> PyResult { use pecos_qec::fault_tolerance::dem_builder::DemBuilder; @@ -1166,6 +1246,10 @@ impl PyDetectorErrorModel { p_idle_z_quadratic_rate, p2_weights, p2_replacement_approximation, + p_meas_crosstalk_local, + p_meas_crosstalk_global, + p_meas_crosstalk_model, + measurement_crosstalk_dem_mode, )?; if let Ok(dag) = circuit.extract::>() @@ -1517,7 +1601,7 @@ impl PyDemBuilder { /// /// Returns: /// Self for method chaining. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -1539,6 +1623,10 @@ impl PyDemBuilder { p_idle_z_quadratic_rate: Option, p2_weights: Option>, p2_replacement_approximation: Option, + p_meas_crosstalk_local: Option, + p_meas_crosstalk_global: Option, + p_meas_crosstalk_model: Option>, + measurement_crosstalk_dem_mode: Option, ) -> PyResult> { slf.noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -1556,6 +1644,10 @@ impl PyDemBuilder { p_idle_z_quadratic_rate, p2_weights, p2_replacement_approximation, + p_meas_crosstalk_local, + p_meas_crosstalk_global, + p_meas_crosstalk_model, + measurement_crosstalk_dem_mode, )?; Ok(slf) } @@ -3418,7 +3510,7 @@ impl PyDemSampler { /// >>> sampler = DemSampler.from_circuit(dag, p1=0.001, p2=0.01) /// >>> sampler = DemSampler.from_circuit(tc, p2=0.01) # TickCircuit also works #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &Bound<'_, pyo3::PyAny>, @@ -3440,6 +3532,10 @@ impl PyDemSampler { p_idle_z_quadratic_rate: Option, p2_weights: Option>, p2_replacement_approximation: Option, + p_meas_crosstalk_local: Option, + p_meas_crosstalk_global: Option, + p_meas_crosstalk_model: Option>, + measurement_crosstalk_dem_mode: Option, ) -> PyResult { let noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -3457,6 +3553,10 @@ impl PyDemSampler { p_idle_z_quadratic_rate, p2_weights, p2_replacement_approximation, + p_meas_crosstalk_local, + p_meas_crosstalk_global, + p_meas_crosstalk_model, + measurement_crosstalk_dem_mode, )?; // Accept both DagCircuit and TickCircuit @@ -3567,7 +3667,7 @@ impl PyDemSampler { /// /// The `observables` argument defines observables. #[staticmethod] - #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] + #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_detectors( influence_map: &PyDagFaultInfluenceMap, @@ -3591,6 +3691,10 @@ impl PyDemSampler { p_idle_z_quadratic_rate: Option, p2_weights: Option>, p2_replacement_approximation: Option, + p_meas_crosstalk_local: Option, + p_meas_crosstalk_global: Option, + p_meas_crosstalk_model: Option>, + measurement_crosstalk_dem_mode: Option, ) -> PyResult { let noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -3608,6 +3712,10 @@ impl PyDemSampler { p_idle_z_quadratic_rate, p2_weights, p2_replacement_approximation, + p_meas_crosstalk_local, + p_meas_crosstalk_global, + p_meas_crosstalk_model, + measurement_crosstalk_dem_mode, )?; let inner = RustNewDemSamplerBuilder::new(&influence_map.inner) .with_noise_config(noise) @@ -4061,7 +4169,7 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -4083,6 +4191,10 @@ impl PyDemSamplerBuilder { p_idle_z_quadratic_rate: Option, p2_weights: Option>, p2_replacement_approximation: Option, + p_meas_crosstalk_local: Option, + p_meas_crosstalk_global: Option, + p_meas_crosstalk_model: Option>, + measurement_crosstalk_dem_mode: Option, ) -> PyResult> { slf.noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -4100,6 +4212,10 @@ impl PyDemSamplerBuilder { p_idle_z_quadratic_rate, p2_weights, p2_replacement_approximation, + p_meas_crosstalk_local, + p_meas_crosstalk_global, + p_meas_crosstalk_model, + measurement_crosstalk_dem_mode, )?; Ok(slf) } From e20eeaf6db765ec0fdcb03efccbe4f72c307d1e9 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 20:35:51 -0600 Subject: [PATCH 050/150] Fail loudly for crosstalk DEMs without payloads --- .../fault_tolerance/dem_builder/builder.rs | 79 ++++++++++++++++++- 1 file changed, 77 insertions(+), 2 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 8afc2fc71..ab0b8fa5a 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -878,14 +878,28 @@ impl<'a> DemBuilder<'a> { )); } - if has_global_payloads && self.noise.p_meas_crosstalk_global > 0.0 { + if self.noise.p_meas_crosstalk_global > 0.0 && !has_global_payloads { + return Err(DemBuilderError::ConfigurationError( + "exact deterministic measurement crosstalk DEM replay requested a positive global rate, but the influence map contains no MeasCrosstalkGlobalPayload locations" + .to_string(), + )); + } + + if self.noise.p_meas_crosstalk_global > 0.0 { return Err(DemBuilderError::ConfigurationError( "exact deterministic measurement crosstalk DEM replay does not yet support global payloads" .to_string(), )); } - if self.noise.p_meas_crosstalk_local <= 0.0 || !has_local_payloads { + if self.noise.p_meas_crosstalk_local > 0.0 && !has_local_payloads { + return Err(DemBuilderError::ConfigurationError( + "exact deterministic measurement crosstalk DEM replay requested a positive local rate, but the influence map contains no MeasCrosstalkLocalPayload locations" + .to_string(), + )); + } + + if self.noise.p_meas_crosstalk_local <= 0.0 { return Ok(()); } @@ -4035,6 +4049,21 @@ mod tests { circuit } + fn single_qubit_no_crosstalk_payload_circuit() -> pecos_quantum::DagCircuit { + use pecos_core::{Gate, QubitId}; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::mz(&[QubitId(0)])); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + circuit + } + #[test] fn test_exact_deterministic_local_measurement_crosstalk_emits_dem_source() { use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; @@ -4064,6 +4093,52 @@ mod tests { assert_eq!(contributions[0].paulis.as_slice(), &[Pauli::X]); } + #[test] + fn test_exact_deterministic_local_measurement_crosstalk_requires_payloads() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_no_crosstalk_payload_circuit(); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_local_rate(0.25) + .set_measurement_crosstalk_transition_model( + MeasurementCrosstalkTransitionModel::bit_flip(0.4, 0.0), + ) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let err = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect_err("positive local crosstalk rate without payloads must fail loudly"); + + assert!(matches!(err, DemBuilderError::ConfigurationError(_))); + assert!( + err.to_string() + .contains("no MeasCrosstalkLocalPayload locations"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_exact_deterministic_global_measurement_crosstalk_requires_payloads() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_no_crosstalk_payload_circuit(); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_global_rate(0.25) + .set_measurement_crosstalk_transition_model( + MeasurementCrosstalkTransitionModel::bit_flip(0.4, 0.0), + ) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let err = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect_err("positive global crosstalk rate without payloads must fail loudly"); + + assert!(matches!(err, DemBuilderError::ConfigurationError(_))); + assert!( + err.to_string() + .contains("no MeasCrosstalkGlobalPayload locations"), + "unexpected error: {err}" + ); + } + #[test] fn test_exact_deterministic_local_measurement_crosstalk_identity_transition_is_empty() { use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; From 4424532b4e238989c703d1052b234891e8268410 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 21:00:27 -0600 Subject: [PATCH 051/150] Expose crosstalk payload gate types to Python --- python/pecos-rslib/pecos_rslib.pyi | 2 ++ .../pecos-rslib/src/dag_circuit_bindings.rs | 16 +++++++++ .../tests/qec/test_dem_metadata_fail_loud.py | 33 +++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index afab181c1..1d78f7d76 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -1442,6 +1442,8 @@ class GateType: QAlloc: GateType QFree: GateType TrackedPauliMeta: GateType + MeasCrosstalkGlobalPayload: GateType + MeasCrosstalkLocalPayload: GateType Custom: GateType @property diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index f033a6b22..14fb496c7 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -613,6 +613,22 @@ impl PyGateType { } } + #[classattr] + #[pyo3(name = "MeasCrosstalkGlobalPayload")] + fn meas_crosstalk_global_payload() -> Self { + Self { + inner: GateType::MeasCrosstalkGlobalPayload, + } + } + + #[classattr] + #[pyo3(name = "MeasCrosstalkLocalPayload")] + fn meas_crosstalk_local_payload() -> Self { + Self { + inner: GateType::MeasCrosstalkLocalPayload, + } + } + #[classattr] #[pyo3(name = "Custom")] fn custom() -> Self { diff --git a/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py b/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py index 97da0d66a..584104062 100644 --- a/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py +++ b/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py @@ -11,6 +11,7 @@ import pytest from pecos_rslib import DagCircuit +from pecos_rslib.quantum import Gate, GateType from pecos_rslib.qec import ( DagFaultAnalyzer, DemBuilder, @@ -49,6 +50,38 @@ def test_valid_metadata_builds_on_all_paths() -> None: assert builder.build().num_detectors == 1 +def test_exact_measurement_crosstalk_payload_emits_python_source_record() -> None: + dag = DagCircuit() + prep = dag.add_gate(Gate(GateType.Prep, qubits=[0])) + payload = dag.add_gate(Gate(GateType.MeasCrosstalkLocalPayload, qubits=[0])) + meas = dag.add_gate(Gate(GateType.Measure, qubits=[0])) + dag.connect(prep, payload, 0) + dag.connect(payload, meas, 0) + dag.set_attr("num_measurements", "1") + dag.set_attr("detectors", '[{"id": 0, "records": [-1]}]') + + im = DagFaultAnalyzer(dag).build_influence_map() + builder = DemBuilder(im) + builder.with_noise( + p1=0.0, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p_meas_crosstalk_local=0.25, + p_meas_crosstalk_model={"0->1": 0.4}, + measurement_crosstalk_dem_mode="exact_deterministic", + ) + builder.with_num_measurements(1) + builder.with_detectors_json('[{"id": 0, "records": [-1]}]') + builder.with_exact_branch_replay_circuit(dag) + dem = builder.build_with_source_tracking() + + records = dem.contribution_render_records() + assert len(records) == 1 + assert records[0]["direct_source_family"] == "MeasurementCrosstalk" + assert records[0]["gate_type_labels"] == ["MeasCrosstalkLocalPayload"] + + # --- out-of-range record offsets ------------------------------------------- From 594dc8b83353aeb78a06c6724464f70d762e983a Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 21:23:26 -0600 Subject: [PATCH 052/150] Preserve crosstalk payload gates in traced replay --- crates/pecos-core/src/gate_type.rs | 36 +++++++++++--- .../pecos-rslib/src/dag_circuit_bindings.rs | 15 ++++-- .../src/pecos/qec/surface/decode.py | 4 ++ .../tests/qec/test_from_guppy_dem.py | 49 +++++++++++++++++-- 4 files changed, 90 insertions(+), 14 deletions(-) diff --git a/crates/pecos-core/src/gate_type.rs b/crates/pecos-core/src/gate_type.rs index 63c532ca8..66ab8046d 100644 --- a/crates/pecos-core/src/gate_type.rs +++ b/crates/pecos-core/src/gate_type.rs @@ -271,8 +271,9 @@ impl GateType { /// /// # Returns /// - /// The number of qubits this gate type requires. All current gate types - /// have a fixed number of qubits (1 or 2). + /// The number of qubits this gate type requires. Variable-arity + /// payload/meta gates return 1 for compatibility with validation code; the + /// concrete gate stores the actual qubit count. #[must_use] pub const fn quantum_arity(self) -> usize { match self { @@ -304,13 +305,12 @@ impl GateType { | GateType::QAlloc | GateType::QFree | GateType::Idle + | GateType::Custom + // Payload/meta gates are variable-arity but return 1 here because + // validation checks `is_multiple_of(quantum_arity())`, and any + // count is a multiple of 1. The actual qubit count is in the gate. | GateType::MeasCrosstalkGlobalPayload | GateType::MeasCrosstalkLocalPayload - | GateType::Custom - // TrackedPauliMeta and Channel are variable-arity but return 1 - // here because gate validation checks - // `is_multiple_of(quantum_arity())` and any count is a multiple - // of 1. The actual qubit count is in the gate. | GateType::Channel | GateType::TrackedPauliMeta => 1, @@ -527,6 +527,12 @@ impl std::str::FromStr for GateType { "QFREE" => Ok(GateType::QFree), "IDLE" => Ok(GateType::Idle), "TRACKEDPAULI" | "TRACKEDPAULIMETA" | "TP" => Ok(GateType::TrackedPauliMeta), + "MEASCROSSTALKGLOBALPAYLOAD" | "MEAS_CROSSTALK_GLOBAL_PAYLOAD" => { + Ok(GateType::MeasCrosstalkGlobalPayload) + } + "MEASCROSSTALKLOCALPAYLOAD" | "MEAS_CROSSTALK_LOCAL_PAYLOAD" => { + Ok(GateType::MeasCrosstalkLocalPayload) + } "CHANNEL" => Ok(GateType::Channel), _ => Err(format!("Unknown gate type: {s}")), } @@ -611,6 +617,14 @@ mod tests { assert_eq!(GateType::from_str("Channel").unwrap(), GateType::Channel); assert_eq!(GateType::from_str("SWAP").unwrap(), GateType::SWAP); assert_eq!(GateType::from_str("CCX").unwrap(), GateType::CCX); + assert_eq!( + GateType::from_str("MeasCrosstalkGlobalPayload").unwrap(), + GateType::MeasCrosstalkGlobalPayload + ); + assert_eq!( + GateType::from_str("MeasCrosstalkLocalPayload").unwrap(), + GateType::MeasCrosstalkLocalPayload + ); // Aliases assert_eq!(GateType::from_str("CNOT").unwrap(), GateType::CX); @@ -618,6 +632,14 @@ mod tests { assert_eq!(GateType::from_str("S").unwrap(), GateType::SZ); assert_eq!(GateType::from_str("TOFFOLI").unwrap(), GateType::CCX); assert_eq!(GateType::from_str("init |0>").unwrap(), GateType::PZ); + assert_eq!( + GateType::from_str("meas_crosstalk_global_payload").unwrap(), + GateType::MeasCrosstalkGlobalPayload + ); + assert_eq!( + GateType::from_str("meas_crosstalk_local_payload").unwrap(), + GateType::MeasCrosstalkLocalPayload + ); // Case-insensitive matching assert_eq!(GateType::from_str("h").unwrap(), GateType::H); diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index 14fb496c7..515f688a1 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -3496,11 +3496,18 @@ impl PyTickHandle { ))); } - // Determine if we need to broadcast (e.g. single-qubit gate on multiple qubits) - let needs_broadcast = - arity > 0 && qubits.len() > arity && qubits.len().is_multiple_of(arity); + let variable_arity_payload = gate_type.is_crosstalk_payload() + || matches!(gate_type, GateType::Channel | GateType::TrackedPauliMeta); - if arity > 0 && qubits.len() != arity && !needs_broadcast { + // Determine if we need to broadcast (e.g. single-qubit gate on multiple qubits). + // Payload/meta gates carry their qubit list as data and must remain a single gate. + let needs_broadcast = !variable_arity_payload + && arity > 0 + && qubits.len() > arity + && qubits.len().is_multiple_of(arity); + + if !variable_arity_payload && arity > 0 && qubits.len() != arity && !needs_broadcast + { return Err(pyo3::exceptions::PyValueError::new_err(format!( "Gate '{name}' requires {} qubit(s), got {} (not a valid multiple)", arity, diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index d72dda77e..927aa6913 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1023,6 +1023,10 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> msg = f"Lowered MZ gate carries {len(meas_ids)} measurement_result_ids for {len(qubits)} qubit(s)" raise ValueError(msg) tick.mz_with_ids(qubits, [int(meas_id) for meas_id in meas_ids]) + elif gate_type == "MeasCrosstalkGlobalPayload": + tick.add_gate("MeasCrosstalkGlobalPayload", qubits) + elif gate_type == "MeasCrosstalkLocalPayload": + tick.add_gate("MeasCrosstalkLocalPayload", qubits) elif gate_type == "RX": tick.rx(angles[0], qubits) elif gate_type == "RY": 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 7364ff563..ba6b1d623 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -84,6 +84,16 @@ def _flat_idle_gates(tc) -> list[tuple[list[int], float]]: return idles +def _flat_gate_qubits(tc, gate_type_name: str) -> list[list[int]]: + dag = tc.to_dag_circuit() + gate_qubits: list[list[int]] = [] + for node_id in dag.nodes(): + gate = dag.gate(node_id) + if gate is not None and gate.gate_type.name == gate_type_name: + gate_qubits.append(list(gate.qubits)) + return gate_qubits + + def test_from_guppy_meas_ids_are_normalized_to_records() -> None: assert _dem_text(detectors_json='[{"id":0,"meas_ids":[0]}]') == _dem_text( detectors_json='[{"id":0,"records":[-1]}]', @@ -200,6 +210,41 @@ def test_lowered_replay_preserves_runtime_idles() -> None: assert _flat_idle_gates(tc) == [([0], 20.0)] +def test_lowered_replay_preserves_measurement_crosstalk_payloads() -> None: + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 0]}}], + "lowered_quantum_ops": [ + {"gate_type": "PZ", "qubits": [0], "angles": [], "params": []}, + { + "gate_type": "MeasCrosstalkLocalPayload", + "qubits": [1, 2], + "angles": [], + "params": [], + }, + { + "gate_type": "MeasCrosstalkGlobalPayload", + "qubits": [3, 4], + "angles": [], + "params": [], + }, + { + "gate_type": "MZ", + "qubits": [0], + "angles": [], + "params": [], + "measurement_result_ids": [0], + }, + ], + }, + ] + + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + + assert _flat_gate_qubits(tc, "MeasCrosstalkLocalPayload") == [[1, 2]] + assert _flat_gate_qubits(tc, "MeasCrosstalkGlobalPayload") == [[3, 4]] + + def test_lowered_runtime_idles_can_drive_memory_noise_dem() -> None: from pecos.qec import DetectorErrorModel @@ -612,9 +657,7 @@ def test_native_abstract_surface_dem_uses_record_metadata_only_for_r0(basis: str decompose_errors=decompose_errors, ) detectorless_logical_errors = [ - line - for line in dem_text.splitlines() - if line.startswith("error") and "L" in line and "D" not in line + line for line in dem_text.splitlines() if line.startswith("error") and "L" in line and "D" not in line ] assert detectorless_logical_errors == [] From 53c1074bff486860edc0d78a93d3d383dabbe38e Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 21:31:26 -0600 Subject: [PATCH 053/150] Document noise event replay diagnostics --- docs/development/noise-event-replay.md | 146 +++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 147 insertions(+) create mode 100644 docs/development/noise-event-replay.md diff --git a/docs/development/noise-event-replay.md b/docs/development/noise-event-replay.md new file mode 100644 index 000000000..3b8ed9955 --- /dev/null +++ b/docs/development/noise-event-replay.md @@ -0,0 +1,146 @@ +# Noise Event Replay and Failure Diagnostics + +This note records a future debugging path for understanding logical failures in +noisy circuit simulations. The goal is to make sampled noise events +reproducible and inspectable without turning normal simulation runs into large +trace dumps. + +## Motivation + +Detector error models tell us how physical error mechanisms can affect +detectors and observables, but they do not show which stochastic noise events +actually occurred in a sampled shot. When a decoder reports a logical failure, +we often want to inspect the concrete sampled events that produced that failure: + +- which noise source sampled an event, +- which ideal gate or payload location it was attached to, +- which branch was selected, +- which qubits and measurement results were involved, +- and whether the resulting syndrome pattern looked decoder-ambiguous. + +This is especially useful for local emulator studies where PECOS controls the +noise sampling. For hardware data, the physical events are not directly known, +but the same machinery can still be used to compare hardware syndromes against +failure patterns from calibrated local simulations. + +## Decoder-Dependent Failure Selection + +"Failed shots only" is a useful logging mode, but it is not intrinsic to the +simulator. Whether a shot is a logical failure depends on the analysis layer: + +- the detector and observable metadata, +- the DEM used for decoding, +- the decoder backend, +- decoder options such as graph-like decomposition or correlated matching, and +- the logical-failure convention used by the experiment. + +For that reason, the simulator should not decide by itself which shots are +interesting. Instead, the preferred long-term flow is: + +1. Run or replay shots with deterministic per-shot seeds. +2. Decode the resulting detection events in the analysis layer. +3. Select failed or otherwise interesting shot ids. +4. Replay only those shot ids with noise-event tracing enabled. + +This keeps logging sparse while preserving a clean separation between simulation +and decoder-specific analysis. + +## Per-Shot Reproducibility + +The key primitive is deterministic replay from stable seeds. A run should be +able to derive all randomness from a root seed and a shot identity: + +```text +run_seed + -> shot_seed(run_seed, shot_index) + -> component_seed(shot_seed, "noise") + -> component_seed(shot_seed, "runtime") + -> component_seed(shot_seed, "decoder" or analysis-only randomness) +``` + +The exact derivation should be explicit, versioned, and independent of worker +count. Replaying `(program, noise_config, runtime_config, run_seed, shot_index)` +should reproduce the same sampled noise events even if the original run used a +different number of workers. + +This is more important than logging everything during the first pass. Sparse +noise logs may be small at low physical error rates, but deterministic replay +lets us defer expensive diagnostics until we know which shots matter. + +## Optional Event Trace Schema + +When tracing is enabled, each sampled non-identity noise event should carry +enough source information to connect it back to the ideal program and DEM source +metadata. A compact JSONL-style record could contain: + +```json +{ + "shot": 17, + "shot_seed": "0x...", + "event_index": 42, + "tick": 19, + "gate_index": 3, + "gate_type": "SZZ", + "gate_qubits": [4, 12], + "source_family": "TwoQubitGate", + "noise_parameter": "p2", + "probability": 0.001, + "branch": "IX", + "random_draw": 0.00042 +} +``` + +Some source families need additional payload fields: + +- idle events should record duration and the axis-specific rate terms, +- replacement branches should record whether the ideal gate was omitted, +- measurement crosstalk should record payload gate type, candidate victim, and + transition label, +- measurement errors should record the measurement result id when available. + +Normal runs should default to no trace. Useful opt-in modes include: + +- trace every sampled event for small debugging runs, +- trace the first `N` shots, +- replay and trace a caller-provided list of shot ids, +- summarize event counts by source family without writing per-event records. + +## Data Volume + +Full logs may be acceptable for small studies because physical error events are +sparse. They can still become large quickly when the number of gates, shots, +distances, or parameter scans increases. The implementation should therefore +avoid coupling ordinary simulation output to full event logs. + +Recommended defaults: + +- store shot seeds or enough metadata to reconstruct them, +- store detection events and logical outcomes as usual, +- write detailed event records only in explicit diagnostic/replay modes, +- support streaming event records so failed-shot replays do not require keeping + all events in memory. + +## Implementation Sketch + +1. Add a worker-independent per-shot seed derivation API. +2. Ensure each simulator/noise layer receives deterministic component RNGs + derived from the shot seed. +3. Add an optional `NoiseEventSink` trait or equivalent callback in Rust. +4. Emit event records only for sampled non-identity branches, with source + metadata attached at the noise-model location. +5. Expose a replay API that accepts explicit shot ids or shot seeds. +6. Add Python helpers that decode first, select failed shots, then replay those + shots with tracing enabled. +7. Add compact summaries that aggregate event counts by source family, gate + type, branch, and qubit region. + +## Open Questions + +- How should runtime-generated events be traced when a runtime has its own RNGs? +- Should event traces include raw random draws, or only branch outcomes plus the + seed needed to reproduce them? +- Which event identifiers should be stable across circuit recompilation, and + which should be explicitly tied to the lowered/traced circuit version? +- How should traces link back to DEM contribution/source records when one sampled + event corresponds to multiple detector/observable effects? + diff --git a/mkdocs.yml b/mkdocs.yml index 521043e26..40b9679f1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -90,6 +90,7 @@ nav: - AST Infrastructure: development/ast-infrastructure.md - Foreign Language Plugins: development/foreign-plugins.md - Parallel Blocks: development/parallel-blocks-and-optimization.md + - Noise Event Replay: development/noise-event-replay.md - Experimental: - experimental/index.md - Composable Noise (pecos-neo): experimental/composable-noise.md From b58771a2ee3db44bf69e7e9a53d49dd0db0daff3 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 11 Jun 2026 21:20:30 -0600 Subject: [PATCH 054/150] Add biased single-qubit DEM noise support --- .../src/fault_tolerance_bindings.rs | 55 +++++++++++++++++-- python/quantum-pecos/src/pecos/qec/dem.py | 8 ++- .../src/pecos/qec/surface/circuit_builder.py | 7 ++- .../src/pecos/qec/surface/decode.py | 31 ++++++++++- .../tests/qec/test_from_guppy_dem.py | 30 ++++++++++ 5 files changed, 121 insertions(+), 10 deletions(-) diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 021316745..7813d1d88 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -73,6 +73,37 @@ use std::collections::BTreeMap; type PyDemMechanismTuple = (f64, Vec, Vec); type PyDemFitResult = (Vec, Vec); +fn parse_p1_weights(weights: BTreeMap) -> PyResult { + use pecos_core::pauli::{X, Y, Z}; + + let mut entries = Vec::with_capacity(weights.len()); + let mut sum = 0.0; + for (label, weight) in weights { + let label = label.trim().to_ascii_uppercase(); + let pauli = match label.as_str() { + "X" => X(0), + "Y" => Y(0), + "Z" => Z(0), + _ => { + let msg = format!("p1_weights keys must be one of ['X', 'Y', 'Z'], got {label:?}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + }; + if !weight.is_finite() || weight < 0.0 { + let msg = + format!("p1_weights[{label:?}] must be finite and non-negative, got {weight}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + sum += weight; + entries.push((pauli, weight)); + } + if (sum - 1.0).abs() >= 1.0e-6 { + let msg = format!("p1_weights relative probabilities must sum to 1.0, got {sum}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + Ok(PauliWeights::new(entries)) +} + fn parse_p2_weights(weights: BTreeMap) -> PyResult { use pecos_core::pauli::{X, Y, Z}; @@ -242,6 +273,7 @@ fn apply_noise_options( p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, @@ -280,6 +312,9 @@ fn apply_noise_options( if let Some(rate) = p_idle_z_quadratic_rate { noise.p_idle_quadratic_rate = rate.max(0.0); } + if let Some(weights) = p1_weights { + noise = noise.set_p1_weights(parse_p1_weights(weights)?); + } if let Some(weights) = p2_weights { noise = noise.set_p2_weights(parse_p2_weights(weights)?); } @@ -1201,7 +1236,7 @@ impl PyDetectorErrorModel { /// >>> print(dem.to_string()) /// >>> sampler = dem.to_sampler() #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &pyo3::Bound<'_, pyo3::PyAny>, @@ -1221,6 +1256,7 @@ impl PyDetectorErrorModel { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, @@ -1244,6 +1280,7 @@ impl PyDetectorErrorModel { p_idle_x_quadratic_rate, p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, + p1_weights, p2_weights, p2_replacement_approximation, p_meas_crosstalk_local, @@ -1601,7 +1638,7 @@ impl PyDemBuilder { /// /// Returns: /// Self for method chaining. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -1621,6 +1658,7 @@ impl PyDemBuilder { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, @@ -1642,6 +1680,7 @@ impl PyDemBuilder { p_idle_x_quadratic_rate, p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, + p1_weights, p2_weights, p2_replacement_approximation, p_meas_crosstalk_local, @@ -3510,7 +3549,7 @@ impl PyDemSampler { /// >>> sampler = DemSampler.from_circuit(dag, p1=0.001, p2=0.01) /// >>> sampler = DemSampler.from_circuit(tc, p2=0.01) # TickCircuit also works #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &Bound<'_, pyo3::PyAny>, @@ -3530,6 +3569,7 @@ impl PyDemSampler { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, @@ -3551,6 +3591,7 @@ impl PyDemSampler { p_idle_x_quadratic_rate, p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, + p1_weights, p2_weights, p2_replacement_approximation, p_meas_crosstalk_local, @@ -3667,7 +3708,7 @@ impl PyDemSampler { /// /// The `observables` argument defines observables. #[staticmethod] - #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_detectors( influence_map: &PyDagFaultInfluenceMap, @@ -3689,6 +3730,7 @@ impl PyDemSampler { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, @@ -3710,6 +3752,7 @@ impl PyDemSampler { p_idle_x_quadratic_rate, p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, + p1_weights, p2_weights, p2_replacement_approximation, p_meas_crosstalk_local, @@ -4169,7 +4212,7 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -4189,6 +4232,7 @@ impl PyDemSamplerBuilder { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, @@ -4210,6 +4254,7 @@ impl PyDemSamplerBuilder { p_idle_x_quadratic_rate, p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, + p1_weights, p2_weights, p2_replacement_approximation, p_meas_crosstalk_local, diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index e4f1a9a46..002a33757 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -41,6 +41,7 @@ from pecos_rslib.qec import DetectorErrorModel as _RustDetectorErrorModel +P1Weights = Mapping[str, float] P2Weights = Mapping[str, float] @@ -59,6 +60,7 @@ def from_guppy( 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, @@ -142,7 +144,10 @@ 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. - p1: Single-qubit gate depolarizing rate. + p1: Single-qubit gate Pauli error rate. + 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_weights: Optional relative probabilities over two-qubit Pauli error labels. Plain labels such as ``"XX"`` are post-gate @@ -272,6 +277,7 @@ def from_guppy( return _RustDetectorErrorModel.from_circuit( tc, p1=p1, + p1_weights=p1_weights, p2=p2, p2_weights=p2_weights, p2_replacement_approximation=p2_replacement_approximation, diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 1eb5245d9..433c6372a 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -2525,6 +2525,7 @@ def generate_dem_from_tick_circuit( tc: TickCircuit, *, p1: float = 0.01, + p1_weights: Mapping[str, float] | None = None, p2: float = 0.01, p2_weights: Mapping[str, float] | None = None, p_meas: float = 0.01, @@ -2563,7 +2564,10 @@ def generate_dem_from_tick_circuit( Args: tc: TickCircuit with detector/observable metadata (required) - p1: Single-qubit depolarizing error rate + p1: Single-qubit Pauli error rate + p1_weights: Optional relative probabilities over single-qubit Pauli + errors (``X``, ``Y``, ``Z``). Values must sum to 1.0; ``p1`` + remains the total single-qubit error rate. p2: Two-qubit depolarizing error rate p2_weights: Optional relative probabilities over the 15 non-identity two-qubit Pauli errors (``IX`` through ``ZZ``). Values must sum to @@ -2623,6 +2627,7 @@ def generate_dem_from_tick_circuit( p2, p_meas, p_prep, + p1_weights=p1_weights, p2_weights=p2_weights, p_idle=p_idle, t1=t1, diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 927aa6913..55f5058b8 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -58,6 +58,7 @@ from pecos.qec.surface.patch import Stabilizer, SurfacePatch +P1Weights = Mapping[str, float] | Sequence[tuple[str, float]] P2Weights = Mapping[str, float] | Sequence[tuple[str, float]] @@ -90,6 +91,9 @@ class NoiseModel: Attributes: p1: Single-qubit gate error rate. + 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 error rate. p2_weights: Optional relative probabilities over two-qubit Pauli error labels. Plain labels such as ``"XX"`` are post-gate Pauli branches; @@ -122,6 +126,7 @@ class NoiseModel: """ p1: float = 0.0 + p1_weights: P1Weights | None = None p2: float = 0.0 p2_weights: P2Weights | None = None p2_replacement_approximation: str | None = None @@ -141,6 +146,7 @@ class NoiseModel: def __post_init__(self) -> None: """Normalize cache-sensitive inputs after dataclass initialization.""" + self.p1_weights = _normalize_p1_weights(self.p1_weights) self.p2_weights = _normalize_p2_weights(self.p2_weights) @property @@ -193,13 +199,26 @@ def physical_error_rate(self) -> float: return max(rates) -def _normalize_p2_weights(p2_weights: P2Weights | None) -> tuple[tuple[str, float], ...] | None: - if p2_weights is None: +def _normalize_pauli_weights(weights: P1Weights | P2Weights | None) -> tuple[tuple[str, float], ...] | None: + if weights is None: return None - items = p2_weights.items() if isinstance(p2_weights, Mapping) else p2_weights + items = weights.items() if isinstance(weights, Mapping) else weights return tuple(sorted((str(label).upper(), float(weight)) for label, weight in items)) +def _normalize_p1_weights(p1_weights: P1Weights | None) -> tuple[tuple[str, float], ...] | None: + return _normalize_pauli_weights(p1_weights) + + +def _p1_weights_dict(p1_weights: P1Weights | None) -> dict[str, float] | None: + normalized = _normalize_p1_weights(p1_weights) + return None if normalized is None else dict(normalized) + + +def _normalize_p2_weights(p2_weights: P2Weights | None) -> tuple[tuple[str, float], ...] | None: + return _normalize_pauli_weights(p2_weights) + + def _p2_weights_dict(p2_weights: P2Weights | None) -> dict[str, float] | None: normalized = _normalize_p2_weights(p2_weights) return None if normalized is None else dict(normalized) @@ -1554,6 +1573,7 @@ def _dem_string_from_cached_surface_topology( "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, + "p1_weights": _p1_weights_dict(noise.p1_weights), "p2_weights": _p2_weights_dict(noise.p2_weights), } if noise.p2_replacement_approximation is not None: @@ -1595,6 +1615,7 @@ def _cached_surface_native_dem_string( ancilla_budget: int | None, circuit_source: Literal["abstract", "traced_qis"], p1: float, + p1_weights: tuple[tuple[str, float], ...] | None, p2: float, p_meas: float, p_prep: float, @@ -1639,6 +1660,7 @@ def _cached_surface_native_dem_string( topology, NoiseModel( p1=p1, + p1_weights=p1_weights, p2=p2, p2_weights=p2_weights, p2_replacement_approximation=p2_replacement_approximation, @@ -1709,6 +1731,7 @@ def _build_native_sampler_from_cached_surface_topology( 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, + p1_weights=_p1_weights_dict(noise.p1_weights), p2_weights=_p2_weights_dict(noise.p2_weights), p2_replacement_approximation=noise.p2_replacement_approximation, ) @@ -1813,6 +1836,7 @@ def generate_circuit_level_dem_from_builder( ancilla_budget, circuit_source, noise.p1, + noise.p1_weights, noise.p2, noise.p_meas, noise.p_prep, @@ -3531,6 +3555,7 @@ def build_native_sampler( ancilla_budget, circuit_source, noise.p1, + noise.p1_weights, noise.p2, noise.p_meas, noise.p_prep, 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 ba6b1d623..4b76c3acd 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -358,6 +358,36 @@ def test_from_circuit_accepts_biased_p2_weights() -> None: assert dem.num_contributions > 0 +def test_from_circuit_accepts_biased_p1_weights() -> None: + from pecos.qec import DetectorErrorModel + + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 0]}}], + "lowered_quantum_ops": [ + {"gate_type": "PZ", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "H", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "MZ", "qubits": [0], "angles": [], "params": [], "measurement_result_ids": [0]}, + ], + }, + ] + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + tc.set_meta("detectors", '[{"id": 0, "records": [-1]}]') + tc.set_meta("observables", "[]") + tc.set_meta("num_measurements", "1") + + dem = DetectorErrorModel.from_circuit( + tc, + p1=0.01, + p1_weights={"X": 1.0, "Y": 0.0, "Z": 0.0}, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + ) + + assert dem.num_contributions > 0 + + def test_reject_partially_lowered_trace_passes_on_uniformly_lowered() -> None: """A trace where every quantum-carrying chunk is also lowered is accepted (this is the real Selene shape; the byte-identical regressions exercise it From 5dcad767f42d711bb28ef43d8c8d94a7d05ec568 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 23:10:30 -0600 Subject: [PATCH 055/150] Fix full-branch review findings 1-6: propagate decode_count_parallel worker/decode errors instead of unwrap_or(chunk.len()), count bare 'detector Dk' declarations in all DEM parsers, flip the global observable_idx not list position in LogicalSubgraphDecoder, enforce non-negative UF edge weights (hard assert + Err on every DEM-text constructor), and gate BpUf predecoding on config.predecoder --- crates/pecos-decoder-core/src/dem.rs | 127 ++++++++++++------ crates/pecos-decoder-core/src/lib.rs | 2 +- .../src/logical_algorithm.rs | 5 +- .../src/logical_subgraph.rs | 36 ++--- .../src/logical_subgraph/window_plan.rs | 11 +- crates/pecos-uf-decoder/src/bp_uf.rs | 15 ++- crates/pecos-uf-decoder/src/css_decoder.rs | 2 + crates/pecos-uf-decoder/src/decoder.rs | 56 +++++++- .../tests/integration_tests.rs | 35 +++++ .../src/fault_tolerance_bindings.rs | 28 ++-- 10 files changed, 241 insertions(+), 76 deletions(-) diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index d7e351633..eafc3dbc2 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -131,10 +131,9 @@ pub mod utils { } // Normalize commands that carry parenthesized parameters: - // `error(0.01) ...` and `detector(x,y,t) Dk` (Stim always parenthesizes - // detector coordinates, so a literal `parts[0] == "detector"` check - // would miss every real declaration and undercount detectors that are - // declared but never referenced by an error mechanism). + // `error(0.01) ...` and `detector(x,y,t) Dk`. Stim emits bare + // `detector Dk` for declarations without coordinates; that form + // already matches via `parts[0]` below. let command = if parts[0].starts_with("error(") { "error" } else if parts[0].starts_with("detector(") { @@ -318,10 +317,7 @@ impl SparseDem { max_observable = Some(max_observable.map_or(l, |m| m.max(l))); } } - ( - det_set.into_iter().collect(), - obs_set.into_iter().collect(), - ) + (det_set.into_iter().collect(), obs_set.into_iter().collect()) } else { // Graphlike mechanism: keep DEM token order. let mut detectors = Vec::new(); @@ -342,17 +338,28 @@ impl SparseDem { }; mechanisms.push((probability, detectors, observables)); - } else if let Some(rest) = line.strip_prefix("detector(") { - let Some(close) = rest.find(')') else { - continue; + } else if let Some(rest) = line.strip_prefix("detector") { + // `detector(x,y,t) Dk` carries coordinates; Stim emits bare + // `detector Dk` for declarations without coordinates. Both + // declare the id, which counts toward `num_detectors` even if + // no error mechanism references it. + let (coords, targets) = if let Some(after) = rest.strip_prefix('(') { + let Some(close) = after.find(')') else { + continue; + }; + let coords: Vec = after[..close] + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + (Some(coords), &after[close + 1..]) + } else { + (None, rest) }; - let coords: Vec = rest[..close] - .split(',') - .filter_map(|s| s.trim().parse().ok()) - .collect(); - for token in rest[close + 1..].split_whitespace() { + for token in targets.split_whitespace() { if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) { - detector_coords.insert(d as usize, coords.clone()); + if let Some(c) = &coords { + detector_coords.insert(d as usize, c.clone()); + } max_detector = Some(max_detector.map_or(d, |m| m.max(d))); } } @@ -453,16 +460,23 @@ impl DemCheckMatrix { .into(), )); } - if let Some(rest) = line.strip_prefix("detector(") { + if let Some(rest) = line.strip_prefix("detector") { // Count the declared detector id, which may not be referenced by - // any error mechanism. All parsers agree on + // any error mechanism. Stim emits `detector(x,y,t) Dk` when + // coordinates are attached and bare `detector Dk` when not; both + // declare the id. All parsers agree on // `max(declared, error-referenced) + 1`. - if let Some(close) = rest.find(')') { - for token in rest[close + 1..].split_whitespace() { - if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) - { - max_detector = Some(max_detector.map_or(d, |m| m.max(d))); - } + let targets = if let Some(after) = rest.strip_prefix('(') { + match after.find(')') { + Some(close) => &after[close + 1..], + None => continue, + } + } else { + rest + }; + for token in targets.split_whitespace() { + if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) { + max_detector = Some(max_detector.map_or(d, |m| m.max(d))); } } continue; @@ -665,16 +679,22 @@ impl DemMatchingGraph { .into(), )); } - if let Some(rest) = line.strip_prefix("detector(") { + if let Some(rest) = line.strip_prefix("detector") { // Count the declared detector id (may not be error-referenced) so // `num_detectors` matches the other parsers and its coordinate is - // not later dropped from `detector_coords`. - if let Some(close) = rest.find(')') { - for token in rest[close + 1..].split_whitespace() { - if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) - { - max_detector = Some(max_detector.map_or(d, |m| m.max(d))); - } + // not later dropped from `detector_coords`. Stim emits bare + // `detector Dk` (no parentheses) for coordinate-less declarations. + let targets = if let Some(after) = rest.strip_prefix('(') { + match after.find(')') { + Some(close) => &after[close + 1..], + None => continue, + } + } else { + rest + }; + for token in targets.split_whitespace() { + if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) { + max_detector = Some(max_detector.map_or(d, |m| m.max(d))); } } continue; @@ -1132,9 +1152,31 @@ mod tests { let dem = "detector(0, 0, 0) D2\nlogical_observable L0\n"; assert_eq!(SparseDem::from_dem_str(dem).unwrap().num_detectors, 3); assert_eq!(DemCheckMatrix::from_dem_str(dem).unwrap().num_detectors, 3); - assert_eq!(DemMatchingGraph::from_dem_str(dem).unwrap().num_detectors, 3); + assert_eq!( + DemMatchingGraph::from_dem_str(dem).unwrap().num_detectors, + 3 + ); + let (dets, _obs) = utils::parse_dem_metadata(dem).unwrap(); + assert_eq!( + dets, 3, + "parse_dem_metadata must count declared detector D2" + ); + } + + #[test] + fn test_parsers_count_bare_detector_declarations() { + // Stim emits coordinate-less declarations as bare `detector Dk` (no + // parentheses). All four parsers must count it like the parenthesized + // form; three of them previously gated on `detector(` and dropped it. + let dem = "error(0.01) D0 L0\ndetector D7\n"; + assert_eq!(SparseDem::from_dem_str(dem).unwrap().num_detectors, 8); + assert_eq!(DemCheckMatrix::from_dem_str(dem).unwrap().num_detectors, 8); + assert_eq!( + DemMatchingGraph::from_dem_str(dem).unwrap().num_detectors, + 8 + ); let (dets, _obs) = utils::parse_dem_metadata(dem).unwrap(); - assert_eq!(dets, 3, "parse_dem_metadata must count declared detector D2"); + assert_eq!(dets, 8, "parse_dem_metadata must count bare detector D7"); } #[test] @@ -1142,10 +1184,19 @@ mod tests { // Only L2 present: index-addressed buffers need 3 slots, not 1. All // parsers must agree on `max + 1`, not distinct-id count. let dem = "error(0.01) D0 L2\ndetector(0,0,0) D0\n"; - assert_eq!(DemMatchingGraph::from_dem_str(dem).unwrap().num_observables, 3); - assert_eq!(DemCheckMatrix::from_dem_str(dem).unwrap().num_observables, 3); + assert_eq!( + DemMatchingGraph::from_dem_str(dem).unwrap().num_observables, + 3 + ); + assert_eq!( + DemCheckMatrix::from_dem_str(dem).unwrap().num_observables, + 3 + ); let (_d, obs) = utils::parse_dem_metadata(dem).unwrap(); - assert_eq!(obs, 3, "parse_dem_metadata must agree (max+1, not distinct count)"); + assert_eq!( + obs, 3, + "parse_dem_metadata must agree (max+1, not distinct count)" + ); } #[test] diff --git a/crates/pecos-decoder-core/src/lib.rs b/crates/pecos-decoder-core/src/lib.rs index acbb2c244..1189cd51b 100644 --- a/crates/pecos-decoder-core/src/lib.rs +++ b/crates/pecos-decoder-core/src/lib.rs @@ -37,9 +37,9 @@ pub mod errors; pub mod ghost_protocol; pub mod k_mwpm; pub mod logical_algorithm; +pub mod logical_subgraph; pub mod matrix; pub mod multi_decoder; -pub mod logical_subgraph; pub mod pauli_frame; pub mod perturbed; pub mod preprocessor; diff --git a/crates/pecos-decoder-core/src/logical_algorithm.rs b/crates/pecos-decoder-core/src/logical_algorithm.rs index f516d2275..dc5c283a5 100644 --- a/crates/pecos-decoder-core/src/logical_algorithm.rs +++ b/crates/pecos-decoder-core/src/logical_algorithm.rs @@ -764,7 +764,10 @@ mod tests { // bit 0; the strategy must flip the GLOBAL bit, not the list position. // (The pre-fix `1 << i` would have produced bits {0,1} = 0b0011.) let mut strat = WindowedLogicalSubgraphStrategy::new( - vec!["error(0.1) D0 L0".to_string(), "error(0.1) D0 L0".to_string()], + vec![ + "error(0.1) D0 L0".to_string(), + "error(0.1) D0 L0".to_string(), + ], vec![vec![0usize], vec![1usize]], vec![1usize, 3usize], |_dem| Ok(Box::new(FixedDecoder(1)) as Box), diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index 52bbb728e..da8fb4c81 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -610,12 +610,7 @@ impl LogicalSubgraphDecoder { // Per-shot observable predictions, accumulated across subgraphs. let mut shot_obs: Vec = vec![0u64; num_shots]; - for (i, (sg, dec)) in self - .subgraphs - .iter() - .zip(self.decoders.iter_mut()) - .enumerate() - { + for (sg, dec) in self.subgraphs.iter().zip(self.decoders.iter_mut()) { let n = sg.detector_map.len(); if n == 0 { continue; @@ -639,7 +634,9 @@ impl LogicalSubgraphDecoder { for (shot_idx, &sub_obs) in sub_masks.iter().enumerate() { if sub_obs & 1 != 0 { - shot_obs[shot_idx] |= 1 << i; + // Flip the subgraph's GLOBAL observable bit, not the list + // position: construction guarantees < 64 observables. + shot_obs[shot_idx] |= 1u64 << sg.observable_idx; } } } @@ -682,7 +679,9 @@ impl ObservableDecoder for LogicalSubgraphDecoder { let sub_obs = dec.decode_to_observables(&buf[..n])?; if sub_obs & 1 != 0 { - obs_mask |= 1 << i; + // Flip the subgraph's GLOBAL observable bit, not the list + // position: construction guarantees < 64 observables. + obs_mask |= 1u64 << sg.observable_idx; } } @@ -757,9 +756,11 @@ impl ParallelLogicalSubgraphDecoder { .collect(); let mut obs_mask = 0u64; - for (i, result) in results.into_iter().enumerate() { + for (sg, result) in self.subgraphs.iter().zip(results) { if result? { - obs_mask |= 1 << i; + // Flip the subgraph's GLOBAL observable bit, not the list + // position: construction guarantees < 64 observables. + obs_mask |= 1u64 << sg.observable_idx; } } Ok(obs_mask) @@ -909,7 +910,7 @@ mod tests { // An out-of-range membership detector id must error, not panic // (the DEM has 2 detectors D0,D1; detector 5 is past `inverse_map`). assert!(matches!( - subgraphs_from_membership(&sdem, &vec![vec![5usize]]), + subgraphs_from_membership(&sdem, &[vec![5usize]]), Err(DecoderError::InvalidConfiguration(_)) )); } @@ -932,7 +933,11 @@ mod tests { assert_eq!(exact, vec![vec![0usize]], "None = exact boundary time only"); let widened = coordinate_membership_from_dem(&sdem, &sc, Some(1)).unwrap(); - assert_eq!(widened, vec![vec![0usize, 1usize]], "radius pulls in time 1"); + assert_eq!( + widened, + vec![vec![0usize, 1usize]], + "radius pulls in time 1" + ); } #[test] @@ -943,8 +948,8 @@ mod tests { let dem = concat!( "detector(1, 0, 0) D0\n", "detector(0, 1, 0) D1\n", - "error(0.01) D0 L0\n", // boundary → qubit 0 X - "error(0.02) D0 ^ D1 L0\n", // decomposed; XOR -> {D0, D1} + "error(0.01) D0 L0\n", // boundary → qubit 0 X + "error(0.02) D0 ^ D1 L0\n", // decomposed; XOR -> {D0, D1} ); let sc = simple_stab_coords(); let sdem = SparseDem::from_dem_str(dem).unwrap(); @@ -1044,9 +1049,10 @@ mod tests { #[test] fn test_too_many_observables_errors() { // 65 observables exceeds the u64 mask capacity. + use std::fmt::Write; let mut dem = String::from("detector(1, 0, 0) D0\n"); for l in 0..65 { - dem.push_str(&format!("error(0.01) D0 L{l}\n")); + writeln!(dem, "error(0.01) D0 L{l}").unwrap(); } let sc = simple_stab_coords(); let err = partition_dem_by_logical(&dem, &sc).unwrap_err(); diff --git a/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs b/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs index 1159582fb..f32da5b5d 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs @@ -141,7 +141,10 @@ impl LogicalSubgraphWindowPlan { /// Local->global detector maps (one per non-empty observable). #[must_use] pub fn detector_maps(&self) -> Vec> { - self.entries.iter().map(|e| e.detector_map.clone()).collect() + self.entries + .iter() + .map(|e| e.detector_map.clone()) + .collect() } /// Estimated number of time windows observable `i` would use at `step` @@ -200,7 +203,11 @@ fn window_count_for_times(times: &[f64], step: usize) -> usize { let mut t_start = 0.0f64; while t_start < total_t { let is_last = t_start + 2.0 * step > total_t; - let t_core_end = if is_last { total_t + 1.0 } else { t_start + step }; + let t_core_end = if is_last { + total_t + 1.0 + } else { + t_start + step + }; if times.iter().any(|&t| t >= t_start && t < t_core_end) { count += 1; } diff --git a/crates/pecos-uf-decoder/src/bp_uf.rs b/crates/pecos-uf-decoder/src/bp_uf.rs index 0a293ce40..428069a55 100644 --- a/crates/pecos-uf-decoder/src/bp_uf.rs +++ b/crates/pecos-uf-decoder/src/bp_uf.rs @@ -151,6 +151,7 @@ impl BpUfDecoder { let dcm = DemCheckMatrix::from_dem_str(dem) .map_err(|e| DecoderError::InvalidConfiguration(e.to_string()))?; let graph = DemMatchingGraph::from_dem_str(dem)?; + UfDecoder::check_non_negative_weights(&graph)?; let uf = UfDecoder::from_matching_graph(&graph, config.uf_config); // Build mechanism → edge mapping. @@ -252,6 +253,7 @@ impl BpUfDecoder { // Matching graph and UF from the decomposed DEM. let match_graph = DemMatchingGraph::from_dem_str(matching_dem)?; + UfDecoder::check_non_negative_weights(&match_graph)?; let uf = UfDecoder::from_matching_graph(&match_graph, config.uf_config); // Map BP mechanisms (non-decomposed) → matching graph edges (decomposed). @@ -453,6 +455,9 @@ impl pecos_decoder_core::bp_matching::BpWeightProvider for BpUfDecoder { } fn is_trivial(&self, syndrome: &[u8]) -> Option { + if !self.uf.config.predecoder { + return None; + } self.uf.predecode_clusters(syndrome) } } @@ -460,8 +465,14 @@ impl pecos_decoder_core::bp_matching::BpWeightProvider for BpUfDecoder { impl pecos_decoder_core::ObservableDecoder for BpUfDecoder { fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { // Fast path: cluster predecoder handles isolated cases without BP. - // This catches 0 defects, single defects, and isolated pairs. - if let Some(obs) = self.uf.predecode_clusters(syndrome) { + // This catches 0 defects, single defects, and isolated pairs. Gated on + // the UF config like the plain `UfDecoder` paths. It deliberately runs + // on construction-time weights, bypassing BP: the cases it accepts are + // provably min-weight under the prior weights, and BP reweighting is + // only consulted for the larger clusters that fall through. + if self.uf.config.predecoder + && let Some(obs) = self.uf.predecode_clusters(syndrome) + { return Ok(obs); } diff --git a/crates/pecos-uf-decoder/src/css_decoder.rs b/crates/pecos-uf-decoder/src/css_decoder.rs index 69baaf239..051e970c1 100644 --- a/crates/pecos-uf-decoder/src/css_decoder.rs +++ b/crates/pecos-uf-decoder/src/css_decoder.rs @@ -113,6 +113,8 @@ impl CssUfDecoder { ) -> Result { let x_graph = DemMatchingGraph::from_dem_str(x_dem)?; let z_graph = DemMatchingGraph::from_dem_str(z_dem)?; + UfDecoder::check_non_negative_weights(&x_graph)?; + UfDecoder::check_non_negative_weights(&z_graph)?; // Auto-detect qubit-edge mapping from detector coordinates. let qubit_map = Self::build_qubit_mapping(&x_graph, &z_graph); diff --git a/crates/pecos-uf-decoder/src/decoder.rs b/crates/pecos-uf-decoder/src/decoder.rs index 5a1c4e29a..974691b8a 100644 --- a/crates/pecos-uf-decoder/src/decoder.rs +++ b/crates/pecos-uf-decoder/src/decoder.rs @@ -144,8 +144,9 @@ pub struct UfDecoder { adj_offset: Vec, /// Number of detectors. num_detectors: usize, - /// Config. - config: UfDecoderConfig, + /// Config. Crate-visible so wrappers (e.g. `BpUfDecoder`) honour the same + /// flags (notably `predecoder`) instead of silently ignoring them. + pub(crate) config: UfDecoderConfig, // === Per-shot reusable buffers === /// Disjoint-set forest: parent[i] = parent of node i. @@ -193,7 +194,44 @@ impl UfDecoder { &self.adj_data[start..end] } + /// Check that every edge weight in `graph` is non-negative. + /// + /// The predecoder's shortcut proofs ("lightest edge is the min-weight + /// correction", "direct pair <= boundary split") require non-negative + /// weights. `ln((1-p)/p)` guarantees that for priors p <= 0.5, but a raw + /// `error(p)` DEM line with p > 0.5 produces a negative weight. Call this + /// at any boundary where DEM text enters, so bad input is rejected as an + /// error instead of panicking in `from_matching_graph`. + /// + /// # Errors + /// + /// Returns `DecoderError::InvalidConfiguration` naming the first offending + /// edge (negative or NaN weight). + pub fn check_non_negative_weights(graph: &DemMatchingGraph) -> Result<(), DecoderError> { + if let Some((idx, e)) = graph + .edges + .iter() + .enumerate() + .find(|(_, e)| e.weight < 0.0 || e.weight.is_nan()) + { + return Err(DecoderError::InvalidConfiguration(format!( + "UfDecoder requires non-negative edge weights (error priors p <= 0.5), \ + but edge {idx} (node {} -- {:?}) has weight {}", + e.node1, e.node2, e.weight, + ))); + } + Ok(()) + } + /// Build from a `DemMatchingGraph`. + /// + /// # Panics + /// + /// Panics if any edge weight is negative or NaN: the predecoder's + /// optimality proofs depend on non-negative weights, so violating that + /// premise must fail loudly rather than silently mis-decode. Callers + /// holding untrusted DEM input should pre-validate with + /// [`Self::check_non_negative_weights`] to get an error instead. #[must_use] pub fn from_matching_graph(graph: &DemMatchingGraph, config: UfDecoderConfig) -> Self { let num_detectors = graph.num_detectors; @@ -225,13 +263,14 @@ impl UfDecoder { } // Construction-time invariant: edge weights are non-negative (true for - // `ln((1-p)/p)` when p < 0.5, i.e. real sub-threshold priors). The + // `ln((1-p)/p)` when p <= 0.5, i.e. real sub-threshold priors). The // predecoder's shortcut proofs ("lightest edge is the min-weight // correction", "direct pair <= boundary split") depend on it; a negative - // weight would silently break them. Checked once here, not per shot. - debug_assert!( + // weight would silently break them, so this is a hard assert (a raw + // `error(p > 0.5)` DEM line violates it). Checked once here, not per shot. + assert!( edges.iter().all(|e| e.weight >= 0.0), - "UfDecoder requires non-negative edge weights (error priors p < 0.5)" + "UfDecoder requires non-negative edge weights (error priors p <= 0.5)" ); // Sort each node's adjacency by weight (lightest first). @@ -283,9 +322,12 @@ impl UfDecoder { /// /// # Errors /// - /// Returns `DecoderError` if the DEM is malformed. + /// Returns `DecoderError` if the DEM is malformed or contains an error + /// prior p > 0.5 (negative edge weight, which the predecoder's optimality + /// proofs do not admit). pub fn from_dem(dem: &str, config: UfDecoderConfig) -> Result { let graph = DemMatchingGraph::from_dem_str(dem)?; + Self::check_non_negative_weights(&graph)?; Ok(Self::from_matching_graph(&graph, config)) } diff --git a/crates/pecos-uf-decoder/tests/integration_tests.rs b/crates/pecos-uf-decoder/tests/integration_tests.rs index 985a179b9..fc75d7ae1 100644 --- a/crates/pecos-uf-decoder/tests/integration_tests.rs +++ b/crates/pecos-uf-decoder/tests/integration_tests.rs @@ -171,3 +171,38 @@ fn test_buffer_reuse_correctness() { let _ = dec.decode_syndrome(&defect_syndrome); } } + +/// A DEM line with an error prior p > 0.5 produces a negative edge weight +/// (`ln((1-p)/p) < 0`), which the predecoder's optimality proofs do not admit. +/// The DEM-text constructors must reject it as an error, not mis-decode. +#[test] +fn test_from_dem_rejects_negative_weight_priors() { + let dem = "error(0.6) D0 L0\nerror(0.01) D0 D1\n"; + assert!(UfDecoder::from_dem(dem, UfDecoderConfig::balanced()).is_err()); + assert!( + pecos_uf_decoder::BpUfDecoder::from_dem(dem, pecos_uf_decoder::BpUfConfig::balanced()) + .is_err() + ); + assert!( + pecos_uf_decoder::CssUfDecoder::from_dems(dem, dem, UfDecoderConfig::balanced()).is_err() + ); +} + +/// The graph-level constructor asserts the same premise loudly for callers +/// that build graphs directly (contract violation, not user input). +#[test] +#[should_panic(expected = "non-negative edge weights")] +fn test_from_matching_graph_asserts_non_negative_weights() { + let dem = "error(0.6) D0 L0\n"; + let graph = DemMatchingGraph::from_dem_str(dem).unwrap(); + let _ = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::balanced()); +} + +/// Weight-zero edges (p = 0.5) are benign: the shortcut proofs hold as ties. +#[test] +fn test_from_dem_accepts_weight_zero_edges() { + let dem = "error(0.5) D0 L0\nerror(0.01) D0 D1\n"; + let mut dec = UfDecoder::from_dem(dem, UfDecoderConfig::balanced()).unwrap(); + let syndrome = vec![0u8; dec.num_detectors()]; + assert_eq!(dec.decode_syndrome(&syndrome), 0); +} diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index caa04793f..a468c9bbd 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -2024,6 +2024,10 @@ fn create_observable_decoder( |e| PyErr::new::(e.to_string()), )?; + // Reject negative-weight edges (error priors p > 0.5) as a Python + // error rather than panicking in `from_matching_graph`. + pecos_decoders::UfDecoder::check_non_negative_weights(&graph) + .map_err(|e| PyErr::new::(e.to_string()))?; let uf = pecos_decoders::UfDecoder::from_matching_graph( &graph, pecos_decoders::UfDecoderConfig::balanced(), @@ -4684,7 +4688,9 @@ impl PyLogicalSubgraphDecoder { let dem_str = dem.to_string(); // Reuse the backend chosen at construction unless the caller overrides, // so parallel workers decode identically to the serial path. - let inner_str = inner_decoder.unwrap_or(self.inner_decoder.as_str()).to_string(); + let inner_str = inner_decoder + .unwrap_or(self.inner_decoder.as_str()) + .to_string(); let n = batch.num_shots; // Materialize row-major data for parallel decode. @@ -4702,7 +4708,10 @@ impl PyLogicalSubgraphDecoder { .build() .map_err(|e| PyErr::new::(e.to_string()))?; - let errors: usize = pool.install(|| { + // Propagate worker construction and decode errors instead of panicking + // across the FFI boundary or silently scoring a failed chunk as + // all-failures (which would inflate the reported logical error rate). + let errors: Result = pool.install(|| { // Split into chunks, each chunk gets its own decoder + batch decode let chunk_size = n.div_ceil(rayon::current_num_threads()); (0..n) @@ -4723,20 +4732,18 @@ impl PyLogicalSubgraphDecoder { Ok(Box::new(SendWrapper(d)) as Box) }, - ) - .unwrap(); + )?; // Collect chunk syndromes and masks for batch decode let chunk_syns: Vec> = chunk.iter().map(|&i| events[i].clone()).collect(); let chunk_masks: Vec = chunk.iter().map(|&i| masks[i]).collect(); dec.decode_count_batched(&chunk_syns, &chunk_masks) - .unwrap_or(chunk.len()) }) - .sum() + .try_reduce(|| 0, |a, b| Ok(a + b)) }); - Ok(errors) + errors.map_err(|e| PyErr::new::(e.to_string())) } /// Number of detectors in each subgraph. @@ -5449,9 +5456,10 @@ impl PyLogicalCircuitDecoder { det_maps, obs_indices, |dem_str| { - let dec = create_observable_decoder(dem_str, &fallback_inner).map_err( - |e| pecos_decoders::DecoderError::InternalError(e.to_string()), - )?; + let dec = + create_observable_decoder(dem_str, &fallback_inner).map_err(|e| { + pecos_decoders::DecoderError::InternalError(e.to_string()) + })?; Ok(Box::new(SendWrapper(dec)) as Box) }, From 47c5f3df2cbb50d63da5084004ff0dd7f1478bb8 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 23:42:04 -0600 Subject: [PATCH 056/150] Add stim_spotcheck phase confirming the fusion-vs-bp ranking transfers to the production stim-generated DEM (memory p=0.005: d=5 1.63x disjoint Jeffreys, d=3 tied), closing the study's last DEM-generator gap --- examples/surface/inner_decoder_study.py | 85 +++++++++++++++++-- .../inner_decoder_study_stim_spotcheck.jsonl | 18 ++++ 2 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 examples/surface/results/inner_decoder_study_stim_spotcheck.jsonl diff --git a/examples/surface/inner_decoder_study.py b/examples/surface/inner_decoder_study.py index bbe281f00..e51a69123 100644 --- a/examples/surface/inner_decoder_study.py +++ b/examples/surface/inner_decoder_study.py @@ -138,10 +138,32 @@ def intervals_disjoint(a: Cell, b: Cell) -> bool: # --------------------------------------------------------------------------- # -def measure_cell(family: str, d: int, rounds: int, p: float, seed: int, inners: list[str], n: int) -> list[Cell]: - """Sample ONE batch and decode it with every inner (paired comparison).""" +def measure_cell( + family: str, + d: int, + rounds: int, + p: float, + seed: int, + inners: list[str], + n: int, + dem_source: str = "native", +) -> list[Cell]: + """Sample ONE batch and decode it with every inner (paired comparison). + + ``dem_source="native"`` uses the PECOS-native ``build_dem`` pipeline (the + main study). ``dem_source="stim"`` uses the exact DEM the production + default decode path consumes (``LogicalCircuitBuilder.build_decoder`` with + ``use_stim_dem=True``): the stim circuit's non-decomposed detector error + model. + """ builder = FAMILIES[family](d, rounds) - dem = builder.build_dem(p1=p, p2=p, p_meas=p) + if dem_source == "stim": + import stim # analysis-only here; the production path already requires it + + stim_str = builder.to_stim(p1=p, p2=p, p_meas=p) + dem = str(stim.Circuit(stim_str).detector_error_model(ignore_decomposition_failures=True)) + else: + dem = builder.build_dem(p1=p, p2=p, p_meas=p) sc = builder.stab_coords() batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) @@ -271,6 +293,49 @@ def run_hyperedge(path: Path) -> None: ) +def run_stim_spotcheck(path: Path) -> None: + """Confirm the ranking transfers to the DEM generator the shipped default decodes. + + The production default path (``LogicalCircuitBuilder.build_decoder``, + ``use_stim_dem=True``) consumes a STIM-generated DEM, while every main study + cell used the PECOS-native ``build_dem``. This cell repeats the memory + fusion-vs-bp contrast on the stim DEM (2026-06-11 review follow-up).""" + done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} + inners = ["fusion_blossom_serial", "pecos_uf:bp", "pymatching"] + p = 0.005 + for d in [3, 5]: + for seed in [1, 2, 3]: + todo = [i for i in inners if ("memory", d, p, seed, i) not in done] + if not todo: + continue + t = time.perf_counter() + cells = measure_cell("memory", d, d, p, seed, todo, 100_000, dem_source="stim") + _append(path, cells) + print( + f"[stim_spotcheck] memory d={d} p={p:.3f} seed={seed}: " + + " ".join(f"{c.inner.split(':')[0][:6]}={c.num_errors}" for c in cells) + + f" ({time.perf_counter() - t:.1f}s)", + flush=True, + ) + # Pooled verdict for the d=5 contrast (the cell where bp is dominated on + # the native DEM): report Jeffreys intervals and disjointness. + agg = _pool(_load(path)) + for d in [3, 5]: + if ("memory", d, p, "fusion_blossom_serial") not in agg or ("memory", d, p, "pecos_uf:bp") not in agg: + continue + fk, fn = agg[("memory", d, p, "fusion_blossom_serial")] + rk, rn = agg[("memory", d, p, "pecos_uf:bp")] + flo, fhi = jeffreys_ci(fk, fn) + rlo, rhi = jeffreys_ci(rk, rn) + sep = "DISJOINT" if fhi < rlo or rhi < flo else "overlap" + ratio = (rk / rn) / (fk / fn) if fk else float("inf") + print( + f"[stim_spotcheck] pooled d={d}: fusion {fk}/{fn} [{flo:.2e},{fhi:.2e}] vs " + f"bp {rk}/{rn} [{rlo:.2e},{rhi:.2e}] -- {ratio:.2f}x, {sep}", + flush=True, + ) + + def run_speed(path: Path) -> None: """Per-shot decode throughput (build vs decode) at the costly d=7 point.""" done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} @@ -444,7 +509,11 @@ def w(s: str = "") -> None: def main() -> None: ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("--phase", required=True, choices=["suppress", "threshold", "hyperedge", "speed", "analyze", "smoke"]) + ap.add_argument( + "--phase", + required=True, + choices=["suppress", "threshold", "hyperedge", "speed", "stim_spotcheck", "analyze", "smoke"], + ) ap.add_argument("--out", type=Path, default=RESULTS_DIR) args = ap.parse_args() @@ -466,7 +535,13 @@ def main() -> None: return path = args.out / f"inner_decoder_study_{args.phase}.jsonl" - {"suppress": run_suppress, "threshold": run_threshold, "hyperedge": run_hyperedge, "speed": run_speed}[args.phase](path) + { + "suppress": run_suppress, + "threshold": run_threshold, + "hyperedge": run_hyperedge, + "speed": run_speed, + "stim_spotcheck": run_stim_spotcheck, + }[args.phase](path) print(f"[done] {args.phase} -> {path}", flush=True) diff --git a/examples/surface/results/inner_decoder_study_stim_spotcheck.jsonl b/examples/surface/results/inner_decoder_study_stim_spotcheck.jsonl new file mode 100644 index 000000000..a16f704c3 --- /dev/null +++ b/examples/surface/results/inner_decoder_study_stim_spotcheck.jsonl @@ -0,0 +1,18 @@ +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 814, "ler": 0.00814, "build_seconds": 0.0005815229378640652, "decode_seconds": 1.0498094509821385} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 841, "ler": 0.00841, "build_seconds": 0.001328750979155302, "decode_seconds": 0.8888753189239651} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 815, "ler": 0.00815, "build_seconds": 0.0007096950430423021, "decode_seconds": 0.4390910370275378} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 805, "ler": 0.00805, "build_seconds": 0.0005728199612349272, "decode_seconds": 1.0615145689807832} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 826, "ler": 0.00826, "build_seconds": 0.0008161291480064392, "decode_seconds": 0.8780001488048583} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 807, "ler": 0.00807, "build_seconds": 0.000710461987182498, "decode_seconds": 0.4365270962007344} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 836, "ler": 0.00836, "build_seconds": 0.000561530003324151, "decode_seconds": 1.042770282132551} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 854, "ler": 0.00854, "build_seconds": 0.0008523988071829081, "decode_seconds": 0.8763707531616092} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 833, "ler": 0.00833, "build_seconds": 0.0006987580563873053, "decode_seconds": 0.43262582598254085} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 534, "ler": 0.00534, "build_seconds": 0.003445373848080635, "decode_seconds": 8.91389643913135} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 897, "ler": 0.00897, "build_seconds": 0.005997291067615151, "decode_seconds": 22.61978363688104} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 533, "ler": 0.00533, "build_seconds": 0.0036377678625285625, "decode_seconds": 1.7914626270066947} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 566, "ler": 0.00566, "build_seconds": 0.003552536014467478, "decode_seconds": 8.98982253507711} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 901, "ler": 0.00901, "build_seconds": 0.006013470934703946, "decode_seconds": 22.358651204034686} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 568, "ler": 0.00568, "build_seconds": 0.0036590120289474726, "decode_seconds": 1.8368506969418377} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 573, "ler": 0.00573, "build_seconds": 0.003564184997230768, "decode_seconds": 9.03868287592195} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 931, "ler": 0.00931, "build_seconds": 0.006468330975621939, "decode_seconds": 22.687049677129835} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 572, "ler": 0.00572, "build_seconds": 0.0038262868765741587, "decode_seconds": 1.8458779270295054} From ddbf9f98276ebed5df13de5ae30e781f53660511 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 11 Jun 2026 23:42:59 -0600 Subject: [PATCH 057/150] Add sine-law idle noise support for DEM construction --- .../src/fault_tolerance/dem_builder/types.rs | 66 ++++++++++++++++- crates/pecos-qec/tests/idle_noise_tests.rs | 17 +++++ .../src/fault_tolerance_bindings.rs | 66 +++++++++++++++-- python/quantum-pecos/src/pecos/qec/dem.py | 13 ++++ .../src/pecos/qec/surface/circuit_builder.py | 30 +++++--- .../src/pecos/qec/surface/decode.py | 71 ++++++++++++++++++- 6 files changed, 244 insertions(+), 19 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 d637d19ed..51025ff67 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -2491,6 +2491,15 @@ pub struct NoiseConfig { /// /// 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. pub p_idle_x_linear_rate: f64, /// Stochastic Y-memory error rate linear in idle duration. @@ -2499,6 +2508,10 @@ pub struct NoiseConfig { 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, /// Per-payload local measurement-crosstalk event rate. /// /// This rate is multiplied by the selected hidden-measurement transition @@ -2658,10 +2671,13 @@ impl Default for NoiseConfig { 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_meas_crosstalk_local: 0.0, p_meas_crosstalk_global: 0.0, p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), @@ -2688,10 +2704,13 @@ impl NoiseConfig { 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_meas_crosstalk_local: 0.0, p_meas_crosstalk_global: 0.0, p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), @@ -2716,10 +2735,13 @@ impl NoiseConfig { 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_meas_crosstalk_local: 0.0, p_meas_crosstalk_global: 0.0, p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), @@ -2744,10 +2766,13 @@ impl NoiseConfig { 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_meas_crosstalk_local: 0.0, p_meas_crosstalk_global: 0.0, p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), @@ -2776,6 +2801,13 @@ impl NoiseConfig { 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 + } + /// 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 { @@ -2799,6 +2831,20 @@ impl NoiseConfig { 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.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 + } + /// Sets T1/T2 relaxation times for idle noise. /// /// When set, idle gates use the Pauli-twirled T1/T2 model instead of @@ -2918,10 +2964,18 @@ impl NoiseConfig { self } - fn idle_memory_probability(linear_rate: f64, quadratic_rate: f64, duration: f64) -> f64 { + fn idle_memory_probability( + linear_rate: f64, + quadratic_rate: f64, + quadratic_sine_rate: f64, + duration: f64, + ) -> f64 { let duration = duration.max(0.0); - (linear_rate.max(0.0) * duration + quadratic_rate.max(0.0) * duration * duration) - .clamp(0.0, 1.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) } /// Dedicated idle-memory Pauli probabilities for `Idle(duration, q)`. @@ -2931,16 +2985,19 @@ impl NoiseConfig { 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, ), }; @@ -2999,10 +3056,13 @@ impl NoiseConfig { || 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 } } diff --git a/crates/pecos-qec/tests/idle_noise_tests.rs b/crates/pecos-qec/tests/idle_noise_tests.rs index 4f195d58d..52fa340cb 100644 --- a/crates/pecos-qec/tests/idle_noise_tests.rs +++ b/crates/pecos-qec/tests/idle_noise_tests.rs @@ -246,6 +246,23 @@ fn idle_memory_pauli_probabilities_match_linear_and_quadratic_model() { assert!((pauli.pz - 0.06).abs() < 1e-15); } +#[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) + .idle_memory_pauli_probs(3.0); + assert_eq!(z_sine.px, 0.0); + assert_eq!(z_sine.py, 0.0); + 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) + .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); +} + #[test] fn dem_builder_scalar_p1_does_not_attach_to_idle() { let dag = build_idle_then_measure(1); diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 7813d1d88..9b1e1452c 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -273,6 +273,10 @@ fn apply_noise_options( p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p_idle_quadratic_sine_rate: Option, + p_idle_x_quadratic_sine_rate: Option, + p_idle_y_quadratic_sine_rate: Option, + p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, @@ -312,6 +316,18 @@ fn apply_noise_options( if let Some(rate) = p_idle_z_quadratic_rate { noise.p_idle_quadratic_rate = rate.max(0.0); } + 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); + } + if let Some(rate) = p_idle_y_quadratic_sine_rate { + noise.p_idle_y_quadratic_sine_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_z_quadratic_sine_rate { + noise.p_idle_quadratic_sine_rate = rate.max(0.0); + } if let Some(weights) = p1_weights { noise = noise.set_p1_weights(parse_p1_weights(weights)?); } @@ -1236,7 +1252,7 @@ impl PyDetectorErrorModel { /// >>> print(dem.to_string()) /// >>> sampler = dem.to_sampler() #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &pyo3::Bound<'_, pyo3::PyAny>, @@ -1256,6 +1272,10 @@ impl PyDetectorErrorModel { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p_idle_quadratic_sine_rate: Option, + p_idle_x_quadratic_sine_rate: Option, + p_idle_y_quadratic_sine_rate: Option, + p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, @@ -1280,6 +1300,10 @@ impl PyDetectorErrorModel { 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, p1_weights, p2_weights, p2_replacement_approximation, @@ -1638,7 +1662,7 @@ impl PyDemBuilder { /// /// Returns: /// Self for method chaining. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -1658,6 +1682,10 @@ impl PyDemBuilder { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p_idle_quadratic_sine_rate: Option, + p_idle_x_quadratic_sine_rate: Option, + p_idle_y_quadratic_sine_rate: Option, + p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, @@ -1680,6 +1708,10 @@ impl PyDemBuilder { 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, p1_weights, p2_weights, p2_replacement_approximation, @@ -3549,7 +3581,7 @@ impl PyDemSampler { /// >>> sampler = DemSampler.from_circuit(dag, p1=0.001, p2=0.01) /// >>> sampler = DemSampler.from_circuit(tc, p2=0.01) # TickCircuit also works #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &Bound<'_, pyo3::PyAny>, @@ -3569,6 +3601,10 @@ impl PyDemSampler { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p_idle_quadratic_sine_rate: Option, + p_idle_x_quadratic_sine_rate: Option, + p_idle_y_quadratic_sine_rate: Option, + p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, @@ -3591,6 +3627,10 @@ impl PyDemSampler { 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, p1_weights, p2_weights, p2_replacement_approximation, @@ -3708,7 +3748,7 @@ impl PyDemSampler { /// /// The `observables` argument defines observables. #[staticmethod] - #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_detectors( influence_map: &PyDagFaultInfluenceMap, @@ -3730,6 +3770,10 @@ impl PyDemSampler { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p_idle_quadratic_sine_rate: Option, + p_idle_x_quadratic_sine_rate: Option, + p_idle_y_quadratic_sine_rate: Option, + p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, @@ -3752,6 +3796,10 @@ impl PyDemSampler { 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, p1_weights, p2_weights, p2_replacement_approximation, @@ -4212,7 +4260,7 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -4232,6 +4280,10 @@ impl PyDemSamplerBuilder { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p_idle_quadratic_sine_rate: Option, + p_idle_x_quadratic_sine_rate: Option, + p_idle_y_quadratic_sine_rate: Option, + p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, @@ -4254,6 +4306,10 @@ impl PyDemSamplerBuilder { 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, p1_weights, p2_weights, p2_replacement_approximation, diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 002a33757..e7c836664 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -77,6 +77,10 @@ def from_guppy( 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, runtime: object | None = None, seed: int = 0, ) -> _RustDetectorErrorModel: @@ -178,6 +182,11 @@ def from_guppy( 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. runtime: Optional Selene runtime selector/plugin. ``None`` selects the default Selene runtime. Runtime plugin objects are passed through to ``pecos.selene_engine(runtime)``. @@ -294,6 +303,10 @@ def from_guppy( 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, ) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 433c6372a..e8e85b2bc 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -1716,17 +1716,18 @@ def _gate_type_name(gate: object) -> str: return str(getattr(gate_type, "name", str(gate_type).rsplit(".", maxsplit=1)[-1])) +def _format_gate_angle(angle: object) -> str: + try: + return repr(float(angle)) + except (TypeError, ValueError): + return repr(angle) + + def _gate_angles_for_message(gate: object) -> list[str]: angles = getattr(gate, "angles", None) if angles is None: angles = getattr(gate, "params", []) - formatted = [] - for angle in angles: - try: - formatted.append(repr(float(angle))) - except (TypeError, ValueError): - formatted.append(repr(angle)) - return formatted + return [_format_gate_angle(angle) for angle in angles] def get_detector_descriptors_from_tick_circuit( @@ -2489,7 +2490,7 @@ def _maximally_decompose_graphlike_dem(dem_text: str) -> str: return "\n".join(rewritten_lines) -def _build_canonical_dem_influence_map(dag: DagCircuit): +def _build_canonical_dem_influence_map(dag: DagCircuit) -> object: """Build the influence map used by the canonical Rust DEM builder. `DagFaultAnalyzer` supplies the detector influence map. Observable and @@ -2541,6 +2542,10 @@ def generate_dem_from_tick_circuit( 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, decompose_errors: bool = True, maximal_decomposition: bool = False, ) -> str: @@ -2588,6 +2593,11 @@ def generate_dem_from_tick_circuit( 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. decompose_errors: If True (default), decompose hyperedge errors into graphlike components using the `^` separator. Set to False to output raw hyperedges. Ignored if maximal_decomposition=True. @@ -2640,6 +2650,10 @@ def generate_dem_from_tick_circuit( 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.with_num_measurements(num_measurements) if metadata_uses_records: diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 55f5058b8..5f2880453 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -123,6 +123,11 @@ class NoiseModel: 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. """ p1: float = 0.0 @@ -143,6 +148,10 @@ class NoiseModel: 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 def __post_init__(self) -> None: """Normalize cache-sensitive inputs after dataclass initialization.""" @@ -159,6 +168,13 @@ 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 + @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 + @property def idle_memory_rates(self) -> tuple[float | None, ...]: """All dedicated Pauli idle-memory rates that require explicit idles.""" @@ -169,6 +185,9 @@ def idle_memory_rates(self) -> tuple[float | None, ...]: 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.effective_p_idle_z_quadratic_sine_rate, ) @staticmethod @@ -1432,6 +1451,10 @@ def _uses_dedicated_idle_noise( 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, ) -> bool: """Return True when noise parameters require explicit idle locations.""" return ( @@ -1448,6 +1471,10 @@ def _uses_dedicated_idle_noise( 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, ) ) ) @@ -1467,6 +1494,10 @@ def _noise_uses_dedicated_idle_noise(noise: NoiseModel) -> bool: 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, ) @@ -1573,6 +1604,10 @@ def _dem_string_from_cached_surface_topology( "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), } @@ -1633,6 +1668,10 @@ def _cached_surface_native_dem_string( 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, ) -> str: """Cache native DEM strings across callers for one topology + noise tuple.""" include_idle_gates = _uses_dedicated_idle_noise( @@ -1647,6 +1686,10 @@ def _cached_surface_native_dem_string( 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, ) topology = _cached_surface_native_topology( patch_key, @@ -1677,6 +1720,10 @@ def _cached_surface_native_dem_string( 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, ) @@ -1701,7 +1748,7 @@ def _build_native_sampler_from_cached_surface_topology( ] = "dem", # "mnm" accepted for compat, mapped to "influence_dem", ) -> NativeSampler: """Construct a native sampler from cached topology-only analysis.""" - from pecos.qec import DemSampler, ParsedDem + from pecos.qec import ParsedDem if sampling_model == "dem": dem_str = _dem_string_from_cached_surface_topology( @@ -1731,6 +1778,10 @@ def _build_native_sampler_from_cached_surface_topology( 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), p2_replacement_approximation=noise.p2_replacement_approximation, @@ -1854,6 +1905,10 @@ def generate_circuit_level_dem_from_builder( 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, ) @@ -2781,6 +2836,7 @@ def _compute_dem_detection_events_z( synx_list: X syndrome arrays, one per round synz_list: Z syndrome arrays, one per round final: Final data qubit measurements + init_synx: Initial X-syndrome baseline measured during logical prep Returns: Detection events array matching the DEM detector ordering @@ -2798,7 +2854,9 @@ def _compute_dem_detection_events_z( events: list[int] = [] if self.num_rounds > 0: - assert init_synx is not None + if init_synx is None: + msg = "init_synx is required for Z-basis circuit-level DEM decoding" + raise ValueError(msg) init_synx_array = np.array(init_synx, dtype=np.uint8) if init_synx_array.shape != synx[0].shape: msg = f"init_synx has shape {init_synx_array.shape}, expected {synx[0].shape}" @@ -2844,6 +2902,7 @@ def _compute_dem_detection_events_x( synx_list: X syndrome arrays, one per round synz_list: Z syndrome arrays, one per round final: Final data qubit measurements + init_synz: Initial Z-syndrome baseline measured during logical prep Returns: Detection events array matching the DEM detector ordering @@ -2861,7 +2920,9 @@ def _compute_dem_detection_events_x( events: list[int] = [] if self.num_rounds > 0: - assert init_synz is not None + if init_synz is None: + msg = "init_synz is required for X-basis circuit-level DEM decoding" + raise ValueError(msg) init_synz_array = np.array(init_synz, dtype=np.uint8) if init_synz_array.shape != synz[0].shape: msg = f"init_synz has shape {init_synz_array.shape}, expected {synz[0].shape}" @@ -3573,6 +3634,10 @@ def build_native_sampler( 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, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() return NativeSampler( From e023b9f580820bf9a7ff03b0076490617eec3e75 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 00:09:02 -0600 Subject: [PATCH 058/150] Add Guppy surface Pauli twirl handoff --- crates/pecos-qec/src/fault_tolerance.rs | 2 + .../src/fault_tolerance/pauli_frame.rs | 554 ++++++++++++++++++ crates/pecos-quantum/src/dag_circuit.rs | 6 + crates/pecos-quantum/src/tick_circuit.rs | 17 +- .../src/fault_tolerance_bindings.rs | 232 ++++++++ .../quantum-pecos/src/pecos/guppy/surface.py | 475 +++++++++++++-- .../quantum-pecos/src/pecos/qec/__init__.py | 2 + python/quantum-pecos/src/pecos/qec/dem.py | 55 +- .../src/pecos/qec/surface/__init__.py | 14 + .../pecos/qec/surface/_detection_events.py | 66 +++ .../src/pecos/qec/surface/_twirl_config.py | 131 +++++ .../src/pecos/qec/surface/_twirl_sites.py | 119 ++++ .../src/pecos/qec/surface/circuit_builder.py | 78 ++- .../src/pecos/qec/surface/decode.py | 507 ++++++++++++++-- .../tests/guppy/test_surface_twirl_render.py | 145 +++++ .../qec/surface/test_pauli_mask_harvest.py | 393 +++++++++++++ .../qec/surface/test_pauli_twirl_handoff.py | 192 ++++++ 17 files changed, 2858 insertions(+), 130 deletions(-) create mode 100644 crates/pecos-qec/src/fault_tolerance/pauli_frame.rs create mode 100644 python/quantum-pecos/src/pecos/qec/surface/_detection_events.py create mode 100644 python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py create mode 100644 python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py create mode 100644 python/quantum-pecos/tests/guppy/test_surface_twirl_render.py create mode 100644 python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py create mode 100644 python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py diff --git a/crates/pecos-qec/src/fault_tolerance.rs b/crates/pecos-qec/src/fault_tolerance.rs index 8aa05b7a7..7997b32eb 100644 --- a/crates/pecos-qec/src/fault_tolerance.rs +++ b/crates/pecos-qec/src/fault_tolerance.rs @@ -25,6 +25,7 @@ pub mod fault_sampler; pub mod gadget_checker; pub mod influence_builder; pub mod lookup_decoder; +pub mod pauli_frame; pub mod pauli_prop_checker; pub mod propagator; pub mod stabilizer_flip_checker; @@ -47,6 +48,7 @@ pub use gadget_checker::{ GadgetSyndromeAnalysis, }; pub use influence_builder::InfluenceBuilder; +pub use pauli_frame::{PauliFrameLookup, PauliFrameLookupError}; pub use pauli_prop_checker::{ DecoderAnalysis, FaultClass, FaultToleranceAnalysis, FaultToleranceFailure, FollowUpConfig, MeasurementRound, PauliPropChecker, PropagationResult, SyndromeAnalysis, SyndromeClass, diff --git a/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs b/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs new file mode 100644 index 000000000..f2d7f5213 --- /dev/null +++ b/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs @@ -0,0 +1,554 @@ +// 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. + +//! Pauli-frame lookup support for sampling Pauli-twirl masks. +//! +//! Twirl sites are emitted as three positional tracked-Pauli annotations per +//! site: X, Y, and Z. The DEM sampler samples decoder-facing detector and +//! observable bits. This lookup adds the deterministic frame update induced by a +//! user-supplied Pauli mask by XOR-ing precomputed detector/observable rows into +//! sampled shots. + +use super::dem_builder::record_offset_to_absolute_index; +use super::propagator::{Direction, apply_gate}; +use pecos_core::gate_type::GateType; +use pecos_core::{Pauli, PauliString}; +use pecos_quantum::{AnnotationKind, DagCircuit}; +use pecos_simulators::PauliProp; +use std::collections::{BTreeMap, BTreeSet}; +use thiserror::Error; + +type MeasurementRecordMap = BTreeMap>; + +/// Errors returned while building or applying a Pauli-frame lookup. +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum PauliFrameLookupError { + /// A tracked-Pauli annotation has no `meta_node` set. + #[error( + "tracked-Pauli annotation is missing its meta_node; cannot determine spacetime position" + )] + MissingMetaNode, + + /// A tracked-Pauli annotation's `meta_node` does not point at a + /// `TrackedPauliMeta` gate in the DAG. + #[error( + "tracked-Pauli annotation references DAG node {meta_node}, which is missing or not a TrackedPauliMeta gate" + )] + MetaNodeNotTrackedPauliMeta { meta_node: usize }, + + /// A measurement gate has malformed measurement IDs. + #[error("measurement node {node} has {meas_ids} measurement id(s) for {qubits} qubit(s)")] + MalformedMeasurementIds { + node: usize, + meas_ids: usize, + qubits: usize, + }, + + /// Detector/observable metadata references a measurement record outside the + /// circuit's measurement range. + #[error( + "{kind} {output} references measurement record offset {record}, but the circuit has {num_measurements} measurement(s)" + )] + InvalidRecordOffset { + kind: &'static str, + output: usize, + record: i32, + num_measurements: usize, + }, + + /// Twirl mask composition requires X/Y/Z triples per site. + #[error("tracked-Pauli count {num_tracked_paulis} is not divisible by 3")] + NonTripletTrackedPaulis { num_tracked_paulis: usize }, + + /// The flat mask buffer length does not match the supplied shape. + #[error("pauli mask buffer has length {len}, expected {expected} for shape ({rows}, {cols})")] + MaskLengthMismatch { + len: usize, + expected: usize, + rows: usize, + cols: usize, + }, + + /// The number of mask rows must match the number of sampled shots. + #[error("pauli mask row count {mask_rows} does not match num_shots {num_shots}")] + MaskShotMismatch { mask_rows: usize, num_shots: usize }, + + /// The number of mask columns must match the number of Pauli-twirl sites. + #[error("pauli mask column count {mask_cols} does not match num_pauli_sites {num_pauli_sites}")] + MaskSiteMismatch { + mask_cols: usize, + num_pauli_sites: usize, + }, + + /// Mask values must use 0=I, 1=X, 2=Y, 3=Z. + #[error("pauli mask value {value} at row {row}, column {col} is outside 0..=3")] + InvalidMaskValue { row: usize, col: usize, value: u8 }, + + /// A sampled output row does not match the lookup dimensions. + #[error("{kind} row {row} has length {actual}, expected {expected}")] + OutputWidthMismatch { + kind: &'static str, + row: usize, + actual: usize, + expected: usize, + }, +} + +/// Deterministic lookup from tracked-Pauli mask values to detector/observable flips. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PauliFrameLookup { + num_pauli_sites: usize, + num_detectors: usize, + num_observables: usize, + detector_rows: Vec>, + observable_rows: Vec>, +} + +impl PauliFrameLookup { + /// Build a Pauli-frame lookup from a DAG circuit and record-based detector + /// and observable definitions. + /// + /// The circuit must carry positional tracked-Pauli annotations. The tracked + /// Paulis are interpreted in groups of three per site, ordered X, Y, Z by + /// the surface-code emitter. + /// + /// # Errors + /// + /// Returns an error when tracked-Pauli metadata is malformed, when tracked + /// annotations are not X/Y/Z triples, or when detector/observable record + /// offsets reference missing measurements. + pub fn from_circuit( + dag: &DagCircuit, + detector_records: &[Vec], + observable_records: &[Vec], + ) -> Result { + let tracked_annotations: Vec<&pecos_quantum::PauliAnnotation> = dag + .annotations() + .iter() + .filter(|ann| matches!(ann.kind, AnnotationKind::TrackedPauli)) + .collect(); + let mut meta_nodes: Vec = dag + .nodes() + .into_iter() + .filter(|&node| { + dag.gate(node) + .is_some_and(|gate| gate.gate_type == GateType::TrackedPauliMeta) + }) + .collect(); + meta_nodes.sort_unstable(); + + if tracked_annotations.len() != meta_nodes.len() { + return Err(PauliFrameLookupError::MissingMetaNode); + } + let tracked: Vec<(&pecos_quantum::PauliAnnotation, usize)> = + tracked_annotations.into_iter().zip(meta_nodes).collect(); + if tracked.len() % 3 != 0 { + return Err(PauliFrameLookupError::NonTripletTrackedPaulis { + num_tracked_paulis: tracked.len(), + }); + } + + let topo_order = dag.topological_order(); + let topo_positions: BTreeMap = topo_order + .iter() + .enumerate() + .map(|(pos, &node)| (node, pos)) + .collect(); + let (measurement_records, num_measurements) = measurement_records_by_node(dag)?; + let detectors_by_measurement = + outputs_by_measurement(num_measurements, detector_records, "detector")?; + let observables_by_measurement = + outputs_by_measurement(num_measurements, observable_records, "observable")?; + + let mut detector_rows = Vec::with_capacity(tracked.len()); + let mut observable_rows = Vec::with_capacity(tracked.len()); + + for (ann, meta_node) in &tracked { + if !dag + .gate(*meta_node) + .is_some_and(|gate| gate.gate_type == GateType::TrackedPauliMeta) + { + return Err(PauliFrameLookupError::MetaNodeNotTrackedPauliMeta { + meta_node: *meta_node, + }); + } + let start_pos = *topo_positions.get(meta_node).ok_or( + PauliFrameLookupError::MetaNodeNotTrackedPauliMeta { + meta_node: *meta_node, + }, + )?; + let affected_measurements = propagate_tracked_pauli_forward( + dag, + &topo_order, + &measurement_records, + start_pos, + &ann.pauli, + ); + detector_rows.push(measurements_to_output_row( + &affected_measurements, + &detectors_by_measurement, + )); + observable_rows.push(measurements_to_output_row( + &affected_measurements, + &observables_by_measurement, + )); + } + + Ok(Self { + num_pauli_sites: tracked.len() / 3, + num_detectors: detector_records.len(), + num_observables: observable_records.len(), + detector_rows, + observable_rows, + }) + } + + /// Number of mask sites. Each site has three tracked rows: X, Y, Z. + #[must_use] + pub fn num_pauli_sites(&self) -> usize { + self.num_pauli_sites + } + + /// Number of tracked-Pauli rows in the lookup. + #[must_use] + pub fn num_tracked_paulis(&self) -> usize { + self.detector_rows.len() + } + + /// Number of detector output columns. + #[must_use] + pub fn num_detectors(&self) -> usize { + self.num_detectors + } + + /// Number of observable output columns. + #[must_use] + pub fn num_observables(&self) -> usize { + self.num_observables + } + + /// Return the detector and observable row for one tracked-Pauli index. + #[must_use] + pub fn row_effects(&self, tracked_idx: usize) -> Option<(&[u32], &[u32])> { + self.detector_rows + .get(tracked_idx) + .zip(self.observable_rows.get(tracked_idx)) + .map(|(det, obs)| (det.as_slice(), obs.as_slice())) + } + + /// Convert a flat `(num_shots, num_pauli_sites)` mask buffer to tracked-row firings. + /// + /// # Errors + /// + /// Returns an error when the mask shape does not match the lookup or when + /// any mask value is outside `0..=3`. + pub fn mask_firings( + &self, + masks: &[u8], + rows: usize, + cols: usize, + ) -> Result>, PauliFrameLookupError> { + self.validate_mask_shape(masks, rows, cols, rows)?; + let mut firings = vec![vec![false; self.num_tracked_paulis()]; rows]; + for row in 0..rows { + for col in 0..cols { + let value = masks[row * cols + col]; + if value != 0 { + firings[row][mask_value_to_tracked_idx(col, value)] = true; + } + } + } + Ok(firings) + } + + /// Compute the mask-induced XOR pattern for detectors and observables. + /// + /// Returns `(det_xor, obs_xor)` where `det_xor[i]` is the detector XOR + /// pattern for shot `i` and `obs_xor[i]` is the observable XOR pattern. + /// + /// # Errors + /// + /// Returns an error when the mask shape does not match the lookup or when + /// any mask value is outside `0..=3`. + pub fn compute_mask_xor( + &self, + masks: &[u8], + rows: usize, + cols: usize, + ) -> Result<(Vec>, Vec>), PauliFrameLookupError> { + let mut det_xor = vec![vec![false; self.num_detectors]; rows]; + let mut obs_xor = vec![vec![false; self.num_observables]; rows]; + self.apply_mask_values(masks, rows, cols, &mut det_xor, &mut obs_xor)?; + Ok((det_xor, obs_xor)) + } + + /// XOR mask-induced frame flips into sampled detector and observable rows. + /// + /// # Errors + /// + /// Returns an error when the mask shape does not match the sampled batch, + /// when any mask value is outside `0..=3`, or when sampled output row widths + /// do not match the lookup dimensions. + pub fn apply_mask_values( + &self, + masks: &[u8], + rows: usize, + cols: usize, + det_events: &mut [Vec], + obs_flips: &mut [Vec], + ) -> Result<(), PauliFrameLookupError> { + self.validate_mask_shape(masks, rows, cols, det_events.len())?; + if obs_flips.len() != rows { + return Err(PauliFrameLookupError::MaskShotMismatch { + mask_rows: rows, + num_shots: obs_flips.len(), + }); + } + + for row in 0..rows { + if det_events[row].len() != self.num_detectors { + return Err(PauliFrameLookupError::OutputWidthMismatch { + kind: "detector", + row, + actual: det_events[row].len(), + expected: self.num_detectors, + }); + } + if obs_flips[row].len() != self.num_observables { + return Err(PauliFrameLookupError::OutputWidthMismatch { + kind: "observable", + row, + actual: obs_flips[row].len(), + expected: self.num_observables, + }); + } + + for col in 0..cols { + let value = masks[row * cols + col]; + if value == 0 { + continue; + } + let tracked_idx = mask_value_to_tracked_idx(col, value); + xor_row(&mut det_events[row], &self.detector_rows[tracked_idx]); + xor_row(&mut obs_flips[row], &self.observable_rows[tracked_idx]); + } + } + + Ok(()) + } + + fn validate_mask_shape( + &self, + masks: &[u8], + rows: usize, + cols: usize, + num_shots: usize, + ) -> Result<(), PauliFrameLookupError> { + let expected = rows.saturating_mul(cols); + if masks.len() != expected { + return Err(PauliFrameLookupError::MaskLengthMismatch { + len: masks.len(), + expected, + rows, + cols, + }); + } + if rows != num_shots { + return Err(PauliFrameLookupError::MaskShotMismatch { + mask_rows: rows, + num_shots, + }); + } + if cols != self.num_pauli_sites { + return Err(PauliFrameLookupError::MaskSiteMismatch { + mask_cols: cols, + num_pauli_sites: self.num_pauli_sites, + }); + } + for row in 0..rows { + for col in 0..cols { + let value = masks[row * cols + col]; + if value > 3 { + return Err(PauliFrameLookupError::InvalidMaskValue { row, col, value }); + } + } + } + Ok(()) + } +} + +fn mask_value_to_tracked_idx(site_idx: usize, value: u8) -> usize { + site_idx * 3 + usize::from(value - 1) +} + +fn xor_row(row: &mut [bool], indices: &[u32]) { + for &idx in indices { + if let Some(bit) = row.get_mut(idx as usize) { + *bit = !*bit; + } + } +} + +fn measurement_records_by_node( + dag: &DagCircuit, +) -> Result<(MeasurementRecordMap, usize), PauliFrameLookupError> { + let mut by_node = BTreeMap::new(); + let mut next_record = 0usize; + let mut num_measurements = 0usize; + + for node in dag.topological_order() { + let Some(gate) = dag.gate(node) else { + continue; + }; + if !matches!( + gate.gate_type, + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked + ) { + continue; + } + if !gate.meas_ids.is_empty() && gate.meas_ids.len() != gate.qubits.len() { + return Err(PauliFrameLookupError::MalformedMeasurementIds { + node, + meas_ids: gate.meas_ids.len(), + qubits: gate.qubits.len(), + }); + } + + let mut entries = Vec::with_capacity(gate.qubits.len()); + for (idx, qubit) in gate.qubits.iter().enumerate() { + let record = if gate.meas_ids.is_empty() { + let record = next_record; + next_record += 1; + record + } else { + gate.meas_ids[idx].index() + }; + num_measurements = num_measurements.max(record + 1); + entries.push((qubit.index(), record)); + } + by_node.insert(node, entries); + } + + Ok((by_node, num_measurements.max(next_record))) +} + +fn outputs_by_measurement( + num_measurements: usize, + records_by_output: &[Vec], + kind: &'static str, +) -> Result>, PauliFrameLookupError> { + let mut outputs = vec![Vec::new(); num_measurements]; + for (output, records) in records_by_output.iter().enumerate() { + for &record in records { + let Some(measurement) = record_offset_to_absolute_index(num_measurements, record) + else { + return Err(PauliFrameLookupError::InvalidRecordOffset { + kind, + output, + record, + num_measurements, + }); + }; + if measurement >= num_measurements { + return Err(PauliFrameLookupError::InvalidRecordOffset { + kind, + output, + record, + num_measurements, + }); + } + outputs[measurement].push(output); + } + } + Ok(outputs) +} + +fn propagate_tracked_pauli_forward( + dag: &DagCircuit, + topo_order: &[usize], + measurement_records: &BTreeMap>, + start_pos: usize, + pauli: &PauliString, +) -> BTreeSet { + let mut prop = pauli_prop_from_string(pauli); + let mut affected_measurements = BTreeSet::new(); + + for &node in topo_order.iter().skip(start_pos + 1) { + let Some(gate) = dag.gate(node) else { + continue; + }; + match gate.gate_type { + GateType::TrackedPauliMeta => {} + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked => { + if let Some(entries) = measurement_records.get(&node) { + for &(qubit, record) in entries { + if prop.contains_x(qubit) { + affected_measurements.insert(record); + } + clear_qubit(&mut prop, qubit); + } + } + } + GateType::PZ | GateType::QAlloc => { + for qubit in &gate.qubits { + clear_qubit(&mut prop, qubit.index()); + } + } + _ => apply_gate(&mut prop, gate, Direction::Forward), + } + } + + affected_measurements +} + +fn measurements_to_output_row( + measurements: &BTreeSet, + outputs_by_measurement: &[Vec], +) -> Vec { + let mut outputs = BTreeSet::new(); + for &measurement in measurements { + if let Some(row) = outputs_by_measurement.get(measurement) { + for &output in row { + if !outputs.remove(&output) { + outputs.insert(output); + } + } + } + } + outputs + .into_iter() + .map(|idx| u32::try_from(idx).expect("detector/observable index must fit into u32")) + .collect() +} + +fn pauli_prop_from_string(pauli: &PauliString) -> PauliProp { + let mut prop = PauliProp::new(); + for (pauli, qubit) in pauli.iter_pairs() { + let qubit = qubit.index(); + match pauli { + Pauli::I => {} + Pauli::X => prop.track_x(&[qubit]), + Pauli::Z => prop.track_z(&[qubit]), + Pauli::Y => prop.track_y(&[qubit]), + } + } + prop +} + +fn clear_qubit(prop: &mut PauliProp, qubit: usize) { + if prop.contains_x(qubit) { + prop.track_x(&[qubit]); + } + if prop.contains_z(qubit) { + prop.track_z(&[qubit]); + } +} diff --git a/crates/pecos-quantum/src/dag_circuit.rs b/crates/pecos-quantum/src/dag_circuit.rs index 7086a6dcf..77de172ff 100644 --- a/crates/pecos-quantum/src/dag_circuit.rs +++ b/crates/pecos-quantum/src/dag_circuit.rs @@ -1832,6 +1832,12 @@ impl DagCircuit { self.annotations.push(ann); } + /// Add a pre-built annotation when the corresponding tracked-Pauli meta + /// gate has already been inserted into the DAG. + pub(crate) fn add_annotation_without_meta_gate(&mut self, ann: PauliAnnotation) { + self.annotations.push(ann); + } + /// Get detector annotations. pub fn detectors(&self) -> impl Iterator { self.annotations diff --git a/crates/pecos-quantum/src/tick_circuit.rs b/crates/pecos-quantum/src/tick_circuit.rs index dbfc3ed8b..8f334300f 100644 --- a/crates/pecos-quantum/src/tick_circuit.rs +++ b/crates/pecos-quantum/src/tick_circuit.rs @@ -2229,6 +2229,7 @@ impl TickCircuit { pub fn tracked_pauli(&mut self, mut pauli: pecos_core::PauliString) -> usize { pauli.set_phase(pecos_core::QuarterPhase::PlusOne); let idx = self.annotations.len(); + self.insert_pauli_meta_tick(&pauli); self.annotations.push(PauliAnnotation { pauli, kind: AnnotationKind::TrackedPauli, @@ -2244,6 +2245,13 @@ impl TickCircuit { idx } + /// Insert a `TrackedPauliMeta` batch in its own tick at the current point. + fn insert_pauli_meta_tick(&mut self, pauli: &pecos_core::PauliString) { + let qubits: Vec = pauli.qubits().into_iter().map(QubitId::from).collect(); + let gate = Gate::simple(GateType::TrackedPauliMeta, qubits); + self.tick().add_gate(gate); + } + /// Get all annotations. #[must_use] pub fn annotations(&self) -> &[PauliAnnotation] { @@ -3395,11 +3403,16 @@ impl From<&TickCircuit> for DagCircuit { } AnnotationKind::TrackedPauli => AnnotationKind::TrackedPauli, }; - dag.add_annotation(PauliAnnotation { + let remapped_annotation = PauliAnnotation { pauli: ann.pauli.clone(), kind: remapped_kind, label: ann.label.clone(), - }); + }; + if matches!(remapped_annotation.kind, AnnotationKind::TrackedPauli) { + dag.add_annotation_without_meta_gate(remapped_annotation); + } else { + dag.add_annotation(remapped_annotation); + } } dag diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 9b1e1452c..80b0831e8 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -42,6 +42,8 @@ //! has_syndrome, causes_logical = influence_map.classify_fault(0, 1) # loc 0, X fault //! ``` +use crate::pecos_array::{Array, ArrayData}; +use pecos_qec::fault_tolerance::PauliFrameLookup as RustPauliFrameLookup; use pecos_qec::fault_tolerance::dem_builder::{ ComparisonMethod as RustComparisonMethod, ContributionEffectSummary as RustContributionEffectSummary, @@ -994,6 +996,178 @@ impl PyInfluenceBuilder { } } +// ============================================================================= +// Pauli Frame Lookup +// ============================================================================= + +#[pyclass(name = "PauliFrameLookup", module = "pecos_rslib.qec")] +pub struct PyPauliFrameLookup { + inner: RustPauliFrameLookup, +} + +#[pymethods] +impl PyPauliFrameLookup { + /// Build a Pauli-frame lookup from positional tracked-Pauli annotations. + /// + /// Args: + /// dag: A `DagCircuit` carrying tracked-Pauli meta-gates. + /// detectors: Detector definitions as measurement-record offsets. + /// observables: Observable definitions as measurement-record offsets. + #[staticmethod] + #[pyo3(signature = (dag, detectors, observables))] + fn from_circuit( + dag: &crate::dag_circuit_bindings::PyDagCircuit, + detectors: Vec>, + observables: Vec>, + ) -> PyResult { + let inner = RustPauliFrameLookup::from_circuit(&dag.inner, &detectors, &observables) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(Self { inner }) + } + + /// Number of Pauli-twirl mask sites. + #[getter] + fn num_pauli_sites(&self) -> usize { + self.inner.num_pauli_sites() + } + + /// Number of tracked-Pauli rows. + #[getter] + fn num_tracked_paulis(&self) -> usize { + self.inner.num_tracked_paulis() + } + + /// Number of detector columns. + #[getter] + fn num_detectors(&self) -> usize { + self.inner.num_detectors() + } + + /// Number of observable columns. + #[getter] + fn num_observables(&self) -> usize { + self.inner.num_observables() + } + + /// Return one tracked-Pauli row as `(detectors, observables)`. + fn row(&self, tracked_idx: usize) -> PyResult<(Vec, Vec)> { + let Some((detectors, observables)) = self.inner.row_effects(tracked_idx) else { + return Err(pyo3::exceptions::PyIndexError::new_err(format!( + "tracked_idx {tracked_idx} is out of range" + ))); + }; + Ok((detectors.to_vec(), observables.to_vec())) + } + + /// Decode a Pauli mask array into tracked-row firings. + fn mask_firings(&self, pauli_masks: &Bound<'_, pyo3::PyAny>) -> PyResult>> { + let (values, rows, cols) = extract_pauli_mask_values(pauli_masks)?; + self.inner + .mask_firings(&values, rows, cols) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + /// Compute per-shot detector/observable XOR patterns for the given masks. + fn compute_mask_xor( + &self, + pauli_masks: &Bound<'_, pyo3::PyAny>, + ) -> PyResult<(Vec>, Vec>)> { + let (values, rows, cols) = extract_pauli_mask_values(pauli_masks)?; + self.inner + .compute_mask_xor(&values, rows, cols) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn __repr__(&self) -> String { + format!( + "PauliFrameLookup(num_pauli_sites={}, num_tracked_paulis={}, num_detectors={}, num_observables={})", + self.num_pauli_sites(), + self.num_tracked_paulis(), + self.num_detectors(), + self.num_observables(), + ) + } +} + +fn extract_pauli_mask_values( + pauli_masks: &Bound<'_, pyo3::PyAny>, +) -> PyResult<(Vec, usize, usize)> { + let array = Array::from_python_value(pauli_masks, None)?; + match &array.data { + ArrayData::I8(arr) => collect_signed_pauli_mask_values(arr), + ArrayData::I16(arr) => collect_signed_pauli_mask_values(arr), + ArrayData::I32(arr) => collect_signed_pauli_mask_values(arr), + ArrayData::I64(arr) => collect_signed_pauli_mask_values(arr), + ArrayData::U8(arr) => collect_unsigned_pauli_mask_values(arr), + ArrayData::U16(arr) => collect_unsigned_pauli_mask_values(arr), + ArrayData::U32(arr) => collect_unsigned_pauli_mask_values(arr), + ArrayData::U64(arr) => collect_unsigned_pauli_mask_values(arr), + ArrayData::Bool(_) + | ArrayData::F32(_) + | ArrayData::F64(_) + | ArrayData::Complex64(_) + | ArrayData::Complex128(_) + | ArrayData::Pauli(_) + | ArrayData::PauliString(_) => Err(pyo3::exceptions::PyTypeError::new_err( + "pauli_masks must be an integer Array with values 0=I, 1=X, 2=Y, 3=Z", + )), + } +} + +fn pauli_mask_shape(arr: &ndarray::ArrayD) -> PyResult<(usize, usize)> { + let shape = arr.shape(); + if shape.len() != 2 { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "pauli_masks must be 2-D with shape (num_shots, num_pauli_sites), got shape {shape:?}" + ))); + } + Ok((shape[0], shape[1])) +} + +fn collect_signed_pauli_mask_values( + arr: &ndarray::ArrayD, +) -> PyResult<(Vec, usize, usize)> +where + T: Copy + Into, +{ + let (rows, cols) = pauli_mask_shape(arr)?; + let mut values = Vec::with_capacity(arr.len()); + for (idx, value) in arr.iter().copied().enumerate() { + let value = value.into(); + if !(0..=3).contains(&value) { + let row = idx / cols; + let col = idx % cols; + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "pauli_masks[{row}, {col}]={value} is outside 0..=3" + ))); + } + values.push(u8::try_from(value).expect("validated pauli mask value fits in u8")); + } + Ok((values, rows, cols)) +} + +fn collect_unsigned_pauli_mask_values( + arr: &ndarray::ArrayD, +) -> PyResult<(Vec, usize, usize)> +where + T: Copy + Into, +{ + let (rows, cols) = pauli_mask_shape(arr)?; + let mut values = Vec::with_capacity(arr.len()); + for (idx, value) in arr.iter().copied().enumerate() { + let value = value.into(); + if value > 3 { + let row = idx / cols; + let col = idx % cols; + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "pauli_masks[{row}, {col}]={value} is outside 0..=3" + ))); + } + values.push(u8::try_from(value).expect("validated pauli mask value fits in u8")); + } + Ok((values, rows, cols)) +} + // ============================================================================= // Detector Error Model // ============================================================================= @@ -3929,6 +4103,63 @@ impl PyDemSampler { self.inner.sample_batch(num_shots, &mut rng) } + /// Sample multiple shots and XOR a known Pauli-frame mask into the outputs. + /// + /// Args: + /// `num_shots`: Number of shots to sample. + /// lookup: Pauli-frame lookup built from the same circuit metadata. + /// `pauli_masks`: Integer array with shape `(num_shots, num_pauli_sites)`. + /// Values are 0=I, 1=X, 2=Y, 3=Z. + /// seed: Optional random seed for reproducibility. + /// + /// Returns: + /// Tuple of (`all_detection_events`, `all_dem_output_flips`). + #[pyo3(signature = (num_shots, lookup, pauli_masks, seed=None))] + fn sample_batch_with_pauli_masks( + &self, + num_shots: usize, + lookup: &PyPauliFrameLookup, + pauli_masks: &Bound<'_, pyo3::PyAny>, + seed: Option, + ) -> PyResult<(Vec>, Vec>)> { + use pecos_random::PecosRng; + use rand::RngExt; + + if lookup.inner.num_detectors() != self.inner.num_outputs() { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "pauli frame lookup has {} detector(s), sampler has {}", + lookup.inner.num_detectors(), + self.inner.num_outputs() + ))); + } + if lookup.inner.num_observables() != self.inner.num_dem_outputs() { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "pauli frame lookup has {} observable(s), sampler has {}", + lookup.inner.num_observables(), + self.inner.num_dem_outputs() + ))); + } + + let (mask_values, mask_rows, mask_cols) = extract_pauli_mask_values(pauli_masks)?; + let mut rng = match seed { + Some(s) => PecosRng::seed_from_u64(s), + None => PecosRng::seed_from_u64(rand::rng().random()), + }; + + let (mut det_events, mut obs_flips) = self.inner.sample_batch(num_shots, &mut rng); + lookup + .inner + .apply_mask_values( + &mask_values, + mask_rows, + mask_cols, + &mut det_events, + &mut obs_flips, + ) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok((det_events, obs_flips)) + } + /// Sample direct tracked-Pauli flips. /// /// Raises: @@ -6123,6 +6354,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/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 9bd615176..97aa6fabf 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -20,7 +20,7 @@ from pecos.qec.surface.schedule import compute_cnot_schedule if TYPE_CHECKING: - from pecos.qec.surface import SurfacePatch + from pecos.qec.surface import GuppyRngMaskConfig, SurfacePatch, TwirlConfig # Module state container (avoids global statement) @@ -45,10 +45,73 @@ def _get_temp_dir() -> Path: return _state.temp_dir +def _render_inline_pcg32() -> list[str]: + """Render Guppy-local PCG32 helpers for runtime twirl masks. + + The user seed is a stream separator. Per-shot entropy comes from + H/measure side-band qubits so the runtime twirl is not a fixed mask. + """ + return [ + "@guppy", + "@no_type_check", + "def _pcg32_mask32(value: nat) -> nat:", + " uint32_mask: nat = 4294967295", + " return value & uint32_mask", + "", + "", + "@guppy", + "@no_type_check", + "def _pcg32_advance(state: nat, inc: nat) -> nat:", + " pcg32_mult: nat = 6364136223846793005", + " return nat(state * pcg32_mult + inc)", + "", + "", + "@guppy", + "@no_type_check", + "def _pcg32_next4(state: nat, inc: nat) -> tuple[nat, int]:", + " old_state = state", + " new_state = _pcg32_advance(state, inc)", + " xorshifted = _pcg32_mask32(((old_state >> nat(18)) ^ old_state) >> nat(27))", + " rot = _pcg32_mask32(old_state >> nat(59))", + " rot_inv = _pcg32_mask32((~rot + nat(1)) & nat(31))", + " output = _pcg32_mask32((xorshifted >> rot) | (xorshifted << rot_inv))", + " return new_state, int(output & nat(3))", + "", + "", + "@guppy", + "@no_type_check", + "def seeded_pcg32_from_sequence(seed: int, sequence: nat) -> tuple[nat, nat]:", + " initstate = nat(42)", + " initseq = nat(seed) ^ sequence", + " inc = nat((initseq << nat(1)) | nat(1))", + " state = _pcg32_advance(nat(0), inc)", + " state += initstate", + " state = _pcg32_advance(state, inc)", + " return state, inc", + "", + "", + "@guppy", + "@no_type_check", + "def seeded_pcg32_with_quantum_entropy(seed: int) -> tuple[nat, nat]:", + " entropy = nat(0)", + " for i in range(32):", + " entropy_q = qubit()", + " h(entropy_q)", + " if measure(entropy_q):", + " entropy = entropy | (nat(1) << nat(i))", + " return seeded_pcg32_from_sequence(seed, entropy)", + "", + "", + ] + + def generate_guppy_source( patch: "SurfacePatch", *, ancilla_budget: int | None = None, + twirl: "TwirlConfig | None" = None, + rng: "GuppyRngMaskConfig | None" = None, + num_rounds: int | None = None, ) -> str: """Generate Guppy source code for a surface code patch. @@ -78,12 +141,42 @@ def generate_guppy_source( ancilla_budget: Optional cap on simultaneously live ancillas. ``None`` or a value ``>= total_ancilla`` emits the unconstrained shape; ``< total_ancilla`` emits batched. + twirl: When provided, emit Pauli-twirl-site mask draws between + consecutive syndrome rounds and apply the sampled physical + Pauli to each data qubit at runtime. Both ``twirl`` and + ``rng`` must be supplied together. The encoding is + ``"bool_array_v1"``: one + ``result("pauli_mask:round:R", array(lo_q0, hi_q0, ...))`` + call per twirl site, with the per-round body Python-time + unrolled at source-generation time so each tag fires exactly + once per shot. ``twirl.frame_output="canonical"`` additionally + emits measurement records in the canonical untwirled DEM frame. + rng: Runtime mask source: a stream-separator seed mixed with + per-shot quantum entropy when ``twirl`` is enabled. Returns: Python/Guppy source code as a string. + + Raises: + ValueError: If exactly one of ``twirl`` / ``rng`` is supplied. """ from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget + if (twirl is None) != (rng is None): + msg = "twirl and rng must be supplied together; got twirl={!r} rng={!r}".format( + twirl, rng + ) + raise ValueError(msg) + if twirl is not None: + twirl._validate_runtime_supported() + if num_rounds is None: + msg = "num_rounds is required when twirl is supplied" + raise ValueError(msg) + if num_rounds < 1: + msg = f"num_rounds must be >= 1, got {num_rounds}" + raise ValueError(msg) + canonical_frame_output = twirl is not None and twirl.frame_output == "canonical" + geom = patch.geometry num_data = geom.num_data num_x_stab = len(geom.x_stabilizers) @@ -91,8 +184,37 @@ def generate_guppy_source( total_ancilla = num_x_stab + num_z_stab effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) constrained = effective_budget < total_ancilla + if twirl is not None and constrained: + msg = ( + f"twirl + constrained ancilla budget is not supported on " + f"the Guppy runtime path " + f"(ancilla_budget={ancilla_budget} < total_ancilla={total_ancilla}); " + "the between_rounds twirl-site schedule assumes the " + "unconstrained syndrome shape. Pass ancilla_budget=None or " + ">= total_ancilla, or omit twirl." + ) + raise ValueError(msg) dx, dz = geom.dx, geom.dz + if twirl is not None: + imports = [ + "from __future__ import annotations", + "from typing import no_type_check", + "", + "from guppylang import guppy", + "from guppylang.std.builtins import array, owned, result", + "from guppylang.std.num import nat", + "from guppylang.std.quantum import cx, discard, h, measure, measure_array, qubit, x, y, z", + ] + else: + imports = [ + "from __future__ import annotations", + "", + "from guppylang import guppy", + "from guppylang.std.builtins import array, owned, result", + "from guppylang.std.quantum import cx, discard, h, measure, measure_array, qubit, x", + ] + lines = [ f'"""Surface code patch (dx={dx}, dz={dz}) implementation in Guppy.', "", @@ -104,13 +226,14 @@ def generate_guppy_source( f"Ancilla qubits: {num_x_stab + num_z_stab} (one per stabilizer)", '"""', "", - "from guppylang import guppy", - "from guppylang.std.builtins import array, owned, result", - "from guppylang.std.quantum import cx, discard, h, measure, measure_array, qubit, x", + *imports, "", "", ] + if twirl is not None: + lines.extend(_render_inline_pcg32()) + # Generate struct definitions lines.extend( [ @@ -164,9 +287,16 @@ def generate_guppy_source( "# === Syndrome Extraction ===", "", "@guppy", - f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz}) -> Syndrome_{dx}x{dz}:", ], ) + if canonical_frame_output: + lines.append( + f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz}, frame_x: array[bool, {num_data}], frame_z: array[bool, {num_data}]) -> Syndrome_{dx}x{dz}:" + ) + else: + lines.append( + f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz}) -> Syndrome_{dx}x{dz}:" + ) if not constrained: # Unconstrained: one ancilla per stabilizer, X-stabs first then @@ -203,11 +333,29 @@ def generate_guppy_source( lines.append(" # Measure ancillas") idx = 0 for stab in geom.x_stabilizers: - lines.append(f" sx{stab.index} = measure(ax{stab.index})") + if canonical_frame_output: + raw_var = f"sx{stab.index}_raw" + flip_var = f"sx{stab.index}_flip" + flip_expr = _xor_expr(f"frame_z[{q}]" for q in stab.data_qubits) + lines.append(f" {raw_var} = measure(ax{stab.index})") + lines.append(f" {flip_var} = {flip_expr}") + lines.append(f" sx{stab.index} = {raw_var} != {flip_var}") + lines.append(f' result("raw:sx{stab.index}:bit:{idx}", {raw_var})') + else: + lines.append(f" sx{stab.index} = measure(ax{stab.index})") lines.append(f' result("sx{stab.index}:meas:{idx}", sx{stab.index})') idx += 1 for stab in geom.z_stabilizers: - lines.append(f" sz{stab.index} = measure(az{stab.index})") + if canonical_frame_output: + raw_var = f"sz{stab.index}_raw" + flip_var = f"sz{stab.index}_flip" + flip_expr = _xor_expr(f"frame_x[{q}]" for q in stab.data_qubits) + lines.append(f" {raw_var} = measure(az{stab.index})") + lines.append(f" {flip_var} = {flip_expr}") + lines.append(f" sz{stab.index} = {raw_var} != {flip_var}") + lines.append(f' result("raw:sz{stab.index}:bit:{idx}", {raw_var})') + else: + lines.append(f" sz{stab.index} = measure(az{stab.index})") lines.append(f' result("sz{stab.index}:meas:{idx}", sz{stab.index})') idx += 1 else: @@ -470,59 +618,211 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ) # Generate memory experiment factories - lines.extend( - [ - "# === Memory Experiments ===", - "", - "def make_memory_z(num_rounds: int):", - ' """Create Z-basis memory experiment."""', - " from guppylang.std.builtins import comptime", - "", - " @guppy", - " def memory_z() -> None:", - f' """Z-basis memory experiment for dx={dx}, dz={dz}."""', - " surf = prep_z_basis()", - " init_syn = init_z_basis(surf)", - ' result("init_synx", init_syn)', - "", - " for _t in range(comptime(num_rounds)):", - " syn = syndrome_extraction(surf)", - ' result("synx", syn.synx)', - ' result("synz", syn.synz)', - "", - " final = measure_z_basis(surf)", - ' result("final", final)', - "", - " return memory_z", - "", - "", - "def make_memory_x(num_rounds: int):", - ' """Create X-basis memory experiment."""', - " from guppylang.std.builtins import comptime", - "", - " @guppy", - " def memory_x() -> None:", - f' """X-basis memory experiment for dx={dx}, dz={dz}."""', - " surf = prep_x_basis()", - " init_syn = init_x_basis(surf)", - ' result("init_synz", init_syn)', - "", - " for _t in range(comptime(num_rounds)):", - " syn = syndrome_extraction(surf)", - ' result("synx", syn.synx)', - ' result("synz", syn.synz)', - "", - " final = measure_x_basis(surf)", - ' result("final", final)', - "", - " return memory_x", - "", - ], - ) + lines.extend(_render_memory_experiments(dx, dz, num_data, twirl, rng, num_rounds)) return "\n".join(lines) +def _xor_expr(terms: object) -> str: + """Return a Guppy bool XOR expression for the given source terms.""" + parts = list(terms) + if not parts: + return "False" + expr = parts[0] + for part in parts[1:]: + expr = f"({expr} != {part})" + return str(expr) + + +def _render_memory_experiments( + dx: int, + dz: int, + num_data: int, + twirl: "TwirlConfig | None", + rng: "GuppyRngMaskConfig | None", + num_rounds: int | None, +) -> list[str]: + """Render both memory factory functions.""" + lines = [ + "# === Memory Experiments ===", + "", + ] + for basis, basis_upper in (("z", "Z"), ("x", "X")): + if twirl is None: + lines.extend(_render_plain_memory_block(basis, basis_upper, dx, dz)) + else: + assert rng is not None + assert num_rounds is not None + lines.extend( + _render_twirled_memory_block( + basis, + basis_upper, + dx, + dz, + num_data, + twirl, + rng, + num_rounds, + ) + ) + return lines + + +def _render_plain_memory_block( + basis: str, + basis_upper: str, + dx: int, + dz: int, +) -> list[str]: + """Render the vanilla handoff memory factory for one basis.""" + init_func = "init_z_basis" if basis == "z" else "init_x_basis" + init_tag = "init_synx" if basis == "z" else "init_synz" + return [ + f"def make_memory_{basis}(num_rounds: int):", + f' """Create {basis_upper}-basis memory experiment."""', + " from guppylang.std.builtins import comptime", + "", + " @guppy", + f" def memory_{basis}() -> None:", + f' """{basis_upper}-basis memory experiment for dx={dx}, dz={dz}."""', + f" surf = prep_{basis}_basis()", + f" init_syn = {init_func}(surf)", + f' result("{init_tag}", init_syn)', + "", + " for _t in range(comptime(num_rounds)):", + " syn = syndrome_extraction(surf)", + ' result("synx", syn.synx)', + ' result("synz", syn.synz)', + "", + f" final = measure_{basis}_basis(surf)", + ' result("final", final)', + "", + f" return memory_{basis}", + "", + "", + ] + + +def _render_twirled_memory_block( + basis: str, + basis_upper: str, + dx: int, + dz: int, + num_data: int, + twirl: "TwirlConfig", + rng: "GuppyRngMaskConfig", + num_rounds: int, +) -> list[str]: + """Render a Python-time unrolled twirled memory factory.""" + from pecos.qec.surface._twirl_sites import num_twirl_sites, pauli_mask_round_tag + + seed = int(rng.seed) + canonical_frame_output = twirl.frame_output == "canonical" + init_func = "init_z_basis" if basis == "z" else "init_x_basis" + init_tag = "init_synx" if basis == "z" else "init_synz" + body: list[str] = [ + f' """{basis_upper}-basis memory experiment for dx={dx}, dz={dz}, num_rounds={num_rounds} (twirled)."""', + f" surf = prep_{basis}_basis()", + " # RNG seed is structural -- changing it does not invalidate the", + " # abstract DEM / topology cache, only the per-shot mask buffer.", + f" rng_state, rng_inc = seeded_pcg32_with_quantum_entropy({seed})", + f' result("frame_mode:{twirl.frame_output}", True)', + f" init_syn = {init_func}(surf)", + f' result("{init_tag}", init_syn)', + "", + ] + if canonical_frame_output: + for q in range(num_data): + body.append(f" fx_{q} = False") + body.append(f" fz_{q} = False") + body.append("") + + # Emit num_rounds - 1 twirled rounds, then one final untwirled round. + n_twirl = num_twirl_sites(num_rounds) + for r in range(n_twirl): + body.append(f" # === Round {r} (twirled) ===") + if canonical_frame_output: + frame_x = ", ".join(f"fx_{q}" for q in range(num_data)) + frame_z = ", ".join(f"fz_{q}" for q in range(num_data)) + body.append( + f" syn = syndrome_extraction(surf, array({frame_x}), array({frame_z}))" + ) + else: + body.append(" syn = syndrome_extraction(surf)") + body.append(' result("synx", syn.synx)') + body.append(' result("synz", syn.synz)') + body.append(" # Pauli twirl site between this round and the next.") + for q in range(num_data): + body.append(f" rng_state, m_{r}_{q} = _pcg32_next4(rng_state, rng_inc)") + body.append(f" if m_{r}_{q} == 1:") + body.append(f" x(surf.data[{q}])") + body.append(f" if m_{r}_{q} == 2:") + body.append(f" y(surf.data[{q}])") + body.append(f" if m_{r}_{q} == 3:") + body.append(f" z(surf.data[{q}])") + body.append(f" lo_{r}_{q} = (m_{r}_{q} == 1) | (m_{r}_{q} == 3)") + body.append(f" hi_{r}_{q} = (m_{r}_{q} == 2) | (m_{r}_{q} == 3)") + if canonical_frame_output: + body.append( + f" twx_{r}_{q} = (m_{r}_{q} == 1) | (m_{r}_{q} == 2)" + ) + body.append( + f" twz_{r}_{q} = (m_{r}_{q} == 2) | (m_{r}_{q} == 3)" + ) + body.append(f" fx_{q} = fx_{q} != twx_{r}_{q}") + body.append(f" fz_{q} = fz_{q} != twz_{r}_{q}") + elements = ", ".join(f"lo_{r}_{q}, hi_{r}_{q}" for q in range(num_data)) + tag = pauli_mask_round_tag(r) + body.append(f' result("{tag}", array({elements}))') + body.append("") + + if num_rounds > 0: + body.append(f" # === Round {num_rounds - 1} (final, no twirl after) ===") + if canonical_frame_output: + frame_x = ", ".join(f"fx_{q}" for q in range(num_data)) + frame_z = ", ".join(f"fz_{q}" for q in range(num_data)) + body.append( + f" syn = syndrome_extraction(surf, array({frame_x}), array({frame_z}))" + ) + else: + body.append(" syn = syndrome_extraction(surf)") + body.append(' result("synx", syn.synx)') + body.append(' result("synz", syn.synz)') + body.append("") + + if canonical_frame_output: + body.append(f" final_raw = measure_{basis}_basis(surf)") + body.append(' result("raw:final", final_raw)') + for q in range(num_data): + flip_var = f"fx_{q}" if basis == "z" else f"fz_{q}" + body.append(f" final_{q} = final_raw[{q}] != {flip_var}") + final_elements = ", ".join(f"final_{q}" for q in range(num_data)) + body.append(f' result("final", array({final_elements}))') + else: + body.append(f" final = measure_{basis}_basis(surf)") + body.append(' result("final", final)') + + return [ + f"def make_memory_{basis}(num_rounds: int):", + f' """Create {basis_upper}-basis twirled memory experiment.', + "", + f" num_rounds must equal {num_rounds} -- the body was unrolled at", + " source-generation time. Mismatched values raise ValueError.", + ' """', + f" if num_rounds != {num_rounds}:", + f' msg = f"this generated module was unrolled for num_rounds={num_rounds}, got {{num_rounds!r}}"', + " raise ValueError(msg)", + "", + " @guppy", + f" def memory_{basis}() -> None:", + *body, + "", + f" return memory_{basis}", + "", + "", + ] + + def _validate_surface_memory_distance(d: int) -> None: """Enforce the surface-memory Guppy entry-point distance contract. @@ -538,23 +838,46 @@ def _validate_surface_memory_distance(d: int) -> None: raise ValueError(msg) -def _guppy_module_cache_key(patch: "SurfacePatch", effective_budget: int) -> str: - """Filesystem-safe cache key spanning full patch identity + budget. +def _guppy_module_cache_key( + patch: "SurfacePatch", + effective_budget: int, + twirl: "TwirlConfig | None" = None, + rng: "GuppyRngMaskConfig | None" = None, + num_rounds: int | None = None, +) -> str: + """Filesystem-safe cache key spanning full patch identity + budget + twirl. Mirrors the topology identity used by the native cache (``decode._surface_patch_cache_key``): dx, dz, orientation, and the rotated flag. Keying on distance/dx-dz alone would collide a rotated and a non-rotated patch of the same shape onto one generated module. + + Twirled source is Python-time unrolled, so the cache key includes + ``num_rounds`` in addition to the structural twirl fields, runtime + frame-output mode, and RNG seed. """ geom = patch.geometry rotated = "rot" if geom.rotated else "unrot" - return f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}_b{effective_budget}" + base = f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}_b{effective_budget}" + if twirl is None: + return base + assert rng is not None + assert num_rounds is not None + twirl_part = ( + f"t-{twirl.scheme}-{twirl.site_schedule}-{twirl.result_encoding}" + f"-frame-{twirl.frame_output}" + f"-s{int(rng.seed)}-r{int(num_rounds)}" + ) + return f"{base}_{twirl_part}" def _load_guppy_module( patch: "SurfacePatch", *, ancilla_budget: int | None = None, + twirl: "TwirlConfig | None" = None, + rng: "GuppyRngMaskConfig | None" = None, + num_rounds: int | None = None, ) -> dict: """Load a Guppy module for a patch, using caching. @@ -562,11 +885,14 @@ def _load_guppy_module( rotated) and the **effective** budget (after clamping via ``normalize_ancilla_budget``), so ``ancilla_budget=None`` and ``ancilla_budget >= total_ancilla`` resolve to the same cache entry - while distinct patch geometries never collide. + while distinct patch geometries never collide. Twirled source also + keys on twirl fields, frame-output mode, RNG seed, and round count. Args: patch: SurfacePatch with geometry ancilla_budget: Optional cap on simultaneously live ancillas + twirl: Pauli-twirl-site declaration (structural) + rng: Runtime mask RNG seed (must be supplied with ``twirl``) Returns: Module dictionary with generated functions @@ -576,13 +902,18 @@ def _load_guppy_module( geom = patch.geometry total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) - cache_key = _guppy_module_cache_key(patch, effective_budget) + cache_key = _guppy_module_cache_key(patch, effective_budget, twirl, rng, num_rounds) if cache_key in _state.module_cache: return _state.module_cache[cache_key] - # Generate source for this (patch, effective_budget) combination. - source = generate_guppy_source(patch, ancilla_budget=ancilla_budget) + source = generate_guppy_source( + patch, + ancilla_budget=ancilla_budget, + twirl=twirl, + rng=rng, + num_rounds=num_rounds, + ) # Write to temp file (required for Guppy introspection). temp_dir = _get_temp_dir() @@ -610,6 +941,8 @@ def generate_memory_experiment( basis: str, *, ancilla_budget: int | None = None, + twirl: "TwirlConfig | None" = None, + rng: "GuppyRngMaskConfig | None" = None, ) -> object: """Generate a memory experiment for a patch. @@ -618,11 +951,19 @@ def generate_memory_experiment( num_rounds: Number of syndrome rounds basis: 'Z' or 'X' ancilla_budget: Optional cap on simultaneously live ancillas + twirl: Pauli-twirl-site declaration; must be supplied with ``rng``. + rng: Runtime mask RNG seed; must be supplied with ``twirl``. Returns: Guppy function for the experiment """ - module = _load_guppy_module(patch, ancilla_budget=ancilla_budget) + module = _load_guppy_module( + patch, + ancilla_budget=ancilla_budget, + twirl=twirl, + rng=rng, + num_rounds=num_rounds if twirl is not None else None, + ) if basis.upper() == "Z": factory = module["make_memory_z"] @@ -640,6 +981,7 @@ def get_num_qubits( *, patch: "SurfacePatch | None" = None, ancilla_budget: int | None = None, + twirl: "TwirlConfig | None" = None, ) -> int: """Get the peak simultaneously-live qubit count for a surface-code program. @@ -657,6 +999,8 @@ def get_num_qubits( ``num_data + min(ancilla_budget, total_ancilla)`` slots are live at once. Clamping matches ``normalize_ancilla_budget``, so the unconstrained-via-``None`` and unconstrained-via-large-int cases collapse. + Twirled Guppy programs allocate one additional side-band entropy qubit at + a time for per-shot mask seeding. Returns: Total qubits the traced program will simultaneously use. @@ -676,7 +1020,8 @@ def get_num_qubits( num_data = d * d total_ancilla = d * d - 1 - return num_data + normalize_ancilla_budget(total_ancilla, ancilla_budget) + twirl_entropy_qubits = 1 if twirl is not None else 0 + return num_data + normalize_ancilla_budget(total_ancilla, ancilla_budget) + twirl_entropy_qubits def generate_surface_code_module(d: int, *, ancilla_budget: int | None = None) -> str: diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index 9b959d6a5..023a60792 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -37,6 +37,7 @@ EquivalenceResult, FaultLocation, InfluenceBuilder, + PauliFrameLookup, ParsedDem, assert_dems_equivalent, compare_dems_exact, @@ -123,6 +124,7 @@ "EquivalenceResult", "FaultLocation", "InfluenceBuilder", + "PauliFrameLookup", "ParsedDem", "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 e7c836664..53302e7d7 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -45,6 +45,59 @@ P2Weights = Mapping[str, float] +def _from_circuit_with_noise( + tc: Any, + *, + p1: float, + p1_weights: P1Weights | None, + p2: float, + p2_weights: P2Weights | None, + 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, + 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, +) -> _RustDetectorErrorModel: + return _RustDetectorErrorModel.from_circuit( + 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, + 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, + ) + + class _DetectorErrorModelMixin: """Namespace for the Python Guppy/QIS-trace convenience constructor.""" @@ -283,7 +336,7 @@ def from_guppy( if num_measurements is not None: tc.set_meta("num_measurements", str(num_measurements)) - return _RustDetectorErrorModel.from_circuit( + return _from_circuit_with_noise( tc, p1=p1, p1_weights=p1_weights, diff --git a/python/quantum-pecos/src/pecos/qec/surface/__init__.py b/python/quantum-pecos/src/pecos/qec/surface/__init__.py index 5aee25b2e..f4d4c3963 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/surface/__init__.py @@ -54,12 +54,16 @@ SurfaceDecoder, build_memory_circuit, build_native_sampler, + build_native_sampler_from_dem, build_stim_circuit_from_patch, + decode_native_samples, + demask_pauli_frame_records, generate_circuit_level_dem, generate_dem_from_patch, generate_repetition_code_dem, generate_surface_code_dem, run_noisy_memory_experiment, + sample_pauli_masks_from_guppy, surface_code_memory, syndromes_to_detection_events, ) @@ -101,12 +105,17 @@ get_stabilizer_touch_label, ) from pecos.qec.surface.plot import plot_patch, plot_surface_code +from pecos.qec.surface._detection_events import extract_detection_events_and_observables from pecos.qec.surface.schedule import ( compute_cnot_schedule, get_stab_schedule, ) +from pecos.qec.surface._twirl_config import GuppyRngMaskConfig, TwirlConfig __all__ = [ + # Twirling config (Pauli-frame randomization) + "GuppyRngMaskConfig", + "TwirlConfig", # Rotated lattice (most common, default) "compute_rotated_x_stabilizers", "compute_rotated_z_stabilizers", @@ -144,12 +153,17 @@ "SurfaceDecoder", "build_memory_circuit", "build_native_sampler", + "build_native_sampler_from_dem", "build_stim_circuit_from_patch", + "decode_native_samples", + "demask_pauli_frame_records", + "extract_detection_events_and_observables", "generate_circuit_level_dem", "generate_dem_from_patch", "generate_repetition_code_dem", "generate_surface_code_dem", "run_noisy_memory_experiment", + "sample_pauli_masks_from_guppy", "surface_code_memory", "syndromes_to_detection_events", # Visualization diff --git a/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py new file mode 100644 index 000000000..14a250631 --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py @@ -0,0 +1,66 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Metadata-driven detection-event extraction for surface memory circuits.""" + +from __future__ import annotations + +import json +from collections.abc import Iterable, Sequence +from typing import Any + + +def extract_detection_events_and_observables( + tick_circuit: Any, + results: Iterable[Sequence[int]], +) -> tuple[list[list[int]], list[list[int]]]: + """Extract fired detectors and observables from flat measurement rows.""" + detectors_json = tick_circuit.get_meta("detectors") + detectors = json.loads(detectors_json) if detectors_json else [] + + observables_json = tick_circuit.get_meta("observables") + observables = json.loads(observables_json) if observables_json else [] + + num_meas_meta = tick_circuit.get_meta("num_measurements") + if num_meas_meta is None or num_meas_meta == "": + msg = ( + "extract_detection_events_and_observables requires " + "tick_circuit.get_meta('num_measurements') to be set" + ) + raise ValueError(msg) + num_meas = int(num_meas_meta) + + detection_events_per_shot: list[list[int]] = [] + observable_flips_per_shot: list[list[int]] = [] + + for row in results: + if len(row) != num_meas: + msg = ( + f"result row has length {len(row)} but tick_circuit metadata " + f"declares num_measurements={num_meas}" + ) + raise ValueError(msg) + + fired_detectors: list[int] = [] + for det_idx, det in enumerate(detectors): + val = 0 + for rec in det["records"]: + idx = num_meas + rec + if 0 <= idx < num_meas: + val ^= int(row[idx]) + if val: + fired_detectors.append(det_idx) + detection_events_per_shot.append(fired_detectors) + + flipped_observables: list[int] = [] + for obs_idx, obs in enumerate(observables): + val = 0 + for rec in obs["records"]: + idx = num_meas + rec + if 0 <= idx < num_meas: + val ^= int(row[idx]) + if val: + flipped_observables.append(obs_idx) + observable_flips_per_shot.append(flipped_observables) + + return detection_events_per_shot, observable_flips_per_shot diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py new file mode 100644 index 000000000..2263583e7 --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py @@ -0,0 +1,131 @@ +"""Configuration objects for Pauli-frame twirling. + +`TwirlConfig` carries the twirl-site declaration: scheme, where in the +circuit twirling sites are emitted, how the per-shot mask is encoded into +the runtime result bundle after the corresponding physical Pauli gates +are applied, and how generated Guppy measurement records are framed. The +first three fields are structural for abstract DEM / topology caches. +`frame_output` is runtime-only: raw and canonical Guppy records share the +same abstract DEM and `PauliFrameLookup`. + +`GuppyRngMaskConfig` carries the **runtime** mask source: a stream-separator +seed mixed with 32 bits of per-shot quantum entropy when the mask is drawn, +applied to data qubits, and recorded via `result()`. Two abstract circuits +identical except for `seed` or `frame_output` reuse the same DEM but produce +different shot-level runtime records, so those values belong in the Guppy- +module / compiled-shot cache layer but NOT in the abstract DEM cache. + +The split mirrors the two-tracks-per-twirl-setting architecture from the +design doc: the abstract circuit (consumer: DEM builder, +`PauliFrameLookup`, decoder structure) consumes `TwirlConfig`; the Guppy +module (consumer: Selene runtime) consumes `TwirlConfig` AND +`GuppyRngMaskConfig`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + + +_SUPPORTED_SCHEMES = ("pauli",) +_SUPPORTED_SITE_SCHEDULES = ("between_rounds",) +_SUPPORTED_RESULT_ENCODINGS = ("bool_array_v1",) +_SUPPORTED_FRAME_OUTPUTS = ("raw", "canonical") + + +@dataclass(frozen=True) +class TwirlConfig: + """Structural Pauli-twirl-site declaration. + + All fields are constrained to the values Phase 0a currently supports. + Future Phase 2 (Clifford twirling) work will extend the `scheme` enum. + + Attributes: + scheme: Twirling family. Phase 0a supports `"pauli"`; the + `"clifford"` value is reserved for Phase 2 ({I, H} + Clifford-frame randomization) and not yet implemented. + site_schedule: Where twirling sites are emitted in the circuit. + `"between_rounds"` (the only supported value) emits one site + between each pair of consecutive syndrome rounds. + result_encoding: How the per-shot mask is recorded in the + runtime result bundle. `"bool_array_v1"` packs the + `2 * num_data` bool bits per round into one tagged array per + twirl site (the only supported encoding -- the earlier + shared-tag scalar-bool variant is unimplementable because + `ShotVec.to_dict()` collapses repeated same-tag calls to the + last value). + frame_output: Runtime Guppy measurement-frame convention. + `"raw"` preserves the landed behavior: measurement tags are + emitted in the physical/twirled frame and callers can + canonicalize with `PauliFrameLookup`. `"canonical"` makes the + generated Guppy program track the Pauli frame classically and + flip emitted measurement bits into the canonical untwirled DEM + frame. This does not change the abstract circuit or DEM + topology; it only changes generated runtime records and must + therefore be part of the Guppy module cache key. + """ + + scheme: Literal["pauli"] = "pauli" + site_schedule: Literal["between_rounds"] = "between_rounds" + result_encoding: Literal["bool_array_v1"] = "bool_array_v1" + frame_output: Literal["raw", "canonical"] = "raw" + + def _validate_runtime_supported(self) -> None: + """Raise ``ValueError`` if any field is outside the supported runtime set. + + The ``Literal`` annotations are static-only hints; this method + enforces them at runtime so e.g. + ``TwirlConfig(result_encoding="bool_scalar_v1")`` (constructed via + ``object.__setattr__`` or a stale call path) fails loudly at the + Guppy / harvest boundary rather than silently producing + encoding-incompatible behavior. + """ + if self.scheme not in _SUPPORTED_SCHEMES: + msg = ( + f"TwirlConfig.scheme={self.scheme!r} is not supported; " + f"expected one of {_SUPPORTED_SCHEMES!r}" + ) + raise ValueError(msg) + if self.site_schedule not in _SUPPORTED_SITE_SCHEDULES: + msg = ( + f"TwirlConfig.site_schedule={self.site_schedule!r} is not " + f"supported; expected one of {_SUPPORTED_SITE_SCHEDULES!r}" + ) + raise ValueError(msg) + if self.result_encoding not in _SUPPORTED_RESULT_ENCODINGS: + msg = ( + f"TwirlConfig.result_encoding={self.result_encoding!r} is " + f"not supported; expected one of {_SUPPORTED_RESULT_ENCODINGS!r}" + ) + raise ValueError(msg) + if self.frame_output not in _SUPPORTED_FRAME_OUTPUTS: + msg = ( + f"TwirlConfig.frame_output={self.frame_output!r} is not " + f"supported; expected one of {_SUPPORTED_FRAME_OUTPUTS!r}" + ) + raise ValueError(msg) + + +@dataclass(frozen=True) +class GuppyRngMaskConfig: + """Runtime Guppy-side mask source. + + The seed separates mask streams. Generated Guppy programs mix it with + 32 measured H-basis entropy bits per shot before drawing the per-shot + mask at quantum runtime. Excluded from the abstract DEM / topology + cache key by construction: changing the seed must NOT invalidate the + abstract circuit's DEM, only the compiled Guppy module + per-shot mask + buffer. + + The Selene harvest helpers also pass this seed to the Stim simulator + so mask draws are reproducible in tests. Studies that need mask-stream + and syndrome-noise randomness to vary independently should expose a + separate simulator seed. + + Attributes: + seed: 64-bit unsigned stream-separator seed. Must be representable + as a 64-bit unsigned integer. + """ + + seed: int diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py new file mode 100644 index 000000000..068185102 --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py @@ -0,0 +1,119 @@ +"""Canonical `(round, qubit) -> site_idx` mapping shared by the abstract +circuit (`circuit_builder.py`) and the Guppy runtime renderer +(`pecos.guppy.surface`). + +Both tracks must agree byte-for-byte on the ordering of twirl-site +metadata: the abstract circuit's `tracked_pauli` annotations populate +rows of the `PauliFrameLookup` matrix `M`, and the Guppy program's +runtime Pauli applications plus per-shot `result()` recordings populate +the matching mask columns. If the two paths disagree about which +`(round, qubit)` maps to which row / column, the per-shot XOR +application silently runs the mask against the wrong tracked-Pauli +annotations and the decoder sees an incoherent syndrome. + +The mapping is currently a simple `site_idx == round_idx` for the +`between_rounds` schedule (one site between each pair of consecutive +syndrome rounds, for a total of `num_rounds - 1` sites). The helpers +below abstract that so a future schedule only needs to update this module. + +Encoding contract for the runtime mask (``"bool_array_v1"``): + +- One result tag PER twirl site, named via :func:`pauli_mask_round_tag` + (`f"{PAULI_MASK_TAG_PREFIX}:round:{round_idx}"`). Each tag is emitted + exactly once per shot, so ``ShotVec.to_dict()`` cannot collapse it. + (An earlier "shared-tag scalar bool" variant was attempted but + unimplementable -- ``ShotVec.to_dict()`` collapses repeated same-tag + ``result()`` calls in one shot to the last value, dropping every + earlier bit on the floor.) +- Each per-round tag carries one bool array of length + ``2 * num_data`` laid out as + ``(qubit_0_lo, qubit_0_hi, qubit_1_lo, qubit_1_hi, ...)``, where + ``lo = (m == 1) | (m == 3)`` and ``hi = (m == 2) | (m == 3)`` for a + Pauli value ``m in {0=I, 1=X, 2=Y, 3=Z}`` drawn at runtime by the + generated Guppy program's inline functional PCG helper and applied as + the matching physical Pauli gate at that twirl site. This is the binary encoding + of the enumeration code, not the Pauli's symplectic X/Z components. +- The decoder + (:func:`pecos.qec.surface.decode._extract_pauli_masks_from_results`) + reads each ``pauli_mask:round:{r}`` tag, packs each ``(lo, hi)`` pair + back into the integer Pauli code via ``m = lo + 2 * hi``, and lays + the result at column :func:`mask_col_for` in row-major + ``(site, qubit)`` order so the output columns match the abstract + ``PauliFrameLookup`` byte-for-byte. +- Side-band result tags emitted by twirl support (`pauli_mask:*`, + `frame_mode:*`, `raw:*`) are not detector-bearing measurement tags. + They must never contain ``":meas:"`` and must never be exactly + ``"final"``; handoff result-provenance code treats only the surface + measurement tag grammar and exact ``"final"`` as measurement records. +""" + +from __future__ import annotations + +# Base prefix for per-round mask result tags. The Guppy renderer emits one +# `result(f"{PAULI_MASK_TAG_PREFIX}:round:{r}", array(lo_q0, hi_q0, ...))` +# call per twirl site so each tag fires exactly once per shot (avoids the +# same-tag-multi-call overwrite trap in `ShotVec.to_dict()`). +PAULI_MASK_TAG_PREFIX = "pauli_mask" + +# Backwards-compatible alias for callers that constructed the bare tag. +PAULI_MASK_TAG = PAULI_MASK_TAG_PREFIX + + +def pauli_mask_round_tag(round_idx: int) -> str: + """Canonical `result()` tag for the twirl mask emitted between syndrome + rounds ``round_idx`` and ``round_idx + 1``. + """ + return f"{PAULI_MASK_TAG_PREFIX}:round:{round_idx}" + + +def num_twirl_sites(num_rounds: int) -> int: + """Number of twirl sites for the `between_rounds` schedule. + + One site between each pair of consecutive syndrome rounds: + `max(0, num_rounds - 1)`. + """ + return max(0, num_rounds - 1) + + +def site_idx_for_round(round_idx: int) -> int: + """Map the round-loop index of the round PRECEDING a twirl site to its + canonical `site_idx`. + + For the `between_rounds` schedule, the twirl site that sits between + syndrome round `r` and round `r + 1` is canonical `site_idx == r`. + Identity by construction; abstracted so a future schedule can override. + """ + return round_idx + + +def num_mask_bits_per_round(num_data: int) -> int: + """Number of bool bits the Guppy program emits per twirl site. + + The bool-bits encoding uses two bits per data qubit (`lo, hi`) so the + per-site contribution is `2 * num_data` bools. + """ + return 2 * num_data + + +def num_mask_bits_per_shot(num_rounds: int, num_data: int) -> int: + """Total bool-bit count one shot's `pauli_mask` result accumulates.""" + return num_twirl_sites(num_rounds) * num_mask_bits_per_round(num_data) + + +def num_pauli_sites(num_rounds: int, num_data: int) -> int: + """Number of mask columns (one per (site, data qubit) cell). + + Matches the column count of the abstract `PauliFrameLookup` matrix `M` + when twirling is enabled. + """ + return num_twirl_sites(num_rounds) * num_data + + +def mask_col_for(site_idx: int, qubit_idx: int, num_data: int) -> int: + """Canonical flat (site, qubit) -> mask column index. + + Row-major over (site, qubit). Decoder-side `sample_pauli_masks_from_guppy` + uses this to assemble the `(num_shots, num_pauli_sites)` u8 array from a + flat bool stream. + """ + return site_idx * num_data + qubit_idx diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index e8e85b2bc..0f7700c1d 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -45,6 +45,7 @@ ) if TYPE_CHECKING: + from pecos.qec.surface._twirl_config import TwirlConfig from pecos.qec.surface.patch import ( LogicalDescriptor, StabilizerDescriptor, @@ -127,6 +128,13 @@ class OpType(Enum): TICK = auto() # Layer separator COMMENT = auto() # Comment/annotation + # Annotation: declares a candidate tracked Pauli at this circuit position. + # The propagator records its forward propagation to detectors and + # observables; per-shot "did this Pauli fire?" is consumed at sampling + # time. ``qubits`` is the single data qubit the Pauli acts on; ``label`` + # carries the Pauli kind and site as ``"{X|Y|Z}@s"``. + TRACKED_PAULI = auto() + @dataclass class SurfaceCircuitStep: @@ -156,6 +164,8 @@ def build_surface_code_circuit( num_rounds: int, basis: str = "Z", ancilla_budget: int | None = None, + *, + twirl: TwirlConfig | None = None, ) -> tuple[list[SurfaceCircuitStep], QubitAllocation]: """Build abstract circuit operations for a surface code memory experiment. @@ -172,6 +182,11 @@ def build_surface_code_circuit( ancilla_budget: Optional cap on simultaneously live ancillas. When provided below the total stabilizer count, ancillas are reused across stabilizer batches following the public Guppy order. + twirl: When provided, emit three ``OpType.TRACKED_PAULI`` annotations + (``X``, ``Y``, ``Z``) per data qubit at each between-round twirl + site. The sites are between counted syndrome rounds only: no site + is emitted before the first counted round, after the last counted + round, or inside the prep-boundary init syndrome block. Returns: Tuple of (operations list, qubit allocation info) @@ -222,6 +237,14 @@ def x_anc_q(stab_idx: int) -> int: def z_anc_q(stab_idx: int) -> int: return allocation.z_ancilla_qubits[stab_idx] + def emit_pauli_twirl_site(target_ops: list[SurfaceCircuitStep], site_idx: int) -> None: + """Append 3 * num_data candidate tracked-Pauli annotations.""" + target_ops.extend( + SurfaceCircuitStep(OpType.TRACKED_PAULI, [data_q(i)], f"{kind}@s{site_idx}") + for i in range(num_data) + for kind in ("X", "Y", "Z") + ) + # Get CNOT schedule cnot_rounds = compute_cnot_schedule(patch) @@ -508,6 +531,9 @@ def z_anc_q(stab_idx: int) -> int: ops.append(SurfaceCircuitStep(OpType.TICK)) + if twirl is not None and rnd < num_rounds - 1: + emit_pauli_twirl_site(ops, rnd) + # ========================================================================= # measure_z_basis / measure_x_basis # ========================================================================= @@ -711,6 +737,13 @@ def render( elif op.op_type == OpType.TICK: lines.append("TICK") + elif op.op_type == OpType.TRACKED_PAULI: + msg = ( + "StimRenderer does not yet handle OpType.TRACKED_PAULI; " + "use TickCircuit / PauliFrameLookup path for twirled DEMs" + ) + raise NotImplementedError(msg) + # Add detector annotations if requested if self.add_detectors: lines.append("") @@ -896,6 +929,13 @@ def render( elif op.op_type == OpType.TICK: pass # DagCircuit doesn't have explicit ticks + elif op.op_type == OpType.TRACKED_PAULI: + msg = ( + "DagCircuitRenderer does not yet handle OpType.TRACKED_PAULI; " + "use TickCircuit / PauliFrameLookup path for twirled DEMs" + ) + raise NotImplementedError(msg) + return circuit @@ -1256,6 +1296,27 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non current_tick_handle = None qubits_in_current_tick = set() + elif op.op_type == OpType.TRACKED_PAULI: + from pecos_rslib import PauliString + + q = op.qubits[0] + kind, sep, site_suffix = op.label.partition("@") + pauli_ctor = { + "X": PauliString.X, + "Y": PauliString.Y, + "Z": PauliString.Z, + }.get(kind) + if pauli_ctor is None or sep != "@" or not site_suffix.startswith("s"): + msg = ( + "OpType.TRACKED_PAULI requires label of the form " + f"'{{X|Y|Z}}@s', got {op.label!r}" + ) + raise ValueError(msg) + circuit.tracked_pauli( + pauli_ctor(q), + label=f"twirl_{site_suffix}_q{q}_{kind}", + ) + # Apply tick-level metadata in place. Gate metadata is attached as each # gate is emitted so batching decisions can account for it immediately. for tick_idx, tick_meta in all_tick_metadata.items(): @@ -1599,6 +1660,7 @@ def generate_tick_circuit_from_patch( add_detectors: bool = True, add_typed_annotations: bool = True, ancilla_budget: int | None = None, + twirl: TwirlConfig | None = None, ) -> TickCircuit: """Generate PECOS TickCircuit from SurfacePatch. @@ -1622,11 +1684,21 @@ def generate_tick_circuit_from_patch( add_detectors: Whether to add detector/observable metadata. add_typed_annotations: Whether to also add typed Pauli annotations. ancilla_budget: Optional cap on simultaneously live ancillas + twirl: Optional Pauli-frame randomization layout. When supplied, + tracked-Pauli annotations are emitted even if + ``add_typed_annotations`` is false; that flag controls detector + and observable typed annotations, not the twirl lookup channel. Returns: PECOS TickCircuit instance """ - ops, allocation = build_surface_code_circuit(patch, num_rounds, basis, ancilla_budget) + ops, allocation = build_surface_code_circuit( + patch, + num_rounds, + basis, + ancilla_budget, + twirl=twirl, + ) renderer = TickCircuitRenderer( add_detectors=add_detectors, add_typed_annotations=add_typed_annotations, @@ -2505,7 +2577,9 @@ def _build_canonical_dem_influence_map(dag: DagCircuit) -> object: annotation_builder = InfluenceBuilder(dag) annotation_builder.with_circuit_annotations() annotation_map = annotation_builder.build() - influence_map.merge_dem_outputs_from(annotation_map) + merge_dem_outputs = getattr(influence_map, "merge_dem_outputs_from", None) + if merge_dem_outputs is not None: + merge_dem_outputs(annotation_map) return influence_map diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 5f2880453..ac954498c 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -44,7 +44,7 @@ from __future__ import annotations from collections.abc import Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from enum import Enum from functools import cache from typing import TYPE_CHECKING, Any, Literal @@ -55,6 +55,7 @@ import stim from numpy.typing import NDArray + from pecos.qec.surface._twirl_config import TwirlConfig from pecos.qec.surface.patch import Stabilizer, SurfacePatch @@ -260,12 +261,14 @@ class _CachedNativeSurfaceTopology: dag_circuit: Any influence_map: Any + pauli_frame_lookup: Any | None detectors_json: str observables_json: str measurement_order: tuple[int, ...] num_measurements: int num_detectors: int num_observables: int + num_pauli_sites: int def _surface_patch_cache_key(patch: SurfacePatch) -> tuple[int, int, str, bool]: @@ -292,6 +295,24 @@ def _cached_surface_patch(patch_key: tuple[int, int, str, bool]) -> SurfacePatch ) +def _abstract_twirl_config(twirl: TwirlConfig | None) -> TwirlConfig | None: + """Drop runtime-only Guppy record-framing fields before DEM caching.""" + if twirl is None: + return None + return replace(twirl, frame_output="raw") + + +def _twirl_traced_qis_rejection_message() -> str: + return ( + "twirl=TwirlConfig() is not supported with circuit_source='traced_qis': " + "tracing runtime RNG twirl would bake one concrete mask realization " + "into the circuit/DEM/lookup, and canonical frame_output may break " + "runtime measurement result-id provenance because measurement tags can " + "be XOR-derived expressions. Use circuit_source='abstract' for twirl " + "for now." + ) + + def syndromes_to_detection_events( syndromes: NDArray[np.uint8], num_rounds: int, @@ -689,6 +710,8 @@ def _index_surface_result_trace_ids( result_ids = trace.get("result_ids") if not isinstance(name, str) or not isinstance(values, list) or not isinstance(result_ids, list): continue + if _is_surface_sideband_result_tag(name): + continue if len(values) != len(result_ids): msg = ( f"runtime result tag {name!r} has {len(values)} value(s) but " @@ -707,6 +730,11 @@ def _index_surface_result_trace_ids( return scalar_trace_ids, array_trace_ids +def _is_surface_sideband_result_tag(name: str) -> bool: + """Return true for non-detector-bearing surface result tags.""" + return name.startswith(("pauli_mask:", "frame_mode:", "raw:")) + + def _surface_abstract_measurement_result_refs(abstract_tc: Any) -> list[tuple[str, str] | tuple[str, str, int]]: """Return the result-tag reference for each abstract surface measurement.""" refs: list[tuple[str, str] | tuple[str, str, int]] = [] @@ -1153,7 +1181,17 @@ def _replay_qis_trace_chunks_into_tick_circuit(chunks: list[dict[str, Any]]) -> """Replay captured QIS operation trace chunks into a ``TickCircuit``.""" if any(chunk.get("lowered_quantum_ops") for chunk in chunks): _reject_partially_lowered_trace(chunks) - return _replay_lowered_qis_trace_into_tick_circuit(chunks) + try: + return _replay_lowered_qis_trace_into_tick_circuit(chunks) + except ValueError as exc: + if "missing measurement_result_ids" not in str(exc): + raise + # Older local Selene/qis-compiler builds can emit lowered gates + # without measurement_result_ids while still carrying the raw QIS + # operations, whose Measure payloads include the stable result ids. + # Replay the raw operations in that compatibility case instead of + # losing provenance. + pass operations: list[dict[str, Any]] = [] for chunk in chunks: @@ -1282,7 +1320,7 @@ def _generate_traced_surface_tick_circuit_with_result_traces( ) -> tuple[Any, list[dict[str, Any]]]: """Trace a surface Guppy program into a ``TickCircuit`` plus result provenance.""" from pecos.guppy import get_num_qubits - from pecos.guppy.surface import generate_memory_experiment + from pecos.guppy.surface import generate_memory_experiment, get_num_qubits program = generate_memory_experiment( patch, @@ -1306,6 +1344,7 @@ def _build_surface_tick_circuit_for_native_model( ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, + twirl: TwirlConfig | None = None, ) -> Any: """Build the TickCircuit used by the native DEM and sampler paths.""" from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch @@ -1316,6 +1355,7 @@ def _build_surface_tick_circuit_for_native_model( basis, ancilla_budget=ancilla_budget, add_typed_annotations=False, + twirl=twirl, ) if circuit_source == "abstract": @@ -1325,6 +1365,9 @@ def _build_surface_tick_circuit_for_native_model( msg = f"Unknown circuit_source {circuit_source!r}" raise ValueError(msg) + if twirl is not None: + raise ValueError(_twirl_traced_qis_rejection_message()) + traced_tc, result_traces = _generate_traced_surface_tick_circuit_with_result_traces( patch, num_rounds, @@ -1359,6 +1402,7 @@ def build_memory_circuit( ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, + twirl: TwirlConfig | None = None, ) -> Any: """Build the standard surface-code memory ``TickCircuit``. @@ -1377,6 +1421,10 @@ def build_memory_circuit( ``"traced_qis"`` for the lowered traced QIS gate stream. runtime: Optional Selene runtime selector/plugin used when ``circuit_source="traced_qis"``. + twirl: Optional Pauli-frame randomization layout. Currently supported + only with ``circuit_source="abstract"``; traced-QIS twirl is + rejected because a runtime trace would bake one sampled mask into + the circuit and can lose canonical result-id provenance. Returns: A Rust-backed ``TickCircuit`` with detector and observable metadata. @@ -1408,6 +1456,7 @@ def build_memory_circuit( ancilla_budget=ancilla_budget, circuit_source=circuit_source, runtime=runtime, + twirl=twirl, ) @@ -1501,6 +1550,61 @@ def _noise_uses_dedicated_idle_noise(noise: NoiseModel) -> bool: ) +def _with_noise_compat(builder: Any, noise: NoiseModel) -> Any: + """Call Rust ``with_noise`` using the richest signature this binding supports.""" + noise_kwargs = { + "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, + "p1_weights": _p1_weights_dict(noise.p1_weights), + "p2_weights": _p2_weights_dict(noise.p2_weights), + } + if noise.p2_replacement_approximation is not None: + noise_kwargs["p2_replacement_approximation"] = noise.p2_replacement_approximation + + try: + return builder.with_noise( + noise.p1, + noise.p2, + noise.p_meas, + noise.p_prep, + **noise_kwargs, + ) + except TypeError as exc: + unsupported = { + key: value + for key, value in noise_kwargs.items() + if key not in {"p_idle", "t1", "t2"} and value is not None + } + if unsupported: + msg = ( + "This pecos_rslib build does not support the requested advanced " + f"surface noise options: {sorted(unsupported)}" + ) + raise TypeError(msg) from exc + return builder.with_noise( + noise.p1, + noise.p2, + noise.p_meas, + noise.p_prep, + p_idle=noise.p_idle, + t1=noise.t1, + t2=noise.t2, + ) + + def _surface_native_topology( patch_key: tuple[int, int, str, bool], num_rounds: int, @@ -1510,6 +1614,7 @@ def _surface_native_topology( include_idle_gates: bool, *, runtime: object | None = None, + twirl: TwirlConfig | None = None, ) -> _CachedNativeSurfaceTopology: """Build topology-only native analysis shared across noise parameters.""" import json @@ -1529,6 +1634,7 @@ def _surface_native_topology( ancilla_budget=ancilla_budget, circuit_source=circuit_source, runtime=runtime, + twirl=twirl, ) if circuit_source == "traced_qis": # Keep this surface helper aligned with DetectorErrorModel.from_guppy: @@ -1546,20 +1652,32 @@ def _surface_native_topology( detectors_json = tc.get_meta("detectors") or "[]" observables_json = tc.get_meta("observables") or "[]" + det_records = [d["records"] for d in json.loads(detectors_json)] if detectors_json else [] + obs_records = [o["records"] for o in json.loads(observables_json)] if observables_json else [] measurement_order = ( tuple(_extract_measurement_order(tc)) if _metadata_uses_record_offsets(detectors_json, observables_json) else () ) num_measurements = int(tc.get_meta("num_measurements") or str(len(measurement_order))) + pauli_frame_lookup = None + num_pauli_sites = 0 + if twirl is not None: + from pecos_rslib.qec import PauliFrameLookup + + pauli_frame_lookup = PauliFrameLookup.from_circuit(dag, det_records, obs_records) + num_pauli_sites = pauli_frame_lookup.num_pauli_sites + return _CachedNativeSurfaceTopology( dag_circuit=dag, influence_map=influence_map, + pauli_frame_lookup=pauli_frame_lookup, detectors_json=detectors_json, observables_json=observables_json, measurement_order=measurement_order, num_measurements=num_measurements, - num_detectors=len(json.loads(detectors_json)) if detectors_json else 0, - num_observables=len(json.loads(observables_json)) if observables_json else 0, + num_detectors=len(det_records), + num_observables=len(obs_records), + num_pauli_sites=num_pauli_sites, ) @@ -1571,6 +1689,8 @@ def _cached_surface_native_topology( ancilla_budget: int | None, circuit_source: Literal["abstract", "traced_qis"], include_idle_gates: bool, + *, + twirl: TwirlConfig | None = None, ) -> _CachedNativeSurfaceTopology: """Cache topology-only native analysis shared across noise parameters.""" return _surface_native_topology( @@ -1580,6 +1700,7 @@ def _cached_surface_native_topology( ancilla_budget, circuit_source, include_idle_gates, + twirl=twirl, ) @@ -1592,35 +1713,7 @@ def _dem_string_from_cached_surface_topology( """Build a DEM string from cached topology and fresh noise parameters.""" from pecos.qec import DemBuilder - noise_kwargs = { - "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, - "p1_weights": _p1_weights_dict(noise.p1_weights), - "p2_weights": _p2_weights_dict(noise.p2_weights), - } - if noise.p2_replacement_approximation is not None: - noise_kwargs["p2_replacement_approximation"] = noise.p2_replacement_approximation - - builder = DemBuilder(topology.influence_map).with_noise( - noise.p1, - noise.p2, - noise.p_meas, - noise.p_prep, - **noise_kwargs, - ) + builder = _with_noise_compat(DemBuilder(topology.influence_map), noise) if hasattr(builder, "with_exact_branch_replay_circuit"): builder = builder.with_exact_branch_replay_circuit(topology.dag_circuit) @@ -1672,6 +1765,7 @@ def _cached_surface_native_dem_string( 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, + twirl: TwirlConfig | None = None, ) -> str: """Cache native DEM strings across callers for one topology + noise tuple.""" include_idle_gates = _uses_dedicated_idle_noise( @@ -1698,6 +1792,7 @@ def _cached_surface_native_dem_string( ancilla_budget, circuit_source, include_idle_gates, + twirl=twirl, ) return _dem_string_from_cached_surface_topology( topology, @@ -1761,31 +1856,7 @@ def _build_native_sampler_from_cached_surface_topology( from pecos.qec import DemSamplerBuilder sampler_builder = ( - DemSamplerBuilder(topology.influence_map) - .with_noise( - noise.p1, - noise.p2, - noise.p_meas, - noise.p_prep, - 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, - p1_weights=_p1_weights_dict(noise.p1_weights), - p2_weights=_p2_weights_dict(noise.p2_weights), - p2_replacement_approximation=noise.p2_replacement_approximation, - ) + _with_noise_compat(DemSamplerBuilder(topology.influence_map), noise) .with_detectors_json(topology.detectors_json) .with_observables_json(topology.observables_json) ) @@ -1804,6 +1875,8 @@ def _build_native_sampler_from_cached_surface_topology( observables_json=topology.observables_json, num_detectors=topology.num_detectors, num_observables=topology.num_observables, + pauli_frame_lookup=topology.pauli_frame_lookup, + num_pauli_sites=topology.num_pauli_sites, sampling_model=sampling_model, ) @@ -1818,6 +1891,7 @@ def generate_circuit_level_dem_from_builder( ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, + twirl: TwirlConfig | None = None, ) -> str: """Generate circuit-level DEM using PECOS native fault propagation. @@ -1850,6 +1924,9 @@ def generate_circuit_level_dem_from_builder( ``circuit_source="traced_qis"``. Custom runtime topologies are not kept in PECOS's in-process topology cache because plugin objects can carry private mutable state. + twirl: Optional Pauli-frame randomization layout. Canonical Guppy + frame-output mode is normalized to the same abstract raw lookup + and DEM topology. Returns: DEM string in standard format @@ -1862,6 +1939,7 @@ def generate_circuit_level_dem_from_builder( >>> dem = generate_circuit_level_dem_from_builder(patch, num_rounds=3, noise=noise) """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) + twirl = _abstract_twirl_config(twirl) patch_key = _surface_patch_cache_key(patch) include_idle_gates = _noise_uses_dedicated_idle_noise(noise) if runtime is not None: @@ -1873,6 +1951,7 @@ def generate_circuit_level_dem_from_builder( circuit_source, include_idle_gates, runtime=runtime, + twirl=twirl, ) return _dem_string_from_cached_surface_topology( topology, @@ -1909,6 +1988,7 @@ def generate_circuit_level_dem_from_builder( 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, ) @@ -3511,7 +3591,11 @@ class NativeSampler: observables_json: JSON string with observable definitions num_detectors: Number of detectors num_observables: Number of observables + pauli_frame_lookup: Optional PECOS lookup for Pauli-twirl mask composition + num_pauli_sites: Number of Pauli-twirl mask sites sampling_model: Which native sampling backend is active + dem_string: Optional graphlike-decomposed DEM string used to build the + sampler. Populated when the ``"dem"`` sampling model is selected. """ sampler: Any @@ -3519,14 +3603,19 @@ class NativeSampler: observables_json: str num_detectors: int num_observables: int + pauli_frame_lookup: Any | None = None + num_pauli_sites: int = 0 sampling_model: Literal["dem", "influence_dem", "mnm"] = ( "dem" # "mnm" accepted for compat, mapped to "influence_dem" ) + dem_string: str | None = None def sample( self, num_shots: int, seed: int | None = None, + *, + pauli_masks: Any | None = None, ) -> tuple[np.ndarray, np.ndarray]: """Sample detection events and observable flips. @@ -3535,13 +3624,28 @@ def sample( Args: num_shots: Number of shots to sample seed: Optional random seed for reproducibility + pauli_masks: Optional integer array of shape + ``(num_shots, num_pauli_sites)`` with values 0=I, 1=X, 2=Y, + 3=Z. Requires ``build_native_sampler(..., + twirl=TwirlConfig())``. Returns: Tuple of (detection_events, observable_flips) as numpy arrays. - detection_events: shape (num_shots, num_detectors) - observable_flips: shape (num_shots, num_observables) """ - det_events, obs_flips = self.sampler.sample_batch(num_shots, seed) + if pauli_masks is None: + det_events, obs_flips = self.sampler.sample_batch(num_shots, seed) + else: + if self.pauli_frame_lookup is None: + msg = "pauli_masks require build_native_sampler(..., twirl=TwirlConfig())" + raise ValueError(msg) + det_events, obs_flips = self.sampler.sample_batch_with_pauli_masks( + num_shots, + self.pauli_frame_lookup, + pauli_masks, + seed, + ) return np.array(det_events, dtype=bool), np.array(obs_flips, dtype=bool) @@ -3552,6 +3656,7 @@ def build_native_sampler( basis: str = "Z", ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", + twirl: TwirlConfig | None = None, sampling_model: Literal[ "dem", "influence_dem", @@ -3581,6 +3686,8 @@ def build_native_sampler( TickCircuit. ``"traced_qis"`` traces the lowered ideal Selene/QIS gate stream and replays that exact gate list into a TickCircuit before native PECOS fault analysis. + twirl: Optional Pauli-frame randomization layout. Canonical runtime + frame-output mode is normalized to the same abstract raw lookup. sampling_model: Which native sampling backend to use. ``"dem"`` samples the generated decomposed DEM and is the default. ``"influence_dem"`` uses the influence-map-based DemSampler with @@ -3598,6 +3705,7 @@ def build_native_sampler( >>> detection_events, observable_flips = sampler.sample(num_shots=10000) """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) + twirl = _abstract_twirl_config(twirl) basis = basis.upper() patch_key = _surface_patch_cache_key(patch) topology = _cached_surface_native_topology( @@ -3607,6 +3715,7 @@ def build_native_sampler( ancilla_budget, circuit_source, _noise_uses_dedicated_idle_noise(noise), + twirl=twirl, ) if sampling_model == "dem": dem_str = _cached_surface_native_dem_string( @@ -3638,6 +3747,7 @@ def build_native_sampler( 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, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() return NativeSampler( @@ -3646,10 +3756,287 @@ def build_native_sampler( observables_json=topology.observables_json, num_detectors=topology.num_detectors, num_observables=topology.num_observables, + pauli_frame_lookup=topology.pauli_frame_lookup, + num_pauli_sites=topology.num_pauli_sites, sampling_model=sampling_model, + dem_string=dem_str, ) return _build_native_sampler_from_cached_surface_topology( topology, noise, sampling_model=sampling_model, ) + + +def build_native_sampler_from_dem( + decomposed_dem: str, + patch: SurfacePatch, + num_rounds: int, + basis: str = "Z", + *, + ancilla_budget: int | None = None, + circuit_source: Literal["abstract", "traced_qis"] = "abstract", + twirl: TwirlConfig | None = None, +) -> NativeSampler: + """Build a native sampler from a caller-supplied decomposed DEM string. + + The supplied DEM is parsed directly and stored verbatim on the returned + sampler. Surface topology metadata and optional Pauli-frame lookup are + taken from the same abstract/traced surface circuit family as + :func:`build_native_sampler`. + """ + ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) + twirl = _abstract_twirl_config(twirl) + basis = basis.upper() + patch_key = _surface_patch_cache_key(patch) + topology = _cached_surface_native_topology( + patch_key, + num_rounds, + basis, + ancilla_budget, + circuit_source, + False, + twirl=twirl, + ) + sampler = _cached_parsed_dem(decomposed_dem).to_dem_sampler() + return NativeSampler( + sampler=sampler, + detectors_json=topology.detectors_json, + observables_json=topology.observables_json, + num_detectors=topology.num_detectors, + num_observables=topology.num_observables, + pauli_frame_lookup=topology.pauli_frame_lookup, + num_pauli_sites=topology.num_pauli_sites, + sampling_model="dem", + dem_string=decomposed_dem, + ) + + +def decode_native_samples( + sampler: NativeSampler, + num_shots: int, + *, + dem: str | None = None, + decoder_type: str = "pymatching", + seed: int | None = None, + pauli_masks: Any | None = None, +) -> int: + """Sample, optionally apply a known Pauli-frame mask, de-mask, and decode.""" + from pecos_rslib.qec import SampleBatch + + dem_str = dem if dem is not None else sampler.dem_string + if dem_str is None: + msg = ( + "decode_native_samples requires a DEM string; " + "pass dem= or build the sampler with sampling_model='dem'" + ) + raise ValueError(msg) + + det_events, obs_flips = sampler.sample(num_shots, seed=seed, pauli_masks=pauli_masks) + + if pauli_masks is not None: + if sampler.pauli_frame_lookup is None: + msg = "pauli_masks require build_native_sampler(..., twirl=TwirlConfig())" + raise ValueError(msg) + det_xor, obs_xor = sampler.pauli_frame_lookup.compute_mask_xor(pauli_masks) + det_events = np.asarray(det_events, dtype=bool) ^ np.asarray(det_xor, dtype=bool) + obs_flips = np.asarray(obs_flips, dtype=bool) ^ np.asarray(obs_xor, dtype=bool) + + det_list = np.asarray(det_events, dtype=np.uint8).tolist() + obs_arr = np.asarray(obs_flips, dtype=np.uint64) + if obs_arr.ndim != 2: + msg = f"expected obs_flips to be 2-D, got shape {obs_arr.shape}" + raise ValueError(msg) + weights = (1 << np.arange(obs_arr.shape[1], dtype=np.uint64)).astype(np.uint64) + obs_masks = (obs_arr * weights).sum(axis=1).astype(np.uint64).tolist() + batch = SampleBatch(det_list, obs_masks) + return batch.decode_count(dem_str, decoder_type) + + +def demask_pauli_frame_records( + pauli_frame_lookup: Any, + raw_events: Any, + raw_obs: Any, + pauli_masks: Any, +) -> tuple[NDArray[np.bool_], NDArray[np.bool_]]: + """Cancel known Pauli-frame mask flips from detector/observable records.""" + events_arr = np.asarray(raw_events, dtype=bool) + obs_arr = np.asarray(raw_obs, dtype=bool) + masks_arr = np.asarray(pauli_masks, dtype=np.int64) + + if events_arr.ndim != 2: + msg = ( + f"raw_events must be 2-D of shape (num_shots, num_detectors); " + f"got ndim={events_arr.ndim}, shape={events_arr.shape}" + ) + 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}" + ) + raise ValueError(msg) + if masks_arr.ndim != 2: + msg = ( + f"pauli_masks must be 2-D of shape (num_shots, num_pauli_sites); " + f"got ndim={masks_arr.ndim}, shape={masks_arr.shape}" + ) + raise ValueError(msg) + if events_arr.shape[0] != obs_arr.shape[0] or events_arr.shape[0] != masks_arr.shape[0]: + msg = ( + "raw_events, raw_obs, and pauli_masks must have the same " + f"num_shots; got {events_arr.shape[0]}, {obs_arr.shape[0]}, " + f"{masks_arr.shape[0]}" + ) + raise ValueError(msg) + + expected_det = pauli_frame_lookup.num_detectors + expected_obs = pauli_frame_lookup.num_observables + expected_sites = pauli_frame_lookup.num_pauli_sites + if events_arr.shape[1] != expected_det: + msg = ( + f"raw_events width {events_arr.shape[1]} != " + f"pauli_frame_lookup.num_detectors {expected_det}" + ) + raise ValueError(msg) + if obs_arr.shape[1] != expected_obs: + msg = ( + f"raw_obs width {obs_arr.shape[1]} != " + f"pauli_frame_lookup.num_observables {expected_obs}" + ) + raise ValueError(msg) + if masks_arr.shape[1] != expected_sites: + msg = ( + f"pauli_masks width {masks_arr.shape[1]} != " + f"pauli_frame_lookup.num_pauli_sites {expected_sites}" + ) + raise ValueError(msg) + + det_xor, obs_xor = pauli_frame_lookup.compute_mask_xor(masks_arr) + return ( + events_arr ^ np.asarray(det_xor, dtype=bool), + obs_arr ^ np.asarray(obs_xor, dtype=bool), + ) + + +def _extract_pauli_masks_from_results( + results: dict[str, Any], + *, + num_rounds: int, + num_data: int, + num_shots: int, +) -> NDArray[np.uint8]: + """Reconstruct per-shot Pauli-mask codes from Guppy result tags.""" + from pecos.qec.surface._twirl_sites import ( + mask_col_for, + num_pauli_sites, + pauli_mask_round_tag, + site_idx_for_round, + ) + + n_twirl = max(0, num_rounds - 1) + bits_per_round = 2 * num_data + out = np.zeros((num_shots, num_pauli_sites(num_rounds, num_data)), dtype=np.uint8) + + for r in range(n_twirl): + tag = pauli_mask_round_tag(r) + if tag not in results: + msg = ( + f"missing Pauli-mask result tag {tag!r} (expected {n_twirl} round " + f"tags for num_rounds={num_rounds}); did the program run with twirl enabled?" + ) + raise ValueError(msg) + per_round = results[tag] + if len(per_round) != num_shots: + msg = ( + f"Pauli-mask tag {tag!r}: got {len(per_round)} shots, " + f"expected {num_shots} shots" + ) + raise ValueError(msg) + + bits = np.asarray(per_round, dtype=np.uint8) + if bits.ndim != 2 or bits.shape[1] != bits_per_round: + msg = ( + f"Pauli-mask tag {tag!r} array has shape {bits.shape}, expected " + f"({num_shots}, {bits_per_round}) = (num_shots, 2*num_data)" + ) + raise ValueError(msg) + + lo = bits[:, 0::2] + hi = bits[:, 1::2] + packed = (lo + (hi << 1)).astype(np.uint8) + + site = site_idx_for_round(r) + for q in range(num_data): + out[:, mask_col_for(site, q, num_data)] = packed[:, q] + + return out + + +def sample_pauli_masks_from_guppy( + patch: SurfacePatch, + *, + num_rounds: int, + num_shots: int, + basis: str, + twirl: TwirlConfig, + rng: Any, + ancilla_budget: int | None = None, +) -> NDArray[np.uint8]: + """Run the Guppy memory program with twirling and harvest mask columns.""" + from selene_sim import SimpleRuntime, Stim, build + + from pecos.compilation_pipeline import compile_guppy_to_hugr + from pecos.guppy.surface import generate_memory_experiment, get_num_qubits + + if twirl is None or rng is None: + msg = "sample_pauli_masks_from_guppy requires both twirl and rng to be set" + raise ValueError(msg) + twirl._validate_runtime_supported() + if num_rounds < 1: + msg = f"num_rounds must be >= 1, got {num_rounds}" + raise ValueError(msg) + + num_data = patch.geometry.num_data + + fn = generate_memory_experiment( + patch, + num_rounds=num_rounds, + basis=basis, + twirl=twirl, + rng=rng, + ancilla_budget=ancilla_budget, + ) + + hugr_bytes = compile_guppy_to_hugr(fn) + instance = build( + hugr_bytes, + name=f"pauli_mask_d{patch.geometry.dx}_r{num_rounds}_{basis.lower()}", + ) + num_qubits = get_num_qubits( + patch=patch, + ancilla_budget=ancilla_budget, + twirl=twirl, + ) + + results: dict[str, list[list[Any]]] = {} + for shot_results in instance.run_shots( + simulator=Stim(random_seed=int(rng.seed)), + n_qubits=num_qubits, + n_shots=num_shots, + runtime=SimpleRuntime(), + n_processes=1, + ): + for name, values in shot_results: + try: + shot_value = list(values) + except TypeError: + shot_value = [values] + results.setdefault(name, []).append(shot_value) + + return _extract_pauli_masks_from_results( + results, + num_rounds=num_rounds, + num_data=num_data, + num_shots=num_shots, + ) diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py new file mode 100644 index 000000000..94f3a6983 --- /dev/null +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import pytest + +pytest.importorskip("guppylang") + +from pecos.guppy.surface import ( # noqa: E402 + _guppy_module_cache_key, + generate_guppy_source, + generate_memory_experiment, +) +from pecos.qec.surface import GuppyRngMaskConfig, TwirlConfig # noqa: E402 +from pecos.qec.surface._twirl_sites import pauli_mask_round_tag # noqa: E402 +from pecos.qec.surface.patch import SurfacePatch # noqa: E402 + + +@pytest.fixture +def patch() -> SurfacePatch: + return SurfacePatch.create(distance=3) + + +def test_no_twirl_source_has_no_rng_or_mask_tags(patch: SurfacePatch) -> None: + src = generate_guppy_source(patch) + + assert "RNG(" not in src + assert "random_int_bounded" not in src + assert "seeded_pcg32_with_quantum_entropy(" not in src + assert 'result("pauli_mask' not in src + assert "for _t in range(comptime(num_rounds)):" in src + assert 'result("final"' in src + + +def test_twirl_source_unrolls_rng_masks_and_runtime_paulis(patch: SurfacePatch) -> None: + src = generate_guppy_source( + patch, + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=42), + num_rounds=3, + ) + + assert "def _pcg32_next4(state: nat, inc: nat) -> tuple[nat, int]:" in src + assert "def seeded_pcg32_with_quantum_entropy(seed: int) -> tuple[nat, nat]:" in src + assert "entropy_q = qubit()" in src + assert "if measure(entropy_q):" in src + assert src.count("rng_state, rng_inc = seeded_pcg32_with_quantum_entropy(42)") == 2 + assert src.count('result("frame_mode:raw", True)') == 2 + assert "rng_state, m_0_0 = _pcg32_next4(rng_state, rng_inc)" in src + assert "if m_0_0 == 1:" in src + assert " x(surf.data[0])" in src + assert "if m_0_0 == 2:" in src + assert " y(surf.data[0])" in src + assert "if m_0_0 == 3:" in src + assert " z(surf.data[0])" in src + + for r in range(2): + assert src.count(f'result("{pauli_mask_round_tag(r)}"') == 2 + assert src.count("# === Round 2 (final, no twirl after) ===") == 2 + + +def test_canonical_frame_output_emits_raw_sibling_tags(patch: SurfacePatch) -> None: + src = generate_guppy_source( + patch, + twirl=TwirlConfig(frame_output="canonical"), + rng=GuppyRngMaskConfig(seed=7), + num_rounds=2, + ) + + assert 'result("frame_mode:canonical", True)' in src + assert "fx_0 = False" in src + assert "fz_0 = False" in src + assert "sx0_raw = measure(ax0)" in src + assert "sx0 = sx0_raw != sx0_flip" in src + assert 'result("raw:sx0:bit:0", sx0_raw)' in src + assert 'result("raw:final", final_raw)' in src + assert 'result("final", array(final_0' in src + + +def test_twirl_validation_requires_rng_and_num_rounds(patch: SurfacePatch) -> None: + with pytest.raises(ValueError, match="twirl and rng must be supplied together"): + generate_guppy_source(patch, twirl=TwirlConfig(), num_rounds=2) + with pytest.raises(ValueError, match="twirl and rng must be supplied together"): + generate_guppy_source(patch, rng=GuppyRngMaskConfig(seed=42)) + with pytest.raises(ValueError, match="num_rounds is required when twirl is supplied"): + generate_guppy_source( + patch, + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=42), + ) + + +def test_twirled_cache_key_includes_seed_rounds_and_frame_mode(patch: SurfacePatch) -> None: + raw = _guppy_module_cache_key( + patch, + effective_budget=10, + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=1), + num_rounds=2, + ) + raw_seed2 = _guppy_module_cache_key( + patch, + effective_budget=10, + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=2), + num_rounds=2, + ) + canonical = _guppy_module_cache_key( + patch, + effective_budget=10, + twirl=TwirlConfig(frame_output="canonical"), + rng=GuppyRngMaskConfig(seed=1), + num_rounds=2, + ) + round3 = _guppy_module_cache_key( + patch, + effective_budget=10, + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=1), + num_rounds=3, + ) + + assert raw != _guppy_module_cache_key(patch, effective_budget=10) + assert raw != raw_seed2 + assert raw != canonical + assert raw != round3 + assert "s1" in raw + assert "s2" in raw_seed2 + assert "frame-raw" in raw + assert "frame-canonical" in canonical + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +@pytest.mark.parametrize("frame_output", ["raw", "canonical"]) +def test_twirled_memory_experiment_compiles( + patch: SurfacePatch, + basis: str, + frame_output: str, +) -> None: + fn = generate_memory_experiment( + patch, + num_rounds=2, + basis=basis, + twirl=TwirlConfig(frame_output=frame_output), + rng=GuppyRngMaskConfig(seed=7), + ) + assert fn is not None 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 new file mode 100644 index 000000000..e49b9785a --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from pecos.qec.surface import ( + GuppyRngMaskConfig, + NoiseModel, + SurfacePatch, + TwirlConfig, + build_memory_circuit, + build_native_sampler, + demask_pauli_frame_records, + extract_detection_events_and_observables, + sample_pauli_masks_from_guppy, +) +from pecos.qec.surface._twirl_sites import num_pauli_sites +from pecos.qec.surface.decode import _extract_pauli_masks_from_results + +pytest.importorskip("guppylang") +pytest.importorskip("selene_sim") + + +@pytest.fixture +def patch_d3() -> SurfacePatch: + return SurfacePatch.create(distance=3) + + +def _bool_array_from_indices(rows: list[list[int]], width: int) -> np.ndarray: + out = np.zeros((len(rows), width), dtype=bool) + for shot, indices in enumerate(rows): + for idx in indices: + out[shot, idx] = True + return out + + +def _canonicalize_raw_rows_from_masks( + patch: SurfacePatch, + *, + basis: str, + num_rounds: int, + raw_rows: list[list[int]], + masks: np.ndarray, +) -> list[list[int]]: + """Apply the same Pauli-frame rule as the generated canonical tracker.""" + num_data = patch.geometry.num_data + init_count = ( + len(patch.geometry.x_stabilizers) + if basis.upper() == "Z" + else len(patch.geometry.z_stabilizers) + ) + expected_width = init_count + num_rounds * patch.geometry.num_ancilla + num_data + canonical_rows: list[list[int]] = [] + + for shot, raw_row in enumerate(raw_rows): + assert len(raw_row) == expected_width + fx = [False] * num_data + fz = [False] * num_data + row: list[int] = [] + offset = 0 + + for _ in range(init_count): + row.append(int(raw_row[offset])) + offset += 1 + + for r in range(num_rounds): + for stab in patch.geometry.x_stabilizers: + flip = False + for q in stab.data_qubits: + flip ^= fz[q] + row.append(int(bool(raw_row[offset]) ^ flip)) + offset += 1 + for stab in patch.geometry.z_stabilizers: + flip = False + for q in stab.data_qubits: + flip ^= fx[q] + row.append(int(bool(raw_row[offset]) ^ flip)) + offset += 1 + if r < num_rounds - 1: + site_offset = r * num_data + for q in range(num_data): + code = int(masks[shot, site_offset + q]) + fx[q] ^= code in (1, 2) + fz[q] ^= code in (2, 3) + + final_flip = fx if basis.upper() == "Z" else fz + for q in range(num_data): + row.append(int(bool(raw_row[offset]) ^ final_flip[q])) + offset += 1 + + assert offset == expected_width + canonical_rows.append(row) + + return canonical_rows + + +def _run_twirled_guppy_rows_masks_and_raw( + patch: SurfacePatch, + *, + basis: str, + num_rounds: int, + num_shots: int, + rng: GuppyRngMaskConfig, + twirl: TwirlConfig, +) -> tuple[list[list[int]], np.ndarray, list[list[int]], list[str]]: + from selene_sim import SimpleRuntime, Stim, build + + from pecos.compilation_pipeline import compile_guppy_to_hugr + from pecos.guppy.surface import generate_memory_experiment, get_num_qubits + + fn = generate_memory_experiment( + patch, + num_rounds=num_rounds, + basis=basis, + twirl=twirl, + rng=rng, + ) + hugr_bytes = compile_guppy_to_hugr(fn) + instance = build( + hugr_bytes, + name=f"pauli_twirl_null_d{patch.geometry.dx}_r{num_rounds}_{basis.lower()}", + ) + + mask_results: dict[str, list[list[int]]] = {} + measurement_rows: list[list[int]] = [] + raw_rows: list[list[int]] = [] + frame_modes: list[str] = [] + for shot_results in instance.run_shots( + simulator=Stim(random_seed=int(rng.seed)), + n_qubits=get_num_qubits(patch=patch, twirl=twirl), + n_shots=num_shots, + runtime=SimpleRuntime(), + n_processes=1, + ): + row: list[int] = [] + raw_row: list[int] = [] + saw_final = False + saw_raw_final = False + frame_mode: str | None = None + + for name, values in shot_results: + try: + shot_value = list(values) + except TypeError: + shot_value = [values] + + if name.startswith("pauli_mask:round:"): + mask_results.setdefault(name, []).append([int(v) for v in shot_value]) + elif name.startswith("frame_mode:"): + assert len(shot_value) == 1 + assert bool(shot_value[0]) + frame_mode = name.removeprefix("frame_mode:") + elif name.startswith("raw:") and ":bit:" in name: + assert len(shot_value) == 1 + raw_row.append(int(shot_value[0])) + elif name == "raw:final": + raw_row.extend(int(v) for v in shot_value) + saw_raw_final = True + elif ":meas:" in name: + assert len(shot_value) == 1 + bit = int(shot_value[0]) + row.append(bit) + if twirl.frame_output == "canonical" and ":init:meas:" in name: + raw_row.append(bit) + elif name == "final": + row.extend(int(v) for v in shot_value) + saw_final = True + + assert saw_final + assert frame_mode == twirl.frame_output + if twirl.frame_output == "canonical": + assert saw_raw_final + measurement_rows.append(row) + raw_rows.append(raw_row) + frame_modes.append(frame_mode) + + masks = _extract_pauli_masks_from_results( + mask_results, + num_rounds=num_rounds, + num_data=patch.geometry.num_data, + num_shots=num_shots, + ) + return measurement_rows, masks, raw_rows, frame_modes + + +def _run_twirled_guppy_measurement_rows_and_masks( + patch: SurfacePatch, + *, + basis: str, + num_rounds: int, + num_shots: int, + rng: GuppyRngMaskConfig, +) -> tuple[list[list[int]], np.ndarray]: + rows, masks, _, _ = _run_twirled_guppy_rows_masks_and_raw( + patch, + basis=basis, + num_rounds=num_rounds, + num_shots=num_shots, + rng=rng, + twirl=TwirlConfig(), + ) + return rows, masks + + +def test_runtime_twirl_masks_vary_across_shots(patch_d3: SurfacePatch) -> None: + masks = sample_pauli_masks_from_guppy( + patch_d3, + num_rounds=3, + num_shots=6, + basis="Z", + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=11), + ) + + unique_rows = np.unique(masks, axis=0) + assert unique_rows.shape[0] >= 2 + + +def test_same_seed_is_reproducible(patch_d3: SurfacePatch) -> None: + kwargs = dict( + num_rounds=3, + num_shots=6, + basis="Z", + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=42), + ) + + masks_a = sample_pauli_masks_from_guppy(patch_d3, **kwargs) + masks_b = sample_pauli_masks_from_guppy(patch_d3, **kwargs) + + np.testing.assert_array_equal(masks_a, masks_b) + + +def test_different_seeds_differ(patch_d3: SurfacePatch) -> None: + common = dict( + num_rounds=3, + num_shots=8, + basis="Z", + twirl=TwirlConfig(), + ) + + masks_seed1 = sample_pauli_masks_from_guppy( + patch_d3, + rng=GuppyRngMaskConfig(seed=1), + **common, + ) + masks_seed2 = sample_pauli_masks_from_guppy( + patch_d3, + rng=GuppyRngMaskConfig(seed=2), + **common, + ) + + assert not np.array_equal(masks_seed1, masks_seed2) + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_runtime_twirled_theta0_demask_null( + patch_d3: SurfacePatch, + basis: str, +) -> None: + num_rounds = 2 + num_shots = 6 + measurement_rows, masks = _run_twirled_guppy_measurement_rows_and_masks( + patch_d3, + basis=basis, + num_rounds=num_rounds, + num_shots=num_shots, + rng=GuppyRngMaskConfig(seed=12345), + ) + assert masks.any() + + tick_circuit = build_memory_circuit( + patch=patch_d3, + rounds=num_rounds, + basis=basis, + twirl=TwirlConfig(), + ) + sampler = build_native_sampler( + patch_d3, + num_rounds=num_rounds, + noise=NoiseModel(), + basis=basis, + twirl=TwirlConfig(), + ) + assert sampler.pauli_frame_lookup is not None + + events_per_shot, obs_per_shot = extract_detection_events_and_observables( + tick_circuit, + measurement_rows, + ) + raw_events = _bool_array_from_indices(events_per_shot, sampler.num_detectors) + raw_obs = _bool_array_from_indices(obs_per_shot, sampler.num_observables) + + events, observables = demask_pauli_frame_records( + sampler.pauli_frame_lookup, + raw_events, + raw_obs, + masks, + ) + + assert not events.any() + assert not observables.any() + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_runtime_canonical_frame_output_theta0_null_and_raw_equivalence( + patch_d3: SurfacePatch, + basis: str, +) -> None: + num_rounds = 2 + num_shots = 6 + twirl = TwirlConfig(frame_output="canonical") + measurement_rows, masks, raw_rows, frame_modes = _run_twirled_guppy_rows_masks_and_raw( + patch_d3, + basis=basis, + num_rounds=num_rounds, + num_shots=num_shots, + rng=GuppyRngMaskConfig(seed=12345), + twirl=twirl, + ) + + assert frame_modes == ["canonical"] * num_shots + assert masks.any() + assert raw_rows + + expected_rows = _canonicalize_raw_rows_from_masks( + patch_d3, + basis=basis, + num_rounds=num_rounds, + raw_rows=raw_rows, + masks=masks, + ) + assert measurement_rows == expected_rows + + tick_circuit = build_memory_circuit( + patch=patch_d3, + rounds=num_rounds, + basis=basis, + twirl=TwirlConfig(), + ) + sampler = build_native_sampler( + patch_d3, + num_rounds=num_rounds, + noise=NoiseModel(), + basis=basis, + twirl=TwirlConfig(), + ) + assert sampler.pauli_frame_lookup is not None + + raw_events_per_shot, raw_obs_per_shot = extract_detection_events_and_observables( + tick_circuit, + raw_rows, + ) + raw_events = _bool_array_from_indices(raw_events_per_shot, sampler.num_detectors) + raw_obs = _bool_array_from_indices(raw_obs_per_shot, sampler.num_observables) + assert raw_events.any() or raw_obs.any() + + demasked_events, demasked_obs = demask_pauli_frame_records( + sampler.pauli_frame_lookup, + raw_events, + raw_obs, + masks, + ) + + events_per_shot, obs_per_shot = extract_detection_events_and_observables( + tick_circuit, + measurement_rows, + ) + events = _bool_array_from_indices(events_per_shot, sampler.num_detectors) + observables = _bool_array_from_indices(obs_per_shot, sampler.num_observables) + + np.testing.assert_array_equal(events, demasked_events) + np.testing.assert_array_equal(observables, demasked_obs) + assert not events.any() + assert not observables.any() + + +def test_d5_compile_smoke(patch_d3: SurfacePatch) -> None: + del patch_d3 + patch_d5 = SurfacePatch.create(distance=5) + masks = sample_pauli_masks_from_guppy( + patch_d5, + num_rounds=3, + num_shots=2, + basis="Z", + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=11), + ) + + num_data = patch_d5.geometry.num_data + assert masks.shape == (2, num_pauli_sites(3, num_data)) + assert masks.dtype == np.uint8 + assert masks.min() >= 0 and masks.max() <= 3 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 new file mode 100644 index 000000000..9bfa05f28 --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from pecos.qec.surface import ( + GuppyRngMaskConfig, + NoiseModel, + SurfacePatch, + TwirlConfig, + build_memory_circuit, + build_native_sampler, + demask_pauli_frame_records, +) +from pecos.qec.surface._twirl_sites import ( + mask_col_for, + num_pauli_sites, + pauli_mask_round_tag, +) +from pecos.qec.surface.decode import _extract_pauli_masks_from_results + + +def test_extract_pauli_masks_packs_bits_in_row_major_site_qubit_order() -> None: + results = { + pauli_mask_round_tag(0): [[1, 0, 1, 1]], + pauli_mask_round_tag(1): [[0, 1, 0, 0]], + } + + mask = _extract_pauli_masks_from_results( + results, + num_rounds=3, + num_data=2, + num_shots=1, + ) + + assert mask.dtype == np.uint8 + assert mask.tolist() == [[1, 3, 2, 0]] + assert mask[0, mask_col_for(0, 0, 2)] == 1 + assert mask[0, mask_col_for(0, 1, 2)] == 3 + assert mask[0, mask_col_for(1, 0, 2)] == 2 + assert mask[0, mask_col_for(1, 1, 2)] == 0 + + +def test_extract_pauli_masks_rejects_missing_or_misshaped_tags() -> None: + with pytest.raises(ValueError, match="missing Pauli-mask result tag"): + _extract_pauli_masks_from_results({}, num_rounds=2, num_data=1, num_shots=1) + + with pytest.raises(ValueError, match=r"expected \(1, 4\)"): + _extract_pauli_masks_from_results( + {pauli_mask_round_tag(0): [[1, 0, 1]]}, + num_rounds=2, + num_data=2, + num_shots=1, + ) + + +def test_demask_helper_cancels_known_pauli_frame_xor() -> None: + patch = SurfacePatch.create(distance=3) + sampler = build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=TwirlConfig(), + ) + assert sampler.pauli_frame_lookup is not None + + masks = np.zeros((2, sampler.num_pauli_sites), dtype=np.uint8) + masks[0, 0] = 1 + masks[1, 0] = 3 + + det_xor, obs_xor = sampler.pauli_frame_lookup.compute_mask_xor(masks.astype(np.int64)) + physical_events = np.zeros((2, sampler.num_detectors), dtype=bool) + physical_obs = np.zeros((2, sampler.num_observables), dtype=bool) + physical_events[0, 0] = True + physical_obs[1, 0] = True + + raw_events = physical_events ^ np.asarray(det_xor, dtype=bool) + raw_obs = physical_obs ^ np.asarray(obs_xor, dtype=bool) + + events, observables = demask_pauli_frame_records( + sampler.pauli_frame_lookup, + raw_events, + raw_obs, + masks, + ) + + np.testing.assert_array_equal(events, physical_events) + np.testing.assert_array_equal(observables, physical_obs) + + +def test_canonical_frame_output_reuses_raw_abstract_sampler_topology() -> None: + patch = SurfacePatch.create(distance=3) + raw = build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=TwirlConfig(), + ) + canonical = build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=TwirlConfig(frame_output="canonical"), + ) + + assert canonical.num_detectors == raw.num_detectors + assert canonical.num_observables == raw.num_observables + assert canonical.num_pauli_sites == raw.num_pauli_sites + assert canonical.pauli_frame_lookup is raw.pauli_frame_lookup + + +def test_surface_traced_qis_rejects_twirl_with_semantic_message() -> None: + patch = SurfacePatch.create(distance=3) + + with pytest.raises(ValueError) as exc_info: + build_memory_circuit( + patch=patch, + rounds=2, + basis="Z", + circuit_source="traced_qis", + twirl=TwirlConfig(), + ) + + msg = str(exc_info.value) + assert "one concrete mask realization" in msg + assert "result-id provenance" in msg + assert "circuit_source='abstract'" in msg + + +@pytest.mark.parametrize("distance", [3, 5]) +@pytest.mark.parametrize("num_rounds", [2, 3]) +def test_tracked_pauli_label_order_matches_mask_col_for( + distance: int, + num_rounds: int, +) -> None: + patch = SurfacePatch.create(distance=distance) + num_data = patch.geometry.num_data + tc = build_memory_circuit( + patch=patch, + rounds=num_rounds, + basis="Z", + twirl=TwirlConfig(), + ) + tracked = [a for a in tc.annotations() if a["kind"] == "tracked_pauli"] + + assert len(tracked) == 3 * num_pauli_sites(num_rounds, num_data) + for site in range(num_rounds - 1): + for q in range(num_data): + col = mask_col_for(site, q, num_data) + base = 3 * col + for offset, kind in enumerate(("X", "Y", "Z")): + assert tracked[base + offset]["label"] == f"twirl_s{site}_q{q}_{kind}" + + +def test_raw_twirled_guppy_trace_result_provenance_ignores_sideband_tags() -> None: + pytest.importorskip("guppylang") + pytest.importorskip("selene_sim") + + from pecos.guppy import get_num_qubits + from pecos.guppy.surface import generate_memory_experiment + from pecos.qec.surface.decode import ( + _index_surface_result_trace_ids, + trace_guppy_into_tick_circuit_with_result_traces, + ) + + patch = SurfacePatch.create(distance=3) + num_rounds = 2 + program = generate_memory_experiment( + patch, + num_rounds=num_rounds, + basis="Z", + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=0), + ) + _, result_traces = trace_guppy_into_tick_circuit_with_result_traces( + program, + get_num_qubits(patch=patch, twirl=TwirlConfig()), + seed=0, + ) + + sideband_names = {trace.get("name") for trace in result_traces if isinstance(trace.get("name"), str)} + assert any(str(name).startswith("pauli_mask:") for name in sideband_names) + assert "frame_mode:raw" in sideband_names + + scalar_trace_ids, array_trace_ids = _index_surface_result_trace_ids(result_traces) + indexed_names = set(scalar_trace_ids) | set(array_trace_ids) + assert any(name.startswith("sx") and ":meas:" in name for name in indexed_names) + assert "final" in indexed_names + assert not any(name.startswith(("pauli_mask:", "frame_mode:", "raw:")) for name in indexed_names) From b8c37766cf370c0ccfa200e6ada1bd91465e5eee Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 00:30:45 -0600 Subject: [PATCH 059/150] Address Pauli twirl review findings --- .../src/pecos/qec/surface/decode.py | 28 ++++- .../qec/surface/test_pauli_twirl_handoff.py | 100 +++++++++++++++++- 2 files changed, 122 insertions(+), 6 deletions(-) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index ac954498c..2ea7e21ed 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -299,6 +299,7 @@ def _abstract_twirl_config(twirl: TwirlConfig | None) -> TwirlConfig | None: """Drop runtime-only Guppy record-framing fields before DEM caching.""" if twirl is None: return None + twirl._validate_runtime_supported() return replace(twirl, frame_output="raw") @@ -1349,6 +1350,9 @@ def _build_surface_tick_circuit_for_native_model( """Build the TickCircuit used by the native DEM and sampler paths.""" from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch + if twirl is not None: + twirl._validate_runtime_supported() + abstract_tc = generate_tick_circuit_from_patch( patch, num_rounds, @@ -1393,6 +1397,18 @@ def _build_surface_tick_circuit_for_native_model( return traced_tc +def _pauli_masks_as_int64(pauli_masks: Any) -> NDArray[np.int64]: + """Return Pauli-mask input in the integer dtype accepted by Rust bindings.""" + masks_arr = np.asarray(pauli_masks) + if not np.issubdtype(masks_arr.dtype, np.integer): + msg = ( + "pauli_masks must be an integer array with values " + "0=I, 1=X, 2=Y, 3=Z" + ) + raise TypeError(msg) + return np.asarray(masks_arr, dtype=np.int64) + + def build_memory_circuit( *, rounds: int, @@ -3640,10 +3656,11 @@ def sample( if self.pauli_frame_lookup is None: msg = "pauli_masks require build_native_sampler(..., twirl=TwirlConfig())" raise ValueError(msg) + masks_arr = _pauli_masks_as_int64(pauli_masks) det_events, obs_flips = self.sampler.sample_batch_with_pauli_masks( num_shots, self.pauli_frame_lookup, - pauli_masks, + masks_arr, seed, ) return np.array(det_events, dtype=bool), np.array(obs_flips, dtype=bool) @@ -3832,13 +3849,14 @@ def decode_native_samples( ) raise ValueError(msg) - det_events, obs_flips = sampler.sample(num_shots, seed=seed, pauli_masks=pauli_masks) + masks_arr = _pauli_masks_as_int64(pauli_masks) if pauli_masks is not None else None + det_events, obs_flips = sampler.sample(num_shots, seed=seed, pauli_masks=masks_arr) - if pauli_masks is not None: + if masks_arr is not None: if sampler.pauli_frame_lookup is None: msg = "pauli_masks require build_native_sampler(..., twirl=TwirlConfig())" raise ValueError(msg) - det_xor, obs_xor = sampler.pauli_frame_lookup.compute_mask_xor(pauli_masks) + det_xor, obs_xor = sampler.pauli_frame_lookup.compute_mask_xor(masks_arr) det_events = np.asarray(det_events, dtype=bool) ^ np.asarray(det_xor, dtype=bool) obs_flips = np.asarray(obs_flips, dtype=bool) ^ np.asarray(obs_xor, dtype=bool) @@ -3862,7 +3880,7 @@ def demask_pauli_frame_records( """Cancel known Pauli-frame mask flips from detector/observable records.""" events_arr = np.asarray(raw_events, dtype=bool) obs_arr = np.asarray(raw_obs, dtype=bool) - masks_arr = np.asarray(pauli_masks, dtype=np.int64) + masks_arr = _pauli_masks_as_int64(pauli_masks) if events_arr.ndim != 2: msg = ( 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 9bfa05f28..d7ee9560e 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 @@ -10,6 +10,7 @@ TwirlConfig, build_memory_circuit, build_native_sampler, + decode_native_samples, demask_pauli_frame_records, ) from pecos.qec.surface._twirl_sites import ( @@ -17,7 +18,10 @@ num_pauli_sites, pauli_mask_round_tag, ) -from pecos.qec.surface.decode import _extract_pauli_masks_from_results +from pecos.qec.surface.decode import ( + _extract_pauli_masks_from_results, + generate_circuit_level_dem_from_builder, +) def test_extract_pauli_masks_packs_bits_in_row_major_site_qubit_order() -> None: @@ -89,6 +93,27 @@ def test_demask_helper_cancels_known_pauli_frame_xor() -> None: np.testing.assert_array_equal(observables, physical_obs) +def test_native_sampler_accepts_harvested_uint8_pauli_masks() -> None: + patch = SurfacePatch.create(distance=3) + sampler = build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=TwirlConfig(), + ) + assert sampler.num_pauli_sites > 0 + + masks = np.zeros((4, sampler.num_pauli_sites), dtype=np.uint8) + masks[:, 0] = [0, 1, 2, 3] + + det_events, obs_flips = sampler.sample(4, seed=123, pauli_masks=masks) + assert det_events.shape == (4, sampler.num_detectors) + assert obs_flips.shape == (4, sampler.num_observables) + + assert decode_native_samples(sampler, 4, seed=123, pauli_masks=masks) == 0 + + def test_canonical_frame_output_reuses_raw_abstract_sampler_topology() -> None: patch = SurfacePatch.create(distance=3) raw = build_native_sampler( @@ -112,6 +137,79 @@ def test_canonical_frame_output_reuses_raw_abstract_sampler_topology() -> None: assert canonical.pauli_frame_lookup is raw.pauli_frame_lookup +@pytest.mark.parametrize( + ("field", "value"), + [ + ("scheme", "clifford"), + ("site_schedule", "per_two_qubit_gate"), + ("result_encoding", "bogus"), + ("frame_output", "physical"), + ], +) +def test_abstract_twirl_builders_reject_unsupported_config( + field: str, + value: str, +) -> None: + patch = SurfacePatch.create(distance=3) + kwargs = {field: value} + twirl = TwirlConfig(**kwargs) # type: ignore[arg-type] + + with pytest.raises(ValueError, match=field): + build_memory_circuit( + patch=patch, + rounds=2, + basis="Z", + twirl=twirl, + ) + + with pytest.raises(ValueError, match=field): + build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=twirl, + ) + + with pytest.raises(ValueError, match=field): + generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=twirl, + ) + + +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) + twirl = TwirlConfig() + + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + basis="Z", + decompose_errors=True, + twirl=twirl, + ) + assert "error(" in dem + + sampler = build_native_sampler( + patch, + num_rounds=2, + noise=noise, + basis="Z", + twirl=twirl, + ) + assert sampler.num_pauli_sites == num_pauli_sites(2, patch.geometry.num_data) + + det_events, obs_flips = sampler.sample(2, seed=7) + assert det_events.shape == (2, sampler.num_detectors) + assert obs_flips.shape == (2, sampler.num_observables) + + def test_surface_traced_qis_rejects_twirl_with_semantic_message() -> None: patch = SurfacePatch.create(distance=3) From 888dad2c8838e85955a5bd930adfc8b6438bbb36 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 08:11:14 -0600 Subject: [PATCH 060/150] Treat Pauli meta ticks as zero-duration --- crates/pecos-quantum/src/tick_circuit.rs | 36 ++++++++++++++++++ .../qec/surface/test_pauli_twirl_handoff.py | 37 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/crates/pecos-quantum/src/tick_circuit.rs b/crates/pecos-quantum/src/tick_circuit.rs index 8f334300f..1be17d783 100644 --- a/crates/pecos-quantum/src/tick_circuit.rs +++ b/crates/pecos-quantum/src/tick_circuit.rs @@ -2297,6 +2297,17 @@ impl TickCircuit { } for tick in &mut self.ticks { + // Metadata-only ticks are zero-duration bookkeeping markers. + // They preserve annotation order but must not create physical idle + // periods on qubits outside the metadata payload. + if !tick.is_empty() + && tick + .gate_batches() + .iter() + .all(|gate| gate.gate_type.is_meta()) + { + continue; + } let active = tick.active_qubits(); for &q in &all_qubits { if !active.contains(&q) { @@ -6139,6 +6150,31 @@ mod tests { assert!(count_after > count_before, "Should have added idle gates"); } + #[test] + fn test_fill_idle_gates_skips_tracked_pauli_meta_ticks() { + use pecos_core::pauli::X; + + let mut tc = TickCircuit::new(); + tc.tick().h(&[0, 1]); + tc.tracked_pauli_labeled("frame_marker", X(0)); + tc.tick().h(&[0, 1]); + + tc.fill_idle_gates(); + + let meta_tick = tc + .ticks() + .iter() + .find(|tick| { + tick.gate_batches() + .iter() + .any(|gate| gate.gate_type == GateType::TrackedPauliMeta) + }) + .expect("tracked-Pauli meta tick"); + + assert_eq!(meta_tick.len(), 1); + assert!(meta_tick.gate_batches()[0].gate_type.is_meta()); + } + #[test] fn test_channel_gate_is_first_class_tick_operation() { let mut tc = TickCircuit::new(); 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 d7ee9560e..8f98cb082 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 @@ -210,6 +210,43 @@ def test_twirl_sine_law_idle_noise_builds_dem_and_sampler() -> None: assert obs_flips.shape == (2, sampler.num_observables) +@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_linear_rate=0.001)), + ("quadratic_idle", NoiseModel(p_idle_quadratic_rate=0.01)), + ("sine_law_idle", NoiseModel(p_idle_x_quadratic_sine_rate=0.03)), + ], +) +def test_twirling_does_not_change_canonical_dem( + label: str, + noise: NoiseModel, +) -> None: + del label + patch = SurfacePatch.create(distance=3) + + untwirled = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + basis="Z", + decompose_errors=True, + ) + twirled = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + basis="Z", + decompose_errors=True, + twirl=TwirlConfig(), + ) + + assert twirled == untwirled + + def test_surface_traced_qis_rejects_twirl_with_semantic_message() -> None: patch = SurfacePatch.create(distance=3) From 0adfae6d427e2557efe701489c0116fa1e0ce2d7 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 09:37:35 -0600 Subject: [PATCH 061/150] Harden meta tick idle accounting --- crates/pecos-core/src/gate_type.rs | 6 +++ crates/pecos-quantum/src/tick_circuit.rs | 66 +++++++++++++++++++----- 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/crates/pecos-core/src/gate_type.rs b/crates/pecos-core/src/gate_type.rs index 66ab8046d..9e0b6845f 100644 --- a/crates/pecos-core/src/gate_type.rs +++ b/crates/pecos-core/src/gate_type.rs @@ -193,6 +193,12 @@ impl GateType { /// /// Meta-gates have a position in the DAG but do not affect quantum state /// and should not create fault locations or receive noise. + /// + /// Idle-duration accounting depends on this predicate: + /// `TickCircuit::fill_idle_gates` treats ticks whose batches are all + /// meta as zero physical duration. Any new meta gate type MUST be added + /// here, or it will manufacture phantom idle periods in + /// idle-duration-driven noise models. #[must_use] pub const fn is_meta(self) -> bool { matches!(self, GateType::TrackedPauliMeta) diff --git a/crates/pecos-quantum/src/tick_circuit.rs b/crates/pecos-quantum/src/tick_circuit.rs index 1be17d783..25c18752a 100644 --- a/crates/pecos-quantum/src/tick_circuit.rs +++ b/crates/pecos-quantum/src/tick_circuit.rs @@ -2260,6 +2260,14 @@ impl TickCircuit { // ==================== Idle ==================== + /// Insert Idle gates after each two-qubit gate on both of its qubits. + /// + /// Delegates to `InsertIdleAfterTwoQubitGates` pass. See [`crate::pass`]. + pub fn insert_idle_after_two_qubit_gates(&mut self, duration: f64) { + use crate::pass::{CircuitPass, InsertIdleAfterTwoQubitGates}; + InsertIdleAfterTwoQubitGates(duration).apply_tick(self); + } + /// Insert identity gates for qubits not operated on during each tick. /// /// For each tick, finds qubits that are in the circuit's qubit set but @@ -2270,6 +2278,17 @@ impl TickCircuit { /// This is separate from `GateType::Idle` which represents explicit /// wait operations with duration-dependent `p_idle` noise. /// + /// Metadata-only ticks (all batches `GateType::is_meta`) are zero + /// physical duration and receive no idle gates. + /// + /// # Panics + /// + /// Panics if a tick mixes meta and physical gate batches: such a tick has + /// physical duration, but per-tick qubit exclusivity makes idle insertion + /// on meta-occupied qubits impossible, so the accounting would be + /// silently wrong. Emit meta batches in their own tick (as + /// `tracked_pauli` does). + /// /// # Example /// /// ``` @@ -2282,14 +2301,6 @@ impl TickCircuit { /// /// circuit.fill_idle_gates(); /// ``` - /// Insert Idle gates after each two-qubit gate on both of its qubits. - /// - /// Delegates to `InsertIdleAfterTwoQubitGates` pass. See [`crate::pass`]. - pub fn insert_idle_after_two_qubit_gates(&mut self, duration: f64) { - use crate::pass::{CircuitPass, InsertIdleAfterTwoQubitGates}; - InsertIdleAfterTwoQubitGates(duration).apply_tick(self); - } - pub fn fill_idle_gates(&mut self) { let all_qubits = self.all_qubits(); if all_qubits.is_empty() { @@ -2300,14 +2311,25 @@ impl TickCircuit { // Metadata-only ticks are zero-duration bookkeeping markers. // They preserve annotation order but must not create physical idle // periods on qubits outside the metadata payload. - if !tick.is_empty() - && tick - .gate_batches() - .iter() - .all(|gate| gate.gate_type.is_meta()) - { + let meta_batches = tick + .gate_batches() + .iter() + .filter(|gate| gate.gate_type.is_meta()) + .count(); + if !tick.is_empty() && meta_batches == tick.gate_batches().len() { continue; } + // A tick that mixes meta and physical gates has ambiguous idle + // accounting: the tick has physical duration, but per-tick qubit + // exclusivity makes it impossible to insert an Idle on a qubit a + // meta gate already occupies. Meta batches belong in their own + // tick (as `tracked_pauli` emits them). + assert!( + meta_batches == 0, + "fill_idle_gates: tick mixes meta and physical gates; emit meta \ + batches in their own tick so idle-duration accounting stays \ + unambiguous" + ); let active = tick.active_qubits(); for &q in &all_qubits { if !active.contains(&q) { @@ -6175,6 +6197,22 @@ mod tests { assert!(meta_tick.gate_batches()[0].gate_type.is_meta()); } + #[test] + #[should_panic(expected = "tick mixes meta and physical gates")] + fn test_fill_idle_gates_rejects_mixed_meta_and_physical_tick() { + let mut tc = TickCircuit::new(); + let mut tick = tc.tick(); + tick.h(&[0]); + tick.try_add_gate(Gate::simple( + GateType::TrackedPauliMeta, + vec![QubitId::from(1)], + )) + .map(|_| ()) + .expect("meta gate on a free qubit must be addable"); + + tc.fill_idle_gates(); + } + #[test] fn test_channel_gate_is_first_class_tick_operation() { let mut tc = TickCircuit::new(); From 930ad80b6bb5b384af993bd1f883ac5104085419 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Fri, 12 Jun 2026 10:05:37 -0600 Subject: [PATCH 062/150] Polish Pauli twirl handoff lint checks --- .../quantum-pecos/src/pecos/guppy/surface.py | 37 +++++++++++-------- .../src/pecos/qec/surface/__init__.py | 4 +- .../pecos/qec/surface/_detection_events.py | 14 +++++-- .../src/pecos/qec/surface/_twirl_config.py | 7 +++- .../src/pecos/qec/surface/_twirl_sites.py | 16 ++++---- .../src/pecos/qec/surface/decode.py | 10 ++--- .../tests/guppy/test_surface_twirl_render.py | 8 ++-- .../qec/surface/test_pauli_mask_harvest.py | 33 ++++++++--------- .../qec/surface/test_pauli_twirl_handoff.py | 3 +- 9 files changed, 74 insertions(+), 58 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 97aa6fabf..fcdbfe2be 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -153,6 +153,8 @@ def generate_guppy_source( emits measurement records in the canonical untwirled DEM frame. rng: Runtime mask source: a stream-separator seed mixed with per-shot quantum entropy when ``twirl`` is enabled. + num_rounds: Number of syndrome rounds to render. Required when + ``twirl`` is enabled because twirled source is unrolled per round. Returns: Python/Guppy source code as a string. @@ -163,12 +165,10 @@ def generate_guppy_source( from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget if (twirl is None) != (rng is None): - msg = "twirl and rng must be supplied together; got twirl={!r} rng={!r}".format( - twirl, rng - ) + msg = f"twirl and rng must be supplied together; got twirl={twirl!r} rng={rng!r}" raise ValueError(msg) if twirl is not None: - twirl._validate_runtime_supported() + twirl.validate_runtime_supported() if num_rounds is None: msg = "num_rounds is required when twirl is supplied" raise ValueError(msg) @@ -291,11 +291,15 @@ def generate_guppy_source( ) if canonical_frame_output: lines.append( - f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz}, frame_x: array[bool, {num_data}], frame_z: array[bool, {num_data}]) -> Syndrome_{dx}x{dz}:" + "def syndrome_extraction(" + f"surf: SurfaceCode_{dx}x{dz}, " + f"frame_x: array[bool, {num_data}], " + f"frame_z: array[bool, {num_data}]" + f") -> Syndrome_{dx}x{dz}:", ) else: lines.append( - f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz}) -> Syndrome_{dx}x{dz}:" + f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz}) -> Syndrome_{dx}x{dz}:", ) if not constrained: @@ -651,8 +655,9 @@ def _render_memory_experiments( if twirl is None: lines.extend(_render_plain_memory_block(basis, basis_upper, dx, dz)) else: - assert rng is not None - assert num_rounds is not None + if rng is None or num_rounds is None: + msg = "twirled memory rendering requires both rng and num_rounds" + raise ValueError(msg) lines.extend( _render_twirled_memory_block( basis, @@ -663,7 +668,7 @@ def _render_memory_experiments( twirl, rng, num_rounds, - ) + ), ) return lines @@ -745,7 +750,7 @@ def _render_twirled_memory_block( frame_x = ", ".join(f"fx_{q}" for q in range(num_data)) frame_z = ", ".join(f"fz_{q}" for q in range(num_data)) body.append( - f" syn = syndrome_extraction(surf, array({frame_x}), array({frame_z}))" + f" syn = syndrome_extraction(surf, array({frame_x}), array({frame_z}))", ) else: body.append(" syn = syndrome_extraction(surf)") @@ -764,10 +769,10 @@ def _render_twirled_memory_block( body.append(f" hi_{r}_{q} = (m_{r}_{q} == 2) | (m_{r}_{q} == 3)") if canonical_frame_output: body.append( - f" twx_{r}_{q} = (m_{r}_{q} == 1) | (m_{r}_{q} == 2)" + f" twx_{r}_{q} = (m_{r}_{q} == 1) | (m_{r}_{q} == 2)", ) body.append( - f" twz_{r}_{q} = (m_{r}_{q} == 2) | (m_{r}_{q} == 3)" + f" twz_{r}_{q} = (m_{r}_{q} == 2) | (m_{r}_{q} == 3)", ) body.append(f" fx_{q} = fx_{q} != twx_{r}_{q}") body.append(f" fz_{q} = fz_{q} != twz_{r}_{q}") @@ -782,7 +787,7 @@ def _render_twirled_memory_block( frame_x = ", ".join(f"fx_{q}" for q in range(num_data)) frame_z = ", ".join(f"fz_{q}" for q in range(num_data)) body.append( - f" syn = syndrome_extraction(surf, array({frame_x}), array({frame_z}))" + f" syn = syndrome_extraction(surf, array({frame_x}), array({frame_z}))", ) else: body.append(" syn = syndrome_extraction(surf)") @@ -861,8 +866,9 @@ def _guppy_module_cache_key( base = f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}_b{effective_budget}" if twirl is None: return base - assert rng is not None - assert num_rounds is not None + if rng is None or num_rounds is None: + msg = "twirled Guppy module cache keys require both rng and num_rounds" + raise ValueError(msg) twirl_part = ( f"t-{twirl.scheme}-{twirl.site_schedule}-{twirl.result_encoding}" f"-frame-{twirl.frame_output}" @@ -893,6 +899,7 @@ def _load_guppy_module( ancilla_budget: Optional cap on simultaneously live ancillas twirl: Pauli-twirl-site declaration (structural) rng: Runtime mask RNG seed (must be supplied with ``twirl``) + num_rounds: Syndrome-round count for unrolled twirled source. Returns: Module dictionary with generated functions diff --git a/python/quantum-pecos/src/pecos/qec/surface/__init__.py b/python/quantum-pecos/src/pecos/qec/surface/__init__.py index f4d4c3963..27130f05c 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/surface/__init__.py @@ -18,6 +18,8 @@ """ # Circuit generation from geometry (unified abstraction) +from pecos.qec.surface._detection_events import extract_detection_events_and_observables +from pecos.qec.surface._twirl_config import GuppyRngMaskConfig, TwirlConfig from pecos.qec.surface.circuit_builder import ( DagCircuitRenderer, GuppyRenderer, @@ -105,12 +107,10 @@ get_stabilizer_touch_label, ) from pecos.qec.surface.plot import plot_patch, plot_surface_code -from pecos.qec.surface._detection_events import extract_detection_events_and_observables from pecos.qec.surface.schedule import ( compute_cnot_schedule, get_stab_schedule, ) -from pecos.qec.surface._twirl_config import GuppyRngMaskConfig, TwirlConfig __all__ = [ # Twirling config (Pauli-frame randomization) diff --git a/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py index 14a250631..95d4c7c58 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py @@ -6,12 +6,20 @@ from __future__ import annotations import json -from collections.abc import Iterable, Sequence -from typing import Any +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + +class _TickCircuitLike(Protocol): + def get_meta(self, key: str) -> str | None: + """Return metadata stored under ``key`` when available.""" + ... def extract_detection_events_and_observables( - tick_circuit: Any, + tick_circuit: _TickCircuitLike, results: Iterable[Sequence[int]], ) -> tuple[list[list[int]], list[list[int]]]: """Extract fired detectors and observables from flat measurement rows.""" diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py index 2263583e7..d35e3a1dc 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py @@ -27,7 +27,6 @@ from dataclasses import dataclass from typing import Literal - _SUPPORTED_SCHEMES = ("pauli",) _SUPPORTED_SITE_SCHEDULES = ("between_rounds",) _SUPPORTED_RESULT_ENCODINGS = ("bool_array_v1",) @@ -71,7 +70,7 @@ class TwirlConfig: result_encoding: Literal["bool_array_v1"] = "bool_array_v1" frame_output: Literal["raw", "canonical"] = "raw" - def _validate_runtime_supported(self) -> None: + def validate_runtime_supported(self) -> None: """Raise ``ValueError`` if any field is outside the supported runtime set. The ``Literal`` annotations are static-only hints; this method @@ -106,6 +105,10 @@ def _validate_runtime_supported(self) -> None: ) raise ValueError(msg) + def _validate_runtime_supported(self) -> None: + """Compatibility alias for the public runtime validator.""" + self.validate_runtime_supported() + @dataclass(frozen=True) class GuppyRngMaskConfig: diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py index 068185102..72fd05d69 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py @@ -1,6 +1,7 @@ -"""Canonical `(round, qubit) -> site_idx` mapping shared by the abstract -circuit (`circuit_builder.py`) and the Guppy runtime renderer -(`pecos.guppy.surface`). +"""Canonical Pauli-twirl site mapping. + +This maps `(round, qubit) -> site_idx` for both the abstract circuit +(`circuit_builder.py`) and the Guppy runtime renderer (`pecos.guppy.surface`). Both tracks must agree byte-for-byte on the ordering of twirl-site metadata: the abstract circuit's `tracked_pauli` annotations populate @@ -60,8 +61,10 @@ def pauli_mask_round_tag(round_idx: int) -> str: - """Canonical `result()` tag for the twirl mask emitted between syndrome - rounds ``round_idx`` and ``round_idx + 1``. + """Return the canonical per-round twirl-mask result tag. + + The tag is emitted between syndrome rounds ``round_idx`` and + ``round_idx + 1``. """ return f"{PAULI_MASK_TAG_PREFIX}:round:{round_idx}" @@ -76,8 +79,7 @@ def num_twirl_sites(num_rounds: int) -> int: def site_idx_for_round(round_idx: int) -> int: - """Map the round-loop index of the round PRECEDING a twirl site to its - canonical `site_idx`. + """Map the preceding round-loop index to a twirl site index. For the `between_rounds` schedule, the twirl site that sits between syndrome round `r` and round `r + 1` is canonical `site_idx == r`. diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 2ea7e21ed..e4b360901 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -299,7 +299,7 @@ def _abstract_twirl_config(twirl: TwirlConfig | None) -> TwirlConfig | None: """Drop runtime-only Guppy record-framing fields before DEM caching.""" if twirl is None: return None - twirl._validate_runtime_supported() + twirl.validate_runtime_supported() return replace(twirl, frame_output="raw") @@ -1192,7 +1192,6 @@ def _replay_qis_trace_chunks_into_tick_circuit(chunks: list[dict[str, Any]]) -> # operations, whose Measure payloads include the stable result ids. # Replay the raw operations in that compatibility case instead of # losing provenance. - pass operations: list[dict[str, Any]] = [] for chunk in chunks: @@ -1320,7 +1319,6 @@ def _generate_traced_surface_tick_circuit_with_result_traces( runtime: object | None = None, ) -> tuple[Any, list[dict[str, Any]]]: """Trace a surface Guppy program into a ``TickCircuit`` plus result provenance.""" - from pecos.guppy import get_num_qubits from pecos.guppy.surface import generate_memory_experiment, get_num_qubits program = generate_memory_experiment( @@ -1351,7 +1349,7 @@ def _build_surface_tick_circuit_for_native_model( from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch if twirl is not None: - twirl._validate_runtime_supported() + twirl.validate_runtime_supported() abstract_tc = generate_tick_circuit_from_patch( patch, @@ -3812,7 +3810,7 @@ def build_native_sampler_from_dem( basis, ancilla_budget, circuit_source, - False, + include_idle_gates=False, twirl=twirl, ) sampler = _cached_parsed_dem(decomposed_dem).to_dem_sampler() @@ -4010,7 +4008,7 @@ def sample_pauli_masks_from_guppy( if twirl is None or rng is None: msg = "sample_pauli_masks_from_guppy requires both twirl and rng to be set" raise ValueError(msg) - twirl._validate_runtime_supported() + twirl.validate_runtime_supported() if num_rounds < 1: msg = f"num_rounds must be >= 1, got {num_rounds}" raise ValueError(msg) diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index 94f3a6983..d0266340a 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -4,14 +4,14 @@ pytest.importorskip("guppylang") -from pecos.guppy.surface import ( # noqa: E402 +from pecos.guppy.surface import ( _guppy_module_cache_key, generate_guppy_source, generate_memory_experiment, ) -from pecos.qec.surface import GuppyRngMaskConfig, TwirlConfig # noqa: E402 -from pecos.qec.surface._twirl_sites import pauli_mask_round_tag # noqa: E402 -from pecos.qec.surface.patch import SurfacePatch # noqa: E402 +from pecos.qec.surface import GuppyRngMaskConfig, TwirlConfig +from pecos.qec.surface._twirl_sites import pauli_mask_round_tag +from pecos.qec.surface.patch import SurfacePatch @pytest.fixture 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 e49b9785a..5ceaff57a 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 @@ -2,7 +2,6 @@ import numpy as np import pytest - from pecos.qec.surface import ( GuppyRngMaskConfig, NoiseModel, @@ -103,10 +102,9 @@ def _run_twirled_guppy_rows_masks_and_raw( rng: GuppyRngMaskConfig, twirl: TwirlConfig, ) -> tuple[list[list[int]], np.ndarray, list[list[int]], list[str]]: - from selene_sim import SimpleRuntime, Stim, build - from pecos.compilation_pipeline import compile_guppy_to_hugr from pecos.guppy.surface import generate_memory_experiment, get_num_qubits + from selene_sim import SimpleRuntime, Stim, build fn = generate_memory_experiment( patch, @@ -217,13 +215,13 @@ def test_runtime_twirl_masks_vary_across_shots(patch_d3: SurfacePatch) -> None: def test_same_seed_is_reproducible(patch_d3: SurfacePatch) -> None: - kwargs = dict( - num_rounds=3, - num_shots=6, - basis="Z", - twirl=TwirlConfig(), - rng=GuppyRngMaskConfig(seed=42), - ) + kwargs = { + "num_rounds": 3, + "num_shots": 6, + "basis": "Z", + "twirl": TwirlConfig(), + "rng": GuppyRngMaskConfig(seed=42), + } masks_a = sample_pauli_masks_from_guppy(patch_d3, **kwargs) masks_b = sample_pauli_masks_from_guppy(patch_d3, **kwargs) @@ -232,12 +230,12 @@ def test_same_seed_is_reproducible(patch_d3: SurfacePatch) -> None: def test_different_seeds_differ(patch_d3: SurfacePatch) -> None: - common = dict( - num_rounds=3, - num_shots=8, - basis="Z", - twirl=TwirlConfig(), - ) + common = { + "num_rounds": 3, + "num_shots": 8, + "basis": "Z", + "twirl": TwirlConfig(), + } masks_seed1 = sample_pauli_masks_from_guppy( patch_d3, @@ -390,4 +388,5 @@ def test_d5_compile_smoke(patch_d3: SurfacePatch) -> None: num_data = patch_d5.geometry.num_data assert masks.shape == (2, num_pauli_sites(3, num_data)) assert masks.dtype == np.uint8 - assert masks.min() >= 0 and masks.max() <= 3 + assert masks.min() >= 0 + assert masks.max() <= 3 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 8f98cb082..971d502e0 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 @@ -2,7 +2,6 @@ import numpy as np import pytest - from pecos.qec.surface import ( GuppyRngMaskConfig, NoiseModel, @@ -250,7 +249,7 @@ def test_twirling_does_not_change_canonical_dem( def test_surface_traced_qis_rejects_twirl_with_semantic_message() -> None: patch = SurfacePatch.create(distance=3) - with pytest.raises(ValueError) as exc_info: + with pytest.raises(ValueError, match="one concrete mask realization") as exc_info: build_memory_circuit( patch=patch, rounds=2, From 1a651bf581549af8b238fb53cf4ac8ca5d9c158b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 10:27:00 -0600 Subject: [PATCH 063/150] Tighten Pauli twirl canonicalization tests --- .../tests/qec/surface/test_pauli_twirl_handoff.py | 4 ++++ 1 file changed, 4 insertions(+) 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 971d502e0..e74c1dbda 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 @@ -134,6 +134,7 @@ def test_canonical_frame_output_reuses_raw_abstract_sampler_topology() -> None: assert canonical.num_observables == raw.num_observables assert canonical.num_pauli_sites == raw.num_pauli_sites assert canonical.pauli_frame_lookup is raw.pauli_frame_lookup + assert canonical.dem_string == raw.dem_string @pytest.mark.parametrize( @@ -153,6 +154,9 @@ def test_abstract_twirl_builders_reject_unsupported_config( kwargs = {field: value} twirl = TwirlConfig(**kwargs) # type: ignore[arg-type] + with pytest.raises(ValueError, match=field): + twirl.validate_runtime_supported() + with pytest.raises(ValueError, match=field): build_memory_circuit( patch=patch, From 17276d28797b421e401ab308ff4a03021e5df4ff Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 10:55:39 -0600 Subject: [PATCH 064/150] Strengthen Guppy Pauli twirl validation --- .../qec/surface/test_pauli_mask_harvest.py | 98 +++++++++++++++++-- 1 file changed, 88 insertions(+), 10 deletions(-) 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 5ceaff57a..98ebf88c6 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 @@ -9,6 +9,7 @@ TwirlConfig, build_memory_circuit, build_native_sampler, + decode_native_samples, demask_pauli_frame_records, extract_detection_events_and_observables, sample_pauli_masks_from_guppy, @@ -300,20 +301,21 @@ def test_runtime_twirled_theta0_demask_null( assert not observables.any() -@pytest.mark.parametrize("basis", ["Z", "X"]) -def test_runtime_canonical_frame_output_theta0_null_and_raw_equivalence( - patch_d3: SurfacePatch, +def _assert_canonical_frame_output_matches_lookup( + patch: SurfacePatch, + *, basis: str, + num_rounds: int, + num_shots: int, + seed: int, ) -> None: - num_rounds = 2 - num_shots = 6 twirl = TwirlConfig(frame_output="canonical") measurement_rows, masks, raw_rows, frame_modes = _run_twirled_guppy_rows_masks_and_raw( - patch_d3, + patch, basis=basis, num_rounds=num_rounds, num_shots=num_shots, - rng=GuppyRngMaskConfig(seed=12345), + rng=GuppyRngMaskConfig(seed=seed), twirl=twirl, ) @@ -322,7 +324,7 @@ def test_runtime_canonical_frame_output_theta0_null_and_raw_equivalence( assert raw_rows expected_rows = _canonicalize_raw_rows_from_masks( - patch_d3, + patch, basis=basis, num_rounds=num_rounds, raw_rows=raw_rows, @@ -331,13 +333,13 @@ def test_runtime_canonical_frame_output_theta0_null_and_raw_equivalence( assert measurement_rows == expected_rows tick_circuit = build_memory_circuit( - patch=patch_d3, + patch=patch, rounds=num_rounds, basis=basis, twirl=TwirlConfig(), ) sampler = build_native_sampler( - patch_d3, + patch, num_rounds=num_rounds, noise=NoiseModel(), basis=basis, @@ -373,6 +375,82 @@ def test_runtime_canonical_frame_output_theta0_null_and_raw_equivalence( assert not observables.any() +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_runtime_canonical_frame_output_theta0_null_and_raw_equivalence( + patch_d3: SurfacePatch, + basis: str, +) -> None: + _assert_canonical_frame_output_matches_lookup( + patch_d3, + basis=basis, + num_rounds=2, + num_shots=6, + seed=12345, + ) + + +@pytest.mark.parametrize( + ("distance", "num_rounds", "basis", "num_shots", "seed"), + [ + (3, 3, "Z", 4, 2024), + (3, 3, "X", 4, 2025), + (5, 3, "Z", 2, 2026), + ], +) +def test_runtime_canonical_frame_output_matches_lookup_matrix( + distance: int, + num_rounds: int, + basis: str, + num_shots: int, + seed: int, +) -> None: + patch = SurfacePatch.create(distance=distance) + _assert_canonical_frame_output_matches_lookup( + patch, + basis=basis, + num_rounds=num_rounds, + num_shots=num_shots, + seed=seed, + ) + + +def test_harvested_runtime_masks_drive_fixed_dem_sampler_null(patch_d3: SurfacePatch) -> None: + num_rounds = 3 + num_shots = 8 + twirl = TwirlConfig() + masks = sample_pauli_masks_from_guppy( + patch_d3, + num_rounds=num_rounds, + num_shots=num_shots, + basis="Z", + twirl=twirl, + rng=GuppyRngMaskConfig(seed=5150), + ) + assert masks.any() + + sampler = build_native_sampler( + patch_d3, + num_rounds=num_rounds, + noise=NoiseModel(), + basis="Z", + twirl=twirl, + ) + assert sampler.pauli_frame_lookup is not None + + raw_events, raw_obs = sampler.sample(num_shots, seed=99, pauli_masks=masks) + assert raw_events.any() or raw_obs.any() + + events, observables = demask_pauli_frame_records( + sampler.pauli_frame_lookup, + raw_events, + raw_obs, + masks, + ) + assert not events.any() + assert not observables.any() + assert decode_native_samples(sampler, num_shots, seed=99, pauli_masks=masks) == 0 + + def test_d5_compile_smoke(patch_d3: SurfacePatch) -> None: del patch_d3 patch_d5 = SurfacePatch.create(distance=5) From 9eccee9220d699ebabd732daf5859b078e788bc0 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 11:29:04 -0600 Subject: [PATCH 065/150] Add gate-local Guppy Pauli twirl schedule --- .../quantum-pecos/src/pecos/guppy/surface.py | 374 +++++++++++++++++- .../src/pecos/qec/surface/_twirl_config.py | 10 +- .../src/pecos/qec/surface/_twirl_sites.py | 76 +++- .../src/pecos/qec/surface/circuit_builder.py | 138 ++++--- .../src/pecos/qec/surface/decode.py | 63 +++ .../tests/guppy/test_surface_twirl_render.py | 36 +- .../qec/surface/test_pauli_mask_harvest.py | 140 ++++++- .../qec/surface/test_pauli_twirl_handoff.py | 94 +++++ 8 files changed, 838 insertions(+), 93 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index fcdbfe2be..7eed802d4 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -189,7 +189,7 @@ def generate_guppy_source( f"twirl + constrained ancilla budget is not supported on " f"the Guppy runtime path " f"(ancilla_budget={ancilla_budget} < total_ancilla={total_ancilla}); " - "the between_rounds twirl-site schedule assumes the " + "the runtime twirl-site schedules assume the " "unconstrained syndrome shape. Pass ancilla_budget=None or " ">= total_ancilla, or omit twirl." ) @@ -622,7 +622,7 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ) # Generate memory experiment factories - lines.extend(_render_memory_experiments(dx, dz, num_data, twirl, rng, num_rounds)) + lines.extend(_render_memory_experiments(patch, dx, dz, num_data, twirl, rng, num_rounds)) return "\n".join(lines) @@ -639,6 +639,7 @@ def _xor_expr(terms: object) -> str: def _render_memory_experiments( + patch: "SurfacePatch", dx: int, dz: int, num_data: int, @@ -658,18 +659,33 @@ def _render_memory_experiments( if rng is None or num_rounds is None: msg = "twirled memory rendering requires both rng and num_rounds" raise ValueError(msg) - lines.extend( - _render_twirled_memory_block( - basis, - basis_upper, - dx, - dz, - num_data, - twirl, - rng, - num_rounds, - ), - ) + if twirl.site_schedule == "before_two_qubit_gate": + lines.extend( + _render_gate_local_twirled_memory_block( + patch, + basis, + basis_upper, + dx, + dz, + num_data, + twirl, + rng, + num_rounds, + ), + ) + else: + lines.extend( + _render_twirled_memory_block( + basis, + basis_upper, + dx, + dz, + num_data, + twirl, + rng, + num_rounds, + ), + ) return lines @@ -828,6 +844,336 @@ def _render_twirled_memory_block( ] +def _frame_vars(prefix: str, idx: int) -> tuple[str, str]: + return f"frame_x_{prefix}{idx}", f"frame_z_{prefix}{idx}" + + +def _append_frame_swap(lines: list[str], indent: str, x_var: str, z_var: str, tmp_var: str) -> None: + lines.append(f"{indent}{tmp_var} = {x_var}") + lines.append(f"{indent}{x_var} = {z_var}") + lines.append(f"{indent}{z_var} = {tmp_var}") + + +def _append_gate_local_draw( + lines: list[str], + indent: str, + *, + site_idx: int, + operand_idx: int, + qubit_expr: str, + frame_vars: tuple[str, str] | None, +) -> tuple[str, str]: + m_var = f"m_g{site_idx}_o{operand_idx}" + lo_var = f"lo_g{site_idx}_o{operand_idx}" + hi_var = f"hi_g{site_idx}_o{operand_idx}" + lines.append(f"{indent}rng_state, {m_var} = _pcg32_next4(rng_state, rng_inc)") + lines.append(f"{indent}if {m_var} == 1:") + lines.append(f"{indent} x({qubit_expr})") + lines.append(f"{indent}if {m_var} == 2:") + lines.append(f"{indent} y({qubit_expr})") + lines.append(f"{indent}if {m_var} == 3:") + lines.append(f"{indent} z({qubit_expr})") + lines.append(f"{indent}{lo_var} = ({m_var} == 1) | ({m_var} == 3)") + lines.append(f"{indent}{hi_var} = ({m_var} == 2) | ({m_var} == 3)") + if frame_vars is not None: + x_frame, z_frame = frame_vars + twx_var = f"twx_g{site_idx}_o{operand_idx}" + twz_var = f"twz_g{site_idx}_o{operand_idx}" + lines.append(f"{indent}{twx_var} = ({m_var} == 1) | ({m_var} == 2)") + lines.append(f"{indent}{twz_var} = ({m_var} == 2) | ({m_var} == 3)") + lines.append(f"{indent}{x_frame} = {x_frame} != {twx_var}") + lines.append(f"{indent}{z_frame} = {z_frame} != {twz_var}") + return lo_var, hi_var + + +def _append_gate_local_layer( + lines: list[str], + indent: str, + *, + site_idx: int, + cx_ops: list[tuple[str, str, tuple[str, str] | None, tuple[str, str] | None]], +) -> int: + """Emit all twirl draws before a parallel CX layer, then the CX layer.""" + from pecos.qec.surface._twirl_sites import pauli_mask_gate_tag + + for control_expr, target_expr, control_frame, target_frame in cx_ops: + lo0, hi0 = _append_gate_local_draw( + lines, + indent, + site_idx=site_idx, + operand_idx=0, + qubit_expr=control_expr, + frame_vars=control_frame, + ) + lo1, hi1 = _append_gate_local_draw( + lines, + indent, + site_idx=site_idx, + operand_idx=1, + qubit_expr=target_expr, + frame_vars=target_frame, + ) + tag = pauli_mask_gate_tag(site_idx) + lines.append(f'{indent}result("{tag}", array({lo0}, {hi0}, {lo1}, {hi1}))') + site_idx += 1 + + for control_expr, target_expr, control_frame, target_frame in cx_ops: + lines.append(f"{indent}cx({control_expr}, {target_expr})") + if control_frame is not None and target_frame is not None: + control_x, control_z = control_frame + target_x, target_z = target_frame + lines.append(f"{indent}{target_x} = {target_x} != {control_x}") + lines.append(f"{indent}{control_z} = {control_z} != {target_z}") + return site_idx + + +def _append_gate_local_measure( + lines: list[str], + indent: str, + *, + bit_var: str, + qubit_expr: str, + result_tag: str, + raw_tag: str, + frame_vars: tuple[str, str] | None, +) -> None: + if frame_vars is None: + lines.append(f"{indent}{bit_var} = measure({qubit_expr})") + else: + raw_var = f"{bit_var}_raw" + frame_x, _frame_z = frame_vars + lines.append(f"{indent}{raw_var} = measure({qubit_expr})") + lines.append(f"{indent}{bit_var} = {raw_var} != {frame_x}") + lines.append(f'{indent}result("{raw_tag}", {raw_var})') + lines.append(f'{indent}result("{result_tag}", {bit_var})') + + +def _render_gate_local_twirled_memory_block( + patch: "SurfacePatch", + basis: str, + basis_upper: str, + dx: int, + dz: int, + num_data: int, + twirl: "TwirlConfig", + rng: "GuppyRngMaskConfig", + num_rounds: int, +) -> list[str]: + """Render a gate-local twirled memory factory.""" + seed = int(rng.seed) + canonical_frame_output = twirl.frame_output == "canonical" + geom = patch.geometry + rounds = compute_cnot_schedule(patch) + init_stab_type = "X" if basis == "z" else "Z" + init_tag = "init_synx" if basis == "z" else "init_synz" + indent = " " + site_idx = 0 + + data_frames = [_frame_vars("d", q) for q in range(num_data)] + x_anc_frames = [_frame_vars("ax", stab.index) for stab in geom.x_stabilizers] + z_anc_frames = [_frame_vars("az", stab.index) for stab in geom.z_stabilizers] + + def data_expr(q: int) -> str: + return f"surf.data[{q}]" + + def anc_expr(stab_type: str, stab_idx: int) -> str: + return f"ax{stab_idx}" if stab_type == "X" else f"az{stab_idx}" + + def anc_frame(stab_type: str, stab_idx: int) -> tuple[str, str] | None: + if not canonical_frame_output: + return None + return x_anc_frames[stab_idx] if stab_type == "X" else z_anc_frames[stab_idx] + + def data_frame(q: int) -> tuple[str, str] | None: + return data_frames[q] if canonical_frame_output else None + + def cx_tuple( + stab_type: str, + stab_idx: int, + data_q: int, + ) -> tuple[str, str, tuple[str, str] | None, tuple[str, str] | None]: + if stab_type == "X": + return ( + anc_expr(stab_type, stab_idx), + data_expr(data_q), + anc_frame(stab_type, stab_idx), + data_frame(data_q), + ) + return ( + data_expr(data_q), + anc_expr(stab_type, stab_idx), + data_frame(data_q), + anc_frame(stab_type, stab_idx), + ) + + body: list[str] = [ + f' """{basis_upper}-basis memory experiment for dx={dx}, dz={dz}, ' + f'num_rounds={num_rounds} (gate-local twirled)."""', + f" surf = prep_{basis}_basis()", + " # RNG seed is structural -- changing it does not invalidate the", + " # abstract DEM / topology cache, only the per-shot mask buffer.", + f" rng_state, rng_inc = seeded_pcg32_with_quantum_entropy({seed})", + f' result("frame_mode:{twirl.frame_output}", True)', + ] + if canonical_frame_output: + for q in range(num_data): + frame_x, frame_z = data_frames[q] + body.append(f"{indent}{frame_x} = False") + body.append(f"{indent}{frame_z} = False") + body.append("") + + # Initial syndrome establishment for the complementary stabilizer family. + init_stabs = list(geom.x_stabilizers if init_stab_type == "X" else geom.z_stabilizers) + for stab in init_stabs: + body.append(f"{indent}{anc_expr(init_stab_type, stab.index)} = qubit()") + if canonical_frame_output: + frame_x, frame_z = anc_frame(init_stab_type, stab.index) or ("", "") + body.append(f"{indent}{frame_x} = False") + body.append(f"{indent}{frame_z} = False") + if init_stab_type == "X": + body.append("") + for stab in init_stabs: + body.append(f"{indent}h(ax{stab.index})") + if canonical_frame_output: + frame_x, frame_z = anc_frame("X", stab.index) or ("", "") + _append_frame_swap(body, indent, frame_x, frame_z, f"tmp_h_init_ax{stab.index}") + + for rnd_idx, rnd_gates in enumerate(rounds): + filtered = [(t, i, q) for t, i, q in rnd_gates if t == init_stab_type] + if not filtered: + continue + body.append("") + body.append(f"{indent}# Init CX round {rnd_idx + 1}") + cx_ops = [cx_tuple(stab_type, stab_idx, data_q) for stab_type, stab_idx, data_q in filtered] + site_idx = _append_gate_local_layer(body, indent, site_idx=site_idx, cx_ops=cx_ops) + + if init_stab_type == "X": + body.append("") + for stab in init_stabs: + body.append(f"{indent}h(ax{stab.index})") + if canonical_frame_output: + frame_x, frame_z = anc_frame("X", stab.index) or ("", "") + _append_frame_swap(body, indent, frame_x, frame_z, f"tmp_h_init2_ax{stab.index}") + + body.append("") + init_bits: list[str] = [] + for idx, stab in enumerate(init_stabs): + bit_var = f"s{init_stab_type.lower()}{stab.index}_init" + init_bits.append(bit_var) + _append_gate_local_measure( + body, + indent, + bit_var=bit_var, + qubit_expr=anc_expr(init_stab_type, stab.index), + result_tag=f"s{init_stab_type.lower()}{stab.index}:init:meas:{idx}", + raw_tag=f"raw:s{init_stab_type.lower()}{stab.index}:init:bit:{idx}", + frame_vars=anc_frame(init_stab_type, stab.index), + ) + body.append(f'{indent}result("{init_tag}", array({", ".join(init_bits)}))') + body.append("") + + for round_idx in range(num_rounds): + body.append(f"{indent}# === Round {round_idx} (gate-local twirled) ===") + for stab in geom.x_stabilizers: + body.append(f"{indent}ax{stab.index} = qubit()") + if canonical_frame_output: + frame_x, frame_z = anc_frame("X", stab.index) or ("", "") + body.append(f"{indent}{frame_x} = False") + body.append(f"{indent}{frame_z} = False") + for stab in geom.z_stabilizers: + body.append(f"{indent}az{stab.index} = qubit()") + if canonical_frame_output: + frame_x, frame_z = anc_frame("Z", stab.index) or ("", "") + body.append(f"{indent}{frame_x} = False") + body.append(f"{indent}{frame_z} = False") + for stab in geom.x_stabilizers: + body.append(f"{indent}h(ax{stab.index})") + if canonical_frame_output: + frame_x, frame_z = anc_frame("X", stab.index) or ("", "") + _append_frame_swap(body, indent, frame_x, frame_z, f"tmp_h_r{round_idx}_ax{stab.index}") + + for rnd_idx, rnd_gates in enumerate(rounds): + body.append("") + body.append(f"{indent}# Round {round_idx} CX layer {rnd_idx + 1}") + cx_ops = [cx_tuple(stab_type, stab_idx, data_q) for stab_type, stab_idx, data_q in rnd_gates] + site_idx = _append_gate_local_layer(body, indent, site_idx=site_idx, cx_ops=cx_ops) + + for stab in geom.x_stabilizers: + body.append(f"{indent}h(ax{stab.index})") + if canonical_frame_output: + frame_x, frame_z = anc_frame("X", stab.index) or ("", "") + _append_frame_swap(body, indent, frame_x, frame_z, f"tmp_h2_r{round_idx}_ax{stab.index}") + + sx_bits: list[str] = [] + sz_bits: list[str] = [] + meas_idx = 0 + for stab in geom.x_stabilizers: + bit_var = f"sx{stab.index}_r{round_idx}" + sx_bits.append(bit_var) + _append_gate_local_measure( + body, + indent, + bit_var=bit_var, + qubit_expr=f"ax{stab.index}", + result_tag=f"sx{stab.index}:meas:{meas_idx}", + raw_tag=f"raw:sx{stab.index}:bit:{meas_idx}", + frame_vars=anc_frame("X", stab.index), + ) + meas_idx += 1 + for stab in geom.z_stabilizers: + bit_var = f"sz{stab.index}_r{round_idx}" + sz_bits.append(bit_var) + _append_gate_local_measure( + body, + indent, + bit_var=bit_var, + qubit_expr=f"az{stab.index}", + result_tag=f"sz{stab.index}:meas:{meas_idx}", + raw_tag=f"raw:sz{stab.index}:bit:{meas_idx}", + frame_vars=anc_frame("Z", stab.index), + ) + meas_idx += 1 + body.append(f'{indent}result("synx", array({", ".join(sx_bits)}))') + body.append(f'{indent}result("synz", array({", ".join(sz_bits)}))') + body.append("") + + if canonical_frame_output: + if basis == "x": + for q in range(num_data): + body.append(f"{indent}h(surf.data[{q}])") + frame_x, frame_z = data_frames[q] + _append_frame_swap(body, indent, frame_x, frame_z, f"tmp_h_final_d{q}") + body.append(f"{indent}final_raw = measure_array(surf.data)") + body.append(f'{indent}result("raw:final", final_raw)') + for q in range(num_data): + frame_x, _frame_z = data_frames[q] + body.append(f"{indent}final_{q} = final_raw[{q}] != {frame_x}") + body.append(f'{indent}result("final", array({", ".join(f"final_{q}" for q in range(num_data))}))') + else: + body.append(f"{indent}final = measure_{basis}_basis(surf)") + body.append(f'{indent}result("final", final)') + + return [ + f"def make_memory_{basis}(num_rounds: int):", + f' """Create {basis_upper}-basis gate-local twirled memory experiment.', + "", + f" num_rounds must equal {num_rounds} -- the body was unrolled at", + " source-generation time. Mismatched values raise ValueError.", + ' """', + f" if num_rounds != {num_rounds}:", + f' msg = f"this generated module was unrolled for num_rounds={num_rounds}, got {{num_rounds!r}}"', + " raise ValueError(msg)", + "", + " @guppy", + f" def memory_{basis}() -> None:", + *body, + "", + f" return memory_{basis}", + "", + "", + ] + + def _validate_surface_memory_distance(d: int) -> None: """Enforce the surface-memory Guppy entry-point distance contract. diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py index d35e3a1dc..3bfab8fa4 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py @@ -28,7 +28,7 @@ from typing import Literal _SUPPORTED_SCHEMES = ("pauli",) -_SUPPORTED_SITE_SCHEDULES = ("between_rounds",) +_SUPPORTED_SITE_SCHEDULES = ("between_rounds", "before_two_qubit_gate") _SUPPORTED_RESULT_ENCODINGS = ("bool_array_v1",) _SUPPORTED_FRAME_OUTPUTS = ("raw", "canonical") @@ -45,8 +45,10 @@ class TwirlConfig: `"clifford"` value is reserved for Phase 2 ({I, H} Clifford-frame randomization) and not yet implemented. site_schedule: Where twirling sites are emitted in the circuit. - `"between_rounds"` (the only supported value) emits one site - between each pair of consecutive syndrome rounds. + `"between_rounds"` emits one site between each pair of + consecutive syndrome rounds. `"before_two_qubit_gate"` emits + one site per operand immediately before every surface-memory + two-qubit gate in the supported Guppy runtime path. result_encoding: How the per-shot mask is recorded in the runtime result bundle. `"bool_array_v1"` packs the `2 * num_data` bool bits per round into one tagged array per @@ -66,7 +68,7 @@ class TwirlConfig: """ scheme: Literal["pauli"] = "pauli" - site_schedule: Literal["between_rounds"] = "between_rounds" + site_schedule: Literal["between_rounds", "before_two_qubit_gate"] = "between_rounds" result_encoding: Literal["bool_array_v1"] = "bool_array_v1" frame_output: Literal["raw", "canonical"] = "raw" diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py index 72fd05d69..e60692578 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py @@ -1,6 +1,6 @@ """Canonical Pauli-twirl site mapping. -This maps `(round, qubit) -> site_idx` for both the abstract circuit +This maps twirl-site declarations for both the abstract circuit (`circuit_builder.py`) and the Guppy runtime renderer (`pecos.guppy.surface`). Both tracks must agree byte-for-byte on the ordering of twirl-site @@ -12,10 +12,12 @@ application silently runs the mask against the wrong tracked-Pauli annotations and the decoder sees an incoherent syndrome. -The mapping is currently a simple `site_idx == round_idx` for the -`between_rounds` schedule (one site between each pair of consecutive -syndrome rounds, for a total of `num_rounds - 1` sites). The helpers -below abstract that so a future schedule only needs to update this module. +The backwards-compatible helpers below describe the `between_rounds` +schedule: `site_idx == round_idx`, one site between each pair of +consecutive syndrome rounds, and one Pauli-mask column per data qubit at +that site. The `before_two_qubit_gate` helpers describe the gate-local +schedule: one tag per two-qubit gate occurrence and two Pauli-mask +columns per tag (control operand then target operand). Encoding contract for the runtime mask (``"bool_array_v1"``): @@ -50,6 +52,13 @@ from __future__ import annotations +from typing import TYPE_CHECKING, Literal + +from pecos.qec.surface.schedule import compute_cnot_schedule + +if TYPE_CHECKING: + from pecos.qec.surface.patch import SurfacePatch + # Base prefix for per-round mask result tags. The Guppy renderer emits one # `result(f"{PAULI_MASK_TAG_PREFIX}:round:{r}", array(lo_q0, hi_q0, ...))` # call per twirl site so each tag fires exactly once per shot (avoids the @@ -69,6 +78,11 @@ def pauli_mask_round_tag(round_idx: int) -> str: return f"{PAULI_MASK_TAG_PREFIX}:round:{round_idx}" +def pauli_mask_gate_tag(site_idx: int) -> str: + """Return the canonical per-two-qubit-gate twirl-mask result tag.""" + return f"{PAULI_MASK_TAG_PREFIX}:gate:{site_idx}" + + def num_twirl_sites(num_rounds: int) -> int: """Number of twirl sites for the `between_rounds` schedule. @@ -119,3 +133,55 @@ def mask_col_for(site_idx: int, qubit_idx: int, num_data: int) -> int: flat bool stream. """ return site_idx * num_data + qubit_idx + + +def mask_col_for_gate_operand(site_idx: int, operand_idx: int) -> int: + """Canonical flat (gate site, operand) -> mask column index. + + Operand order is the physical two-qubit gate order: control first, + target second. Each gate-local twirl site therefore contributes two + integer Pauli-code columns. + """ + if operand_idx not in (0, 1): + msg = f"operand_idx must be 0 or 1, got {operand_idx}" + raise ValueError(msg) + return site_idx * 2 + operand_idx + + +def num_two_qubit_gate_twirl_sites( + patch: SurfacePatch, + *, + num_rounds: int, + basis: str, +) -> int: + """Number of two-qubit gate occurrences twirled by the gate-local schedule.""" + init_stabilizer_type = "X" if basis.upper() == "Z" else "Z" + cnot_rounds = compute_cnot_schedule(patch) + init_gates = sum( + 1 + for cx_round in cnot_rounds + for stab_type, _stab_idx, _data_idx in cx_round + if stab_type == init_stabilizer_type + ) + counted_round_gates = sum(len(cx_round) for cx_round in cnot_rounds) * num_rounds + return init_gates + counted_round_gates + + +def num_pauli_sites_for_schedule( + patch: SurfacePatch, + *, + num_rounds: int, + basis: str, + site_schedule: Literal["between_rounds", "before_two_qubit_gate"] = "between_rounds", +) -> int: + """Number of integer Pauli-code columns for a schedule.""" + if site_schedule == "between_rounds": + return num_pauli_sites(num_rounds, patch.geometry.num_data) + if site_schedule == "before_two_qubit_gate": + return 2 * num_two_qubit_gate_twirl_sites( + patch, + num_rounds=num_rounds, + basis=basis, + ) + msg = f"unsupported Pauli-twirl site_schedule={site_schedule!r}" + raise ValueError(msg) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 0f7700c1d..327424015 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -183,10 +183,11 @@ def build_surface_code_circuit( provided below the total stabilizer count, ancillas are reused across stabilizer batches following the public Guppy order. twirl: When provided, emit three ``OpType.TRACKED_PAULI`` annotations - (``X``, ``Y``, ``Z``) per data qubit at each between-round twirl - site. The sites are between counted syndrome rounds only: no site - is emitted before the first counted round, after the last counted - round, or inside the prep-boundary init syndrome block. + (``X``, ``Y``, ``Z``) per Pauli-mask column. The + ``"between_rounds"`` schedule emits one column per data qubit at + each site between counted syndrome rounds. The + ``"before_two_qubit_gate"`` schedule emits one column per operand + immediately before each surface-memory two-qubit gate. Returns: Tuple of (operations list, qubit allocation info) @@ -199,6 +200,9 @@ def build_surface_code_circuit( num_z_anc = len(geom.z_stabilizers) total_ancilla = num_x_anc + num_z_anc effective_ancilla_budget = _normalize_ancilla_budget(total_ancilla, ancilla_budget) + if twirl is not None: + twirl.validate_runtime_supported() + twirl_site_schedule = None if twirl is None else twirl.site_schedule # Qubit allocation layout. Under ancilla reuse, stabilizers map onto a # shared ancilla pool and different stabilizers can intentionally share the @@ -237,7 +241,7 @@ def x_anc_q(stab_idx: int) -> int: def z_anc_q(stab_idx: int) -> int: return allocation.z_ancilla_qubits[stab_idx] - def emit_pauli_twirl_site(target_ops: list[SurfaceCircuitStep], site_idx: int) -> None: + def emit_between_round_twirl_site(target_ops: list[SurfaceCircuitStep], site_idx: int) -> None: """Append 3 * num_data candidate tracked-Pauli annotations.""" target_ops.extend( SurfaceCircuitStep(OpType.TRACKED_PAULI, [data_q(i)], f"{kind}@s{site_idx}") @@ -245,6 +249,42 @@ def emit_pauli_twirl_site(target_ops: list[SurfaceCircuitStep], site_idx: int) - for kind in ("X", "Y", "Z") ) + gate_twirl_site_idx = 0 + + def emit_gate_local_twirl_site( + target_ops: list[SurfaceCircuitStep], + site_idx: int, + control_q: int, + target_q: int, + ) -> None: + """Append tracked-Pauli annotations for one two-qubit-gate site.""" + for operand_idx, q in enumerate((control_q, target_q)): + target_ops.extend( + SurfaceCircuitStep( + OpType.TRACKED_PAULI, + [q], + f"{kind}@g{site_idx}o{operand_idx}", + ) + for kind in ("X", "Y", "Z") + ) + + def emit_gate_local_twirl_layer( + target_ops: list[SurfaceCircuitStep], + cx_ops: list[tuple[int, int, str]], + ) -> None: + """Append all gate-local twirl annotations before a parallel CX layer.""" + nonlocal gate_twirl_site_idx + if twirl_site_schedule != "before_two_qubit_gate": + return + for control_q, target_q, _label in cx_ops: + emit_gate_local_twirl_site( + target_ops, + gate_twirl_site_idx, + control_q, + target_q, + ) + gate_twirl_site_idx += 1 + # Get CNOT schedule cnot_rounds = compute_cnot_schedule(patch) @@ -299,25 +339,16 @@ def emit_pauli_twirl_site(target_ops: list[SurfaceCircuitStep], site_idx: int) - for rnd_idx, cx_round in enumerate(cnot_rounds): ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"CX round {rnd_idx + 1}")) + cx_ops: list[tuple[int, int, str]] = [] for stab_type, stab_idx, data_idx in cx_round: if stab_type != init_stabilizer_type: continue if stab_type == "X": - ops.append( - SurfaceCircuitStep( - OpType.CX, - [x_anc_q(stab_idx), data_q(data_idx)], - f"X{stab_idx}", - ), - ) + cx_ops.append((x_anc_q(stab_idx), data_q(data_idx), f"X{stab_idx}")) else: - ops.append( - SurfaceCircuitStep( - OpType.CX, - [data_q(data_idx), z_anc_q(stab_idx)], - f"Z{stab_idx}", - ), - ) + cx_ops.append((data_q(data_idx), z_anc_q(stab_idx), f"Z{stab_idx}")) + emit_gate_local_twirl_layer(ops, cx_ops) + ops.extend(SurfaceCircuitStep(OpType.CX, [control, target], label) for control, target, label in cx_ops) ops.append(SurfaceCircuitStep(OpType.TICK)) if init_stabilizer_type == "X": @@ -368,26 +399,17 @@ def emit_pauli_twirl_site(target_ops: list[SurfaceCircuitStep], site_idx: int) - for rnd_idx, cx_round in enumerate(cnot_rounds): ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"CX round {rnd_idx + 1}")) + cx_ops: list[tuple[int, int, str]] = [] for stab_type, stab_idx, data_idx in cx_round: ancilla_q = batch_ancillas.get((stab_type, stab_idx)) if ancilla_q is None: continue if stab_type == "X": - ops.append( - SurfaceCircuitStep( - OpType.CX, - [ancilla_q, data_q(data_idx)], - f"X{stab_idx}", - ), - ) + cx_ops.append((ancilla_q, data_q(data_idx), f"X{stab_idx}")) else: - ops.append( - SurfaceCircuitStep( - OpType.CX, - [data_q(data_idx), ancilla_q], - f"Z{stab_idx}", - ), - ) + cx_ops.append((data_q(data_idx), ancilla_q, f"Z{stab_idx}")) + emit_gate_local_twirl_layer(ops, cx_ops) + ops.extend(SurfaceCircuitStep(OpType.CX, [control, target], label) for control, target, label in cx_ops) ops.append(SurfaceCircuitStep(OpType.TICK)) if init_stabilizer_type == "X": @@ -428,23 +450,14 @@ def emit_pauli_twirl_site(target_ops: list[SurfaceCircuitStep], site_idx: int) - for rnd_idx, cx_round in enumerate(cnot_rounds): ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"CX round {rnd_idx + 1}")) + cx_ops: list[tuple[int, int, str]] = [] for stab_type, stab_idx, data_idx in cx_round: if stab_type == "X": - ops.append( - SurfaceCircuitStep( - OpType.CX, - [x_anc_q(stab_idx), data_q(data_idx)], - f"X{stab_idx}", - ), - ) + cx_ops.append((x_anc_q(stab_idx), data_q(data_idx), f"X{stab_idx}")) else: - ops.append( - SurfaceCircuitStep( - OpType.CX, - [data_q(data_idx), z_anc_q(stab_idx)], - f"Z{stab_idx}", - ), - ) + cx_ops.append((data_q(data_idx), z_anc_q(stab_idx), f"Z{stab_idx}")) + emit_gate_local_twirl_layer(ops, cx_ops) + ops.extend(SurfaceCircuitStep(OpType.CX, [control, target], label) for control, target, label in cx_ops) ops.append(SurfaceCircuitStep(OpType.TICK)) ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on X ancillas")) @@ -489,26 +502,20 @@ def emit_pauli_twirl_site(target_ops: list[SurfaceCircuitStep], site_idx: int) - for rnd_idx, cx_round in enumerate(cnot_rounds): ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"CX round {rnd_idx + 1}")) + cx_ops: list[tuple[int, int, str]] = [] for stab_type, stab_idx, data_idx in cx_round: ancilla_q = batch_ancillas.get((stab_type, stab_idx)) if ancilla_q is None: continue if stab_type == "X": - ops.append( - SurfaceCircuitStep( - OpType.CX, - [ancilla_q, data_q(data_idx)], - f"X{stab_idx}", - ), - ) + cx_ops.append((ancilla_q, data_q(data_idx), f"X{stab_idx}")) else: - ops.append( - SurfaceCircuitStep( - OpType.CX, - [data_q(data_idx), ancilla_q], - f"Z{stab_idx}", - ), - ) + cx_ops.append((data_q(data_idx), ancilla_q, f"Z{stab_idx}")) + emit_gate_local_twirl_layer(ops, cx_ops) + ops.extend( + SurfaceCircuitStep(OpType.CX, [control, target], label) + for control, target, label in cx_ops + ) ops.append(SurfaceCircuitStep(OpType.TICK)) if x_stabilizers_in_batch: @@ -531,8 +538,8 @@ def emit_pauli_twirl_site(target_ops: list[SurfaceCircuitStep], site_idx: int) - ops.append(SurfaceCircuitStep(OpType.TICK)) - if twirl is not None and rnd < num_rounds - 1: - emit_pauli_twirl_site(ops, rnd) + if twirl_site_schedule == "between_rounds" and rnd < num_rounds - 1: + emit_between_round_twirl_site(ops, rnd) # ========================================================================= # measure_z_basis / measure_x_basis @@ -1306,10 +1313,11 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non "Y": PauliString.Y, "Z": PauliString.Z, }.get(kind) - if pauli_ctor is None or sep != "@" or not site_suffix.startswith("s"): + if pauli_ctor is None or sep != "@" or not site_suffix.startswith(("s", "g")): msg = ( "OpType.TRACKED_PAULI requires label of the form " - f"'{{X|Y|Z}}@s', got {op.label!r}" + f"'{{X|Y|Z}}@s' or " + f"'{{X|Y|Z}}@go', got {op.label!r}" ) raise ValueError(msg) circuit.tracked_pauli( diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index e4b360901..8294ecbb7 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -3941,15 +3941,75 @@ def _extract_pauli_masks_from_results( num_rounds: int, num_data: int, num_shots: int, + patch: SurfacePatch | None = None, + basis: str = "Z", + twirl: TwirlConfig | None = None, ) -> NDArray[np.uint8]: """Reconstruct per-shot Pauli-mask codes from Guppy result tags.""" from pecos.qec.surface._twirl_sites import ( mask_col_for, + mask_col_for_gate_operand, num_pauli_sites, + num_pauli_sites_for_schedule, + num_two_qubit_gate_twirl_sites, + pauli_mask_gate_tag, pauli_mask_round_tag, site_idx_for_round, ) + site_schedule = "between_rounds" if twirl is None else twirl.site_schedule + if site_schedule == "before_two_qubit_gate": + if patch is None: + msg = "patch is required to extract before_two_qubit_gate Pauli masks" + raise ValueError(msg) + n_twirl = num_two_qubit_gate_twirl_sites( + patch, + num_rounds=num_rounds, + basis=basis, + ) + out = np.zeros( + ( + num_shots, + num_pauli_sites_for_schedule( + patch, + num_rounds=num_rounds, + basis=basis, + site_schedule="before_two_qubit_gate", + ), + ), + dtype=np.uint8, + ) + for site in range(n_twirl): + tag = pauli_mask_gate_tag(site) + if tag not in results: + msg = ( + f"missing Pauli-mask result tag {tag!r} (expected {n_twirl} gate " + f"tags for num_rounds={num_rounds}, basis={basis!r}); " + "did the program run with gate-local twirl enabled?" + ) + raise ValueError(msg) + per_gate = results[tag] + if len(per_gate) != num_shots: + msg = ( + f"Pauli-mask tag {tag!r}: got {len(per_gate)} shots, " + f"expected {num_shots} shots" + ) + raise ValueError(msg) + + bits = np.asarray(per_gate, dtype=np.uint8) + if bits.ndim != 2 or bits.shape[1] != 4: + msg = ( + f"Pauli-mask tag {tag!r} array has shape {bits.shape}, expected " + f"({num_shots}, 4) = (num_shots, 2*gate_operands)" + ) + raise ValueError(msg) + lo = bits[:, 0::2] + hi = bits[:, 1::2] + packed = (lo + (hi << 1)).astype(np.uint8) + for operand in range(2): + out[:, mask_col_for_gate_operand(site, operand)] = packed[:, operand] + return out + n_twirl = max(0, num_rounds - 1) bits_per_round = 2 * num_data out = np.zeros((num_shots, num_pauli_sites(num_rounds, num_data)), dtype=np.uint8) @@ -4055,4 +4115,7 @@ def sample_pauli_masks_from_guppy( num_rounds=num_rounds, num_data=num_data, num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, ) diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index d0266340a..910ea4336 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -10,7 +10,7 @@ generate_memory_experiment, ) from pecos.qec.surface import GuppyRngMaskConfig, TwirlConfig -from pecos.qec.surface._twirl_sites import pauli_mask_round_tag +from pecos.qec.surface._twirl_sites import pauli_mask_gate_tag, pauli_mask_round_tag from pecos.qec.surface.patch import SurfacePatch @@ -75,6 +75,27 @@ def test_canonical_frame_output_emits_raw_sibling_tags(patch: SurfacePatch) -> N assert 'result("final", array(final_0' in src +def test_gate_local_twirl_source_emits_gate_tags_and_ancilla_frame_tracking( + patch: SurfacePatch, +) -> None: + src = generate_guppy_source( + patch, + twirl=TwirlConfig(site_schedule="before_two_qubit_gate", frame_output="canonical"), + rng=GuppyRngMaskConfig(seed=7), + num_rounds=2, + ) + + assert 'result("frame_mode:canonical", True)' in src + assert f'result("{pauli_mask_gate_tag(0)}", array(' in src + assert "rng_state, m_g0_o0 = _pcg32_next4(rng_state, rng_inc)" in src + assert "x(ax" in src + assert "x(surf.data[" in src + assert "frame_x_ax" in src + assert "frame_z_az" in src + assert "raw:sx" in src + assert 'result("pauli_mask:round:' not in src + + def test_twirl_validation_requires_rng_and_num_rounds(patch: SurfacePatch) -> None: with pytest.raises(ValueError, match="twirl and rng must be supplied together"): generate_guppy_source(patch, twirl=TwirlConfig(), num_rounds=2) @@ -117,29 +138,40 @@ def test_twirled_cache_key_includes_seed_rounds_and_frame_mode(patch: SurfacePat rng=GuppyRngMaskConfig(seed=1), num_rounds=3, ) + gate_local = _guppy_module_cache_key( + patch, + effective_budget=10, + twirl=TwirlConfig(site_schedule="before_two_qubit_gate"), + rng=GuppyRngMaskConfig(seed=1), + num_rounds=2, + ) assert raw != _guppy_module_cache_key(patch, effective_budget=10) assert raw != raw_seed2 assert raw != canonical assert raw != round3 + assert raw != gate_local assert "s1" in raw assert "s2" in raw_seed2 assert "frame-raw" in raw assert "frame-canonical" in canonical + assert "before_two_qubit_gate" in gate_local @pytest.mark.parametrize("basis", ["Z", "X"]) @pytest.mark.parametrize("frame_output", ["raw", "canonical"]) +@pytest.mark.parametrize("site_schedule", ["between_rounds", "before_two_qubit_gate"]) def test_twirled_memory_experiment_compiles( patch: SurfacePatch, basis: str, frame_output: str, + site_schedule: str, ) -> None: fn = generate_memory_experiment( patch, num_rounds=2, basis=basis, - twirl=TwirlConfig(frame_output=frame_output), + twirl=TwirlConfig(site_schedule=site_schedule, frame_output=frame_output), rng=GuppyRngMaskConfig(seed=7), ) assert fn is not None 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 98ebf88c6..828ecb43c 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 @@ -14,7 +14,7 @@ extract_detection_events_and_observables, sample_pauli_masks_from_guppy, ) -from pecos.qec.surface._twirl_sites import num_pauli_sites +from pecos.qec.surface._twirl_sites import num_pauli_sites, num_pauli_sites_for_schedule from pecos.qec.surface.decode import _extract_pauli_masks_from_results pytest.importorskip("guppylang") @@ -143,7 +143,7 @@ def _run_twirled_guppy_rows_masks_and_raw( except TypeError: shot_value = [values] - if name.startswith("pauli_mask:round:"): + if name.startswith("pauli_mask:"): mask_results.setdefault(name, []).append([int(v) for v in shot_value]) elif name.startswith("frame_mode:"): assert len(shot_value) == 1 @@ -159,7 +159,11 @@ def _run_twirled_guppy_rows_masks_and_raw( assert len(shot_value) == 1 bit = int(shot_value[0]) row.append(bit) - if twirl.frame_output == "canonical" and ":init:meas:" in name: + if ( + twirl.frame_output == "canonical" + and twirl.site_schedule == "between_rounds" + and ":init:meas:" in name + ): raw_row.append(bit) elif name == "final": row.extend(int(v) for v in shot_value) @@ -178,6 +182,9 @@ def _run_twirled_guppy_rows_masks_and_raw( num_rounds=num_rounds, num_data=patch.geometry.num_data, num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, ) return measurement_rows, masks, raw_rows, frame_modes @@ -301,6 +308,68 @@ def test_runtime_twirled_theta0_demask_null( assert not observables.any() +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_runtime_gate_local_twirled_theta0_demask_null( + patch_d3: SurfacePatch, + basis: str, +) -> None: + num_rounds = 2 + num_shots = 4 + twirl = TwirlConfig(site_schedule="before_two_qubit_gate") + measurement_rows, masks, _, _ = _run_twirled_guppy_rows_masks_and_raw( + patch_d3, + basis=basis, + num_rounds=num_rounds, + num_shots=num_shots, + rng=GuppyRngMaskConfig(seed=23456), + twirl=twirl, + ) + assert masks.any() + assert masks.shape == ( + num_shots, + num_pauli_sites_for_schedule( + patch_d3, + num_rounds=num_rounds, + basis=basis, + site_schedule="before_two_qubit_gate", + ), + ) + + tick_circuit = build_memory_circuit( + patch=patch_d3, + rounds=num_rounds, + basis=basis, + twirl=twirl, + ) + sampler = build_native_sampler( + patch_d3, + num_rounds=num_rounds, + noise=NoiseModel(), + basis=basis, + twirl=twirl, + ) + assert sampler.pauli_frame_lookup is not None + assert sampler.num_pauli_sites == masks.shape[1] + + events_per_shot, obs_per_shot = extract_detection_events_and_observables( + tick_circuit, + measurement_rows, + ) + raw_events = _bool_array_from_indices(events_per_shot, sampler.num_detectors) + raw_obs = _bool_array_from_indices(obs_per_shot, sampler.num_observables) + assert raw_events.any() or raw_obs.any() + + events, observables = demask_pauli_frame_records( + sampler.pauli_frame_lookup, + raw_events, + raw_obs, + masks, + ) + + assert not events.any() + assert not observables.any() + + def _assert_canonical_frame_output_matches_lookup( patch: SurfacePatch, *, @@ -375,6 +444,71 @@ def _assert_canonical_frame_output_matches_lookup( assert not observables.any() +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_runtime_gate_local_canonical_frame_output_matches_lookup( + patch_d3: SurfacePatch, + basis: str, +) -> None: + num_rounds = 2 + num_shots = 4 + twirl = TwirlConfig(site_schedule="before_two_qubit_gate", frame_output="canonical") + measurement_rows, masks, raw_rows, frame_modes = _run_twirled_guppy_rows_masks_and_raw( + patch_d3, + basis=basis, + num_rounds=num_rounds, + num_shots=num_shots, + rng=GuppyRngMaskConfig(seed=34567), + twirl=twirl, + ) + + assert frame_modes == ["canonical"] * num_shots + assert masks.any() + assert raw_rows + + abstract_twirl = TwirlConfig(site_schedule="before_two_qubit_gate") + tick_circuit = build_memory_circuit( + patch=patch_d3, + rounds=num_rounds, + basis=basis, + twirl=abstract_twirl, + ) + sampler = build_native_sampler( + patch_d3, + num_rounds=num_rounds, + noise=NoiseModel(), + basis=basis, + twirl=abstract_twirl, + ) + assert sampler.pauli_frame_lookup is not None + + raw_events_per_shot, raw_obs_per_shot = extract_detection_events_and_observables( + tick_circuit, + raw_rows, + ) + raw_events = _bool_array_from_indices(raw_events_per_shot, sampler.num_detectors) + raw_obs = _bool_array_from_indices(raw_obs_per_shot, sampler.num_observables) + assert raw_events.any() or raw_obs.any() + + demasked_events, demasked_obs = demask_pauli_frame_records( + sampler.pauli_frame_lookup, + raw_events, + raw_obs, + masks, + ) + + events_per_shot, obs_per_shot = extract_detection_events_and_observables( + tick_circuit, + measurement_rows, + ) + events = _bool_array_from_indices(events_per_shot, sampler.num_detectors) + observables = _bool_array_from_indices(obs_per_shot, sampler.num_observables) + + np.testing.assert_array_equal(events, demasked_events) + np.testing.assert_array_equal(observables, demasked_obs) + assert not events.any() + assert not observables.any() + + @pytest.mark.parametrize("basis", ["Z", "X"]) def test_runtime_canonical_frame_output_theta0_null_and_raw_equivalence( patch_d3: SurfacePatch, 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 e74c1dbda..356c93875 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 @@ -14,7 +14,11 @@ ) from pecos.qec.surface._twirl_sites import ( mask_col_for, + mask_col_for_gate_operand, num_pauli_sites, + num_pauli_sites_for_schedule, + num_two_qubit_gate_twirl_sites, + pauli_mask_gate_tag, pauli_mask_round_tag, ) from pecos.qec.surface.decode import ( @@ -57,6 +61,38 @@ def test_extract_pauli_masks_rejects_missing_or_misshaped_tags() -> None: ) +def test_extract_gate_local_pauli_masks_packs_operand_order() -> None: + patch = SurfacePatch.create(distance=3) + results = { + pauli_mask_gate_tag(site): [[0, 0, 0, 0]] + for site in range(num_two_qubit_gate_twirl_sites(patch, num_rounds=1, basis="Z")) + } + results[pauli_mask_gate_tag(0)] = [[1, 0, 0, 1]] + + mask = _extract_pauli_masks_from_results( + results, + num_rounds=1, + num_data=patch.geometry.num_data, + num_shots=1, + patch=patch, + basis="Z", + twirl=TwirlConfig(site_schedule="before_two_qubit_gate"), + ) + + assert mask.dtype == np.uint8 + assert mask.shape == ( + 1, + num_pauli_sites_for_schedule( + patch, + num_rounds=1, + basis="Z", + site_schedule="before_two_qubit_gate", + ), + ) + assert mask[0, mask_col_for_gate_operand(0, 0)] == 1 + assert mask[0, mask_col_for_gate_operand(0, 1)] == 2 + + def test_demask_helper_cancels_known_pauli_frame_xor() -> None: patch = SurfacePatch.create(distance=3) sampler = build_native_sampler( @@ -250,6 +286,29 @@ def test_twirling_does_not_change_canonical_dem( assert twirled == untwirled +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) + + untwirled = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + basis="Z", + decompose_errors=True, + ) + twirled = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + basis="Z", + decompose_errors=True, + twirl=TwirlConfig(site_schedule="before_two_qubit_gate"), + ) + + assert twirled == untwirled + + def test_surface_traced_qis_rejects_twirl_with_semantic_message() -> None: patch = SurfacePatch.create(distance=3) @@ -293,6 +352,41 @@ def test_tracked_pauli_label_order_matches_mask_col_for( assert tracked[base + offset]["label"] == f"twirl_s{site}_q{q}_{kind}" +def test_gate_local_tracked_pauli_label_order_matches_gate_operand_cols() -> None: + from pecos.qec.surface.schedule import compute_cnot_schedule + + patch = SurfacePatch.create(distance=3) + num_rounds = 2 + tc = build_memory_circuit( + patch=patch, + rounds=num_rounds, + basis="Z", + twirl=TwirlConfig(site_schedule="before_two_qubit_gate"), + ) + tracked = [a for a in tc.annotations() if a["kind"] == "tracked_pauli"] + expected_cols = num_pauli_sites_for_schedule( + patch, + num_rounds=num_rounds, + basis="Z", + site_schedule="before_two_qubit_gate", + ) + assert len(tracked) == 3 * expected_cols + + first_init_x_gate = next( + (stab_idx, data_idx) + for cx_round in compute_cnot_schedule(patch) + for stab_type, stab_idx, data_idx in cx_round + if stab_type == "X" + ) + stab_idx, data_idx = first_init_x_gate + first_control = patch.geometry.num_data + stab_idx + expected_first_labels = [ + *(f"twirl_g0o0_q{first_control}_{kind}" for kind in ("X", "Y", "Z")), + *(f"twirl_g0o1_q{data_idx}_{kind}" for kind in ("X", "Y", "Z")), + ] + assert [tracked[i]["label"] for i in range(6)] == expected_first_labels + + def test_raw_twirled_guppy_trace_result_provenance_ignores_sideband_tags() -> None: pytest.importorskip("guppylang") pytest.importorskip("selene_sim") From 96e4313d43ba28718caaa8422b4efa6fd8e2c523 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 12:07:43 -0600 Subject: [PATCH 066/150] Add gate-local twirl mask variation guard --- .../tests/qec/surface/test_pauli_mask_harvest.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 828ecb43c..26043fa3a 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 @@ -222,6 +222,20 @@ def test_runtime_twirl_masks_vary_across_shots(patch_d3: SurfacePatch) -> None: assert unique_rows.shape[0] >= 2 +def test_runtime_gate_local_twirl_masks_vary_across_shots(patch_d3: SurfacePatch) -> None: + masks = sample_pauli_masks_from_guppy( + patch_d3, + num_rounds=2, + num_shots=6, + basis="Z", + twirl=TwirlConfig(site_schedule="before_two_qubit_gate"), + rng=GuppyRngMaskConfig(seed=12), + ) + + unique_rows = np.unique(masks, axis=0) + assert unique_rows.shape[0] >= 2 + + def test_same_seed_is_reproducible(patch_d3: SurfacePatch) -> None: kwargs = { "num_rounds": 3, From b8414f4a17d78109ee17cbd8cf22d5cddf11d15d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 14:04:24 -0600 Subject: [PATCH 067/150] Add scaled Guppy Pauli twirl activation Add TwirlConfig.twirl_probability for runtime twirl activation, with fixed per-site RNG consumption so same-seed runs remain pairable across activation probabilities. Scaled runs emit explicit pauli_active side-band tags while legacy f=1.0 bundles keep the existing pauli_mask schema. Normalize twirl_probability out of abstract DEM and lookup caching, include its threshold in the Guppy module cache key, and validate inactive sites cannot record non-identity Pauli codes. Note: fixed RNG consumption changes exact pre-change same-seed mask streams; same-seed reproducibility within a build is unaffected. --- .../quantum-pecos/src/pecos/guppy/surface.py | 134 +++++++++--- .../src/pecos/qec/surface/__init__.py | 2 + .../src/pecos/qec/surface/_twirl_config.py | 37 +++- .../src/pecos/qec/surface/_twirl_sites.py | 12 + .../src/pecos/qec/surface/decode.py | 207 +++++++++++++++++- .../tests/guppy/test_surface_twirl_render.py | 53 ++++- .../qec/surface/test_pauli_mask_harvest.py | 193 +++++++++++++++- .../qec/surface/test_pauli_twirl_handoff.py | 103 ++++++++- 8 files changed, 689 insertions(+), 52 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 7eed802d4..6f54c22e0 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -68,13 +68,20 @@ def _render_inline_pcg32() -> list[str]: "", "@guppy", "@no_type_check", - "def _pcg32_next4(state: nat, inc: nat) -> tuple[nat, int]:", + "def _pcg32_next32(state: nat, inc: nat) -> tuple[nat, nat]:", " old_state = state", " new_state = _pcg32_advance(state, inc)", " xorshifted = _pcg32_mask32(((old_state >> nat(18)) ^ old_state) >> nat(27))", " rot = _pcg32_mask32(old_state >> nat(59))", " rot_inv = _pcg32_mask32((~rot + nat(1)) & nat(31))", " output = _pcg32_mask32((xorshifted >> rot) | (xorshifted << rot_inv))", + " return new_state, output", + "", + "", + "@guppy", + "@no_type_check", + "def _pcg32_next4(state: nat, inc: nat) -> tuple[nat, int]:", + " new_state, output = _pcg32_next32(state, inc)", " return new_state, int(output & nat(3))", "", "", @@ -724,6 +731,45 @@ def _render_plain_memory_block( ] +def _twirl_activation_threshold(twirl: "TwirlConfig") -> int: + """Return the full-width PCG threshold for a twirl activation probability.""" + probability = float(twirl.twirl_probability) + # Validation lives on TwirlConfig; clamp the mathematically exact endpoint + # after float multiplication so f=1.0 always activates every 32-bit draw. + threshold = int(probability * (1 << 32)) + return min(max(threshold, 0), 1 << 32) + + +def _emit_scaled_activation_tags(twirl: "TwirlConfig") -> bool: + """Whether generated source needs explicit activation side-band tags.""" + return float(twirl.twirl_probability) != 1.0 + + +def _append_pauli_draw( + lines: list[str], + indent: str, + *, + active_var: str, + draw_var: str, + m_var: str, + qubit_expr: str, + threshold: int, +) -> None: + """Emit fixed-consumption activation + Pauli draw code for one twirl operand.""" + lines.append(f"{indent}rng_state, active_draw_{m_var} = _pcg32_next32(rng_state, rng_inc)") + lines.append(f"{indent}{active_var} = active_draw_{m_var} < nat({threshold})") + lines.append(f"{indent}rng_state, {draw_var} = _pcg32_next4(rng_state, rng_inc)") + lines.append(f"{indent}{m_var} = 0") + lines.append(f"{indent}if {active_var}:") + lines.append(f"{indent} {m_var} = {draw_var}") + lines.append(f"{indent}if {m_var} == 1:") + lines.append(f"{indent} x({qubit_expr})") + lines.append(f"{indent}if {m_var} == 2:") + lines.append(f"{indent} y({qubit_expr})") + lines.append(f"{indent}if {m_var} == 3:") + lines.append(f"{indent} z({qubit_expr})") + + def _render_twirled_memory_block( basis: str, basis_upper: str, @@ -735,10 +781,12 @@ def _render_twirled_memory_block( num_rounds: int, ) -> list[str]: """Render a Python-time unrolled twirled memory factory.""" - from pecos.qec.surface._twirl_sites import num_twirl_sites, pauli_mask_round_tag + from pecos.qec.surface._twirl_sites import num_twirl_sites, pauli_active_round_tag, pauli_mask_round_tag seed = int(rng.seed) canonical_frame_output = twirl.frame_output == "canonical" + activation_threshold = _twirl_activation_threshold(twirl) + emit_activation_tags = _emit_scaled_activation_tags(twirl) init_func = "init_z_basis" if basis == "z" else "init_x_basis" init_tag = "init_synx" if basis == "z" else "init_synz" body: list[str] = [ @@ -774,13 +822,15 @@ def _render_twirled_memory_block( body.append(' result("synz", syn.synz)') body.append(" # Pauli twirl site between this round and the next.") for q in range(num_data): - body.append(f" rng_state, m_{r}_{q} = _pcg32_next4(rng_state, rng_inc)") - body.append(f" if m_{r}_{q} == 1:") - body.append(f" x(surf.data[{q}])") - body.append(f" if m_{r}_{q} == 2:") - body.append(f" y(surf.data[{q}])") - body.append(f" if m_{r}_{q} == 3:") - body.append(f" z(surf.data[{q}])") + _append_pauli_draw( + body, + " ", + active_var=f"active_{r}_{q}", + draw_var=f"m_draw_{r}_{q}", + m_var=f"m_{r}_{q}", + qubit_expr=f"surf.data[{q}]", + threshold=activation_threshold, + ) body.append(f" lo_{r}_{q} = (m_{r}_{q} == 1) | (m_{r}_{q} == 3)") body.append(f" hi_{r}_{q} = (m_{r}_{q} == 2) | (m_{r}_{q} == 3)") if canonical_frame_output: @@ -795,6 +845,10 @@ def _render_twirled_memory_block( elements = ", ".join(f"lo_{r}_{q}, hi_{r}_{q}" for q in range(num_data)) tag = pauli_mask_round_tag(r) body.append(f' result("{tag}", array({elements}))') + if emit_activation_tags: + active_elements = ", ".join(f"active_{r}_{q}" for q in range(num_data)) + active_tag = pauli_active_round_tag(r) + body.append(f' result("{active_tag}", array({active_elements}))') body.append("") if num_rounds > 0: @@ -862,17 +916,22 @@ def _append_gate_local_draw( operand_idx: int, qubit_expr: str, frame_vars: tuple[str, str] | None, -) -> tuple[str, str]: + threshold: int, +) -> tuple[str, str, str]: m_var = f"m_g{site_idx}_o{operand_idx}" + active_var = f"active_g{site_idx}_o{operand_idx}" + draw_var = f"m_draw_g{site_idx}_o{operand_idx}" lo_var = f"lo_g{site_idx}_o{operand_idx}" hi_var = f"hi_g{site_idx}_o{operand_idx}" - lines.append(f"{indent}rng_state, {m_var} = _pcg32_next4(rng_state, rng_inc)") - lines.append(f"{indent}if {m_var} == 1:") - lines.append(f"{indent} x({qubit_expr})") - lines.append(f"{indent}if {m_var} == 2:") - lines.append(f"{indent} y({qubit_expr})") - lines.append(f"{indent}if {m_var} == 3:") - lines.append(f"{indent} z({qubit_expr})") + _append_pauli_draw( + lines, + indent, + active_var=active_var, + draw_var=draw_var, + m_var=m_var, + qubit_expr=qubit_expr, + threshold=threshold, + ) lines.append(f"{indent}{lo_var} = ({m_var} == 1) | ({m_var} == 3)") lines.append(f"{indent}{hi_var} = ({m_var} == 2) | ({m_var} == 3)") if frame_vars is not None: @@ -883,7 +942,7 @@ def _append_gate_local_draw( lines.append(f"{indent}{twz_var} = ({m_var} == 2) | ({m_var} == 3)") lines.append(f"{indent}{x_frame} = {x_frame} != {twx_var}") lines.append(f"{indent}{z_frame} = {z_frame} != {twz_var}") - return lo_var, hi_var + return lo_var, hi_var, active_var def _append_gate_local_layer( @@ -892,29 +951,36 @@ def _append_gate_local_layer( *, site_idx: int, cx_ops: list[tuple[str, str, tuple[str, str] | None, tuple[str, str] | None]], + threshold: int, + emit_activation_tags: bool, ) -> int: """Emit all twirl draws before a parallel CX layer, then the CX layer.""" - from pecos.qec.surface._twirl_sites import pauli_mask_gate_tag + from pecos.qec.surface._twirl_sites import pauli_active_gate_tag, pauli_mask_gate_tag for control_expr, target_expr, control_frame, target_frame in cx_ops: - lo0, hi0 = _append_gate_local_draw( + lo0, hi0, active0 = _append_gate_local_draw( lines, indent, site_idx=site_idx, operand_idx=0, qubit_expr=control_expr, frame_vars=control_frame, + threshold=threshold, ) - lo1, hi1 = _append_gate_local_draw( + lo1, hi1, active1 = _append_gate_local_draw( lines, indent, site_idx=site_idx, operand_idx=1, qubit_expr=target_expr, frame_vars=target_frame, + threshold=threshold, ) tag = pauli_mask_gate_tag(site_idx) lines.append(f'{indent}result("{tag}", array({lo0}, {hi0}, {lo1}, {hi1}))') + if emit_activation_tags: + active_tag = pauli_active_gate_tag(site_idx) + lines.append(f'{indent}result("{active_tag}", array({active0}, {active1}))') site_idx += 1 for control_expr, target_expr, control_frame, target_frame in cx_ops: @@ -962,6 +1028,8 @@ def _render_gate_local_twirled_memory_block( """Render a gate-local twirled memory factory.""" seed = int(rng.seed) canonical_frame_output = twirl.frame_output == "canonical" + activation_threshold = _twirl_activation_threshold(twirl) + emit_activation_tags = _emit_scaled_activation_tags(twirl) geom = patch.geometry rounds = compute_cnot_schedule(patch) init_stab_type = "X" if basis == "z" else "Z" @@ -1045,7 +1113,14 @@ def cx_tuple( body.append("") body.append(f"{indent}# Init CX round {rnd_idx + 1}") cx_ops = [cx_tuple(stab_type, stab_idx, data_q) for stab_type, stab_idx, data_q in filtered] - site_idx = _append_gate_local_layer(body, indent, site_idx=site_idx, cx_ops=cx_ops) + site_idx = _append_gate_local_layer( + body, + indent, + site_idx=site_idx, + cx_ops=cx_ops, + threshold=activation_threshold, + emit_activation_tags=emit_activation_tags, + ) if init_stab_type == "X": body.append("") @@ -1096,7 +1171,14 @@ def cx_tuple( body.append("") body.append(f"{indent}# Round {round_idx} CX layer {rnd_idx + 1}") cx_ops = [cx_tuple(stab_type, stab_idx, data_q) for stab_type, stab_idx, data_q in rnd_gates] - site_idx = _append_gate_local_layer(body, indent, site_idx=site_idx, cx_ops=cx_ops) + site_idx = _append_gate_local_layer( + body, + indent, + site_idx=site_idx, + cx_ops=cx_ops, + threshold=activation_threshold, + emit_activation_tags=emit_activation_tags, + ) for stab in geom.x_stabilizers: body.append(f"{indent}h(ax{stab.index})") @@ -1205,7 +1287,7 @@ def _guppy_module_cache_key( Twirled source is Python-time unrolled, so the cache key includes ``num_rounds`` in addition to the structural twirl fields, runtime - frame-output mode, and RNG seed. + frame-output mode, RNG seed, and activation-probability threshold. """ geom = patch.geometry rotated = "rot" if geom.rotated else "unrot" @@ -1218,6 +1300,7 @@ def _guppy_module_cache_key( twirl_part = ( f"t-{twirl.scheme}-{twirl.site_schedule}-{twirl.result_encoding}" f"-frame-{twirl.frame_output}" + f"-p{_twirl_activation_threshold(twirl)}" f"-s{int(rng.seed)}-r{int(num_rounds)}" ) return f"{base}_{twirl_part}" @@ -1238,7 +1321,8 @@ def _load_guppy_module( ``normalize_ancilla_budget``), so ``ancilla_budget=None`` and ``ancilla_budget >= total_ancilla`` resolve to the same cache entry while distinct patch geometries never collide. Twirled source also - keys on twirl fields, frame-output mode, RNG seed, and round count. + keys on twirl fields, frame-output mode, activation-probability threshold, + RNG seed, and round count. Args: patch: SurfacePatch with geometry diff --git a/python/quantum-pecos/src/pecos/qec/surface/__init__.py b/python/quantum-pecos/src/pecos/qec/surface/__init__.py index 27130f05c..925d93684 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/surface/__init__.py @@ -65,6 +65,7 @@ generate_repetition_code_dem, generate_surface_code_dem, run_noisy_memory_experiment, + sample_pauli_activations_from_guppy, sample_pauli_masks_from_guppy, surface_code_memory, syndromes_to_detection_events, @@ -163,6 +164,7 @@ "generate_repetition_code_dem", "generate_surface_code_dem", "run_noisy_memory_experiment", + "sample_pauli_activations_from_guppy", "sample_pauli_masks_from_guppy", "surface_code_memory", "syndromes_to_detection_events", diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py index 3bfab8fa4..c24fdf78e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py @@ -3,17 +3,27 @@ `TwirlConfig` carries the twirl-site declaration: scheme, where in the circuit twirling sites are emitted, how the per-shot mask is encoded into the runtime result bundle after the corresponding physical Pauli gates -are applied, and how generated Guppy measurement records are framed. The -first three fields are structural for abstract DEM / topology caches. -`frame_output` is runtime-only: raw and canonical Guppy records share the -same abstract DEM and `PauliFrameLookup`. +are applied, how generated Guppy measurement records are framed, and the +runtime activation probability. `scheme`, `site_schedule`, and +`result_encoding` are structural for abstract DEM / topology caches. +`frame_output` and `twirl_probability` are runtime-only: raw/canonical and +scaled/unscaled Guppy records share the same abstract DEM and +`PauliFrameLookup`. + +Scaled twirl uses fixed RNG consumption: generated Guppy draws both an +activation decision and a Pauli code at every site, even when inactive. This +intentionally changed the exact seed-to-mask stream from the pre-scaled +implementation, but preserves same-seed reproducibility within one build and +enables common-random-number comparisons across different activation +probabilities. `GuppyRngMaskConfig` carries the **runtime** mask source: a stream-separator seed mixed with 32 bits of per-shot quantum entropy when the mask is drawn, applied to data qubits, and recorded via `result()`. Two abstract circuits -identical except for `seed` or `frame_output` reuse the same DEM but produce -different shot-level runtime records, so those values belong in the Guppy- -module / compiled-shot cache layer but NOT in the abstract DEM cache. +identical except for `seed`, `frame_output`, or `twirl_probability` reuse the +same DEM but produce different shot-level runtime records, so those values +belong in the Guppy-module / compiled-shot cache layer but NOT in the abstract +DEM cache. The split mirrors the two-tracks-per-twirl-setting architecture from the design doc: the abstract circuit (consumer: DEM builder, @@ -65,12 +75,18 @@ class TwirlConfig: frame. This does not change the abstract circuit or DEM topology; it only changes generated runtime records and must therefore be part of the Guppy module cache key. + twirl_probability: Per-site activation probability. Runtime Guppy + source draws an activation bit and a Pauli code at every twirl + site. If inactive, the recorded Pauli code is identity. This + changes runtime records and generated source, but not the + abstract DEM / `PauliFrameLookup` structure. """ scheme: Literal["pauli"] = "pauli" site_schedule: Literal["between_rounds", "before_two_qubit_gate"] = "between_rounds" result_encoding: Literal["bool_array_v1"] = "bool_array_v1" frame_output: Literal["raw", "canonical"] = "raw" + twirl_probability: float = 1.0 def validate_runtime_supported(self) -> None: """Raise ``ValueError`` if any field is outside the supported runtime set. @@ -106,6 +122,13 @@ def validate_runtime_supported(self) -> None: f"supported; expected one of {_SUPPORTED_FRAME_OUTPUTS!r}" ) raise ValueError(msg) + probability = float(self.twirl_probability) + if not 0.0 <= probability <= 1.0: + msg = ( + f"TwirlConfig.twirl_probability={self.twirl_probability!r} " + "is not supported; expected a finite probability in [0, 1]" + ) + raise ValueError(msg) def _validate_runtime_supported(self) -> None: """Compatibility alias for the public runtime validator.""" diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py index e60692578..02b3663e4 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py @@ -44,6 +44,7 @@ ``(site, qubit)`` order so the output columns match the abstract ``PauliFrameLookup`` byte-for-byte. - Side-band result tags emitted by twirl support (`pauli_mask:*`, + `pauli_active:*`, `frame_mode:*`, `raw:*`) are not detector-bearing measurement tags. They must never contain ``":meas:"`` and must never be exactly ``"final"``; handoff result-provenance code treats only the surface @@ -64,6 +65,7 @@ # call per twirl site so each tag fires exactly once per shot (avoids the # same-tag-multi-call overwrite trap in `ShotVec.to_dict()`). PAULI_MASK_TAG_PREFIX = "pauli_mask" +PAULI_ACTIVE_TAG_PREFIX = "pauli_active" # Backwards-compatible alias for callers that constructed the bare tag. PAULI_MASK_TAG = PAULI_MASK_TAG_PREFIX @@ -83,6 +85,16 @@ def pauli_mask_gate_tag(site_idx: int) -> str: return f"{PAULI_MASK_TAG_PREFIX}:gate:{site_idx}" +def pauli_active_round_tag(round_idx: int) -> str: + """Return the canonical per-round twirl-activation result tag.""" + return f"{PAULI_ACTIVE_TAG_PREFIX}:round:{round_idx}" + + +def pauli_active_gate_tag(site_idx: int) -> str: + """Return the canonical per-two-qubit-gate activation result tag.""" + return f"{PAULI_ACTIVE_TAG_PREFIX}:gate:{site_idx}" + + def num_twirl_sites(num_rounds: int) -> int: """Number of twirl sites for the `between_rounds` schedule. diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 8294ecbb7..06f418bff 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -300,7 +300,7 @@ def _abstract_twirl_config(twirl: TwirlConfig | None) -> TwirlConfig | None: if twirl is None: return None twirl.validate_runtime_supported() - return replace(twirl, frame_output="raw") + return replace(twirl, frame_output="raw", twirl_probability=1.0) def _twirl_traced_qis_rejection_message() -> str: @@ -733,7 +733,7 @@ def _index_surface_result_trace_ids( def _is_surface_sideband_result_tag(name: str) -> bool: """Return true for non-detector-bearing surface result tags.""" - return name.startswith(("pauli_mask:", "frame_mode:", "raw:")) + return name.startswith(("pauli_mask:", "pauli_active:", "frame_mode:", "raw:")) def _surface_abstract_measurement_result_refs(abstract_tc: Any) -> list[tuple[str, str] | tuple[str, str, int]]: @@ -4008,6 +4008,18 @@ def _extract_pauli_masks_from_results( packed = (lo + (hi << 1)).astype(np.uint8) for operand in range(2): out[:, mask_col_for_gate_operand(site, operand)] = packed[:, operand] + active = _extract_pauli_activations_from_results( + results, + num_rounds=num_rounds, + num_data=num_data, + num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, + ) + if np.any((~active) & (out != 0)): + msg = "malformed Pauli twirl bundle: inactive gate-local site recorded a non-identity Pauli" + raise ValueError(msg) return out n_twirl = max(0, num_rounds - 1) @@ -4046,10 +4058,131 @@ def _extract_pauli_masks_from_results( for q in range(num_data): out[:, mask_col_for(site, q, num_data)] = packed[:, q] + active = _extract_pauli_activations_from_results( + results, + num_rounds=num_rounds, + num_data=num_data, + num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, + ) + if np.any((~active) & (out != 0)): + msg = "malformed Pauli twirl bundle: inactive round site recorded a non-identity Pauli" + raise ValueError(msg) return out -def sample_pauli_masks_from_guppy( +def _extract_pauli_activations_from_results( + results: dict[str, Any], + *, + num_rounds: int, + num_data: int, + num_shots: int, + patch: SurfacePatch | None = None, + basis: str = "Z", + twirl: TwirlConfig | None = None, +) -> NDArray[np.bool_]: + """Reconstruct per-shot twirl activation bits from Guppy result tags. + + Legacy `twirl_probability=1.0` bundles have no activation tags; they are + interpreted as active at every site. Scaled-twirl bundles must carry explicit + activation tags so skipped sites and active identity draws remain auditable. + """ + from pecos.qec.surface._twirl_sites import ( + mask_col_for, + mask_col_for_gate_operand, + num_pauli_sites, + num_pauli_sites_for_schedule, + num_two_qubit_gate_twirl_sites, + pauli_active_gate_tag, + pauli_active_round_tag, + site_idx_for_round, + ) + + site_schedule = "between_rounds" if twirl is None else twirl.site_schedule + probability = 1.0 if twirl is None else float(twirl.twirl_probability) + has_active_tags = any(str(name).startswith("pauli_active:") for name in results) + require_active_tags = has_active_tags or probability != 1.0 + + if site_schedule == "before_two_qubit_gate": + if patch is None: + msg = "patch is required to extract before_two_qubit_gate Pauli activations" + raise ValueError(msg) + n_twirl = num_two_qubit_gate_twirl_sites( + patch, + num_rounds=num_rounds, + basis=basis, + ) + out = np.ones( + ( + num_shots, + num_pauli_sites_for_schedule( + patch, + num_rounds=num_rounds, + basis=basis, + site_schedule="before_two_qubit_gate", + ), + ), + dtype=bool, + ) + if not require_active_tags: + return out + out[...] = False + for site in range(n_twirl): + tag = pauli_active_gate_tag(site) + if tag not in results: + msg = f"missing Pauli-activation result tag {tag!r}" + raise ValueError(msg) + per_gate = results[tag] + if len(per_gate) != num_shots: + msg = ( + f"Pauli-activation tag {tag!r}: got {len(per_gate)} shots, " + f"expected {num_shots} shots" + ) + raise ValueError(msg) + bits = np.asarray(per_gate, dtype=bool) + if bits.ndim != 2 or bits.shape[1] != 2: + msg = ( + f"Pauli-activation tag {tag!r} array has shape {bits.shape}, " + f"expected ({num_shots}, 2) = (num_shots, gate_operands)" + ) + raise ValueError(msg) + for operand in range(2): + out[:, mask_col_for_gate_operand(site, operand)] = bits[:, operand] + return out + + n_twirl = max(0, num_rounds - 1) + out = np.ones((num_shots, num_pauli_sites(num_rounds, num_data)), dtype=bool) + if not require_active_tags: + return out + out[...] = False + for r in range(n_twirl): + tag = pauli_active_round_tag(r) + if tag not in results: + msg = f"missing Pauli-activation result tag {tag!r}" + raise ValueError(msg) + per_round = results[tag] + if len(per_round) != num_shots: + msg = ( + f"Pauli-activation tag {tag!r}: got {len(per_round)} shots, " + f"expected {num_shots} shots" + ) + raise ValueError(msg) + bits = np.asarray(per_round, dtype=bool) + if bits.ndim != 2 or bits.shape[1] != num_data: + msg = ( + f"Pauli-activation tag {tag!r} array has shape {bits.shape}, " + f"expected ({num_shots}, {num_data}) = (num_shots, num_data)" + ) + raise ValueError(msg) + site = site_idx_for_round(r) + for q in range(num_data): + out[:, mask_col_for(site, q, num_data)] = bits[:, q] + return out + + +def _sample_pauli_sideband_results_from_guppy( patch: SurfacePatch, *, num_rounds: int, @@ -4058,8 +4191,8 @@ def sample_pauli_masks_from_guppy( twirl: TwirlConfig, rng: Any, ancilla_budget: int | None = None, -) -> NDArray[np.uint8]: - """Run the Guppy memory program with twirling and harvest mask columns.""" +) -> dict[str, list[list[Any]]]: + """Run the Guppy memory program with twirling and harvest side-band tags.""" from selene_sim import SimpleRuntime, Stim, build from pecos.compilation_pipeline import compile_guppy_to_hugr @@ -4073,8 +4206,6 @@ def sample_pauli_masks_from_guppy( msg = f"num_rounds must be >= 1, got {num_rounds}" raise ValueError(msg) - num_data = patch.geometry.num_data - fn = generate_memory_experiment( patch, num_rounds=num_rounds, @@ -4108,7 +4239,33 @@ def sample_pauli_masks_from_guppy( shot_value = list(values) except TypeError: shot_value = [values] - results.setdefault(name, []).append(shot_value) + if name.startswith(("pauli_mask:", "pauli_active:")): + results.setdefault(name, []).append(shot_value) + return results + + +def sample_pauli_masks_from_guppy( + patch: SurfacePatch, + *, + num_rounds: int, + num_shots: int, + basis: str, + twirl: TwirlConfig, + rng: Any, + ancilla_budget: int | None = None, +) -> NDArray[np.uint8]: + """Run the Guppy memory program with twirling and harvest mask columns.""" + twirl.validate_runtime_supported() + num_data = patch.geometry.num_data + results = _sample_pauli_sideband_results_from_guppy( + patch, + num_rounds=num_rounds, + num_shots=num_shots, + basis=basis, + twirl=twirl, + rng=rng, + ancilla_budget=ancilla_budget, + ) return _extract_pauli_masks_from_results( results, @@ -4119,3 +4276,37 @@ def sample_pauli_masks_from_guppy( basis=basis, twirl=twirl, ) + + +def sample_pauli_activations_from_guppy( + patch: SurfacePatch, + *, + num_rounds: int, + num_shots: int, + basis: str, + twirl: TwirlConfig, + rng: Any, + ancilla_budget: int | None = None, +) -> NDArray[np.bool_]: + """Run the Guppy memory program with twirling and harvest activation bits.""" + twirl.validate_runtime_supported() + num_data = patch.geometry.num_data + results = _sample_pauli_sideband_results_from_guppy( + patch, + num_rounds=num_rounds, + num_shots=num_shots, + basis=basis, + twirl=twirl, + rng=rng, + ancilla_budget=ancilla_budget, + ) + + return _extract_pauli_activations_from_results( + results, + num_rounds=num_rounds, + num_data=num_data, + num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, + ) diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index 910ea4336..4799ef520 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -10,7 +10,12 @@ generate_memory_experiment, ) from pecos.qec.surface import GuppyRngMaskConfig, TwirlConfig -from pecos.qec.surface._twirl_sites import pauli_mask_gate_tag, pauli_mask_round_tag +from pecos.qec.surface._twirl_sites import ( + pauli_active_gate_tag, + pauli_active_round_tag, + pauli_mask_gate_tag, + pauli_mask_round_tag, +) from pecos.qec.surface.patch import SurfacePatch @@ -38,13 +43,18 @@ def test_twirl_source_unrolls_rng_masks_and_runtime_paulis(patch: SurfacePatch) num_rounds=3, ) + assert "def _pcg32_next32(state: nat, inc: nat) -> tuple[nat, nat]:" in src assert "def _pcg32_next4(state: nat, inc: nat) -> tuple[nat, int]:" in src assert "def seeded_pcg32_with_quantum_entropy(seed: int) -> tuple[nat, nat]:" in src assert "entropy_q = qubit()" in src assert "if measure(entropy_q):" in src assert src.count("rng_state, rng_inc = seeded_pcg32_with_quantum_entropy(42)") == 2 assert src.count('result("frame_mode:raw", True)') == 2 - assert "rng_state, m_0_0 = _pcg32_next4(rng_state, rng_inc)" in src + assert "rng_state, active_draw_m_0_0 = _pcg32_next32(rng_state, rng_inc)" in src + assert "active_0_0 = active_draw_m_0_0 < nat(4294967296)" in src + assert "rng_state, m_draw_0_0 = _pcg32_next4(rng_state, rng_inc)" in src + assert "if active_0_0:" in src + assert " m_0_0 = m_draw_0_0" in src assert "if m_0_0 == 1:" in src assert " x(surf.data[0])" in src assert "if m_0_0 == 2:" in src @@ -54,9 +64,23 @@ def test_twirl_source_unrolls_rng_masks_and_runtime_paulis(patch: SurfacePatch) for r in range(2): assert src.count(f'result("{pauli_mask_round_tag(r)}"') == 2 + assert f'result("{pauli_active_round_tag(r)}"' not in src assert src.count("# === Round 2 (final, no twirl after) ===") == 2 +def test_scaled_twirl_source_emits_activation_tags_and_threshold(patch: SurfacePatch) -> None: + src = generate_guppy_source( + patch, + twirl=TwirlConfig(twirl_probability=0.5), + rng=GuppyRngMaskConfig(seed=42), + num_rounds=2, + ) + + assert "active_0_0 = active_draw_m_0_0 < nat(2147483648)" in src + assert "rng_state, m_draw_0_0 = _pcg32_next4(rng_state, rng_inc)" in src + assert f'result("{pauli_active_round_tag(0)}", array(active_0_0' in src + + def test_canonical_frame_output_emits_raw_sibling_tags(patch: SurfacePatch) -> None: src = generate_guppy_source( patch, @@ -87,7 +111,8 @@ def test_gate_local_twirl_source_emits_gate_tags_and_ancilla_frame_tracking( assert 'result("frame_mode:canonical", True)' in src assert f'result("{pauli_mask_gate_tag(0)}", array(' in src - assert "rng_state, m_g0_o0 = _pcg32_next4(rng_state, rng_inc)" in src + assert "rng_state, active_draw_m_g0_o0 = _pcg32_next32(rng_state, rng_inc)" in src + assert "rng_state, m_draw_g0_o0 = _pcg32_next4(rng_state, rng_inc)" in src assert "x(ax" in src assert "x(surf.data[" in src assert "frame_x_ax" in src @@ -96,6 +121,18 @@ def test_gate_local_twirl_source_emits_gate_tags_and_ancilla_frame_tracking( assert 'result("pauli_mask:round:' not in src +def test_scaled_gate_local_source_emits_activation_tags(patch: SurfacePatch) -> None: + src = generate_guppy_source( + patch, + twirl=TwirlConfig(site_schedule="before_two_qubit_gate", twirl_probability=0.25), + rng=GuppyRngMaskConfig(seed=7), + num_rounds=1, + ) + + assert "active_g0_o0 = active_draw_m_g0_o0 < nat(1073741824)" in src + assert f'result("{pauli_active_gate_tag(0)}", array(active_g0_o0, active_g0_o1))' in src + + def test_twirl_validation_requires_rng_and_num_rounds(patch: SurfacePatch) -> None: with pytest.raises(ValueError, match="twirl and rng must be supplied together"): generate_guppy_source(patch, twirl=TwirlConfig(), num_rounds=2) @@ -145,14 +182,24 @@ def test_twirled_cache_key_includes_seed_rounds_and_frame_mode(patch: SurfacePat rng=GuppyRngMaskConfig(seed=1), num_rounds=2, ) + scaled = _guppy_module_cache_key( + patch, + effective_budget=10, + twirl=TwirlConfig(twirl_probability=0.5), + rng=GuppyRngMaskConfig(seed=1), + num_rounds=2, + ) assert raw != _guppy_module_cache_key(patch, effective_budget=10) assert raw != raw_seed2 assert raw != canonical assert raw != round3 assert raw != gate_local + assert raw != scaled assert "s1" in raw assert "s2" in raw_seed2 + assert "p4294967296" in raw + assert "p2147483648" in scaled assert "frame-raw" in raw assert "frame-canonical" in canonical assert "before_two_qubit_gate" in gate_local 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 26043fa3a..eb1ac06d1 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 @@ -12,10 +12,11 @@ decode_native_samples, demask_pauli_frame_records, extract_detection_events_and_observables, + sample_pauli_activations_from_guppy, sample_pauli_masks_from_guppy, ) from pecos.qec.surface._twirl_sites import num_pauli_sites, num_pauli_sites_for_schedule -from pecos.qec.surface.decode import _extract_pauli_masks_from_results +from pecos.qec.surface.decode import _extract_pauli_activations_from_results, _extract_pauli_masks_from_results pytest.importorskip("guppylang") pytest.importorskip("selene_sim") @@ -120,7 +121,7 @@ def _run_twirled_guppy_rows_masks_and_raw( name=f"pauli_twirl_null_d{patch.geometry.dx}_r{num_rounds}_{basis.lower()}", ) - mask_results: dict[str, list[list[int]]] = {} + sideband_results: dict[str, list[list[int]]] = {} measurement_rows: list[list[int]] = [] raw_rows: list[list[int]] = [] frame_modes: list[str] = [] @@ -143,8 +144,8 @@ def _run_twirled_guppy_rows_masks_and_raw( except TypeError: shot_value = [values] - if name.startswith("pauli_mask:"): - mask_results.setdefault(name, []).append([int(v) for v in shot_value]) + if name.startswith(("pauli_mask:", "pauli_active:")): + sideband_results.setdefault(name, []).append([int(v) for v in shot_value]) elif name.startswith("frame_mode:"): assert len(shot_value) == 1 assert bool(shot_value[0]) @@ -178,7 +179,7 @@ def _run_twirled_guppy_rows_masks_and_raw( frame_modes.append(frame_mode) masks = _extract_pauli_masks_from_results( - mask_results, + sideband_results, num_rounds=num_rounds, num_data=patch.geometry.num_data, num_shots=num_shots, @@ -189,6 +190,70 @@ def _run_twirled_guppy_rows_masks_and_raw( return measurement_rows, masks, raw_rows, frame_modes +def _sample_twirled_guppy_masks_and_activations( + patch: SurfacePatch, + *, + basis: str, + num_rounds: int, + num_shots: int, + rng: GuppyRngMaskConfig, + twirl: TwirlConfig, +) -> tuple[np.ndarray, np.ndarray]: + from pecos.compilation_pipeline import compile_guppy_to_hugr + from pecos.guppy.surface import generate_memory_experiment, get_num_qubits + from selene_sim import SimpleRuntime, Stim, build + + fn = generate_memory_experiment( + patch, + num_rounds=num_rounds, + basis=basis, + twirl=twirl, + rng=rng, + ) + hugr_bytes = compile_guppy_to_hugr(fn) + instance = build( + hugr_bytes, + name=f"pauli_twirl_active_d{patch.geometry.dx}_r{num_rounds}_{basis.lower()}", + ) + + sideband_results: dict[str, list[list[int]]] = {} + for shot_results in instance.run_shots( + simulator=Stim(random_seed=int(rng.seed)), + n_qubits=get_num_qubits(patch=patch, twirl=twirl), + n_shots=num_shots, + runtime=SimpleRuntime(), + n_processes=1, + ): + for name, values in shot_results: + if not name.startswith(("pauli_mask:", "pauli_active:")): + continue + try: + shot_value = list(values) + except TypeError: + shot_value = [values] + sideband_results.setdefault(name, []).append([int(v) for v in shot_value]) + + masks = _extract_pauli_masks_from_results( + sideband_results, + num_rounds=num_rounds, + num_data=patch.geometry.num_data, + num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, + ) + activations = _extract_pauli_activations_from_results( + sideband_results, + num_rounds=num_rounds, + num_data=patch.geometry.num_data, + num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, + ) + return masks, activations + + def _run_twirled_guppy_measurement_rows_and_masks( patch: SurfacePatch, *, @@ -236,6 +301,124 @@ def test_runtime_gate_local_twirl_masks_vary_across_shots(patch_d3: SurfacePatch assert unique_rows.shape[0] >= 2 +def test_scaled_twirl_probability_zero_records_inactive_identity( + patch_d3: SurfacePatch, +) -> None: + twirl = TwirlConfig(twirl_probability=0.0) + masks, active = _sample_twirled_guppy_masks_and_activations( + patch_d3, + num_rounds=2, + num_shots=3, + basis="Z", + twirl=twirl, + rng=GuppyRngMaskConfig(seed=19), + ) + + assert masks.shape == active.shape + assert not masks.any() + assert not active.any() + + +def test_scaled_twirl_records_active_identity_distinct_from_inactive_identity( + patch_d3: SurfacePatch, +) -> None: + twirl = TwirlConfig(twirl_probability=0.5) + masks, active = _sample_twirled_guppy_masks_and_activations( + patch_d3, + num_rounds=2, + num_shots=16, + basis="Z", + twirl=twirl, + rng=GuppyRngMaskConfig(seed=20), + ) + + assert active.any() + assert (~active).any() + assert np.any(active & (masks == 0)) + assert np.any((~active) & (masks == 0)) + assert not np.any((~active) & (masks != 0)) + + +def test_scaled_twirl_fixed_rng_consumption_aligns_same_seed_codes( + patch_d3: SurfacePatch, +) -> None: + common = { + "num_rounds": 2, + "num_shots": 8, + "basis": "Z", + "rng": GuppyRngMaskConfig(seed=21), + } + full_masks, full_active = _sample_twirled_guppy_masks_and_activations( + patch_d3, + twirl=TwirlConfig(twirl_probability=1.0), + **common, + ) + half_masks, half_active = _sample_twirled_guppy_masks_and_activations( + patch_d3, + twirl=TwirlConfig(twirl_probability=0.5), + **common, + ) + + assert full_active.all() + assert half_active.any() + np.testing.assert_array_equal(half_masks[half_active], full_masks[half_active]) + + +def _assert_jeffreys_rate_near( + successes: int, + total: int, + expected: float, + *, + sigma: float = 6.0, +) -> None: + assert total > 0 + alpha = successes + 0.5 + beta = total - successes + 0.5 + mean = alpha / (alpha + beta) + variance = (alpha * beta) / ((alpha + beta) ** 2 * (alpha + beta + 1)) + assert abs(mean - expected) <= sigma * float(np.sqrt(variance)) + + +def test_scaled_twirl_empirical_activation_and_code_rates( + patch_d3: SurfacePatch, +) -> None: + num_rounds = 2 + num_shots = 64 + twirl = TwirlConfig(twirl_probability=0.5) + rng = GuppyRngMaskConfig(seed=22) + masks, active = _sample_twirled_guppy_masks_and_activations( + patch_d3, + num_rounds=num_rounds, + num_shots=num_shots, + basis="Z", + twirl=twirl, + rng=rng, + ) + + public_active = sample_pauli_activations_from_guppy( + patch_d3, + num_rounds=num_rounds, + num_shots=num_shots, + basis="Z", + twirl=twirl, + rng=rng, + ) + np.testing.assert_array_equal(public_active, active) + + active_count = int(active.sum()) + total_sites = int(active.size) + _assert_jeffreys_rate_near(active_count, total_sites, 0.5) + + active_codes = masks[active] + assert active_codes.size == active_count + for code in range(4): + _assert_jeffreys_rate_near( + int(np.count_nonzero(active_codes == code)), + active_count, + 0.25, + ) + + def test_same_seed_is_reproducible(patch_d3: SurfacePatch) -> None: kwargs = { "num_rounds": 3, 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 356c93875..4fbecf65a 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 @@ -18,10 +18,13 @@ num_pauli_sites, num_pauli_sites_for_schedule, num_two_qubit_gate_twirl_sites, + pauli_active_gate_tag, + pauli_active_round_tag, pauli_mask_gate_tag, pauli_mask_round_tag, ) from pecos.qec.surface.decode import ( + _extract_pauli_activations_from_results, _extract_pauli_masks_from_results, generate_circuit_level_dem_from_builder, ) @@ -48,6 +51,54 @@ def test_extract_pauli_masks_packs_bits_in_row_major_site_qubit_order() -> None: assert mask[0, mask_col_for(1, 1, 2)] == 0 +def test_extract_scaled_round_twirl_requires_and_validates_activation_tags() -> None: + scaled = TwirlConfig(twirl_probability=0.5) + results = { + pauli_mask_round_tag(0): [[1, 0, 0, 0]], + pauli_active_round_tag(0): [[True, False]], + } + + mask = _extract_pauli_masks_from_results( + results, + num_rounds=2, + num_data=2, + num_shots=1, + twirl=scaled, + ) + active = _extract_pauli_activations_from_results( + results, + num_rounds=2, + num_data=2, + num_shots=1, + twirl=scaled, + ) + + assert mask.tolist() == [[1, 0]] + assert active.tolist() == [[True, False]] + + with pytest.raises(ValueError, match="missing Pauli-activation result tag"): + _extract_pauli_masks_from_results( + {pauli_mask_round_tag(0): [[0, 0, 0, 0]]}, + num_rounds=2, + num_data=2, + num_shots=1, + twirl=scaled, + ) + + malformed = { + pauli_mask_round_tag(0): [[1, 0, 0, 0]], + pauli_active_round_tag(0): [[False, True]], + } + with pytest.raises(ValueError, match="inactive round site recorded a non-identity"): + _extract_pauli_masks_from_results( + malformed, + num_rounds=2, + num_data=2, + num_shots=1, + twirl=scaled, + ) + + def test_extract_pauli_masks_rejects_missing_or_misshaped_tags() -> None: with pytest.raises(ValueError, match="missing Pauli-mask result tag"): _extract_pauli_masks_from_results({}, num_rounds=2, num_data=1, num_shots=1) @@ -93,6 +144,34 @@ def test_extract_gate_local_pauli_masks_packs_operand_order() -> None: assert mask[0, mask_col_for_gate_operand(0, 1)] == 2 +def test_extract_scaled_gate_local_twirl_validates_activation_tags() -> None: + patch = SurfacePatch.create(distance=3) + twirl = TwirlConfig(site_schedule="before_two_qubit_gate", twirl_probability=0.5) + results = { + pauli_mask_gate_tag(site): [[0, 0, 0, 0]] + for site in range(num_two_qubit_gate_twirl_sites(patch, num_rounds=1, basis="Z")) + } + results.update( + { + pauli_active_gate_tag(site): [[True, True]] + for site in range(num_two_qubit_gate_twirl_sites(patch, num_rounds=1, basis="Z")) + }, + ) + results[pauli_mask_gate_tag(0)] = [[1, 0, 0, 0]] + results[pauli_active_gate_tag(0)] = [[False, True]] + + with pytest.raises(ValueError, match="inactive gate-local site recorded a non-identity"): + _extract_pauli_masks_from_results( + results, + num_rounds=1, + num_data=patch.geometry.num_data, + num_shots=1, + patch=patch, + basis="Z", + twirl=twirl, + ) + + def test_demask_helper_cancels_known_pauli_frame_xor() -> None: patch = SurfacePatch.create(distance=3) sampler = build_native_sampler( @@ -165,12 +244,24 @@ def test_canonical_frame_output_reuses_raw_abstract_sampler_topology() -> None: basis="Z", twirl=TwirlConfig(frame_output="canonical"), ) + scaled = build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=TwirlConfig(twirl_probability=0.5), + ) assert canonical.num_detectors == raw.num_detectors assert canonical.num_observables == raw.num_observables assert canonical.num_pauli_sites == raw.num_pauli_sites assert canonical.pauli_frame_lookup is raw.pauli_frame_lookup assert canonical.dem_string == raw.dem_string + assert scaled.num_detectors == raw.num_detectors + assert scaled.num_observables == raw.num_observables + assert scaled.num_pauli_sites == raw.num_pauli_sites + assert scaled.pauli_frame_lookup is raw.pauli_frame_lookup + assert scaled.dem_string == raw.dem_string @pytest.mark.parametrize( @@ -180,11 +271,13 @@ def test_canonical_frame_output_reuses_raw_abstract_sampler_topology() -> None: ("site_schedule", "per_two_qubit_gate"), ("result_encoding", "bogus"), ("frame_output", "physical"), + ("twirl_probability", -0.1), + ("twirl_probability", 1.1), ], ) def test_abstract_twirl_builders_reject_unsupported_config( field: str, - value: str, + value: object, ) -> None: patch = SurfacePatch.create(distance=3) kwargs = {field: value} @@ -400,25 +493,27 @@ def test_raw_twirled_guppy_trace_result_provenance_ignores_sideband_tags() -> No patch = SurfacePatch.create(distance=3) num_rounds = 2 + twirl = TwirlConfig(twirl_probability=0.5) program = generate_memory_experiment( patch, num_rounds=num_rounds, basis="Z", - twirl=TwirlConfig(), + twirl=twirl, rng=GuppyRngMaskConfig(seed=0), ) _, result_traces = trace_guppy_into_tick_circuit_with_result_traces( program, - get_num_qubits(patch=patch, twirl=TwirlConfig()), + get_num_qubits(patch=patch, twirl=twirl), seed=0, ) sideband_names = {trace.get("name") for trace in result_traces if isinstance(trace.get("name"), str)} assert any(str(name).startswith("pauli_mask:") for name in sideband_names) + assert any(str(name).startswith("pauli_active:") for name in sideband_names) assert "frame_mode:raw" in sideband_names scalar_trace_ids, array_trace_ids = _index_surface_result_trace_ids(result_traces) indexed_names = set(scalar_trace_ids) | set(array_trace_ids) assert any(name.startswith("sx") and ":meas:" in name for name in indexed_names) assert "final" in indexed_names - assert not any(name.startswith(("pauli_mask:", "frame_mode:", "raw:")) for name in indexed_names) + assert not any(name.startswith(("pauli_mask:", "pauli_active:", "frame_mode:", "raw:")) for name in indexed_names) From faf8ece8a4e95edcf1f6d96cf76cbfbe8d671495 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 16:15:51 -0600 Subject: [PATCH 068/150] Add SZZ surface interaction basis skeleton --- .../src/pecos/qec/surface/circuit_builder.py | 614 +++++++++++++++++- .../src/pecos/qec/surface/decode.py | 47 +- .../qec/surface/test_szz_interaction_basis.py | 269 ++++++++ 3 files changed, 917 insertions(+), 13 deletions(-) create mode 100644 python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 327424015..ac7f8345c 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -115,11 +115,17 @@ class OpType(Enum): # Single-qubit gates H = auto() # Hadamard + SX = auto() # sqrt X + SXDG = auto() # sqrt X dagger + SZ = auto() # sqrt Z / phase + SZDG = auto() # sqrt Z dagger X = auto() # Pauli X Z = auto() # Pauli Z # Two-qubit gates CX = auto() # CNOT + SZZ = auto() # sqrt ZZ + SZZDG = auto() # sqrt ZZ dagger # Measurement MEASURE = auto() # Destructive measurement @@ -159,6 +165,216 @@ def total(self) -> int: return len(set(self.data_qubits) | set(self.x_ancilla_qubits) | set(self.z_ancilla_qubits)) +@dataclass(frozen=True, order=True) +class SzzTouchSign: + """Signed SZZ touch in the v1 surface-memory sign convention.""" + + stabilizer_type: str + stabilizer_index: int + data_qubit: int + sign: int + + +@dataclass(frozen=True, order=True) +class SzzBoundaryCompensation: + """Single-qubit Clifford compensation for an odd boundary residual.""" + + stabilizer_type: str + stabilizer_index: int + data_qubit: int + gate: str + + +@dataclass(frozen=True, order=True) +class SzzClass2Stream: + """Standing deterministic Pauli stream induced by a class-2 residual.""" + + stabilizer_type: str + data_qubit: int + pauli: str + + +@dataclass(frozen=True) +class SzzResidualPlan: + """Validated SZZ sign convention and residual bookkeeping.""" + + signs: tuple[SzzTouchSign, ...] + boundary_compensations: tuple[SzzBoundaryCompensation, ...] + class2_streams: tuple[SzzClass2Stream, ...] + + +def _normalize_interaction_basis(interaction_basis: str) -> str: + """Validate and normalize the surface two-qubit interaction basis.""" + normalized = interaction_basis.lower() + if normalized not in {"cx", "szz"}: + msg = f"interaction_basis must be 'cx' or 'szz', got {interaction_basis!r}" + raise ValueError(msg) + return normalized + + +def _szz_residual_class(sum_signs: int) -> str: + """Classify a signed residual sum modulo four.""" + residue = sum_signs % 4 + if residue == 0: + return "identity" + if residue == 2: + return "pauli" + return "odd" + + +def _iter_surface_stabilizer_touches(patch: SurfacePatch) -> list[tuple[str, int, tuple[int, ...], bool]]: + """Return stabilizer touch rows in deterministic X-then-Z order.""" + geom = patch.geometry + rows: list[tuple[str, int, tuple[int, ...], bool]] = [] + rows.extend(("X", stab.index, tuple(stab.data_qubits), bool(stab.is_boundary)) for stab in geom.x_stabilizers) + rows.extend(("Z", stab.index, tuple(stab.data_qubits), bool(stab.is_boundary)) for stab in geom.z_stabilizers) + return rows + + +def _default_szz_sign_vector(patch: SurfacePatch) -> tuple[SzzTouchSign, ...]: + """Return the v1 hard-coded SZZ sign vector. + + Bulk checks use all ``SZZ``. Boundary checks use one ``SZZdg`` on the + second data operand, giving ancilla class 0 while preserving a stable, + geometry-derived convention. + """ + signs: list[SzzTouchSign] = [] + for stabilizer_type, stabilizer_index, data_qubits, is_boundary in _iter_surface_stabilizer_touches(patch): + if is_boundary and len(data_qubits) != 2: + msg = ( + "SZZ v1 expects boundary stabilizers to have weight 2; " + f"{stabilizer_type}{stabilizer_index} has weight {len(data_qubits)}" + ) + raise ValueError(msg) + for touch_index, data_qubit in enumerate(data_qubits): + sign = -1 if is_boundary and touch_index == 1 else 1 + signs.append( + SzzTouchSign( + stabilizer_type=stabilizer_type, + stabilizer_index=stabilizer_index, + data_qubit=data_qubit, + sign=sign, + ), + ) + return tuple(sorted(signs)) + + +def _validate_szz_sign_vector( + patch: SurfacePatch, + signs: tuple[SzzTouchSign, ...], +) -> SzzResidualPlan: + """Validate an SZZ sign vector and derive fixed residual bookkeeping.""" + expected_keys = { + (stabilizer_type, stabilizer_index, data_qubit) + for stabilizer_type, stabilizer_index, data_qubits, _is_boundary in _iter_surface_stabilizer_touches(patch) + for data_qubit in data_qubits + } + seen_keys: set[tuple[str, int, int]] = set() + check_sums: dict[tuple[str, int], int] = {} + data_sums: dict[tuple[str, int], int] = {} + data_touch_counts: dict[tuple[str, int], int] = {} + + for entry in signs: + if entry.sign not in {-1, 1}: + msg = f"SZZ sign entries must be +/-1, got {entry.sign!r} for {entry}" + raise ValueError(msg) + key = (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit) + if key in seen_keys: + msg = f"duplicate SZZ sign entry for touch {key}" + raise ValueError(msg) + seen_keys.add(key) + check_key = (entry.stabilizer_type, entry.stabilizer_index) + data_key = (entry.stabilizer_type, entry.data_qubit) + check_sums[check_key] = check_sums.get(check_key, 0) + entry.sign + data_sums[data_key] = data_sums.get(data_key, 0) + entry.sign + data_touch_counts[data_key] = data_touch_counts.get(data_key, 0) + 1 + + missing = sorted(expected_keys - seen_keys) + extra = sorted(seen_keys - expected_keys) + if missing or extra: + msg = f"SZZ sign vector must cover exactly the surface touches; missing={missing}, extra={extra}" + raise ValueError(msg) + + for (stabilizer_type, stabilizer_index), sum_signs in sorted(check_sums.items()): + residual_class = _szz_residual_class(sum_signs) + if residual_class != "identity": + msg = ( + "SZZ sign vector rejected: " + f"{stabilizer_type}{stabilizer_index} ancilla residual is {residual_class} " + f"(sum={sum_signs}, mod4={sum_signs % 4}); v1 has no record-flip checks" + ) + raise ValueError(msg) + + compensations: list[SzzBoundaryCompensation] = [] + streams: list[SzzClass2Stream] = [] + for (stabilizer_type, data_qubit), sum_signs in sorted(data_sums.items()): + residual_class = _szz_residual_class(sum_signs) + if residual_class == "identity": + continue + if residual_class == "pauli": + streams.append( + SzzClass2Stream( + stabilizer_type=stabilizer_type, + data_qubit=data_qubit, + pauli="X" if stabilizer_type == "X" else "Z", + ), + ) + continue + if data_touch_counts[(stabilizer_type, data_qubit)] != 1: + msg = ( + "SZZ sign vector rejected: odd residual on a non-boundary data class " + f"{stabilizer_type}, data={data_qubit}, sum={sum_signs}" + ) + raise ValueError(msg) + touch = next( + entry + for entry in signs + if entry.stabilizer_type == stabilizer_type and entry.data_qubit == data_qubit + ) + gate = { + ("X", 1): "SXDG", + ("X", -1): "SX", + ("Z", 1): "SZDG", + ("Z", -1): "SZ", + }[(stabilizer_type, touch.sign)] + compensations.append( + SzzBoundaryCompensation( + stabilizer_type=touch.stabilizer_type, + stabilizer_index=touch.stabilizer_index, + data_qubit=touch.data_qubit, + gate=gate, + ), + ) + + return SzzResidualPlan( + signs=tuple(sorted(signs)), + boundary_compensations=tuple(sorted(compensations)), + class2_streams=tuple(sorted(streams)), + ) + + +def _default_szz_residual_plan(patch: SurfacePatch) -> SzzResidualPlan: + """Return the validated v1 SZZ residual plan for a patch.""" + return _validate_szz_sign_vector(patch, _default_szz_sign_vector(patch)) + + +def _propagate_szz_frame_bits(x_a: bool, z_a: bool, x_b: bool, z_b: bool) -> tuple[bool, bool, bool, bool]: + """Propagate local Pauli-frame bits through uncompensated SZZ/SZZdg.""" + common = x_a ^ x_b + return x_a, z_a ^ common, x_b, z_b ^ common + + +def _propagate_compensated_szz_frame_bits(x_a: bool, z_a: bool, x_b: bool, z_b: bool) -> tuple[bool, bool, bool, bool]: + """Propagate local Pauli-frame bits through the compensated CZ-equivalent interaction.""" + return x_a, z_a ^ x_b, x_b, z_b ^ x_a + + +def _propagate_sxx_frame_bits(x_a: bool, z_a: bool, x_b: bool, z_b: bool) -> tuple[bool, bool, bool, bool]: + """Propagate local Pauli-frame bits through the SXX/SXXdg mirror.""" + common = z_a ^ z_b + return x_a ^ common, z_a, x_b ^ common, z_b + + def build_surface_code_circuit( patch: SurfacePatch, num_rounds: int, @@ -166,6 +382,7 @@ def build_surface_code_circuit( ancilla_budget: int | None = None, *, twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", ) -> tuple[list[SurfaceCircuitStep], QubitAllocation]: """Build abstract circuit operations for a surface code memory experiment. @@ -188,6 +405,10 @@ def build_surface_code_circuit( each site between counted syndrome rounds. The ``"before_two_qubit_gate"`` schedule emits one column per operand immediately before each surface-memory two-qubit gate. + interaction_basis: Surface-memory two-qubit interaction basis. + ``"cx"`` preserves the existing CNOT extraction circuit. ``"szz"`` + emits the direct-renderer SZZ/SZZdg abstract template; v1 rejects + ancilla reuse and twirl integration until the later stage gates. Returns: Tuple of (operations list, qubit allocation info) @@ -200,6 +421,7 @@ def build_surface_code_circuit( num_z_anc = len(geom.z_stabilizers) total_ancilla = num_x_anc + num_z_anc effective_ancilla_budget = _normalize_ancilla_budget(total_ancilla, ancilla_budget) + interaction_basis = _normalize_interaction_basis(interaction_basis) if twirl is not None: twirl.validate_runtime_supported() twirl_site_schedule = None if twirl is None else twirl.site_schedule @@ -288,6 +510,28 @@ def emit_gate_local_twirl_layer( # Get CNOT schedule cnot_rounds = compute_cnot_schedule(patch) + if interaction_basis == "szz": + if effective_ancilla_budget != total_ancilla: + msg = ( + "interaction_basis='szz' does not yet support constrained " + "ancilla budgets; pass ancilla_budget=None or a budget >= total_ancilla" + ) + raise ValueError(msg) + if twirl is not None: + msg = "interaction_basis='szz' twirl integration is staged later; omit twirl for Stage 1" + raise ValueError(msg) + return ( + _build_surface_code_circuit_szz( + patch, + num_rounds, + basis, + allocation, + cnot_rounds, + _default_szz_residual_plan(patch), + ), + allocation, + ) + ops: list[SurfaceCircuitStep] = [] # ========================================================================= @@ -556,6 +800,193 @@ def emit_gate_local_twirl_layer( return ops, allocation +def _build_surface_code_circuit_szz( + patch: SurfacePatch, + num_rounds: int, + basis: str, + allocation: QubitAllocation, + cnot_rounds: list[list[tuple[str, int, int]]], + residual_plan: SzzResidualPlan, +) -> list[SurfaceCircuitStep]: + """Build the Stage-1 abstract SZZ/SZZdg surface-memory template.""" + geom = patch.geometry + num_data = geom.num_data + sign_by_touch = { + (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): entry.sign for entry in residual_plan.signs + } + compensation_by_touch = { + (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): entry.gate + for entry in residual_plan.boundary_compensations + } + gate_by_name = { + "SX": OpType.SX, + "SXDG": OpType.SXDG, + "SZ": OpType.SZ, + "SZDG": OpType.SZDG, + } + + def data_q(i: int) -> int: + return allocation.data_qubits[i] + + def x_anc_q(stab_idx: int) -> int: + return allocation.x_ancilla_qubits[stab_idx] + + def z_anc_q(stab_idx: int) -> int: + return allocation.z_ancilla_qubits[stab_idx] + + def anc_q(stabilizer_type: str, stab_idx: int) -> int: + return x_anc_q(stab_idx) if stabilizer_type == "X" else z_anc_q(stab_idx) + + def stabilizers_for(stabilizer_type: str) -> list: + return geom.x_stabilizers if stabilizer_type == "X" else geom.z_stabilizers + + def append_szz_layer( + target_ops: list[SurfaceCircuitStep], + rnd_idx: int, + layer_gates: list[tuple[str, int, int]], + ) -> None: + target_ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"SZZ round {rnd_idx + 1}")) + x_touches = [(stab_idx, data_idx) for stab_type, stab_idx, data_idx in layer_gates if stab_type == "X"] + for _stab_idx, data_idx in x_touches: + target_ops.append(SurfaceCircuitStep(OpType.H, [data_q(data_idx)], f"szz_x_touch_pre_d{data_idx}")) + + for stab_type, stab_idx, data_idx in layer_gates: + sign = sign_by_touch[(stab_type, stab_idx, data_idx)] + op_type = OpType.SZZ if sign > 0 else OpType.SZZDG + target_ops.append( + SurfaceCircuitStep( + op_type, + [anc_q(stab_type, stab_idx), data_q(data_idx)], + f"{stab_type}{stab_idx}", + ), + ) + + for _stab_idx, data_idx in x_touches: + target_ops.append(SurfaceCircuitStep(OpType.H, [data_q(data_idx)], f"szz_x_touch_post_d{data_idx}")) + + for stab_type, stab_idx, data_idx in layer_gates: + compensation = compensation_by_touch.get((stab_type, stab_idx, data_idx)) + if compensation is None: + continue + target_ops.append( + SurfaceCircuitStep( + gate_by_name[compensation], + [data_q(data_idx)], + f"szz_boundary_comp:{compensation}:{stab_type}{stab_idx}:d{data_idx}", + ), + ) + + ops: list[SurfaceCircuitStep] = [] + + # ========================================================================= + # prep_z_basis / prep_x_basis + # ========================================================================= + ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"prep_{basis.lower()}_basis")) + ops.extend(SurfaceCircuitStep(OpType.ALLOC, [data_q(i)], f"data[{i}]") for i in range(num_data)) + if basis.upper() == "X": + ops.extend(SurfaceCircuitStep(OpType.H, [data_q(i)]) for i in range(num_data)) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + # ========================================================================= + # init_{basis}_basis syndrome establishment + # ========================================================================= + init_stabilizer_type = "X" if basis.upper() == "Z" else "Z" + init_stabilizers = list(stabilizers_for(init_stabilizer_type)) + ops.append( + SurfaceCircuitStep( + OpType.COMMENT, + label=f"init_{init_stabilizer_type.lower()}_syndrome", + ), + ) + ops.extend( + SurfaceCircuitStep( + OpType.ALLOC, + [anc_q(init_stabilizer_type, s.index)], + f"a{init_stabilizer_type.lower()}{s.index}", + ) + for s in init_stabilizers + ) + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) + ops.extend( + SurfaceCircuitStep( + OpType.H, + [anc_q(init_stabilizer_type, s.index)], + f"a{init_stabilizer_type.lower()}{s.index}", + ) + for s in init_stabilizers + ) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + for rnd_idx, cnot_round in enumerate(cnot_rounds): + layer_gates = [ + (stab_type, stab_idx, data_idx) + for stab_type, stab_idx, data_idx in cnot_round + if stab_type == init_stabilizer_type + ] + append_szz_layer(ops, rnd_idx, layer_gates) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) + ops.extend( + SurfaceCircuitStep( + OpType.H, + [anc_q(init_stabilizer_type, s.index)], + f"a{init_stabilizer_type.lower()}{s.index}", + ) + for s in init_stabilizers + ) + + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Measure ancillas")) + init_label_prefix = "sx" if init_stabilizer_type == "X" else "sz" + ops.extend( + SurfaceCircuitStep( + OpType.MEASURE, + [anc_q(init_stabilizer_type, s.index)], + f"{init_label_prefix}{s.index}", + ) + for s in init_stabilizers + ) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + # ========================================================================= + # syndrome_extraction + # ========================================================================= + for rnd in range(num_rounds): + ops.append( + SurfaceCircuitStep(OpType.COMMENT, label=f"syndrome_extraction round {rnd + 1}"), + ) + ops.extend(SurfaceCircuitStep(OpType.ALLOC, [x_anc_q(s.index)], f"ax{s.index}") for s in geom.x_stabilizers) + ops.extend(SurfaceCircuitStep(OpType.ALLOC, [z_anc_q(s.index)], f"az{s.index}") for s in geom.z_stabilizers) + + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) + ops.extend(SurfaceCircuitStep(OpType.H, [x_anc_q(s.index)], f"ax{s.index}") for s in geom.x_stabilizers) + ops.extend(SurfaceCircuitStep(OpType.H, [z_anc_q(s.index)], f"az{s.index}") for s in geom.z_stabilizers) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + for rnd_idx, cnot_round in enumerate(cnot_rounds): + append_szz_layer(ops, rnd_idx, list(cnot_round)) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) + ops.extend(SurfaceCircuitStep(OpType.H, [x_anc_q(s.index)], f"ax{s.index}") for s in geom.x_stabilizers) + ops.extend(SurfaceCircuitStep(OpType.H, [z_anc_q(s.index)], f"az{s.index}") for s in geom.z_stabilizers) + + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Measure ancillas")) + ops.extend(SurfaceCircuitStep(OpType.MEASURE, [x_anc_q(s.index)], f"sx{s.index}") for s in geom.x_stabilizers) + ops.extend(SurfaceCircuitStep(OpType.MEASURE, [z_anc_q(s.index)], f"sz{s.index}") for s in geom.z_stabilizers) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + # ========================================================================= + # measure_z_basis / measure_x_basis + # ========================================================================= + ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"measure_{basis.lower()}_basis")) + if basis.upper() == "X": + ops.extend(SurfaceCircuitStep(OpType.H, [data_q(i)]) for i in range(num_data)) + ops.extend(SurfaceCircuitStep(OpType.MEASURE, [data_q(i)], f"final[{i}]") for i in range(num_data)) + + return ops + + def classify_stabilizer_boundary(stab_type: str, data_qubits: tuple[int, ...], d: int, dz: int | None = None) -> str: """Public wrapper for classifying a boundary stabilizer.""" from pecos.qec.surface.schedule import _classify_boundary @@ -717,12 +1148,44 @@ def render( if self.p1 > 0: lines.append(f"DEPOLARIZE1({self.p1}) {op.qubits[0]}") + elif op.op_type == OpType.SX: + lines.append(f"SQRT_X {op.qubits[0]}") + if self.p1 > 0: + lines.append(f"DEPOLARIZE1({self.p1}) {op.qubits[0]}") + + elif op.op_type == OpType.SXDG: + lines.append(f"SQRT_X_DAG {op.qubits[0]}") + if self.p1 > 0: + lines.append(f"DEPOLARIZE1({self.p1}) {op.qubits[0]}") + + elif op.op_type == OpType.SZ: + lines.append(f"S {op.qubits[0]}") + if self.p1 > 0: + lines.append(f"DEPOLARIZE1({self.p1}) {op.qubits[0]}") + + elif op.op_type == OpType.SZDG: + lines.append(f"S_DAG {op.qubits[0]}") + if self.p1 > 0: + lines.append(f"DEPOLARIZE1({self.p1}) {op.qubits[0]}") + elif op.op_type == OpType.CX: c, t = op.qubits lines.append(f"CX {c} {t}") if self.p2 > 0: lines.append(f"DEPOLARIZE2({self.p2}) {c} {t}") + elif op.op_type == OpType.SZZ: + a, b = op.qubits + lines.append(f"SQRT_ZZ {a} {b}") + if self.p2 > 0: + lines.append(f"DEPOLARIZE2({self.p2}) {a} {b}") + + elif op.op_type == OpType.SZZDG: + a, b = op.qubits + lines.append(f"SQRT_ZZ_DAG {a} {b}") + if self.p2 > 0: + lines.append(f"DEPOLARIZE2({self.p2}) {a} {b}") + elif op.op_type == OpType.MEASURE: q = op.qubits[0] if self.p_meas > 0: @@ -892,7 +1355,7 @@ def render( _basis: str, ) -> DagCircuit: """Render to PECOS DagCircuit.""" - from pecos_rslib import DagCircuit + from pecos_rslib import DagCircuit, Gate, GateType circuit = DagCircuit() allocated: set[int] = set() @@ -916,6 +1379,18 @@ def render( elif op.op_type == OpType.H: circuit.h([op.qubits[0]]) + elif op.op_type == OpType.SX: + circuit.add_gate(Gate(GateType.SX, qubits=[op.qubits[0]])) + + elif op.op_type == OpType.SXDG: + circuit.add_gate(Gate(GateType.SXdg, qubits=[op.qubits[0]])) + + elif op.op_type == OpType.SZ: + circuit.sz([op.qubits[0]]) + + elif op.op_type == OpType.SZDG: + circuit.szdg([op.qubits[0]]) + elif op.op_type == OpType.X: circuit.x([op.qubits[0]]) @@ -925,6 +1400,12 @@ def render( elif op.op_type == OpType.CX: circuit.cx([(op.qubits[0], op.qubits[1])]) + elif op.op_type == OpType.SZZ: + circuit.szz([(op.qubits[0], op.qubits[1])]) + + elif op.op_type == OpType.SZZDG: + circuit.szzdg([(op.qubits[0], op.qubits[1])]) + elif op.op_type == OpType.MEASURE: q = op.qubits[0] if op.label.startswith(("sx", "sz")): @@ -1046,9 +1527,9 @@ def get_stabilizer_from_label(label: str) -> str: return f"Z{int(label[2:])}" return "" - # Helper to get stabilizer name for a CX gate - def get_cx_stabilizer(control: int, target: int, label: str = "") -> str: - """Get stabilizer name for a CX gate (e.g., 'X0', 'Z2').""" + # Helper to get stabilizer name for a two-qubit check interaction. + def get_check_stabilizer(control: int, target: int, label: str = "") -> str: + """Get stabilizer name for a two-qubit check gate (e.g., 'X0', 'Z2').""" from_label = get_stabilizer_from_label(label) if from_label: return from_label @@ -1089,11 +1570,18 @@ def get_ancilla_gate_metadata(qubit: int, label: str = "") -> dict[str, object]: metadata["ancilla_qubit"] = qubit return metadata - def get_cx_gate_metadata(control: int, target: int, label: str = "") -> dict[str, object]: - stab_label = get_cx_stabilizer(control, target, label) + def get_two_qubit_check_metadata( + control: int, + target: int, + label: str = "", + *, + gate_kind: str, + ) -> dict[str, object]: + stab_label = get_check_stabilizer(control, target, label) if not stab_label: return {} metadata = get_stabilizer_metadata(stab_label) + metadata["interaction_gate"] = gate_kind ancilla_qubit = next( (q for q in (control, target) if q in stabilizer_by_ancilla_qubit), None, @@ -1147,7 +1635,8 @@ def is_syndrome_context(phase: str, round_index: int) -> bool: if round_index >= 0 or phase.startswith("init_syndrome"): return True return round_index == -1 and ( - phase in {"syndrome_h_pre", "syndrome_h_post", "measure_ancilla"} or phase.startswith("cx_round_") + phase in {"syndrome_h_pre", "syndrome_h_post", "measure_ancilla"} + or phase.startswith(("cx_round_", "szz_round_")) ) def gate_metadata(meta: dict | None = None) -> dict: @@ -1193,7 +1682,7 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non elif "Prepare ancillas" in op.label: current_phase = "init_syndrome_prep" if current_round < 0 else "syndrome_prep" current_cx_round = 0 - elif "Hadamard on X ancillas" in op.label: + elif "Hadamard on X ancillas" in op.label or "Hadamard on SZZ ancillas" in op.label: current_phase = ( "syndrome_h_pre" if current_phase in {"syndrome_prep", "init_syndrome_prep"} @@ -1202,6 +1691,9 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non elif "CX round" in op.label: current_cx_round = int(op.label.split()[-1]) current_phase = f"cx_round_{current_cx_round}" + elif "SZZ round" in op.label: + current_cx_round = int(op.label.split()[-1]) + current_phase = f"szz_round_{current_cx_round}" elif "Measure ancillas" in op.label: current_phase = "measure_ancilla" elif "prep_z_basis" in op.label or "prep_x_basis" in op.label: @@ -1242,6 +1734,42 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non meta["label"] = op.label apply_gate_metadata(tick, meta or None) + elif op.op_type == OpType.SX: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).sx([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SXDG: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).sxdg([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SZ: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).sz([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SZDG: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).szdg([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + elif op.op_type == OpType.X: q = op.qubits[0] tick = get_tick_for_qubits([q]).x([q]) @@ -1264,7 +1792,25 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non qubits = op.qubits tick = get_tick_for_qubits(qubits).cx([(qubits[0], qubits[1])]) mark_qubits_used(qubits) - meta = get_cx_gate_metadata(qubits[0], qubits[1], op.label) + meta = get_two_qubit_check_metadata(qubits[0], qubits[1], op.label, gate_kind="CX") + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SZZ: + qubits = op.qubits + tick = get_tick_for_qubits(qubits).szz([(qubits[0], qubits[1])]) + mark_qubits_used(qubits) + meta = get_two_qubit_check_metadata(qubits[0], qubits[1], op.label, gate_kind="SZZ") + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SZZDG: + qubits = op.qubits + tick = get_tick_for_qubits(qubits).szzdg([(qubits[0], qubits[1])]) + mark_qubits_used(qubits) + meta = get_two_qubit_check_metadata(qubits[0], qubits[1], op.label, gate_kind="SZZdg") if op.label: meta["label"] = op.label apply_gate_metadata(tick, meta or None) @@ -1585,10 +2131,12 @@ def generate_stim_from_patch( basis: str = "Z", *, ancilla_budget: int | None = None, + interaction_basis: str = "cx", p1: float = 0.0, p2: float = 0.0, p_meas: float = 0.0, p_prep: float = 0.0, + add_detectors: bool = True, ) -> str: """Generate Stim circuit from SurfacePatch. @@ -1597,16 +2145,32 @@ def generate_stim_from_patch( num_rounds: Number of syndrome rounds basis: 'Z' or 'X' ancilla_budget: Optional cap on simultaneously live ancillas + interaction_basis: Surface-memory two-qubit interaction basis. p1: Single-qubit error rate p2: Two-qubit error rate p_meas: Measurement error rate p_prep: Initialization error rate + add_detectors: Whether to add detector/observable annotations. Returns: Stim circuit string """ - ops, allocation = build_surface_code_circuit(patch, num_rounds, basis, ancilla_budget) - renderer = StimRenderer(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep) + interaction_basis = _normalize_interaction_basis(interaction_basis) + if interaction_basis == "szz" and add_detectors: + msg = ( + "interaction_basis='szz' detector annotations require Stage 2 " + "class-2 stream correction; pass add_detectors=False for Stage 1 " + "structural gate rendering" + ) + raise ValueError(msg) + ops, allocation = build_surface_code_circuit( + patch, + num_rounds, + basis, + ancilla_budget, + interaction_basis=interaction_basis, + ) + renderer = StimRenderer(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep, add_detectors=add_detectors) return renderer.render(ops, allocation, patch, num_rounds, basis) @@ -1643,6 +2207,8 @@ def generate_dag_circuit_from_patch( num_rounds: int, basis: str = "Z", ancilla_budget: int | None = None, + *, + interaction_basis: str = "cx", ) -> DagCircuit: """Generate PECOS DagCircuit from SurfacePatch. @@ -1651,11 +2217,18 @@ def generate_dag_circuit_from_patch( num_rounds: Number of syndrome rounds basis: 'Z' or 'X' ancilla_budget: Optional cap on simultaneously live ancillas + interaction_basis: Surface-memory two-qubit interaction basis. Returns: PECOS DagCircuit instance """ - ops, allocation = build_surface_code_circuit(patch, num_rounds, basis, ancilla_budget) + ops, allocation = build_surface_code_circuit( + patch, + num_rounds, + basis, + ancilla_budget, + interaction_basis=interaction_basis, + ) renderer = DagCircuitRenderer() return renderer.render(ops, allocation, patch, num_rounds, basis) @@ -1669,6 +2242,7 @@ def generate_tick_circuit_from_patch( add_typed_annotations: bool = True, ancilla_budget: int | None = None, twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", ) -> TickCircuit: """Generate PECOS TickCircuit from SurfacePatch. @@ -1696,16 +2270,26 @@ def generate_tick_circuit_from_patch( tracked-Pauli annotations are emitted even if ``add_typed_annotations`` is false; that flag controls detector and observable typed annotations, not the twirl lookup channel. + interaction_basis: Surface-memory two-qubit interaction basis. Returns: PECOS TickCircuit instance """ + interaction_basis = _normalize_interaction_basis(interaction_basis) + if interaction_basis == "szz" and add_detectors: + msg = ( + "interaction_basis='szz' detector annotations require Stage 2 " + "class-2 stream correction; pass add_detectors=False for Stage 1 " + "structural gate rendering" + ) + raise ValueError(msg) ops, allocation = build_surface_code_circuit( patch, num_rounds, basis, ancilla_budget, twirl=twirl, + interaction_basis=interaction_basis, ) renderer = TickCircuitRenderer( add_detectors=add_detectors, @@ -1945,12 +2529,18 @@ def tick_circuit_to_stim( simple_gate_map = { "H": ("H", "single"), + "SX": ("SQRT_X", "single"), + "SXdg": ("SQRT_X_DAG", "single"), + "SZ": ("S", "single"), + "SZdg": ("S_DAG", "single"), "X": ("X", "single"), "Y": ("Y", "single"), "Z": ("Z", "single"), "CX": ("CX", "two"), "CY": ("CY", "two"), "CZ": ("CZ", "two"), + "SZZ": ("SQRT_ZZ", "two"), + "SZZdg": ("SQRT_ZZ_DAG", "two"), "MZ": ("M", "measure"), "PZ": ("R", "prep"), "QAlloc": ("R", "prep"), diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 06f418bff..52d4d36e2 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1344,12 +1344,28 @@ def _build_surface_tick_circuit_for_native_model( circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", ) -> Any: """Build the TickCircuit used by the native DEM and sampler paths.""" - from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis, generate_tick_circuit_from_patch if twirl is not None: twirl.validate_runtime_supported() + interaction_basis = _normalize_interaction_basis(interaction_basis) + if interaction_basis != "cx": + if circuit_source == "traced_qis": + msg = ( + "interaction_basis='szz' is not supported with circuit_source='traced_qis' " + "until the Guppy emitter stage" + ) + raise ValueError(msg) + msg = ( + "interaction_basis='szz' native detector/DEM/sampler support requires " + "Stage 2 class-2 stream-corrected detector validation; use " + "generate_tick_circuit_from_patch(..., add_detectors=False) for " + "Stage 1 structural rendering" + ) + raise ValueError(msg) abstract_tc = generate_tick_circuit_from_patch( patch, @@ -1358,6 +1374,7 @@ def _build_surface_tick_circuit_for_native_model( ancilla_budget=ancilla_budget, add_typed_annotations=False, twirl=twirl, + interaction_basis=interaction_basis, ) if circuit_source == "abstract": @@ -1417,6 +1434,7 @@ def build_memory_circuit( circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", ) -> Any: """Build the standard surface-code memory ``TickCircuit``. @@ -1439,6 +1457,7 @@ def build_memory_circuit( only with ``circuit_source="abstract"``; traced-QIS twirl is rejected because a runtime trace would bake one sampled mask into the circuit and can lose canonical result-id provenance. + interaction_basis: Surface-memory two-qubit interaction basis. Returns: A Rust-backed ``TickCircuit`` with detector and observable metadata. @@ -1471,6 +1490,7 @@ def build_memory_circuit( circuit_source=circuit_source, runtime=runtime, twirl=twirl, + interaction_basis=interaction_basis, ) @@ -1629,6 +1649,7 @@ def _surface_native_topology( *, runtime: object | None = None, twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", ) -> _CachedNativeSurfaceTopology: """Build topology-only native analysis shared across noise parameters.""" import json @@ -1649,6 +1670,7 @@ def _surface_native_topology( circuit_source=circuit_source, runtime=runtime, twirl=twirl, + interaction_basis=interaction_basis, ) if circuit_source == "traced_qis": # Keep this surface helper aligned with DetectorErrorModel.from_guppy: @@ -1705,6 +1727,7 @@ def _cached_surface_native_topology( include_idle_gates: bool, *, twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", ) -> _CachedNativeSurfaceTopology: """Cache topology-only native analysis shared across noise parameters.""" return _surface_native_topology( @@ -1715,6 +1738,7 @@ def _cached_surface_native_topology( circuit_source, include_idle_gates, twirl=twirl, + interaction_basis=interaction_basis, ) @@ -1780,6 +1804,7 @@ def _cached_surface_native_dem_string( p_idle_y_quadratic_sine_rate: float | None = None, p_idle_z_quadratic_sine_rate: float | None = None, twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", ) -> str: """Cache native DEM strings across callers for one topology + noise tuple.""" include_idle_gates = _uses_dedicated_idle_noise( @@ -1807,6 +1832,7 @@ def _cached_surface_native_dem_string( circuit_source, include_idle_gates, twirl=twirl, + interaction_basis=interaction_basis, ) return _dem_string_from_cached_surface_topology( topology, @@ -1906,6 +1932,7 @@ def generate_circuit_level_dem_from_builder( circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", ) -> str: """Generate circuit-level DEM using PECOS native fault propagation. @@ -1941,6 +1968,7 @@ def generate_circuit_level_dem_from_builder( twirl: Optional Pauli-frame randomization layout. Canonical Guppy frame-output mode is normalized to the same abstract raw lookup and DEM topology. + interaction_basis: Surface-memory two-qubit interaction basis. Returns: DEM string in standard format @@ -1954,6 +1982,9 @@ def generate_circuit_level_dem_from_builder( """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) twirl = _abstract_twirl_config(twirl) + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis + + interaction_basis = _normalize_interaction_basis(interaction_basis) patch_key = _surface_patch_cache_key(patch) include_idle_gates = _noise_uses_dedicated_idle_noise(noise) if runtime is not None: @@ -1966,6 +1997,7 @@ def generate_circuit_level_dem_from_builder( include_idle_gates, runtime=runtime, twirl=twirl, + interaction_basis=interaction_basis, ) return _dem_string_from_cached_surface_topology( topology, @@ -2003,6 +2035,7 @@ def generate_circuit_level_dem_from_builder( 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, ) @@ -3672,6 +3705,7 @@ def build_native_sampler( ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", sampling_model: Literal[ "dem", "influence_dem", @@ -3703,6 +3737,7 @@ def build_native_sampler( before native PECOS fault analysis. twirl: Optional Pauli-frame randomization layout. Canonical runtime frame-output mode is normalized to the same abstract raw lookup. + interaction_basis: Surface-memory two-qubit interaction basis. sampling_model: Which native sampling backend to use. ``"dem"`` samples the generated decomposed DEM and is the default. ``"influence_dem"`` uses the influence-map-based DemSampler with @@ -3721,6 +3756,9 @@ def build_native_sampler( """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) twirl = _abstract_twirl_config(twirl) + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis + + interaction_basis = _normalize_interaction_basis(interaction_basis) basis = basis.upper() patch_key = _surface_patch_cache_key(patch) topology = _cached_surface_native_topology( @@ -3731,6 +3769,7 @@ def build_native_sampler( circuit_source, _noise_uses_dedicated_idle_noise(noise), twirl=twirl, + interaction_basis=interaction_basis, ) if sampling_model == "dem": dem_str = _cached_surface_native_dem_string( @@ -3763,6 +3802,7 @@ def build_native_sampler( 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, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() return NativeSampler( @@ -3792,6 +3832,7 @@ def build_native_sampler_from_dem( ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", ) -> NativeSampler: """Build a native sampler from a caller-supplied decomposed DEM string. @@ -3802,6 +3843,9 @@ def build_native_sampler_from_dem( """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) twirl = _abstract_twirl_config(twirl) + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis + + interaction_basis = _normalize_interaction_basis(interaction_basis) basis = basis.upper() patch_key = _surface_patch_cache_key(patch) topology = _cached_surface_native_topology( @@ -3812,6 +3856,7 @@ def build_native_sampler_from_dem( circuit_source, include_idle_gates=False, twirl=twirl, + interaction_basis=interaction_basis, ) sampler = _cached_parsed_dem(decomposed_dem).to_dem_sampler() return NativeSampler( 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 new file mode 100644 index 000000000..663e89601 --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -0,0 +1,269 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +from __future__ import annotations + +import numpy as np +import pecos as pc +import pytest +from pecos.qec.surface import SurfacePatch, TwirlConfig +from pecos.qec.surface.circuit_builder import ( + OpType, + SzzTouchSign, + _default_szz_residual_plan, + _default_szz_sign_vector, + _propagate_compensated_szz_frame_bits, + _propagate_sxx_frame_bits, + _propagate_szz_frame_bits, + _szz_residual_class, + _validate_szz_sign_vector, + build_surface_code_circuit, + generate_dag_circuit_from_patch, + generate_stim_from_patch, + generate_tick_circuit_from_patch, +) +from pecos.qec.surface.decode import build_memory_circuit + + +def _to_numpy_complex(matrix: object) -> np.ndarray: + arr = np.asarray(matrix) + if arr.ndim >= 1 and arr.shape[-1:] == (2,) and not np.issubdtype(arr.dtype, np.complexfloating): + return arr[..., 0].astype(float) + 1j * arr[..., 1].astype(float) + return np.asarray(matrix, dtype=complex) + + +def _pauli_matrix(pauli: object) -> np.ndarray: + return _to_numpy_complex(pauli.to_matrix()) + + +def _kron(left: np.ndarray, right: np.ndarray) -> np.ndarray: + return _to_numpy_complex(pc.kron(pc.array(left), pc.array(right))) + + +I2 = _pauli_matrix(pc.PauliString.I()) +PAULI_X = _pauli_matrix(pc.X(0)) +PAULI_Z = _pauli_matrix(pc.Z(0)) +H = (PAULI_X + PAULI_Z) / np.sqrt(2.0) +SZ = (I2 + PAULI_Z) / 2 + 1j * (I2 - PAULI_Z) / 2 +SZDG = SZ.conj().T +SX = np.cos(np.pi / 4) * I2 - 1j * np.sin(np.pi / 4) * PAULI_X +I4 = _kron(I2, I2) +ZI = _kron(PAULI_Z, I2) +IZ = _kron(I2, PAULI_Z) +ZZ = _pauli_matrix(pc.Z(0) & pc.Z(1)) +CZ = (I4 + ZI + IZ - ZZ) / 2 +SZZ = np.cos(np.pi / 4) * I4 - 1j * np.sin(np.pi / 4) * ZZ +SZZDG = SZZ.conj().T +CX = _kron(I2, H) @ CZ @ _kron(I2, H) +SXX = _kron(H, H) @ SZZ @ _kron(H, H) + + +def _equiv_up_to_global_phase(left: np.ndarray, right: np.ndarray, *, atol: float = 1e-10) -> bool: + flat_right = right.ravel() + flat_left = left.ravel() + nonzero = np.flatnonzero(np.abs(flat_right) > atol) + if nonzero.size == 0: + return bool(np.allclose(left, right, atol=atol)) + ratio = flat_left[nonzero[0]] / flat_right[nonzero[0]] + return bool(np.allclose(flat_left, ratio * flat_right, atol=atol)) + + +def _pauli_from_bits(x_bit: bool, z_bit: bool) -> np.ndarray: + op = I2 + if x_bit: + op = PAULI_X @ op + if z_bit: + op = PAULI_Z @ op + return op + + +def _bits_from_pauli(op: np.ndarray) -> tuple[bool, bool]: + for x_bit in (False, True): + for z_bit in (False, True): + if _equiv_up_to_global_phase(op, _pauli_from_bits(x_bit, z_bit)): + return x_bit, z_bit + msg = f"not a Pauli up to phase:\n{op}" + raise AssertionError(msg) + + +def _matrix_frame_update( + unitary: np.ndarray, + x_a: bool, + z_a: bool, + x_b: bool, + z_b: bool, +) -> tuple[bool, bool, bool, bool]: + before = _kron(_pauli_from_bits(x_a, z_a), _pauli_from_bits(x_b, z_b)) + after = unitary @ before @ unitary.conj().T + basis = (I2, PAULI_X, PAULI_Z, PAULI_X @ PAULI_Z) + for left in basis: + for right in basis: + candidate = _kron(left, right) + if _equiv_up_to_global_phase(after, candidate): + ax, az = _bits_from_pauli(left) + bx, bz = _bits_from_pauli(right) + return ax, az, bx, bz + msg = f"not a tensor-product Pauli up to phase:\n{after}" + raise AssertionError(msg) + + +def test_szz_unitary_identities() -> None: + assert _equiv_up_to_global_phase(SZZ @ _kron(SZDG, SZDG), CZ) + assert _equiv_up_to_global_phase(SZZ @ ZZ, SZZDG) + assert _equiv_up_to_global_phase( + _kron(I2, H) @ SZZ @ _kron(I2, H), + CX @ _kron(SZ, SX), + ) + + +@pytest.mark.parametrize("x_a", [False, True]) +@pytest.mark.parametrize("z_a", [False, True]) +@pytest.mark.parametrize("x_b", [False, True]) +@pytest.mark.parametrize("z_b", [False, True]) +def test_szz_frame_rules_match_matrix_oracle( + x_a: bool, + z_a: bool, + x_b: bool, + z_b: bool, +) -> None: + assert _propagate_szz_frame_bits(x_a, z_a, x_b, z_b) == _matrix_frame_update( + SZZ, + x_a, + z_a, + x_b, + z_b, + ) + assert _propagate_szz_frame_bits(x_a, z_a, x_b, z_b) == _matrix_frame_update( + SZZDG, + x_a, + z_a, + x_b, + z_b, + ) + assert _propagate_compensated_szz_frame_bits(x_a, z_a, x_b, z_b) == _matrix_frame_update( + CZ, + x_a, + z_a, + x_b, + z_b, + ) + assert _propagate_sxx_frame_bits(x_a, z_a, x_b, z_b) == _matrix_frame_update( + SXX, + x_a, + z_a, + x_b, + z_b, + ) + + +def test_szz_residual_classifier_and_default_plan() -> None: + patch = SurfacePatch.create(distance=3) + plan = _default_szz_residual_plan(patch) + + assert _szz_residual_class(0) == "identity" + assert _szz_residual_class(4) == "identity" + assert _szz_residual_class(2) == "pauli" + assert _szz_residual_class(-2) == "pauli" + assert _szz_residual_class(1) == "odd" + assert _szz_residual_class(-1) == "odd" + + assert {entry.sign for entry in plan.signs} == {-1, 1} + assert {entry.gate for entry in plan.boundary_compensations} == { + "SX", + "SXDG", + "SZ", + "SZDG", + } + assert {entry.pauli for entry in plan.class2_streams} == {"X", "Z"} + + +def test_szz_bad_sign_vector_rejected_loudly() -> None: + patch = SurfacePatch.create(distance=3) + signs = list(_default_szz_sign_vector(patch)) + bad_index = next(i for i, entry in enumerate(signs) if entry.sign == -1) + bad_entry = signs[bad_index] + signs[bad_index] = SzzTouchSign( + bad_entry.stabilizer_type, + bad_entry.stabilizer_index, + bad_entry.data_qubit, + 1, + ) + + with pytest.raises(ValueError, match="ancilla residual is pauli"): + _validate_szz_sign_vector(patch, tuple(signs)) + + +def test_szz_builder_emits_szz_template_and_rejects_stage_later_features() -> None: + patch = SurfacePatch.create(distance=3) + + cx_ops, _ = build_surface_code_circuit(patch, num_rounds=1, interaction_basis="cx") + assert any(op.op_type == OpType.CX for op in cx_ops) + assert not any(op.op_type in {OpType.SZZ, OpType.SZZDG} for op in cx_ops) + + szz_ops, _ = build_surface_code_circuit(patch, num_rounds=1, interaction_basis="szz") + op_types = {op.op_type for op in szz_ops} + assert OpType.CX not in op_types + assert {OpType.SZZ, OpType.SZZDG, OpType.SX, OpType.SXDG, OpType.SZ, OpType.SZDG} <= op_types + + with pytest.raises(ValueError, match="does not yet support constrained ancilla budgets"): + build_surface_code_circuit(patch, num_rounds=1, ancilla_budget=1, interaction_basis="szz") + + with pytest.raises(ValueError, match="twirl integration is staged later"): + build_surface_code_circuit( + patch, + num_rounds=1, + twirl=TwirlConfig(), + interaction_basis="szz", + ) + + +def test_szz_tick_circuit_uses_named_szz_gates() -> None: + patch = SurfacePatch.create(distance=3) + tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + add_detectors=False, + interaction_basis="szz", + ) + + gate_names = { + gate.gate_type.name + for tick_index in range(tick_circuit.num_ticks()) + for gate in tick_circuit.get_tick(tick_index).gate_batches() + } + assert "CX" not in gate_names + assert {"SZZ", "SZZdg", "SX", "SXdg", "SZ", "SZdg"} <= gate_names + + +def test_szz_direct_renderers_accept_interaction_basis() -> None: + patch = SurfacePatch.create(distance=3) + + stim_text = generate_stim_from_patch( + patch, + num_rounds=1, + interaction_basis="szz", + add_detectors=False, + ) + assert "CX" not in stim_text + assert "SQRT_X" in stim_text + assert "S_DAG" in stim_text + assert "SQRT_ZZ" in stim_text + assert "SQRT_ZZ_DAG" in stim_text + + dag_circuit = generate_dag_circuit_from_patch(patch, num_rounds=1, interaction_basis="szz") + gate_names = {dag_circuit.gate(node).gate_type.name for node in dag_circuit.nodes()} + assert "CX" not in gate_names + assert {"SZZ", "SZZdg", "SX", "SXdg", "SZ", "SZdg"} <= gate_names + + +def test_szz_detector_paths_reject_until_stage2() -> None: + patch = SurfacePatch.create(distance=3) + + with pytest.raises(ValueError, match="detector annotations require Stage 2"): + generate_stim_from_patch(patch, num_rounds=1, interaction_basis="szz") + + with pytest.raises(ValueError, match="detector annotations require Stage 2"): + generate_tick_circuit_from_patch(patch, num_rounds=1, interaction_basis="szz") + + with pytest.raises(ValueError, match="native detector/DEM/sampler support requires Stage 2"): + build_memory_circuit(patch=patch, rounds=1, interaction_basis="szz") From ea0dde38bd1a9fb22d9cba83864d58abcb2358bd Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 17:03:59 -0600 Subject: [PATCH 069/150] Add SZZ noiseless detector equivalence --- .../pecos/qec/surface/_detection_events.py | 14 ++- .../src/pecos/qec/surface/circuit_builder.py | 106 +++++++++-------- .../src/pecos/qec/surface/decode.py | 23 ++-- .../qec/surface/test_szz_interaction_basis.py | 109 ++++++++++++++++-- 4 files changed, 179 insertions(+), 73 deletions(-) diff --git a/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py index 95d4c7c58..7589408ad 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py @@ -18,6 +18,16 @@ def get_meta(self, key: str) -> str | None: ... +def _record_offsets(entry: dict[str, object], num_measurements: int) -> list[int]: + records = entry.get("records") + if records is not None: + return [int(record) for record in records] # type: ignore[union-attr] + meas_ids = entry.get("meas_ids") + if meas_ids is not None: + return [int(meas_id) - num_measurements for meas_id in meas_ids] # type: ignore[union-attr] + return [] + + def extract_detection_events_and_observables( tick_circuit: _TickCircuitLike, results: Iterable[Sequence[int]], @@ -52,7 +62,7 @@ def extract_detection_events_and_observables( fired_detectors: list[int] = [] for det_idx, det in enumerate(detectors): val = 0 - for rec in det["records"]: + for rec in _record_offsets(det, num_meas): idx = num_meas + rec if 0 <= idx < num_meas: val ^= int(row[idx]) @@ -63,7 +73,7 @@ def extract_detection_events_and_observables( flipped_observables: list[int] = [] for obs_idx, obs in enumerate(observables): val = 0 - for rec in obs["records"]: + for rec in _record_offsets(obs, num_meas): idx = num_meas + rec if 0 <= idx < num_meas: val ^= int(row[idx]) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index ac7f8345c..b62f0adea 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -407,8 +407,8 @@ def build_surface_code_circuit( immediately before each surface-memory two-qubit gate. interaction_basis: Surface-memory two-qubit interaction basis. ``"cx"`` preserves the existing CNOT extraction circuit. ``"szz"`` - emits the direct-renderer SZZ/SZZdg abstract template; v1 rejects - ancilla reuse and twirl integration until the later stage gates. + emits the direct-renderer SZZ/SZZdg abstract template with local + data-qubit compensation. Returns: Tuple of (operations list, qubit allocation info) @@ -808,21 +808,26 @@ def _build_surface_code_circuit_szz( cnot_rounds: list[list[tuple[str, int, int]]], residual_plan: SzzResidualPlan, ) -> list[SurfaceCircuitStep]: - """Build the Stage-1 abstract SZZ/SZZdg surface-memory template.""" + """Build the abstract SZZ/SZZdg surface-memory template.""" geom = patch.geometry num_data = geom.num_data sign_by_touch = { (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): entry.sign for entry in residual_plan.signs } - compensation_by_touch = { - (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): entry.gate - for entry in residual_plan.boundary_compensations + data_compensation_by_touch = { + (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): { + ("X", 1): OpType.SXDG, + ("X", -1): OpType.SX, + ("Z", 1): OpType.SZDG, + ("Z", -1): OpType.SZ, + }[(entry.stabilizer_type, entry.sign)] + for entry in residual_plan.signs } - gate_by_name = { - "SX": OpType.SX, - "SXDG": OpType.SXDG, - "SZ": OpType.SZ, - "SZDG": OpType.SZDG, + gate_name_by_type = { + OpType.SX: "SX", + OpType.SXDG: "SXDG", + OpType.SZ: "SZ", + OpType.SZDG: "SZDG", } def data_q(i: int) -> int: @@ -865,14 +870,12 @@ def append_szz_layer( target_ops.append(SurfaceCircuitStep(OpType.H, [data_q(data_idx)], f"szz_x_touch_post_d{data_idx}")) for stab_type, stab_idx, data_idx in layer_gates: - compensation = compensation_by_touch.get((stab_type, stab_idx, data_idx)) - if compensation is None: - continue + compensation = data_compensation_by_touch[(stab_type, stab_idx, data_idx)] target_ops.append( SurfaceCircuitStep( - gate_by_name[compensation], + compensation, [data_q(data_idx)], - f"szz_boundary_comp:{compensation}:{stab_type}{stab_idx}:d{data_idx}", + f"szz_touch_comp:{gate_name_by_type[compensation]}:{stab_type}{stab_idx}:d{data_idx}", ), ) @@ -2156,13 +2159,6 @@ def generate_stim_from_patch( Stim circuit string """ interaction_basis = _normalize_interaction_basis(interaction_basis) - if interaction_basis == "szz" and add_detectors: - msg = ( - "interaction_basis='szz' detector annotations require Stage 2 " - "class-2 stream correction; pass add_detectors=False for Stage 1 " - "structural gate rendering" - ) - raise ValueError(msg) ops, allocation = build_surface_code_circuit( patch, num_rounds, @@ -2276,13 +2272,6 @@ def generate_tick_circuit_from_patch( PECOS TickCircuit instance """ interaction_basis = _normalize_interaction_basis(interaction_basis) - if interaction_basis == "szz" and add_detectors: - msg = ( - "interaction_basis='szz' detector annotations require Stage 2 " - "class-2 stream correction; pass add_detectors=False for Stage 1 " - "structural gate rendering" - ) - raise ValueError(msg) ops, allocation = build_surface_code_circuit( patch, num_rounds, @@ -2542,6 +2531,7 @@ def tick_circuit_to_stim( "SZZ": ("SQRT_ZZ", "two"), "SZZdg": ("SQRT_ZZ_DAG", "two"), "MZ": ("M", "measure"), + "MeasureFree": ("M", "measure"), "PZ": ("R", "prep"), "QAlloc": ("R", "prep"), } @@ -2661,9 +2651,10 @@ def _gate_to_stim( detectors_json = tc.get_meta("detectors") if detectors_json: detectors = json.loads(detectors_json) + num_measurements = int(tc.get_meta("num_measurements") or "0") for det in detectors: coords = det["coords"] - records = det["records"] + records = _metadata_record_offsets(det, num_measurements) coord_str = ", ".join(str(c) for c in coords) record_str = " ".join(f"rec[{r}]" for r in records) lines.append(f"DETECTOR({coord_str}) {record_str}") @@ -2672,9 +2663,10 @@ def _gate_to_stim( observables_json = tc.get_meta("observables") if observables_json: observables = json.loads(observables_json) + num_measurements = int(tc.get_meta("num_measurements") or "0") for obs in observables: obs_id = obs["id"] - records = obs["records"] + records = _metadata_record_offsets(obs, num_measurements) record_str = " ".join(f"rec[{r}]" for r in records) lines.append(f"OBSERVABLE_INCLUDE({obs_id}) {record_str}") @@ -2772,14 +2764,14 @@ def generate_dem_from_tick_circuit_via_pauli_frame( meas_to_detectors: dict[int, list[int]] = defaultdict(list) for det in detectors: det_id = det["id"] - for rec in det["records"]: + for rec in _metadata_record_offsets(det, num_measurements): abs_meas = num_measurements + rec # rec is negative meas_to_detectors[abs_meas].append(det_id) meas_to_observables: dict[int, list[int]] = defaultdict(list) for obs in observables: obs_id = obs["id"] - for rec in obs["records"]: + for rec in _metadata_record_offsets(obs, num_measurements): abs_meas = num_measurements + rec meas_to_observables[abs_meas].append(obs_id) @@ -3160,24 +3152,25 @@ def _maximally_decompose_graphlike_dem(dem_text: str) -> str: return "\n".join(rewritten_lines) -def _build_canonical_dem_influence_map(dag: DagCircuit) -> object: - """Build the influence map used by the canonical Rust DEM builder. - - `DagFaultAnalyzer` supplies the detector influence map. Observable and - tracked-Pauli annotations need positional propagation from - `InfluenceBuilder`; merging those non-detector outputs keeps this legacy - surface helper aligned with `DetectorErrorModel.from_circuit`. - """ - from pecos.qec import DagFaultAnalyzer, InfluenceBuilder +def _build_canonical_dem_influence_map( + dag: DagCircuit, + *, + include_circuit_annotations: bool = False, +) -> object: + """Build the influence map used by the metadata-driven Rust DEM builder.""" + from pecos.qec import DagFaultAnalyzer analyzer = DagFaultAnalyzer(dag) influence_map = analyzer.build_influence_map() - annotation_builder = InfluenceBuilder(dag) - annotation_builder.with_circuit_annotations() - annotation_map = annotation_builder.build() - merge_dem_outputs = getattr(influence_map, "merge_dem_outputs_from", None) - if merge_dem_outputs is not None: - merge_dem_outputs(annotation_map) + if include_circuit_annotations: + from pecos.qec import InfluenceBuilder + + annotation_builder = InfluenceBuilder(dag) + annotation_builder.with_circuit_annotations() + annotation_map = annotation_builder.build() + merge_dem_outputs = getattr(influence_map, "merge_dem_outputs_from", None) + if merge_dem_outputs is not None: + merge_dem_outputs(annotation_map) return influence_map @@ -3194,6 +3187,19 @@ def _metadata_uses_record_offsets(*metadata_jsons: str | None) -> bool: return False +def _metadata_record_offsets(entry: dict[str, object], num_measurements: int) -> list[int]: + """Return Stim-style negative record offsets for a metadata entry.""" + records = entry.get("records") + if records is not None: + return [int(record) for record in records] # type: ignore[union-attr] + + meas_ids = entry.get("meas_ids") + if meas_ids is not None: + return [int(meas_id) - num_measurements for meas_id in meas_ids] # type: ignore[union-attr] + + return [] + + def generate_dem_from_tick_circuit( tc: TickCircuit, *, @@ -3327,6 +3333,8 @@ def generate_dem_from_tick_circuit( 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 hasattr(builder, "with_exact_branch_replay_circuit"): + builder = builder.with_exact_branch_replay_circuit(dag) builder.with_num_measurements(num_measurements) if metadata_uses_records: builder.with_measurement_order(measurement_order) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 52d4d36e2..9a46ac76f 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1352,18 +1352,10 @@ def _build_surface_tick_circuit_for_native_model( if twirl is not None: twirl.validate_runtime_supported() interaction_basis = _normalize_interaction_basis(interaction_basis) - if interaction_basis != "cx": - if circuit_source == "traced_qis": - msg = ( - "interaction_basis='szz' is not supported with circuit_source='traced_qis' " - "until the Guppy emitter stage" - ) - raise ValueError(msg) + if interaction_basis != "cx" and circuit_source == "traced_qis": msg = ( - "interaction_basis='szz' native detector/DEM/sampler support requires " - "Stage 2 class-2 stream-corrected detector validation; use " - "generate_tick_circuit_from_patch(..., add_detectors=False) for " - "Stage 1 structural rendering" + "interaction_basis='szz' is not supported with circuit_source='traced_qis' " + "until the Guppy emitter supports that basis" ) raise ValueError(msg) @@ -1657,6 +1649,7 @@ def _surface_native_topology( from pecos.qec.surface.circuit_builder import ( _build_canonical_dem_influence_map, _extract_measurement_order, + _metadata_record_offsets, _metadata_uses_record_offsets, normalize_traced_qis_tick_circuit, ) @@ -1688,12 +1681,16 @@ def _surface_native_topology( detectors_json = tc.get_meta("detectors") or "[]" observables_json = tc.get_meta("observables") or "[]" - det_records = [d["records"] for d in json.loads(detectors_json)] if detectors_json else [] - obs_records = [o["records"] for o in json.loads(observables_json)] if observables_json else [] measurement_order = ( tuple(_extract_measurement_order(tc)) if _metadata_uses_record_offsets(detectors_json, observables_json) else () ) num_measurements = int(tc.get_meta("num_measurements") or str(len(measurement_order))) + det_records = [ + _metadata_record_offsets(detector, num_measurements) for detector in json.loads(detectors_json) + ] if detectors_json else [] + obs_records = [ + _metadata_record_offsets(observable, num_measurements) for observable in json.loads(observables_json) + ] if observables_json else [] pauli_frame_lookup = None num_pauli_sites = 0 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 663e89601..82d4da150 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 @@ -3,10 +3,13 @@ from __future__ import annotations +import json + import numpy as np import pecos as pc import pytest -from pecos.qec.surface import SurfacePatch, TwirlConfig +import stim +from pecos.qec.surface import NoiseModel, SurfacePatch, TwirlConfig from pecos.qec.surface.circuit_builder import ( OpType, SzzTouchSign, @@ -22,7 +25,7 @@ generate_stim_from_patch, generate_tick_circuit_from_patch, ) -from pecos.qec.surface.decode import build_memory_circuit +from pecos.qec.surface.decode import build_memory_circuit, generate_circuit_level_dem_from_builder def _to_numpy_complex(matrix: object) -> np.ndarray: @@ -107,6 +110,37 @@ def _matrix_frame_update( raise AssertionError(msg) +def _record_parity(raw_records: np.ndarray, records: list[int]) -> np.ndarray: + num_measurements = raw_records.shape[1] + if not records: + return np.zeros(raw_records.shape[0], dtype=bool) + indices = [num_measurements + int(record) for record in records] + return np.bitwise_xor.reduce(raw_records[:, indices], axis=1) + + +def _assert_noiseless_record_metadata_is_zero(stim_text: str, tick_circuit: object) -> None: + circuit = stim.Circuit(stim_text) + circuit.detector_error_model() + + raw_records = circuit.compile_sampler(seed=20260612).sample(shots=32) + detectors = json.loads(tick_circuit.get_meta("detectors") or "[]") + observables = json.loads(tick_circuit.get_meta("observables") or "[]") + + for detector in detectors: + assert not np.any(_record_parity(raw_records, detector["records"])) + for observable in observables: + assert not np.any(_record_parity(raw_records, observable["records"])) + + det_samples, obs_samples = circuit.compile_detector_sampler(seed=20260612).sample( + shots=32, + separate_observables=True, + ) + assert det_samples.shape == (32, circuit.num_detectors) + assert obs_samples.shape == (32, circuit.num_observables) + assert not np.any(det_samples) + assert not np.any(obs_samples) + + def test_szz_unitary_identities() -> None: assert _equiv_up_to_global_phase(SZZ @ _kron(SZDG, SZDG), CZ) assert _equiv_up_to_global_phase(SZZ @ ZZ, SZZDG) @@ -204,6 +238,12 @@ def test_szz_builder_emits_szz_template_and_rejects_stage_later_features() -> No op_types = {op.op_type for op in szz_ops} assert OpType.CX not in op_types assert {OpType.SZZ, OpType.SZZDG, OpType.SX, OpType.SXDG, OpType.SZ, OpType.SZDG} <= op_types + szz_gate_count = sum(op.op_type in {OpType.SZZ, OpType.SZZDG} for op in szz_ops) + data_compensation_count = sum( + op.label.startswith("szz_touch_comp:") and op.op_type in {OpType.SX, OpType.SXDG, OpType.SZ, OpType.SZDG} + for op in szz_ops + ) + assert data_compensation_count == szz_gate_count with pytest.raises(ValueError, match="does not yet support constrained ancilla budgets"): build_surface_code_circuit(patch, num_rounds=1, ancilla_budget=1, interaction_basis="szz") @@ -256,14 +296,65 @@ def test_szz_direct_renderers_accept_interaction_basis() -> None: assert {"SZZ", "SZZdg", "SX", "SXdg", "SZ", "SZdg"} <= gate_names -def test_szz_detector_paths_reject_until_stage2() -> None: +def test_szz_detector_paths_accept_abstract_basis_and_reject_traced_qis() -> None: patch = SurfacePatch.create(distance=3) - with pytest.raises(ValueError, match="detector annotations require Stage 2"): - generate_stim_from_patch(patch, num_rounds=1, interaction_basis="szz") + stim_text = generate_stim_from_patch(patch, num_rounds=1, interaction_basis="szz") + assert "DETECTOR" in stim_text + + tick_circuit = generate_tick_circuit_from_patch(patch, num_rounds=1, interaction_basis="szz") + assert int(tick_circuit.get_meta("num_detectors")) > 0 + + memory_circuit = build_memory_circuit(patch=patch, rounds=1, interaction_basis="szz") + assert int(memory_circuit.get_meta("num_detectors")) == int(tick_circuit.get_meta("num_detectors")) + + with pytest.raises(ValueError, match="circuit_source='traced_qis'"): + build_memory_circuit( + patch=patch, + rounds=1, + circuit_source="traced_qis", + interaction_basis="szz", + ) + + +@pytest.mark.parametrize("distance", [3, 5]) +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_noiseless_detector_record_equivalence(distance: int, basis: str) -> None: + patch = SurfacePatch.create(distance=distance) + + cx_text = generate_stim_from_patch(patch, num_rounds=3, basis=basis, interaction_basis="cx") + szz_text = generate_stim_from_patch(patch, num_rounds=3, basis=basis, interaction_basis="szz") + cx_tick = generate_tick_circuit_from_patch(patch, num_rounds=3, basis=basis, interaction_basis="cx") + szz_tick = generate_tick_circuit_from_patch(patch, num_rounds=3, basis=basis, interaction_basis="szz") + + cx_circuit = stim.Circuit(cx_text) + szz_circuit = stim.Circuit(szz_text) + assert cx_circuit.num_measurements == szz_circuit.num_measurements + assert cx_circuit.num_detectors == szz_circuit.num_detectors + assert cx_circuit.num_observables == szz_circuit.num_observables + assert cx_tick.get_meta("detectors") == szz_tick.get_meta("detectors") + assert cx_tick.get_meta("observables") == szz_tick.get_meta("observables") + + _assert_noiseless_record_metadata_is_zero(cx_text, cx_tick) + _assert_noiseless_record_metadata_is_zero(szz_text, szz_tick) + - with pytest.raises(ValueError, match="detector annotations require Stage 2"): - generate_tick_circuit_from_patch(patch, num_rounds=1, interaction_basis="szz") +def test_szz_native_dem_path_uses_interaction_basis() -> None: + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001) + + cx_dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + interaction_basis="cx", + ) + szz_dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + interaction_basis="szz", + ) - with pytest.raises(ValueError, match="native detector/DEM/sampler support requires Stage 2"): - build_memory_circuit(patch=patch, rounds=1, interaction_basis="szz") + assert cx_dem != szz_dem + assert stim.DetectorErrorModel(szz_dem).num_detectors > 0 From 8ebc302baaaf1efbc1418cf80441d853b612ed7c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 18:13:42 -0600 Subject: [PATCH 070/150] Tighten SZZ residual metadata --- .../pecos/qec/surface/_detection_events.py | 3 +- .../src/pecos/qec/surface/circuit_builder.py | 25 +++++++++------- .../qec/surface/test_surface_metadata.py | 29 +++++++++++++++++++ .../qec/surface/test_szz_interaction_basis.py | 2 +- 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py index 7589408ad..06da3d14e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py @@ -25,7 +25,8 @@ def _record_offsets(entry: dict[str, object], num_measurements: int) -> list[int meas_ids = entry.get("meas_ids") if meas_ids is not None: return [int(meas_id) - num_measurements for meas_id in meas_ids] # type: ignore[union-attr] - return [] + msg = "detector/observable metadata entry must define either 'records' or 'meas_ids'" + raise ValueError(msg) def extract_detection_events_and_observables( diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index b62f0adea..69893a3ef 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -177,7 +177,7 @@ class SzzTouchSign: @dataclass(frozen=True, order=True) class SzzBoundaryCompensation: - """Single-qubit Clifford compensation for an odd boundary residual.""" + """Analysis-only compensation for an odd uncompensated boundary residual.""" stabilizer_type: str stabilizer_index: int @@ -186,8 +186,8 @@ class SzzBoundaryCompensation: @dataclass(frozen=True, order=True) -class SzzClass2Stream: - """Standing deterministic Pauli stream induced by a class-2 residual.""" +class SzzClass2Residual: + """Analysis-only class-2 data residual of the uncompensated sign vector.""" stabilizer_type: str data_qubit: int @@ -196,11 +196,15 @@ class SzzClass2Stream: @dataclass(frozen=True) class SzzResidualPlan: - """Validated SZZ sign convention and residual bookkeeping.""" + """Validated SZZ sign convention and uncompensated residual bookkeeping. + + The active SZZ template cancels data residuals with immediate per-touch + compensation; no class-2 residual stream is emitted by the circuit. + """ signs: tuple[SzzTouchSign, ...] boundary_compensations: tuple[SzzBoundaryCompensation, ...] - class2_streams: tuple[SzzClass2Stream, ...] + class2_residuals: tuple[SzzClass2Residual, ...] def _normalize_interaction_basis(interaction_basis: str) -> str: @@ -306,14 +310,14 @@ def _validate_szz_sign_vector( raise ValueError(msg) compensations: list[SzzBoundaryCompensation] = [] - streams: list[SzzClass2Stream] = [] + class2_residuals: list[SzzClass2Residual] = [] for (stabilizer_type, data_qubit), sum_signs in sorted(data_sums.items()): residual_class = _szz_residual_class(sum_signs) if residual_class == "identity": continue if residual_class == "pauli": - streams.append( - SzzClass2Stream( + class2_residuals.append( + SzzClass2Residual( stabilizer_type=stabilizer_type, data_qubit=data_qubit, pauli="X" if stabilizer_type == "X" else "Z", @@ -349,7 +353,7 @@ def _validate_szz_sign_vector( return SzzResidualPlan( signs=tuple(sorted(signs)), boundary_compensations=tuple(sorted(compensations)), - class2_streams=tuple(sorted(streams)), + class2_residuals=tuple(sorted(class2_residuals)), ) @@ -3197,7 +3201,8 @@ def _metadata_record_offsets(entry: dict[str, object], num_measurements: int) -> if meas_ids is not None: return [int(meas_id) - num_measurements for meas_id in meas_ids] # type: ignore[union-attr] - return [] + msg = "detector/observable metadata entry must define either 'records' or 'meas_ids'" + raise ValueError(msg) def generate_dem_from_tick_circuit( diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py b/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py index e52803d90..1eed66782 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py @@ -15,6 +15,7 @@ SurfacePatch, classify_stabilizer_boundary, describe_surface_memory_experiment, + extract_detection_events_and_observables, generate_tick_circuit_from_patch, get_detector_descriptors_from_tick_circuit, get_measurement_order_from_tick_circuit, @@ -25,11 +26,39 @@ get_stabilizer_schedule_metadata, get_stabilizer_touch_label, ) +from pecos.qec.surface.circuit_builder import _metadata_record_offsets if TYPE_CHECKING: from pecos.qec.surface import SurfacePatchDescriptor +class _MetadataOnlyTickCircuit: + def __init__(self, metadata: dict[str, str]) -> None: + self._metadata = metadata + + def get_meta(self, key: str) -> str | None: + return self._metadata.get(key) + + +def test_metadata_record_offsets_require_records_or_meas_ids() -> None: + """Local metadata consumers should not silently ignore malformed entries.""" + assert _metadata_record_offsets({"records": []}, 3) == [] + assert _metadata_record_offsets({"meas_ids": [1, 2]}, 5) == [-4, -3] + + with pytest.raises(ValueError, match=r"records.*meas_ids"): + _metadata_record_offsets({"id": 0}, 3) + + tick_circuit = _MetadataOnlyTickCircuit( + { + "detectors": json.dumps([{"id": 0, "coords": [0, 0, 0]}]), + "observables": "[]", + "num_measurements": "1", + }, + ) + with pytest.raises(ValueError, match=r"records.*meas_ids"): + extract_detection_events_and_observables(tick_circuit, [[0]]) + + def test_surface_schedule_helpers_expose_region_and_touch_labels() -> None: """Surface metadata helpers should expose stable boundary and touch labels.""" patch = SurfacePatch.create(distance=3) 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 82d4da150..b530e911f 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 @@ -208,7 +208,7 @@ def test_szz_residual_classifier_and_default_plan() -> None: "SZ", "SZDG", } - assert {entry.pauli for entry in plan.class2_streams} == {"X", "Z"} + assert {entry.pauli for entry in plan.class2_residuals} == {"X", "Z"} def test_szz_bad_sign_vector_rejected_loudly() -> None: From 38297bbee01b9c88092c35d317d1f21f9eefaf8d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 19:30:31 -0600 Subject: [PATCH 071/150] Add SZZ Guppy surface basis --- .../quantum-pecos/src/pecos/guppy/surface.py | 431 ++++++++++++++---- .../src/pecos/qec/surface/circuit_builder.py | 9 +- .../src/pecos/qec/surface/decode.py | 17 +- .../tests/guppy/test_surface_twirl_render.py | 47 ++ .../qec/surface/test_szz_interaction_basis.py | 17 +- .../tests/qec/test_from_guppy_dem.py | 36 +- 6 files changed, 439 insertions(+), 118 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 6f54c22e0..ce397954a 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -14,6 +14,7 @@ import importlib.util import sys import tempfile +from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, ClassVar @@ -32,12 +33,18 @@ class _ModuleState: # Keyed by full patch identity + effective budget (dx, dz, orientation, # rotated, effective_budget) so distinct patch geometries -- e.g. rotated # vs non-rotated at the same dx/dz -- never collide on a cached module. - distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int], dict]] = {} + distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str], dict]] = {} _state = _ModuleState() +def _normalize_surface_interaction_basis(interaction_basis: str) -> str: + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis + + return _normalize_interaction_basis(interaction_basis) + + def _get_temp_dir() -> Path: """Get or create temporary directory for generated code.""" if _state.temp_dir is None: @@ -119,10 +126,13 @@ def generate_guppy_source( twirl: "TwirlConfig | None" = None, rng: "GuppyRngMaskConfig | None" = None, num_rounds: int | None = None, + interaction_basis: str = "cx", ) -> str: """Generate Guppy source code for a surface code patch. - Uses a 4-round parallel CNOT schedule for syndrome extraction. + Uses a 4-round parallel schedule for syndrome extraction. The default + ``interaction_basis="cx"`` emits the CNOT template; ``"szz"`` emits the + signed SZZ/SZZdg template with immediate data compensation. ``ancilla_budget=None`` (default) emits the unconstrained shape: one ancilla per stabilizer, all measured in parallel at the end of @@ -162,6 +172,9 @@ def generate_guppy_source( per-shot quantum entropy when ``twirl`` is enabled. num_rounds: Number of syndrome rounds to render. Required when ``twirl`` is enabled because twirled source is unrolled per round. + interaction_basis: Surface-memory interaction basis: ``"cx"`` or + ``"szz"``. SZZ Guppy generation currently supports only the + unconstrained, non-twirled memory shape. Returns: Python/Guppy source code as a string. @@ -170,7 +183,9 @@ def generate_guppy_source( ValueError: If exactly one of ``twirl`` / ``rng`` is supplied. """ from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget + from pecos.qec.surface.circuit_builder import _default_szz_residual_plan + interaction_basis = _normalize_surface_interaction_basis(interaction_basis) if (twirl is None) != (rng is None): msg = f"twirl and rng must be supplied together; got twirl={twirl!r} rng={rng!r}" raise ValueError(msg) @@ -191,6 +206,16 @@ def generate_guppy_source( total_ancilla = num_x_stab + num_z_stab effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) constrained = effective_budget < total_ancilla + if interaction_basis == "szz" and constrained: + msg = ( + "interaction_basis='szz' does not yet support constrained ancilla " + f"budgets on the Guppy runtime path (ancilla_budget={ancilla_budget} " + f"< total_ancilla={total_ancilla})" + ) + raise ValueError(msg) + if interaction_basis == "szz" and twirl is not None: + msg = "interaction_basis='szz' Guppy runtime twirl integration is staged later" + raise ValueError(msg) if twirl is not None and constrained: msg = ( f"twirl + constrained ancilla budget is not supported on " @@ -213,6 +238,16 @@ def generate_guppy_source( "from guppylang.std.num import nat", "from guppylang.std.quantum import cx, discard, h, measure, measure_array, qubit, x, y, z", ] + elif interaction_basis == "szz": + imports = [ + "from __future__ import annotations", + "", + "from guppylang import guppy", + "from guppylang.std.angles import angle", + "from guppylang.std.builtins import array, owned, result", + "from guppylang.std.qsystem.functional import zz_phase", + "from guppylang.std.quantum import discard, h, measure, measure_array, qubit, s, sdg, v, vdg, x", + ] else: imports = [ "from __future__ import annotations", @@ -231,6 +266,7 @@ def generate_guppy_source( f"X stabilizers: {num_x_stab}", f"Z stabilizers: {num_z_stab}", f"Ancilla qubits: {num_x_stab + num_z_stab} (one per stabilizer)", + f"Interaction basis: {interaction_basis}", '"""', "", *imports, @@ -241,14 +277,21 @@ def generate_guppy_source( if twirl is not None: lines.extend(_render_inline_pcg32()) - # Generate struct definitions + # Generate struct definitions. lines.extend( [ "@guppy.struct", f"class SurfaceCode_{dx}x{dz}:", f' """Surface code patch with dx={dx}, dz={dz} ({num_data} data qubits)."""', "", - f" data: array[qubit, {num_data}]", + ], + ) + if interaction_basis == "szz": + lines.extend(f" d{i}: qubit" for i in range(num_data)) + else: + lines.append(f" data: array[qubit, {num_data}]") + lines.extend( + [ "", "", "@guppy.struct", @@ -262,32 +305,83 @@ def generate_guppy_source( ], ) + szz_data_args = ", ".join(f"d{i}" for i in range(num_data)) + + def _append_szz_data_unpack(target: list[str], indent: str) -> None: + target.extend(f"{indent}d{i} = surf.d{i}" for i in range(num_data)) + + def _szz_data_expr(data_q: int) -> str: + return f"d{data_q}" + # Generate state preparation functions - lines.extend( - [ - "# === State Preparation ===", - "", - "@guppy", - f"def prep_z_basis() -> SurfaceCode_{dx}x{dz}:", - ' """Prepare logical |0_L> state."""', - f" data = array(qubit() for _ in range({num_data}))", - f" return SurfaceCode_{dx}x{dz}(data)", - "", - "", - "@guppy", - f"def prep_x_basis() -> SurfaceCode_{dx}x{dz}:", - ' """Prepare logical |+_L> state."""', - f" data = array(qubit() for _ in range({num_data}))", - f" for i in range({num_data}):", - " h(data[i])", - f" return SurfaceCode_{dx}x{dz}(data)", - "", - "", - ], - ) + lines.extend(["# === State Preparation ===", "", "@guppy", f"def prep_z_basis() -> SurfaceCode_{dx}x{dz}:"]) + lines.append(' """Prepare logical |0_L> state."""') + if interaction_basis == "szz": + lines.extend(f" d{i} = qubit()" for i in range(num_data)) + lines.append(f" return SurfaceCode_{dx}x{dz}({szz_data_args})") + else: + lines.append(f" data = array(qubit() for _ in range({num_data}))") + lines.append(f" return SurfaceCode_{dx}x{dz}(data)") + lines.extend(["", "", "@guppy", f"def prep_x_basis() -> SurfaceCode_{dx}x{dz}:"]) + lines.append(' """Prepare logical |+_L> state."""') + if interaction_basis == "szz": + lines.extend(f" d{i} = qubit()" for i in range(num_data)) + lines.extend(f" h(d{i})" for i in range(num_data)) + lines.append(f" return SurfaceCode_{dx}x{dz}({szz_data_args})") + else: + lines.append(f" data = array(qubit() for _ in range({num_data}))") + lines.append(f" for i in range({num_data}):") + lines.append(" h(data[i])") + lines.append(f" return SurfaceCode_{dx}x{dz}(data)") + lines.extend(["", ""]) - # Generate syndrome extraction with parallel CNOT schedule. + # Generate syndrome extraction with the selected parallel interaction schedule. rounds = compute_cnot_schedule(patch) + szz_sign_by_touch: dict[tuple[str, int, int], int] = {} + if interaction_basis == "szz": + residual_plan = _default_szz_residual_plan(patch) + szz_sign_by_touch = { + (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): entry.sign + for entry in residual_plan.signs + } + + def _szz_ancilla_expr(stab_type: str, stab_idx: int) -> str: + return f"ax{stab_idx}" if stab_type == "X" else f"az{stab_idx}" + + def _append_szz_layer( + target: list[str], + indent: str, + rnd_idx: int, + layer_gates: list[tuple[str, int, int]], + ancilla_expr: Callable[[str, int], str], + data_expr: Callable[[int], str], + ) -> None: + target.append("") + target.append(f"{indent}# SZZ round {rnd_idx + 1}") + x_touches = [(stab_idx, data_q) for stab_type, stab_idx, data_q in layer_gates if stab_type == "X"] + for _stab_idx, data_q in x_touches: + target.append(f"{indent}h({data_expr(data_q)})") + + for stab_type, stab_idx, data_q in layer_gates: + sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] + half_turns = "0.5" if sign > 0 else "-0.5" + target.append( + f"{indent}{ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)} = " + f"zz_phase({ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)}, angle({half_turns}))", + ) + + for _stab_idx, data_q in x_touches: + target.append(f"{indent}h({data_expr(data_q)})") + + for stab_type, stab_idx, data_q in layer_gates: + sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] + compensation = { + ("X", 1): "vdg", + ("X", -1): "v", + ("Z", 1): "sdg", + ("Z", -1): "s", + }[(stab_type, sign)] + target.append(f"{indent}{compensation}({data_expr(data_q)})") lines.extend( [ @@ -305,9 +399,15 @@ def generate_guppy_source( f") -> Syndrome_{dx}x{dz}:", ) else: - lines.append( - f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz}) -> Syndrome_{dx}x{dz}:", - ) + if interaction_basis == "szz": + lines.append( + f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz} @ owned) " + f"-> tuple[SurfaceCode_{dx}x{dz}, Syndrome_{dx}x{dz}]:", + ) + else: + lines.append( + f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz}) -> Syndrome_{dx}x{dz}:", + ) if not constrained: # Unconstrained: one ancilla per stabilizer, X-stabs first then @@ -319,26 +419,42 @@ def generate_guppy_source( " # Allocate ancilla qubits (one per stabilizer)", ], ) + if interaction_basis == "szz": + _append_szz_data_unpack(lines, " ") lines.extend(f" ax{stab.index} = qubit()" for stab in geom.x_stabilizers) lines.extend(f" az{stab.index} = qubit()" for stab in geom.z_stabilizers) - lines.append("") - lines.append(" # Hadamard on X ancillas") - lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + if interaction_basis == "cx": + lines.append("") + lines.append(" # Hadamard on X ancillas") + lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + + for rnd_idx, rnd_gates in enumerate(rounds): + lines.append("") + lines.append(f" # Round {rnd_idx + 1}") + for stab_type, stab_idx, data_q in rnd_gates: + if stab_type == "X": + lines.append(f" cx(ax{stab_idx}, surf.data[{data_q}])") + else: + lines.append(f" cx(surf.data[{data_q}], az{stab_idx})") - for rnd_idx, rnd_gates in enumerate(rounds): lines.append("") - lines.append(f" # Round {rnd_idx + 1}") - for stab_type, stab_idx, data_q in rnd_gates: - if stab_type == "X": - lines.append(f" cx(ax{stab_idx}, surf.data[{data_q}])") - else: - lines.append(f" cx(surf.data[{data_q}], az{stab_idx})") + lines.append(" # Hadamard on X ancillas") + lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + else: + lines.append("") + lines.append(" # Hadamard on SZZ ancillas") + lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + lines.extend(f" h(az{stab.index})" for stab in geom.z_stabilizers) - lines.append("") - lines.append(" # Hadamard on X ancillas") - lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + for rnd_idx, rnd_gates in enumerate(rounds): + _append_szz_layer(lines, " ", rnd_idx, list(rnd_gates), _szz_ancilla_expr, _szz_data_expr) + + lines.append("") + lines.append(" # Hadamard on SZZ ancillas") + lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + lines.extend(f" h(az{stab.index})" for stab in geom.z_stabilizers) lines.append("") lines.append(" # Measure ancillas") @@ -437,17 +553,13 @@ def generate_guppy_source( x_calls = ", ".join(f"sx{s.index}" for s in geom.x_stabilizers) z_calls = ", ".join(f"sz{s.index}" for s in geom.z_stabilizers) - lines.extend( - [ - "", - f" synx = array({x_calls})", - f" synz = array({z_calls})", - "", - f" return Syndrome_{dx}x{dz}(synx, synz)", - "", - "", - ], - ) + lines.extend(["", f" synx = array({x_calls})", f" synz = array({z_calls})", ""]) + if interaction_basis == "szz": + lines.append(f" surf = SurfaceCode_{dx}x{dz}({szz_data_args})") + lines.append(f" return surf, Syndrome_{dx}x{dz}(synx, synz)") + else: + lines.append(f" return Syndrome_{dx}x{dz}(synx, synz)") + lines.extend(["", ""]) def append_init_syndrome_function(function_name: str, stab_type: str) -> None: """Append a basis-prep syndrome-establishment helper.""" @@ -467,36 +579,66 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: "", "", "@guppy", - f"def {function_name}(surf: SurfaceCode_{dx}x{dz}) -> {return_type}:", + ( + f"def {function_name}(surf: SurfaceCode_{dx}x{dz} @ owned) " + f"-> tuple[SurfaceCode_{dx}x{dz}, {return_type}]:" + if interaction_basis == "szz" + else f"def {function_name}(surf: SurfaceCode_{dx}x{dz}) -> {return_type}:" + ), f' """{doc}"""', ], ) + if interaction_basis == "szz": + _append_szz_data_unpack(lines, " ") if not constrained: if stab_type == "X": lines.extend(f" ax{stab.index} = qubit()" for stab in stabs) - lines.append("") - lines.append(" # Hadamard on X ancillas") - lines.extend(f" h(ax{stab.index})" for stab in stabs) else: lines.extend(f" az{stab.index} = qubit()" for stab in stabs) - for rnd_idx, rnd_gates in enumerate(rounds): - filtered = [(t, i, q) for t, i, q in rnd_gates if t == stab_type] - if not filtered: - continue + if interaction_basis == "cx": + if stab_type == "X": + lines.append("") + lines.append(" # Hadamard on X ancillas") + lines.extend(f" h(ax{stab.index})" for stab in stabs) + + for rnd_idx, rnd_gates in enumerate(rounds): + filtered = [(t, i, q) for t, i, q in rnd_gates if t == stab_type] + if not filtered: + continue + lines.append("") + lines.append(f" # Round {rnd_idx + 1}") + for _stab_type, stab_idx, data_q in filtered: + if stab_type == "X": + lines.append(f" cx(ax{stab_idx}, surf.data[{data_q}])") + else: + lines.append(f" cx(surf.data[{data_q}], az{stab_idx})") + + if stab_type == "X": + lines.append("") + lines.append(" # Hadamard on X ancillas") + lines.extend(f" h(ax{stab.index})" for stab in stabs) + else: lines.append("") - lines.append(f" # Round {rnd_idx + 1}") - for _stab_type, stab_idx, data_q in filtered: - if stab_type == "X": - lines.append(f" cx(ax{stab_idx}, surf.data[{data_q}])") - else: - lines.append(f" cx(surf.data[{data_q}], az{stab_idx})") + lines.append(" # Hadamard on SZZ ancillas") + if stab_type == "X": + lines.extend(f" h(ax{stab.index})" for stab in stabs) + else: + lines.extend(f" h(az{stab.index})" for stab in stabs) + + for rnd_idx, rnd_gates in enumerate(rounds): + filtered = [(t, i, q) for t, i, q in rnd_gates if t == stab_type] + if not filtered: + continue + _append_szz_layer(lines, " ", rnd_idx, filtered, _szz_ancilla_expr, _szz_data_expr) - if stab_type == "X": lines.append("") - lines.append(" # Hadamard on X ancillas") - lines.extend(f" h(ax{stab.index})" for stab in stabs) + lines.append(" # Hadamard on SZZ ancillas") + if stab_type == "X": + lines.extend(f" h(ax{stab.index})" for stab in stabs) + else: + lines.extend(f" h(az{stab.index})" for stab in stabs) lines.append("") lines.append(" # Measure init ancillas") @@ -561,12 +703,12 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: lines.append(f' result("{syn_var}:init:meas:{idx}", {syn_var})') idx += 1 - lines.extend( - [ - "", - f" return array({return_calls})", - ], - ) + lines.append("") + if interaction_basis == "szz": + lines.append(f" surf = SurfaceCode_{dx}x{dz}({szz_data_args})") + lines.append(f" return surf, array({return_calls})") + else: + lines.append(f" return array({return_calls})") append_init_syndrome_function("init_z_basis", "X") append_init_syndrome_function("init_x_basis", "Z") @@ -579,19 +721,33 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: "@guppy", f"def measure_z_basis(surf: SurfaceCode_{dx}x{dz} @ owned) -> array[bool, {num_data}]:", ' """Destructively measure in Z basis."""', - " return measure_array(surf.data)", + ], + ) + if interaction_basis == "szz": + _append_szz_data_unpack(lines, " ") + z_meas = ", ".join(f"measure(d{i})" for i in range(num_data)) + lines.append(f" return array({z_meas})") + else: + lines.append(" return measure_array(surf.data)") + lines.extend( + [ "", "", "@guppy", f"def measure_x_basis(surf: SurfaceCode_{dx}x{dz} @ owned) -> array[bool, {num_data}]:", ' """Destructively measure in X basis."""', - f" for i in range({num_data}):", - " h(surf.data[i])", - " return measure_array(surf.data)", - "", - "", ], ) + if interaction_basis == "szz": + _append_szz_data_unpack(lines, " ") + lines.extend(f" h(d{i})" for i in range(num_data)) + x_meas = ", ".join(f"measure(d{i})" for i in range(num_data)) + lines.append(f" return array({x_meas})") + else: + lines.append(f" for i in range({num_data}):") + lines.append(" h(surf.data[i])") + lines.append(" return measure_array(surf.data)") + lines.extend(["", ""]) # Generate logical operators logical_x_qubits = list(geom.logical_x.data_qubits) if geom.logical_x else [] @@ -606,7 +762,10 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ' """Apply logical X (string along left edge)."""', ], ) - lines.extend(f" x(surf.data[{q}])" for q in logical_x_qubits) + if interaction_basis == "szz": + lines.extend(f" x(surf.d{q})" for q in logical_x_qubits) + else: + lines.extend(f" x(surf.data[{q}])" for q in logical_x_qubits) lines.extend( [ @@ -619,7 +778,10 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: "", ], ) - lines.extend(f" z(surf.data[{q}])" for q in logical_z_qubits) + if interaction_basis == "szz": + lines.extend(f" z(surf.d{q})" for q in logical_z_qubits) + else: + lines.extend(f" z(surf.data[{q}])" for q in logical_z_qubits) lines.extend( [ @@ -629,7 +791,7 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ) # Generate memory experiment factories - lines.extend(_render_memory_experiments(patch, dx, dz, num_data, twirl, rng, num_rounds)) + lines.extend(_render_memory_experiments(patch, dx, dz, num_data, twirl, rng, num_rounds, interaction_basis)) return "\n".join(lines) @@ -653,6 +815,7 @@ def _render_memory_experiments( twirl: "TwirlConfig | None", rng: "GuppyRngMaskConfig | None", num_rounds: int | None, + interaction_basis: str, ) -> list[str]: """Render both memory factory functions.""" lines = [ @@ -661,7 +824,7 @@ def _render_memory_experiments( ] for basis, basis_upper in (("z", "Z"), ("x", "X")): if twirl is None: - lines.extend(_render_plain_memory_block(basis, basis_upper, dx, dz)) + lines.extend(_render_plain_memory_block(basis, basis_upper, dx, dz, interaction_basis)) else: if rng is None or num_rounds is None: msg = "twirled memory rendering requires both rng and num_rounds" @@ -701,10 +864,17 @@ def _render_plain_memory_block( basis_upper: str, dx: int, dz: int, + interaction_basis: str, ) -> list[str]: """Render the vanilla handoff memory factory for one basis.""" init_func = "init_z_basis" if basis == "z" else "init_x_basis" init_tag = "init_synx" if basis == "z" else "init_synz" + if interaction_basis == "szz": + init_line = f" surf, init_syn = {init_func}(surf)" + syndrome_line = " surf, syn = syndrome_extraction(surf)" + else: + init_line = f" init_syn = {init_func}(surf)" + syndrome_line = " syn = syndrome_extraction(surf)" return [ f"def make_memory_{basis}(num_rounds: int):", f' """Create {basis_upper}-basis memory experiment."""', @@ -714,11 +884,11 @@ def _render_plain_memory_block( f" def memory_{basis}() -> None:", f' """{basis_upper}-basis memory experiment for dx={dx}, dz={dz}."""', f" surf = prep_{basis}_basis()", - f" init_syn = {init_func}(surf)", + init_line, f' result("{init_tag}", init_syn)', "", " for _t in range(comptime(num_rounds)):", - " syn = syndrome_extraction(surf)", + syndrome_line, ' result("synx", syn.synx)', ' result("synz", syn.synz)', "", @@ -1277,6 +1447,7 @@ def _guppy_module_cache_key( twirl: "TwirlConfig | None" = None, rng: "GuppyRngMaskConfig | None" = None, num_rounds: int | None = None, + interaction_basis: str = "cx", ) -> str: """Filesystem-safe cache key spanning full patch identity + budget + twirl. @@ -1291,7 +1462,9 @@ def _guppy_module_cache_key( """ geom = patch.geometry rotated = "rot" if geom.rotated else "unrot" - base = f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}_b{effective_budget}" + interaction_basis = _normalize_surface_interaction_basis(interaction_basis) + interaction_part = "" if interaction_basis == "cx" else f"_ib{interaction_basis}" + base = f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}_b{effective_budget}{interaction_part}" if twirl is None: return base if rng is None or num_rounds is None: @@ -1313,6 +1486,7 @@ def _load_guppy_module( twirl: "TwirlConfig | None" = None, rng: "GuppyRngMaskConfig | None" = None, num_rounds: int | None = None, + interaction_basis: str = "cx", ) -> dict: """Load a Guppy module for a patch, using caching. @@ -1330,16 +1504,25 @@ def _load_guppy_module( twirl: Pauli-twirl-site declaration (structural) rng: Runtime mask RNG seed (must be supplied with ``twirl``) num_rounds: Syndrome-round count for unrolled twirled source. + interaction_basis: Surface-memory interaction basis. Returns: Module dictionary with generated functions """ from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget + interaction_basis = _normalize_surface_interaction_basis(interaction_basis) geom = patch.geometry total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) - cache_key = _guppy_module_cache_key(patch, effective_budget, twirl, rng, num_rounds) + cache_key = _guppy_module_cache_key( + patch, + effective_budget, + twirl, + rng, + num_rounds, + interaction_basis, + ) if cache_key in _state.module_cache: return _state.module_cache[cache_key] @@ -1350,6 +1533,7 @@ def _load_guppy_module( twirl=twirl, rng=rng, num_rounds=num_rounds, + interaction_basis=interaction_basis, ) # Write to temp file (required for Guppy introspection). @@ -1380,6 +1564,7 @@ def generate_memory_experiment( ancilla_budget: int | None = None, twirl: "TwirlConfig | None" = None, rng: "GuppyRngMaskConfig | None" = None, + interaction_basis: str = "cx", ) -> object: """Generate a memory experiment for a patch. @@ -1390,6 +1575,7 @@ def generate_memory_experiment( ancilla_budget: Optional cap on simultaneously live ancillas twirl: Pauli-twirl-site declaration; must be supplied with ``rng``. rng: Runtime mask RNG seed; must be supplied with ``twirl``. + interaction_basis: Surface-memory interaction basis. Returns: Guppy function for the experiment @@ -1400,6 +1586,7 @@ def generate_memory_experiment( twirl=twirl, rng=rng, num_rounds=num_rounds if twirl is not None else None, + interaction_basis=interaction_basis, ) if basis.upper() == "Z": @@ -1419,6 +1606,7 @@ def get_num_qubits( patch: "SurfacePatch | None" = None, ancilla_budget: int | None = None, twirl: "TwirlConfig | None" = None, + interaction_basis: str = "cx", ) -> int: """Get the peak simultaneously-live qubit count for a surface-code program. @@ -1444,6 +1632,7 @@ def get_num_qubits( """ from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget + interaction_basis = _normalize_surface_interaction_basis(interaction_basis) if (d is None) == (patch is None): msg = "get_num_qubits requires exactly one of d=... or patch=..." raise ValueError(msg) @@ -1457,17 +1646,30 @@ def get_num_qubits( num_data = d * d total_ancilla = d * d - 1 + if interaction_basis == "szz" and normalize_ancilla_budget(total_ancilla, ancilla_budget) < total_ancilla: + msg = "interaction_basis='szz' does not yet support constrained ancilla budgets on the Guppy runtime path" + raise ValueError(msg) + if interaction_basis == "szz" and twirl is not None: + msg = "interaction_basis='szz' Guppy runtime twirl integration is staged later" + raise ValueError(msg) + twirl_entropy_qubits = 1 if twirl is not None else 0 return num_data + normalize_ancilla_budget(total_ancilla, ancilla_budget) + twirl_entropy_qubits -def generate_surface_code_module(d: int, *, ancilla_budget: int | None = None) -> str: +def generate_surface_code_module( + d: int, + *, + ancilla_budget: int | None = None, + interaction_basis: str = "cx", +) -> str: """Generate source code for a distance-d surface code module. Args: d: Code distance (must be odd >= 3) ancilla_budget: Optional cap on simultaneously live ancillas; forwarded to ``generate_guppy_source``. + interaction_basis: Surface-memory interaction basis. Returns: Python/Guppy source code as a string @@ -1477,10 +1679,19 @@ def generate_surface_code_module(d: int, *, ancilla_budget: int | None = None) - from pecos.qec.surface import SurfacePatch patch = SurfacePatch.create(distance=d) - return generate_guppy_source(patch, ancilla_budget=ancilla_budget) + return generate_guppy_source( + patch, + ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, + ) -def _surface_code_module_for_patch(patch: "SurfacePatch", *, ancilla_budget: int | None = None) -> dict: +def _surface_code_module_for_patch( + patch: "SurfacePatch", + *, + ancilla_budget: int | None = None, + interaction_basis: str = "cx", +) -> dict: """Load + cache a surface-code module for an arbitrary patch. Cache key spans full patch identity (dx, dz, orientation, rotated) plus @@ -1491,32 +1702,44 @@ def _surface_code_module_for_patch(patch: "SurfacePatch", *, ancilla_budget: int """ from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget + interaction_basis = _normalize_surface_interaction_basis(interaction_basis) geom = patch.geometry total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) - cache_key = (patch.dx, patch.dz, geom.orientation.name, geom.rotated, effective_budget) + cache_key = (patch.dx, patch.dz, geom.orientation.name, geom.rotated, effective_budget, interaction_basis) if cache_key in _state.distance_module_cache: return _state.distance_module_cache[cache_key] - module = _load_guppy_module(patch, ancilla_budget=ancilla_budget) + module = _load_guppy_module( + patch, + ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, + ) # Metadata derived from the actual patch geometry. module["distance"] = patch.distance module["num_data"] = geom.num_data module["num_stab"] = total_ancilla module["ancilla_budget"] = effective_budget + module["interaction_basis"] = interaction_basis _state.distance_module_cache[cache_key] = module return module -def get_surface_code_module(d: int, *, ancilla_budget: int | None = None) -> dict: +def get_surface_code_module( + d: int, + *, + ancilla_budget: int | None = None, + interaction_basis: str = "cx", +) -> dict: """Get a loaded surface code module for distance d. Args: d: Code distance (must be odd >= 3) ancilla_budget: Optional cap on simultaneously live ancillas + interaction_basis: Surface-memory interaction basis. Returns: Dictionary with module contents and metadata @@ -1525,7 +1748,11 @@ def get_surface_code_module(d: int, *, ancilla_budget: int | None = None) -> dic _validate_surface_memory_distance(d) patch = SurfacePatch.create(distance=d) - return _surface_code_module_for_patch(patch, ancilla_budget=ancilla_budget) + return _surface_code_module_for_patch( + patch, + ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, + ) def make_surface_code( @@ -1534,6 +1761,7 @@ def make_surface_code( basis: str, *, ancilla_budget: int | None = None, + interaction_basis: str = "cx", ) -> object: """Create a surface code memory experiment. @@ -1546,6 +1774,7 @@ def make_surface_code( a finite budget emits a stabilizer-batched program that matches the abstract circuit's ``batched_stabilizers(patch, effective_budget)`` schedule. + interaction_basis: Surface-memory interaction basis. Returns: Compiled Guppy program @@ -1554,7 +1783,11 @@ def make_surface_code( msg = f"basis must be 'Z' or 'X', got {basis!r}" raise ValueError(msg) - module = get_surface_code_module(distance, ancilla_budget=ancilla_budget) + module = get_surface_code_module( + distance, + ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, + ) factory = module["make_memory_z"] if basis.upper() == "Z" else module["make_memory_x"] diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 69893a3ef..549bcb2f9 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -1333,6 +1333,8 @@ def render( patch: SurfacePatch, _num_rounds: int, _basis: str, + *, + interaction_basis: str = "cx", ) -> str: """Render to Guppy source code. @@ -1347,7 +1349,7 @@ def render( from pecos.guppy.surface import generate_guppy_source # Use the canonical Guppy generator to ensure identical output - return generate_guppy_source(patch) + return generate_guppy_source(patch, interaction_basis=interaction_basis) class DagCircuitRenderer(CircuitRenderer): @@ -2178,6 +2180,8 @@ def generate_guppy_from_patch( patch: SurfacePatch, _num_rounds: int = 1, _basis: str = "Z", + *, + interaction_basis: str = "cx", ) -> str: """Generate Guppy code from SurfacePatch. @@ -2193,13 +2197,14 @@ def generate_guppy_from_patch( patch: Surface code patch _num_rounds: Unused (factory functions accept this at runtime) _basis: Unused (module includes both Z and X basis functions) + interaction_basis: Surface-memory two-qubit interaction basis. Returns: Guppy source code string (full module) """ from pecos.guppy.surface import generate_guppy_source - return generate_guppy_source(patch) + return generate_guppy_source(patch, interaction_basis=interaction_basis) def generate_dag_circuit_from_patch( diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 9a46ac76f..ac003292c 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1284,6 +1284,7 @@ def _generate_traced_surface_tick_circuit( basis: str, *, ancilla_budget: int | None = None, + interaction_basis: str = "cx", runtime: object | None = None, ) -> Any: """Trace the lowered ideal Selene/QIS op stream and replay it into a TickCircuit. @@ -1305,6 +1306,7 @@ def _generate_traced_surface_tick_circuit( num_rounds, basis, ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, runtime=runtime, ) return tc @@ -1316,6 +1318,7 @@ def _generate_traced_surface_tick_circuit_with_result_traces( basis: str, *, ancilla_budget: int | None = None, + interaction_basis: str = "cx", runtime: object | None = None, ) -> tuple[Any, list[dict[str, Any]]]: """Trace a surface Guppy program into a ``TickCircuit`` plus result provenance.""" @@ -1326,10 +1329,15 @@ def _generate_traced_surface_tick_circuit_with_result_traces( num_rounds, basis, ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, ) return trace_guppy_into_tick_circuit_with_result_traces( program, - get_num_qubits(patch=patch, ancilla_budget=ancilla_budget), + get_num_qubits( + patch=patch, + ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, + ), seed=0, runtime=runtime, ) @@ -1352,12 +1360,6 @@ def _build_surface_tick_circuit_for_native_model( if twirl is not None: twirl.validate_runtime_supported() interaction_basis = _normalize_interaction_basis(interaction_basis) - if interaction_basis != "cx" and circuit_source == "traced_qis": - msg = ( - "interaction_basis='szz' is not supported with circuit_source='traced_qis' " - "until the Guppy emitter supports that basis" - ) - raise ValueError(msg) abstract_tc = generate_tick_circuit_from_patch( patch, @@ -1384,6 +1386,7 @@ def _build_surface_tick_circuit_for_native_model( num_rounds, basis, ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, runtime=runtime, ) diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index 4799ef520..dfe3036a0 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -35,6 +35,53 @@ def test_no_twirl_source_has_no_rng_or_mask_tags(patch: SurfacePatch) -> None: assert 'result("final"' in src +def test_szz_source_uses_signed_zz_phase_template(patch: SurfacePatch) -> None: + src = generate_guppy_source(patch, interaction_basis="szz") + + assert "from guppylang.std.angles import angle" in src + assert "from guppylang.std.qsystem.functional import zz_phase" in src + assert "zz_phase(" in src + assert "angle(0.5)" in src + assert "angle(-0.5)" in src + assert "cx(" not in src + assert "h(az" in src + assert "vdg(d" in src + assert "sdg(d" in src + + +def test_szz_source_rejects_staged_later_runtime_shapes(patch: SurfacePatch) -> None: + with pytest.raises(ValueError, match="constrained ancilla budgets"): + generate_guppy_source(patch, interaction_basis="szz", ancilla_budget=1) + + with pytest.raises(ValueError, match="twirl integration is staged later"): + generate_guppy_source( + patch, + interaction_basis="szz", + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=42), + num_rounds=2, + ) + + +def test_szz_basis_forks_guppy_module_cache_key(patch: SurfacePatch) -> None: + cx_key = _guppy_module_cache_key(patch, effective_budget=8) + szz_key = _guppy_module_cache_key(patch, effective_budget=8, interaction_basis="szz") + + assert cx_key != szz_key + assert "_ibcx" not in cx_key + assert "_ibszz" in szz_key + + +def test_szz_memory_experiment_compiles_to_guppy_function(patch: SurfacePatch) -> None: + fn = generate_memory_experiment( + patch, + num_rounds=2, + basis="Z", + interaction_basis="szz", + ) + assert fn is not None + + def test_twirl_source_unrolls_rng_masks_and_runtime_paulis(patch: SurfacePatch) -> None: src = generate_guppy_source( patch, 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 b530e911f..6ffece8cf 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 @@ -296,7 +296,7 @@ def test_szz_direct_renderers_accept_interaction_basis() -> None: assert {"SZZ", "SZZdg", "SX", "SXdg", "SZ", "SZdg"} <= gate_names -def test_szz_detector_paths_accept_abstract_basis_and_reject_traced_qis() -> None: +def test_szz_detector_paths_accept_abstract_and_traced_qis_basis() -> None: patch = SurfacePatch.create(distance=3) stim_text = generate_stim_from_patch(patch, num_rounds=1, interaction_basis="szz") @@ -308,13 +308,14 @@ def test_szz_detector_paths_accept_abstract_basis_and_reject_traced_qis() -> Non memory_circuit = build_memory_circuit(patch=patch, rounds=1, interaction_basis="szz") assert int(memory_circuit.get_meta("num_detectors")) == int(tick_circuit.get_meta("num_detectors")) - with pytest.raises(ValueError, match="circuit_source='traced_qis'"): - build_memory_circuit( - patch=patch, - rounds=1, - circuit_source="traced_qis", - interaction_basis="szz", - ) + traced_memory_circuit = build_memory_circuit( + patch=patch, + rounds=1, + circuit_source="traced_qis", + interaction_basis="szz", + ) + assert traced_memory_circuit.get_meta("circuit_source") == "traced_qis" + assert int(traced_memory_circuit.get_meta("num_detectors")) == int(tick_circuit.get_meta("num_detectors")) @pytest.mark.parametrize("distance", [3, 5]) 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 4b76c3acd..e2200c31a 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -4,6 +4,7 @@ """Regression tests for the Guppy-to-DEM convenience path.""" import json +from typing import ClassVar import pytest from guppylang import guppy @@ -495,6 +496,37 @@ def test_from_guppy_surface_code_is_byte_identical_to_reference() -> None: assert got == ref_dem, f"surface from_guppy not byte-identical ({basis})" +def test_from_guppy_szz_surface_code_is_byte_identical_to_reference() -> None: + """SZZ-basis surface Guppy generation must match the traced-QIS reference DEM.""" + p = {"p1": 0.005, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} + for basis in ("Z", "X"): + patch = SurfacePatch.create(distance=3) + ref = _build_surface_tick_circuit_for_native_model( + patch, + 3, + basis, + circuit_source="traced_qis", + interaction_basis="szz", + ) + ref.lower_clifford_rotations() + ref.assign_missing_meas_ids() + ref_dem = DetectorErrorModel.from_circuit(ref, **p).to_string() + got = DetectorErrorModel.from_guppy( + make_surface_code( + distance=3, + num_rounds=3, + basis=basis, + interaction_basis="szz", + ), + num_qubits=get_num_qubits(3, interaction_basis="szz"), + detectors_json=ref.get_meta("detectors"), + observables_json=ref.get_meta("observables"), + num_measurements=int(ref.get_meta("num_measurements")), + **p, + ).to_string() + assert got == ref_dem, f"SZZ surface from_guppy not byte-identical ({basis})" + + def test_from_guppy_out_of_range_record_fails_loud() -> None: with pytest.raises(ValueError, match=r"out of range|record offset"): _dem_text(detectors_json='[{"id":0,"records":[-2]}]') # only 1 measurement @@ -1084,8 +1116,8 @@ def test_result_tag_remap_validation_rejects_unbound_traced_meas_ids() -> None: def test_result_tag_remap_validation_rejects_unstamped_measurements() -> None: class FakeGate: gate_type = "MZ" - qubits = [0] - meas_ids: list[int] = [] + qubits: ClassVar[list[int]] = [0] + meas_ids: ClassVar[list[int]] = [] class FakeTick: def gate_batches(self): From 4e09ba87799a72f2d81678f9c3c57176fb40a713 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 20:45:06 -0600 Subject: [PATCH 072/150] Tighten SZZ Guppy review coverage --- python/quantum-pecos/src/pecos/guppy/surface.py | 15 +++++++++------ .../tests/guppy/test_surface_twirl_render.py | 2 ++ .../tests/qec/test_from_guppy_dem.py | 11 ++++++----- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index ce397954a..9a33a75cf 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -246,7 +246,7 @@ def generate_guppy_source( "from guppylang.std.angles import angle", "from guppylang.std.builtins import array, owned, result", "from guppylang.std.qsystem.functional import zz_phase", - "from guppylang.std.quantum import discard, h, measure, measure_array, qubit, s, sdg, v, vdg, x", + "from guppylang.std.quantum import discard, h, measure, measure_array, qubit, s, sdg, v, vdg, x, z", ] else: imports = [ @@ -254,7 +254,7 @@ def generate_guppy_source( "", "from guppylang import guppy", "from guppylang.std.builtins import array, owned, result", - "from guppylang.std.quantum import cx, discard, h, measure, measure_array, qubit, x", + "from guppylang.std.quantum import cx, discard, h, measure, measure_array, qubit, x, z", ] lines = [ @@ -415,13 +415,18 @@ def _append_szz_layer( # abstract circuit's unconstrained-path measurement order. lines.extend( [ - ' """Extract full syndrome using 4-round parallel CNOT schedule."""', - " # Allocate ancilla qubits (one per stabilizer)", + ( + ' """Extract full syndrome using 4-round parallel SZZ/SZZdg schedule."""' + if interaction_basis == "szz" + else ' """Extract full syndrome using 4-round parallel CNOT schedule."""' + ), ], ) if interaction_basis == "szz": + lines.append(" # Unpack data qubits") _append_szz_data_unpack(lines, " ") + lines.append(" # Allocate ancilla qubits (one per stabilizer)") lines.extend(f" ax{stab.index} = qubit()" for stab in geom.x_stabilizers) lines.extend(f" az{stab.index} = qubit()" for stab in geom.z_stabilizers) @@ -774,8 +779,6 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: "@guppy", f"def apply_logical_z(surf: SurfaceCode_{dx}x{dz}) -> None:", ' """Apply logical Z (string along top edge)."""', - " from guppylang.std.quantum import z", - "", ], ) if interaction_basis == "szz": diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index dfe3036a0..f219264b5 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -46,7 +46,9 @@ def test_szz_source_uses_signed_zz_phase_template(patch: SurfacePatch) -> None: assert "cx(" not in src assert "h(az" in src assert "vdg(d" in src + assert "v(d" in src assert "sdg(d" in src + assert "s(d" in src def test_szz_source_rejects_staged_later_runtime_shapes(patch: SurfacePatch) -> 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 e2200c31a..b461b650c 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -496,11 +496,12 @@ def test_from_guppy_surface_code_is_byte_identical_to_reference() -> None: assert got == ref_dem, f"surface from_guppy not byte-identical ({basis})" -def test_from_guppy_szz_surface_code_is_byte_identical_to_reference() -> None: +@pytest.mark.parametrize("distance", [3, 5]) +def test_from_guppy_szz_surface_code_is_byte_identical_to_reference(distance: int) -> None: """SZZ-basis surface Guppy generation must match the traced-QIS reference DEM.""" p = {"p1": 0.005, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} for basis in ("Z", "X"): - patch = SurfacePatch.create(distance=3) + patch = SurfacePatch.create(distance=distance) ref = _build_surface_tick_circuit_for_native_model( patch, 3, @@ -513,18 +514,18 @@ def test_from_guppy_szz_surface_code_is_byte_identical_to_reference() -> None: ref_dem = DetectorErrorModel.from_circuit(ref, **p).to_string() got = DetectorErrorModel.from_guppy( make_surface_code( - distance=3, + distance=distance, num_rounds=3, basis=basis, interaction_basis="szz", ), - num_qubits=get_num_qubits(3, interaction_basis="szz"), + num_qubits=get_num_qubits(distance, interaction_basis="szz"), detectors_json=ref.get_meta("detectors"), observables_json=ref.get_meta("observables"), num_measurements=int(ref.get_meta("num_measurements")), **p, ).to_string() - assert got == ref_dem, f"SZZ surface from_guppy not byte-identical ({basis})" + assert got == ref_dem, f"SZZ surface from_guppy not byte-identical (d={distance}, {basis})" def test_from_guppy_out_of_range_record_fails_loud() -> None: From 71d6aad5bc264d1be99b6bf7d0f244823fbbea5a Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 20:57:35 -0600 Subject: [PATCH 073/150] Add SZZ forward-flow pulse guard --- .../src/pecos/qec/surface/circuit_builder.py | 239 ++++++++++++++++++ .../qec/surface/test_szz_interaction_basis.py | 106 ++++++++ 2 files changed, 345 insertions(+) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 549bcb2f9..b1de6681c 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -165,6 +165,35 @@ def total(self) -> int: return len(set(self.data_qubits) | set(self.x_ancilla_qubits) | set(self.z_ancilla_qubits)) +@dataclass(frozen=True) +class SzzForwardFlowPulse: + """One pending-Clifford discharge site in the SZZ device-flow model.""" + + host_index: int + host_op_type: str + host_label: str + qubit: int + kind: str + pending_clifford: str + + +@dataclass(frozen=True) +class SzzForwardFlowSummary: + """Pulse accounting for the SZZ pending-Clifford forward-flow model.""" + + abstract_single_qubit_ops: int + physical_prefix_pulses: int + two_qubit_prefix_pulses: int + measurement_prefix_pulses: int + virtual_z_two_qubit_carries: int + virtual_z_measure_discards: int + two_qubit_gates: int + measurements: int + prep_events: int + free_standing_single_qubit_ops: int + pulses: tuple[SzzForwardFlowPulse, ...] + + @dataclass(frozen=True, order=True) class SzzTouchSign: """Signed SZZ touch in the v1 surface-memory sign convention.""" @@ -379,6 +408,215 @@ def _propagate_sxx_frame_bits(x_a: bool, z_a: bool, x_b: bool, z_b: bool) -> tup return x_a ^ common, z_a, x_b ^ common, z_b +_SIGNED_PAULI_NAMES = { + 1: "X", + -1: "-X", + 2: "Y", + -2: "-Y", + 3: "Z", + -3: "-Z", +} +_SZZ_FLOW_IDENTITY: tuple[int, int] = (1, 3) +_SZZ_FLOW_SINGLE_QUBIT_GATES = { + OpType.H, + OpType.SX, + OpType.SXDG, + OpType.SZ, + OpType.SZDG, + OpType.X, + OpType.Z, +} +_SZZ_FLOW_GATE_ACTIONS: dict[OpType, dict[int, int]] = { + OpType.H: {1: 3, 2: -2, 3: 1}, + OpType.SX: {1: 1, 2: 3, 3: -2}, + OpType.SXDG: {1: 1, 2: -3, 3: 2}, + OpType.SZ: {1: 2, 2: -1, 3: 3}, + OpType.SZDG: {1: -2, 2: 1, 3: 3}, + OpType.X: {1: 1, 2: -2, 3: -3}, + OpType.Z: {1: -1, 2: -2, 3: 3}, +} + + +def _szz_flow_apply_gate_to_pauli(op_type: OpType, pauli: int) -> int: + sign = -1 if pauli < 0 else 1 + return sign * _SZZ_FLOW_GATE_ACTIONS[op_type][abs(pauli)] + + +def _szz_flow_compose_pending_gate(pending: tuple[int, int], op_type: OpType) -> tuple[int, int]: + """Append one 1q Clifford to a pending signed-Pauli-image register.""" + return ( + _szz_flow_apply_gate_to_pauli(op_type, pending[0]), + _szz_flow_apply_gate_to_pauli(op_type, pending[1]), + ) + + +def _szz_flow_is_virtual_z(pending: tuple[int, int]) -> bool: + """Return whether a pending Clifford is a zero-pulse virtual-Z update.""" + return pending[1] == 3 + + +def _szz_flow_clifford_name(pending: tuple[int, int]) -> str: + return f"X->{_SIGNED_PAULI_NAMES[pending[0]]},Z->{_SIGNED_PAULI_NAMES[pending[1]]}" + + +def _analyze_szz_forward_flow(ops: list[SurfaceCircuitStep]) -> SzzForwardFlowSummary: + """Analyze SZZ pending-Clifford forward-flow pulse accounting. + + The abstract SZZ template intentionally contains free-standing 1q + Cliffords. The SZZ device model executes those Cliffords only as prefixes + of the next SZZ/SZZdg or MZ on that qubit. Virtual-Z pending Cliffords carry + through SZZ/SZZdg and are discarded at MZ. + """ + pending_by_qubit: dict[int, tuple[int, int]] = {} + pulses: list[SzzForwardFlowPulse] = [] + abstract_single_qubit_ops = 0 + physical_prefix_pulses = 0 + two_qubit_prefix_pulses = 0 + measurement_prefix_pulses = 0 + virtual_z_two_qubit_carries = 0 + virtual_z_measure_discards = 0 + two_qubit_gates = 0 + measurements = 0 + prep_events = 0 + + def pending_for(q: int) -> tuple[int, int]: + return pending_by_qubit.setdefault(q, _SZZ_FLOW_IDENTITY) + + def pulse_event( + *, + host_index: int, + op: SurfaceCircuitStep, + qubit: int, + kind: str, + pending: tuple[int, int], + ) -> SzzForwardFlowPulse: + return SzzForwardFlowPulse( + host_index=host_index, + host_op_type=op.op_type.name, + host_label=op.label, + qubit=qubit, + kind=kind, + pending_clifford=_szz_flow_clifford_name(pending), + ) + + def reset_for_prep(q: int, op: SurfaceCircuitStep) -> None: + current = pending_for(q) + if current != _SZZ_FLOW_IDENTITY: + msg = ( + "SZZ forward-flow cannot reset a qubit with a pending " + f"Clifford: q={q}, pending={_szz_flow_clifford_name(current)}, " + f"op={op.op_type.name} {op.label!r}" + ) + raise ValueError(msg) + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + + def discharge_for_two_qubit(q: int, host_index: int, op: SurfaceCircuitStep) -> None: + nonlocal physical_prefix_pulses, two_qubit_prefix_pulses, virtual_z_two_qubit_carries + current = pending_for(q) + if current == _SZZ_FLOW_IDENTITY: + return + if _szz_flow_is_virtual_z(current): + virtual_z_two_qubit_carries += 1 + pulses.append( + pulse_event( + host_index=host_index, + op=op, + qubit=q, + kind="virtual_z_two_qubit_carry", + pending=current, + ), + ) + return + physical_prefix_pulses += 1 + two_qubit_prefix_pulses += 1 + pulses.append( + pulse_event( + host_index=host_index, + op=op, + qubit=q, + kind="physical_two_qubit_prefix", + pending=current, + ), + ) + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + + def discharge_for_measurement(q: int, host_index: int, op: SurfaceCircuitStep) -> None: + nonlocal physical_prefix_pulses, measurement_prefix_pulses, virtual_z_measure_discards + current = pending_for(q) + if current == _SZZ_FLOW_IDENTITY: + return + if _szz_flow_is_virtual_z(current): + virtual_z_measure_discards += 1 + pulses.append( + pulse_event( + host_index=host_index, + op=op, + qubit=q, + kind="virtual_z_measure_discard", + pending=current, + ), + ) + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + return + physical_prefix_pulses += 1 + measurement_prefix_pulses += 1 + pulses.append( + pulse_event( + host_index=host_index, + op=op, + qubit=q, + kind="physical_measurement_prefix", + pending=current, + ), + ) + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + + for host_index, op in enumerate(ops): + if op.op_type in {OpType.COMMENT, OpType.TICK, OpType.TRACKED_PAULI}: + continue + if op.op_type in {OpType.ALLOC, OpType.PREP}: + prep_events += 1 + reset_for_prep(op.qubits[0], op) + continue + if op.op_type in _SZZ_FLOW_SINGLE_QUBIT_GATES: + q = op.qubits[0] + abstract_single_qubit_ops += 1 + pending_by_qubit[q] = _szz_flow_compose_pending_gate(pending_for(q), op.op_type) + continue + if op.op_type in {OpType.SZZ, OpType.SZZDG}: + two_qubit_gates += 1 + for q in op.qubits: + discharge_for_two_qubit(q, host_index, op) + continue + if op.op_type == OpType.CX: + msg = "SZZ forward-flow analysis only supports SZZ/SZZdg two-qubit gates" + raise ValueError(msg) + if op.op_type == OpType.MEASURE: + measurements += 1 + discharge_for_measurement(op.qubits[0], host_index, op) + continue + + remaining = {q: pending for q, pending in pending_by_qubit.items() if pending != _SZZ_FLOW_IDENTITY} + if remaining: + formatted = {q: _szz_flow_clifford_name(pending) for q, pending in sorted(remaining.items())} + msg = f"SZZ forward-flow ended with pending Cliffords: {formatted}" + raise ValueError(msg) + + return SzzForwardFlowSummary( + abstract_single_qubit_ops=abstract_single_qubit_ops, + physical_prefix_pulses=physical_prefix_pulses, + two_qubit_prefix_pulses=two_qubit_prefix_pulses, + measurement_prefix_pulses=measurement_prefix_pulses, + virtual_z_two_qubit_carries=virtual_z_two_qubit_carries, + virtual_z_measure_discards=virtual_z_measure_discards, + two_qubit_gates=two_qubit_gates, + measurements=measurements, + prep_events=prep_events, + free_standing_single_qubit_ops=0, + pulses=tuple(pulses), + ) + + def build_surface_code_circuit( patch: SurfacePatch, num_rounds: int, @@ -991,6 +1229,7 @@ def append_szz_layer( ops.extend(SurfaceCircuitStep(OpType.H, [data_q(i)]) for i in range(num_data)) ops.extend(SurfaceCircuitStep(OpType.MEASURE, [data_q(i)], f"final[{i}]") for i in range(num_data)) + _analyze_szz_forward_flow(ops) return ops 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 6ffece8cf..c6a029b3b 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 @@ -12,7 +12,9 @@ from pecos.qec.surface import NoiseModel, SurfacePatch, TwirlConfig from pecos.qec.surface.circuit_builder import ( OpType, + SurfaceCircuitStep, SzzTouchSign, + _analyze_szz_forward_flow, _default_szz_residual_plan, _default_szz_sign_vector, _propagate_compensated_szz_frame_bits, @@ -275,6 +277,110 @@ def test_szz_tick_circuit_uses_named_szz_gates() -> None: assert {"SZZ", "SZZdg", "SX", "SXdg", "SZ", "SZdg"} <= gate_names +def test_szz_forward_flow_merges_h_sandwich_before_host() -> None: + ops = [ + SurfaceCircuitStep(OpType.ALLOC, [0]), + SurfaceCircuitStep(OpType.ALLOC, [1]), + SurfaceCircuitStep(OpType.H, [0]), + SurfaceCircuitStep(OpType.H, [0]), + SurfaceCircuitStep(OpType.SZZ, [0, 1], "g"), + SurfaceCircuitStep(OpType.MEASURE, [0], "m0"), + SurfaceCircuitStep(OpType.MEASURE, [1], "m1"), + ] + + summary = _analyze_szz_forward_flow(ops) + + assert summary.abstract_single_qubit_ops == 2 + assert summary.physical_prefix_pulses == 0 + assert summary.free_standing_single_qubit_ops == 0 + assert summary.pulses == () + + +def test_szz_forward_flow_carries_virtual_z_to_measurement() -> None: + ops = [ + SurfaceCircuitStep(OpType.ALLOC, [0]), + SurfaceCircuitStep(OpType.ALLOC, [1]), + SurfaceCircuitStep(OpType.SZ, [0]), + SurfaceCircuitStep(OpType.SZZ, [0, 1], "g"), + SurfaceCircuitStep(OpType.MEASURE, [0], "m0"), + SurfaceCircuitStep(OpType.MEASURE, [1], "m1"), + ] + + summary = _analyze_szz_forward_flow(ops) + + assert summary.physical_prefix_pulses == 0 + assert summary.virtual_z_two_qubit_carries == 1 + assert summary.virtual_z_measure_discards == 1 + assert [event.kind for event in summary.pulses] == [ + "virtual_z_two_qubit_carry", + "virtual_z_measure_discard", + ] + + +def test_szz_forward_flow_counts_physical_prefixes_at_hosts() -> None: + ops = [ + SurfaceCircuitStep(OpType.ALLOC, [0]), + SurfaceCircuitStep(OpType.ALLOC, [1]), + SurfaceCircuitStep(OpType.H, [0]), + SurfaceCircuitStep(OpType.SZZ, [0, 1], "g"), + SurfaceCircuitStep(OpType.H, [1]), + SurfaceCircuitStep(OpType.MEASURE, [0], "m0"), + SurfaceCircuitStep(OpType.MEASURE, [1], "m1"), + ] + + summary = _analyze_szz_forward_flow(ops) + + assert summary.physical_prefix_pulses == 2 + assert summary.two_qubit_prefix_pulses == 1 + assert summary.measurement_prefix_pulses == 1 + assert [event.kind for event in summary.pulses] == [ + "physical_two_qubit_prefix", + "physical_measurement_prefix", + ] + + +@pytest.mark.parametrize( + ("basis", "expected"), + [ + ( + "Z", + { + "abstract_single_qubit_ops": 108, + "physical_prefix_pulses": 54, + "two_qubit_prefix_pulses": 38, + "measurement_prefix_pulses": 16, + "virtual_z_two_qubit_carries": 10, + "virtual_z_measure_discards": 5, + }, + ), + ( + "X", + { + "abstract_single_qubit_ops": 102, + "physical_prefix_pulses": 54, + "two_qubit_prefix_pulses": 37, + "measurement_prefix_pulses": 17, + "virtual_z_two_qubit_carries": 10, + "virtual_z_measure_discards": 3, + }, + ), + ], +) +def test_szz_forward_flow_surface_pulse_count_snapshot(basis: str, expected: dict[str, int]) -> None: + patch = SurfacePatch.create(distance=3) + ops, _ = build_surface_code_circuit(patch, num_rounds=1, basis=basis, interaction_basis="szz") + + summary = _analyze_szz_forward_flow(ops) + + assert summary.two_qubit_gates == 36 + assert summary.measurements == 21 + assert summary.prep_events == 21 + assert summary.free_standing_single_qubit_ops == 0 + for field, value in expected.items(): + assert getattr(summary, field) == value + assert summary.physical_prefix_pulses < summary.abstract_single_qubit_ops + + def test_szz_direct_renderers_accept_interaction_basis() -> None: patch = SurfacePatch.create(distance=3) From 63f19035dd5b23dfa0ca8bd1dcb72a2c77d70327 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 21:07:02 -0600 Subject: [PATCH 074/150] Guard SZZ surface DEM pulse noise --- .../src/pecos/qec/surface/decode.py | 23 ++++++++++++ .../qec/surface/test_szz_interaction_basis.py | 35 +++++++++++++++++-- .../tests/qec/test_from_guppy_dem.py | 2 +- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index ac003292c..0772bc9b1 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1579,6 +1579,27 @@ def _noise_uses_dedicated_idle_noise(noise: NoiseModel) -> bool: ) +def _reject_szz_unlowered_physical_noise(noise: NoiseModel, interaction_basis: str) -> None: + """Reject SZZ surface DEM noise that still needs post-flow pulse locations.""" + if interaction_basis != "szz": + return + reasons: list[str] = [] + if noise.p1 > 0.0: + reasons.append("p1") + if _noise_uses_dedicated_idle_noise(noise): + reasons.append("dedicated idle noise") + if not reasons: + return + joined = ", ".join(reasons) + msg = ( + "interaction_basis='szz' surface DEM generation does not yet support " + f"{joined} because the DEM must use post-flow prefix pulse locations " + "rather than the abstract H/SX/SZ scaffold; set p1=0 and omit " + "dedicated idle noise until SZZ pulse-location DEM lowering is enabled" + ) + raise ValueError(msg) + + def _with_noise_compat(builder: Any, noise: NoiseModel) -> Any: """Call Rust ``with_noise`` using the richest signature this binding supports.""" noise_kwargs = { @@ -1985,6 +2006,7 @@ def generate_circuit_level_dem_from_builder( from pecos.qec.surface.circuit_builder import _normalize_interaction_basis interaction_basis = _normalize_interaction_basis(interaction_basis) + _reject_szz_unlowered_physical_noise(noise, interaction_basis) patch_key = _surface_patch_cache_key(patch) include_idle_gates = _noise_uses_dedicated_idle_noise(noise) if runtime is not None: @@ -3759,6 +3781,7 @@ def build_native_sampler( from pecos.qec.surface.circuit_builder import _normalize_interaction_basis interaction_basis = _normalize_interaction_basis(interaction_basis) + _reject_szz_unlowered_physical_noise(noise, interaction_basis) basis = basis.upper() patch_key = _surface_patch_cache_key(patch) topology = _cached_surface_native_topology( 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 c6a029b3b..087d8d98e 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 @@ -27,7 +27,7 @@ generate_stim_from_patch, generate_tick_circuit_from_patch, ) -from pecos.qec.surface.decode import build_memory_circuit, generate_circuit_level_dem_from_builder +from pecos.qec.surface.decode import build_memory_circuit, build_native_sampler, generate_circuit_level_dem_from_builder def _to_numpy_complex(matrix: object) -> np.ndarray: @@ -448,7 +448,7 @@ def test_szz_noiseless_detector_record_equivalence(distance: int, basis: str) -> def test_szz_native_dem_path_uses_interaction_basis() -> None: patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001) + noise = NoiseModel(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, @@ -465,3 +465,34 @@ def test_szz_native_dem_path_uses_interaction_basis() -> None: assert cx_dem != szz_dem assert stim.DetectorErrorModel(szz_dem).num_detectors > 0 + + +@pytest.mark.parametrize( + ("noise", "match"), + [ + (NoiseModel(p1=0.001), r"p1.*post-flow prefix pulse locations"), + (NoiseModel(p_idle=0.001), r"dedicated idle noise.*post-flow prefix pulse locations"), + ], +) +def test_szz_native_dem_rejects_unlowered_physical_noise(noise: NoiseModel, match: str) -> None: + patch = SurfacePatch.create(distance=3) + + with pytest.raises(ValueError, match=match): + generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=noise, + interaction_basis="szz", + ) + + +def test_szz_native_sampler_rejects_unlowered_physical_noise() -> None: + patch = SurfacePatch.create(distance=3) + + with pytest.raises(ValueError, match=r"p1.*post-flow prefix pulse locations"): + build_native_sampler( + patch, + num_rounds=1, + noise=NoiseModel(p1=0.001), + interaction_basis="szz", + ) 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 b461b650c..d3dcdf116 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -499,7 +499,7 @@ def test_from_guppy_surface_code_is_byte_identical_to_reference() -> None: @pytest.mark.parametrize("distance", [3, 5]) def test_from_guppy_szz_surface_code_is_byte_identical_to_reference(distance: int) -> None: """SZZ-basis surface Guppy generation must match the traced-QIS reference DEM.""" - p = {"p1": 0.005, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} + p = {"p1": 0.0, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} for basis in ("Z", "X"): patch = SurfacePatch.create(distance=distance) ref = _build_surface_tick_circuit_for_native_model( From 67d90e79822f0cdaae97d46bcaa32e581c85d5f8 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 21:18:04 -0600 Subject: [PATCH 075/150] Add SZZ gate-specific noise rates --- .../fault_tolerance/dem_builder/builder.rs | 5 +- .../dem_builder/dem_sampler.rs | 13 +++- .../fault_tolerance/dem_builder/sampler.rs | 2 +- .../src/fault_tolerance/dem_builder/types.rs | 46 ++++++++++++-- .../src/fault_tolerance_bindings.rs | 49 +++++++++++++-- .../src/pecos/qec/surface/decode.py | 37 +++++++++++ .../qec/surface/test_szz_interaction_basis.py | 61 +++++++++++++++++++ 7 files changed, 198 insertions(+), 15 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 ab0b8fa5a..eae98d6f4 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -434,6 +434,7 @@ impl<'a> DemBuilder<'a> { let p1 = flat / 4; let p2 = flat % 4; let pauli = pauli_pair_for_weight(p1, p2); + let p2_total = self.noise.p2_rate_for_gate(loc1.gate_type); let weight = if self.noise.p2_replacement_approximation == ReplacementBranchApproximation::BranchImpact || self.noise.p2_replacement_approximation @@ -447,10 +448,10 @@ impl<'a> DemBuilder<'a> { self.noise.p2_replacement_approximation, ) }; - self.noise.p2 * weight + p2_total * weight }); } - [per_channel_probability(self.noise.p2, 15); 15] + [per_channel_probability(self.noise.p2_rate_for_gate(loc1.gate_type), 15); 15] } /// Sets the number of measurements (used for record offset calculation). 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 4fe4b1de7..d788c6508 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 @@ -1848,6 +1848,7 @@ pub(crate) struct SamplingEngineBuilder<'a> { influence_map: &'a DagFaultInfluenceMap, p1: f64, p2: f64, + p2_gate_rates: BTreeMap, p_meas: f64, p_prep: f64, p1_weights: Option, @@ -1874,6 +1875,7 @@ impl<'a> SamplingEngineBuilder<'a> { influence_map, p1: 0.01, p2: 0.01, + p2_gate_rates: BTreeMap::new(), p_meas: 0.01, p_prep: 0.01, p1_weights: None, @@ -1893,6 +1895,7 @@ impl<'a> SamplingEngineBuilder<'a> { pub fn with_noise(mut self, p1: f64, p2: f64, p_meas: f64, p_prep: f64) -> Self { self.p1 = p1; self.p2 = p2; + self.p2_gate_rates.clear(); self.p_meas = p_meas; self.p_prep = p_prep; self.p1_weights = None; @@ -1907,6 +1910,7 @@ impl<'a> SamplingEngineBuilder<'a> { pub fn with_noise_config(mut self, noise: NoiseConfig) -> Self { self.p1 = noise.p1; self.p2 = noise.p2; + self.p2_gate_rates = noise.p2_gate_rates.clone(); self.p_meas = noise.p_meas; self.p_prep = noise.p_prep; self.p1_weights = noise.p1_weights.clone(); @@ -2134,7 +2138,9 @@ impl<'a> SamplingEngineBuilder<'a> { } // Process two-qubit gates as pairs - let has_any_2q_noise = self.per_gate.is_some() || self.p2 > 0.0; + let has_any_2q_noise = self.per_gate.is_some() + || 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) { @@ -2380,12 +2386,13 @@ impl<'a> SamplingEngineBuilder<'a> { std::array::from_fn(|i| pg.rate_2q(gate, i)) } } else { + let p2_total = self.p2_gate_rates.get(&gate).copied().unwrap_or(self.p2); if let Some(weights) = &self.p2_weights { return std::array::from_fn(|idx| { let flat = idx + 1; let p1 = flat / 4; let p2 = flat % 4; - self.p2 + p2_total * weights.two_qubit_weight_for( gate, &pauli_pair_for_weight(p1, p2), @@ -2393,7 +2400,7 @@ impl<'a> SamplingEngineBuilder<'a> { ) }); } - [self.p2 / 15.0; 15] + [p2_total / 15.0; 15] } } 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 6bd954490..6018d82f3 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -1441,7 +1441,7 @@ pub(crate) fn compute_location_probs_from_noise( | GateType::SWAP | GateType::RXX | GateType::RYY - | GateType::RZZ => noise.p2, + | 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); 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 51025ff67..66ca3ef38 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -2437,6 +2437,12 @@ pub struct NoiseConfig { pub p1: f64, /// Two-qubit gate error rate. pub p2: f64, + /// Optional per-gate total error-rate overrides for two-qubit gates. + /// + /// When a two-qubit gate type appears here, this total rate replaces + /// `p2` while still using `p2_weights` to distribute probability across + /// Pauli-pair channels. + pub p2_gate_rates: BTreeMap, /// Measurement error rate. pub p_meas: f64, /// Initialization (prep) error rate. @@ -2660,6 +2666,7 @@ impl Default for NoiseConfig { Self { p1: 0.01, p2: 0.01, + p2_gate_rates: BTreeMap::new(), p_meas: 0.01, p_prep: 0.01, p_idle: 0.0, @@ -2693,6 +2700,7 @@ impl NoiseConfig { Self { p1, p2, + p2_gate_rates: BTreeMap::new(), p_meas, p_prep, p_idle: 0.0, @@ -2724,6 +2732,7 @@ impl NoiseConfig { Self { p1, p2, + p2_gate_rates: BTreeMap::new(), p_meas, p_prep, p_idle, @@ -2755,6 +2764,7 @@ impl NoiseConfig { Self { p1: p, p2: p, + p2_gate_rates: BTreeMap::new(), p_meas: p, p_prep: p, p_idle: p, @@ -2894,6 +2904,32 @@ impl NoiseConfig { self } + /// Sets a total two-qubit error-rate override for one gate type. + /// + /// The override changes only the total rate. If `p2_weights` is configured, + /// those weights still determine the relative Pauli-pair distribution for + /// this gate. + #[must_use] + pub fn set_p2_gate_rate(mut self, gate_type: GateType, rate: f64) -> Self { + self.p2_gate_rates.insert(gate_type, rate.max(0.0)); + self + } + + /// Returns the total two-qubit error rate for `gate_type`. + #[must_use] + pub fn p2_rate_for_gate(&self, gate_type: GateType) -> f64 { + self.p2_gate_rates + .get(&gate_type) + .copied() + .unwrap_or(self.p2) + } + + /// Returns true when any scalar or per-gate two-qubit rate is positive. + #[must_use] + pub fn has_any_p2_noise(&self) -> bool { + self.p2 > 0.0 || self.p2_gate_rates.values().any(|rate| *rate > 0.0) + } + /// Sets how replacement entries in `p2_weights` are approximated. #[must_use] pub fn set_p2_replacement_approximation( @@ -3542,18 +3578,20 @@ impl PerGateTypeNoise { self.rate_1q(gate, pauli_idx) } - /// Lookup 2Q Pauli pair rate for a gate. Returns `base.p2 / 15.0` - /// if the gate type is not in the map. `pair_idx` follows [`PAULI_2Q_ORDER`]. + /// Lookup 2Q Pauli pair rate for a gate. Returns the base two-qubit gate + /// rate divided over the 15 Pauli pairs if the gate type is not in the + /// map. `pair_idx` follows [`PAULI_2Q_ORDER`]. #[must_use] pub fn rate_2q(&self, gate: GateType, pair_idx: usize) -> f64 { self.rates_2q .get(&gate) - .map_or(self.base.p2 / 15.0, |r| r[pair_idx]) + .map_or(self.base.p2_rate_for_gate(gate) / 15.0, |r| r[pair_idx]) } /// Lookup 2Q Pauli pair rate for a gate on a specific ordered /// qubit pair. Tries `(gate, q_control, q_target)` in the per-qubits - /// map first, then the per-gate-type map, then `base.p2 / 15.0`. + /// map first, then the per-gate-type map, then the base two-qubit gate + /// rate divided over the 15 Pauli pairs. #[must_use] pub fn rate_2q_on( &self, diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 80b0831e8..5a12e518e 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -43,6 +43,7 @@ //! ``` use crate::pecos_array::{Array, ArrayData}; +use pecos_core::gate_type::GateType; use pecos_qec::fault_tolerance::PauliFrameLookup as RustPauliFrameLookup; use pecos_qec::fault_tolerance::dem_builder::{ ComparisonMethod as RustComparisonMethod, @@ -71,6 +72,7 @@ use pecos_quantum::QubitId; use pyo3::Py; use pyo3::prelude::*; use std::collections::BTreeMap; +use std::str::FromStr; type PyDemMechanismTuple = (f64, Vec, Vec); type PyDemFitResult = (Vec, Vec); @@ -203,6 +205,27 @@ fn parse_replacement_approximation( } } +fn parse_p2_gate_rates(rates: BTreeMap) -> PyResult> { + let mut parsed = BTreeMap::new(); + for (label, rate) in rates { + if !rate.is_finite() || rate < 0.0 { + let msg = + format!("p2_gate_rates[{label:?}] must be finite and non-negative, got {rate}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + let gate_type = GateType::from_str(label.trim()).map_err(|err| { + let msg = format!("unsupported p2_gate_rates gate label {label:?}: {err}"); + pyo3::exceptions::PyValueError::new_err(msg) + })?; + if !gate_type.is_two_qubit() { + let msg = format!("p2_gate_rates keys must name two-qubit gates, got {label:?}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + parsed.insert(gate_type, rate); + } + Ok(parsed) +} + fn parse_measurement_crosstalk_dem_mode( value: Option, ) -> PyResult { @@ -281,6 +304,7 @@ fn apply_noise_options( p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, + p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, @@ -336,6 +360,11 @@ fn apply_noise_options( if let Some(weights) = p2_weights { noise = noise.set_p2_weights(parse_p2_weights(weights)?); } + if let Some(rates) = p2_gate_rates { + for (gate_type, rate) in parse_p2_gate_rates(rates)? { + noise = noise.set_p2_gate_rate(gate_type, rate); + } + } noise = noise.set_p2_replacement_approximation(parse_replacement_approximation( p2_replacement_approximation, )?); @@ -1426,7 +1455,7 @@ impl PyDetectorErrorModel { /// >>> print(dem.to_string()) /// >>> sampler = dem.to_sampler() #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &pyo3::Bound<'_, pyo3::PyAny>, @@ -1452,6 +1481,7 @@ impl PyDetectorErrorModel { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, + p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, @@ -1480,6 +1510,7 @@ impl PyDetectorErrorModel { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, + p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, @@ -1836,7 +1867,7 @@ impl PyDemBuilder { /// /// Returns: /// Self for method chaining. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -1862,6 +1893,7 @@ impl PyDemBuilder { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, + p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, @@ -1888,6 +1920,7 @@ impl PyDemBuilder { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, + p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, @@ -3755,7 +3788,7 @@ impl PyDemSampler { /// >>> sampler = DemSampler.from_circuit(dag, p1=0.001, p2=0.01) /// >>> sampler = DemSampler.from_circuit(tc, p2=0.01) # TickCircuit also works #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &Bound<'_, pyo3::PyAny>, @@ -3781,6 +3814,7 @@ impl PyDemSampler { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, + p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, @@ -3807,6 +3841,7 @@ impl PyDemSampler { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, + p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, @@ -3922,7 +3957,7 @@ impl PyDemSampler { /// /// The `observables` argument defines observables. #[staticmethod] - #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_detectors( influence_map: &PyDagFaultInfluenceMap, @@ -3950,6 +3985,7 @@ impl PyDemSampler { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, + p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, @@ -3976,6 +4012,7 @@ impl PyDemSampler { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, + p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, @@ -4491,7 +4528,7 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -4517,6 +4554,7 @@ impl PyDemSamplerBuilder { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, + p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, @@ -4543,6 +4581,7 @@ impl PyDemSamplerBuilder { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, + p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 0772bc9b1..244f07837 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -96,6 +96,10 @@ class NoiseModel: error labels ``"X"``, ``"Y"``, and ``"Z"``. Values must sum to 1.0; ``p1`` remains the total single-qubit error rate. p2: Two-qubit gate error rate. + p2_szz: Optional total error-rate override for ``SZZ`` gates. When + unset, ``SZZ`` uses ``p2``. + p2_szzdg: Optional total error-rate override for ``SZZdg`` gates. When + unset, ``SZZdg`` uses ``p2``. 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"`` are replacement @@ -134,6 +138,8 @@ class NoiseModel: p1: float = 0.0 p1_weights: P1Weights | None = None p2: float = 0.0 + p2_szz: float | None = None + p2_szzdg: float | None = None p2_weights: P2Weights | None = None p2_replacement_approximation: str | None = None p_meas: float = 0.0 @@ -158,6 +164,10 @@ def __post_init__(self) -> None: """Normalize cache-sensitive inputs after dataclass initialization.""" self.p1_weights = _normalize_p1_weights(self.p1_weights) self.p2_weights = _normalize_p2_weights(self.p2_weights) + if self.p2_szz is not 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) @property def effective_p_idle_z_linear_rate(self) -> float | None: @@ -191,6 +201,11 @@ def idle_memory_rates(self) -> tuple[float | None, ...]: self.effective_p_idle_z_quadratic_sine_rate, ) + @property + def p2_gate_rates(self) -> tuple[float | None, ...]: + """Explicit two-qubit gate-rate overrides.""" + return (self.p2_szz, self.p2_szzdg) + @staticmethod def uniform(physical_error_rate: float) -> NoiseModel: """Create a uniform circuit-level noise model from one physical error rate.""" @@ -203,6 +218,7 @@ def is_noiseless(self) -> bool: return ( self.p1 == 0.0 and self.p2 == 0.0 + and all(rate is None or rate == 0.0 for rate in self.p2_gate_rates) and self.p_meas == 0.0 and self.p_prep == 0.0 and (self.p_idle is None or self.p_idle == 0.0) @@ -213,6 +229,7 @@ def is_noiseless(self) -> bool: def physical_error_rate(self) -> float: """Approximate combined physical error rate.""" rates = [self.p1, self.p2, self.p_meas, self.p_prep] + rates.extend(rate for rate in self.p2_gate_rates if rate is not None) if self.p_idle is not None: rates.append(self.p_idle) rates.extend(rate for rate in self.idle_memory_rates if rate is not None) @@ -244,6 +261,15 @@ 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: + rates: dict[str, float] = {} + if noise.p2_szz is not None: + rates["SZZ"] = noise.p2_szz + if noise.p2_szzdg is not None: + rates["SZZdg"] = noise.p2_szzdg + return rates or None + + @dataclass class DecodingResult: """Result from decoding a single shot.""" @@ -1621,6 +1647,9 @@ def _with_noise_compat(builder: Any, noise: NoiseModel) -> Any: "p1_weights": _p1_weights_dict(noise.p1_weights), "p2_weights": _p2_weights_dict(noise.p2_weights), } + p2_gate_rates = _p2_gate_rates_dict(noise) + if p2_gate_rates is not None: + noise_kwargs["p2_gate_rates"] = p2_gate_rates if noise.p2_replacement_approximation is not None: noise_kwargs["p2_replacement_approximation"] = noise.p2_replacement_approximation @@ -1804,6 +1833,8 @@ def _cached_surface_native_dem_string( p1: float, p1_weights: tuple[tuple[str, float], ...] | None, p2: float, + p2_szz: float | None, + p2_szzdg: float | None, p_meas: float, p_prep: float, decompose_errors: bool, @@ -1861,6 +1892,8 @@ def _cached_surface_native_dem_string( p1=p1, p1_weights=p1_weights, p2=p2, + p2_szz=p2_szz, + p2_szzdg=p2_szzdg, p2_weights=p2_weights, p2_replacement_approximation=p2_replacement_approximation, p_meas=p_meas, @@ -2036,6 +2069,8 @@ def generate_circuit_level_dem_from_builder( noise.p1, noise.p1_weights, noise.p2, + noise.p2_szz, + noise.p2_szzdg, noise.p_meas, noise.p_prep, decompose_errors=decompose_errors, @@ -3804,6 +3839,8 @@ def build_native_sampler( noise.p1, noise.p1_weights, noise.p2, + noise.p2_szz, + noise.p2_szzdg, noise.p_meas, noise.p_prep, decompose_errors=True, 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 087d8d98e..80287125c 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 @@ -467,6 +467,67 @@ def test_szz_native_dem_path_uses_interaction_basis() -> None: assert stim.DetectorErrorModel(szz_dem).num_detectors > 0 +def test_szz_native_dem_respects_gate_specific_p2_overrides() -> None: + patch = SurfacePatch.create(distance=3) + + 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}), + 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}), + 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}), + interaction_basis="szz", + ) + override_only_dem = generate_circuit_level_dem_from_builder( + 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}, + ), + interaction_basis="szz", + ) + + assert no_szz_dem != inherited_dem + assert no_szzdg_dem != inherited_dem + assert "error(" in override_only_dem + + +def test_szz_native_influence_sampler_respects_override_only_p2() -> None: + patch = SurfacePatch.create(distance=3) + + 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}), + 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}), + interaction_basis="szz", + sampling_model="influence_dem", + ) + + assert "mechanisms=0" in repr(zero_sampler.sampler) + assert "mechanisms=0" not in repr(active_sampler.sampler) + + @pytest.mark.parametrize( ("noise", "match"), [ From e7e303043864f1adc19d4061521a95ff7cdf69b7 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 21:33:30 -0600 Subject: [PATCH 076/150] Add SZZ prefix p1 lowering --- .../fault_tolerance/dem_builder/builder.rs | 9 +- .../dem_builder/dem_sampler.rs | 13 +- .../fault_tolerance/dem_builder/sampler.rs | 2 +- .../src/fault_tolerance/dem_builder/types.rs | 40 ++++- .../src/fault_tolerance_bindings.rs | 47 +++++- .../src/pecos/qec/surface/circuit_builder.py | 153 ++++++++++++++++++ .../src/pecos/qec/surface/decode.py | 71 ++++++-- .../qec/surface/test_szz_interaction_basis.py | 52 +++++- 8 files changed, 355 insertions(+), 32 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 eae98d6f4..c4d736f90 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -370,15 +370,16 @@ impl<'a> DemBuilder<'a> { pg.rate_1q(loc.gate_type, 2), ]; } + let p1_total = self.noise.p1_rate_for_gate(loc.gate_type); if let Some(weights) = &self.noise.p1_weights { use pecos_core::pauli::{X, Y, Z}; return [ - self.noise.p1 * weights.weight_for(&X(0)), - self.noise.p1 * weights.weight_for(&Y(0)), - self.noise.p1 * weights.weight_for(&Z(0)), + p1_total * weights.weight_for(&X(0)), + p1_total * weights.weight_for(&Y(0)), + p1_total * weights.weight_for(&Z(0)), ]; } - let per = per_channel_probability(self.noise.p1, 3); + let per = per_channel_probability(p1_total, 3); [per, per, per] } 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 d788c6508..637f60cf4 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 @@ -1847,6 +1847,7 @@ pub(crate) struct SamplingEngineBuilder<'a> { per_gate: Option, influence_map: &'a DagFaultInfluenceMap, p1: f64, + p1_gate_rates: BTreeMap, p2: f64, p2_gate_rates: BTreeMap, p_meas: f64, @@ -1874,6 +1875,7 @@ impl<'a> SamplingEngineBuilder<'a> { Self { influence_map, p1: 0.01, + p1_gate_rates: BTreeMap::new(), p2: 0.01, p2_gate_rates: BTreeMap::new(), p_meas: 0.01, @@ -1894,6 +1896,7 @@ impl<'a> SamplingEngineBuilder<'a> { #[must_use] pub fn with_noise(mut self, p1: f64, p2: f64, p_meas: f64, p_prep: f64) -> Self { self.p1 = p1; + self.p1_gate_rates.clear(); self.p2 = p2; self.p2_gate_rates.clear(); self.p_meas = p_meas; @@ -1909,6 +1912,7 @@ impl<'a> SamplingEngineBuilder<'a> { #[must_use] pub fn with_noise_config(mut self, noise: NoiseConfig) -> Self { self.p1 = noise.p1; + self.p1_gate_rates = noise.p1_gate_rates.clone(); self.p2 = noise.p2; self.p2_gate_rates = noise.p2_gate_rates.clone(); self.p_meas = noise.p_meas; @@ -2331,15 +2335,16 @@ impl<'a> SamplingEngineBuilder<'a> { ] } } else { + let p1_total = self.p1_gate_rates.get(&gate).copied().unwrap_or(self.p1); if let Some(weights) = &self.p1_weights { use pecos_core::pauli::{X, Y, Z}; return [ - self.p1 * weights.weight_for(&X(0)), - self.p1 * weights.weight_for(&Y(0)), - self.p1 * weights.weight_for(&Z(0)), + p1_total * weights.weight_for(&X(0)), + p1_total * weights.weight_for(&Y(0)), + p1_total * weights.weight_for(&Z(0)), ]; } - [self.p1 / 3.0; 3] + [p1_total / 3.0; 3] } } 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 6018d82f3..3da43b5b8 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -1450,7 +1450,7 @@ pub(crate) fn compute_location_probs_from_noise( 0.0 } } - _ => noise.p1, + _ => noise.p1_rate_for_gate(loc.gate_type), } }) .collect() 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 66ca3ef38..84b824810 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -2435,6 +2435,12 @@ pub fn omitted_two_qubit_gate_pauli_twirl( pub struct NoiseConfig { /// Single-qubit gate error rate. pub p1: f64, + /// Optional per-gate total error-rate overrides for single-qubit gates. + /// + /// When a single-qubit gate type appears here, this total rate replaces + /// `p1` while still using `p1_weights` to distribute probability across + /// Pauli channels. + pub p1_gate_rates: BTreeMap, /// Two-qubit gate error rate. pub p2: f64, /// Optional per-gate total error-rate overrides for two-qubit gates. @@ -2665,6 +2671,7 @@ impl Default for NoiseConfig { fn default() -> Self { Self { p1: 0.01, + p1_gate_rates: BTreeMap::new(), p2: 0.01, p2_gate_rates: BTreeMap::new(), p_meas: 0.01, @@ -2699,6 +2706,7 @@ impl NoiseConfig { pub fn new(p1: f64, p2: f64, p_meas: f64, p_prep: f64) -> Self { Self { p1, + p1_gate_rates: BTreeMap::new(), p2, p2_gate_rates: BTreeMap::new(), p_meas, @@ -2731,6 +2739,7 @@ impl NoiseConfig { pub fn with_idle(p1: f64, p2: f64, p_meas: f64, p_prep: f64, p_idle: f64) -> Self { Self { p1, + p1_gate_rates: BTreeMap::new(), p2, p2_gate_rates: BTreeMap::new(), p_meas, @@ -2763,6 +2772,7 @@ impl NoiseConfig { pub fn uniform(p: f64) -> Self { Self { p1: p, + p1_gate_rates: BTreeMap::new(), p2: p, p2_gate_rates: BTreeMap::new(), p_meas: p, @@ -2897,6 +2907,26 @@ impl NoiseConfig { self } + /// Sets a total single-qubit error-rate override for one gate type. + /// + /// The override changes only the total rate. If `p1_weights` is configured, + /// those weights still determine the relative Pauli distribution for this + /// gate. + #[must_use] + pub fn set_p1_gate_rate(mut self, gate_type: GateType, rate: f64) -> Self { + self.p1_gate_rates.insert(gate_type, rate.max(0.0)); + self + } + + /// Returns the total single-qubit error rate for `gate_type`. + #[must_use] + pub fn p1_rate_for_gate(&self, gate_type: GateType) -> f64 { + self.p1_gate_rates + .get(&gate_type) + .copied() + .unwrap_or(self.p1) + } + /// Sets custom per-Pauli weights for two-qubit gates. #[must_use] pub fn set_p2_weights(mut self, weights: PauliWeights) -> Self { @@ -3542,8 +3572,9 @@ impl PerGateTypeNoise { self } - /// Lookup 1Q Pauli rate for a gate. Returns `base.p1 / 3.0` if the - /// gate type is not in the map. `pauli_idx` is 0=X, 1=Y, 2=Z. + /// Lookup 1Q Pauli rate for a gate. Returns the base single-qubit gate + /// rate divided over the 3 Pauli channels if the gate type is not in the + /// map. `pauli_idx` is 0=X, 1=Y, 2=Z. /// /// `Idle` is a no-op by default. It receives noise only from explicitly /// attached idle rates or from the base idle-noise model. @@ -3564,11 +3595,12 @@ impl PerGateTypeNoise { } return 0.0; } - self.base.p1 / 3.0 + self.base.p1_rate_for_gate(gate) / 3.0 } /// Lookup 1Q Pauli rate for a gate on a specific qubit. Tries the - /// per-qubit map first, then the per-gate-type map, then `base.p1 / 3.0`. + /// per-qubit map first, then the per-gate-type map, then the base + /// single-qubit gate rate divided over the 3 Pauli channels. /// `pauli_idx` is 0=X, 1=Y, 2=Z. #[must_use] pub fn rate_1q_on(&self, gate: GateType, qubit: QubitId, pauli_idx: usize) -> f64 { diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 5a12e518e..402e594b4 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -226,6 +226,27 @@ fn parse_p2_gate_rates(rates: BTreeMap) -> PyResult) -> PyResult> { + let mut parsed = BTreeMap::new(); + for (label, rate) in rates { + if !rate.is_finite() || rate < 0.0 { + let msg = + format!("p1_gate_rates[{label:?}] must be finite and non-negative, got {rate}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + let gate_type = GateType::from_str(label.trim()).map_err(|err| { + let msg = format!("unsupported p1_gate_rates gate label {label:?}: {err}"); + pyo3::exceptions::PyValueError::new_err(msg) + })?; + if !gate_type.is_single_qubit() { + let msg = format!("p1_gate_rates keys must name single-qubit gates, got {label:?}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + parsed.insert(gate_type, rate); + } + Ok(parsed) +} + fn parse_measurement_crosstalk_dem_mode( value: Option, ) -> PyResult { @@ -310,6 +331,7 @@ fn apply_noise_options( p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p1_gate_rates: Option>, ) -> PyResult { noise.p_idle = p_idle.unwrap_or(0.0); if let (Some(t1_val), Some(t2_val)) = (t1, t2) { @@ -365,6 +387,11 @@ fn apply_noise_options( noise = noise.set_p2_gate_rate(gate_type, rate); } } + if let Some(rates) = p1_gate_rates { + for (gate_type, rate) in parse_p1_gate_rates(rates)? { + noise = noise.set_p1_gate_rate(gate_type, rate); + } + } noise = noise.set_p2_replacement_approximation(parse_replacement_approximation( p2_replacement_approximation, )?); @@ -1455,7 +1482,7 @@ impl PyDetectorErrorModel { /// >>> print(dem.to_string()) /// >>> sampler = dem.to_sampler() #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &pyo3::Bound<'_, pyo3::PyAny>, @@ -1487,6 +1514,7 @@ impl PyDetectorErrorModel { p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p1_gate_rates: Option>, ) -> PyResult { use pecos_qec::fault_tolerance::dem_builder::DemBuilder; @@ -1516,6 +1544,7 @@ impl PyDetectorErrorModel { p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p1_gate_rates, )?; if let Ok(dag) = circuit.extract::>() @@ -1867,7 +1896,7 @@ impl PyDemBuilder { /// /// Returns: /// Self for method chaining. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -1899,6 +1928,7 @@ impl PyDemBuilder { p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p1_gate_rates: Option>, ) -> PyResult> { slf.noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -1926,6 +1956,7 @@ impl PyDemBuilder { p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p1_gate_rates, )?; Ok(slf) } @@ -3788,7 +3819,7 @@ impl PyDemSampler { /// >>> sampler = DemSampler.from_circuit(dag, p1=0.001, p2=0.01) /// >>> sampler = DemSampler.from_circuit(tc, p2=0.01) # TickCircuit also works #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &Bound<'_, pyo3::PyAny>, @@ -3820,6 +3851,7 @@ impl PyDemSampler { p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p1_gate_rates: Option>, ) -> PyResult { let noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -3847,6 +3879,7 @@ impl PyDemSampler { p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p1_gate_rates, )?; // Accept both DagCircuit and TickCircuit @@ -3957,7 +3990,7 @@ impl PyDemSampler { /// /// The `observables` argument defines observables. #[staticmethod] - #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn with_detectors( influence_map: &PyDagFaultInfluenceMap, @@ -3991,6 +4024,7 @@ impl PyDemSampler { p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p1_gate_rates: Option>, ) -> PyResult { let noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -4018,6 +4052,7 @@ impl PyDemSampler { p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p1_gate_rates, )?; let inner = RustNewDemSamplerBuilder::new(&influence_map.inner) .with_noise_config(noise) @@ -4528,7 +4563,7 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -4560,6 +4595,7 @@ impl PyDemSamplerBuilder { p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p1_gate_rates: Option>, ) -> PyResult> { slf.noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -4587,6 +4623,7 @@ impl PyDemSamplerBuilder { p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p1_gate_rates, )?; Ok(slf) } diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index b1de6681c..f4adb7d0f 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -115,8 +115,12 @@ class OpType(Enum): # Single-qubit gates H = auto() # Hadamard + F = auto() # face Clifford + FDG = auto() # face Clifford dagger SX = auto() # sqrt X SXDG = auto() # sqrt X dagger + SY = auto() # sqrt Y + SYDG = auto() # sqrt Y dagger SZ = auto() # sqrt Z / phase SZDG = auto() # sqrt Z dagger X = auto() # Pauli X @@ -428,8 +432,12 @@ def _propagate_sxx_frame_bits(x_a: bool, z_a: bool, x_b: bool, z_b: bool) -> tup } _SZZ_FLOW_GATE_ACTIONS: dict[OpType, dict[int, int]] = { OpType.H: {1: 3, 2: -2, 3: 1}, + OpType.F: {1: 2, 2: 3, 3: 1}, + OpType.FDG: {1: 3, 2: 1, 3: 2}, OpType.SX: {1: 1, 2: 3, 3: -2}, OpType.SXDG: {1: 1, 2: -3, 3: 2}, + OpType.SY: {1: -3, 2: 2, 3: 1}, + OpType.SYDG: {1: 3, 2: 2, 3: -1}, OpType.SZ: {1: 2, 2: -1, 3: 3}, OpType.SZDG: {1: -2, 2: 1, 3: 3}, OpType.X: {1: 1, 2: -2, 3: -3}, @@ -617,6 +625,107 @@ def discharge_for_measurement(q: int, host_index: int, op: SurfaceCircuitStep) - ) +_SZZ_FLOW_PHYSICAL_PREFIX_BY_PENDING: dict[tuple[int, int], tuple[OpType | None, OpType]] = { + (3, 1): (None, OpType.H), + (-3, 1): (None, OpType.SY), + (2, 1): (None, OpType.F), + (-2, 1): (OpType.Z, OpType.F), +} + + +def _lower_szz_forward_flow_ops(ops: list[SurfaceCircuitStep]) -> list[SurfaceCircuitStep]: + """Return an SZZ physical-prefix TickCircuit op stream. + + Free-standing single-qubit Cliffords in the abstract SZZ template are + accumulated into a pending local Clifford and discharged as a physical + prefix pulse on the next SZZ/SZZdg or MZ host. Zero-noise Z-frame gates are + emitted only when needed to preserve the exact signed Clifford before a + physical prefix pulse. + """ + pending_by_qubit: dict[int, tuple[int, int]] = {} + lowered: list[SurfaceCircuitStep] = [] + + def pending_for(q: int) -> tuple[int, int]: + return pending_by_qubit.setdefault(q, _SZZ_FLOW_IDENTITY) + + def reset_for_prep(q: int, op: SurfaceCircuitStep) -> None: + current = pending_for(q) + if current != _SZZ_FLOW_IDENTITY: + msg = ( + "SZZ forward-flow cannot reset a qubit with a pending " + f"Clifford: q={q}, pending={_szz_flow_clifford_name(current)}, " + f"op={op.op_type.name} {op.label!r}" + ) + raise ValueError(msg) + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + + def discharge(q: int, host: SurfaceCircuitStep) -> None: + current = pending_for(q) + if current == _SZZ_FLOW_IDENTITY: + return + if _szz_flow_is_virtual_z(current): + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY if host.op_type == OpType.MEASURE else current + return + try: + virtual_gate, physical_gate = _SZZ_FLOW_PHYSICAL_PREFIX_BY_PENDING[current] + except KeyError as exc: + msg = ( + "SZZ forward-flow cannot lower pending Clifford " + f"{_szz_flow_clifford_name(current)} on q={q} before " + f"{host.op_type.name} {host.label!r}" + ) + raise ValueError(msg) from exc + if virtual_gate is not None: + lowered.append( + SurfaceCircuitStep( + virtual_gate, + [q], + f"szz_virtual_prefix:{virtual_gate.name}:{host.label}:q{q}", + ), + ) + lowered.append( + SurfaceCircuitStep( + physical_gate, + [q], + f"szz_physical_prefix:{physical_gate.name}:{host.label}:q{q}", + ), + ) + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + + for op in ops: + if op.op_type in {OpType.COMMENT, OpType.TICK, OpType.TRACKED_PAULI}: + lowered.append(op) + continue + if op.op_type in {OpType.ALLOC, OpType.PREP}: + reset_for_prep(op.qubits[0], op) + lowered.append(op) + continue + if op.op_type in _SZZ_FLOW_SINGLE_QUBIT_GATES: + q = op.qubits[0] + pending_by_qubit[q] = _szz_flow_compose_pending_gate(pending_for(q), op.op_type) + continue + if op.op_type in {OpType.SZZ, OpType.SZZDG}: + for q in op.qubits: + discharge(q, op) + lowered.append(op) + continue + if op.op_type == OpType.CX: + msg = "SZZ forward-flow lowering only supports SZZ/SZZdg two-qubit gates" + raise ValueError(msg) + if op.op_type == OpType.MEASURE: + discharge(op.qubits[0], op) + lowered.append(op) + continue + lowered.append(op) + + remaining = {q: pending for q, pending in pending_by_qubit.items() if pending != _SZZ_FLOW_IDENTITY} + if remaining: + formatted = {q: _szz_flow_clifford_name(pending) for q, pending in sorted(remaining.items())} + msg = f"SZZ forward-flow lowering ended with pending Cliffords: {formatted}" + raise ValueError(msg) + return lowered + + def build_surface_code_circuit( patch: SurfacePatch, num_rounds: int, @@ -1982,6 +2091,24 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non meta["label"] = op.label apply_gate_metadata(tick, meta or None) + elif op.op_type == OpType.F: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).f([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.FDG: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).fdg([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + elif op.op_type == OpType.SX: q = op.qubits[0] tick = get_tick_for_qubits([q]).sx([q]) @@ -2000,6 +2127,24 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non meta["label"] = op.label apply_gate_metadata(tick, meta or None) + elif op.op_type == OpType.SY: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).sy([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SYDG: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).sydg([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + elif op.op_type == OpType.SZ: q = op.qubits[0] tick = get_tick_for_qubits([q]).sz([q]) @@ -2487,6 +2632,7 @@ def generate_tick_circuit_from_patch( ancilla_budget: int | None = None, twirl: TwirlConfig | None = None, interaction_basis: str = "cx", + szz_physical_prefixes: bool = False, ) -> TickCircuit: """Generate PECOS TickCircuit from SurfacePatch. @@ -2515,11 +2661,16 @@ def generate_tick_circuit_from_patch( ``add_typed_annotations`` is false; that flag controls detector and observable typed annotations, not the twirl lookup channel. interaction_basis: Surface-memory two-qubit interaction basis. + szz_physical_prefixes: If true, lower the abstract SZZ single-qubit + scaffold into physical prefix pulses for native DEM analysis. Returns: PECOS TickCircuit instance """ interaction_basis = _normalize_interaction_basis(interaction_basis) + if szz_physical_prefixes and interaction_basis != "szz": + msg = "szz_physical_prefixes=True requires interaction_basis='szz'" + raise ValueError(msg) ops, allocation = build_surface_code_circuit( patch, num_rounds, @@ -2528,6 +2679,8 @@ def generate_tick_circuit_from_patch( twirl=twirl, interaction_basis=interaction_basis, ) + if szz_physical_prefixes: + ops = _lower_szz_forward_flow_ops(ops) renderer = TickCircuitRenderer( add_detectors=add_detectors, add_typed_annotations=add_typed_annotations, diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 244f07837..73287f300 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -287,6 +287,7 @@ class _CachedNativeSurfaceTopology: dag_circuit: Any influence_map: Any + szz_physical_prefixes: bool pauli_frame_lookup: Any | None detectors_json: str observables_json: str @@ -1379,6 +1380,7 @@ def _build_surface_tick_circuit_for_native_model( runtime: object | None = None, twirl: TwirlConfig | None = None, interaction_basis: str = "cx", + szz_physical_prefixes: bool = False, ) -> Any: """Build the TickCircuit used by the native DEM and sampler paths.""" from pecos.qec.surface.circuit_builder import _normalize_interaction_basis, generate_tick_circuit_from_patch @@ -1386,6 +1388,9 @@ def _build_surface_tick_circuit_for_native_model( if twirl is not None: twirl.validate_runtime_supported() interaction_basis = _normalize_interaction_basis(interaction_basis) + if szz_physical_prefixes and (interaction_basis != "szz" or circuit_source != "abstract"): + msg = "SZZ physical-prefix lowering requires interaction_basis='szz' and circuit_source='abstract'" + raise ValueError(msg) abstract_tc = generate_tick_circuit_from_patch( patch, @@ -1395,6 +1400,7 @@ def _build_surface_tick_circuit_for_native_model( add_typed_annotations=False, twirl=twirl, interaction_basis=interaction_basis, + szz_physical_prefixes=szz_physical_prefixes, ) if circuit_source == "abstract": @@ -1605,13 +1611,17 @@ def _noise_uses_dedicated_idle_noise(noise: NoiseModel) -> bool: ) -def _reject_szz_unlowered_physical_noise(noise: NoiseModel, interaction_basis: str) -> None: +def _reject_szz_unlowered_physical_noise( + noise: NoiseModel, + interaction_basis: str, + circuit_source: Literal["abstract", "traced_qis"], +) -> None: """Reject SZZ surface DEM noise that still needs post-flow pulse locations.""" if interaction_basis != "szz": return reasons: list[str] = [] - if noise.p1 > 0.0: - reasons.append("p1") + if noise.p1 > 0.0 and circuit_source != "abstract": + reasons.append("p1 with circuit_source='traced_qis'") if _noise_uses_dedicated_idle_noise(noise): reasons.append("dedicated idle noise") if not reasons: @@ -1620,13 +1630,33 @@ def _reject_szz_unlowered_physical_noise(noise: NoiseModel, interaction_basis: s msg = ( "interaction_basis='szz' surface DEM generation does not yet support " f"{joined} because the DEM must use post-flow prefix pulse locations " - "rather than the abstract H/SX/SZ scaffold; set p1=0 and omit " - "dedicated idle noise until SZZ pulse-location DEM lowering is enabled" + "rather than the abstract H/SX/SZ scaffold; use circuit_source='abstract' " + "for p1 and omit dedicated idle noise until SZZ idle pulse-location DEM " + "lowering is enabled" ) raise ValueError(msg) -def _with_noise_compat(builder: Any, noise: NoiseModel) -> Any: +def _use_szz_physical_prefixes( + noise: NoiseModel, + interaction_basis: str, + circuit_source: Literal["abstract", "traced_qis"], +) -> bool: + return interaction_basis == "szz" and circuit_source == "abstract" and noise.p1 > 0.0 + + +def _szz_prefix_p1_gate_rates(topology: _CachedNativeSurfaceTopology) -> dict[str, float] | None: + if not topology.szz_physical_prefixes: + return None + return {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} + + +def _with_noise_compat( + builder: Any, + noise: NoiseModel, + *, + p1_gate_rates: Mapping[str, float] | None = None, +) -> Any: """Call Rust ``with_noise`` using the richest signature this binding supports.""" noise_kwargs = { "p_idle": noise.p_idle, @@ -1647,6 +1677,8 @@ def _with_noise_compat(builder: Any, noise: NoiseModel) -> Any: "p1_weights": _p1_weights_dict(noise.p1_weights), "p2_weights": _p2_weights_dict(noise.p2_weights), } + if p1_gate_rates is not None: + noise_kwargs["p1_gate_rates"] = {str(gate): float(rate) for gate, rate in p1_gate_rates.items()} p2_gate_rates = _p2_gate_rates_dict(noise) if p2_gate_rates is not None: noise_kwargs["p2_gate_rates"] = p2_gate_rates @@ -1695,6 +1727,7 @@ def _surface_native_topology( runtime: object | None = None, twirl: TwirlConfig | None = None, interaction_basis: str = "cx", + szz_physical_prefixes: bool = False, ) -> _CachedNativeSurfaceTopology: """Build topology-only native analysis shared across noise parameters.""" import json @@ -1717,6 +1750,7 @@ def _surface_native_topology( runtime=runtime, twirl=twirl, interaction_basis=interaction_basis, + szz_physical_prefixes=szz_physical_prefixes, ) if circuit_source == "traced_qis": # Keep this surface helper aligned with DetectorErrorModel.from_guppy: @@ -1756,6 +1790,7 @@ def _surface_native_topology( return _CachedNativeSurfaceTopology( dag_circuit=dag, influence_map=influence_map, + szz_physical_prefixes=szz_physical_prefixes, pauli_frame_lookup=pauli_frame_lookup, detectors_json=detectors_json, observables_json=observables_json, @@ -1778,6 +1813,7 @@ def _cached_surface_native_topology( *, twirl: TwirlConfig | None = None, interaction_basis: str = "cx", + szz_physical_prefixes: bool = False, ) -> _CachedNativeSurfaceTopology: """Cache topology-only native analysis shared across noise parameters.""" return _surface_native_topology( @@ -1789,6 +1825,7 @@ def _cached_surface_native_topology( include_idle_gates, twirl=twirl, interaction_basis=interaction_basis, + szz_physical_prefixes=szz_physical_prefixes, ) @@ -1801,7 +1838,11 @@ def _dem_string_from_cached_surface_topology( """Build a DEM string from cached topology and fresh noise parameters.""" from pecos.qec import DemBuilder - builder = _with_noise_compat(DemBuilder(topology.influence_map), noise) + builder = _with_noise_compat( + DemBuilder(topology.influence_map), + noise, + p1_gate_rates=_szz_prefix_p1_gate_rates(topology), + ) if hasattr(builder, "with_exact_branch_replay_circuit"): builder = builder.with_exact_branch_replay_circuit(topology.dag_circuit) @@ -1876,6 +1917,7 @@ def _cached_surface_native_dem_string( p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, ) + szz_physical_prefixes = interaction_basis == "szz" and circuit_source == "abstract" and p1 > 0.0 topology = _cached_surface_native_topology( patch_key, num_rounds, @@ -1885,6 +1927,7 @@ def _cached_surface_native_dem_string( include_idle_gates, twirl=twirl, interaction_basis=interaction_basis, + szz_physical_prefixes=szz_physical_prefixes, ) return _dem_string_from_cached_surface_topology( topology, @@ -1950,7 +1993,11 @@ def _build_native_sampler_from_cached_surface_topology( from pecos.qec import DemSamplerBuilder sampler_builder = ( - _with_noise_compat(DemSamplerBuilder(topology.influence_map), noise) + _with_noise_compat( + DemSamplerBuilder(topology.influence_map), + noise, + p1_gate_rates=_szz_prefix_p1_gate_rates(topology), + ) .with_detectors_json(topology.detectors_json) .with_observables_json(topology.observables_json) ) @@ -2039,9 +2086,10 @@ def generate_circuit_level_dem_from_builder( from pecos.qec.surface.circuit_builder import _normalize_interaction_basis interaction_basis = _normalize_interaction_basis(interaction_basis) - _reject_szz_unlowered_physical_noise(noise, interaction_basis) + _reject_szz_unlowered_physical_noise(noise, interaction_basis, circuit_source) patch_key = _surface_patch_cache_key(patch) include_idle_gates = _noise_uses_dedicated_idle_noise(noise) + szz_physical_prefixes = _use_szz_physical_prefixes(noise, interaction_basis, circuit_source) if runtime is not None: topology = _surface_native_topology( patch_key, @@ -2053,6 +2101,7 @@ def generate_circuit_level_dem_from_builder( runtime=runtime, twirl=twirl, interaction_basis=interaction_basis, + szz_physical_prefixes=szz_physical_prefixes, ) return _dem_string_from_cached_surface_topology( topology, @@ -3816,9 +3865,10 @@ def build_native_sampler( from pecos.qec.surface.circuit_builder import _normalize_interaction_basis interaction_basis = _normalize_interaction_basis(interaction_basis) - _reject_szz_unlowered_physical_noise(noise, interaction_basis) + _reject_szz_unlowered_physical_noise(noise, interaction_basis, circuit_source) basis = basis.upper() patch_key = _surface_patch_cache_key(patch) + szz_physical_prefixes = _use_szz_physical_prefixes(noise, interaction_basis, circuit_source) topology = _cached_surface_native_topology( patch_key, num_rounds, @@ -3828,6 +3878,7 @@ def build_native_sampler( _noise_uses_dedicated_idle_noise(noise), twirl=twirl, interaction_basis=interaction_basis, + szz_physical_prefixes=szz_physical_prefixes, ) if sampling_model == "dem": dem_str = _cached_surface_native_dem_string( 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 80287125c..d5df2a1b8 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 @@ -528,10 +528,28 @@ def test_szz_native_influence_sampler_respects_override_only_p2() -> None: assert "mechanisms=0" not in repr(active_sampler.sampler) +@pytest.mark.parametrize("sampling_model", ["dem", "influence_dem"]) +def test_szz_native_sampler_accepts_p1_with_physical_prefix_lowering(sampling_model: str) -> None: + patch = SurfacePatch.create(distance=3) + + sampler = build_native_sampler( + patch, + num_rounds=1, + noise=NoiseModel(p1=0.001), + interaction_basis="szz", + sampling_model=sampling_model, + ) + det_events, obs_flips = sampler.sample(4, seed=20260612) + + assert det_events.shape == (4, sampler.num_detectors) + assert obs_flips.shape == (4, sampler.num_observables) + if sampling_model == "influence_dem": + assert "mechanisms=0" not in repr(sampler.sampler) + + @pytest.mark.parametrize( ("noise", "match"), [ - (NoiseModel(p1=0.001), r"p1.*post-flow prefix pulse locations"), (NoiseModel(p_idle=0.001), r"dedicated idle noise.*post-flow prefix pulse locations"), ], ) @@ -547,13 +565,39 @@ def test_szz_native_dem_rejects_unlowered_physical_noise(noise: NoiseModel, matc ) -def test_szz_native_sampler_rejects_unlowered_physical_noise() -> None: +def test_szz_native_dem_rejects_traced_qis_p1() -> None: patch = SurfacePatch.create(distance=3) - with pytest.raises(ValueError, match=r"p1.*post-flow prefix pulse locations"): - build_native_sampler( + with pytest.raises(ValueError, match=r"p1 with circuit_source='traced_qis'.*post-flow prefix pulse locations"): + generate_circuit_level_dem_from_builder( patch, num_rounds=1, noise=NoiseModel(p1=0.001), interaction_basis="szz", + circuit_source="traced_qis", + ) + + +def test_szz_native_dem_accepts_p1_with_physical_prefix_lowering() -> None: + patch = SurfacePatch.create(distance=3) + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=NoiseModel(p1=0.001), + interaction_basis="szz", + ) + + assert "error(" in dem + assert stim.DetectorErrorModel(dem).num_detectors > 0 + + +def test_szz_native_sampler_rejects_unlowered_idle_noise() -> None: + patch = SurfacePatch.create(distance=3) + + with pytest.raises(ValueError, match=r"dedicated idle noise.*post-flow prefix pulse locations"): + build_native_sampler( + patch, + num_rounds=1, + noise=NoiseModel(p_idle=0.001), + interaction_basis="szz", ) From a624ce10e945acd0ad162bf563728b69aa069e8a Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 22:58:53 -0600 Subject: [PATCH 077/150] Tighten SZZ prefix DEM coverage --- .../src/fault_tolerance_bindings.rs | 32 ++++++------- .../qec/surface/test_szz_interaction_basis.py | 47 ++++++++++++++++++- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 402e594b4..a863414f2 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -325,12 +325,12 @@ fn apply_noise_options( p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, - p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p2_gate_rates: Option>, p1_gate_rates: Option>, ) -> PyResult { noise.p_idle = p_idle.unwrap_or(0.0); @@ -1482,7 +1482,7 @@ impl PyDetectorErrorModel { /// >>> print(dem.to_string()) /// >>> sampler = dem.to_sampler() #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p2_gate_rates=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &pyo3::Bound<'_, pyo3::PyAny>, @@ -1508,12 +1508,12 @@ impl PyDetectorErrorModel { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, - p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p2_gate_rates: Option>, p1_gate_rates: Option>, ) -> PyResult { use pecos_qec::fault_tolerance::dem_builder::DemBuilder; @@ -1538,12 +1538,12 @@ impl PyDetectorErrorModel { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, - p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p2_gate_rates, p1_gate_rates, )?; if let Ok(dag) = @@ -1896,7 +1896,7 @@ impl PyDemBuilder { /// /// Returns: /// Self for method chaining. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p2_gate_rates=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -1922,12 +1922,12 @@ impl PyDemBuilder { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, - p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p2_gate_rates: Option>, p1_gate_rates: Option>, ) -> PyResult> { slf.noise = apply_noise_options( @@ -1950,12 +1950,12 @@ impl PyDemBuilder { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, - p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p2_gate_rates, p1_gate_rates, )?; Ok(slf) @@ -3819,7 +3819,7 @@ impl PyDemSampler { /// >>> sampler = DemSampler.from_circuit(dag, p1=0.001, p2=0.01) /// >>> sampler = DemSampler.from_circuit(tc, p2=0.01) # TickCircuit also works #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p2_gate_rates=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &Bound<'_, pyo3::PyAny>, @@ -3845,12 +3845,12 @@ impl PyDemSampler { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, - p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p2_gate_rates: Option>, p1_gate_rates: Option>, ) -> PyResult { let noise = apply_noise_options( @@ -3873,12 +3873,12 @@ impl PyDemSampler { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, - p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p2_gate_rates, p1_gate_rates, )?; @@ -3990,7 +3990,7 @@ impl PyDemSampler { /// /// The `observables` argument defines observables. #[staticmethod] - #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] + #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p2_gate_rates=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn with_detectors( influence_map: &PyDagFaultInfluenceMap, @@ -4018,12 +4018,12 @@ impl PyDemSampler { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, - p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p2_gate_rates: Option>, p1_gate_rates: Option>, ) -> PyResult { let noise = apply_noise_options( @@ -4046,12 +4046,12 @@ impl PyDemSampler { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, - p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p2_gate_rates, p1_gate_rates, )?; let inner = RustNewDemSamplerBuilder::new(&influence_map.inner) @@ -4563,7 +4563,7 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p2_gate_rates=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -4589,12 +4589,12 @@ impl PyDemSamplerBuilder { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, - p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p2_gate_rates: Option>, p1_gate_rates: Option>, ) -> PyResult> { slf.noise = apply_noise_options( @@ -4617,12 +4617,12 @@ impl PyDemSamplerBuilder { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, - p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p2_gate_rates, p1_gate_rates, )?; Ok(slf) 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 d5df2a1b8..a8bf1d96c 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 @@ -27,7 +27,14 @@ generate_stim_from_patch, generate_tick_circuit_from_patch, ) -from pecos.qec.surface.decode import build_memory_circuit, build_native_sampler, generate_circuit_level_dem_from_builder +from pecos.qec.surface.decode import ( + _dem_string_from_cached_surface_topology, + _surface_native_topology, + _surface_patch_cache_key, + build_memory_circuit, + build_native_sampler, + generate_circuit_level_dem_from_builder, +) def _to_numpy_complex(matrix: object) -> np.ndarray: @@ -528,6 +535,44 @@ def test_szz_native_influence_sampler_respects_override_only_p2() -> None: assert "mechanisms=0" not in repr(active_sampler.sampler) +@pytest.mark.parametrize("basis", ["Z", "X"]) +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) + + plain = _surface_native_topology( + patch_key, + 1, + basis, + None, + "abstract", + False, + interaction_basis="szz", + szz_physical_prefixes=False, + ) + lowered = _surface_native_topology( + patch_key, + 1, + basis, + None, + "abstract", + False, + interaction_basis="szz", + szz_physical_prefixes=True, + ) + + assert _dem_string_from_cached_surface_topology( + lowered, + noise, + decompose_errors=False, + ) == _dem_string_from_cached_surface_topology( + plain, + noise, + decompose_errors=False, + ) + + @pytest.mark.parametrize("sampling_model", ["dem", "influence_dem"]) def test_szz_native_sampler_accepts_p1_with_physical_prefix_lowering(sampling_model: str) -> None: patch = SurfacePatch.create(distance=3) From b83ddc1fbdfadebfd6683a40c3b4cc2b110a0da8 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 23:11:53 -0600 Subject: [PATCH 078/150] Enable SZZ idle prefix lowering --- crates/pecos-quantum/src/tick_circuit.rs | 63 +++++++- .../src/pecos/qec/surface/circuit_builder.py | 55 +++++-- .../src/pecos/qec/surface/decode.py | 19 ++- .../qec/surface/test_szz_interaction_basis.py | 145 ++++++++++++++++-- 4 files changed, 235 insertions(+), 47 deletions(-) diff --git a/crates/pecos-quantum/src/tick_circuit.rs b/crates/pecos-quantum/src/tick_circuit.rs index 25c18752a..72698dd14 100644 --- a/crates/pecos-quantum/src/tick_circuit.rs +++ b/crates/pecos-quantum/src/tick_circuit.rs @@ -615,6 +615,14 @@ impl<'a> GateInstanceRef<'a> { } } +fn gate_batch_has_zero_physical_duration(gate: GateBatchRef<'_>) -> bool { + match gate.get_attr("_physical_duration") { + Some(Attribute::Float(duration)) => *duration == 0.0, + Some(Attribute::Int(duration)) => *duration == 0, + _ => false, + } +} + impl Tick { /// Create a new empty tick. #[must_use] @@ -2308,15 +2316,18 @@ impl TickCircuit { } for tick in &mut self.ticks { - // Metadata-only ticks are zero-duration bookkeeping markers. - // They preserve annotation order but must not create physical idle - // periods on qubits outside the metadata payload. + // Metadata-only and explicitly zero-duration ticks preserve + // ordering but must not create physical idle periods. + let batch_count = tick.gate_batches().len(); let meta_batches = tick - .gate_batches() - .iter() - .filter(|gate| gate.gate_type.is_meta()) + .iter_gate_batches() + .filter(|gate| gate.as_gate().gate_type.is_meta()) + .count(); + let zero_duration_batches = tick + .iter_gate_batches() + .filter(|gate| gate_batch_has_zero_physical_duration(*gate)) .count(); - if !tick.is_empty() && meta_batches == tick.gate_batches().len() { + if !tick.is_empty() && meta_batches + zero_duration_batches == batch_count { continue; } // A tick that mixes meta and physical gates has ambiguous idle @@ -2330,6 +2341,12 @@ impl TickCircuit { batches in their own tick so idle-duration accounting stays \ unambiguous" ); + assert!( + zero_duration_batches == 0, + "fill_idle_gates: tick mixes zero-duration and physical gates; emit \ + zero-duration frame updates in their own tick so idle-duration \ + accounting stays unambiguous" + ); let active = tick.active_qubits(); for &q in &all_qubits { if !active.contains(&q) { @@ -6197,6 +6214,26 @@ mod tests { assert!(meta_tick.gate_batches()[0].gate_type.is_meta()); } + #[test] + fn test_fill_idle_gates_skips_zero_duration_ticks() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0, 1]); + tc.tick() + .z(&[0]) + .meta("_physical_duration", Attribute::Float(0.0)); + tc.tick().h(&[0, 1]); + + tc.fill_idle_gates(); + + let zero_duration_tick = tc.get_tick(1).expect("zero-duration tick"); + assert_eq!(zero_duration_tick.gate_count(), 1); + assert_eq!(zero_duration_tick.gate_batches()[0].gate_type, GateType::Z); + assert_eq!( + zero_duration_tick.get_gate_attr(0, "_physical_duration"), + Some(&Attribute::Float(0.0)) + ); + } + #[test] #[should_panic(expected = "tick mixes meta and physical gates")] fn test_fill_idle_gates_rejects_mixed_meta_and_physical_tick() { @@ -6213,6 +6250,18 @@ mod tests { tc.fill_idle_gates(); } + #[test] + #[should_panic(expected = "tick mixes zero-duration and physical gates")] + fn test_fill_idle_gates_rejects_mixed_zero_duration_and_physical_tick() { + let mut tc = TickCircuit::new(); + tc.tick() + .h(&[0]) + .z(&[1]) + .meta("_physical_duration", Attribute::Float(0.0)); + + tc.fill_idle_gates(); + } + #[test] fn test_channel_gate_is_first_class_tick_operation() { let mut tc = TickCircuit::new(); diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index f4adb7d0f..a33d42255 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -659,13 +659,13 @@ def reset_for_prep(q: int, op: SurfaceCircuitStep) -> None: raise ValueError(msg) pending_by_qubit[q] = _SZZ_FLOW_IDENTITY - def discharge(q: int, host: SurfaceCircuitStep) -> None: + def discharge(q: int, host: SurfaceCircuitStep) -> tuple[SurfaceCircuitStep | None, SurfaceCircuitStep | None]: current = pending_for(q) if current == _SZZ_FLOW_IDENTITY: - return + return None, None if _szz_flow_is_virtual_z(current): pending_by_qubit[q] = _SZZ_FLOW_IDENTITY if host.op_type == OpType.MEASURE else current - return + return None, None try: virtual_gate, physical_gate = _SZZ_FLOW_PHYSICAL_PREFIX_BY_PENDING[current] except KeyError as exc: @@ -675,22 +675,30 @@ def discharge(q: int, host: SurfaceCircuitStep) -> None: f"{host.op_type.name} {host.label!r}" ) raise ValueError(msg) from exc + virtual_step = None if virtual_gate is not None: - lowered.append( - SurfaceCircuitStep( - virtual_gate, - [q], - f"szz_virtual_prefix:{virtual_gate.name}:{host.label}:q{q}", - ), - ) - lowered.append( - SurfaceCircuitStep( - physical_gate, + virtual_step = SurfaceCircuitStep( + virtual_gate, [q], - f"szz_physical_prefix:{physical_gate.name}:{host.label}:q{q}", - ), + f"szz_virtual_prefix:{virtual_gate.name}:{host.label}:q{q}", + ) + physical_step = SurfaceCircuitStep( + physical_gate, + [q], + f"szz_physical_prefix:{physical_gate.name}:{host.label}:q{q}", ) pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + return virtual_step, physical_step + + def append_prefix_ticks(virtual_steps: list[SurfaceCircuitStep], physical_steps: list[SurfaceCircuitStep]) -> None: + if virtual_steps: + lowered.append(SurfaceCircuitStep(OpType.TICK)) + lowered.extend(virtual_steps) + lowered.append(SurfaceCircuitStep(OpType.TICK)) + if physical_steps: + lowered.append(SurfaceCircuitStep(OpType.TICK)) + lowered.extend(physical_steps) + lowered.append(SurfaceCircuitStep(OpType.TICK)) for op in ops: if op.op_type in {OpType.COMMENT, OpType.TICK, OpType.TRACKED_PAULI}: @@ -705,15 +713,26 @@ def discharge(q: int, host: SurfaceCircuitStep) -> None: pending_by_qubit[q] = _szz_flow_compose_pending_gate(pending_for(q), op.op_type) continue if op.op_type in {OpType.SZZ, OpType.SZZDG}: + virtual_steps: list[SurfaceCircuitStep] = [] + physical_steps: list[SurfaceCircuitStep] = [] for q in op.qubits: - discharge(q, op) + virtual_step, physical_step = discharge(q, op) + if virtual_step is not None: + virtual_steps.append(virtual_step) + if physical_step is not None: + physical_steps.append(physical_step) + append_prefix_ticks(virtual_steps, physical_steps) lowered.append(op) continue if op.op_type == OpType.CX: msg = "SZZ forward-flow lowering only supports SZZ/SZZdg two-qubit gates" raise ValueError(msg) if op.op_type == OpType.MEASURE: - discharge(op.qubits[0], op) + virtual_step, physical_step = discharge(op.qubits[0], op) + append_prefix_ticks( + [] if virtual_step is None else [virtual_step], + [] if physical_step is None else [physical_step], + ) lowered.append(op) continue lowered.append(op) @@ -2179,6 +2198,8 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non meta = get_ancilla_gate_metadata(q, op.label) if op.label: meta["label"] = op.label + if op.label.startswith("szz_virtual_prefix:"): + meta["_physical_duration"] = 0.0 apply_gate_metadata(tick, meta or None) elif op.op_type == OpType.CX: diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 73287f300..ea49b5517 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1622,8 +1622,8 @@ def _reject_szz_unlowered_physical_noise( reasons: list[str] = [] if noise.p1 > 0.0 and circuit_source != "abstract": reasons.append("p1 with circuit_source='traced_qis'") - if _noise_uses_dedicated_idle_noise(noise): - reasons.append("dedicated idle noise") + if _noise_uses_dedicated_idle_noise(noise) and circuit_source != "abstract": + reasons.append("dedicated idle noise with circuit_source='traced_qis'") if not reasons: return joined = ", ".join(reasons) @@ -1631,8 +1631,7 @@ def _reject_szz_unlowered_physical_noise( "interaction_basis='szz' surface DEM generation does not yet support " f"{joined} because the DEM must use post-flow prefix pulse locations " "rather than the abstract H/SX/SZ scaffold; use circuit_source='abstract' " - "for p1 and omit dedicated idle noise until SZZ idle pulse-location DEM " - "lowering is enabled" + "for p1 or dedicated idle noise" ) raise ValueError(msg) @@ -1642,7 +1641,11 @@ def _use_szz_physical_prefixes( interaction_basis: str, circuit_source: Literal["abstract", "traced_qis"], ) -> bool: - return interaction_basis == "szz" and circuit_source == "abstract" and noise.p1 > 0.0 + return ( + interaction_basis == "szz" + and circuit_source == "abstract" + and (noise.p1 > 0.0 or _noise_uses_dedicated_idle_noise(noise)) + ) def _szz_prefix_p1_gate_rates(topology: _CachedNativeSurfaceTopology) -> dict[str, float] | None: @@ -1917,7 +1920,11 @@ def _cached_surface_native_dem_string( p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, ) - szz_physical_prefixes = interaction_basis == "szz" and circuit_source == "abstract" and p1 > 0.0 + szz_physical_prefixes = ( + interaction_basis == "szz" + and circuit_source == "abstract" + and (p1 > 0.0 or include_idle_gates) + ) topology = _cached_surface_native_topology( patch_key, num_rounds, 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 a8bf1d96c..248279cb6 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 @@ -150,6 +150,13 @@ def _assert_noiseless_record_metadata_is_zero(stim_text: str, tick_circuit: obje assert not np.any(obs_samples) +def _gate_labels_for_tick(tick_circuit: object, tick_index: int) -> list[str | None]: + return [ + tick_circuit.get_gate_meta(tick_index, gate_index, "label") + for gate_index, _gate in enumerate(tick_circuit.get_tick(tick_index).gate_batches()) + ] + + def test_szz_unitary_identities() -> None: assert _equiv_up_to_global_phase(SZZ @ _kron(SZDG, SZDG), CZ) assert _equiv_up_to_global_phase(SZZ @ ZZ, SZZDG) @@ -573,6 +580,44 @@ def test_szz_prefix_lowering_preserves_p2_influence_dem(basis: str) -> None: ) +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_prefix_lowering_emits_dedicated_prefix_ticks(basis: str) -> None: + patch = SurfacePatch.create(distance=3) + tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + basis=basis, + interaction_basis="szz", + szz_physical_prefixes=True, + ) + + saw_physical_prefix = False + saw_virtual_prefix = False + for tick_index in range(tick_circuit.num_ticks()): + tick = tick_circuit.get_tick(tick_index) + labels = _gate_labels_for_tick(tick_circuit, tick_index) + prefix_labels = [label for label in labels if label and label.startswith("szz_")] + if not prefix_labels: + continue + + assert len(prefix_labels) == len(labels) + if prefix_labels[0].startswith("szz_virtual_prefix:"): + saw_virtual_prefix = True + assert all(label.startswith("szz_virtual_prefix:") for label in prefix_labels) + for gate_index, gate in enumerate(tick.gate_batches()): + assert gate.gate_type.name == "Z" + assert tick_circuit.get_gate_meta(tick_index, gate_index, "_physical_duration") == 0.0 + else: + saw_physical_prefix = True + assert all(label.startswith("szz_physical_prefix:") for label in prefix_labels) + assert {gate.gate_type.name for gate in tick.gate_batches()} <= {"H", "F", "SY"} + for gate_index, _gate in enumerate(tick.gate_batches()): + assert tick_circuit.get_gate_meta(tick_index, gate_index, "_physical_duration") is None + + assert saw_physical_prefix + assert saw_virtual_prefix + + @pytest.mark.parametrize("sampling_model", ["dem", "influence_dem"]) def test_szz_native_sampler_accepts_p1_with_physical_prefix_lowering(sampling_model: str) -> None: patch = SurfacePatch.create(distance=3) @@ -592,21 +637,19 @@ def test_szz_native_sampler_accepts_p1_with_physical_prefix_lowering(sampling_mo assert "mechanisms=0" not in repr(sampler.sampler) -@pytest.mark.parametrize( - ("noise", "match"), - [ - (NoiseModel(p_idle=0.001), r"dedicated idle noise.*post-flow prefix pulse locations"), - ], -) -def test_szz_native_dem_rejects_unlowered_physical_noise(noise: NoiseModel, match: str) -> None: +def test_szz_native_dem_rejects_traced_qis_idle_noise() -> None: patch = SurfacePatch.create(distance=3) - with pytest.raises(ValueError, match=match): + with pytest.raises( + ValueError, + match=r"dedicated idle noise with circuit_source='traced_qis'.*post-flow prefix pulse locations", + ): generate_circuit_level_dem_from_builder( patch, num_rounds=1, - noise=noise, + noise=NoiseModel(p_idle=0.001), interaction_basis="szz", + circuit_source="traced_qis", ) @@ -636,13 +679,81 @@ def test_szz_native_dem_accepts_p1_with_physical_prefix_lowering() -> None: assert stim.DetectorErrorModel(dem).num_detectors > 0 -def test_szz_native_sampler_rejects_unlowered_idle_noise() -> None: +@pytest.mark.parametrize("sampling_model", ["dem", "influence_dem"]) +def test_szz_native_sampler_accepts_idle_with_physical_prefix_lowering(sampling_model: str) -> None: patch = SurfacePatch.create(distance=3) - with pytest.raises(ValueError, match=r"dedicated idle noise.*post-flow prefix pulse locations"): - build_native_sampler( - patch, - num_rounds=1, - noise=NoiseModel(p_idle=0.001), - interaction_basis="szz", - ) + sampler = build_native_sampler( + patch, + num_rounds=1, + noise=NoiseModel(p_idle=0.001), + interaction_basis="szz", + sampling_model=sampling_model, + ) + det_events, obs_flips = sampler.sample(4, seed=20260612) + + assert det_events.shape == (4, sampler.num_detectors) + assert obs_flips.shape == (4, sampler.num_observables) + if sampling_model == "influence_dem": + assert "mechanisms=0" not in repr(sampler.sampler) + + +def test_szz_native_dem_accepts_idle_with_physical_prefix_lowering() -> None: + patch = SurfacePatch.create(distance=3) + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=NoiseModel(p_idle=0.001), + interaction_basis="szz", + ) + + assert "error(" in dem + assert stim.DetectorErrorModel(dem).num_detectors > 0 + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +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) + + actual = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + basis=basis, + noise=noise, + interaction_basis="szz", + decompose_errors=False, + ) + lowered = _surface_native_topology( + patch_key, + 1, + basis, + None, + "abstract", + True, + interaction_basis="szz", + szz_physical_prefixes=True, + ) + plain = _surface_native_topology( + patch_key, + 1, + basis, + None, + "abstract", + True, + interaction_basis="szz", + szz_physical_prefixes=False, + ) + + expected = _dem_string_from_cached_surface_topology( + lowered, + noise, + decompose_errors=False, + ) + assert actual == expected + assert actual != _dem_string_from_cached_surface_topology( + plain, + noise, + decompose_errors=False, + ) From f97c1489daab3cab31ada2e56ff05b919ac8deb8 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 23:37:55 -0600 Subject: [PATCH 079/150] Tighten SZZ idle prefix guards --- crates/pecos-quantum/src/lib.rs | 4 +- crates/pecos-quantum/src/tick_circuit.rs | 11 ++-- python/pecos-rslib/pecos_rslib.pyi | 2 + .../pecos-rslib/src/dag_circuit_bindings.rs | 4 +- python/pecos-rslib/src/namespace_modules.rs | 4 ++ .../src/pecos/qec/surface/circuit_builder.py | 3 +- .../src/pecos/quantum/__init__.py | 2 + .../qec/surface/test_szz_interaction_basis.py | 64 ++++++++++++++++++- 8 files changed, 84 insertions(+), 10 deletions(-) diff --git a/crates/pecos-quantum/src/lib.rs b/crates/pecos-quantum/src/lib.rs index 44bf47a1b..4c4db3667 100644 --- a/crates/pecos-quantum/src/lib.rs +++ b/crates/pecos-quantum/src/lib.rs @@ -86,8 +86,8 @@ pub use dag_circuit::{ TraversalWorkBuffers, }; pub use tick_circuit::{ - CustomGateError, GateSignatureMismatchError, QubitConflictError, Tick, TickCircuit, - TickGateError, TickHandle, TickMeasRef, TickMeasureHandle, TickPrepHandle, + CustomGateError, GateSignatureMismatchError, PHYSICAL_DURATION_META_KEY, QubitConflictError, + Tick, TickCircuit, TickGateError, TickHandle, TickMeasRef, TickMeasureHandle, TickPrepHandle, }; // Re-export commonly used types from dependencies diff --git a/crates/pecos-quantum/src/tick_circuit.rs b/crates/pecos-quantum/src/tick_circuit.rs index 72698dd14..fc273a14c 100644 --- a/crates/pecos-quantum/src/tick_circuit.rs +++ b/crates/pecos-quantum/src/tick_circuit.rs @@ -76,6 +76,9 @@ use crate::dag_circuit::{AnnotationKind, DagCircuit, PauliAnnotation}; use std::fmt; use std::ops::{Deref, Index}; +/// Gate metadata key for explicitly zero-duration physical frame updates. +pub const PHYSICAL_DURATION_META_KEY: &str = "_physical_duration"; + fn meta_json_array(circuit: &TickCircuit, key: &str) -> Result, String> { let Some(attr) = circuit.get_meta(key) else { return Ok(Vec::new()); @@ -616,7 +619,7 @@ impl<'a> GateInstanceRef<'a> { } fn gate_batch_has_zero_physical_duration(gate: GateBatchRef<'_>) -> bool { - match gate.get_attr("_physical_duration") { + match gate.get_attr(PHYSICAL_DURATION_META_KEY) { Some(Attribute::Float(duration)) => *duration == 0.0, Some(Attribute::Int(duration)) => *duration == 0, _ => false, @@ -6220,7 +6223,7 @@ mod tests { tc.tick().h(&[0, 1]); tc.tick() .z(&[0]) - .meta("_physical_duration", Attribute::Float(0.0)); + .meta(PHYSICAL_DURATION_META_KEY, Attribute::Float(0.0)); tc.tick().h(&[0, 1]); tc.fill_idle_gates(); @@ -6229,7 +6232,7 @@ mod tests { assert_eq!(zero_duration_tick.gate_count(), 1); assert_eq!(zero_duration_tick.gate_batches()[0].gate_type, GateType::Z); assert_eq!( - zero_duration_tick.get_gate_attr(0, "_physical_duration"), + zero_duration_tick.get_gate_attr(0, PHYSICAL_DURATION_META_KEY), Some(&Attribute::Float(0.0)) ); } @@ -6257,7 +6260,7 @@ mod tests { tc.tick() .h(&[0]) .z(&[1]) - .meta("_physical_duration", Attribute::Float(0.0)); + .meta(PHYSICAL_DURATION_META_KEY, Attribute::Float(0.0)); tc.fill_idle_gates(); } diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index 1d78f7d76..65545d5df 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -1397,6 +1397,8 @@ class llvm: # Tick Circuit # ============================================================================= +PHYSICAL_DURATION_META_KEY: str + class GateType: """Gate type marker.""" diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index 515f688a1..ecc1deba7 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -26,7 +26,8 @@ use crate::dtypes::AngleParam; use crate::gate_registry_bindings::PyGateRegistry; use pecos_core::{Angle64, ChannelExpr, GateQubits, GateSignature, Pauli, TimeUnits}; use pecos_quantum::{ - Attribute, DagCircuit, Gate, GateType, QubitId, Tick, TickCircuit, TickGateError, + Attribute, DagCircuit, Gate, GateType, PHYSICAL_DURATION_META_KEY, QubitId, Tick, TickCircuit, + TickGateError, }; use pyo3::prelude::*; use pyo3::types::{PyBytes, PyDict, PyList}; @@ -3823,6 +3824,7 @@ pub fn register_quantum_circuit_types(parent_module: &Bound<'_, PyModule>) -> Py parent_module.add_class::()?; parent_module.add_class::()?; parent_module.add_class::()?; + parent_module.add("PHYSICAL_DURATION_META_KEY", PHYSICAL_DURATION_META_KEY)?; // Add exceptions parent_module.add( diff --git a/python/pecos-rslib/src/namespace_modules.rs b/python/pecos-rslib/src/namespace_modules.rs index 865d6d718..83574b7a3 100644 --- a/python/pecos-rslib/src/namespace_modules.rs +++ b/python/pecos-rslib/src/namespace_modules.rs @@ -20,6 +20,10 @@ pub fn register_quantum_module(parent: &Bound<'_, PyModule>) -> PyResult<()> { quantum.add("TickHandle", parent.getattr("TickHandle")?)?; quantum.add("TickPrepHandle", parent.getattr("TickPrepHandle")?)?; quantum.add("TickMeasureHandle", parent.getattr("TickMeasureHandle")?)?; + quantum.add( + "PHYSICAL_DURATION_META_KEY", + parent.getattr("PHYSICAL_DURATION_META_KEY")?, + )?; quantum.add( "DagCircuitWouldCycleError", parent.getattr("DagCircuitWouldCycleError")?, diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index a33d42255..ee65203e3 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -43,6 +43,7 @@ get_stabilizer_region, get_stabilizer_touch_label, ) +from pecos.quantum import PHYSICAL_DURATION_META_KEY if TYPE_CHECKING: from pecos.qec.surface._twirl_config import TwirlConfig @@ -2199,7 +2200,7 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non if op.label: meta["label"] = op.label if op.label.startswith("szz_virtual_prefix:"): - meta["_physical_duration"] = 0.0 + meta[PHYSICAL_DURATION_META_KEY] = 0.0 apply_gate_metadata(tick, meta or None) elif op.op_type == OpType.CX: diff --git a/python/quantum-pecos/src/pecos/quantum/__init__.py b/python/quantum-pecos/src/pecos/quantum/__init__.py index d9c190b84..3a0d84c38 100644 --- a/python/quantum-pecos/src/pecos/quantum/__init__.py +++ b/python/quantum-pecos/src/pecos/quantum/__init__.py @@ -92,6 +92,7 @@ H5, H6, ISWAP, + PHYSICAL_DURATION_META_KEY, SWAP, SX, SXX, @@ -269,6 +270,7 @@ def pauli_string( "H5", "H6", "ISWAP", + "PHYSICAL_DURATION_META_KEY", "SWAP", "SX", "SXX", 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 248279cb6..c1b5de65b 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 @@ -24,6 +24,7 @@ _validate_szz_sign_vector, build_surface_code_circuit, generate_dag_circuit_from_patch, + generate_dem_from_tick_circuit, generate_stim_from_patch, generate_tick_circuit_from_patch, ) @@ -35,6 +36,7 @@ build_native_sampler, generate_circuit_level_dem_from_builder, ) +from pecos.quantum import PHYSICAL_DURATION_META_KEY def _to_numpy_complex(matrix: object) -> np.ndarray: @@ -157,6 +159,17 @@ def _gate_labels_for_tick(tick_circuit: object, tick_index: int) -> list[str | N ] +def _retag_virtual_prefix_duration(tick_circuit: object, duration: float) -> int: + count = 0 + for tick_index in range(tick_circuit.num_ticks()): + for gate_index, _gate in enumerate(tick_circuit.get_tick(tick_index).gate_batches()): + label = tick_circuit.get_gate_meta(tick_index, gate_index, "label") + if label and label.startswith("szz_virtual_prefix:"): + tick_circuit.set_gate_meta(tick_index, gate_index, PHYSICAL_DURATION_META_KEY, duration) + count += 1 + return count + + def test_szz_unitary_identities() -> None: assert _equiv_up_to_global_phase(SZZ @ _kron(SZDG, SZDG), CZ) assert _equiv_up_to_global_phase(SZZ @ ZZ, SZZDG) @@ -606,13 +619,13 @@ def test_szz_prefix_lowering_emits_dedicated_prefix_ticks(basis: str) -> None: assert all(label.startswith("szz_virtual_prefix:") for label in prefix_labels) for gate_index, gate in enumerate(tick.gate_batches()): assert gate.gate_type.name == "Z" - assert tick_circuit.get_gate_meta(tick_index, gate_index, "_physical_duration") == 0.0 + assert tick_circuit.get_gate_meta(tick_index, gate_index, PHYSICAL_DURATION_META_KEY) == 0.0 else: saw_physical_prefix = True assert all(label.startswith("szz_physical_prefix:") for label in prefix_labels) assert {gate.gate_type.name for gate in tick.gate_batches()} <= {"H", "F", "SY"} for gate_index, _gate in enumerate(tick.gate_batches()): - assert tick_circuit.get_gate_meta(tick_index, gate_index, "_physical_duration") is None + assert tick_circuit.get_gate_meta(tick_index, gate_index, PHYSICAL_DURATION_META_KEY) is None assert saw_physical_prefix assert saw_virtual_prefix @@ -757,3 +770,50 @@ def test_szz_idle_dem_uses_lowered_prefix_topology(basis: str) -> None: noise, decompose_errors=False, ) + + +def test_szz_virtual_prefix_ticks_do_not_contribute_idle_dem() -> None: + patch = SurfacePatch.create(distance=3) + noise_kwargs = { + "p1": 0.0, + "p2": 0.0, + "p_meas": 0.0, + "p_prep": 0.0, + "p_idle_z_linear_rate": 0.01, + "decompose_errors": False, + } + + tagged = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + szz_physical_prefixes=True, + ) + retagged = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + szz_physical_prefixes=True, + ) + assert _retag_virtual_prefix_duration(retagged, 1.0) > 0 + + tagged.fill_idle_gates() + retagged.fill_idle_gates() + + tagged_dem = generate_dem_from_tick_circuit(tagged, **noise_kwargs) + retagged_dem = generate_dem_from_tick_circuit(retagged, **noise_kwargs) + + assert ( + generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + basis="Z", + noise=NoiseModel(p_idle_z_linear_rate=0.01), + interaction_basis="szz", + decompose_errors=False, + ) + == tagged_dem + ) + assert retagged_dem != tagged_dem From ea742f8102fe629764463b7f2259775fdb7b3474 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 09:55:54 -0600 Subject: [PATCH 080/150] Expose surface interaction basis selection --- .../surface/native_dem_threshold_sweep.py | 164 ++++++++++++++---- .../src/pecos/qec/surface/decode.py | 32 +++- .../tests/qec/surface/test_surface_decoder.py | 30 +++- .../tests/qec/test_qec_ux_entrypoints.py | 18 ++ 4 files changed, 208 insertions(+), 36 deletions(-) diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index 4839a9ad3..6a6b8baec 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -8,7 +8,8 @@ - direct ``selene_sim`` execution with either Selene ``Stim`` or the PECOS Selene stabilizer plugin - optional native DEM sampling via ``build_native_sampler(...)`` -- a depolarizing noise model with ``p2 = p``, ``p1 = p/30``, ``p_meas = p_prep = p/3`` +- circuit-level rates ``p2 = p``, ``p1 = p/30``, ``p_meas = p_prep = p/3`` + with selectable ``sim`` runtime noise builders - ``SurfaceDecoder(...)`` with PECOS-native DEMs (PyMatching or Tesseract) For the ``sim`` backend, decoding is performed relative to a cached noiseless @@ -261,7 +262,7 @@ def _backend_runtime_label(sample_backend: str, native_circuit_source: str = "ab if sample_backend == "sim": return ( "sim(Guppy(...)).classical(selene_engine()).quantum(pecos.stabilizer()) " - f"+ PECOS depolarizing noise + native DEM source={native_circuit_source} + noiseless " + f"+ PECOS runtime noise + native DEM source={native_circuit_source} + noiseless " "reference-trajectory calibration" ) if sample_backend == "selene_sim": @@ -552,7 +553,11 @@ def _noise_model_description(args: argparse.Namespace) -> str: p1s = getattr(args, "p1_scale", 1.0 / 30.0) pms = getattr(args, "p_meas_scale", 1.0 / 3.0) pps = getattr(args, "p_prep_scale", 1.0 / 3.0) - return f"depolarizing with p1={p1s:.4g}*p, p2=p, p_meas={pms:.4g}*p, p_prep={pps:.4g}*p" + 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"depolarizing runtime ({base})" def _create_dem_decoder(decoder_type: str, dem_str: str, *, tesseract_beam: int = 5) -> object: @@ -652,6 +657,7 @@ def _decoder_runtime( physical_error_rate: float, dem_mode: str, native_circuit_source: str, + interaction_basis: str = "cx", decoder_type: str = "pymatching", ancilla_budget: int | None = None, p1_scale: float = 0.1, @@ -678,6 +684,7 @@ def _decoder_runtime( circuit_level_dem_mode=dem_mode, circuit_level_dem_source=native_circuit_source, ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, ) return _DecoderRuntime( patch=patch, @@ -697,6 +704,7 @@ def _native_sampler_runtime( physical_error_rate: float, dem_mode: str, native_circuit_source: str, + interaction_basis: str = "cx", decoder_type: str = "pymatching", ancilla_budget: int | None = None, p1_scale: float = 0.1, @@ -714,6 +722,7 @@ def _native_sampler_runtime( physical_error_rate, dem_mode, native_circuit_source, + interaction_basis=interaction_basis, decoder_type=decoder_type, ancilla_budget=ancilla_budget, p1_scale=p1_scale, @@ -727,6 +736,7 @@ def _native_sampler_runtime( basis=basis, circuit_source=native_circuit_source, ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, ) # PyMatching needs decomposed (graph-like) DEMs; Tesseract and check-matrix # decoders handle hyperedges natively and should get the full DEM. @@ -741,6 +751,7 @@ def _native_sampler_runtime( decompose_errors=False, circuit_source=native_circuit_source, ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, ) dem_decoder = _create_dem_decoder(decoder_type, dem_str) # The traced-QIS sampler stack has a noticeable one-time initialization cost @@ -764,7 +775,9 @@ def _sim_reference_trajectory( distance: int, total_rounds: int, basis: str, -) -> tuple[tuple[tuple[int, ...], ...], tuple[tuple[int, ...], ...], tuple[int, ...]]: + interaction_basis: str, + sim_noise_model: str, +) -> tuple[tuple[tuple[int, ...], ...], tuple[tuple[int, ...], ...], tuple[int, ...], tuple[int, ...]]: """Cache a noiseless gate-level trajectory used as a decoding reference.""" import numpy as np from pecos.qec.surface import SurfacePatch @@ -778,6 +791,8 @@ def _sim_reference_trajectory( total_rounds=total_rounds, num_shots=1, seed=0, + interaction_basis=interaction_basis, + sim_noise_model=sim_noise_model, ) synx_rows = _reshape_round_values( @@ -793,32 +808,40 @@ def _sim_reference_trajectory( "synz", ) final = np.asarray(_result_rows_for_key(result_dict, "final")[0], dtype=np.uint8) + init_key = "init_synx" if basis.upper() == "Z" else "init_synz" + init = np.asarray(_result_rows_for_key(result_dict, init_key)[0], dtype=np.uint8) return ( tuple(tuple(int(v) for v in row) for row in synx_rows), tuple(tuple(int(v) for v in row) for row in synz_rows), tuple(int(v) for v in final.tolist()), + tuple(int(v) for v in init.tolist()), ) @cache -def _compiled_guppy_hugr(distance: int, total_rounds: int, basis: str) -> bytes: +def _compiled_guppy_hugr(distance: int, total_rounds: int, basis: str, interaction_basis: str = "cx") -> bytes: """Cache compiled HUGR bytes for the direct selene_sim backend.""" from pecos.compilation_pipeline import compile_guppy_to_hugr from pecos.guppy import make_surface_code - program = make_surface_code(distance=distance, num_rounds=total_rounds, basis=basis) + program = make_surface_code( + distance=distance, + num_rounds=total_rounds, + basis=basis, + interaction_basis=interaction_basis, + ) return compile_guppy_to_hugr(program) @cache -def _selene_instance(distance: int, total_rounds: int, basis: str) -> object: +def _selene_instance(distance: int, total_rounds: int, basis: str, interaction_basis: str = "cx") -> object: """Cache a built Selene instance for one circuit shape.""" from selene_sim import build instance = build( - _compiled_guppy_hugr(distance, total_rounds, basis), - name=f"surface_d{distance}_{basis.lower()}_r{total_rounds}", + _compiled_guppy_hugr(distance, total_rounds, basis, interaction_basis), + name=f"surface_d{distance}_{basis.lower()}_{interaction_basis}_r{total_rounds}", ) _CACHED_SELENE_INSTANCES.append(instance) return instance @@ -837,6 +860,8 @@ def _run_gate_backend_result_dict( p1_scale: float = 0.1, p_meas_scale: float = 0.5, p_prep_scale: float = 0.5, + interaction_basis: str = "cx", + sim_noise_model: str = "depolarizing", ) -> dict[str, list[list[int]]]: """Run one gate-level backend and normalize results to a shot-map-like dict.""" import os @@ -860,11 +885,11 @@ def run_direct_selene_backend(*, simulator: object) -> dict[str, list[list[int]] ) compile_start = time.perf_counter() - _compiled_guppy_hugr(distance, total_rounds, basis) + _compiled_guppy_hugr(distance, total_rounds, basis, interaction_basis) compile_seconds = time.perf_counter() - compile_start build_start = time.perf_counter() - instance = _selene_instance(distance, total_rounds, basis) + instance = _selene_instance(distance, total_rounds, basis, interaction_basis) build_seconds = time.perf_counter() - build_start reset_start = time.perf_counter() @@ -885,7 +910,7 @@ def run_direct_selene_backend(*, simulator: object) -> dict[str, list[list[int]] run_start = time.perf_counter() for shot_results in instance.run_shots( simulator=simulator, - n_qubits=get_num_qubits(distance), + n_qubits=get_num_qubits(distance, interaction_basis=interaction_basis), n_shots=num_shots, error_model=error_model, runtime=SimpleRuntime(), @@ -914,24 +939,45 @@ def run_direct_selene_backend(*, simulator: object) -> dict[str, list[list[int]] if sample_backend == "sim": backend_start = time.perf_counter() noise_start = time.perf_counter() - noise_model = pecos.depolarizing_noise() - noise_model.set_probabilities( - physical_error_rate * p_prep_scale, # p_prep - physical_error_rate * p_meas_scale, # p_meas_0 - physical_error_rate * p_meas_scale, # p_meas_1 - physical_error_rate * p1_scale, # p1 (single-qubit gates) - physical_error_rate, # p2 (two-qubit gates) - ) + if sim_noise_model == "general": + 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_leakage_scale(0.0) + .with_p_idle_coherent(use_coherent_idle) + .with_seed(seed) + ) + elif sim_noise_model == "depolarizing": + noise_model = pecos.depolarizing_noise() + noise_model.set_probabilities( + physical_error_rate * p_prep_scale, # p_prep + physical_error_rate * p_meas_scale, # p_meas_0 + physical_error_rate * p_meas_scale, # p_meas_1 + physical_error_rate * p1_scale, # p1 (single-qubit gates) + physical_error_rate, # p2 (two-qubit gates) + ) + else: + msg = f"Unknown sim noise model: {sim_noise_model}" + raise ValueError(msg) noise_seconds = time.perf_counter() - noise_start program_start = time.perf_counter() - program = make_surface_code(distance=distance, num_rounds=total_rounds, basis=basis) + program = make_surface_code( + distance=distance, + num_rounds=total_rounds, + basis=basis, + interaction_basis=interaction_basis, + ) program_seconds = time.perf_counter() - program_start run_start = time.perf_counter() shot_vec = ( pecos.sim(program) .classical(pecos.selene_engine()) .quantum(pecos.stabilizer()) - .qubits(get_num_qubits(distance)) + .qubits(get_num_qubits(distance, interaction_basis=interaction_basis)) .noise(noise_model) .seed(seed) .run(num_shots) @@ -979,6 +1025,8 @@ def _profile_gate_backends( duration_rounds_by_distance: dict[int, tuple[int, ...]], shots: int, seed: int, + interaction_basis: str, + sim_noise_model: str, warmup_repetitions: int, benchmark_repetitions: int, ) -> None: @@ -1045,6 +1093,8 @@ def _profile_gate_backends( total_rounds=total_rounds, num_shots=shots, seed=combo_seed + rep, + interaction_basis=interaction_basis, + sim_noise_model=sim_noise_model, ) runs: list[dict[str, float]] = [] @@ -1059,6 +1109,8 @@ def _profile_gate_backends( num_shots=shots, seed=combo_seed + warmup_repetitions + rep, timing_sink=timing, + interaction_basis=interaction_basis, + sim_noise_model=sim_noise_model, ) runs.append(timing) @@ -1102,6 +1154,8 @@ def _run_memory_point( p1_scale: float = 0.1, p_meas_scale: float = 0.5, p_prep_scale: float = 0.5, + interaction_basis: str = "cx", + sim_noise_model: str = "depolarizing", ) -> SweepPoint: """Run one surface-memory point and decode it with native PECOS DEMs.""" import numpy as np @@ -1114,6 +1168,7 @@ def _run_memory_point( physical_error_rate, dem_mode, native_circuit_source, + interaction_basis=interaction_basis, decoder_type=decoder_type, ancilla_budget=ancilla_budget, p1_scale=p1_scale, @@ -1130,15 +1185,18 @@ def _run_memory_point( num_raw_errors: int | None = 0 if sample_backend in {"sim", "selene_sim", "selene_stabilizer_plugin"}: - ref_synx_rows, ref_synz_rows, ref_final_row = _sim_reference_trajectory( + ref_synx_rows, ref_synz_rows, ref_final_row, ref_init_row = _sim_reference_trajectory( sample_backend, distance, total_rounds, basis.upper(), + interaction_basis, + sim_noise_model, ) ref_synx_list = [np.asarray(row, dtype=np.uint8) for row in ref_synx_rows] ref_synz_list = [np.asarray(row, dtype=np.uint8) for row in ref_synz_rows] ref_final = np.asarray(ref_final_row, dtype=np.uint8) + ref_init = np.asarray(ref_init_row, dtype=np.uint8) result_dict = _run_gate_backend_result_dict( sample_backend=sample_backend, distance=distance, @@ -1150,16 +1208,26 @@ def _run_memory_point( p1_scale=p1_scale, p_meas_scale=p_meas_scale, p_prep_scale=p_prep_scale, + interaction_basis=interaction_basis, + sim_noise_model=sim_noise_model, ) synx_rows = _result_rows_for_key(result_dict, "synx") synz_rows = _result_rows_for_key(result_dict, "synz") final_rows = _result_rows_for_key(result_dict, "final") - - if len(synx_rows) != num_shots or len(synz_rows) != num_shots or len(final_rows) != num_shots: + init_key = "init_synx" if basis.upper() == "Z" else "init_synz" + init_rows = _result_rows_for_key(result_dict, init_key) + + if ( + len(synx_rows) != num_shots + or len(synz_rows) != num_shots + or len(final_rows) != num_shots + or len(init_rows) != num_shots + ): msg = ( "Result register lengths do not match the requested shot count: " - f"synx={len(synx_rows)}, synz={len(synz_rows)}, final={len(final_rows)}, shots={num_shots}" + f"synx={len(synx_rows)}, synz={len(synz_rows)}, final={len(final_rows)}, " + f"{init_key}={len(init_rows)}, shots={num_shots}" ) raise ValueError( msg, @@ -1169,12 +1237,16 @@ def _run_memory_point( synx_list = _reshape_round_values(synx_rows[shot_idx], total_rounds, num_x_stab, "synx") synz_list = _reshape_round_values(synz_rows[shot_idx], total_rounds, num_z_stab, "synz") final = np.asarray(final_rows[shot_idx], dtype=np.uint8) + init = np.asarray(init_rows[shot_idx], dtype=np.uint8) if final.size != patch.geometry.num_data: msg = f"Register 'final' has {final.size} bits for one shot, expected {patch.geometry.num_data}" raise ValueError( msg, ) + if init.shape != ref_init.shape: + msg = f"Register {init_key!r} has shape {init.shape}, expected {ref_init.shape}" + raise ValueError(msg) # Decode relative to the noiseless gate-level baseline so the native # DEM sees deviations from the actual circuit trajectory. @@ -1187,6 +1259,7 @@ def _run_memory_point( for synz, ref_synz in zip(synz_list, ref_synz_list, strict=True) ] final = final ^ ref_final + init = init ^ ref_init raw_parity = int(sum(int(final[q]) for q in logical_qubits) % 2) if num_raw_errors is None: @@ -1195,9 +1268,9 @@ def _run_memory_point( num_raw_errors += raw_parity if basis.upper() == "Z": - is_error, _ = decoder.decode_memory_z(synx_list, synz_list, final) + is_error, _ = decoder.decode_memory_z(synx_list, synz_list, final, init_synx=init) else: - is_error, _ = decoder.decode_memory_x(synx_list, synz_list, final) + is_error, _ = decoder.decode_memory_x(synx_list, synz_list, final, init_synz=init) num_logical_errors += int(is_error) elif sample_backend == "native_sampler": native_runtime = _native_sampler_runtime( @@ -1207,6 +1280,7 @@ def _run_memory_point( physical_error_rate, dem_mode, native_circuit_source, + interaction_basis=interaction_basis, decoder_type=decoder_type, ancilla_budget=ancilla_budget, p1_scale=p1_scale, @@ -1874,6 +1948,8 @@ def _write_json_results( "shots": args.shots, "dem_mode": args.dem_mode, "native_circuit_source": args.native_circuit_source, + "interaction_basis": args.interaction_basis, + "sim_noise_model": args.sim_noise_model, "seed": args.seed, "backend_runtime_descriptions": { backend: _backend_runtime_label(backend, args.native_circuit_source) @@ -3511,6 +3587,8 @@ def _config_for_report(args: argparse.Namespace) -> dict[str, Any]: # appendix page can read the same field from either source. "sample_backend_mode": getattr(args, "sample_backend", None), "native_circuit_source": getattr(args, "native_circuit_source", None), + "interaction_basis": getattr(args, "interaction_basis", None), + "sim_noise_model": getattr(args, "sim_noise_model", None), "decoder": getattr(args, "decoder", ["pymatching"]), "noise_model": _noise_model_description(args), "seed": getattr(args, "seed", None), @@ -3621,6 +3699,21 @@ def _parse_args() -> argparse.Namespace: "matching the standard circuit-level noise model from the QEC literature." ), ) + parser.add_argument( + "--interaction-basis", + choices=["cx", "szz"], + default="cx", + help="Surface-memory two-qubit interaction basis to generate and analyze.", + ) + parser.add_argument( + "--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_coherent=False." + ), + ) parser.add_argument( "--dem-mode", choices=["native_decomposed", "native_full"], @@ -3845,16 +3938,15 @@ def _print_config_banner( print(f"shots / point : {args.shots}") print(f"sample backend mode: {args.sample_backend}") print(f"executed backends: {backends}") + print(f"interaction basis: {args.interaction_basis}") + print(f"sim noise model : {args.sim_noise_model}") print(f"DEM mode : {args.dem_mode}") print(f"native circuit source: {args.native_circuit_source}") decoders = getattr(args, "decoder", ["pymatching"]) print(f"decoder(s) : {', '.join(decoders)} via SurfaceDecoder(native PECOS DEM)") for backend in backends: print(f"runtime[{backend}] : {_backend_runtime_label(backend, args.native_circuit_source)}") - p1s = getattr(args, "p1_scale", 0.1) - pms = getattr(args, "p_meas_scale", 0.5) - pps = getattr(args, "p_prep_scale", 0.5) - print(f"noise model : depolarizing with p1={p1s}*p, p2=p, p_meas={pms}*p, p_prep={pps}*p") + print(f"noise model : {_noise_model_description(args)}") print("fit model : p_L(r) = 0.5 * (1 - (1 - 2 * epsilon) ** r)") if output_dir is not None: print(f"artifact dir : {output_dir}") @@ -3902,6 +3994,8 @@ def _run_one_memory_point( p1_scale=getattr(args, "p1_scale", 0.1), p_meas_scale=getattr(args, "p_meas_scale", 0.5), p_prep_scale=getattr(args, "p_prep_scale", 0.5), + interaction_basis=getattr(args, "interaction_basis", "cx"), + sim_noise_model=getattr(args, "sim_noise_model", "depolarizing"), ) elapsed_seconds = time.perf_counter() - point_start naive_per_round = ler_per_round_exp(point.logical_error_rate, point.total_rounds) @@ -3919,6 +4013,7 @@ def _run_one_memory_point( "physical_error_rate": physical_error_rate, "total_rounds": total_rounds, "num_shots": args.shots, + "interaction_basis": getattr(args, "interaction_basis", "cx"), "elapsed_seconds": elapsed_seconds, } return point, timing_row @@ -4194,6 +4289,9 @@ def _print_post_sweep_analysis( def main() -> int: """Run the threshold sweep CLI and optionally write summary artifacts.""" args = _parse_args() + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis + + args.interaction_basis = _normalize_interaction_basis(args.interaction_basis) if args.open_html: args.save_html = True if args.save_html: @@ -4238,6 +4336,8 @@ def main() -> int: duration_rounds_by_distance=duration_rounds_by_distance, shots=args.shots, seed=args.seed, + interaction_basis=args.interaction_basis, + sim_noise_model=args.sim_noise_model, warmup_repetitions=args.benchmark_warmup, benchmark_repetitions=args.benchmark_repetitions, ) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index ea49b5517..8d86956bb 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -2527,6 +2527,7 @@ def __init__( circuit_level_dem_mode: Literal["native_full", "native_decomposed"] = "native_full", circuit_level_dem_source: Literal["abstract", "traced_qis"] = "abstract", ancilla_budget: int | None = None, + interaction_basis: str = "cx", ) -> None: """Initialize decoder from surface code patch. @@ -2559,7 +2560,11 @@ def __init__( the native circuit-level DEM path. When provided, the decoder builds its DEM from the corresponding batched ancilla-reuse circuit instead of the default dedicated-ancilla circuit. + interaction_basis: Surface-memory two-qubit interaction basis, + ``"cx"`` or ``"szz"``. """ + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis + self.patch = patch self.num_rounds = num_rounds self.noise = noise or NoiseModel(p2=0.01, p_meas=0.01) @@ -2568,6 +2573,7 @@ def __init__( self.circuit_level_dem_mode = circuit_level_dem_mode self.circuit_level_dem_source = circuit_level_dem_source self.ancilla_budget = ancilla_budget + self.interaction_basis = _normalize_interaction_basis(interaction_basis) # Lazily create decoders self._x_decoder = None @@ -2604,6 +2610,7 @@ def _get_circuit_level_dem(self, basis: str) -> str: decompose_errors=self.circuit_level_dem_mode == "native_decomposed", circuit_source=self.circuit_level_dem_source, ancilla_budget=self.ancilla_budget, + interaction_basis=self.interaction_basis, ) if basis.upper() == "Z": self._z_dem = dem @@ -3461,6 +3468,7 @@ class SimulationResult: raw_error_rate: Raw error rate (no decoding) decoded: Whether decoding was applied decoder_type: Decoder backend used (if decoded) + interaction_basis: Surface-memory two-qubit interaction basis. """ distance: int @@ -3473,6 +3481,7 @@ class SimulationResult: raw_error_rate: float decoded: bool decoder_type: str | None = None + interaction_basis: str = "cx" def _memory_noise_model( @@ -3502,6 +3511,7 @@ def surface_code_memory( decode: bool = True, circuit_source: Literal["abstract", "traced_qis"] = "abstract", ancilla_budget: int | None = None, + interaction_basis: str = "cx", ) -> SimulationResult: """Run the recommended native surface-code memory workflow. @@ -3523,6 +3533,8 @@ def surface_code_memory( decode: If false, report the raw observable-flip rate. circuit_source: ``"abstract"`` or ``"traced_qis"`` circuit source. ancilla_budget: Optional cap on simultaneously live ancillas. + interaction_basis: Surface-memory two-qubit interaction basis, + ``"cx"`` or ``"szz"``. Returns: ``SimulationResult`` with logical and raw error counts/rates. @@ -3534,8 +3546,10 @@ def surface_code_memory( 0.0 """ from pecos.qec import ParsedDem + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis from pecos.qec.surface.patch import SurfacePatch + interaction_basis = _normalize_interaction_basis(interaction_basis) if distance < 1: msg = f"distance must be >= 1, got {distance}" raise ValueError(msg) @@ -3557,6 +3571,7 @@ def surface_code_memory( decompose_errors=True, ancilla_budget=ancilla_budget, circuit_source=circuit_source, + interaction_basis=interaction_basis, ) batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(shots, seed) num_raw_errors = sum(1 for shot in range(shots) if batch.get_observable_mask(shot) != 0) @@ -3573,6 +3588,7 @@ def surface_code_memory( raw_error_rate=num_raw_errors / shots if shots else 0.0, decoded=decode, decoder_type=decoder_type if decode else None, + interaction_basis=interaction_basis, ) @@ -3585,6 +3601,7 @@ def run_noisy_memory_experiment( *, decode: bool = True, decoder_type: str = "pymatching", + interaction_basis: str = "cx", ) -> SimulationResult: """Run a noisy surface code memory experiment with optional decoding. @@ -3602,6 +3619,8 @@ def run_noisy_memory_experiment( noise: Noise model parameters decode: If True, use decoding to correct errors decoder_type: Decoder backend (pymatching, fusion_blossom, bp_osd, etc.) + interaction_basis: Surface-memory two-qubit interaction basis, + ``"cx"`` or ``"szz"``. Returns: SimulationResult with error rate statistics @@ -3624,7 +3643,9 @@ def run_noisy_memory_experiment( from pecos.compilation_pipeline import compile_guppy_to_hugr from pecos.guppy.surface import get_num_qubits, make_surface_code from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis + interaction_basis = _normalize_interaction_basis(interaction_basis) # Create patch and decoder patch = SurfacePatch.create(distance=distance) geom = patch.geometry @@ -3645,11 +3666,17 @@ def run_noisy_memory_experiment( num_rounds=num_rounds, noise=noise, decoder_type=dt, + interaction_basis=interaction_basis, ) # Build and compile circuit - num_qubits = get_num_qubits(distance) - prog = make_surface_code(distance=distance, num_rounds=num_rounds, basis=basis) + num_qubits = get_num_qubits(distance, interaction_basis=interaction_basis) + prog = make_surface_code( + distance=distance, + num_rounds=num_rounds, + basis=basis, + interaction_basis=interaction_basis, + ) hugr_bytes = compile_guppy_to_hugr(prog) instance = build(hugr_bytes, name=f"surface_d{distance}") @@ -3724,6 +3751,7 @@ def run_noisy_memory_experiment( raw_error_rate=num_raw_errors / num_shots if num_shots > 0 else 0.0, decoded=decode, decoder_type=decoder_type if decode else None, + interaction_basis=interaction_basis, ) 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 4df6535ab..e456d689c 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -223,10 +223,10 @@ def test_get_dem_caches_circuit_level_dem(self, monkeypatch: pytest.MonkeyPatch) real_generate = decode_module.generate_circuit_level_dem_from_builder calls = 0 - def wrapped_generate(*args: object, **kwargs: object) -> str: + def wrapped_generate(*_args: object, **kwargs: object) -> str: nonlocal calls calls += 1 - return real_generate(*args, **kwargs) + return real_generate(*_args, **kwargs) monkeypatch.setattr(decode_module, "generate_circuit_level_dem_from_builder", wrapped_generate) @@ -236,6 +236,32 @@ def wrapped_generate(*args: object, **kwargs: object) -> str: assert dem_1 == dem_2 assert calls == 1 + def test_get_dem_passes_interaction_basis_to_native_builder(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Decoder DEM generation should use the requested interaction basis.""" + import pecos.qec.surface.decode as decode_module + + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p2=0.01, p_meas=0.01) + seen: dict[str, object] = {} + + def wrapped_generate(*_args: object, **kwargs: object) -> str: + seen["interaction_basis"] = kwargs.get("interaction_basis") + return "error(0.01) D0\n" + + monkeypatch.setattr(decode_module, "generate_circuit_level_dem_from_builder", wrapped_generate) + + decoder = SurfaceDecoder( + patch, + num_rounds=3, + noise=noise, + circuit_level_dem_mode="native_decomposed", + interaction_basis="SZZ", + ) + + assert decoder.interaction_basis == "szz" + assert decoder.get_dem("Z", circuit_level=True) == "error(0.01) D0\n" + assert seen["interaction_basis"] == "szz" + def test_decode_trivial_syndrome_z(self) -> None: """Decode trivial Z syndrome (no errors).""" patch = SurfacePatch.create(distance=3) 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 84e03a7a5..e3fb18deb 100644 --- a/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py +++ b/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py @@ -54,6 +54,24 @@ def test_surface_code_memory_runs_native_zero_noise_quick_start() -> None: assert result.num_rounds == 1 assert result.logical_error_rate == 0.0 assert result.raw_error_rate == 0.0 + assert result.interaction_basis == "cx" + + +def test_surface_code_memory_accepts_szz_interaction_basis() -> None: + from pecos.qec.surface import surface_code_memory + + result = surface_code_memory( + distance=3, + physical_error_rate=0.0, + shots=4, + rounds=1, + seed=123, + interaction_basis="szz", + ) + + assert result.interaction_basis == "szz" + assert result.logical_error_rate == 0.0 + assert result.raw_error_rate == 0.0 def test_surface_code_memory_rejects_ambiguous_noise_inputs() -> None: From 09291bad10d5c1f7d7c5712c2682f285ab9b5263 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 10:35:07 -0600 Subject: [PATCH 081/150] Fix traced SZZ DEM validation and raw sampler routing --- .../surface/native_dem_threshold_sweep.py | 18 +- .../src/pecos/decoders/__init__.py | 2 + .../src/pecos/qec/surface/circuit_builder.py | 8 + .../src/pecos/qec/surface/decode.py | 9 +- .../pecos/unit/test_surface_sweep_math.py | 7 + .../qec/surface/test_szz_interaction_basis.py | 162 +++++++++++++++++- 6 files changed, 186 insertions(+), 20 deletions(-) diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index 6a6b8baec..90b27e54a 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -568,18 +568,18 @@ def _create_dem_decoder(decoder_type: str, dem_str: str, *, tesseract_beam: int via DemAwareDecoder which extracts the check matrix from the DEM. """ if decoder_type == "tesseract": - from pecos_rslib.decoders import TesseractDecoder + from pecos.decoders import TesseractDecoder dem_filtered = "\n".join(line for line in dem_str.split("\n") if not line.startswith("logical_observable")) return TesseractDecoder.from_dem(dem_filtered, preset="fast", det_beam=tesseract_beam) if decoder_type in _CHECK_MATRIX_DECODERS: - from pecos_rslib.decoders import DemAwareDecoder + from pecos.decoders import DemAwareDecoder dem_filtered = "\n".join(line for line in dem_str.split("\n") if not line.startswith("logical_observable")) return DemAwareDecoder.from_dem(dem_filtered, decoder_type=decoder_type) - from pecos_rslib.decoders import PyMatchingDecoder + from pecos.decoders import PyMatchingDecoder return PyMatchingDecoder.from_dem(dem_str) @@ -618,7 +618,7 @@ def _decode_all_shots( ) # PyMatching batch: takes flattened (num_shots * num_detectors) u8 array - from pecos_rslib.decoders import PyMatchingDecoder + from pecos.decoders import PyMatchingDecoder if isinstance(dem_decoder, PyMatchingDecoder): flat = detection_events.astype(np.uint8).flatten().tolist() @@ -628,7 +628,7 @@ def _decode_all_shots( return int(np.sum(predicted != true_flips)) # Tesseract batch: takes list of syndromes, parallel rayon - from pecos_rslib.decoders import TesseractDecoder + from pecos.decoders import TesseractDecoder if isinstance(dem_decoder, TesseractDecoder): syndromes = [detection_events[i].astype(np.uint8).tolist() for i in range(num_shots)] @@ -696,6 +696,13 @@ def _decoder_runtime( ) +def _native_sampler_model_for_decoder(decoder_type: str) -> str: + """Choose the native sampler model paired with a DEM decoder.""" + if decoder_type == "pymatching": + return "dem" + return "influence_dem" + + @cache def _native_sampler_runtime( distance: int, @@ -737,6 +744,7 @@ def _native_sampler_runtime( circuit_source=native_circuit_source, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, + sampling_model=_native_sampler_model_for_decoder(decoder_type), ) # PyMatching needs decomposed (graph-like) DEMs; Tesseract and check-matrix # decoders handle hyperedges natively and should get the full DEM. diff --git a/python/quantum-pecos/src/pecos/decoders/__init__.py b/python/quantum-pecos/src/pecos/decoders/__init__.py index d2344d55b..56119208e 100644 --- a/python/quantum-pecos/src/pecos/decoders/__init__.py +++ b/python/quantum-pecos/src/pecos/decoders/__init__.py @@ -23,6 +23,7 @@ BpOsdDecoder, BpResult, CheckMatrix, + DemAwareDecoder, FusionBlossomDecoder, MinSumBpBuilder, MinSumBpDecoder, @@ -48,6 +49,7 @@ "BpOsdDecoder", "BpResult", "CheckMatrix", + "DemAwareDecoder", "DummyDecoder", "FusionBlossomDecoder", "MinSumBpBuilder", diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index ee65203e3..722a60a2a 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -2943,6 +2943,8 @@ def tick_circuit_to_stim( "H": ("H", "single"), "SX": ("SQRT_X", "single"), "SXdg": ("SQRT_X_DAG", "single"), + "SY": ("SQRT_Y", "single"), + "SYdg": ("SQRT_Y_DAG", "single"), "SZ": ("S", "single"), "SZdg": ("S_DAG", "single"), "X": ("X", "single"), @@ -2994,6 +2996,12 @@ def _gate_to_stim( msg = f"Unsupported traced Clifford RZ angle: {angle!r}" raise ValueError(msg) + if gate_name == "F": + return [("S_DAG", qubits), ("H", qubits)], "single" + + if gate_name == "Fdg": + return [("H", qubits), ("S", qubits)], "single" + if gate_name == "RZZ": if not gate.angles: return [], None diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 8d86956bb..c30a37c3b 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1616,12 +1616,10 @@ def _reject_szz_unlowered_physical_noise( interaction_basis: str, circuit_source: Literal["abstract", "traced_qis"], ) -> None: - """Reject SZZ surface DEM noise that still needs post-flow pulse locations.""" + """Reject SZZ surface DEM noise without well-defined gate locations.""" if interaction_basis != "szz": return reasons: list[str] = [] - if noise.p1 > 0.0 and circuit_source != "abstract": - reasons.append("p1 with circuit_source='traced_qis'") if _noise_uses_dedicated_idle_noise(noise) and circuit_source != "abstract": reasons.append("dedicated idle noise with circuit_source='traced_qis'") if not reasons: @@ -1629,9 +1627,8 @@ def _reject_szz_unlowered_physical_noise( joined = ", ".join(reasons) msg = ( "interaction_basis='szz' surface DEM generation does not yet support " - f"{joined} because the DEM must use post-flow prefix pulse locations " - "rather than the abstract H/SX/SZ scaffold; use circuit_source='abstract' " - "for p1 or dedicated idle noise" + f"{joined} because idle noise needs explicit post-flow idle locations; " + "use circuit_source='abstract' for dedicated idle noise" ) raise ValueError(msg) diff --git a/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py b/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py index 7f01a1e00..930d6654b 100644 --- a/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py +++ b/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py @@ -195,6 +195,13 @@ def test_linear_regression_requires_matching_lengths(sweep: ModuleType) -> None: sweep._linear_regression([1.0, 2.0], [3.0]) +def test_native_sampler_model_tracks_decoder_dem_requirements(sweep: ModuleType) -> None: + """Raw-DEM decoders should sample the exact native influence model.""" + assert sweep._native_sampler_model_for_decoder("pymatching") == "dem" + assert sweep._native_sampler_model_for_decoder("tesseract") == "influence_dem" + assert sweep._native_sampler_model_for_decoder("bp_osd") == "influence_dem" + + # --------------------------------------------------------------------------- # Per-round rate fit # --------------------------------------------------------------------------- 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 c1b5de65b..ddc4ab30e 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 @@ -4,6 +4,7 @@ from __future__ import annotations import json +import re import numpy as np import pecos as pc @@ -25,6 +26,7 @@ build_surface_code_circuit, generate_dag_circuit_from_patch, generate_dem_from_tick_circuit, + generate_dem_from_tick_circuit_via_stim, generate_stim_from_patch, generate_tick_circuit_from_patch, ) @@ -72,6 +74,16 @@ def _kron(left: np.ndarray, right: np.ndarray) -> np.ndarray: SXX = _kron(H, H) @ SZZ @ _kron(H, H) +def _raw_dem_errors(dem_text: str) -> dict[str, float]: + errors: dict[str, float] = {} + for line in dem_text.splitlines(): + match = re.match(r"error\(([^)]+)\)\s*(.*)", line.strip()) + if match: + target = match.group(2).strip() + errors[target] = errors.get(target, 0.0) + float(match.group(1)) + return errors + + def _equiv_up_to_global_phase(left: np.ndarray, right: np.ndarray, *, atol: float = 1e-10) -> bool: flat_right = right.ravel() flat_left = left.ravel() @@ -593,6 +605,87 @@ def test_szz_prefix_lowering_preserves_p2_influence_dem(basis: str) -> None: ) +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_lowered_native_dem_matches_stim_for_prefix_noise(basis: str) -> None: + patch = SurfacePatch.create(distance=3) + tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=3, + basis=basis, + interaction_basis="szz", + szz_physical_prefixes=True, + ) + p2 = 0.006 + noise_args = { + "p1": p2 / 30, + "p2": p2, + "p_meas": p2 / 3, + "p_prep": p2 / 3, + } + + native_errors = _raw_dem_errors( + generate_dem_from_tick_circuit( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + stim_errors = _raw_dem_errors( + generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + + assert set(native_errors) == set(stim_errors) + for target, native_probability in native_errors.items(): + stim_probability = stim_errors[target] + rel_diff = abs(native_probability - stim_probability) / max( + native_probability, + stim_probability, + 1e-12, + ) + assert rel_diff < 0.005, ( + f"{basis} lowered SZZ DEM mismatch for {target}: " + f"PECOS={native_probability:.8f}, Stim={stim_probability:.8f}" + ) + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_abstract_p2_only_raw_dem_matches_cx(basis: str) -> None: + patch = SurfacePatch.create(distance=3) + noise_args = { + "p1": 0.0, + "p2": 0.006, + "p_meas": 0.0, + "p_prep": 0.0, + } + cx_tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=3, + basis=basis, + interaction_basis="cx", + ) + szz_tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=3, + basis=basis, + interaction_basis="szz", + szz_physical_prefixes=False, + ) + + assert generate_dem_from_tick_circuit( + cx_tick_circuit, + decompose_errors=False, + **noise_args, + ) == generate_dem_from_tick_circuit( + szz_tick_circuit, + decompose_errors=False, + **noise_args, + ) + + @pytest.mark.parametrize("basis", ["Z", "X"]) def test_szz_prefix_lowering_emits_dedicated_prefix_ticks(basis: str) -> None: patch = SurfacePatch.create(distance=3) @@ -655,7 +748,7 @@ def test_szz_native_dem_rejects_traced_qis_idle_noise() -> None: with pytest.raises( ValueError, - match=r"dedicated idle noise with circuit_source='traced_qis'.*post-flow prefix pulse locations", + match=r"dedicated idle noise with circuit_source='traced_qis'.*explicit post-flow idle locations", ): generate_circuit_level_dem_from_builder( patch, @@ -666,19 +759,70 @@ def test_szz_native_dem_rejects_traced_qis_idle_noise() -> None: ) -def test_szz_native_dem_rejects_traced_qis_p1() -> None: +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_traced_qis_native_dem_matches_stim_for_p1(basis: str) -> None: + from pecos.qec.surface.circuit_builder import normalize_traced_qis_tick_circuit + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + patch = SurfacePatch.create(distance=3) + tick_circuit = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=1, + basis=basis, + circuit_source="traced_qis", + interaction_basis="szz", + ) + normalize_traced_qis_tick_circuit(tick_circuit, context="SZZ traced-QIS p1 test") + noise_args = { + "p1": 0.001, + "p2": 0.0, + "p_meas": 0.0, + "p_prep": 0.0, + } - with pytest.raises(ValueError, match=r"p1 with circuit_source='traced_qis'.*post-flow prefix pulse locations"): - generate_circuit_level_dem_from_builder( - patch, - num_rounds=1, - noise=NoiseModel(p1=0.001), - interaction_basis="szz", - circuit_source="traced_qis", + native_errors = _raw_dem_errors( + generate_dem_from_tick_circuit( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + stim_errors = _raw_dem_errors( + generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + + assert set(native_errors) == set(stim_errors) + for target, native_probability in native_errors.items(): + stim_probability = stim_errors[target] + rel_diff = abs(native_probability - stim_probability) / max( + native_probability, + stim_probability, + 1e-12, + ) + assert rel_diff < 0.005, ( + f"{basis} traced-QIS SZZ p1 DEM mismatch for {target}: " + f"PECOS={native_probability:.8f}, Stim={stim_probability:.8f}" ) +def test_szz_public_native_dem_accepts_traced_qis_p1() -> None: + patch = SurfacePatch.create(distance=3) + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=NoiseModel(p1=0.001), + interaction_basis="szz", + circuit_source="traced_qis", + ) + + assert "error(" in dem + assert stim.DetectorErrorModel(dem).num_detectors > 0 + + def test_szz_native_dem_accepts_p1_with_physical_prefix_lowering() -> None: patch = SurfacePatch.create(distance=3) dem = generate_circuit_level_dem_from_builder( From 04e5cfee97c57780258ea1eb166aa6b6dff0aab4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 11:09:00 -0600 Subject: [PATCH 082/150] Expose correlated PyMatching DEM option --- .../dem_builder/equivalence.rs | 6 ++- .../surface/native_dem_threshold_sweep.py | 48 ++++++++++++++----- .../src/pecos/qec/surface/decode.py | 13 +++-- .../pecos/unit/test_surface_sweep_math.py | 8 ++++ 4 files changed, 57 insertions(+), 18 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/equivalence.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/equivalence.rs index d18e60b01..67b5f7e72 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/equivalence.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/equivalence.rs @@ -19,8 +19,10 @@ //! //! - Two DEMs are equivalent if they produce the same probability distribution //! over (`detector_events`, `dem_output_flips`) patterns. -//! - Decomposed DEMs (using ^) create independent error channels that are `XORed`. -//! - Different decomposition strategies can produce equivalent sampling results. +//! - Decomposed DEMs (using ^) split one correlated error mechanism into +//! graphlike components that are `XORed` when that mechanism fires. +//! - Different decomposition strategies can produce equivalent sampling results +//! when their correlated mechanisms have the same combined effects. //! - For non-decomposed DEMs, mechanisms must match exactly. //! //! # Comparison Methods diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index 90b27e54a..a1f64518f 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -563,9 +563,10 @@ def _noise_model_description(args: argparse.Namespace) -> str: def _create_dem_decoder(decoder_type: str, dem_str: str, *, tesseract_beam: int = 5) -> object: """Create a DEM-level decoder from a DEM string. - Supports MWPM decoders (pymatching), search decoders (tesseract), and - check-matrix decoders (bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp) - via DemAwareDecoder which extracts the check matrix from the DEM. + Supports MWPM decoders (pymatching, pymatching_correlated), search + decoders (tesseract), and check-matrix decoders (bp_osd, bp_lsd, + union_find, relay_bp, min_sum_bp) via DemAwareDecoder which extracts the + check matrix from the DEM. """ if decoder_type == "tesseract": from pecos.decoders import TesseractDecoder @@ -581,6 +582,9 @@ def _create_dem_decoder(decoder_type: str, dem_str: str, *, tesseract_beam: int from pecos.decoders import PyMatchingDecoder + if decoder_type == "pymatching_correlated": + return PyMatchingDecoder.from_dem_with_correlations(dem_str, enable_correlations=True) + return PyMatchingDecoder.from_dem(dem_str) @@ -679,7 +683,7 @@ def _decoder_runtime( patch, num_rounds=total_rounds, noise=noise, - decoder_type=decoder_type, + decoder_type="pymatching" if decoder_type == "pymatching_correlated" else decoder_type, use_circuit_level_dem=True, circuit_level_dem_mode=dem_mode, circuit_level_dem_source=native_circuit_source, @@ -698,7 +702,7 @@ def _decoder_runtime( def _native_sampler_model_for_decoder(decoder_type: str) -> str: """Choose the native sampler model paired with a DEM decoder.""" - if decoder_type == "pymatching": + if decoder_type in {"pymatching", "pymatching_correlated"}: return "dem" return "influence_dem" @@ -746,9 +750,11 @@ def _native_sampler_runtime( interaction_basis=interaction_basis, sampling_model=_native_sampler_model_for_decoder(decoder_type), ) - # PyMatching needs decomposed (graph-like) DEMs; Tesseract and check-matrix - # decoders handle hyperedges natively and should get the full DEM. - if decoder_type == "pymatching": + # PyMatching uses graphlike decomposed DEMs. The correlated variant also + # consumes decomposition separators as correlation metadata. Tesseract and + # check-matrix decoders handle hyperedges natively and should get the full + # DEM. + if decoder_type in {"pymatching", "pymatching_correlated"}: dem_str = runtime.decoder.get_dem(basis.upper(), circuit_level=True) else: dem_str = generate_circuit_level_dem_from_builder( @@ -1304,7 +1310,13 @@ def _run_memory_point( # The DemSampler keeps all per-shot data in Rust -- nothing crosses to Python. dem_str_for_rust = native_runtime.dem_str rust_sampler = getattr(sampler, "sampler", None) - if dem_str_for_rust and rust_sampler and hasattr(rust_sampler, "sample_decode_count"): + use_rust_sample_decode = ( + decoder_type != "pymatching_correlated" + and dem_str_for_rust + and rust_sampler + and hasattr(rust_sampler, "sample_decode_count") + ) + if use_rust_sample_decode: # Use parallel path for slow decoders (Tesseract, BP+OSD, etc.) if decoder_type != "pymatching" and hasattr(rust_sampler, "sample_decode_count_parallel"): num_logical_errors = rust_sampler.sample_decode_count_parallel( @@ -3726,16 +3738,30 @@ def _parse_args() -> argparse.Namespace: "--dem-mode", choices=["native_decomposed", "native_full"], default="native_decomposed", - help="PECOS native DEM mode. PyMatching typically wants native_decomposed.", + help=( + "PECOS native DEM mode. Graph decoders such as PyMatching require " + "native_decomposed; raw-DEM decoders should use native_full." + ), ) parser.add_argument( "--decoder", nargs="+", - choices=["pymatching", "tesseract", "bp_osd", "bp_lsd", "union_find", "relay_bp", "min_sum_bp"], + choices=[ + "pymatching", + "pymatching_correlated", + "tesseract", + "bp_osd", + "bp_lsd", + "union_find", + "relay_bp", + "min_sum_bp", + ], default=["pymatching"], help=( "Decoder(s) for circuit-level DEM decoding. Specify multiple to " "compare them side-by-side in plots and reports. Default: pymatching. " + "pymatching_correlated enables PyMatching's DEM-correlation mode for " + "decomposed errors. " "Check-matrix decoders (bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp) " "extract a check matrix from the DEM automatically." ), diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index c30a37c3b..8e2bd2765 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -2055,9 +2055,11 @@ def generate_circuit_level_dem_from_builder( num_rounds: Number of syndrome extraction rounds noise: Noise model parameters basis: Memory basis ('X' or 'Z') - decompose_errors: If True, return PECOS's native decomposed DEM - representation, which is more appropriate for graph-based - decoders like PyMatching. + decompose_errors: If True, return PECOS's native graphlike-decomposed + DEM representation for graph decoders such as PyMatching. The + decomposition preserves correlated mechanism metadata with ``^`` + separators, but graph decoders may still approximate hyperedge + correlations. ancilla_budget: Optional cap on simultaneously live ancillas. When provided below the total stabilizer count, the native DEM is built from the same batched ancilla-reuse circuit family used by Guppy. @@ -2547,8 +2549,9 @@ def __init__( circuit_level_dem_mode: Which PECOS-native DEM representation to use when circuit-level DEMs are enabled. ``"native_full"`` preserves the current non-decomposed DEM output. ``"native_decomposed"`` - returns PECOS's graphlike decomposed DEM output, which is often - a better fit for graph decoders such as PyMatching. + returns PECOS's graphlike decomposed DEM output for graph + decoders such as PyMatching. This is a decoder-facing + approximation of hyperedge correlations, not an exact raw DEM. circuit_level_dem_source: Which ideal circuit to analyze when building native circuit-level DEMs. ``"abstract"`` uses the high-level surface TickCircuit, while ``"traced_qis"`` traces diff --git a/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py b/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py index 930d6654b..1c4f80788 100644 --- a/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py +++ b/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py @@ -198,10 +198,18 @@ def test_linear_regression_requires_matching_lengths(sweep: ModuleType) -> None: def test_native_sampler_model_tracks_decoder_dem_requirements(sweep: ModuleType) -> None: """Raw-DEM decoders should sample the exact native influence model.""" assert sweep._native_sampler_model_for_decoder("pymatching") == "dem" + assert sweep._native_sampler_model_for_decoder("pymatching_correlated") == "dem" assert sweep._native_sampler_model_for_decoder("tesseract") == "influence_dem" assert sweep._native_sampler_model_for_decoder("bp_osd") == "influence_dem" +def test_create_dem_decoder_supports_correlated_pymatching(sweep: ModuleType) -> None: + """The explicit correlated PyMatching option should construct a decoder.""" + decoder = sweep._create_dem_decoder("pymatching_correlated", "error(0.1) D0 ^ D1") + + assert "PyMatchingDecoder" in repr(decoder) + + # --------------------------------------------------------------------------- # Per-round rate fit # --------------------------------------------------------------------------- From c722969da7b477d9a11c2e7318899e8dd84560a0 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 11:31:58 -0600 Subject: [PATCH 083/150] Add DEM decomposition diagnostics --- .../surface/dem_decomposition_diagnostics.py | 498 ++++++++++++++++++ 1 file changed, 498 insertions(+) create mode 100644 examples/surface/dem_decomposition_diagnostics.py diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py new file mode 100644 index 000000000..e558dc791 --- /dev/null +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -0,0 +1,498 @@ +"""Diagnose raw DEMs and graphlike decompositions for traced-QIS surface circuits. + +The script keeps sampling fixed: each case samples once from the exact native +influence-model DEM, then decodes the same detector events with several decoder +views of the model. This separates raw DEM generation from graphlike +decomposition quality. + +Example: + uv run python examples/surface/dem_decomposition_diagnostics.py \\ + --distances 3 5 --bases X Z --interaction-bases cx szz \\ + --p 0.006 --shots 10000 --tesseract-beams 5 20 +""" + +from __future__ import annotations + +import argparse +import json +import re +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +import numpy as np + +ERROR_RE = re.compile(r"error\(([^)]+)\)\s*(.*)") +DET_RE = re.compile(r"\bD(\d+)\b") +OBS_RE = re.compile(r"\bL(\d+)\b") + + +@dataclass(frozen=True) +class DemStats: + error_lines: int + probability_sum: float + separator_lines: int + hyperedge_lines: int + logical_lines: int + max_component_detectors: int + max_line_detectors: int + pure_logical_components: int + + +@dataclass(frozen=True) +class RawDemComparison: + native_errors: int + stim_errors: int + only_native: int + only_stim: int + common: int + max_abs_probability_diff: float + max_rel_probability_diff: float + l1_probability_diff: float + + +@dataclass(frozen=True) +class DecodeSummary: + decoder: str + logical_errors: int + logical_error_rate: float + elapsed_s: float + + +@dataclass(frozen=True) +class CaseResult: + distance: int + rounds: int + basis: str + interaction_basis: str + p: float + shots: int + raw_comparison: RawDemComparison + dem_stats: dict[str, DemStats] + decoders: list[DecodeSummary] + + +def _combine_independent_probabilities(left: float, right: float) -> float: + """Combine independent mechanisms with the same XOR effect.""" + return left * (1.0 - right) + right * (1.0 - left) + + +def _toggle(values: set[int], value: int) -> None: + if value in values: + values.remove(value) + else: + values.add(value) + + +def _canonical_effect_key(targets: str) -> str: + """Canonicalize DEM targets by XORing all ``^`` components.""" + detectors: set[int] = set() + observables: set[int] = set() + for component in targets.split("^"): + for detector in DET_RE.findall(component): + _toggle(detectors, int(detector)) + for observable in OBS_RE.findall(component): + _toggle(observables, int(observable)) + tokens = [f"D{det}" for det in sorted(detectors)] + tokens.extend(f"L{obs}" for obs in sorted(observables)) + return " ".join(tokens) + + +def dem_effect_probabilities(dem_text: str) -> dict[str, float]: + """Aggregate DEM error probabilities by combined detector/observable effect.""" + effects: dict[str, float] = {} + for line in dem_text.splitlines(): + match = ERROR_RE.match(line.strip()) + if not match: + continue + probability = float(match.group(1)) + key = _canonical_effect_key(match.group(2)) + if not key: + continue + effects[key] = _combine_independent_probabilities(effects.get(key, 0.0), probability) + return effects + + +def compare_raw_dems(native_dem: str, stim_dem: str) -> RawDemComparison: + """Compare raw native and Stim DEMs after aggregating duplicate effects.""" + native = dem_effect_probabilities(native_dem) + stim = dem_effect_probabilities(stim_dem) + native_keys = set(native) + stim_keys = set(stim) + common = native_keys & stim_keys + + max_abs = 0.0 + max_rel = 0.0 + l1 = 0.0 + for key in common: + diff = abs(native[key] - stim[key]) + max_abs = max(max_abs, diff) + max_rel = max(max_rel, diff / max(native[key], stim[key], 1e-18)) + l1 += diff + for key in native_keys - stim_keys: + l1 += native[key] + for key in stim_keys - native_keys: + l1 += stim[key] + + return RawDemComparison( + native_errors=len(native), + stim_errors=len(stim), + only_native=len(native_keys - stim_keys), + only_stim=len(stim_keys - native_keys), + common=len(common), + max_abs_probability_diff=max_abs, + max_rel_probability_diff=max_rel, + l1_probability_diff=l1, + ) + + +def dem_stats(dem_text: str) -> DemStats: + """Summarize the structure of a DEM string.""" + error_lines = 0 + probability_sum = 0.0 + separator_lines = 0 + hyperedge_lines = 0 + logical_lines = 0 + max_component_detectors = 0 + max_line_detectors = 0 + pure_logical_components = 0 + + for line in dem_text.splitlines(): + match = ERROR_RE.match(line.strip()) + if not match: + continue + error_lines += 1 + probability_sum += float(match.group(1)) + targets = match.group(2) + components = targets.split(" ^ ") + if len(components) > 1: + separator_lines += 1 + if "L" in targets: + logical_lines += 1 + + line_detectors = len(DET_RE.findall(targets)) + max_line_detectors = max(max_line_detectors, line_detectors) + if line_detectors > 2: + hyperedge_lines += 1 + + for component in components: + component_detectors = len(DET_RE.findall(component)) + component_observables = len(OBS_RE.findall(component)) + max_component_detectors = max(max_component_detectors, component_detectors) + if component_detectors == 0 and component_observables > 0: + pure_logical_components += 1 + + return DemStats( + error_lines=error_lines, + probability_sum=probability_sum, + separator_lines=separator_lines, + hyperedge_lines=hyperedge_lines, + logical_lines=logical_lines, + max_component_detectors=max_component_detectors, + max_line_detectors=max_line_detectors, + pure_logical_components=pure_logical_components, + ) + + +def strip_logical_observable_lines(dem_text: str) -> str: + return "\n".join(line for line in dem_text.splitlines() if not line.startswith("logical_observable")) + + +def true_observable_flips(observable_flips: np.ndarray) -> np.ndarray: + if observable_flips.ndim == 1: + return observable_flips.astype(np.uint8) + if observable_flips.shape[1] == 0: + return np.zeros(observable_flips.shape[0], dtype=np.uint8) + return observable_flips[:, 0].astype(np.uint8) + + +def decode_with_tesseract( + dem_text: str, + detection_events: np.ndarray, + observable_flips: np.ndarray, + *, + beam: int, +) -> int: + from pecos.decoders import TesseractDecoder + + decoder = TesseractDecoder.from_dem( + strip_logical_observable_lines(dem_text), + preset="fast", + det_beam=beam, + ) + expected = true_observable_flips(observable_flips) + syndromes = [detection_events[index].astype(np.uint8).tolist() for index in range(len(detection_events))] + results = decoder.decode_batch(syndromes) + return sum(int(int(result.observables_mask & 1) != expected[index]) for index, result in enumerate(results)) + + +def decode_with_pymatching( + dem_text: str, + detection_events: np.ndarray, + observable_flips: np.ndarray, + *, + correlated: bool, +) -> int: + from pecos.decoders import PyMatchingDecoder + + if correlated: + decoder = PyMatchingDecoder.from_dem_with_correlations(dem_text, enable_correlations=True) + else: + decoder = PyMatchingDecoder.from_dem(dem_text) + + expected = true_observable_flips(observable_flips) + predictions = decoder.decode_batch( + detection_events.astype(np.uint8).flatten().tolist(), + len(detection_events), + ) + predicted = np.array([prediction[0] if prediction else 0 for prediction in predictions], dtype=np.uint8) + return int(np.sum(predicted != expected)) + + +def _timed_decode(label: str, callback: Any, shots: int) -> DecodeSummary: + start = time.perf_counter() + errors = int(callback()) + elapsed = time.perf_counter() - start + return DecodeSummary( + decoder=label, + logical_errors=errors, + logical_error_rate=errors / shots if shots else 0.0, + elapsed_s=elapsed, + ) + + +def run_case( + *, + distance: int, + rounds: int, + basis: str, + interaction_basis: str, + p: float, + shots: int, + seed: int, + tesseract_beams: list[int], +) -> CaseResult: + from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler + from pecos.qec.surface.circuit_builder import ( + generate_dem_from_tick_circuit_via_stim, + normalize_traced_qis_tick_circuit, + ) + from pecos.qec.surface.decode import ( + _build_surface_tick_circuit_for_native_model, + generate_circuit_level_dem_from_builder, + ) + + patch = SurfacePatch.create(distance=distance) + noise = NoiseModel(p1=p / 30.0, p2=p, p_meas=p / 3.0, p_prep=p / 3.0) + noise_args = { + "p1": noise.p1, + "p2": noise.p2, + "p_meas": noise.p_meas, + "p_prep": noise.p_prep, + } + + tick_circuit = _build_surface_tick_circuit_for_native_model( + patch, + rounds, + basis, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ) + normalize_traced_qis_tick_circuit(tick_circuit, context="DEM decomposition diagnostics") + + native_raw = generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=False, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ) + native_decomposed = generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=True, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ) + stim_raw = generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + **noise_args, + ) + stim_decomposed = generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=True, + **noise_args, + ) + + sampler = build_native_sampler( + patch, + rounds, + noise, + basis=basis, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + sampling_model="influence_dem", + ) + detection_events, observable_flips = sampler.sample(num_shots=shots, seed=seed) + + decoders = [ + _timed_decode( + f"native_raw_tesseract_b{beam}", + lambda beam=beam: decode_with_tesseract( + native_raw, + detection_events, + observable_flips, + beam=beam, + ), + shots, + ) + for beam in tesseract_beams + ] + decoders.extend( + [ + _timed_decode( + "native_decomp_pymatching", + lambda: decode_with_pymatching( + native_decomposed, + detection_events, + observable_flips, + correlated=False, + ), + shots, + ), + _timed_decode( + "native_decomp_pymatching_correlated", + lambda: decode_with_pymatching( + native_decomposed, + detection_events, + observable_flips, + correlated=True, + ), + shots, + ), + _timed_decode( + "stim_decomp_pymatching", + lambda: decode_with_pymatching( + stim_decomposed, + detection_events, + observable_flips, + correlated=False, + ), + shots, + ), + _timed_decode( + "stim_decomp_pymatching_correlated", + lambda: decode_with_pymatching( + stim_decomposed, + detection_events, + observable_flips, + correlated=True, + ), + shots, + ), + ], + ) + + return CaseResult( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=p, + shots=shots, + raw_comparison=compare_raw_dems(native_raw, stim_raw), + dem_stats={ + "native_raw": dem_stats(native_raw), + "native_decomposed": dem_stats(native_decomposed), + "stim_raw": dem_stats(stim_raw), + "stim_decomposed": dem_stats(stim_decomposed), + }, + decoders=decoders, + ) + + +def print_case(result: CaseResult) -> None: + print( + f"\n=== d={result.distance} r={result.rounds} basis={result.basis} " + f"basis2q={result.interaction_basis} p={result.p:g} shots={result.shots} ===", + ) + raw = result.raw_comparison + print( + "raw native vs Stim: " + f"native={raw.native_errors} stim={raw.stim_errors} " + f"only_native={raw.only_native} only_stim={raw.only_stim} " + f"max_rel={raw.max_rel_probability_diff:.3e} " + f"l1={raw.l1_probability_diff:.3e}", + ) + + print("DEM stats:") + print(" source errors psum sep hyper max_comp max_line pure_L") + for name, stats in result.dem_stats.items(): + print( + f" {name:<18} {stats.error_lines:6d} {stats.probability_sum:10.6f} " + f"{stats.separator_lines:5d} {stats.hyperedge_lines:5d} " + f"{stats.max_component_detectors:8d} {stats.max_line_detectors:8d} " + f"{stats.pure_logical_components:6d}", + ) + + print("Decode on identical raw influence samples:") + print(" decoder errors LER elapsed") + for summary in result.decoders: + print( + f" {summary.decoder:<36} {summary.logical_errors:6d} " + f"{summary.logical_error_rate:10.6f} {summary.elapsed_s:8.3f}s", + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--distances", nargs="+", type=int, default=[3, 5]) + parser.add_argument("--rounds", type=int, default=None, help="Rounds to use. Defaults to distance.") + parser.add_argument("--bases", nargs="+", choices=["X", "Z"], default=["X", "Z"]) + parser.add_argument("--interaction-bases", nargs="+", choices=["cx", "szz"], default=["cx", "szz"]) + parser.add_argument("--p", nargs="+", type=float, default=[0.006]) + parser.add_argument("--shots", type=int, default=10000) + parser.add_argument("--seed", type=int, default=20260613) + parser.add_argument("--tesseract-beams", nargs="+", type=int, default=[5]) + parser.add_argument("--save-json", type=Path, default=None) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + results: list[CaseResult] = [] + for distance in args.distances: + rounds = args.rounds if args.rounds is not None else distance + for basis in args.bases: + for interaction_basis in args.interaction_bases: + for p in args.p: + result = run_case( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=p, + shots=args.shots, + seed=args.seed, + tesseract_beams=args.tesseract_beams, + ) + results.append(result) + print_case(result) + + if args.save_json is not None: + payload = [asdict(result) for result in results] + args.save_json.parent.mkdir(parents=True, exist_ok=True) + args.save_json.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + print(f"\nWrote {args.save_json}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From cba0f05695ea092150a7daf2b9f96d3e5f388dc4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 11:35:12 -0600 Subject: [PATCH 084/150] Add two-fault DEM decomposition diagnostics --- .../surface/dem_decomposition_diagnostics.py | 186 ++++++++++++++++-- 1 file changed, 165 insertions(+), 21 deletions(-) diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py index e558dc791..d6ffb66b5 100644 --- a/examples/surface/dem_decomposition_diagnostics.py +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -60,6 +60,18 @@ class DecodeSummary: elapsed_s: float +@dataclass(frozen=True) +class PairAnalysisSummary: + decoder: str + pair_probability_mass: float + wrong_probability_mass: float + wrong_probability_fraction: float + disagree_tesseract_probability_mass: float + disagree_tesseract_probability_fraction: float + wrong_count: int + disagree_tesseract_count: int + + @dataclass(frozen=True) class CaseResult: distance: int @@ -71,6 +83,7 @@ class CaseResult: raw_comparison: RawDemComparison dem_stats: dict[str, DemStats] decoders: list[DecodeSummary] + pair_analysis: list[PairAnalysisSummary] | None def _combine_independent_probabilities(left: float, right: float) -> float: @@ -207,13 +220,24 @@ def true_observable_flips(observable_flips: np.ndarray) -> np.ndarray: return observable_flips[:, 0].astype(np.uint8) -def decode_with_tesseract( - dem_text: str, - detection_events: np.ndarray, - observable_flips: np.ndarray, - *, - beam: int, -) -> int: +def dense_effect_arrays(effects: dict[str, float]) -> tuple[list[str], np.ndarray, np.ndarray, np.ndarray]: + """Convert effect keys into dense detector rows and observable flips.""" + keys = list(effects) + probabilities = np.array([effects[key] for key in keys], dtype=float) + max_detector = max((int(detector) for key in keys for detector in DET_RE.findall(key)), default=-1) + detection_events = np.zeros((len(keys), max_detector + 1), dtype=np.uint8) + observable_flips = np.zeros(len(keys), dtype=np.uint8) + + for index, key in enumerate(keys): + detectors = [int(detector) for detector in DET_RE.findall(key)] + if detectors: + detection_events[index, detectors] = 1 + observable_flips[index] = 1 if "0" in OBS_RE.findall(key) else 0 + + return keys, probabilities, detection_events, observable_flips + + +def tesseract_predictions(dem_text: str, detection_events: np.ndarray, *, beam: int) -> np.ndarray: from pecos.decoders import TesseractDecoder decoder = TesseractDecoder.from_dem( @@ -221,32 +245,45 @@ def decode_with_tesseract( preset="fast", det_beam=beam, ) - expected = true_observable_flips(observable_flips) - syndromes = [detection_events[index].astype(np.uint8).tolist() for index in range(len(detection_events))] - results = decoder.decode_batch(syndromes) - return sum(int(int(result.observables_mask & 1) != expected[index]) for index, result in enumerate(results)) + 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) -def decode_with_pymatching( - dem_text: str, - detection_events: np.ndarray, - observable_flips: np.ndarray, - *, - correlated: bool, -) -> int: +def pymatching_predictions(dem_text: str, detection_events: np.ndarray, *, correlated: bool) -> np.ndarray: from pecos.decoders import PyMatchingDecoder if correlated: decoder = PyMatchingDecoder.from_dem_with_correlations(dem_text, enable_correlations=True) else: decoder = PyMatchingDecoder.from_dem(dem_text) - - expected = true_observable_flips(observable_flips) predictions = decoder.decode_batch( detection_events.astype(np.uint8).flatten().tolist(), len(detection_events), ) - predicted = np.array([prediction[0] if prediction else 0 for prediction in predictions], dtype=np.uint8) + return np.array([prediction[0] if prediction else 0 for prediction in predictions], dtype=np.uint8) + + +def decode_with_tesseract( + dem_text: str, + detection_events: np.ndarray, + observable_flips: np.ndarray, + *, + beam: int, +) -> int: + expected = true_observable_flips(observable_flips) + predicted = tesseract_predictions(dem_text, detection_events, beam=beam) + return int(np.sum(predicted != expected)) + + +def decode_with_pymatching( + dem_text: str, + detection_events: np.ndarray, + observable_flips: np.ndarray, + *, + correlated: bool, +) -> int: + expected = true_observable_flips(observable_flips) + predicted = pymatching_predictions(dem_text, detection_events, correlated=correlated) return int(np.sum(predicted != expected)) @@ -262,6 +299,82 @@ def _timed_decode(label: str, callback: Any, shots: int) -> DecodeSummary: ) +def two_fault_pair_analysis( + *, + native_raw: str, + native_decomposed: str, + stim_decomposed: str, + max_effects: int, +) -> list[PairAnalysisSummary] | None: + """Exhaustively compare decoders on all two-mechanism XOR combinations.""" + effects = dem_effect_probabilities(native_raw) + keys, probabilities, detection_events, observable_flips = dense_effect_arrays(effects) + if len(keys) > max_effects: + return None + + pair_rows: list[np.ndarray] = [] + pair_observables: list[int] = [] + pair_weights: list[float] = [] + for left in range(len(keys)): + for right in range(left + 1, len(keys)): + pair_rows.append(detection_events[left] ^ detection_events[right]) + pair_observables.append(int(observable_flips[left] ^ observable_flips[right])) + pair_weights.append(float(probabilities[left] * probabilities[right])) + + if not pair_rows: + return [] + + pair_detection_events = np.asarray(pair_rows, dtype=np.uint8) + pair_observable_flips = np.asarray(pair_observables, dtype=np.uint8) + weights = np.asarray(pair_weights, dtype=float) + total_weight = float(np.sum(weights)) + + predictions = { + "native_raw_tesseract_b5": tesseract_predictions(native_raw, pair_detection_events, beam=5), + "native_decomp_pymatching": pymatching_predictions( + native_decomposed, + pair_detection_events, + correlated=False, + ), + "native_decomp_pymatching_correlated": pymatching_predictions( + native_decomposed, + pair_detection_events, + correlated=True, + ), + "stim_decomp_pymatching": pymatching_predictions( + stim_decomposed, + pair_detection_events, + correlated=False, + ), + "stim_decomp_pymatching_correlated": pymatching_predictions( + stim_decomposed, + pair_detection_events, + correlated=True, + ), + } + reference = predictions["native_raw_tesseract_b5"] + + summaries = [] + for name, predicted in predictions.items(): + wrong = predicted != pair_observable_flips + disagree = predicted != reference + wrong_mass = float(np.sum(weights[wrong])) + disagree_mass = float(np.sum(weights[disagree])) + summaries.append( + PairAnalysisSummary( + decoder=name, + pair_probability_mass=total_weight, + wrong_probability_mass=wrong_mass, + wrong_probability_fraction=wrong_mass / total_weight if total_weight else 0.0, + disagree_tesseract_probability_mass=disagree_mass, + disagree_tesseract_probability_fraction=disagree_mass / total_weight if total_weight else 0.0, + wrong_count=int(np.sum(wrong)), + disagree_tesseract_count=int(np.sum(disagree)), + ), + ) + return summaries + + def run_case( *, distance: int, @@ -272,6 +385,8 @@ def run_case( shots: int, seed: int, tesseract_beams: list[int], + pair_analysis: bool, + pair_analysis_max_effects: int, ) -> CaseResult: from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler from pecos.qec.surface.circuit_builder import ( @@ -414,6 +529,14 @@ def run_case( "stim_decomposed": dem_stats(stim_decomposed), }, decoders=decoders, + pair_analysis=two_fault_pair_analysis( + native_raw=native_raw, + native_decomposed=native_decomposed, + stim_decomposed=stim_decomposed, + max_effects=pair_analysis_max_effects, + ) + if pair_analysis + else None, ) @@ -449,6 +572,19 @@ def print_case(result: CaseResult) -> None: f"{summary.logical_error_rate:10.6f} {summary.elapsed_s:8.3f}s", ) + if result.pair_analysis is None: + return + print("Exact two-fault analysis:") + print(" decoder wrong_mass wrong_frac disagree_mass disagree_frac") + for summary in result.pair_analysis: + print( + f" {summary.decoder:<36} " + f"{summary.wrong_probability_mass:10.6f} " + f"{summary.wrong_probability_fraction:10.4f} " + f"{summary.disagree_tesseract_probability_mass:13.6f} " + f"{summary.disagree_tesseract_probability_fraction:13.4f}", + ) + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) @@ -460,6 +596,12 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--shots", type=int, default=10000) parser.add_argument("--seed", type=int, default=20260613) parser.add_argument("--tesseract-beams", nargs="+", type=int, default=[5]) + parser.add_argument( + "--pair-analysis", + action="store_true", + help="Exhaustively compare decoders on all two-fault combinations when the effect count is small enough.", + ) + parser.add_argument("--pair-analysis-max-effects", type=int, default=400) parser.add_argument("--save-json", type=Path, default=None) return parser.parse_args() @@ -481,6 +623,8 @@ def main() -> int: shots=args.shots, seed=args.seed, tesseract_beams=args.tesseract_beams, + pair_analysis=args.pair_analysis, + pair_analysis_max_effects=args.pair_analysis_max_effects, ) results.append(result) print_case(result) From c64e79be858b8d6d75eb92d1e4f7ad2d562b0806 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 12:04:17 -0600 Subject: [PATCH 085/150] Add terminal graphlike DEM projection --- .../src/fault_tolerance/dem_builder/types.rs | 297 ++++++++++++++++++ .../surface/dem_decomposition_diagnostics.py | 155 +++++++++ .../surface/native_dem_threshold_sweep.py | 5 +- .../src/fault_tolerance_bindings.rs | 10 + .../src/pecos/qec/surface/decode.py | 98 ++++-- 5 files changed, 535 insertions(+), 30 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 84b824810..09b4762a4 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -5017,6 +5017,271 @@ impl DetectorErrorModel { lines.join("\n") } + fn detector_coordinate_map(&self) -> BTreeMap { + self.detectors + .iter() + .filter_map(|detector| detector.coords.map(|coords| (detector.id, coords))) + .collect() + } + + fn detector_coordinate_distance( + left: u32, + right: u32, + detector_coords: &BTreeMap, + ) -> f64 { + let left_coords = + detector_coords + .get(&left) + .copied() + .unwrap_or([f64::from(left), 0.0, 0.0]); + let right_coords = + detector_coords + .get(&right) + .copied() + .unwrap_or([f64::from(right), 0.0, 0.0]); + left_coords + .iter() + .zip(right_coords) + .map(|(a, b)| (*a - b).powi(2)) + .sum::() + .sqrt() + } + + fn min_coordinate_terminal_pairs( + detectors: &[u32], + detector_coords: &BTreeMap, + ) -> (Vec<(u32, u32)>, Vec) { + if detectors.len() > 20 { + return Self::greedy_coordinate_terminal_pairs(detectors, detector_coords); + } + + fn solve( + mask: u64, + detectors: &[u32], + detector_coords: &BTreeMap, + memo: &mut BTreeMap, Vec)>, + ) -> (f64, Vec<(u32, u32)>, Vec) { + if let Some(cached) = memo.get(&mask) { + return cached.clone(); + } + + let count = mask.count_ones(); + let result = if count == 0 { + (0.0, Vec::new(), Vec::new()) + } else if count == 1 { + let index = mask.trailing_zeros() as usize; + (0.0, Vec::new(), vec![detectors[index]]) + } else if count % 2 == 1 { + let mut best: Option<(f64, Vec<(u32, u32)>, Vec)> = None; + for index in 0..detectors.len() { + if mask & (1_u64 << index) == 0 { + continue; + } + let rest = mask & !(1_u64 << index); + let (cost, pairs, mut singles) = solve(rest, detectors, detector_coords, memo); + singles.push(detectors[index]); + if best + .as_ref() + .is_none_or(|(best_cost, _, _)| cost < *best_cost) + { + best = Some((cost, pairs, singles)); + } + } + best.expect("odd non-empty mask must have a singleton candidate") + } else { + let first = mask.trailing_zeros() as usize; + let rest_without_first = mask & !(1_u64 << first); + let mut best: Option<(f64, Vec<(u32, u32)>, Vec)> = None; + for second in first + 1..detectors.len() { + if rest_without_first & (1_u64 << second) == 0 { + continue; + } + let rest = rest_without_first & !(1_u64 << second); + let (sub_cost, mut pairs, singles) = + solve(rest, detectors, detector_coords, memo); + let pair = (detectors[first], detectors[second]); + let cost = sub_cost + + DetectorErrorModel::detector_coordinate_distance( + pair.0, + pair.1, + detector_coords, + ); + pairs.insert(0, pair); + if best + .as_ref() + .is_none_or(|(best_cost, _, _)| cost < *best_cost) + { + best = Some((cost, pairs, singles)); + } + } + best.expect("even mask with at least two bits must have a pair candidate") + }; + + memo.insert(mask, result.clone()); + result + } + + let mut memo = BTreeMap::new(); + let mask = (1_u64 << detectors.len()) - 1; + let (_, pairs, singles) = solve(mask, detectors, detector_coords, &mut memo); + (pairs, singles) + } + + fn greedy_coordinate_terminal_pairs( + detectors: &[u32], + detector_coords: &BTreeMap, + ) -> (Vec<(u32, u32)>, Vec) { + let mut remaining: BTreeSet = detectors.iter().copied().collect(); + let mut pairs = Vec::new(); + let mut singles = Vec::new(); + + if remaining.len() % 2 == 1 { + let singleton = remaining + .iter() + .copied() + .max_by(|left, right| { + let left_nearest = remaining + .iter() + .copied() + .filter(|candidate| candidate != left) + .map(|candidate| { + Self::detector_coordinate_distance(*left, candidate, detector_coords) + }) + .fold(f64::INFINITY, f64::min); + let right_nearest = remaining + .iter() + .copied() + .filter(|candidate| candidate != right) + .map(|candidate| { + Self::detector_coordinate_distance(*right, candidate, detector_coords) + }) + .fold(f64::INFINITY, f64::min); + left_nearest + .partial_cmp(&right_nearest) + .unwrap_or(Ordering::Equal) + }) + .expect("odd non-empty detector set should have a singleton"); + remaining.remove(&singleton); + singles.push(singleton); + } + + while let Some(left) = remaining.pop_first() { + let Some(right) = remaining.iter().copied().min_by(|a, b| { + let da = Self::detector_coordinate_distance(left, *a, detector_coords); + let db = Self::detector_coordinate_distance(left, *b, detector_coords); + da.partial_cmp(&db) + .unwrap_or(Ordering::Equal) + .then_with(|| a.cmp(b)) + }) else { + singles.push(left); + break; + }; + remaining.remove(&right); + pairs.push((left, right)); + } + + (pairs, singles) + } + + fn terminal_graphlike_parts( + effect: &FaultMechanism, + detector_coords: &BTreeMap, + ) -> Vec { + let (pairs, singles) = + Self::min_coordinate_terminal_pairs(&effect.detectors, detector_coords); + let mut parts: Vec = pairs + .into_iter() + .map(|(left, right)| FaultMechanism::from_unsorted([left, right], [])) + .collect(); + parts.extend( + singles + .into_iter() + .map(|detector| FaultMechanism::from_unsorted([detector], [])), + ); + + if parts.is_empty() { + if !effect.dem_outputs.is_empty() { + parts.push(FaultMechanism::from_unsorted( + std::iter::empty(), + effect.dem_outputs.iter().copied(), + )); + } + } else if !effect.dem_outputs.is_empty() { + let last = parts + .last_mut() + .expect("non-empty parts checked before attaching observables"); + last.dem_outputs = effect.dem_outputs.clone(); + } + + parts + } + + /// Converts the DEM to a terminal-only graphlike projection. + /// + /// Contributions are first grouped into the same raw mechanisms as + /// [`Self::to_string`]. Each grouped effect is then rendered as graphlike + /// components whose XOR is exactly the original detector/observable effect. + /// Pair components use only detectors present in the raw effect. Ordinary + /// low-weight effects use the exact minimum-total-distance pairing from + /// detector coordinates; unusually large effects use a deterministic + /// nearest-neighbor fallback to avoid exponential render time. This is a + /// decoder-facing projection for graph matchers; it is not source proof. + #[must_use] + pub fn to_string_terminal_graphlike_decomposed(&self) -> String { + let mut lines = Vec::new(); + + for det in &self.detectors { + if let Some([x, y, z]) = det.coords { + lines.push(format!("detector({x}, {y}, {z}) D{}", det.id)); + } else { + lines.push(format!("detector D{}", det.id)); + } + } + + for obs in &self.observables { + lines.push(format!("logical_observable L{}", obs.id)); + } + + let mut by_effect: BTreeMap = BTreeMap::new(); + for contrib in &self.contributions { + by_effect + .entry(contrib.effect.standard_effect()) + .and_modify(|p| *p = combine_independent_probs(*p, contrib.probability)) + .or_insert(contrib.probability); + } + + let detector_coords = self.detector_coordinate_map(); + let mut by_targets: BTreeMap = BTreeMap::new(); + for (effect, total_prob) in by_effect { + if effect.is_standard_empty() || total_prob <= 0.0 { + continue; + } + + let targets = Self::format_decomposed_parts(Self::terminal_graphlike_parts( + &effect, + &detector_coords, + )); + if !targets.is_empty() { + by_targets + .entry(targets) + .and_modify(|p| *p = combine_independent_probs(*p, total_prob)) + .or_insert(total_prob); + } + } + + for (targets, total_prob) in by_targets { + if !targets.is_empty() && total_prob > 0.0 { + lines.push(format!( + "error({}) {}", + format_probability(total_prob), + targets + )); + } + } + + lines.join("\n") + } + fn collect_singleton_index(&self) -> SingletonDecompositionIndex { SingletonDecompositionIndex::from_contributions(&self.contributions) } @@ -6907,6 +7172,38 @@ mod tests { assert!(!maximal.contains("error(0.01) D0 D1")); } + #[test] + fn test_terminal_graphlike_decomposed_uses_min_coordinate_terminal_pairs() { + let mut dem = DetectorErrorModel::new(); + + dem.add_detector(DetectorDef::new(0).with_coords([0.0, 0.0, 0.0])); + dem.add_detector(DetectorDef::new(1).with_coords([10.0, 0.0, 0.0])); + dem.add_detector(DetectorDef::new(2).with_coords([1.0, 0.0, 0.0])); + dem.add_detector(DetectorDef::new(3).with_coords([11.0, 0.0, 0.0])); + dem.add_direct_contribution(FaultMechanism::from_unsorted([0, 1, 2, 3], []), 0.01); + + let projected = dem.to_string_terminal_graphlike_decomposed(); + + assert!(projected.contains("error(0.01) D0 D2 ^ D1 D3")); + assert!(!projected.contains("D0 D1 ^ D2 D3")); + } + + #[test] + fn test_terminal_graphlike_decomposed_attaches_observable_to_terminal_component() { + let mut dem = DetectorErrorModel::new(); + + dem.add_detector(DetectorDef::new(0).with_coords([0.0, 0.0, 0.0])); + dem.add_detector(DetectorDef::new(1).with_coords([1.0, 0.0, 0.0])); + dem.add_detector(DetectorDef::new(2).with_coords([10.0, 0.0, 0.0])); + dem.add_dem_output(DemOutput::new(0)); + dem.add_direct_contribution(FaultMechanism::from_unsorted([0, 1, 2], [0]), 0.01); + + let projected = dem.to_string_terminal_graphlike_decomposed(); + + assert!(projected.contains("logical_observable L0")); + assert!(projected.contains("error(0.01) D0 D1 ^ D2 L0")); + } + #[test] fn test_contribution_effect_summaries_include_graphlike_decomposable_count() { let mut dem = DetectorErrorModel::new(); diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py index d6ffb66b5..f703cb9eb 100644 --- a/examples/surface/dem_decomposition_diagnostics.py +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -15,6 +15,7 @@ import argparse import json +import math import re import time from dataclasses import asdict, dataclass @@ -26,6 +27,7 @@ ERROR_RE = re.compile(r"error\(([^)]+)\)\s*(.*)") DET_RE = re.compile(r"\bD(\d+)\b") OBS_RE = re.compile(r"\bL(\d+)\b") +DETECTOR_COORD_RE = re.compile(r"detector\(([^)]*)\) D(\d+)") @dataclass(frozen=True) @@ -160,6 +162,111 @@ def compare_raw_dems(native_dem: str, stim_dem: str) -> RawDemComparison: ) +def parse_detector_coords(dem_text: str) -> dict[int, tuple[float, ...]]: + """Parse detector coordinate annotations from DEM text.""" + coords: dict[int, tuple[float, ...]] = {} + for line in dem_text.splitlines(): + match = DETECTOR_COORD_RE.match(line.strip()) + if not match: + continue + values = tuple(float(value.strip()) for value in match.group(1).split(",") if value.strip()) + coords[int(match.group(2))] = values + return coords + + +def detector_coord_distance( + left: int, + right: int, + coords: dict[int, tuple[float, ...]], +) -> float: + """Coordinate distance with detector-id fallback for missing annotations.""" + left_coords = coords.get(left, (float(left),)) + right_coords = coords.get(right, (float(right),)) + dims = max(len(left_coords), len(right_coords)) + left_coords = left_coords + (0.0,) * (dims - len(left_coords)) + right_coords = right_coords + (0.0,) * (dims - len(right_coords)) + return math.sqrt(sum((a - b) ** 2 for a, b in zip(left_coords, right_coords, strict=True))) + + +def min_coord_terminal_pairs( + detectors: tuple[int, ...], + coords: dict[int, tuple[float, ...]], +) -> tuple[list[tuple[int, int]], list[int]]: + """Pair terminals by minimum coordinate distance, leaving one singleton if odd.""" + if len(detectors) <= 1: + return [], list(detectors) + if len(detectors) == 2: + return [(detectors[0], detectors[1])], [] + + best_cost: float | None = None + best_pairs: list[tuple[int, int]] = [] + best_singles: list[int] = [] + for left_index, left in enumerate(detectors): + for right in detectors[left_index + 1 :]: + rest = tuple(detector for detector in detectors if detector not in {left, right}) + pairs, singles = min_coord_terminal_pairs(rest, coords) + pairs = [(left, right), *pairs] + cost = sum(detector_coord_distance(a, b, coords) for a, b in pairs) + if best_cost is None or cost < best_cost: + best_cost = cost + best_pairs = pairs + best_singles = singles + return best_pairs, best_singles + + +def terminal_graphlike_projection(raw_dem: str) -> str: + """Project raw DEM effects into minimum-span terminal-only graphlike pieces. + + Each raw mechanism keeps its combined detector/observable effect exactly, + but the rendered decomposition uses only detectors present in that raw + effect. This avoids cancellation/path detectors introduced by graph-path + decompositions while still producing graphlike components for matching + decoders. + """ + coords = parse_detector_coords(raw_dem) + annotation_lines: list[str] = [] + by_targets: dict[str, float] = {} + + for line in raw_dem.splitlines(): + stripped = line.strip() + if stripped.startswith(("detector", "logical_observable")): + annotation_lines.append(line) + continue + + match = ERROR_RE.match(stripped) + if not match: + continue + probability = float(match.group(1)) + effect = _canonical_effect_key(match.group(2)) + detectors = tuple(sorted(int(detector) for detector in DET_RE.findall(effect))) + observables = sorted(int(observable) for observable in OBS_RE.findall(effect)) + pairs, singles = min_coord_terminal_pairs(detectors, coords) + + components = [f"D{left} D{right}" for left, right in pairs] + components.extend(f"D{detector}" for detector in singles) + if not components and observables: + # PyMatching cannot use a pure logical component. Preserve the raw + # effect so construction fails instead of silently changing it. + components = [f"L{observable}" for observable in observables] + else: + for observable in observables: + components[-1] = f"{components[-1]} L{observable}" + + rendered_targets = " ^ ".join(components) + if rendered_targets: + by_targets[rendered_targets] = _combine_independent_probabilities( + by_targets.get(rendered_targets, 0.0), + probability, + ) + + rendered_lines = [ + f"error({probability:.16g}) {targets}" + for targets, probability in sorted(by_targets.items()) + if probability > 0.0 + ] + return "\n".join([*annotation_lines, *rendered_lines]) + + def dem_stats(dem_text: str) -> DemStats: """Summarize the structure of a DEM string.""" error_lines = 0 @@ -304,6 +411,7 @@ def two_fault_pair_analysis( native_raw: str, native_decomposed: str, stim_decomposed: str, + terminal_decomposed: str, max_effects: int, ) -> list[PairAnalysisSummary] | None: """Exhaustively compare decoders on all two-mechanism XOR combinations.""" @@ -351,6 +459,16 @@ def two_fault_pair_analysis( pair_detection_events, correlated=True, ), + "terminal_decomp_pymatching": pymatching_predictions( + terminal_decomposed, + pair_detection_events, + correlated=False, + ), + "terminal_decomp_pymatching_correlated": pymatching_predictions( + terminal_decomposed, + pair_detection_events, + correlated=True, + ), } reference = predictions["native_raw_tesseract_b5"] @@ -434,6 +552,21 @@ def run_case( circuit_source="traced_qis", interaction_basis=interaction_basis, ) + try: + terminal_decomposed = generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=True, + dem_decomposition="terminal_graphlike", + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ) + except RuntimeError as exc: + if "terminal graphlike" not in str(exc): + raise + terminal_decomposed = terminal_graphlike_projection(native_raw) stim_raw = generate_dem_from_tick_circuit_via_stim( tick_circuit, decompose_errors=False, @@ -511,6 +644,26 @@ def run_case( ), shots, ), + _timed_decode( + "terminal_decomp_pymatching", + lambda: decode_with_pymatching( + terminal_decomposed, + detection_events, + observable_flips, + correlated=False, + ), + shots, + ), + _timed_decode( + "terminal_decomp_pymatching_correlated", + lambda: decode_with_pymatching( + terminal_decomposed, + detection_events, + observable_flips, + correlated=True, + ), + shots, + ), ], ) @@ -527,12 +680,14 @@ def run_case( "native_decomposed": dem_stats(native_decomposed), "stim_raw": dem_stats(stim_raw), "stim_decomposed": dem_stats(stim_decomposed), + "terminal_decomposed": dem_stats(terminal_decomposed), }, decoders=decoders, pair_analysis=two_fault_pair_analysis( native_raw=native_raw, native_decomposed=native_decomposed, stim_decomposed=stim_decomposed, + terminal_decomposed=terminal_decomposed, max_effects=pair_analysis_max_effects, ) if pair_analysis diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index a1f64518f..567c665e0 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -3736,11 +3736,12 @@ def _parse_args() -> argparse.Namespace: ) parser.add_argument( "--dem-mode", - choices=["native_decomposed", "native_full"], + choices=["native_decomposed", "native_full", "native_terminal_graphlike"], default="native_decomposed", help=( "PECOS native DEM mode. Graph decoders such as PyMatching require " - "native_decomposed; raw-DEM decoders should use native_full." + "native_decomposed or native_terminal_graphlike; raw-DEM decoders " + "should use native_full." ), ) parser.add_argument( diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index a863414f2..3d7c68d0c 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -1646,6 +1646,16 @@ impl PyDetectorErrorModel { self.inner.to_string_source_graphlike_decomposed() } + /// Convert the DEM to a terminal-only graphlike projection. + /// + /// Raw mechanisms are first grouped exactly as in `to_string()`. Each raw + /// effect is then projected to graphlike terminal components using detector + /// coordinates. This is a decoder-facing representation for graph matchers, + /// not source-proof decomposition. + fn to_string_terminal_graphlike_decomposed(&self) -> String { + self.inner.to_string_terminal_graphlike_decomposed() + } + /// Convert the DEM using the explicit historical graphlike-search renderer. /// /// This may decompose residual hyperedges by searching for graphlike diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 8e2bd2765..fd0a184af 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -61,6 +61,8 @@ P1Weights = Mapping[str, float] | Sequence[tuple[str, float]] P2Weights = Mapping[str, float] | Sequence[tuple[str, float]] +NativeDemDecomposition = Literal["source_graphlike", "terminal_graphlike"] +CircuitLevelDemMode = Literal["native_full", "native_decomposed", "native_terminal_graphlike"] def _validate_probability(name: str, value: float) -> float: @@ -1834,6 +1836,7 @@ def _dem_string_from_cached_surface_topology( noise: NoiseModel, *, decompose_errors: bool, + dem_decomposition: NativeDemDecomposition = "source_graphlike", ) -> str: """Build a DEM string from cached topology and fresh noise parameters.""" from pecos.qec import DemBuilder @@ -1858,10 +1861,19 @@ def _dem_string_from_cached_surface_topology( ) if not decompose_errors: return dem.to_string() - source_graphlike = getattr(dem, "to_string_source_graphlike_decomposed", None) - if source_graphlike is not None: - return source_graphlike() - return dem.to_string_decomposed() + if dem_decomposition == "source_graphlike": + source_graphlike = getattr(dem, "to_string_source_graphlike_decomposed", None) + if source_graphlike is not None: + return source_graphlike() + return dem.to_string_decomposed() + if dem_decomposition == "terminal_graphlike": + terminal_graphlike = getattr(dem, "to_string_terminal_graphlike_decomposed", None) + if terminal_graphlike is None: + msg = "This pecos_rslib build does not support terminal graphlike DEM decomposition" + raise RuntimeError(msg) + return terminal_graphlike() + msg = f"Unknown native DEM decomposition mode {dem_decomposition!r}" + raise ValueError(msg) @cache @@ -1879,6 +1891,7 @@ def _cached_surface_native_dem_string( p_meas: float, p_prep: float, decompose_errors: bool, + dem_decomposition: NativeDemDecomposition = "source_graphlike", p2_weights: tuple[tuple[str, float], ...] | None = None, p2_replacement_approximation: str | None = None, p_idle: float | None = None, @@ -1962,6 +1975,7 @@ def _cached_surface_native_dem_string( p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, ), decompose_errors=decompose_errors, + dem_decomposition=dem_decomposition, ) @@ -2033,6 +2047,7 @@ def generate_circuit_level_dem_from_builder( basis: str = "Z", *, decompose_errors: bool = False, + dem_decomposition: NativeDemDecomposition = "source_graphlike", ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, @@ -2060,6 +2075,11 @@ def generate_circuit_level_dem_from_builder( decomposition preserves correlated mechanism metadata with ``^`` separators, but graph decoders may still approximate hyperedge correlations. + dem_decomposition: Which native graphlike projection to use when + ``decompose_errors=True``. ``"source_graphlike"`` preserves the + existing source-informed decomposition. ``"terminal_graphlike"`` + groups raw mechanisms first, then pairs only detector terminals + present in each raw effect by coordinate distance. ancilla_budget: Optional cap on simultaneously live ancillas. When provided below the total stabilizer count, the native DEM is built from the same batched ancilla-reuse circuit family used by Guppy. @@ -2113,8 +2133,33 @@ def generate_circuit_level_dem_from_builder( topology, noise, decompose_errors=decompose_errors, + dem_decomposition=dem_decomposition, ) + cache_kwargs = { + "p2_weights": noise.p2_weights, + "p2_replacement_approximation": noise.p2_replacement_approximation, + "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, + "twirl": twirl, + "interaction_basis": interaction_basis, + } + if dem_decomposition != "source_graphlike": + cache_kwargs["dem_decomposition"] = dem_decomposition + return _cached_surface_native_dem_string( patch_key, num_rounds, @@ -2129,25 +2174,7 @@ def generate_circuit_level_dem_from_builder( noise.p_meas, noise.p_prep, decompose_errors=decompose_errors, - p2_weights=noise.p2_weights, - p2_replacement_approximation=noise.p2_replacement_approximation, - 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, - twirl=twirl, - interaction_basis=interaction_basis, + **cache_kwargs, ) @@ -2523,7 +2550,7 @@ def __init__( ] = "pymatching", *, use_circuit_level_dem: bool = True, - circuit_level_dem_mode: Literal["native_full", "native_decomposed"] = "native_full", + circuit_level_dem_mode: CircuitLevelDemMode = "native_full", circuit_level_dem_source: Literal["abstract", "traced_qis"] = "abstract", ancilla_budget: int | None = None, interaction_basis: str = "cx", @@ -2549,9 +2576,11 @@ def __init__( circuit_level_dem_mode: Which PECOS-native DEM representation to use when circuit-level DEMs are enabled. ``"native_full"`` preserves the current non-decomposed DEM output. ``"native_decomposed"`` - returns PECOS's graphlike decomposed DEM output for graph - decoders such as PyMatching. This is a decoder-facing - approximation of hyperedge correlations, not an exact raw DEM. + returns the source-informed graphlike output for graph decoders. + ``"native_terminal_graphlike"`` first groups raw mechanisms, + then projects each mechanism onto graphlike terminal components. + Decomposed modes are decoder-facing approximations of hyperedge + correlations, not exact raw DEMs. circuit_level_dem_source: Which ideal circuit to analyze when building native circuit-level DEMs. ``"abstract"`` uses the high-level surface TickCircuit, while ``"traced_qis"`` traces @@ -2570,6 +2599,13 @@ def __init__( self.noise = noise or NoiseModel(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 { + "native_full", + "native_decomposed", + "native_terminal_graphlike", + }: + msg = f"Unknown circuit_level_dem_mode {circuit_level_dem_mode!r}" + raise ValueError(msg) self.circuit_level_dem_mode = circuit_level_dem_mode self.circuit_level_dem_source = circuit_level_dem_source self.ancilla_budget = ancilla_budget @@ -2602,12 +2638,18 @@ def _get_circuit_level_dem(self, basis: str) -> str: Returns: DEM string in Stim format """ + dem_decomposition: NativeDemDecomposition = ( + "terminal_graphlike" + if self.circuit_level_dem_mode == "native_terminal_graphlike" + else "source_graphlike" + ) dem = generate_circuit_level_dem_from_builder( self.patch, self.num_rounds, self.noise, basis=basis, - decompose_errors=self.circuit_level_dem_mode == "native_decomposed", + decompose_errors=self.circuit_level_dem_mode != "native_full", + dem_decomposition=dem_decomposition, circuit_source=self.circuit_level_dem_source, ancilla_budget=self.ancilla_budget, interaction_basis=self.interaction_basis, From 6cbbadb38e563a2491d1d93eeb1e5267df6bdb54 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 13:00:40 -0600 Subject: [PATCH 086/150] Add graphlike DEM diagnostic filters --- .../surface/dem_decomposition_diagnostics.py | 135 ++++++++++-------- .../tests/qec/surface/test_surface_decoder.py | 31 ++++ 2 files changed, 108 insertions(+), 58 deletions(-) diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py index f703cb9eb..486d92b41 100644 --- a/examples/surface/dem_decomposition_diagnostics.py +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -28,6 +28,14 @@ DET_RE = re.compile(r"\bD(\d+)\b") OBS_RE = re.compile(r"\bL(\d+)\b") DETECTOR_COORD_RE = re.compile(r"detector\(([^)]*)\) D(\d+)") +GRAPHLIKE_DECODER_CHOICES = [ + "native_decomp_pymatching", + "native_decomp_pymatching_correlated", + "stim_decomp_pymatching", + "stim_decomp_pymatching_correlated", + "terminal_decomp_pymatching", + "terminal_decomp_pymatching_correlated", +] @dataclass(frozen=True) @@ -503,6 +511,7 @@ def run_case( shots: int, seed: int, tesseract_beams: list[int], + decoder_names: set[str], pair_analysis: bool, pair_analysis_max_effects: int, ) -> CaseResult: @@ -602,69 +611,66 @@ def run_case( ) for beam in tesseract_beams ] - decoders.extend( - [ - _timed_decode( - "native_decomp_pymatching", - lambda: decode_with_pymatching( - native_decomposed, - detection_events, - observable_flips, - correlated=False, - ), - shots, + graphlike_decoder_specs = [ + ( + "native_decomp_pymatching", + lambda: decode_with_pymatching( + native_decomposed, + detection_events, + observable_flips, + correlated=False, ), - _timed_decode( - "native_decomp_pymatching_correlated", - lambda: decode_with_pymatching( - native_decomposed, - detection_events, - observable_flips, - correlated=True, - ), - shots, + ), + ( + "native_decomp_pymatching_correlated", + lambda: decode_with_pymatching( + native_decomposed, + detection_events, + observable_flips, + correlated=True, ), - _timed_decode( - "stim_decomp_pymatching", - lambda: decode_with_pymatching( - stim_decomposed, - detection_events, - observable_flips, - correlated=False, - ), - shots, + ), + ( + "stim_decomp_pymatching", + lambda: decode_with_pymatching( + stim_decomposed, + detection_events, + observable_flips, + correlated=False, ), - _timed_decode( - "stim_decomp_pymatching_correlated", - lambda: decode_with_pymatching( - stim_decomposed, - detection_events, - observable_flips, - correlated=True, - ), - shots, + ), + ( + "stim_decomp_pymatching_correlated", + lambda: decode_with_pymatching( + stim_decomposed, + detection_events, + observable_flips, + correlated=True, ), - _timed_decode( - "terminal_decomp_pymatching", - lambda: decode_with_pymatching( - terminal_decomposed, - detection_events, - observable_flips, - correlated=False, - ), - shots, + ), + ( + "terminal_decomp_pymatching", + lambda: decode_with_pymatching( + terminal_decomposed, + detection_events, + observable_flips, + correlated=False, ), - _timed_decode( - "terminal_decomp_pymatching_correlated", - lambda: decode_with_pymatching( - terminal_decomposed, - detection_events, - observable_flips, - correlated=True, - ), - shots, + ), + ( + "terminal_decomp_pymatching_correlated", + lambda: decode_with_pymatching( + terminal_decomposed, + detection_events, + observable_flips, + correlated=True, ), - ], + ), + ] + decoders.extend( + _timed_decode(name, callback, shots) + for name, callback in graphlike_decoder_specs + if name in decoder_names ) return CaseResult( @@ -751,6 +757,18 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--shots", type=int, default=10000) parser.add_argument("--seed", type=int, default=20260613) parser.add_argument("--tesseract-beams", nargs="+", type=int, default=[5]) + parser.add_argument( + "--skip-tesseract", + action="store_true", + help="Skip raw-DEM Tesseract decoding for larger graphlike-only sampled comparisons.", + ) + parser.add_argument( + "--decoders", + nargs="+", + choices=GRAPHLIKE_DECODER_CHOICES, + default=GRAPHLIKE_DECODER_CHOICES, + help="Graphlike decoder variants to run in sampled comparisons.", + ) parser.add_argument( "--pair-analysis", action="store_true", @@ -777,7 +795,8 @@ def main() -> int: p=p, shots=args.shots, seed=args.seed, - tesseract_beams=args.tesseract_beams, + tesseract_beams=[] if args.skip_tesseract else args.tesseract_beams, + decoder_names=set(args.decoders), pair_analysis=args.pair_analysis, pair_analysis_max_effects=args.pair_analysis_max_effects, ) 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 e456d689c..f0956ddc3 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -262,6 +262,37 @@ def wrapped_generate(*_args: object, **kwargs: object) -> str: assert decoder.get_dem("Z", circuit_level=True) == "error(0.01) D0\n" assert seen["interaction_basis"] == "szz" + def test_get_dem_passes_terminal_graphlike_mode_to_native_builder( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The terminal graphlike mode should select the terminal DEM projection.""" + import pecos.qec.surface.decode as decode_module + + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p2=0.01, p_meas=0.01) + seen: dict[str, object] = {} + + def wrapped_generate(*_args: object, **kwargs: object) -> str: + seen["decompose_errors"] = kwargs.get("decompose_errors") + seen["dem_decomposition"] = kwargs.get("dem_decomposition") + return "error(0.01) D0\n" + + monkeypatch.setattr(decode_module, "generate_circuit_level_dem_from_builder", wrapped_generate) + + decoder = SurfaceDecoder( + patch, + num_rounds=3, + noise=noise, + circuit_level_dem_mode="native_terminal_graphlike", + ) + + assert decoder.get_dem("Z", circuit_level=True) == "error(0.01) D0\n" + assert seen == { + "decompose_errors": True, + "dem_decomposition": "terminal_graphlike", + } + def test_decode_trivial_syndrome_z(self) -> None: """Decode trivial Z syndrome (no errors).""" patch = SurfacePatch.create(distance=3) From fc8db9223be46042db2396be6f7b5e01b011ee0e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 13:15:27 -0600 Subject: [PATCH 087/150] Add graphlike DEM projection benchmark --- .../graphlike_dem_projection_benchmark.py | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 examples/surface/graphlike_dem_projection_benchmark.py diff --git a/examples/surface/graphlike_dem_projection_benchmark.py b/examples/surface/graphlike_dem_projection_benchmark.py new file mode 100644 index 000000000..fc1c4f71c --- /dev/null +++ b/examples/surface/graphlike_dem_projection_benchmark.py @@ -0,0 +1,325 @@ +"""Benchmark graphlike DEM projections on fixed traced-QIS surface-code samples. + +This is a narrower companion to ``dem_decomposition_diagnostics.py``. It builds +each DEM view once, samples once from the exact native influence model, then +times correlated PyMatching construction and batch decoding for the selected +graphlike projections. +""" + +from __future__ import annotations + +import argparse +import json +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +import numpy as np +from dem_decomposition_diagnostics import ( + compare_raw_dems, + dem_stats, + terminal_graphlike_projection, + true_observable_flips, +) + + +@dataclass(frozen=True) +class TimedValue: + label: str + elapsed_s: float + + +@dataclass(frozen=True) +class VariantResult: + variant: str + dem_stats: dict[str, Any] + dem_build_s: float + decoder_build_s: float + decode_s: float + logical_errors: int + logical_error_rate: float + + +@dataclass(frozen=True) +class BenchmarkResult: + distance: int + rounds: int + basis: str + interaction_basis: str + p: float + shots: int + setup_timings: list[TimedValue] + raw_comparison: dict[str, Any] + variants: list[VariantResult] + + +def timed(label: str, callback: Any) -> tuple[Any, float]: + print(f"[start] {label}", flush=True) + start = time.perf_counter() + value = callback() + elapsed = time.perf_counter() - start + print(f"[done] {label}: {elapsed:.3f}s", flush=True) + return value, elapsed + + +def decode_with_correlated_pymatching( + dem_text: str, + detection_events: np.ndarray, + observable_flips: np.ndarray, +) -> tuple[int, float, float]: + from pecos.decoders import PyMatchingDecoder + + decoder, decoder_build_s = timed( + "build correlated PyMatching", + lambda: PyMatchingDecoder.from_dem_with_correlations(dem_text, enable_correlations=True), + ) + + expected = true_observable_flips(observable_flips) + + def decode() -> list[list[int]]: + flat = detection_events.astype(np.uint8).flatten().tolist() + return decoder.decode_batch(flat, len(detection_events)) + + predictions, decode_s = timed("decode batch", decode) + predicted = np.array([prediction[0] if prediction else 0 for prediction in predictions], dtype=np.uint8) + logical_errors = int(np.sum(predicted != expected)) + return logical_errors, decoder_build_s, decode_s + + +def build_case( + *, + distance: int, + rounds: int, + basis: str, + interaction_basis: str, + p: float, + shots: int, + seed: int, + variants: list[str], +) -> BenchmarkResult: + from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler + from pecos.qec.surface.circuit_builder import ( + generate_dem_from_tick_circuit_via_stim, + normalize_traced_qis_tick_circuit, + ) + from pecos.qec.surface.decode import ( + _build_surface_tick_circuit_for_native_model, + generate_circuit_level_dem_from_builder, + ) + + print( + f"\n=== d={distance} r={rounds} basis={basis} basis2q={interaction_basis} " + f"p={p:g} shots={shots} ===", + flush=True, + ) + 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_args = { + "p1": noise.p1, + "p2": noise.p2, + "p_meas": noise.p_meas, + "p_prep": noise.p_prep, + } + + tick_circuit, elapsed = timed( + "build traced-QIS tick circuit", + lambda: _build_surface_tick_circuit_for_native_model( + patch, + rounds, + basis, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ), + ) + setup_timings.append(TimedValue("build_traced_qis_tick_circuit", elapsed)) + normalize_traced_qis_tick_circuit(tick_circuit, context="graphlike DEM projection benchmark") + + native_raw, elapsed = timed( + "build native raw DEM", + lambda: generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=False, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ), + ) + setup_timings.append(TimedValue("build_native_raw_dem", elapsed)) + + stim_raw, elapsed = timed( + "build Stim raw DEM", + lambda: generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + setup_timings.append(TimedValue("build_stim_raw_dem", elapsed)) + raw_comparison = asdict(compare_raw_dems(native_raw, stim_raw)) + print( + "raw native vs Stim: " + f"only_native={raw_comparison['only_native']} only_stim={raw_comparison['only_stim']} " + f"max_rel={raw_comparison['max_rel_probability_diff']:.3e}", + flush=True, + ) + + sampler, elapsed = timed( + "build native influence sampler", + lambda: build_native_sampler( + patch, + rounds, + noise, + basis=basis, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + sampling_model="influence_dem", + ), + ) + setup_timings.append(TimedValue("build_native_influence_sampler", elapsed)) + + (detection_events, observable_flips), elapsed = timed( + "sample native influence events", + lambda: sampler.sample(num_shots=shots, seed=seed), + ) + setup_timings.append(TimedValue("sample_native_influence_events", elapsed)) + + def build_variant_dem(variant: str) -> str: + if variant == "native_source": + return generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=True, + dem_decomposition="source_graphlike", + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ) + if variant == "native_terminal": + try: + return generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=True, + dem_decomposition="terminal_graphlike", + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ) + except RuntimeError as exc: + if "terminal graphlike" not in str(exc): + raise + print("[info] using Python terminal graphlike projection fallback", flush=True) + return terminal_graphlike_projection(native_raw) + if variant == "stim": + return generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=True, + **noise_args, + ) + msg = f"unknown variant {variant!r}" + raise ValueError(msg) + + results: list[VariantResult] = [] + for variant in variants: + dem_text, dem_build_s = timed(f"build {variant} DEM", lambda variant=variant: build_variant_dem(variant)) + stats = asdict(dem_stats(dem_text)) + print( + f"{variant} DEM: errors={stats['error_lines']} sep={stats['separator_lines']} " + f"hyper={stats['hyperedge_lines']} max_line={stats['max_line_detectors']}", + flush=True, + ) + logical_errors, decoder_build_s, decode_s = decode_with_correlated_pymatching( + dem_text, + detection_events, + observable_flips, + ) + result = VariantResult( + variant=variant, + dem_stats=stats, + dem_build_s=dem_build_s, + decoder_build_s=decoder_build_s, + decode_s=decode_s, + logical_errors=logical_errors, + logical_error_rate=logical_errors / shots if shots else 0.0, + ) + print( + f"{variant}: errors={result.logical_errors} " + f"LER={result.logical_error_rate:.6f} " + f"build={result.dem_build_s:.3f}s " + f"matcher={result.decoder_build_s:.3f}s " + f"decode={result.decode_s:.3f}s", + flush=True, + ) + results.append(result) + + return BenchmarkResult( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=p, + shots=shots, + setup_timings=setup_timings, + raw_comparison=raw_comparison, + variants=results, + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--distances", nargs="+", type=int, default=[7]) + parser.add_argument("--rounds", type=int, default=None, help="Rounds to use. Defaults to distance.") + parser.add_argument("--bases", nargs="+", choices=["X", "Z"], default=["X"]) + parser.add_argument("--interaction-bases", nargs="+", choices=["cx", "szz"], default=["cx", "szz"]) + parser.add_argument("--p", type=float, default=0.006) + parser.add_argument("--shots", type=int, default=3000) + parser.add_argument("--seed", type=int, default=20260613) + parser.add_argument( + "--variants", + nargs="+", + choices=["native_source", "native_terminal", "stim"], + default=["native_terminal", "stim", "native_source"], + ) + parser.add_argument("--save-json", type=Path, default=None) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + results = [] + for distance in args.distances: + rounds = args.rounds if args.rounds is not None else distance + results.extend( + build_case( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=args.p, + shots=args.shots, + seed=args.seed, + variants=args.variants, + ) + for basis in args.bases + for interaction_basis in args.interaction_bases + ) + + if args.save_json is not None: + args.save_json.parent.mkdir(parents=True, exist_ok=True) + args.save_json.write_text( + json.dumps([asdict(result) for result in results], indent=2, sort_keys=True), + encoding="utf-8", + ) + print(f"\nWrote {args.save_json}", flush=True) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From c3d2f765ddff132ce11db9ff153de96cc98b3b15 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 13 Jun 2026 14:04:09 -0600 Subject: [PATCH 088/150] Add deterministic measurement crosstalk DEM replay mode --- .../fault_tolerance/dem_builder/builder.rs | 172 ++++++++++++++++-- .../src/fault_tolerance/dem_builder/types.rs | 4 + .../src/fault_tolerance_bindings.rs | 8 +- 3 files changed, 170 insertions(+), 14 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 ab0b8fa5a..48ced572a 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -903,7 +903,10 @@ impl<'a> DemBuilder<'a> { return Ok(()); } - if self.noise.p_meas_crosstalk_model.has_leakage() { + if self.noise.p_meas_crosstalk_model.has_leakage() + && self.noise.measurement_crosstalk_dem_mode + != MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + { return Err(DemBuilderError::ConfigurationError( "exact deterministic measurement crosstalk DEM replay does not yet support leakage transitions" .to_string(), @@ -1305,8 +1308,11 @@ impl<'a> DemBuilder<'a> { } GateType::MeasCrosstalkLocalPayload if !loc.before - && self.noise.measurement_crosstalk_dem_mode - == MeasurementCrosstalkDemMode::ExactDeterministic + && matches!( + self.noise.measurement_crosstalk_dem_mode, + MeasurementCrosstalkDemMode::ExactDeterministic + | MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + ) && self.noise.p_meas_crosstalk_local > 0.0 => { self.process_measurement_crosstalk_local_source_tracked( @@ -1567,26 +1573,101 @@ impl<'a> DemBuilder<'a> { let hidden = self .hidden_mz_result_before_crosstalk_payload(context, loc) .expect("measurement crosstalk exact deterministic hidden result was validated"); - let transition_probability = if hidden.flip { - self.noise.p_meas_crosstalk_model.p_1_to_0 + let (bit_flip_probability, leak_probability) = if hidden.flip { + ( + self.noise.p_meas_crosstalk_model.p_1_to_0, + self.noise.p_meas_crosstalk_model.p_1_to_leak, + ) } else { - self.noise.p_meas_crosstalk_model.p_0_to_1 + ( + self.noise.p_meas_crosstalk_model.p_0_to_1, + self.noise.p_meas_crosstalk_model.p_0_to_leak, + ) }; - let p = self.noise.p_meas_crosstalk_local * transition_probability; - if p <= 0.0 { - return; + if self.noise.measurement_crosstalk_dem_mode + == MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + { + let leak_pauli_probability = leak_probability / 4.0; + self.process_measurement_crosstalk_local_pauli_rates_source_tracked( + loc_idx, + [ + self.noise.p_meas_crosstalk_local + * (bit_flip_probability + leak_pauli_probability), + self.noise.p_meas_crosstalk_local * leak_pauli_probability, + self.noise.p_meas_crosstalk_local * leak_pauli_probability, + ], + dem, + meas_to_detectors, + meas_to_observables, + ); + } else { + self.process_measurement_crosstalk_local_pauli_rates_source_tracked( + loc_idx, + [ + self.noise.p_meas_crosstalk_local * bit_flip_probability, + 0.0, + 0.0, + ], + dem, + meas_to_detectors, + meas_to_observables, + ); } + } - let mechanism = + /// Processes local measurement-crosstalk payloads as single-location Pauli + /// source channels while preserving crosstalk source metadata. + fn process_measurement_crosstalk_local_pauli_rates_source_tracked( + &self, + loc_idx: usize, + rates: [f64; 3], + dem: &mut DetectorErrorModel, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, + ) { + let loc = &self.influence_map.locations[loc_idx]; + let [rate_x, rate_y, rate_z] = rates; + let x_effect = self.compute_mechanism(loc_idx, Pauli::X, meas_to_detectors, meas_to_observables); - if !mechanism.is_empty() { + let z_effect = + self.compute_mechanism(loc_idx, Pauli::Z, meas_to_detectors, meas_to_observables); + + if rate_x > 0.0 && !x_effect.is_empty() { dem.add_direct_contribution_with_source( - mechanism, - p, + x_effect.clone(), + rate_x, SourceMetadata::new(&[loc_idx], &[Pauli::X], &[loc.gate_type], &[loc.before]) .with_direct_source_family(DirectSourceFamily::MeasurementCrosstalk), ); } + 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], &[loc.gate_type], &[loc.before]) + .with_direct_source_family(DirectSourceFamily::MeasurementCrosstalk), + ); + } + + 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], &[loc.gate_type], &[loc.before]) + .with_direct_source_family(DirectSourceFamily::MeasurementCrosstalk), + ); + } else { + dem.add_direct_contribution_with_source( + y_effect, + rate_y, + SourceMetadata::new(&[loc_idx], &[Pauli::Y], &[loc.gate_type], &[loc.before]) + .with_direct_source_family(DirectSourceFamily::MeasurementCrosstalk), + ); + } + } } /// Processes a single-qubit gate fault with source tracking. @@ -4180,6 +4261,71 @@ mod tests { ); } + #[test] + fn test_exact_deterministic_local_measurement_crosstalk_rejects_leakage() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_local_crosstalk_circuit(false); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_local_rate(0.25) + .set_measurement_crosstalk_transition_model(MeasurementCrosstalkTransitionModel { + p_0_to_1: 0.4, + p_0_to_leak: 0.2, + p_1_to_0: 0.0, + p_1_to_leak: 0.0, + }) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let err = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect_err("plain exact deterministic crosstalk should reject leakage"); + + assert!(matches!(err, DemBuilderError::ConfigurationError(_))); + assert!( + err.to_string().contains("leakage transitions"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_exact_deterministic_local_measurement_crosstalk_leakage_as_depolarizing() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_local_crosstalk_circuit(false); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_local_rate(0.25) + .set_measurement_crosstalk_transition_model(MeasurementCrosstalkTransitionModel { + p_0_to_1: 0.4, + p_0_to_leak: 0.2, + p_1_to_0: 0.0, + p_1_to_leak: 0.0, + }) + .set_measurement_crosstalk_dem_mode( + MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing, + ); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("deterministic leak2depolar local crosstalk should be representable"); + + let contributions = dem.contributions_for_effect(&[0], &[]); + assert_eq!(contributions.len(), 2); + let total_probability: f64 = contributions + .iter() + .map(|contribution| contribution.probability) + .sum(); + assert!((total_probability - 0.125).abs() < 1.0e-12); + assert!(contributions.iter().any(|contribution| { + contribution.paulis.as_slice() == [Pauli::X] + && (contribution.probability - 0.1125).abs() < 1.0e-12 + })); + assert!(contributions.iter().any(|contribution| { + contribution.paulis.as_slice() == [Pauli::Y] + && (contribution.probability - 0.0125).abs() < 1.0e-12 + })); + assert!(contributions.iter().all(|contribution| { + contribution.source_gate_types.as_slice() == [GateType::MeasCrosstalkLocalPayload] + })); + } + #[test] fn test_from_circuit_exact_branch_replay_skips_empty_replacement_effect() { use crate::fault_tolerance::dem_builder::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 51025ff67..fe5fbd655 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -2541,6 +2541,10 @@ pub enum MeasurementCrosstalkDemMode { /// Replay payloads exactly when the hidden measurement outcome is /// deterministic and state-independent. ExactDeterministic, + /// Replay deterministic local payloads while treating hidden leakage + /// transitions as the `leak2depolar` replacement channel: one quarter each + /// of I, X, Y, and Z on the victim qubit. + ExactDeterministicLeakageAsDepolarizing, } /// Hidden-measurement transition probabilities for local measurement crosstalk. diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 80b0831e8..0bb5f7d20 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -219,8 +219,14 @@ fn parse_measurement_crosstalk_dem_mode( "exact_deterministic" | "exact" | "deterministic" => { Ok(MeasurementCrosstalkDemMode::ExactDeterministic) } + "exact_deterministic_leakage_as_depolarizing" + | "exact_leakage_as_depolarizing" + | "deterministic_leakage_as_depolarizing" + | "leakage_as_depolarizing" => { + Ok(MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing) + } _ => Err(pyo3::exceptions::PyValueError::new_err( - "measurement_crosstalk_dem_mode must be 'omitted' or 'exact_deterministic'", + "measurement_crosstalk_dem_mode must be 'omitted', 'exact_deterministic', or 'exact_deterministic_leakage_as_depolarizing'", )), } } From cd7edf4ccb46f9d237aa9a0b7b516e9fc518f5bf Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 14:07:29 -0600 Subject: [PATCH 089/150] Add surface interaction quality report --- .../surface/szz_circuit_quality_report.py | 491 ++++++++++++++++++ 1 file changed, 491 insertions(+) create mode 100644 examples/surface/szz_circuit_quality_report.py diff --git a/examples/surface/szz_circuit_quality_report.py b/examples/surface/szz_circuit_quality_report.py new file mode 100644 index 000000000..2df111646 --- /dev/null +++ b/examples/surface/szz_circuit_quality_report.py @@ -0,0 +1,491 @@ +"""Report CX vs SZZ/SZZdg surface-memory circuit quality metrics. + +This script is intentionally descriptive rather than a threshold sweep. It +counts the gate locations that drive the simple circuit-level noise model and, +optionally, compares raw PECOS and Stim DEMs for the traced-QIS circuit path. + +Example: + uv run python examples/surface/szz_circuit_quality_report.py \\ + --distances 3 5 --bases X Z --interaction-bases cx szz +""" + +from __future__ import annotations + +import argparse +import json +import time +from collections import Counter +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +from dem_decomposition_diagnostics import compare_raw_dems, dem_stats +from pecos.qec.surface import NoiseModel, OpType, SurfacePatch, build_surface_code_circuit +from pecos.qec.surface.circuit_builder import ( + _analyze_szz_forward_flow, + generate_dem_from_tick_circuit_via_stim, + normalize_traced_qis_tick_circuit, +) +from pecos.qec.surface.decode import ( + _build_surface_tick_circuit_for_native_model, + generate_circuit_level_dem_from_builder, +) +from pecos.quantum import PHYSICAL_DURATION_META_KEY + +PREP_GATES = {"PZ", "PX", "PY", "QAlloc"} +MEASUREMENT_GATES = {"MZ", "MX", "MY", "MeasureFree"} +IDLE_GATES = {"Idle", "I"} +TWO_QUBIT_GATES = { + "CX", + "CY", + "CZ", + "CH", + "CRZ", + "SXX", + "SXXdg", + "SYY", + "SYYdg", + "SZZ", + "SZZdg", + "RXX", + "RYY", + "RZZ", + "SWAP", + "ISWAP", +} +THREE_QUBIT_GATES = {"CCX", "CCZ"} +SZZ_ABSTRACT_PREFIX_P1_FREE_GATES = {"Z", "SZ", "SZdg"} + + +@dataclass(frozen=True) +class TickCircuitStats: + source: str + build_s: float + total_ticks: int + nonempty_ticks: int + gate_batches: int + gate_locations: int + prep_locations: int + measurement_locations: int + single_qubit_locations: int + p1_model_locations: int + p1_exempt_locations: int + two_qubit_locations: int + idle_locations: int + zero_duration_locations: int + max_tick_width: int + gate_counts: dict[str, int] + first_order_fault_mass: dict[str, float] + + +@dataclass(frozen=True) +class AbstractCircuitStats: + step_counts: dict[str, int] + szz_forward_flow: dict[str, Any] | None + + +@dataclass(frozen=True) +class DemReport: + native_stats: dict[str, Any] + stim_stats: dict[str, Any] + native_vs_stim: dict[str, Any] + native_build_s: float + stim_build_s: float + + +@dataclass(frozen=True) +class CaseReport: + distance: int + rounds: int + basis: str + interaction_basis: str + p: float + p1_ratio: float + abstract: AbstractCircuitStats + tick_circuits: list[TickCircuitStats] + dem: DemReport | None + + +def _gate_type_name(gate: Any) -> str: + gate_type = getattr(gate, "gate_type", "") + return str(getattr(gate_type, "name", gate_type)).rsplit(".", maxsplit=1)[-1] + + +def _gate_location_count(gate_name: str, qubits: list[int]) -> int: + if gate_name in TWO_QUBIT_GATES: + return len(qubits) // 2 + if gate_name in THREE_QUBIT_GATES: + return len(qubits) // 3 + return len(qubits) + + +def _is_zero_duration(tick: Any, gate_index: int) -> bool: + value = tick.get_gate_attr(gate_index, PHYSICAL_DURATION_META_KEY) + return value == 0 + + +def _noise_fault_mass( + *, + p: float, + p1_ratio: float, + p1_locations: int, + p2_locations: int, + prep_locations: int, + measurement_locations: int, + idle_locations: int, + p_idle: float, +) -> dict[str, float]: + p1 = p / p1_ratio + p_prep = p / 3.0 + p_meas = p / 3.0 + return { + "p1": p1_locations * p1, + "p2": p2_locations * p, + "prep": prep_locations * p_prep, + "measurement": measurement_locations * p_meas, + "idle": idle_locations * p_idle, + "total": p1_locations * p1 + + p2_locations * p + + prep_locations * p_prep + + measurement_locations * p_meas + + idle_locations * p_idle, + } + + +def _tick_circuit_stats( + *, + source: str, + tick_circuit: Any, + build_s: float, + interaction_basis: str, + p: float, + p1_ratio: float, + p_idle: float, +) -> TickCircuitStats: + gate_counts: Counter[str] = Counter() + total_locations = 0 + gate_batches = 0 + prep_locations = 0 + measurement_locations = 0 + single_qubit_locations = 0 + p1_exempt_locations = 0 + two_qubit_locations = 0 + idle_locations = 0 + zero_duration_locations = 0 + nonempty_ticks = 0 + max_tick_width = 0 + p1_exempt_names = ( + SZZ_ABSTRACT_PREFIX_P1_FREE_GATES + if source == "abstract_physical_prefix" and interaction_basis == "szz" + else set() + ) + + for tick_index in range(int(tick_circuit.num_ticks())): + tick = tick_circuit.get_tick(tick_index) + if tick is None or tick.is_empty(): + continue + nonempty_ticks += 1 + tick_width = 0 + for gate_index, gate in enumerate(tick.gate_batches()): + gate_name = _gate_type_name(gate) + qubits = [int(qubit) for qubit in getattr(gate, "qubits", [])] + locations = _gate_location_count(gate_name, qubits) + gate_batches += 1 + total_locations += locations + tick_width += locations + gate_counts[gate_name] += locations + if _is_zero_duration(tick, gate_index): + zero_duration_locations += locations + if gate_name in PREP_GATES: + prep_locations += locations + elif gate_name in MEASUREMENT_GATES: + measurement_locations += locations + elif gate_name in IDLE_GATES: + idle_locations += locations + elif gate_name in TWO_QUBIT_GATES or gate_name in THREE_QUBIT_GATES: + two_qubit_locations += locations + else: + single_qubit_locations += locations + if gate_name in p1_exempt_names or _is_zero_duration(tick, gate_index): + p1_exempt_locations += locations + max_tick_width = max(max_tick_width, tick_width) + + p1_model_locations = single_qubit_locations - p1_exempt_locations + return TickCircuitStats( + source=source, + build_s=build_s, + total_ticks=int(tick_circuit.num_ticks()), + nonempty_ticks=nonempty_ticks, + gate_batches=gate_batches, + gate_locations=total_locations, + prep_locations=prep_locations, + measurement_locations=measurement_locations, + single_qubit_locations=single_qubit_locations, + p1_model_locations=p1_model_locations, + p1_exempt_locations=p1_exempt_locations, + two_qubit_locations=two_qubit_locations, + idle_locations=idle_locations, + zero_duration_locations=zero_duration_locations, + max_tick_width=max_tick_width, + gate_counts=dict(sorted(gate_counts.items())), + first_order_fault_mass=_noise_fault_mass( + p=p, + p1_ratio=p1_ratio, + p1_locations=p1_model_locations, + p2_locations=two_qubit_locations, + prep_locations=prep_locations, + measurement_locations=measurement_locations, + idle_locations=idle_locations, + p_idle=p_idle, + ), + ) + + +def _timed(callback: Any) -> tuple[Any, float]: + start = time.perf_counter() + value = callback() + return value, time.perf_counter() - start + + +def _abstract_stats(patch: SurfacePatch, rounds: int, basis: str, interaction_basis: str) -> AbstractCircuitStats: + ops, _allocation = build_surface_code_circuit( + patch, + rounds, + basis=basis, + interaction_basis=interaction_basis, + ) + step_counts = Counter(op.op_type.name for op in ops if op.op_type not in {OpType.COMMENT, OpType.TICK}) + flow = None + if interaction_basis == "szz": + summary = _analyze_szz_forward_flow(ops) + flow = asdict(summary) + flow.pop("pulses", None) + return AbstractCircuitStats(step_counts=dict(sorted(step_counts.items())), szz_forward_flow=flow) + + +def _build_tick_view( + *, + patch: SurfacePatch, + rounds: int, + basis: str, + interaction_basis: str, + source: str, +) -> tuple[Any, float]: + circuit_source = "abstract" + szz_physical_prefixes = False + if source == "traced_qis": + circuit_source = "traced_qis" + elif source == "abstract_physical_prefix": + if interaction_basis != "szz": + msg = "abstract_physical_prefix is only meaningful for interaction_basis='szz'" + raise ValueError(msg) + szz_physical_prefixes = True + elif source != "abstract": + msg = f"unknown tick circuit source {source!r}" + raise ValueError(msg) + + tick_circuit, elapsed = _timed( + lambda: _build_surface_tick_circuit_for_native_model( + patch, + rounds, + basis, + circuit_source=circuit_source, + interaction_basis=interaction_basis, + szz_physical_prefixes=szz_physical_prefixes, + ), + ) + if source == "traced_qis": + normalize_traced_qis_tick_circuit(tick_circuit, context="SZZ circuit quality report") + return tick_circuit, elapsed + + +def _dem_report( + *, + patch: SurfacePatch, + rounds: int, + basis: str, + interaction_basis: str, + tick_circuit: Any, + 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_args = { + "p1": noise.p1, + "p2": noise.p2, + "p_prep": noise.p_prep, + "p_meas": noise.p_meas, + } + native_dem, native_build_s = _timed( + lambda: generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=False, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ), + ) + stim_dem, stim_build_s = _timed( + lambda: generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + return DemReport( + native_stats=asdict(dem_stats(native_dem)), + stim_stats=asdict(dem_stats(stim_dem)), + native_vs_stim=asdict(compare_raw_dems(native_dem, stim_dem)), + native_build_s=native_build_s, + stim_build_s=stim_build_s, + ) + + +def build_case( + *, + distance: int, + rounds: int, + basis: str, + interaction_basis: str, + p: float, + p1_ratio: float, + p_idle: float, + sources: list[str], + include_dem: bool, +) -> CaseReport: + patch = SurfacePatch.create(distance=distance) + abstract = _abstract_stats(patch, rounds, basis, interaction_basis) + tick_stats: list[TickCircuitStats] = [] + traced_tick_circuit = None + + print(f"\n=== d={distance} r={rounds} basis={basis} basis2q={interaction_basis} ===", flush=True) + for source in sources: + tick_circuit, build_s = _build_tick_view( + patch=patch, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + source=source, + ) + if source == "traced_qis": + traced_tick_circuit = tick_circuit + stats = _tick_circuit_stats( + source=source, + tick_circuit=tick_circuit, + build_s=build_s, + interaction_basis=interaction_basis, + p=p, + p1_ratio=p1_ratio, + p_idle=p_idle, + ) + tick_stats.append(stats) + mass = stats.first_order_fault_mass + print( + f"{source:24} ticks={stats.nonempty_ticks:5d}/{stats.total_ticks:<5d} " + f"p1_locs={stats.p1_model_locations:5d} " + f"p2_locs={stats.two_qubit_locations:5d} " + f"prep={stats.prep_locations:5d} meas={stats.measurement_locations:5d} " + f"mass={mass['total']:.6g} " + f"build={stats.build_s:.3f}s", + flush=True, + ) + + dem = None + if include_dem: + if traced_tick_circuit is None: + traced_tick_circuit, _build_s = _build_tick_view( + patch=patch, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + source="traced_qis", + ) + print("building traced-QIS raw DEM comparison...", flush=True) + dem = _dem_report( + patch=patch, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + tick_circuit=traced_tick_circuit, + p=p, + p1_ratio=p1_ratio, + ) + comparison = dem.native_vs_stim + print( + "raw native vs Stim: " + f"only_native={comparison['only_native']} only_stim={comparison['only_stim']} " + f"max_rel={comparison['max_rel_probability_diff']:.3e}", + flush=True, + ) + + return CaseReport( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=p, + p1_ratio=p1_ratio, + abstract=abstract, + tick_circuits=tick_stats, + dem=dem, + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--distances", nargs="+", type=int, default=[3, 5]) + parser.add_argument("--rounds", type=int, default=None, help="Rounds to use. Defaults to distance.") + parser.add_argument("--bases", nargs="+", choices=["X", "Z"], default=["X", "Z"]) + parser.add_argument("--interaction-bases", nargs="+", choices=["cx", "szz"], default=["cx", "szz"]) + parser.add_argument("--sources", nargs="+", choices=["abstract", "abstract_physical_prefix", "traced_qis"]) + parser.add_argument("--p", type=float, default=0.006) + parser.add_argument("--p1-ratio", type=float, default=30.0, help="Use p1=p/p1_ratio.") + parser.add_argument("--p-idle", type=float, default=0.0, help="Optional idle rate for the rough mass estimate.") + parser.add_argument("--include-dem", action="store_true", help="Also compare traced-QIS raw native and Stim DEMs.") + parser.add_argument("--save-json", type=Path, default=None) + return parser.parse_args() + + +def _default_sources(interaction_basis: str) -> list[str]: + if interaction_basis == "szz": + return ["abstract", "abstract_physical_prefix", "traced_qis"] + return ["abstract", "traced_qis"] + + +def main() -> int: + args = parse_args() + results: list[CaseReport] = [] + for distance in args.distances: + rounds = args.rounds if args.rounds is not None else distance + for basis in args.bases: + for interaction_basis in args.interaction_bases: + sources = args.sources if args.sources is not None else _default_sources(interaction_basis) + results.append( + build_case( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=args.p, + p1_ratio=args.p1_ratio, + p_idle=args.p_idle, + sources=sources, + include_dem=args.include_dem, + ), + ) + + if args.save_json is not None: + args.save_json.parent.mkdir(parents=True, exist_ok=True) + args.save_json.write_text( + json.dumps([asdict(result) for result in results], indent=2, sort_keys=True), + encoding="utf-8", + ) + print(f"\nWrote {args.save_json}", flush=True) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From dedd7abd6b9684c5b62aafc471dfbe51b3f23ae4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 14:35:55 -0600 Subject: [PATCH 090/150] Align SZZ frame gate noise accounting --- .../surface/dem_decomposition_diagnostics.py | 2 + .../graphlike_dem_projection_benchmark.py | 3 + .../surface/szz_circuit_quality_report.py | 5 +- python/pecos-rslib/pecos_rslib.pyi | 5 ++ .../pecos-rslib/src/dag_circuit_bindings.rs | 46 +++++++++++++ .../src/pecos/qec/surface/circuit_builder.py | 21 +++++- .../src/pecos/qec/surface/decode.py | 10 +-- .../qec/surface/test_szz_interaction_basis.py | 66 +++++++++++++++++++ .../qec/test_traced_qis_clifford_pipeline.py | 48 +++++++++++++- 9 files changed, 197 insertions(+), 9 deletions(-) diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py index 486d92b41..8c529ba13 100644 --- a/examples/surface/dem_decomposition_diagnostics.py +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -36,6 +36,7 @@ "terminal_decomp_pymatching", "terminal_decomp_pymatching_correlated", ] +SZZ_Z_FRAME_P1_GATE_RATES = {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} @dataclass(frozen=True) @@ -529,6 +530,7 @@ def run_case( noise = NoiseModel(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, "p2": noise.p2, "p_meas": noise.p_meas, "p_prep": noise.p_prep, diff --git a/examples/surface/graphlike_dem_projection_benchmark.py b/examples/surface/graphlike_dem_projection_benchmark.py index fc1c4f71c..6be23f129 100644 --- a/examples/surface/graphlike_dem_projection_benchmark.py +++ b/examples/surface/graphlike_dem_projection_benchmark.py @@ -23,6 +23,8 @@ true_observable_flips, ) +SZZ_Z_FRAME_P1_GATE_RATES = {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} + @dataclass(frozen=True) class TimedValue: @@ -118,6 +120,7 @@ def build_case( noise = NoiseModel(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, "p2": noise.p2, "p_meas": noise.p_meas, "p_prep": noise.p_prep, diff --git a/examples/surface/szz_circuit_quality_report.py b/examples/surface/szz_circuit_quality_report.py index 2df111646..fb55f78f2 100644 --- a/examples/surface/szz_circuit_quality_report.py +++ b/examples/surface/szz_circuit_quality_report.py @@ -55,6 +55,8 @@ } THREE_QUBIT_GATES = {"CCX", "CCZ"} SZZ_ABSTRACT_PREFIX_P1_FREE_GATES = {"Z", "SZ", "SZdg"} +SZZ_Z_FRAME_P1_FREE_SOURCES = {"abstract_physical_prefix", "traced_qis"} +SZZ_Z_FRAME_P1_GATE_RATES = {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} @dataclass(frozen=True) @@ -176,7 +178,7 @@ def _tick_circuit_stats( max_tick_width = 0 p1_exempt_names = ( SZZ_ABSTRACT_PREFIX_P1_FREE_GATES - if source == "abstract_physical_prefix" and interaction_basis == "szz" + if source in SZZ_Z_FRAME_P1_FREE_SOURCES and interaction_basis == "szz" else set() ) @@ -312,6 +314,7 @@ def _dem_report( noise = NoiseModel(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, "p2": noise.p2, "p_prep": noise.p_prep, "p_meas": noise.p_meas, diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index 65545d5df..48a19cb51 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -1663,6 +1663,11 @@ class TickCircuit: def set_gate_meta(self, tick_idx: int, gate_idx: int, key: str, value: Any) -> None: ... def get_gate_meta(self, tick_idx: int, gate_idx: int, key: str) -> Any | None: ... def lower_clifford_rotations(self) -> None: ... + def remove_identity(self) -> None: ... + def cancel_inverses(self) -> None: ... + def merge_adjacent_rotations(self) -> None: ... + def peephole_optimize(self) -> None: ... + def absorb_basis_gates(self) -> None: ... def assign_missing_meas_ids(self) -> None: ... def insert_idle_after_two_qubit_gates(self, duration: float = 1.0) -> None: ... def fill_idle_gates(self) -> None: ... diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index ecc1deba7..153a68991 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -2672,6 +2672,52 @@ impl PyTickCircuit { SimplifyRotations.apply_tick(&mut self.inner); } + /// Remove identity gates and zero-angle rotations. + /// + /// Modifies the circuit in place. + fn remove_identity(&mut self) { + use pecos_quantum::pass::{CircuitPass, RemoveIdentity}; + RemoveIdentity.apply_tick(&mut self.inner); + } + + /// Cancel adjacent inverse gate pairs. + /// + /// This removes adjacent inverse pairs such as H-H, SX-SXdg, and SZZ-SZZdg + /// when they act on the same qubits with no intervening operation on those + /// qubits. Modifies the circuit in place. + fn cancel_inverses(&mut self) { + use pecos_quantum::pass::{CancelInverses, CircuitPass}; + CancelInverses.apply_tick(&mut self.inner); + } + + /// Merge adjacent same-axis rotation gates. + /// + /// Consecutive rotations such as RZ(a) followed by RZ(b) on the same qubit + /// become one RZ(a+b). Run lower_clifford_rotations() afterwards when + /// special-angle rotations should become named Clifford gates. + /// Modifies the circuit in place. + fn merge_adjacent_rotations(&mut self) { + use pecos_quantum::pass::{CircuitPass, MergeAdjacentRotations}; + MergeAdjacentRotations.apply_tick(&mut self.inner); + } + + /// Run PECOS's local peephole optimizer. + /// + /// Currently recognizes small Clifford patterns such as H-conjugated + /// two-qubit gates. Modifies the circuit in place. + fn peephole_optimize(&mut self) { + use pecos_quantum::pass::{CircuitPass, PeepholeOptimize}; + PeepholeOptimize.apply_tick(&mut self.inner); + } + + /// Absorb redundant Z-diagonal gates next to Z preparations/measurements. + /// + /// Modifies the circuit in place. + fn absorb_basis_gates(&mut self) { + use pecos_quantum::pass::{AbsorbBasisGates, CircuitPass}; + AbsorbBasisGates.apply_tick(&mut self.inner); + } + /// Assign MeasId to measurement gates that don't have them. /// /// Use on circuits from external sources (QIS trace, Stim import) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 722a60a2a..2853ee98f 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -2915,6 +2915,7 @@ def tick_circuit_to_stim( tc: TickCircuit, *, p1: float = 0.0, + p1_gate_rates: Mapping[str, float] | None = None, p2: float = 0.0, p_meas: float = 0.0, p_prep: float = 0.0, @@ -2927,6 +2928,9 @@ def tick_circuit_to_stim( Args: tc: TickCircuit instance with detector/observable metadata p1: Single-qubit error rate + p1_gate_rates: Optional per-gate override for single-qubit error + rates. Gate names are PECOS ``GateType`` names such as ``"Z"``, + ``"SZ"``, and ``"SZdg"``. p2: Two-qubit error rate p_meas: Measurement error rate p_prep: Initialization error rate @@ -3067,8 +3071,9 @@ def _gate_to_stim( op_qubit_str = " ".join(str(q) for q in op_qubits) lines.append(f"{stim_name} {op_qubit_str}") - if noise_kind == "single" and p1 > 0: - lines.append(f"DEPOLARIZE1({p1}) {qubit_str}") + p1_for_gate = p1 if p1_gate_rates is None else float(p1_gate_rates.get(gate.gate_type.name, p1)) + if noise_kind == "single" and p1_for_gate > 0: + lines.append(f"DEPOLARIZE1({p1_for_gate}) {qubit_str}") elif noise_kind == "two" and p2 > 0: lines.append(f"DEPOLARIZE2({p2}) {qubit_str}") elif noise_kind == "prep" and p_prep > 0: @@ -3444,6 +3449,7 @@ def generate_dem_from_tick_circuit_via_stim( tc: TickCircuit, *, p1: float = 0.01, + p1_gate_rates: Mapping[str, float] | None = None, p2: float = 0.01, p_meas: float = 0.01, p_prep: float = 0.01, @@ -3459,6 +3465,8 @@ def generate_dem_from_tick_circuit_via_stim( Args: tc: TickCircuit with detector/observable metadata p1: Single-qubit depolarizing error rate + p1_gate_rates: Optional per-gate override for single-qubit + depolarizing rates. Gate names are PECOS ``GateType`` names. p2: Two-qubit depolarizing error rate p_meas: Measurement error rate p_prep: Initialization (prep) error rate @@ -3478,7 +3486,14 @@ def generate_dem_from_tick_circuit_via_stim( msg = "Stim is required for this function. Install with: pip install stim" raise ImportError(msg) from e - stim_str = tick_circuit_to_stim(tc, p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep) + stim_str = tick_circuit_to_stim( + tc, + p1=p1, + p1_gate_rates=p1_gate_rates, + p2=p2, + p_meas=p_meas, + p_prep=p_prep, + ) circuit = stim.Circuit(stim_str) dem = circuit.detector_error_model(decompose_errors=decompose_errors or maximal_decomposition) if maximal_decomposition: diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index fd0a184af..af30a271a 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -290,6 +290,7 @@ class _CachedNativeSurfaceTopology: dag_circuit: Any influence_map: Any szz_physical_prefixes: bool + z_frame_gate_p1_free: bool pauli_frame_lookup: Any | None detectors_json: str observables_json: str @@ -1647,8 +1648,8 @@ def _use_szz_physical_prefixes( ) -def _szz_prefix_p1_gate_rates(topology: _CachedNativeSurfaceTopology) -> dict[str, float] | None: - if not topology.szz_physical_prefixes: +def _szz_z_frame_p1_gate_rates(topology: _CachedNativeSurfaceTopology) -> dict[str, float] | None: + if not topology.z_frame_gate_p1_free: return None return {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} @@ -1793,6 +1794,7 @@ def _surface_native_topology( dag_circuit=dag, influence_map=influence_map, szz_physical_prefixes=szz_physical_prefixes, + z_frame_gate_p1_free=interaction_basis == "szz", pauli_frame_lookup=pauli_frame_lookup, detectors_json=detectors_json, observables_json=observables_json, @@ -1844,7 +1846,7 @@ def _dem_string_from_cached_surface_topology( builder = _with_noise_compat( DemBuilder(topology.influence_map), noise, - p1_gate_rates=_szz_prefix_p1_gate_rates(topology), + p1_gate_rates=_szz_z_frame_p1_gate_rates(topology), ) if hasattr(builder, "with_exact_branch_replay_circuit"): builder = builder.with_exact_branch_replay_circuit(topology.dag_circuit) @@ -2014,7 +2016,7 @@ def _build_native_sampler_from_cached_surface_topology( _with_noise_compat( DemSamplerBuilder(topology.influence_map), noise, - p1_gate_rates=_szz_prefix_p1_gate_rates(topology), + p1_gate_rates=_szz_z_frame_p1_gate_rates(topology), ) .with_detectors_json(topology.detectors_json) .with_observables_json(topology.observables_json) 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 ddc4ab30e..58f03e1ed 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 @@ -823,6 +823,72 @@ def test_szz_public_native_dem_accepts_traced_qis_p1() -> None: assert stim.DetectorErrorModel(dem).num_detectors > 0 +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_public_traced_qis_dem_matches_stim_with_z_frame_p1_free(basis: str) -> None: + from pecos.qec.surface.circuit_builder import normalize_traced_qis_tick_circuit + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + + patch = SurfacePatch.create(distance=3) + tick_circuit = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=1, + basis=basis, + circuit_source="traced_qis", + interaction_basis="szz", + ) + normalize_traced_qis_tick_circuit(tick_circuit, context="SZZ public traced-QIS p1 test") + noise = NoiseModel(p1=0.001) + + native_errors = _raw_dem_errors( + generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=noise, + basis=basis, + decompose_errors=False, + circuit_source="traced_qis", + interaction_basis="szz", + ), + ) + stim_errors = _raw_dem_errors( + generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + p1=noise.p1, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p1_gate_rates={"Z": 0.0, "SZ": 0.0, "SZdg": 0.0}, + ), + ) + + assert set(native_errors) == set(stim_errors) + for target, native_probability in native_errors.items(): + stim_probability = stim_errors[target] + rel_diff = abs(native_probability - stim_probability) / max( + native_probability, + stim_probability, + 1e-12, + ) + assert rel_diff < 0.005, ( + f"{basis} public traced-QIS SZZ p1 DEM mismatch for {target}: " + f"PECOS={native_probability:.8f}, Stim={stim_probability:.8f}" + ) + + +def test_szz_z_frame_gates_are_p1_free_for_native_noise() -> None: + from types import SimpleNamespace + + from pecos.qec.surface.decode import _szz_z_frame_p1_gate_rates + + assert _szz_z_frame_p1_gate_rates(SimpleNamespace(z_frame_gate_p1_free=True)) == { + "Z": 0.0, + "SZ": 0.0, + "SZdg": 0.0, + } + assert _szz_z_frame_p1_gate_rates(SimpleNamespace(z_frame_gate_p1_free=False)) is None + + def test_szz_native_dem_accepts_p1_with_physical_prefix_lowering() -> None: patch = SurfacePatch.create(distance=3) dem = generate_circuit_level_dem_from_builder( diff --git a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py index 7cc55b476..8ca5eb525 100644 --- a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py +++ b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py @@ -7,7 +7,6 @@ import random import pytest - from pecos.qec.surface import SurfacePatch from pecos.qec.surface.circuit_builder import normalize_traced_qis_tick_circuit from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model @@ -240,6 +239,53 @@ def test_normalize_traced_qis_tick_circuit_rejects_raw_rzz_after_lowering(): normalize_traced_qis_tick_circuit(tc, context="test non-Clifford RZZ normalization") +def _gate_names(tc): + return [ + gate.gate_type.name + for tick_index in range(tc.num_ticks()) + for gate in tc.get_tick(tick_index).gate_batches() + ] + + +def test_tick_circuit_pass_bindings_cancel_and_remove_simple_gates(): + tc = TickCircuit() + tc.tick().h([0]) + tc.tick().h([0]) + tc.tick().i([1]) + + tc.cancel_inverses() + tc.remove_identity() + + assert _gate_names(tc) == [] + + +def test_tick_circuit_pass_bindings_merge_and_lower_rotations(): + tc = TickCircuit() + tc.tick().rz(math.pi / 4, [0]) + tc.tick().rz(math.pi / 4, [0]) + + tc.merge_adjacent_rotations() + tc.lower_clifford_rotations() + + assert _gate_names(tc) == ["SZ"] + + +def test_tick_circuit_pass_bindings_absorb_basis_and_peephole(): + absorbed = TickCircuit() + absorbed.tick().pz([0]) + absorbed.tick().sz([0]) + absorbed.tick().mz([0]) + absorbed.absorb_basis_gates() + assert _gate_names(absorbed) == ["PZ", "MZ"] + + optimized = TickCircuit() + optimized.tick().h([1]) + optimized.tick().cx([(0, 1)]) + optimized.tick().h([1]) + optimized.peephole_optimize() + assert _gate_names(optimized) == ["CZ"] + + def test_explicit_python_gate_names_map_to_rust_clifford_gates(): tc = build_explicit_clifford_gate_circuit() noise = depolarizing().p1(0.03).p2(0.15).p_meas(0).p_prep(0) From 3bf2e7bbf80d4ee25a2499e09dcb58cd7e574d10 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 14:51:28 -0600 Subject: [PATCH 091/150] Simplify traced Clifford gate chains --- crates/pecos-quantum/src/pass.rs | 327 +++++++++++++++++- python/pecos-rslib/pecos_rslib.pyi | 1 + .../pecos-rslib/src/dag_circuit_bindings.rs | 8 + .../src/pecos/qec/surface/circuit_builder.py | 7 + .../qec/test_traced_qis_clifford_pipeline.py | 34 ++ 5 files changed, 376 insertions(+), 1 deletion(-) diff --git a/crates/pecos-quantum/src/pass.rs b/crates/pecos-quantum/src/pass.rs index 98160d49b..879ab67ab 100644 --- a/crates/pecos-quantum/src/pass.rs +++ b/crates/pecos-quantum/src/pass.rs @@ -21,7 +21,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use pecos_core::gate_type::GateType; -use pecos_core::{Angle64, Gate, GateQubits, QubitId}; +use pecos_core::{Angle64, Clifford, Gate, GateQubits, QubitId}; use crate::{Attribute, DagCircuit, Tick, TickCircuit}; @@ -106,6 +106,11 @@ pub fn absorb_basis_gates(circuit: &mut TickCircuit) { AbsorbBasisGates.apply_tick(circuit); } +/// Simplify adjacent single-qubit Clifford chains on each qubit. +pub fn simplify_single_qubit_clifford_chains(circuit: &mut TickCircuit) { + SimplifySingleQubitCliffordChains.apply_tick(circuit); +} + /// Compact ticks by ASAP scheduling (merge gates into earlier ticks). pub fn compact_ticks(circuit: &mut TickCircuit) { CompactTicks.apply_tick(circuit); @@ -1169,6 +1174,237 @@ impl CircuitPass for AbsorbBasisGates { } } +const SINGLE_QUBIT_CLIFFORD_CANDIDATES: [GateType; 12] = [ + GateType::Z, + GateType::SZ, + GateType::SZdg, + GateType::X, + GateType::Y, + GateType::H, + GateType::SX, + GateType::SXdg, + GateType::SY, + GateType::SYdg, + GateType::F, + GateType::Fdg, +]; + +#[derive(Clone, Debug)] +struct SingleQubitCliffordChain { + positions: Vec<(usize, usize)>, + gates: Vec, + product: Clifford, +} + +impl SingleQubitCliffordChain { + fn new(position: (usize, usize), gate_type: GateType, clifford: Clifford) -> Self { + Self { + positions: vec![position], + gates: vec![gate_type], + product: clifford, + } + } + + fn push(&mut self, position: (usize, usize), gate_type: GateType, clifford: Clifford) { + self.positions.push(position); + self.gates.push(gate_type); + self.product = clifford.compose(self.product); + } +} + +fn gate_type_to_single_qubit_clifford(gate_type: GateType) -> Option { + match gate_type { + GateType::I => Some(Clifford::I), + GateType::X => Some(Clifford::X), + GateType::Y => Some(Clifford::Y), + GateType::Z => Some(Clifford::Z), + GateType::H => Some(Clifford::H), + GateType::SX => Some(Clifford::SX), + GateType::SXdg => Some(Clifford::SXdg), + GateType::SY => Some(Clifford::SY), + GateType::SYdg => Some(Clifford::SYdg), + GateType::SZ => Some(Clifford::SZ), + GateType::SZdg => Some(Clifford::SZdg), + GateType::F => Some(Clifford::F), + GateType::Fdg => Some(Clifford::Fdg), + _ => None, + } +} + +fn plain_single_qubit_clifford_gate(gate: &Gate) -> Option { + if gate.qubits.len() != 1 + || !gate.angles.is_empty() + || !gate.params.is_empty() + || !gate.meas_ids.is_empty() + || gate.channel.is_some() + { + return None; + } + gate_type_to_single_qubit_clifford(gate.gate_type) +} + +fn single_qubit_clifford_sequence_product(sequence: &[GateType]) -> Clifford { + let mut product = Clifford::I; + for &gate_type in sequence { + let clifford = gate_type_to_single_qubit_clifford(gate_type) + .expect("candidate gate types must be one-qubit Cliffords"); + product = clifford.compose(product); + } + product +} + +fn is_z_axis_frame_candidate(gate_type: GateType) -> bool { + matches!( + gate_type, + GateType::I | GateType::Z | GateType::SZ | GateType::SZdg + ) +} + +fn single_qubit_clifford_sequence_score(sequence: &[GateType]) -> (usize, usize) { + let non_frame_count = sequence + .iter() + .filter(|&&gate_type| !is_z_axis_frame_candidate(gate_type)) + .count(); + (non_frame_count, sequence.len()) +} + +fn canonical_single_qubit_clifford_sequence(clifford: Clifford) -> Vec { + if clifford == Clifford::I { + return Vec::new(); + } + + let mut best_sequence: Option> = None; + let mut best_score: Option<(usize, usize)> = None; + + for &candidate in &SINGLE_QUBIT_CLIFFORD_CANDIDATES { + let sequence = vec![candidate]; + if single_qubit_clifford_sequence_product(&sequence) == clifford { + let score = single_qubit_clifford_sequence_score(&sequence); + if best_score.is_none_or(|best| score < best) { + best_score = Some(score); + best_sequence = Some(sequence); + } + } + } + + for &first in &SINGLE_QUBIT_CLIFFORD_CANDIDATES { + for &second in &SINGLE_QUBIT_CLIFFORD_CANDIDATES { + let sequence = vec![first, second]; + if single_qubit_clifford_sequence_product(&sequence) == clifford { + let score = single_qubit_clifford_sequence_score(&sequence); + if best_score.is_none_or(|best| score < best) { + best_score = Some(score); + best_sequence = Some(sequence); + } + } + } + } + + best_sequence.unwrap_or_else(|| { + panic!("no existing-gate decomposition found for one-qubit Clifford {clifford}") + }) +} + +fn flush_single_qubit_clifford_chain( + chain: SingleQubitCliffordChain, + replacements: &mut BTreeMap<(usize, usize), GateType>, + to_remove: &mut HashSet<(usize, usize)>, +) { + if chain.positions.len() < 2 { + return; + } + + let canonical = canonical_single_qubit_clifford_sequence(chain.product); + let original_score = single_qubit_clifford_sequence_score(&chain.gates); + let canonical_score = single_qubit_clifford_sequence_score(&canonical); + if canonical_score >= original_score || canonical.len() > chain.positions.len() { + return; + } + + for (position, gate_type) in chain.positions.iter().zip(canonical.iter()) { + replacements.insert(*position, *gate_type); + } + for position in chain.positions.iter().skip(canonical.len()) { + to_remove.insert(*position); + } +} + +/// Simplify adjacent single-qubit Clifford chains on each qubit. +/// +/// The pass follows each qubit's operation timeline, composes adjacent plain +/// one-qubit Clifford gates exactly, and replaces the chain with a deterministic +/// sequence over existing PECOS gate names. Gates carrying parameters, +/// measurement IDs, channel payloads, or batch metadata are treated as barriers. +pub struct SimplifySingleQubitCliffordChains; + +impl CircuitPass for SimplifySingleQubitCliffordChains { + fn apply_tick(&self, circuit: &mut TickCircuit) { + split_batched_tick_commands(circuit); + + let mut pending: BTreeMap = BTreeMap::new(); + let mut replacements: BTreeMap<(usize, usize), GateType> = BTreeMap::new(); + let mut to_remove: HashSet<(usize, usize)> = HashSet::new(); + + for (ti, tick) in circuit.iter_ticks() { + for gate_ref in tick.iter_gate_batches() { + let position = (ti, gate_ref.batch_index()); + let gate = gate_ref.as_gate(); + + if gate_ref.attrs().next().is_none() + && let Some(clifford) = plain_single_qubit_clifford_gate(gate) + { + let qubit = gate.qubits[0]; + if let Some(chain) = pending.get_mut(&qubit) { + chain.push(position, gate.gate_type, clifford); + } else { + pending.insert( + qubit, + SingleQubitCliffordChain::new(position, gate.gate_type, clifford), + ); + } + continue; + } + + for &qubit in &gate.qubits { + if let Some(chain) = pending.remove(&qubit) { + flush_single_qubit_clifford_chain(chain, &mut replacements, &mut to_remove); + } + } + } + } + + for (_, chain) in pending { + flush_single_qubit_clifford_chain(chain, &mut replacements, &mut to_remove); + } + + for (&(ti, gi), &gate_type) in &replacements { + if let Some(tick) = circuit.get_tick_mut(ti) { + tick.update_gate_batch(gi, |gate| { + gate.gate_type = gate_type; + gate.angles.clear(); + gate.params.clear(); + gate.meas_ids.clear(); + gate.channel = None; + }) + .unwrap_or_else(|err| panic!("{err}")); + } + } + + let mut remove_list: Vec<(usize, usize)> = to_remove.into_iter().collect(); + remove_list.sort_unstable(); + for &(ti, gi) in remove_list.iter().rev() { + if let Some(tick) = circuit.get_tick_mut(ti) { + tick.remove_gate(gi); + } + } + } + + fn apply_dag(&self, _circuit: &mut DagCircuit) { + // Tick-only for now: this pass intentionally preserves tick-local + // metadata and rewrites concrete scheduled gate positions. + } +} + /// ASAP-schedule gates to minimise tick count, then drop empty ticks. /// /// For each gate (processed in original tick order), the pass assigns it to @@ -1823,6 +2059,8 @@ mod tests { GateType::SYdg => Some(unitary_rep::SY(q0).dg()), GateType::SZ => Some(unitary_rep::SZ(q0)), GateType::SZdg => Some(unitary_rep::SZ(q0).dg()), + GateType::F => Some(unitary_rep::SZ(q0) * unitary_rep::SX(q0)), + GateType::Fdg => Some(unitary_rep::SX(q0).dg() * unitary_rep::SZ(q0).dg()), GateType::T => Some(unitary_rep::T(q0)), GateType::Tdg => Some(unitary_rep::T(q0).dg()), GateType::RX => { @@ -2885,6 +3123,93 @@ mod tests { assert!(saw_untouched); } + #[test] + fn single_qubit_clifford_canonical_sequences_cover_all_1q() { + for &clifford in Clifford::all_1q() { + let sequence = canonical_single_qubit_clifford_sequence(clifford); + assert_eq!( + single_qubit_clifford_sequence_product(&sequence), + clifford, + "canonical sequence {sequence:?} does not implement {clifford}" + ); + assert!( + sequence.len() <= 2, + "canonical sequence for {clifford} should use at most two gates" + ); + } + } + + #[test] + fn simplify_single_qubit_clifford_chains_reduces_batched_chains() { + let mut original = TickCircuit::new(); + original.tick().sx(&[0, 1]); + original.tick().sz(&[0, 1]); + + let mut simplified = original.clone(); + SimplifySingleQubitCliffordChains.apply_tick(&mut simplified); + + assert_circuits_equiv(&original, &simplified); + let gates: Vec<&Gate> = simplified + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gate_batches) + .collect(); + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|gate| gate.gate_type == GateType::F)); + } + + #[test] + fn simplify_single_qubit_clifford_chains_removes_identity_products() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().h(&[0]); + + SimplifySingleQubitCliffordChains.apply_tick(&mut tc); + + assert!(tc.ticks().iter().all(|tick| tick.is_empty())); + } + + #[test] + fn simplify_single_qubit_clifford_chains_respects_multi_qubit_barriers() { + let mut tc = TickCircuit::new(); + tc.tick().sx(&[0]); + tc.tick().cx(&[(0, 1)]); + tc.tick().sz(&[0]); + + SimplifySingleQubitCliffordChains.apply_tick(&mut tc); + + let gate_types: Vec = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gate_batches) + .map(|gate| gate.gate_type) + .collect(); + assert_eq!(gate_types, vec![GateType::SX, GateType::CX, GateType::SZ]); + } + + #[test] + fn simplify_single_qubit_clifford_chains_preserves_annotated_gates() { + let mut tc = TickCircuit::new(); + tc.tick() + .sx(&[0]) + .meta("role", Attribute::String("calibrated".into())); + tc.tick().sz(&[0]); + + SimplifySingleQubitCliffordChains.apply_tick(&mut tc); + + let gate_types: Vec = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gate_batches) + .map(|gate| gate.gate_type) + .collect(); + assert_eq!(gate_types, vec![GateType::SX, GateType::SZ]); + assert_eq!( + tc.get_tick(0).unwrap().get_gate_attr(0, "role"), + Some(&Attribute::String("calibrated".into())) + ); + } + #[test] fn split_batched_tick_commands_preserves_payloads_attrs_and_counters() { let mut tc = TickCircuit::new(); diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index 48a19cb51..f5a792e9f 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -1668,6 +1668,7 @@ class TickCircuit: def merge_adjacent_rotations(self) -> None: ... def peephole_optimize(self) -> None: ... def absorb_basis_gates(self) -> None: ... + def simplify_single_qubit_clifford_chains(self) -> None: ... def assign_missing_meas_ids(self) -> None: ... def insert_idle_after_two_qubit_gates(self, duration: float = 1.0) -> None: ... def fill_idle_gates(self) -> None: ... diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index 153a68991..50f110181 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -2718,6 +2718,14 @@ impl PyTickCircuit { AbsorbBasisGates.apply_tick(&mut self.inner); } + /// Simplify adjacent single-qubit Clifford chains. + /// + /// Modifies the circuit in place. + fn simplify_single_qubit_clifford_chains(&mut self) { + use pecos_quantum::pass::{CircuitPass, SimplifySingleQubitCliffordChains}; + SimplifySingleQubitCliffordChains.apply_tick(&mut self.inner); + } + /// Assign MeasId to measurement gates that don't have them. /// /// Use on circuits from external sources (QIS trace, Stim import) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 2853ee98f..980df977e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -2714,6 +2714,7 @@ def normalize_traced_qis_tick_circuit( tick_circuit: object, *, context: str = "traced-QIS DEM construction", + simplify_single_qubit_clifford_chains: bool = True, ) -> None: """Normalize a traced-QIS TickCircuit before DEM/DAG analysis. @@ -2723,6 +2724,12 @@ def normalize_traced_qis_tick_circuit( this helper at every traced-QIS boundary before converting to a DAG. """ _call_required_tick_circuit_method(tick_circuit, "lower_clifford_rotations", context) + if simplify_single_qubit_clifford_chains: + _call_required_tick_circuit_method( + tick_circuit, + "simplify_single_qubit_clifford_chains", + context, + ) _call_required_tick_circuit_method(tick_circuit, "assign_missing_meas_ids", context) assert_traced_qis_tick_circuit_dem_ready(tick_circuit, context=context) diff --git a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py index 8ca5eb525..25c162953 100644 --- a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py +++ b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py @@ -286,6 +286,40 @@ def test_tick_circuit_pass_bindings_absorb_basis_and_peephole(): assert _gate_names(optimized) == ["CZ"] +def test_tick_circuit_pass_bindings_simplify_single_qubit_clifford_chains(): + tc = TickCircuit() + tc.tick().sx([0]) + tc.tick().sz([0]) + + tc.simplify_single_qubit_clifford_chains() + + assert _gate_names(tc) == ["F"] + + +def test_normalize_traced_qis_tick_circuit_simplifies_single_qubit_clifford_chains(): + tc = TickCircuit() + tc.tick().sx([0]) + tc.tick().sz([0]) + + normalize_traced_qis_tick_circuit(tc, context="test one-qubit Clifford normalization") + + assert _gate_names(tc) == ["F"] + + +def test_normalize_traced_qis_tick_circuit_can_skip_single_qubit_clifford_simplification(): + tc = TickCircuit() + tc.tick().sx([0]) + tc.tick().sz([0]) + + normalize_traced_qis_tick_circuit( + tc, + context="test one-qubit Clifford normalization", + simplify_single_qubit_clifford_chains=False, + ) + + assert _gate_names(tc) == ["SX", "SZ"] + + def test_explicit_python_gate_names_map_to_rust_clifford_gates(): tc = build_explicit_clifford_gate_circuit() noise = depolarizing().p1(0.03).p2(0.15).p_meas(0).p_prep(0) From 0f6a41b6e96ae3949b77b68813f9eaaacfd4a07a Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 13 Jun 2026 15:41:39 -0600 Subject: [PATCH 092/150] Support averaged global measurement crosstalk DEM replay --- .../fault_tolerance/dem_builder/builder.rs | 505 ++++++++++++++---- .../src/fault_tolerance/dem_builder/types.rs | 12 +- .../src/fault_tolerance/influence_builder.rs | 46 +- .../src/fault_tolerance/propagator/dag.rs | 17 +- .../src/fault_tolerance_bindings.rs | 8 +- .../src/pecos/qec/surface/decode.py | 96 +++- .../tests/qec/test_from_guppy_dem.py | 53 ++ 7 files changed, 623 insertions(+), 114 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 48ced572a..1e7d35155 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -21,11 +21,12 @@ use super::types::{ ReplacementBranchApproximation, SourceMetadata, record_offset_to_absolute_index, }; use crate::fault_tolerance::propagator::dag::DagSpacetimeLocation; -use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Pauli}; +use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Direction, Pauli, apply_gate}; use pecos_core::BitSet; use pecos_core::gate_type::GateType; use pecos_simulators::{ - SymbolicMeasurementResult, SymbolicSparseStab, symbolic_sparse_stab::MeasurementHistory, + PauliProp, SymbolicMeasurementResult, SymbolicSparseStab, + symbolic_sparse_stab::MeasurementHistory, }; use smallvec::SmallVec; use std::cell::RefCell; @@ -885,13 +886,6 @@ impl<'a> DemBuilder<'a> { )); } - if self.noise.p_meas_crosstalk_global > 0.0 { - return Err(DemBuilderError::ConfigurationError( - "exact deterministic measurement crosstalk DEM replay does not yet support global payloads" - .to_string(), - )); - } - if self.noise.p_meas_crosstalk_local > 0.0 && !has_local_payloads { return Err(DemBuilderError::ConfigurationError( "exact deterministic measurement crosstalk DEM replay requested a positive local rate, but the influence map contains no MeasCrosstalkLocalPayload locations" @@ -899,13 +893,16 @@ impl<'a> DemBuilder<'a> { )); } - if self.noise.p_meas_crosstalk_local <= 0.0 { + if self.noise.p_meas_crosstalk_local <= 0.0 && self.noise.p_meas_crosstalk_global <= 0.0 { return Ok(()); } if self.noise.p_meas_crosstalk_model.has_leakage() - && self.noise.measurement_crosstalk_dem_mode - != MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + && !matches!( + self.noise.measurement_crosstalk_dem_mode, + MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + | MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing + ) { return Err(DemBuilderError::ConfigurationError( "exact deterministic measurement crosstalk DEM replay does not yet support leakage transitions" @@ -913,15 +910,34 @@ impl<'a> DemBuilder<'a> { )); } + let needs_circuit_context = + self.noise.measurement_crosstalk_dem_mode != MeasurementCrosstalkDemMode::Omitted; let Some(context) = self.exact_branch_context else { - return Err(DemBuilderError::ConfigurationError( - "exact deterministic measurement crosstalk DEM replay requires a circuit-aware builder context" - .to_string(), - )); + if needs_circuit_context { + return Err(DemBuilderError::ConfigurationError( + "measurement crosstalk DEM replay requires a circuit-aware builder context" + .to_string(), + )); + } + return Ok(()); }; + let requires_deterministic_hidden_measurements = matches!( + self.noise.measurement_crosstalk_dem_mode, + MeasurementCrosstalkDemMode::ExactDeterministic + | MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + ); + if !requires_deterministic_hidden_measurements { + return Ok(()); + } + for (loc_idx, loc) in self.influence_map.locations.iter().enumerate() { - if loc.before || loc.gate_type != GateType::MeasCrosstalkLocalPayload { + if loc.before + || !matches!( + loc.gate_type, + GateType::MeasCrosstalkLocalPayload | GateType::MeasCrosstalkGlobalPayload + ) + { continue; } let result = self.hidden_mz_result_before_crosstalk_payload(context, loc)?; @@ -989,6 +1005,114 @@ impl<'a> DemBuilder<'a> { ))) } + fn exact_measurement_crosstalk_pauli_effect( + &self, + context: ExactBranchReplayContext<'_>, + loc: &DagSpacetimeLocation, + pauli: Pauli, + ) -> Result { + let mut triggered_dets: SmallVec<[u32; 4]> = SmallVec::new(); + let mut triggered_obs: SmallVec<[u32; 2]> = SmallVec::new(); + + for detector in &self.detectors { + let indices = + self.measurement_indices_from_refs(&detector.records, &detector.meas_ids)?; + if self.measurement_parity_anticommutes_after_crosstalk_payload( + context, loc, pauli, &indices, + )? { + xor_toggle_4(&mut triggered_dets, detector.id); + } + } + + for observable in &self.observables { + let indices = + self.measurement_indices_from_refs(&observable.records, &observable.meas_ids)?; + if self.measurement_parity_anticommutes_after_crosstalk_payload( + context, loc, pauli, &indices, + )? { + xor_toggle_2(&mut triggered_obs, observable.id); + } + } + + triggered_dets.sort_unstable(); + triggered_obs.sort_unstable(); + Ok(FaultMechanism::from_sorted_with_tracked_paulis( + triggered_dets, + triggered_obs, + SmallVec::new(), + )) + } + + fn measurement_parity_anticommutes_after_crosstalk_payload( + &self, + context: ExactBranchReplayContext<'_>, + loc: &DagSpacetimeLocation, + pauli: Pauli, + measurement_indices: &[usize], + ) -> Result { + let victim = loc.qubits.first().copied().ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "measurement crosstalk payload at node {} has no victim qubit", + loc.node + )) + })?; + let victim_index = victim.index(); + let mut prop = PauliProp::new(); + for &measurement_idx in measurement_indices { + let &(_, qubit, basis) = self + .influence_map + .measurements + .get(measurement_idx) + .ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "measurement crosstalk exact replay has no measurement {measurement_idx}" + )) + })?; + match basis { + 0 => prop.track_z(&[qubit]), + 1 => prop.track_x(&[qubit]), + other => { + return Err(DemBuilderError::ConfigurationError(format!( + "measurement crosstalk exact replay does not support measurement basis {other}" + ))); + } + } + } + + let topo_order = context.circuit.topological_order(); + let payload_pos = topo_order + .iter() + .position(|&node| node == loc.node) + .ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "measurement crosstalk payload node {} was not found in the replay circuit", + loc.node + )) + })?; + for &node in topo_order[payload_pos + 1..].iter().rev() { + if let Some(gate) = context.circuit.gate(node) { + apply_gate(&mut prop, gate, Direction::Backward); + } + } + + Ok(Self::pauli_anticommutes_with_prop_on_qubit( + &prop, + victim_index, + pauli, + )) + } + + fn pauli_anticommutes_with_prop_on_qubit(prop: &PauliProp, qubit: usize, pauli: Pauli) -> bool { + let has_x = prop.contains_x(qubit); + let has_z = prop.contains_z(qubit); + match pauli { + Pauli::I => false, + Pauli::X => has_z, + Pauli::Z => has_x, + Pauli::Y => has_x ^ has_z, + } + } + fn apply_symbolic_gate_for_crosstalk_hidden_mz( sim: &mut SymbolicSparseStab, node: usize, @@ -1007,105 +1131,112 @@ impl<'a> DemBuilder<'a> { } Ok(()) }; + let pairs = || -> Result, DemBuilderError> { + require(2)?; + if qubits.len() % 2 != 0 { + return Err(DemBuilderError::ConfigurationError(format!( + "measurement crosstalk replay expected gate {:?} at node {} to have an even number of qubits, got {}", + gate_type, + node, + qubits.len() + ))); + } + Ok(qubits + .chunks_exact(2) + .map(|pair| (pair[0], pair[1])) + .collect()) + }; match gate_type { GateType::H => { require(1)?; - sim.h(&[qubits[0]]); + sim.h(qubits); } GateType::F => { require(1)?; - sim.sx(&[qubits[0]]); - sim.sz(&[qubits[0]]); + sim.sx(qubits); + sim.sz(qubits); } GateType::Fdg => { require(1)?; - sim.szdg(&[qubits[0]]); - sim.sxdg(&[qubits[0]]); + sim.szdg(qubits); + sim.sxdg(qubits); } GateType::SX => { require(1)?; - sim.sx(&[qubits[0]]); + sim.sx(qubits); } GateType::SXdg => { require(1)?; - sim.sxdg(&[qubits[0]]); + sim.sxdg(qubits); } GateType::SY => { require(1)?; - sim.sy(&[qubits[0]]); + sim.sy(qubits); } GateType::SYdg => { require(1)?; - sim.sydg(&[qubits[0]]); + sim.sydg(qubits); } GateType::SZ => { require(1)?; - sim.sz(&[qubits[0]]); + sim.sz(qubits); } GateType::SZdg => { require(1)?; - sim.szdg(&[qubits[0]]); + sim.szdg(qubits); } GateType::X => { require(1)?; - sim.x(&[qubits[0]]); + sim.x(qubits); } GateType::Y => { require(1)?; - sim.y(&[qubits[0]]); + sim.y(qubits); } GateType::Z => { require(1)?; - sim.z(&[qubits[0]]); + sim.z(qubits); } GateType::CX => { - require(2)?; - sim.cx(&[(qubits[0], qubits[1])]); + sim.cx(&pairs()?); } GateType::CY => { - require(2)?; - sim.cy(&[(qubits[0], qubits[1])]); + sim.cy(&pairs()?); } GateType::CZ => { - require(2)?; - sim.cz(&[(qubits[0], qubits[1])]); + sim.cz(&pairs()?); } GateType::SXX => { - require(2)?; - sim.sxx(&[(qubits[0], qubits[1])]); + sim.sxx(&pairs()?); } GateType::SXXdg => { - require(2)?; - sim.sxxdg(&[(qubits[0], qubits[1])]); + sim.sxxdg(&pairs()?); } GateType::SYY => { - require(2)?; - sim.syy(&[(qubits[0], qubits[1])]); + sim.syy(&pairs()?); } GateType::SYYdg => { - require(2)?; - sim.syydg(&[(qubits[0], qubits[1])]); + sim.syydg(&pairs()?); } GateType::SZZ => { - require(2)?; - sim.szz(&[(qubits[0], qubits[1])]); + sim.szz(&pairs()?); } GateType::SZZdg => { - require(2)?; - sim.szzdg(&[(qubits[0], qubits[1])]); + sim.szzdg(&pairs()?); } GateType::SWAP => { - require(2)?; - sim.swap(&[(qubits[0], qubits[1])]); + sim.swap(&pairs()?); } GateType::MZ | GateType::MeasureFree => { require(1)?; - sim.mz(&[qubits[0]]); + sim.mz(qubits); } GateType::PZ | GateType::QAlloc => { require(1)?; - sim.pz(qubits[0]); + for &qubit in qubits { + sim.pz(qubit); + } } GateType::I | GateType::Idle @@ -1312,11 +1443,31 @@ impl<'a> DemBuilder<'a> { self.noise.measurement_crosstalk_dem_mode, MeasurementCrosstalkDemMode::ExactDeterministic | MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + | MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing ) && self.noise.p_meas_crosstalk_local > 0.0 => { - self.process_measurement_crosstalk_local_source_tracked( + self.process_measurement_crosstalk_source_tracked( loc_idx, + self.noise.p_meas_crosstalk_local, + dem, + meas_to_detectors, + meas_to_observables, + ); + } + GateType::MeasCrosstalkGlobalPayload + if !loc.before + && matches!( + self.noise.measurement_crosstalk_dem_mode, + MeasurementCrosstalkDemMode::ExactDeterministic + | MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + | MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing + ) + && self.noise.p_meas_crosstalk_global > 0.0 => + { + self.process_measurement_crosstalk_source_tracked( + loc_idx, + self.noise.p_meas_crosstalk_global, dem, meas_to_detectors, meas_to_observables, @@ -1559,55 +1710,70 @@ impl<'a> DemBuilder<'a> { /// Processes local measurement-crosstalk payloads when hidden outcomes are /// deterministic and state-independent. - fn process_measurement_crosstalk_local_source_tracked( + fn process_measurement_crosstalk_source_tracked( &self, loc_idx: usize, + payload_rate: f64, dem: &mut DetectorErrorModel, meas_to_detectors: &BTreeMap>, meas_to_observables: &BTreeMap>, ) { - let context = self - .exact_branch_context - .expect("measurement crosstalk exact deterministic mode was validated with context"); - let loc = &self.influence_map.locations[loc_idx]; - let hidden = self - .hidden_mz_result_before_crosstalk_payload(context, loc) - .expect("measurement crosstalk exact deterministic hidden result was validated"); - let (bit_flip_probability, leak_probability) = if hidden.flip { - ( - self.noise.p_meas_crosstalk_model.p_1_to_0, - self.noise.p_meas_crosstalk_model.p_1_to_leak, - ) - } else { - ( - self.noise.p_meas_crosstalk_model.p_0_to_1, - self.noise.p_meas_crosstalk_model.p_0_to_leak, - ) - }; - if self.noise.measurement_crosstalk_dem_mode - == MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing - { + let (bit_flip_probability, leak_probability) = + match self.noise.measurement_crosstalk_dem_mode { + MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing => ( + 0.5 * (self.noise.p_meas_crosstalk_model.p_0_to_1 + + self.noise.p_meas_crosstalk_model.p_1_to_0), + 0.5 * (self.noise.p_meas_crosstalk_model.p_0_to_leak + + self.noise.p_meas_crosstalk_model.p_1_to_leak), + ), + MeasurementCrosstalkDemMode::ExactDeterministic + | MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing => { + let context = self.exact_branch_context.expect( + "measurement crosstalk exact deterministic mode was validated with context", + ); + let loc = &self.influence_map.locations[loc_idx]; + let hidden = self + .hidden_mz_result_before_crosstalk_payload(context, loc) + .expect( + "measurement crosstalk exact deterministic hidden result was validated", + ); + if hidden.flip { + ( + self.noise.p_meas_crosstalk_model.p_1_to_0, + self.noise.p_meas_crosstalk_model.p_1_to_leak, + ) + } else { + ( + self.noise.p_meas_crosstalk_model.p_0_to_1, + self.noise.p_meas_crosstalk_model.p_0_to_leak, + ) + } + } + MeasurementCrosstalkDemMode::Omitted => { + return; + } + }; + if matches!( + self.noise.measurement_crosstalk_dem_mode, + MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + | MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing + ) { let leak_pauli_probability = leak_probability / 4.0; - self.process_measurement_crosstalk_local_pauli_rates_source_tracked( + self.process_measurement_crosstalk_pauli_rates_source_tracked( loc_idx, [ - self.noise.p_meas_crosstalk_local - * (bit_flip_probability + leak_pauli_probability), - self.noise.p_meas_crosstalk_local * leak_pauli_probability, - self.noise.p_meas_crosstalk_local * leak_pauli_probability, + payload_rate * (bit_flip_probability + leak_pauli_probability), + payload_rate * leak_pauli_probability, + payload_rate * leak_pauli_probability, ], dem, meas_to_detectors, meas_to_observables, ); } else { - self.process_measurement_crosstalk_local_pauli_rates_source_tracked( + self.process_measurement_crosstalk_pauli_rates_source_tracked( loc_idx, - [ - self.noise.p_meas_crosstalk_local * bit_flip_probability, - 0.0, - 0.0, - ], + [payload_rate * bit_flip_probability, 0.0, 0.0], dem, meas_to_detectors, meas_to_observables, @@ -1617,7 +1783,7 @@ impl<'a> DemBuilder<'a> { /// Processes local measurement-crosstalk payloads as single-location Pauli /// source channels while preserving crosstalk source metadata. - fn process_measurement_crosstalk_local_pauli_rates_source_tracked( + fn process_measurement_crosstalk_pauli_rates_source_tracked( &self, loc_idx: usize, rates: [f64; 3], @@ -1627,10 +1793,19 @@ impl<'a> DemBuilder<'a> { ) { let loc = &self.influence_map.locations[loc_idx]; let [rate_x, rate_y, rate_z] = rates; - let x_effect = - self.compute_mechanism(loc_idx, Pauli::X, meas_to_detectors, meas_to_observables); - let z_effect = - self.compute_mechanism(loc_idx, Pauli::Z, meas_to_detectors, meas_to_observables); + let effect = |pauli| -> FaultMechanism { + if loc.gate_type == GateType::MeasCrosstalkGlobalPayload { + let context = self.exact_branch_context.expect( + "measurement crosstalk exact deterministic mode was validated with context", + ); + self.exact_measurement_crosstalk_pauli_effect(context, loc, pauli) + .expect("global measurement crosstalk exact replay was validated") + } else { + self.compute_mechanism(loc_idx, pauli, meas_to_detectors, meas_to_observables) + } + }; + let x_effect = effect(Pauli::X); + let z_effect = effect(Pauli::Z); if rate_x > 0.0 && !x_effect.is_empty() { dem.add_direct_contribution_with_source( @@ -2993,13 +3168,30 @@ fn omitted_branch_flips_measurement_parity_from_histories( ideal_history: &MeasurementHistory, branch_history: &MeasurementHistory, measurement_indices: &[usize], +) -> Result { + measurement_parity_differs_from_histories( + ideal_history, + branch_history, + measurement_indices, + &format!( + "exact_branch_replay omitted gate at node {}", + request.gate_node + ), + ) +} + +fn measurement_parity_differs_from_histories( + ideal_history: &MeasurementHistory, + branch_history: &MeasurementHistory, + measurement_indices: &[usize], + context: &str, ) -> Result { let ideal = measurement_parity_expression(ideal_history, measurement_indices, "ideal")?; let branch = measurement_parity_expression(branch_history, measurement_indices, "branch")?; if ideal.dependencies != branch.dependencies { return Err(DemBuilderError::ConfigurationError(format!( - "exact_branch_replay omitted gate at node {} changes measurement dependencies for parity {:?}; this branch is not representable as a single deterministic DEM event", - request.gate_node, measurement_indices + "{context} changes measurement dependencies for parity {:?}; this branch is not representable as a single deterministic DEM event", + measurement_indices ))); } Ok(ideal.flip ^ branch.flip) @@ -4086,7 +4278,12 @@ mod tests { .expect("exact branch replay should emit representable branch effects"); let contributions = dem.contributions_for_effect(&[0], &[]); - assert_eq!(contributions.len(), 1); + assert_eq!( + contributions.len(), + 1, + "all effects:\n{}", + dem.all_contribution_effects() + ); assert!((contributions[0].probability - 0.01).abs() < 1.0e-12); assert!(contributions[0].replacement_branch); assert_eq!( @@ -4145,6 +4342,41 @@ mod tests { circuit } + fn two_qubit_global_crosstalk_circuit() -> pecos_quantum::DagCircuit { + use pecos_core::{Gate, QubitId}; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(1)])); + circuit.add_gate_auto_wire(Gate::meas_crosstalk_global_payload(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::mz(&[QubitId(1)])); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + circuit + } + + fn two_qubit_global_crosstalk_random_victim_circuit() -> pecos_quantum::DagCircuit { + use pecos_core::{Gate, QubitId}; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(1)])); + circuit.add_gate_auto_wire(Gate::h(&[QubitId(1)])); + circuit.add_gate_auto_wire(Gate::meas_crosstalk_global_payload(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::mz(&[QubitId(1)])); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + circuit + } + #[test] fn test_exact_deterministic_local_measurement_crosstalk_emits_dem_source() { use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; @@ -4161,7 +4393,12 @@ mod tests { .expect("deterministic local measurement crosstalk should be representable"); let contributions = dem.contributions_for_effect(&[0], &[]); - assert_eq!(contributions.len(), 1); + assert_eq!( + contributions.len(), + 1, + "all effects:\n{}", + dem.all_contribution_effects() + ); assert!((contributions[0].probability - 0.1).abs() < 1.0e-12); assert_eq!( contributions[0].direct_source_family, @@ -4174,6 +4411,40 @@ mod tests { assert_eq!(contributions[0].paulis.as_slice(), &[Pauli::X]); } + #[test] + fn test_exact_deterministic_global_measurement_crosstalk_emits_victim_dem_source() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = two_qubit_global_crosstalk_circuit(); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_global_rate(0.25) + .set_measurement_crosstalk_transition_model( + MeasurementCrosstalkTransitionModel::bit_flip(0.4, 0.0), + ) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("deterministic global measurement crosstalk should be representable"); + + let contributions = dem.contributions_for_effect(&[0], &[]); + assert_eq!( + contributions.len(), + 1, + "all effects:\n{}", + dem.all_contribution_effects() + ); + assert!((contributions[0].probability - 0.1).abs() < 1.0e-12); + assert_eq!( + contributions[0].direct_source_family, + Some(DirectSourceFamily::MeasurementCrosstalk) + ); + assert_eq!( + contributions[0].source_gate_types.as_slice(), + &[GateType::MeasCrosstalkGlobalPayload] + ); + assert_eq!(contributions[0].paulis.as_slice(), &[Pauli::X]); + } + #[test] fn test_exact_deterministic_local_measurement_crosstalk_requires_payloads() { use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; @@ -4261,6 +4532,48 @@ mod tests { ); } + #[test] + fn test_averaged_global_measurement_crosstalk_accepts_hidden_randomness() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = two_qubit_global_crosstalk_random_victim_circuit(); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_global_rate(0.25) + .set_measurement_crosstalk_transition_model(MeasurementCrosstalkTransitionModel { + p_0_to_1: 0.4, + p_0_to_leak: 0.2, + p_1_to_0: 0.0, + p_1_to_leak: 0.0, + }) + .set_measurement_crosstalk_dem_mode( + MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing, + ); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("averaged global leak2depolar crosstalk should handle random hidden MZ"); + + let contributions = dem.contributions_for_effect(&[0], &[]); + assert_eq!( + contributions.len(), + 2, + "all effects:\n{}", + dem.all_contribution_effects() + ); + assert!(contributions.iter().any(|contribution| { + contribution.paulis.as_slice() == [Pauli::X] + && (contribution.probability - 0.05625).abs() < 1.0e-12 + })); + assert!(contributions.iter().any(|contribution| { + contribution.paulis.as_slice() == [Pauli::Y] + && (contribution.probability - 0.00625).abs() < 1.0e-12 + })); + assert!(contributions.iter().all(|contribution| { + contribution.direct_source_family == Some(DirectSourceFamily::MeasurementCrosstalk) + && contribution.source_gate_types.as_slice() + == [GateType::MeasCrosstalkGlobalPayload] + })); + } + #[test] fn test_exact_deterministic_local_measurement_crosstalk_rejects_leakage() { use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; 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 fe5fbd655..aa08b7c00 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -2519,11 +2519,6 @@ pub struct NoiseConfig { /// DEM replay is enabled. pub p_meas_crosstalk_local: f64, /// Per-payload global measurement-crosstalk event rate. - /// - /// Global payload DEM replay is intentionally not implemented yet because - /// the source semantics need to be represented explicitly. If exact - /// crosstalk DEM replay is requested with a positive global rate, the DEM - /// builder fails loudly. pub p_meas_crosstalk_global: f64, /// Hidden-measurement transition probabilities used by measurement /// crosstalk DEM replay. @@ -2545,6 +2540,13 @@ pub enum MeasurementCrosstalkDemMode { /// transitions as the `leak2depolar` replacement channel: one quarter each /// of I, X, Y, and Z on the victim qubit. ExactDeterministicLeakageAsDepolarizing, + /// Replay payloads by averaging over the hidden measurement outcome while + /// treating leakage transitions as the `leak2depolar` replacement channel. + /// + /// This is appropriate when the hidden measurement is state-dependent + /// rather than deterministic, as happens for global measurement crosstalk + /// victims in generic circuits. + AveragedHiddenLeakageAsDepolarizing, } /// Hidden-measurement transition probabilities for local measurement crosstalk. diff --git a/crates/pecos-qec/src/fault_tolerance/influence_builder.rs b/crates/pecos-qec/src/fault_tolerance/influence_builder.rs index bef7ed93c..6a2b73b3c 100644 --- a/crates/pecos-qec/src/fault_tolerance/influence_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/influence_builder.rs @@ -36,7 +36,7 @@ use super::propagator::{DagFaultAnalyzer, DagPropagator, Direction, Pauli, apply use pecos_core::QubitId; use pecos_simulators::{PauliProp, SymbolicSparseStab}; use smallvec::SmallVec; -use std::collections::BinaryHeap; +use std::collections::{BTreeSet, BinaryHeap}; struct ObservablePropagationWork<'a> { recorder: &'a mut CompoundRecorder, @@ -479,6 +479,7 @@ impl<'a> InfluenceBuilder<'a> { /// Extract fault locations from the propagator. fn extract_locations(propagator: &DagPropagator<'_>) -> Vec { let mut locations = Vec::new(); + let mut prepared_qubits: BTreeSet = BTreeSet::new(); for &node in propagator.topo_order() { if let Some(gate) = propagator.gate(node) { @@ -497,7 +498,20 @@ impl<'a> InfluenceBuilder<'a> { // Standard circuit noise model: one fault location per gate. // Measurement: before. All others: after. let before = is_measurement; - for &q in &qubits { + let location_qubits: Vec = + if gate.gate_type == pecos_quantum::GateType::MeasCrosstalkGlobalPayload { + qubits.iter().copied().for_each(|q| { + prepared_qubits.remove(&q); + }); + let victims = prepared_qubits.iter().copied().collect(); + qubits.iter().copied().for_each(|q| { + prepared_qubits.insert(q); + }); + victims + } else { + qubits.clone() + }; + for q in location_qubits { locations.push(DagSpacetimeLocation { node, qubits: vec![q], @@ -506,6 +520,12 @@ impl<'a> InfluenceBuilder<'a> { idle_duration: gate.idle_duration(), }); } + if matches!( + gate.gate_type, + pecos_quantum::GateType::PZ | pecos_quantum::GateType::QAlloc + ) { + prepared_qubits.extend(qubits.iter().copied()); + } } } @@ -799,6 +819,7 @@ impl<'a> InfluenceBuilder<'a> { let mut map: std::collections::HashMap<(usize, bool), Vec<(usize, usize)>> = std::collections::HashMap::new(); let mut loc_idx = 0; + let mut prepared_qubits: BTreeSet = BTreeSet::new(); for &node in propagator.topo_order() { if let Some(gate) = propagator.gate(node) { @@ -812,11 +833,30 @@ impl<'a> InfluenceBuilder<'a> { ); let before = is_measurement; - for q in &gate.qubits { + let location_qubits: Vec = + if gate.gate_type == pecos_quantum::GateType::MeasCrosstalkGlobalPayload { + gate.qubits.iter().copied().for_each(|q| { + prepared_qubits.remove(&q); + }); + let victims = prepared_qubits.iter().copied().collect(); + gate.qubits.iter().copied().for_each(|q| { + prepared_qubits.insert(q); + }); + victims + } else { + gate.qubits.to_vec() + }; + for q in &location_qubits { let qi = q.index(); map.entry((node, before)).or_default().push((qi, loc_idx)); loc_idx += 1; } + if matches!( + gate.gate_type, + pecos_quantum::GateType::PZ | pecos_quantum::GateType::QAlloc + ) { + prepared_qubits.extend(gate.qubits.iter().copied()); + } } } diff --git a/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs b/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs index 8e11576d7..0c9d9b85b 100644 --- a/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs +++ b/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs @@ -1811,6 +1811,7 @@ impl<'a> DagFaultAnalyzer<'a> { let estimated_locations = topo_order.len() * 4; let mut locations = FaultLocations::with_capacity(estimated_locations, propagator.max_node()); + let mut prepared_qubits: BTreeSet = BTreeSet::new(); for &node in &topo_order { if let Some(gate) = propagator.gate(node) { @@ -1836,10 +1837,24 @@ impl<'a> DagFaultAnalyzer<'a> { } else { 0.0 }; - for &q in &qubits { + let location_qubits: Vec = + if gate.gate_type == GateType::MeasCrosstalkGlobalPayload { + for &q in &qubits { + prepared_qubits.remove(&q); + } + let victims = prepared_qubits.iter().copied().collect(); + prepared_qubits.extend(qubits.iter().copied()); + victims + } else { + qubits.iter().copied().collect() + }; + for q in location_qubits { let single_qubit: SmallVec<[usize; 2]> = smallvec::smallvec![q]; locations.push(node, single_qubit, before, gate.gate_type, idle_duration); } + if matches!(gate.gate_type, GateType::PZ | GateType::QAlloc) { + prepared_qubits.extend(qubits.iter().copied()); + } } } diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 0bb5f7d20..30f55673f 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -225,8 +225,14 @@ fn parse_measurement_crosstalk_dem_mode( | "leakage_as_depolarizing" => { Ok(MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing) } + "averaged_hidden_leakage_as_depolarizing" + | "average_hidden_leakage_as_depolarizing" + | "state_averaged_leakage_as_depolarizing" + | "averaged_leakage_as_depolarizing" => { + Ok(MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing) + } _ => Err(pyo3::exceptions::PyValueError::new_err( - "measurement_crosstalk_dem_mode must be 'omitted', 'exact_deterministic', or 'exact_deterministic_leakage_as_depolarizing'", + "measurement_crosstalk_dem_mode must be 'omitted', 'exact_deterministic', 'exact_deterministic_leakage_as_depolarizing', or 'averaged_hidden_leakage_as_depolarizing'", )), } } diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index e4b360901..67092fa82 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -852,12 +852,42 @@ def _runtime_idle_seconds_to_time_units(duration_seconds: float) -> Any: return TimeUnits(units) -def _replay_qis_trace_into_tick_circuit(operations: list[dict[str, Any]]) -> Any: +def _validate_measurement_crosstalk_topology( + measurement_crosstalk_topology: str | None, +) -> str | None: + if measurement_crosstalk_topology in (None, "none", "runtime_payloads"): + return None + if measurement_crosstalk_topology == "global_from_measurements": + return measurement_crosstalk_topology + msg = ( + "measurement_crosstalk_topology must be None, 'runtime_payloads', " + "or 'global_from_measurements'" + ) + raise ValueError(msg) + + +def _should_add_global_measurement_crosstalk_payload( + measurement_crosstalk_topology: str | None, +) -> bool: + return ( + _validate_measurement_crosstalk_topology(measurement_crosstalk_topology) + == "global_from_measurements" + ) + + +def _replay_qis_trace_into_tick_circuit( + operations: list[dict[str, Any]], + *, + measurement_crosstalk_topology: str | None = None, +) -> Any: """Replay traced QIS operations into a PECOS TickCircuit.""" import heapq from pecos_rslib.quantum import TickCircuit + measurement_crosstalk_topology = _validate_measurement_crosstalk_topology( + measurement_crosstalk_topology + ) tick_circuit = TickCircuit() active_slots: dict[int, int] = {} free_slots: list[int] = [] @@ -994,11 +1024,21 @@ def tuple_args(payload: Any, op_name: str, arity: int) -> tuple[Any, ...]: ) elif op_name == "Measure": 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( + measurement_crosstalk_topology + ): + # Global crosstalk payload qubits are guaranteed not to be + # affected; for measurement-induced global crosstalk this is + # exactly the measured payload. + tick_circuit.tick().add_gate( + "MeasCrosstalkGlobalPayload", [measurement_qubit] + ) # Stamp the QIS-provided result_id as the MeasId rather than # discarding it and letting assign_missing_meas_ids() invent # sequential ids (which would be wrong for non-sequential ids). tick.mz_with_ids( - [mapped_slot(int(program_id), op_name)], + [measurement_qubit], [int(result_id)], ) elif op_name == "Reset": @@ -1029,7 +1069,11 @@ def _gate_triples(qubits: list[int], gate_type: str) -> list[tuple[int, int, int return [(qubits[i], qubits[i + 1], qubits[i + 2]) for i in range(0, len(qubits), 3)] -def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> Any: +def _replay_lowered_qis_trace_into_tick_circuit( + chunks: list[dict[str, Any]], + *, + measurement_crosstalk_topology: str | None = None, +) -> Any: """Replay lowered post-Selene ByteMessage gate batches into a TickCircuit. The lowered trace emits gates one at a time. We replay each into its own @@ -1043,6 +1087,9 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> """ from pecos_rslib.quantum import TickCircuit + measurement_crosstalk_topology = _validate_measurement_crosstalk_topology( + measurement_crosstalk_topology + ) tick_circuit = TickCircuit() for chunk in chunks: @@ -1089,6 +1136,15 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> if len(meas_ids) != len(qubits): msg = f"Lowered MZ gate carries {len(meas_ids)} measurement_result_ids for {len(qubits)} qubit(s)" raise ValueError(msg) + if _should_add_global_measurement_crosstalk_payload( + measurement_crosstalk_topology + ): + # Global crosstalk payload qubits are guaranteed not to be + # affected; for measurement-induced global crosstalk this is + # exactly the measured payload. + tick_circuit.tick().add_gate( + "MeasCrosstalkGlobalPayload", qubits + ) tick.mz_with_ids(qubits, [int(meas_id) for meas_id in meas_ids]) elif gate_type == "MeasCrosstalkGlobalPayload": tick.add_gate("MeasCrosstalkGlobalPayload", qubits) @@ -1178,12 +1234,22 @@ def _reject_partially_lowered_trace(chunks: list[dict[str, Any]]) -> None: raise ValueError(msg) -def _replay_qis_trace_chunks_into_tick_circuit(chunks: list[dict[str, Any]]) -> Any: +def _replay_qis_trace_chunks_into_tick_circuit( + chunks: list[dict[str, Any]], + *, + measurement_crosstalk_topology: str | None = None, +) -> Any: """Replay captured QIS operation trace chunks into a ``TickCircuit``.""" + measurement_crosstalk_topology = _validate_measurement_crosstalk_topology( + measurement_crosstalk_topology + ) if any(chunk.get("lowered_quantum_ops") for chunk in chunks): _reject_partially_lowered_trace(chunks) try: - return _replay_lowered_qis_trace_into_tick_circuit(chunks) + return _replay_lowered_qis_trace_into_tick_circuit( + chunks, + measurement_crosstalk_topology=measurement_crosstalk_topology, + ) except ValueError as exc: if "missing measurement_result_ids" not in str(exc): raise @@ -1196,7 +1262,10 @@ def _replay_qis_trace_chunks_into_tick_circuit(chunks: list[dict[str, Any]]) -> operations: list[dict[str, Any]] = [] for chunk in chunks: operations.extend(list(chunk.get("operations", []))) - return _replay_qis_trace_into_tick_circuit(operations) + return _replay_qis_trace_into_tick_circuit( + operations, + measurement_crosstalk_topology=measurement_crosstalk_topology, + ) def named_result_traces_from_operation_trace(chunks: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -1233,10 +1302,17 @@ def trace_guppy_into_tick_circuit_with_result_traces( *, seed: int = 0, runtime: object | None = None, + measurement_crosstalk_topology: str | None = None, ) -> tuple[Any, list[dict[str, Any]]]: """Trace a Guppy/QIS program into a ``TickCircuit`` plus result-tag provenance.""" chunks = capture_guppy_operation_trace(program, num_qubits, seed=seed, runtime=runtime) - return _replay_qis_trace_chunks_into_tick_circuit(chunks), named_result_traces_from_operation_trace(chunks) + return ( + _replay_qis_trace_chunks_into_tick_circuit( + chunks, + measurement_crosstalk_topology=measurement_crosstalk_topology, + ), + named_result_traces_from_operation_trace(chunks), + ) def trace_guppy_into_tick_circuit( @@ -1245,6 +1321,7 @@ def trace_guppy_into_tick_circuit( *, seed: int = 0, runtime: object | None = None, + measurement_crosstalk_topology: str | None = None, ) -> Any: """Trace a Guppy/QIS program's lowered Selene op stream into a ``TickCircuit``. @@ -1275,7 +1352,10 @@ def trace_guppy_into_tick_circuit( caller supplies that. """ chunks = capture_guppy_operation_trace(program, num_qubits, seed=seed, runtime=runtime) - return _replay_qis_trace_chunks_into_tick_circuit(chunks) + return _replay_qis_trace_chunks_into_tick_circuit( + chunks, + measurement_crosstalk_topology=measurement_crosstalk_topology, + ) def _generate_traced_surface_tick_circuit( 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 4b76c3acd..308009ad7 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -245,6 +245,59 @@ def test_lowered_replay_preserves_measurement_crosstalk_payloads() -> None: assert _flat_gate_qubits(tc, "MeasCrosstalkGlobalPayload") == [[3, 4]] +def test_lowered_replay_can_add_global_crosstalk_payloads_from_measurements() -> None: + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 7]}}], + "lowered_quantum_ops": [ + { + "gate_type": "MZ", + "qubits": [11, 12], + "angles": [], + "params": [], + "measurement_result_ids": [7, 8], + }, + ], + }, + ] + + without_payloads = _replay_lowered_qis_trace_into_tick_circuit(chunks) + with_payloads = _replay_lowered_qis_trace_into_tick_circuit( + chunks, + measurement_crosstalk_topology="global_from_measurements", + ) + + assert _flat_gate_qubits(without_payloads, "MeasCrosstalkGlobalPayload") == [] + assert _flat_gate_qubits(with_payloads, "MeasCrosstalkGlobalPayload") == [ + [11, 12] + ] + assert _flat_mz_ids(with_payloads) == [7, 8] + + +def test_raw_replay_can_add_global_crosstalk_payloads_from_measurements() -> None: + operations = [ + {"AllocateQubit": {"id": 0}}, + {"AllocateResult": {"id": 9}}, + {"Quantum": {"Measure": [0, 9]}}, + ] + + tc = _replay_qis_trace_into_tick_circuit( + operations, + measurement_crosstalk_topology="global_from_measurements", + ) + + assert _flat_gate_qubits(tc, "MeasCrosstalkGlobalPayload") == [[0]] + assert _flat_mz_ids(tc) == [9] + + +def test_replay_rejects_unknown_measurement_crosstalk_topology() -> None: + with pytest.raises(ValueError, match="measurement_crosstalk_topology"): + _replay_lowered_qis_trace_into_tick_circuit( + [], + measurement_crosstalk_topology="local_from_vibes", + ) + + def test_lowered_runtime_idles_can_drive_memory_noise_dem() -> None: from pecos.qec import DetectorErrorModel From d7c30557841cde6781c67b01f8f2dcaf960a6c4f Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 15:45:11 -0600 Subject: [PATCH 093/150] Document SZZ DEM modeling assumptions --- .../surface/dem_decomposition_diagnostics.py | 21 +++++++-- .../graphlike_dem_projection_benchmark.py | 14 +++++- .../surface/native_dem_threshold_sweep.py | 3 +- .../surface/szz_circuit_quality_report.py | 14 ++++++ .../src/pecos/qec/surface/circuit_builder.py | 8 +++- .../src/pecos/qec/surface/decode.py | 44 ++++++++++++++----- 6 files changed, 87 insertions(+), 17 deletions(-) diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py index 8c529ba13..7788d5552 100644 --- a/examples/surface/dem_decomposition_diagnostics.py +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -2,7 +2,8 @@ The script keeps sampling fixed: each case samples once from the exact native influence-model DEM, then decodes the same detector events with several decoder -views of the model. This separates raw DEM generation from graphlike +views of the model. The graphlike views are lossy hyperedge-to-edge projections +for graph decoders, so this separates raw DEM generation from graphlike decomposition quality. Example: @@ -36,6 +37,9 @@ "terminal_decomp_pymatching", "terminal_decomp_pymatching_correlated", ] +# The staged SZZ device model treats Z/SZ/SZdg frame updates as noiseless +# virtual operations. CX-vs-SZZ p1 location comparisons include this assumption +# as well as the gate-basis difference. SZZ_Z_FRAME_P1_GATE_RATES = {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} @@ -139,7 +143,12 @@ def dem_effect_probabilities(dem_text: str) -> dict[str, float]: def compare_raw_dems(native_dem: str, stim_dem: str) -> RawDemComparison: - """Compare raw native and Stim DEMs after aggregating duplicate effects.""" + """Compare raw native and Stim DEMs after aggregating duplicate effects. + + ``only_native`` and ``only_stim`` report structural differences. Nonzero + probability deltas with zero structural differences reflect independent + probability-combination and serialization-rounding conventions. + """ native = dem_effect_probabilities(native_dem) stim = dem_effect_probabilities(stim_dem) native_keys = set(native) @@ -230,7 +239,8 @@ def terminal_graphlike_projection(raw_dem: str) -> str: but the rendered decomposition uses only detectors present in that raw effect. This avoids cancellation/path detectors introduced by graph-path decompositions while still producing graphlike components for matching - decoders. + decoders. The result is a lossy decoder-facing projection of hyperedge + correlations, not an exact raw DEM. """ coords = parse_detector_coords(raw_dem) annotation_lines: list[str] = [] @@ -716,6 +726,11 @@ def print_case(result: CaseResult) -> None: f"max_rel={raw.max_rel_probability_diff:.3e} " f"l1={raw.l1_probability_diff:.3e}", ) + if raw.only_native == 0 and raw.only_stim == 0 and raw.max_rel_probability_diff > 0: + print( + " raw structures match; probability deltas reflect " + "combination/rounding conventions.", + ) print("DEM stats:") print(" source errors psum sep hyper max_comp max_line pure_L") diff --git a/examples/surface/graphlike_dem_projection_benchmark.py b/examples/surface/graphlike_dem_projection_benchmark.py index 6be23f129..c1bab4fe5 100644 --- a/examples/surface/graphlike_dem_projection_benchmark.py +++ b/examples/surface/graphlike_dem_projection_benchmark.py @@ -3,7 +3,8 @@ This is a narrower companion to ``dem_decomposition_diagnostics.py``. It builds each DEM view once, samples once from the exact native influence model, then times correlated PyMatching construction and batch decoding for the selected -graphlike projections. +graphlike projections. Those graphlike projections are lossy decoder-facing +views of raw hyperedge mechanisms. """ from __future__ import annotations @@ -23,6 +24,7 @@ true_observable_flips, ) +# SZZ/SZZdg surface diagnostics model Z-frame gates as virtual and p1-free. SZZ_Z_FRAME_P1_GATE_RATES = {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} @@ -169,6 +171,16 @@ def build_case( f"max_rel={raw_comparison['max_rel_probability_diff']:.3e}", flush=True, ) + if ( + raw_comparison["only_native"] == 0 + and raw_comparison["only_stim"] == 0 + and raw_comparison["max_rel_probability_diff"] > 0 + ): + print( + "raw structures match; max_rel is a probability-combination/" + "rounding delta.", + flush=True, + ) sampler, elapsed = timed( "build native influence sampler", diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index 567c665e0..511d81f7f 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -3741,7 +3741,8 @@ def _parse_args() -> argparse.Namespace: help=( "PECOS native DEM mode. Graph decoders such as PyMatching require " "native_decomposed or native_terminal_graphlike; raw-DEM decoders " - "should use native_full." + "should use native_full. The graphlike modes are lossy " + "hyperedge-to-edge decoder projections." ), ) parser.add_argument( diff --git a/examples/surface/szz_circuit_quality_report.py b/examples/surface/szz_circuit_quality_report.py index fb55f78f2..493661e79 100644 --- a/examples/surface/szz_circuit_quality_report.py +++ b/examples/surface/szz_circuit_quality_report.py @@ -3,6 +3,9 @@ This script is intentionally descriptive rather than a threshold sweep. It counts the gate locations that drive the simple circuit-level noise model and, optionally, compares raw PECOS and Stim DEMs for the traced-QIS circuit path. +For SZZ/SZZdg cases the staged device model treats Z/SZ/SZdg frame updates as +noiseless virtual operations, so p1 location comparisons include that +assumption as well as the gate-basis change. Example: uv run python examples/surface/szz_circuit_quality_report.py \\ @@ -56,6 +59,7 @@ THREE_QUBIT_GATES = {"CCX", "CCZ"} SZZ_ABSTRACT_PREFIX_P1_FREE_GATES = {"Z", "SZ", "SZdg"} SZZ_Z_FRAME_P1_FREE_SOURCES = {"abstract_physical_prefix", "traced_qis"} +# SZZ/SZZdg surface diagnostics model Z-frame gates as virtual and p1-free. SZZ_Z_FRAME_P1_GATE_RATES = {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} @@ -422,6 +426,16 @@ def build_case( f"max_rel={comparison['max_rel_probability_diff']:.3e}", flush=True, ) + if ( + comparison["only_native"] == 0 + and comparison["only_stim"] == 0 + and comparison["max_rel_probability_diff"] > 0 + ): + print( + "raw structures match; max_rel is a probability-combination/" + "rounding delta.", + flush=True, + ) return CaseReport( distance=distance, diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 980df977e..d81ec155e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -2937,7 +2937,9 @@ def tick_circuit_to_stim( p1: Single-qubit error rate p1_gate_rates: Optional per-gate override for single-qubit error rates. Gate names are PECOS ``GateType`` names such as ``"Z"``, - ``"SZ"``, and ``"SZdg"``. + ``"SZ"``, and ``"SZdg"``. The surface SZZ reference path uses + this to mirror the staged PECOS device model where Z/SZ/SZdg frame + updates are virtual and p1-free. p2: Two-qubit error rate p_meas: Measurement error rate p_prep: Initialization error rate @@ -3473,7 +3475,9 @@ def generate_dem_from_tick_circuit_via_stim( tc: TickCircuit with detector/observable metadata p1: Single-qubit depolarizing error rate p1_gate_rates: Optional per-gate override for single-qubit - depolarizing rates. Gate names are PECOS ``GateType`` names. + depolarizing rates. Gate names are PECOS ``GateType`` names. The + surface SZZ reference path uses this to mirror the staged PECOS + device model where Z/SZ/SZdg frame updates are virtual and p1-free. p2: Two-qubit depolarizing error rate p_meas: Measurement error rate p_prep: Initialization (prep) error rate diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index af30a271a..21e9edb4e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -61,6 +61,8 @@ P1Weights = Mapping[str, float] | Sequence[tuple[str, float]] P2Weights = Mapping[str, float] | Sequence[tuple[str, float]] +# Native graphlike decompositions are decoder-facing projections of raw +# hyperedge mechanisms, not alternate exact DEM serializations. NativeDemDecomposition = Literal["source_graphlike", "terminal_graphlike"] CircuitLevelDemMode = Literal["native_full", "native_decomposed", "native_terminal_graphlike"] @@ -1649,6 +1651,14 @@ def _use_szz_physical_prefixes( def _szz_z_frame_p1_gate_rates(topology: _CachedNativeSurfaceTopology) -> dict[str, float] | None: + """Return virtual-Z frame p1 overrides for the staged SZZ device model. + + The current SZZ surface basis treats Z/SZ/SZdg frame updates as noiseless + virtual operations. That device-model assumption is keyed from + ``interaction_basis == "szz"`` in the staged API, so CX-vs-SZZ p1 location + comparisons include this free-Z modeling choice as well as gate basis + differences. + """ if not topology.z_frame_gate_p1_free: return None return {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} @@ -1794,6 +1804,9 @@ def _surface_native_topology( dag_circuit=dag, influence_map=influence_map, szz_physical_prefixes=szz_physical_prefixes, + # Staged SZZ device model: Z/SZ/SZdg frame updates are virtual and + # receive no p1 noise. Keep CX-vs-SZZ p1 location comparisons scoped to + # that asymmetric device assumption. z_frame_gate_p1_free=interaction_basis == "szz", pauli_frame_lookup=pauli_frame_lookup, detectors_json=detectors_json, @@ -2074,14 +2087,15 @@ def generate_circuit_level_dem_from_builder( basis: Memory basis ('X' or 'Z') decompose_errors: If True, return PECOS's native graphlike-decomposed DEM representation for graph decoders such as PyMatching. The - decomposition preserves correlated mechanism metadata with ``^`` - separators, but graph decoders may still approximate hyperedge - correlations. + decomposition is a lossy hyperedge-to-edge projection; it preserves + correlated mechanism metadata with ``^`` separators where available, + but it is not an exact raw DEM serialization. dem_decomposition: Which native graphlike projection to use when ``decompose_errors=True``. ``"source_graphlike"`` preserves the existing source-informed decomposition. ``"terminal_graphlike"`` groups raw mechanisms first, then pairs only detector terminals - present in each raw effect by coordinate distance. + present in each raw effect by coordinate distance. Both modes are + decoder-facing approximations of raw hyperedge mechanisms. ancilla_budget: Optional cap on simultaneously live ancillas. When provided below the total stabilizer count, the native DEM is built from the same batched ancilla-reuse circuit family used by Guppy. @@ -2097,7 +2111,11 @@ def generate_circuit_level_dem_from_builder( twirl: Optional Pauli-frame randomization layout. Canonical Guppy frame-output mode is normalized to the same abstract raw lookup and DEM topology. - interaction_basis: Surface-memory two-qubit interaction basis. + interaction_basis: Surface-memory two-qubit interaction basis. The + staged ``"szz"`` path currently assumes a virtual-Z device model: + Z/SZ/SZdg frame updates are p1-free. That is a device assumption + keyed from this basis selector, not a general claim about CX + hardware. Returns: DEM string in standard format @@ -2578,11 +2596,14 @@ def __init__( circuit_level_dem_mode: Which PECOS-native DEM representation to use when circuit-level DEMs are enabled. ``"native_full"`` preserves the current non-decomposed DEM output. ``"native_decomposed"`` - returns the source-informed graphlike output for graph decoders. + returns the source-informed graphlike projection for graph + decoders. ``"native_terminal_graphlike"`` first groups raw mechanisms, then projects each mechanism onto graphlike terminal components. - Decomposed modes are decoder-facing approximations of hyperedge - correlations, not exact raw DEMs. + Decomposed modes are lossy decoder-facing approximations of + hyperedge correlations, not exact raw DEMs. Correlated graph + decoding can use some preserved ``^`` metadata, but raw-DEM + decoders should use ``"native_full"``. circuit_level_dem_source: Which ideal circuit to analyze when building native circuit-level DEMs. ``"abstract"`` uses the high-level surface TickCircuit, while ``"traced_qis"`` traces @@ -2592,7 +2613,8 @@ def __init__( builds its DEM from the corresponding batched ancilla-reuse circuit instead of the default dedicated-ancilla circuit. interaction_basis: Surface-memory two-qubit interaction basis, - ``"cx"`` or ``"szz"``. + ``"cx"`` or ``"szz"``. The staged ``"szz"`` path currently + treats Z/SZ/SZdg frame updates as p1-free virtual operations. """ from pecos.qec.surface.circuit_builder import _normalize_interaction_basis @@ -3924,7 +3946,9 @@ def build_native_sampler( frame-output mode is normalized to the same abstract raw lookup. interaction_basis: Surface-memory two-qubit interaction basis. sampling_model: Which native sampling backend to use. ``"dem"`` - samples the generated decomposed DEM and is the default. + samples the generated source-graphlike DEM projection and is the + default; this is a decoder-facing approximation of raw hyperedges, + not the exact raw DEM. ``"influence_dem"`` uses the influence-map-based DemSampler with detector definitions. ``"mnm"`` is accepted for compatibility and maps to ``"influence_dem"``. From bd0b1cc283d943f706ca80a46f692d8772913d29 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 16:34:25 -0600 Subject: [PATCH 094/150] Align Guppy DEM reference normalization --- .../tests/qec/test_from_guppy_dem.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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 20588dcb9..17d1ffc59 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -13,7 +13,10 @@ from pecos.guppy import get_num_qubits, make_surface_code from pecos.qec import DetectorErrorModel from pecos.qec.surface import NoiseModel, SurfacePatch -from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch +from pecos.qec.surface.circuit_builder import ( + generate_tick_circuit_from_patch, + normalize_traced_qis_tick_circuit, +) from pecos.qec.surface.decode import ( _build_surface_tick_circuit_for_native_model, _copy_surface_tick_circuit_metadata, @@ -270,7 +273,7 @@ def test_lowered_replay_can_add_global_crosstalk_payloads_from_measurements() -> assert _flat_gate_qubits(without_payloads, "MeasCrosstalkGlobalPayload") == [] assert _flat_gate_qubits(with_payloads, "MeasCrosstalkGlobalPayload") == [ - [11, 12] + [11, 12], ] assert _flat_mz_ids(with_payloads) == [7, 8] @@ -535,8 +538,7 @@ def test_from_guppy_surface_code_is_byte_identical_to_reference() -> None: basis, circuit_source="traced_qis", ) - ref.lower_clifford_rotations() - ref.assign_missing_meas_ids() + normalize_traced_qis_tick_circuit(ref, context="from_guppy surface reference") ref_dem = DetectorErrorModel.from_circuit(ref, **p).to_string() got = DetectorErrorModel.from_guppy( make_surface_code(distance=3, num_rounds=3, basis=basis), @@ -562,8 +564,7 @@ def test_from_guppy_szz_surface_code_is_byte_identical_to_reference(distance: in circuit_source="traced_qis", interaction_basis="szz", ) - ref.lower_clifford_rotations() - ref.assign_missing_meas_ids() + normalize_traced_qis_tick_circuit(ref, context="from_guppy SZZ surface reference") ref_dem = DetectorErrorModel.from_circuit(ref, **p).to_string() got = DetectorErrorModel.from_guppy( make_surface_code( @@ -643,8 +644,7 @@ def _constrained_surface_via_guppy(*, d, basis, rounds, budget, noise): ancilla_budget=budget, circuit_source="traced_qis", ) - ref.lower_clifford_rotations() - ref.assign_missing_meas_ids() + normalize_traced_qis_tick_circuit(ref, context="from_guppy constrained surface reference") ref_dem = DetectorErrorModel.from_circuit(ref, **noise).to_string() got = DetectorErrorModel.from_guppy( @@ -663,7 +663,7 @@ def _constrained_surface_via_guppy(*, d, basis, rounds, budget, noise): [ (3, "Z", 2, 1), # small-and-fast, minimum budget (one stabilizer/batch) (3, "X", 2, 2), # asymmetric basis, X/Z paired per batch - (9, "Z", 3, 17), # canonical high-distance stress + (5, "Z", 3, 5), # medium constrained case without high-distance DEM cost ], ) def test_from_guppy_constrained_surface_dem_byte_identical( From 63328dd58c40d94e06f58de3e0ccb8b2fd2c1bc0 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 13 Jun 2026 17:25:56 -0600 Subject: [PATCH 095/150] Report DEM diagnostic progress and partial results --- .../surface/dem_decomposition_diagnostics.py | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py index 7788d5552..3daf08f39 100644 --- a/examples/surface/dem_decomposition_diagnostics.py +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -764,6 +764,17 @@ def print_case(result: CaseResult) -> None: ) +def write_results_json(path: Path, results: list[CaseResult]) -> None: + """Write completed case results to JSON. + + Diagnostics can be expensive for larger distance/round combinations, so the + CLI writes after every finished case instead of only at process exit. + """ + payload = [asdict(result) for result in results] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--distances", nargs="+", type=int, default=[3, 5]) @@ -799,11 +810,20 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() results: list[CaseResult] = [] + total_cases = len(args.distances) * len(args.bases) * len(args.interaction_bases) * len(args.p) + case_index = 0 for distance in args.distances: rounds = args.rounds if args.rounds is not None else distance for basis in args.bases: for interaction_basis in args.interaction_bases: for p in args.p: + case_index += 1 + label = ( + f"d={distance} r={rounds} basis={basis} " + f"basis2q={interaction_basis} p={p:g} shots={args.shots}" + ) + print(f"\n[{case_index}/{total_cases}] Starting {label}", flush=True) + start = time.perf_counter() result = run_case( distance=distance, rounds=rounds, @@ -818,12 +838,15 @@ def main() -> int: pair_analysis_max_effects=args.pair_analysis_max_effects, ) results.append(result) + elapsed = time.perf_counter() - start + print(f"[{case_index}/{total_cases}] Finished {label} in {elapsed:.3f}s", flush=True) print_case(result) + if args.save_json is not None: + write_results_json(args.save_json, results) + print(f"Wrote partial results to {args.save_json}", flush=True) if args.save_json is not None: - payload = [asdict(result) for result in results] - args.save_json.parent.mkdir(parents=True, exist_ok=True) - args.save_json.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + write_results_json(args.save_json, results) print(f"\nWrote {args.save_json}") return 0 From f727699e6d93fe1d947a35bd0e1d04a468040745 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 17:27:02 -0600 Subject: [PATCH 096/150] Add surface sweep JSON comparison tool --- .../surface/compare_surface_sweep_json.py | 332 ++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 examples/surface/compare_surface_sweep_json.py diff --git a/examples/surface/compare_surface_sweep_json.py b/examples/surface/compare_surface_sweep_json.py new file mode 100644 index 000000000..9db10b020 --- /dev/null +++ b/examples/surface/compare_surface_sweep_json.py @@ -0,0 +1,332 @@ +"""Compare two surface-sweep JSON artifacts. + +This helper is intentionally lightweight: it reads the JSON files emitted by +``native_dem_threshold_sweep.py`` and prints Markdown tables with matched +logical-error rates, Wilson binomial intervals, ratios, differences, and +normal-approximation z-scores. It is useful for comparing CX vs SZZ/SZZdg +surface-code runs that used the same sweep grid. + +Example: + python examples/surface/compare_surface_sweep_json.py \\ + /tmp/pecos-szz-validation/native_cx_d357_r1_5k_results.json \\ + /tmp/pecos-szz-validation/native_szz_d357_r1_5k_results.json \\ + --left-label CX --right-label SZZ +""" + +from __future__ import annotations + +import argparse +import json +import math +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +Z_95 = 1.959963984540054 + + +@dataclass(frozen=True) +class Point: + backend: str + basis: str + distance: int + p: float + rounds: int + errors: int + shots: int + + @property + def rate(self) -> float: + return self.errors / self.shots if self.shots else math.nan + + +@dataclass(frozen=True) +class Comparison: + key: tuple[str, str, int, float, int] + left: Point + right: Point + + +def _as_point(raw: dict[str, Any]) -> Point: + return Point( + backend=str(raw.get("backend", "")), + basis=str(raw["basis"]).upper(), + distance=int(raw["distance"]), + p=float(raw["physical_error_rate"]), + rounds=int(raw["total_rounds"]), + errors=int(raw["num_logical_errors"]), + shots=int(raw["num_shots"]), + ) + + +def load_points(path: Path) -> dict[tuple[str, str, int, float, int], Point]: + data = json.loads(path.read_text()) + points = data.get("points") + if not isinstance(points, list): + msg = f"{path} does not contain a list-valued 'points' field" + raise TypeError(msg) + + loaded: dict[tuple[str, str, int, float, int], Point] = {} + for raw in points: + point = _as_point(raw) + key = (point.backend, point.basis, point.distance, point.p, point.rounds) + if key in loaded: + msg = f"{path} contains duplicate point key {key}" + raise ValueError(msg) + loaded[key] = point + return loaded + + +def wilson_interval(errors: int, shots: int, z: float = Z_95) -> tuple[float, float]: + if shots <= 0: + return math.nan, math.nan + phat = errors / shots + denom = 1.0 + z * z / shots + center = (phat + z * z / (2.0 * shots)) / denom + half = z * math.sqrt((phat * (1.0 - phat) + z * z / (4.0 * shots)) / shots) / denom + return max(0.0, center - half), min(1.0, center + half) + + +def standard_error(errors: int, shots: int) -> float: + if shots <= 0: + return math.nan + rate = errors / shots + return math.sqrt(rate * (1.0 - rate) / shots) + + +def z_score(left: Point, right: Point) -> float: + denom = math.sqrt( + standard_error(left.errors, left.shots) ** 2 + + standard_error(right.errors, right.shots) ** 2, + ) + if denom == 0.0: + return math.nan + return (right.rate - left.rate) / denom + + +def ratio(left: Point, right: Point) -> float: + if left.rate == 0.0: + return math.inf if right.rate > 0.0 else 1.0 + return right.rate / left.rate + + +def format_rate(point: Point, *, include_ci: bool) -> str: + base = f"{point.rate:.4g} ({point.errors}/{point.shots})" + if not include_ci: + return base + low, high = wilson_interval(point.errors, point.shots) + return f"{base} [{low:.4g}, {high:.4g}]" + + +def format_float(value: float, precision: int = 2) -> str: + if math.isnan(value): + return "nan" + if math.isinf(value): + return "inf" + return f"{value:.{precision}f}" + + +def matched_comparisons( + left: dict[tuple[str, str, int, float, int], Point], + right: dict[tuple[str, str, int, float, int], Point], +) -> list[Comparison]: + return [Comparison(key, left[key], right[key]) for key in sorted(set(left) & set(right))] + + +def aggregate_points(points: list[Point]) -> Point: + if not points: + msg = "cannot aggregate an empty point list" + raise ValueError(msg) + first = points[0] + return Point( + backend=first.backend, + basis=first.basis, + distance=first.distance, + p=math.nan, + rounds=first.rounds, + errors=sum(point.errors for point in points), + shots=sum(point.shots for point in points), + ) + + +def emit_point_table( + comparisons: list[Comparison], + *, + left_label: str, + right_label: str, + include_ci: bool, +) -> str: + lines = [ + "## Matched Points", + "", + f"| backend | basis | d | rounds | p | {left_label} | {right_label} | ratio | diff | z |", + "|---------|-------|---|--------|---|------|------|-------|------|---|", + ] + for comparison in comparisons: + backend, basis, distance, p, rounds = comparison.key + left = comparison.left + right = comparison.right + lines.append( + "| " + f"{backend} | {basis} | {distance} | {rounds} | {p:g} | " + f"{format_rate(left, include_ci=include_ci)} | " + f"{format_rate(right, include_ci=include_ci)} | " + f"{format_float(ratio(left, right))} | " + f"{right.rate - left.rate:+.4g} | " + f"{format_float(z_score(left, right))} |", + ) + return "\n".join(lines) + + +def emit_aggregate_table( + comparisons: list[Comparison], + *, + left_label: str, + right_label: str, + include_ci: bool, +) -> str: + grouped: dict[tuple[str, str, int, int], list[Comparison]] = defaultdict(list) + for comparison in comparisons: + backend, basis, distance, _p, rounds = comparison.key + grouped[(backend, basis, distance, rounds)].append(comparison) + + lines = [ + "## Aggregate Over Physical Error Rates", + "", + f"| backend | basis | d | rounds | {left_label} | {right_label} | ratio | diff | z |", + "|---------|-------|---|--------|------|------|-------|------|---|", + ] + for key in sorted(grouped): + backend, basis, distance, rounds = key + group = grouped[key] + left = aggregate_points([comparison.left for comparison in group]) + right = aggregate_points([comparison.right for comparison in group]) + lines.append( + "| " + f"{backend} | {basis} | {distance} | {rounds} | " + f"{format_rate(left, include_ci=include_ci)} | " + f"{format_rate(right, include_ci=include_ci)} | " + f"{format_float(ratio(left, right))} | " + f"{right.rate - left.rate:+.4g} | " + f"{format_float(z_score(left, right))} |", + ) + return "\n".join(lines) + + +def emit_overall_table( + comparisons: list[Comparison], + *, + left_label: str, + right_label: str, + include_ci: bool, +) -> str: + grouped: dict[tuple[str, str], list[Comparison]] = defaultdict(list) + for comparison in comparisons: + backend, basis, _distance, _p, _rounds = comparison.key + grouped[(backend, basis)].append(comparison) + + lines = [ + "## Overall By Backend And Basis", + "", + f"| backend | basis | {left_label} | {right_label} | ratio | diff | z |", + "|---------|-------|------|------|-------|------|---|", + ] + for key in sorted(grouped): + backend, basis = key + group = grouped[key] + left = aggregate_points([comparison.left for comparison in group]) + right = aggregate_points([comparison.right for comparison in group]) + lines.append( + "| " + f"{backend} | {basis} | " + f"{format_rate(left, include_ci=include_ci)} | " + f"{format_rate(right, include_ci=include_ci)} | " + f"{format_float(ratio(left, right))} | " + f"{right.rate - left.rate:+.4g} | " + f"{format_float(z_score(left, right))} |", + ) + return "\n".join(lines) + + +def build_report( + left_path: Path, + right_path: Path, + *, + left_label: str, + right_label: str, + include_ci: bool, +) -> str: + left = load_points(left_path) + right = load_points(right_path) + comparisons = matched_comparisons(left, right) + if not comparisons: + msg = "No common (backend, basis, distance, p, rounds) points found" + raise ValueError(msg) + + left_only = len(set(left) - set(right)) + right_only = len(set(right) - set(left)) + lines = [ + f"# Sweep Comparison: {left_label} vs {right_label}", + "", + f"- left: `{left_path}`", + f"- right: `{right_path}`", + f"- matched points: {len(comparisons)}", + f"- left-only points: {left_only}", + f"- right-only points: {right_only}", + "", + emit_point_table( + comparisons, + left_label=left_label, + right_label=right_label, + include_ci=include_ci, + ), + "", + emit_aggregate_table( + comparisons, + left_label=left_label, + right_label=right_label, + include_ci=include_ci, + ), + "", + emit_overall_table( + comparisons, + left_label=left_label, + right_label=right_label, + include_ci=include_ci, + ), + ] + return "\n".join(lines) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("left", type=Path, help="First native_dem_threshold_sweep.py JSON artifact.") + parser.add_argument("right", type=Path, help="Second native_dem_threshold_sweep.py JSON artifact.") + parser.add_argument("--left-label", default="left", help="Label for the first artifact.") + parser.add_argument("--right-label", default="right", help="Label for the second artifact.") + parser.add_argument("--no-ci", action="store_true", help="Omit Wilson 95% binomial intervals.") + parser.add_argument("--output", type=Path, default=None, help="Optional Markdown output path.") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + report = build_report( + args.left, + args.right, + left_label=args.left_label, + right_label=args.right_label, + include_ci=not args.no_ci, + ) + if args.output is None: + print(report) + else: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(report + "\n") + print(f"Wrote comparison report to {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From e01005ee70c3a9f57724772d5c47ce992970fdb2 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 18:34:10 -0600 Subject: [PATCH 097/150] Improve graphlike surface decoder validation --- .../surface/compare_surface_sweep_json.py | 110 +++++++++++--- .../surface/native_dem_threshold_sweep.py | 45 +++--- .../src/fault_tolerance_bindings.rs | 16 +- .../src/pecos/qec/surface/decode.py | 115 ++++++++++----- .../pecos/unit/test_surface_sweep_compare.py | 137 ++++++++++++++++++ .../tests/qec/surface/test_surface_decoder.py | 128 ++++++++++++++++ 6 files changed, 462 insertions(+), 89 deletions(-) create mode 100644 python/quantum-pecos/tests/pecos/unit/test_surface_sweep_compare.py diff --git a/examples/surface/compare_surface_sweep_json.py b/examples/surface/compare_surface_sweep_json.py index 9db10b020..5bbf8b137 100644 --- a/examples/surface/compare_surface_sweep_json.py +++ b/examples/surface/compare_surface_sweep_json.py @@ -1,10 +1,10 @@ """Compare two surface-sweep JSON artifacts. -This helper is intentionally lightweight: it reads the JSON files emitted by -``native_dem_threshold_sweep.py`` and prints Markdown tables with matched -logical-error rates, Wilson binomial intervals, ratios, differences, and -normal-approximation z-scores. It is useful for comparing CX vs SZZ/SZZdg -surface-code runs that used the same sweep grid. +This helper reads the JSON files emitted by ``native_dem_threshold_sweep.py`` +and prints Markdown tables with matched logical-error rates, binomial +intervals, ratios, differences, and descriptive normal-approximation z-scores. +It is useful for comparing CX vs SZZ/SZZdg surface-code runs that used the same +sweep grid. Example: python examples/surface/compare_surface_sweep_json.py \\ @@ -24,6 +24,7 @@ from typing import Any Z_95 = 1.959963984540054 +CI_METHODS = {"jeffreys", "wilson"} @dataclass(frozen=True) @@ -88,6 +89,31 @@ def wilson_interval(errors: int, shots: int, z: float = Z_95) -> tuple[float, fl return max(0.0, center - half), min(1.0, center + half) +def jeffreys_interval(errors: int, shots: int, confidence: float = 0.95) -> tuple[float, float]: + """Return a Jeffreys equal-tailed interval for one binomial proportion.""" + if shots <= 0: + return math.nan, math.nan + from scipy.stats import beta + + alpha = (1.0 - confidence) / 2.0 + lower = 0.0 if errors == 0 else float(beta.ppf(alpha, errors + 0.5, shots - errors + 0.5)) + upper = ( + 1.0 + if errors == shots + else float(beta.ppf(1.0 - alpha, errors + 0.5, shots - errors + 0.5)) + ) + return lower, upper + + +def binomial_interval(errors: int, shots: int, method: str) -> tuple[float, float]: + if method == "jeffreys": + return jeffreys_interval(errors, shots) + if method == "wilson": + return wilson_interval(errors, shots) + msg = f"unknown interval method {method!r}" + raise ValueError(msg) + + def standard_error(errors: int, shots: int) -> float: if shots <= 0: return math.nan @@ -111,11 +137,11 @@ def ratio(left: Point, right: Point) -> float: return right.rate / left.rate -def format_rate(point: Point, *, include_ci: bool) -> str: +def format_rate(point: Point, *, include_ci: bool, interval_method: str) -> str: base = f"{point.rate:.4g} ({point.errors}/{point.shots})" if not include_ci: return base - low, high = wilson_interval(point.errors, point.shots) + low, high = binomial_interval(point.errors, point.shots, interval_method) return f"{base} [{low:.4g}, {high:.4g}]" @@ -156,6 +182,7 @@ def emit_point_table( left_label: str, right_label: str, include_ci: bool, + interval_method: str, ) -> str: lines = [ "## Matched Points", @@ -170,8 +197,8 @@ def emit_point_table( lines.append( "| " f"{backend} | {basis} | {distance} | {rounds} | {p:g} | " - f"{format_rate(left, include_ci=include_ci)} | " - f"{format_rate(right, include_ci=include_ci)} | " + f"{format_rate(left, include_ci=include_ci, interval_method=interval_method)} | " + f"{format_rate(right, include_ci=include_ci, interval_method=interval_method)} | " f"{format_float(ratio(left, right))} | " f"{right.rate - left.rate:+.4g} | " f"{format_float(z_score(left, right))} |", @@ -185,6 +212,7 @@ def emit_aggregate_table( left_label: str, right_label: str, include_ci: bool, + interval_method: str, ) -> str: grouped: dict[tuple[str, str, int, int], list[Comparison]] = defaultdict(list) for comparison in comparisons: @@ -205,8 +233,8 @@ def emit_aggregate_table( lines.append( "| " f"{backend} | {basis} | {distance} | {rounds} | " - f"{format_rate(left, include_ci=include_ci)} | " - f"{format_rate(right, include_ci=include_ci)} | " + f"{format_rate(left, include_ci=include_ci, interval_method=interval_method)} | " + f"{format_rate(right, include_ci=include_ci, interval_method=interval_method)} | " f"{format_float(ratio(left, right))} | " f"{right.rate - left.rate:+.4g} | " f"{format_float(z_score(left, right))} |", @@ -214,12 +242,13 @@ def emit_aggregate_table( return "\n".join(lines) -def emit_overall_table( +def emit_cross_distance_pooled_table( comparisons: list[Comparison], *, left_label: str, right_label: str, include_ci: bool, + interval_method: str, ) -> str: grouped: dict[tuple[str, str], list[Comparison]] = defaultdict(list) for comparison in comparisons: @@ -227,7 +256,11 @@ def emit_overall_table( grouped[(backend, basis)].append(comparison) lines = [ - "## Overall By Backend And Basis", + "## Pooled Across Distances By Backend And Basis", + "", + "This table intentionally pools across distances. It is useful as a rough", + "event-count summary, but it is dominated by lower-distance points and is", + "not a scaling or threshold statement.", "", f"| backend | basis | {left_label} | {right_label} | ratio | diff | z |", "|---------|-------|------|------|-------|------|---|", @@ -240,8 +273,8 @@ def emit_overall_table( lines.append( "| " f"{backend} | {basis} | " - f"{format_rate(left, include_ci=include_ci)} | " - f"{format_rate(right, include_ci=include_ci)} | " + f"{format_rate(left, include_ci=include_ci, interval_method=interval_method)} | " + f"{format_rate(right, include_ci=include_ci, interval_method=interval_method)} | " f"{format_float(ratio(left, right))} | " f"{right.rate - left.rate:+.4g} | " f"{format_float(z_score(left, right))} |", @@ -256,6 +289,8 @@ def build_report( left_label: str, right_label: str, include_ci: bool, + interval_method: str, + include_cross_distance_pooled: bool, ) -> str: left = load_points(left_path) right = load_points(right_path) @@ -274,12 +309,18 @@ def build_report( f"- matched points: {len(comparisons)}", f"- left-only points: {left_only}", f"- right-only points: {right_only}", + f"- intervals: {'none' if not include_ci else f'{interval_method} 95%'}", + "- z-scores: descriptive unpooled Wald z-scores, uncorrected for multiple comparisons", + "- read low-count and zero-count rows cautiously", + "- cross-distance pooled totals are omitted by default; use " + "`--include-cross-distance-pooled` for a rough event-count summary", "", emit_point_table( comparisons, left_label=left_label, right_label=right_label, include_ci=include_ci, + interval_method=interval_method, ), "", emit_aggregate_table( @@ -287,15 +328,22 @@ def build_report( left_label=left_label, right_label=right_label, include_ci=include_ci, - ), - "", - emit_overall_table( - comparisons, - left_label=left_label, - right_label=right_label, - include_ci=include_ci, + interval_method=interval_method, ), ] + if include_cross_distance_pooled: + lines.extend( + [ + "", + emit_cross_distance_pooled_table( + comparisons, + left_label=left_label, + right_label=right_label, + include_ci=include_ci, + interval_method=interval_method, + ), + ], + ) return "\n".join(lines) @@ -305,7 +353,21 @@ def parse_args() -> argparse.Namespace: parser.add_argument("right", type=Path, help="Second native_dem_threshold_sweep.py JSON artifact.") parser.add_argument("--left-label", default="left", help="Label for the first artifact.") parser.add_argument("--right-label", default="right", help="Label for the second artifact.") - parser.add_argument("--no-ci", action="store_true", help="Omit Wilson 95% binomial intervals.") + parser.add_argument( + "--ci", + choices=sorted(CI_METHODS), + default="jeffreys", + help="Binomial interval method to report when intervals are enabled.", + ) + parser.add_argument("--no-ci", action="store_true", help="Omit 95% binomial intervals.") + parser.add_argument( + "--include-cross-distance-pooled", + action="store_true", + help=( + "Also emit totals pooled across all distances by backend and basis. " + "This is not a scaling or threshold summary." + ), + ) parser.add_argument("--output", type=Path, default=None, help="Optional Markdown output path.") return parser.parse_args() @@ -318,6 +380,8 @@ def main() -> int: left_label=args.left_label, right_label=args.right_label, include_ci=not args.no_ci, + interval_method=args.ci, + include_cross_distance_pooled=args.include_cross_distance_pooled, ) if args.output is None: print(report) diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index 511d81f7f..9ce2b9124 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -563,10 +563,10 @@ def _noise_model_description(args: argparse.Namespace) -> str: def _create_dem_decoder(decoder_type: str, dem_str: str, *, tesseract_beam: int = 5) -> object: """Create a DEM-level decoder from a DEM string. - Supports MWPM decoders (pymatching, pymatching_correlated), search - decoders (tesseract), and check-matrix decoders (bp_osd, bp_lsd, - union_find, relay_bp, min_sum_bp) via DemAwareDecoder which extracts the - check matrix from the DEM. + Supports MWPM decoders (pymatching, pymatching_correlated, + pymatching_uncorrelated), search decoders (tesseract), and check-matrix + decoders (bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp) via + DemAwareDecoder which extracts the check matrix from the DEM. """ if decoder_type == "tesseract": from pecos.decoders import TesseractDecoder @@ -582,7 +582,7 @@ def _create_dem_decoder(decoder_type: str, dem_str: str, *, tesseract_beam: int from pecos.decoders import PyMatchingDecoder - if decoder_type == "pymatching_correlated": + if decoder_type in {"pymatching", "pymatching_correlated"}: return PyMatchingDecoder.from_dem_with_correlations(dem_str, enable_correlations=True) return PyMatchingDecoder.from_dem(dem_str) @@ -683,7 +683,7 @@ def _decoder_runtime( patch, num_rounds=total_rounds, noise=noise, - decoder_type="pymatching" if decoder_type == "pymatching_correlated" else decoder_type, + decoder_type=decoder_type, use_circuit_level_dem=True, circuit_level_dem_mode=dem_mode, circuit_level_dem_source=native_circuit_source, @@ -702,7 +702,7 @@ def _decoder_runtime( def _native_sampler_model_for_decoder(decoder_type: str) -> str: """Choose the native sampler model paired with a DEM decoder.""" - if decoder_type in {"pymatching", "pymatching_correlated"}: + if decoder_type in {"pymatching", "pymatching_correlated", "pymatching_uncorrelated"}: return "dem" return "influence_dem" @@ -750,11 +750,11 @@ def _native_sampler_runtime( interaction_basis=interaction_basis, sampling_model=_native_sampler_model_for_decoder(decoder_type), ) - # PyMatching uses graphlike decomposed DEMs. The correlated variant also - # consumes decomposition separators as correlation metadata. Tesseract and - # check-matrix decoders handle hyperedges natively and should get the full - # DEM. - if decoder_type in {"pymatching", "pymatching_correlated"}: + # PyMatching uses graphlike decomposed DEMs. The production default and the + # explicit correlated alias consume decomposition separators as correlation + # metadata. Tesseract and check-matrix decoders handle hyperedges natively + # and should get the full DEM. + if decoder_type in {"pymatching", "pymatching_correlated", "pymatching_uncorrelated"}: dem_str = runtime.decoder.get_dem(basis.upper(), circuit_level=True) else: dem_str = generate_circuit_level_dem_from_builder( @@ -1303,33 +1303,28 @@ def _run_memory_point( ) sampler = native_runtime.sampler dem_decoder = native_runtime.dem_decoder - detection_events, observable_flips = sampler.sample(num_shots=num_shots, seed=seed) num_raw_errors = None # Fast path: sample+decode entirely in Rust via ObservableDecoder trait. # The DemSampler keeps all per-shot data in Rust -- nothing crosses to Python. dem_str_for_rust = native_runtime.dem_str rust_sampler = getattr(sampler, "sampler", None) - use_rust_sample_decode = ( - decoder_type != "pymatching_correlated" - and dem_str_for_rust - and rust_sampler - and hasattr(rust_sampler, "sample_decode_count") - ) + rust_decoder_type = "pymatching" if decoder_type == "pymatching_correlated" else decoder_type + use_rust_sample_decode = dem_str_for_rust and rust_sampler and hasattr(rust_sampler, "sample_decode_count") if use_rust_sample_decode: # Use parallel path for slow decoders (Tesseract, BP+OSD, etc.) - if decoder_type != "pymatching" and hasattr(rust_sampler, "sample_decode_count_parallel"): + if rust_decoder_type != "pymatching" and hasattr(rust_sampler, "sample_decode_count_parallel"): num_logical_errors = rust_sampler.sample_decode_count_parallel( dem_str_for_rust, num_shots, - decoder_type, + rust_decoder_type, seed, ) else: num_logical_errors = rust_sampler.sample_decode_count( dem_str_for_rust, num_shots, - decoder_type, + rust_decoder_type, seed, ) else: @@ -3751,6 +3746,7 @@ def _parse_args() -> argparse.Namespace: choices=[ "pymatching", "pymatching_correlated", + "pymatching_uncorrelated", "tesseract", "bp_osd", "bp_lsd", @@ -3762,8 +3758,9 @@ def _parse_args() -> argparse.Namespace: help=( "Decoder(s) for circuit-level DEM decoding. Specify multiple to " "compare them side-by-side in plots and reports. Default: pymatching. " - "pymatching_correlated enables PyMatching's DEM-correlation mode for " - "decomposed errors. " + "pymatching and pymatching_correlated enable PyMatching's DEM-correlation " + "mode for decomposed errors; pymatching_uncorrelated keeps the plain " + "graphlike baseline for A/B diagnostics. " "Check-matrix decoders (bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp) " "extract a check matrix from the DEM automatically." ), diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 228e047f0..3b607e317 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -2217,7 +2217,7 @@ fn create_observable_decoder( }; match decoder_type { - "pymatching" => { + "pymatching" | "pymatching_correlated" => { // Default: correlated matching enabled (exploits X-Z correlations // from depolarizing noise for ~20% fewer errors at d>=5). let d = PyMatchingDecoder::from_dem_with_correlations(dem, true) @@ -3454,8 +3454,9 @@ impl PySampleBatch { /// /// Args: /// dem: DEM string in standard DEM text format for the decoder. - /// `decoder_type`: "pymatching", "tesseract", "`bp_osd`", "`bp_lsd`", "`union_find`", - /// "`relay_bp`", or "`min_sum_bp`". + /// `decoder_type`: "pymatching", "`pymatching_correlated`", + /// "`pymatching_uncorrelated`", "tesseract", "`bp_osd`", + /// "`bp_lsd`", "`union_find`", "`relay_bp`", or "`min_sum_bp`". /// /// Returns: /// Number of logical errors. @@ -4428,7 +4429,9 @@ impl PyDemSampler { /// Args: /// dem: DEM string in standard DEM text format for the decoder. /// `num_shots`: Number of shots to sample and decode. - /// `decoder_type`: "pymatching" or "tesseract". + /// `decoder_type`: "pymatching", "`pymatching_correlated`", + /// "`pymatching_uncorrelated`", "tesseract", or another + /// decoder accepted by `create_observable_decoder`. /// seed: Optional random seed for reproducibility. /// /// Returns: @@ -4473,7 +4476,9 @@ impl PyDemSampler { /// Args: /// dem: DEM string in standard DEM text format for the decoder. /// `num_shots`: Number of shots to sample and decode. - /// `decoder_type`: "pymatching", "tesseract", "`bp_osd`", "`bp_lsd`", or "`union_find`". + /// `decoder_type`: "pymatching", "`pymatching_correlated`", + /// "`pymatching_uncorrelated`", "tesseract", "`bp_osd`", + /// "`bp_lsd`", or "`union_find`". /// seed: Optional base random seed. Each thread gets seed + `thread_id`. /// `num_workers`: Number of parallel workers (default: number of CPUs). /// @@ -6420,6 +6425,7 @@ fn decoder_dem_requirement(decoder_type: &str) -> PyResult { let base = decoder_type.split(':').next().unwrap_or(decoder_type); match base { "pymatching" + | "pymatching_correlated" | "pymatching_uncorrelated" | "fusion_blossom" | "fusion_blossom_serial" diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 70afa1a55..68df785cb 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -80,6 +80,8 @@ class DecoderType(str, Enum): """Available decoder backends.""" PYMATCHING = "pymatching" + PYMATCHING_CORRELATED = "pymatching_correlated" + PYMATCHING_UNCORRELATED = "pymatching_uncorrelated" FUSION_BLOSSOM = "fusion_blossom" BP_OSD = "bp_osd" BP_LSD = "bp_lsd" @@ -87,6 +89,20 @@ class DecoderType(str, Enum): TESSERACT = "tesseract" +DEM_DECODER_TYPES = { + DecoderType.PYMATCHING, + DecoderType.PYMATCHING_CORRELATED, + DecoderType.PYMATCHING_UNCORRELATED, + DecoderType.TESSERACT, +} + +PYMATCHING_DECODER_TYPES = { + DecoderType.PYMATCHING, + DecoderType.PYMATCHING_CORRELATED, + DecoderType.PYMATCHING_UNCORRELATED, +} + + @dataclass class NoiseModel: """Circuit-level noise parameters for QEC simulation. @@ -918,7 +934,7 @@ def _replay_qis_trace_into_tick_circuit( from pecos_rslib.quantum import TickCircuit measurement_crosstalk_topology = _validate_measurement_crosstalk_topology( - measurement_crosstalk_topology + measurement_crosstalk_topology, ) tick_circuit = TickCircuit() active_slots: dict[int, int] = {} @@ -1058,13 +1074,14 @@ def tuple_args(payload: Any, op_name: str, arity: int) -> tuple[Any, ...]: 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( - measurement_crosstalk_topology + measurement_crosstalk_topology, ): # Global crosstalk payload qubits are guaranteed not to be # affected; for measurement-induced global crosstalk this is # exactly the measured payload. tick_circuit.tick().add_gate( - "MeasCrosstalkGlobalPayload", [measurement_qubit] + "MeasCrosstalkGlobalPayload", + [measurement_qubit], ) # Stamp the QIS-provided result_id as the MeasId rather than # discarding it and letting assign_missing_meas_ids() invent @@ -1120,7 +1137,7 @@ def _replay_lowered_qis_trace_into_tick_circuit( from pecos_rslib.quantum import TickCircuit measurement_crosstalk_topology = _validate_measurement_crosstalk_topology( - measurement_crosstalk_topology + measurement_crosstalk_topology, ) tick_circuit = TickCircuit() @@ -1169,13 +1186,14 @@ def _replay_lowered_qis_trace_into_tick_circuit( msg = f"Lowered MZ gate carries {len(meas_ids)} measurement_result_ids for {len(qubits)} qubit(s)" raise ValueError(msg) if _should_add_global_measurement_crosstalk_payload( - measurement_crosstalk_topology + measurement_crosstalk_topology, ): # Global crosstalk payload qubits are guaranteed not to be # affected; for measurement-induced global crosstalk this is # exactly the measured payload. tick_circuit.tick().add_gate( - "MeasCrosstalkGlobalPayload", qubits + "MeasCrosstalkGlobalPayload", + qubits, ) tick.mz_with_ids(qubits, [int(meas_id) for meas_id in meas_ids]) elif gate_type == "MeasCrosstalkGlobalPayload": @@ -1273,7 +1291,7 @@ def _replay_qis_trace_chunks_into_tick_circuit( ) -> Any: """Replay captured QIS operation trace chunks into a ``TickCircuit``.""" measurement_crosstalk_topology = _validate_measurement_crosstalk_topology( - measurement_crosstalk_topology + measurement_crosstalk_topology, ) if any(chunk.get("lowered_quantum_ops") for chunk in chunks): _reject_partially_lowered_trace(chunks) @@ -1378,6 +1396,8 @@ def trace_guppy_into_tick_circuit( runtime: Optional Selene runtime selector/plugin. ``None`` selects the default Selene runtime. Runtime plugin objects are passed through to ``pecos.selene_engine(runtime)``. + measurement_crosstalk_topology: Optional measurement-crosstalk replay + mode for stamping global measurement-crosstalk payload markers. Returns: A ``TickCircuit`` with no detector/observable metadata attached; the @@ -2642,6 +2662,8 @@ def __init__( noise: NoiseModel | None = None, decoder_type: Literal[ "pymatching", + "pymatching_correlated", + "pymatching_uncorrelated", "fusion_blossom", "bp_osd", "bp_lsd", @@ -2662,7 +2684,13 @@ def __init__( num_rounds: Number of syndrome extraction rounds noise: Noise model for edge weights (defaults to uniform) decoder_type: Decoder backend to use: - - "pymatching": Fast C++ MWPM decoder (default) + - "pymatching": Fast C++ MWPM decoder (default). For + decomposed circuit-level DEMs, this enables PyMatching's + DEM-correlation metadata when available. + - "pymatching_correlated": Explicit alias for the circuit-level + correlated PyMatching path. + - "pymatching_uncorrelated": Plain graphlike PyMatching path, + useful for A/B diagnostics. - "fusion_blossom": Pure Rust MWPM decoder - "bp_osd": Belief Propagation + OSD - "bp_lsd": Belief Propagation + LSD @@ -2800,10 +2828,7 @@ def _get_z_decoder(self) -> Any: """Get or create decoder for Z-basis memory (decodes Z syndromes for X errors).""" if self._z_decoder is None: # For PyMatching and Tesseract with circuit-level DEMs, use DEM directly - if self.use_circuit_level_dem and self.decoder_type in ( - DecoderType.PYMATCHING, - DecoderType.TESSERACT, - ): + if self.use_circuit_level_dem and self.decoder_type in DEM_DECODER_TYPES: self._z_decoder = self._create_decoder_from_dem("Z") else: self._z_decoder = self._create_decoder(self._get_z_check_matrix()) @@ -2821,8 +2846,12 @@ def _create_decoder(self, H: NDArray[np.uint8]) -> Any: data_weight = self._compute_weight(p_data) meas_weight = self._compute_weight(p_meas) - if self.decoder_type == DecoderType.PYMATCHING: - from pecos_rslib.decoders import CheckMatrix, PyMatchingDecoder + if self.decoder_type in PYMATCHING_DECODER_TYPES: + from pecos.decoders import CheckMatrix, PyMatchingDecoder + + if self.decoder_type == DecoderType.PYMATCHING_CORRELATED: + msg = "pymatching_correlated requires circuit-level DEM decoding" + raise ValueError(msg) weights = [data_weight] * num_data check_matrix = CheckMatrix.from_dense(H.tolist()).with_weights(weights) @@ -2836,7 +2865,7 @@ def _create_decoder(self, H: NDArray[np.uint8]) -> Any: ) if self.decoder_type == DecoderType.FUSION_BLOSSOM: - from pecos_rslib.decoders import FusionBlossomDecoder + from pecos.decoders import FusionBlossomDecoder # FusionBlossom uses check matrix directly # For multi-round, we need to construct the space-time graph manually @@ -2887,13 +2916,22 @@ def _create_decoder_from_dem(self, basis: str) -> Any: else: self._x_dem = dem - if self.decoder_type == DecoderType.PYMATCHING: - from pecos_rslib.decoders import PyMatchingDecoder + if self.decoder_type in PYMATCHING_DECODER_TYPES: + from pecos.decoders import PyMatchingDecoder + + if self.circuit_level_dem_mode == "native_full": + if self.decoder_type == DecoderType.PYMATCHING_CORRELATED: + msg = "pymatching_correlated requires a decomposed circuit-level DEM mode" + raise ValueError(msg) + return PyMatchingDecoder.from_dem(dem) + + if self.decoder_type == DecoderType.PYMATCHING_UNCORRELATED: + return PyMatchingDecoder.from_dem(dem) - return PyMatchingDecoder.from_dem(dem) + return PyMatchingDecoder.from_dem_with_correlations(dem, enable_correlations=True) if self.decoder_type == DecoderType.TESSERACT: - from pecos_rslib.decoders import TesseractDecoder + from pecos.decoders import TesseractDecoder # Tesseract's remove_zero_probability_errors() doesn't handle # DEM_LOGICAL_OBSERVABLE instructions. Filter them out - the @@ -2911,7 +2949,7 @@ def _create_fusion_blossom_spacetime( meas_weight: float, ) -> Any: """Create FusionBlossom decoder with space-time matching graph.""" - from pecos_rslib.decoders import FusionBlossomDecoder + from pecos.decoders import FusionBlossomDecoder num_stab = H.shape[0] num_data = H.shape[1] @@ -2971,12 +3009,12 @@ def _create_ldpc_decoder( p_data: float, ) -> Any: """Create LDPC decoder (BP+OSD, BP+LSD, or UnionFind).""" - from pecos_rslib.decoders import SparseMatrix + from pecos.decoders import SparseMatrix sparse_H = SparseMatrix(H.tolist()) if self.decoder_type == DecoderType.BP_OSD: - from pecos_rslib.decoders import BpOsdBuilder + from pecos.decoders import BpOsdBuilder return ( BpOsdBuilder(sparse_H, error_rate=p_data) @@ -2988,12 +3026,12 @@ def _create_ldpc_decoder( ) if self.decoder_type == DecoderType.BP_LSD: - from pecos_rslib.decoders import BpLsdBuilder + from pecos.decoders import BpLsdBuilder return BpLsdBuilder(sparse_H, error_rate=p_data).max_iter(100).bp_method("product_sum").lsd_order(0).build() if self.decoder_type == DecoderType.UNION_FIND: - from pecos_rslib.decoders import UnionFindBuilder + from pecos.decoders import UnionFindBuilder return UnionFindBuilder(sparse_H).method("inversion").build() @@ -3007,7 +3045,7 @@ def _create_tesseract_decoder( _p_meas: float, ) -> Any: """Create Tesseract decoder from check matrix by generating DEM.""" - from pecos_rslib.decoders import TesseractDecoder + from pecos.decoders import TesseractDecoder # Determine stabilizer type based on check matrix shape z_check = self._get_z_check_matrix() @@ -3073,10 +3111,7 @@ def _get_x_decoder(self) -> Any: """Get or create decoder for X-basis memory (decodes X syndromes for Z errors).""" if self._x_decoder is None: # For PyMatching and Tesseract with circuit-level DEMs, use DEM directly - if self.use_circuit_level_dem and self.decoder_type in ( - DecoderType.PYMATCHING, - DecoderType.TESSERACT, - ): + if self.use_circuit_level_dem and self.decoder_type in DEM_DECODER_TYPES: self._x_decoder = self._create_decoder_from_dem("X") else: self._x_decoder = self._create_decoder(self._get_x_check_matrix()) @@ -3086,6 +3121,8 @@ def _is_mwpm_decoder(self) -> bool: """Check if using an MWPM or Tesseract decoder (vs LDPC).""" return self.decoder_type in ( DecoderType.PYMATCHING, + DecoderType.PYMATCHING_CORRELATED, + DecoderType.PYMATCHING_UNCORRELATED, DecoderType.FUSION_BLOSSOM, DecoderType.TESSERACT, ) @@ -3378,10 +3415,7 @@ def decode_memory_z( final_parity = sum(final[q] for q in logical_z_qubits) % 2 # DEM-based path: compute full detection events matching DEM detector order - if self.use_circuit_level_dem and self.decoder_type in ( - DecoderType.PYMATCHING, - DecoderType.TESSERACT, - ): + if self.use_circuit_level_dem and self.decoder_type in DEM_DECODER_TYPES: events = self._compute_dem_detection_events_z(synx_list, synz_list, final, init_synx=init_synx) events_flat = events.ravel().astype(np.uint8) @@ -3477,10 +3511,7 @@ def decode_memory_x( final_parity = sum(final[q] for q in logical_x_qubits) % 2 # DEM-based path: compute full detection events matching DEM detector order - if self.use_circuit_level_dem and self.decoder_type in ( - DecoderType.PYMATCHING, - DecoderType.TESSERACT, - ): + if self.use_circuit_level_dem and self.decoder_type in DEM_DECODER_TYPES: events = self._compute_dem_detection_events_x(synx_list, synz_list, final, init_synz=init_synz) events_flat = events.ravel().astype(np.uint8) @@ -3644,6 +3675,13 @@ def _memory_noise_model( return NoiseModel.uniform(p) +def _recommended_graphlike_decomposition_for_decoder(decoder_type: str) -> NativeDemDecomposition: + base = decoder_type.split(":", 1)[0] + if base in {"pymatching", "pymatching_correlated", "pymatching_uncorrelated"}: + return "terminal_graphlike" + return "source_graphlike" + + def surface_code_memory( *, distance: int = 3, @@ -3675,6 +3713,8 @@ def surface_code_memory( rounds: Number of syndrome-extraction rounds. Defaults to ``distance``. basis: Memory basis, ``"Z"`` or ``"X"``. decoder_type: Decoder backend passed to ``SampleBatch.decode_count``. + PyMatching-family decoders use PECOS's terminal graphlike DEM + projection for this recommended workflow. seed: Optional sampler seed. decode: If false, report the raw observable-flip rate. circuit_source: ``"abstract"`` or ``"traced_qis"`` circuit source. @@ -3715,6 +3755,7 @@ def surface_code_memory( noise=noise_model, basis=basis, decompose_errors=True, + dem_decomposition=_recommended_graphlike_decomposition_for_decoder(decoder_type), ancilla_budget=ancilla_budget, circuit_source=circuit_source, interaction_basis=interaction_basis, diff --git a/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_compare.py b/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_compare.py new file mode 100644 index 000000000..e20e8fdda --- /dev/null +++ b/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_compare.py @@ -0,0 +1,137 @@ +# 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. + +"""Unit tests for examples/surface/compare_surface_sweep_json.py.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from types import ModuleType + + +def _repo_root() -> Path: + cur = Path(__file__).resolve() + for candidate in [cur, *cur.parents]: + if (candidate / "Justfile").is_file() and (candidate / "examples").is_dir(): + return candidate + msg = f"Could not locate repo root above {cur}" + raise RuntimeError(msg) + + +_COMPARE_MODULE_NAME = "_surface_sweep_compare_under_test" + + +def _load_compare_module() -> ModuleType: + example_path = _repo_root() / "examples" / "surface" / "compare_surface_sweep_json.py" + spec = importlib.util.spec_from_file_location(_COMPARE_MODULE_NAME, example_path) + if spec is None or spec.loader is None: + msg = f"Could not load comparison module from {example_path}" + raise RuntimeError(msg) + module = importlib.util.module_from_spec(spec) + sys.modules[_COMPARE_MODULE_NAME] = module + try: + spec.loader.exec_module(module) + except Exception: + sys.modules.pop(_COMPARE_MODULE_NAME, None) + raise + return module + + +@pytest.fixture(scope="module") +def compare() -> ModuleType: + return _load_compare_module() + + +def _point(*, distance: int, p: float, errors: int, shots: int = 1000) -> dict[str, object]: + return { + "backend": "native_sampler", + "basis": "Z", + "distance": distance, + "physical_error_rate": p, + "total_rounds": distance, + "num_logical_errors": errors, + "num_shots": shots, + } + + +def _write_sweep(path: Path, points: list[dict[str, object]]) -> None: + path.write_text(json.dumps({"points": points}), encoding="utf-8") + + +def test_report_defaults_to_jeffreys_and_omits_cross_distance_pooled( + compare: ModuleType, + tmp_path: Path, +) -> None: + left = tmp_path / "left.json" + right = tmp_path / "right.json" + _write_sweep(left, [_point(distance=3, p=0.004, errors=5), _point(distance=5, p=0.004, errors=2)]) + _write_sweep(right, [_point(distance=3, p=0.004, errors=6), _point(distance=5, p=0.004, errors=1)]) + + report = compare.build_report( + left, + right, + left_label="CX", + right_label="SZZ", + include_ci=True, + interval_method="jeffreys", + include_cross_distance_pooled=False, + ) + + assert "- intervals: jeffreys 95%" in report + assert "- z-scores: descriptive unpooled Wald z-scores" in report + assert "## Aggregate Over Physical Error Rates" in report + assert "## Pooled Across Distances" not in report + + +def test_cross_distance_pooled_table_is_explicitly_labeled( + compare: ModuleType, + tmp_path: Path, +) -> None: + left = tmp_path / "left.json" + right = tmp_path / "right.json" + _write_sweep(left, [_point(distance=3, p=0.004, errors=5), _point(distance=5, p=0.004, errors=2)]) + _write_sweep(right, [_point(distance=3, p=0.004, errors=6), _point(distance=5, p=0.004, errors=1)]) + + report = compare.build_report( + left, + right, + left_label="CX", + right_label="SZZ", + include_ci=True, + interval_method="jeffreys", + include_cross_distance_pooled=True, + ) + + assert "## Pooled Across Distances By Backend And Basis" in report + assert "not a scaling or threshold statement" in report + + +def test_duplicate_point_keys_raise(compare: ModuleType, tmp_path: Path) -> None: + duplicate = tmp_path / "duplicate.json" + point = _point(distance=3, p=0.004, errors=5) + _write_sweep(duplicate, [point, point]) + + with pytest.raises(ValueError, match="duplicate point key"): + compare.load_points(duplicate) + + +def test_wilson_interval_remains_available(compare: ModuleType) -> None: + low, high = compare.binomial_interval(0, 100, "wilson") + assert low == pytest.approx(0.0, abs=1e-15) + assert 0.0 < high < 0.1 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 f0956ddc3..2caa90f22 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -187,6 +187,134 @@ def test_decoder_types(self) -> None: d3 = SurfaceDecoder(patch, decoder_type="bp_osd", noise=noise) assert d3.decoder_type.value == "bp_osd" + # Explicit PyMatching DEM-correlation modes + d4 = SurfaceDecoder(patch, decoder_type="pymatching_correlated", noise=noise) + assert d4.decoder_type.value == "pymatching_correlated" + + d5 = SurfaceDecoder(patch, decoder_type="pymatching_uncorrelated", noise=noise) + assert d5.decoder_type.value == "pymatching_uncorrelated" + + def test_circuit_level_pymatching_uses_correlations_by_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The production circuit-level PyMatching path should consume DEM correlation metadata.""" + import pecos.decoders as decoders_module + import pecos.qec.surface.decode as decode_module + + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p2=0.01, p_meas=0.01) + seen: dict[str, object] = {} + + def wrapped_generate(*_args: object, **_kwargs: object) -> str: + return "error(0.01) D0 ^ D1 L0\n" + + class DummyPyMatchingDecoder: + @classmethod + def from_dem_with_correlations(cls, dem: str, *, enable_correlations: bool) -> object: + seen["method"] = "from_dem_with_correlations" + seen["dem"] = dem + seen["enable_correlations"] = enable_correlations + return object() + + @classmethod + def from_dem(cls, _dem: str) -> object: + raise AssertionError + + monkeypatch.setattr(decode_module, "generate_circuit_level_dem_from_builder", wrapped_generate) + monkeypatch.setattr(decoders_module, "PyMatchingDecoder", DummyPyMatchingDecoder) + + decoder = SurfaceDecoder( + patch, + num_rounds=3, + noise=noise, + decoder_type="pymatching", + circuit_level_dem_mode="native_decomposed", + ) + + assert decoder._get_z_decoder() is not None # noqa: SLF001 + assert seen == { + "method": "from_dem_with_correlations", + "dem": "error(0.01) D0 ^ D1 L0\n", + "enable_correlations": True, + } + + def test_circuit_level_uncorrelated_pymatching_uses_plain_dem(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The explicit uncorrelated option remains available for A/B diagnostics.""" + import pecos.decoders as decoders_module + import pecos.qec.surface.decode as decode_module + + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p2=0.01, p_meas=0.01) + seen: dict[str, object] = {} + + def wrapped_generate(*_args: object, **_kwargs: object) -> str: + return "error(0.01) D0 ^ D1 L0\n" + + class DummyPyMatchingDecoder: + @classmethod + def from_dem_with_correlations(cls, _dem: str, **_kwargs: object) -> object: + raise AssertionError + + @classmethod + def from_dem(cls, dem: str) -> object: + seen["method"] = "from_dem" + seen["dem"] = dem + return object() + + monkeypatch.setattr(decode_module, "generate_circuit_level_dem_from_builder", wrapped_generate) + monkeypatch.setattr(decoders_module, "PyMatchingDecoder", DummyPyMatchingDecoder) + + decoder = SurfaceDecoder( + patch, + num_rounds=3, + noise=noise, + decoder_type="pymatching_uncorrelated", + circuit_level_dem_mode="native_decomposed", + ) + + assert decoder._get_z_decoder() is not None # noqa: SLF001 + assert seen == { + "method": "from_dem", + "dem": "error(0.01) D0 ^ D1 L0\n", + } + + 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) + decoder = SurfaceDecoder( + patch, + decoder_type="pymatching_correlated", + noise=noise, + use_circuit_level_dem=False, + ) + + with pytest.raises(ValueError, match="requires circuit-level DEM"): + decoder._get_z_decoder() # noqa: SLF001 + + 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) + decoder = SurfaceDecoder( + patch, + decoder_type="pymatching_correlated", + noise=noise, + circuit_level_dem_mode="native_full", + ) + + with pytest.raises(ValueError, match="requires a decomposed"): + decoder._get_z_decoder() # noqa: SLF001 + + def test_recommended_memory_workflow_uses_terminal_graphlike_for_pymatching(self) -> None: + """The high-level memory helper should use the best measured graphlike projection.""" + import pecos.qec.surface.decode as decode_module + + for decoder_type in ["pymatching", "pymatching_correlated", "pymatching_uncorrelated"]: + assert ( + decode_module._recommended_graphlike_decomposition_for_decoder(decoder_type) # noqa: SLF001 + == "terminal_graphlike" + ) + assert decode_module._recommended_graphlike_decomposition_for_decoder("tesseract") == "source_graphlike" # noqa: SLF001 + def test_get_dem(self) -> None: """Test DEM generation via decoder.""" patch = SurfacePatch.create(distance=3) From 76af0d3ea5d5aaffbad8fa70919eaf8732491694 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 13 Jun 2026 18:40:26 -0600 Subject: [PATCH 098/150] Make DEM diagnostics resumable from saved results --- .../surface/dem_decomposition_diagnostics.py | 124 +++++++++++++++++- 1 file changed, 121 insertions(+), 3 deletions(-) diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py index 3daf08f39..c1564562a 100644 --- a/examples/surface/dem_decomposition_diagnostics.py +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -41,6 +41,7 @@ # virtual operations. CX-vs-SZZ p1 location comparisons include this assumption # as well as the gate-basis difference. SZZ_Z_FRAME_P1_GATE_RATES = {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} +RESULT_SCHEMA_VERSION = 2 @dataclass(frozen=True) @@ -89,12 +90,16 @@ class PairAnalysisSummary: @dataclass(frozen=True) class CaseResult: + result_schema_version: int distance: int rounds: int basis: str interaction_basis: str p: float shots: int + seed: int + pair_analysis_requested: bool + pair_analysis_max_effects: int raw_comparison: RawDemComparison dem_stats: dict[str, DemStats] decoders: list[DecodeSummary] @@ -686,12 +691,16 @@ def run_case( ) return CaseResult( + result_schema_version=RESULT_SCHEMA_VERSION, distance=distance, rounds=rounds, basis=basis, interaction_basis=interaction_basis, p=p, shots=shots, + seed=seed, + pair_analysis_requested=pair_analysis, + pair_analysis_max_effects=pair_analysis_max_effects, raw_comparison=compare_raw_dems(native_raw, stim_raw), dem_stats={ "native_raw": dem_stats(native_raw), @@ -764,17 +773,95 @@ def print_case(result: CaseResult) -> None: ) -def write_results_json(path: Path, results: list[CaseResult]) -> None: +def result_payload(result: CaseResult | dict[str, Any]) -> dict[str, Any]: + """Return a JSON-serializable case payload.""" + if isinstance(result, CaseResult): + return asdict(result) + return result + + +def write_results_json(path: Path, results: list[CaseResult | dict[str, Any]]) -> None: """Write completed case results to JSON. Diagnostics can be expensive for larger distance/round combinations, so the CLI writes after every finished case instead of only at process exit. """ - payload = [asdict(result) for result in results] + payload = [result_payload(result) for result in results] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") +def expected_decoder_labels(args: argparse.Namespace) -> tuple[str, ...]: + """Return the decoder labels expected for this run configuration.""" + labels = [] if args.skip_tesseract else [f"native_raw_tesseract_b{beam}" for beam in args.tesseract_beams] + labels.extend(args.decoders) + return tuple(labels) + + +def case_key( + *, + distance: int, + rounds: int, + basis: str, + interaction_basis: str, + p: float, + shots: int, + seed: int, + decoder_labels: tuple[str, ...], + pair_analysis: bool, + pair_analysis_max_effects: int, +) -> tuple[Any, ...]: + """Build a strict cache key for one sampled diagnostic case.""" + return ( + RESULT_SCHEMA_VERSION, + distance, + rounds, + basis, + interaction_basis, + f"{p:.17g}", + shots, + seed, + decoder_labels, + pair_analysis, + pair_analysis_max_effects, + ) + + +def cached_case_key(payload: dict[str, Any]) -> tuple[Any, ...] | None: + """Return the cache key for a saved case, or ``None`` if it is incomplete.""" + try: + decoder_labels = tuple(decoder["decoder"] for decoder in payload["decoders"]) + return ( + payload["result_schema_version"], + payload["distance"], + payload["rounds"], + payload["basis"], + payload["interaction_basis"], + f"{float(payload['p']):.17g}", + payload["shots"], + payload["seed"], + decoder_labels, + payload["pair_analysis_requested"], + payload["pair_analysis_max_effects"], + ) + except KeyError: + return None + + +def load_cached_results(path: Path) -> tuple[list[dict[str, Any]], dict[tuple[Any, ...], dict[str, Any]]]: + """Load resumable diagnostic results from a previous JSON file.""" + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, list): + raise ValueError(f"Expected a list of case results in {path}") + results = [item for item in payload if isinstance(item, dict)] + by_key = { + key: result + for result in results + if (key := cached_case_key(result)) is not None + } + return results, by_key + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--distances", nargs="+", type=int, default=[3, 5]) @@ -804,12 +891,26 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument("--pair-analysis-max-effects", type=int, default=400) parser.add_argument("--save-json", type=Path, default=None) + parser.add_argument( + "--resume", + action="store_true", + help="Reuse matching completed cases from --save-json instead of recomputing them.", + ) return parser.parse_args() def main() -> int: args = parse_args() - results: list[CaseResult] = [] + if args.resume and args.save_json is None: + raise ValueError("--resume requires --save-json so completed cases have a source") + + results: list[CaseResult | dict[str, Any]] = [] + cached_by_key: dict[tuple[Any, ...], dict[str, Any]] = {} + if args.resume and args.save_json is not None and args.save_json.exists(): + results, cached_by_key = load_cached_results(args.save_json) + print(f"Loaded {len(cached_by_key)} resumable cases from {args.save_json}", flush=True) + + decoder_labels = expected_decoder_labels(args) total_cases = len(args.distances) * len(args.bases) * len(args.interaction_bases) * len(args.p) case_index = 0 for distance in args.distances: @@ -822,6 +923,22 @@ def main() -> int: f"d={distance} r={rounds} basis={basis} " f"basis2q={interaction_basis} p={p:g} shots={args.shots}" ) + key = case_key( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=p, + shots=args.shots, + seed=args.seed, + decoder_labels=decoder_labels, + pair_analysis=args.pair_analysis, + pair_analysis_max_effects=args.pair_analysis_max_effects, + ) + if key in cached_by_key: + print(f"\n[{case_index}/{total_cases}] Reusing cached {label}", flush=True) + continue + print(f"\n[{case_index}/{total_cases}] Starting {label}", flush=True) start = time.perf_counter() result = run_case( @@ -838,6 +955,7 @@ def main() -> int: pair_analysis_max_effects=args.pair_analysis_max_effects, ) results.append(result) + cached_by_key[key] = result_payload(result) elapsed = time.perf_counter() - start print(f"[{case_index}/{total_cases}] Finished {label} in {elapsed:.3f}s", flush=True) print_case(result) From 6df15f1d8bba83df159d1977143c669c52c2ebb1 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 21:36:19 -0600 Subject: [PATCH 099/150] Add surface check plan metadata --- .../src/pecos/qec/surface/_check_plan.py | 145 ++++++++++++++++++ .../src/pecos/qec/surface/decode.py | 122 ++++++++++++--- .../tests/qec/surface/test_check_plan.py | 119 ++++++++++++++ 3 files changed, 361 insertions(+), 25 deletions(-) create mode 100644 python/quantum-pecos/src/pecos/qec/surface/_check_plan.py create mode 100644 python/quantum-pecos/tests/qec/surface/test_check_plan.py diff --git a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py new file mode 100644 index 000000000..e50cf9e1e --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py @@ -0,0 +1,145 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Resolved surface-code check-plan metadata.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from typing import Any + +CHECK_PLAN_METADATA_FORMAT = "pecos.surface.check_plan" +CHECK_PLAN_METADATA_VERSION = 1 +CHECK_PLAN_HASH_ALGORITHM = "sha256" +CHECK_PLAN_HASH_SERIALIZATION = "canonical-json-v1" + +_DEFAULT_CHECK_PLAN_BY_BASIS = { + "cx": "cx_standard_v1", + "szz": "szz_current_v1", +} + + +def _normalize_interaction_basis_name(interaction_basis: str) -> str: + normalized = interaction_basis.lower() + if normalized not in _DEFAULT_CHECK_PLAN_BY_BASIS: + msg = f"interaction_basis must be 'cx' or 'szz', got {interaction_basis!r}" + raise ValueError(msg) + return normalized + + +def _normalize_check_plan_id(check_plan: str) -> str: + normalized = check_plan.lower() + if normalized not in _PLAN_SEMANTICS: + msg = f"unknown check_plan {check_plan!r}; expected one of {sorted(_PLAN_SEMANTICS)}" + raise ValueError(msg) + return normalized + + +_PLAN_SEMANTICS: dict[str, dict[str, Any]] = { + "cx_standard_v1": { + "plan_id": "cx_standard_v1", + "interaction_basis": "cx", + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + }, + "x_check": { + "template": "current_cx_x_check_v1", + "measurement_sign_policy": "none", + }, + "z_check": { + "template": "current_cx_z_check_v1", + "measurement_sign_policy": "none", + }, + "prefix_policy": "none", + }, + "szz_current_v1": { + "plan_id": "szz_current_v1", + "interaction_basis": "szz", + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + }, + "x_check": { + "template": "current_szz_x_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "z_check": { + "template": "current_szz_z_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "prefix_policy": "forward_flow_virtual_z_v1", + }, +} + + +def canonical_check_plan_json(value: dict[str, Any]) -> str: + """Serialize check-plan metadata with stable cross-platform bytes.""" + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def _semantic_hash(semantic_content: dict[str, Any]) -> str: + encoded = canonical_check_plan_json(semantic_content).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +@dataclass(frozen=True) +class ResolvedSurfaceCheckPlan: + """Internal resolved check-plan metadata for current surface-memory presets.""" + + plan_id: str + interaction_basis: str + semantic_content: dict[str, Any] + resolved_metadata: dict[str, Any] + resolved_hash: str + + +def resolve_surface_check_plan( + *, + interaction_basis: str | None = None, + check_plan: str | None = None, +) -> ResolvedSurfaceCheckPlan: + """Resolve the public check-plan selector to deterministic metadata. + + ``check_plan`` is the source of truth. ``interaction_basis`` remains a + backward-compatible default-plan selector and must agree when both are + provided. + """ + normalized_basis = None if interaction_basis is None else _normalize_interaction_basis_name(interaction_basis) + if check_plan is None: + plan_id = _DEFAULT_CHECK_PLAN_BY_BASIS[normalized_basis or "cx"] + else: + plan_id = _normalize_check_plan_id(check_plan) + + semantic_content = json.loads(canonical_check_plan_json(_PLAN_SEMANTICS[plan_id])) + plan_basis = str(semantic_content["interaction_basis"]) + if normalized_basis is not None and normalized_basis != plan_basis: + msg = ( + f"interaction_basis={normalized_basis!r} conflicts with " + f"check_plan={plan_id!r}, which uses interaction_basis={plan_basis!r}" + ) + raise ValueError(msg) + + resolved_hash = _semantic_hash(semantic_content) + resolved_metadata = { + "format": CHECK_PLAN_METADATA_FORMAT, + "metadata_version": CHECK_PLAN_METADATA_VERSION, + "hash_algorithm": CHECK_PLAN_HASH_ALGORITHM, + "hash_serialization": CHECK_PLAN_HASH_SERIALIZATION, + "semantic_content": semantic_content, + } + return ResolvedSurfaceCheckPlan( + plan_id=plan_id, + interaction_basis=plan_basis, + semantic_content=semantic_content, + resolved_metadata=resolved_metadata, + resolved_hash=resolved_hash, + ) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 68df785cb..ce4899f1f 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -51,6 +51,8 @@ import numpy as np +from pecos.qec.surface._check_plan import resolve_surface_check_plan + if TYPE_CHECKING: import stim from numpy.typing import NDArray @@ -317,6 +319,10 @@ class _CachedNativeSurfaceTopology: num_detectors: int num_observables: int num_pauli_sites: int + interaction_basis: str + check_plan: str + resolved_check_plan: dict[str, Any] + resolved_check_plan_hash: str def _surface_patch_cache_key(patch: SurfacePatch) -> tuple[int, int, str, bool]: @@ -1900,6 +1906,7 @@ def _surface_native_topology( pauli_frame_lookup = PauliFrameLookup.from_circuit(dag, det_records, obs_records) num_pauli_sites = pauli_frame_lookup.num_pauli_sites + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis) return _CachedNativeSurfaceTopology( dag_circuit=dag, influence_map=influence_map, @@ -1916,6 +1923,10 @@ def _surface_native_topology( num_detectors=len(det_records), num_observables=len(obs_records), num_pauli_sites=num_pauli_sites, + interaction_basis=resolved_plan.interaction_basis, + check_plan=resolved_plan.plan_id, + resolved_check_plan=resolved_plan.resolved_metadata, + resolved_check_plan_hash=resolved_plan.resolved_hash, ) @@ -1931,8 +1942,10 @@ def _cached_surface_native_topology( twirl: TwirlConfig | None = None, interaction_basis: str = "cx", szz_physical_prefixes: bool = False, + resolved_check_plan_hash: str = "", ) -> _CachedNativeSurfaceTopology: """Cache topology-only native analysis shared across noise parameters.""" + _ = resolved_check_plan_hash return _surface_native_topology( patch_key, num_rounds, @@ -2026,8 +2039,10 @@ def _cached_surface_native_dem_string( p_idle_z_quadratic_sine_rate: float | None = None, twirl: TwirlConfig | None = None, interaction_basis: str = "cx", + resolved_check_plan_hash: str = "", ) -> str: """Cache native DEM strings across callers for one topology + noise tuple.""" + _ = resolved_check_plan_hash include_idle_gates = _uses_dedicated_idle_noise( p_idle=p_idle, t1=t1, @@ -2060,6 +2075,7 @@ def _cached_surface_native_dem_string( twirl=twirl, interaction_basis=interaction_basis, szz_physical_prefixes=szz_physical_prefixes, + resolved_check_plan_hash=resolved_check_plan_hash, ) return _dem_string_from_cached_surface_topology( topology, @@ -2152,6 +2168,10 @@ def _build_native_sampler_from_cached_surface_topology( pauli_frame_lookup=topology.pauli_frame_lookup, num_pauli_sites=topology.num_pauli_sites, sampling_model=sampling_model, + interaction_basis=topology.interaction_basis, + check_plan=topology.check_plan, + resolved_check_plan=topology.resolved_check_plan, + resolved_check_plan_hash=topology.resolved_check_plan_hash, ) @@ -2167,7 +2187,8 @@ def generate_circuit_level_dem_from_builder( circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, twirl: TwirlConfig | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> str: """Generate circuit-level DEM using PECOS native fault propagation. @@ -2211,11 +2232,14 @@ def generate_circuit_level_dem_from_builder( twirl: Optional Pauli-frame randomization layout. Canonical Guppy frame-output mode is normalized to the same abstract raw lookup and DEM topology. - interaction_basis: Surface-memory two-qubit interaction basis. The - staged ``"szz"`` path currently assumes a virtual-Z device model: - Z/SZ/SZdg frame updates are p1-free. That is a device assumption - keyed from this basis selector, not a general claim about CX - hardware. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. This is the source of + truth when supplied; ``interaction_basis`` must agree if also + supplied. The staged SZZ plan currently assumes a virtual-Z device + model: Z/SZ/SZdg frame updates are p1-free. That is a device + assumption keyed from the resolved plan, not a general claim about + CX hardware. Returns: DEM string in standard format @@ -2229,9 +2253,9 @@ def generate_circuit_level_dem_from_builder( """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) twirl = _abstract_twirl_config(twirl) - from pecos.qec.surface.circuit_builder import _normalize_interaction_basis - interaction_basis = _normalize_interaction_basis(interaction_basis) + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + interaction_basis = resolved_plan.interaction_basis _reject_szz_unlowered_physical_noise(noise, interaction_basis, circuit_source) patch_key = _surface_patch_cache_key(patch) include_idle_gates = _noise_uses_dedicated_idle_noise(noise) @@ -2276,6 +2300,7 @@ def generate_circuit_level_dem_from_builder( "p_idle_z_quadratic_sine_rate": noise.p_idle_z_quadratic_sine_rate, "twirl": twirl, "interaction_basis": interaction_basis, + "resolved_check_plan_hash": resolved_plan.resolved_hash, } if dem_decomposition != "source_graphlike": cache_kwargs["dem_decomposition"] = dem_decomposition @@ -3646,6 +3671,9 @@ class SimulationResult: decoded: Whether decoding was applied decoder_type: Decoder backend used (if decoded) interaction_basis: Surface-memory two-qubit interaction basis. + check_plan: Named surface check-plan preset. + resolved_check_plan: Canonical resolved check-plan metadata. + resolved_check_plan_hash: SHA-256 hash of the resolved plan semantics. """ distance: int @@ -3659,6 +3687,9 @@ class SimulationResult: decoded: bool decoder_type: str | None = None interaction_basis: str = "cx" + check_plan: str = "cx_standard_v1" + resolved_check_plan: dict[str, Any] | None = None + resolved_check_plan_hash: str = "" def _memory_noise_model( @@ -3695,7 +3726,8 @@ def surface_code_memory( decode: bool = True, circuit_source: Literal["abstract", "traced_qis"] = "abstract", ancilla_budget: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> SimulationResult: """Run the recommended native surface-code memory workflow. @@ -3719,8 +3751,11 @@ def surface_code_memory( decode: If false, report the raw observable-flip rate. circuit_source: ``"abstract"`` or ``"traced_qis"`` circuit source. ancilla_budget: Optional cap on simultaneously live ancillas. - interaction_basis: Surface-memory two-qubit interaction basis, - ``"cx"`` or ``"szz"``. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. This is the source of + truth when supplied; ``interaction_basis`` must agree if also + supplied. Returns: ``SimulationResult`` with logical and raw error counts/rates. @@ -3732,10 +3767,10 @@ def surface_code_memory( 0.0 """ from pecos.qec import ParsedDem - from pecos.qec.surface.circuit_builder import _normalize_interaction_basis from pecos.qec.surface.patch import SurfacePatch - interaction_basis = _normalize_interaction_basis(interaction_basis) + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + interaction_basis = resolved_plan.interaction_basis if distance < 1: msg = f"distance must be >= 1, got {distance}" raise ValueError(msg) @@ -3759,6 +3794,7 @@ def surface_code_memory( ancilla_budget=ancilla_budget, circuit_source=circuit_source, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, ) batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(shots, seed) num_raw_errors = sum(1 for shot in range(shots) if batch.get_observable_mask(shot) != 0) @@ -3776,6 +3812,9 @@ def surface_code_memory( decoded=decode, decoder_type=decoder_type if decode else None, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, + resolved_check_plan=resolved_plan.resolved_metadata, + resolved_check_plan_hash=resolved_plan.resolved_hash, ) @@ -3788,7 +3827,8 @@ def run_noisy_memory_experiment( *, decode: bool = True, decoder_type: str = "pymatching", - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> SimulationResult: """Run a noisy surface code memory experiment with optional decoding. @@ -3806,8 +3846,11 @@ def run_noisy_memory_experiment( noise: Noise model parameters decode: If True, use decoding to correct errors decoder_type: Decoder backend (pymatching, fusion_blossom, bp_osd, etc.) - interaction_basis: Surface-memory two-qubit interaction basis, - ``"cx"`` or ``"szz"``. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. This is the source of + truth when supplied; ``interaction_basis`` must agree if also + supplied. Returns: SimulationResult with error rate statistics @@ -3830,9 +3873,9 @@ def run_noisy_memory_experiment( from pecos.compilation_pipeline import compile_guppy_to_hugr from pecos.guppy.surface import get_num_qubits, make_surface_code from pecos.qec.surface import SurfacePatch - from pecos.qec.surface.circuit_builder import _normalize_interaction_basis - interaction_basis = _normalize_interaction_basis(interaction_basis) + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + interaction_basis = resolved_plan.interaction_basis # Create patch and decoder patch = SurfacePatch.create(distance=distance) geom = patch.geometry @@ -3939,6 +3982,9 @@ def run_noisy_memory_experiment( decoded=decode, decoder_type=decoder_type if decode else None, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, + resolved_check_plan=resolved_plan.resolved_metadata, + resolved_check_plan_hash=resolved_plan.resolved_hash, ) @@ -3971,6 +4017,11 @@ class NativeSampler: sampling_model: Which native sampling backend is active dem_string: Optional graphlike-decomposed DEM string used to build the sampler. Populated when the ``"dem"`` sampling model is selected. + interaction_basis: Surface-memory two-qubit interaction basis resolved + from ``check_plan``. + check_plan: Named surface check-plan preset. + resolved_check_plan: Canonical resolved check-plan metadata. + resolved_check_plan_hash: SHA-256 hash of the resolved plan semantics. """ sampler: Any @@ -3984,6 +4035,10 @@ class NativeSampler: "dem" # "mnm" accepted for compat, mapped to "influence_dem" ) dem_string: str | None = None + interaction_basis: str = "cx" + check_plan: str = "cx_standard_v1" + resolved_check_plan: dict[str, Any] | None = None + resolved_check_plan_hash: str = "" def sample( self, @@ -4033,12 +4088,13 @@ def build_native_sampler( ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", twirl: TwirlConfig | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, sampling_model: Literal[ "dem", "influence_dem", "mnm", ] = "dem", # "mnm" accepted for compat, mapped to "influence_dem", + check_plan: str | None = None, ) -> NativeSampler: """Build a PECOS native sampler for threshold estimation. @@ -4065,7 +4121,8 @@ def build_native_sampler( before native PECOS fault analysis. twirl: Optional Pauli-frame randomization layout. Canonical runtime frame-output mode is normalized to the same abstract raw lookup. - interaction_basis: Surface-memory two-qubit interaction basis. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. sampling_model: Which native sampling backend to use. ``"dem"`` samples the generated source-graphlike DEM projection and is the default; this is a decoder-facing approximation of raw hyperedges, @@ -4073,6 +4130,9 @@ def build_native_sampler( ``"influence_dem"`` uses the influence-map-based DemSampler with detector definitions. ``"mnm"`` is accepted for compatibility and maps to ``"influence_dem"``. + check_plan: Named surface check-plan preset. This is the source of + truth when supplied; ``interaction_basis`` must agree if also + supplied. Returns: NativeSampler that can generate samples for threshold estimation @@ -4086,9 +4146,9 @@ def build_native_sampler( """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) twirl = _abstract_twirl_config(twirl) - from pecos.qec.surface.circuit_builder import _normalize_interaction_basis - interaction_basis = _normalize_interaction_basis(interaction_basis) + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + interaction_basis = resolved_plan.interaction_basis _reject_szz_unlowered_physical_noise(noise, interaction_basis, circuit_source) basis = basis.upper() patch_key = _surface_patch_cache_key(patch) @@ -4103,6 +4163,7 @@ def build_native_sampler( twirl=twirl, interaction_basis=interaction_basis, szz_physical_prefixes=szz_physical_prefixes, + resolved_check_plan_hash=resolved_plan.resolved_hash, ) if sampling_model == "dem": dem_str = _cached_surface_native_dem_string( @@ -4138,6 +4199,7 @@ def build_native_sampler( p_idle_z_quadratic_sine_rate=noise.p_idle_z_quadratic_sine_rate, twirl=twirl, interaction_basis=interaction_basis, + resolved_check_plan_hash=resolved_plan.resolved_hash, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() return NativeSampler( @@ -4150,6 +4212,10 @@ def build_native_sampler( num_pauli_sites=topology.num_pauli_sites, sampling_model=sampling_model, dem_string=dem_str, + interaction_basis=resolved_plan.interaction_basis, + check_plan=resolved_plan.plan_id, + resolved_check_plan=resolved_plan.resolved_metadata, + resolved_check_plan_hash=resolved_plan.resolved_hash, ) return _build_native_sampler_from_cached_surface_topology( topology, @@ -4167,7 +4233,8 @@ def build_native_sampler_from_dem( ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", twirl: TwirlConfig | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> NativeSampler: """Build a native sampler from a caller-supplied decomposed DEM string. @@ -4178,9 +4245,9 @@ def build_native_sampler_from_dem( """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) twirl = _abstract_twirl_config(twirl) - from pecos.qec.surface.circuit_builder import _normalize_interaction_basis - interaction_basis = _normalize_interaction_basis(interaction_basis) + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + interaction_basis = resolved_plan.interaction_basis basis = basis.upper() patch_key = _surface_patch_cache_key(patch) topology = _cached_surface_native_topology( @@ -4192,6 +4259,7 @@ def build_native_sampler_from_dem( include_idle_gates=False, twirl=twirl, interaction_basis=interaction_basis, + resolved_check_plan_hash=resolved_plan.resolved_hash, ) sampler = _cached_parsed_dem(decomposed_dem).to_dem_sampler() return NativeSampler( @@ -4204,6 +4272,10 @@ def build_native_sampler_from_dem( num_pauli_sites=topology.num_pauli_sites, sampling_model="dem", dem_string=decomposed_dem, + interaction_basis=resolved_plan.interaction_basis, + check_plan=resolved_plan.plan_id, + resolved_check_plan=resolved_plan.resolved_metadata, + resolved_check_plan_hash=resolved_plan.resolved_hash, ) diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py new file mode 100644 index 000000000..11e7829db --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -0,0 +1,119 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Surface check-plan metadata tests.""" + +from __future__ import annotations + +import hashlib + +import pytest + + +def test_check_plan_default_resolves_to_cx_metadata() -> None: + from pecos.qec.surface._check_plan import canonical_check_plan_json, resolve_surface_check_plan + + plan = resolve_surface_check_plan() + + assert plan.plan_id == "cx_standard_v1" + assert plan.interaction_basis == "cx" + assert plan.resolved_metadata["metadata_version"] == 1 + assert plan.resolved_metadata["hash_algorithm"] == "sha256" + assert plan.resolved_metadata["hash_serialization"] == "canonical-json-v1" + assert "metadata_version" not in plan.semantic_content + assert plan.resolved_hash == hashlib.sha256( + canonical_check_plan_json(plan.semantic_content).encode("utf-8"), + ).hexdigest() + + +def test_check_plan_is_source_of_truth_for_basis() -> None: + from pecos.qec.surface._check_plan import resolve_surface_check_plan + + plan = resolve_surface_check_plan(check_plan="szz_current_v1") + + assert plan.plan_id == "szz_current_v1" + assert plan.interaction_basis == "szz" + + +def test_check_plan_and_interaction_basis_mismatch_fails_loudly() -> None: + from pecos.qec.surface._check_plan import resolve_surface_check_plan + + with pytest.raises(ValueError, match="conflicts with check_plan"): + resolve_surface_check_plan( + interaction_basis="cx", + check_plan="szz_current_v1", + ) + + +def test_surface_code_memory_records_resolved_check_plan() -> None: + from pecos.qec.surface import surface_code_memory + + result = surface_code_memory( + distance=3, + physical_error_rate=0.0, + shots=4, + rounds=1, + seed=123, + check_plan="szz_current_v1", + ) + + assert result.interaction_basis == "szz" + assert result.check_plan == "szz_current_v1" + assert result.resolved_check_plan is not None + assert result.resolved_check_plan["semantic_content"]["interaction_basis"] == "szz" + assert len(result.resolved_check_plan_hash) == 64 + + +def test_surface_code_memory_rejects_plan_basis_mismatch() -> None: + from pecos.qec.surface import surface_code_memory + + with pytest.raises(ValueError, match="conflicts with check_plan"): + surface_code_memory( + distance=3, + physical_error_rate=0.0, + shots=0, + rounds=1, + interaction_basis="cx", + check_plan="szz_current_v1", + ) + + +def test_check_plan_does_not_change_current_szz_dem() -> None: + from pecos.qec.surface import NoiseModel, 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) + + by_basis = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=noise, + interaction_basis="szz", + ) + by_plan = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=noise, + check_plan="szz_current_v1", + ) + + assert by_plan == by_basis + + +def test_native_sampler_records_resolved_check_plan() -> None: + from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler + + patch = SurfacePatch.create(distance=3) + sampler = build_native_sampler( + patch, + num_rounds=1, + noise=NoiseModel(p2=0.001), + check_plan="szz_current_v1", + ) + + assert sampler.interaction_basis == "szz" + assert sampler.check_plan == "szz_current_v1" + assert sampler.resolved_check_plan is not None + assert sampler.resolved_check_plan["semantic_content"]["interaction_basis"] == "szz" + assert len(sampler.resolved_check_plan_hash) == 64 From 379d8c00a4e419b80715d91703337ebf4352226f Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 23:58:07 -0600 Subject: [PATCH 100/150] Support MeasureFree in sim_neo extract_commands and align stale measurement-count tests to the circuit's num_measurements --- .../pecos-rslib-exp/src/sim_neo_bindings.rs | 6 +- .../tests/qec/test_meas_sampling_backend.py | 18 +++--- .../tests/qec/test_raw_measurement_result.py | 63 ++++++++++--------- 3 files changed, 50 insertions(+), 37 deletions(-) diff --git a/python/pecos-rslib-exp/src/sim_neo_bindings.rs b/python/pecos-rslib-exp/src/sim_neo_bindings.rs index 57fd31c7f..92c5a405e 100644 --- a/python/pecos-rslib-exp/src/sim_neo_bindings.rs +++ b/python/pecos-rslib-exp/src/sim_neo_bindings.rs @@ -1295,7 +1295,11 @@ fn extract_commands(py_tc: &Bound<'_, PyAny>) -> PyResult { cb = cb.tdg(&qubits); } - "MZ" => { + "MZ" | "Measure" | "MeasureFree" => { + // Z-basis measurement; ``MeasureFree`` additionally frees the + // qubit, which is a no-op for the fixed-width simulator. Kept + // consistent with build_rust_tick_circuit_from_gates and the + // surface DEM path, which treat all three as Z measurements. cb = cb.mz(&qubits); } "RX" => { diff --git a/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py b/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py index a0564c682..23f04dd23 100644 --- a/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py +++ b/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py @@ -33,7 +33,7 @@ def coherent(): class TestD3SurfaceCode57vs48: def test_raw_output_is_57_measurements(self, d3_tc, depol): r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).shots(10).seed(42).run() - assert len(r[0]) == 57 + assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_nondet_measurement_mean_half(self, d3_tc, depol): shots = 5000 @@ -44,8 +44,12 @@ def test_nondet_measurement_mean_half(self, d3_tc, depol): def test_det_measurement_mean_low(self, d3_tc, depol): shots = 5000 r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() - mean_4 = sum(s[4] for s in r) / shots - assert mean_4 < 0.1, f"meas[4]={mean_4:.3f}" + # Index 8 is the first round-1 Z-stabilizer measurement; on the |0_L> + # memory experiment it is deterministic (+1). Indices 0-7 are the four + # init-round X-stabilizer measurements plus round-1 X-stabilizers, which + # are random on |0_L>. + mean_det = sum(s[8] for s in r) / shots + assert mean_det < 0.1, f"meas[8]={mean_det:.3f}" def test_z_type_detection_rates_match_stabilizer(self, d3_tc, depol): """Z-type detector rates match stabilizer (known-good subset).""" @@ -150,11 +154,11 @@ def rates(results): class TestMethodDispatch: def test_auto_no_idle_rz(self, d3_tc, depol): r = sim_neo(d3_tc).quantum(meas_sampling("auto")).noise(depol).shots(10).seed(42).run() - assert len(r[0]) == 57 + assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_auto_with_idle_rz(self, d3_tc, coherent): r = sim_neo(d3_tc).quantum(meas_sampling("auto")).noise(coherent).shots(10).seed(42).run() - assert len(r[0]) == 57 + assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_stochastic_rejects_idle_rz(self, d3_tc, coherent): with pytest.raises(Exception, match="idle_rz"): @@ -162,11 +166,11 @@ def test_stochastic_rejects_idle_rz(self, d3_tc, coherent): def test_coherent_no_idle_rz(self, d3_tc, depol): r = sim_neo(d3_tc).quantum(meas_sampling("coherent")).noise(depol).shots(10).seed(42).run() - assert len(r[0]) == 57 + assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_coherent_with_idle_rz(self, d3_tc, coherent): r = sim_neo(d3_tc).quantum(meas_sampling("coherent")).noise(coherent).shots(10).seed(42).run() - assert len(r[0]) == 57 + assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_invalid_method(self, d3_tc, depol): with pytest.raises(Exception, match="Unknown"): diff --git a/python/quantum-pecos/tests/qec/test_raw_measurement_result.py b/python/quantum-pecos/tests/qec/test_raw_measurement_result.py index 7bb169a1e..facf6b587 100644 --- a/python/quantum-pecos/tests/qec/test_raw_measurement_result.py +++ b/python/quantum-pecos/tests/qec/test_raw_measurement_result.py @@ -16,33 +16,38 @@ @pytest.fixture def d3_results(): - """Run both backends on the same circuit and return their results.""" + """Run both backends on the same circuit and return their results. + + The third element is the circuit's declared measurement count, used so the + assertions track the actual circuit contract rather than a hard-coded value. + """ patch = SurfacePatch.create(distance=3) tc = _build_surface_tick_circuit_for_native_model(patch, 6, "Z", circuit_source="abstract") + num_meas = int(tc.get_meta("num_measurements")) depol = depolarizing().p1(0.005).p2(0.005).p_meas(0.005).p_prep(0.005) stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(100).seed(42).run() meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(100).seed(42).run() - return stab_r, meas_r + return stab_r, meas_r, num_meas class TestCommonProtocol: """Both backends return objects with the same interface.""" def test_len(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results assert len(stab_r) == 100 assert len(meas_r) == 100 def test_indexing(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, num_meas = d3_results # r[shot] returns a sequence of u8 values s0 = stab_r[0] d0 = meas_r[0] - assert len(s0) == len(d0) == 57 # d=3 surface code has 57 measurements + assert len(s0) == len(d0) == num_meas def test_item_values(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results # Individual values are 0 or 1 for val in stab_r[0]: assert val in (0, 1) @@ -50,80 +55,80 @@ def test_item_values(self, d3_results): assert val in (0, 1) def test_list_conversion(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, num_meas = d3_results s_row = list(stab_r[0]) d_row = list(meas_r[0]) assert all(isinstance(v, int) for v in s_row) assert all(isinstance(v, int) for v in d_row) - assert len(s_row) == len(d_row) == 57 + assert len(s_row) == len(d_row) == num_meas def test_iteration(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, num_meas = d3_results stab_count = 0 for row in stab_r: stab_count += 1 - assert len(row) == 57 + assert len(row) == num_meas assert stab_count == 100 dem_count = 0 for row in meas_r: dem_count += 1 - assert len(row) == 57 + assert len(row) == num_meas assert dem_count == 100 def test_out_of_range_raises_index_error(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results with pytest.raises(IndexError): stab_r[100] with pytest.raises(IndexError): meas_r[100] def test_num_shots_property(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results assert stab_r.num_shots == 100 assert meas_r.num_shots == 100 def test_num_measurements_property(self, d3_results): - stab_r, meas_r = d3_results - assert stab_r.num_measurements == 57 - assert meas_r.num_measurements == 57 + stab_r, meas_r, num_meas = d3_results + assert stab_r.num_measurements == num_meas + assert meas_r.num_measurements == num_meas def test_get_method(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results # get(shot, meas) returns 0 or 1 assert stab_r.get(0, 0) in (0, 1) assert meas_r.get(0, 0) in (0, 1) def test_get_out_of_range(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, num_meas = d3_results with pytest.raises(IndexError): stab_r.get(100, 0) with pytest.raises(IndexError): - meas_r.get(0, 57) + meas_r.get(0, num_meas) def test_get_shot_method(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, num_meas = d3_results s = stab_r.get_shot(0) d = meas_r.get_shot(0) - assert len(s) == len(d) == 57 + assert len(s) == len(d) == num_meas def test_to_list(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, num_meas = d3_results sl = stab_r.to_list() dl = meas_r.to_list() assert len(sl) == len(dl) == 100 - assert len(sl[0]) == len(dl[0]) == 57 + assert len(sl[0]) == len(dl[0]) == num_meas def test_negative_index_raises_index_error(self, d3_results): """Negative indexing raises IndexError, never OverflowError.""" - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results with pytest.raises(IndexError): stab_r[-1] with pytest.raises(IndexError): meas_r[-1] def test_negative_get_raises_index_error(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results # Negative shot with pytest.raises(IndexError): stab_r.get(-1, 0) @@ -136,7 +141,7 @@ def test_negative_get_raises_index_error(self, d3_results): meas_r.get(0, -1) def test_get_shot_negative_raises_index_error(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results with pytest.raises(IndexError): stab_r.get_shot(-1) with pytest.raises(IndexError): @@ -144,7 +149,7 @@ def test_get_shot_negative_raises_index_error(self, d3_results): def test_out_of_range_uses_len(self, d3_results): """result[len(result)] raises IndexError.""" - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results with pytest.raises(IndexError): stab_r[len(stab_r)] with pytest.raises(IndexError): @@ -173,7 +178,7 @@ def test_generic_consumer_stabilizer(self): result = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(1000).seed(42).run() means = self.compute_measurement_means(result) - assert len(means) == 57 + assert len(means) == int(tc.get_meta("num_measurements")) # Non-det measurements should be ~0.5, det should be ~0 nondet = sum(1 for m in means if abs(m - 0.5) < 0.15) assert nondet > 0 @@ -185,6 +190,6 @@ def test_generic_consumer_meas_sampling(self): result = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(1000).seed(42).run() means = self.compute_measurement_means(result) - assert len(means) == 57 + assert len(means) == int(tc.get_meta("num_measurements")) nondet = sum(1 for m in means if abs(m - 0.5) < 0.15) assert nondet > 0 From fe5d68ceef3f9cb6dd5fee77744e42a493e6b572 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 23:58:07 -0600 Subject: [PATCH 101/150] Normalize the fresh traced circuit in the constrained-budget cache test to match the native DEM path --- .../tests/qec/surface/test_surface_decoder.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 2caa90f22..eb3934c47 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -628,7 +628,11 @@ def test_constrained_budget_uses_cache_and_matches_fresh_build(self) -> None: DEM built fresh from the corresponding TickCircuit, for both the ``abstract`` and ``traced_qis`` sources -- pinning that caching is sound for constrained budgets, not just unconstrained ones.""" - from pecos.qec.surface.circuit_builder import generate_dem_from_tick_circuit, generate_tick_circuit_from_patch + from pecos.qec.surface.circuit_builder import ( + generate_dem_from_tick_circuit, + generate_tick_circuit_from_patch, + normalize_traced_qis_tick_circuit, + ) from pecos.qec.surface.decode import ( _build_surface_tick_circuit_for_native_model, generate_circuit_level_dem_from_builder, @@ -658,6 +662,10 @@ def test_constrained_budget_uses_cache_and_matches_fresh_build(self) -> None: ancilla_budget=2, circuit_source="traced_qis", ) + # The native topology path normalizes traced-QIS circuits (Clifford-rotation + # lowering + single-qubit Clifford-chain simplification) before DEM + # construction; the fresh comparison must apply the same normalization. + normalize_traced_qis_tick_circuit(traced_tc, context="constrained budget cache test") cached_traced = generate_circuit_level_dem_from_builder( patch, num_rounds=2, From 9e4fceb8208c9900b122748446e64a759c3fe84d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 14 Jun 2026 10:16:36 -0600 Subject: [PATCH 102/150] Restore QEC branch health checks --- .../pecos-rslib-exp/src/sim_neo_bindings.rs | 2 + .../src/fault_tolerance_bindings.rs | 16 +++ .../qec/test_decomposed_dem_invariants.py | 113 +++++++++++------- .../tests/qec/test_dem_sampler_vs_stim.py | 37 ++---- .../tests/qec/test_from_guppy_result_tags.py | 15 ++- 5 files changed, 101 insertions(+), 82 deletions(-) diff --git a/python/pecos-rslib-exp/src/sim_neo_bindings.rs b/python/pecos-rslib-exp/src/sim_neo_bindings.rs index 92c5a405e..9f656c12f 100644 --- a/python/pecos-rslib-exp/src/sim_neo_bindings.rs +++ b/python/pecos-rslib-exp/src/sim_neo_bindings.rs @@ -925,6 +925,7 @@ fn build_rust_tick_circuit_from_gates( "QAlloc" | "PZ" | "Prep" => { pz_qubits.extend(qubit_ids); } + "TrackedPauli" | "TrackedPauliMeta" => {} _ => { let core_gate = build_gate_from_python(gate, &gate_name, &qubit_ids)?; other_gates.push(core_gate); @@ -1357,6 +1358,7 @@ fn extract_commands(py_tc: &Bound<'_, PyAny>) -> PyResult { // Identity/Idle gates: skip (no-op for simulation) } + "TrackedPauli" | "TrackedPauliMeta" => {} _ => { return Err(PyErr::new::(format!( "Unsupported gate type '{name}' in extract_commands. \ diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 3b607e317..8cada712b 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -1445,6 +1445,22 @@ fn contribution_record_to_pydict( dict.set_item("direct_source_family", family_label)?; } dict.set_item("replacement_branch", contribution.replacement_branch)?; + if let Some(parts) = &contribution.source_component_effects { + dict.set_item( + "source_component_detectors", + parts + .iter() + .map(|part| part.detectors.to_vec()) + .collect::>(), + )?; + dict.set_item( + "source_component_dem_outputs", + parts + .iter() + .map(|part| part.dem_outputs.to_vec()) + .collect::>(), + )?; + } match contribution.source_type { RustFaultSourceType::Direct => { 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 11f2e90ad..45db9a669 100644 --- a/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py +++ b/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py @@ -104,6 +104,20 @@ def xor_effect_rows(left: dict[str, list[int]], right: dict[str, list[int]]) -> ) +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] = {} @@ -204,6 +218,7 @@ def _find_gate_attrs( phase: str | None = None, label_prefix: str | None = None, stabilizer: str | None = None, + syndrome_round: int | None = None, ) -> dict[str, object]: """Find the first DAG gate attribute record matching the requested filters.""" for node in sorted(dag.nodes()): @@ -217,10 +232,13 @@ def _find_gate_attrs( continue if stabilizer is not None and attrs.get("stabilizer") != stabilizer: continue + if syndrome_round is not None and attrs.get("syndrome_round") != syndrome_round: + continue return attrs msg = ( f"no gate attrs found for gate_type={gate_type!r}, phase={phase!r}, " - f"label_prefix={label_prefix!r}, stabilizer={stabilizer!r}" + f"label_prefix={label_prefix!r}, stabilizer={stabilizer!r}, " + f"syndrome_round={syndrome_round!r}" ) raise AssertionError(msg) @@ -238,18 +256,24 @@ def test_surface_tick_gate_metadata_preserves_phase_round_context_in_dag() -> No assert h_pre["syndrome_round"] == 0 assert "cx_round" not in h_pre - ancilla_reset = _find_gate_attrs(dag, "PZ", phase="syndrome_prep", label_prefix="ax") - assert ancilla_reset["phase"] == "syndrome_prep" - assert ancilla_reset["syndrome_round"] == 1 - assert "cx_round" not in ancilla_reset - assert ancilla_reset["stabilizer"] == "X0" - assert ancilla_reset["stabilizer_kind"] == "X" - assert ancilla_reset["stabilizer_index"] == 0 - assert ancilla_reset["stabilizer_is_boundary"] is True - assert ancilla_reset["stabilizer_region"] - assert ancilla_reset["ancilla_qubit"] >= patch.num_data - - cx = _find_gate_attrs(dag, "CX", phase="cx_round_1") + ancilla_prep = _find_gate_attrs( + dag, + "QAlloc", + phase="syndrome_prep", + label_prefix="ax", + syndrome_round=1, + ) + assert ancilla_prep["phase"] == "syndrome_prep" + assert ancilla_prep["syndrome_round"] == 1 + assert "cx_round" not in ancilla_prep + assert ancilla_prep["stabilizer"] == "X0" + assert ancilla_prep["stabilizer_kind"] == "X" + assert ancilla_prep["stabilizer_index"] == 0 + assert ancilla_prep["stabilizer_is_boundary"] is True + assert ancilla_prep["stabilizer_region"] + assert ancilla_prep["ancilla_qubit"] >= patch.num_data + + cx = _find_gate_attrs(dag, "CX", phase="cx_round_1", syndrome_round=0) assert cx["phase"] == "cx_round_1" assert cx["syndrome_round"] == 0 assert cx["cx_round"] == 1 @@ -263,7 +287,13 @@ def test_surface_tick_gate_metadata_preserves_phase_round_context_in_dag() -> No assert cx["ancilla_qubit"] >= patch.num_data assert cx["data_qubit"] < patch.num_data - ancilla_measure = _find_gate_attrs(dag, "MZ", phase="measure_ancilla", label_prefix="sx") + ancilla_measure = _find_gate_attrs( + dag, + "MeasureFree", + phase="measure_ancilla", + label_prefix="sx", + syndrome_round=0, + ) assert ancilla_measure["phase"] == "measure_ancilla" assert ancilla_measure["syndrome_round"] == 0 assert ancilla_measure["cx_round"] == 4 @@ -284,13 +314,13 @@ def test_surface_tick_gate_metadata_tracks_reused_ancillas_by_label() -> None: dag = tc.to_dag_circuit() first_alloc = _find_gate_attrs(dag, "QAlloc", phase="syndrome_prep", label_prefix="ax0") - reused_reset = _find_gate_attrs(dag, "PZ", phase="syndrome_prep", label_prefix="ax1") + reused_alloc = _find_gate_attrs(dag, "QAlloc", phase="syndrome_prep", label_prefix="ax1") reused_cx = _find_gate_attrs(dag, "CX", phase="cx_round_1", stabilizer="X1") assert first_alloc["stabilizer"] == "X0" - assert reused_reset["stabilizer"] == "X1" - assert reused_reset["ancilla_qubit"] == first_alloc["ancilla_qubit"] - assert reused_reset["ancilla_qubit"] == patch.num_data + assert reused_alloc["stabilizer"] == "X1" + assert reused_alloc["ancilla_qubit"] == first_alloc["ancilla_qubit"] + assert reused_alloc["ancilla_qubit"] == patch.num_data assert reused_cx["stabilizer"] == "X1" assert reused_cx["ancilla_qubit"] == patch.num_data @@ -481,20 +511,23 @@ def test_structured_source_tracking_bindings_are_self_consistent(basis: str) -> @pytest.mark.parametrize("basis", ["X", "Z"]) -def test_structured_source_tracking_y_decomposed_rows_xor_back_to_effect(basis: str) -> None: - """Y-decomposed structured rows should XOR back to their parent effect.""" +def test_structured_source_component_rows_xor_back_to_effect(basis: str) -> None: + """Source component rows should XOR back to their parent effect.""" dem = build_source_tracked_dem(distance=3, basis=basis, rounds=20) - summaries = [row for row in dem.contribution_effect_summaries() if row["y_decomposed_count"] > 0] - assert summaries + 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: + continue + rows.append((summary, row)) - for summary in summaries[:20]: - contributions = dem.contributions_for_effect(summary["detectors"], summary["dem_outputs"]) - y_rows = [row for row in contributions if row["source_type"] == "YDecomposed"] - assert y_rows - for row in y_rows: - assert xor_lists(row["x_detectors"], row["z_detectors"]) == summary["detectors"] - assert xor_lists(row["x_dem_outputs"], row["z_dem_outputs"]) == summary["dem_outputs"] + assert rows + + for summary, row in rows[:100]: + dets, outputs = xor_source_components(row) + assert dets == summary["detectors"] + assert outputs == summary["dem_outputs"] @pytest.mark.parametrize("basis", ["X", "Z"]) @@ -542,20 +575,9 @@ def test_structured_one_sided_direct_component_rows_are_exposed(basis: str) -> N assert rows for summary, row in rows[:100]: - left_non_empty = bool(row["component_1_detectors"] or row["component_1_dem_outputs"]) - right_non_empty = bool(row["component_2_detectors"] or row["component_2_dem_outputs"]) - assert left_non_empty != right_non_empty - assert row["direct_source_family"] == "TwoLocationOneSidedComponent" - direct_dets, direct_logs = xor_effect_rows( - { - "detectors": row["component_1_detectors"], - "dem_outputs": row["component_1_dem_outputs"], - }, - { - "detectors": row["component_2_detectors"], - "dem_outputs": row["component_2_dem_outputs"], - }, - ) + 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"] @@ -576,7 +598,8 @@ 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"] == "TwoLocationOneSidedComponent" 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) @pytest.mark.parametrize("basis", ["X", "Z"]) @@ -626,7 +649,7 @@ 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("YDecomposed" in row["source_type_counts"] for row in render_summaries) + assert any("DirectOneSidedComponent" in row["source_type_counts"] for row in render_summaries) @pytest.mark.parametrize("basis", ["X", "Z"]) diff --git a/python/quantum-pecos/tests/qec/test_dem_sampler_vs_stim.py b/python/quantum-pecos/tests/qec/test_dem_sampler_vs_stim.py index dd4f7d943..d4dd35a59 100644 --- a/python/quantum-pecos/tests/qec/test_dem_sampler_vs_stim.py +++ b/python/quantum-pecos/tests/qec/test_dem_sampler_vs_stim.py @@ -12,6 +12,7 @@ import numpy as np import pytest +from pecos.qec.surface import get_measurement_order_from_tick_circuit if TYPE_CHECKING: from pecos.quantum import DagCircuit, TickCircuit @@ -21,34 +22,8 @@ def extract_measurement_order(tc: "TickCircuit") -> list[int]: - """Extract measurement order from TickCircuit. - - Returns a list of qubit indices in the order they were measured. - This is needed to map detector record offsets (which use TickCircuit - measurement indices) to influence map indices (which use DAG order). - - Args: - tc: TickCircuit to extract measurement order from. - - Returns: - List of qubit indices in measurement execution order. - """ - measurement_order = [] - - for tick_idx in range(tc.num_ticks()): - tick = tc.get_tick(tick_idx) - if tick is None: - continue - for gate in tick.gate_batches(): - gate_type = str(gate.gate_type) - if "MZ" in gate_type: - for qubit in gate.qubits: - if hasattr(qubit, "index"): - measurement_order.append(qubit.index()) - else: - measurement_order.append(int(qubit)) - - return measurement_order + """Extract measurement order from TickCircuit.""" + return get_measurement_order_from_tick_circuit(tc) def parse_dem_string(dem_str: str) -> dict[tuple, float]: @@ -174,7 +149,11 @@ def test_dem_mechanism_counts_match( **noise_params, decompose_errors=False, ) - stim_dem = generate_dem_from_tick_circuit_via_stim(tc, **noise_params) + stim_dem = generate_dem_from_tick_circuit_via_stim( + tc, + **noise_params, + decompose_errors=False, + ) comparison = compare_dems(pecos_dem, stim_dem) diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py b/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py index 160bff950..909f43b89 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py @@ -29,7 +29,7 @@ import pytest from guppylang import guppy from guppylang.std.builtins import result -from guppylang.std.quantum import measure, qubit, x +from guppylang.std.quantum import cx, h, measure, qubit from pecos.guppy import get_num_qubits, make_surface_code from pecos.qec import DetectorErrorModel @@ -40,9 +40,9 @@ # tag_c -> [2] (ordinals of the measurements the tags actually record, not # the order of the result() calls). # -# Each qubit gets a *different* number of single-qubit gates before measure -# (qa: 0, qb: 1, qc: 2). With p1 > 0 those gates contribute distinct error -# mechanisms touching only that qubit's measurement, so the DEMs for +# The nontrivial pre-history entangles qb and qc after an H on qb. With +# p1/p2 > 0 this produces distinct mechanisms for detectors anchored to qa, +# qb, and qc, so the DEMs for # detectors anchored to records [-3], [-2], [-1] differ in their (number of) # mechanisms / probabilities. A test asserting result_tags equals positional # records is then load-bearing: a wrong ordinal mapping would produce a @@ -54,9 +54,8 @@ def _scrambled_three_measurements() -> None: qa = qubit() qb = qubit() qc = qubit() - x(qb) - x(qc) - x(qc) + h(qb) + cx(qb, qc) a = measure(qa) b = measure(qb) c = measure(qc) @@ -65,7 +64,7 @@ def _scrambled_three_measurements() -> None: result("tag_b", b) -_NOISE = {"p1": 0.01, "p2": 0.0, "p_meas": 0.1, "p_prep": 0.005} +_NOISE = {"p1": 0.01, "p2": 0.02, "p_meas": 0.1, "p_prep": 0.005} def _from_guppy(detectors_json: str, *, observables_json: str = "[]") -> str: From 930d4f061d5d2a7113d1718ace2f151e7eece18c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 14 Jun 2026 12:27:20 -0600 Subject: [PATCH 103/150] Thread check plan metadata through topology cache --- .../quantum-pecos/src/pecos/qec/surface/decode.py | 13 ++++++++++++- .../tests/qec/surface/test_check_plan.py | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index ce4899f1f..79343afd8 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1846,6 +1846,7 @@ def _surface_native_topology( runtime: object | None = None, twirl: TwirlConfig | None = None, interaction_basis: str = "cx", + check_plan: str | None = None, szz_physical_prefixes: bool = False, ) -> _CachedNativeSurfaceTopology: """Build topology-only native analysis shared across noise parameters.""" @@ -1859,6 +1860,8 @@ def _surface_native_topology( normalize_traced_qis_tick_circuit, ) + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + interaction_basis = resolved_plan.interaction_basis patch = _cached_surface_patch(patch_key) tc = _build_surface_tick_circuit_for_native_model( patch, @@ -1906,7 +1909,6 @@ def _surface_native_topology( pauli_frame_lookup = PauliFrameLookup.from_circuit(dag, det_records, obs_records) num_pauli_sites = pauli_frame_lookup.num_pauli_sites - resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis) return _CachedNativeSurfaceTopology( dag_circuit=dag, influence_map=influence_map, @@ -1941,6 +1943,7 @@ def _cached_surface_native_topology( *, twirl: TwirlConfig | None = None, interaction_basis: str = "cx", + check_plan: str | None = None, szz_physical_prefixes: bool = False, resolved_check_plan_hash: str = "", ) -> _CachedNativeSurfaceTopology: @@ -1955,6 +1958,7 @@ def _cached_surface_native_topology( include_idle_gates, twirl=twirl, interaction_basis=interaction_basis, + check_plan=check_plan, szz_physical_prefixes=szz_physical_prefixes, ) @@ -2039,6 +2043,7 @@ def _cached_surface_native_dem_string( p_idle_z_quadratic_sine_rate: float | None = None, twirl: TwirlConfig | None = None, interaction_basis: str = "cx", + check_plan: str | None = None, resolved_check_plan_hash: str = "", ) -> str: """Cache native DEM strings across callers for one topology + noise tuple.""" @@ -2074,6 +2079,7 @@ def _cached_surface_native_dem_string( include_idle_gates, twirl=twirl, interaction_basis=interaction_basis, + check_plan=check_plan, szz_physical_prefixes=szz_physical_prefixes, resolved_check_plan_hash=resolved_check_plan_hash, ) @@ -2271,6 +2277,7 @@ def generate_circuit_level_dem_from_builder( runtime=runtime, twirl=twirl, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, ) return _dem_string_from_cached_surface_topology( @@ -2300,6 +2307,7 @@ def generate_circuit_level_dem_from_builder( "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, "resolved_check_plan_hash": resolved_plan.resolved_hash, } if dem_decomposition != "source_graphlike": @@ -4162,6 +4170,7 @@ def build_native_sampler( _noise_uses_dedicated_idle_noise(noise), twirl=twirl, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, resolved_check_plan_hash=resolved_plan.resolved_hash, ) @@ -4199,6 +4208,7 @@ def build_native_sampler( 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, resolved_check_plan_hash=resolved_plan.resolved_hash, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() @@ -4259,6 +4269,7 @@ def build_native_sampler_from_dem( include_idle_gates=False, twirl=twirl, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, resolved_check_plan_hash=resolved_plan.resolved_hash, ) sampler = _cached_parsed_dem(decomposed_dem).to_dem_sampler() 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 11e7829db..02ccf46f0 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -110,6 +110,7 @@ def test_native_sampler_records_resolved_check_plan() -> None: num_rounds=1, noise=NoiseModel(p2=0.001), check_plan="szz_current_v1", + sampling_model="influence_dem", ) assert sampler.interaction_basis == "szz" From 659f996f8077119362cc65bbf4202ecfed416cac Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 14 Jun 2026 13:46:48 -0600 Subject: [PATCH 104/150] Clean decoder study report whitespace --- examples/surface/results/inner_decoder_study_report.md | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/surface/results/inner_decoder_study_report.md b/examples/surface/results/inner_decoder_study_report.md index 0925e703d..82f40a73d 100644 --- a/examples/surface/results/inner_decoder_study_report.md +++ b/examples/surface/results/inner_decoder_study_report.md @@ -180,4 +180,3 @@ binomial per (family, d, p, inner). `k/n` = failures / shots. | memory | belief_matching | 27.7 | 24.13 | 482.5 | | memory | tesseract | 16.9 | 44.44 | 888.8 | | memory | pecos_uf:bp | 26.3 | 49.61 | 992.2 | - From 3a63c1e9e26b95c056c54c489d50a03344f1f6fa Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 14 Jun 2026 19:16:19 -0600 Subject: [PATCH 105/150] Harden parser/binding robustness: SparseDem rejects malformed D/L tokens like the other parsers, and the two algorithm-decoder bindings return PyErr instead of panicking on empty segments or missing boundary-gate fields --- crates/pecos-decoder-core/src/dem.rs | 58 ++++++++++++--- .../src/fault_tolerance_bindings.rs | 73 ++++++++++++------- 2 files changed, 95 insertions(+), 36 deletions(-) diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index eafc3dbc2..c2812664b 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -302,15 +302,25 @@ impl SparseDem { let mut det_set = std::collections::BTreeSet::new(); let mut obs_set = std::collections::BTreeSet::new(); for token in tokens.split('^').flat_map(str::split_whitespace) { - if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) - { + // Reject a malformed `D` / `L` token rather than + // silently dropping it -- matches DemCheckMatrix / + // DemMatchingGraph so all parsers agree on what is valid. + if let Some(d_str) = token.strip_prefix('D') { + let d: u32 = d_str.parse().map_err(|_| { + DecoderError::InvalidConfiguration(format!( + "Invalid detector: {token}" + )) + })?; if !det_set.remove(&d) { det_set.insert(d); } max_detector = Some(max_detector.map_or(d, |m| m.max(d))); - } else if let Some(l) = - token.strip_prefix('L').and_then(|s| s.parse::().ok()) - { + } else if let Some(l_str) = token.strip_prefix('L') { + let l: u32 = l_str.parse().map_err(|_| { + DecoderError::InvalidConfiguration(format!( + "Invalid observable: {token}" + )) + })?; if !obs_set.remove(&l) { obs_set.insert(l); } @@ -323,13 +333,22 @@ impl SparseDem { let mut detectors = Vec::new(); let mut observables = Vec::new(); for token in tokens.split_whitespace() { - if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) - { + // Reject malformed `D` / `L` (parser-agreement + // contract, see the decomposed branch above). + if let Some(d_str) = token.strip_prefix('D') { + let d: u32 = d_str.parse().map_err(|_| { + DecoderError::InvalidConfiguration(format!( + "Invalid detector: {token}" + )) + })?; detectors.push(d); max_detector = Some(max_detector.map_or(d, |m| m.max(d))); - } else if let Some(l) = - token.strip_prefix('L').and_then(|s| s.parse::().ok()) - { + } else if let Some(l_str) = token.strip_prefix('L') { + let l: u32 = l_str.parse().map_err(|_| { + DecoderError::InvalidConfiguration(format!( + "Invalid observable: {token}" + )) + })?; observables.push(l); max_observable = Some(max_observable.map_or(l, |m| m.max(l))); } @@ -1179,6 +1198,25 @@ mod tests { assert_eq!(dets, 8, "parse_dem_metadata must count bare detector D7"); } + #[test] + fn test_parsers_reject_malformed_detector_token() { + // A `D` / `L` token in an error line is malformed. All three + // error-line parsers must reject it (not silently drop it) so they agree + // on what a valid DEM is. SparseDem previously skipped these silently. + let bad_det = "error(0.01) Dfoo L0\n"; + assert!(SparseDem::from_dem_str(bad_det).is_err()); + assert!(DemCheckMatrix::from_dem_str(bad_det).is_err()); + assert!(DemMatchingGraph::from_dem_str(bad_det).is_err()); + + let bad_obs = "error(0.01) D0 Lbar\n"; + assert!(SparseDem::from_dem_str(bad_obs).is_err()); + assert!(DemCheckMatrix::from_dem_str(bad_obs).is_err()); + assert!(DemMatchingGraph::from_dem_str(bad_obs).is_err()); + + // A well-formed line still parses. + assert!(SparseDem::from_dem_str("error(0.01) D0 D1 L0\n").is_ok()); + } + #[test] fn test_parser_observable_count_is_max_plus_one_for_noncontiguous_ids() { // Only L2 present: index-addressed buffers need 3 slots, not 1. All diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index a468c9bbd..c2fc89d2d 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4936,6 +4936,23 @@ impl PyWindowedLogicalSubgraphDecoder { // Logical Algorithm Decoder (Python class) // ============================================================================= +/// Read a required `u32` bit field off a boundary-gate descriptor dict, returning +/// a clear `PyErr` (not a panic) when a malformed descriptor omits the field. +/// Shared by the two algorithm-decoder bindings below. +fn req_bit( + dict: &pyo3::Bound<'_, pyo3::types::PyDict>, + key: &str, + gate_type: &str, +) -> PyResult { + dict.get_item(key)? + .ok_or_else(|| { + PyErr::new::(format!( + "boundary gate '{gate_type}' missing required field '{key}'" + )) + })? + .extract() +} + /// Decoder for logical quantum algorithms with per-segment logical-subgraph decoder and /// Pauli frame propagation at transversal gate boundaries. /// @@ -4986,7 +5003,11 @@ impl PyLogicalAlgorithmDecoder { .extract()?; // Parse stab_coords from the first segment (original orientation) - let first_seg = &seg_list[0]; + let first_seg = seg_list.first().ok_or_else(|| { + PyErr::new::( + "algorithm descriptor has no segments", + ) + })?; let sc_list: Vec> = first_seg .get_item("stab_coords")? .ok_or_else(|| PyErr::new::("stab_coords"))? @@ -5048,32 +5069,28 @@ impl PyLogicalAlgorithmDecoder { .extract()?; match gate_type.as_str() { "Hadamard" => { - let x: u32 = gate_dict.get_item("x_obs_bit")?.unwrap().extract()?; - let z: u32 = gate_dict.get_item("z_obs_bit")?.unwrap().extract()?; bg_vec.push(BoundaryGate::Hadamard { - x_obs_bit: x, - z_obs_bit: z, + x_obs_bit: req_bit(gate_dict, "x_obs_bit", &gate_type)?, + z_obs_bit: req_bit(gate_dict, "z_obs_bit", &gate_type)?, }); } "Cnot" => { bg_vec.push(BoundaryGate::Cnot { - ctrl_x_bit: gate_dict.get_item("ctrl_x_bit")?.unwrap().extract()?, - ctrl_z_bit: gate_dict.get_item("ctrl_z_bit")?.unwrap().extract()?, - tgt_x_bit: gate_dict.get_item("tgt_x_bit")?.unwrap().extract()?, - tgt_z_bit: gate_dict.get_item("tgt_z_bit")?.unwrap().extract()?, + ctrl_x_bit: req_bit(gate_dict, "ctrl_x_bit", &gate_type)?, + ctrl_z_bit: req_bit(gate_dict, "ctrl_z_bit", &gate_type)?, + tgt_x_bit: req_bit(gate_dict, "tgt_x_bit", &gate_type)?, + tgt_z_bit: req_bit(gate_dict, "tgt_z_bit", &gate_type)?, }); } "SGate" => { - let x: u32 = gate_dict.get_item("x_obs_bit")?.unwrap().extract()?; - let z: u32 = gate_dict.get_item("z_obs_bit")?.unwrap().extract()?; bg_vec.push(BoundaryGate::SGate { - x_obs_bit: x, - z_obs_bit: z, + x_obs_bit: req_bit(gate_dict, "x_obs_bit", &gate_type)?, + z_obs_bit: req_bit(gate_dict, "z_obs_bit", &gate_type)?, }); } "TGateInjection" => { - let z: u32 = gate_dict.get_item("z_obs_bit")?.unwrap().extract()?; - let a: u32 = gate_dict.get_item("ancilla_z_bit")?.unwrap().extract()?; + let z = req_bit(gate_dict, "z_obs_bit", &gate_type)?; + let a = req_bit(gate_dict, "ancilla_z_bit", &gate_type)?; bg_vec.push(BoundaryGate::TGateInjection { z_obs_bit: z, ancilla_z_bit: a, @@ -5241,7 +5258,11 @@ impl PyLogicalCircuitDecoder { .extract()?; // Parse stab_coords from first segment - let first_seg = &seg_list[0]; + let first_seg = seg_list.first().ok_or_else(|| { + PyErr::new::( + "algorithm descriptor has no segments", + ) + })?; let sc_list: Vec> = first_seg .get_item("stab_coords")? .ok_or_else(|| PyErr::new::("stab_coords"))? @@ -5303,27 +5324,27 @@ impl PyLogicalCircuitDecoder { match gate_type.as_str() { "Hadamard" => { bg_vec.push(BoundaryGate::Hadamard { - x_obs_bit: gate_dict.get_item("x_obs_bit")?.unwrap().extract()?, - z_obs_bit: gate_dict.get_item("z_obs_bit")?.unwrap().extract()?, + x_obs_bit: req_bit(gate_dict, "x_obs_bit", &gate_type)?, + z_obs_bit: req_bit(gate_dict, "z_obs_bit", &gate_type)?, }); } "Cnot" => { bg_vec.push(BoundaryGate::Cnot { - ctrl_x_bit: gate_dict.get_item("ctrl_x_bit")?.unwrap().extract()?, - ctrl_z_bit: gate_dict.get_item("ctrl_z_bit")?.unwrap().extract()?, - tgt_x_bit: gate_dict.get_item("tgt_x_bit")?.unwrap().extract()?, - tgt_z_bit: gate_dict.get_item("tgt_z_bit")?.unwrap().extract()?, + ctrl_x_bit: req_bit(gate_dict, "ctrl_x_bit", &gate_type)?, + ctrl_z_bit: req_bit(gate_dict, "ctrl_z_bit", &gate_type)?, + tgt_x_bit: req_bit(gate_dict, "tgt_x_bit", &gate_type)?, + tgt_z_bit: req_bit(gate_dict, "tgt_z_bit", &gate_type)?, }); } "SGate" => { bg_vec.push(BoundaryGate::SGate { - x_obs_bit: gate_dict.get_item("x_obs_bit")?.unwrap().extract()?, - z_obs_bit: gate_dict.get_item("z_obs_bit")?.unwrap().extract()?, + x_obs_bit: req_bit(gate_dict, "x_obs_bit", &gate_type)?, + z_obs_bit: req_bit(gate_dict, "z_obs_bit", &gate_type)?, }); } "TGateInjection" => { - let z: u32 = gate_dict.get_item("z_obs_bit")?.unwrap().extract()?; - let a: u32 = gate_dict.get_item("ancilla_z_bit")?.unwrap().extract()?; + let z = req_bit(gate_dict, "z_obs_bit", &gate_type)?; + let a = req_bit(gate_dict, "ancilla_z_bit", &gate_type)?; bg_vec.push(BoundaryGate::TGateInjection { z_obs_bit: z, ancilla_z_bit: a, From a0af8f894852f2931d452bc7d565712593f2fd47 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 14 Jun 2026 21:07:43 -0600 Subject: [PATCH 106/150] Address residual review nits: reject boundary-gate observable bits >=64 in the descriptor bindings (+ debug_assert and obs_bits helper at the apply site), and correct the predecoder zero-alloc docstring --- .../src/logical_algorithm.rs | 74 +++++++++++++++++++ crates/pecos-uf-decoder/src/decoder.rs | 7 +- .../src/fault_tolerance_bindings.rs | 13 +++- 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/crates/pecos-decoder-core/src/logical_algorithm.rs b/crates/pecos-decoder-core/src/logical_algorithm.rs index dc5c283a5..12220e5a0 100644 --- a/crates/pecos-decoder-core/src/logical_algorithm.rs +++ b/crates/pecos-decoder-core/src/logical_algorithm.rs @@ -77,6 +77,32 @@ impl BoundaryGate { pub fn is_decision_point(&self) -> bool { matches!(self, Self::TGateInjection { .. }) } + + /// All observable-frame bit indices this gate references. Each must be < 64 + /// (they index a `u64` frame). Used to assert that invariant at apply time. + #[must_use] + pub fn obs_bits(&self) -> Vec { + match self { + Self::Hadamard { + x_obs_bit, + z_obs_bit, + } + | Self::SGate { + x_obs_bit, + z_obs_bit, + } => vec![*x_obs_bit, *z_obs_bit], + Self::Cnot { + ctrl_x_bit, + ctrl_z_bit, + tgt_x_bit, + tgt_z_bit, + } => vec![*ctrl_x_bit, *ctrl_z_bit, *tgt_x_bit, *tgt_z_bit], + Self::TGateInjection { + z_obs_bit, + ancilla_z_bit, + } => vec![*z_obs_bit, *ancilla_z_bit], + } + } } /// Full description of a logical algorithm for decoding. @@ -149,6 +175,14 @@ impl LogicalAlgorithmDecoder { /// Apply boundary gate to a Pauli frame. /// Used when consuming the frame at logical operations. pub fn apply_boundary_gate(frame: &mut u64, gate: &BoundaryGate) { + // All bits index the u64 observable frame, so each must be < 64. The + // Python descriptor binding enforces this fail-loud at construction; this + // documents and guards the invariant for direct Rust callers (a shift by + // >= 64 is otherwise an overflow panic in debug / unspecified in release). + debug_assert!( + gate.obs_bits().iter().all(|&b| b < 64), + "boundary gate observable bit >= 64" + ); match gate { BoundaryGate::Hadamard { x_obs_bit, @@ -812,6 +846,46 @@ mod tests { assert_eq!(frame, 0b10); // X became Z } + #[test] + fn test_boundary_gate_obs_bits_cover_all_fields() { + // The apply-time `< 64` assert relies on obs_bits() listing EVERY bit a + // gate references -- a missed field would let an out-of-range shift slip. + assert_eq!( + BoundaryGate::Hadamard { + x_obs_bit: 2, + z_obs_bit: 5 + } + .obs_bits(), + vec![2, 5] + ); + assert_eq!( + BoundaryGate::Cnot { + ctrl_x_bit: 1, + ctrl_z_bit: 2, + tgt_x_bit: 3, + tgt_z_bit: 4 + } + .obs_bits(), + vec![1, 2, 3, 4] + ); + assert_eq!( + BoundaryGate::SGate { + x_obs_bit: 7, + z_obs_bit: 9 + } + .obs_bits(), + vec![7, 9] + ); + assert_eq!( + BoundaryGate::TGateInjection { + z_obs_bit: 6, + ancilla_z_bit: 8 + } + .obs_bits(), + vec![6, 8] + ); + } + #[test] fn test_cnot_frame() { let mut frame = 0b0001u64; // X on control (bit 0) diff --git a/crates/pecos-uf-decoder/src/decoder.rs b/crates/pecos-uf-decoder/src/decoder.rs index 974691b8a..85944b203 100644 --- a/crates/pecos-uf-decoder/src/decoder.rs +++ b/crates/pecos-uf-decoder/src/decoder.rs @@ -23,7 +23,12 @@ //! 5. Peel a spanning forest (BFS from boundary) to extract the correction: //! an edge is in the correction iff its subtree has odd parity. //! -//! All data structures are flat arrays. Zero per-shot allocation after init. +//! All data structures are flat arrays; the full grow+peel decode path does zero +//! per-shot allocation after init. The optional cluster predecoder +//! ([`UfDecoder::predecode_clusters`]) is the exception: it runs on `&self` +//! (before the per-shot state is reset) and allocates two small scratch buffers +//! per call. It is only entered for trivial syndromes (0-2 isolated defects), +//! falling through to the zero-alloc path otherwise. use pecos_decoder_core::correlated_decoder::MatchingDecoder; use pecos_decoder_core::dem::DemMatchingGraph; diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index c2fc89d2d..2da8cc3d7 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4944,13 +4944,22 @@ fn req_bit( key: &str, gate_type: &str, ) -> PyResult { - dict.get_item(key)? + let bit: u32 = dict + .get_item(key)? .ok_or_else(|| { PyErr::new::(format!( "boundary gate '{gate_type}' missing required field '{key}'" )) })? - .extract() + .extract()?; + // Every boundary-gate bit indexes a u64 observable frame (`1u64 << bit`), so + // it must be < 64 -- reject out-of-range here rather than shift-overflow later. + if bit >= 64 { + return Err(PyErr::new::(format!( + "boundary gate '{gate_type}' field '{key}' = {bit} exceeds the 64-observable frame limit" + ))); + } + Ok(bit) } /// Decoder for logical quantum algorithms with per-segment logical-subgraph decoder and From 7e63b2f82719481baf5b18d89180ee305960c927 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 15 Jun 2026 09:46:19 -0600 Subject: [PATCH 107/150] Handle Selene runtime capacity after merged runtime changes. --- crates/pecos-qis/src/selene_runtime.rs | 307 +++++++++++++++++---- docs/development/from-guppy-dem-handoff.md | 6 +- 2 files changed, 249 insertions(+), 64 deletions(-) diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index a8643f0a7..385c3e659 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -202,6 +202,9 @@ pub struct SeleneRuntime { #[allow(dead_code)] instance: Option<*mut c_void>, + /// Number of qubits the current runtime instance was initialized with. + initialized_num_qubits: Option, + /// Current classical state state: ClassicalState, @@ -241,6 +244,9 @@ pub struct SeleneRuntime { /// End timestamp of the last scheduled physical operation per runtime qubit. last_gate_time_end_nanos: Vec, + + /// Shot metadata waiting for a lazily loaded runtime plugin. + pending_shot_start: Option<(u64, Option)>, } // SAFETY: SeleneRuntime owns its instance pointer exclusively. @@ -259,6 +265,7 @@ impl SeleneRuntime { library_search_dirs: Vec::new(), library: None, instance: None, + initialized_num_qubits: None, state: ClassicalState::default(), operations_buffer: Vec::new(), batch_size: 100, @@ -272,6 +279,7 @@ impl SeleneRuntime { program_to_runtime_results: BTreeMap::new(), runtime_to_program_results: BTreeMap::new(), last_gate_time_end_nanos: Vec::new(), + pending_shot_start: None, } } @@ -313,17 +321,11 @@ impl SeleneRuntime { self.interface.as_ref().map_or(0, |i| i.operations.len()) ); - // Update qubit and result counts from new execution - self.num_qubits = operations - .allocated_qubits - .iter() - .max() - .map_or(0, |&q| q + 1); - self.num_results = operations - .allocated_results - .iter() - .max() - .map_or(0, |&r| r + 1); + // Update capacities from both explicit allocation records and direct + // program handles used by older QIR/LLVM examples. + let (num_qubits, num_results) = collector_capacity(&operations); + self.num_qubits = num_qubits; + self.num_results = num_results; self.interface = Some(operations); self.current_op_index = 0; @@ -395,8 +397,85 @@ impl SeleneRuntime { self.library = Some(ManuallyDrop::new(lib)); self.instance = Some(instance); + self.initialized_num_qubits = Some(self.num_qubits); + } + + self.apply_pending_shot_start()?; + Ok(()) + } + + fn ensure_plugin_capacity(&mut self) -> Result<()> { + let Some(initialized_num_qubits) = self.initialized_num_qubits else { + return Ok(()); + }; + + if self.num_qubits <= initialized_num_qubits { + return Ok(()); + } + + debug!( + "Reinitializing Selene plugin capacity from {} to {} qubits", + initialized_num_qubits, self.num_qubits + ); + self.reset_plugin_instance()?; + self.load_plugin() + } + + fn reset_plugin_instance(&mut self) -> Result<()> { + if let Some(lib) = &self.library + && let Some(instance) = self.instance + { + unsafe { + if let Ok(exit_fn) = + lib.get:: i32>(b"selene_runtime_exit") + { + let errno = exit_fn(instance); + if errno != 0 { + return Err(RuntimeError::ExecutionError(format!( + "Selene runtime exit failed with errno {errno}" + ))); + } + } + } + } + + self.instance = None; + self.library = None; + self.initialized_num_qubits = None; + self.program_to_runtime_qubits.clear(); + self.program_to_runtime_results.clear(); + self.runtime_to_program_results.clear(); + self.last_gate_time_end_nanos.clear(); + Ok(()) + } + + fn apply_pending_shot_start(&mut self) -> Result<()> { + let Some((shot_id, seed)) = self.pending_shot_start else { + return Ok(()); + }; + let Some(lib) = &self.library else { + return Ok(()); + }; + let Some(instance) = self.instance else { + return Ok(()); + }; + + unsafe { + if let Ok(shot_start_fn) = lib + .get:: i32>( + b"selene_runtime_shot_start", + ) + { + let errno = shot_start_fn(instance, shot_id, seed.unwrap_or(0)); + if errno != 0 { + return Err(RuntimeError::ExecutionError(format!( + "Shot start failed with errno {errno}" + ))); + } + } } + self.pending_shot_start = None; Ok(()) } @@ -1035,6 +1114,7 @@ impl Clone for SeleneRuntime { library_search_dirs: self.library_search_dirs.clone(), library: None, // Will be reloaded on demand instance: None, // Will be recreated on demand + initialized_num_qubits: None, state: self.state.clone(), operations_buffer: self.operations_buffer.clone(), batch_size: self.batch_size, @@ -1048,8 +1128,91 @@ impl Clone for SeleneRuntime { program_to_runtime_results: self.program_to_runtime_results.clone(), runtime_to_program_results: self.runtime_to_program_results.clone(), last_gate_time_end_nanos: self.last_gate_time_end_nanos.clone(), + pending_shot_start: self.pending_shot_start, + } + } +} + +fn collector_capacity(interface: &OperationCollector) -> (usize, usize) { + let (mut num_qubits, mut num_results) = operation_capacity(&interface.operations); + + for &qubit in &interface.allocated_qubits { + include_qubit(&mut num_qubits, qubit); + } + for &result in &interface.allocated_results { + include_result(&mut num_results, result); + } + + (num_qubits, num_results) +} + +fn operation_capacity(operations: &[Operation]) -> (usize, usize) { + let mut num_qubits = 0; + let mut num_results = 0; + + for op in operations { + match op { + Operation::Quantum(qop) => { + include_quantum_op_capacity(qop, &mut num_qubits, &mut num_results) + } + Operation::AllocateQubit { id } | Operation::ReleaseQubit { id } => { + include_qubit(&mut num_qubits, *id); + } + Operation::AllocateResult { id } => include_result(&mut num_results, *id), + Operation::RecordOutput { result_id, .. } => { + include_result(&mut num_results, *result_id); + } + Operation::Barrier => {} } } + + (num_qubits, num_results) +} + +fn include_quantum_op_capacity(qop: &QuantumOp, num_qubits: &mut usize, num_results: &mut usize) { + match qop { + QuantumOp::H(qubit) + | QuantumOp::X(qubit) + | QuantumOp::Y(qubit) + | QuantumOp::Z(qubit) + | QuantumOp::S(qubit) + | QuantumOp::Sdg(qubit) + | QuantumOp::T(qubit) + | QuantumOp::Tdg(qubit) + | QuantumOp::RX(_, qubit) + | QuantumOp::RY(_, qubit) + | QuantumOp::RZ(_, qubit) + | QuantumOp::RXY(_, _, qubit) + | QuantumOp::Idle(_, qubit) + | QuantumOp::Reset(qubit) => include_qubit(num_qubits, *qubit), + QuantumOp::CX(qubit_1, qubit_2) + | QuantumOp::CY(qubit_1, qubit_2) + | QuantumOp::CZ(qubit_1, qubit_2) + | QuantumOp::CH(qubit_1, qubit_2) + | QuantumOp::CRZ(_, qubit_1, qubit_2) + | QuantumOp::ZZ(qubit_1, qubit_2) + | QuantumOp::RZZ(_, qubit_1, qubit_2) => { + include_qubit(num_qubits, *qubit_1); + include_qubit(num_qubits, *qubit_2); + } + QuantumOp::CCX(qubit_1, qubit_2, qubit_3) => { + include_qubit(num_qubits, *qubit_1); + include_qubit(num_qubits, *qubit_2); + include_qubit(num_qubits, *qubit_3); + } + QuantumOp::Measure(qubit, result) => { + include_qubit(num_qubits, *qubit); + include_result(num_results, *result); + } + } +} + +fn include_qubit(num_qubits: &mut usize, qubit: usize) { + *num_qubits = (*num_qubits).max(qubit + 1); +} + +fn include_result(num_results: &mut usize, result: usize) { + *num_results = (*num_results).max(result + 1); } impl QisRuntime for SeleneRuntime { @@ -1059,17 +1222,12 @@ impl QisRuntime for SeleneRuntime { interface.operations.len() ); - // Count qubits and results - self.num_qubits = interface - .allocated_qubits - .iter() - .max() - .map_or(0, |&q| q + 1); - self.num_results = interface - .allocated_results - .iter() - .max() - .map_or(0, |&r| r + 1); + // Count qubits and results from both explicit allocation records and + // direct program handles. Some legacy LLVM/QIR inputs use qubit handles + // like 0 and 1 without emitting __quantum__rt__qubit_allocate calls. + let (num_qubits, num_results) = collector_capacity(&interface); + self.num_qubits = num_qubits; + self.num_results = num_results; debug!( "Interface has {} qubits and {} result slots", @@ -1099,6 +1257,10 @@ impl QisRuntime for SeleneRuntime { } fn lower_operations(&mut self, operations: &[Operation]) -> Result> { + let (num_qubits, num_results) = operation_capacity(operations); + self.num_qubits = self.num_qubits.max(num_qubits); + self.num_results = self.num_results.max(num_results); + self.ensure_plugin_capacity()?; self.load_plugin()?; let mut lowered_ops = Vec::new(); @@ -1216,30 +1378,6 @@ impl QisRuntime for SeleneRuntime { } fn shot_start(&mut self, shot_id: u64, seed: Option) -> Result<()> { - // Try to load the plugin if not already loaded - if self.library.is_none() && std::path::Path::new(&self.plugin_path).exists() { - self.load_plugin()?; - } - - if let Some(lib) = &self.library - && let Some(instance) = self.instance - { - unsafe { - if let Ok(shot_start_fn) = lib - .get:: i32>( - b"selene_runtime_shot_start", - ) - { - let errno = shot_start_fn(instance, shot_id, seed.unwrap_or(0)); - if errno != 0 { - return Err(RuntimeError::ExecutionError(format!( - "Shot start failed with errno {errno}" - ))); - } - } - } - } - // Reset state for new shot self.state = ClassicalState::default(); self.current_op_index = 0; @@ -1249,6 +1387,8 @@ impl QisRuntime for SeleneRuntime { self.program_to_runtime_results.clear(); self.runtime_to_program_results.clear(); self.last_gate_time_end_nanos.clear(); + self.pending_shot_start = Some((shot_id, seed)); + self.apply_pending_shot_start()?; Ok(()) } @@ -1265,6 +1405,7 @@ impl QisRuntime for SeleneRuntime { } } } + self.pending_shot_start = None; // Return the shot with measurements and registers let shot = Shot { @@ -1277,27 +1418,14 @@ impl QisRuntime for SeleneRuntime { } fn reset(&mut self) -> Result<()> { - // Clean up the runtime instance - if let Some(lib) = &self.library - && let Some(instance) = self.instance - { - unsafe { - if let Ok(exit_fn) = - lib.get:: i32>(b"selene_runtime_exit") - { - let _ = exit_fn(instance); - } - } - } - - self.instance = None; - self.library = None; + self.reset_plugin_instance()?; self.state = ClassicalState::default(); self.current_op_index = 0; self.program_to_runtime_qubits.clear(); self.program_to_runtime_results.clear(); self.runtime_to_program_results.clear(); self.last_gate_time_end_nanos.clear(); + self.pending_shot_start = None; Ok(()) } @@ -1372,4 +1500,61 @@ mod tests { vec![QuantumOp::Idle(20e-9, 0), QuantumOp::RXY(1.0, 0.5, 0)] ); } + + #[test] + fn test_collector_capacity_includes_direct_program_handles() { + let mut collector = OperationCollector::new(); + collector.queue_operation(QuantumOp::H(0).into()); + collector.queue_operation(QuantumOp::CX(0, 3).into()); + collector.queue_operation(QuantumOp::Measure(3, 7).into()); + collector.queue_operation(Operation::RecordOutput { + result_id: 7, + register_name: "c".to_string(), + }); + + assert_eq!(collector_capacity(&collector), (4, 8)); + } + + #[test] + fn test_collector_capacity_includes_explicit_allocations() { + let mut collector = OperationCollector::new(); + collector.queue_operation(Operation::AllocateQubit { id: 5 }); + collector.queue_operation(Operation::AllocateResult { id: 2 }); + collector.queue_operation(QuantumOp::H(1).into()); + + assert_eq!(collector_capacity(&collector), (6, 3)); + } + + #[test] + fn test_shot_start_defers_until_plugin_load() { + let mut runtime = SeleneRuntime::new("/path/to/selene.so"); + runtime.shot_start(42, Some(1234)).unwrap(); + + assert_eq!(runtime.pending_shot_start, Some((42, Some(1234)))); + + runtime.shot_end().unwrap(); + assert_eq!(runtime.pending_shot_start, None); + } + + #[test] + fn test_clone_does_not_reuse_initialized_plugin_capacity() { + let mut runtime = SeleneRuntime::new("/path/to/selene.so"); + runtime.num_qubits = 3; + runtime.initialized_num_qubits = Some(3); + + let cloned = runtime.clone(); + + assert_eq!(cloned.num_qubits, 3); + assert_eq!(cloned.initialized_num_qubits, None); + } + + #[test] + fn test_reset_clears_initialized_plugin_capacity() { + let mut runtime = SeleneRuntime::new("/path/to/selene.so"); + runtime.initialized_num_qubits = Some(3); + + runtime.reset().unwrap(); + + assert_eq!(runtime.initialized_num_qubits, None); + } } diff --git a/docs/development/from-guppy-dem-handoff.md b/docs/development/from-guppy-dem-handoff.md index 54766dbff..16c6d8c05 100644 --- a/docs/development/from-guppy-dem-handoff.md +++ b/docs/development/from-guppy-dem-handoff.md @@ -15,7 +15,7 @@ entry point. The intended shape is: This should support calls like: -```python +```python,notest from pecos.guppy import get_num_qubits, make_surface_code from pecos.qec import DetectorErrorModel @@ -44,7 +44,7 @@ dem = DetectorErrorModel.from_guppy( `pecos_rslib.qec.DetectorErrorModel` is not currently subclassable from Python. Defining: -```python +```python,notest class DetectorErrorModel(_RustDetectorErrorModel): ... ``` @@ -58,7 +58,7 @@ TypeError: type 'pecos_rslib.qec.DetectorErrorModel' is not an acceptable base t The current local fix is to re-export the Rust class directly and attach the Python convenience constructor: -```python +```python,notest DetectorErrorModel = _RustDetectorErrorModel DetectorErrorModel.from_guppy = classmethod(...) ``` From 088671d17f7dab454cf6d659ca05dd69f0bd4e4c Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 15 Jun 2026 10:24:42 -0600 Subject: [PATCH 108/150] Add logical decoder descriptor validation regressions. --- .../test_logical_circuit_decoder_windowing.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py b/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py index 5f1dd9a79..2e506748a 100644 --- a/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py +++ b/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py @@ -38,6 +38,18 @@ def _memory_descriptor(d: int, rounds: int) -> dict: return b.build_algorithm_descriptor(p1=0.001, p2=0.001, p_meas=0.001) +def _h_boundary_descriptor() -> dict: + patch = SurfacePatch.create(3) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", 3, "Z") + b.add_transversal_h("A") + b.add_memory("A", 3, "X") + desc = b.build_algorithm_descriptor(p1=0.001, p2=0.001, p_meas=0.001) + assert desc["boundary_gates"][0][0]["type"] == "Hadamard" + return desc + + def test_unlimited_budget_reports_unlimited(): dec = LogicalCircuitDecoder(_memory_descriptor(3, 9), budget="unlimited") assert dec.effective_windowing == "unlimited" @@ -89,5 +101,32 @@ def test_windowed_full_fallback_still_decodes(): assert dec.decode([0] * ndet) == 0 +def test_logical_circuit_decoder_rejects_empty_segments(): + """Malformed algorithm descriptors should raise a Python error, not panic.""" + desc = _memory_descriptor(3, 3) + desc["segments"] = [] + + with pytest.raises(ValueError, match="no segments"): + LogicalCircuitDecoder(desc, budget="unlimited") + + +def test_logical_circuit_decoder_rejects_missing_boundary_gate_bit(): + """Boundary gate descriptors must fail loudly when required bit fields are absent.""" + desc = _h_boundary_descriptor() + del desc["boundary_gates"][0][0]["x_obs_bit"] + + with pytest.raises(ValueError, match="missing required field 'x_obs_bit'"): + LogicalCircuitDecoder(desc, budget="unlimited") + + +def test_logical_circuit_decoder_rejects_out_of_range_boundary_gate_bit(): + """Boundary gate bits index a u64 observable frame and must be below 64.""" + desc = _h_boundary_descriptor() + desc["boundary_gates"][0][0]["x_obs_bit"] = 64 + + with pytest.raises(ValueError, match="exceeds the 64-observable frame limit"): + LogicalCircuitDecoder(desc, budget="unlimited") + + if __name__ == "__main__": pytest.main([__file__, "-v"]) From ceaccc9ae60b6d2d8148c2ff2bd4cb130c5f742f Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 15 Jun 2026 11:20:12 -0600 Subject: [PATCH 109/150] Respect explicit QIS physical qubit capacity. --- crates/pecos-qis/src/ccengine.rs | 71 ++++++++++-- crates/pecos-qis/src/selene_runtime.rs | 148 +++++++++++++++++++++---- 2 files changed, 185 insertions(+), 34 deletions(-) diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index 377ea7a64..af3978717 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -496,20 +496,27 @@ impl QisEngine { self.num_physical_slots = 0; } - fn allocate_qubit_slot(&mut self, program_id: usize) -> usize { + fn allocate_qubit_slot(&mut self, program_id: usize) -> Result { if let Some(&slot) = self.active_qubit_slots.get(&program_id) { - return slot; + return Ok(slot); } let slot = if let Some(slot) = self.free_qubit_slots.pop_first() { slot } else { + if let Some(limit) = self.num_qubits_hint + && self.num_physical_slots >= limit + { + return Err(PecosError::Generic(format!( + "QIS program requires more than the configured {limit} physical qubit slots while allocating program qubit {program_id}" + ))); + } self.num_physical_slots }; self.num_physical_slots = self.num_physical_slots.max(slot + 1); self.active_qubit_slots.insert(program_id, slot); self.seen_program_qubits.insert(program_id); - slot + Ok(slot) } fn release_qubit_slot(&mut self, program_id: usize) { @@ -529,7 +536,7 @@ impl QisEngine { ))); } - Ok(self.allocate_qubit_slot(program_id)) + self.allocate_qubit_slot(program_id) } /// Convert dynamic QIS operations into a `ByteMessage` for the quantum engine. @@ -548,7 +555,7 @@ impl QisEngine { for op in ops { match op { Operation::AllocateQubit { id } => { - let slot = self.allocate_qubit_slot(*id); + let slot = self.allocate_qubit_slot(*id)?; builder.pz(&[slot]); } Operation::ReleaseQubit { id } => { @@ -1275,11 +1282,11 @@ impl ClassicalEngine for QisEngine { // return the physical-slot high-water mark instead. The runtime can // report its own baseline (e.g. from `allocated_qubits` metadata) and // we take the larger of the two. - let num_qubits = self - .runtime - .num_qubits() - .max(self.num_physical_slots) - .max(self.num_qubits_hint.unwrap_or(0)); + let num_qubits = if let Some(hint) = self.num_qubits_hint { + hint + } else { + self.runtime.num_qubits().max(self.num_physical_slots) + }; debug!("QisEngine: num_qubits() returning {num_qubits}"); num_qubits } @@ -1884,4 +1891,48 @@ mod tests { "unexpected error: {err}" ); } + + #[test] + fn test_num_qubits_hint_is_physical_capacity_for_sparse_handles() { + let mut engine = QisEngine::with_runtime(Box::new(DummyRuntime::default())); + engine.set_num_qubits_hint(98); + let ops = vec![ + Operation::AllocateQubit { id: 81 }, + Operation::AllocateQubit { id: 105 }, + QuantumOp::CX(81, 105).into(), + ]; + + let commands = engine + .operations_to_bytemessage(&ops) + .expect("sparse handles should map onto live physical slots"); + + let lowered = commands.quantum_ops().expect("parse lowered commands"); + assert_eq!(lowered[0].qubits.as_slice(), &[pecos_core::QubitId(0)]); + assert_eq!(lowered[1].qubits.as_slice(), &[pecos_core::QubitId(1)]); + assert_eq!( + lowered[2].qubits.as_slice(), + &[pecos_core::QubitId(0), pecos_core::QubitId(1)] + ); + assert_eq!(engine.num_physical_slots, 2); + assert_eq!(engine.num_qubits(), 98); + } + + #[test] + fn test_qubit_hint_rejects_too_many_live_physical_slots() { + let mut engine = QisEngine::with_runtime(Box::new(DummyRuntime::default())); + engine.set_num_qubits_hint(1); + let ops = vec![ + Operation::AllocateQubit { id: 81 }, + Operation::AllocateQubit { id: 105 }, + ]; + + let Err(err) = engine.operations_to_bytemessage(&ops) else { + panic!("allocating beyond the physical qubit hint should error"); + }; + + assert!( + err.to_string().contains("more than the configured 1"), + "unexpected error: {err}" + ); + } } diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index 385c3e659..f88ed9a1e 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -6,7 +6,7 @@ use crate::runtime::{ClassicalState, QisRuntime, Result, RuntimeError, Shot}; use log::{debug, trace}; use pecos_qis_ffi_types::{Operation, OperationCollector, QuantumOp}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::ffi::{CString, c_void}; use std::mem::ManuallyDrop; use std::path::{Path, PathBuf}; @@ -217,6 +217,21 @@ pub struct SeleneRuntime { /// Number of qubits num_qubits: usize, + /// Explicit physical runtime capacity requested by the caller. + /// + /// Some generated programs use sparse, monotonically increasing logical + /// handles while guaranteeing a smaller maximum number of live physical + /// slots. In those cases the `.qubits(...)` hint is the runtime capacity; + /// if the program actually exceeds it, plugin qalloc fails loudly. + num_qubits_hint: Option, + + /// Whether the loaded operation stream uses explicit qalloc/qfree records. + /// + /// In this mode program qubit IDs are logical handles, not dense physical + /// runtime slots. Runtime plugin capacity must therefore follow the maximum + /// simultaneously-live allocation count instead of `max(program_id) + 1`. + uses_explicit_qubit_allocation: bool, + /// Number of allocated result slots num_results: usize, @@ -270,6 +285,8 @@ impl SeleneRuntime { operations_buffer: Vec::new(), batch_size: 100, num_qubits: 0, + num_qubits_hint: None, + uses_explicit_qubit_allocation: false, num_results: 0, interface: None, current_op_index: 0, @@ -321,11 +338,14 @@ impl SeleneRuntime { self.interface.as_ref().map_or(0, |i| i.operations.len()) ); - // Update capacities from both explicit allocation records and direct - // program handles used by older QIR/LLVM examples. + // Update capacities from explicit allocation records when available, + // otherwise fall back to direct program handles used by older QIR/LLVM + // examples. let (num_qubits, num_results) = collector_capacity(&operations); self.num_qubits = num_qubits; self.num_results = num_results; + self.uses_explicit_qubit_allocation = + has_explicit_qubit_allocations(&operations.operations); self.interface = Some(operations); self.current_op_index = 0; @@ -340,11 +360,12 @@ impl SeleneRuntime { } self.apply_library_search_dirs()?; + let plugin_num_qubits = self.plugin_num_qubits(); debug!( "Loading Selene plugin from {} with {} qubits, {} results, and {} init args", self.plugin_path, - self.num_qubits, + plugin_num_qubits, self.num_results, self.init_args.len() ); @@ -383,7 +404,7 @@ impl SeleneRuntime { let mut instance: *mut c_void = std::ptr::null_mut(); let errno = init_fn( &raw mut instance, - self.num_qubits as u64, + plugin_num_qubits as u64, 0, // start time arg_ptrs.len() as u32, argv, @@ -397,7 +418,7 @@ impl SeleneRuntime { self.library = Some(ManuallyDrop::new(lib)); self.instance = Some(instance); - self.initialized_num_qubits = Some(self.num_qubits); + self.initialized_num_qubits = Some(plugin_num_qubits); } self.apply_pending_shot_start()?; @@ -409,18 +430,23 @@ impl SeleneRuntime { return Ok(()); }; - if self.num_qubits <= initialized_num_qubits { + let plugin_num_qubits = self.plugin_num_qubits(); + if plugin_num_qubits <= initialized_num_qubits { return Ok(()); } debug!( "Reinitializing Selene plugin capacity from {} to {} qubits", - initialized_num_qubits, self.num_qubits + initialized_num_qubits, plugin_num_qubits ); self.reset_plugin_instance()?; self.load_plugin() } + fn plugin_num_qubits(&self) -> usize { + self.num_qubits_hint.unwrap_or(self.num_qubits) + } + fn reset_plugin_instance(&mut self) -> Result<()> { if let Some(lib) = &self.library && let Some(instance) = self.instance @@ -550,7 +576,6 @@ impl SeleneRuntime { } Operation::AllocateQubit { id } => { trace!("Allocating qubit {id}"); - self.num_qubits = self.num_qubits.max(id + 1); self.current_op_index += 1; } Operation::AllocateResult { id } => { @@ -606,7 +631,9 @@ impl SeleneRuntime { let runtime_qubit = self.runtime_qalloc()?; self.program_to_runtime_qubits .insert(program_qubit, runtime_qubit); - self.num_qubits = self.num_qubits.max(program_qubit + 1); + if !self.uses_explicit_qubit_allocation { + self.num_qubits = self.num_qubits.max(program_qubit + 1); + } Ok(runtime_qubit) } @@ -877,6 +904,8 @@ impl SeleneRuntime { QuantumOp::Measure(qubit, result_id) => { let runtime_qubit = self.runtime_qubit_for_program(*qubit)?; self.call_runtime_measure(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)?; @@ -1119,6 +1148,8 @@ impl Clone for SeleneRuntime { operations_buffer: self.operations_buffer.clone(), batch_size: self.batch_size, num_qubits: self.num_qubits, + num_qubits_hint: self.num_qubits_hint, + uses_explicit_qubit_allocation: self.uses_explicit_qubit_allocation, num_results: self.num_results, interface: self.interface.clone(), current_op_index: self.current_op_index, @@ -1134,10 +1165,14 @@ impl Clone for SeleneRuntime { } fn collector_capacity(interface: &OperationCollector) -> (usize, usize) { - let (mut num_qubits, mut num_results) = operation_capacity(&interface.operations); + let uses_explicit_allocations = has_explicit_qubit_allocations(&interface.operations); + let (mut num_qubits, mut num_results) = + operation_capacity_with_mode(&interface.operations, uses_explicit_allocations); - for &qubit in &interface.allocated_qubits { - include_qubit(&mut num_qubits, qubit); + if !uses_explicit_allocations { + for &qubit in &interface.allocated_qubits { + include_qubit(&mut num_qubits, qubit); + } } for &result in &interface.allocated_results { include_result(&mut num_results, result); @@ -1146,17 +1181,38 @@ fn collector_capacity(interface: &OperationCollector) -> (usize, usize) { (num_qubits, num_results) } -fn operation_capacity(operations: &[Operation]) -> (usize, usize) { +fn has_explicit_qubit_allocations(operations: &[Operation]) -> bool { + operations.iter().any(|op| { + matches!( + op, + Operation::AllocateQubit { .. } | Operation::ReleaseQubit { .. } + ) + }) +} + +fn operation_capacity_with_mode( + operations: &[Operation], + uses_explicit_allocations: bool, +) -> (usize, usize) { let mut num_qubits = 0; let mut num_results = 0; + let mut live_qubits = BTreeSet::new(); + let mut max_live_qubits = 0; for op in operations { match op { - Operation::Quantum(qop) => { + Operation::Quantum(qop) if !uses_explicit_allocations => { include_quantum_op_capacity(qop, &mut num_qubits, &mut num_results) } - Operation::AllocateQubit { id } | Operation::ReleaseQubit { id } => { - include_qubit(&mut num_qubits, *id); + Operation::Quantum(qop) => { + include_quantum_result_capacity(qop, &mut num_results); + } + Operation::AllocateQubit { id } => { + live_qubits.insert(*id); + max_live_qubits = max_live_qubits.max(live_qubits.len()); + } + Operation::ReleaseQubit { id } => { + live_qubits.remove(id); } Operation::AllocateResult { id } => include_result(&mut num_results, *id), Operation::RecordOutput { result_id, .. } => { @@ -1165,10 +1221,19 @@ fn operation_capacity(operations: &[Operation]) -> (usize, usize) { Operation::Barrier => {} } } + if uses_explicit_allocations { + num_qubits = max_live_qubits; + } (num_qubits, num_results) } +fn include_quantum_result_capacity(qop: &QuantumOp, num_results: &mut usize) { + if let QuantumOp::Measure(_, result) = qop { + include_result(num_results, *result); + } +} + fn include_quantum_op_capacity(qop: &QuantumOp, num_qubits: &mut usize, num_results: &mut usize) { match qop { QuantumOp::H(qubit) @@ -1222,12 +1287,13 @@ impl QisRuntime for SeleneRuntime { interface.operations.len() ); - // Count qubits and results from both explicit allocation records and - // direct program handles. Some legacy LLVM/QIR inputs use qubit handles - // like 0 and 1 without emitting __quantum__rt__qubit_allocate calls. + // Count qubits from explicit allocation records when present, + // otherwise from direct program handles. Some legacy LLVM/QIR inputs + // use qubit handles like 0 and 1 without emitting allocation calls. let (num_qubits, num_results) = collector_capacity(&interface); self.num_qubits = num_qubits; self.num_results = num_results; + self.uses_explicit_qubit_allocation = has_explicit_qubit_allocations(&interface.operations); debug!( "Interface has {} qubits and {} result slots", @@ -1257,7 +1323,11 @@ impl QisRuntime for SeleneRuntime { } fn lower_operations(&mut self, operations: &[Operation]) -> Result> { - let (num_qubits, num_results) = operation_capacity(operations); + if has_explicit_qubit_allocations(operations) { + self.uses_explicit_qubit_allocation = true; + } + let (num_qubits, num_results) = + operation_capacity_with_mode(operations, self.uses_explicit_qubit_allocation); self.num_qubits = self.num_qubits.max(num_qubits); self.num_results = self.num_results.max(num_results); self.ensure_plugin_capacity()?; @@ -1354,10 +1424,11 @@ impl QisRuntime for SeleneRuntime { } fn num_qubits(&self) -> usize { - self.num_qubits + self.plugin_num_qubits() } fn set_num_qubits(&mut self, num_qubits: usize) { + self.num_qubits_hint = Some(num_qubits); self.num_qubits = self.num_qubits.max(num_qubits); } @@ -1520,9 +1591,38 @@ mod tests { let mut collector = OperationCollector::new(); collector.queue_operation(Operation::AllocateQubit { id: 5 }); collector.queue_operation(Operation::AllocateResult { id: 2 }); - collector.queue_operation(QuantumOp::H(1).into()); + collector.queue_operation(QuantumOp::H(5).into()); + + assert_eq!(collector_capacity(&collector), (1, 3)); + } + + #[test] + fn test_collector_capacity_uses_max_live_explicit_allocations() { + let mut collector = OperationCollector::new(); + collector.queue_operation(Operation::AllocateQubit { id: 81 }); + collector.queue_operation(Operation::AllocateQubit { id: 97 }); + collector.queue_operation(QuantumOp::CX(81, 97).into()); + collector.queue_operation(Operation::ReleaseQubit { id: 97 }); + collector.queue_operation(Operation::AllocateQubit { id: 105 }); + collector.queue_operation(QuantumOp::Measure(105, 9).into()); + + assert_eq!(collector_capacity(&collector), (2, 10)); + } + + #[test] + fn test_explicit_qubit_hint_caps_plugin_capacity() { + let mut runtime = SeleneRuntime::new("/path/to/selene.so"); + runtime.set_num_qubits(98); + + let (num_qubits, _) = operation_capacity_with_mode( + &[QuantumOp::CX(81, 105).into()], + runtime.uses_explicit_qubit_allocation, + ); + runtime.num_qubits = runtime.num_qubits.max(num_qubits); - assert_eq!(collector_capacity(&collector), (6, 3)); + assert_eq!(runtime.num_qubits, 106); + assert_eq!(runtime.plugin_num_qubits(), 98); + assert_eq!(runtime.num_qubits(), 98); } #[test] From 0b6b9257dbabc8797017685d3929972c7e9f7da6 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 15 Jun 2026 11:42:03 -0600 Subject: [PATCH 110/150] Support constrained SZZ surface memory circuits. --- .../quantum-pecos/src/pecos/guppy/surface.py | 114 ++++++++----- .../src/pecos/qec/surface/circuit_builder.py | 156 ++++++++++-------- .../guppy/test_surface_ancilla_budget.py | 22 +++ .../tests/guppy/test_surface_twirl_render.py | 13 +- 4 files changed, 194 insertions(+), 111 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 9a33a75cf..77dbec321 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -173,8 +173,8 @@ def generate_guppy_source( num_rounds: Number of syndrome rounds to render. Required when ``twirl`` is enabled because twirled source is unrolled per round. interaction_basis: Surface-memory interaction basis: ``"cx"`` or - ``"szz"``. SZZ Guppy generation currently supports only the - unconstrained, non-twirled memory shape. + ``"szz"``. SZZ Guppy generation supports the same non-twirled + unconstrained and constrained ancilla shapes as the CX template. Returns: Python/Guppy source code as a string. @@ -206,13 +206,6 @@ def generate_guppy_source( total_ancilla = num_x_stab + num_z_stab effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) constrained = effective_budget < total_ancilla - if interaction_basis == "szz" and constrained: - msg = ( - "interaction_basis='szz' does not yet support constrained ancilla " - f"budgets on the Guppy runtime path (ancilla_budget={ancilla_budget} " - f"< total_ancilla={total_ancilla})" - ) - raise ValueError(msg) if interaction_basis == "szz" and twirl is not None: msg = "interaction_basis='szz' Guppy runtime twirl integration is staged later" raise ValueError(msg) @@ -498,6 +491,9 @@ def _append_szz_layer( lines.append( f' """Extract full syndrome in {len(batches)} ancilla-reuse batches (budget={effective_budget})."""', ) + if interaction_basis == "szz": + lines.append(" # Unpack data qubits") + _append_szz_data_unpack(lines, " ") idx = 0 for batch_idx, batch in enumerate(batches): lines.append("") @@ -514,13 +510,18 @@ def _append_szz_layer( batch_anc_var[(stab_type, stab_idx)] = var lines.append(f" {var} = qubit()") - x_in_batch = [(t, i) for (t, i) in batch if t == "X"] - if x_in_batch: - lines.append(" # Hadamard on X ancillas in this batch") - for stab_type, stab_idx in x_in_batch: + if interaction_basis == "cx": + x_in_batch = [(t, i) for (t, i) in batch if t == "X"] + if x_in_batch: + lines.append(" # Hadamard on X ancillas in this batch") + for stab_type, stab_idx in x_in_batch: + lines.append(f" h({batch_anc_var[(stab_type, stab_idx)]})") + else: + lines.append(" # Hadamard on SZZ ancillas in this batch") + for stab_type, stab_idx in batch: lines.append(f" h({batch_anc_var[(stab_type, stab_idx)]})") - # Filter the full CX schedule to just this batch's stabilizers. + # Filter the full interaction schedule to just this batch's stabilizers. batch_keys = set(batch_anc_var.keys()) for rnd_idx, rnd_gates in enumerate(rounds): rnd_in_batch = [ @@ -532,17 +533,36 @@ def _append_szz_layer( continue lines.append("") lines.append(f" # Batch {batch_idx + 1} round {rnd_idx + 1}") - for stab_type, stab_idx, data_q in rnd_in_batch: - anc = batch_anc_var[(stab_type, stab_idx)] - if stab_type == "X": - lines.append(f" cx({anc}, surf.data[{data_q}])") - else: - lines.append(f" cx(surf.data[{data_q}], {anc})") + if interaction_basis == "cx": + for stab_type, stab_idx, data_q in rnd_in_batch: + anc = batch_anc_var[(stab_type, stab_idx)] + if stab_type == "X": + lines.append(f" cx({anc}, surf.data[{data_q}])") + else: + lines.append(f" cx(surf.data[{data_q}], {anc})") + else: + _append_szz_layer( + lines, + " ", + rnd_idx, + rnd_in_batch, + lambda stab_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[ + (stab_type, stab_idx) + ], + _szz_data_expr, + ) - if x_in_batch: + if interaction_basis == "cx": + x_in_batch = [(t, i) for (t, i) in batch if t == "X"] + if x_in_batch: + lines.append("") + lines.append(" # Hadamard on X ancillas in this batch") + for stab_type, stab_idx in x_in_batch: + lines.append(f" h({batch_anc_var[(stab_type, stab_idx)]})") + else: lines.append("") - lines.append(" # Hadamard on X ancillas in this batch") - for stab_type, stab_idx in x_in_batch: + lines.append(" # Hadamard on SZZ ancillas in this batch") + for stab_type, stab_idx in batch: lines.append(f" h({batch_anc_var[(stab_type, stab_idx)]})") lines.append("") @@ -670,8 +690,13 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: batch_anc_var[(selected_type, stab_idx)] = var lines.append(f" {var} = qubit()") - if stab_type == "X": - lines.append(" # Hadamard on X ancillas in this batch") + if interaction_basis == "cx": + if stab_type == "X": + lines.append(" # Hadamard on X ancillas in this batch") + for selected_type, stab_idx in init_batch: + lines.append(f" h({batch_anc_var[(selected_type, stab_idx)]})") + else: + lines.append(" # Hadamard on SZZ ancillas in this batch") for selected_type, stab_idx in init_batch: lines.append(f" h({batch_anc_var[(selected_type, stab_idx)]})") @@ -686,16 +711,34 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: continue lines.append("") lines.append(f" # Batch {batch_idx + 1} round {rnd_idx + 1}") - for selected_type, stab_idx, data_q in rnd_in_batch: - anc = batch_anc_var[(selected_type, stab_idx)] - if selected_type == "X": - lines.append(f" cx({anc}, surf.data[{data_q}])") - else: - lines.append(f" cx(surf.data[{data_q}], {anc})") - - if stab_type == "X": + if interaction_basis == "cx": + for selected_type, stab_idx, data_q in rnd_in_batch: + anc = batch_anc_var[(selected_type, stab_idx)] + if selected_type == "X": + lines.append(f" cx({anc}, surf.data[{data_q}])") + else: + lines.append(f" cx(surf.data[{data_q}], {anc})") + else: + _append_szz_layer( + lines, + " ", + rnd_idx, + rnd_in_batch, + lambda selected_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[ + (selected_type, stab_idx) + ], + _szz_data_expr, + ) + + if interaction_basis == "cx": + if stab_type == "X": + lines.append("") + lines.append(" # Hadamard on X ancillas in this batch") + for selected_type, stab_idx in init_batch: + lines.append(f" h({batch_anc_var[(selected_type, stab_idx)]})") + else: lines.append("") - lines.append(" # Hadamard on X ancillas in this batch") + lines.append(" # Hadamard on SZZ ancillas in this batch") for selected_type, stab_idx in init_batch: lines.append(f" h({batch_anc_var[(selected_type, stab_idx)]})") @@ -1649,9 +1692,6 @@ def get_num_qubits( num_data = d * d total_ancilla = d * d - 1 - if interaction_basis == "szz" and normalize_ancilla_budget(total_ancilla, ancilla_budget) < total_ancilla: - msg = "interaction_basis='szz' does not yet support constrained ancilla budgets on the Guppy runtime path" - raise ValueError(msg) if interaction_basis == "szz" and twirl is not None: msg = "interaction_basis='szz' Guppy runtime twirl integration is staged later" raise ValueError(msg) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index d81ec155e..5e614c979 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -882,12 +882,6 @@ def emit_gate_local_twirl_layer( cnot_rounds = compute_cnot_schedule(patch) if interaction_basis == "szz": - if effective_ancilla_budget != total_ancilla: - msg = ( - "interaction_basis='szz' does not yet support constrained " - "ancilla budgets; pass ancilla_budget=None or a budget >= total_ancilla" - ) - raise ValueError(msg) if twirl is not None: msg = "interaction_basis='szz' twirl integration is staged later; omit twirl for Stage 1" raise ValueError(msg) @@ -1213,8 +1207,64 @@ def z_anc_q(stab_idx: int) -> int: def anc_q(stabilizer_type: str, stab_idx: int) -> int: return x_anc_q(stab_idx) if stabilizer_type == "X" else z_anc_q(stab_idx) - def stabilizers_for(stabilizer_type: str) -> list: - return geom.x_stabilizers if stabilizer_type == "X" else geom.z_stabilizers + def stabilizer_batches_for(selected_type: str | None = None) -> list[list[tuple[str, int]]]: + """Return the same ancilla-reuse batches used by the CX template.""" + total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) + allocation_ancilla_count = len(set(allocation.x_ancilla_qubits + allocation.z_ancilla_qubits)) + if allocation_ancilla_count >= total_ancilla: + batch = [("X", s.index) for s in geom.x_stabilizers] + batch.extend(("Z", s.index) for s in geom.z_stabilizers) + batches = [batch] + else: + batches = _batched_stabilizers(patch, allocation_ancilla_count) + + if selected_type is None: + return batches + return [ + [(stab_type, stab_idx) for stab_type, stab_idx in batch if stab_type == selected_type] + for batch in batches + if any(stab_type == selected_type for stab_type, _stab_idx in batch) + ] + + def append_prepare_szz_ancillas(target_ops: list[SurfaceCircuitStep], batch: list[tuple[str, int]]) -> None: + target_ops.extend( + SurfaceCircuitStep( + OpType.ALLOC, + [anc_q(stab_type, stab_idx)], + f"a{stab_type.lower()}{stab_idx}", + ) + for stab_type, stab_idx in batch + ) + target_ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) + target_ops.extend( + SurfaceCircuitStep( + OpType.H, + [anc_q(stab_type, stab_idx)], + f"a{stab_type.lower()}{stab_idx}", + ) + for stab_type, stab_idx in batch + ) + + def append_measure_szz_ancillas(target_ops: list[SurfaceCircuitStep], batch: list[tuple[str, int]]) -> None: + target_ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) + target_ops.extend( + SurfaceCircuitStep( + OpType.H, + [anc_q(stab_type, stab_idx)], + f"a{stab_type.lower()}{stab_idx}", + ) + for stab_type, stab_idx in batch + ) + + target_ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Measure ancillas")) + target_ops.extend( + SurfaceCircuitStep( + OpType.MEASURE, + [anc_q(stab_type, stab_idx)], + f"{'sx' if stab_type == 'X' else 'sz'}{stab_idx}", + ) + for stab_type, stab_idx in batch + ) def append_szz_layer( target_ops: list[SurfaceCircuitStep], @@ -1265,90 +1315,54 @@ def append_szz_layer( # init_{basis}_basis syndrome establishment # ========================================================================= init_stabilizer_type = "X" if basis.upper() == "Z" else "Z" - init_stabilizers = list(stabilizers_for(init_stabilizer_type)) ops.append( SurfaceCircuitStep( OpType.COMMENT, label=f"init_{init_stabilizer_type.lower()}_syndrome", ), ) - ops.extend( - SurfaceCircuitStep( - OpType.ALLOC, - [anc_q(init_stabilizer_type, s.index)], - f"a{init_stabilizer_type.lower()}{s.index}", - ) - for s in init_stabilizers - ) - ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) - ops.extend( - SurfaceCircuitStep( - OpType.H, - [anc_q(init_stabilizer_type, s.index)], - f"a{init_stabilizer_type.lower()}{s.index}", - ) - for s in init_stabilizers - ) - ops.append(SurfaceCircuitStep(OpType.TICK)) - - for rnd_idx, cnot_round in enumerate(cnot_rounds): - layer_gates = [ - (stab_type, stab_idx, data_idx) - for stab_type, stab_idx, data_idx in cnot_round - if stab_type == init_stabilizer_type - ] - append_szz_layer(ops, rnd_idx, layer_gates) + for init_batch in stabilizer_batches_for(init_stabilizer_type): + append_prepare_szz_ancillas(ops, init_batch) ops.append(SurfaceCircuitStep(OpType.TICK)) - ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) - ops.extend( - SurfaceCircuitStep( - OpType.H, - [anc_q(init_stabilizer_type, s.index)], - f"a{init_stabilizer_type.lower()}{s.index}", - ) - for s in init_stabilizers - ) + init_keys = set(init_batch) + for rnd_idx, cnot_round in enumerate(cnot_rounds): + layer_gates = [ + (stab_type, stab_idx, data_idx) + for stab_type, stab_idx, data_idx in cnot_round + if (stab_type, stab_idx) in init_keys + ] + append_szz_layer(ops, rnd_idx, layer_gates) + ops.append(SurfaceCircuitStep(OpType.TICK)) - ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Measure ancillas")) - init_label_prefix = "sx" if init_stabilizer_type == "X" else "sz" - ops.extend( - SurfaceCircuitStep( - OpType.MEASURE, - [anc_q(init_stabilizer_type, s.index)], - f"{init_label_prefix}{s.index}", - ) - for s in init_stabilizers - ) - ops.append(SurfaceCircuitStep(OpType.TICK)) + append_measure_szz_ancillas(ops, init_batch) + ops.append(SurfaceCircuitStep(OpType.TICK)) # ========================================================================= # syndrome_extraction # ========================================================================= + stabilizer_batches = stabilizer_batches_for() for rnd in range(num_rounds): ops.append( SurfaceCircuitStep(OpType.COMMENT, label=f"syndrome_extraction round {rnd + 1}"), ) - ops.extend(SurfaceCircuitStep(OpType.ALLOC, [x_anc_q(s.index)], f"ax{s.index}") for s in geom.x_stabilizers) - ops.extend(SurfaceCircuitStep(OpType.ALLOC, [z_anc_q(s.index)], f"az{s.index}") for s in geom.z_stabilizers) - - ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) - ops.extend(SurfaceCircuitStep(OpType.H, [x_anc_q(s.index)], f"ax{s.index}") for s in geom.x_stabilizers) - ops.extend(SurfaceCircuitStep(OpType.H, [z_anc_q(s.index)], f"az{s.index}") for s in geom.z_stabilizers) - ops.append(SurfaceCircuitStep(OpType.TICK)) - for rnd_idx, cnot_round in enumerate(cnot_rounds): - append_szz_layer(ops, rnd_idx, list(cnot_round)) + for batch in stabilizer_batches: + append_prepare_szz_ancillas(ops, batch) ops.append(SurfaceCircuitStep(OpType.TICK)) - ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) - ops.extend(SurfaceCircuitStep(OpType.H, [x_anc_q(s.index)], f"ax{s.index}") for s in geom.x_stabilizers) - ops.extend(SurfaceCircuitStep(OpType.H, [z_anc_q(s.index)], f"az{s.index}") for s in geom.z_stabilizers) + batch_keys = set(batch) + for rnd_idx, cnot_round in enumerate(cnot_rounds): + layer_gates = [ + (stab_type, stab_idx, data_idx) + for stab_type, stab_idx, data_idx in cnot_round + if (stab_type, stab_idx) in batch_keys + ] + append_szz_layer(ops, rnd_idx, layer_gates) + ops.append(SurfaceCircuitStep(OpType.TICK)) - ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Measure ancillas")) - ops.extend(SurfaceCircuitStep(OpType.MEASURE, [x_anc_q(s.index)], f"sx{s.index}") for s in geom.x_stabilizers) - ops.extend(SurfaceCircuitStep(OpType.MEASURE, [z_anc_q(s.index)], f"sz{s.index}") for s in geom.z_stabilizers) - ops.append(SurfaceCircuitStep(OpType.TICK)) + append_measure_szz_ancillas(ops, batch) + ops.append(SurfaceCircuitStep(OpType.TICK)) # ========================================================================= # measure_z_basis / measure_x_basis diff --git a/python/quantum-pecos/tests/guppy/test_surface_ancilla_budget.py b/python/quantum-pecos/tests/guppy/test_surface_ancilla_budget.py index 424f2efdf..a3b68082d 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_ancilla_budget.py +++ b/python/quantum-pecos/tests/guppy/test_surface_ancilla_budget.py @@ -50,3 +50,25 @@ def test_constrained_ancilla_surface_code_traces_to_native_tick_circuit() -> Non assert circuit.get_meta("ancilla_budget") == "2" assert int(circuit.get_meta("num_measurements")) > 0 + + +def test_constrained_szz_surface_code_traces_to_native_tick_circuit() -> None: + """Budgeted SZZ/SZZdg surface programs should share the constrained Guppy path.""" + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + + patch = SurfacePatch.create(distance=3) + circuit = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=1, + basis="Z", + ancilla_budget=2, + circuit_source="traced_qis", + interaction_basis="szz", + ) + + gate_counts = circuit.gate_counts_by_type() + assert circuit.get_meta("ancilla_budget") == "2" + assert int(circuit.get_meta("num_measurements")) > 0 + assert gate_counts.get("RZZ", 0) > 0 + assert gate_counts.get("CX", 0) == 0 diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index f219264b5..16a4551e1 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -51,10 +51,17 @@ def test_szz_source_uses_signed_zz_phase_template(patch: SurfacePatch) -> None: assert "s(d" in src -def test_szz_source_rejects_staged_later_runtime_shapes(patch: SurfacePatch) -> None: - with pytest.raises(ValueError, match="constrained ancilla budgets"): - generate_guppy_source(patch, interaction_basis="szz", ancilla_budget=1) +def test_szz_source_supports_constrained_ancilla_budget(patch: SurfacePatch) -> None: + src = generate_guppy_source(patch, interaction_basis="szz", ancilla_budget=1) + + assert "zz_phase(" in src + assert "cx(" not in src + assert '"""Extract full syndrome in 8 ancilla-reuse batches (budget=1)."""' in src + assert "_a_b0_p0 = qubit()" in src + assert "_init_a_b0_p0 = qubit()" in src + +def test_szz_source_rejects_staged_later_runtime_shapes(patch: SurfacePatch) -> None: with pytest.raises(ValueError, match="twirl integration is staged later"): generate_guppy_source( patch, From b1042765dd4f16d7e6d9fb4cdc546874c85cfbbd Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 15 Jun 2026 15:04:04 -0600 Subject: [PATCH 111/150] Thread check plans through Guppy surface generation --- .../quantum-pecos/src/pecos/guppy/surface.py | 129 ++++++++++++++---- .../src/pecos/qec/surface/decode.py | 26 +++- .../tests/qec/surface/test_check_plan.py | 37 +++++ 3 files changed, 160 insertions(+), 32 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 77dbec321..b56d4494a 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -22,6 +22,7 @@ if TYPE_CHECKING: from pecos.qec.surface import GuppyRngMaskConfig, SurfacePatch, TwirlConfig + from pecos.qec.surface._check_plan import ResolvedSurfaceCheckPlan # Module state container (avoids global statement) @@ -33,7 +34,7 @@ class _ModuleState: # Keyed by full patch identity + effective budget (dx, dz, orientation, # rotated, effective_budget) so distinct patch geometries -- e.g. rotated # vs non-rotated at the same dx/dz -- never collide on a cached module. - distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str], dict]] = {} + distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str, str], dict]] = {} _state = _ModuleState() @@ -45,6 +46,19 @@ def _normalize_surface_interaction_basis(interaction_basis: str) -> str: return _normalize_interaction_basis(interaction_basis) +def _resolve_surface_check_plan( + *, + interaction_basis: str | None = None, + check_plan: str | None = None, +) -> "ResolvedSurfaceCheckPlan": + from pecos.qec.surface._check_plan import resolve_surface_check_plan + + return resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + + def _get_temp_dir() -> Path: """Get or create temporary directory for generated code.""" if _state.temp_dir is None: @@ -126,7 +140,8 @@ def generate_guppy_source( twirl: "TwirlConfig | None" = None, rng: "GuppyRngMaskConfig | None" = None, num_rounds: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> str: """Generate Guppy source code for a surface code patch. @@ -172,9 +187,12 @@ def generate_guppy_source( per-shot quantum entropy when ``twirl`` is enabled. num_rounds: Number of syndrome rounds to render. Required when ``twirl`` is enabled because twirled source is unrolled per round. - interaction_basis: Surface-memory interaction basis: ``"cx"`` or - ``"szz"``. SZZ Guppy generation supports the same non-twirled - unconstrained and constrained ancilla shapes as the CX template. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. This is the source of + truth when supplied; ``interaction_basis`` must agree if also + supplied. Current Guppy generation maps the resolved plan to the + corresponding CX or SZZ/SZZdg concrete template. Returns: Python/Guppy source code as a string. @@ -185,7 +203,11 @@ def generate_guppy_source( from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget from pecos.qec.surface.circuit_builder import _default_szz_residual_plan - interaction_basis = _normalize_surface_interaction_basis(interaction_basis) + resolved_plan = _resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + interaction_basis = resolved_plan.interaction_basis if (twirl is None) != (rng is None): msg = f"twirl and rng must be supplied together; got twirl={twirl!r} rng={rng!r}" raise ValueError(msg) @@ -260,6 +282,7 @@ def generate_guppy_source( f"Z stabilizers: {num_z_stab}", f"Ancilla qubits: {num_x_stab + num_z_stab} (one per stabilizer)", f"Interaction basis: {interaction_basis}", + f"Check plan: {resolved_plan.plan_id}", '"""', "", *imports, @@ -1493,7 +1516,8 @@ def _guppy_module_cache_key( twirl: "TwirlConfig | None" = None, rng: "GuppyRngMaskConfig | None" = None, num_rounds: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> str: """Filesystem-safe cache key spanning full patch identity + budget + twirl. @@ -1508,9 +1532,17 @@ def _guppy_module_cache_key( """ geom = patch.geometry rotated = "rot" if geom.rotated else "unrot" - interaction_basis = _normalize_surface_interaction_basis(interaction_basis) + resolved_plan = _resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + interaction_basis = resolved_plan.interaction_basis interaction_part = "" if interaction_basis == "cx" else f"_ib{interaction_basis}" - base = f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}_b{effective_budget}{interaction_part}" + check_plan_part = "" if check_plan is None else f"_cp{resolved_plan.plan_id}" + base = ( + f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}" + f"_b{effective_budget}{interaction_part}{check_plan_part}" + ) if twirl is None: return base if rng is None or num_rounds is None: @@ -1532,7 +1564,8 @@ def _load_guppy_module( twirl: "TwirlConfig | None" = None, rng: "GuppyRngMaskConfig | None" = None, num_rounds: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> dict: """Load a Guppy module for a patch, using caching. @@ -1550,14 +1583,20 @@ def _load_guppy_module( twirl: Pauli-twirl-site declaration (structural) rng: Runtime mask RNG seed (must be supplied with ``twirl``) num_rounds: Syndrome-round count for unrolled twirled source. - interaction_basis: Surface-memory interaction basis. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. Returns: Module dictionary with generated functions """ from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget - interaction_basis = _normalize_surface_interaction_basis(interaction_basis) + resolved_plan = _resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + interaction_basis = resolved_plan.interaction_basis geom = patch.geometry total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) @@ -1568,6 +1607,7 @@ def _load_guppy_module( rng, num_rounds, interaction_basis, + resolved_plan.plan_id if check_plan is not None else None, ) if cache_key in _state.module_cache: @@ -1580,6 +1620,7 @@ def _load_guppy_module( rng=rng, num_rounds=num_rounds, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, ) # Write to temp file (required for Guppy introspection). @@ -1610,7 +1651,8 @@ def generate_memory_experiment( ancilla_budget: int | None = None, twirl: "TwirlConfig | None" = None, rng: "GuppyRngMaskConfig | None" = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> object: """Generate a memory experiment for a patch. @@ -1621,7 +1663,9 @@ def generate_memory_experiment( ancilla_budget: Optional cap on simultaneously live ancillas twirl: Pauli-twirl-site declaration; must be supplied with ``rng``. rng: Runtime mask RNG seed; must be supplied with ``twirl``. - interaction_basis: Surface-memory interaction basis. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. Returns: Guppy function for the experiment @@ -1633,6 +1677,7 @@ def generate_memory_experiment( rng=rng, num_rounds=num_rounds if twirl is not None else None, interaction_basis=interaction_basis, + check_plan=check_plan, ) if basis.upper() == "Z": @@ -1652,7 +1697,8 @@ def get_num_qubits( patch: "SurfacePatch | None" = None, ancilla_budget: int | None = None, twirl: "TwirlConfig | None" = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> int: """Get the peak simultaneously-live qubit count for a surface-code program. @@ -1678,7 +1724,11 @@ def get_num_qubits( """ from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget - interaction_basis = _normalize_surface_interaction_basis(interaction_basis) + resolved_plan = _resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + interaction_basis = resolved_plan.interaction_basis if (d is None) == (patch is None): msg = "get_num_qubits requires exactly one of d=... or patch=..." raise ValueError(msg) @@ -1704,7 +1754,8 @@ def generate_surface_code_module( d: int, *, ancilla_budget: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> str: """Generate source code for a distance-d surface code module. @@ -1712,7 +1763,9 @@ def generate_surface_code_module( d: Code distance (must be odd >= 3) ancilla_budget: Optional cap on simultaneously live ancillas; forwarded to ``generate_guppy_source``. - interaction_basis: Surface-memory interaction basis. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. Returns: Python/Guppy source code as a string @@ -1726,6 +1779,7 @@ def generate_surface_code_module( patch, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, + check_plan=check_plan, ) @@ -1733,7 +1787,8 @@ def _surface_code_module_for_patch( patch: "SurfacePatch", *, ancilla_budget: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> dict: """Load + cache a surface-code module for an arbitrary patch. @@ -1745,11 +1800,23 @@ def _surface_code_module_for_patch( """ from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget - interaction_basis = _normalize_surface_interaction_basis(interaction_basis) + resolved_plan = _resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + interaction_basis = resolved_plan.interaction_basis geom = patch.geometry total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) - cache_key = (patch.dx, patch.dz, geom.orientation.name, geom.rotated, effective_budget, interaction_basis) + cache_key = ( + patch.dx, + patch.dz, + geom.orientation.name, + geom.rotated, + effective_budget, + interaction_basis, + resolved_plan.plan_id, + ) if cache_key in _state.distance_module_cache: return _state.distance_module_cache[cache_key] @@ -1758,6 +1825,7 @@ def _surface_code_module_for_patch( patch, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, ) # Metadata derived from the actual patch geometry. @@ -1766,6 +1834,9 @@ def _surface_code_module_for_patch( module["num_stab"] = total_ancilla module["ancilla_budget"] = effective_budget module["interaction_basis"] = interaction_basis + module["check_plan"] = resolved_plan.plan_id + module["resolved_check_plan"] = resolved_plan.resolved_metadata + module["resolved_check_plan_hash"] = resolved_plan.resolved_hash _state.distance_module_cache[cache_key] = module return module @@ -1775,14 +1846,17 @@ def get_surface_code_module( d: int, *, ancilla_budget: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> dict: """Get a loaded surface code module for distance d. Args: d: Code distance (must be odd >= 3) ancilla_budget: Optional cap on simultaneously live ancillas - interaction_basis: Surface-memory interaction basis. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. Returns: Dictionary with module contents and metadata @@ -1795,6 +1869,7 @@ def get_surface_code_module( patch, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, + check_plan=check_plan, ) @@ -1804,7 +1879,8 @@ def make_surface_code( basis: str, *, ancilla_budget: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> object: """Create a surface code memory experiment. @@ -1817,7 +1893,9 @@ def make_surface_code( a finite budget emits a stabilizer-batched program that matches the abstract circuit's ``batched_stabilizers(patch, effective_budget)`` schedule. - interaction_basis: Surface-memory interaction basis. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. Returns: Compiled Guppy program @@ -1830,6 +1908,7 @@ def make_surface_code( distance, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, + check_plan=check_plan, ) factory = module["make_memory_z"] if basis.upper() == "Z" else module["make_memory_x"] diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 79343afd8..3999c731f 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1422,7 +1422,8 @@ def _generate_traced_surface_tick_circuit( basis: str, *, ancilla_budget: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, runtime: object | None = None, ) -> Any: """Trace the lowered ideal Selene/QIS op stream and replay it into a TickCircuit. @@ -1445,6 +1446,7 @@ def _generate_traced_surface_tick_circuit( basis, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, + check_plan=check_plan, runtime=runtime, ) return tc @@ -1456,7 +1458,8 @@ def _generate_traced_surface_tick_circuit_with_result_traces( basis: str, *, ancilla_budget: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, runtime: object | None = None, ) -> tuple[Any, list[dict[str, Any]]]: """Trace a surface Guppy program into a ``TickCircuit`` plus result provenance.""" @@ -1468,6 +1471,7 @@ def _generate_traced_surface_tick_circuit_with_result_traces( basis, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, + check_plan=check_plan, ) return trace_guppy_into_tick_circuit_with_result_traces( program, @@ -1475,6 +1479,7 @@ def _generate_traced_surface_tick_circuit_with_result_traces( patch=patch, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, + check_plan=check_plan, ), seed=0, runtime=runtime, @@ -1490,7 +1495,8 @@ def _build_surface_tick_circuit_for_native_model( circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, twirl: TwirlConfig | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, szz_physical_prefixes: bool = False, ) -> Any: """Build the TickCircuit used by the native DEM and sampler paths.""" @@ -1498,7 +1504,11 @@ def _build_surface_tick_circuit_for_native_model( if twirl is not None: twirl.validate_runtime_supported() - interaction_basis = _normalize_interaction_basis(interaction_basis) + resolved_plan = resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + interaction_basis = _normalize_interaction_basis(resolved_plan.interaction_basis) if szz_physical_prefixes and (interaction_basis != "szz" or circuit_source != "abstract"): msg = "SZZ physical-prefix lowering requires interaction_basis='szz' and circuit_source='abstract'" raise ValueError(msg) @@ -1530,6 +1540,7 @@ def _build_surface_tick_circuit_for_native_model( basis, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, runtime=runtime, ) @@ -1572,7 +1583,7 @@ def build_memory_circuit( circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, twirl: TwirlConfig | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, ) -> Any: """Build the standard surface-code memory ``TickCircuit``. @@ -1845,7 +1856,7 @@ def _surface_native_topology( *, runtime: object | None = None, twirl: TwirlConfig | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, check_plan: str | None = None, szz_physical_prefixes: bool = False, ) -> _CachedNativeSurfaceTopology: @@ -1872,6 +1883,7 @@ def _surface_native_topology( runtime=runtime, twirl=twirl, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, ) if circuit_source == "traced_qis": @@ -1942,7 +1954,7 @@ def _cached_surface_native_topology( include_idle_gates: bool, *, twirl: TwirlConfig | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, check_plan: str | None = None, szz_physical_prefixes: bool = False, resolved_check_plan_hash: str = "", 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 02ccf46f0..557ff4c1f 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -45,6 +45,43 @@ def test_check_plan_and_interaction_basis_mismatch_fails_loudly() -> None: ) +def test_guppy_surface_code_module_records_resolved_check_plan() -> None: + from pecos.guppy import get_surface_code_module + + module = get_surface_code_module(3, check_plan="szz_current_v1") + + assert module["interaction_basis"] == "szz" + assert module["check_plan"] == "szz_current_v1" + assert module["resolved_check_plan"]["semantic_content"]["interaction_basis"] == "szz" + assert len(module["resolved_check_plan_hash"]) == 64 + + +def test_guppy_surface_code_rejects_plan_basis_mismatch() -> None: + from pecos.guppy import make_surface_code + + with pytest.raises(ValueError, match="conflicts with check_plan"): + make_surface_code( + distance=3, + num_rounds=1, + basis="Z", + interaction_basis="cx", + check_plan="szz_current_v1", + ) + + +def test_guppy_surface_code_accepts_check_plan_as_source_of_truth() -> None: + from pecos.guppy import make_surface_code + + program = make_surface_code( + distance=3, + num_rounds=1, + basis="Z", + check_plan="szz_current_v1", + ) + + assert program is not None + + def test_surface_code_memory_records_resolved_check_plan() -> None: from pecos.qec.surface import surface_code_memory From e9df730adfba1070336c2772bdd9123f7f33c402 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 15 Jun 2026 18:50:02 -0600 Subject: [PATCH 112/150] Require check plans to match current surface renderers --- .../quantum-pecos/src/pecos/guppy/surface.py | 5 + .../src/pecos/qec/surface/_check_plan.py | 112 ++++++++++++++++++ .../src/pecos/qec/surface/decode.py | 10 +- .../tests/qec/surface/test_check_plan.py | 45 ++++++- 4 files changed, 170 insertions(+), 2 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index b56d4494a..9958cc1f0 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -201,12 +201,17 @@ def generate_guppy_source( ValueError: If exactly one of ``twirl`` / ``rng`` is supplied. """ from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget + from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer from pecos.qec.surface.circuit_builder import _default_szz_residual_plan resolved_plan = _resolve_surface_check_plan( interaction_basis=interaction_basis, check_plan=check_plan, ) + require_current_surface_check_plan_renderer( + resolved_plan, + context="Guppy surface-code source generation", + ) interaction_basis = resolved_plan.interaction_basis if (twirl is None) != (rng is None): msg = f"twirl and rng must be supplied together; got twirl={twirl!r} rng={rng!r}" diff --git a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py index e50cf9e1e..51a011e9f 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py @@ -14,6 +14,7 @@ CHECK_PLAN_METADATA_VERSION = 1 CHECK_PLAN_HASH_ALGORITHM = "sha256" CHECK_PLAN_HASH_SERIALIZATION = "canonical-json-v1" +CURRENT_SURFACE_CHECK_PLAN_RENDERER = "pecos.surface.current_renderer_v1" _DEFAULT_CHECK_PLAN_BY_BASIS = { "cx": "cx_standard_v1", @@ -41,6 +42,12 @@ def _normalize_check_plan_id(check_plan: str) -> str: "cx_standard_v1": { "plan_id": "cx_standard_v1", "interaction_basis": "cx", + "synthesis_identity": { + "family": "cx", + "szz_phase_pattern": "none", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + }, "schedule": { "round_policy": "constant", "site_policy": "global", @@ -59,6 +66,12 @@ def _normalize_check_plan_id(check_plan: str) -> str: "szz_current_v1": { "plan_id": "szz_current_v1", "interaction_basis": "szz", + "synthesis_identity": { + "family": "szz", + "szz_phase_pattern": "standard", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + }, "schedule": { "round_policy": "constant", "site_policy": "global", @@ -86,6 +99,18 @@ def canonical_check_plan_json(value: dict[str, Any]) -> str: return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) +def surface_check_plan_ids() -> tuple[str, ...]: + """Return known surface check-plan IDs in deterministic order.""" + return tuple(sorted(_PLAN_SEMANTICS)) + + +def default_surface_check_plan_id(interaction_basis: str | None = None) -> str: + """Return the default check-plan ID for a two-qubit interaction basis.""" + if interaction_basis is None: + return _DEFAULT_CHECK_PLAN_BY_BASIS["cx"] + return _DEFAULT_CHECK_PLAN_BY_BASIS[_normalize_interaction_basis_name(interaction_basis)] + + def _semantic_hash(semantic_content: dict[str, Any]) -> str: encoded = canonical_check_plan_json(semantic_content).encode("utf-8") return hashlib.sha256(encoded).hexdigest() @@ -97,6 +122,7 @@ class ResolvedSurfaceCheckPlan: plan_id: str interaction_basis: str + synthesis_identity: dict[str, Any] semantic_content: dict[str, Any] resolved_metadata: dict[str, Any] resolved_hash: str @@ -139,7 +165,93 @@ def resolve_surface_check_plan( return ResolvedSurfaceCheckPlan( plan_id=plan_id, interaction_basis=plan_basis, + synthesis_identity=dict(semantic_content["synthesis_identity"]), semantic_content=semantic_content, resolved_metadata=resolved_metadata, resolved_hash=resolved_hash, ) + + +def require_current_surface_check_plan_renderer( + resolved_plan: ResolvedSurfaceCheckPlan, + *, + context: str, +) -> None: + """Fail if the current source renderers cannot realize ``resolved_plan``. + + Check plans are intended to be source-level circuit contracts, not metadata + labels pasted onto whatever circuit the old ``interaction_basis`` path + happened to emit. Keep this guard close to circuit generation until each new + plan has an explicit renderer. + """ + semantic = resolved_plan.semantic_content + synthesis = resolved_plan.synthesis_identity + schedule = semantic.get("schedule", {}) + + expected_synthesis = { + "cx": { + "family": "cx", + "szz_phase_pattern": "none", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + }, + "szz": { + "family": "szz", + "szz_phase_pattern": "standard", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + }, + }[resolved_plan.interaction_basis] + expected_schedule = { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + } + if synthesis != expected_synthesis or schedule != expected_schedule: + msg = ( + f"{context} cannot realize check_plan={resolved_plan.plan_id!r} " + f"with {CURRENT_SURFACE_CHECK_PLAN_RENDERER}; synthesis_identity={synthesis!r}, " + f"schedule={schedule!r}" + ) + raise NotImplementedError(msg) + + if resolved_plan.interaction_basis == "cx": + expected_checks = { + "prefix_policy": "none", + "x_check": { + "template": "current_cx_x_check_v1", + "measurement_sign_policy": "none", + }, + "z_check": { + "template": "current_cx_z_check_v1", + "measurement_sign_policy": "none", + }, + } + else: + expected_checks = { + "prefix_policy": "forward_flow_virtual_z_v1", + "x_check": { + "template": "current_szz_x_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "z_check": { + "template": "current_szz_z_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + } + + actual_checks = { + "prefix_policy": semantic.get("prefix_policy"), + "x_check": semantic.get("x_check"), + "z_check": semantic.get("z_check"), + } + if actual_checks != expected_checks: + msg = ( + f"{context} cannot realize check_plan={resolved_plan.plan_id!r} " + f"with {CURRENT_SURFACE_CHECK_PLAN_RENDERER}; check templates={actual_checks!r}" + ) + raise NotImplementedError(msg) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 3999c731f..7d91551f1 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -51,7 +51,7 @@ import numpy as np -from pecos.qec.surface._check_plan import resolve_surface_check_plan +from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan if TYPE_CHECKING: import stim @@ -1508,6 +1508,10 @@ def _build_surface_tick_circuit_for_native_model( interaction_basis=interaction_basis, check_plan=check_plan, ) + require_current_surface_check_plan_renderer( + resolved_plan, + context="surface native TickCircuit generation", + ) interaction_basis = _normalize_interaction_basis(resolved_plan.interaction_basis) if szz_physical_prefixes and (interaction_basis != "szz" or circuit_source != "abstract"): msg = "SZZ physical-prefix lowering requires interaction_basis='szz' and circuit_source='abstract'" @@ -1872,6 +1876,10 @@ def _surface_native_topology( ) resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + require_current_surface_check_plan_renderer( + resolved_plan, + context="surface native topology construction", + ) interaction_basis = resolved_plan.interaction_basis patch = _cached_surface_patch(patch_key) tc = _build_surface_tick_circuit_for_native_model( 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 557ff4c1f..d10f75831 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -6,17 +6,32 @@ from __future__ import annotations import hashlib +from dataclasses import replace import pytest def test_check_plan_default_resolves_to_cx_metadata() -> None: - from pecos.qec.surface._check_plan import canonical_check_plan_json, resolve_surface_check_plan + from pecos.qec.surface._check_plan import ( + canonical_check_plan_json, + default_surface_check_plan_id, + resolve_surface_check_plan, + surface_check_plan_ids, + ) plan = resolve_surface_check_plan() + assert surface_check_plan_ids() == ("cx_standard_v1", "szz_current_v1") + assert default_surface_check_plan_id() == "cx_standard_v1" + assert default_surface_check_plan_id("SZZ") == "szz_current_v1" assert plan.plan_id == "cx_standard_v1" assert plan.interaction_basis == "cx" + assert plan.synthesis_identity == { + "family": "cx", + "szz_phase_pattern": "none", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + } assert plan.resolved_metadata["metadata_version"] == 1 assert plan.resolved_metadata["hash_algorithm"] == "sha256" assert plan.resolved_metadata["hash_serialization"] == "canonical-json-v1" @@ -33,6 +48,34 @@ def test_check_plan_is_source_of_truth_for_basis() -> None: assert plan.plan_id == "szz_current_v1" assert plan.interaction_basis == "szz" + assert plan.synthesis_identity == { + "family": "szz", + "szz_phase_pattern": "standard", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + } + + +def test_current_renderer_rejects_unimplemented_plan_semantics() -> None: + from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan + + plan = resolve_surface_check_plan(check_plan="szz_current_v1") + unsupported = dict(plan.semantic_content) + unsupported["synthesis_identity"] = { + **dict(plan.synthesis_identity), + "szz_phase_pattern": "checkerboard", + } + unsupported_plan = replace( + plan, + synthesis_identity=dict(unsupported["synthesis_identity"]), + semantic_content=unsupported, + ) + + with pytest.raises(NotImplementedError, match=r"unit-test.*checkerboard"): + require_current_surface_check_plan_renderer( + unsupported_plan, + context="unit-test", + ) def test_check_plan_and_interaction_basis_mismatch_fails_loudly() -> None: From 5692adfd2c4a483ee13c01618f02be9a58e92bc2 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 15 Jun 2026 19:03:44 -0600 Subject: [PATCH 113/150] Accept check plans in direct surface builders --- .../src/pecos/qec/surface/circuit_builder.py | 49 ++++++++++--- .../src/pecos/qec/surface/decode.py | 4 ++ .../tests/qec/surface/test_check_plan.py | 70 +++++++++++++++++++ 3 files changed, 114 insertions(+), 9 deletions(-) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 5e614c979..6f58d17a4 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -35,6 +35,7 @@ from pecos.qec.surface._ancilla_batching import ( normalize_ancilla_budget as _normalize_ancilla_budget, ) +from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan # Stabilizer geometry helpers live in the low-level patch module (single # source of truth). Only the two used by the circuit renderer are imported @@ -753,7 +754,8 @@ def build_surface_code_circuit( ancilla_budget: int | None = None, *, twirl: TwirlConfig | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> tuple[list[SurfaceCircuitStep], QubitAllocation]: """Build abstract circuit operations for a surface code memory experiment. @@ -780,6 +782,9 @@ def build_surface_code_circuit( ``"cx"`` preserves the existing CNOT extraction circuit. ``"szz"`` emits the direct-renderer SZZ/SZZdg abstract template with local data-qubit compensation. + check_plan: Named surface check-plan preset. This is the source of + truth when supplied; ``interaction_basis`` must agree if also + supplied. Returns: Tuple of (operations list, qubit allocation info) @@ -792,7 +797,15 @@ def build_surface_code_circuit( num_z_anc = len(geom.z_stabilizers) total_ancilla = num_x_anc + num_z_anc effective_ancilla_budget = _normalize_ancilla_budget(total_ancilla, ancilla_budget) - interaction_basis = _normalize_interaction_basis(interaction_basis) + resolved_plan = resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + require_current_surface_check_plan_renderer( + resolved_plan, + context="abstract surface-code circuit generation", + ) + interaction_basis = _normalize_interaction_basis(resolved_plan.interaction_basis) if twirl is not None: twirl.validate_runtime_supported() twirl_site_schedule = None if twirl is None else twirl.site_schedule @@ -2560,7 +2573,8 @@ def generate_stim_from_patch( basis: str = "Z", *, ancilla_budget: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, p1: float = 0.0, p2: float = 0.0, p_meas: float = 0.0, @@ -2575,6 +2589,7 @@ def generate_stim_from_patch( basis: 'Z' or 'X' ancilla_budget: Optional cap on simultaneously live ancillas interaction_basis: Surface-memory two-qubit interaction basis. + check_plan: Named surface check-plan preset. p1: Single-qubit error rate p2: Two-qubit error rate p_meas: Measurement error rate @@ -2584,13 +2599,13 @@ def generate_stim_from_patch( Returns: Stim circuit string """ - interaction_basis = _normalize_interaction_basis(interaction_basis) ops, allocation = build_surface_code_circuit( patch, num_rounds, basis, ancilla_budget, interaction_basis=interaction_basis, + check_plan=check_plan, ) renderer = StimRenderer(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep, add_detectors=add_detectors) return renderer.render(ops, allocation, patch, num_rounds, basis) @@ -2601,7 +2616,8 @@ def generate_guppy_from_patch( _num_rounds: int = 1, _basis: str = "Z", *, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> str: """Generate Guppy code from SurfacePatch. @@ -2618,13 +2634,14 @@ def generate_guppy_from_patch( _num_rounds: Unused (factory functions accept this at runtime) _basis: Unused (module includes both Z and X basis functions) interaction_basis: Surface-memory two-qubit interaction basis. + check_plan: Named surface check-plan preset. Returns: Guppy source code string (full module) """ from pecos.guppy.surface import generate_guppy_source - return generate_guppy_source(patch, interaction_basis=interaction_basis) + return generate_guppy_source(patch, interaction_basis=interaction_basis, check_plan=check_plan) def generate_dag_circuit_from_patch( @@ -2633,7 +2650,8 @@ def generate_dag_circuit_from_patch( basis: str = "Z", ancilla_budget: int | None = None, *, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> DagCircuit: """Generate PECOS DagCircuit from SurfacePatch. @@ -2643,6 +2661,7 @@ def generate_dag_circuit_from_patch( basis: 'Z' or 'X' ancilla_budget: Optional cap on simultaneously live ancillas interaction_basis: Surface-memory two-qubit interaction basis. + check_plan: Named surface check-plan preset. Returns: PECOS DagCircuit instance @@ -2653,6 +2672,7 @@ def generate_dag_circuit_from_patch( basis, ancilla_budget, interaction_basis=interaction_basis, + check_plan=check_plan, ) renderer = DagCircuitRenderer() return renderer.render(ops, allocation, patch, num_rounds, basis) @@ -2667,7 +2687,8 @@ def generate_tick_circuit_from_patch( add_typed_annotations: bool = True, ancilla_budget: int | None = None, twirl: TwirlConfig | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, szz_physical_prefixes: bool = False, ) -> TickCircuit: """Generate PECOS TickCircuit from SurfacePatch. @@ -2697,13 +2718,22 @@ def generate_tick_circuit_from_patch( ``add_typed_annotations`` is false; that flag controls detector and observable typed annotations, not the twirl lookup channel. interaction_basis: Surface-memory two-qubit interaction basis. + check_plan: Named surface check-plan preset. szz_physical_prefixes: If true, lower the abstract SZZ single-qubit scaffold into physical prefix pulses for native DEM analysis. Returns: PECOS TickCircuit instance """ - interaction_basis = _normalize_interaction_basis(interaction_basis) + resolved_plan = resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + require_current_surface_check_plan_renderer( + resolved_plan, + context="abstract surface TickCircuit generation", + ) + interaction_basis = _normalize_interaction_basis(resolved_plan.interaction_basis) if szz_physical_prefixes and interaction_basis != "szz": msg = "szz_physical_prefixes=True requires interaction_basis='szz'" raise ValueError(msg) @@ -2714,6 +2744,7 @@ def generate_tick_circuit_from_patch( ancilla_budget, twirl=twirl, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, ) if szz_physical_prefixes: ops = _lower_szz_forward_flow_ops(ops) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 7d91551f1..92e69bd3e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1525,6 +1525,7 @@ def _build_surface_tick_circuit_for_native_model( add_typed_annotations=False, twirl=twirl, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, ) @@ -1588,6 +1589,7 @@ def build_memory_circuit( runtime: object | None = None, twirl: TwirlConfig | None = None, interaction_basis: str | None = None, + check_plan: str | None = None, ) -> Any: """Build the standard surface-code memory ``TickCircuit``. @@ -1611,6 +1613,7 @@ def build_memory_circuit( rejected because a runtime trace would bake one sampled mask into the circuit and can lose canonical result-id provenance. interaction_basis: Surface-memory two-qubit interaction basis. + check_plan: Named surface check-plan preset. Returns: A Rust-backed ``TickCircuit`` with detector and observable metadata. @@ -1644,6 +1647,7 @@ def build_memory_circuit( runtime=runtime, twirl=twirl, interaction_basis=interaction_basis, + check_plan=check_plan, ) 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 d10f75831..365afde70 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -181,6 +181,76 @@ def test_check_plan_does_not_change_current_szz_dem() -> None: assert by_plan == by_basis +def test_direct_surface_renderers_accept_check_plan_as_source_of_truth() -> None: + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.circuit_builder import ( + generate_dag_circuit_from_patch, + generate_guppy_from_patch, + generate_stim_from_patch, + generate_tick_circuit_from_patch, + ) + from pecos.qec.surface.decode import build_memory_circuit + + patch = SurfacePatch.create(distance=3) + + stim_text = generate_stim_from_patch( + patch, + num_rounds=1, + check_plan="szz_current_v1", + add_detectors=False, + ) + assert "CX" not in stim_text + assert "SQRT_ZZ" in stim_text + + dag_circuit = generate_dag_circuit_from_patch( + patch, + num_rounds=1, + check_plan="szz_current_v1", + ) + assert "SZZ" in {dag_circuit.gate(node).gate_type.name for node in dag_circuit.nodes()} + + tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + check_plan="szz_current_v1", + ) + assert int(tick_circuit.get_meta("num_detectors")) > 0 + + memory_circuit = build_memory_circuit( + patch=patch, + rounds=1, + check_plan="szz_current_v1", + ) + assert int(memory_circuit.get_meta("num_detectors")) == int(tick_circuit.get_meta("num_detectors")) + + guppy_source = generate_guppy_from_patch(patch, check_plan="szz_current_v1") + assert "Check plan: szz_current_v1" in guppy_source + + +def test_direct_surface_renderers_reject_plan_basis_mismatch() -> None: + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch + from pecos.qec.surface.decode import build_memory_circuit + + patch = SurfacePatch.create(distance=3) + + with pytest.raises(ValueError, match="conflicts with check_plan"): + generate_tick_circuit_from_patch( + patch, + num_rounds=1, + interaction_basis="cx", + check_plan="szz_current_v1", + ) + + with pytest.raises(ValueError, match="conflicts with check_plan"): + build_memory_circuit( + patch=patch, + rounds=1, + interaction_basis="cx", + check_plan="szz_current_v1", + ) + + def test_native_sampler_records_resolved_check_plan() -> None: from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler From 984bc0461c9fe02e40bf453feceb85c211614ad2 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 15 Jun 2026 20:22:21 -0600 Subject: [PATCH 114/150] Add boundary-first SZZ surface check plan --- .../quantum-pecos/src/pecos/guppy/surface.py | 4 +- .../src/pecos/qec/surface/_check_plan.py | 57 ++++++++++++--- .../src/pecos/qec/surface/circuit_builder.py | 50 ++++++++++++- .../tests/qec/surface/test_check_plan.py | 70 ++++++++++++++++++- .../qec/surface/test_szz_interaction_basis.py | 15 +++- 5 files changed, 181 insertions(+), 15 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 9958cc1f0..d62ea3a54 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -202,7 +202,7 @@ def generate_guppy_source( """ from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer - from pecos.qec.surface.circuit_builder import _default_szz_residual_plan + from pecos.qec.surface.circuit_builder import _szz_residual_plan_for_check_plan resolved_plan = _resolve_surface_check_plan( interaction_basis=interaction_basis, @@ -360,7 +360,7 @@ def _szz_data_expr(data_q: int) -> str: rounds = compute_cnot_schedule(patch) szz_sign_by_touch: dict[tuple[str, int, int], int] = {} if interaction_basis == "szz": - residual_plan = _default_szz_residual_plan(patch) + residual_plan = _szz_residual_plan_for_check_plan(patch, resolved_plan) szz_sign_by_touch = { (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): entry.sign for entry in residual_plan.signs diff --git a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py index 51a011e9f..3876ab7d1 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py @@ -91,6 +91,34 @@ def _normalize_check_plan_id(check_plan: str) -> str: }, "prefix_policy": "forward_flow_virtual_z_v1", }, + "szz_boundary_first_v1": { + "plan_id": "szz_boundary_first_v1", + "interaction_basis": "szz", + "synthesis_identity": { + "family": "szz", + "szz_phase_pattern": "boundary-first", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + }, + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + }, + "x_check": { + "template": "current_szz_x_check_v1", + "sign_policy": "boundary_first_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "z_check": { + "template": "current_szz_z_check_v1", + "sign_policy": "boundary_first_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "prefix_policy": "forward_flow_virtual_z_v1", + }, } @@ -188,20 +216,26 @@ def require_current_surface_check_plan_renderer( synthesis = resolved_plan.synthesis_identity schedule = semantic.get("schedule", {}) - expected_synthesis = { - "cx": { + if resolved_plan.interaction_basis == "cx": + expected_synthesis = { "family": "cx", "szz_phase_pattern": "none", "interaction_order": "pecos-default", "ancilla_schedule": "default", - }, - "szz": { + } + else: + expected_synthesis = { "family": "szz", - "szz_phase_pattern": "standard", + "szz_phase_pattern": synthesis.get("szz_phase_pattern"), "interaction_order": "pecos-default", "ancilla_schedule": "default", - }, - }[resolved_plan.interaction_basis] + } + if synthesis.get("szz_phase_pattern") not in {"standard", "boundary-first"}: + msg = ( + f"{context} cannot realize check_plan={resolved_plan.plan_id!r} " + f"with {CURRENT_SURFACE_CHECK_PLAN_RENDERER}; synthesis_identity={synthesis!r}" + ) + raise NotImplementedError(msg) expected_schedule = { "round_policy": "constant", "site_policy": "global", @@ -228,17 +262,22 @@ def require_current_surface_check_plan_renderer( }, } else: + szz_sign_policy_by_pattern = { + "standard": "default_szz_sign_vector_v1", + "boundary-first": "boundary_first_szz_sign_vector_v1", + } + sign_policy = szz_sign_policy_by_pattern[str(synthesis["szz_phase_pattern"])] expected_checks = { "prefix_policy": "forward_flow_virtual_z_v1", "x_check": { "template": "current_szz_x_check_v1", - "sign_policy": "default_szz_sign_vector_v1", + "sign_policy": sign_policy, "residual_policy": "per_touch_compensated", "measurement_sign_policy": "explicit_template_metadata", }, "z_check": { "template": "current_szz_z_check_v1", - "sign_policy": "default_szz_sign_vector_v1", + "sign_policy": sign_policy, "residual_policy": "per_touch_compensated", "measurement_sign_policy": "explicit_template_metadata", }, diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 6f58d17a4..9524b51b3 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -47,6 +47,7 @@ from pecos.quantum import PHYSICAL_DURATION_META_KEY if TYPE_CHECKING: + from pecos.qec.surface._check_plan import ResolvedSurfaceCheckPlan from pecos.qec.surface._twirl_config import TwirlConfig from pecos.qec.surface.patch import ( LogicalDescriptor, @@ -298,6 +299,34 @@ def _default_szz_sign_vector(patch: SurfacePatch) -> tuple[SzzTouchSign, ...]: return tuple(sorted(signs)) +def _boundary_first_szz_sign_vector(patch: SurfacePatch) -> tuple[SzzTouchSign, ...]: + """Return the SZZ sign vector with boundary daggers on first operands. + + This is the first non-default SZZ/SZZdg source-level check plan. It keeps + the same schedule and compensation model as the default plan but changes + the concrete signed SZZ touch chosen on each weight-2 boundary check. + """ + signs: list[SzzTouchSign] = [] + for stabilizer_type, stabilizer_index, data_qubits, is_boundary in _iter_surface_stabilizer_touches(patch): + if is_boundary and len(data_qubits) != 2: + msg = ( + "SZZ boundary-first expects boundary stabilizers to have weight 2; " + f"{stabilizer_type}{stabilizer_index} has weight {len(data_qubits)}" + ) + raise ValueError(msg) + for touch_index, data_qubit in enumerate(data_qubits): + sign = -1 if is_boundary and touch_index == 0 else 1 + signs.append( + SzzTouchSign( + stabilizer_type=stabilizer_type, + stabilizer_index=stabilizer_index, + data_qubit=data_qubit, + sign=sign, + ), + ) + return tuple(sorted(signs)) + + def _validate_szz_sign_vector( patch: SurfacePatch, signs: tuple[SzzTouchSign, ...], @@ -397,6 +426,25 @@ def _default_szz_residual_plan(patch: SurfacePatch) -> SzzResidualPlan: return _validate_szz_sign_vector(patch, _default_szz_sign_vector(patch)) +def _szz_residual_plan_for_check_plan( + patch: SurfacePatch, + resolved_plan: ResolvedSurfaceCheckPlan, +) -> SzzResidualPlan: + """Return the concrete SZZ residual plan for a resolved check plan.""" + if resolved_plan.interaction_basis != "szz": + msg = f"SZZ residual plans require interaction_basis='szz', got {resolved_plan.interaction_basis!r}" + raise ValueError(msg) + + pattern = str(resolved_plan.synthesis_identity["szz_phase_pattern"]) + if pattern == "standard": + return _default_szz_residual_plan(patch) + if pattern == "boundary-first": + return _validate_szz_sign_vector(patch, _boundary_first_szz_sign_vector(patch)) + + msg = f"unsupported SZZ phase pattern {pattern!r} for check_plan={resolved_plan.plan_id!r}" + raise NotImplementedError(msg) + + def _propagate_szz_frame_bits(x_a: bool, z_a: bool, x_b: bool, z_b: bool) -> tuple[bool, bool, bool, bool]: """Propagate local Pauli-frame bits through uncompensated SZZ/SZZdg.""" common = x_a ^ x_b @@ -905,7 +953,7 @@ def emit_gate_local_twirl_layer( basis, allocation, cnot_rounds, - _default_szz_residual_plan(patch), + _szz_residual_plan_for_check_plan(patch, resolved_plan), ), allocation, ) 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 365afde70..8b6acba73 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -21,7 +21,7 @@ def test_check_plan_default_resolves_to_cx_metadata() -> None: plan = resolve_surface_check_plan() - assert surface_check_plan_ids() == ("cx_standard_v1", "szz_current_v1") + assert surface_check_plan_ids() == ("cx_standard_v1", "szz_boundary_first_v1", "szz_current_v1") assert default_surface_check_plan_id() == "cx_standard_v1" assert default_surface_check_plan_id("SZZ") == "szz_current_v1" assert plan.plan_id == "cx_standard_v1" @@ -56,6 +56,23 @@ def test_check_plan_is_source_of_truth_for_basis() -> None: } +def test_boundary_first_szz_check_plan_resolves_to_concrete_synthesis() -> None: + from pecos.qec.surface._check_plan import resolve_surface_check_plan + + plan = resolve_surface_check_plan(check_plan="szz_boundary_first_v1") + + assert plan.plan_id == "szz_boundary_first_v1" + assert plan.interaction_basis == "szz" + assert plan.synthesis_identity == { + "family": "szz", + "szz_phase_pattern": "boundary-first", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + } + assert plan.semantic_content["x_check"]["sign_policy"] == "boundary_first_szz_sign_vector_v1" + assert plan.semantic_content["z_check"]["sign_policy"] == "boundary_first_szz_sign_vector_v1" + + def test_current_renderer_rejects_unimplemented_plan_semantics() -> None: from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan @@ -227,6 +244,57 @@ def test_direct_surface_renderers_accept_check_plan_as_source_of_truth() -> None assert "Check plan: szz_current_v1" in guppy_source +def test_boundary_first_szz_check_plan_changes_source_gates_not_metadata() -> None: + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.circuit_builder import ( + OpType, + build_surface_code_circuit, + generate_guppy_from_patch, + generate_tick_circuit_from_patch, + ) + + patch = SurfacePatch.create(distance=3) + + current_ops, _ = build_surface_code_circuit( + patch, + num_rounds=1, + check_plan="szz_current_v1", + ) + boundary_first_ops, _ = build_surface_code_circuit( + patch, + num_rounds=1, + check_plan="szz_boundary_first_v1", + ) + + def szz_gate_signature(ops: object) -> list[tuple[str, tuple[int, ...], str]]: + return [ + (op.op_type.name, tuple(op.qubits), op.label) + for op in ops + if op.op_type in {OpType.SZZ, OpType.SZZDG} + ] + + assert szz_gate_signature(boundary_first_ops) != szz_gate_signature(current_ops) + assert sum(op.op_type == OpType.SZZDG for op in boundary_first_ops) == sum( + op.op_type == OpType.SZZDG for op in current_ops + ) + + current_tick = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + check_plan="szz_current_v1", + ) + boundary_first_tick = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + check_plan="szz_boundary_first_v1", + ) + assert boundary_first_tick.get_meta("detectors") == current_tick.get_meta("detectors") + assert boundary_first_tick.get_meta("observables") == current_tick.get_meta("observables") + + source = generate_guppy_from_patch(patch, check_plan="szz_boundary_first_v1") + assert "Check plan: szz_boundary_first_v1" in source + + def test_direct_surface_renderers_reject_plan_basis_mismatch() -> None: from pecos.qec.surface import SurfacePatch from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch 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 58f03e1ed..2e5a1eda3 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 @@ -286,8 +286,19 @@ def test_szz_builder_emits_szz_template_and_rejects_stage_later_features() -> No ) assert data_compensation_count == szz_gate_count - with pytest.raises(ValueError, match="does not yet support constrained ancilla budgets"): - build_surface_code_circuit(patch, num_rounds=1, ancilla_budget=1, interaction_basis="szz") + constrained_szz_ops, _ = build_surface_code_circuit( + patch, + num_rounds=1, + ancilla_budget=1, + interaction_basis="szz", + ) + constrained_szz_gate_count = sum(op.op_type in {OpType.SZZ, OpType.SZZDG} for op in constrained_szz_ops) + constrained_compensation_count = sum( + op.label.startswith("szz_touch_comp:") and op.op_type in {OpType.SX, OpType.SXDG, OpType.SZ, OpType.SZDG} + for op in constrained_szz_ops + ) + assert constrained_szz_gate_count == szz_gate_count + assert constrained_compensation_count == constrained_szz_gate_count with pytest.raises(ValueError, match="twirl integration is staged later"): build_surface_code_circuit( From dba699fd0e39965e95629414f35fbcae15aeb432 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 15 Jun 2026 22:45:49 -0600 Subject: [PATCH 115/150] Use trace-friendly backend for Guppy operation capture --- python/quantum-pecos/src/pecos/qec/surface/decode.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 92e69bd3e..de5ae6ba7 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1341,11 +1341,16 @@ def capture_guppy_operation_trace( ) -> list[dict[str, Any]]: """Capture a Guppy/QIS program's Selene operation trace chunks.""" import pecos + import pecos_rslib + # Trace capture records the runtime-lowered QIS operations and result tags; + # DEM validation/fault propagation happens after replay. Use a permissive + # trace backend instead of asking stabilizer evolution to validate every + # runtime-emitted rotation while we are only collecting provenance. sim_builder = ( pecos.sim(program) .classical(pecos.selene_engine(runtime)) - .quantum(pecos.stabilizer()) + .quantum(pecos_rslib.coin_toss()) .qubits(num_qubits) .seed(seed) ) From 7934d1ad9be55d53d5b28956ad3b4982906a5752 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Tue, 16 Jun 2026 09:09:14 -0600 Subject: [PATCH 116/150] Test trace-friendly Guppy operation capture --- .../tests/qec/test_from_guppy_dem.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 17d1ffc59..93af19a64 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -28,7 +28,9 @@ _replay_qis_trace_into_tick_circuit, _surface_runtime_measurement_remap_from_result_traces, _validate_result_tag_remap_against_traced_measurements, + capture_guppy_operation_trace, generate_circuit_level_dem_from_builder, + named_result_traces_from_operation_trace, trace_guppy_into_tick_circuit_with_result_traces, ) @@ -53,6 +55,20 @@ def _measurement_feedback() -> None: result("b1", b1) +def test_operation_trace_capture_uses_trace_friendly_quantum_backend(monkeypatch: pytest.MonkeyPatch) -> None: + import pecos + + def forbidden_stabilizer(): + raise AssertionError("trace capture should not validate operations with stabilizer evolution") + + monkeypatch.setattr(pecos, "stabilizer", forbidden_stabilizer) + + chunks = capture_guppy_operation_trace(_single_measurement, num_qubits=1, seed=0) + result_names = [trace.get("name") for trace in named_result_traces_from_operation_trace(chunks)] + + assert "m" in result_names + + def _dem_text(*, detectors_json: str = "[]", observables_json: str = "[]") -> str: dem = DetectorErrorModel.from_guppy( _single_measurement, From ba9f4c9e0ff9576dd5b7991256ed19093b8736f3 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Tue, 16 Jun 2026 18:24:05 -0600 Subject: [PATCH 117/150] Add uniform Clifford frame SZZ DEM plumbing --- .../src/pecos/qec/surface/__init__.py | 19 ++ .../qec/surface/_clifford_deformation.py | 277 ++++++++++++++++++ .../src/pecos/qec/surface/circuit_builder.py | 186 ++++++++++-- .../src/pecos/qec/surface/decode.py | 40 ++- .../qec/surface/test_clifford_deformation.py | 159 ++++++++++ 5 files changed, 660 insertions(+), 21 deletions(-) create mode 100644 python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py create mode 100644 python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py diff --git a/python/quantum-pecos/src/pecos/qec/surface/__init__.py b/python/quantum-pecos/src/pecos/qec/surface/__init__.py index 925d93684..4aba263df 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/surface/__init__.py @@ -18,6 +18,16 @@ """ # Circuit generation from geometry (unified abstraction) +from pecos.qec.surface._clifford_deformation import ( + LocalCliffordFrame, + ResolvedPauliCheck, + ResolvedPauliLogical, + ResolvedSurfaceCliffordFrame, + SignedPauli, + global_surface_frame, + normalize_surface_frame_policy, + resolve_surface_clifford_frame, +) from pecos.qec.surface._detection_events import extract_detection_events_and_observables from pecos.qec.surface._twirl_config import GuppyRngMaskConfig, TwirlConfig from pecos.qec.surface.circuit_builder import ( @@ -117,6 +127,15 @@ # Twirling config (Pauli-frame randomization) "GuppyRngMaskConfig", "TwirlConfig", + # Clifford-deformed surface-code metadata + "LocalCliffordFrame", + "ResolvedPauliCheck", + "ResolvedPauliLogical", + "ResolvedSurfaceCliffordFrame", + "SignedPauli", + "global_surface_frame", + "normalize_surface_frame_policy", + "resolve_surface_clifford_frame", # Rotated lattice (most common, default) "compute_rotated_x_stabilizers", "compute_rotated_z_stabilizers", diff --git a/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py b/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py new file mode 100644 index 000000000..8f1b0051b --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py @@ -0,0 +1,277 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Surface-code Clifford-deformation metadata. + +This module resolves source-level surface-code checks and logical operators +through a concrete local Clifford frame. It intentionally stops before circuit +emission: renderers should consume the resolved Pauli checks instead of +guessing whether a frame can be represented by the legacy CSS X/Z helper. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from collections.abc import Sequence + + from pecos.qec.surface.patch import Stabilizer, SurfacePatch + +PauliAxis = Literal["X", "Y", "Z"] +SurfaceFramePolicy = Literal[ + "identity", + "global_h", + "global_axis_cycle_f", + "global_axis_cycle_f2", +] + +_SUPPORTED_GLOBAL_FRAME_POLICIES = frozenset( + { + "identity", + "global_h", + "global_axis_cycle_f", + "global_axis_cycle_f2", + }, +) + + +@dataclass(frozen=True, order=True) +class SignedPauli: + """One signed single-qubit Pauli image.""" + + axis: PauliAxis + sign: int = 1 + + def __post_init__(self) -> None: + axis = str(self.axis).upper() + if axis not in {"X", "Y", "Z"}: + msg = f"Pauli axis must be 'X', 'Y', or 'Z', got {self.axis!r}" + raise ValueError(msg) + sign = int(self.sign) + if sign not in {-1, 1}: + msg = f"Pauli sign must be +/-1, got {self.sign!r}" + raise ValueError(msg) + object.__setattr__(self, "axis", axis) + object.__setattr__(self, "sign", sign) + + def label(self) -> str: + """Return a compact signed label such as ``X`` or ``-Y``.""" + return self.axis if self.sign > 0 else f"-{self.axis}" + + +@dataclass(frozen=True) +class LocalCliffordFrame: + """Images of source X and Z under one local Clifford frame.""" + + x_image: SignedPauli + z_image: SignedPauli + + def image(self, source_axis: str) -> SignedPauli: + """Return the signed physical Pauli image for source ``X`` or ``Z``.""" + axis = source_axis.upper() + if axis == "X": + return self.x_image + if axis == "Z": + return self.z_image + msg = f"source_axis must be 'X' or 'Z', got {source_axis!r}" + raise ValueError(msg) + + +@dataclass(frozen=True) +class ResolvedPauliCheck: + """A source stabilizer resolved to concrete physical Pauli axes.""" + + source_kind: PauliAxis + stabilizer_index: int + data_qubits: tuple[int, ...] + paulis: tuple[SignedPauli, ...] + is_boundary: bool + + @property + def axes(self) -> tuple[PauliAxis, ...]: + """Physical Pauli axes in data-qubit order.""" + return tuple(pauli.axis for pauli in self.paulis) + + @property + def signs(self) -> tuple[int, ...]: + """Physical Pauli signs in data-qubit order.""" + return tuple(pauli.sign for pauli in self.paulis) + + @property + def is_uniform_axis(self) -> bool: + """Whether every data qubit is checked in the same physical axis.""" + return len(set(self.axes)) <= 1 + + @property + def uniform_axis(self) -> PauliAxis | None: + """Return the uniform physical axis, or ``None`` for mixed checks.""" + if not self.is_uniform_axis or not self.axes: + return None + return self.axes[0] + + @property + def requires_deformed_check_synthesis(self) -> bool: + """Whether the legacy CSS helper cannot synthesize this check.""" + return self.uniform_axis not in {"X", "Z"} or not self.is_uniform_axis + + +@dataclass(frozen=True) +class ResolvedPauliLogical: + """A source logical operator resolved to concrete physical Pauli axes.""" + + source_kind: PauliAxis + data_qubits: tuple[int, ...] + paulis: tuple[SignedPauli, ...] + + @property + def axes(self) -> tuple[PauliAxis, ...]: + """Physical Pauli axes in data-qubit order.""" + return tuple(pauli.axis for pauli in self.paulis) + + @property + def is_uniform_axis(self) -> bool: + """Whether every data qubit is measured in the same physical axis.""" + return len(set(self.axes)) <= 1 + + @property + def uniform_axis(self) -> PauliAxis | None: + """Return the uniform physical axis, or ``None`` for mixed logicals.""" + if not self.is_uniform_axis or not self.axes: + return None + return self.axes[0] + + +@dataclass(frozen=True) +class ResolvedSurfaceCliffordFrame: + """Resolved source checks/logicals for one concrete surface-code frame.""" + + policy: str + data_frames: tuple[LocalCliffordFrame, ...] + x_checks: tuple[ResolvedPauliCheck, ...] + z_checks: tuple[ResolvedPauliCheck, ...] + logical_x: ResolvedPauliLogical + logical_z: ResolvedPauliLogical + + @property + def checks(self) -> tuple[ResolvedPauliCheck, ...]: + """All resolved checks in source X-then-Z order.""" + return (*self.x_checks, *self.z_checks) + + @property + def requires_deformed_check_synthesis(self) -> bool: + """Whether any check requires the generic deformed-check path.""" + return any(check.requires_deformed_check_synthesis for check in self.checks) + + def css_physical_memory_basis(self, source_basis: str) -> PauliAxis: + """Return the physical X/Z basis if the CSS helper can represent this frame. + + This is intentionally stricter than asking only where the logical memory + axis maps. A global ``F`` frame maps source-Z memory to physical-X + readout, but it also maps source-X stabilizers to physical-Y checks. + Such a circuit is not representable by the current CSS helper and must + use a deformed-check renderer. + """ + basis = source_basis.upper() + if basis == "X": + logical_axis = self.logical_x.uniform_axis + elif basis == "Z": + logical_axis = self.logical_z.uniform_axis + else: + msg = f"source_basis must be 'X' or 'Z', got {source_basis!r}" + raise ValueError(msg) + + if self.requires_deformed_check_synthesis: + msg = ( + f"Frame policy {self.policy!r} requires deformed check synthesis " + "and cannot be represented by the legacy CSS surface helper." + ) + raise NotImplementedError(msg) + if logical_axis not in {"X", "Z"}: + msg = ( + f"Frame policy {self.policy!r} maps source {basis}-memory to " + f"physical {logical_axis}; the CSS helper supports only X/Z." + ) + raise NotImplementedError(msg) + return logical_axis + + +def normalize_surface_frame_policy(policy: str) -> str: + """Normalize and validate a named surface Clifford frame policy.""" + normalized = str(policy).lower().replace("-", "_") + if normalized not in _SUPPORTED_GLOBAL_FRAME_POLICIES: + msg = ( + f"unknown surface Clifford frame policy {policy!r}; expected one of " + f"{sorted(_SUPPORTED_GLOBAL_FRAME_POLICIES)}" + ) + raise ValueError(msg) + return normalized + + +def global_surface_frame(policy: str, num_data: int) -> tuple[LocalCliffordFrame, ...]: + """Return one of the supported parameter-free global frame maps.""" + if num_data < 0: + msg = f"num_data must be non-negative, got {num_data}" + raise ValueError(msg) + normalized = normalize_surface_frame_policy(policy) + frame = _global_frame_element(normalized) + return tuple(frame for _ in range(num_data)) + + +def resolve_surface_clifford_frame( + patch: SurfacePatch, + *, + policy: str = "identity", + data_frames: Sequence[LocalCliffordFrame] | None = None, +) -> ResolvedSurfaceCliffordFrame: + """Resolve source surface checks/logicals through a local Clifford frame.""" + normalized = normalize_surface_frame_policy(policy) + frames = tuple(data_frames) if data_frames is not None else global_surface_frame(normalized, patch.num_data) + if len(frames) != patch.num_data: + msg = f"data frame length {len(frames)} does not match patch.num_data={patch.num_data}" + raise ValueError(msg) + + def resolve_check(stabilizer: Stabilizer) -> ResolvedPauliCheck: + return ResolvedPauliCheck( + source_kind=stabilizer.stab_type, # type: ignore[arg-type] + stabilizer_index=stabilizer.index, + data_qubits=tuple(stabilizer.data_qubits), + paulis=tuple(frames[q].image(stabilizer.stab_type) for q in stabilizer.data_qubits), + is_boundary=stabilizer.is_boundary, + ) + + geom = patch.geometry + if geom.logical_x is None or geom.logical_z is None: + msg = "Surface patch must have both X and Z logical operators" + raise ValueError(msg) + + return ResolvedSurfaceCliffordFrame( + policy=normalized, + data_frames=frames, + x_checks=tuple(resolve_check(stabilizer) for stabilizer in patch.x_stabilizers), + z_checks=tuple(resolve_check(stabilizer) for stabilizer in patch.z_stabilizers), + logical_x=ResolvedPauliLogical( + source_kind="X", + data_qubits=tuple(geom.logical_x.data_qubits), + paulis=tuple(frames[q].image("X") for q in geom.logical_x.data_qubits), + ), + logical_z=ResolvedPauliLogical( + source_kind="Z", + data_qubits=tuple(geom.logical_z.data_qubits), + paulis=tuple(frames[q].image("Z") for q in geom.logical_z.data_qubits), + ), + ) + + +def _global_frame_element(policy: str) -> LocalCliffordFrame: + if policy == "identity": + return LocalCliffordFrame(SignedPauli("X"), SignedPauli("Z")) + if policy == "global_h": + return LocalCliffordFrame(SignedPauli("Z"), SignedPauli("X")) + if policy == "global_axis_cycle_f": + return LocalCliffordFrame(SignedPauli("Y"), SignedPauli("X")) + if policy == "global_axis_cycle_f2": + return LocalCliffordFrame(SignedPauli("Z"), SignedPauli("Y")) + msg = f"unknown surface Clifford frame policy {policy!r}" + raise ValueError(msg) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 9524b51b3..bc219d43b 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -36,6 +36,9 @@ normalize_ancilla_budget as _normalize_ancilla_budget, ) from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan +from pecos.qec.surface._clifford_deformation import ( + resolve_surface_clifford_frame, +) # Stabilizer geometry helpers live in the low-level patch module (single # source of truth). Only the two used by the circuit renderer are imported @@ -48,6 +51,11 @@ if TYPE_CHECKING: from pecos.qec.surface._check_plan import ResolvedSurfaceCheckPlan + from pecos.qec.surface._clifford_deformation import ( + PauliAxis, + ResolvedPauliCheck, + ResolvedSurfaceCliffordFrame, + ) from pecos.qec.surface._twirl_config import TwirlConfig from pecos.qec.surface.patch import ( LogicalDescriptor, @@ -445,6 +453,58 @@ def _szz_residual_plan_for_check_plan( raise NotImplementedError(msg) +def _resolve_szz_clifford_frame_for_builder( + patch: SurfacePatch, + *, + interaction_basis: str, + clifford_frame_policy: str | None, +) -> ResolvedSurfaceCliffordFrame | None: + """Resolve an optional source-level Clifford frame for SZZ rendering.""" + if clifford_frame_policy is None: + return None + if interaction_basis != "szz": + msg = "clifford_frame_policy currently requires interaction_basis='szz'" + raise NotImplementedError(msg) + resolved_frame = resolve_surface_clifford_frame(patch, policy=clifford_frame_policy) + unsupported = [check for check in resolved_frame.checks if not check.is_uniform_axis] + if unsupported: + preview = ", ".join(f"{check.source_kind}{check.stabilizer_index}:{check.axes}" for check in unsupported[:5]) + suffix = f", ... {len(unsupported) - 5} more" if len(unsupported) > 5 else "" + msg = ( + "clifford_frame_policy currently supports only uniform-axis " + f"stabilizer checks; mixed checks: {preview}{suffix}" + ) + raise NotImplementedError(msg) + return resolved_frame + + +def _szz_memory_physical_axis( + basis: str, + resolved_clifford_frame: ResolvedSurfaceCliffordFrame | None, +) -> PauliAxis: + """Return the physical data-measurement axis for a source memory basis.""" + source_basis = basis.upper() + if source_basis not in {"X", "Z"}: + msg = f"basis must be 'X' or 'Z', got {basis!r}" + raise ValueError(msg) + if resolved_clifford_frame is None: + return source_basis # type: ignore[return-value] + + logical = ( + resolved_clifford_frame.logical_x + if source_basis == "X" + else resolved_clifford_frame.logical_z + ) + if not logical.is_uniform_axis or logical.uniform_axis is None: + msg = ( + f"clifford frame policy {resolved_clifford_frame.policy!r} maps " + f"source {source_basis}-memory to a mixed logical measurement " + f"axis {logical.axes}; mixed final readout is not implemented yet" + ) + raise NotImplementedError(msg) + return logical.uniform_axis + + def _propagate_szz_frame_bits(x_a: bool, z_a: bool, x_b: bool, z_b: bool) -> tuple[bool, bool, bool, bool]: """Propagate local Pauli-frame bits through uncompensated SZZ/SZZdg.""" common = x_a ^ x_b @@ -804,6 +864,7 @@ def build_surface_code_circuit( twirl: TwirlConfig | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> tuple[list[SurfaceCircuitStep], QubitAllocation]: """Build abstract circuit operations for a surface code memory experiment. @@ -833,6 +894,9 @@ def build_surface_code_circuit( check_plan: Named surface check-plan preset. This is the source of truth when supplied; ``interaction_basis`` must agree if also supplied. + clifford_frame_policy: Optional source-level Clifford-deformation + policy. Currently supported only by the SZZ renderer for global + uniform-axis frames. Returns: Tuple of (operations list, qubit allocation info) @@ -854,6 +918,11 @@ def build_surface_code_circuit( context="abstract surface-code circuit generation", ) interaction_basis = _normalize_interaction_basis(resolved_plan.interaction_basis) + resolved_clifford_frame = _resolve_szz_clifford_frame_for_builder( + patch, + interaction_basis=interaction_basis, + clifford_frame_policy=clifford_frame_policy, + ) if twirl is not None: twirl.validate_runtime_supported() twirl_site_schedule = None if twirl is None else twirl.site_schedule @@ -954,6 +1023,7 @@ def emit_gate_local_twirl_layer( allocation, cnot_rounds, _szz_residual_plan_for_check_plan(patch, resolved_plan), + resolved_clifford_frame, ), allocation, ) @@ -1233,28 +1303,27 @@ def _build_surface_code_circuit_szz( allocation: QubitAllocation, cnot_rounds: list[list[tuple[str, int, int]]], residual_plan: SzzResidualPlan, + resolved_clifford_frame: ResolvedSurfaceCliffordFrame | None = None, ) -> list[SurfaceCircuitStep]: """Build the abstract SZZ/SZZdg surface-memory template.""" geom = patch.geometry num_data = geom.num_data + check_by_key: dict[tuple[str, int], ResolvedPauliCheck] = {} + if resolved_clifford_frame is not None: + check_by_key.update({("X", check.stabilizer_index): check for check in resolved_clifford_frame.x_checks}) + check_by_key.update({("Z", check.stabilizer_index): check for check in resolved_clifford_frame.z_checks}) sign_by_touch = { (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): entry.sign for entry in residual_plan.signs } - data_compensation_by_touch = { - (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): { - ("X", 1): OpType.SXDG, - ("X", -1): OpType.SX, - ("Z", 1): OpType.SZDG, - ("Z", -1): OpType.SZ, - }[(entry.stabilizer_type, entry.sign)] - for entry in residual_plan.signs - } gate_name_by_type = { OpType.SX: "SX", OpType.SXDG: "SXDG", + OpType.SY: "SY", + OpType.SYDG: "SYDG", OpType.SZ: "SZ", OpType.SZDG: "SZDG", } + basis_axis = _szz_memory_physical_axis(basis, resolved_clifford_frame) def data_q(i: int) -> int: return allocation.data_qubits[i] @@ -1268,6 +1337,55 @@ def z_anc_q(stab_idx: int) -> int: def anc_q(stabilizer_type: str, stab_idx: int) -> int: return x_anc_q(stab_idx) if stabilizer_type == "X" else z_anc_q(stab_idx) + def physical_axis_for_touch(stabilizer_type: str, stab_idx: int, data_idx: int) -> PauliAxis: + if resolved_clifford_frame is None: + return "X" if stabilizer_type == "X" else "Z" + check = check_by_key[(stabilizer_type, stab_idx)] + try: + offset = check.data_qubits.index(data_idx) + except ValueError as exc: + msg = f"data qubit {data_idx} is not in resolved check {stabilizer_type}{stab_idx}" + raise ValueError(msg) from exc + return check.paulis[offset].axis + + def append_axis_rotation_to_z( + target_ops: list[SurfaceCircuitStep], + axis: PauliAxis, + qubit: int, + label_prefix: str, + ) -> None: + gate = { + "X": OpType.H, + "Y": OpType.SXDG, + "Z": None, + }[axis] + if gate is not None: + target_ops.append(SurfaceCircuitStep(gate, [qubit], f"{label_prefix}:to_z")) + + def append_axis_rotation_from_z( + target_ops: list[SurfaceCircuitStep], + axis: PauliAxis, + qubit: int, + label_prefix: str, + ) -> None: + gate = { + "X": OpType.H, + "Y": OpType.SX, + "Z": None, + }[axis] + if gate is not None: + target_ops.append(SurfaceCircuitStep(gate, [qubit], f"{label_prefix}:from_z")) + + def szz_touch_compensation(axis: PauliAxis, sign: int) -> OpType: + return { + ("X", 1): OpType.SXDG, + ("X", -1): OpType.SX, + ("Y", 1): OpType.SYDG, + ("Y", -1): OpType.SY, + ("Z", 1): OpType.SZDG, + ("Z", -1): OpType.SZ, + }[(axis, sign)] + def stabilizer_batches_for(selected_type: str | None = None) -> list[list[tuple[str, int]]]: """Return the same ancilla-reuse batches used by the CX template.""" total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) @@ -1333,9 +1451,14 @@ def append_szz_layer( layer_gates: list[tuple[str, int, int]], ) -> None: target_ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"SZZ round {rnd_idx + 1}")) - x_touches = [(stab_idx, data_idx) for stab_type, stab_idx, data_idx in layer_gates if stab_type == "X"] - for _stab_idx, data_idx in x_touches: - target_ops.append(SurfaceCircuitStep(OpType.H, [data_q(data_idx)], f"szz_x_touch_pre_d{data_idx}")) + for stab_type, stab_idx, data_idx in layer_gates: + axis = physical_axis_for_touch(stab_type, stab_idx, data_idx) + append_axis_rotation_to_z( + target_ops, + axis, + data_q(data_idx), + f"szz_{axis.lower()}_touch_pre:{stab_type}{stab_idx}:d{data_idx}", + ) for stab_type, stab_idx, data_idx in layer_gates: sign = sign_by_touch[(stab_type, stab_idx, data_idx)] @@ -1348,16 +1471,24 @@ def append_szz_layer( ), ) - for _stab_idx, data_idx in x_touches: - target_ops.append(SurfaceCircuitStep(OpType.H, [data_q(data_idx)], f"szz_x_touch_post_d{data_idx}")) + for stab_type, stab_idx, data_idx in layer_gates: + axis = physical_axis_for_touch(stab_type, stab_idx, data_idx) + append_axis_rotation_from_z( + target_ops, + axis, + data_q(data_idx), + f"szz_{axis.lower()}_touch_post:{stab_type}{stab_idx}:d{data_idx}", + ) for stab_type, stab_idx, data_idx in layer_gates: - compensation = data_compensation_by_touch[(stab_type, stab_idx, data_idx)] + axis = physical_axis_for_touch(stab_type, stab_idx, data_idx) + sign = sign_by_touch[(stab_type, stab_idx, data_idx)] + compensation = szz_touch_compensation(axis, sign) target_ops.append( SurfaceCircuitStep( compensation, [data_q(data_idx)], - f"szz_touch_comp:{gate_name_by_type[compensation]}:{stab_type}{stab_idx}:d{data_idx}", + f"szz_touch_comp:{gate_name_by_type[compensation]}:{axis}:{stab_type}{stab_idx}:d{data_idx}", ), ) @@ -1368,8 +1499,13 @@ def append_szz_layer( # ========================================================================= ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"prep_{basis.lower()}_basis")) ops.extend(SurfaceCircuitStep(OpType.ALLOC, [data_q(i)], f"data[{i}]") for i in range(num_data)) - if basis.upper() == "X": - ops.extend(SurfaceCircuitStep(OpType.H, [data_q(i)]) for i in range(num_data)) + for i in range(num_data): + append_axis_rotation_to_z( + ops, + basis_axis, + data_q(i), + f"prep_{basis_axis.lower()}_basis_d{i}", + ) ops.append(SurfaceCircuitStep(OpType.TICK)) # ========================================================================= @@ -1429,8 +1565,13 @@ def append_szz_layer( # measure_z_basis / measure_x_basis # ========================================================================= ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"measure_{basis.lower()}_basis")) - if basis.upper() == "X": - ops.extend(SurfaceCircuitStep(OpType.H, [data_q(i)]) for i in range(num_data)) + for i in range(num_data): + append_axis_rotation_from_z( + ops, + basis_axis, + data_q(i), + f"measure_{basis_axis.lower()}_basis_d{i}", + ) ops.extend(SurfaceCircuitStep(OpType.MEASURE, [data_q(i)], f"final[{i}]") for i in range(num_data)) _analyze_szz_forward_flow(ops) @@ -2738,6 +2879,7 @@ def generate_tick_circuit_from_patch( interaction_basis: str | None = None, check_plan: str | None = None, szz_physical_prefixes: bool = False, + clifford_frame_policy: str | None = None, ) -> TickCircuit: """Generate PECOS TickCircuit from SurfacePatch. @@ -2769,6 +2911,9 @@ def generate_tick_circuit_from_patch( check_plan: Named surface check-plan preset. szz_physical_prefixes: If true, lower the abstract SZZ single-qubit scaffold into physical prefix pulses for native DEM analysis. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ generation. Currently supports global uniform-axis + frames. Returns: PECOS TickCircuit instance @@ -2793,6 +2938,7 @@ def generate_tick_circuit_from_patch( twirl=twirl, interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, + clifford_frame_policy=clifford_frame_policy, ) if szz_physical_prefixes: ops = _lower_szz_forward_flow_ops(ops) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index de5ae6ba7..a510b4a7a 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1340,9 +1340,10 @@ def capture_guppy_operation_trace( runtime: object | None = None, ) -> list[dict[str, Any]]: """Capture a Guppy/QIS program's Selene operation trace chunks.""" - import pecos import pecos_rslib + import pecos + # Trace capture records the runtime-lowered QIS operations and result tags; # DEM validation/fault propagation happens after replay. Use a permissive # trace backend instead of asking stabilizer evolution to validate every @@ -1503,6 +1504,7 @@ def _build_surface_tick_circuit_for_native_model( interaction_basis: str | None = None, check_plan: str | None = None, szz_physical_prefixes: bool = False, + clifford_frame_policy: str | None = None, ) -> Any: """Build the TickCircuit used by the native DEM and sampler paths.""" from pecos.qec.surface.circuit_builder import _normalize_interaction_basis, generate_tick_circuit_from_patch @@ -1521,6 +1523,13 @@ def _build_surface_tick_circuit_for_native_model( if szz_physical_prefixes and (interaction_basis != "szz" or circuit_source != "abstract"): msg = "SZZ physical-prefix lowering requires interaction_basis='szz' and circuit_source='abstract'" raise ValueError(msg) + if clifford_frame_policy is not None and circuit_source != "abstract": + msg = ( + "clifford_frame_policy currently requires circuit_source='abstract'; " + "traced-QIS support must generate the Guppy program from the same " + "concrete Clifford-deformed checks before binding result-tag metadata" + ) + raise NotImplementedError(msg) abstract_tc = generate_tick_circuit_from_patch( patch, @@ -1532,6 +1541,7 @@ def _build_surface_tick_circuit_for_native_model( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, + clifford_frame_policy=clifford_frame_policy, ) if circuit_source == "abstract": @@ -1595,6 +1605,7 @@ def build_memory_circuit( twirl: TwirlConfig | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> Any: """Build the standard surface-code memory ``TickCircuit``. @@ -1619,6 +1630,8 @@ def build_memory_circuit( the circuit and can lose canonical result-id provenance. interaction_basis: Surface-memory two-qubit interaction basis. check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for native abstract SZZ generation. Returns: A Rust-backed ``TickCircuit`` with detector and observable metadata. @@ -1653,6 +1666,7 @@ def build_memory_circuit( twirl=twirl, interaction_basis=interaction_basis, check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, ) @@ -1872,6 +1886,7 @@ def _surface_native_topology( interaction_basis: str | None = None, check_plan: str | None = None, szz_physical_prefixes: bool = False, + clifford_frame_policy: str | None = None, ) -> _CachedNativeSurfaceTopology: """Build topology-only native analysis shared across noise parameters.""" import json @@ -1902,6 +1917,7 @@ def _surface_native_topology( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, + clifford_frame_policy=clifford_frame_policy, ) if circuit_source == "traced_qis": # Keep this surface helper aligned with DetectorErrorModel.from_guppy: @@ -1975,6 +1991,7 @@ def _cached_surface_native_topology( check_plan: str | None = None, szz_physical_prefixes: bool = False, resolved_check_plan_hash: str = "", + clifford_frame_policy: str | None = None, ) -> _CachedNativeSurfaceTopology: """Cache topology-only native analysis shared across noise parameters.""" _ = resolved_check_plan_hash @@ -1989,6 +2006,7 @@ def _cached_surface_native_topology( interaction_basis=interaction_basis, check_plan=check_plan, szz_physical_prefixes=szz_physical_prefixes, + clifford_frame_policy=clifford_frame_policy, ) @@ -2074,6 +2092,7 @@ def _cached_surface_native_dem_string( interaction_basis: str = "cx", check_plan: str | None = None, resolved_check_plan_hash: str = "", + clifford_frame_policy: str | None = None, ) -> str: """Cache native DEM strings across callers for one topology + noise tuple.""" _ = resolved_check_plan_hash @@ -2111,6 +2130,7 @@ def _cached_surface_native_dem_string( check_plan=check_plan, szz_physical_prefixes=szz_physical_prefixes, resolved_check_plan_hash=resolved_check_plan_hash, + clifford_frame_policy=clifford_frame_policy, ) return _dem_string_from_cached_surface_topology( topology, @@ -2224,6 +2244,7 @@ def generate_circuit_level_dem_from_builder( twirl: TwirlConfig | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> str: """Generate circuit-level DEM using PECOS native fault propagation. @@ -2275,6 +2296,10 @@ def generate_circuit_level_dem_from_builder( model: Z/SZ/SZdg frame updates are p1-free. That is a device assumption keyed from the resolved plan, not a general claim about CX hardware. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for native abstract SZZ generation. Traced-QIS support + requires Guppy to emit the same concrete deformed checks and is + rejected until that path is implemented. Returns: DEM string in standard format @@ -2308,6 +2333,7 @@ def generate_circuit_level_dem_from_builder( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, + clifford_frame_policy=clifford_frame_policy, ) return _dem_string_from_cached_surface_topology( topology, @@ -2338,6 +2364,7 @@ def generate_circuit_level_dem_from_builder( "interaction_basis": interaction_basis, "check_plan": resolved_plan.plan_id, "resolved_check_plan_hash": resolved_plan.resolved_hash, + "clifford_frame_policy": clifford_frame_policy, } if dem_decomposition != "source_graphlike": cache_kwargs["dem_decomposition"] = dem_decomposition @@ -3765,6 +3792,7 @@ def surface_code_memory( ancilla_budget: int | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> SimulationResult: """Run the recommended native surface-code memory workflow. @@ -3793,6 +3821,8 @@ def surface_code_memory( check_plan: Named surface check-plan preset. This is the source of truth when supplied; ``interaction_basis`` must agree if also supplied. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for native abstract SZZ generation. Returns: ``SimulationResult`` with logical and raw error counts/rates. @@ -3832,6 +3862,7 @@ def surface_code_memory( circuit_source=circuit_source, interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, + clifford_frame_policy=clifford_frame_policy, ) batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(shots, seed) num_raw_errors = sum(1 for shot in range(shots) if batch.get_observable_mask(shot) != 0) @@ -4132,6 +4163,7 @@ def build_native_sampler( "mnm", ] = "dem", # "mnm" accepted for compat, mapped to "influence_dem", check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> NativeSampler: """Build a PECOS native sampler for threshold estimation. @@ -4170,6 +4202,8 @@ def build_native_sampler( check_plan: Named surface check-plan preset. This is the source of truth when supplied; ``interaction_basis`` must agree if also supplied. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for native abstract SZZ generation. Returns: NativeSampler that can generate samples for threshold estimation @@ -4202,6 +4236,7 @@ def build_native_sampler( check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, resolved_check_plan_hash=resolved_plan.resolved_hash, + clifford_frame_policy=clifford_frame_policy, ) if sampling_model == "dem": dem_str = _cached_surface_native_dem_string( @@ -4239,6 +4274,7 @@ def build_native_sampler( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, resolved_check_plan_hash=resolved_plan.resolved_hash, + clifford_frame_policy=clifford_frame_policy, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() return NativeSampler( @@ -4274,6 +4310,7 @@ def build_native_sampler_from_dem( twirl: TwirlConfig | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> NativeSampler: """Build a native sampler from a caller-supplied decomposed DEM string. @@ -4300,6 +4337,7 @@ def build_native_sampler_from_dem( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, resolved_check_plan_hash=resolved_plan.resolved_hash, + clifford_frame_policy=clifford_frame_policy, ) sampler = _cached_parsed_dem(decomposed_dem).to_dem_sampler() return NativeSampler( diff --git a/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py new file mode 100644 index 000000000..21195fa36 --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import pytest +from pecos.qec.surface import ( + LocalCliffordFrame, + NoiseModel, + OpType, + SignedPauli, + SurfacePatch, + build_memory_circuit, + build_surface_code_circuit, + generate_tick_circuit_from_patch, + global_surface_frame, + resolve_surface_clifford_frame, +) +from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder + + +def test_identity_frame_resolves_to_css_surface_checks() -> None: + patch = SurfacePatch.create(distance=3) + + resolved = resolve_surface_clifford_frame(patch, policy="identity") + + assert not resolved.requires_deformed_check_synthesis + assert {check.uniform_axis for check in resolved.x_checks} == {"X"} + assert {check.uniform_axis for check in resolved.z_checks} == {"Z"} + assert resolved.logical_x.uniform_axis == "X" + assert resolved.logical_z.uniform_axis == "Z" + assert resolved.css_physical_memory_basis("X") == "X" + assert resolved.css_physical_memory_basis("Z") == "Z" + + +def test_global_h_frame_resolves_to_css_basis_swap() -> None: + patch = SurfacePatch.create(distance=3) + + resolved = resolve_surface_clifford_frame(patch, policy="global-h") + + assert not resolved.requires_deformed_check_synthesis + assert {check.uniform_axis for check in resolved.x_checks} == {"Z"} + assert {check.uniform_axis for check in resolved.z_checks} == {"X"} + assert resolved.logical_x.uniform_axis == "Z" + assert resolved.logical_z.uniform_axis == "X" + assert resolved.css_physical_memory_basis("X") == "Z" + assert resolved.css_physical_memory_basis("Z") == "X" + + +def test_axis_cycle_frames_resolve_but_require_deformed_checks() -> None: + patch = SurfacePatch.create(distance=3) + + resolved_f = resolve_surface_clifford_frame(patch, policy="global_axis_cycle_f") + resolved_f2 = resolve_surface_clifford_frame(patch, policy="global_axis_cycle_f2") + + assert resolved_f.requires_deformed_check_synthesis + assert {check.uniform_axis for check in resolved_f.x_checks} == {"Y"} + assert {check.uniform_axis for check in resolved_f.z_checks} == {"X"} + assert resolved_f.logical_x.uniform_axis == "Y" + assert resolved_f.logical_z.uniform_axis == "X" + + assert resolved_f2.requires_deformed_check_synthesis + assert {check.uniform_axis for check in resolved_f2.x_checks} == {"Z"} + assert {check.uniform_axis for check in resolved_f2.z_checks} == {"Y"} + assert resolved_f2.logical_x.uniform_axis == "Z" + assert resolved_f2.logical_z.uniform_axis == "Y" + + with pytest.raises(NotImplementedError, match="deformed check synthesis"): + resolved_f.css_physical_memory_basis("Z") + with pytest.raises(NotImplementedError, match="deformed check synthesis"): + resolved_f2.css_physical_memory_basis("X") + + +def test_explicit_mixed_local_frame_marks_only_mixed_checks_deformed() -> None: + patch = SurfacePatch.create(distance=3) + frames = list(global_surface_frame("identity", patch.num_data)) + frames[0] = LocalCliffordFrame(SignedPauli("Z"), SignedPauli("X")) + + resolved = resolve_surface_clifford_frame( + patch, + policy="identity", + data_frames=frames, + ) + + assert resolved.requires_deformed_check_synthesis + assert any(check.requires_deformed_check_synthesis for check in resolved.checks) + assert any(not check.is_uniform_axis for check in resolved.checks) + assert any(check.axes == ("Z", "X") for check in resolved.x_checks if len(check.axes) == 2) + + +def test_rejects_frame_map_with_wrong_length() -> None: + patch = SurfacePatch.create(distance=3) + frames = global_surface_frame("identity", patch.num_data - 1) + + with pytest.raises(ValueError, match=r"does not match patch\.num_data"): + resolve_surface_clifford_frame(patch, data_frames=frames) + + +def test_global_axis_cycle_f_emits_uniform_y_szz_check_scaffold() -> None: + patch = SurfacePatch.create(distance=3) + + ops, _allocation = build_surface_code_circuit( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) + + assert any(op.op_type == OpType.SXDG and "szz_y_touch_pre:X" in op.label for op in ops) + assert any(op.op_type == OpType.SX and "szz_y_touch_post:X" in op.label for op in ops) + assert any(op.op_type == OpType.SYDG and "szz_touch_comp:SYDG:Y:X" in op.label for op in ops) + assert any(op.op_type == OpType.H and op.label == "prep_x_basis_d0:to_z" for op in ops) + assert any(op.op_type == OpType.H and op.label == "measure_x_basis_d0:from_z" for op in ops) + assert any(op.op_type == OpType.MEASURE and op.label.startswith("sx") for op in ops) + assert any(op.op_type == OpType.MEASURE and op.label.startswith("sz") for op in ops) + + +def test_global_axis_cycle_f_tick_circuit_keeps_source_detector_metadata() -> None: + patch = SurfacePatch.create(distance=3) + + tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) + + detectors = tick_circuit.get_meta("detectors") + observables = tick_circuit.get_meta("observables") + assert detectors + assert observables + assert tick_circuit.get_meta("basis") == "Z" + + +def test_global_axis_cycle_f_native_abstract_dem_path_accepts_frame_policy() -> None: + patch = SurfacePatch.create(distance=3) + + 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), + basis="Z", + circuit_source="abstract", + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) + + assert isinstance(dem, str) + + +def test_clifford_frame_policy_rejects_traced_qis_until_guppy_matches_frame() -> None: + with pytest.raises(NotImplementedError, match="requires circuit_source='abstract'"): + build_memory_circuit( + distance=3, + rounds=1, + basis="Z", + circuit_source="traced_qis", + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) From 3ada9b170368f2b933b06242c784666f869d3991 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Tue, 16 Jun 2026 18:58:28 -0600 Subject: [PATCH 118/150] Support traced SZZ Clifford-frame surface programs --- .../quantum-pecos/src/pecos/guppy/surface.py | 176 ++++++++++++++++-- .../src/pecos/qec/surface/circuit_builder.py | 10 +- .../src/pecos/qec/surface/decode.py | 22 +-- .../tests/guppy/test_surface_twirl_render.py | 35 ++++ .../qec/surface/test_clifford_deformation.py | 24 ++- 5 files changed, 224 insertions(+), 43 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index d62ea3a54..4aba2eab9 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -34,7 +34,7 @@ class _ModuleState: # Keyed by full patch identity + effective budget (dx, dz, orientation, # rotated, effective_budget) so distinct patch geometries -- e.g. rotated # vs non-rotated at the same dx/dz -- never collide on a cached module. - distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str, str], dict]] = {} + distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str, str, str | None], dict]] = {} _state = _ModuleState() @@ -142,6 +142,7 @@ def generate_guppy_source( num_rounds: int | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> str: """Generate Guppy source code for a surface code patch. @@ -193,6 +194,8 @@ def generate_guppy_source( truth when supplied; ``interaction_basis`` must agree if also supplied. Current Guppy generation maps the resolved plan to the corresponding CX or SZZ/SZZdg concrete template. + clifford_frame_policy: Optional source-level Clifford-deformation + policy. Currently supported for global uniform-axis SZZ frames. Returns: Python/Guppy source code as a string. @@ -202,7 +205,11 @@ def generate_guppy_source( """ from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer - from pecos.qec.surface.circuit_builder import _szz_residual_plan_for_check_plan + from pecos.qec.surface.circuit_builder import ( + _resolve_szz_clifford_frame_for_builder, + _szz_memory_physical_axis, + _szz_residual_plan_for_check_plan, + ) resolved_plan = _resolve_surface_check_plan( interaction_basis=interaction_basis, @@ -213,6 +220,11 @@ def generate_guppy_source( context="Guppy surface-code source generation", ) interaction_basis = resolved_plan.interaction_basis + resolved_clifford_frame = _resolve_szz_clifford_frame_for_builder( + patch, + interaction_basis=interaction_basis, + clifford_frame_policy=clifford_frame_policy, + ) if (twirl is None) != (rng is None): msg = f"twirl and rng must be supplied together; got twirl={twirl!r} rng={rng!r}" raise ValueError(msg) @@ -266,7 +278,7 @@ def generate_guppy_source( "from guppylang.std.angles import angle", "from guppylang.std.builtins import array, owned, result", "from guppylang.std.qsystem.functional import zz_phase", - "from guppylang.std.quantum import discard, h, measure, measure_array, qubit, s, sdg, v, vdg, x, z", + "from guppylang.std.quantum import discard, h, measure, measure_array, qubit, s, sdg, v, vdg, x, y, z", ] else: imports = [ @@ -334,11 +346,90 @@ def _append_szz_data_unpack(target: list[str], indent: str) -> None: def _szz_data_expr(data_q: int) -> str: return f"d{data_q}" + szz_check_by_key = {} + if resolved_clifford_frame is not None: + szz_check_by_key.update({("X", check.stabilizer_index): check for check in resolved_clifford_frame.x_checks}) + szz_check_by_key.update({("Z", check.stabilizer_index): check for check in resolved_clifford_frame.z_checks}) + + def _szz_physical_axis_for_touch(stabilizer_type: str, stab_idx: int, data_q: int) -> str: + if resolved_clifford_frame is None: + return "X" if stabilizer_type == "X" else "Z" + check = szz_check_by_key[(stabilizer_type, stab_idx)] + try: + offset = check.data_qubits.index(data_q) + except ValueError as exc: + msg = f"data qubit {data_q} is not in resolved check {stabilizer_type}{stab_idx}" + raise ValueError(msg) from exc + return check.paulis[offset].axis + + def _szz_physical_axis_for_memory_basis(source_basis: str) -> str: + return _szz_memory_physical_axis(source_basis, resolved_clifford_frame) + + def _szz_physical_axis_for_logical(source_logical: str) -> str: + if resolved_clifford_frame is None: + return source_logical + logical = resolved_clifford_frame.logical_x if source_logical == "X" else resolved_clifford_frame.logical_z + if not logical.is_uniform_axis or logical.uniform_axis is None: + msg = ( + f"clifford frame policy {resolved_clifford_frame.policy!r} maps " + f"source logical {source_logical} to mixed axes {logical.axes}; " + "mixed logical operator rendering is not implemented yet" + ) + raise NotImplementedError(msg) + return logical.uniform_axis + + def _append_szz_axis_rotation_to_z(target: list[str], indent: str, axis: str, qubit_expr: str) -> None: + if axis == "X": + target.append(f"{indent}h({qubit_expr})") + elif axis == "Y": + target.append(f"{indent}vdg({qubit_expr})") + elif axis != "Z": + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + + def _append_szz_axis_rotation_from_z(target: list[str], indent: str, axis: str, qubit_expr: str) -> None: + if axis == "X": + target.append(f"{indent}h({qubit_expr})") + elif axis == "Y": + target.append(f"{indent}v({qubit_expr})") + elif axis != "Z": + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + + def _append_szz_y_compensation(target: list[str], indent: str, qubit_expr: str, *, dagger: bool) -> None: + target.append(f"{indent}sdg({qubit_expr})") + target.append(f"{indent}{'vdg' if dagger else 'v'}({qubit_expr})") + target.append(f"{indent}s({qubit_expr})") + + def _append_szz_touch_compensation(target: list[str], indent: str, axis: str, sign: int, qubit_expr: str) -> None: + if axis == "X": + target.append(f"{indent}{'vdg' if sign > 0 else 'v'}({qubit_expr})") + elif axis == "Y": + _append_szz_y_compensation(target, indent, qubit_expr, dagger=sign > 0) + elif axis == "Z": + target.append(f"{indent}{'sdg' if sign > 0 else 's'}({qubit_expr})") + else: + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + + def _append_szz_logical_pauli(target: list[str], indent: str, axis: str, qubit_expr: str) -> None: + if axis == "X": + target.append(f"{indent}x({qubit_expr})") + elif axis == "Y": + target.append(f"{indent}y({qubit_expr})") + elif axis == "Z": + target.append(f"{indent}z({qubit_expr})") + else: + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + # Generate state preparation functions lines.extend(["# === State Preparation ===", "", "@guppy", f"def prep_z_basis() -> SurfaceCode_{dx}x{dz}:"]) lines.append(' """Prepare logical |0_L> state."""') if interaction_basis == "szz": lines.extend(f" d{i} = qubit()" for i in range(num_data)) + for i in range(num_data): + _append_szz_axis_rotation_to_z(lines, " ", _szz_physical_axis_for_memory_basis("Z"), f"d{i}") lines.append(f" return SurfaceCode_{dx}x{dz}({szz_data_args})") else: lines.append(f" data = array(qubit() for _ in range({num_data}))") @@ -347,7 +438,8 @@ def _szz_data_expr(data_q: int) -> str: lines.append(' """Prepare logical |+_L> state."""') if interaction_basis == "szz": lines.extend(f" d{i} = qubit()" for i in range(num_data)) - lines.extend(f" h(d{i})" for i in range(num_data)) + for i in range(num_data): + _append_szz_axis_rotation_to_z(lines, " ", _szz_physical_axis_for_memory_basis("X"), f"d{i}") lines.append(f" return SurfaceCode_{dx}x{dz}({szz_data_args})") else: lines.append(f" data = array(qubit() for _ in range({num_data}))") @@ -379,9 +471,9 @@ def _append_szz_layer( ) -> None: target.append("") target.append(f"{indent}# SZZ round {rnd_idx + 1}") - x_touches = [(stab_idx, data_q) for stab_type, stab_idx, data_q in layer_gates if stab_type == "X"] - for _stab_idx, data_q in x_touches: - target.append(f"{indent}h({data_expr(data_q)})") + for stab_type, stab_idx, data_q in layer_gates: + axis = _szz_physical_axis_for_touch(stab_type, stab_idx, data_q) + _append_szz_axis_rotation_to_z(target, indent, axis, data_expr(data_q)) for stab_type, stab_idx, data_q in layer_gates: sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] @@ -391,18 +483,14 @@ def _append_szz_layer( f"zz_phase({ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)}, angle({half_turns}))", ) - for _stab_idx, data_q in x_touches: - target.append(f"{indent}h({data_expr(data_q)})") + for stab_type, stab_idx, data_q in layer_gates: + axis = _szz_physical_axis_for_touch(stab_type, stab_idx, data_q) + _append_szz_axis_rotation_from_z(target, indent, axis, data_expr(data_q)) for stab_type, stab_idx, data_q in layer_gates: sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] - compensation = { - ("X", 1): "vdg", - ("X", -1): "v", - ("Z", 1): "sdg", - ("Z", -1): "s", - }[(stab_type, sign)] - target.append(f"{indent}{compensation}({data_expr(data_q)})") + axis = _szz_physical_axis_for_touch(stab_type, stab_idx, data_q) + _append_szz_touch_compensation(target, indent, axis, sign, data_expr(data_q)) lines.extend( [ @@ -801,6 +889,8 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ) if interaction_basis == "szz": _append_szz_data_unpack(lines, " ") + for i in range(num_data): + _append_szz_axis_rotation_from_z(lines, " ", _szz_physical_axis_for_memory_basis("Z"), f"d{i}") z_meas = ", ".join(f"measure(d{i})" for i in range(num_data)) lines.append(f" return array({z_meas})") else: @@ -816,7 +906,8 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ) if interaction_basis == "szz": _append_szz_data_unpack(lines, " ") - lines.extend(f" h(d{i})" for i in range(num_data)) + for i in range(num_data): + _append_szz_axis_rotation_from_z(lines, " ", _szz_physical_axis_for_memory_basis("X"), f"d{i}") x_meas = ", ".join(f"measure(d{i})" for i in range(num_data)) lines.append(f" return array({x_meas})") else: @@ -839,7 +930,9 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ], ) if interaction_basis == "szz": - lines.extend(f" x(surf.d{q})" for q in logical_x_qubits) + logical_x_axis = _szz_physical_axis_for_logical("X") + for q in logical_x_qubits: + _append_szz_logical_pauli(lines, " ", logical_x_axis, f"surf.d{q}") else: lines.extend(f" x(surf.data[{q}])" for q in logical_x_qubits) @@ -853,7 +946,9 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ], ) if interaction_basis == "szz": - lines.extend(f" z(surf.d{q})" for q in logical_z_qubits) + logical_z_axis = _szz_physical_axis_for_logical("Z") + for q in logical_z_qubits: + _append_szz_logical_pauli(lines, " ", logical_z_axis, f"surf.d{q}") else: lines.extend(f" z(surf.data[{q}])" for q in logical_z_qubits) @@ -1523,6 +1618,7 @@ def _guppy_module_cache_key( num_rounds: int | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> str: """Filesystem-safe cache key spanning full patch identity + budget + twirl. @@ -1544,9 +1640,10 @@ def _guppy_module_cache_key( interaction_basis = resolved_plan.interaction_basis interaction_part = "" if interaction_basis == "cx" else f"_ib{interaction_basis}" check_plan_part = "" if check_plan is None else f"_cp{resolved_plan.plan_id}" + frame_part = "" if clifford_frame_policy is None else f"_cf{str(clifford_frame_policy).lower().replace('-', '_')}" base = ( f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}" - f"_b{effective_budget}{interaction_part}{check_plan_part}" + f"_b{effective_budget}{interaction_part}{check_plan_part}{frame_part}" ) if twirl is None: return base @@ -1571,6 +1668,7 @@ def _load_guppy_module( num_rounds: int | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> dict: """Load a Guppy module for a patch, using caching. @@ -1591,6 +1689,8 @@ def _load_guppy_module( interaction_basis: Backward-compatible selector for the default ``check_plan`` of a two-qubit interaction basis. check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ/SZZdg surface-code generation. Returns: Module dictionary with generated functions @@ -1613,6 +1713,7 @@ def _load_guppy_module( num_rounds, interaction_basis, resolved_plan.plan_id if check_plan is not None else None, + clifford_frame_policy, ) if cache_key in _state.module_cache: @@ -1626,6 +1727,7 @@ def _load_guppy_module( num_rounds=num_rounds, interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, + clifford_frame_policy=clifford_frame_policy, ) # Write to temp file (required for Guppy introspection). @@ -1658,6 +1760,7 @@ def generate_memory_experiment( rng: "GuppyRngMaskConfig | None" = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> object: """Generate a memory experiment for a patch. @@ -1671,6 +1774,8 @@ def generate_memory_experiment( interaction_basis: Backward-compatible selector for the default ``check_plan`` of a two-qubit interaction basis. check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ/SZZdg surface-code generation. Returns: Guppy function for the experiment @@ -1683,6 +1788,7 @@ def generate_memory_experiment( num_rounds=num_rounds if twirl is not None else None, interaction_basis=interaction_basis, check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, ) if basis.upper() == "Z": @@ -1704,6 +1810,7 @@ def get_num_qubits( twirl: "TwirlConfig | None" = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> int: """Get the peak simultaneously-live qubit count for a surface-code program. @@ -1747,6 +1854,19 @@ def get_num_qubits( num_data = d * d total_ancilla = d * d - 1 + if clifford_frame_policy is not None: + if patch is None: + from pecos.qec.surface import SurfacePatch + + patch = SurfacePatch.create(distance=d) + from pecos.qec.surface.circuit_builder import _resolve_szz_clifford_frame_for_builder + + _resolve_szz_clifford_frame_for_builder( + patch, + interaction_basis=interaction_basis, + clifford_frame_policy=clifford_frame_policy, + ) + if interaction_basis == "szz" and twirl is not None: msg = "interaction_basis='szz' Guppy runtime twirl integration is staged later" raise ValueError(msg) @@ -1761,6 +1881,7 @@ def generate_surface_code_module( ancilla_budget: int | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> str: """Generate source code for a distance-d surface code module. @@ -1771,6 +1892,8 @@ def generate_surface_code_module( interaction_basis: Backward-compatible selector for the default ``check_plan`` of a two-qubit interaction basis. check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ/SZZdg surface-code generation. Returns: Python/Guppy source code as a string @@ -1785,6 +1908,7 @@ def generate_surface_code_module( ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, ) @@ -1794,6 +1918,7 @@ def _surface_code_module_for_patch( ancilla_budget: int | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> dict: """Load + cache a surface-code module for an arbitrary patch. @@ -1821,6 +1946,7 @@ def _surface_code_module_for_patch( effective_budget, interaction_basis, resolved_plan.plan_id, + None if clifford_frame_policy is None else str(clifford_frame_policy).lower().replace("-", "_"), ) if cache_key in _state.distance_module_cache: @@ -1831,6 +1957,7 @@ def _surface_code_module_for_patch( ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, + clifford_frame_policy=clifford_frame_policy, ) # Metadata derived from the actual patch geometry. @@ -1840,6 +1967,7 @@ def _surface_code_module_for_patch( module["ancilla_budget"] = effective_budget module["interaction_basis"] = interaction_basis module["check_plan"] = resolved_plan.plan_id + module["clifford_frame_policy"] = clifford_frame_policy module["resolved_check_plan"] = resolved_plan.resolved_metadata module["resolved_check_plan_hash"] = resolved_plan.resolved_hash @@ -1853,6 +1981,7 @@ def get_surface_code_module( ancilla_budget: int | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> dict: """Get a loaded surface code module for distance d. @@ -1862,6 +1991,8 @@ def get_surface_code_module( interaction_basis: Backward-compatible selector for the default ``check_plan`` of a two-qubit interaction basis. check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ/SZZdg surface-code generation. Returns: Dictionary with module contents and metadata @@ -1875,6 +2006,7 @@ def get_surface_code_module( ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, ) @@ -1886,6 +2018,7 @@ def make_surface_code( ancilla_budget: int | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> object: """Create a surface code memory experiment. @@ -1901,6 +2034,8 @@ def make_surface_code( interaction_basis: Backward-compatible selector for the default ``check_plan`` of a two-qubit interaction basis. check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ/SZZdg surface-code generation. Returns: Compiled Guppy program @@ -1914,6 +2049,7 @@ def make_surface_code( ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, ) factory = module["make_memory_z"] if basis.upper() == "Z" else module["make_memory_x"] diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index bc219d43b..6ecee2cfc 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -2807,6 +2807,7 @@ def generate_guppy_from_patch( *, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> str: """Generate Guppy code from SurfacePatch. @@ -2824,13 +2825,20 @@ def generate_guppy_from_patch( _basis: Unused (module includes both Z and X basis functions) interaction_basis: Surface-memory two-qubit interaction basis. check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ/SZZdg surface-code generation. Returns: Guppy source code string (full module) """ from pecos.guppy.surface import generate_guppy_source - return generate_guppy_source(patch, interaction_basis=interaction_basis, check_plan=check_plan) + return generate_guppy_source( + patch, + interaction_basis=interaction_basis, + check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, + ) def generate_dag_circuit_from_patch( diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index a510b4a7a..b76f9ff13 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1431,6 +1431,7 @@ def _generate_traced_surface_tick_circuit( interaction_basis: str | None = None, check_plan: str | None = None, runtime: object | None = None, + clifford_frame_policy: str | None = None, ) -> Any: """Trace the lowered ideal Selene/QIS op stream and replay it into a TickCircuit. @@ -1454,6 +1455,7 @@ def _generate_traced_surface_tick_circuit( interaction_basis=interaction_basis, check_plan=check_plan, runtime=runtime, + clifford_frame_policy=clifford_frame_policy, ) return tc @@ -1467,6 +1469,7 @@ def _generate_traced_surface_tick_circuit_with_result_traces( interaction_basis: str | None = None, check_plan: str | None = None, runtime: object | None = None, + clifford_frame_policy: str | None = None, ) -> tuple[Any, list[dict[str, Any]]]: """Trace a surface Guppy program into a ``TickCircuit`` plus result provenance.""" from pecos.guppy.surface import generate_memory_experiment, get_num_qubits @@ -1478,6 +1481,7 @@ def _generate_traced_surface_tick_circuit_with_result_traces( ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, ) return trace_guppy_into_tick_circuit_with_result_traces( program, @@ -1486,6 +1490,7 @@ def _generate_traced_surface_tick_circuit_with_result_traces( ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, ), seed=0, runtime=runtime, @@ -1523,14 +1528,6 @@ def _build_surface_tick_circuit_for_native_model( if szz_physical_prefixes and (interaction_basis != "szz" or circuit_source != "abstract"): msg = "SZZ physical-prefix lowering requires interaction_basis='szz' and circuit_source='abstract'" raise ValueError(msg) - if clifford_frame_policy is not None and circuit_source != "abstract": - msg = ( - "clifford_frame_policy currently requires circuit_source='abstract'; " - "traced-QIS support must generate the Guppy program from the same " - "concrete Clifford-deformed checks before binding result-tag metadata" - ) - raise NotImplementedError(msg) - abstract_tc = generate_tick_circuit_from_patch( patch, num_rounds, @@ -1562,6 +1559,7 @@ def _build_surface_tick_circuit_for_native_model( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, runtime=runtime, + clifford_frame_policy=clifford_frame_policy, ) measurement_index_remap = _surface_runtime_measurement_remap_from_result_traces(abstract_tc, result_traces) @@ -2297,9 +2295,9 @@ def generate_circuit_level_dem_from_builder( assumption keyed from the resolved plan, not a general claim about CX hardware. clifford_frame_policy: Optional source-level Clifford-deformation - policy for native abstract SZZ generation. Traced-QIS support - requires Guppy to emit the same concrete deformed checks and is - rejected until that path is implemented. + policy for native SZZ generation. For ``circuit_source="traced_qis"``, + the Guppy program is generated from the same concrete deformed + checks before runtime result tags are bound to surface metadata. Returns: DEM string in standard format @@ -3822,7 +3820,7 @@ def surface_code_memory( truth when supplied; ``interaction_basis`` must agree if also supplied. clifford_frame_policy: Optional source-level Clifford-deformation - policy for native abstract SZZ generation. + policy for native SZZ generation. Returns: ``SimulationResult`` with logical and raw error counts/rates. diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index 16a4551e1..abff4e129 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -75,10 +75,34 @@ def test_szz_source_rejects_staged_later_runtime_shapes(patch: SurfacePatch) -> def test_szz_basis_forks_guppy_module_cache_key(patch: SurfacePatch) -> None: cx_key = _guppy_module_cache_key(patch, effective_budget=8) szz_key = _guppy_module_cache_key(patch, effective_budget=8, interaction_basis="szz") + framed_key = _guppy_module_cache_key( + patch, + effective_budget=8, + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) assert cx_key != szz_key + assert framed_key != szz_key assert "_ibcx" not in cx_key assert "_ibszz" in szz_key + assert "_cfglobal_axis_cycle_f" in framed_key + + +def test_szz_axis_cycle_frame_source_uses_y_check_scaffold(patch: SurfacePatch) -> None: + src = generate_guppy_source( + patch, + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) + + assert "from guppylang.std.quantum import" in src + assert ", x, y, z" in src + assert "sdg(d0)\n vdg(d0)\n s(d0)" in src + assert "def apply_logical_x" in src + assert "y(surf.d" in src + assert "def apply_logical_z" in src + assert "x(surf.d" in src def test_szz_memory_experiment_compiles_to_guppy_function(patch: SurfacePatch) -> None: @@ -91,6 +115,17 @@ def test_szz_memory_experiment_compiles_to_guppy_function(patch: SurfacePatch) - assert fn is not None +def test_szz_axis_cycle_memory_experiment_compiles_to_guppy_function(patch: SurfacePatch) -> None: + fn = generate_memory_experiment( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) + assert fn is not None + + def test_twirl_source_unrolls_rng_masks_and_runtime_paulis(patch: SurfacePatch) -> None: src = generate_guppy_source( patch, 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 21195fa36..eecd067a7 100644 --- a/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py +++ b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py @@ -147,13 +147,17 @@ def test_global_axis_cycle_f_native_abstract_dem_path_accepts_frame_policy() -> assert isinstance(dem, str) -def test_clifford_frame_policy_rejects_traced_qis_until_guppy_matches_frame() -> None: - with pytest.raises(NotImplementedError, match="requires circuit_source='abstract'"): - build_memory_circuit( - distance=3, - rounds=1, - basis="Z", - circuit_source="traced_qis", - interaction_basis="szz", - clifford_frame_policy="global_axis_cycle_f", - ) +def test_clifford_frame_policy_traced_qis_binds_runtime_result_tags() -> None: + tick_circuit = build_memory_circuit( + distance=3, + rounds=1, + basis="Z", + circuit_source="traced_qis", + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) + + assert tick_circuit.get_meta("surface_metadata_record_binding") == "runtime_result_tags" + assert tick_circuit.get_meta("basis") == "Z" + assert tick_circuit.get_meta("detectors") + assert tick_circuit.get_meta("observables") From 6edc487e6e61c8a4d366d9ad02f6c0b7b1db9bc0 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 17 Jun 2026 23:02:25 -0600 Subject: [PATCH 119/150] Support checkerboard Clifford surface frames --- .../quantum-pecos/src/pecos/guppy/surface.py | 61 +++++++++---- .../qec/surface/_clifford_deformation.py | 64 +++++++++++++- .../src/pecos/qec/surface/circuit_builder.py | 69 ++++++++++----- .../qec/surface/test_clifford_deformation.py | 87 +++++++++++++++++++ 4 files changed, 235 insertions(+), 46 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 4aba2eab9..2cd2f4e9a 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -195,7 +195,8 @@ def generate_guppy_source( supplied. Current Guppy generation maps the resolved plan to the corresponding CX or SZZ/SZZdg concrete template. clifford_frame_policy: Optional source-level Clifford-deformation - policy. Currently supported for global uniform-axis SZZ frames. + policy. Currently supported for SZZ global axis-cycle and + checkerboard XZZX/ZXXZ deformed-check frames. Returns: Python/Guppy source code as a string. @@ -207,7 +208,7 @@ def generate_guppy_source( from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer from pecos.qec.surface.circuit_builder import ( _resolve_szz_clifford_frame_for_builder, - _szz_memory_physical_axis, + _szz_memory_physical_axis_for_data, _szz_residual_plan_for_check_plan, ) @@ -362,21 +363,23 @@ def _szz_physical_axis_for_touch(stabilizer_type: str, stab_idx: int, data_q: in raise ValueError(msg) from exc return check.paulis[offset].axis - def _szz_physical_axis_for_memory_basis(source_basis: str) -> str: - return _szz_memory_physical_axis(source_basis, resolved_clifford_frame) + def _szz_physical_axis_for_memory_data(source_basis: str, data_q: int) -> str: + return _szz_memory_physical_axis_for_data( + source_basis, + resolved_clifford_frame, + data_q, + ) - def _szz_physical_axis_for_logical(source_logical: str) -> str: + def _szz_physical_axis_for_logical_data(source_logical: str, data_q: int) -> str: if resolved_clifford_frame is None: return source_logical logical = resolved_clifford_frame.logical_x if source_logical == "X" else resolved_clifford_frame.logical_z - if not logical.is_uniform_axis or logical.uniform_axis is None: - msg = ( - f"clifford frame policy {resolved_clifford_frame.policy!r} maps " - f"source logical {source_logical} to mixed axes {logical.axes}; " - "mixed logical operator rendering is not implemented yet" - ) - raise NotImplementedError(msg) - return logical.uniform_axis + try: + offset = logical.data_qubits.index(data_q) + except ValueError as exc: + msg = f"data qubit {data_q} is not in source logical {source_logical}" + raise ValueError(msg) from exc + return logical.paulis[offset].axis def _append_szz_axis_rotation_to_z(target: list[str], indent: str, axis: str, qubit_expr: str) -> None: if axis == "X": @@ -429,7 +432,12 @@ def _append_szz_logical_pauli(target: list[str], indent: str, axis: str, qubit_e if interaction_basis == "szz": lines.extend(f" d{i} = qubit()" for i in range(num_data)) for i in range(num_data): - _append_szz_axis_rotation_to_z(lines, " ", _szz_physical_axis_for_memory_basis("Z"), f"d{i}") + _append_szz_axis_rotation_to_z( + lines, + " ", + _szz_physical_axis_for_memory_data("Z", i), + f"d{i}", + ) lines.append(f" return SurfaceCode_{dx}x{dz}({szz_data_args})") else: lines.append(f" data = array(qubit() for _ in range({num_data}))") @@ -439,7 +447,12 @@ def _append_szz_logical_pauli(target: list[str], indent: str, axis: str, qubit_e if interaction_basis == "szz": lines.extend(f" d{i} = qubit()" for i in range(num_data)) for i in range(num_data): - _append_szz_axis_rotation_to_z(lines, " ", _szz_physical_axis_for_memory_basis("X"), f"d{i}") + _append_szz_axis_rotation_to_z( + lines, + " ", + _szz_physical_axis_for_memory_data("X", i), + f"d{i}", + ) lines.append(f" return SurfaceCode_{dx}x{dz}({szz_data_args})") else: lines.append(f" data = array(qubit() for _ in range({num_data}))") @@ -890,7 +903,12 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: if interaction_basis == "szz": _append_szz_data_unpack(lines, " ") for i in range(num_data): - _append_szz_axis_rotation_from_z(lines, " ", _szz_physical_axis_for_memory_basis("Z"), f"d{i}") + _append_szz_axis_rotation_from_z( + lines, + " ", + _szz_physical_axis_for_memory_data("Z", i), + f"d{i}", + ) z_meas = ", ".join(f"measure(d{i})" for i in range(num_data)) lines.append(f" return array({z_meas})") else: @@ -907,7 +925,12 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: if interaction_basis == "szz": _append_szz_data_unpack(lines, " ") for i in range(num_data): - _append_szz_axis_rotation_from_z(lines, " ", _szz_physical_axis_for_memory_basis("X"), f"d{i}") + _append_szz_axis_rotation_from_z( + lines, + " ", + _szz_physical_axis_for_memory_data("X", i), + f"d{i}", + ) x_meas = ", ".join(f"measure(d{i})" for i in range(num_data)) lines.append(f" return array({x_meas})") else: @@ -930,8 +953,8 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ], ) if interaction_basis == "szz": - logical_x_axis = _szz_physical_axis_for_logical("X") for q in logical_x_qubits: + logical_x_axis = _szz_physical_axis_for_logical_data("X", q) _append_szz_logical_pauli(lines, " ", logical_x_axis, f"surf.d{q}") else: lines.extend(f" x(surf.data[{q}])" for q in logical_x_qubits) @@ -946,8 +969,8 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ], ) if interaction_basis == "szz": - logical_z_axis = _szz_physical_axis_for_logical("Z") for q in logical_z_qubits: + logical_z_axis = _szz_physical_axis_for_logical_data("Z", q) _append_szz_logical_pauli(lines, " ", logical_z_axis, f"surf.d{q}") else: lines.extend(f" z(surf.data[{q}])" for q in logical_z_qubits) diff --git a/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py b/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py index 8f1b0051b..5269a4dd3 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py @@ -25,6 +25,8 @@ "global_h", "global_axis_cycle_f", "global_axis_cycle_f2", + "checkerboard_xzzx", + "checkerboard_zxxz", ] _SUPPORTED_GLOBAL_FRAME_POLICIES = frozenset( @@ -35,6 +37,15 @@ "global_axis_cycle_f2", }, ) +_SUPPORTED_CHECKERBOARD_FRAME_POLICIES = frozenset( + { + "checkerboard_xzzx", + "checkerboard_zxxz", + }, +) +_SUPPORTED_FRAME_POLICIES = ( + _SUPPORTED_GLOBAL_FRAME_POLICIES | _SUPPORTED_CHECKERBOARD_FRAME_POLICIES +) @dataclass(frozen=True, order=True) @@ -200,10 +211,10 @@ def css_physical_memory_basis(self, source_basis: str) -> PauliAxis: def normalize_surface_frame_policy(policy: str) -> str: """Normalize and validate a named surface Clifford frame policy.""" normalized = str(policy).lower().replace("-", "_") - if normalized not in _SUPPORTED_GLOBAL_FRAME_POLICIES: + if normalized not in _SUPPORTED_FRAME_POLICIES: msg = ( f"unknown surface Clifford frame policy {policy!r}; expected one of " - f"{sorted(_SUPPORTED_GLOBAL_FRAME_POLICIES)}" + f"{sorted(_SUPPORTED_FRAME_POLICIES)}" ) raise ValueError(msg) return normalized @@ -215,6 +226,12 @@ def global_surface_frame(policy: str, num_data: int) -> tuple[LocalCliffordFrame msg = f"num_data must be non-negative, got {num_data}" raise ValueError(msg) normalized = normalize_surface_frame_policy(policy) + if normalized not in _SUPPORTED_GLOBAL_FRAME_POLICIES: + msg = ( + f"surface Clifford frame policy {policy!r} is local; call " + "resolve_surface_clifford_frame(...) with a patch instead" + ) + raise ValueError(msg) frame = _global_frame_element(normalized) return tuple(frame for _ in range(num_data)) @@ -227,7 +244,11 @@ def resolve_surface_clifford_frame( ) -> ResolvedSurfaceCliffordFrame: """Resolve source surface checks/logicals through a local Clifford frame.""" normalized = normalize_surface_frame_policy(policy) - frames = tuple(data_frames) if data_frames is not None else global_surface_frame(normalized, patch.num_data) + frames = ( + tuple(data_frames) + if data_frames is not None + else _surface_frame_for_policy(patch, normalized) + ) if len(frames) != patch.num_data: msg = f"data frame length {len(frames)} does not match patch.num_data={patch.num_data}" raise ValueError(msg) @@ -275,3 +296,40 @@ def _global_frame_element(policy: str) -> LocalCliffordFrame: return LocalCliffordFrame(SignedPauli("Z"), SignedPauli("Y")) msg = f"unknown surface Clifford frame policy {policy!r}" raise ValueError(msg) + + +def _surface_frame_for_policy( + patch: SurfacePatch, + policy: str, +) -> tuple[LocalCliffordFrame, ...]: + """Return the resolved data-qubit frame for a named policy.""" + if policy in _SUPPORTED_GLOBAL_FRAME_POLICIES: + return global_surface_frame(policy, patch.num_data) + if policy in _SUPPORTED_CHECKERBOARD_FRAME_POLICIES: + return _checkerboard_h_frame( + patch, + h_on_even=(policy == "checkerboard_xzzx"), + ) + msg = f"unknown surface Clifford frame policy {policy!r}" + raise ValueError(msg) + + +def _checkerboard_h_frame( + patch: SurfacePatch, + *, + h_on_even: bool, +) -> tuple[LocalCliffordFrame, ...]: + """Return a checkerboard H deformation for rotated surface checks. + + With H on even-parity data sites, source X and Z checks become XZZX in the + data-qubit order used by the rotated-patch stabilizer supports. Flipping the + parity gives the paired ZXXZ orientation. + """ + identity = _global_frame_element("identity") + hadamard = _global_frame_element("global_h") + frames: list[LocalCliffordFrame] = [] + for data_idx in range(patch.num_data): + row, col = patch.geometry.id_to_pos[data_idx] + even = (row + col) % 2 == 0 + frames.append(hadamard if even == h_on_even else identity) + return tuple(frames) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 6ecee2cfc..36c7612f7 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -465,24 +465,14 @@ def _resolve_szz_clifford_frame_for_builder( if interaction_basis != "szz": msg = "clifford_frame_policy currently requires interaction_basis='szz'" raise NotImplementedError(msg) - resolved_frame = resolve_surface_clifford_frame(patch, policy=clifford_frame_policy) - unsupported = [check for check in resolved_frame.checks if not check.is_uniform_axis] - if unsupported: - preview = ", ".join(f"{check.source_kind}{check.stabilizer_index}:{check.axes}" for check in unsupported[:5]) - suffix = f", ... {len(unsupported) - 5} more" if len(unsupported) > 5 else "" - msg = ( - "clifford_frame_policy currently supports only uniform-axis " - f"stabilizer checks; mixed checks: {preview}{suffix}" - ) - raise NotImplementedError(msg) - return resolved_frame + return resolve_surface_clifford_frame(patch, policy=clifford_frame_policy) def _szz_memory_physical_axis( basis: str, resolved_clifford_frame: ResolvedSurfaceCliffordFrame | None, ) -> PauliAxis: - """Return the physical data-measurement axis for a source memory basis.""" + """Return the uniform physical axis for a source memory basis, if any.""" source_basis = basis.upper() if source_basis not in {"X", "Z"}: msg = f"basis must be 'X' or 'Z', got {basis!r}" @@ -490,19 +480,41 @@ def _szz_memory_physical_axis( if resolved_clifford_frame is None: return source_basis # type: ignore[return-value] - logical = ( - resolved_clifford_frame.logical_x - if source_basis == "X" - else resolved_clifford_frame.logical_z - ) - if not logical.is_uniform_axis or logical.uniform_axis is None: + axes = { + frame.image(source_basis).axis + for frame in resolved_clifford_frame.data_frames + } + if len(axes) != 1: msg = ( f"clifford frame policy {resolved_clifford_frame.policy!r} maps " - f"source {source_basis}-memory to a mixed logical measurement " - f"axis {logical.axes}; mixed final readout is not implemented yet" + f"source {source_basis}-memory to mixed data measurement axes " + f"{sorted(axes)}; call _szz_memory_physical_axis_for_data instead" ) raise NotImplementedError(msg) - return logical.uniform_axis + return next(iter(axes)) + + +def _szz_memory_physical_axis_for_data( + basis: str, + resolved_clifford_frame: ResolvedSurfaceCliffordFrame | None, + data_idx: int, +) -> PauliAxis: + """Return the physical prep/readout axis for one source-basis data qubit.""" + source_basis = basis.upper() + if source_basis not in {"X", "Z"}: + msg = f"basis must be 'X' or 'Z', got {basis!r}" + raise ValueError(msg) + if resolved_clifford_frame is None: + return source_basis # type: ignore[return-value] + try: + frame = resolved_clifford_frame.data_frames[data_idx] + except IndexError as exc: + msg = ( + f"data qubit {data_idx} is outside resolved frame with " + f"{len(resolved_clifford_frame.data_frames)} data frames" + ) + raise ValueError(msg) from exc + return frame.image(source_basis).axis def _propagate_szz_frame_bits(x_a: bool, z_a: bool, x_b: bool, z_b: bool) -> tuple[bool, bool, bool, bool]: @@ -895,8 +907,9 @@ def build_surface_code_circuit( truth when supplied; ``interaction_basis`` must agree if also supplied. clifford_frame_policy: Optional source-level Clifford-deformation - policy. Currently supported only by the SZZ renderer for global - uniform-axis frames. + policy. Currently supported only by the SZZ renderer. Global + axis-cycle frames and checkerboard XZZX/ZXXZ frames are rendered + as concrete deformed checks. Returns: Tuple of (operations list, qubit allocation info) @@ -1323,7 +1336,6 @@ def _build_surface_code_circuit_szz( OpType.SZ: "SZ", OpType.SZDG: "SZDG", } - basis_axis = _szz_memory_physical_axis(basis, resolved_clifford_frame) def data_q(i: int) -> int: return allocation.data_qubits[i] @@ -1348,6 +1360,13 @@ def physical_axis_for_touch(stabilizer_type: str, stab_idx: int, data_idx: int) raise ValueError(msg) from exc return check.paulis[offset].axis + def physical_axis_for_memory_data(data_idx: int) -> PauliAxis: + return _szz_memory_physical_axis_for_data( + basis, + resolved_clifford_frame, + data_idx, + ) + def append_axis_rotation_to_z( target_ops: list[SurfaceCircuitStep], axis: PauliAxis, @@ -1500,6 +1519,7 @@ def append_szz_layer( ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"prep_{basis.lower()}_basis")) ops.extend(SurfaceCircuitStep(OpType.ALLOC, [data_q(i)], f"data[{i}]") for i in range(num_data)) for i in range(num_data): + basis_axis = physical_axis_for_memory_data(i) append_axis_rotation_to_z( ops, basis_axis, @@ -1566,6 +1586,7 @@ def append_szz_layer( # ========================================================================= ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"measure_{basis.lower()}_basis")) for i in range(num_data): + basis_axis = physical_axis_for_memory_data(i) append_axis_rotation_from_z( ops, basis_axis, 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 eecd067a7..61e0d1308 100644 --- a/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py +++ b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py @@ -68,6 +68,26 @@ def test_axis_cycle_frames_resolve_but_require_deformed_checks() -> None: resolved_f2.css_physical_memory_basis("X") +def test_checkerboard_frames_resolve_to_mixed_xzzx_zxxz_checks() -> None: + patch = SurfacePatch.create(distance=3) + + xzzx = resolve_surface_clifford_frame(patch, policy="checkerboard-xzzx") + zxxz = resolve_surface_clifford_frame(patch, policy="checkerboard_zxxz") + + assert xzzx.requires_deformed_check_synthesis + assert zxxz.requires_deformed_check_synthesis + assert {check.axes for check in xzzx.checks if len(check.axes) == 4} == { + ("X", "Z", "Z", "X"), + } + assert {check.axes for check in zxxz.checks if len(check.axes) == 4} == { + ("Z", "X", "X", "Z"), + } + assert not xzzx.logical_x.is_uniform_axis + assert not xzzx.logical_z.is_uniform_axis + assert not zxxz.logical_x.is_uniform_axis + assert not zxxz.logical_z.is_uniform_axis + + def test_explicit_mixed_local_frame_marks_only_mixed_checks_deformed() -> None: patch = SurfacePatch.create(distance=3) frames = list(global_surface_frame("identity", patch.num_data)) @@ -113,6 +133,25 @@ def test_global_axis_cycle_f_emits_uniform_y_szz_check_scaffold() -> None: assert any(op.op_type == OpType.MEASURE and op.label.startswith("sz") for op in ops) +def test_checkerboard_xzzx_emits_mixed_szz_check_scaffold() -> None: + patch = SurfacePatch.create(distance=3) + + ops, _allocation = build_surface_code_circuit( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + clifford_frame_policy="checkerboard_xzzx", + ) + + assert any(op.op_type == OpType.H and op.label == "prep_x_basis_d0:to_z" for op in ops) + assert any(op.op_type == OpType.H and op.label == "measure_x_basis_d0:from_z" for op in ops) + assert not any(op.label == "prep_x_basis_d1:to_z" for op in ops) + assert any("szz_x_touch_pre:X1:d1:to_z" in op.label for op in ops) + assert any("szz_touch_comp:SXDG:X:X1:d1" in op.label for op in ops) + assert any("szz_touch_comp:SZDG:Z:X1:d2" in op.label for op in ops) + + def test_global_axis_cycle_f_tick_circuit_keeps_source_detector_metadata() -> None: patch = SurfacePatch.create(distance=3) @@ -131,6 +170,22 @@ def test_global_axis_cycle_f_tick_circuit_keeps_source_detector_metadata() -> No assert tick_circuit.get_meta("basis") == "Z" +def test_checkerboard_xzzx_tick_circuit_keeps_source_detector_metadata() -> None: + patch = SurfacePatch.create(distance=3) + + tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + clifford_frame_policy="checkerboard_xzzx", + ) + + assert tick_circuit.get_meta("basis") == "Z" + assert tick_circuit.get_meta("detectors") + assert tick_circuit.get_meta("observables") + + def test_global_axis_cycle_f_native_abstract_dem_path_accepts_frame_policy() -> None: patch = SurfacePatch.create(distance=3) @@ -147,6 +202,22 @@ def test_global_axis_cycle_f_native_abstract_dem_path_accepts_frame_policy() -> assert isinstance(dem, str) +def test_checkerboard_xzzx_native_abstract_dem_path_accepts_frame_policy() -> None: + patch = SurfacePatch.create(distance=3) + + 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), + basis="Z", + circuit_source="abstract", + interaction_basis="szz", + clifford_frame_policy="checkerboard_xzzx", + ) + + assert isinstance(dem, str) + + def test_clifford_frame_policy_traced_qis_binds_runtime_result_tags() -> None: tick_circuit = build_memory_circuit( distance=3, @@ -161,3 +232,19 @@ def test_clifford_frame_policy_traced_qis_binds_runtime_result_tags() -> None: assert tick_circuit.get_meta("basis") == "Z" assert tick_circuit.get_meta("detectors") assert tick_circuit.get_meta("observables") + + +def test_checkerboard_xzzx_traced_qis_binds_runtime_result_tags() -> None: + tick_circuit = build_memory_circuit( + distance=3, + rounds=1, + basis="Z", + circuit_source="traced_qis", + interaction_basis="szz", + clifford_frame_policy="checkerboard_xzzx", + ) + + assert tick_circuit.get_meta("surface_metadata_record_binding") == "runtime_result_tags" + assert tick_circuit.get_meta("basis") == "Z" + assert tick_circuit.get_meta("detectors") + assert tick_circuit.get_meta("observables") From 5dfc8b640d4c41711a910d34473a367a350032dc Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 18 Jun 2026 10:09:16 -0600 Subject: [PATCH 120/150] Cover both checkerboard surface frame orientations --- .../qec/surface/test_clifford_deformation.py | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) 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 61e0d1308..e2c09de11 100644 --- a/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py +++ b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py @@ -133,7 +133,20 @@ def test_global_axis_cycle_f_emits_uniform_y_szz_check_scaffold() -> None: assert any(op.op_type == OpType.MEASURE and op.label.startswith("sz") for op in ops) -def test_checkerboard_xzzx_emits_mixed_szz_check_scaffold() -> None: +@pytest.mark.parametrize( + ("policy", "rotated_data", "unrotated_data", "x_touch_data", "z_touch_data"), + [ + ("checkerboard_xzzx", 0, 1, 1, 2), + ("checkerboard_zxxz", 1, 0, 2, 1), + ], +) +def test_checkerboard_frames_emit_mixed_szz_check_scaffold( + policy: str, + rotated_data: int, + unrotated_data: int, + x_touch_data: int, + z_touch_data: int, +) -> None: patch = SurfacePatch.create(distance=3) ops, _allocation = build_surface_code_circuit( @@ -141,15 +154,23 @@ def test_checkerboard_xzzx_emits_mixed_szz_check_scaffold() -> None: num_rounds=1, basis="Z", interaction_basis="szz", - clifford_frame_policy="checkerboard_xzzx", + clifford_frame_policy=policy, ) - assert any(op.op_type == OpType.H and op.label == "prep_x_basis_d0:to_z" for op in ops) - assert any(op.op_type == OpType.H and op.label == "measure_x_basis_d0:from_z" for op in ops) - assert not any(op.label == "prep_x_basis_d1:to_z" for op in ops) - assert any("szz_x_touch_pre:X1:d1:to_z" in op.label for op in ops) - assert any("szz_touch_comp:SXDG:X:X1:d1" in op.label for op in ops) - assert any("szz_touch_comp:SZDG:Z:X1:d2" in op.label for op in ops) + assert any( + op.op_type == OpType.H + and op.label == f"prep_x_basis_d{rotated_data}:to_z" + for op in ops + ) + assert any( + op.op_type == OpType.H + and op.label == f"measure_x_basis_d{rotated_data}:from_z" + for op in ops + ) + assert not any(op.label == f"prep_x_basis_d{unrotated_data}:to_z" for op in ops) + assert any(f"szz_x_touch_pre:X1:d{x_touch_data}:to_z" in op.label for op in ops) + assert any(f"szz_touch_comp:SXDG:X:X1:d{x_touch_data}" in op.label for op in ops) + assert any(f"szz_touch_comp:SZDG:Z:X1:d{z_touch_data}" in op.label for op in ops) def test_global_axis_cycle_f_tick_circuit_keeps_source_detector_metadata() -> None: @@ -170,7 +191,8 @@ def test_global_axis_cycle_f_tick_circuit_keeps_source_detector_metadata() -> No assert tick_circuit.get_meta("basis") == "Z" -def test_checkerboard_xzzx_tick_circuit_keeps_source_detector_metadata() -> None: +@pytest.mark.parametrize("policy", ["checkerboard_xzzx", "checkerboard_zxxz"]) +def test_checkerboard_tick_circuit_keeps_source_detector_metadata(policy: str) -> None: patch = SurfacePatch.create(distance=3) tick_circuit = generate_tick_circuit_from_patch( @@ -178,7 +200,7 @@ def test_checkerboard_xzzx_tick_circuit_keeps_source_detector_metadata() -> None num_rounds=1, basis="Z", interaction_basis="szz", - clifford_frame_policy="checkerboard_xzzx", + clifford_frame_policy=policy, ) assert tick_circuit.get_meta("basis") == "Z" @@ -202,7 +224,8 @@ def test_global_axis_cycle_f_native_abstract_dem_path_accepts_frame_policy() -> assert isinstance(dem, str) -def test_checkerboard_xzzx_native_abstract_dem_path_accepts_frame_policy() -> None: +@pytest.mark.parametrize("policy", ["checkerboard_xzzx", "checkerboard_zxxz"]) +def test_checkerboard_native_abstract_dem_path_accepts_frame_policy(policy: str) -> None: patch = SurfacePatch.create(distance=3) dem = generate_circuit_level_dem_from_builder( @@ -212,7 +235,7 @@ def test_checkerboard_xzzx_native_abstract_dem_path_accepts_frame_policy() -> No basis="Z", circuit_source="abstract", interaction_basis="szz", - clifford_frame_policy="checkerboard_xzzx", + clifford_frame_policy=policy, ) assert isinstance(dem, str) @@ -234,14 +257,15 @@ def test_clifford_frame_policy_traced_qis_binds_runtime_result_tags() -> None: assert tick_circuit.get_meta("observables") -def test_checkerboard_xzzx_traced_qis_binds_runtime_result_tags() -> None: +@pytest.mark.parametrize("policy", ["checkerboard_xzzx", "checkerboard_zxxz"]) +def test_checkerboard_traced_qis_binds_runtime_result_tags(policy: str) -> None: tick_circuit = build_memory_circuit( distance=3, rounds=1, basis="Z", circuit_source="traced_qis", interaction_basis="szz", - clifford_frame_policy="checkerboard_xzzx", + clifford_frame_policy=policy, ) assert tick_circuit.get_meta("surface_metadata_record_binding") == "runtime_result_tags" From e9a7d7a3e3c5efb8e3bc568873b80ce1e66c5a6b Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 22 Jun 2026 17:54:22 -0600 Subject: [PATCH 121/150] Add balanced surface ancilla scheduling --- .../quantum-pecos/src/pecos/guppy/surface.py | 20 ++- .../pecos/qec/surface/_ancilla_batching.py | 152 ++++++++++++++++-- .../src/pecos/qec/surface/_check_plan.py | 114 ++++++++++++- .../src/pecos/qec/surface/circuit_builder.py | 33 +++- .../qec/surface/test_ancilla_batching.py | 64 ++++++++ .../tests/qec/surface/test_check_plan.py | 64 +++++++- 6 files changed, 426 insertions(+), 21 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 2cd2f4e9a..ff0e2ab90 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -205,7 +205,10 @@ def generate_guppy_source( ValueError: If exactly one of ``twirl`` / ``rng`` is supplied. """ from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget - from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer + from pecos.qec.surface._check_plan import ( + ancilla_schedule_for_check_plan, + require_current_surface_check_plan_renderer, + ) from pecos.qec.surface.circuit_builder import ( _resolve_szz_clifford_frame_for_builder, _szz_memory_physical_axis_for_data, @@ -220,6 +223,7 @@ def generate_guppy_source( resolved_plan, context="Guppy surface-code source generation", ) + ancilla_schedule = ancilla_schedule_for_check_plan(resolved_plan) interaction_basis = resolved_plan.interaction_basis resolved_clifford_frame = _resolve_szz_clifford_frame_for_builder( patch, @@ -614,9 +618,13 @@ def _append_szz_layer( idx += 1 else: # Constrained: stabilizer-batched. The batch sequence is the - # shared `batched_stabilizers(patch, effective_budget)` so the + # shared `batched_stabilizers(patch, effective_budget, schedule=...)` so the # abstract circuit's measurement order matches by construction. - batches = batched_stabilizers(patch, effective_budget) + batches = batched_stabilizers( + patch, + effective_budget, + ancilla_schedule=ancilla_schedule, + ) lines.append( f' """Extract full syndrome in {len(batches)} ancilla-reuse batches (budget={effective_budget})."""', ) @@ -804,7 +812,11 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: lines.append(f" sz{stab.index} = measure(az{stab.index})") lines.append(f' result("sz{stab.index}:init:meas:{idx}", sz{stab.index})') else: - batches = batched_stabilizers(patch, effective_budget) + batches = batched_stabilizers( + patch, + effective_budget, + ancilla_schedule=ancilla_schedule, + ) idx = 0 for batch_idx, batch in enumerate(batches): init_batch = [(t, i) for t, i in batch if t == stab_type] diff --git a/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py index 256b8a565..71314b8fa 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py @@ -23,12 +23,41 @@ from __future__ import annotations +from math import ceil from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import Iterable + from pecos.qec.surface.geometry import SurfacePatch +DEFAULT_ANCILLA_SCHEDULE = "default" +BALANCED_DATA_ANCILLA_SCHEDULE = "balanced-data-v1" +SUPPORTED_ANCILLA_SCHEDULES = frozenset( + {DEFAULT_ANCILLA_SCHEDULE, BALANCED_DATA_ANCILLA_SCHEDULE}, +) + + +def normalize_ancilla_schedule(ancilla_schedule: str | None = None) -> str: + """Return the canonical named ancilla-reuse schedule. + + ``None`` is the historical default batching policy. Non-default policies + must be explicit in source-level check-plan metadata so DEM caches and + traced programs cannot accidentally collide with the legacy schedule. + """ + if ancilla_schedule is None: + return DEFAULT_ANCILLA_SCHEDULE + normalized = str(ancilla_schedule).lower().replace("_", "-") + if normalized not in SUPPORTED_ANCILLA_SCHEDULES: + msg = ( + f"ancilla_schedule must be one of {sorted(SUPPORTED_ANCILLA_SCHEDULES)}, " + f"got {ancilla_schedule!r}" + ) + raise ValueError(msg) + return normalized + + def normalize_ancilla_budget(total_ancilla: int, ancilla_budget: int | None) -> int: """Clamp an ancilla budget to the valid range for a patch. @@ -59,6 +88,8 @@ def normalize_ancilla_budget(total_ancilla: int, ancilla_budget: int | None) -> def batched_stabilizers( patch: SurfacePatch, ancilla_budget: int, + *, + ancilla_schedule: str | None = None, ) -> list[list[tuple[str, int]]]: """Partition stabilizers into ancilla-reuse batches. @@ -68,13 +99,21 @@ def batched_stabilizers( ``ancilla_budget`` stabilizers each; within each batch every stabilizer is measured concurrently using one ancilla qubit. - The stabilizer order is **load-bearing** production semantics shared by - the abstract circuit and the Guppy emitter: ascending stabilizer index, - X before Z on ties. Note the traced-vs-traced Selene parity tests cannot - catch a regression here -- both sides import this one helper, so a policy - change moves them together. The concrete batch-order and source-level - CX-emission pins (``tests/qec/surface/test_ancilla_batching.py``) are what - actually guard this order; preserve it. + The default stabilizer order is **load-bearing** production semantics + shared by the abstract circuit and the Guppy emitter: ascending stabilizer + index, X before Z on ties. Note the traced-vs-traced Selene parity tests + cannot catch a regression here -- both sides import this one helper, so a + policy change moves them together. The concrete batch-order and + source-level CX-emission pins + (``tests/qec/surface/test_ancilla_batching.py``) are what actually guard + this order; preserve it. + + ``ancilla_schedule="balanced-data-v1"`` is an explicit non-default policy + for constrained-ancilla programs. It greedily spreads stabilizer supports + across each batch so data qubits see check interactions more uniformly + through the batched sequence. This does not change result tags or detector + semantics; callers must still record the chosen schedule in check-plan + metadata so cached DEMs do not collide with the default schedule. ``ancilla_budget`` is validated through :func:`normalize_ancilla_budget` (rejects ``None``, ``bool``, @@ -83,12 +122,107 @@ def batched_stabilizers( public ``ancilla_budget`` API surface, not an opaque ``range()`` or silent-empty failure. """ + schedule = normalize_ancilla_schedule(ancilla_schedule) geom = patch.geometry total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) + stabilizers = _canonical_stabilizer_order(patch) + if schedule == BALANCED_DATA_ANCILLA_SCHEDULE: + return _balanced_data_batches(patch, stabilizers, effective_budget) + + return [stabilizers[start : start + effective_budget] for start in range(0, len(stabilizers), effective_budget)] + + +def _canonical_stabilizer_order(patch: SurfacePatch) -> list[tuple[str, int]]: + geom = patch.geometry stabilizers = [("X", stab.index) for stab in geom.x_stabilizers] stabilizers.extend(("Z", stab.index) for stab in geom.z_stabilizers) - stabilizers.sort(key=lambda stab: (stab[1], 0 if stab[0] == "X" else 1)) + stabilizers.sort(key=_canonical_stabilizer_sort_key) + return stabilizers - return [stabilizers[start : start + effective_budget] for start in range(0, len(stabilizers), effective_budget)] + +def _canonical_stabilizer_sort_key(stabilizer: tuple[str, int]) -> tuple[int, int]: + stab_type, stab_idx = stabilizer + return (stab_idx, 0 if stab_type == "X" else 1) + + +def _balanced_data_batches( + patch: SurfacePatch, + stabilizers: list[tuple[str, int]], + effective_budget: int, +) -> list[list[tuple[str, int]]]: + """Greedily spread data-qubit supports inside each constrained batch.""" + if effective_budget >= len(stabilizers): + return [stabilizers] + + by_stabilizer = _stabilizer_support_lookup(patch) + canonical_order = {stabilizer: index for index, stabilizer in enumerate(stabilizers)} + remaining = set(stabilizers) + batches: list[list[tuple[str, int]]] = [] + for batch_index, target_size in enumerate(_balanced_batch_sizes(len(stabilizers), effective_budget)): + batch: list[tuple[str, int]] = [] + touched_data: set[int] = set() + x_count = 0 + z_count = 0 + + while remaining and len(batch) < target_size: + score_state = (frozenset(touched_data), x_count, z_count, batch_index) + + def score( + stabilizer: tuple[str, int], + *, + state: tuple[frozenset[int], int, int, int] = score_state, + ) -> tuple[int, int, float, float, int]: + bound_touched_data, bound_x_count, bound_z_count, bound_batch_index = state + support, row, col = by_stabilizer[stabilizer] + overlap = sum(data_qubit in bound_touched_data for data_qubit in support) + next_x = bound_x_count + (1 if stabilizer[0] == "X" else 0) + next_z = bound_z_count + (1 if stabilizer[0] == "Z" else 0) + type_imbalance = abs(next_x - next_z) + # Alternate the spatial sweep direction between batches so the + # deterministic tie-break does not repeatedly privilege the + # same edge of the patch. + row_key = row if bound_batch_index % 2 == 0 else -row + col_key = col if bound_batch_index % 2 == 0 else -col + return (overlap, type_imbalance, row_key, col_key, canonical_order[stabilizer]) + + selected = min(remaining, key=score) + remaining.remove(selected) + batch.append(selected) + support, _row, _col = by_stabilizer[selected] + touched_data.update(support) + if selected[0] == "X": + x_count += 1 + else: + z_count += 1 + + batches.append(batch) + + return batches + + +def _balanced_batch_sizes(total: int, effective_budget: int) -> list[int]: + """Return near-equal batch sizes, each at most ``effective_budget``.""" + num_batches = ceil(total / effective_budget) + base_size, remainder = divmod(total, num_batches) + return [base_size + (1 if index < remainder else 0) for index in range(num_batches)] + + +def _stabilizer_support_lookup( + patch: SurfacePatch, +) -> dict[tuple[str, int], tuple[tuple[int, ...], float, float]]: + geom = patch.geometry + lookup: dict[tuple[str, int], tuple[tuple[int, ...], float, float]] = {} + + def add(stabilizers: Iterable[object], stab_type: str) -> None: + for stabilizer in stabilizers: + support = tuple(int(q) for q in stabilizer.data_qubits) + positions = [geom.id_to_pos[q] for q in support] + row = sum(pos[0] for pos in positions) / len(positions) + col = sum(pos[1] for pos in positions) / len(positions) + lookup[(stab_type, int(stabilizer.index))] = (support, row, col) + + add(geom.x_stabilizers, "X") + add(geom.z_stabilizers, "Z") + return lookup diff --git a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py index 3876ab7d1..7cd4b355a 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py @@ -10,6 +10,12 @@ from dataclasses import dataclass from typing import Any +from pecos.qec.surface._ancilla_batching import ( + BALANCED_DATA_ANCILLA_SCHEDULE, + DEFAULT_ANCILLA_SCHEDULE, + normalize_ancilla_schedule, +) + CHECK_PLAN_METADATA_FORMAT = "pecos.surface.check_plan" CHECK_PLAN_METADATA_VERSION = 1 CHECK_PLAN_HASH_ALGORITHM = "sha256" @@ -63,6 +69,31 @@ def _normalize_check_plan_id(check_plan: str) -> str: }, "prefix_policy": "none", }, + "cx_balanced_data_v1": { + "plan_id": "cx_balanced_data_v1", + "interaction_basis": "cx", + "synthesis_identity": { + "family": "cx", + "szz_phase_pattern": "none", + "interaction_order": "pecos-default", + "ancilla_schedule": "balanced-data-v1", + }, + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + "ancilla_batch_policy": "balanced-data-v1", + }, + "x_check": { + "template": "current_cx_x_check_v1", + "measurement_sign_policy": "none", + }, + "z_check": { + "template": "current_cx_z_check_v1", + "measurement_sign_policy": "none", + }, + "prefix_policy": "none", + }, "szz_current_v1": { "plan_id": "szz_current_v1", "interaction_basis": "szz", @@ -119,6 +150,64 @@ def _normalize_check_plan_id(check_plan: str) -> str: }, "prefix_policy": "forward_flow_virtual_z_v1", }, + "szz_balanced_data_v1": { + "plan_id": "szz_balanced_data_v1", + "interaction_basis": "szz", + "synthesis_identity": { + "family": "szz", + "szz_phase_pattern": "standard", + "interaction_order": "pecos-default", + "ancilla_schedule": "balanced-data-v1", + }, + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + "ancilla_batch_policy": "balanced-data-v1", + }, + "x_check": { + "template": "current_szz_x_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "z_check": { + "template": "current_szz_z_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "prefix_policy": "forward_flow_virtual_z_v1", + }, + "szz_boundary_first_balanced_data_v1": { + "plan_id": "szz_boundary_first_balanced_data_v1", + "interaction_basis": "szz", + "synthesis_identity": { + "family": "szz", + "szz_phase_pattern": "boundary-first", + "interaction_order": "pecos-default", + "ancilla_schedule": "balanced-data-v1", + }, + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + "ancilla_batch_policy": "balanced-data-v1", + }, + "x_check": { + "template": "current_szz_x_check_v1", + "sign_policy": "boundary_first_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "z_check": { + "template": "current_szz_z_check_v1", + "sign_policy": "boundary_first_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "prefix_policy": "forward_flow_virtual_z_v1", + }, } @@ -200,6 +289,18 @@ def resolve_surface_check_plan( ) +def ancilla_schedule_for_check_plan(resolved_plan: ResolvedSurfaceCheckPlan) -> str: + """Return the concrete ancilla-reuse schedule encoded by a check plan.""" + return normalize_ancilla_schedule( + str( + resolved_plan.synthesis_identity.get( + "ancilla_schedule", + DEFAULT_ANCILLA_SCHEDULE, + ), + ), + ) + + def require_current_surface_check_plan_renderer( resolved_plan: ResolvedSurfaceCheckPlan, *, @@ -215,20 +316,21 @@ def require_current_surface_check_plan_renderer( semantic = resolved_plan.semantic_content synthesis = resolved_plan.synthesis_identity schedule = semantic.get("schedule", {}) + ancilla_schedule = ancilla_schedule_for_check_plan(resolved_plan) if resolved_plan.interaction_basis == "cx": expected_synthesis = { "family": "cx", "szz_phase_pattern": "none", "interaction_order": "pecos-default", - "ancilla_schedule": "default", + "ancilla_schedule": ancilla_schedule, } else: expected_synthesis = { "family": "szz", "szz_phase_pattern": synthesis.get("szz_phase_pattern"), "interaction_order": "pecos-default", - "ancilla_schedule": "default", + "ancilla_schedule": ancilla_schedule, } if synthesis.get("szz_phase_pattern") not in {"standard", "boundary-first"}: msg = ( @@ -236,11 +338,19 @@ def require_current_surface_check_plan_renderer( f"with {CURRENT_SURFACE_CHECK_PLAN_RENDERER}; synthesis_identity={synthesis!r}" ) raise NotImplementedError(msg) + if ancilla_schedule not in {DEFAULT_ANCILLA_SCHEDULE, BALANCED_DATA_ANCILLA_SCHEDULE}: + msg = ( + f"{context} cannot realize check_plan={resolved_plan.plan_id!r} " + f"with {CURRENT_SURFACE_CHECK_PLAN_RENDERER}; ancilla_schedule={ancilla_schedule!r}" + ) + raise NotImplementedError(msg) expected_schedule = { "round_policy": "constant", "site_policy": "global", "edge_order": "current_surface_cnot_schedule_v1", } + if ancilla_schedule != DEFAULT_ANCILLA_SCHEDULE: + expected_schedule["ancilla_batch_policy"] = ancilla_schedule if synthesis != expected_synthesis or schedule != expected_schedule: msg = ( f"{context} cannot realize check_plan={resolved_plan.plan_id!r} " diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 36c7612f7..b3fbe5c5c 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -35,7 +35,11 @@ from pecos.qec.surface._ancilla_batching import ( normalize_ancilla_budget as _normalize_ancilla_budget, ) -from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan +from pecos.qec.surface._check_plan import ( + ancilla_schedule_for_check_plan, + require_current_surface_check_plan_renderer, + resolve_surface_check_plan, +) from pecos.qec.surface._clifford_deformation import ( resolve_surface_clifford_frame, ) @@ -930,6 +934,7 @@ def build_surface_code_circuit( resolved_plan, context="abstract surface-code circuit generation", ) + ancilla_schedule = ancilla_schedule_for_check_plan(resolved_plan) interaction_basis = _normalize_interaction_basis(resolved_plan.interaction_basis) resolved_clifford_frame = _resolve_szz_clifford_frame_for_builder( patch, @@ -955,7 +960,11 @@ def build_surface_code_circuit( ancilla_pool = list(range(num_data, num_data + effective_ancilla_budget)) x_ancilla_qubits = [-1] * num_x_anc z_ancilla_qubits = [-1] * num_z_anc - for batch in _batched_stabilizers(patch, effective_ancilla_budget): + for batch in _batched_stabilizers( + patch, + effective_ancilla_budget, + ancilla_schedule=ancilla_schedule, + ): for pool_idx, (stab_type, stab_idx) in enumerate(batch): if stab_type == "X": x_ancilla_qubits[stab_idx] = ancilla_pool[pool_idx] @@ -1036,6 +1045,7 @@ def emit_gate_local_twirl_layer( allocation, cnot_rounds, _szz_residual_plan_for_check_plan(patch, resolved_plan), + ancilla_schedule, resolved_clifford_frame, ), allocation, @@ -1121,7 +1131,11 @@ def emit_gate_local_twirl_layer( ops.append(SurfaceCircuitStep(OpType.TICK)) else: - stabilizer_batches = _batched_stabilizers(patch, effective_ancilla_budget) + stabilizer_batches = _batched_stabilizers( + patch, + effective_ancilla_budget, + ancilla_schedule=ancilla_schedule, + ) for batch in stabilizer_batches: init_batch = [(stab_type, stab_idx) for stab_type, stab_idx in batch if stab_type == init_stabilizer_type] if not init_batch: @@ -1226,7 +1240,11 @@ def emit_gate_local_twirl_layer( ops.append(SurfaceCircuitStep(OpType.TICK)) else: - stabilizer_batches = _batched_stabilizers(patch, effective_ancilla_budget) + stabilizer_batches = _batched_stabilizers( + patch, + effective_ancilla_budget, + ancilla_schedule=ancilla_schedule, + ) for batch in stabilizer_batches: ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Prepare ancillas")) batch_ancillas = { @@ -1316,6 +1334,7 @@ def _build_surface_code_circuit_szz( allocation: QubitAllocation, cnot_rounds: list[list[tuple[str, int, int]]], residual_plan: SzzResidualPlan, + ancilla_schedule: str, resolved_clifford_frame: ResolvedSurfaceCliffordFrame | None = None, ) -> list[SurfaceCircuitStep]: """Build the abstract SZZ/SZZdg surface-memory template.""" @@ -1414,7 +1433,11 @@ def stabilizer_batches_for(selected_type: str | None = None) -> list[list[tuple[ batch.extend(("Z", s.index) for s in geom.z_stabilizers) batches = [batch] else: - batches = _batched_stabilizers(patch, allocation_ancilla_count) + batches = _batched_stabilizers( + patch, + allocation_ancilla_count, + ancilla_schedule=ancilla_schedule, + ) if selected_type is None: return batches diff --git a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py index f6bdc67e2..aa69079cc 100644 --- a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py +++ b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py @@ -19,8 +19,10 @@ import pytest from pecos.qec.surface import SurfacePatch from pecos.qec.surface._ancilla_batching import ( + BALANCED_DATA_ANCILLA_SCHEDULE, batched_stabilizers, normalize_ancilla_budget, + normalize_ancilla_schedule, ) # --- normalize_ancilla_budget ----------------------------------------------- @@ -62,6 +64,21 @@ def test_normalize_ancilla_budget_rejects_non_int() -> None: normalize_ancilla_budget(8, "1") +# --- normalize_ancilla_schedule --------------------------------------------- + + +def test_normalize_ancilla_schedule_accepts_named_policies() -> None: + assert normalize_ancilla_schedule(None) == "default" + assert normalize_ancilla_schedule("default") == "default" + assert normalize_ancilla_schedule("balanced_data_v1") == BALANCED_DATA_ANCILLA_SCHEDULE + assert normalize_ancilla_schedule("balanced-data-v1") == BALANCED_DATA_ANCILLA_SCHEDULE + + +def test_normalize_ancilla_schedule_rejects_unknown_policy() -> None: + with pytest.raises(ValueError, match=r"ancilla_schedule must be one of"): + normalize_ancilla_schedule("row-scan") + + # --- batched_stabilizers (concrete sequences) ------------------------------- @@ -159,6 +176,53 @@ def test_batched_stabilizers_clamps_oversized_budget() -> None: assert len(huge[0]) == total +def test_balanced_data_schedule_is_explicit_and_deterministic() -> None: + """The balanced schedule is a named non-default policy, not a change to + the legacy batching semantics.""" + patch = SurfacePatch.create(distance=3) + + assert batched_stabilizers(patch, 2) == [ + [("X", 0), ("Z", 0)], + [("X", 1), ("Z", 1)], + [("X", 2), ("Z", 2)], + [("X", 3), ("Z", 3)], + ] + assert batched_stabilizers( + patch, + 2, + ancilla_schedule=BALANCED_DATA_ANCILLA_SCHEDULE, + ) == [ + [("X", 0), ("Z", 3)], + [("X", 3), ("Z", 0)], + [("Z", 1), ("Z", 2)], + [("X", 2), ("X", 1)], + ] + + +def test_balanced_data_schedule_spreads_d9_a17_batches() -> None: + """The d=9/a17 target gets equal-size batches and no data qubit whose + four adjacent checks all live in one batch.""" + patch = SurfacePatch.create(distance=9) + batches = batched_stabilizers( + patch, + 17, + ancilla_schedule=BALANCED_DATA_ANCILLA_SCHEDULE, + ) + + assert [len(batch) for batch in batches] == [16, 16, 16, 16, 16] + + batch_of = {stabilizer: batch_idx for batch_idx, batch in enumerate(batches) for stabilizer in batch} + touches_by_data: dict[int, set[int]] = {} + for stab in patch.geometry.x_stabilizers: + for data_qubit in stab.data_qubits: + touches_by_data.setdefault(data_qubit, set()).add(batch_of[("X", stab.index)]) + for stab in patch.geometry.z_stabilizers: + for data_qubit in stab.data_qubits: + touches_by_data.setdefault(data_qubit, set()).add(batch_of[("Z", stab.index)]) + + assert min(len(batch_indices) for batch_indices in touches_by_data.values()) > 1 + + # --- D1: pin emitted CX sequences for the constrained Guppy codegen -------- # The byte-identical traced-vs-traced DEM oracle and the lowered-qubit-stream # invariant catch many constrained-codegen errors, but not a wrong-CX-order / 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 8b6acba73..543950b00 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -21,7 +21,14 @@ def test_check_plan_default_resolves_to_cx_metadata() -> None: plan = resolve_surface_check_plan() - assert surface_check_plan_ids() == ("cx_standard_v1", "szz_boundary_first_v1", "szz_current_v1") + assert surface_check_plan_ids() == ( + "cx_balanced_data_v1", + "cx_standard_v1", + "szz_balanced_data_v1", + "szz_boundary_first_balanced_data_v1", + "szz_boundary_first_v1", + "szz_current_v1", + ) assert default_surface_check_plan_id() == "cx_standard_v1" assert default_surface_check_plan_id("SZZ") == "szz_current_v1" assert plan.plan_id == "cx_standard_v1" @@ -73,6 +80,39 @@ def test_boundary_first_szz_check_plan_resolves_to_concrete_synthesis() -> None: assert plan.semantic_content["z_check"]["sign_policy"] == "boundary_first_szz_sign_vector_v1" +@pytest.mark.parametrize( + ("plan_id", "interaction_basis", "phase_pattern"), + [ + ("cx_balanced_data_v1", "cx", "none"), + ("szz_balanced_data_v1", "szz", "standard"), + ("szz_boundary_first_balanced_data_v1", "szz", "boundary-first"), + ], +) +def test_balanced_data_check_plans_resolve_to_explicit_schedule( + plan_id: str, + interaction_basis: str, + phase_pattern: str, +) -> None: + from pecos.qec.surface._check_plan import ( + ancilla_schedule_for_check_plan, + require_current_surface_check_plan_renderer, + resolve_surface_check_plan, + ) + + plan = resolve_surface_check_plan(check_plan=plan_id) + + assert plan.interaction_basis == interaction_basis + assert plan.synthesis_identity == { + "family": interaction_basis, + "szz_phase_pattern": phase_pattern, + "interaction_order": "pecos-default", + "ancilla_schedule": "balanced-data-v1", + } + assert plan.semantic_content["schedule"]["ancilla_batch_policy"] == "balanced-data-v1" + assert ancilla_schedule_for_check_plan(plan) == "balanced-data-v1" + require_current_surface_check_plan_renderer(plan, context="unit-test") + + def test_current_renderer_rejects_unimplemented_plan_semantics() -> None: from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan @@ -142,6 +182,28 @@ def test_guppy_surface_code_accepts_check_plan_as_source_of_truth() -> None: assert program is not None +@pytest.mark.parametrize( + "check_plan", + [ + "cx_balanced_data_v1", + "szz_balanced_data_v1", + "szz_boundary_first_balanced_data_v1", + ], +) +def test_guppy_surface_code_accepts_balanced_data_check_plans(check_plan: str) -> None: + from pecos.guppy import make_surface_code + + program = make_surface_code( + distance=3, + num_rounds=1, + basis="Z", + ancilla_budget=2, + check_plan=check_plan, + ) + + assert program is not None + + def test_surface_code_memory_records_resolved_check_plan() -> None: from pecos.qec.surface import surface_code_memory From 099c6d338157ad984daa3a9bc04251463bd5f562 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 22 Jun 2026 19:52:32 -0600 Subject: [PATCH 122/150] Forward-flow SZZ Guppy data frames within helpers --- .../quantum-pecos/src/pecos/guppy/surface.py | 192 +++++++++++++++++- .../tests/guppy/test_surface_twirl_render.py | 14 +- 2 files changed, 194 insertions(+), 12 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index ff0e2ab90..f29111049 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -148,7 +148,9 @@ def generate_guppy_source( Uses a 4-round parallel schedule for syndrome extraction. The default ``interaction_basis="cx"`` emits the CNOT template; ``"szz"`` emits the - signed SZZ/SZZdg template with immediate data compensation. + signed SZZ/SZZdg template. SZZ helpers forward-flow single-qubit data + frames within each helper and then explicitly flush the frame before + returning, preserving the reusable Guppy result-tag structure. ``ancilla_budget=None`` (default) emits the unconstrained shape: one ancilla per stabilizer, all measured in parallel at the end of @@ -210,7 +212,11 @@ def generate_guppy_source( require_current_surface_check_plan_renderer, ) from pecos.qec.surface.circuit_builder import ( + _SZZ_FLOW_IDENTITY, + OpType, _resolve_szz_clifford_frame_for_builder, + _szz_flow_compose_pending_gate, + _szz_flow_is_virtual_z, _szz_memory_physical_axis_for_data, _szz_residual_plan_for_check_plan, ) @@ -419,6 +425,111 @@ def _append_szz_touch_compensation(target: list[str], indent: str, axis: str, si msg = f"unsupported Pauli axis {axis!r}" raise ValueError(msg) + def _szz_axis_rotation_to_z_gates(axis: str) -> tuple[OpType, ...]: + if axis == "X": + return (OpType.H,) + if axis == "Y": + return (OpType.SXDG,) + if axis == "Z": + return () + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + + def _szz_axis_rotation_from_z_gates(axis: str) -> tuple[OpType, ...]: + if axis == "X": + return (OpType.H,) + if axis == "Y": + return (OpType.SX,) + if axis == "Z": + return () + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + + def _szz_touch_compensation_gates(axis: str, sign: int) -> tuple[OpType, ...]: + if axis == "X": + return (OpType.SXDG if sign > 0 else OpType.SX,) + if axis == "Y": + return ( + OpType.SZDG, + OpType.SXDG if sign > 0 else OpType.SX, + OpType.SZ, + ) + if axis == "Z": + return (OpType.SZDG if sign > 0 else OpType.SZ,) + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + + def _append_szz_flow_gate(target: list[str], indent: str, op_type: OpType, qubit_expr: str) -> None: + op_name = { + OpType.H: "h", + OpType.SX: "v", + OpType.SXDG: "vdg", + OpType.SZ: "s", + OpType.SZDG: "sdg", + OpType.X: "x", + OpType.Z: "z", + }.get(op_type) + if op_name is None: + msg = f"unsupported Guppy SZZ forward-flow gate {op_type.name}" + raise ValueError(msg) + target.append(f"{indent}{op_name}({qubit_expr})") + + _szz_guppy_prefix_cache: dict[tuple[int, int], tuple[OpType, ...]] = {_SZZ_FLOW_IDENTITY: ()} + _szz_guppy_prefix_generators = ( + OpType.H, + OpType.SX, + OpType.SXDG, + OpType.SZ, + OpType.SZDG, + OpType.X, + OpType.Z, + ) + + def _szz_guppy_prefix_gates_for_pending(pending: tuple[int, int]) -> tuple[OpType, ...]: + """Return an exact Guppy-supported 1q Clifford sequence for ``pending``.""" + cached = _szz_guppy_prefix_cache.get(pending) + if cached is not None: + return cached + + queue: list[tuple[tuple[int, int], tuple[OpType, ...]]] = [(_SZZ_FLOW_IDENTITY, ())] + seen = {_SZZ_FLOW_IDENTITY} + while queue: + current, gates = queue.pop(0) + for gate in _szz_guppy_prefix_generators: + next_pending = _szz_flow_compose_pending_gate(current, gate) + if next_pending in seen: + continue + next_gates = (*gates, gate) + _szz_guppy_prefix_cache[next_pending] = next_gates + if next_pending == pending: + return next_gates + seen.add(next_pending) + queue.append((next_pending, next_gates)) + + msg = f"cannot synthesize Guppy SZZ forward-flow Clifford prefix for {pending!r}" + raise ValueError(msg) + + def _append_szz_flush_data_frame( + target: list[str], + indent: str, + pending_by_data: dict[int, tuple[int, int]], + *, + reason: str, + ) -> None: + """Materialize pending data Cliffords before returning a frame-free helper.""" + emitted_comment = False + for data_q, pending in sorted(pending_by_data.items()): + if pending == _SZZ_FLOW_IDENTITY: + continue + prefix = _szz_guppy_prefix_gates_for_pending(pending) + if prefix and not emitted_comment: + target.append("") + target.append(f"{indent}# Flush SZZ data frame before {reason}") + emitted_comment = True + for gate in prefix: + _append_szz_flow_gate(target, indent, gate, f"d{data_q}") + pending_by_data[data_q] = _SZZ_FLOW_IDENTITY + def _append_szz_logical_pauli(target: list[str], indent: str, axis: str, qubit_expr: str) -> None: if axis == "X": target.append(f"{indent}x({qubit_expr})") @@ -485,14 +596,37 @@ def _append_szz_layer( layer_gates: list[tuple[str, int, int]], ancilla_expr: Callable[[str, int], str], data_expr: Callable[[int], str], + pending_by_data: dict[int, tuple[int, int]] | None = None, ) -> None: + def compose_data(data_q: int, gates: tuple[OpType, ...]) -> None: + if pending_by_data is None: + for gate in gates: + _append_szz_flow_gate(target, indent, gate, data_expr(data_q)) + return + pending = pending_by_data.setdefault(data_q, _SZZ_FLOW_IDENTITY) + for gate in gates: + pending = _szz_flow_compose_pending_gate(pending, gate) + pending_by_data[data_q] = pending + + def discharge_data_for_szz(data_q: int) -> None: + if pending_by_data is None: + return + pending = pending_by_data.setdefault(data_q, _SZZ_FLOW_IDENTITY) + if pending == _SZZ_FLOW_IDENTITY or _szz_flow_is_virtual_z(pending): + return + prefix = _szz_guppy_prefix_gates_for_pending(pending) + for gate in prefix: + _append_szz_flow_gate(target, indent, gate, data_expr(data_q)) + pending_by_data[data_q] = _SZZ_FLOW_IDENTITY + target.append("") target.append(f"{indent}# SZZ round {rnd_idx + 1}") for stab_type, stab_idx, data_q in layer_gates: axis = _szz_physical_axis_for_touch(stab_type, stab_idx, data_q) - _append_szz_axis_rotation_to_z(target, indent, axis, data_expr(data_q)) + compose_data(data_q, _szz_axis_rotation_to_z_gates(axis)) for stab_type, stab_idx, data_q in layer_gates: + discharge_data_for_szz(data_q) sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] half_turns = "0.5" if sign > 0 else "-0.5" target.append( @@ -502,12 +636,12 @@ def _append_szz_layer( for stab_type, stab_idx, data_q in layer_gates: axis = _szz_physical_axis_for_touch(stab_type, stab_idx, data_q) - _append_szz_axis_rotation_from_z(target, indent, axis, data_expr(data_q)) + compose_data(data_q, _szz_axis_rotation_from_z_gates(axis)) for stab_type, stab_idx, data_q in layer_gates: sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] axis = _szz_physical_axis_for_touch(stab_type, stab_idx, data_q) - _append_szz_touch_compensation(target, indent, axis, sign, data_expr(data_q)) + compose_data(data_q, _szz_touch_compensation_gates(axis, sign)) lines.extend( [ @@ -552,6 +686,10 @@ def _append_szz_layer( lines.append(" # Unpack data qubits") _append_szz_data_unpack(lines, " ") + szz_syndrome_pending_by_data = ( + dict.fromkeys(range(num_data), _SZZ_FLOW_IDENTITY) if interaction_basis == "szz" else None + ) + lines.append(" # Allocate ancilla qubits (one per stabilizer)") lines.extend(f" ax{stab.index} = qubit()" for stab in geom.x_stabilizers) lines.extend(f" az{stab.index} = qubit()" for stab in geom.z_stabilizers) @@ -580,7 +718,15 @@ def _append_szz_layer( lines.extend(f" h(az{stab.index})" for stab in geom.z_stabilizers) for rnd_idx, rnd_gates in enumerate(rounds): - _append_szz_layer(lines, " ", rnd_idx, list(rnd_gates), _szz_ancilla_expr, _szz_data_expr) + _append_szz_layer( + lines, + " ", + rnd_idx, + list(rnd_gates), + _szz_ancilla_expr, + _szz_data_expr, + pending_by_data=szz_syndrome_pending_by_data, + ) lines.append("") lines.append(" # Hadamard on SZZ ancillas") @@ -631,6 +777,9 @@ def _append_szz_layer( if interaction_basis == "szz": lines.append(" # Unpack data qubits") _append_szz_data_unpack(lines, " ") + szz_syndrome_pending_by_data = ( + dict.fromkeys(range(num_data), _SZZ_FLOW_IDENTITY) if interaction_basis == "szz" else None + ) idx = 0 for batch_idx, batch in enumerate(batches): lines.append("") @@ -687,6 +836,7 @@ def _append_szz_layer( (stab_type, stab_idx) ], _szz_data_expr, + pending_by_data=szz_syndrome_pending_by_data, ) if interaction_basis == "cx": @@ -717,6 +867,10 @@ def _append_szz_layer( lines.extend(["", f" synx = array({x_calls})", f" synz = array({z_calls})", ""]) if interaction_basis == "szz": + if szz_syndrome_pending_by_data is None: + msg = "internal error: SZZ syndrome extraction did not initialize data-frame state" + raise ValueError(msg) + _append_szz_flush_data_frame(lines, " ", szz_syndrome_pending_by_data, reason="syndrome return") lines.append(f" surf = SurfaceCode_{dx}x{dz}({szz_data_args})") lines.append(f" return surf, Syndrome_{dx}x{dz}(synx, synz)") else: @@ -752,6 +906,9 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ) if interaction_basis == "szz": _append_szz_data_unpack(lines, " ") + szz_init_pending_by_data = ( + dict.fromkeys(range(num_data), _SZZ_FLOW_IDENTITY) if interaction_basis == "szz" else None + ) if not constrained: if stab_type == "X": @@ -793,7 +950,15 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: filtered = [(t, i, q) for t, i, q in rnd_gates if t == stab_type] if not filtered: continue - _append_szz_layer(lines, " ", rnd_idx, filtered, _szz_ancilla_expr, _szz_data_expr) + _append_szz_layer( + lines, + " ", + rnd_idx, + filtered, + _szz_ancilla_expr, + _szz_data_expr, + pending_by_data=szz_init_pending_by_data, + ) lines.append("") lines.append(" # Hadamard on SZZ ancillas") @@ -865,11 +1030,12 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: " ", rnd_idx, rnd_in_batch, - lambda selected_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[ - (selected_type, stab_idx) - ], - _szz_data_expr, - ) + lambda selected_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[ + (selected_type, stab_idx) + ], + _szz_data_expr, + pending_by_data=szz_init_pending_by_data, + ) if interaction_basis == "cx": if stab_type == "X": @@ -894,6 +1060,10 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: lines.append("") if interaction_basis == "szz": + if szz_init_pending_by_data is None: + msg = "internal error: SZZ init helper did not initialize data-frame state" + raise ValueError(msg) + _append_szz_flush_data_frame(lines, " ", szz_init_pending_by_data, reason=f"{function_name} return") lines.append(f" surf = SurfaceCode_{dx}x{dz}({szz_data_args})") lines.append(f" return surf, array({return_calls})") else: diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index abff4e129..f710863c7 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -89,6 +89,17 @@ def test_szz_basis_forks_guppy_module_cache_key(patch: SurfacePatch) -> None: assert "_cfglobal_axis_cycle_f" in framed_key +def test_szz_source_keeps_reusable_memory_body_and_flushes_helper_frame(patch: SurfacePatch) -> None: + src = generate_guppy_source(patch, interaction_basis="szz", num_rounds=2) + + assert "for _t in range(comptime(num_rounds)):" in src + assert "surf, syn = syndrome_extraction(surf)" in src + assert "# Flush SZZ data frame before syndrome return" in src + assert "# Flush SZZ data frame before init_z_basis return" in src + assert "# Flush SZZ data frame before init_x_basis return" in src + assert 'result("final", final)' in src + + def test_szz_axis_cycle_frame_source_uses_y_check_scaffold(patch: SurfacePatch) -> None: src = generate_guppy_source( patch, @@ -98,7 +109,8 @@ def test_szz_axis_cycle_frame_source_uses_y_check_scaffold(patch: SurfacePatch) assert "from guppylang.std.quantum import" in src assert ", x, y, z" in src - assert "sdg(d0)\n vdg(d0)\n s(d0)" in src + assert "# Flush SZZ data frame before syndrome return" in src + assert "sdg(d0)\n vdg(d0)\n s(d0)" not in src assert "def apply_logical_x" in src assert "y(surf.d" in src assert "def apply_logical_z" in src From 0f943105520aeae563d8b459d273c1880f0748e0 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 22 Jun 2026 20:43:12 -0600 Subject: [PATCH 123/150] Add optional SZZ runtime barriers --- .../quantum-pecos/src/pecos/guppy/surface.py | 59 +++++++++++++++---- .../tests/guppy/test_surface_twirl_render.py | 47 +++++++++++++++ 2 files changed, 95 insertions(+), 11 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index f29111049..b746b3c1e 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -34,7 +34,7 @@ class _ModuleState: # Keyed by full patch identity + effective budget (dx, dz, orientation, # rotated, effective_budget) so distinct patch geometries -- e.g. rotated # vs non-rotated at the same dx/dz -- never collide on a cached module. - distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str, str, str | None], dict]] = {} + distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str, str, str | None, bool], dict]] = {} _state = _ModuleState() @@ -143,6 +143,7 @@ def generate_guppy_source( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool = False, ) -> str: """Generate Guppy source code for a surface code patch. @@ -199,6 +200,11 @@ def generate_guppy_source( clifford_frame_policy: Optional source-level Clifford-deformation policy. Currently supported for SZZ global axis-cycle and checkerboard XZZX/ZXXZ deformed-check frames. + szz_runtime_barriers: When true, emit qsystem RuntimeBarrier + operations immediately before SZZ/SZZdg host regions. This has no + ideal-unitary effect, but gives runtimes a principled scheduling + boundary before local data-frame pulses are discharged into their + entangling host. Returns: Python/Guppy source code as a string. @@ -287,7 +293,7 @@ def generate_guppy_source( "", "from guppylang import guppy", "from guppylang.std.angles import angle", - "from guppylang.std.builtins import array, owned, result", + "from guppylang.std.builtins import array, barrier, owned, result", "from guppylang.std.qsystem.functional import zz_phase", "from guppylang.std.quantum import discard, h, measure, measure_array, qubit, s, sdg, v, vdg, x, y, z", ] @@ -626,6 +632,8 @@ def discharge_data_for_szz(data_q: int) -> None: compose_data(data_q, _szz_axis_rotation_to_z_gates(axis)) for stab_type, stab_idx, data_q in layer_gates: + if szz_runtime_barriers: + target.append(f"{indent}barrier({ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)})") discharge_data_for_szz(data_q) sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] half_turns = "0.5" if sign > 0 else "-0.5" @@ -1818,12 +1826,14 @@ def _validate_surface_memory_distance(d: int) -> None: def _guppy_module_cache_key( patch: "SurfacePatch", effective_budget: int, + *, twirl: "TwirlConfig | None" = None, rng: "GuppyRngMaskConfig | None" = None, num_rounds: int | None = None, interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool = False, ) -> str: """Filesystem-safe cache key spanning full patch identity + budget + twirl. @@ -1834,7 +1844,8 @@ def _guppy_module_cache_key( Twirled source is Python-time unrolled, so the cache key includes ``num_rounds`` in addition to the structural twirl fields, runtime - frame-output mode, RNG seed, and activation-probability threshold. + frame-output mode, RNG seed, activation-probability threshold, and + whether qsystem runtime barriers are emitted around SZZ host regions. """ geom = patch.geometry rotated = "rot" if geom.rotated else "unrot" @@ -1846,9 +1857,10 @@ def _guppy_module_cache_key( interaction_part = "" if interaction_basis == "cx" else f"_ib{interaction_basis}" check_plan_part = "" if check_plan is None else f"_cp{resolved_plan.plan_id}" frame_part = "" if clifford_frame_policy is None else f"_cf{str(clifford_frame_policy).lower().replace('-', '_')}" + runtime_barrier_part = "_szzrb" if szz_runtime_barriers else "" base = ( f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}" - f"_b{effective_budget}{interaction_part}{check_plan_part}{frame_part}" + f"_b{effective_budget}{interaction_part}{check_plan_part}{frame_part}{runtime_barrier_part}" ) if twirl is None: return base @@ -1874,6 +1886,7 @@ def _load_guppy_module( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool = False, ) -> dict: """Load a Guppy module for a patch, using caching. @@ -1883,7 +1896,7 @@ def _load_guppy_module( ``ancilla_budget >= total_ancilla`` resolve to the same cache entry while distinct patch geometries never collide. Twirled source also keys on twirl fields, frame-output mode, activation-probability threshold, - RNG seed, and round count. + RNG seed, round count, and qsystem runtime barrier usage. Args: patch: SurfacePatch with geometry @@ -1896,6 +1909,8 @@ def _load_guppy_module( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. + szz_runtime_barriers: Whether generated SZZ source includes qsystem + RuntimeBarrier operations before host regions. Returns: Module dictionary with generated functions @@ -1913,12 +1928,13 @@ def _load_guppy_module( cache_key = _guppy_module_cache_key( patch, effective_budget, - twirl, - rng, - num_rounds, - interaction_basis, - resolved_plan.plan_id if check_plan is not None else None, - clifford_frame_policy, + twirl=twirl, + rng=rng, + num_rounds=num_rounds, + interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id if check_plan is not None else None, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, ) if cache_key in _state.module_cache: @@ -1933,6 +1949,7 @@ def _load_guppy_module( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, ) # Write to temp file (required for Guppy introspection). @@ -1966,6 +1983,7 @@ def generate_memory_experiment( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool = False, ) -> object: """Generate a memory experiment for a patch. @@ -1981,6 +1999,8 @@ def generate_memory_experiment( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. + szz_runtime_barriers: Emit qsystem RuntimeBarrier operations before + SZZ/SZZdg host regions. Returns: Guppy function for the experiment @@ -1994,6 +2014,7 @@ def generate_memory_experiment( interaction_basis=interaction_basis, check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, ) if basis.upper() == "Z": @@ -2087,6 +2108,7 @@ def generate_surface_code_module( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool = False, ) -> str: """Generate source code for a distance-d surface code module. @@ -2099,6 +2121,8 @@ def generate_surface_code_module( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. + szz_runtime_barriers: Emit qsystem RuntimeBarrier operations before + SZZ/SZZdg host regions. Returns: Python/Guppy source code as a string @@ -2114,6 +2138,7 @@ def generate_surface_code_module( interaction_basis=interaction_basis, check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, ) @@ -2124,6 +2149,7 @@ def _surface_code_module_for_patch( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool = False, ) -> dict: """Load + cache a surface-code module for an arbitrary patch. @@ -2152,6 +2178,7 @@ def _surface_code_module_for_patch( interaction_basis, resolved_plan.plan_id, None if clifford_frame_policy is None else str(clifford_frame_policy).lower().replace("-", "_"), + bool(szz_runtime_barriers), ) if cache_key in _state.distance_module_cache: @@ -2163,6 +2190,7 @@ def _surface_code_module_for_patch( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, ) # Metadata derived from the actual patch geometry. @@ -2173,6 +2201,7 @@ def _surface_code_module_for_patch( module["interaction_basis"] = interaction_basis module["check_plan"] = resolved_plan.plan_id module["clifford_frame_policy"] = clifford_frame_policy + module["szz_runtime_barriers"] = bool(szz_runtime_barriers) module["resolved_check_plan"] = resolved_plan.resolved_metadata module["resolved_check_plan_hash"] = resolved_plan.resolved_hash @@ -2187,6 +2216,7 @@ def get_surface_code_module( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool = False, ) -> dict: """Get a loaded surface code module for distance d. @@ -2198,6 +2228,8 @@ def get_surface_code_module( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. + szz_runtime_barriers: Emit qsystem RuntimeBarrier operations before + SZZ/SZZdg host regions. Returns: Dictionary with module contents and metadata @@ -2212,6 +2244,7 @@ def get_surface_code_module( interaction_basis=interaction_basis, check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, ) @@ -2224,6 +2257,7 @@ def make_surface_code( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool = False, ) -> object: """Create a surface code memory experiment. @@ -2241,6 +2275,8 @@ def make_surface_code( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. + szz_runtime_barriers: Emit qsystem RuntimeBarrier operations before + SZZ/SZZdg host regions. Returns: Compiled Guppy program @@ -2255,6 +2291,7 @@ def make_surface_code( interaction_basis=interaction_basis, check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, ) factory = module["make_memory_z"] if basis.upper() == "Z" else module["make_memory_x"] diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index f710863c7..6603fc3a7 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -81,12 +81,20 @@ def test_szz_basis_forks_guppy_module_cache_key(patch: SurfacePatch) -> None: interaction_basis="szz", clifford_frame_policy="global_axis_cycle_f", ) + barrier_key = _guppy_module_cache_key( + patch, + effective_budget=8, + interaction_basis="szz", + szz_runtime_barriers=True, + ) assert cx_key != szz_key assert framed_key != szz_key + assert barrier_key != szz_key assert "_ibcx" not in cx_key assert "_ibszz" in szz_key assert "_cfglobal_axis_cycle_f" in framed_key + assert "_szzrb" in barrier_key def test_szz_source_keeps_reusable_memory_body_and_flushes_helper_frame(patch: SurfacePatch) -> None: @@ -100,6 +108,34 @@ def test_szz_source_keeps_reusable_memory_body_and_flushes_helper_frame(patch: S assert 'result("final", final)' in src +def test_szz_runtime_barriers_precede_data_prefix_and_host(patch: SurfacePatch) -> None: + src = generate_guppy_source( + patch, + interaction_basis="szz", + szz_runtime_barriers=True, + ) + + assert "from guppylang.std.builtins import array, barrier, owned, result" in src + assert "barrier(" in src + assert "barrier(" not in generate_guppy_source( + patch, + interaction_basis="szz", + ) + + lines = src.splitlines() + for index, line in enumerate(lines): + if "vdg(d1)" in line: + nearby = lines[index - 2 : index + 2] + assert "barrier(" in nearby[0] + assert "h(d1)" in nearby[1] + assert "vdg(d1)" in nearby[2] + assert "zz_phase(" in nearby[3] + break + else: + msg = "expected an SZZ touch with a Vdg data-prefix pulse" + raise AssertionError(msg) + + def test_szz_axis_cycle_frame_source_uses_y_check_scaffold(patch: SurfacePatch) -> None: src = generate_guppy_source( patch, @@ -138,6 +174,17 @@ def test_szz_axis_cycle_memory_experiment_compiles_to_guppy_function(patch: Surf assert fn is not None +def test_szz_runtime_barrier_memory_experiment_compiles_to_guppy_function(patch: SurfacePatch) -> None: + fn = generate_memory_experiment( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + szz_runtime_barriers=True, + ) + assert fn is not None + + def test_twirl_source_unrolls_rng_masks_and_runtime_paulis(patch: SurfacePatch) -> None: src = generate_guppy_source( patch, From dc1dfe417060e37345136b55fd99e9df2f012c81 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 22 Jun 2026 21:10:39 -0600 Subject: [PATCH 124/150] Add selective SZZ runtime barrier policy --- .../quantum-pecos/src/pecos/guppy/surface.py | 126 +++++++++++++----- .../tests/guppy/test_surface_twirl_render.py | 44 +++++- 2 files changed, 137 insertions(+), 33 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index b746b3c1e..be0bde3af 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -34,12 +34,42 @@ class _ModuleState: # Keyed by full patch identity + effective budget (dx, dz, orientation, # rotated, effective_budget) so distinct patch geometries -- e.g. rotated # vs non-rotated at the same dx/dz -- never collide on a cached module. - distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str, str, str | None, bool], dict]] = {} + distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str, str, str | None, str], dict]] = {} _state = _ModuleState() +_SZZ_RUNTIME_BARRIER_POLICY_NONE = "none" +_SZZ_RUNTIME_BARRIER_POLICY_ALL = "all" +_SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX = "data-prefix" +_SZZ_RUNTIME_BARRIER_POLICIES = frozenset( + { + _SZZ_RUNTIME_BARRIER_POLICY_NONE, + _SZZ_RUNTIME_BARRIER_POLICY_ALL, + _SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX, + }, +) + + +def _normalize_szz_runtime_barrier_policy(value: bool | str) -> str: + """Return the canonical SZZ runtime-barrier policy token.""" + if isinstance(value, bool): + return _SZZ_RUNTIME_BARRIER_POLICY_ALL if value else _SZZ_RUNTIME_BARRIER_POLICY_NONE + normalized = str(value).strip().lower().replace("_", "-") + if normalized in {"1", "true", "t", "yes", "y", "on"}: + return _SZZ_RUNTIME_BARRIER_POLICY_ALL + if normalized in {"0", "false", "f", "no", "n", "off"}: + return _SZZ_RUNTIME_BARRIER_POLICY_NONE + if normalized not in _SZZ_RUNTIME_BARRIER_POLICIES: + msg = ( + "szz_runtime_barriers must be a boolean or one of " + f"{sorted(_SZZ_RUNTIME_BARRIER_POLICIES)}, got {value!r}" + ) + raise ValueError(msg) + return normalized + + def _normalize_surface_interaction_basis(interaction_basis: str) -> str: from pecos.qec.surface.circuit_builder import _normalize_interaction_basis @@ -143,7 +173,7 @@ def generate_guppy_source( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, - szz_runtime_barriers: bool = False, + szz_runtime_barriers: bool | str = False, ) -> str: """Generate Guppy source code for a surface code patch. @@ -200,11 +230,14 @@ def generate_guppy_source( clifford_frame_policy: Optional source-level Clifford-deformation policy. Currently supported for SZZ global axis-cycle and checkerboard XZZX/ZXXZ deformed-check frames. - szz_runtime_barriers: When true, emit qsystem RuntimeBarrier - operations immediately before SZZ/SZZdg host regions. This has no - ideal-unitary effect, but gives runtimes a principled scheduling - boundary before local data-frame pulses are discharged into their - entangling host. + szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. ``False`` + or ``"none"`` emits no barriers; ``True`` or ``"all"`` emits a + public Guppy ``barrier`` before every SZZ/SZZdg host region; + ``"data-prefix"`` emits one only when a non-virtual local + data-frame pulse is about to be discharged into that host. Barriers + have no ideal-unitary effect, but give runtimes a principled + scheduling boundary before selected local data-frame pulses and + their entangling host. Returns: Python/Guppy source code as a string. @@ -237,6 +270,13 @@ def generate_guppy_source( ) ancilla_schedule = ancilla_schedule_for_check_plan(resolved_plan) interaction_basis = resolved_plan.interaction_basis + szz_runtime_barrier_policy = _normalize_szz_runtime_barrier_policy(szz_runtime_barriers) + if ( + interaction_basis != "szz" + and szz_runtime_barrier_policy != _SZZ_RUNTIME_BARRIER_POLICY_NONE + ): + msg = "szz_runtime_barriers is only supported for interaction_basis='szz'" + raise ValueError(msg) resolved_clifford_frame = _resolve_szz_clifford_frame_for_builder( patch, interaction_basis=interaction_basis, @@ -632,8 +672,22 @@ def discharge_data_for_szz(data_q: int) -> None: compose_data(data_q, _szz_axis_rotation_to_z_gates(axis)) for stab_type, stab_idx, data_q in layer_gates: - if szz_runtime_barriers: - target.append(f"{indent}barrier({ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)})") + pending = ( + _SZZ_FLOW_IDENTITY + if pending_by_data is None + else pending_by_data.setdefault(data_q, _SZZ_FLOW_IDENTITY) + ) + has_data_prefix = ( + pending != _SZZ_FLOW_IDENTITY and not _szz_flow_is_virtual_z(pending) + ) + if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_ALL or ( + szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX + and has_data_prefix + ): + target.append( + f"{indent}barrier({ancilla_expr(stab_type, stab_idx)}, " + f"{data_expr(data_q)})", + ) discharge_data_for_szz(data_q) sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] half_turns = "0.5" if sign > 0 else "-0.5" @@ -1833,7 +1887,7 @@ def _guppy_module_cache_key( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, - szz_runtime_barriers: bool = False, + szz_runtime_barriers: bool | str = False, ) -> str: """Filesystem-safe cache key spanning full patch identity + budget + twirl. @@ -1845,7 +1899,7 @@ def _guppy_module_cache_key( Twirled source is Python-time unrolled, so the cache key includes ``num_rounds`` in addition to the structural twirl fields, runtime frame-output mode, RNG seed, activation-probability threshold, and - whether qsystem runtime barriers are emitted around SZZ host regions. + the SZZ runtime-barrier policy. """ geom = patch.geometry rotated = "rot" if geom.rotated else "unrot" @@ -1854,10 +1908,21 @@ def _guppy_module_cache_key( check_plan=check_plan, ) interaction_basis = resolved_plan.interaction_basis + szz_runtime_barrier_policy = _normalize_szz_runtime_barrier_policy(szz_runtime_barriers) + if ( + interaction_basis != "szz" + and szz_runtime_barrier_policy != _SZZ_RUNTIME_BARRIER_POLICY_NONE + ): + msg = "szz_runtime_barriers is only supported for interaction_basis='szz'" + raise ValueError(msg) interaction_part = "" if interaction_basis == "cx" else f"_ib{interaction_basis}" check_plan_part = "" if check_plan is None else f"_cp{resolved_plan.plan_id}" frame_part = "" if clifford_frame_policy is None else f"_cf{str(clifford_frame_policy).lower().replace('-', '_')}" - runtime_barrier_part = "_szzrb" if szz_runtime_barriers else "" + runtime_barrier_part = ( + "" + if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_NONE + else f"_szzrb-{szz_runtime_barrier_policy}" + ) base = ( f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}" f"_b{effective_budget}{interaction_part}{check_plan_part}{frame_part}{runtime_barrier_part}" @@ -1886,7 +1951,7 @@ def _load_guppy_module( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, - szz_runtime_barriers: bool = False, + szz_runtime_barriers: bool | str = False, ) -> dict: """Load a Guppy module for a patch, using caching. @@ -1896,7 +1961,7 @@ def _load_guppy_module( ``ancilla_budget >= total_ancilla`` resolve to the same cache entry while distinct patch geometries never collide. Twirled source also keys on twirl fields, frame-output mode, activation-probability threshold, - RNG seed, round count, and qsystem runtime barrier usage. + RNG seed, round count, and SZZ runtime-barrier policy. Args: patch: SurfacePatch with geometry @@ -1909,8 +1974,7 @@ def _load_guppy_module( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. - szz_runtime_barriers: Whether generated SZZ source includes qsystem - RuntimeBarrier operations before host regions. + szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. Returns: Module dictionary with generated functions @@ -1983,7 +2047,7 @@ def generate_memory_experiment( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, - szz_runtime_barriers: bool = False, + szz_runtime_barriers: bool | str = False, ) -> object: """Generate a memory experiment for a patch. @@ -1999,8 +2063,7 @@ def generate_memory_experiment( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. - szz_runtime_barriers: Emit qsystem RuntimeBarrier operations before - SZZ/SZZdg host regions. + szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. Returns: Guppy function for the experiment @@ -2108,7 +2171,7 @@ def generate_surface_code_module( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, - szz_runtime_barriers: bool = False, + szz_runtime_barriers: bool | str = False, ) -> str: """Generate source code for a distance-d surface code module. @@ -2121,8 +2184,7 @@ def generate_surface_code_module( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. - szz_runtime_barriers: Emit qsystem RuntimeBarrier operations before - SZZ/SZZdg host regions. + szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. Returns: Python/Guppy source code as a string @@ -2149,7 +2211,7 @@ def _surface_code_module_for_patch( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, - szz_runtime_barriers: bool = False, + szz_runtime_barriers: bool | str = False, ) -> dict: """Load + cache a surface-code module for an arbitrary patch. @@ -2166,6 +2228,7 @@ def _surface_code_module_for_patch( check_plan=check_plan, ) interaction_basis = resolved_plan.interaction_basis + szz_runtime_barrier_policy = _normalize_szz_runtime_barrier_policy(szz_runtime_barriers) geom = patch.geometry total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) @@ -2178,7 +2241,7 @@ def _surface_code_module_for_patch( interaction_basis, resolved_plan.plan_id, None if clifford_frame_policy is None else str(clifford_frame_policy).lower().replace("-", "_"), - bool(szz_runtime_barriers), + szz_runtime_barrier_policy, ) if cache_key in _state.distance_module_cache: @@ -2190,7 +2253,7 @@ def _surface_code_module_for_patch( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, clifford_frame_policy=clifford_frame_policy, - szz_runtime_barriers=szz_runtime_barriers, + szz_runtime_barriers=szz_runtime_barrier_policy, ) # Metadata derived from the actual patch geometry. @@ -2201,7 +2264,8 @@ def _surface_code_module_for_patch( module["interaction_basis"] = interaction_basis module["check_plan"] = resolved_plan.plan_id module["clifford_frame_policy"] = clifford_frame_policy - module["szz_runtime_barriers"] = bool(szz_runtime_barriers) + module["szz_runtime_barriers"] = szz_runtime_barrier_policy != _SZZ_RUNTIME_BARRIER_POLICY_NONE + module["szz_runtime_barrier_policy"] = szz_runtime_barrier_policy module["resolved_check_plan"] = resolved_plan.resolved_metadata module["resolved_check_plan_hash"] = resolved_plan.resolved_hash @@ -2216,7 +2280,7 @@ def get_surface_code_module( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, - szz_runtime_barriers: bool = False, + szz_runtime_barriers: bool | str = False, ) -> dict: """Get a loaded surface code module for distance d. @@ -2228,8 +2292,7 @@ def get_surface_code_module( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. - szz_runtime_barriers: Emit qsystem RuntimeBarrier operations before - SZZ/SZZdg host regions. + szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. Returns: Dictionary with module contents and metadata @@ -2257,7 +2320,7 @@ def make_surface_code( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, - szz_runtime_barriers: bool = False, + szz_runtime_barriers: bool | str = False, ) -> object: """Create a surface code memory experiment. @@ -2275,8 +2338,7 @@ def make_surface_code( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. - szz_runtime_barriers: Emit qsystem RuntimeBarrier operations before - SZZ/SZZdg host regions. + szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. Returns: Compiled Guppy program diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index 6603fc3a7..05676e9ff 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -87,14 +87,22 @@ def test_szz_basis_forks_guppy_module_cache_key(patch: SurfacePatch) -> None: interaction_basis="szz", szz_runtime_barriers=True, ) + prefix_barrier_key = _guppy_module_cache_key( + patch, + effective_budget=8, + interaction_basis="szz", + szz_runtime_barriers="data-prefix", + ) assert cx_key != szz_key assert framed_key != szz_key assert barrier_key != szz_key + assert prefix_barrier_key != barrier_key assert "_ibcx" not in cx_key assert "_ibszz" in szz_key assert "_cfglobal_axis_cycle_f" in framed_key - assert "_szzrb" in barrier_key + assert "_szzrb-all" in barrier_key + assert "_szzrb-data-prefix" in prefix_barrier_key def test_szz_source_keeps_reusable_memory_body_and_flushes_helper_frame(patch: SurfacePatch) -> None: @@ -136,6 +144,40 @@ def test_szz_runtime_barriers_precede_data_prefix_and_host(patch: SurfacePatch) raise AssertionError(msg) +def test_szz_data_prefix_runtime_barriers_only_guard_real_prefixes(patch: SurfacePatch) -> None: + all_src = generate_guppy_source( + patch, + interaction_basis="szz", + szz_runtime_barriers="all", + ) + prefix_src = generate_guppy_source( + patch, + interaction_basis="szz", + szz_runtime_barriers="data-prefix", + ) + + assert "barrier(" in prefix_src + assert prefix_src.count("barrier(") < all_src.count("barrier(") + + lines = prefix_src.splitlines() + for index, line in enumerate(lines): + if "vdg(d1)" in line: + nearby = lines[index - 2 : index + 2] + assert "barrier(" in nearby[0] + assert "h(d1)" in nearby[1] + assert "vdg(d1)" in nearby[2] + assert "zz_phase(" in nearby[3] + break + else: + msg = "expected an SZZ touch with a Vdg data-prefix pulse" + raise AssertionError(msg) + + +def test_szz_runtime_barriers_reject_cx_source(patch: SurfacePatch) -> None: + with pytest.raises(ValueError, match="interaction_basis='szz'"): + generate_guppy_source(patch, szz_runtime_barriers="data-prefix") + + def test_szz_axis_cycle_frame_source_uses_y_check_scaffold(patch: SurfacePatch) -> None: src = generate_guppy_source( patch, From 16e351546c2bbf8d29c30143e291ca4abed47333 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 24 Jun 2026 21:48:31 -0600 Subject: [PATCH 125/150] Add generic trace metadata plumbing for QIS replay --- crates/pecos-qis-ffi-types/src/lib.rs | 2 +- crates/pecos-qis-ffi-types/src/operations.rs | 38 +++ crates/pecos-qis-ffi/src/ffi.rs | 179 +++++++++++- crates/pecos-qis-ffi/src/lib.rs | 2 +- crates/pecos-qis/src/ccengine.rs | 255 ++++++++++++++---- crates/pecos-qis/src/runtime.rs | 18 +- crates/pecos-qis/src/selene_runtime.rs | 9 +- .../src/pecos/qec/surface/decode.py | 20 ++ .../tests/qec/test_from_guppy_dem.py | 25 ++ 9 files changed, 496 insertions(+), 52 deletions(-) diff --git a/crates/pecos-qis-ffi-types/src/lib.rs b/crates/pecos-qis-ffi-types/src/lib.rs index 94cc36807..26da989e2 100644 --- a/crates/pecos-qis-ffi-types/src/lib.rs +++ b/crates/pecos-qis-ffi-types/src/lib.rs @@ -7,7 +7,7 @@ mod operations; -pub use operations::{NamedResultTrace, Operation, QuantumOp}; +pub use operations::{LoweredQuantumOp, NamedResultTrace, Operation, QuantumOp, TraceMetadata}; const DEFAULT_OPERATION_CAPACITY: usize = 1024; const DEFAULT_MEASUREMENT_CAPACITY: usize = 256; diff --git a/crates/pecos-qis-ffi-types/src/operations.rs b/crates/pecos-qis-ffi-types/src/operations.rs index 61cf38b11..103828414 100644 --- a/crates/pecos-qis-ffi-types/src/operations.rs +++ b/crates/pecos-qis-ffi-types/src/operations.rs @@ -3,6 +3,11 @@ //! This module defines the quantum operations that can be collected by the interface //! and later executed by a runtime. +use std::collections::BTreeMap; + +/// Structured metadata attached to QIS operations or lowered quantum operations. +pub type TraceMetadata = BTreeMap; + /// Runtime provenance for a named `result(...)` output. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct NamedResultTrace { @@ -20,6 +25,13 @@ pub enum Operation { /// Quantum gate operation Quantum(QuantumOp), + /// Source-level metadata intended to annotate subsequent lowered operations. + /// + /// Runtimes may preserve this metadata when lowering, scheduling, or expanding + /// operations. PECOS's direct lowering path attaches it to the next emitted + /// simulator gate and then clears it. + TraceMetadata { metadata: TraceMetadata }, + /// Allocate a qubit AllocateQubit { id: usize }, @@ -86,6 +98,32 @@ pub enum QuantumOp { Reset(usize), } +/// A lowered quantum operation plus any provenance supplied by the lowering runtime. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct LoweredQuantumOp { + /// Lowered operation to send to the quantum/noise engine. + pub op: QuantumOp, + /// Generic trace/source metadata associated with this lowered operation. + pub metadata: TraceMetadata, +} + +impl LoweredQuantumOp { + /// Create a lowered operation with explicit metadata. + #[must_use] + pub fn new(op: QuantumOp, metadata: TraceMetadata) -> Self { + Self { op, metadata } + } +} + +impl From for LoweredQuantumOp { + fn from(op: QuantumOp) -> Self { + Self { + op, + metadata: TraceMetadata::new(), + } + } +} + impl From for Operation { fn from(op: QuantumOp) -> Self { Operation::Quantum(op) diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index 120f53e3a..30e5035e4 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -4,7 +4,7 @@ //! with Rust. These functions simply collect operations into the thread-local interface //! without performing any simulation or complex state management. -use crate::{Operation, QuantumOp, with_interface}; +use crate::{Operation, QuantumOp, TraceMetadata, with_interface}; use log::debug; use std::cell::Cell; @@ -26,6 +26,59 @@ fn i64_to_usize(value: i64) -> usize { usize::try_from(value).expect("Invalid ID: value must be non-negative and fit in usize") } +unsafe fn read_tket_string_arg( + func_name: &str, + arg_name: &str, + ptr: *const u8, + len: i64, +) -> Option { + let Ok(len) = usize::try_from(len) else { + log::error!("{func_name}: invalid {arg_name} length {len}"); + return None; + }; + if ptr.is_null() { + log::error!("{func_name}: null {arg_name} pointer"); + return None; + } + + // The tket2 string format is: {len: u8, data: [u8; len]}. + // The pointer references the length byte, so skip it to read the payload. + let data_ptr = unsafe { ptr.add(1) }; + let bytes = unsafe { std::slice::from_raw_parts(data_ptr, len) }; + match std::str::from_utf8(bytes) { + Ok(value) => Some(value.to_string()), + Err(_) => { + log::error!("{func_name}: invalid UTF-8 in {arg_name}"); + None + } + } +} + +unsafe fn read_direct_string_arg( + func_name: &str, + arg_name: &str, + ptr: *const u8, + len: i64, +) -> Option { + let Ok(len) = usize::try_from(len) else { + log::error!("{func_name}: invalid {arg_name} length {len}"); + return None; + }; + if ptr.is_null() { + log::error!("{func_name}: null {arg_name} pointer"); + return None; + } + + let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }; + match std::str::from_utf8(bytes) { + Ok(value) => Some(value.to_string()), + Err(_) => { + log::error!("{func_name}: invalid UTF-8 in {arg_name}"); + None + } + } +} + // --- Gate FFI Macros --- // // These macros generate the boilerplate for FFI gate functions. @@ -353,6 +406,79 @@ pub unsafe extern "C" fn __quantum__rt__record(data: *const std::ffi::c_char) { } } +fn queue_trace_metadata(key: String, value: String) { + let mut metadata = TraceMetadata::new(); + metadata.insert(key, value); + with_interface(|interface| { + interface.queue_operation(Operation::TraceMetadata { metadata }); + }); +} + +/// Attach source/runtime metadata to the next lowerable quantum operation. +/// +/// This function uses the tket2 string ABI: each string pointer references a +/// `{len: u8, data: [u8; len]}` payload and the length argument gives the data +/// length. Metadata is intentionally represented as ordinary key/value strings +/// so callers can add generic provenance without PECOS knowing about a specific +/// runtime or hardware target. +/// +/// # Safety +/// The key and value pointers must be valid tket2 string structs with at least +/// `len + 1` bytes. Invalid pointers cause undefined behavior. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pecos_qis_trace_metadata( + key_ptr: *const u8, + key_len: i64, + value_ptr: *const u8, + value_len: i64, +) { + let Some(key) = + (unsafe { read_tket_string_arg("pecos_qis_trace_metadata", "key", key_ptr, key_len) }) + else { + return; + }; + let Some(value) = (unsafe { + read_tket_string_arg("pecos_qis_trace_metadata", "value", value_ptr, value_len) + }) else { + return; + }; + queue_trace_metadata(key, value); +} + +/// Attach source/runtime metadata to the next lowerable quantum operation. +/// +/// This variant uses direct string data pointers instead of the tket2 string +/// struct layout. It is useful for runtime shims that already carry plain +/// pointer/length pairs. +/// +/// # Safety +/// The key and value pointers must reference valid UTF-8 data of the provided +/// lengths. Invalid pointers cause undefined behavior. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pecos_qis_trace_metadata_direct( + key_ptr: *const u8, + key_len: i64, + value_ptr: *const u8, + value_len: i64, +) { + let Some(key) = (unsafe { + read_direct_string_arg("pecos_qis_trace_metadata_direct", "key", key_ptr, key_len) + }) else { + return; + }; + let Some(value) = (unsafe { + read_direct_string_arg( + "pecos_qis_trace_metadata_direct", + "value", + value_ptr, + value_len, + ) + }) else { + return; + }; + queue_trace_metadata(key, value); +} + // --- Selene-style FFI Functions --- // // These functions match the naming convention used by Selene's hugr-qis compiler. @@ -1309,6 +1435,57 @@ mod tests { }); } + #[test] + fn test_trace_metadata_direct() { + setup_test(); + let key = b"source_label"; + let value = b"szz_prefix:H:data_0"; + unsafe { + pecos_qis_trace_metadata_direct( + key.as_ptr(), + key.len() as i64, + value.as_ptr(), + value.len() as i64, + ); + } + + with_interface(|iface| { + assert_eq!(iface.operations.len(), 1); + let Operation::TraceMetadata { metadata } = &iface.operations[0] else { + panic!("expected trace metadata operation"); + }; + assert_eq!( + metadata.get("source_label").map(String::as_str), + Some("szz_prefix:H:data_0") + ); + }); + } + + #[test] + fn test_trace_metadata_tket_string_layout() { + setup_test(); + let key = [ + 11_u8, b's', b'o', b'u', b'r', b'c', b'e', b'_', b'k', b'i', b'n', b'd', + ]; + let value = [ + 10_u8, b's', b'z', b'z', b'_', b'p', b'r', b'e', b'f', b'i', b'x', + ]; + unsafe { + pecos_qis_trace_metadata(key.as_ptr(), 11, value.as_ptr(), 10); + } + + with_interface(|iface| { + assert_eq!(iface.operations.len(), 1); + let Operation::TraceMetadata { metadata } = &iface.operations[0] else { + panic!("expected trace metadata operation"); + }; + assert_eq!( + metadata.get("source_kind").map(String::as_str), + Some("szz_prefix") + ); + }); + } + // --- Measurement and reset tests --- #[test] diff --git a/crates/pecos-qis-ffi/src/lib.rs b/crates/pecos-qis-ffi/src/lib.rs index e83988691..5a2cb8f07 100644 --- a/crates/pecos-qis-ffi/src/lib.rs +++ b/crates/pecos-qis-ffi/src/lib.rs @@ -268,7 +268,7 @@ fn get_execution_context() -> Option<*mut ExecutionContext> { // Re-export all types from pecos-qis-ffi-types pub use pecos_qis_ffi_types::{ - NamedResultTrace, Operation, OperationCollector, OperationList, QuantumOp, + NamedResultTrace, Operation, OperationCollector, OperationList, QuantumOp, TraceMetadata, }; /// Type alias for the quantum executor callback diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index af3978717..9a6bb8224 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -24,7 +24,8 @@ use pecos_engines::{ ByteMessage, ByteMessageBuilder, ClassicalEngine, ControlEngine, Engine, EngineStage, }; use pecos_qis_ffi_types::{ - NamedResultTrace, Operation, OperationCollector as OperationList, QuantumOp, + LoweredQuantumOp, NamedResultTrace, Operation, OperationCollector as OperationList, QuantumOp, + TraceMetadata, }; use pecos_random::PecosRng; use std::collections::{BTreeMap, BTreeSet}; @@ -45,6 +46,7 @@ pub struct LoweredQuantumGateTrace { pub params: Vec, pub qubits: Vec, pub measurement_result_ids: Vec, + pub metadata: TraceMetadata, } /// One traced batch of QIS operations and their lowered simulator commands. @@ -70,6 +72,12 @@ pub type OperationTraceStore = Arc>>; /// Result from worker thread - returns both the operations and the interface type WorkerResult = Result<(OperationList, BoxedInterface), String>; +/// Simulator commands plus one metadata record per lowered quantum gate. +struct LoweredCommandBatch { + commands: ByteMessage, + gate_metadata: Vec, +} + /// State for dynamic circuit execution /// /// The LLVM program runs in a worker thread. When it needs a measurement result, @@ -546,17 +554,33 @@ impl QisEngine { /// by `sim()` operate on a fixed physical qubit pool, so we must honor /// `AllocateQubit`/`ReleaseQubit` and remap program handles back onto reusable /// physical slots before sending the quantum ops downstream. - fn operations_to_bytemessage(&mut self, ops: &[Operation]) -> Result { + fn push_gate_metadata( + gate_metadata: &mut Vec, + pending_metadata: &mut TraceMetadata, + ) { + gate_metadata.push(std::mem::take(pending_metadata)); + } + + fn operations_to_lowered_commands( + &mut self, + ops: &[Operation], + ) -> Result { let mut builder = std::mem::take(&mut self.command_builder); builder.reset(); self.measurement_mapping.clear(); + let mut gate_metadata = Vec::new(); + let mut pending_metadata = TraceMetadata::new(); let result = (|| -> Result<(), PecosError> { for op in ops { match op { + Operation::TraceMetadata { metadata } => { + pending_metadata.extend(metadata.clone()); + } Operation::AllocateQubit { id } => { let slot = self.allocate_qubit_slot(*id)?; builder.pz(&[slot]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } Operation::ReleaseQubit { id } => { self.release_qubit_slot(*id); @@ -567,45 +591,56 @@ impl QisEngine { Operation::Quantum(qop) => match qop { QuantumOp::H(qubit) => { builder.h(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::X(qubit) => { builder.x(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Y(qubit) => { builder.y(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Z(qubit) => { builder.z(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::S(qubit) => { builder.sz(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Sdg(qubit) => { builder.szdg(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::T(qubit) => { builder.t(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Tdg(qubit) => { builder.tdg(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::RX(angle, qubit) => { builder.rx( Angle64::from_radians(*angle), &[self.mapped_qubit(*qubit, qop)?], ); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::RY(angle, qubit) => { builder.ry( Angle64::from_radians(*angle), &[self.mapped_qubit(*qubit, qop)?], ); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::RZ(angle, qubit) => { builder.rz( Angle64::from_radians(*angle), &[self.mapped_qubit(*qubit, qop)?], ); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::RXY(theta, phi, qubit) => { builder.r1xy( @@ -613,25 +648,30 @@ impl QisEngine { Angle64::from_radians(*phi), &[self.mapped_qubit(*qubit, qop)?], ); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Idle(duration, qubit) => { builder.idle(*duration, &[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::CX(control, target) => { builder.cx(&[( self.mapped_qubit(*control, qop)?, self.mapped_qubit(*target, qop)?, )]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Measure(qubit, result_id) => { self.measurement_mapping.push(*result_id); builder.mz(&[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)?, self.mapped_qubit(*qubit2, qop)?, )]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::RZZ(angle, qubit1, qubit2) => { builder.rzz( @@ -641,9 +681,11 @@ impl QisEngine { self.mapped_qubit(*qubit2, qop)?, )], ); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Reset(qubit) => { builder.pz(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } _ => { return Err(PecosError::Generic(format!( @@ -654,10 +696,18 @@ impl QisEngine { } } + if !pending_metadata.is_empty() { + warn!( + "QIS operation trace metadata was not followed by a lowerable quantum operation" + ); + } Ok(()) })(); - let message = result.map(|()| builder.build()); + let message = result.map(|()| LoweredCommandBatch { + commands: builder.build(), + gate_metadata, + }); self.command_builder = builder; message } @@ -667,68 +717,80 @@ impl QisEngine { /// Selene runtime plugins can opt in to lowering so their scheduler sees /// the same operation stream that Selene would receive. Other runtimes keep /// using PECOS's direct QIS lowering path. - fn lower_operations_to_bytemessage( + fn lower_operations_to_commands( &mut self, ops: &[Operation], - ) -> Result { + ) -> Result { if self.runtime.supports_operation_lowering() { let lowered_ops = self .runtime - .lower_operations(ops) + .lower_operations_with_metadata(ops) .map_err(|e| PecosError::Generic(format!("Runtime lowering error: {e}")))?; - return self.quantum_ops_to_bytemessage(lowered_ops); + return self.quantum_ops_to_lowered_commands(lowered_ops); } - self.operations_to_bytemessage(ops) + self.operations_to_lowered_commands(ops) } /// Convert already-materialized quantum ops into a `ByteMessage`. /// /// This path is used by runtimes that already present qubit ids in the fixed /// simulator space, so no allocate/release remapping is needed. - fn quantum_ops_to_bytemessage( + fn quantum_ops_to_lowered_commands( &mut self, - ops: Vec, - ) -> Result { + ops: Vec, + ) -> Result { let mut builder = std::mem::take(&mut self.command_builder); builder.reset(); self.measurement_mapping.clear(); + let mut gate_metadata = Vec::new(); let result = (|| -> Result<(), PecosError> { - for op in ops { + for LoweredQuantumOp { op, metadata } in ops { match op { QuantumOp::H(qubit) => { builder.h(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::X(qubit) => { builder.x(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::Y(qubit) => { builder.y(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::Z(qubit) => { builder.z(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::S(qubit) => { builder.sz(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::Sdg(qubit) => { builder.szdg(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::T(qubit) => { builder.t(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::Tdg(qubit) => { builder.tdg(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::RX(angle, qubit) => { builder.rx(Angle64::from_radians(angle), &[qubit]); + gate_metadata.push(metadata); } QuantumOp::RY(angle, qubit) => { builder.ry(Angle64::from_radians(angle), &[qubit]); + gate_metadata.push(metadata); } QuantumOp::RZ(angle, qubit) => { builder.rz(Angle64::from_radians(angle), &[qubit]); + gate_metadata.push(metadata); } QuantumOp::RXY(theta, phi, qubit) => { builder.r1xy( @@ -736,25 +798,32 @@ impl QisEngine { Angle64::from_radians(phi), &[qubit], ); + gate_metadata.push(metadata); } QuantumOp::Idle(duration, qubit) => { builder.idle(duration, &[qubit]); + gate_metadata.push(metadata); } QuantumOp::CX(control, target) => { builder.cx(&[(control, target)]); + gate_metadata.push(metadata); } QuantumOp::Measure(qubit, result_id) => { self.measurement_mapping.push(result_id); builder.mz(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::ZZ(qubit1, qubit2) => { builder.szz(&[(qubit1, qubit2)]); + gate_metadata.push(metadata); } QuantumOp::RZZ(angle, qubit1, qubit2) => { builder.rzz(Angle64::from_radians(angle), &[(qubit1, qubit2)]); + gate_metadata.push(metadata); } QuantumOp::Reset(qubit) => { builder.pz(&[qubit]); + gate_metadata.push(metadata); } _ => { return Err(PecosError::Generic(format!( @@ -767,10 +836,21 @@ impl QisEngine { Ok(()) })(); - let message = result.map(|()| builder.build()); + let message = result.map(|()| LoweredCommandBatch { + commands: builder.build(), + gate_metadata, + }); self.command_builder = builder; message } + + fn quantum_ops_to_bytemessage( + &mut self, + ops: Vec, + ) -> Result { + self.quantum_ops_to_lowered_commands(ops.into_iter().map(LoweredQuantumOp::from).collect()) + .map(|lowered| lowered.commands) + } } impl Clone for QisEngine { @@ -845,12 +925,20 @@ impl QisEngine { fn lowered_quantum_ops_trace( commands: &ByteMessage, measurement_mapping: &[usize], + gate_metadata: &[TraceMetadata], ) -> Vec { match commands.quantum_ops() { Ok(gates) => { let mut measurement_cursor = 0usize; let mut traces = Vec::with_capacity(gates.len()); - for gate in gates { + if gate_metadata.len() != gates.len() { + warn!( + "Lowered operation trace has {} metadata record(s) for {} gate(s)", + gate_metadata.len(), + gates.len() + ); + } + for (gate_index, gate) in gates.iter().enumerate() { let gate_type = gate.gate_type.to_string(); let qubits = gate .qubits @@ -882,6 +970,7 @@ impl QisEngine { params: gate.params.iter().copied().collect::>(), qubits, measurement_result_ids, + metadata: gate_metadata.get(gate_index).cloned().unwrap_or_default(), }); } if measurement_cursor != measurement_mapping.len() { @@ -905,14 +994,20 @@ impl QisEngine { stage: &str, ops: &[Operation], waiting_for_result_id: Option, - lowered_quantum_ops: Option<&ByteMessage>, + lowered_quantum_ops: Option<&LoweredCommandBatch>, ) { if self.operation_trace_dir.is_none() && self.operation_trace_collector.is_none() { return; } let lowered_trace = lowered_quantum_ops - .map(|commands| Self::lowered_quantum_ops_trace(commands, &self.measurement_mapping)) + .map(|lowered| { + Self::lowered_quantum_ops_trace( + &lowered.commands, + &self.measurement_mapping, + &lowered.gate_metadata, + ) + }) .unwrap_or_default(); let file_name = format!( "engine_{:04}_shot_{:06}_chunk_{:04}_{}.json", @@ -1517,14 +1612,14 @@ impl ControlEngine for QisEngine { // Track how many operations we're sending for simulation self.simulated_op_count = ops.len(); if !ops.is_empty() { - let commands = self.lower_operations_to_bytemessage(&ops)?; + let lowered = self.lower_operations_to_commands(&ops)?; self.trace_operations_chunk( "pending_start", &ops, Some(result_id), - Some(&commands), + Some(&lowered), ); - return Ok(EngineStage::NeedsProcessing(commands)); + return Ok(EngineStage::NeedsProcessing(lowered.commands)); } } } @@ -1536,9 +1631,9 @@ impl ControlEngine for QisEngine { if !self.pending_dynamic_ops.is_empty() { let final_ops = std::mem::take(&mut self.pending_dynamic_ops); if !final_ops.is_empty() { - let commands = self.lower_operations_to_bytemessage(&final_ops)?; - self.trace_operations_chunk("pending_final", &final_ops, None, Some(&commands)); - return Ok(EngineStage::NeedsProcessing(commands)); + let lowered = self.lower_operations_to_commands(&final_ops)?; + self.trace_operations_chunk("pending_final", &final_ops, None, Some(&lowered)); + return Ok(EngineStage::NeedsProcessing(lowered.commands)); } } self.trace_named_result_traces_from_dynamic_handle(); @@ -1583,9 +1678,9 @@ impl ControlEngine for QisEngine { if !self.pending_dynamic_ops.is_empty() { let final_ops = std::mem::take(&mut self.pending_dynamic_ops); if !final_ops.is_empty() { - let commands = self.lower_operations_to_bytemessage(&final_ops)?; - self.trace_operations_chunk("pending_final", &final_ops, None, Some(&commands)); - return Ok(EngineStage::NeedsProcessing(commands)); + let lowered = self.lower_operations_to_commands(&final_ops)?; + self.trace_operations_chunk("pending_final", &final_ops, None, Some(&lowered)); + return Ok(EngineStage::NeedsProcessing(lowered.commands)); } } self.trace_named_result_traces_from_dynamic_handle(); @@ -1628,14 +1723,14 @@ impl ControlEngine for QisEngine { if let Some(ops) = self.get_dynamic_operations() { self.simulated_op_count += ops.len(); if !ops.is_empty() { - let commands = self.lower_operations_to_bytemessage(&ops)?; + let lowered = self.lower_operations_to_commands(&ops)?; self.trace_operations_chunk( "pending_continue", &ops, Some(result_id), - Some(&commands), + Some(&lowered), ); - return Ok(EngineStage::NeedsProcessing(commands)); + return Ok(EngineStage::NeedsProcessing(lowered.commands)); } } } @@ -1648,9 +1743,9 @@ impl ControlEngine for QisEngine { if !self.pending_dynamic_ops.is_empty() { let final_ops = std::mem::take(&mut self.pending_dynamic_ops); if !final_ops.is_empty() { - let commands = self.lower_operations_to_bytemessage(&final_ops)?; - self.trace_operations_chunk("pending_final", &final_ops, None, Some(&commands)); - return Ok(EngineStage::NeedsProcessing(commands)); + let lowered = self.lower_operations_to_commands(&final_ops)?; + self.trace_operations_chunk("pending_final", &final_ops, None, Some(&lowered)); + return Ok(EngineStage::NeedsProcessing(lowered.commands)); } } self.trace_named_result_traces_from_dynamic_handle(); @@ -1733,10 +1828,10 @@ mod tests { QuantumOp::Idle(20e-9, 0).into(), QuantumOp::Measure(0, 7).into(), ]; - let commands = engine - .operations_to_bytemessage(&ops) - .expect("convert ops to bytemessage"); - engine.trace_operations_chunk("unit_test", &ops, Some(7), Some(&commands)); + let lowered = engine + .operations_to_lowered_commands(&ops) + .expect("convert ops to lowered commands"); + engine.trace_operations_chunk("unit_test", &ops, Some(7), Some(&lowered)); let mut trace_files = std::fs::read_dir(temp_dir.path()) .expect("read trace dir") @@ -1758,6 +1853,10 @@ mod tests { assert_eq!(value["operations"][1]["Quantum"]["H"], 0); assert_eq!(value["operations"][2]["Quantum"]["Idle"][0], 20e-9); assert_eq!(value["lowered_quantum_ops"][0]["gate_type"], "PZ"); + assert_eq!( + value["lowered_quantum_ops"][0]["metadata"], + serde_json::json!({}) + ); assert_eq!(value["lowered_quantum_ops"][1]["gate_type"], "H"); assert_eq!(value["lowered_quantum_ops"][2]["gate_type"], "Idle"); assert_eq!(value["lowered_quantum_ops"][2]["params"][0], 20e-9); @@ -1779,6 +1878,43 @@ mod tests { ); } + #[test] + fn test_direct_lowering_attaches_trace_metadata_to_next_gate() { + let mut engine = QisEngine::with_runtime(Box::new(DummyRuntime::default())); + let collector: OperationTraceStore = Arc::new(Mutex::new(Vec::new())); + engine.set_operation_trace_collector(collector.clone()); + engine.begin_trace_shot(); + + let mut metadata = TraceMetadata::new(); + metadata.insert( + "source_label".to_string(), + "szz_physical_prefix:H:X0:q0".to_string(), + ); + metadata.insert("source_kind".to_string(), "szz_prefix".to_string()); + let ops = vec![ + Operation::TraceMetadata { metadata }, + QuantumOp::H(0).into(), + QuantumOp::Measure(0, 7).into(), + ]; + + let lowered = engine + .operations_to_lowered_commands(&ops) + .expect("convert ops to lowered commands"); + engine.trace_operations_chunk("unit_test", &ops, None, Some(&lowered)); + + let in_memory = collector.lock().expect("collector lock"); + assert_eq!(in_memory.len(), 1); + assert_eq!(in_memory[0].lowered_quantum_ops[0].gate_type, "H"); + assert_eq!( + in_memory[0].lowered_quantum_ops[0] + .metadata + .get("source_label"), + Some(&"szz_physical_prefix:H:X0:q0".to_string()) + ); + assert_eq!(in_memory[0].lowered_quantum_ops[1].gate_type, "MZ"); + assert!(in_memory[0].lowered_quantum_ops[1].metadata.is_empty()); + } + #[derive(Clone, Default)] struct IdleLoweringRuntime { state: ClassicalState, @@ -1827,6 +1963,19 @@ mod tests { QuantumOp::Measure(0, 17), ]) } + + fn lower_operations_with_metadata( + &mut self, + _operations: &[Operation], + ) -> RuntimeResult> { + let mut idle_metadata = TraceMetadata::new(); + idle_metadata.insert("runtime_stage".to_string(), "scheduled_idle".to_string()); + Ok(vec![ + LoweredQuantumOp::new(QuantumOp::Idle(20e-9, 0), idle_metadata), + QuantumOp::H(0).into(), + QuantumOp::Measure(0, 17).into(), + ]) + } } #[test] @@ -1837,16 +1986,22 @@ mod tests { engine.begin_trace_shot(); let ops = vec![QuantumOp::H(0).into()]; - let commands = engine - .lower_operations_to_bytemessage(&ops) - .expect("runtime lower ops to bytemessage"); - engine.trace_operations_chunk("unit_test", &ops, None, Some(&commands)); + let lowered = engine + .lower_operations_to_commands(&ops) + .expect("runtime lower ops to commands"); + engine.trace_operations_chunk("unit_test", &ops, None, Some(&lowered)); let in_memory = collector.lock().expect("collector lock"); assert_eq!(in_memory.len(), 1); assert_eq!(in_memory[0].lowered_quantum_ops[0].gate_type, "Idle"); assert_eq!(in_memory[0].lowered_quantum_ops[0].params, vec![20e-9]); assert_eq!(in_memory[0].lowered_quantum_ops[0].qubits, vec![0]); + assert_eq!( + in_memory[0].lowered_quantum_ops[0] + .metadata + .get("runtime_stage"), + Some(&"scheduled_idle".to_string()) + ); assert_eq!(in_memory[0].lowered_quantum_ops[1].gate_type, "H"); assert_eq!(in_memory[0].lowered_quantum_ops[2].gate_type, "MZ"); assert_eq!( @@ -1860,11 +2015,14 @@ mod tests { let mut engine = QisEngine::with_runtime(Box::new(DummyRuntime::default())); let ops = vec![QuantumOp::H(0).into(), QuantumOp::Measure(0, 7).into()]; - let commands = engine - .operations_to_bytemessage(&ops) + let lowered_commands = engine + .operations_to_lowered_commands(&ops) .expect("convert ops with implicit static handles"); - let lowered = commands.quantum_ops().expect("parse lowered commands"); + let lowered = lowered_commands + .commands + .quantum_ops() + .expect("parse lowered commands"); assert_eq!(lowered.len(), 2); assert_eq!(lowered[0].gate_type.to_string(), "H"); assert_eq!(lowered[0].qubits.as_slice(), &[pecos_core::QubitId(0)]); @@ -1882,7 +2040,7 @@ mod tests { QuantumOp::X(0).into(), ]; - let Err(err) = engine.operations_to_bytemessage(&ops) else { + let Err(err) = engine.operations_to_lowered_commands(&ops) else { panic!("released qubit reuse should error"); }; @@ -1902,11 +2060,14 @@ mod tests { QuantumOp::CX(81, 105).into(), ]; - let commands = engine - .operations_to_bytemessage(&ops) + let lowered_commands = engine + .operations_to_lowered_commands(&ops) .expect("sparse handles should map onto live physical slots"); - let lowered = commands.quantum_ops().expect("parse lowered commands"); + let lowered = lowered_commands + .commands + .quantum_ops() + .expect("parse lowered commands"); assert_eq!(lowered[0].qubits.as_slice(), &[pecos_core::QubitId(0)]); assert_eq!(lowered[1].qubits.as_slice(), &[pecos_core::QubitId(1)]); assert_eq!( @@ -1926,7 +2087,7 @@ mod tests { Operation::AllocateQubit { id: 105 }, ]; - let Err(err) = engine.operations_to_bytemessage(&ops) else { + let Err(err) = engine.operations_to_lowered_commands(&ops) else { panic!("allocating beyond the physical qubit hint should error"); }; diff --git a/crates/pecos-qis/src/runtime.rs b/crates/pecos-qis/src/runtime.rs index 961e16ede..20d4ef664 100644 --- a/crates/pecos-qis/src/runtime.rs +++ b/crates/pecos-qis/src/runtime.rs @@ -11,7 +11,7 @@ //! doesn't perform quantum simulation but manages program execution flow. use log::trace; -use pecos_qis_ffi_types::{Operation, OperationCollector, QuantumOp}; +use pecos_qis_ffi_types::{LoweredQuantumOp, Operation, OperationCollector, QuantumOp}; use std::collections::BTreeMap; /// Result type for runtime operations @@ -242,6 +242,22 @@ pub trait QisRuntime: Send + Sync + dyn_clone::DynClone { )) } + /// Lower freshly collected program operations through the runtime with provenance. + /// + /// Runtimes that can preserve source/scheduler metadata should override this + /// method. The default preserves the existing `lower_operations` behavior and + /// attaches empty metadata to every lowered operation. + /// + /// # Errors + /// Returns an error if the runtime cannot accept or lower the operations. + fn lower_operations_with_metadata( + &mut self, + operations: &[Operation], + ) -> Result> { + self.lower_operations(operations) + .map(|ops| ops.into_iter().map(LoweredQuantumOp::from).collect()) + } + /// Check if the runtime needs to re-execute with known measurements /// /// This is set to true after measurements are provided for programs diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index f88ed9a1e..f5898ec91 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -599,6 +599,10 @@ impl SeleneRuntime { // The actual result mapping is handled by the runtime's results collection self.current_op_index += 1; } + Operation::TraceMetadata { .. } => { + trace!("Trace metadata encountered"); + self.current_op_index += 1; + } Operation::Barrier => { trace!("Barrier encountered"); // Barriers don't produce quantum ops but can break batches @@ -875,7 +879,9 @@ impl SeleneRuntime { self.runtime_qfree(runtime_qubit)?; } } - Operation::RecordOutput { .. } | Operation::Barrier => {} + Operation::RecordOutput { .. } + | Operation::TraceMetadata { .. } + | Operation::Barrier => {} Operation::Quantum(qop) => self.submit_quantum_op_to_runtime(qop, lowered_ops)?, } @@ -1218,6 +1224,7 @@ fn operation_capacity_with_mode( Operation::RecordOutput { result_id, .. } => { include_result(&mut num_results, *result_id); } + Operation::TraceMetadata { .. } => {} Operation::Barrier => {} } } diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index b76f9ff13..b7db9f396 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1124,6 +1124,24 @@ def _gate_triples(qubits: list[int], gate_type: str) -> list[tuple[int, int, int return [(qubits[i], qubits[i + 1], qubits[i + 2]) for i in range(0, len(qubits), 3)] +def _lowered_gate_metadata(gate: Mapping[str, Any]) -> dict[str, Any]: + """Return validated runtime/source metadata for a lowered trace gate.""" + metadata = gate.get("metadata") + if metadata is None: + return {} + if not isinstance(metadata, Mapping): + msg = f"Lowered gate metadata must be an object, got {metadata!r}" + raise ValueError(msg) + return {str(key): value for key, value in metadata.items()} + + +def _set_lowered_gate_metadata(tick: Any, metadata: Mapping[str, Any]) -> None: + """Attach lowered trace metadata to the gate most recently added to ``tick``.""" + if not metadata: + return + tick.metas(metadata) + + def _replay_lowered_qis_trace_into_tick_circuit( chunks: list[dict[str, Any]], *, @@ -1153,6 +1171,7 @@ def _replay_lowered_qis_trace_into_tick_circuit( qubits = [int(q) for q in gate.get("qubits", [])] angles = [float(theta) for theta in gate.get("angles", [])] params = [float(param) for param in gate.get("params", [])] + metadata = _lowered_gate_metadata(gate) tick = tick_circuit.tick() if gate_type == "H": @@ -1235,6 +1254,7 @@ def _replay_lowered_qis_trace_into_tick_circuit( else: msg = f"Unsupported lowered traced gate {gate_type!r}" raise ValueError(msg) + _set_lowered_gate_metadata(tick, metadata) # Compact: ASAP-schedule gates into minimal ticks tick_circuit.compact_ticks() 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 93af19a64..22d9a43b0 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -230,6 +230,31 @@ def test_lowered_replay_preserves_runtime_idles() -> None: assert _flat_idle_gates(tc) == [([0], 20.0)] +def test_lowered_replay_preserves_gate_metadata() -> None: + chunks = [ + { + "operations": [{"Quantum": {"H": 0}}], + "lowered_quantum_ops": [ + { + "gate_type": "H", + "qubits": [0], + "angles": [], + "params": [], + "metadata": { + "source_label": "szz_physical_prefix:H:X0:q0", + "source_kind": "szz_prefix", + }, + }, + ], + }, + ] + + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + + assert tc.get_gate_meta(0, 0, "source_label") == "szz_physical_prefix:H:X0:q0" + assert tc.get_gate_meta(0, 0, "source_kind") == "szz_prefix" + + def test_lowered_replay_preserves_measurement_crosstalk_payloads() -> None: chunks = [ { From 38fb2534bea290f624a92f605733ad5ae36134fc Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 24 Jun 2026 23:13:14 -0600 Subject: [PATCH 126/150] Add SZZ source metadata plumbing --- crates/pecos-hugr-qis/src/compiler.rs | 53 +++++++++++++ crates/pecos-qis-ffi/src/ffi.rs | 65 ++++++++++++++++ .../quantum-pecos/src/pecos/guppy/surface.py | 76 +++++++++++++++++-- .../tests/guppy/test_hugr_to_llvm_parsing.py | 21 +++++ .../tests/qec/surface/test_check_plan.py | 4 + 5 files changed, 214 insertions(+), 5 deletions(-) diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index 91bf4eb0d..857adeb07 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -66,6 +66,8 @@ use crate::utils::read_hugr_envelope; const LLVM_MAIN: &str = "qmain"; const METADATA: &[(&str, &[&str])] = &[("name", &["mainlib"])]; +const HUGR_SYMBOL_PREFIX: &str = "__hugr__."; +const TRACE_METADATA_HUGR_SYMBOL: &str = "pecos_qis_trace_metadata_hugr"; // Extension registry is defined in the parent module @@ -389,6 +391,56 @@ fn compile<'c, 'hugr: 'c>( Ok(module) } +fn is_llvm_symbol_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '$' | '-') +} + +/// Normalize PECOS-owned helper declarations that Guppy/HUGR lowers under a +/// private `__hugr__.*` symbol name. +/// +/// These helpers are part of PECOS's runtime ABI, not ordinary user functions, +/// so they need stable external symbols for dynamic linking. Keep this rewrite +/// deliberately narrow: only the metadata helper declaration receives this +/// treatment. +fn normalize_pecos_helper_symbols_in_llvm(mut llvm_ir: String) -> String { + let private_prefix = format!("@{HUGR_SYMBOL_PREFIX}{TRACE_METADATA_HUGR_SYMBOL}."); + let public_symbol = format!("@{TRACE_METADATA_HUGR_SYMBOL}"); + + while let Some(start) = llvm_ir.find(&private_prefix) { + let suffix_start = start + private_prefix.len(); + let suffix_len = llvm_ir[suffix_start..] + .chars() + .take_while(|ch| is_llvm_symbol_char(*ch)) + .map(char::len_utf8) + .sum::(); + if suffix_len == 0 { + break; + } + llvm_ir.replace_range(start..suffix_start + suffix_len, &public_symbol); + } + + llvm_ir +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_trace_metadata_helper_symbol() { + let llvm = concat!( + "declare void @__hugr__.pecos_qis_trace_metadata_hugr.16(i8*, i8*)\n", + "call void @__hugr__.pecos_qis_trace_metadata_hugr.16(i8* %0, i8* %1)\n", + "call void @__hugr__.other_helper.16()\n", + ) + .to_string(); + let normalized = normalize_pecos_helper_symbols_in_llvm(llvm); + assert!(normalized.contains("declare void @pecos_qis_trace_metadata_hugr(i8*, i8*)")); + assert!(normalized.contains("call void @pecos_qis_trace_metadata_hugr(i8* %0, i8* %1)")); + assert!(normalized.contains("@__hugr__.other_helper.16")); + } +} + /// Compile HUGR bytes to LLVM IR string /// /// This is the main entry point for the compiler. @@ -422,6 +474,7 @@ pub fn compile_hugr_bytes_to_string_with_options( // Get the module string let mut llvm_str = module.to_string(); + llvm_str = normalize_pecos_helper_symbols_in_llvm(llvm_str); // Workaround: Manually add the EntryPoint attribute if it's missing // This is needed because inkwell sometimes doesn't properly serialize string attributes diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index 30e5035e4..38630036f 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -445,6 +445,46 @@ pub unsafe extern "C" fn pecos_qis_trace_metadata( queue_trace_metadata(key, value); } +/// Attach source/runtime metadata to the next lowerable quantum operation. +/// +/// This variant matches the HUGR lowering ABI for Guppy string arguments: each +/// argument is passed as a pointer to a tket2 string payload whose first byte is +/// the string length. +/// +/// # Safety +/// The key and value pointers must be valid tket2 string structs. Invalid +/// pointers cause undefined behavior. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pecos_qis_trace_metadata_hugr(key_ptr: *const u8, value_ptr: *const u8) { + if key_ptr.is_null() { + log::error!("pecos_qis_trace_metadata_hugr: null key pointer"); + return; + } + if value_ptr.is_null() { + log::error!("pecos_qis_trace_metadata_hugr: null value pointer"); + return; + } + + let key_len = i64::from(unsafe { *key_ptr }); + let value_len = i64::from(unsafe { *value_ptr }); + let Some(key) = + (unsafe { read_tket_string_arg("pecos_qis_trace_metadata_hugr", "key", key_ptr, key_len) }) + else { + return; + }; + let Some(value) = (unsafe { + read_tket_string_arg( + "pecos_qis_trace_metadata_hugr", + "value", + value_ptr, + value_len, + ) + }) else { + return; + }; + queue_trace_metadata(key, value); +} + /// Attach source/runtime metadata to the next lowerable quantum operation. /// /// This variant uses direct string data pointers instead of the tket2 string @@ -1486,6 +1526,31 @@ mod tests { }); } + #[test] + fn test_trace_metadata_hugr_string_layout() { + setup_test(); + let key = [ + 11_u8, b's', b'o', b'u', b'r', b'c', b'e', b'_', b'k', b'i', b'n', b'd', + ]; + let value = [ + 10_u8, b's', b'z', b'z', b'_', b'p', b'r', b'e', b'f', b'i', b'x', + ]; + unsafe { + pecos_qis_trace_metadata_hugr(key.as_ptr(), value.as_ptr()); + } + + with_interface(|iface| { + assert_eq!(iface.operations.len(), 1); + let Operation::TraceMetadata { metadata } = &iface.operations[0] else { + panic!("expected trace metadata operation"); + }; + assert_eq!( + metadata.get("source_kind").map(String::as_str), + Some("szz_prefix") + ); + }); + } + // --- Measurement and reset tests --- #[test] diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index be0bde3af..a05d94033 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -367,6 +367,16 @@ def generate_guppy_source( if twirl is not None: lines.extend(_render_inline_pcg32()) + if interaction_basis == "szz": + lines.extend( + [ + "@guppy.declare", + "def pecos_qis_trace_metadata_hugr(key: str, value: str) -> None: ...", + "", + "", + ], + ) + # Generate struct definitions. lines.extend( [ @@ -505,7 +515,34 @@ def _szz_touch_compensation_gates(axis: str, sign: int) -> tuple[OpType, ...]: msg = f"unsupported Pauli axis {axis!r}" raise ValueError(msg) - def _append_szz_flow_gate(target: list[str], indent: str, op_type: OpType, qubit_expr: str) -> None: + def _append_szz_trace_metadata(target: list[str], indent: str, key: str, value: str) -> None: + target.append(f'{indent}pecos_qis_trace_metadata_hugr("{key}", "{value}")') + + def _append_szz_gate_trace_metadata( + target: list[str], + indent: str, + *, + source_kind: str, + source_label: str, + host_label: str | None = None, + gate: OpType | None = None, + ) -> None: + _append_szz_trace_metadata(target, indent, "source_kind", source_kind) + _append_szz_trace_metadata(target, indent, "source_label", source_label) + if host_label is not None: + _append_szz_trace_metadata(target, indent, "szz_host_label", host_label) + if gate is not None: + _append_szz_trace_metadata(target, indent, "source_gate", gate.name) + + def _append_szz_flow_gate( + target: list[str], + indent: str, + op_type: OpType, + qubit_expr: str, + *, + source_label: str | None = None, + host_label: str | None = None, + ) -> None: op_name = { OpType.H: "h", OpType.SX: "v", @@ -518,6 +555,15 @@ def _append_szz_flow_gate(target: list[str], indent: str, op_type: OpType, qubit if op_name is None: msg = f"unsupported Guppy SZZ forward-flow gate {op_type.name}" raise ValueError(msg) + if source_label is not None: + _append_szz_gate_trace_metadata( + target, + indent, + source_kind="szz_data_prefix", + source_label=source_label, + host_label=host_label, + gate=op_type, + ) target.append(f"{indent}{op_name}({qubit_expr})") _szz_guppy_prefix_cache: dict[tuple[int, int], tuple[OpType, ...]] = {_SZZ_FLOW_IDENTITY: ()} @@ -654,15 +700,26 @@ def compose_data(data_q: int, gates: tuple[OpType, ...]) -> None: pending = _szz_flow_compose_pending_gate(pending, gate) pending_by_data[data_q] = pending - def discharge_data_for_szz(data_q: int) -> None: + def discharge_data_for_szz( + data_q: int, + *, + host_label: str, + ) -> None: if pending_by_data is None: return pending = pending_by_data.setdefault(data_q, _SZZ_FLOW_IDENTITY) if pending == _SZZ_FLOW_IDENTITY or _szz_flow_is_virtual_z(pending): return prefix = _szz_guppy_prefix_gates_for_pending(pending) - for gate in prefix: - _append_szz_flow_gate(target, indent, gate, data_expr(data_q)) + for prefix_idx, gate in enumerate(prefix): + _append_szz_flow_gate( + target, + indent, + gate, + data_expr(data_q), + source_label=f"{host_label}:prefix:{prefix_idx}:{gate.name}", + host_label=host_label, + ) pending_by_data[data_q] = _SZZ_FLOW_IDENTITY target.append("") @@ -688,9 +745,18 @@ def discharge_data_for_szz(data_q: int) -> None: f"{indent}barrier({ancilla_expr(stab_type, stab_idx)}, " f"{data_expr(data_q)})", ) - discharge_data_for_szz(data_q) sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] + host_gate = OpType.SZZ if sign > 0 else OpType.SZZDG + host_label = f"szz:r{rnd_idx + 1}:{stab_type}{stab_idx}:d{data_q}:{host_gate.name}" + discharge_data_for_szz(data_q, host_label=host_label) half_turns = "0.5" if sign > 0 else "-0.5" + _append_szz_gate_trace_metadata( + target, + indent, + source_kind="szz_host", + source_label=host_label, + gate=host_gate, + ) target.append( f"{indent}{ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)} = " f"zz_phase({ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)}, angle({half_turns}))", diff --git a/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py b/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py index b4b27d46f..4861ebb45 100644 --- a/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py +++ b/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py @@ -61,3 +61,24 @@ def hadamard_test() -> bool: assert "@___qalloc()" in llvm_ir, "Should have Selene qubit allocation" assert "@___rxy" in llvm_ir or "@___rz" in llvm_ir, "Should have Selene rotation gates for H" assert "@___lazy_measure" in llvm_ir, "Should have Selene measurement" + + +def test_trace_metadata_helper_uses_public_symbol() -> None: + """Test that declared trace metadata helpers compile to the public FFI symbol.""" + try: + from guppylang import guppy + from pecos_rslib import compile_hugr_to_qis + except ImportError as e: + pytest.skip(f"Required imports not available: {e}") + + @guppy.declare + def pecos_qis_trace_metadata_hugr(key: str, value: str) -> None: ... + + @guppy + def metadata_probe() -> None: + pecos_qis_trace_metadata_hugr("source_kind", "szz_host") + + llvm_ir = compile_hugr_to_qis(metadata_probe.compile().to_bytes()) + + assert "@pecos_qis_trace_metadata_hugr" in llvm_ir + assert "@__hugr__.pecos_qis_trace_metadata_hugr" not in llvm_ir 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 543950b00..1a057ddd4 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -304,6 +304,10 @@ def test_direct_surface_renderers_accept_check_plan_as_source_of_truth() -> None guppy_source = generate_guppy_from_patch(patch, check_plan="szz_current_v1") assert "Check plan: szz_current_v1" in guppy_source + assert "def pecos_qis_trace_metadata_hugr(key: str, value: str) -> None: ..." in guppy_source + assert 'pecos_qis_trace_metadata_hugr("source_kind", "szz_host")' in guppy_source + assert 'pecos_qis_trace_metadata_hugr("source_kind", "szz_data_prefix")' in guppy_source + assert 'pecos_qis_trace_metadata_hugr("szz_host_label", "szz:' in guppy_source def test_boundary_first_szz_check_plan_changes_source_gates_not_metadata() -> None: From eda0cd8fb3a895aa3ad2cf30ee9a4313c2d34c19 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 25 Jun 2026 02:07:00 -0600 Subject: [PATCH 127/150] Preserve qubit-scoped SZZ trace metadata --- crates/pecos-hugr-qis/src/compiler.rs | 60 +- crates/pecos-qis-ffi-types/src/operations.rs | 11 +- crates/pecos-qis-ffi/src/ffi.rs | 94 ++- crates/pecos-qis/src/ccengine.rs | 7 +- crates/pecos-qis/src/selene_runtime.rs | 788 +++++++++++++++++- .../quantum-pecos/src/pecos/guppy/surface.py | 39 +- .../tests/guppy/test_hugr_to_llvm_parsing.py | 13 +- .../tests/qec/surface/test_check_plan.py | 10 +- .../tests/qec/test_from_guppy_dem.py | 35 +- 9 files changed, 975 insertions(+), 82 deletions(-) diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index 857adeb07..733d49292 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -68,6 +68,7 @@ const LLVM_MAIN: &str = "qmain"; const METADATA: &[(&str, &[&str])] = &[("name", &["mainlib"])]; const HUGR_SYMBOL_PREFIX: &str = "__hugr__."; const TRACE_METADATA_HUGR_SYMBOL: &str = "pecos_qis_trace_metadata_hugr"; +const TRACE_METADATA_QUBIT_HUGR_SYMBOL: &str = "pecos_qis_trace_metadata_qubit_hugr"; // Extension registry is defined in the parent module @@ -402,24 +403,50 @@ fn is_llvm_symbol_char(ch: char) -> bool { /// so they need stable external symbols for dynamic linking. Keep this rewrite /// deliberately narrow: only the metadata helper declaration receives this /// treatment. -fn normalize_pecos_helper_symbols_in_llvm(mut llvm_ir: String) -> String { - let private_prefix = format!("@{HUGR_SYMBOL_PREFIX}{TRACE_METADATA_HUGR_SYMBOL}."); - let public_symbol = format!("@{TRACE_METADATA_HUGR_SYMBOL}"); +fn normalize_pecos_helper_symbols_in_llvm(llvm_ir: String) -> String { + let helper_symbols = [TRACE_METADATA_HUGR_SYMBOL, TRACE_METADATA_QUBIT_HUGR_SYMBOL]; + let mut normalized = String::with_capacity(llvm_ir.len()); + let mut cursor = 0; - while let Some(start) = llvm_ir.find(&private_prefix) { - let suffix_start = start + private_prefix.len(); - let suffix_len = llvm_ir[suffix_start..] + while let Some(relative_start) = llvm_ir[cursor..].find('@') { + let start = cursor + relative_start; + normalized.push_str(&llvm_ir[cursor..start]); + + let symbol_start = start + 1; + let symbol_len = llvm_ir[symbol_start..] .chars() .take_while(|ch| is_llvm_symbol_char(*ch)) .map(char::len_utf8) .sum::(); - if suffix_len == 0 { - break; + if symbol_len == 0 { + normalized.push('@'); + cursor = symbol_start; + continue; } - llvm_ir.replace_range(start..suffix_start + suffix_len, &public_symbol); + + let symbol_end = symbol_start + symbol_len; + let symbol = &llvm_ir[symbol_start..symbol_end]; + if let Some(rest) = symbol.strip_prefix(HUGR_SYMBOL_PREFIX) { + let mut parts = rest.rsplit('.'); + let suffix = parts.next(); + let helper_name = parts.next(); + if let (Some(_suffix), Some(helper_name)) = (suffix, helper_name) { + if helper_symbols.iter().any(|helper| helper == &helper_name) { + normalized.push('@'); + normalized.push_str(helper_name); + cursor = symbol_end; + continue; + } + } + } + + normalized.push('@'); + normalized.push_str(symbol); + cursor = symbol_end; } - llvm_ir + normalized.push_str(&llvm_ir[cursor..]); + normalized } #[cfg(test)] @@ -431,12 +458,25 @@ mod tests { let llvm = concat!( "declare void @__hugr__.pecos_qis_trace_metadata_hugr.16(i8*, i8*)\n", "call void @__hugr__.pecos_qis_trace_metadata_hugr.16(i8* %0, i8* %1)\n", + "call void @__hugr__.__main__.pecos_qis_trace_metadata_hugr.18(i8* %0, i8* %1)\n", + "declare i64 @__hugr__.pecos_qis_trace_metadata_qubit_hugr.19(i64, i8*, i8*)\n", + "%q = call i64 @__hugr__.pecos_qis_trace_metadata_qubit_hugr.19(i64 %0, i8* %1, i8* %2)\n", + "%q2 = call i64 @__hugr__.__main__.pecos_qis_trace_metadata_qubit_hugr.21(i64 %0, i8* %1, i8* %2)\n", "call void @__hugr__.other_helper.16()\n", ) .to_string(); let normalized = normalize_pecos_helper_symbols_in_llvm(llvm); assert!(normalized.contains("declare void @pecos_qis_trace_metadata_hugr(i8*, i8*)")); assert!(normalized.contains("call void @pecos_qis_trace_metadata_hugr(i8* %0, i8* %1)")); + assert!( + normalized.contains("declare i64 @pecos_qis_trace_metadata_qubit_hugr(i64, i8*, i8*)") + ); + assert!(normalized.contains( + "%q = call i64 @pecos_qis_trace_metadata_qubit_hugr(i64 %0, i8* %1, i8* %2)" + )); + assert!(normalized.contains( + "%q2 = call i64 @pecos_qis_trace_metadata_qubit_hugr(i64 %0, i8* %1, i8* %2)" + )); assert!(normalized.contains("@__hugr__.other_helper.16")); } } diff --git a/crates/pecos-qis-ffi-types/src/operations.rs b/crates/pecos-qis-ffi-types/src/operations.rs index 103828414..7cfc5f604 100644 --- a/crates/pecos-qis-ffi-types/src/operations.rs +++ b/crates/pecos-qis-ffi-types/src/operations.rs @@ -30,7 +30,16 @@ pub enum Operation { /// Runtimes may preserve this metadata when lowering, scheduling, or expanding /// operations. PECOS's direct lowering path attaches it to the next emitted /// simulator gate and then clears it. - TraceMetadata { metadata: TraceMetadata }, + TraceMetadata { + metadata: TraceMetadata, + /// Optional source qubit that owns this metadata. + /// + /// Runtime lowering uses this to wait for the next compatible source + /// operation touching the same qubit, which is stricter than attaching + /// metadata to the next operation in global program order. + #[serde(default)] + qubit: Option, + }, /// Allocate a qubit AllocateQubit { id: usize }, diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index 38630036f..3583705d1 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -406,11 +406,11 @@ pub unsafe extern "C" fn __quantum__rt__record(data: *const std::ffi::c_char) { } } -fn queue_trace_metadata(key: String, value: String) { +fn queue_trace_metadata(key: String, value: String, qubit: Option) { let mut metadata = TraceMetadata::new(); metadata.insert(key, value); with_interface(|interface| { - interface.queue_operation(Operation::TraceMetadata { metadata }); + interface.queue_operation(Operation::TraceMetadata { metadata, qubit }); }); } @@ -442,7 +442,7 @@ pub unsafe extern "C" fn pecos_qis_trace_metadata( }) else { return; }; - queue_trace_metadata(key, value); + queue_trace_metadata(key, value, None); } /// Attach source/runtime metadata to the next lowerable quantum operation. @@ -482,7 +482,56 @@ pub unsafe extern "C" fn pecos_qis_trace_metadata_hugr(key_ptr: *const u8, value }) else { return; }; - queue_trace_metadata(key, value); + queue_trace_metadata(key, value, None); +} + +/// Attach source/runtime metadata to the next operation on a specific qubit. +/// +/// Returning the qubit handle gives Guppy/HUGR a data dependency that preserves +/// the metadata call immediately before the gate it annotates. +/// +/// # Safety +/// The key and value pointers must be valid tket2 string structs. Invalid +/// pointers cause undefined behavior. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pecos_qis_trace_metadata_qubit_hugr( + qubit: i64, + key_ptr: *const u8, + value_ptr: *const u8, +) -> i64 { + if key_ptr.is_null() { + log::error!("pecos_qis_trace_metadata_qubit_hugr: null key pointer"); + return qubit; + } + if value_ptr.is_null() { + log::error!("pecos_qis_trace_metadata_qubit_hugr: null value pointer"); + return qubit; + } + + let key_len = i64::from(unsafe { *key_ptr }); + let value_len = i64::from(unsafe { *value_ptr }); + let Some(key) = (unsafe { + read_tket_string_arg( + "pecos_qis_trace_metadata_qubit_hugr", + "key", + key_ptr, + key_len, + ) + }) else { + return qubit; + }; + let Some(value) = (unsafe { + read_tket_string_arg( + "pecos_qis_trace_metadata_qubit_hugr", + "value", + value_ptr, + value_len, + ) + }) else { + return qubit; + }; + queue_trace_metadata(key, value, Some(i64_to_usize(qubit))); + qubit } /// Attach source/runtime metadata to the next lowerable quantum operation. @@ -516,7 +565,7 @@ pub unsafe extern "C" fn pecos_qis_trace_metadata_direct( }) else { return; }; - queue_trace_metadata(key, value); + queue_trace_metadata(key, value, None); } // --- Selene-style FFI Functions --- @@ -1491,9 +1540,10 @@ mod tests { with_interface(|iface| { assert_eq!(iface.operations.len(), 1); - let Operation::TraceMetadata { metadata } = &iface.operations[0] else { + let Operation::TraceMetadata { metadata, qubit } = &iface.operations[0] else { panic!("expected trace metadata operation"); }; + assert_eq!(*qubit, None); assert_eq!( metadata.get("source_label").map(String::as_str), Some("szz_prefix:H:data_0") @@ -1516,9 +1566,10 @@ mod tests { with_interface(|iface| { assert_eq!(iface.operations.len(), 1); - let Operation::TraceMetadata { metadata } = &iface.operations[0] else { + let Operation::TraceMetadata { metadata, qubit } = &iface.operations[0] else { panic!("expected trace metadata operation"); }; + assert_eq!(*qubit, None); assert_eq!( metadata.get("source_kind").map(String::as_str), Some("szz_prefix") @@ -1541,9 +1592,36 @@ mod tests { with_interface(|iface| { assert_eq!(iface.operations.len(), 1); - let Operation::TraceMetadata { metadata } = &iface.operations[0] else { + let Operation::TraceMetadata { metadata, qubit } = &iface.operations[0] else { + panic!("expected trace metadata operation"); + }; + assert_eq!(*qubit, None); + assert_eq!( + metadata.get("source_kind").map(String::as_str), + Some("szz_prefix") + ); + }); + } + + #[test] + fn test_trace_metadata_qubit_hugr_returns_qubit_and_queues_metadata() { + setup_test(); + let key = [ + 11_u8, b's', b'o', b'u', b'r', b'c', b'e', b'_', b'k', b'i', b'n', b'd', + ]; + let value = [ + 10_u8, b's', b'z', b'z', b'_', b'p', b'r', b'e', b'f', b'i', b'x', + ]; + let returned = + unsafe { pecos_qis_trace_metadata_qubit_hugr(17, key.as_ptr(), value.as_ptr()) }; + assert_eq!(returned, 17); + + with_interface(|iface| { + assert_eq!(iface.operations.len(), 1); + let Operation::TraceMetadata { metadata, qubit } = &iface.operations[0] else { panic!("expected trace metadata operation"); }; + assert_eq!(*qubit, Some(17)); assert_eq!( metadata.get("source_kind").map(String::as_str), Some("szz_prefix") diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index 9a6bb8224..f56601b3e 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -574,7 +574,7 @@ impl QisEngine { let result = (|| -> Result<(), PecosError> { for op in ops { match op { - Operation::TraceMetadata { metadata } => { + Operation::TraceMetadata { metadata, .. } => { pending_metadata.extend(metadata.clone()); } Operation::AllocateQubit { id } => { @@ -1892,7 +1892,10 @@ mod tests { ); metadata.insert("source_kind".to_string(), "szz_prefix".to_string()); let ops = vec![ - Operation::TraceMetadata { metadata }, + Operation::TraceMetadata { + metadata, + qubit: None, + }, QuantumOp::H(0).into(), QuantumOp::Measure(0, 7).into(), ]; diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index f5898ec91..3b22f1622 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -5,8 +5,10 @@ use crate::runtime::{ClassicalState, QisRuntime, Result, RuntimeError, Shot}; use log::{debug, trace}; -use pecos_qis_ffi_types::{Operation, OperationCollector, QuantumOp}; -use std::collections::{BTreeMap, BTreeSet}; +use pecos_qis_ffi_types::{ + LoweredQuantumOp, Operation, OperationCollector, QuantumOp, TraceMetadata, +}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::ffi::{CString, c_void}; use std::mem::ManuallyDrop; use std::path::{Path, PathBuf}; @@ -59,6 +61,12 @@ impl RuntimeOperationBatch { } } +#[derive(Debug, Clone)] +struct SourceTraceMetadata { + op: QuantumOp, + metadata: TraceMetadata, +} + #[repr(C)] struct SeleneRuntimeGetOperationInterface { rzz_fn: extern "C" fn(RuntimeGetOperationInstance, u64, u64, f64), @@ -888,6 +896,49 @@ impl SeleneRuntime { Ok(()) } + fn map_quantum_op_to_runtime_qubits(&mut self, qop: &QuantumOp) -> Result { + let mut map = |qubit: usize| -> Result { + let runtime_qubit = self.runtime_qubit_for_program(qubit)?; + usize::try_from(runtime_qubit).map_err(|_| { + RuntimeError::ExecutionError(format!( + "Runtime qubit id {runtime_qubit} does not fit in usize" + )) + }) + }; + + Ok(match qop { + QuantumOp::H(qubit) => QuantumOp::H(map(*qubit)?), + QuantumOp::X(qubit) => QuantumOp::X(map(*qubit)?), + QuantumOp::Y(qubit) => QuantumOp::Y(map(*qubit)?), + QuantumOp::Z(qubit) => QuantumOp::Z(map(*qubit)?), + QuantumOp::S(qubit) => QuantumOp::S(map(*qubit)?), + QuantumOp::Sdg(qubit) => QuantumOp::Sdg(map(*qubit)?), + QuantumOp::T(qubit) => QuantumOp::T(map(*qubit)?), + QuantumOp::Tdg(qubit) => QuantumOp::Tdg(map(*qubit)?), + QuantumOp::RX(theta, qubit) => QuantumOp::RX(*theta, map(*qubit)?), + QuantumOp::RY(theta, qubit) => QuantumOp::RY(*theta, map(*qubit)?), + QuantumOp::RZ(theta, qubit) => QuantumOp::RZ(*theta, map(*qubit)?), + QuantumOp::RXY(theta, phi, qubit) => QuantumOp::RXY(*theta, *phi, map(*qubit)?), + QuantumOp::Idle(duration, qubit) => QuantumOp::Idle(*duration, map(*qubit)?), + QuantumOp::CX(control, target) => QuantumOp::CX(map(*control)?, map(*target)?), + QuantumOp::CY(control, target) => QuantumOp::CY(map(*control)?, map(*target)?), + QuantumOp::CZ(control, target) => QuantumOp::CZ(map(*control)?, map(*target)?), + QuantumOp::CH(control, target) => QuantumOp::CH(map(*control)?, map(*target)?), + QuantumOp::CRZ(theta, control, target) => { + QuantumOp::CRZ(*theta, map(*control)?, map(*target)?) + } + QuantumOp::CCX(control_1, control_2, target) => { + QuantumOp::CCX(map(*control_1)?, map(*control_2)?, map(*target)?) + } + QuantumOp::ZZ(qubit_1, qubit_2) => QuantumOp::ZZ(map(*qubit_1)?, map(*qubit_2)?), + QuantumOp::RZZ(theta, qubit_1, qubit_2) => { + QuantumOp::RZZ(*theta, map(*qubit_1)?, map(*qubit_2)?) + } + QuantumOp::Measure(qubit, result_id) => QuantumOp::Measure(map(*qubit)?, *result_id), + QuantumOp::Reset(qubit) => QuantumOp::Reset(map(*qubit)?), + }) + } + fn submit_quantum_op_to_runtime( &mut self, qop: &QuantumOp, @@ -919,54 +970,13 @@ impl SeleneRuntime { } _ => { lowered_ops.extend(self.drain_runtime_operations()?); - lowered_ops.push(self.map_passthrough_op_to_runtime_qubits(qop)?); + lowered_ops.push(self.map_quantum_op_to_runtime_qubits(qop)?); } } Ok(()) } - fn map_passthrough_op_to_runtime_qubits(&mut self, qop: &QuantumOp) -> Result { - let mut map = |qubit: usize| -> Result { - let runtime_qubit = self.runtime_qubit_for_program(qubit)?; - usize::try_from(runtime_qubit).map_err(|_| { - RuntimeError::ExecutionError(format!( - "Runtime qubit id {runtime_qubit} does not fit in usize" - )) - }) - }; - - Ok(match qop { - QuantumOp::H(qubit) => QuantumOp::H(map(*qubit)?), - QuantumOp::X(qubit) => QuantumOp::X(map(*qubit)?), - QuantumOp::Y(qubit) => QuantumOp::Y(map(*qubit)?), - QuantumOp::Z(qubit) => QuantumOp::Z(map(*qubit)?), - QuantumOp::S(qubit) => QuantumOp::S(map(*qubit)?), - QuantumOp::Sdg(qubit) => QuantumOp::Sdg(map(*qubit)?), - QuantumOp::T(qubit) => QuantumOp::T(map(*qubit)?), - QuantumOp::Tdg(qubit) => QuantumOp::Tdg(map(*qubit)?), - QuantumOp::RX(theta, qubit) => QuantumOp::RX(*theta, map(*qubit)?), - QuantumOp::RY(theta, qubit) => QuantumOp::RY(*theta, map(*qubit)?), - QuantumOp::CX(control, target) => QuantumOp::CX(map(*control)?, map(*target)?), - QuantumOp::CY(control, target) => QuantumOp::CY(map(*control)?, map(*target)?), - QuantumOp::CZ(control, target) => QuantumOp::CZ(map(*control)?, map(*target)?), - QuantumOp::CH(control, target) => QuantumOp::CH(map(*control)?, map(*target)?), - QuantumOp::CRZ(theta, control, target) => { - QuantumOp::CRZ(*theta, map(*control)?, map(*target)?) - } - QuantumOp::CCX(control_1, control_2, target) => { - QuantumOp::CCX(map(*control_1)?, map(*control_2)?, map(*target)?) - } - QuantumOp::ZZ(qubit_1, qubit_2) => QuantumOp::ZZ(map(*qubit_1)?, map(*qubit_2)?), - QuantumOp::Idle(duration, qubit) => QuantumOp::Idle(*duration, map(*qubit)?), - QuantumOp::RXY(..) - | QuantumOp::RZ(..) - | QuantumOp::RZZ(..) - | QuantumOp::Measure(..) - | QuantumOp::Reset(..) => qop.clone(), - }) - } - fn drain_runtime_operations(&mut self) -> Result> { self.load_plugin()?; let mut lowered_ops = Vec::new(); @@ -1017,6 +1027,363 @@ impl SeleneRuntime { Ok(lowered_ops) } + fn push_lowered_ops_with_source_metadata( + lowered_ops: &mut Vec, + ops: Vec, + source_metadata: &mut VecDeque, + ) { + for op in ops { + let metadata = if let Some(source_index) = source_metadata + .iter() + .position(|source| Self::source_trace_metadata_matches_lowered_op(source, &op)) + { + source_metadata + .remove(source_index) + .map(|source| source.metadata) + .unwrap_or_default() + } else { + TraceMetadata::new() + }; + lowered_ops.push(LoweredQuantumOp::new(op, metadata)); + } + } + + fn merge_trace_metadata(target: &mut TraceMetadata, metadata: TraceMetadata) -> Result<()> { + for (key, value) in metadata { + if let Some(existing) = target.get(&key) + && existing != &value + { + return Err(RuntimeError::ExecutionError(format!( + "conflicting trace metadata for key {key:?}: {existing:?} != {value:?}" + ))); + } + target.insert(key, value); + } + Ok(()) + } + + fn take_pending_trace_metadata_for_source_op( + qop: &QuantumOp, + pending_global_metadata: &mut TraceMetadata, + pending_qubit_metadata: &mut BTreeMap, + ) -> Result { + let mut metadata = std::mem::take(pending_global_metadata); + let mut consumed_qubits = Vec::new(); + + for qubit in Self::quantum_op_qubits(qop) { + let Some(pending) = pending_qubit_metadata.get(&qubit) else { + continue; + }; + if !Self::trace_metadata_can_annotate_source_op(pending, qop) { + continue; + } + Self::merge_trace_metadata(&mut metadata, pending.clone())?; + consumed_qubits.push(qubit); + } + + for qubit in consumed_qubits { + pending_qubit_metadata.remove(&qubit); + } + + Ok(metadata) + } + + fn trace_metadata_can_annotate_source_op(metadata: &TraceMetadata, qop: &QuantumOp) -> bool { + let Some(source_gate) = metadata.get("source_gate").map(String::as_str) else { + return true; + }; + match source_gate { + "SZZ" | "SZZDG" => Self::two_qubit_gate_qubits(qop).is_some(), + _ => Self::single_qubit_gate_qubit(qop).is_some(), + } + } + + fn source_trace_metadata_matches_lowered_op( + source: &SourceTraceMetadata, + lowered: &QuantumOp, + ) -> bool { + if source.metadata.contains_key("source_gate") { + return Self::source_gate_metadata_matches_lowered_op(source, lowered); + } + Self::source_op_matches_lowered_op(&source.op, lowered) + } + + fn source_gate_metadata_matches_lowered_op( + source: &SourceTraceMetadata, + lowered: &QuantumOp, + ) -> bool { + let Some(source_gate) = source.metadata.get("source_gate").map(String::as_str) else { + return false; + }; + if matches!(source_gate, "SZZ" | "SZZDG") { + let Some((source_qubit_1, source_qubit_2)) = Self::two_qubit_gate_qubits(&source.op) + else { + return false; + }; + let Some((lowered_qubit_1, lowered_qubit_2)) = Self::two_qubit_gate_qubits(lowered) + else { + return false; + }; + return Self::same_unordered_pair( + source_qubit_1, + source_qubit_2, + lowered_qubit_1, + lowered_qubit_2, + ); + } + let Some(source_qubit) = Self::single_qubit_gate_qubit(&source.op) else { + return false; + }; + let Some(lowered_qubit) = Self::single_qubit_gate_qubit(lowered) else { + return false; + }; + source_qubit == lowered_qubit + } + + fn source_op_matches_lowered_op(source: &QuantumOp, lowered: &QuantumOp) -> bool { + match (source, lowered) { + (QuantumOp::H(source_qubit), QuantumOp::H(lowered_qubit)) + | (QuantumOp::X(source_qubit), QuantumOp::X(lowered_qubit)) + | (QuantumOp::Y(source_qubit), QuantumOp::Y(lowered_qubit)) + | (QuantumOp::Z(source_qubit), QuantumOp::Z(lowered_qubit)) + | (QuantumOp::S(source_qubit), QuantumOp::S(lowered_qubit)) + | (QuantumOp::Sdg(source_qubit), QuantumOp::Sdg(lowered_qubit)) + | (QuantumOp::T(source_qubit), QuantumOp::T(lowered_qubit)) + | (QuantumOp::Tdg(source_qubit), QuantumOp::Tdg(lowered_qubit)) + | (QuantumOp::Reset(source_qubit), QuantumOp::Reset(lowered_qubit)) => { + source_qubit == lowered_qubit + } + ( + QuantumOp::RX(source_theta, source_qubit), + QuantumOp::RX(lowered_theta, lowered_qubit), + ) + | ( + QuantumOp::RY(source_theta, source_qubit), + QuantumOp::RY(lowered_theta, lowered_qubit), + ) + | ( + QuantumOp::RZ(source_theta, source_qubit), + QuantumOp::RZ(lowered_theta, lowered_qubit), + ) + | ( + QuantumOp::Idle(source_theta, source_qubit), + QuantumOp::Idle(lowered_theta, lowered_qubit), + ) => source_qubit == lowered_qubit && Self::same_float(*source_theta, *lowered_theta), + ( + QuantumOp::RXY(source_theta, source_phi, source_qubit), + QuantumOp::RXY(lowered_theta, lowered_phi, lowered_qubit), + ) => { + source_qubit == lowered_qubit + && Self::same_float(*source_theta, *lowered_theta) + && Self::same_float(*source_phi, *lowered_phi) + } + ( + QuantumOp::CX(source_control, source_target), + QuantumOp::CX(lowered_control, lowered_target), + ) + | ( + QuantumOp::CY(source_control, source_target), + QuantumOp::CY(lowered_control, lowered_target), + ) + | ( + QuantumOp::CZ(source_control, source_target), + QuantumOp::CZ(lowered_control, lowered_target), + ) + | ( + QuantumOp::CH(source_control, source_target), + QuantumOp::CH(lowered_control, lowered_target), + ) => Self::same_pair( + *source_control, + *source_target, + *lowered_control, + *lowered_target, + ), + ( + QuantumOp::CRZ(source_theta, source_control, source_target), + QuantumOp::CRZ(lowered_theta, lowered_control, lowered_target), + ) => { + Self::same_float(*source_theta, *lowered_theta) + && Self::same_pair( + *source_control, + *source_target, + *lowered_control, + *lowered_target, + ) + } + ( + QuantumOp::CCX(source_control_1, source_control_2, source_target), + QuantumOp::CCX(lowered_control_1, lowered_control_2, lowered_target), + ) => { + (source_control_1, source_control_2, source_target) + == (lowered_control_1, lowered_control_2, lowered_target) + } + ( + QuantumOp::ZZ(source_qubit_1, source_qubit_2), + QuantumOp::ZZ(lowered_qubit_1, lowered_qubit_2), + ) => Self::same_unordered_pair( + *source_qubit_1, + *source_qubit_2, + *lowered_qubit_1, + *lowered_qubit_2, + ), + ( + QuantumOp::RZZ(source_theta, source_qubit_1, source_qubit_2), + QuantumOp::RZZ(lowered_theta, lowered_qubit_1, lowered_qubit_2), + ) => { + Self::same_float(*source_theta, *lowered_theta) + && Self::same_unordered_pair( + *source_qubit_1, + *source_qubit_2, + *lowered_qubit_1, + *lowered_qubit_2, + ) + } + ( + QuantumOp::ZZ(source_qubit_1, source_qubit_2), + QuantumOp::RZZ(_, lowered_qubit_1, lowered_qubit_2), + ) => Self::same_unordered_pair( + *source_qubit_1, + *source_qubit_2, + *lowered_qubit_1, + *lowered_qubit_2, + ), + ( + QuantumOp::Measure(source_qubit, source_result), + QuantumOp::Measure(lowered_qubit, lowered_result), + ) => source_qubit == lowered_qubit && source_result == lowered_result, + _ => false, + } + } + + fn same_float(left: f64, right: f64) -> bool { + (left - right).abs() <= 1e-12 + } + + fn same_pair(left_a: usize, left_b: usize, right_a: usize, right_b: usize) -> bool { + (left_a, left_b) == (right_a, right_b) + } + + fn same_unordered_pair(left_a: usize, left_b: usize, right_a: usize, right_b: usize) -> bool { + Self::same_pair(left_a, left_b, right_a, right_b) + || Self::same_pair(left_a, left_b, right_b, right_a) + } + + fn quantum_op_qubits(qop: &QuantumOp) -> BTreeSet { + let mut qubits = BTreeSet::new(); + match qop { + QuantumOp::H(qubit) + | QuantumOp::X(qubit) + | QuantumOp::Y(qubit) + | QuantumOp::Z(qubit) + | QuantumOp::S(qubit) + | QuantumOp::Sdg(qubit) + | QuantumOp::T(qubit) + | QuantumOp::Tdg(qubit) + | QuantumOp::RX(_, qubit) + | QuantumOp::RY(_, qubit) + | QuantumOp::RZ(_, qubit) + | QuantumOp::RXY(_, _, qubit) + | QuantumOp::Idle(_, qubit) + | QuantumOp::Measure(qubit, _) + | QuantumOp::Reset(qubit) => { + qubits.insert(*qubit); + } + QuantumOp::CX(qubit_1, qubit_2) + | QuantumOp::CY(qubit_1, qubit_2) + | QuantumOp::CZ(qubit_1, qubit_2) + | QuantumOp::CH(qubit_1, qubit_2) + | QuantumOp::CRZ(_, qubit_1, qubit_2) + | QuantumOp::ZZ(qubit_1, qubit_2) + | QuantumOp::RZZ(_, qubit_1, qubit_2) => { + qubits.insert(*qubit_1); + qubits.insert(*qubit_2); + } + QuantumOp::CCX(qubit_1, qubit_2, qubit_3) => { + qubits.insert(*qubit_1); + qubits.insert(*qubit_2); + qubits.insert(*qubit_3); + } + } + qubits + } + + fn single_qubit_gate_qubit(qop: &QuantumOp) -> Option { + match qop { + QuantumOp::H(qubit) + | QuantumOp::X(qubit) + | QuantumOp::Y(qubit) + | QuantumOp::Z(qubit) + | QuantumOp::S(qubit) + | QuantumOp::Sdg(qubit) + | QuantumOp::T(qubit) + | QuantumOp::Tdg(qubit) + | QuantumOp::RX(_, qubit) + | QuantumOp::RY(_, qubit) + | QuantumOp::RZ(_, qubit) + | QuantumOp::RXY(_, _, qubit) => Some(*qubit), + _ => None, + } + } + + fn two_qubit_gate_qubits(qop: &QuantumOp) -> Option<(usize, usize)> { + match qop { + QuantumOp::CX(qubit_1, qubit_2) + | QuantumOp::CY(qubit_1, qubit_2) + | QuantumOp::CZ(qubit_1, qubit_2) + | QuantumOp::CH(qubit_1, qubit_2) + | QuantumOp::CRZ(_, qubit_1, qubit_2) + | QuantumOp::ZZ(qubit_1, qubit_2) + | QuantumOp::RZZ(_, qubit_1, qubit_2) => Some((*qubit_1, *qubit_2)), + _ => None, + } + } + + fn fail_if_metadata_was_not_lowered( + source_metadata: &VecDeque, + ) -> Result<()> { + let leftover_metadata = source_metadata + .iter() + .filter(|source| Self::trace_metadata_requires_lowering(&source.metadata)) + .collect::>(); + if leftover_metadata.is_empty() { + return Ok(()); + } + let examples = leftover_metadata + .iter() + .take(3) + .map(|source| format!("{:?} for {:?}", source.metadata, source.op)) + .collect::>() + .join(", "); + Err(RuntimeError::ExecutionError(format!( + "runtime lowering did not emit non-idle operations for {} metadata-bearing source operation(s); examples: {examples}", + leftover_metadata.len() + ))) + } + + fn trace_metadata_requires_lowering(metadata: &TraceMetadata) -> bool { + metadata + .get("source_lowering_required") + .is_some_and(|value| value.eq_ignore_ascii_case("true")) + } + + fn fail_if_qubit_metadata_was_not_consumed( + pending_qubit_metadata: &BTreeMap, + ) -> Result<()> { + if pending_qubit_metadata.is_empty() { + return Ok(()); + } + let examples = pending_qubit_metadata + .iter() + .take(3) + .map(|(qubit, metadata)| format!("qubit {qubit}: {metadata:?}")) + .collect::>() + .join(", "); + Err(RuntimeError::ExecutionError(format!( + "qubit-scoped trace metadata was not followed by a compatible quantum operation for {} qubit(s); examples: {examples}", + pending_qubit_metadata.len() + ))) + } + fn convert_runtime_batch(&mut self, batch: RuntimeOperationBatch) -> Result> { let mut lowered_ops = Vec::new(); let start_time = batch.start_time_nanos; @@ -1349,6 +1716,86 @@ impl QisRuntime for SeleneRuntime { Ok(lowered_ops) } + fn lower_operations_with_metadata( + &mut self, + operations: &[Operation], + ) -> Result> { + if has_explicit_qubit_allocations(operations) { + self.uses_explicit_qubit_allocation = true; + } + let (num_qubits, num_results) = + operation_capacity_with_mode(operations, self.uses_explicit_qubit_allocation); + self.num_qubits = self.num_qubits.max(num_qubits); + self.num_results = self.num_results.max(num_results); + self.ensure_plugin_capacity()?; + self.load_plugin()?; + + let mut lowered_ops = Vec::new(); + let mut source_metadata = VecDeque::new(); + let mut pending_global_metadata = TraceMetadata::new(); + let mut pending_qubit_metadata: BTreeMap = BTreeMap::new(); + + for op in operations { + match op { + Operation::TraceMetadata { metadata, qubit } => { + if let Some(qubit) = qubit { + let pending = pending_qubit_metadata.entry(*qubit).or_default(); + Self::merge_trace_metadata(pending, metadata.clone())?; + } else { + Self::merge_trace_metadata(&mut pending_global_metadata, metadata.clone())?; + } + } + Operation::Quantum(qop) => { + let metadata = Self::take_pending_trace_metadata_for_source_op( + qop, + &mut pending_global_metadata, + &mut pending_qubit_metadata, + )?; + if !metadata.is_empty() { + let source_op = self.map_quantum_op_to_runtime_qubits(qop)?; + source_metadata.push_back(SourceTraceMetadata { + op: source_op, + metadata, + }); + } + let mut emitted_ops = Vec::new(); + self.submit_operation_to_runtime(op, &mut emitted_ops)?; + Self::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + emitted_ops, + &mut source_metadata, + ); + } + _ => { + let mut emitted_ops = Vec::new(); + self.submit_operation_to_runtime(op, &mut emitted_ops)?; + Self::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + emitted_ops, + &mut source_metadata, + ); + } + } + } + + let emitted_ops = self.drain_runtime_operations()?; + Self::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + emitted_ops, + &mut source_metadata, + ); + + if !pending_global_metadata.is_empty() { + return Err(RuntimeError::ExecutionError(format!( + "trace metadata was not followed by a quantum operation: {pending_global_metadata:?}" + ))); + } + Self::fail_if_qubit_metadata_was_not_consumed(&pending_qubit_metadata)?; + Self::fail_if_metadata_was_not_lowered(&source_metadata)?; + + Ok(lowered_ops) + } + fn provide_measurements(&mut self, measurements: BTreeMap) -> Result<()> { debug!( "Received {} measurement results, num_results={}, allocated_results={:?}", @@ -1579,6 +2026,259 @@ mod tests { ); } + #[test] + fn test_source_metadata_attaches_to_first_non_idle_lowered_op() { + let mut metadata = TraceMetadata::new(); + metadata.insert("source_label".to_string(), "probe:szz-host".to_string()); + let mut source_metadata = VecDeque::from([SourceTraceMetadata { + op: QuantumOp::RZZ(0.5, 0, 1), + metadata, + }]); + let mut lowered_ops = Vec::new(); + + SeleneRuntime::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + vec![QuantumOp::Idle(20e-9, 0), QuantumOp::RZZ(0.5, 0, 1)], + &mut source_metadata, + ); + + assert!(lowered_ops[0].metadata.is_empty()); + assert_eq!( + lowered_ops[1] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:szz-host") + ); + assert!(source_metadata.is_empty()); + } + + #[test] + fn test_source_idle_metadata_can_attach_to_idle_op() { + let mut metadata = TraceMetadata::new(); + metadata.insert("source_label".to_string(), "probe:idle".to_string()); + let mut source_metadata = VecDeque::from([SourceTraceMetadata { + op: QuantumOp::Idle(20e-9, 0), + metadata, + }]); + let mut lowered_ops = Vec::new(); + + SeleneRuntime::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + vec![QuantumOp::Idle(20e-9, 0)], + &mut source_metadata, + ); + + assert_eq!( + lowered_ops[0] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:idle") + ); + assert!(source_metadata.is_empty()); + } + + #[test] + fn test_unmatched_metadata_does_not_block_later_compatible_metadata() { + let mut rz_metadata = TraceMetadata::new(); + rz_metadata.insert("source_label".to_string(), "probe:virtual-rz".to_string()); + let mut rzz_metadata = TraceMetadata::new(); + rzz_metadata.insert("source_label".to_string(), "probe:szz-host".to_string()); + let mut source_metadata = VecDeque::from([ + SourceTraceMetadata { + op: QuantumOp::RZ(0.25, 0), + metadata: rz_metadata, + }, + SourceTraceMetadata { + op: QuantumOp::RZZ(0.5, 0, 1), + metadata: rzz_metadata, + }, + ]); + let mut lowered_ops = Vec::new(); + + SeleneRuntime::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + vec![QuantumOp::RZZ(0.5, 0, 1)], + &mut source_metadata, + ); + + assert_eq!( + lowered_ops[0] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:szz-host") + ); + assert_eq!(source_metadata.len(), 1); + assert_eq!( + source_metadata[0] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:virtual-rz") + ); + } + + #[test] + fn test_source_metadata_can_attach_after_runtime_reordering() { + let mut first_metadata = TraceMetadata::new(); + first_metadata.insert("source_label".to_string(), "probe:first".to_string()); + let mut second_metadata = TraceMetadata::new(); + second_metadata.insert("source_label".to_string(), "probe:second".to_string()); + let mut source_metadata = VecDeque::from([ + SourceTraceMetadata { + op: QuantumOp::RZZ(0.5, 0, 1), + metadata: first_metadata, + }, + SourceTraceMetadata { + op: QuantumOp::RZZ(-0.5, 2, 3), + metadata: second_metadata, + }, + ]); + let mut lowered_ops = Vec::new(); + + SeleneRuntime::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + vec![QuantumOp::RZZ(-0.5, 2, 3), QuantumOp::RZZ(0.5, 0, 1)], + &mut source_metadata, + ); + + assert_eq!( + lowered_ops[0] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:second") + ); + assert_eq!( + lowered_ops[1] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:first") + ); + assert!(source_metadata.is_empty()); + } + + #[test] + fn test_source_gate_metadata_matches_runtime_normalized_single_qubit_pulse() { + let mut metadata = TraceMetadata::new(); + metadata.insert("source_gate".to_string(), "H".to_string()); + metadata.insert("source_label".to_string(), "probe:h-prefix".to_string()); + let mut source_metadata = VecDeque::from([SourceTraceMetadata { + op: QuantumOp::RXY(std::f64::consts::FRAC_PI_2, -std::f64::consts::FRAC_PI_2, 2), + metadata, + }]); + let mut lowered_ops = Vec::new(); + + SeleneRuntime::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + vec![QuantumOp::RXY(std::f64::consts::FRAC_PI_2, 0.0, 2)], + &mut source_metadata, + ); + + assert_eq!( + lowered_ops[0] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:h-prefix") + ); + assert!(source_metadata.is_empty()); + } + + #[test] + fn test_qubit_scoped_metadata_waits_for_compatible_source_op() { + let mut h_metadata = TraceMetadata::new(); + h_metadata.insert("source_gate".to_string(), "H".to_string()); + h_metadata.insert("source_label".to_string(), "probe:h-prefix".to_string()); + let mut szz_metadata = TraceMetadata::new(); + szz_metadata.insert("source_gate".to_string(), "SZZ".to_string()); + szz_metadata.insert("source_label".to_string(), "probe:szz-host".to_string()); + + let mut pending_global_metadata = TraceMetadata::new(); + let mut pending_qubit_metadata = BTreeMap::from([(1, szz_metadata), (8, h_metadata)]); + + let rxy_metadata = SeleneRuntime::take_pending_trace_metadata_for_source_op( + &QuantumOp::RXY(std::f64::consts::FRAC_PI_2, 0.0, 8), + &mut pending_global_metadata, + &mut pending_qubit_metadata, + ) + .expect("take metadata for RXY"); + assert_eq!( + rxy_metadata.get("source_label").map(String::as_str), + Some("probe:h-prefix") + ); + assert!(pending_qubit_metadata.contains_key(&1)); + assert!(!pending_qubit_metadata.contains_key(&8)); + + let rzz_metadata = SeleneRuntime::take_pending_trace_metadata_for_source_op( + &QuantumOp::RZZ(-std::f64::consts::FRAC_PI_2, 9, 1), + &mut pending_global_metadata, + &mut pending_qubit_metadata, + ) + .expect("take metadata for RZZ"); + assert_eq!( + rzz_metadata.get("source_label").map(String::as_str), + Some("probe:szz-host") + ); + assert!(pending_qubit_metadata.is_empty()); + } + + #[test] + fn test_conflicting_trace_metadata_fails_loudly() { + let mut left_metadata = TraceMetadata::new(); + left_metadata.insert("source_label".to_string(), "probe:left".to_string()); + let mut right_metadata = TraceMetadata::new(); + right_metadata.insert("source_label".to_string(), "probe:right".to_string()); + + let mut pending_global_metadata = TraceMetadata::new(); + let mut pending_qubit_metadata = BTreeMap::from([(0, left_metadata), (1, right_metadata)]); + + let error = SeleneRuntime::take_pending_trace_metadata_for_source_op( + &QuantumOp::RZZ(std::f64::consts::FRAC_PI_2, 0, 1), + &mut pending_global_metadata, + &mut pending_qubit_metadata, + ) + .expect_err("conflicting source labels should fail"); + assert!(error.to_string().contains("conflicting trace metadata")); + } + + #[test] + fn test_optional_unlowered_trace_metadata_is_allowed() { + let mut metadata = TraceMetadata::new(); + metadata.insert( + "source_label".to_string(), + "probe:optimized-away-prefix".to_string(), + ); + let source_metadata = VecDeque::from([SourceTraceMetadata { + op: QuantumOp::RXY(std::f64::consts::FRAC_PI_2, 0.0, 4), + metadata, + }]); + + SeleneRuntime::fail_if_metadata_was_not_lowered(&source_metadata) + .expect("optional metadata may be optimized away by the runtime"); + } + + #[test] + fn test_required_unlowered_trace_metadata_fails_loudly() { + let mut metadata = TraceMetadata::new(); + metadata.insert( + "source_label".to_string(), + "probe:required-host".to_string(), + ); + metadata.insert("source_lowering_required".to_string(), "true".to_string()); + let source_metadata = VecDeque::from([SourceTraceMetadata { + op: QuantumOp::RZZ(std::f64::consts::FRAC_PI_2, 0, 1), + metadata, + }]); + + let error = SeleneRuntime::fail_if_metadata_was_not_lowered(&source_metadata) + .expect_err("required metadata should fail when it is not lowered"); + assert!(error.to_string().contains("required-host")); + } + #[test] fn test_collector_capacity_includes_direct_program_handles() { let mut collector = OperationCollector::new(); diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index a05d94033..f3ff8e6e5 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -371,7 +371,11 @@ def generate_guppy_source( lines.extend( [ "@guppy.declare", - "def pecos_qis_trace_metadata_hugr(key: str, value: str) -> None: ...", + ( + "def pecos_qis_trace_metadata_qubit_hugr(" + "q: qubit @ owned, key: str, value: str" + ") -> qubit: ..." + ), "", "", ], @@ -515,8 +519,16 @@ def _szz_touch_compensation_gates(axis: str, sign: int) -> tuple[OpType, ...]: msg = f"unsupported Pauli axis {axis!r}" raise ValueError(msg) - def _append_szz_trace_metadata(target: list[str], indent: str, key: str, value: str) -> None: - target.append(f'{indent}pecos_qis_trace_metadata_hugr("{key}", "{value}")') + def _append_szz_trace_metadata( + target: list[str], + indent: str, + key: str, + value: str, + qubit_expr: str, + ) -> None: + target.append( + f'{indent}{qubit_expr} = pecos_qis_trace_metadata_qubit_hugr({qubit_expr}, "{key}", "{value}")', + ) def _append_szz_gate_trace_metadata( target: list[str], @@ -524,15 +536,25 @@ def _append_szz_gate_trace_metadata( *, source_kind: str, source_label: str, + qubit_expr: str, host_label: str | None = None, gate: OpType | None = None, + lowering_required: bool = False, ) -> None: - _append_szz_trace_metadata(target, indent, "source_kind", source_kind) - _append_szz_trace_metadata(target, indent, "source_label", source_label) + _append_szz_trace_metadata(target, indent, "source_kind", source_kind, qubit_expr) + _append_szz_trace_metadata(target, indent, "source_label", source_label, qubit_expr) if host_label is not None: - _append_szz_trace_metadata(target, indent, "szz_host_label", host_label) + _append_szz_trace_metadata(target, indent, "szz_host_label", host_label, qubit_expr) if gate is not None: - _append_szz_trace_metadata(target, indent, "source_gate", gate.name) + _append_szz_trace_metadata(target, indent, "source_gate", gate.name, qubit_expr) + if lowering_required: + _append_szz_trace_metadata( + target, + indent, + "source_lowering_required", + "true", + qubit_expr, + ) def _append_szz_flow_gate( target: list[str], @@ -561,6 +583,7 @@ def _append_szz_flow_gate( indent, source_kind="szz_data_prefix", source_label=source_label, + qubit_expr=qubit_expr, host_label=host_label, gate=op_type, ) @@ -755,7 +778,9 @@ def discharge_data_for_szz( indent, source_kind="szz_host", source_label=host_label, + qubit_expr=data_expr(data_q), gate=host_gate, + lowering_required=True, ) target.append( f"{indent}{ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)} = " diff --git a/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py b/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py index 4861ebb45..111f9fb33 100644 --- a/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py +++ b/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py @@ -67,18 +67,23 @@ def test_trace_metadata_helper_uses_public_symbol() -> None: """Test that declared trace metadata helpers compile to the public FFI symbol.""" try: from guppylang import guppy + from guppylang.std.builtins import owned + from guppylang.std.quantum import h, measure, qubit from pecos_rslib import compile_hugr_to_qis except ImportError as e: pytest.skip(f"Required imports not available: {e}") @guppy.declare - def pecos_qis_trace_metadata_hugr(key: str, value: str) -> None: ... + def pecos_qis_trace_metadata_qubit_hugr(q: qubit @ owned, key: str, value: str) -> qubit: ... @guppy def metadata_probe() -> None: - pecos_qis_trace_metadata_hugr("source_kind", "szz_host") + q = qubit() + q = pecos_qis_trace_metadata_qubit_hugr(q, "source_kind", "szz_host") + h(q) + _ = measure(q) llvm_ir = compile_hugr_to_qis(metadata_probe.compile().to_bytes()) - assert "@pecos_qis_trace_metadata_hugr" in llvm_ir - assert "@__hugr__.pecos_qis_trace_metadata_hugr" not in llvm_ir + assert "@pecos_qis_trace_metadata_qubit_hugr" in llvm_ir + assert "@__hugr__.pecos_qis_trace_metadata_qubit_hugr" not in llvm_ir 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 1a057ddd4..79dada3d8 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -304,10 +304,12 @@ def test_direct_surface_renderers_accept_check_plan_as_source_of_truth() -> None guppy_source = generate_guppy_from_patch(patch, check_plan="szz_current_v1") assert "Check plan: szz_current_v1" in guppy_source - assert "def pecos_qis_trace_metadata_hugr(key: str, value: str) -> None: ..." in guppy_source - assert 'pecos_qis_trace_metadata_hugr("source_kind", "szz_host")' in guppy_source - assert 'pecos_qis_trace_metadata_hugr("source_kind", "szz_data_prefix")' in guppy_source - assert 'pecos_qis_trace_metadata_hugr("szz_host_label", "szz:' in guppy_source + assert "def pecos_qis_trace_metadata_qubit_hugr(" in guppy_source + assert " = pecos_qis_trace_metadata_qubit_hugr(" in guppy_source + assert '"source_kind", "szz_host"' in guppy_source + assert '"source_kind", "szz_data_prefix"' in guppy_source + assert '"szz_host_label", "szz:' in guppy_source + assert '"source_lowering_required", "true"' in guppy_source def test_boundary_first_szz_check_plan_changes_source_gates_not_metadata() -> 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 22d9a43b0..af02c778c 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -8,7 +8,7 @@ import pytest from guppylang import guppy -from guppylang.std.builtins import result +from guppylang.std.builtins import owned, result from guppylang.std.quantum import h, measure, qubit, x from pecos.guppy import get_num_qubits, make_surface_code from pecos.qec import DetectorErrorModel @@ -55,11 +55,25 @@ def _measurement_feedback() -> None: result("b1", b1) +@guppy.declare +def pecos_qis_trace_metadata_qubit_hugr(q: qubit @ owned, key: str, value: str) -> qubit: ... + + +@guppy +def _metadata_before_h_gate() -> None: + q = qubit() + q = pecos_qis_trace_metadata_qubit_hugr(q, "source_kind", "szz_data_prefix") + q = pecos_qis_trace_metadata_qubit_hugr(q, "source_label", "probe:prefix") + h(q) + _ = measure(q) + + def test_operation_trace_capture_uses_trace_friendly_quantum_backend(monkeypatch: pytest.MonkeyPatch) -> None: import pecos def forbidden_stabilizer(): - raise AssertionError("trace capture should not validate operations with stabilizer evolution") + msg = "trace capture should not validate operations with stabilizer evolution" + raise AssertionError(msg) monkeypatch.setattr(pecos, "stabilizer", forbidden_stabilizer) @@ -69,6 +83,23 @@ def forbidden_stabilizer(): assert "m" in result_names +def test_qubit_trace_metadata_stays_ordered_before_gate() -> None: + chunks = capture_guppy_operation_trace(_metadata_before_h_gate, num_qubits=1, seed=0) + lowered_ops = [ + op + for chunk in chunks + for op in chunk.get("lowered_quantum_ops", []) + ] + + assert lowered_ops[1]["gate_type"] == "R1XY" + assert lowered_ops[1]["metadata"] == { + "source_kind": "szz_data_prefix", + "source_label": "probe:prefix", + } + assert lowered_ops[-1]["gate_type"] == "MZ" + assert lowered_ops[-1]["metadata"] == {} + + def _dem_text(*, detectors_json: str = "[]", observables_json: str = "[]") -> str: dem = DetectorErrorModel.from_guppy( _single_measurement, From 3210d0b2e007c3023029161b916b2641381bb5aa Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 25 Jun 2026 09:51:04 -0600 Subject: [PATCH 128/150] Fence SZZ prefixes before runtime barriers --- crates/pecos-qis/src/selene_runtime.rs | 11 +++++++++++ .../quantum-pecos/src/pecos/guppy/surface.py | 18 +++++++++--------- .../tests/qec/surface/test_check_plan.py | 19 +++++++++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index 3b22f1622..53803a964 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -1709,6 +1709,9 @@ impl QisRuntime for SeleneRuntime { let mut lowered_ops = Vec::new(); for op in operations { + if matches!(op, Operation::Barrier) { + lowered_ops.extend(self.drain_runtime_operations()?); + } self.submit_operation_to_runtime(op, &mut lowered_ops)?; } @@ -1766,6 +1769,14 @@ impl QisRuntime for SeleneRuntime { &mut source_metadata, ); } + Operation::Barrier => { + let emitted_ops = self.drain_runtime_operations()?; + Self::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + emitted_ops, + &mut source_metadata, + ); + } _ => { let mut emitted_ops = Vec::new(); self.submit_operation_to_runtime(op, &mut emitted_ops)?; diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index f3ff8e6e5..57e14ef6d 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -233,11 +233,11 @@ def generate_guppy_source( szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. ``False`` or ``"none"`` emits no barriers; ``True`` or ``"all"`` emits a public Guppy ``barrier`` before every SZZ/SZZdg host region; - ``"data-prefix"`` emits one only when a non-virtual local - data-frame pulse is about to be discharged into that host. Barriers - have no ideal-unitary effect, but give runtimes a principled - scheduling boundary before selected local data-frame pulses and - their entangling host. + ``"data-prefix"`` emits one only after a non-virtual local + data-frame pulse is discharged and before its host. Barriers have + no ideal-unitary effect, but give runtimes a principled scheduling + boundary between selected local data-frame pulses and their + entangling host. Returns: Python/Guppy source code as a string. @@ -760,6 +760,10 @@ def discharge_data_for_szz( has_data_prefix = ( pending != _SZZ_FLOW_IDENTITY and not _szz_flow_is_virtual_z(pending) ) + sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] + host_gate = OpType.SZZ if sign > 0 else OpType.SZZDG + host_label = f"szz:r{rnd_idx + 1}:{stab_type}{stab_idx}:d{data_q}:{host_gate.name}" + discharge_data_for_szz(data_q, host_label=host_label) if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_ALL or ( szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX and has_data_prefix @@ -768,10 +772,6 @@ def discharge_data_for_szz( f"{indent}barrier({ancilla_expr(stab_type, stab_idx)}, " f"{data_expr(data_q)})", ) - sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] - host_gate = OpType.SZZ if sign > 0 else OpType.SZZDG - host_label = f"szz:r{rnd_idx + 1}:{stab_type}{stab_idx}:d{data_q}:{host_gate.name}" - discharge_data_for_szz(data_q, host_label=host_label) half_turns = "0.5" if sign > 0 else "-0.5" _append_szz_gate_trace_metadata( target, 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 79dada3d8..eed293d92 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -312,6 +312,25 @@ def test_direct_surface_renderers_accept_check_plan_as_source_of_truth() -> None assert '"source_lowering_required", "true"' in guppy_source +def test_szz_runtime_barrier_fences_data_prefix_before_host() -> None: + from pecos.guppy.surface import generate_guppy_source + from pecos.qec.surface import SurfacePatch + + patch = SurfacePatch.create(distance=3) + source = generate_guppy_source( + patch, + check_plan="szz_current_v1", + szz_runtime_barriers="data-prefix", + ) + + prefix_index = source.index('"source_kind", "szz_data_prefix"') + barrier_index = source.index("barrier(") + host_index = source.index('"source_kind", "szz_host"', prefix_index) + zz_phase_index = source.index("zz_phase(", barrier_index) + + assert prefix_index < barrier_index < host_index < zz_phase_index + + def test_boundary_first_szz_check_plan_changes_source_gates_not_metadata() -> None: from pecos.qec.surface import SurfacePatch from pecos.qec.surface.circuit_builder import ( From a1abbd1273121978246f60368a1f97cd2071404d Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 25 Jun 2026 09:57:29 -0600 Subject: [PATCH 129/150] Document Guppy barrier trace gap --- .../tests/qec/test_from_guppy_dem.py | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) 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 af02c778c..dfdad3354 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -8,7 +8,7 @@ import pytest from guppylang import guppy -from guppylang.std.builtins import owned, result +from guppylang.std.builtins import barrier, owned, result from guppylang.std.quantum import h, measure, qubit, x from pecos.guppy import get_num_qubits, make_surface_code from pecos.qec import DetectorErrorModel @@ -68,6 +68,17 @@ def _metadata_before_h_gate() -> None: _ = measure(q) +@guppy +def _barrier_between_single_qubit_gates() -> None: + q0 = qubit() + q1 = qubit() + h(q0) + barrier(q0, q1) + h(q1) + _ = measure(q0) + _ = measure(q1) + + def test_operation_trace_capture_uses_trace_friendly_quantum_backend(monkeypatch: pytest.MonkeyPatch) -> None: import pecos @@ -83,6 +94,29 @@ def forbidden_stabilizer(): assert "m" in result_names +@pytest.mark.xfail( + reason=( + "Guppy public barrier(...) is currently optimized away before PECOS " + "QIS operation collection; hosted SZZ prefix scheduling needs a " + "barrier-preserving or hosted-operation lowering path." + ), + strict=True, +) +def test_guppy_barrier_survives_into_qis_operation_trace() -> None: + chunks = capture_guppy_operation_trace( + _barrier_between_single_qubit_gates, + num_qubits=2, + seed=0, + ) + operations = [ + operation + for chunk in chunks + for operation in chunk.get("operations", []) + ] + + assert any(operation == "Barrier" or "Barrier" in operation for operation in operations) + + def test_qubit_trace_metadata_stays_ordered_before_gate() -> None: chunks = capture_guppy_operation_trace(_metadata_before_h_gate, num_qubits=1, seed=0) lowered_ops = [ From 88ee9b16b77e63243c9ddd36450feaad5e9c0b6d Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 25 Jun 2026 10:13:12 -0600 Subject: [PATCH 130/150] Add hosted operation design note --- docs/development/hosted-operations.md | 187 ++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/development/hosted-operations.md diff --git a/docs/development/hosted-operations.md b/docs/development/hosted-operations.md new file mode 100644 index 000000000..22313b5c0 --- /dev/null +++ b/docs/development/hosted-operations.md @@ -0,0 +1,187 @@ +# Hosted Operations + +This note sketches a generic PECOS abstraction for operations that must stay +attached to a later quantum host operation during lowering, scheduling, tracing, +and DEM construction. + +## Motivation + +Some source circuits contain local operations whose physical meaning is only +complete when paired with a later host operation. A common example is a +single-qubit Clifford basis change that prepares a data qubit for a two-qubit +interaction. If a compiler or runtime legally moves that local pulse far away +from the host, the ideal unitary can remain correct while the physical idle +noise model changes substantially. + +Plain source adjacency and public language-level barriers are not enough as a +long-term contract: + +- Source order can be changed by Guppy/HUGR/QIR/runtime lowering. +- Public barriers may be optimized away before QIS operation collection. +- A barrier says "do not reorder across this point"; it does not say "this + local pulse is hosted by that two-qubit gate". + +PECOS needs a way to represent this source intent directly and fail loudly when +the intent is dropped or cannot be honored. + +## Definition + +A hosted operation relationship binds one or more local source operations to one +host source operation: + +- `host_id`: stable source identifier for the host operation. +- `host_kind`: generic host category, for example `two_qubit_gate` or + `measurement`. +- `local_role`: local operation role, for example `basis_prefix`, + `basis_suffix`, `frame_update`, or `readout_prefix`. +- `local_qubits`: source qubits touched by the local operation. +- `host_qubits`: source qubits touched by the host operation. +- `policy`: requested lowering/scheduling policy. + +The relationship is not a physical-device-specific instruction. It is source +intent that runtimes may use to lower better schedules, and that PECOS can use +to validate traces and build diagnostics. + +## Initial Policies + +Start with strict, observable policies instead of implicit best effort: + +- `metadata_only`: preserve provenance, but do not require adjacency. +- `same_runtime_batch`: the local operation and host must be submitted to the + runtime in the same hosted group or batch. +- `max_idle_time`: the lowered trace must show no more than a configured time + between the local operation and host on the local qubit. +- `lowering_required`: the host must produce a compatible lowered operation. + +For any policy stronger than `metadata_only`, failure should be explicit and +actionable. Silent fallback to unhosted behavior is not acceptable. + +## Trace Requirements + +Traces should preserve both the local and host sides: + +- local lowered operations carry `source_kind`, `source_label`, `host_id`, and + `local_role`. +- host lowered operations carry `source_kind`, `source_label`, and `host_id`. +- replay code can pair local operations to hosts by exact `host_id`, not by + nearest-neighbor inference. +- audit tools can report whether provenance is exact, inferred, mismatched, or + missing. + +Exact provenance is necessary but not sufficient. It proves PECOS can identify +the intended host; it does not prove the lowered schedule kept the operations +adjacent or within a noise-model threshold. + +## Candidate Implementation Layers + +### 1. Metadata-Only Vertical Slice + +The smallest useful slice is the current trace-metadata approach: + +1. Emit qubit-scoped metadata before each local operation and host operation. +2. Preserve metadata through QIS operation collection and runtime replay. +3. Attach metadata to lowered operations. +4. Require exact host matching in downstream audits. + +This slice is useful for diagnostics and DEM cache identity, but it does not +constrain scheduling. + +### 2. Barrier-Preserving Lowering + +Preserving public barriers into `Operation::Barrier` can create runtime replay +batch boundaries, and PECOS replay should drain at those boundaries. This is a +valid generic improvement, but it still does not express "this local pulse is +hosted by that operation". It should not be the only hosted-operation plan. + +Current state: + +- SLR QIR codegen can emit QIR barrier calls such as + `__quantum__qis__barrierN__body`. +- `pecos-qis-ffi-types` and `pecos-qis-ffi` already have an + `Operation::Barrier` control-flow marker. +- PECOS traced Selene replay drains runtime operations at `Operation::Barrier` + when that marker is present. +- A minimal Guppy public `barrier(...)` probe is currently optimized away before + PECOS QIS operation collection. The captured raw operation trace contains + allocations, gates, measurements, and releases, but no `Barrier` operation. + +So barrier preservation requires a Guppy/HUGR/QIR/QIS bridge that lowers public +barriers or qsystem `RuntimeBarrier` operations into `Operation::Barrier` +instead of dropping them as pass-through no-ops. This is useful, but still +secondary to a hosted-operation relationship because a barrier does not identify +which host operation a local pulse belongs to. + +### 3. Explicit Hosted Operation + +The stronger long-term abstraction is a QIS-level hosted operation or hosted +group: + +```text +HostedGroup { + host_id, + locals: [QuantumOp], + host: QuantumOp, + policy, +} +``` + +An equivalent representation could be a pair of begin/end host markers around a +set of normal `QuantumOp`s, if that is easier to thread through existing +collectors. The key requirement is that the runtime receives a relationship, +not just a sequence of independent gates. + +## Surface-Code SZZ Example + +For SZZ/SZZdg surface-code checks: + +1. The source renderer forward-flows data-frame Cliffords. +2. Before an SZZ/SZZdg interaction, it emits any required non-virtual local + data prefix. +3. The prefix is tagged as `local_role=basis_prefix` with the SZZ/SZZdg + `host_id`. +4. The SZZ/SZZdg interaction is tagged as the host with the same `host_id`. +5. Runtime trace replay verifies exact provenance and, when requested, a + bounded prefix-host idle threshold. + +This should remain generic. PECOS should not encode downstream device names or +experiment packages into the abstraction. + +## Fail-Loud Conditions + +PECOS should fail loudly when: + +- hosted metadata is attached to a source qubit but never consumed by a + compatible source operation. +- two hosted metadata maps disagree on a key for the same source operation. +- a required host is optimized away or cannot be matched to a lowered operation. +- exact host provenance is requested but only inferred matching is available. +- a scheduling policy such as `max_idle_time` is exceeded in the lowered trace. + +Error messages should include source labels, qubits, host labels, observed idle +duration or tick separation, and the relevant policy. + +## Open Questions + +- Should hosted groups be a first-class QIS `Operation`, or represented as + markers plus normal `QuantumOp`s? +- Which Guppy/HUGR constructs can carry hosted relationships without being + optimized away? +- Do runtime plugins need an explicit hosted-operation callback, or can PECOS + lower hosted groups into existing runtime APIs with flush boundaries and + metadata? +- How should hosted operations interact with DEM generation for decoders that + consume raw hypergraph DEMs versus decomposed DEMs? +- Can the same abstraction cover measurement-hosted readout prefixes and + two-qubit-gate-hosted basis prefixes? + +## Recommended Next Slice + +1. Add a minimal barrier-survival diagnostic for public Guppy barriers in QIS + traces. +2. If preserving public barriers is small and generic, implement it as a + separate quality-of-lowering improvement. +3. Prototype an explicit hosted SZZ prefix relationship in the QIS trace path. +4. Use idle audits to compare hosted and unhosted lowering on representative + surface-code memory circuits. +5. Keep downstream experiments guarded by exact provenance and prefix-host idle + thresholds until hosted scheduling is proven by traces. From f8495a68d2a77a7789ccda891ed0347e6d43ecde Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 25 Jun 2026 11:08:44 -0600 Subject: [PATCH 131/150] Add hosted SZZ metadata keys --- .../quantum-pecos/src/pecos/guppy/surface.py | 6 ++++++ .../tests/qec/surface/test_check_plan.py | 20 +++++++++++++++++++ .../tests/qec/test_from_guppy_dem.py | 4 ++++ 3 files changed, 30 insertions(+) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 57e14ef6d..e69f45d3d 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -538,6 +538,7 @@ def _append_szz_gate_trace_metadata( source_label: str, qubit_expr: str, host_label: str | None = None, + local_role: str | None = None, gate: OpType | None = None, lowering_required: bool = False, ) -> None: @@ -545,6 +546,9 @@ def _append_szz_gate_trace_metadata( _append_szz_trace_metadata(target, indent, "source_label", source_label, qubit_expr) if host_label is not None: _append_szz_trace_metadata(target, indent, "szz_host_label", host_label, qubit_expr) + _append_szz_trace_metadata(target, indent, "host_id", host_label, qubit_expr) + if local_role is not None: + _append_szz_trace_metadata(target, indent, "local_role", local_role, qubit_expr) if gate is not None: _append_szz_trace_metadata(target, indent, "source_gate", gate.name, qubit_expr) if lowering_required: @@ -585,6 +589,7 @@ def _append_szz_flow_gate( source_label=source_label, qubit_expr=qubit_expr, host_label=host_label, + local_role="basis_prefix", gate=op_type, ) target.append(f"{indent}{op_name}({qubit_expr})") @@ -779,6 +784,7 @@ def discharge_data_for_szz( source_kind="szz_host", source_label=host_label, qubit_expr=data_expr(data_q), + host_label=host_label, gate=host_gate, lowering_required=True, ) 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 eed293d92..ec941932e 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -309,6 +309,8 @@ def test_direct_surface_renderers_accept_check_plan_as_source_of_truth() -> None assert '"source_kind", "szz_host"' in guppy_source assert '"source_kind", "szz_data_prefix"' in guppy_source assert '"szz_host_label", "szz:' in guppy_source + assert '"host_id", "szz:' in guppy_source + assert '"local_role", "basis_prefix"' in guppy_source assert '"source_lowering_required", "true"' in guppy_source @@ -331,6 +333,24 @@ def test_szz_runtime_barrier_fences_data_prefix_before_host() -> None: assert prefix_index < barrier_index < host_index < zz_phase_index +def test_szz_data_prefixes_emit_generic_hosted_metadata() -> None: + from pecos.guppy.surface import generate_guppy_source + from pecos.qec.surface import SurfacePatch + + source = generate_guppy_source( + SurfacePatch.create(distance=3), + check_plan="szz_current_v1", + ) + + prefix_index = source.index('"source_kind", "szz_data_prefix"') + role_index = source.index('"local_role", "basis_prefix"', prefix_index) + prefix_host_index = source.index('"host_id", "szz:', prefix_index) + host_index = source.index('"source_kind", "szz_host"', prefix_index) + host_id_index = source.index('"host_id", "szz:', host_index) + + assert prefix_index < prefix_host_index < role_index < host_index < host_id_index + + def test_boundary_first_szz_check_plan_changes_source_gates_not_metadata() -> None: from pecos.qec.surface import SurfacePatch from pecos.qec.surface.circuit_builder import ( 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 dfdad3354..b02b65f7e 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -64,6 +64,8 @@ def _metadata_before_h_gate() -> None: q = qubit() q = pecos_qis_trace_metadata_qubit_hugr(q, "source_kind", "szz_data_prefix") q = pecos_qis_trace_metadata_qubit_hugr(q, "source_label", "probe:prefix") + q = pecos_qis_trace_metadata_qubit_hugr(q, "host_id", "probe:host") + q = pecos_qis_trace_metadata_qubit_hugr(q, "local_role", "basis_prefix") h(q) _ = measure(q) @@ -127,6 +129,8 @@ def test_qubit_trace_metadata_stays_ordered_before_gate() -> None: assert lowered_ops[1]["gate_type"] == "R1XY" assert lowered_ops[1]["metadata"] == { + "host_id": "probe:host", + "local_role": "basis_prefix", "source_kind": "szz_data_prefix", "source_label": "probe:prefix", } From 51f0aca98e4dff5babe660ebb17b854d43ffc106 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Fri, 26 Jun 2026 11:41:56 -0600 Subject: [PATCH 132/150] Add hosted operation metadata validation --- .../src/pecos/quantum/__init__.py | 12 + .../quantum-pecos/src/pecos/quantum/hosted.py | 281 ++++++++++++++++++ .../tests/pecos/test_hosted_operations.py | 197 ++++++++++++ 3 files changed, 490 insertions(+) create mode 100644 python/quantum-pecos/src/pecos/quantum/hosted.py create mode 100644 python/quantum-pecos/tests/pecos/test_hosted_operations.py diff --git a/python/quantum-pecos/src/pecos/quantum/__init__.py b/python/quantum-pecos/src/pecos/quantum/__init__.py index 3a0d84c38..6cd99b993 100644 --- a/python/quantum-pecos/src/pecos/quantum/__init__.py +++ b/python/quantum-pecos/src/pecos/quantum/__init__.py @@ -69,6 +69,13 @@ from typing import TYPE_CHECKING from pecos.quantum import commute, gate_groups +from pecos.quantum.hosted import ( + HOST_ID_META_KEY, + LOCAL_ROLE_META_KEY, + HostedGateRecord, + HostedOperationBinding, + validate_hosted_operations, +) from pecos.typing import INTEGER_TYPES if TYPE_CHECKING: @@ -269,7 +276,9 @@ def pauli_string( "H4", "H5", "H6", + "HOST_ID_META_KEY", "ISWAP", + "LOCAL_ROLE_META_KEY", "PHYSICAL_DURATION_META_KEY", "SWAP", "SX", @@ -293,6 +302,8 @@ def pauli_string( "GateRegistry", "GateType", "H", + "HostedGateRecord", + "HostedOperationBinding", "HugrConversionError", "Pauli", "PauliSequence", @@ -325,4 +336,5 @@ def pauli_string( "is_quantum_operation", "pauli_string", "sparse_stab", + "validate_hosted_operations", ] diff --git a/python/quantum-pecos/src/pecos/quantum/hosted.py b/python/quantum-pecos/src/pecos/quantum/hosted.py new file mode 100644 index 000000000..cfaac8cc3 --- /dev/null +++ b/python/quantum-pecos/src/pecos/quantum/hosted.py @@ -0,0 +1,281 @@ +"""Hosted-operation metadata utilities. + +A hosted operation is a source-local operation whose semantic role is tied to a +later host operation. For example, an SZZ surface-code data-prefix pulse can +declare ``host_id=`` and ``local_role=basis_prefix`` while the +lowered SZZ/SZZdg host carries the same ``host_id``. Runtimes are free to +lower and schedule the gates, but traced circuits can then fail loudly if the +source-host relationship was lost or separated too far. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + +HOST_ID_META_KEY = "host_id" +LOCAL_ROLE_META_KEY = "local_role" + + +@dataclass(frozen=True) +class HostedGateRecord: + """A traced gate carrying hosted-operation metadata.""" + + tick_index: int + gate_index: int + gate_name: str + qubits: tuple[int, ...] + host_id: str + local_role: str + metadata: Mapping[str, object] + + +@dataclass(frozen=True) +class HostedOperationBinding: + """A concrete local-to-host relationship recovered from traced metadata.""" + + host_id: str + local_role: str + local: HostedGateRecord + host: HostedGateRecord + tick_separation: int + + +def validate_hosted_operations( + tick_circuit: object, + *, + host_id_key: str = HOST_ID_META_KEY, + local_role_key: str = LOCAL_ROLE_META_KEY, + max_tick_separation: int | None = None, + require_shared_qubit: bool = True, + require_host_after_local: bool = True, + context: str = "hosted operation validation", +) -> tuple[HostedOperationBinding, ...]: + """Validate and bind hosted-operation metadata in a traced ``TickCircuit``. + + A gate with ``local_role_key`` is a hosted local operation and must carry a + non-empty ``host_id_key``. By default it binds to the first later gate with + the same host id and no local role. When ``require_shared_qubit`` is true, + the bound host must touch at least one of the local gate's qubits. + + Args: + tick_circuit: PECOS ``TickCircuit``-like object with ``num_ticks()``, + ``get_tick()``, tick ``gate_batches()``, and optional + ``get_gate_meta(tick, gate, key)`` support. + host_id_key: Metadata key naming the source host relationship. + local_role_key: Metadata key identifying local operations. + max_tick_separation: Optional maximum absolute signed tick separation + between the local and selected host operation. + require_shared_qubit: Require the selected host to share a qubit with + the local operation. + require_host_after_local: Require host tick/gate order to follow local + tick/gate order. Disable only for metadata-shape audits that need + to report ordering drift instead of rejecting it immediately. + context: Human-readable context included in failures. + + Returns: + Tuple of recovered local-to-host bindings. + + Raises: + ValueError: If a local operation is missing a host id, has no later + compatible host, or exceeds ``max_tick_separation``. + TypeError: If the supplied object is not TickCircuit-like. + """ + if max_tick_separation is not None and max_tick_separation < 0: + msg = f"{context}: max_tick_separation must be non-negative, got {max_tick_separation}" + raise ValueError(msg) + + records = _hosted_gate_records( + tick_circuit, + host_id_key=host_id_key, + local_role_key=local_role_key, + context=context, + ) + bindings: list[HostedOperationBinding] = [] + for local in records: + if not local.local_role: + continue + if not local.host_id: + msg = ( + f"{context}: hosted local gate {local.gate_name}@t{local.tick_index}/" + f"g{local.gate_index} on qubits {local.qubits} has local_role " + f"{local.local_role!r} but no non-empty {host_id_key!r} metadata." + ) + raise ValueError(msg) + host_candidates = _matching_host_records( + records, + local, + require_shared_qubit=require_shared_qubit, + ) + if not host_candidates: + shared_clause = " sharing a qubit" if require_shared_qubit else "" + msg = ( + f"{context}: hosted local gate {local.gate_name}@t{local.tick_index}/" + f"g{local.gate_index} on qubits {local.qubits} with host_id " + f"{local.host_id!r} and local_role {local.local_role!r} has no " + f"host gate{shared_clause} carrying the same host_id." + ) + raise ValueError(msg) + later_hosts = [ + candidate + for candidate in host_candidates + if _gate_order(candidate) > _gate_order(local) + ] + if require_host_after_local and not later_hosts: + nearest_host = host_candidates[-1] + msg = ( + f"{context}: hosted local gate {local.gate_name}@t{local.tick_index}/" + f"g{local.gate_index} on qubits {local.qubits} with host_id " + f"{local.host_id!r} and local_role {local.local_role!r} has matching " + f"host metadata only before it; nearest host is {nearest_host.gate_name}" + f"@t{nearest_host.tick_index}/g{nearest_host.gate_index}. This " + "indicates source-host ordering drift in the traced runtime schedule." + ) + raise ValueError(msg) + host = later_hosts[0] if later_hosts else host_candidates[-1] + tick_separation = host.tick_index - local.tick_index + if max_tick_separation is not None and abs(tick_separation) > max_tick_separation: + msg = ( + f"{context}: hosted local gate {local.gate_name}@t{local.tick_index}/" + f"g{local.gate_index} on qubits {local.qubits} with host_id " + f"{local.host_id!r} binds to {host.gate_name}@t{host.tick_index}/" + f"g{host.gate_index} with signed tick separation {tick_separation}, " + f"exceeding max_tick_separation={max_tick_separation}." + ) + raise ValueError(msg) + bindings.append( + HostedOperationBinding( + host_id=local.host_id, + local_role=local.local_role, + local=local, + host=host, + tick_separation=tick_separation, + ), + ) + return tuple(bindings) + + +def _matching_host_records( + records: Sequence[HostedGateRecord], + local: HostedGateRecord, + *, + require_shared_qubit: bool, +) -> tuple[HostedGateRecord, ...]: + local_qubits = set(local.qubits) + candidates: list[HostedGateRecord] = [] + for candidate in records: + if candidate.host_id != local.host_id or candidate.local_role: + continue + if require_shared_qubit and local_qubits.isdisjoint(candidate.qubits): + continue + candidates.append(candidate) + return tuple(candidates) + + +def _gate_order(record: HostedGateRecord) -> tuple[int, int]: + return (record.tick_index, record.gate_index) + + +def _hosted_gate_records( + tick_circuit: object, + *, + host_id_key: str, + local_role_key: str, + context: str, +) -> tuple[HostedGateRecord, ...]: + records: list[HostedGateRecord] = [] + for tick_index, gate_index, gate in _iter_tick_gates(tick_circuit, context=context): + metadata = _gate_metadata( + tick_circuit, + tick_index, + gate_index, + keys=(host_id_key, local_role_key), + ) + host_id = _metadata_text(metadata, host_id_key) + local_role = _metadata_text(metadata, local_role_key) + if not host_id and not local_role: + continue + records.append( + HostedGateRecord( + tick_index=tick_index, + gate_index=gate_index, + gate_name=_gate_name(gate), + qubits=tuple(int(qubit) for qubit in getattr(gate, "qubits", ())), + host_id=host_id, + local_role=local_role, + metadata=metadata, + ), + ) + return tuple(records) + + +def _iter_tick_gates( + tick_circuit: object, + *, + context: str, +) -> tuple[tuple[int, int, object], ...]: + try: + num_ticks = int(tick_circuit.num_ticks()) + except AttributeError as exc: + msg = f"{context}: expected a TickCircuit with num_ticks()." + raise TypeError(msg) from exc + + gate_locations: list[tuple[int, int, object]] = [] + for tick_index in range(num_ticks): + try: + tick = tick_circuit.get_tick(tick_index) + except AttributeError as exc: + msg = f"{context}: expected a TickCircuit with get_tick()." + raise TypeError(msg) from exc + if tick is None: + continue + try: + gate_batches = tick.gate_batches() + except AttributeError as exc: + msg = f"{context}: expected TickCircuit ticks with gate_batches()." + raise TypeError(msg) from exc + gate_locations.extend( + (tick_index, gate_index, gate) + for gate_index, gate in enumerate(gate_batches) + ) + return tuple(gate_locations) + + +def _gate_metadata( + tick_circuit: object, + tick_index: int, + gate_index: int, + *, + keys: Sequence[str], +) -> dict[str, object]: + getter = getattr(tick_circuit, "get_gate_meta", None) + if getter is None: + return {} + metadata: dict[str, object] = {} + for key in keys: + try: + value = getter(tick_index, gate_index, key) + except (AttributeError, IndexError, KeyError, TypeError): + continue + if value is not None: + metadata[key] = value + return metadata + + +def _metadata_text(metadata: Mapping[str, object], key: str) -> str: + value = metadata.get(key) + return "" if value is None else str(value) + + +def _gate_name(gate: object) -> str: + gate_type = getattr(gate, "gate_type", None) + name = getattr(gate_type, "name", None) + if name is not None: + return str(name) + if gate_type is not None: + return str(gate_type) + return type(gate).__name__ diff --git a/python/quantum-pecos/tests/pecos/test_hosted_operations.py b/python/quantum-pecos/tests/pecos/test_hosted_operations.py new file mode 100644 index 000000000..b9a787ed9 --- /dev/null +++ b/python/quantum-pecos/tests/pecos/test_hosted_operations.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import pytest +from pecos.quantum.hosted import validate_hosted_operations + + +class FakeGateType: + def __init__(self, name: str) -> None: + self.name = name + + +class FakeGate: + def __init__( + self, + name: str, + qubits: list[int], + *, + meta: dict[str, object] | None = None, + ) -> None: + self.gate_type = FakeGateType(name) + self.qubits = qubits + self.meta = meta or {} + + +class FakeTick: + def __init__(self, gates: list[FakeGate]) -> None: + self._gates = gates + + def gate_batches(self) -> list[FakeGate]: + return self._gates + + +class FakeTickCircuit: + def __init__(self, ticks: list[list[FakeGate]]) -> None: + self._ticks = [FakeTick(gates) for gates in ticks] + + def num_ticks(self) -> int: + return len(self._ticks) + + def get_tick(self, tick_index: int) -> FakeTick: + return self._ticks[tick_index] + + def get_gate_meta(self, tick_index: int, gate_index: int, key: str) -> object: + return self._ticks[tick_index].gate_batches()[gate_index].meta[key] + + +def test_validate_hosted_operations_binds_local_to_later_host() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "H", + [2], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("Idle", [2])], + [FakeGate("SZZ", [2, 5], meta={"host_id": "host:a"})], + ], + ) + + bindings = validate_hosted_operations(circuit) + + assert len(bindings) == 1 + assert bindings[0].host_id == "host:a" + assert bindings[0].local_role == "basis_prefix" + assert bindings[0].local.gate_name == "H" + assert bindings[0].host.gate_name == "SZZ" + assert bindings[0].tick_separation == 2 + + +def test_validate_hosted_operations_selects_later_shared_host_record() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "SX", + [2], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("RZ", [9], meta={"host_id": "host:a"})], + [FakeGate("SZZ", [2, 5], meta={"host_id": "host:a"})], + ], + ) + + bindings = validate_hosted_operations(circuit) + + assert len(bindings) == 1 + assert bindings[0].host.gate_name == "SZZ" + assert bindings[0].host.qubits == (2, 5) + + +def test_validate_hosted_operations_can_bind_without_shared_qubit_requirement() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "SX", + [2], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("RZ", [9], meta={"host_id": "host:a"})], + ], + ) + + bindings = validate_hosted_operations(circuit, require_shared_qubit=False) + + assert len(bindings) == 1 + assert bindings[0].host.gate_name == "RZ" + + +def test_validate_hosted_operations_rejects_ordering_drift_by_default() -> None: + circuit = FakeTickCircuit( + [ + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + ], + ) + + with pytest.raises(ValueError, match="matching host metadata only before it"): + validate_hosted_operations(circuit) + + +def test_validate_hosted_operations_can_bind_prior_host_for_metadata_shape_audit() -> None: + circuit = FakeTickCircuit( + [ + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + ], + ) + + bindings = validate_hosted_operations(circuit, require_host_after_local=False) + + assert len(bindings) == 1 + assert bindings[0].host.gate_name == "SZZ" + assert bindings[0].tick_separation == -1 + + +def test_validate_hosted_operations_rejects_missing_local_host_id() -> None: + circuit = FakeTickCircuit( + [[FakeGate("H", [0], meta={"local_role": "basis_prefix"})]], + ) + + with pytest.raises(ValueError, match="no non-empty 'host_id' metadata"): + validate_hosted_operations(circuit) + + +def test_validate_hosted_operations_rejects_unbound_local() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "H", + [0], + meta={"host_id": "missing", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("SZZ", [0, 1], meta={"host_id": "other"})], + ], + ) + + with pytest.raises(ValueError, match="has no host gate sharing a qubit"): + validate_hosted_operations(circuit) + + +def test_validate_hosted_operations_rejects_large_tick_separation() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("Idle", [0])], + [FakeGate("Idle", [0])], + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + ], + ) + + with pytest.raises(ValueError, match="exceeding max_tick_separation=2"): + validate_hosted_operations(circuit, max_tick_separation=2) From 568ed65db40140d2766a18afb14ab96a97d4479e Mon Sep 17 00:00:00 2001 From: ciaranra Date: Fri, 26 Jun 2026 14:24:55 -0600 Subject: [PATCH 133/150] Add strict hosted trace validation plumbing --- python/quantum-pecos/src/pecos/qec/dem.py | 17 ++- .../src/pecos/qec/surface/decode.py | 123 ++++++++++++++++-- .../tests/pecos/test_hosted_operations.py | 60 +++++++++ 3 files changed, 191 insertions(+), 9 deletions(-) diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 53302e7d7..e2b0e8a9d 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -136,6 +136,8 @@ def from_guppy( p_idle_z_quadratic_sine_rate: float | None = None, runtime: object | None = None, seed: int = 0, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> _RustDetectorErrorModel: """Build a circuit-level DEM from a Guppy program by tracing it. @@ -244,6 +246,12 @@ def from_guppy( the default Selene runtime. Runtime plugin objects are passed through to ``pecos.selene_engine(runtime)``. seed: Seed for the ideal trace run. + require_hosted_operation_order: If true, validate generic + hosted-operation metadata after trace replay. A gate with + ``local_role`` metadata must bind to a later same-``host_id`` + host gate sharing a qubit. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. Returns: A ``DetectorErrorModel`` built from the traced circuit. @@ -305,7 +313,14 @@ def from_guppy( ) raise ValueError(msg) from exc - tc = trace_guppy_into_tick_circuit(guppy, num_qubits, seed=seed, runtime=runtime) + tc = trace_guppy_into_tick_circuit( + guppy, + 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, diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index b7db9f396..0ba338208 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -52,6 +52,7 @@ import numpy as np from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan +from pecos.quantum import validate_hosted_operations if TYPE_CHECKING: import stim @@ -1131,7 +1132,7 @@ def _lowered_gate_metadata(gate: Mapping[str, Any]) -> dict[str, Any]: return {} if not isinstance(metadata, Mapping): msg = f"Lowered gate metadata must be an object, got {metadata!r}" - raise ValueError(msg) + raise TypeError(msg) return {str(key): value for key, value in metadata.items()} @@ -1385,16 +1386,22 @@ def trace_guppy_into_tick_circuit_with_result_traces( seed: int = 0, runtime: object | None = None, measurement_crosstalk_topology: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> tuple[Any, list[dict[str, Any]]]: """Trace a Guppy/QIS program into a ``TickCircuit`` plus result-tag provenance.""" chunks = capture_guppy_operation_trace(program, num_qubits, seed=seed, runtime=runtime) - return ( - _replay_qis_trace_chunks_into_tick_circuit( - chunks, - measurement_crosstalk_topology=measurement_crosstalk_topology, - ), - named_result_traces_from_operation_trace(chunks), + tick_circuit = _replay_qis_trace_chunks_into_tick_circuit( + chunks, + measurement_crosstalk_topology=measurement_crosstalk_topology, + ) + _validate_trace_hosted_operations_if_requested( + tick_circuit, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, + context="trace_guppy_into_tick_circuit_with_result_traces", ) + return tick_circuit, named_result_traces_from_operation_trace(chunks) def trace_guppy_into_tick_circuit( @@ -1404,6 +1411,8 @@ def trace_guppy_into_tick_circuit( seed: int = 0, runtime: object | None = None, measurement_crosstalk_topology: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> Any: """Trace a Guppy/QIS program's lowered Selene op stream into a ``TickCircuit``. @@ -1430,16 +1439,47 @@ def trace_guppy_into_tick_circuit( ``pecos.selene_engine(runtime)``. measurement_crosstalk_topology: Optional measurement-crosstalk replay mode for stamping global measurement-crosstalk payload markers. + require_hosted_operation_order: If true, validate generic hosted-operation + metadata after trace replay. A gate with ``local_role`` metadata + must bind to a later same-``host_id`` host gate sharing a qubit. + This catches runtime/compiler lowering that reorders hosted local + pulses after the operation they semantically prepare. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. Returns: A ``TickCircuit`` with no detector/observable metadata attached; the caller supplies that. """ chunks = capture_guppy_operation_trace(program, num_qubits, seed=seed, runtime=runtime) - return _replay_qis_trace_chunks_into_tick_circuit( + tick_circuit = _replay_qis_trace_chunks_into_tick_circuit( chunks, measurement_crosstalk_topology=measurement_crosstalk_topology, ) + _validate_trace_hosted_operations_if_requested( + tick_circuit, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, + context="trace_guppy_into_tick_circuit", + ) + return tick_circuit + + +def _validate_trace_hosted_operations_if_requested( + tick_circuit: Any, + *, + require_hosted_operation_order: bool, + max_hosted_tick_separation: int | None, + context: str, +) -> None: + if not require_hosted_operation_order and max_hosted_tick_separation is None: + return + validate_hosted_operations( + tick_circuit, + max_tick_separation=max_hosted_tick_separation, + require_host_after_local=require_hosted_operation_order, + context=context, + ) def _generate_traced_surface_tick_circuit( @@ -1452,6 +1492,8 @@ def _generate_traced_surface_tick_circuit( check_plan: str | None = None, runtime: object | None = None, clifford_frame_policy: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> Any: """Trace the lowered ideal Selene/QIS op stream and replay it into a TickCircuit. @@ -1476,6 +1518,8 @@ def _generate_traced_surface_tick_circuit( check_plan=check_plan, runtime=runtime, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) return tc @@ -1490,6 +1534,8 @@ def _generate_traced_surface_tick_circuit_with_result_traces( check_plan: str | None = None, runtime: object | None = None, clifford_frame_policy: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> tuple[Any, list[dict[str, Any]]]: """Trace a surface Guppy program into a ``TickCircuit`` plus result provenance.""" from pecos.guppy.surface import generate_memory_experiment, get_num_qubits @@ -1514,6 +1560,8 @@ def _generate_traced_surface_tick_circuit_with_result_traces( ), seed=0, runtime=runtime, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -1530,6 +1578,8 @@ def _build_surface_tick_circuit_for_native_model( check_plan: str | None = None, szz_physical_prefixes: bool = False, clifford_frame_policy: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> Any: """Build the TickCircuit used by the native DEM and sampler paths.""" from pecos.qec.surface.circuit_builder import _normalize_interaction_basis, generate_tick_circuit_from_patch @@ -1580,6 +1630,8 @@ def _build_surface_tick_circuit_for_native_model( check_plan=resolved_plan.plan_id, runtime=runtime, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) measurement_index_remap = _surface_runtime_measurement_remap_from_result_traces(abstract_tc, result_traces) @@ -1624,6 +1676,8 @@ def build_memory_circuit( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> Any: """Build the standard surface-code memory ``TickCircuit``. @@ -1650,6 +1704,11 @@ def build_memory_circuit( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for native abstract SZZ generation. + require_hosted_operation_order: For ``circuit_source="traced_qis"``, + validate generic hosted-operation metadata after trace replay. A + hosted local gate must appear before its same-``host_id`` host. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. Returns: A Rust-backed ``TickCircuit`` with detector and observable metadata. @@ -1685,6 +1744,8 @@ def build_memory_circuit( interaction_basis=interaction_basis, check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -1905,6 +1966,8 @@ def _surface_native_topology( check_plan: str | None = None, szz_physical_prefixes: bool = False, clifford_frame_policy: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> _CachedNativeSurfaceTopology: """Build topology-only native analysis shared across noise parameters.""" import json @@ -1936,6 +1999,8 @@ def _surface_native_topology( check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) if circuit_source == "traced_qis": # Keep this surface helper aligned with DetectorErrorModel.from_guppy: @@ -2010,6 +2075,8 @@ def _cached_surface_native_topology( szz_physical_prefixes: bool = False, resolved_check_plan_hash: str = "", clifford_frame_policy: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> _CachedNativeSurfaceTopology: """Cache topology-only native analysis shared across noise parameters.""" _ = resolved_check_plan_hash @@ -2025,6 +2092,8 @@ def _cached_surface_native_topology( check_plan=check_plan, szz_physical_prefixes=szz_physical_prefixes, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -2111,6 +2180,9 @@ def _cached_surface_native_dem_string( check_plan: str | None = None, resolved_check_plan_hash: str = "", clifford_frame_policy: str | None = None, + *, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> str: """Cache native DEM strings across callers for one topology + noise tuple.""" _ = resolved_check_plan_hash @@ -2149,6 +2221,8 @@ def _cached_surface_native_dem_string( szz_physical_prefixes=szz_physical_prefixes, resolved_check_plan_hash=resolved_check_plan_hash, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) return _dem_string_from_cached_surface_topology( topology, @@ -2263,6 +2337,8 @@ def generate_circuit_level_dem_from_builder( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> str: """Generate circuit-level DEM using PECOS native fault propagation. @@ -2318,6 +2394,12 @@ def generate_circuit_level_dem_from_builder( policy for native SZZ generation. For ``circuit_source="traced_qis"``, the Guppy program is generated from the same concrete deformed checks before runtime result tags are bound to surface metadata. + require_hosted_operation_order: For ``circuit_source="traced_qis"``, + validate generic hosted-operation metadata after runtime trace + replay. This is intended for source-local pulses that semantically + prepare a later host operation, such as SZZ/SZZdg data prefixes. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. Returns: DEM string in standard format @@ -2352,6 +2434,8 @@ def generate_circuit_level_dem_from_builder( check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) return _dem_string_from_cached_surface_topology( topology, @@ -2383,6 +2467,8 @@ def generate_circuit_level_dem_from_builder( "check_plan": resolved_plan.plan_id, "resolved_check_plan_hash": resolved_plan.resolved_hash, "clifford_frame_policy": clifford_frame_policy, + "require_hosted_operation_order": require_hosted_operation_order, + "max_hosted_tick_separation": max_hosted_tick_separation, } if dem_decomposition != "source_graphlike": cache_kwargs["dem_decomposition"] = dem_decomposition @@ -3811,6 +3897,8 @@ def surface_code_memory( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> SimulationResult: """Run the recommended native surface-code memory workflow. @@ -3841,6 +3929,11 @@ def surface_code_memory( supplied. clifford_frame_policy: Optional source-level Clifford-deformation policy for native SZZ generation. + require_hosted_operation_order: For ``circuit_source="traced_qis"``, + validate generic hosted-operation metadata after runtime trace + replay. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. Returns: ``SimulationResult`` with logical and raw error counts/rates. @@ -3881,6 +3974,8 @@ def surface_code_memory( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(shots, seed) num_raw_errors = sum(1 for shot in range(shots) if batch.get_observable_mask(shot) != 0) @@ -4182,6 +4277,9 @@ def build_native_sampler( ] = "dem", # "mnm" accepted for compat, mapped to "influence_dem", check_plan: str | None = None, clifford_frame_policy: str | None = None, + *, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> NativeSampler: """Build a PECOS native sampler for threshold estimation. @@ -4222,6 +4320,11 @@ def build_native_sampler( supplied. clifford_frame_policy: Optional source-level Clifford-deformation policy for native abstract SZZ generation. + require_hosted_operation_order: For ``circuit_source="traced_qis"``, + validate generic hosted-operation metadata after runtime trace + replay. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. Returns: NativeSampler that can generate samples for threshold estimation @@ -4255,6 +4358,8 @@ def build_native_sampler( szz_physical_prefixes=szz_physical_prefixes, resolved_check_plan_hash=resolved_plan.resolved_hash, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) if sampling_model == "dem": dem_str = _cached_surface_native_dem_string( @@ -4293,6 +4398,8 @@ def build_native_sampler( check_plan=resolved_plan.plan_id, resolved_check_plan_hash=resolved_plan.resolved_hash, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() return NativeSampler( diff --git a/python/quantum-pecos/tests/pecos/test_hosted_operations.py b/python/quantum-pecos/tests/pecos/test_hosted_operations.py index b9a787ed9..ce773f452 100644 --- a/python/quantum-pecos/tests/pecos/test_hosted_operations.py +++ b/python/quantum-pecos/tests/pecos/test_hosted_operations.py @@ -1,6 +1,7 @@ from __future__ import annotations import pytest +from pecos.qec.surface.decode import _validate_trace_hosted_operations_if_requested from pecos.quantum.hosted import validate_hosted_operations @@ -195,3 +196,62 @@ def test_validate_hosted_operations_rejects_large_tick_separation() -> None: with pytest.raises(ValueError, match="exceeding max_tick_separation=2"): validate_hosted_operations(circuit, max_tick_separation=2) + + +def test_trace_hosted_validation_is_noop_unless_requested() -> None: + circuit = FakeTickCircuit( + [[FakeGate("H", [0], meta={"local_role": "basis_prefix"})]], + ) + + _validate_trace_hosted_operations_if_requested( + circuit, + require_hosted_operation_order=False, + max_hosted_tick_separation=None, + context="test trace validation", + ) + + +def test_trace_hosted_validation_rejects_ordering_drift_when_requested() -> None: + circuit = FakeTickCircuit( + [ + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + ], + ) + + with pytest.raises(ValueError, match="ordering drift"): + _validate_trace_hosted_operations_if_requested( + circuit, + require_hosted_operation_order=True, + max_hosted_tick_separation=None, + context="test trace validation", + ) + + +def test_trace_hosted_validation_can_check_separation_without_order_guard() -> None: + circuit = FakeTickCircuit( + [ + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + ], + ) + + with pytest.raises(ValueError, match="exceeding max_tick_separation=0"): + _validate_trace_hosted_operations_if_requested( + circuit, + require_hosted_operation_order=False, + max_hosted_tick_separation=0, + context="test trace validation", + ) From 43a33bdc6b4844193b1bf1943607f42e91bb1b83 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Fri, 26 Jun 2026 16:36:37 -0600 Subject: [PATCH 134/150] Add hosted SZZ barrier handoff target --- docs/development/hosted-operations.md | 7 ++- .../tests/guppy/test_surface_twirl_render.py | 52 ++++++++++--------- .../tests/qec/test_from_guppy_dem.py | 31 +++++++++++ 3 files changed, 65 insertions(+), 25 deletions(-) diff --git a/docs/development/hosted-operations.md b/docs/development/hosted-operations.md index 22313b5c0..8a256bba9 100644 --- a/docs/development/hosted-operations.md +++ b/docs/development/hosted-operations.md @@ -104,6 +104,9 @@ Current state: - A minimal Guppy public `barrier(...)` probe is currently optimized away before PECOS QIS operation collection. The captured raw operation trace contains allocations, gates, measurements, and releases, but no `Barrier` operation. +- The generated surface SZZ/SZZdg path can emit public Guppy barriers via + `szz_runtime_barriers`, but those barriers currently disappear for the same + reason before PECOS QIS operation collection. So barrier preservation requires a Guppy/HUGR/QIR/QIS bridge that lowers public barriers or qsystem `RuntimeBarrier` operations into `Operation::Barrier` @@ -177,7 +180,9 @@ duration or tick separation, and the relevant policy. ## Recommended Next Slice 1. Add a minimal barrier-survival diagnostic for public Guppy barriers in QIS - traces. + traces. Current strict-xfail regression targets: + `test_guppy_barrier_survives_into_qis_operation_trace` and + `test_szz_runtime_barrier_survives_into_qis_operation_trace`. 2. If preserving public barriers is small and generic, implement it as a separate quality-of-lowering improvement. 3. Prototype an explicit hosted SZZ prefix relationship in the QIS trace path. diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index 05676e9ff..f01d8b44d 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -24,6 +24,32 @@ def patch() -> SurfacePatch: return SurfacePatch.create(distance=3) +def _assert_szz_prefix_barrier_host_order(src: str) -> None: + """Assert a real SZZ data-prefix pulse is fenced before its host.""" + lines = src.splitlines() + for index, line in enumerate(lines): + if "vdg(d1)" not in line: + continue + prefix_meta = next( + i + for i in range(index - 1, -1, -1) + if '"source_kind", "szz_data_prefix"' in lines[i] + ) + barrier_index = next(i for i in range(index + 1, len(lines)) if "barrier(" in lines[i]) + host_meta = next( + i + for i in range(barrier_index + 1, len(lines)) + if '"source_kind", "szz_host"' in lines[i] + ) + zz_phase_index = next(i for i in range(host_meta + 1, len(lines)) if "zz_phase(" in lines[i]) + + assert prefix_meta < index < barrier_index < host_meta < zz_phase_index + return + + msg = "expected an SZZ touch with a Vdg data-prefix pulse" + raise AssertionError(msg) + + def test_no_twirl_source_has_no_rng_or_mask_tags(patch: SurfacePatch) -> None: src = generate_guppy_source(patch) @@ -130,18 +156,7 @@ def test_szz_runtime_barriers_precede_data_prefix_and_host(patch: SurfacePatch) interaction_basis="szz", ) - lines = src.splitlines() - for index, line in enumerate(lines): - if "vdg(d1)" in line: - nearby = lines[index - 2 : index + 2] - assert "barrier(" in nearby[0] - assert "h(d1)" in nearby[1] - assert "vdg(d1)" in nearby[2] - assert "zz_phase(" in nearby[3] - break - else: - msg = "expected an SZZ touch with a Vdg data-prefix pulse" - raise AssertionError(msg) + _assert_szz_prefix_barrier_host_order(src) def test_szz_data_prefix_runtime_barriers_only_guard_real_prefixes(patch: SurfacePatch) -> None: @@ -159,18 +174,7 @@ def test_szz_data_prefix_runtime_barriers_only_guard_real_prefixes(patch: Surfac assert "barrier(" in prefix_src assert prefix_src.count("barrier(") < all_src.count("barrier(") - lines = prefix_src.splitlines() - for index, line in enumerate(lines): - if "vdg(d1)" in line: - nearby = lines[index - 2 : index + 2] - assert "barrier(" in nearby[0] - assert "h(d1)" in nearby[1] - assert "vdg(d1)" in nearby[2] - assert "zz_phase(" in nearby[3] - break - else: - msg = "expected an SZZ touch with a Vdg data-prefix pulse" - raise AssertionError(msg) + _assert_szz_prefix_barrier_host_order(prefix_src) def test_szz_runtime_barriers_reject_cx_source(patch: SurfacePatch) -> 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 b02b65f7e..a5644b66f 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -119,6 +119,37 @@ def test_guppy_barrier_survives_into_qis_operation_trace() -> None: assert any(operation == "Barrier" or "Barrier" in operation for operation in operations) +@pytest.mark.xfail( + reason=( + "Generated SZZ runtime barriers are emitted as public Guppy barrier(...) " + "calls today, which are optimized away before PECOS QIS operation " + "collection; hosted SZZ scheduling needs a barrier-preserving or " + "hosted-operation lowering path." + ), + strict=True, +) +def test_szz_runtime_barrier_survives_into_qis_operation_trace() -> None: + program = make_surface_code( + distance=3, + num_rounds=1, + basis="Z", + interaction_basis="szz", + szz_runtime_barriers="data-prefix", + ) + chunks = capture_guppy_operation_trace( + program, + num_qubits=get_num_qubits(d=3, interaction_basis="szz"), + seed=0, + ) + operations = [ + operation + for chunk in chunks + for operation in chunk.get("operations", []) + ] + + assert any(operation == "Barrier" or "Barrier" in str(operation) for operation in operations) + + def test_qubit_trace_metadata_stays_ordered_before_gate() -> None: chunks = capture_guppy_operation_trace(_metadata_before_h_gate, num_qubits=1, seed=0) lowered_ops = [ From 514463c1e5071a2e095dc0dde7fa0761c90cb5af Mon Sep 17 00:00:00 2001 From: ciaranra Date: Fri, 26 Jun 2026 21:47:39 -0600 Subject: [PATCH 135/150] Add runtime barrier helper for SZZ traces --- crates/pecos-hugr-qis/src/compiler.rs | 17 ++++++++-- crates/pecos-qis-ffi/src/ffi.rs | 29 ++++++++++++++++ docs/development/hosted-operations.md | 16 +++++---- .../quantum-pecos/src/pecos/guppy/surface.py | 9 +++-- .../src/pecos/qec/surface/decode.py | 34 +++++++++++++++++++ .../tests/guppy/test_surface_twirl_render.py | 16 +++++---- .../tests/qec/surface/test_check_plan.py | 2 +- .../qec/surface/test_szz_interaction_basis.py | 26 ++++++++++++++ .../tests/qec/test_from_guppy_dem.py | 9 ----- 9 files changed, 131 insertions(+), 27 deletions(-) diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index 733d49292..4684cabf1 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -69,6 +69,7 @@ const METADATA: &[(&str, &[&str])] = &[("name", &["mainlib"])]; const HUGR_SYMBOL_PREFIX: &str = "__hugr__."; const TRACE_METADATA_HUGR_SYMBOL: &str = "pecos_qis_trace_metadata_hugr"; const TRACE_METADATA_QUBIT_HUGR_SYMBOL: &str = "pecos_qis_trace_metadata_qubit_hugr"; +const RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL: &str = "pecos_qis_runtime_barrier_qubit_hugr"; // Extension registry is defined in the parent module @@ -401,10 +402,14 @@ fn is_llvm_symbol_char(ch: char) -> bool { /// /// These helpers are part of PECOS's runtime ABI, not ordinary user functions, /// so they need stable external symbols for dynamic linking. Keep this rewrite -/// deliberately narrow: only the metadata helper declaration receives this +/// deliberately narrow: only PECOS-owned QIS helper declarations receive this /// treatment. fn normalize_pecos_helper_symbols_in_llvm(llvm_ir: String) -> String { - let helper_symbols = [TRACE_METADATA_HUGR_SYMBOL, TRACE_METADATA_QUBIT_HUGR_SYMBOL]; + let helper_symbols = [ + TRACE_METADATA_HUGR_SYMBOL, + TRACE_METADATA_QUBIT_HUGR_SYMBOL, + RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL, + ]; let mut normalized = String::with_capacity(llvm_ir.len()); let mut cursor = 0; @@ -462,6 +467,8 @@ mod tests { "declare i64 @__hugr__.pecos_qis_trace_metadata_qubit_hugr.19(i64, i8*, i8*)\n", "%q = call i64 @__hugr__.pecos_qis_trace_metadata_qubit_hugr.19(i64 %0, i8* %1, i8* %2)\n", "%q2 = call i64 @__hugr__.__main__.pecos_qis_trace_metadata_qubit_hugr.21(i64 %0, i8* %1, i8* %2)\n", + "%q3 = call i64 @__hugr__.pecos_qis_runtime_barrier_qubit_hugr.22(i64 %0)\n", + "%q4 = call i64 @__hugr__.__main__.pecos_qis_runtime_barrier_qubit_hugr.23(i64 %0)\n", "call void @__hugr__.other_helper.16()\n", ) .to_string(); @@ -477,6 +484,12 @@ mod tests { assert!(normalized.contains( "%q2 = call i64 @pecos_qis_trace_metadata_qubit_hugr(i64 %0, i8* %1, i8* %2)" )); + assert!( + normalized.contains("%q3 = call i64 @pecos_qis_runtime_barrier_qubit_hugr(i64 %0)") + ); + assert!( + normalized.contains("%q4 = call i64 @pecos_qis_runtime_barrier_qubit_hugr(i64 %0)") + ); assert!(normalized.contains("@__hugr__.other_helper.16")); } } diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index 3583705d1..d2655fcbe 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -534,6 +534,24 @@ pub unsafe extern "C" fn pecos_qis_trace_metadata_qubit_hugr( qubit } +/// Insert a runtime scheduling barrier after prior operations touching this qubit. +/// +/// Returning the qubit handle gives Guppy/HUGR a data dependency that keeps the +/// barrier between the preceding operation on this qubit and the following +/// operation that consumes the returned handle. The barrier itself is a +/// runtime-level batch/drain marker; it does not emit a quantum gate. +/// +/// # Safety +/// Called from C/LLVM code. Qubit must be a valid non-negative ID. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pecos_qis_runtime_barrier_qubit_hugr(qubit: i64) -> i64 { + let _ = i64_to_usize(qubit); + with_interface(|interface| { + interface.queue_operation(Operation::Barrier); + }); + qubit +} + /// Attach source/runtime metadata to the next lowerable quantum operation. /// /// This variant uses direct string data pointers instead of the tket2 string @@ -1629,6 +1647,17 @@ mod tests { }); } + #[test] + fn test_runtime_barrier_qubit_hugr_returns_qubit_and_queues_barrier() { + setup_test(); + let returned = unsafe { pecos_qis_runtime_barrier_qubit_hugr(17) }; + assert_eq!(returned, 17); + + with_interface(|iface| { + assert_eq!(iface.operations, vec![Operation::Barrier]); + }); + } + // --- Measurement and reset tests --- #[test] diff --git a/docs/development/hosted-operations.md b/docs/development/hosted-operations.md index 8a256bba9..4f6ebf375 100644 --- a/docs/development/hosted-operations.md +++ b/docs/development/hosted-operations.md @@ -104,9 +104,11 @@ Current state: - A minimal Guppy public `barrier(...)` probe is currently optimized away before PECOS QIS operation collection. The captured raw operation trace contains allocations, gates, measurements, and releases, but no `Barrier` operation. -- The generated surface SZZ/SZZdg path can emit public Guppy barriers via - `szz_runtime_barriers`, but those barriers currently disappear for the same - reason before PECOS QIS operation collection. +- The generated surface SZZ/SZZdg path uses a PECOS-owned + `pecos_qis_runtime_barrier_qubit_hugr` helper for `szz_runtime_barriers`. + The helper returns its qubit argument to create a Guppy/HUGR data dependency + and queues a real `Operation::Barrier`, so Selene runtime lowering drains the + current batch before the following host operation. So barrier preservation requires a Guppy/HUGR/QIR/QIS bridge that lowers public barriers or qsystem `RuntimeBarrier` operations into `Operation::Barrier` @@ -180,9 +182,11 @@ duration or tick separation, and the relevant policy. ## Recommended Next Slice 1. Add a minimal barrier-survival diagnostic for public Guppy barriers in QIS - traces. Current strict-xfail regression targets: - `test_guppy_barrier_survives_into_qis_operation_trace` and - `test_szz_runtime_barrier_survives_into_qis_operation_trace`. + traces. Current strict-xfail target: + `test_guppy_barrier_survives_into_qis_operation_trace`. The generated SZZ + path has a positive regression, + `test_szz_runtime_barrier_survives_into_qis_operation_trace`, through the + PECOS runtime-barrier helper. 2. If preserving public barriers is small and generic, implement it as a separate quality-of-lowering improvement. 3. Prototype an explicit hosted SZZ prefix relationship in the QIS trace path. diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index e69f45d3d..2677fbdab 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -333,7 +333,7 @@ def generate_guppy_source( "", "from guppylang import guppy", "from guppylang.std.angles import angle", - "from guppylang.std.builtins import array, barrier, owned, result", + "from guppylang.std.builtins import array, owned, result", "from guppylang.std.qsystem.functional import zz_phase", "from guppylang.std.quantum import discard, h, measure, measure_array, qubit, s, sdg, v, vdg, x, y, z", ] @@ -377,6 +377,9 @@ def generate_guppy_source( ") -> qubit: ..." ), "", + "@guppy.declare", + "def pecos_qis_runtime_barrier_qubit_hugr(q: qubit @ owned) -> qubit: ...", + "", "", ], ) @@ -774,8 +777,8 @@ def discharge_data_for_szz( and has_data_prefix ): target.append( - f"{indent}barrier({ancilla_expr(stab_type, stab_idx)}, " - f"{data_expr(data_q)})", + f"{indent}{data_expr(data_q)} = " + f"pecos_qis_runtime_barrier_qubit_hugr({data_expr(data_q)})", ) half_turns = "0.5" if sign > 0 else "-0.5" _append_szz_gate_trace_metadata( diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 0ba338208..168faf7f3 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1492,6 +1492,7 @@ def _generate_traced_surface_tick_circuit( check_plan: str | None = None, runtime: object | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> Any: @@ -1518,6 +1519,7 @@ def _generate_traced_surface_tick_circuit( check_plan=check_plan, runtime=runtime, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -1534,6 +1536,7 @@ def _generate_traced_surface_tick_circuit_with_result_traces( check_plan: str | None = None, runtime: object | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> tuple[Any, list[dict[str, Any]]]: @@ -1548,6 +1551,7 @@ def _generate_traced_surface_tick_circuit_with_result_traces( interaction_basis=interaction_basis, check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, ) return trace_guppy_into_tick_circuit_with_result_traces( program, @@ -1578,6 +1582,7 @@ def _build_surface_tick_circuit_for_native_model( check_plan: str | None = None, szz_physical_prefixes: bool = False, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> Any: @@ -1630,6 +1635,7 @@ def _build_surface_tick_circuit_for_native_model( check_plan=resolved_plan.plan_id, runtime=runtime, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -1676,6 +1682,7 @@ def build_memory_circuit( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> Any: @@ -1704,6 +1711,10 @@ def build_memory_circuit( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for native abstract SZZ generation. + szz_runtime_barriers: Optional SZZ/SZZdg runtime-barrier policy used + for traced-QIS Guppy generation. This emits PECOS runtime barrier + helpers between selected data-prefix pulses and their host + SZZ/SZZdg operations. require_hosted_operation_order: For ``circuit_source="traced_qis"``, validate generic hosted-operation metadata after trace replay. A hosted local gate must appear before its same-``host_id`` host. @@ -1744,6 +1755,7 @@ def build_memory_circuit( interaction_basis=interaction_basis, check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -1966,6 +1978,7 @@ def _surface_native_topology( check_plan: str | None = None, szz_physical_prefixes: bool = False, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> _CachedNativeSurfaceTopology: @@ -1999,6 +2012,7 @@ def _surface_native_topology( check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -2075,6 +2089,7 @@ def _cached_surface_native_topology( szz_physical_prefixes: bool = False, resolved_check_plan_hash: str = "", clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> _CachedNativeSurfaceTopology: @@ -2092,6 +2107,7 @@ def _cached_surface_native_topology( check_plan=check_plan, szz_physical_prefixes=szz_physical_prefixes, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -2180,6 +2196,7 @@ def _cached_surface_native_dem_string( check_plan: str | None = None, resolved_check_plan_hash: str = "", clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, *, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, @@ -2221,6 +2238,7 @@ def _cached_surface_native_dem_string( szz_physical_prefixes=szz_physical_prefixes, resolved_check_plan_hash=resolved_check_plan_hash, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -2337,6 +2355,7 @@ def generate_circuit_level_dem_from_builder( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> str: @@ -2394,6 +2413,8 @@ def generate_circuit_level_dem_from_builder( policy for native SZZ generation. For ``circuit_source="traced_qis"``, the Guppy program is generated from the same concrete deformed checks before runtime result tags are bound to surface metadata. + szz_runtime_barriers: Optional SZZ/SZZdg runtime-barrier policy for + traced-QIS Guppy generation. require_hosted_operation_order: For ``circuit_source="traced_qis"``, validate generic hosted-operation metadata after runtime trace replay. This is intended for source-local pulses that semantically @@ -2434,6 +2455,7 @@ def generate_circuit_level_dem_from_builder( check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -2467,6 +2489,7 @@ def generate_circuit_level_dem_from_builder( "check_plan": resolved_plan.plan_id, "resolved_check_plan_hash": resolved_plan.resolved_hash, "clifford_frame_policy": clifford_frame_policy, + "szz_runtime_barriers": szz_runtime_barriers, "require_hosted_operation_order": require_hosted_operation_order, "max_hosted_tick_separation": max_hosted_tick_separation, } @@ -3897,6 +3920,7 @@ def surface_code_memory( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> SimulationResult: @@ -3929,6 +3953,8 @@ def surface_code_memory( supplied. clifford_frame_policy: Optional source-level Clifford-deformation policy for native SZZ generation. + szz_runtime_barriers: Optional SZZ/SZZdg runtime-barrier policy for + traced-QIS Guppy generation. require_hosted_operation_order: For ``circuit_source="traced_qis"``, validate generic hosted-operation metadata after runtime trace replay. @@ -3974,6 +4000,7 @@ def surface_code_memory( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -4277,6 +4304,7 @@ def build_native_sampler( ] = "dem", # "mnm" accepted for compat, mapped to "influence_dem", check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, *, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, @@ -4320,6 +4348,8 @@ def build_native_sampler( supplied. clifford_frame_policy: Optional source-level Clifford-deformation policy for native abstract SZZ generation. + szz_runtime_barriers: Optional SZZ/SZZdg runtime-barrier policy for + traced-QIS Guppy generation. require_hosted_operation_order: For ``circuit_source="traced_qis"``, validate generic hosted-operation metadata after runtime trace replay. @@ -4358,6 +4388,7 @@ def build_native_sampler( szz_physical_prefixes=szz_physical_prefixes, resolved_check_plan_hash=resolved_plan.resolved_hash, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -4398,6 +4429,7 @@ def build_native_sampler( check_plan=resolved_plan.plan_id, resolved_check_plan_hash=resolved_plan.resolved_hash, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -4436,6 +4468,7 @@ def build_native_sampler_from_dem( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, ) -> NativeSampler: """Build a native sampler from a caller-supplied decomposed DEM string. @@ -4463,6 +4496,7 @@ def build_native_sampler_from_dem( check_plan=resolved_plan.plan_id, resolved_check_plan_hash=resolved_plan.resolved_hash, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, ) sampler = _cached_parsed_dem(decomposed_dem).to_dem_sampler() return NativeSampler( diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index f01d8b44d..845e573a2 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -19,6 +19,10 @@ from pecos.qec.surface.patch import SurfacePatch +_SZZ_RUNTIME_BARRIER_HELPER = "pecos_qis_runtime_barrier_qubit_hugr(" +_SZZ_RUNTIME_BARRIER_CALL = "= pecos_qis_runtime_barrier_qubit_hugr(" + + @pytest.fixture def patch() -> SurfacePatch: return SurfacePatch.create(distance=3) @@ -35,7 +39,7 @@ def _assert_szz_prefix_barrier_host_order(src: str) -> None: for i in range(index - 1, -1, -1) if '"source_kind", "szz_data_prefix"' in lines[i] ) - barrier_index = next(i for i in range(index + 1, len(lines)) if "barrier(" in lines[i]) + barrier_index = next(i for i in range(index + 1, len(lines)) if _SZZ_RUNTIME_BARRIER_CALL in lines[i]) host_meta = next( i for i in range(barrier_index + 1, len(lines)) @@ -149,9 +153,9 @@ def test_szz_runtime_barriers_precede_data_prefix_and_host(patch: SurfacePatch) szz_runtime_barriers=True, ) - assert "from guppylang.std.builtins import array, barrier, owned, result" in src - assert "barrier(" in src - assert "barrier(" not in generate_guppy_source( + assert "from guppylang.std.builtins import array, owned, result" in src + assert _SZZ_RUNTIME_BARRIER_HELPER in src + assert _SZZ_RUNTIME_BARRIER_CALL not in generate_guppy_source( patch, interaction_basis="szz", ) @@ -171,8 +175,8 @@ def test_szz_data_prefix_runtime_barriers_only_guard_real_prefixes(patch: Surfac szz_runtime_barriers="data-prefix", ) - assert "barrier(" in prefix_src - assert prefix_src.count("barrier(") < all_src.count("barrier(") + assert _SZZ_RUNTIME_BARRIER_CALL in prefix_src + assert prefix_src.count(_SZZ_RUNTIME_BARRIER_CALL) < all_src.count(_SZZ_RUNTIME_BARRIER_CALL) _assert_szz_prefix_barrier_host_order(prefix_src) 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 ec941932e..8af97250c 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -326,7 +326,7 @@ def test_szz_runtime_barrier_fences_data_prefix_before_host() -> None: ) prefix_index = source.index('"source_kind", "szz_data_prefix"') - barrier_index = source.index("barrier(") + barrier_index = source.index("= pecos_qis_runtime_barrier_qubit_hugr(") host_index = source.index('"source_kind", "szz_host"', prefix_index) zz_phase_index = source.index("zz_phase(", barrier_index) 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 2e5a1eda3..11b72a283 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 @@ -474,6 +474,32 @@ def test_szz_detector_paths_accept_abstract_and_traced_qis_basis() -> None: assert int(traced_memory_circuit.get_meta("num_detectors")) == int(tick_circuit.get_meta("num_detectors")) +def test_szz_runtime_barriers_allow_strict_traced_hosted_order() -> None: + patch = SurfacePatch.create(distance=3) + + tick_circuit = build_memory_circuit( + patch=patch, + rounds=1, + circuit_source="traced_qis", + interaction_basis="szz", + szz_runtime_barriers="data-prefix", + require_hosted_operation_order=True, + ) + assert tick_circuit.get_meta("circuit_source") == "traced_qis" + assert int(tick_circuit.get_meta("num_detectors")) > 0 + + 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), + circuit_source="traced_qis", + interaction_basis="szz", + szz_runtime_barriers="data-prefix", + require_hosted_operation_order=True, + ) + assert stim.DetectorErrorModel(dem).num_detectors == int(tick_circuit.get_meta("num_detectors")) + + @pytest.mark.parametrize("distance", [3, 5]) @pytest.mark.parametrize("basis", ["Z", "X"]) def test_szz_noiseless_detector_record_equivalence(distance: int, basis: str) -> 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 a5644b66f..cb0abfdd8 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -119,15 +119,6 @@ def test_guppy_barrier_survives_into_qis_operation_trace() -> None: assert any(operation == "Barrier" or "Barrier" in operation for operation in operations) -@pytest.mark.xfail( - reason=( - "Generated SZZ runtime barriers are emitted as public Guppy barrier(...) " - "calls today, which are optimized away before PECOS QIS operation " - "collection; hosted SZZ scheduling needs a barrier-preserving or " - "hosted-operation lowering path." - ), - strict=True, -) def test_szz_runtime_barrier_survives_into_qis_operation_trace() -> None: program = make_surface_code( distance=3, From e5b889c4a9fe11220d02657dcf229fd70d2a6fe0 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 27 Jun 2026 12:57:08 -0600 Subject: [PATCH 136/150] Reject ambiguous hosted operation metadata --- .../src/pecos/qec/surface/decode.py | 1 + .../quantum-pecos/src/pecos/quantum/hosted.py | 47 +++++++++++ .../tests/pecos/test_hosted_operations.py | 83 +++++++++++++++++++ 3 files changed, 131 insertions(+) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 168faf7f3..933d6b8e5 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1478,6 +1478,7 @@ def _validate_trace_hosted_operations_if_requested( tick_circuit, max_tick_separation=max_hosted_tick_separation, require_host_after_local=require_hosted_operation_order, + require_unique_host_id=True, context=context, ) diff --git a/python/quantum-pecos/src/pecos/quantum/hosted.py b/python/quantum-pecos/src/pecos/quantum/hosted.py index cfaac8cc3..48a01606e 100644 --- a/python/quantum-pecos/src/pecos/quantum/hosted.py +++ b/python/quantum-pecos/src/pecos/quantum/hosted.py @@ -53,6 +53,7 @@ def validate_hosted_operations( max_tick_separation: int | None = None, require_shared_qubit: bool = True, require_host_after_local: bool = True, + require_unique_host_id: bool = False, context: str = "hosted operation validation", ) -> tuple[HostedOperationBinding, ...]: """Validate and bind hosted-operation metadata in a traced ``TickCircuit``. @@ -75,6 +76,10 @@ def validate_hosted_operations( require_host_after_local: Require host tick/gate order to follow local tick/gate order. Disable only for metadata-shape audits that need to report ordering drift instead of rejecting it immediately. + require_unique_host_id: Require each host id to appear on at most one + host gate. Enable this for strict validation because repeated host + records make first-later-host binding ambiguous across repeated + helper invocations. context: Human-readable context included in failures. Returns: @@ -95,6 +100,12 @@ def validate_hosted_operations( local_role_key=local_role_key, context=context, ) + if require_unique_host_id: + _raise_if_repeated_host_records( + records, + host_id_key=host_id_key, + context=context, + ) bindings: list[HostedOperationBinding] = [] for local in records: if not local.local_role: @@ -176,6 +187,42 @@ def _matching_host_records( return tuple(candidates) +def _raise_if_repeated_host_records( + records: Sequence[HostedGateRecord], + *, + host_id_key: str, + context: str, +) -> None: + host_records_by_id: dict[str, list[HostedGateRecord]] = {} + for record in records: + if record.local_role or not record.host_id: + continue + host_records_by_id.setdefault(record.host_id, []).append(record) + repeated = { + host_id: host_records + for host_id, host_records in host_records_by_id.items() + if len(host_records) > 1 + } + if not repeated: + return + host_id, host_records = next(iter(repeated.items())) + first_locations = ", ".join( + f"{record.gate_name}@t{record.tick_index}/g{record.gate_index}" + for record in host_records[:4] + ) + extra_count = len(host_records) - 4 + if extra_count > 0: + first_locations = f"{first_locations}, ... (+{extra_count} more)" + msg = ( + f"{context}: hosted metadata key {host_id_key!r} is ambiguous because " + f"host_id {host_id!r} appears on {len(host_records)} host gates " + f"({first_locations}). Strict hosted-operation validation requires " + "host ids to identify one source host gate; include invocation-scoped " + "metadata before validating ordering or tick separation." + ) + raise ValueError(msg) + + def _gate_order(record: HostedGateRecord) -> tuple[int, int]: return (record.tick_index, record.gate_index) diff --git a/python/quantum-pecos/tests/pecos/test_hosted_operations.py b/python/quantum-pecos/tests/pecos/test_hosted_operations.py index ce773f452..fb53aa460 100644 --- a/python/quantum-pecos/tests/pecos/test_hosted_operations.py +++ b/python/quantum-pecos/tests/pecos/test_hosted_operations.py @@ -92,6 +92,58 @@ def test_validate_hosted_operations_selects_later_shared_host_record() -> None: assert bindings[0].host.qubits == (2, 5) +def test_validate_hosted_operations_can_require_unique_host_ids() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + ], + ) + + with pytest.raises(ValueError, match="host_id 'host:a' appears on 2 host gates"): + validate_hosted_operations(circuit, require_unique_host_id=True) + + +def test_validate_hosted_operations_unique_host_ids_allow_many_locals() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + FakeGate( + "S", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + ], + ) + + bindings = validate_hosted_operations(circuit, require_unique_host_id=True) + + assert len(bindings) == 2 + assert {binding.local.gate_name for binding in bindings} == {"H", "S"} + assert all(binding.host.gate_name == "SZZ" for binding in bindings) + + def test_validate_hosted_operations_can_bind_without_shared_qubit_requirement() -> None: circuit = FakeTickCircuit( [ @@ -234,6 +286,37 @@ def test_trace_hosted_validation_rejects_ordering_drift_when_requested() -> None ) +def test_trace_hosted_validation_rejects_repeated_host_ids_when_requested() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + ], + ) + + with pytest.raises(ValueError, match="host_id 'host:a' appears on 2 host gates"): + _validate_trace_hosted_operations_if_requested( + circuit, + require_hosted_operation_order=True, + max_hosted_tick_separation=None, + context="test trace validation", + ) + + def test_trace_hosted_validation_can_check_separation_without_order_guard() -> None: circuit = FakeTickCircuit( [ From cb868f5d9f0d77ab83179f10895cc9be2679a372 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 27 Jun 2026 15:45:31 -0600 Subject: [PATCH 137/150] Scope SZZ hosted metadata by counted round --- .../quantum-pecos/src/pecos/guppy/surface.py | 256 +++++++++++++++++- .../tests/qec/surface/test_check_plan.py | 63 +++++ 2 files changed, 306 insertions(+), 13 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 2677fbdab..3d765988d 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -720,6 +720,7 @@ def _append_szz_layer( ancilla_expr: Callable[[str, int], str], data_expr: Callable[[int], str], pending_by_data: dict[int, tuple[int, int]] | None = None, + host_label_scope: str | None = None, ) -> None: def compose_data(data_q: int, gates: tuple[OpType, ...]) -> None: if pending_by_data is None: @@ -770,7 +771,12 @@ def discharge_data_for_szz( ) sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] host_gate = OpType.SZZ if sign > 0 else OpType.SZZDG - host_label = f"szz:r{rnd_idx + 1}:{stab_type}{stab_idx}:d{data_q}:{host_gate.name}" + host_label_core = f"r{rnd_idx + 1}:{stab_type}{stab_idx}:d{data_q}:{host_gate.name}" + host_label = ( + f"szz:{host_label_core}" + if host_label_scope is None + else f"szz:{host_label_scope}:{host_label_core}" + ) discharge_data_for_szz(data_q, host_label=host_label) if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_ALL or ( szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX @@ -888,6 +894,7 @@ def discharge_data_for_szz( _szz_ancilla_expr, _szz_data_expr, pending_by_data=szz_syndrome_pending_by_data, + host_label_scope="syndrome_extraction", ) lines.append("") @@ -999,6 +1006,7 @@ def discharge_data_for_szz( ], _szz_data_expr, pending_by_data=szz_syndrome_pending_by_data, + host_label_scope="syndrome_extraction", ) if interaction_basis == "cx": @@ -1120,6 +1128,7 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: _szz_ancilla_expr, _szz_data_expr, pending_by_data=szz_init_pending_by_data, + host_label_scope=function_name, ) lines.append("") @@ -1197,6 +1206,7 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ], _szz_data_expr, pending_by_data=szz_init_pending_by_data, + host_label_scope=function_name, ) if interaction_basis == "cx": @@ -1326,8 +1336,218 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ], ) - # Generate memory experiment factories - lines.extend(_render_memory_experiments(patch, dx, dz, num_data, twirl, rng, num_rounds, interaction_basis)) + def _append_inline_szz_syndrome_extraction( + target: list[str], + indent: str, + *, + host_label_scope: str, + ) -> None: + """Append one SZZ syndrome-extraction body with unique hosted ids.""" + if interaction_basis != "szz": + msg = "inline SZZ syndrome extraction requires interaction_basis='szz'" + raise ValueError(msg) + target.append(f"{indent}# Inline SZZ syndrome extraction ({host_label_scope})") + target.append(f"{indent}# Unpack data qubits") + _append_szz_data_unpack(target, indent) + pending_by_data = dict.fromkeys(range(num_data), _SZZ_FLOW_IDENTITY) + + if not constrained: + target.append(f"{indent}# Allocate ancilla qubits (one per stabilizer)") + target.extend(f"{indent}ax{stab.index} = qubit()" for stab in geom.x_stabilizers) + target.extend(f"{indent}az{stab.index} = qubit()" for stab in geom.z_stabilizers) + + target.append("") + target.append(f"{indent}# Hadamard on SZZ ancillas") + target.extend(f"{indent}h(ax{stab.index})" for stab in geom.x_stabilizers) + target.extend(f"{indent}h(az{stab.index})" for stab in geom.z_stabilizers) + + for rnd_idx, rnd_gates in enumerate(rounds): + _append_szz_layer( + target, + indent, + rnd_idx, + list(rnd_gates), + _szz_ancilla_expr, + _szz_data_expr, + pending_by_data=pending_by_data, + host_label_scope=host_label_scope, + ) + + target.append("") + target.append(f"{indent}# Hadamard on SZZ ancillas") + target.extend(f"{indent}h(ax{stab.index})" for stab in geom.x_stabilizers) + target.extend(f"{indent}h(az{stab.index})" for stab in geom.z_stabilizers) + + target.append("") + target.append(f"{indent}# Measure ancillas") + idx = 0 + for stab in geom.x_stabilizers: + target.append(f"{indent}sx{stab.index} = measure(ax{stab.index})") + target.append(f'{indent}result("sx{stab.index}:meas:{idx}", sx{stab.index})') + idx += 1 + for stab in geom.z_stabilizers: + target.append(f"{indent}sz{stab.index} = measure(az{stab.index})") + target.append(f'{indent}result("sz{stab.index}:meas:{idx}", sz{stab.index})') + idx += 1 + else: + batches = batched_stabilizers( + patch, + effective_budget, + ancilla_schedule=ancilla_schedule, + ) + idx = 0 + for batch_idx, batch in enumerate(batches): + target.append("") + target.append(f"{indent}# Batch {batch_idx + 1}/{len(batches)} of stabilizers") + batch_anc_var: dict[tuple[str, int], str] = {} + for pos, (stab_type, stab_idx) in enumerate(batch): + var = f"_a_b{batch_idx}_p{pos}" + batch_anc_var[(stab_type, stab_idx)] = var + target.append(f"{indent}{var} = qubit()") + + target.append(f"{indent}# Hadamard on SZZ ancillas in this batch") + for stab_type, stab_idx in batch: + target.append(f"{indent}h({batch_anc_var[(stab_type, stab_idx)]})") + + batch_keys = set(batch_anc_var.keys()) + for rnd_idx, rnd_gates in enumerate(rounds): + rnd_in_batch = [ + (stab_type, stab_idx, data_q) + for stab_type, stab_idx, data_q in rnd_gates + if (stab_type, stab_idx) in batch_keys + ] + if not rnd_in_batch: + continue + target.append("") + target.append(f"{indent}# Batch {batch_idx + 1} round {rnd_idx + 1}") + _append_szz_layer( + target, + indent, + rnd_idx, + rnd_in_batch, + lambda stab_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[ + (stab_type, stab_idx) + ], + _szz_data_expr, + pending_by_data=pending_by_data, + host_label_scope=host_label_scope, + ) + + target.append("") + target.append(f"{indent}# Hadamard on SZZ ancillas in this batch") + for stab_type, stab_idx in batch: + target.append(f"{indent}h({batch_anc_var[(stab_type, stab_idx)]})") + + target.append("") + target.append(f"{indent}# Measure batch {batch_idx + 1} ancillas") + for stab_type, stab_idx in batch: + anc = batch_anc_var[(stab_type, stab_idx)] + syn_var = f"sx{stab_idx}" if stab_type == "X" else f"sz{stab_idx}" + target.append(f"{indent}{syn_var} = measure({anc})") + target.append(f'{indent}result("{syn_var}:meas:{idx}", {syn_var})') + idx += 1 + + x_calls = ", ".join(f"sx{s.index}" for s in geom.x_stabilizers) + z_calls = ", ".join(f"sz{s.index}" for s in geom.z_stabilizers) + target.extend(["", f"{indent}synx = array({x_calls})", f"{indent}synz = array({z_calls})", ""]) + _append_szz_flush_data_frame( + target, + indent, + pending_by_data, + reason=f"{host_label_scope} inline syndrome", + ) + target.append(f"{indent}surf = SurfaceCode_{dx}x{dz}({szz_data_args})") + + def _render_plain_szz_round_helper(round_idx: int) -> list[str]: + helper_name = f"syndrome_extraction_memory_r{round_idx}" + body = [ + "", + "", + "@guppy", + ( + f"def {helper_name}(surf: SurfaceCode_{dx}x{dz} @ owned) " + f"-> tuple[SurfaceCode_{dx}x{dz}, Syndrome_{dx}x{dz}]:" + ), + ( + f' """Extract counted SZZ syndrome round {round_idx} ' + 'with round-scoped hosted metadata."""' + ), + ] + _append_inline_szz_syndrome_extraction( + body, + " ", + host_label_scope=f"memory_r{round_idx}", + ) + body.append(f" return surf, Syndrome_{dx}x{dz}(synx, synz)") + return body + + def _render_plain_szz_memory_block( + basis: str, + basis_upper: str, + rendered_num_rounds: int, + ) -> list[str]: + init_func = "init_z_basis" if basis == "z" else "init_x_basis" + init_tag = "init_synx" if basis == "z" else "init_synz" + body: list[str] = [ + ( + f' """{basis_upper}-basis SZZ memory experiment for ' + f"dx={dx}, dz={dz}, num_rounds={rendered_num_rounds}." + '"""' + ), + f" surf = prep_{basis}_basis()", + f" surf, init_syn = {init_func}(surf)", + f' result("{init_tag}", init_syn)', + "", + ] + for round_idx in range(rendered_num_rounds): + body.append(f" # === Counted syndrome round {round_idx} ===") + body.append(f" surf, syn = syndrome_extraction_memory_r{round_idx}(surf)") + body.append(' result("synx", syn.synx)') + body.append(' result("synz", syn.synz)') + body.append("") + + body.extend( + [ + f" final = measure_{basis}_basis(surf)", + ' result("final", final)', + ], + ) + return [ + f"def make_memory_{basis}(num_rounds: int):", + f' """Create {basis_upper}-basis SZZ memory experiment.', + "", + f" num_rounds must equal {rendered_num_rounds} -- the body was unrolled at", + " source-generation time so hosted-operation metadata is unique per", + " counted syndrome round. Mismatched values raise ValueError.", + ' """', + f" if num_rounds != {rendered_num_rounds}:", + ( + f' msg = f"this generated module was unrolled for ' + f'num_rounds={rendered_num_rounds}, got {{num_rounds!r}}"' + ), + " raise ValueError(msg)", + "", + " @guppy", + f" def memory_{basis}() -> None:", + *body, + "", + f" return memory_{basis}", + "", + "", + ] + + # Generate memory experiment factories. Plain SZZ memory programs are + # unrolled when the round count is available so hosted metadata identifies + # the concrete counted syndrome round instead of the reusable helper body. + if twirl is None and interaction_basis == "szz" and num_rounds is not None: + lines.extend(["# === Counted SZZ Syndrome Helpers ==="]) + for round_idx in range(num_rounds): + lines.extend(_render_plain_szz_round_helper(round_idx)) + lines.extend(["# === Memory Experiments ===", ""]) + for basis, basis_upper in (("z", "Z"), ("x", "X")): + lines.extend(_render_plain_szz_memory_block(basis, basis_upper, num_rounds)) + else: + lines.extend(_render_memory_experiments(patch, dx, dz, num_data, twirl, rng, num_rounds, interaction_basis)) return "\n".join(lines) @@ -1999,7 +2219,9 @@ def _guppy_module_cache_key( Twirled source is Python-time unrolled, so the cache key includes ``num_rounds`` in addition to the structural twirl fields, runtime frame-output mode, RNG seed, activation-probability threshold, and - the SZZ runtime-barrier policy. + the SZZ runtime-barrier policy. Plain SZZ source is also unrolled when + ``num_rounds`` is supplied so hosted-operation metadata can identify the + concrete counted syndrome round. """ geom = patch.geometry rotated = "rot" if geom.rotated else "unrot" @@ -2028,6 +2250,8 @@ def _guppy_module_cache_key( f"_b{effective_budget}{interaction_part}{check_plan_part}{frame_part}{runtime_barrier_part}" ) if twirl is None: + if interaction_basis == "szz" and num_rounds is not None: + return f"{base}_r{int(num_rounds)}" return base if rng is None or num_rounds is None: msg = "twirled Guppy module cache keys require both rng and num_rounds" @@ -2168,14 +2392,18 @@ def generate_memory_experiment( Returns: Guppy function for the experiment """ + resolved_plan = _resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) module = _load_guppy_module( patch, ancilla_budget=ancilla_budget, twirl=twirl, rng=rng, - num_rounds=num_rounds if twirl is not None else None, - interaction_basis=interaction_basis, - check_plan=check_plan, + num_rounds=num_rounds if twirl is not None or resolved_plan.interaction_basis == "szz" else None, + interaction_basis=resolved_plan.interaction_basis, + check_plan=resolved_plan.plan_id if check_plan is not None else None, clifford_frame_policy=clifford_frame_policy, szz_runtime_barriers=szz_runtime_barriers, ) @@ -2447,15 +2675,17 @@ def make_surface_code( msg = f"basis must be 'Z' or 'X', got {basis!r}" raise ValueError(msg) - module = get_surface_code_module( - distance, + from pecos.qec.surface import SurfacePatch + + _validate_surface_memory_distance(distance) + patch = SurfacePatch.create(distance=distance) + return generate_memory_experiment( + patch, + num_rounds, + basis, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, szz_runtime_barriers=szz_runtime_barriers, ) - - factory = module["make_memory_z"] if basis.upper() == "Z" else module["make_memory_x"] - - return factory(num_rounds) 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 8af97250c..6ea69257f 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -351,6 +351,69 @@ def test_szz_data_prefixes_emit_generic_hosted_metadata() -> None: assert prefix_index < prefix_host_index < role_index < host_index < host_id_index +def test_szz_hosted_metadata_labels_include_helper_scope() -> None: + from pecos.guppy.surface import generate_guppy_source + from pecos.qec.surface import SurfacePatch + + source = generate_guppy_source( + SurfacePatch.create(distance=3), + check_plan="szz_current_v1", + ) + + assert '"host_id", "szz:init_z_basis:' in source + assert '"host_id", "szz:init_x_basis:' in source + assert '"host_id", "szz:syndrome_extraction:' in source + + +def test_plain_szz_memory_source_unrolls_hosted_metadata_by_counted_round() -> None: + from pecos.guppy.surface import generate_guppy_source + from pecos.qec.surface import SurfacePatch + + source = generate_guppy_source( + SurfacePatch.create(distance=3), + check_plan="szz_current_v1", + num_rounds=2, + ) + + assert "for _t in range(comptime(num_rounds))" not in source + assert "if num_rounds != 2:" in source + assert "def syndrome_extraction_memory_r0" in source + assert "def syndrome_extraction_memory_r1" in source + assert '"host_id", "szz:memory_r0:' in source + assert '"host_id", "szz:memory_r1:' in source + assert '"host_id", "szz:memory_r2:' not in source + + +def test_plain_szz_memory_cache_key_includes_counted_rounds() -> None: + from pecos.guppy.surface import _guppy_module_cache_key + from pecos.qec.surface import SurfacePatch + + patch = SurfacePatch.create(distance=3) + + key_one = _guppy_module_cache_key( + patch, + 8, + check_plan="szz_current_v1", + num_rounds=1, + ) + key_two = _guppy_module_cache_key( + patch, + 8, + check_plan="szz_current_v1", + num_rounds=2, + ) + key_generic = _guppy_module_cache_key( + patch, + 8, + check_plan="szz_current_v1", + ) + + assert key_one.endswith("_r1") + assert key_two.endswith("_r2") + assert key_one != key_two + assert key_generic not in {key_one, key_two} + + def test_boundary_first_szz_check_plan_changes_source_gates_not_metadata() -> None: from pecos.qec.surface import SurfacePatch from pecos.qec.surface.circuit_builder import ( From b103696050bc15f4977c532ba14adb0e96366353 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 27 Jun 2026 18:42:10 -0600 Subject: [PATCH 138/150] Use physical SZZ hosted prefixes in Guppy --- .../quantum-pecos/src/pecos/guppy/surface.py | 86 ++++++++++++++++--- .../src/pecos/qec/surface/circuit_builder.py | 4 + .../quantum-pecos/src/pecos/quantum/hosted.py | 10 ++- .../tests/guppy/test_surface_twirl_render.py | 14 +-- .../tests/pecos/test_hosted_operations.py | 41 +++++++++ .../qec/surface/test_szz_interaction_basis.py | 8 +- 6 files changed, 144 insertions(+), 19 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 3d765988d..1d16bb756 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -252,8 +252,10 @@ def generate_guppy_source( ) from pecos.qec.surface.circuit_builder import ( _SZZ_FLOW_IDENTITY, + _SZZ_FLOW_PHYSICAL_PREFIX_BY_PENDING, OpType, _resolve_szz_clifford_frame_for_builder, + _szz_flow_clifford_name, _szz_flow_compose_pending_gate, _szz_flow_is_virtual_z, _szz_memory_physical_axis_for_data, @@ -334,7 +336,7 @@ def generate_guppy_source( "from guppylang import guppy", "from guppylang.std.angles import angle", "from guppylang.std.builtins import array, owned, result", - "from guppylang.std.qsystem.functional import zz_phase", + "from guppylang.std.qsystem.functional import phased_x, rz, zz_phase", "from guppylang.std.quantum import discard, h, measure, measure_array, qubit, s, sdg, v, vdg, x, y, z", ] else: @@ -597,6 +599,67 @@ def _append_szz_flow_gate( ) target.append(f"{indent}{op_name}({qubit_expr})") + def _append_szz_physical_prefix_gate( + target: list[str], + indent: str, + op_type: OpType, + qubit_expr: str, + *, + source_label: str, + host_label: str, + ) -> None: + """Append one hosted physical SZZ prefix pulse. + + Guppy's public quantum stdlib does not expose PECOS ``F``/``SY`` + names directly. The hardware-level SZZ forward-flow table still has + a one-pulse interpretation: ``SY`` is a Y-axis sqrt pulse, and ``F`` + is an X-axis sqrt pulse plus a virtual Z-frame update. We attach the + hosted metadata only to the physical pulse so scheduling diagnostics + track the operation that must remain adjacent to its SZZ/SZZdg host. + """ + _append_szz_gate_trace_metadata( + target, + indent, + source_kind="szz_data_prefix", + source_label=source_label, + qubit_expr=qubit_expr, + host_label=host_label, + local_role="basis_prefix", + gate=op_type, + ) + if op_type == OpType.H: + target.append(f"{indent}h({qubit_expr})") + elif op_type == OpType.SX: + target.append(f"{indent}{qubit_expr} = phased_x({qubit_expr}, angle(0.5), angle(0.0))") + elif op_type == OpType.SXDG: + target.append(f"{indent}{qubit_expr} = phased_x({qubit_expr}, angle(-0.5), angle(0.0))") + elif op_type == OpType.SY: + target.append(f"{indent}{qubit_expr} = phased_x({qubit_expr}, angle(0.5), angle(0.5))") + elif op_type == OpType.SYDG: + target.append(f"{indent}{qubit_expr} = phased_x({qubit_expr}, angle(-0.5), angle(0.5))") + elif op_type == OpType.F: + target.append(f"{indent}{qubit_expr} = phased_x({qubit_expr}, angle(0.5), angle(0.0))") + target.append(f"{indent}{qubit_expr} = rz({qubit_expr}, angle(0.5))") + elif op_type == OpType.FDG: + target.append(f"{indent}{qubit_expr} = phased_x({qubit_expr}, angle(0.5), angle(-0.5))") + target.append(f"{indent}{qubit_expr} = rz({qubit_expr}, angle(-0.5))") + else: + msg = f"unsupported hosted SZZ physical prefix gate {op_type.name}" + raise ValueError(msg) + + def _szz_guppy_physical_prefix_for_pending( + pending: tuple[int, int], + ) -> tuple[OpType | None, OpType]: + """Return the source-level physical-prefix lowering for ``pending``.""" + try: + return _SZZ_FLOW_PHYSICAL_PREFIX_BY_PENDING[pending] + except KeyError as exc: + msg = ( + "SZZ Guppy hosted-prefix lowering cannot lower pending " + f"Clifford {_szz_flow_clifford_name(pending)}" + ) + raise ValueError(msg) from exc + _szz_guppy_prefix_cache: dict[tuple[int, int], tuple[OpType, ...]] = {_SZZ_FLOW_IDENTITY: ()} _szz_guppy_prefix_generators = ( OpType.H, @@ -742,16 +805,17 @@ def discharge_data_for_szz( pending = pending_by_data.setdefault(data_q, _SZZ_FLOW_IDENTITY) if pending == _SZZ_FLOW_IDENTITY or _szz_flow_is_virtual_z(pending): return - prefix = _szz_guppy_prefix_gates_for_pending(pending) - for prefix_idx, gate in enumerate(prefix): - _append_szz_flow_gate( - target, - indent, - gate, - data_expr(data_q), - source_label=f"{host_label}:prefix:{prefix_idx}:{gate.name}", - host_label=host_label, - ) + virtual_gate, physical_gate = _szz_guppy_physical_prefix_for_pending(pending) + if virtual_gate is not None: + _append_szz_flow_gate(target, indent, virtual_gate, data_expr(data_q)) + _append_szz_physical_prefix_gate( + target, + indent, + physical_gate, + data_expr(data_q), + source_label=f"{host_label}:prefix:0:{physical_gate.name}", + host_label=host_label, + ) pending_by_data[data_q] = _SZZ_FLOW_IDENTITY target.append("") diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index b3fbe5c5c..fc3df1622 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -756,6 +756,10 @@ def discharge_for_measurement(q: int, host_index: int, op: SurfaceCircuitStep) - (-3, 1): (None, OpType.SY), (2, 1): (None, OpType.F), (-2, 1): (OpType.Z, OpType.F), + (1, 2): (None, OpType.SXDG), + (-1, 2): (OpType.Z, OpType.SXDG), + (3, 2): (None, OpType.FDG), + (-3, 2): (OpType.Z, OpType.FDG), } diff --git a/python/quantum-pecos/src/pecos/quantum/hosted.py b/python/quantum-pecos/src/pecos/quantum/hosted.py index 48a01606e..009f88db7 100644 --- a/python/quantum-pecos/src/pecos/quantum/hosted.py +++ b/python/quantum-pecos/src/pecos/quantum/hosted.py @@ -19,6 +19,14 @@ HOST_ID_META_KEY = "host_id" LOCAL_ROLE_META_KEY = "local_role" +HOSTED_PROVENANCE_META_KEYS = ( + "source_kind", + "source_label", + "source_gate", + "szz_host_label", + "source_lowering_required", + "label", +) @dataclass(frozen=True) @@ -240,7 +248,7 @@ def _hosted_gate_records( tick_circuit, tick_index, gate_index, - keys=(host_id_key, local_role_key), + keys=(host_id_key, local_role_key, *HOSTED_PROVENANCE_META_KEYS), ) host_id = _metadata_text(metadata, host_id_key) local_role = _metadata_text(metadata, local_role_key) diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index 845e573a2..0c85e5cd4 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -18,7 +18,6 @@ ) from pecos.qec.surface.patch import SurfacePatch - _SZZ_RUNTIME_BARRIER_HELPER = "pecos_qis_runtime_barrier_qubit_hugr(" _SZZ_RUNTIME_BARRIER_CALL = "= pecos_qis_runtime_barrier_qubit_hugr(" @@ -32,7 +31,7 @@ def _assert_szz_prefix_barrier_host_order(src: str) -> None: """Assert a real SZZ data-prefix pulse is fenced before its host.""" lines = src.splitlines() for index, line in enumerate(lines): - if "vdg(d1)" not in line: + if "phased_x(" not in line: continue prefix_meta = next( i @@ -50,7 +49,7 @@ def _assert_szz_prefix_barrier_host_order(src: str) -> None: assert prefix_meta < index < barrier_index < host_meta < zz_phase_index return - msg = "expected an SZZ touch with a Vdg data-prefix pulse" + msg = "expected an SZZ touch with a hosted phased_x data-prefix pulse" raise AssertionError(msg) @@ -69,7 +68,8 @@ def test_szz_source_uses_signed_zz_phase_template(patch: SurfacePatch) -> None: src = generate_guppy_source(patch, interaction_basis="szz") assert "from guppylang.std.angles import angle" in src - assert "from guppylang.std.qsystem.functional import zz_phase" in src + assert "from guppylang.std.qsystem.functional import phased_x, rz, zz_phase" in src + assert "phased_x(" in src assert "zz_phase(" in src assert "angle(0.5)" in src assert "angle(-0.5)" in src @@ -138,8 +138,10 @@ def test_szz_basis_forks_guppy_module_cache_key(patch: SurfacePatch) -> None: def test_szz_source_keeps_reusable_memory_body_and_flushes_helper_frame(patch: SurfacePatch) -> None: src = generate_guppy_source(patch, interaction_basis="szz", num_rounds=2) - assert "for _t in range(comptime(num_rounds)):" in src - assert "surf, syn = syndrome_extraction(surf)" in src + assert "def syndrome_extraction_memory_r0" in src + assert "def syndrome_extraction_memory_r1" in src + assert "surf, syn = syndrome_extraction_memory_r0(surf)" in src + assert "surf, syn = syndrome_extraction_memory_r1(surf)" in src assert "# Flush SZZ data frame before syndrome return" in src assert "# Flush SZZ data frame before init_z_basis return" in src assert "# Flush SZZ data frame before init_x_basis return" in src diff --git a/python/quantum-pecos/tests/pecos/test_hosted_operations.py b/python/quantum-pecos/tests/pecos/test_hosted_operations.py index fb53aa460..cd8f42fa2 100644 --- a/python/quantum-pecos/tests/pecos/test_hosted_operations.py +++ b/python/quantum-pecos/tests/pecos/test_hosted_operations.py @@ -70,6 +70,47 @@ def test_validate_hosted_operations_binds_local_to_later_host() -> None: assert bindings[0].tick_separation == 2 +def test_validate_hosted_operations_preserves_source_provenance_metadata() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "SXdg", + [2], + meta={ + "host_id": "host:a", + "local_role": "basis_prefix", + "source_kind": "szz_data_prefix", + "source_label": "host:a:prefix:1:SXDG", + "source_gate": "SXDG", + }, + ), + ], + [ + FakeGate( + "SZZ", + [2, 5], + meta={ + "host_id": "host:a", + "source_kind": "szz_host", + "source_label": "host:a", + "source_gate": "SZZ", + }, + ), + ], + ], + ) + + binding = validate_hosted_operations(circuit)[0] + + assert binding.local.metadata["source_kind"] == "szz_data_prefix" + assert binding.local.metadata["source_label"] == "host:a:prefix:1:SXDG" + assert binding.local.metadata["source_gate"] == "SXDG" + assert binding.host.metadata["source_kind"] == "szz_host" + assert binding.host.metadata["source_label"] == "host:a" + assert binding.host.metadata["source_gate"] == "SZZ" + + def test_validate_hosted_operations_selects_later_shared_host_record() -> None: circuit = FakeTickCircuit( [ 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 11b72a283..e96fcc4b1 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 @@ -753,7 +753,13 @@ def test_szz_prefix_lowering_emits_dedicated_prefix_ticks(basis: str) -> None: else: saw_physical_prefix = True assert all(label.startswith("szz_physical_prefix:") for label in prefix_labels) - assert {gate.gate_type.name for gate in tick.gate_batches()} <= {"H", "F", "SY"} + assert {gate.gate_type.name for gate in tick.gate_batches()} <= { + "H", + "F", + "Fdg", + "SXdg", + "SY", + } for gate_index, _gate in enumerate(tick.gate_batches()): assert tick_circuit.get_gate_meta(tick_index, gate_index, PHYSICAL_DURATION_META_KEY) is None From 969ade0f615d19df950b334056845d9347aaebdb Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 27 Jun 2026 20:35:37 -0600 Subject: [PATCH 139/150] Allow SZZ Guppy builds without trace metadata --- .../quantum-pecos/src/pecos/guppy/surface.py | 62 ++++++++++++++++--- .../tests/qec/surface/test_check_plan.py | 19 ++++++ 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 1d16bb756..6789bf8a2 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -174,6 +174,7 @@ def generate_guppy_source( check_plan: str | None = None, clifford_frame_policy: str | None = None, szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, ) -> str: """Generate Guppy source code for a surface code patch. @@ -238,6 +239,10 @@ def generate_guppy_source( no ideal-unitary effect, but give runtimes a principled scheduling boundary between selected local data-frame pulses and their entangling host. + trace_metadata: Emit PECOS trace metadata helpers for SZZ/SZZdg + hosted-operation diagnostics and strict DEM construction. Disable + only for execution-only builds whose compiler/linker cannot resolve + PECOS trace metadata helper symbols. Returns: Python/Guppy source code as a string. @@ -370,21 +375,28 @@ def generate_guppy_source( lines.extend(_render_inline_pcg32()) if interaction_basis == "szz": - lines.extend( + helper_declarations: list[str] = [] + if trace_metadata: + helper_declarations.extend( + [ + "@guppy.declare", + ( + "def pecos_qis_trace_metadata_qubit_hugr(" + "q: qubit @ owned, key: str, value: str" + ") -> qubit: ..." + ), + "", + ], + ) + helper_declarations.extend( [ - "@guppy.declare", - ( - "def pecos_qis_trace_metadata_qubit_hugr(" - "q: qubit @ owned, key: str, value: str" - ") -> qubit: ..." - ), - "", "@guppy.declare", "def pecos_qis_runtime_barrier_qubit_hugr(q: qubit @ owned) -> qubit: ...", "", "", ], ) + lines.extend(helper_declarations) # Generate struct definitions. lines.extend( @@ -531,6 +543,8 @@ def _append_szz_trace_metadata( value: str, qubit_expr: str, ) -> None: + if not trace_metadata: + return target.append( f'{indent}{qubit_expr} = pecos_qis_trace_metadata_qubit_hugr({qubit_expr}, "{key}", "{value}")', ) @@ -2272,6 +2286,7 @@ def _guppy_module_cache_key( check_plan: str | None = None, clifford_frame_policy: str | None = None, szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, ) -> str: """Filesystem-safe cache key spanning full patch identity + budget + twirl. @@ -2309,9 +2324,11 @@ def _guppy_module_cache_key( if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_NONE else f"_szzrb-{szz_runtime_barrier_policy}" ) + trace_metadata_part = "" if trace_metadata else "_trace-metadata-off" base = ( f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}" - f"_b{effective_budget}{interaction_part}{check_plan_part}{frame_part}{runtime_barrier_part}" + f"_b{effective_budget}{interaction_part}{check_plan_part}{frame_part}" + f"{runtime_barrier_part}{trace_metadata_part}" ) if twirl is None: if interaction_basis == "szz" and num_rounds is not None: @@ -2340,6 +2357,7 @@ def _load_guppy_module( check_plan: str | None = None, clifford_frame_policy: str | None = None, szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, ) -> dict: """Load a Guppy module for a patch, using caching. @@ -2363,6 +2381,10 @@ def _load_guppy_module( clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. + trace_metadata: Emit PECOS trace metadata helpers in generated SZZ + source. Keep enabled for traced-QIS/DEM paths; disable for + execution-only builds whose compiler/linker cannot resolve the + metadata helper symbols. Returns: Module dictionary with generated functions @@ -2387,6 +2409,7 @@ def _load_guppy_module( check_plan=resolved_plan.plan_id if check_plan is not None else None, clifford_frame_policy=clifford_frame_policy, szz_runtime_barriers=szz_runtime_barriers, + trace_metadata=trace_metadata, ) if cache_key in _state.module_cache: @@ -2402,6 +2425,7 @@ def _load_guppy_module( check_plan=resolved_plan.plan_id, clifford_frame_policy=clifford_frame_policy, szz_runtime_barriers=szz_runtime_barriers, + trace_metadata=trace_metadata, ) # Write to temp file (required for Guppy introspection). @@ -2436,6 +2460,7 @@ def generate_memory_experiment( check_plan: str | None = None, clifford_frame_policy: str | None = None, szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, ) -> object: """Generate a memory experiment for a patch. @@ -2452,6 +2477,10 @@ def generate_memory_experiment( clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. + trace_metadata: Emit PECOS trace metadata helpers in generated SZZ + source. Keep enabled for traced-QIS/DEM paths; disable for + execution-only builds whose compiler/linker cannot resolve the + metadata helper symbols. Returns: Guppy function for the experiment @@ -2470,6 +2499,7 @@ def generate_memory_experiment( check_plan=resolved_plan.plan_id if check_plan is not None else None, clifford_frame_policy=clifford_frame_policy, szz_runtime_barriers=szz_runtime_barriers, + trace_metadata=trace_metadata, ) if basis.upper() == "Z": @@ -2564,6 +2594,7 @@ def generate_surface_code_module( check_plan: str | None = None, clifford_frame_policy: str | None = None, szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, ) -> str: """Generate source code for a distance-d surface code module. @@ -2577,6 +2608,8 @@ def generate_surface_code_module( clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. + trace_metadata: Emit PECOS trace metadata helpers in generated SZZ + source. Returns: Python/Guppy source code as a string @@ -2593,6 +2626,7 @@ def generate_surface_code_module( check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, szz_runtime_barriers=szz_runtime_barriers, + trace_metadata=trace_metadata, ) @@ -2604,6 +2638,7 @@ def _surface_code_module_for_patch( check_plan: str | None = None, clifford_frame_policy: str | None = None, szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, ) -> dict: """Load + cache a surface-code module for an arbitrary patch. @@ -2634,6 +2669,7 @@ def _surface_code_module_for_patch( resolved_plan.plan_id, None if clifford_frame_policy is None else str(clifford_frame_policy).lower().replace("-", "_"), szz_runtime_barrier_policy, + trace_metadata, ) if cache_key in _state.distance_module_cache: @@ -2646,6 +2682,7 @@ def _surface_code_module_for_patch( check_plan=resolved_plan.plan_id, clifford_frame_policy=clifford_frame_policy, szz_runtime_barriers=szz_runtime_barrier_policy, + trace_metadata=trace_metadata, ) # Metadata derived from the actual patch geometry. @@ -2658,6 +2695,7 @@ def _surface_code_module_for_patch( module["clifford_frame_policy"] = clifford_frame_policy module["szz_runtime_barriers"] = szz_runtime_barrier_policy != _SZZ_RUNTIME_BARRIER_POLICY_NONE module["szz_runtime_barrier_policy"] = szz_runtime_barrier_policy + module["trace_metadata"] = trace_metadata module["resolved_check_plan"] = resolved_plan.resolved_metadata module["resolved_check_plan_hash"] = resolved_plan.resolved_hash @@ -2713,6 +2751,7 @@ def make_surface_code( check_plan: str | None = None, clifford_frame_policy: str | None = None, szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, ) -> object: """Create a surface code memory experiment. @@ -2731,6 +2770,10 @@ def make_surface_code( clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. + trace_metadata: Emit PECOS trace metadata helpers in generated SZZ + source. Keep enabled for traced-QIS/DEM paths; disable for + execution-only builds whose compiler/linker cannot resolve the + metadata helper symbols. Returns: Compiled Guppy program @@ -2752,4 +2795,5 @@ def make_surface_code( check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, szz_runtime_barriers=szz_runtime_barriers, + trace_metadata=trace_metadata, ) 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 6ea69257f..eb110cbf4 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -314,6 +314,25 @@ def test_direct_surface_renderers_accept_check_plan_as_source_of_truth() -> None assert '"source_lowering_required", "true"' in guppy_source +def test_szz_guppy_source_can_disable_trace_metadata_for_execution() -> None: + from pecos.guppy.surface import generate_guppy_source + from pecos.qec.surface import SurfacePatch + + patch = SurfacePatch.create(distance=3) + guppy_source = generate_guppy_source( + patch, + num_rounds=1, + interaction_basis="szz", + check_plan="szz_current_v1", + trace_metadata=False, + ) + + assert "def pecos_qis_trace_metadata_qubit_hugr(" not in guppy_source + assert "pecos_qis_trace_metadata_qubit_hugr(" not in guppy_source + assert "zz_phase(" in guppy_source + assert "result(" in guppy_source + + def test_szz_runtime_barrier_fences_data_prefix_before_host() -> None: from pecos.guppy.surface import generate_guppy_source from pecos.qec.surface import SurfacePatch From f4dbff6044557425c5b3ff87e9b9ec170c8402c2 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 27 Jun 2026 22:07:46 -0600 Subject: [PATCH 140/150] Use two-qubit runtime barrier for SZZ hosted prefixes --- crates/pecos-hugr-qis/src/compiler.rs | 10 ++++ crates/pecos-qis-ffi/src/ffi.rs | 46 +++++++++++++++++++ docs/development/hosted-operations.md | 15 ++++-- .../quantum-pecos/src/pecos/guppy/surface.py | 14 ++++-- .../tests/guppy/test_hugr_to_llvm_parsing.py | 31 +++++++++++++ .../tests/guppy/test_surface_twirl_render.py | 12 ++--- .../tests/qec/surface/test_check_plan.py | 6 +-- 7 files changed, 117 insertions(+), 17 deletions(-) diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index 4684cabf1..e98cf4ce1 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -70,6 +70,7 @@ const HUGR_SYMBOL_PREFIX: &str = "__hugr__."; const TRACE_METADATA_HUGR_SYMBOL: &str = "pecos_qis_trace_metadata_hugr"; const TRACE_METADATA_QUBIT_HUGR_SYMBOL: &str = "pecos_qis_trace_metadata_qubit_hugr"; const RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL: &str = "pecos_qis_runtime_barrier_qubit_hugr"; +const RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL: &str = "pecos_qis_runtime_barrier_qubits2_hugr"; // Extension registry is defined in the parent module @@ -409,6 +410,7 @@ fn normalize_pecos_helper_symbols_in_llvm(llvm_ir: String) -> String { TRACE_METADATA_HUGR_SYMBOL, TRACE_METADATA_QUBIT_HUGR_SYMBOL, RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL, + RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL, ]; let mut normalized = String::with_capacity(llvm_ir.len()); let mut cursor = 0; @@ -469,6 +471,8 @@ mod tests { "%q2 = call i64 @__hugr__.__main__.pecos_qis_trace_metadata_qubit_hugr.21(i64 %0, i8* %1, i8* %2)\n", "%q3 = call i64 @__hugr__.pecos_qis_runtime_barrier_qubit_hugr.22(i64 %0)\n", "%q4 = call i64 @__hugr__.__main__.pecos_qis_runtime_barrier_qubit_hugr.23(i64 %0)\n", + "%q5 = call { i64, i64 } @__hugr__.pecos_qis_runtime_barrier_qubits2_hugr.24(i64 %0, i64 %1)\n", + "%q6 = call { i64, i64 } @__hugr__.__main__.pecos_qis_runtime_barrier_qubits2_hugr.25(i64 %0, i64 %1)\n", "call void @__hugr__.other_helper.16()\n", ) .to_string(); @@ -490,6 +494,12 @@ mod tests { assert!( normalized.contains("%q4 = call i64 @pecos_qis_runtime_barrier_qubit_hugr(i64 %0)") ); + assert!(normalized.contains( + "%q5 = call { i64, i64 } @pecos_qis_runtime_barrier_qubits2_hugr(i64 %0, i64 %1)" + )); + assert!(normalized.contains( + "%q6 = call { i64, i64 } @pecos_qis_runtime_barrier_qubits2_hugr(i64 %0, i64 %1)" + )); assert!(normalized.contains("@__hugr__.other_helper.16")); } } diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index d2655fcbe..0035c2d63 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -8,6 +8,14 @@ use crate::{Operation, QuantumOp, TraceMetadata, with_interface}; use log::debug; use std::cell::Cell; +/// C ABI return value for helpers that consume and return two qubits. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct QubitPair { + pub first: i64, + pub second: i64, +} + // Thread-local counter to prevent infinite loops in collection mode. // After MAX_COLLECTION_READS, `___read_future_bool` returns true to break out of // loops like "repeat_until_one" (while not result: ... result = measure(q)). @@ -552,6 +560,27 @@ pub unsafe extern "C" fn pecos_qis_runtime_barrier_qubit_hugr(qubit: i64) -> i64 qubit } +/// Insert a runtime scheduling barrier after prior operations touching either qubit. +/// +/// The returned qubit pair gives Guppy/HUGR data dependencies on both inputs. A +/// caller can place this helper immediately before a hosted local pulse so that +/// the local pulse cannot be scheduled before the host qubit is ready. +/// +/// # Safety +/// Called from C/LLVM code. Qubits must be valid non-negative IDs. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pecos_qis_runtime_barrier_qubits2_hugr( + first: i64, + second: i64, +) -> QubitPair { + let _ = i64_to_usize(first); + let _ = i64_to_usize(second); + with_interface(|interface| { + interface.queue_operation(Operation::Barrier); + }); + QubitPair { first, second } +} + /// Attach source/runtime metadata to the next lowerable quantum operation. /// /// This variant uses direct string data pointers instead of the tket2 string @@ -1658,6 +1687,23 @@ mod tests { }); } + #[test] + fn test_runtime_barrier_qubits2_hugr_returns_qubits_and_queues_barrier() { + setup_test(); + let returned = unsafe { pecos_qis_runtime_barrier_qubits2_hugr(17, 23) }; + assert_eq!( + returned, + QubitPair { + first: 17, + second: 23, + }, + ); + + with_interface(|iface| { + assert_eq!(iface.operations, vec![Operation::Barrier]); + }); + } + // --- Measurement and reset tests --- #[test] diff --git a/docs/development/hosted-operations.md b/docs/development/hosted-operations.md index 4f6ebf375..7acf0f0d5 100644 --- a/docs/development/hosted-operations.md +++ b/docs/development/hosted-operations.md @@ -104,11 +104,16 @@ Current state: - A minimal Guppy public `barrier(...)` probe is currently optimized away before PECOS QIS operation collection. The captured raw operation trace contains allocations, gates, measurements, and releases, but no `Barrier` operation. -- The generated surface SZZ/SZZdg path uses a PECOS-owned - `pecos_qis_runtime_barrier_qubit_hugr` helper for `szz_runtime_barriers`. - The helper returns its qubit argument to create a Guppy/HUGR data dependency - and queues a real `Operation::Barrier`, so Selene runtime lowering drains the - current batch before the following host operation. +- PECOS-owned runtime-barrier helpers such as + `pecos_qis_runtime_barrier_qubit_hugr` return their qubit arguments to create + Guppy/HUGR data dependencies and queue a real `Operation::Barrier`, so Selene + runtime lowering drains the current batch before later dependent operations. +- SZZ/SZZdg data-prefix barriers use the two-qubit + `pecos_qis_runtime_barrier_qubits2_hugr` helper. The helper consumes and + returns the host ancilla and data qubit, and the generated source places it + before the hosted data-prefix pulse. This is the dependency needed to prevent + the data-prefix pulse from being scheduled long before the host qubit is + ready. So barrier preservation requires a Guppy/HUGR/QIR/QIS bridge that lowers public barriers or qsystem `RuntimeBarrier` operations into `Operation::Barrier` diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 6789bf8a2..ef0fa9ff1 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -393,6 +393,13 @@ def generate_guppy_source( "@guppy.declare", "def pecos_qis_runtime_barrier_qubit_hugr(q: qubit @ owned) -> qubit: ...", "", + "@guppy.declare", + ( + "def pecos_qis_runtime_barrier_qubits2_hugr(" + "q0: qubit @ owned, q1: qubit @ owned" + ") -> tuple[qubit, qubit]: ..." + ), + "", "", ], ) @@ -855,15 +862,16 @@ def discharge_data_for_szz( if host_label_scope is None else f"szz:{host_label_scope}:{host_label_core}" ) - discharge_data_for_szz(data_q, host_label=host_label) if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_ALL or ( szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX and has_data_prefix ): target.append( - f"{indent}{data_expr(data_q)} = " - f"pecos_qis_runtime_barrier_qubit_hugr({data_expr(data_q)})", + f"{indent}{ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)} = " + "pecos_qis_runtime_barrier_qubits2_hugr(" + f"{ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)})", ) + discharge_data_for_szz(data_q, host_label=host_label) half_turns = "0.5" if sign > 0 else "-0.5" _append_szz_gate_trace_metadata( target, diff --git a/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py b/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py index 111f9fb33..e811012c1 100644 --- a/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py +++ b/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py @@ -87,3 +87,34 @@ def metadata_probe() -> None: assert "@pecos_qis_trace_metadata_qubit_hugr" in llvm_ir assert "@__hugr__.pecos_qis_trace_metadata_qubit_hugr" not in llvm_ir + + +def test_runtime_barrier_pair_helper_uses_public_symbol() -> None: + """Test that two-qubit runtime-barrier helpers compile to the public FFI symbol.""" + try: + from guppylang import guppy + from guppylang.std.builtins import owned + from guppylang.std.quantum import cx, h, measure, qubit + from pecos_rslib import compile_hugr_to_qis + except ImportError as e: + pytest.skip(f"Required imports not available: {e}") + + @guppy.declare + def pecos_qis_runtime_barrier_qubits2_hugr( + q0: qubit @ owned, + q1: qubit @ owned, + ) -> tuple[qubit, qubit]: ... + + @guppy + def barrier_pair_probe() -> tuple[bool, bool]: + q0 = qubit() + q1 = qubit() + h(q0) + q0, q1 = pecos_qis_runtime_barrier_qubits2_hugr(q0, q1) + cx(q0, q1) + return measure(q0), measure(q1) + + llvm_ir = compile_hugr_to_qis(barrier_pair_probe.compile().to_bytes()) + + assert "@pecos_qis_runtime_barrier_qubits2_hugr" in llvm_ir + assert "@__hugr__.pecos_qis_runtime_barrier_qubits2_hugr" not in llvm_ir diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index 0c85e5cd4..97e01d4d0 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -18,8 +18,8 @@ ) from pecos.qec.surface.patch import SurfacePatch -_SZZ_RUNTIME_BARRIER_HELPER = "pecos_qis_runtime_barrier_qubit_hugr(" -_SZZ_RUNTIME_BARRIER_CALL = "= pecos_qis_runtime_barrier_qubit_hugr(" +_SZZ_RUNTIME_BARRIER_HELPER = "pecos_qis_runtime_barrier_qubits2_hugr(" +_SZZ_RUNTIME_BARRIER_CALL = "= pecos_qis_runtime_barrier_qubits2_hugr(" @pytest.fixture @@ -28,7 +28,7 @@ def patch() -> SurfacePatch: def _assert_szz_prefix_barrier_host_order(src: str) -> None: - """Assert a real SZZ data-prefix pulse is fenced before its host.""" + """Assert a real SZZ data-prefix pulse is fenced with its host qubits.""" lines = src.splitlines() for index, line in enumerate(lines): if "phased_x(" not in line: @@ -38,15 +38,15 @@ def _assert_szz_prefix_barrier_host_order(src: str) -> None: for i in range(index - 1, -1, -1) if '"source_kind", "szz_data_prefix"' in lines[i] ) - barrier_index = next(i for i in range(index + 1, len(lines)) if _SZZ_RUNTIME_BARRIER_CALL in lines[i]) + barrier_index = next(i for i in range(prefix_meta - 1, -1, -1) if _SZZ_RUNTIME_BARRIER_CALL in lines[i]) host_meta = next( i - for i in range(barrier_index + 1, len(lines)) + for i in range(index + 1, len(lines)) if '"source_kind", "szz_host"' in lines[i] ) zz_phase_index = next(i for i in range(host_meta + 1, len(lines)) if "zz_phase(" in lines[i]) - assert prefix_meta < index < barrier_index < host_meta < zz_phase_index + assert barrier_index < prefix_meta < index < host_meta < zz_phase_index return msg = "expected an SZZ touch with a hosted phased_x data-prefix pulse" 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 eb110cbf4..bd964421d 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -344,12 +344,12 @@ def test_szz_runtime_barrier_fences_data_prefix_before_host() -> None: szz_runtime_barriers="data-prefix", ) - prefix_index = source.index('"source_kind", "szz_data_prefix"') - barrier_index = source.index("= pecos_qis_runtime_barrier_qubit_hugr(") + barrier_index = source.index("= pecos_qis_runtime_barrier_qubits2_hugr(") + prefix_index = source.index('"source_kind", "szz_data_prefix"', barrier_index) host_index = source.index('"source_kind", "szz_host"', prefix_index) zz_phase_index = source.index("zz_phase(", barrier_index) - assert prefix_index < barrier_index < host_index < zz_phase_index + assert barrier_index < prefix_index < host_index < zz_phase_index def test_szz_data_prefixes_emit_generic_hosted_metadata() -> None: From 5187e7438fd8f61c8b384051ebf5874d05b241a5 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 27 Jun 2026 22:57:47 -0600 Subject: [PATCH 141/150] Use native Selene runtime barriers --- crates/pecos-qis/src/selene_runtime.rs | 45 ++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index 53803a964..931d61b10 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -870,6 +870,47 @@ impl SeleneRuntime { Ok(()) } + fn call_runtime_global_barrier(&self, sleep_time: u64) -> 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()) + })?; + + unsafe { + let Ok(global_barrier_fn) = lib + .get:: i32>( + b"selene_runtime_global_barrier", + ) + else { + return Ok(false); + }; + let errno = global_barrier_fn(instance, sleep_time); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "global_barrier failed with errno {errno}" + ))); + } + } + + Ok(true) + } + + fn lower_runtime_barrier(&mut self) -> Result> { + self.load_plugin()?; + + // Runtime-native barriers keep scheduler-specific ordering decisions + // inside the plugin. Falling back to a drain preserves compatibility + // with older plugins that do not expose Selene barrier symbols. + if self.call_runtime_global_barrier(0)? { + return Ok(Vec::new()); + } + + self.drain_runtime_operations() + } + fn submit_operation_to_runtime( &mut self, op: &Operation, @@ -1710,7 +1751,7 @@ impl QisRuntime for SeleneRuntime { for op in operations { if matches!(op, Operation::Barrier) { - lowered_ops.extend(self.drain_runtime_operations()?); + lowered_ops.extend(self.lower_runtime_barrier()?); } self.submit_operation_to_runtime(op, &mut lowered_ops)?; } @@ -1770,7 +1811,7 @@ impl QisRuntime for SeleneRuntime { ); } Operation::Barrier => { - let emitted_ops = self.drain_runtime_operations()?; + let emitted_ops = self.lower_runtime_barrier()?; Self::push_lowered_ops_with_source_metadata( &mut lowered_ops, emitted_ops, From 124aa4c4ec020f93b41fc8d34aadead53773f760 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 28 Jun 2026 02:59:30 -0600 Subject: [PATCH 142/150] Pack generated SZZ trace metadata --- crates/pecos-qis-ffi/src/ffi.rs | 75 +++++++++++++-- .../quantum-pecos/src/pecos/guppy/surface.py | 38 ++++---- .../tests/guppy/test_surface_twirl_render.py | 8 +- .../tests/qec/surface/test_check_plan.py | 95 ++++++++++++++----- 4 files changed, 167 insertions(+), 49 deletions(-) diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index 0035c2d63..0b53ca3a1 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -34,6 +34,8 @@ fn i64_to_usize(value: i64) -> usize { usize::try_from(value).expect("Invalid ID: value must be non-negative and fit in usize") } +const PACKED_TRACE_METADATA_JSON_KEY: &str = "__pecos_trace_metadata_json_v1__"; + unsafe fn read_tket_string_arg( func_name: &str, arg_name: &str, @@ -414,9 +416,30 @@ pub unsafe extern "C" fn __quantum__rt__record(data: *const std::ffi::c_char) { } } -fn queue_trace_metadata(key: String, value: String, qubit: Option) { - let mut metadata = TraceMetadata::new(); - metadata.insert(key, value); +fn trace_metadata_from_key_value( + func_name: &str, + key: String, + value: String, +) -> Option { + if key != PACKED_TRACE_METADATA_JSON_KEY { + let mut metadata = TraceMetadata::new(); + metadata.insert(key, value); + return Some(metadata); + } + + match serde_json::from_str::(&value) { + Ok(metadata) => Some(metadata), + Err(err) => { + log::error!("{func_name}: invalid packed trace metadata JSON: {err}"); + None + } + } +} + +fn queue_trace_metadata(func_name: &str, key: String, value: String, qubit: Option) { + let Some(metadata) = trace_metadata_from_key_value(func_name, key, value) else { + return; + }; with_interface(|interface| { interface.queue_operation(Operation::TraceMetadata { metadata, qubit }); }); @@ -450,7 +473,7 @@ pub unsafe extern "C" fn pecos_qis_trace_metadata( }) else { return; }; - queue_trace_metadata(key, value, None); + queue_trace_metadata("pecos_qis_trace_metadata", key, value, None); } /// Attach source/runtime metadata to the next lowerable quantum operation. @@ -490,7 +513,7 @@ pub unsafe extern "C" fn pecos_qis_trace_metadata_hugr(key_ptr: *const u8, value }) else { return; }; - queue_trace_metadata(key, value, None); + queue_trace_metadata("pecos_qis_trace_metadata_hugr", key, value, None); } /// Attach source/runtime metadata to the next operation on a specific qubit. @@ -538,7 +561,12 @@ pub unsafe extern "C" fn pecos_qis_trace_metadata_qubit_hugr( }) else { return qubit; }; - queue_trace_metadata(key, value, Some(i64_to_usize(qubit))); + queue_trace_metadata( + "pecos_qis_trace_metadata_qubit_hugr", + key, + value, + Some(i64_to_usize(qubit)), + ); qubit } @@ -612,7 +640,7 @@ pub unsafe extern "C" fn pecos_qis_trace_metadata_direct( }) else { return; }; - queue_trace_metadata(key, value, None); + queue_trace_metadata("pecos_qis_trace_metadata_direct", key, value, None); } // --- Selene-style FFI Functions --- @@ -1676,6 +1704,39 @@ mod tests { }); } + #[test] + fn test_trace_metadata_qubit_hugr_expands_packed_json_metadata() { + setup_test(); + let mut key = Vec::with_capacity(PACKED_TRACE_METADATA_JSON_KEY.len() + 1); + key.push(PACKED_TRACE_METADATA_JSON_KEY.len() as u8); + key.extend_from_slice(PACKED_TRACE_METADATA_JSON_KEY.as_bytes()); + let value = br#"{"host_id":"probe:host","source_kind":"szz_host"}"#; + let mut packed = Vec::with_capacity(value.len() + 1); + packed.push(value.len() as u8); + packed.extend_from_slice(value); + + let returned = + unsafe { pecos_qis_trace_metadata_qubit_hugr(19, key.as_ptr(), packed.as_ptr()) }; + assert_eq!(returned, 19); + + with_interface(|iface| { + assert_eq!(iface.operations.len(), 1); + let Operation::TraceMetadata { metadata, qubit } = &iface.operations[0] else { + panic!("expected trace metadata operation"); + }; + assert_eq!(*qubit, Some(19)); + assert_eq!( + metadata.get("source_kind").map(String::as_str), + Some("szz_host") + ); + assert_eq!( + metadata.get("host_id").map(String::as_str), + Some("probe:host") + ); + assert!(!metadata.contains_key(PACKED_TRACE_METADATA_JSON_KEY)); + }); + } + #[test] fn test_runtime_barrier_qubit_hugr_returns_qubit_and_queues_barrier() { setup_test(); diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index ef0fa9ff1..db4af438a 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -12,6 +12,7 @@ """ import importlib.util +import json import sys import tempfile from collections.abc import Callable @@ -50,6 +51,7 @@ class _ModuleState: _SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX, }, ) +_PACKED_TRACE_METADATA_JSON_KEY = "__pecos_trace_metadata_json_v1__" def _normalize_szz_runtime_barrier_policy(value: bool | str) -> str: @@ -543,17 +545,20 @@ def _szz_touch_compensation_gates(axis: str, sign: int) -> tuple[OpType, ...]: msg = f"unsupported Pauli axis {axis!r}" raise ValueError(msg) - def _append_szz_trace_metadata( + def _append_szz_trace_metadata_payload( target: list[str], indent: str, - key: str, - value: str, + metadata: dict[str, str], qubit_expr: str, ) -> None: - if not trace_metadata: + if not trace_metadata or not metadata: return + payload = json.dumps(metadata, separators=(",", ":"), sort_keys=True) target.append( - f'{indent}{qubit_expr} = pecos_qis_trace_metadata_qubit_hugr({qubit_expr}, "{key}", "{value}")', + f"{indent}{qubit_expr} = pecos_qis_trace_metadata_qubit_hugr(" + f"{qubit_expr}, " + f"{json.dumps(_PACKED_TRACE_METADATA_JSON_KEY)}, " + f"{json.dumps(payload)})", ) def _append_szz_gate_trace_metadata( @@ -568,23 +573,20 @@ def _append_szz_gate_trace_metadata( gate: OpType | None = None, lowering_required: bool = False, ) -> None: - _append_szz_trace_metadata(target, indent, "source_kind", source_kind, qubit_expr) - _append_szz_trace_metadata(target, indent, "source_label", source_label, qubit_expr) + metadata = { + "source_kind": source_kind, + "source_label": source_label, + } if host_label is not None: - _append_szz_trace_metadata(target, indent, "szz_host_label", host_label, qubit_expr) - _append_szz_trace_metadata(target, indent, "host_id", host_label, qubit_expr) + metadata["szz_host_label"] = host_label + metadata["host_id"] = host_label if local_role is not None: - _append_szz_trace_metadata(target, indent, "local_role", local_role, qubit_expr) + metadata["local_role"] = local_role if gate is not None: - _append_szz_trace_metadata(target, indent, "source_gate", gate.name, qubit_expr) + metadata["source_gate"] = gate.name if lowering_required: - _append_szz_trace_metadata( - target, - indent, - "source_lowering_required", - "true", - qubit_expr, - ) + metadata["source_lowering_required"] = "true" + _append_szz_trace_metadata_payload(target, indent, metadata, qubit_expr) def _append_szz_flow_gate( target: list[str], diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index 97e01d4d0..cc52065e9 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -22,6 +22,10 @@ _SZZ_RUNTIME_BARRIER_CALL = "= pecos_qis_runtime_barrier_qubits2_hugr(" +def _line_has_trace_metadata(line: str, key: str, value: str) -> bool: + return f'"{key}", "{value}"' in line or f'\\"{key}\\":\\"{value}\\"' in line + + @pytest.fixture def patch() -> SurfacePatch: return SurfacePatch.create(distance=3) @@ -36,13 +40,13 @@ def _assert_szz_prefix_barrier_host_order(src: str) -> None: prefix_meta = next( i for i in range(index - 1, -1, -1) - if '"source_kind", "szz_data_prefix"' in lines[i] + if _line_has_trace_metadata(lines[i], "source_kind", "szz_data_prefix") ) barrier_index = next(i for i in range(prefix_meta - 1, -1, -1) if _SZZ_RUNTIME_BARRIER_CALL in lines[i]) host_meta = next( i for i in range(index + 1, len(lines)) - if '"source_kind", "szz_host"' in lines[i] + if _line_has_trace_metadata(lines[i], "source_kind", "szz_host") ) zz_phase_index = next(i for i in range(host_meta + 1, len(lines)) if "zz_phase(" in lines[i]) 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 bd964421d..98ffee709 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -5,11 +5,48 @@ from __future__ import annotations +import ast import hashlib +import json from dataclasses import replace import pytest +_PACKED_TRACE_METADATA_JSON_KEY = "__pecos_trace_metadata_json_v1__" + + +def _packed_trace_metadata_records(source: str) -> list[tuple[int, dict[str, str]]]: + records: list[tuple[int, dict[str, str]]] = [] + sentinel = f"{json.dumps(_PACKED_TRACE_METADATA_JSON_KEY)}, " + for line_index, line in enumerate(source.splitlines()): + if "pecos_qis_trace_metadata_qubit_hugr(" not in line or sentinel not in line: + continue + packed_literal = line.split(sentinel, 1)[1].rsplit(")", 1)[0] + records.append((line_index, json.loads(ast.literal_eval(packed_literal)))) + return records + + +def _first_metadata_line( + source: str, + *, + start_line: int = 0, + **expected: str, +) -> tuple[int, dict[str, str]]: + for line_index, metadata in _packed_trace_metadata_records(source): + if line_index < start_line: + continue + if all(metadata.get(key) == value for key, value in expected.items()): + return line_index, metadata + msg = f"could not find packed trace metadata after line {start_line}: {expected!r}" + raise AssertionError(msg) + + +def _has_metadata_prefix(source: str, key: str, prefix: str) -> bool: + return any( + isinstance(metadata.get(key), str) and metadata[key].startswith(prefix) + for _, metadata in _packed_trace_metadata_records(source) + ) + def test_check_plan_default_resolves_to_cx_metadata() -> None: from pecos.qec.surface._check_plan import ( @@ -306,12 +343,14 @@ def test_direct_surface_renderers_accept_check_plan_as_source_of_truth() -> None assert "Check plan: szz_current_v1" in guppy_source assert "def pecos_qis_trace_metadata_qubit_hugr(" in guppy_source assert " = pecos_qis_trace_metadata_qubit_hugr(" in guppy_source - assert '"source_kind", "szz_host"' in guppy_source - assert '"source_kind", "szz_data_prefix"' in guppy_source - assert '"szz_host_label", "szz:' in guppy_source - assert '"host_id", "szz:' in guppy_source - assert '"local_role", "basis_prefix"' in guppy_source - assert '"source_lowering_required", "true"' in guppy_source + assert _PACKED_TRACE_METADATA_JSON_KEY in guppy_source + records = [metadata for _, metadata in _packed_trace_metadata_records(guppy_source)] + assert any(metadata.get("source_kind") == "szz_host" for metadata in records) + assert any(metadata.get("source_kind") == "szz_data_prefix" for metadata in records) + assert any(str(metadata.get("szz_host_label", "")).startswith("szz:") for metadata in records) + assert any(str(metadata.get("host_id", "")).startswith("szz:") for metadata in records) + assert any(metadata.get("local_role") == "basis_prefix" for metadata in records) + assert any(metadata.get("source_lowering_required") == "true" for metadata in records) def test_szz_guppy_source_can_disable_trace_metadata_for_execution() -> None: @@ -344,10 +383,15 @@ def test_szz_runtime_barrier_fences_data_prefix_before_host() -> None: szz_runtime_barriers="data-prefix", ) - barrier_index = source.index("= pecos_qis_runtime_barrier_qubits2_hugr(") - prefix_index = source.index('"source_kind", "szz_data_prefix"', barrier_index) - host_index = source.index('"source_kind", "szz_host"', prefix_index) - zz_phase_index = source.index("zz_phase(", barrier_index) + lines = source.splitlines() + barrier_index = next(i for i, line in enumerate(lines) if "= pecos_qis_runtime_barrier_qubits2_hugr(" in line) + prefix_index, _ = _first_metadata_line( + source, + start_line=barrier_index, + source_kind="szz_data_prefix", + ) + host_index, _ = _first_metadata_line(source, start_line=prefix_index, source_kind="szz_host") + zz_phase_index = next(i for i in range(barrier_index, len(lines)) if "zz_phase(" in lines[i]) assert barrier_index < prefix_index < host_index < zz_phase_index @@ -361,13 +405,20 @@ def test_szz_data_prefixes_emit_generic_hosted_metadata() -> None: check_plan="szz_current_v1", ) - prefix_index = source.index('"source_kind", "szz_data_prefix"') - role_index = source.index('"local_role", "basis_prefix"', prefix_index) - prefix_host_index = source.index('"host_id", "szz:', prefix_index) - host_index = source.index('"source_kind", "szz_host"', prefix_index) - host_id_index = source.index('"host_id", "szz:', host_index) + prefix_index, prefix_metadata = _first_metadata_line( + source, + source_kind="szz_data_prefix", + ) + host_index, host_metadata = _first_metadata_line( + source, + start_line=prefix_index + 1, + source_kind="szz_host", + ) - assert prefix_index < prefix_host_index < role_index < host_index < host_id_index + assert prefix_metadata["local_role"] == "basis_prefix" + assert prefix_metadata["host_id"].startswith("szz:") + assert host_metadata["host_id"].startswith("szz:") + assert prefix_index < host_index def test_szz_hosted_metadata_labels_include_helper_scope() -> None: @@ -379,9 +430,9 @@ def test_szz_hosted_metadata_labels_include_helper_scope() -> None: check_plan="szz_current_v1", ) - assert '"host_id", "szz:init_z_basis:' in source - assert '"host_id", "szz:init_x_basis:' in source - assert '"host_id", "szz:syndrome_extraction:' in source + assert _has_metadata_prefix(source, "host_id", "szz:init_z_basis:") + assert _has_metadata_prefix(source, "host_id", "szz:init_x_basis:") + assert _has_metadata_prefix(source, "host_id", "szz:syndrome_extraction:") def test_plain_szz_memory_source_unrolls_hosted_metadata_by_counted_round() -> None: @@ -398,9 +449,9 @@ def test_plain_szz_memory_source_unrolls_hosted_metadata_by_counted_round() -> N assert "if num_rounds != 2:" in source assert "def syndrome_extraction_memory_r0" in source assert "def syndrome_extraction_memory_r1" in source - assert '"host_id", "szz:memory_r0:' in source - assert '"host_id", "szz:memory_r1:' in source - assert '"host_id", "szz:memory_r2:' not in source + assert _has_metadata_prefix(source, "host_id", "szz:memory_r0:") + assert _has_metadata_prefix(source, "host_id", "szz:memory_r1:") + assert not _has_metadata_prefix(source, "host_id", "szz:memory_r2:") def test_plain_szz_memory_cache_key_includes_counted_rounds() -> None: From 11c6fd257f4819b94f2db1efc7a82a552e8fe910 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 29 Jun 2026 20:59:44 -0600 Subject: [PATCH 143/150] Pin balanced batch-order touch-gap optimum --- .../qec/surface/test_ancilla_batching.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py index aa69079cc..983d48ed0 100644 --- a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py +++ b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py @@ -16,6 +16,8 @@ from __future__ import annotations +from itertools import permutations + import pytest from pecos.qec.surface import SurfacePatch from pecos.qec.surface._ancilla_batching import ( @@ -223,6 +225,56 @@ def test_balanced_data_schedule_spreads_d9_a17_batches() -> None: assert min(len(batch_indices) for batch_indices in touches_by_data.values()) > 1 +def test_balanced_data_d9_a17_batch_order_is_touch_gap_optimal() -> None: + """Batch reordering alone does not improve the d=9/a17 touch-gap proxy. + + This pins the result of the first idle-hotspot scheduler probe: the next + candidate should target lower-level touch/layer placement, not just + permuting the already-balanced ancilla batches. + """ + from pecos.qec.surface.schedule import compute_cnot_schedule + + patch = SurfacePatch.create(distance=9) + balanced_batches = batched_stabilizers( + patch, + 17, + ancilla_schedule=BALANCED_DATA_ANCILLA_SCHEDULE, + ) + assert [len(batch) for batch in balanced_batches] == [16, 16, 16, 16, 16] + + def touch_gap_metrics(batches: list[list[tuple[str, int]]]) -> tuple[int, int]: + events_by_data: dict[int, list[int]] = {} + time = 0 + for batch in batches: + batch_keys = set(batch) + for round_gates in compute_cnot_schedule(patch): + for stab_type, stab_idx, data_qubit in round_gates: + if (stab_type, stab_idx) in batch_keys: + events_by_data.setdefault(data_qubit, []).append(time) + time += 1 + period = time + worst_gap = 0 + squared_gap_sum = 0 + for touch_times in events_by_data.values(): + touch_times.sort() + gaps = [ + touch_times[index + 1] - touch_times[index] + for index in range(len(touch_times) - 1) + ] + gaps.append(period + touch_times[0] - touch_times[-1]) + worst_gap = max(worst_gap, *gaps) + squared_gap_sum += sum(gap * gap for gap in gaps) + return worst_gap, squared_gap_sum + + baseline_metrics = touch_gap_metrics(balanced_batches) + best_permutation_metrics = min( + touch_gap_metrics([balanced_batches[index] for index in permutation]) + for permutation in permutations(range(len(balanced_batches))) + ) + + assert baseline_metrics == best_permutation_metrics + + # --- D1: pin emitted CX sequences for the constrained Guppy codegen -------- # The byte-identical traced-vs-traced DEM oracle and the lowered-qubit-stream # invariant catch many constrained-codegen errors, but not a wrong-CX-order / From 1554207614f7db94d42466d20eea33833dd4c3b9 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 29 Jun 2026 21:08:12 -0600 Subject: [PATCH 144/150] Pin balanced round-order touch-gap tradeoffs --- .../qec/surface/test_ancilla_batching.py | 101 +++++++++++++----- 1 file changed, 73 insertions(+), 28 deletions(-) diff --git a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py index 983d48ed0..c618256dd 100644 --- a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py +++ b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py @@ -26,6 +26,42 @@ normalize_ancilla_budget, normalize_ancilla_schedule, ) +from pecos.qec.surface.schedule import compute_cnot_schedule + +StabilizerKey = tuple[str, int] +TouchGapMetrics = tuple[int, int] + + +def _touch_gap_metrics_for_batches( + patch: SurfacePatch, + batches: list[list[StabilizerKey]], + *, + round_order: tuple[int, int, int, int] = (0, 1, 2, 3), +) -> TouchGapMetrics: + cnot_rounds = compute_cnot_schedule(patch) + ordered_rounds = [cnot_rounds[index] for index in round_order] + events_by_data: dict[int, list[int]] = {} + time = 0 + for batch in batches: + batch_keys = set(batch) + for round_gates in ordered_rounds: + for stab_type, stab_idx, data_qubit in round_gates: + if (stab_type, stab_idx) in batch_keys: + events_by_data.setdefault(data_qubit, []).append(time) + time += 1 + period = time + worst_gap = 0 + squared_gap_sum = 0 + for touch_times in events_by_data.values(): + touch_times.sort() + gaps = [ + touch_times[index + 1] - touch_times[index] + for index in range(len(touch_times) - 1) + ] + gaps.append(period + touch_times[0] - touch_times[-1]) + worst_gap = max(worst_gap, *gaps) + squared_gap_sum += sum(gap * gap for gap in gaps) + return worst_gap, squared_gap_sum # --- normalize_ancilla_budget ----------------------------------------------- @@ -232,8 +268,6 @@ def test_balanced_data_d9_a17_batch_order_is_touch_gap_optimal() -> None: candidate should target lower-level touch/layer placement, not just permuting the already-balanced ancilla batches. """ - from pecos.qec.surface.schedule import compute_cnot_schedule - patch = SurfacePatch.create(distance=9) balanced_batches = batched_stabilizers( patch, @@ -242,39 +276,50 @@ def test_balanced_data_d9_a17_batch_order_is_touch_gap_optimal() -> None: ) assert [len(batch) for batch in balanced_batches] == [16, 16, 16, 16, 16] - def touch_gap_metrics(batches: list[list[tuple[str, int]]]) -> tuple[int, int]: - events_by_data: dict[int, list[int]] = {} - time = 0 - for batch in batches: - batch_keys = set(batch) - for round_gates in compute_cnot_schedule(patch): - for stab_type, stab_idx, data_qubit in round_gates: - if (stab_type, stab_idx) in batch_keys: - events_by_data.setdefault(data_qubit, []).append(time) - time += 1 - period = time - worst_gap = 0 - squared_gap_sum = 0 - for touch_times in events_by_data.values(): - touch_times.sort() - gaps = [ - touch_times[index + 1] - touch_times[index] - for index in range(len(touch_times) - 1) - ] - gaps.append(period + touch_times[0] - touch_times[-1]) - worst_gap = max(worst_gap, *gaps) - squared_gap_sum += sum(gap * gap for gap in gaps) - return worst_gap, squared_gap_sum - - baseline_metrics = touch_gap_metrics(balanced_batches) + baseline_metrics = _touch_gap_metrics_for_batches(patch, balanced_batches) best_permutation_metrics = min( - touch_gap_metrics([balanced_batches[index] for index in permutation]) + _touch_gap_metrics_for_batches( + patch, + [balanced_batches[index] for index in permutation], + ) for permutation in permutations(range(len(balanced_batches))) ) assert baseline_metrics == best_permutation_metrics +def test_balanced_data_d9_a17_round_order_touch_gap_tradeoff() -> None: + """Round-order changes expose a real but correctness-gated tradeoff. + + The best max-gap round permutation improves the repeated-round data-touch + gap proxy, but increases the squared-gap proxy. Promoting such a candidate + needs a named check-plan schedule and hook/residual correctness checks, not + an ad hoc source-order edit. + """ + patch = SurfacePatch.create(distance=9) + balanced_batches = batched_stabilizers( + patch, + 17, + ancilla_schedule=BALANCED_DATA_ANCILLA_SCHEDULE, + ) + baseline_metrics = _touch_gap_metrics_for_batches(patch, balanced_batches) + + round_candidates = [ + (_touch_gap_metrics_for_batches(patch, balanced_batches, round_order=round_order), round_order) + for round_order in permutations(range(4)) + ] + best_max_gap = min(round_candidates) + best_sumsq_with_baseline_max_gap = min( + candidate + for candidate in round_candidates + if candidate[0][0] == baseline_metrics[0] + ) + + assert baseline_metrics == (14, 11292) + assert best_max_gap == ((13, 11464), (3, 1, 0, 2)) + assert best_sumsq_with_baseline_max_gap == ((14, 11220), (1, 0, 3, 2)) + + # --- D1: pin emitted CX sequences for the constrained Guppy codegen -------- # The byte-identical traced-vs-traced DEM oracle and the lowered-qubit-stream # invariant catch many constrained-codegen errors, but not a wrong-CX-order / From 67ce2d71de2cded523afff16f6b83b633f9256a3 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 29 Jun 2026 21:32:54 -0600 Subject: [PATCH 145/150] Add SZZ round-order check plan --- .../quantum-pecos/src/pecos/guppy/surface.py | 4 +- .../src/pecos/qec/surface/_check_plan.py | 49 +++++++++++++++++++ .../src/pecos/qec/surface/circuit_builder.py | 4 +- .../src/pecos/qec/surface/schedule.py | 48 +++++++++++++++++- .../qec/surface/test_ancilla_batching.py | 27 +++++++++- .../tests/qec/surface/test_check_plan.py | 30 ++++++++++++ 6 files changed, 156 insertions(+), 6 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index db4af438a..7b6fb32a2 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -255,6 +255,7 @@ def generate_guppy_source( from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget from pecos.qec.surface._check_plan import ( ancilla_schedule_for_check_plan, + cnot_round_order_for_check_plan, require_current_surface_check_plan_renderer, ) from pecos.qec.surface.circuit_builder import ( @@ -278,6 +279,7 @@ def generate_guppy_source( context="Guppy surface-code source generation", ) ancilla_schedule = ancilla_schedule_for_check_plan(resolved_plan) + cnot_round_order = cnot_round_order_for_check_plan(resolved_plan) interaction_basis = resolved_plan.interaction_basis szz_runtime_barrier_policy = _normalize_szz_runtime_barrier_policy(szz_runtime_barriers) if ( @@ -786,7 +788,7 @@ def _append_szz_logical_pauli(target: list[str], indent: str, axis: str, qubit_e lines.extend(["", ""]) # Generate syndrome extraction with the selected parallel interaction schedule. - rounds = compute_cnot_schedule(patch) + rounds = compute_cnot_schedule(patch, round_order=cnot_round_order) szz_sign_by_touch: dict[tuple[str, int, int], int] = {} if interaction_basis == "szz": residual_plan = _szz_residual_plan_for_check_plan(patch, resolved_plan) diff --git a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py index 7cd4b355a..ffa5b3177 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py @@ -15,6 +15,11 @@ DEFAULT_ANCILLA_SCHEDULE, normalize_ancilla_schedule, ) +from pecos.qec.surface.schedule import ( + CNOT_ROUND_ORDER_3102, + DEFAULT_CNOT_ROUND_ORDER, + normalize_cnot_round_order, +) CHECK_PLAN_METADATA_FORMAT = "pecos.surface.check_plan" CHECK_PLAN_METADATA_VERSION = 1 @@ -179,6 +184,36 @@ def _normalize_check_plan_id(check_plan: str) -> str: }, "prefix_policy": "forward_flow_virtual_z_v1", }, + "szz_balanced_data_round_order_3102_v1": { + "plan_id": "szz_balanced_data_round_order_3102_v1", + "interaction_basis": "szz", + "synthesis_identity": { + "family": "szz", + "szz_phase_pattern": "standard", + "interaction_order": "pecos-default", + "ancilla_schedule": "balanced-data-v1", + }, + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + "ancilla_batch_policy": "balanced-data-v1", + "round_order": CNOT_ROUND_ORDER_3102, + }, + "x_check": { + "template": "current_szz_x_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "z_check": { + "template": "current_szz_z_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "prefix_policy": "forward_flow_virtual_z_v1", + }, "szz_boundary_first_balanced_data_v1": { "plan_id": "szz_boundary_first_balanced_data_v1", "interaction_basis": "szz", @@ -301,6 +336,17 @@ def ancilla_schedule_for_check_plan(resolved_plan: ResolvedSurfaceCheckPlan) -> ) +def cnot_round_order_for_check_plan(resolved_plan: ResolvedSurfaceCheckPlan) -> str: + """Return the concrete windmill round-order policy encoded by a check plan.""" + schedule = resolved_plan.semantic_content.get("schedule", {}) + if not isinstance(schedule, dict): + msg = f"check_plan={resolved_plan.plan_id!r} has invalid schedule metadata" + raise TypeError(msg) + raw_round_order = str(schedule.get("round_order", DEFAULT_CNOT_ROUND_ORDER)) + normalize_cnot_round_order(raw_round_order) + return raw_round_order.lower().replace("_", "-") + + def require_current_surface_check_plan_renderer( resolved_plan: ResolvedSurfaceCheckPlan, *, @@ -317,6 +363,7 @@ def require_current_surface_check_plan_renderer( synthesis = resolved_plan.synthesis_identity schedule = semantic.get("schedule", {}) ancilla_schedule = ancilla_schedule_for_check_plan(resolved_plan) + round_order = cnot_round_order_for_check_plan(resolved_plan) if resolved_plan.interaction_basis == "cx": expected_synthesis = { @@ -351,6 +398,8 @@ def require_current_surface_check_plan_renderer( } if ancilla_schedule != DEFAULT_ANCILLA_SCHEDULE: expected_schedule["ancilla_batch_policy"] = ancilla_schedule + if round_order != DEFAULT_CNOT_ROUND_ORDER: + expected_schedule["round_order"] = CNOT_ROUND_ORDER_3102 if synthesis != expected_synthesis or schedule != expected_schedule: msg = ( f"{context} cannot realize check_plan={resolved_plan.plan_id!r} " diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index fc3df1622..27e375977 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -37,6 +37,7 @@ ) from pecos.qec.surface._check_plan import ( ancilla_schedule_for_check_plan, + cnot_round_order_for_check_plan, require_current_surface_check_plan_renderer, resolve_surface_check_plan, ) @@ -939,6 +940,7 @@ def build_surface_code_circuit( context="abstract surface-code circuit generation", ) ancilla_schedule = ancilla_schedule_for_check_plan(resolved_plan) + cnot_round_order = cnot_round_order_for_check_plan(resolved_plan) interaction_basis = _normalize_interaction_basis(resolved_plan.interaction_basis) resolved_clifford_frame = _resolve_szz_clifford_frame_for_builder( patch, @@ -1035,7 +1037,7 @@ def emit_gate_local_twirl_layer( gate_twirl_site_idx += 1 # Get CNOT schedule - cnot_rounds = compute_cnot_schedule(patch) + cnot_rounds = compute_cnot_schedule(patch, round_order=cnot_round_order) if interaction_basis == "szz": if twirl is not None: diff --git a/python/quantum-pecos/src/pecos/qec/surface/schedule.py b/python/quantum-pecos/src/pecos/qec/surface/schedule.py index 8972fc934..1ba9ff9bc 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/schedule.py +++ b/python/quantum-pecos/src/pecos/qec/surface/schedule.py @@ -28,8 +28,45 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import Sequence + from pecos.qec.surface.patch import SurfacePatch +DEFAULT_CNOT_ROUND_ORDER = "default" +CNOT_ROUND_ORDER_3102 = "round-order-3102-v1" +SUPPORTED_CNOT_ROUND_ORDERS = frozenset( + { + DEFAULT_CNOT_ROUND_ORDER, + CNOT_ROUND_ORDER_3102, + }, +) + + +def normalize_cnot_round_order( + round_order: str | Sequence[int] | None = None, +) -> tuple[int, int, int, int]: + """Return a validated 4-layer windmill round order.""" + if round_order is None: + return (0, 1, 2, 3) + if isinstance(round_order, str): + normalized = round_order.lower().replace("_", "-") + if normalized == DEFAULT_CNOT_ROUND_ORDER: + return (0, 1, 2, 3) + if normalized == CNOT_ROUND_ORDER_3102: + return (3, 1, 0, 2) + msg = ( + f"round_order must be one of {sorted(SUPPORTED_CNOT_ROUND_ORDERS)} " + f"or a permutation of (0, 1, 2, 3), got {round_order!r}" + ) + raise ValueError(msg) + if tuple(round_order) not in { + (0, 1, 2, 3), + (3, 1, 0, 2), + }: + msg = f"round_order must be a supported permutation of (0, 1, 2, 3), got {round_order!r}" + raise ValueError(msg) + return tuple(round_order) + def _classify_boundary(stab_type: str, data_qubits: tuple[int, ...], dx: int, dz: int) -> str: """Classify which boundary a weight-2 stabilizer sits on. @@ -107,11 +144,18 @@ def get_stab_schedule( raise ValueError(msg) -def compute_cnot_schedule(patch: SurfacePatch) -> list[list[tuple[str, int, int]]]: +def compute_cnot_schedule( + patch: SurfacePatch, + *, + round_order: str | Sequence[int] | None = None, +) -> list[list[tuple[str, int, int]]]: """Compute the 4-round parallel CNOT schedule for a surface code patch. Args: patch: A SurfacePatch instance. + round_order: Optional named or explicit permutation of the four windmill + touch layers. ``None`` and ``"default"`` preserve the historical + order ``(0, 1, 2, 3)``. Returns: List of 4 rounds, each a list of (stab_type, stab_index, data_qubit) @@ -136,4 +180,4 @@ def compute_cnot_schedule(patch: SurfacePatch) -> list[list[tuple[str, int, int] for rnd in rounds: rnd.sort(key=lambda g: (g[1], 0 if g[0] == "X" else 1)) - return rounds + return [rounds[index] for index in normalize_cnot_round_order(round_order)] diff --git a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py index c618256dd..30b4c4163 100644 --- a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py +++ b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py @@ -26,7 +26,11 @@ normalize_ancilla_budget, normalize_ancilla_schedule, ) -from pecos.qec.surface.schedule import compute_cnot_schedule +from pecos.qec.surface.schedule import ( + CNOT_ROUND_ORDER_3102, + compute_cnot_schedule, + normalize_cnot_round_order, +) StabilizerKey = tuple[str, int] TouchGapMetrics = tuple[int, int] @@ -261,6 +265,25 @@ def test_balanced_data_schedule_spreads_d9_a17_batches() -> None: assert min(len(batch_indices) for batch_indices in touches_by_data.values()) > 1 +def test_cnot_round_order_3102_permuted_layers() -> None: + """The named round-order candidate preserves layer contents exactly.""" + patch = SurfacePatch.create(distance=5) + default_rounds = compute_cnot_schedule(patch) + + assert normalize_cnot_round_order(None) == (0, 1, 2, 3) + assert normalize_cnot_round_order("default") == (0, 1, 2, 3) + assert normalize_cnot_round_order(CNOT_ROUND_ORDER_3102) == (3, 1, 0, 2) + assert normalize_cnot_round_order((3, 1, 0, 2)) == (3, 1, 0, 2) + assert compute_cnot_schedule(patch, round_order=CNOT_ROUND_ORDER_3102) == [ + default_rounds[3], + default_rounds[1], + default_rounds[0], + default_rounds[2], + ] + with pytest.raises(ValueError, match=r"round_order"): + normalize_cnot_round_order((0, 1, 3, 2)) + + def test_balanced_data_d9_a17_batch_order_is_touch_gap_optimal() -> None: """Batch reordering alone does not improve the d=9/a17 touch-gap proxy. @@ -316,7 +339,7 @@ def test_balanced_data_d9_a17_round_order_touch_gap_tradeoff() -> None: ) assert baseline_metrics == (14, 11292) - assert best_max_gap == ((13, 11464), (3, 1, 0, 2)) + assert best_max_gap == ((13, 11464), normalize_cnot_round_order(CNOT_ROUND_ORDER_3102)) assert best_sumsq_with_baseline_max_gap == ((14, 11220), (1, 0, 3, 2)) 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 98ffee709..57a6a24fd 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -61,6 +61,7 @@ def test_check_plan_default_resolves_to_cx_metadata() -> None: assert surface_check_plan_ids() == ( "cx_balanced_data_v1", "cx_standard_v1", + "szz_balanced_data_round_order_3102_v1", "szz_balanced_data_v1", "szz_boundary_first_balanced_data_v1", "szz_boundary_first_v1", @@ -150,6 +151,34 @@ def test_balanced_data_check_plans_resolve_to_explicit_schedule( require_current_surface_check_plan_renderer(plan, context="unit-test") +def test_round_order_check_plan_resolves_to_explicit_schedule() -> None: + from pecos.qec.surface._check_plan import ( + cnot_round_order_for_check_plan, + require_current_surface_check_plan_renderer, + resolve_surface_check_plan, + ) + from pecos.qec.surface.schedule import CNOT_ROUND_ORDER_3102 + + plan = resolve_surface_check_plan(check_plan="szz_balanced_data_round_order_3102_v1") + + assert plan.interaction_basis == "szz" + assert plan.synthesis_identity == { + "family": "szz", + "szz_phase_pattern": "standard", + "interaction_order": "pecos-default", + "ancilla_schedule": "balanced-data-v1", + } + assert plan.semantic_content["schedule"] == { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + "ancilla_batch_policy": "balanced-data-v1", + "round_order": CNOT_ROUND_ORDER_3102, + } + assert cnot_round_order_for_check_plan(plan) == CNOT_ROUND_ORDER_3102 + require_current_surface_check_plan_renderer(plan, context="unit-test") + + def test_current_renderer_rejects_unimplemented_plan_semantics() -> None: from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan @@ -224,6 +253,7 @@ def test_guppy_surface_code_accepts_check_plan_as_source_of_truth() -> None: [ "cx_balanced_data_v1", "szz_balanced_data_v1", + "szz_balanced_data_round_order_3102_v1", "szz_boundary_first_balanced_data_v1", ], ) From cf8fe472761bf8ad9e7fa0a295c3f37a5d139c15 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 29 Jun 2026 22:21:30 -0600 Subject: [PATCH 146/150] Gate round-order renderer metadata --- .../tests/qec/surface/test_check_plan.py | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) 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 57a6a24fd..8f461ed3a 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -565,6 +565,82 @@ def szz_gate_signature(ops: object) -> list[tuple[str, tuple[int, ...], str]]: assert "Check plan: szz_boundary_first_v1" in source +def test_round_order_szz_check_plan_changes_host_order_not_metadata() -> None: + from pecos.guppy.surface import generate_guppy_source + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.circuit_builder import ( + OpType, + build_surface_code_circuit, + generate_tick_circuit_from_patch, + ) + + patch = SurfacePatch.create(distance=3) + baseline_plan = "szz_balanced_data_v1" + round_order_plan = "szz_balanced_data_round_order_3102_v1" + + def szz_gate_signature(plan_id: str) -> list[tuple[str, tuple[int, ...], str]]: + ops, _ = build_surface_code_circuit( + patch, + num_rounds=1, + ancilla_budget=2, + check_plan=plan_id, + ) + return [ + (op.label, tuple(op.qubits), op.op_type.name) + for op in ops + if op.op_type in {OpType.SZZ, OpType.SZZDG} + ] + + baseline_signature = szz_gate_signature(baseline_plan) + round_order_signature = szz_gate_signature(round_order_plan) + assert round_order_signature != baseline_signature + assert round_order_signature[:4] == [ + ("X0", (9, 0), "SZZ"), + ("X0", (9, 1), "SZZDG"), + ("X3", (9, 7), "SZZ"), + ("X3", (9, 8), "SZZDG"), + ] + + baseline_tick = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + ancilla_budget=2, + check_plan=baseline_plan, + ) + round_order_tick = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + ancilla_budget=2, + check_plan=round_order_plan, + ) + assert round_order_tick.get_meta("detectors") == baseline_tick.get_meta("detectors") + assert round_order_tick.get_meta("observables") == baseline_tick.get_meta("observables") + + def guppy_syndrome_host_ids(plan_id: str) -> list[str]: + source = generate_guppy_source( + patch, + num_rounds=1, + ancilla_budget=2, + check_plan=plan_id, + ) + return [ + str(metadata["host_id"]) + for _, metadata in _packed_trace_metadata_records(source) + if metadata.get("source_kind") == "szz_host" + and str(metadata.get("host_id", "")).startswith("szz:syndrome_extraction:") + ] + + baseline_hosts = guppy_syndrome_host_ids(baseline_plan) + round_order_hosts = guppy_syndrome_host_ids(round_order_plan) + assert round_order_hosts != baseline_hosts + assert round_order_hosts[:4] == [ + "szz:syndrome_extraction:r1:X0:d0:SZZ", + "szz:syndrome_extraction:r1:Z3:d5:SZZDG", + "szz:syndrome_extraction:r4:X0:d1:SZZDG", + "szz:syndrome_extraction:r4:Z3:d2:SZZ", + ] + + def test_direct_surface_renderers_reject_plan_basis_mismatch() -> None: from pecos.qec.surface import SurfacePatch from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch From dbc967c010dc74fd9d2f1f33734a189794f1e46b Mon Sep 17 00:00:00 2001 From: ciaranra Date: Tue, 30 Jun 2026 09:16:21 -0600 Subject: [PATCH 147/150] Gate round-order SZZ DEM equivalence --- .../qec/surface/test_szz_interaction_basis.py | 51 +++++++++++++++++++ .../tests/qec/test_from_guppy_dem.py | 33 ++++++++++-- 2 files changed, 80 insertions(+), 4 deletions(-) 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 e96fcc4b1..b2ae92343 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 @@ -852,6 +852,57 @@ def test_szz_traced_qis_native_dem_matches_stim_for_p1(basis: str) -> None: ) +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_round_order_szz_traced_qis_native_dem_matches_stim_for_p1(basis: str) -> None: + from pecos.qec.surface.circuit_builder import normalize_traced_qis_tick_circuit + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + + patch = SurfacePatch.create(distance=3) + tick_circuit = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=1, + basis=basis, + ancilla_budget=2, + circuit_source="traced_qis", + check_plan="szz_balanced_data_round_order_3102_v1", + ) + normalize_traced_qis_tick_circuit(tick_circuit, context="round-order SZZ traced-QIS p1 test") + noise_args = { + "p1": 0.001, + "p2": 0.0, + "p_meas": 0.0, + "p_prep": 0.0, + } + + native_errors = _raw_dem_errors( + generate_dem_from_tick_circuit( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + stim_errors = _raw_dem_errors( + generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + + assert set(native_errors) == set(stim_errors) + for target, native_probability in native_errors.items(): + stim_probability = stim_errors[target] + rel_diff = abs(native_probability - stim_probability) / max( + native_probability, + stim_probability, + 1e-12, + ) + assert rel_diff < 0.005, ( + f"{basis} round-order traced-QIS SZZ p1 DEM mismatch for {target}: " + f"PECOS={native_probability:.8f}, Stim={stim_probability:.8f}" + ) + + def test_szz_public_native_dem_accepts_traced_qis_p1() -> None: patch = SurfacePatch.create(distance=3) dem = generate_circuit_level_dem_from_builder( 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 cb0abfdd8..13fddda06 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -766,7 +766,7 @@ def test_from_guppy_redundant_records_and_meas_ids_are_accepted() -> None: # --------------------------------------------------------------------------- -def _constrained_surface_via_guppy(*, d, basis, rounds, budget, noise): +def _constrained_surface_via_guppy(*, d, basis, rounds, budget, noise, check_plan: str | None = None): """Build the constrained-surface DEM through `from_guppy`.""" patch = SurfacePatch.create(distance=d) ref = _build_surface_tick_circuit_for_native_model( @@ -775,13 +775,20 @@ def _constrained_surface_via_guppy(*, d, basis, rounds, budget, noise): basis=basis, ancilla_budget=budget, circuit_source="traced_qis", + check_plan=check_plan, ) normalize_traced_qis_tick_circuit(ref, context="from_guppy constrained surface reference") ref_dem = DetectorErrorModel.from_circuit(ref, **noise).to_string() got = DetectorErrorModel.from_guppy( - make_surface_code(distance=d, num_rounds=rounds, basis=basis, ancilla_budget=budget), - num_qubits=get_num_qubits(d, ancilla_budget=budget), + make_surface_code( + distance=d, + num_rounds=rounds, + basis=basis, + ancilla_budget=budget, + check_plan=check_plan, + ), + num_qubits=get_num_qubits(d, ancilla_budget=budget, check_plan=check_plan), detectors_json=ref.get_meta("detectors"), observables_json=ref.get_meta("observables"), num_measurements=int(ref.get_meta("num_measurements")), @@ -822,7 +829,23 @@ def test_from_guppy_constrained_surface_dem_byte_identical( ) -def test_constrained_surface_traced_metadata_matches_abstract() -> None: +def test_from_guppy_round_order_szz_surface_dem_byte_identical() -> None: + """Round-order SZZ check plans must stay byte-identical through from_guppy.""" + noise = {"p1": 0.0, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} + ref_dem, got, _ = _constrained_surface_via_guppy( + d=3, + basis="X", + rounds=2, + budget=2, + noise=noise, + check_plan="szz_balanced_data_round_order_3102_v1", + ) + + assert got == ref_dem + + +@pytest.mark.parametrize("check_plan", [None, "szz_balanced_data_round_order_3102_v1"]) +def test_constrained_surface_traced_metadata_matches_abstract(check_plan: str | None) -> None: """Traced surface metadata preserves structure but binds via MeasIds. Runtime traces may reorder measurements, so detector/observable metadata @@ -837,6 +860,7 @@ def test_constrained_surface_traced_metadata_matches_abstract() -> None: basis="Z", ancilla_budget=2, circuit_source="abstract", + check_plan=check_plan, ) traced_tc = _build_surface_tick_circuit_for_native_model( patch, @@ -844,6 +868,7 @@ def test_constrained_surface_traced_metadata_matches_abstract() -> None: basis="Z", ancilla_budget=2, circuit_source="traced_qis", + check_plan=check_plan, ) for key in ( "basis", From f5c902c4e8510bc0e972707907a8aaf8daa2c956 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Tue, 30 Jun 2026 10:51:02 -0600 Subject: [PATCH 148/150] Gate round-order noiseless SZZ records --- .../qec/surface/test_szz_interaction_basis.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) 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 b2ae92343..3f2d97863 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 @@ -522,6 +522,53 @@ def test_szz_noiseless_detector_record_equivalence(distance: int, basis: str) -> _assert_noiseless_record_metadata_is_zero(szz_text, szz_tick) +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_round_order_szz_noiseless_detector_record_equivalence(basis: str) -> None: + patch = SurfacePatch.create(distance=3) + baseline_plan = "szz_balanced_data_v1" + round_order_plan = "szz_balanced_data_round_order_3102_v1" + + baseline_text = generate_stim_from_patch( + patch, + num_rounds=3, + basis=basis, + ancilla_budget=2, + check_plan=baseline_plan, + ) + round_order_text = generate_stim_from_patch( + patch, + num_rounds=3, + basis=basis, + ancilla_budget=2, + check_plan=round_order_plan, + ) + baseline_tick = generate_tick_circuit_from_patch( + patch, + num_rounds=3, + basis=basis, + ancilla_budget=2, + check_plan=baseline_plan, + ) + round_order_tick = generate_tick_circuit_from_patch( + patch, + num_rounds=3, + basis=basis, + ancilla_budget=2, + check_plan=round_order_plan, + ) + + baseline_circuit = stim.Circuit(baseline_text) + round_order_circuit = stim.Circuit(round_order_text) + assert round_order_text != baseline_text + assert round_order_circuit.num_measurements == baseline_circuit.num_measurements + assert round_order_circuit.num_detectors == baseline_circuit.num_detectors + assert round_order_circuit.num_observables == baseline_circuit.num_observables + assert round_order_tick.get_meta("detectors") == baseline_tick.get_meta("detectors") + assert round_order_tick.get_meta("observables") == baseline_tick.get_meta("observables") + + _assert_noiseless_record_metadata_is_zero(round_order_text, round_order_tick) + + 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) From e71a914cdde5f769e3e79ea790fae56529648822 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 1 Jul 2026 14:22:43 -0600 Subject: [PATCH 149/150] Add 1032 round-order surface schedule --- .../src/pecos/qec/surface/_check_plan.py | 33 +++++++- .../src/pecos/qec/surface/schedule.py | 5 ++ .../qec/surface/test_ancilla_batching.py | 18 ++++- .../tests/qec/surface/test_check_plan.py | 80 +++++++++++++------ .../qec/surface/test_szz_interaction_basis.py | 31 +++++-- .../tests/qec/test_from_guppy_dem.py | 20 ++++- 6 files changed, 148 insertions(+), 39 deletions(-) diff --git a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py index ffa5b3177..1b25d6af3 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py @@ -16,6 +16,7 @@ normalize_ancilla_schedule, ) from pecos.qec.surface.schedule import ( + CNOT_ROUND_ORDER_1032, CNOT_ROUND_ORDER_3102, DEFAULT_CNOT_ROUND_ORDER, normalize_cnot_round_order, @@ -214,6 +215,36 @@ def _normalize_check_plan_id(check_plan: str) -> str: }, "prefix_policy": "forward_flow_virtual_z_v1", }, + "szz_balanced_data_round_order_1032_v1": { + "plan_id": "szz_balanced_data_round_order_1032_v1", + "interaction_basis": "szz", + "synthesis_identity": { + "family": "szz", + "szz_phase_pattern": "standard", + "interaction_order": "pecos-default", + "ancilla_schedule": "balanced-data-v1", + }, + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + "ancilla_batch_policy": "balanced-data-v1", + "round_order": CNOT_ROUND_ORDER_1032, + }, + "x_check": { + "template": "current_szz_x_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "z_check": { + "template": "current_szz_z_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "prefix_policy": "forward_flow_virtual_z_v1", + }, "szz_boundary_first_balanced_data_v1": { "plan_id": "szz_boundary_first_balanced_data_v1", "interaction_basis": "szz", @@ -399,7 +430,7 @@ def require_current_surface_check_plan_renderer( if ancilla_schedule != DEFAULT_ANCILLA_SCHEDULE: expected_schedule["ancilla_batch_policy"] = ancilla_schedule if round_order != DEFAULT_CNOT_ROUND_ORDER: - expected_schedule["round_order"] = CNOT_ROUND_ORDER_3102 + expected_schedule["round_order"] = round_order if synthesis != expected_synthesis or schedule != expected_schedule: msg = ( f"{context} cannot realize check_plan={resolved_plan.plan_id!r} " diff --git a/python/quantum-pecos/src/pecos/qec/surface/schedule.py b/python/quantum-pecos/src/pecos/qec/surface/schedule.py index 1ba9ff9bc..2ad7473c3 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/schedule.py +++ b/python/quantum-pecos/src/pecos/qec/surface/schedule.py @@ -34,10 +34,12 @@ DEFAULT_CNOT_ROUND_ORDER = "default" CNOT_ROUND_ORDER_3102 = "round-order-3102-v1" +CNOT_ROUND_ORDER_1032 = "round-order-1032-v1" SUPPORTED_CNOT_ROUND_ORDERS = frozenset( { DEFAULT_CNOT_ROUND_ORDER, CNOT_ROUND_ORDER_3102, + CNOT_ROUND_ORDER_1032, }, ) @@ -54,6 +56,8 @@ def normalize_cnot_round_order( return (0, 1, 2, 3) if normalized == CNOT_ROUND_ORDER_3102: return (3, 1, 0, 2) + if normalized == CNOT_ROUND_ORDER_1032: + return (1, 0, 3, 2) msg = ( f"round_order must be one of {sorted(SUPPORTED_CNOT_ROUND_ORDERS)} " f"or a permutation of (0, 1, 2, 3), got {round_order!r}" @@ -62,6 +66,7 @@ def normalize_cnot_round_order( if tuple(round_order) not in { (0, 1, 2, 3), (3, 1, 0, 2), + (1, 0, 3, 2), }: msg = f"round_order must be a supported permutation of (0, 1, 2, 3), got {round_order!r}" raise ValueError(msg) diff --git a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py index 30b4c4163..46c9ce3df 100644 --- a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py +++ b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py @@ -27,6 +27,7 @@ normalize_ancilla_schedule, ) from pecos.qec.surface.schedule import ( + CNOT_ROUND_ORDER_1032, CNOT_ROUND_ORDER_3102, compute_cnot_schedule, normalize_cnot_round_order, @@ -265,21 +266,29 @@ def test_balanced_data_schedule_spreads_d9_a17_batches() -> None: assert min(len(batch_indices) for batch_indices in touches_by_data.values()) > 1 -def test_cnot_round_order_3102_permuted_layers() -> None: - """The named round-order candidate preserves layer contents exactly.""" +def test_cnot_round_order_candidates_permute_layers() -> None: + """The named round-order candidates preserve layer contents exactly.""" patch = SurfacePatch.create(distance=5) default_rounds = compute_cnot_schedule(patch) assert normalize_cnot_round_order(None) == (0, 1, 2, 3) assert normalize_cnot_round_order("default") == (0, 1, 2, 3) assert normalize_cnot_round_order(CNOT_ROUND_ORDER_3102) == (3, 1, 0, 2) + assert normalize_cnot_round_order(CNOT_ROUND_ORDER_1032) == (1, 0, 3, 2) assert normalize_cnot_round_order((3, 1, 0, 2)) == (3, 1, 0, 2) + assert normalize_cnot_round_order((1, 0, 3, 2)) == (1, 0, 3, 2) assert compute_cnot_schedule(patch, round_order=CNOT_ROUND_ORDER_3102) == [ default_rounds[3], default_rounds[1], default_rounds[0], default_rounds[2], ] + assert compute_cnot_schedule(patch, round_order=CNOT_ROUND_ORDER_1032) == [ + default_rounds[1], + default_rounds[0], + default_rounds[3], + default_rounds[2], + ] with pytest.raises(ValueError, match=r"round_order"): normalize_cnot_round_order((0, 1, 3, 2)) @@ -340,7 +349,10 @@ def test_balanced_data_d9_a17_round_order_touch_gap_tradeoff() -> None: assert baseline_metrics == (14, 11292) assert best_max_gap == ((13, 11464), normalize_cnot_round_order(CNOT_ROUND_ORDER_3102)) - assert best_sumsq_with_baseline_max_gap == ((14, 11220), (1, 0, 3, 2)) + assert best_sumsq_with_baseline_max_gap == ( + (14, 11220), + normalize_cnot_round_order(CNOT_ROUND_ORDER_1032), + ) # --- D1: pin emitted CX sequences for the constrained Guppy codegen -------- 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 8f461ed3a..8c83726b8 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -61,6 +61,7 @@ def test_check_plan_default_resolves_to_cx_metadata() -> None: assert surface_check_plan_ids() == ( "cx_balanced_data_v1", "cx_standard_v1", + "szz_balanced_data_round_order_1032_v1", "szz_balanced_data_round_order_3102_v1", "szz_balanced_data_v1", "szz_boundary_first_balanced_data_v1", @@ -157,26 +158,32 @@ def test_round_order_check_plan_resolves_to_explicit_schedule() -> None: require_current_surface_check_plan_renderer, resolve_surface_check_plan, ) - from pecos.qec.surface.schedule import CNOT_ROUND_ORDER_3102 + from pecos.qec.surface.schedule import CNOT_ROUND_ORDER_1032, CNOT_ROUND_ORDER_3102 - plan = resolve_surface_check_plan(check_plan="szz_balanced_data_round_order_3102_v1") + cases = [ + ("szz_balanced_data_round_order_3102_v1", CNOT_ROUND_ORDER_3102), + ("szz_balanced_data_round_order_1032_v1", CNOT_ROUND_ORDER_1032), + ] - assert plan.interaction_basis == "szz" - assert plan.synthesis_identity == { - "family": "szz", - "szz_phase_pattern": "standard", - "interaction_order": "pecos-default", - "ancilla_schedule": "balanced-data-v1", - } - assert plan.semantic_content["schedule"] == { - "round_policy": "constant", - "site_policy": "global", - "edge_order": "current_surface_cnot_schedule_v1", - "ancilla_batch_policy": "balanced-data-v1", - "round_order": CNOT_ROUND_ORDER_3102, - } - assert cnot_round_order_for_check_plan(plan) == CNOT_ROUND_ORDER_3102 - require_current_surface_check_plan_renderer(plan, context="unit-test") + for plan_id, expected_round_order in cases: + plan = resolve_surface_check_plan(check_plan=plan_id) + + assert plan.interaction_basis == "szz" + assert plan.synthesis_identity == { + "family": "szz", + "szz_phase_pattern": "standard", + "interaction_order": "pecos-default", + "ancilla_schedule": "balanced-data-v1", + } + assert plan.semantic_content["schedule"] == { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + "ancilla_batch_policy": "balanced-data-v1", + "round_order": expected_round_order, + } + assert cnot_round_order_for_check_plan(plan) == expected_round_order + require_current_surface_check_plan_renderer(plan, context="unit-test") def test_current_renderer_rejects_unimplemented_plan_semantics() -> None: @@ -253,6 +260,7 @@ def test_guppy_surface_code_accepts_check_plan_as_source_of_truth() -> None: [ "cx_balanced_data_v1", "szz_balanced_data_v1", + "szz_balanced_data_round_order_1032_v1", "szz_balanced_data_round_order_3102_v1", "szz_boundary_first_balanced_data_v1", ], @@ -565,7 +573,33 @@ def szz_gate_signature(ops: object) -> list[tuple[str, tuple[int, ...], str]]: assert "Check plan: szz_boundary_first_v1" in source -def test_round_order_szz_check_plan_changes_host_order_not_metadata() -> None: +@pytest.mark.parametrize( + ("round_order_plan", "expected_hosts"), + [ + ( + "szz_balanced_data_round_order_3102_v1", + [ + "szz:syndrome_extraction:r1:X0:d0:SZZ", + "szz:syndrome_extraction:r1:Z3:d5:SZZDG", + "szz:syndrome_extraction:r4:X0:d1:SZZDG", + "szz:syndrome_extraction:r4:Z3:d2:SZZ", + ], + ), + ( + "szz_balanced_data_round_order_1032_v1", + [ + "szz:syndrome_extraction:r3:X0:d0:SZZ", + "szz:syndrome_extraction:r3:Z3:d5:SZZDG", + "szz:syndrome_extraction:r4:X0:d1:SZZDG", + "szz:syndrome_extraction:r4:Z3:d2:SZZ", + ], + ), + ], +) +def test_round_order_szz_check_plan_changes_host_order_not_metadata( + round_order_plan: str, + expected_hosts: list[str], +) -> None: from pecos.guppy.surface import generate_guppy_source from pecos.qec.surface import SurfacePatch from pecos.qec.surface.circuit_builder import ( @@ -576,7 +610,6 @@ def test_round_order_szz_check_plan_changes_host_order_not_metadata() -> None: patch = SurfacePatch.create(distance=3) baseline_plan = "szz_balanced_data_v1" - round_order_plan = "szz_balanced_data_round_order_3102_v1" def szz_gate_signature(plan_id: str) -> list[tuple[str, tuple[int, ...], str]]: ops, _ = build_surface_code_circuit( @@ -633,12 +666,7 @@ def guppy_syndrome_host_ids(plan_id: str) -> list[str]: baseline_hosts = guppy_syndrome_host_ids(baseline_plan) round_order_hosts = guppy_syndrome_host_ids(round_order_plan) assert round_order_hosts != baseline_hosts - assert round_order_hosts[:4] == [ - "szz:syndrome_extraction:r1:X0:d0:SZZ", - "szz:syndrome_extraction:r1:Z3:d5:SZZDG", - "szz:syndrome_extraction:r4:X0:d1:SZZDG", - "szz:syndrome_extraction:r4:Z3:d2:SZZ", - ] + assert round_order_hosts[:4] == expected_hosts def test_direct_surface_renderers_reject_plan_basis_mismatch() -> None: 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 3f2d97863..d0fa4fa5a 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 @@ -522,11 +522,20 @@ def test_szz_noiseless_detector_record_equivalence(distance: int, basis: str) -> _assert_noiseless_record_metadata_is_zero(szz_text, szz_tick) +@pytest.mark.parametrize( + "check_plan", + [ + "szz_balanced_data_round_order_1032_v1", + "szz_balanced_data_round_order_3102_v1", + ], +) @pytest.mark.parametrize("basis", ["Z", "X"]) -def test_round_order_szz_noiseless_detector_record_equivalence(basis: str) -> None: +def test_round_order_szz_noiseless_detector_record_equivalence( + basis: str, + check_plan: str, +) -> None: patch = SurfacePatch.create(distance=3) baseline_plan = "szz_balanced_data_v1" - round_order_plan = "szz_balanced_data_round_order_3102_v1" baseline_text = generate_stim_from_patch( patch, @@ -540,7 +549,7 @@ def test_round_order_szz_noiseless_detector_record_equivalence(basis: str) -> No num_rounds=3, basis=basis, ancilla_budget=2, - check_plan=round_order_plan, + check_plan=check_plan, ) baseline_tick = generate_tick_circuit_from_patch( patch, @@ -554,7 +563,7 @@ def test_round_order_szz_noiseless_detector_record_equivalence(basis: str) -> No num_rounds=3, basis=basis, ancilla_budget=2, - check_plan=round_order_plan, + check_plan=check_plan, ) baseline_circuit = stim.Circuit(baseline_text) @@ -899,8 +908,18 @@ def test_szz_traced_qis_native_dem_matches_stim_for_p1(basis: str) -> None: ) +@pytest.mark.parametrize( + "check_plan", + [ + "szz_balanced_data_round_order_1032_v1", + "szz_balanced_data_round_order_3102_v1", + ], +) @pytest.mark.parametrize("basis", ["Z", "X"]) -def test_round_order_szz_traced_qis_native_dem_matches_stim_for_p1(basis: str) -> None: +def test_round_order_szz_traced_qis_native_dem_matches_stim_for_p1( + basis: str, + check_plan: str, +) -> None: from pecos.qec.surface.circuit_builder import normalize_traced_qis_tick_circuit from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model @@ -911,7 +930,7 @@ def test_round_order_szz_traced_qis_native_dem_matches_stim_for_p1(basis: str) - basis=basis, ancilla_budget=2, circuit_source="traced_qis", - check_plan="szz_balanced_data_round_order_3102_v1", + check_plan=check_plan, ) normalize_traced_qis_tick_circuit(tick_circuit, context="round-order SZZ traced-QIS p1 test") noise_args = { 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 13fddda06..1bf723e1e 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -829,7 +829,14 @@ def test_from_guppy_constrained_surface_dem_byte_identical( ) -def test_from_guppy_round_order_szz_surface_dem_byte_identical() -> None: +@pytest.mark.parametrize( + "check_plan", + [ + "szz_balanced_data_round_order_1032_v1", + "szz_balanced_data_round_order_3102_v1", + ], +) +def test_from_guppy_round_order_szz_surface_dem_byte_identical(check_plan: str) -> None: """Round-order SZZ check plans must stay byte-identical through from_guppy.""" noise = {"p1": 0.0, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} ref_dem, got, _ = _constrained_surface_via_guppy( @@ -838,13 +845,20 @@ def test_from_guppy_round_order_szz_surface_dem_byte_identical() -> None: rounds=2, budget=2, noise=noise, - check_plan="szz_balanced_data_round_order_3102_v1", + check_plan=check_plan, ) assert got == ref_dem -@pytest.mark.parametrize("check_plan", [None, "szz_balanced_data_round_order_3102_v1"]) +@pytest.mark.parametrize( + "check_plan", + [ + None, + "szz_balanced_data_round_order_1032_v1", + "szz_balanced_data_round_order_3102_v1", + ], +) def test_constrained_surface_traced_metadata_matches_abstract(check_plan: str | None) -> None: """Traced surface metadata preserves structure but binds via MeasIds. From af85f9d17e8ff4a9e053f6cacd7ec1540dd8ee22 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Tue, 7 Jul 2026 11:26:49 -0600 Subject: [PATCH 150/150] Fix PECOS dev merge integration issues --- .../src/logical_algorithm.rs | 17 ++- .../src/logical_subgraph.rs | 13 +- .../src/logical_subgraph/committed.rs | 4 +- crates/pecos-decoders/src/lib.rs | 8 +- crates/pecos-engines/src/classical.rs | 15 ++ crates/pecos-engines/src/sim_builder.rs | 26 +++- crates/pecos-hugr-qis/src/compiler.rs | 117 ++++++++++++++- .../src/fault_tolerance/dem_builder/types.rs | 139 ++++++++++++++++++ .../examples/profile_decode.rs | 6 +- crates/pecos-uf-decoder/src/bp_uf.rs | 11 +- crates/pecos-uf-decoder/src/css_decoder.rs | 9 +- crates/pecos-uf-decoder/src/decoder.rs | 9 +- .../src/logical_subgraph_windowed.rs | 5 +- .../tests/cross_decoder_tests.rs | 14 +- .../src/fault_tolerance_bindings.rs | 8 +- 15 files changed, 350 insertions(+), 51 deletions(-) diff --git a/crates/pecos-decoder-core/src/logical_algorithm.rs b/crates/pecos-decoder-core/src/logical_algorithm.rs index 66cdd0e52..a57150c51 100644 --- a/crates/pecos-decoder-core/src/logical_algorithm.rs +++ b/crates/pecos-decoder-core/src/logical_algorithm.rs @@ -237,7 +237,9 @@ impl LogicalAlgorithmDecoder { impl ObservableDecoder for LogicalAlgorithmDecoder { fn decode_obs(&mut self, syndrome: &[u8]) -> Result { - Ok(crate::obs_mask::ObsMask::from_u64(self.decode_shot(syndrome)?)) + Ok(crate::obs_mask::ObsMask::from_u64( + self.decode_shot(syndrome)?, + )) } } @@ -261,12 +263,13 @@ impl ObservableDecoder for LogicalAlgorithmDecoder { /// use pecos_decoder_core::logical_algorithm::{ /// AlgorithmDescriptor, LogicalAlgorithmDecoder, SegmentDescriptor, StreamingLogicalDecoder, /// }; +/// use pecos_decoder_core::obs_mask::ObsMask; /// /// struct AnyDetectionDecoder; /// /// impl ObservableDecoder for AnyDetectionDecoder { -/// fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { -/// Ok(u64::from(syndrome.iter().any(|&bit| bit != 0))) +/// fn decode_obs(&mut self, syndrome: &[u8]) -> Result { +/// Ok(ObsMask::from_u64(u64::from(syndrome.iter().any(|&bit| bit != 0)))) /// } /// } /// @@ -577,7 +580,9 @@ impl LogicalCircuitDecoder { impl ObservableDecoder for LogicalCircuitDecoder { fn decode_obs(&mut self, syndrome: &[u8]) -> Result { - Ok(crate::obs_mask::ObsMask::from_u64(self.decode_shot(syndrome)?)) + Ok(crate::obs_mask::ObsMask::from_u64( + self.decode_shot(syndrome)?, + )) } } @@ -771,8 +776,8 @@ mod tests { struct FixedDecoder(u64); impl ObservableDecoder for FixedDecoder { - fn decode_to_observables(&mut self, _: &[u8]) -> Result { - Ok(self.0) + fn decode_obs(&mut self, _: &[u8]) -> Result { + Ok(crate::obs_mask::ObsMask::from_u64(self.0)) } } diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index 11ce72c41..e34a206b5 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -777,18 +777,21 @@ mod tests { struct NullDecoder; impl ObservableDecoder for NullDecoder { - fn decode_to_observables(&mut self, _: &[u8]) -> Result { - Ok(0) + fn decode_obs(&mut self, _: &[u8]) -> Result { + Ok(crate::obs_mask::ObsMask::from_u64(0)) } } struct FixedDecoder(u64); impl ObservableDecoder for FixedDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { if syndrome.iter().any(|&v| v != 0) { - Ok(self.0) + Ok(crate::obs_mask::ObsMask::from_u64(self.0)) } else { - Ok(0) + Ok(crate::obs_mask::ObsMask::from_u64(0)) } } } diff --git a/crates/pecos-decoder-core/src/logical_subgraph/committed.rs b/crates/pecos-decoder-core/src/logical_subgraph/committed.rs index 19ed7c312..27e2f1e8c 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph/committed.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph/committed.rs @@ -127,7 +127,9 @@ impl ObservableDecoder for CommittedLogicalSubgraphDecoder { fn decode_obs(&mut self, syndrome: &[u8]) -> Result { // Full decode: committed XOR active let active = self.decode_active(syndrome)?; - Ok(crate::obs_mask::ObsMask::from_u64(self.committed_obs ^ active)) + Ok(crate::obs_mask::ObsMask::from_u64( + self.committed_obs ^ active, + )) } } diff --git a/crates/pecos-decoders/src/lib.rs b/crates/pecos-decoders/src/lib.rs index 1b7ad8b46..31b60cf46 100644 --- a/crates/pecos-decoders/src/lib.rs +++ b/crates/pecos-decoders/src/lib.rs @@ -22,9 +22,8 @@ pub use pecos_decoder_core::{ // Re-export observable subgraph decoder (for transversal gates) pub use pecos_decoder_core::logical_subgraph::{ - DetectorGroup, LogicalSubgraph, LogicalSubgraphDecoder, - ParallelLogicalSubgraphDecoder, QubitStabCoords, StabCoords, StabType, - partition_dem_by_logical, + DetectorGroup, LogicalSubgraph, LogicalSubgraphDecoder, ParallelLogicalSubgraphDecoder, + QubitStabCoords, StabCoords, StabType, partition_dem_by_logical, }; // Re-export LDPC decoders when feature is enabled @@ -103,8 +102,7 @@ pub use pecos_uf_decoder::{ AStarConfig, AStarDecoder, BeamSearchConfig, BeamSearchWindowedDecoder, BpSchedule as UfBpSchedule, BpUfConfig, BpUfDecoder, CssUfDecoder, OverlappingWindowedDecoder, QubitEdgeMapping, SandwichWindowedDecoder, StreamingWindowedDecoder, UfDecoder, - UfDecoderConfig, WindowedConfig, WindowedDecoder, - WindowedLogicalSubgraphDecoder, + UfDecoderConfig, WindowedConfig, WindowedDecoder, WindowedLogicalSubgraphDecoder, }; // Re-export Relay BP decoder when feature is enabled diff --git a/crates/pecos-engines/src/classical.rs b/crates/pecos-engines/src/classical.rs index 3c6dc3375..0403e7418 100644 --- a/crates/pecos-engines/src/classical.rs +++ b/crates/pecos-engines/src/classical.rs @@ -18,6 +18,17 @@ pub trait ClassicalEngine: Engine + DynClone + Send + // Default implementation does nothing. } + /// Whether this engine's qubit count is only known after execution because it + /// allocates qubits dynamically. + /// + /// For such engines, [`Self::num_qubits`] returning 0 before execution means + /// "not yet known", not "genuinely zero qubits". Static engines that parse + /// their whole program up front know their exact count and return `false` + /// (the default); dynamic runtimes return `true`. + fn has_dynamic_qubit_count(&self) -> bool { + false + } + /// Generate a `ByteMessage` containing the next batch of quantum commands to execute. /// An empty message indicates no more commands are available. /// @@ -110,6 +121,10 @@ impl ClassicalEngine for Box { (**self).set_num_qubits_hint(num_qubits); } + fn has_dynamic_qubit_count(&self) -> bool { + (**self).has_dynamic_qubit_count() + } + fn generate_commands(&mut self) -> Result { (**self).generate_commands() } diff --git a/crates/pecos-engines/src/sim_builder.rs b/crates/pecos-engines/src/sim_builder.rs index dc9a5caba..c2f5fc3f0 100644 --- a/crates/pecos-engines/src/sim_builder.rs +++ b/crates/pecos-engines/src/sim_builder.rs @@ -284,7 +284,15 @@ impl SimBuilder { ) })?; - classical_engine.set_num_qubits_hint(num_qubits); + // Forward a qubit-count hint only when it is meaningful. An explicit + // count is the caller's choice and is always forwarded, even 0. But an + // inferred 0 from a dynamic classical engine means "unknown", not "zero + // qubits"; forwarding it would freeze dynamic allocation at 0. + match self.explicit_num_qubits { + Some(explicit) => classical_engine.set_num_qubits_hint(explicit), + None if num_qubits > 0 => classical_engine.set_num_qubits_hint(num_qubits), + None => {} + } // Build quantum engine (require explicit qubit specification) let quantum_engine = if let Some(mut builder) = self.quantum_builder { @@ -292,7 +300,21 @@ impl SimBuilder { builder.set_qubits_if_needed(num_qubits); builder.build_boxed()? } else { - // Default: sparse stabilizer + // Default: fixed-size sparse stabilizer. Dynamic classical engines + // may report 0 before execution, but the default quantum engine + // cannot grow to fit runtime qalloc operations. + if self.explicit_num_qubits.is_none() + && num_qubits == 0 + && classical_engine.has_dynamic_qubit_count() + { + return Err(PecosError::Input( + "A dynamic classical engine reports 0 qubits before execution, but the \ + default quantum engine is fixed-size and cannot grow to fit qubits \ + allocated at runtime. Specify .qubits(n) or provide a quantum engine \ + via .quantum()." + .to_string(), + )); + } Box::new(SparseStabEngine::new(num_qubits)) }; diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index e98cf4ce1..b13b10bf4 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -44,6 +44,7 @@ use tket::hugr::llvm::inkwell::passes::PassBuilderOptions; use tket::hugr::llvm::inkwell::targets::{ CodeModel, InitializationConfig, RelocMode, Target, TargetMachine, TargetTriple, }; +use tket::hugr::llvm::inkwell::values::FunctionValue; use tket::hugr::llvm::utils::fat::FatExt as _; use tket::hugr::llvm::{ CodegenExtsBuilder, @@ -53,13 +54,14 @@ use tket::hugr::llvm::{ use tket::hugr::ops::DataflowParent; use tket::hugr::{Hugr, HugrView, Node}; use tket::llvm::rotation::RotationCodegenExtension; -use tket_qsystem::QSystemPass; +use tket::passes::ComposablePass; use tket_qsystem::llvm::array_utils::ArrayLowering; use tket_qsystem::llvm::futures::FuturesCodegenExtension; use tket_qsystem::llvm::{ debug::DebugCodegenExtension, prelude::QISPreludeCodegen, qsystem::QSystemCodegenExtension, random::RandomCodegenExtension, result::ResultsCodegenExtension, utils::UtilsCodegenExtension, }; +use tket_qsystem::{QSystemPass, QSystemPlatform}; // Import read_hugr_envelope from utils module use crate::utils::read_hugr_envelope; @@ -72,6 +74,16 @@ const TRACE_METADATA_QUBIT_HUGR_SYMBOL: &str = "pecos_qis_trace_metadata_qubit_h const RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL: &str = "pecos_qis_runtime_barrier_qubit_hugr"; const RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL: &str = "pecos_qis_runtime_barrier_qubits2_hugr"; +const PECOS_HELPER_ABIS: &[(&str, &str)] = &[ + (TRACE_METADATA_HUGR_SYMBOL, "void (ptr, ptr)"), + (TRACE_METADATA_QUBIT_HUGR_SYMBOL, "i64 (i64, ptr, ptr)"), + (RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL, "i64 (i64)"), + ( + RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL, + "{ i64, i64 } (i64, i64)", + ), +]; + // Extension registry is defined in the parent module /// Compilation arguments @@ -87,6 +99,8 @@ pub struct CompileArgs { pub target_triple: Option, /// Optimization level pub opt_level: OptimizationLevel, + /// Target QSystem platform + pub platform: QSystemPlatform, } impl Default for CompileArgs { @@ -97,6 +111,7 @@ impl Default for CompileArgs { save_hugr: None, target_triple: None, opt_level: OptimizationLevel::Default, + platform: QSystemPlatform::Helios, } } } @@ -105,13 +120,13 @@ impl Default for CompileArgs { /// /// Note: `QSystemPass` internally calls `inline_constant_functions` when the /// `llvm` feature is enabled, so we don't need to call it separately. -fn process_hugr(hugr: &mut Hugr) -> Result<()> { - QSystemPass::default().run(hugr)?; +fn process_hugr(hugr: &mut Hugr, platform: QSystemPlatform) -> Result<()> { + QSystemPass::defaults(platform).run(hugr)?; Ok(()) } /// Build codegen extensions for LLVM generation -fn codegen_extensions() -> CodegenExtsMap<'static, Hugr> { +fn codegen_extensions(platform: QSystemPlatform) -> CodegenExtsMap<'static, Hugr> { use crate::array::SeleneHeapArrayCodegen; let pcg = QISPreludeCodegen; @@ -125,7 +140,7 @@ fn codegen_extensions() -> CodegenExtsMap<'static, Hugr> { .add_default_static_array_extensions() .add_default_borrow_array_extensions(pcg.clone()) .add_extension(FuturesCodegenExtension) - .add_extension(QSystemCodegenExtension::from(pcg.clone())) + .add_extension(QSystemCodegenExtension::from((platform, pcg.clone()))) .add_extension(RandomCodegenExtension) .add_extension(ResultsCodegenExtension::new( SeleneHeapArrayCodegen::LOWERING, @@ -197,7 +212,7 @@ fn get_module_with_std_exts<'c>( namer: Rc, hugr: &'c mut Hugr, ) -> Result> { - process_hugr(hugr)?; + process_hugr(hugr, args.platform)?; if let Some(filename) = &args.save_hugr { let file = fs::File::create(filename)?; @@ -209,7 +224,7 @@ fn get_module_with_std_exts<'c>( namer, hugr, &args.name, - Rc::new(codegen_extensions()), + Rc::new(codegen_extensions(args.platform)), ) } @@ -329,6 +344,92 @@ fn optimize_module( Ok(()) } +fn pecos_helper_public_name(symbol: &str) -> Option<&'static str> { + if PECOS_HELPER_ABIS.iter().any(|(name, _)| *name == symbol) { + return Some(match symbol { + TRACE_METADATA_HUGR_SYMBOL => TRACE_METADATA_HUGR_SYMBOL, + TRACE_METADATA_QUBIT_HUGR_SYMBOL => TRACE_METADATA_QUBIT_HUGR_SYMBOL, + RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL => RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL, + RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL => RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL, + _ => unreachable!("checked helper symbol must be mapped"), + }); + } + + let rest = symbol.strip_prefix(HUGR_SYMBOL_PREFIX)?; + let mut parts = rest.rsplit('.'); + let _suffix = parts.next()?; + let helper_name = parts.next()?; + PECOS_HELPER_ABIS + .iter() + .find_map(|(name, _)| (*name == helper_name).then_some(*name)) +} + +fn expected_helper_abi(public_name: &str) -> &'static str { + PECOS_HELPER_ABIS + .iter() + .find_map(|(name, abi)| (*name == public_name).then_some(*abi)) + .expect("recognized helper must have ABI") +} + +fn function_abi_string(function: FunctionValue<'_>) -> String { + function.get_type().print_to_string().to_string() +} + +fn validate_pecos_helper_abi(function: FunctionValue<'_>, public_name: &str) -> Result<()> { + let actual = function_abi_string(function); + let expected = expected_helper_abi(public_name); + if actual != expected { + return Err(anyhow!( + "PECOS helper '{public_name}' has LLVM type '{actual}', but pecos-qis-ffi ABI expects '{expected}'" + )); + } + Ok(()) +} + +/// Normalize PECOS-owned helper declarations inside the LLVM module before +/// verification/bitcode emission, so both text and bitcode expose the runtime +/// ABI symbols exported by pecos-qis-ffi. +fn normalize_pecos_helper_symbols_in_module(module: &Module) -> Result<()> { + for &(public_name, _) in PECOS_HELPER_ABIS { + let candidates = module + .get_functions() + .filter_map(|function| { + let name = function.get_name().to_str().ok()?; + (pecos_helper_public_name(name) == Some(public_name)).then_some(function) + }) + .collect::>(); + + if candidates.is_empty() { + continue; + } + + for &candidate in &candidates { + validate_pecos_helper_abi(candidate, public_name)?; + } + + let canonical = candidates + .iter() + .copied() + .find(|function| function.get_name().to_str().ok() == Some(public_name)) + .unwrap_or(candidates[0]); + + if canonical.get_name().to_str().ok() != Some(public_name) { + canonical.as_global_value().set_name(public_name); + } + + for candidate in candidates { + if candidate == canonical { + continue; + } + candidate.replace_all_uses_with(canonical); + // SAFETY: all uses were rewired to the canonical declaration above. + unsafe { candidate.delete() }; + } + } + + Ok(()) +} + /// Compile the given HUGR to an LLVM module /// This function is the primary entry point for the compiler fn compile<'c, 'hugr: 'c>( @@ -374,6 +475,8 @@ fn compile<'c, 'hugr: 'c>( .map_err(|e| anyhow!("Failed to add metadata: {e}"))?; } + normalize_pecos_helper_symbols_in_module(&module)?; + // Optimize optimize_module(&module, &target_machine, args.opt_level)?; 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 70dd14baa..ad2c2a58d 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -3643,6 +3643,145 @@ impl PerGateTypeNoise { } self.rate_2q(gate, pair_idx) } + + /// Convert this QEC per-gate noise model into the event-driven NEO + /// [`pecos_neo::noise::PerGatePauliChannel`]. + /// + /// Idle noise is intentionally rejected here: this Pauli channel represents + /// gate/prep/measurement faults and cannot carry duration-dependent idle + /// models without changing the simulated physics. + #[cfg(feature = "neo")] + #[must_use] + pub fn to_neo_channel(&self) -> pecos_neo::noise::PerGatePauliChannel { + assert!( + !self.base.uses_dedicated_idle_noise() + && !self.rates_1q.contains_key(&GateType::Idle) + && !self + .rates_1q_per_qubit + .keys() + .any(|(gate, _)| *gate == GateType::Idle), + "PerGateTypeNoise::to_neo_channel cannot carry idle noise; compose an idle channel separately" + ); + + let mut channel = pecos_neo::noise::PerGatePauliChannel::new() + .with_base(self.base.p1, self.base.p2) + .with_meas_init(self.p_meas, self.p_init); + + for (&gate, &total) in &self.base.p1_gate_rates { + if self.rates_1q.contains_key(&gate) { + continue; + } + channel = channel.with_1q_rates(core_gate_to_neo(gate), [total / 3.0; 3]); + } + for (&gate, &rates) in &self.rates_1q { + channel = channel.with_1q_rates(core_gate_to_neo(gate), rates); + } + for (&(gate, qubit), &rates) in &self.rates_1q_per_qubit { + channel = channel.with_1q_rates_for_qubit(core_gate_to_neo(gate), qubit, rates); + } + + for (&gate, &total) in &self.base.p2_gate_rates { + if self.rates_2q.contains_key(&gate) { + continue; + } + channel = channel.with_2q_rates( + core_gate_to_neo(gate), + qec_2q_rates_to_neo([total / 15.0; 15]), + ); + } + for (&gate, &rates) in &self.rates_2q { + channel = channel.with_2q_rates(core_gate_to_neo(gate), qec_2q_rates_to_neo(rates)); + } + for (&(gate, first, second), &rates) in &self.rates_2q_per_qubits { + channel = channel.with_2q_rates_for_qubits( + core_gate_to_neo(gate), + first, + second, + qec_2q_rates_to_neo(rates), + ); + } + + for (&qubit, &p) in &self.measurement_rates { + channel = channel.with_meas_rate_for_qubit(qubit, p); + } + for (&qubit, &p) in &self.init_rates { + channel = channel.with_init_rate_for_qubit(qubit, p); + } + + channel + } +} + +#[cfg(feature = "neo")] +fn core_gate_to_neo(gate: GateType) -> pecos_neo::GateType { + match gate { + GateType::I => pecos_neo::GateType::I, + GateType::X => pecos_neo::GateType::X, + GateType::Y => pecos_neo::GateType::Y, + GateType::Z => pecos_neo::GateType::Z, + GateType::SX => pecos_neo::GateType::SX, + GateType::SXdg => pecos_neo::GateType::SXdg, + GateType::SY => pecos_neo::GateType::SY, + GateType::SYdg => pecos_neo::GateType::SYdg, + GateType::SZ => pecos_neo::GateType::SZ, + GateType::SZdg => pecos_neo::GateType::SZdg, + GateType::H => pecos_neo::GateType::H, + GateType::F => pecos_neo::GateType::F, + GateType::Fdg => pecos_neo::GateType::Fdg, + GateType::RX => pecos_neo::GateType::RX, + GateType::RY => pecos_neo::GateType::RY, + GateType::RZ => pecos_neo::GateType::RZ, + GateType::T => pecos_neo::GateType::T, + GateType::Tdg => pecos_neo::GateType::Tdg, + GateType::U => pecos_neo::GateType::U, + GateType::R1XY => pecos_neo::GateType::R1XY, + GateType::CX => pecos_neo::GateType::CX, + GateType::CY => pecos_neo::GateType::CY, + GateType::CZ => pecos_neo::GateType::CZ, + GateType::SXX => pecos_neo::GateType::SXX, + GateType::SXXdg => pecos_neo::GateType::SXXdg, + GateType::SYY => pecos_neo::GateType::SYY, + GateType::SYYdg => pecos_neo::GateType::SYYdg, + GateType::SZZ => pecos_neo::GateType::SZZ, + GateType::SZZdg => pecos_neo::GateType::SZZdg, + GateType::SWAP => pecos_neo::GateType::SWAP, + GateType::CRZ => pecos_neo::GateType::CRZ, + GateType::RXX => pecos_neo::GateType::RXX, + GateType::RYY => pecos_neo::GateType::RYY, + GateType::RZZ => pecos_neo::GateType::RZZ, + GateType::CCX => pecos_neo::GateType::CCX, + GateType::MZ => pecos_neo::GateType::MZ, + GateType::MeasureLeaked => pecos_neo::GateType::MeasureLeaked, + GateType::MeasureFree => pecos_neo::GateType::MeasureFree, + GateType::PZ => pecos_neo::GateType::PZ, + GateType::QAlloc => pecos_neo::GateType::QAlloc, + GateType::QFree => pecos_neo::GateType::QFree, + GateType::Idle => pecos_neo::GateType::Idle, + unsupported => { + panic!("unsupported gate type for NEO per-gate noise conversion: {unsupported:?}") + } + } +} + +#[cfg(feature = "neo")] +fn qec_2q_rates_to_neo(qec: [f64; 15]) -> [f64; 15] { + [ + qec[3], // XI + qec[7], // YI + qec[11], // ZI + qec[0], // IX + qec[1], // IY + qec[2], // IZ + qec[4], // XX + qec[5], // XY + qec[6], // XZ + qec[8], // YX + qec[9], // YY + qec[10], // YZ + qec[12], // ZX + qec[13], // ZY + qec[14], // ZZ + ] } // ============================================================================ diff --git a/crates/pecos-uf-decoder/examples/profile_decode.rs b/crates/pecos-uf-decoder/examples/profile_decode.rs index 7bcd30ff5..78bcd54fa 100644 --- a/crates/pecos-uf-decoder/examples/profile_decode.rs +++ b/crates/pecos-uf-decoder/examples/profile_decode.rs @@ -16,7 +16,7 @@ fn shots_as_f64(num_shots: usize) -> f64 { fn profile_decoder(name: &str, dem: &str, num_shots: usize) { let graph = DemMatchingGraph::from_dem_str(dem).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::fast()).unwrap(); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::fast()); let num_det = graph.num_detectors; // Generate random syndromes @@ -59,7 +59,7 @@ fn profile_phases(name: &str, dem: &str, num_shots: usize) { .collect(); // Phase 1: measure reset + syndrome loading only - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::fast()).unwrap(); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::fast()); let t0 = Instant::now(); for syn in &syndromes { dec.syndrome_validate(syn); // reset + grow (no peel) @@ -126,7 +126,7 @@ fn main() { println!(); println!("=== Balanced (Prim MST) ==="); let graph = DemMatchingGraph::from_dem_str(D5_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::balanced()).unwrap(); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::balanced()); let num_det = graph.num_detectors; let mut rng = fastrand::Rng::with_seed(42); diff --git a/crates/pecos-uf-decoder/src/bp_uf.rs b/crates/pecos-uf-decoder/src/bp_uf.rs index 428069a55..ac24baaf7 100644 --- a/crates/pecos-uf-decoder/src/bp_uf.rs +++ b/crates/pecos-uf-decoder/src/bp_uf.rs @@ -463,7 +463,10 @@ impl pecos_decoder_core::bp_matching::BpWeightProvider for BpUfDecoder { } impl pecos_decoder_core::ObservableDecoder for BpUfDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { // Fast path: cluster predecoder handles isolated cases without BP. // This catches 0 defects, single defects, and isolated pairs. Gated on // the UF config like the plain `UfDecoder` paths. It deliberately runs @@ -473,7 +476,7 @@ impl pecos_decoder_core::ObservableDecoder for BpUfDecoder { if self.uf.config.predecoder && let Some(obs) = self.uf.predecode_clusters(syndrome) { - return Ok(obs); + return Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64(obs)); } let num_defects = syndrome.iter().filter(|&&v| v != 0).count(); @@ -571,10 +574,10 @@ impl pecos_decoder_core::ObservableDecoder for BpUfDecoder { let (mask2, _) = self .uf .decode_with_weights(syndrome, &self.adjusted_weights)?; - return Ok(mask2); + return Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64(mask2)); } - Ok(mask) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64(mask)) } } diff --git a/crates/pecos-uf-decoder/src/css_decoder.rs b/crates/pecos-uf-decoder/src/css_decoder.rs index 051e970c1..29cdfc5c3 100644 --- a/crates/pecos-uf-decoder/src/css_decoder.rs +++ b/crates/pecos-uf-decoder/src/css_decoder.rs @@ -297,7 +297,10 @@ impl pecos_decoder_core::ObservableDecoder for CssUfDecoder { /// /// The syndrome is split at `x_num_detectors` into X and Z parts. /// Returns the XOR of both observable masks. - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { let split = self.x_num_detectors; if syndrome.len() < split { return Err(DecoderError::DecodingFailed(format!( @@ -309,7 +312,9 @@ impl pecos_decoder_core::ObservableDecoder for CssUfDecoder { let x_syn = &syndrome[..split]; let z_syn = &syndrome[split..]; let (x_obs, z_obs) = self.decode_css(x_syn, z_syn)?; - Ok(x_obs ^ z_obs) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64( + x_obs ^ z_obs, + )) } } diff --git a/crates/pecos-uf-decoder/src/decoder.rs b/crates/pecos-uf-decoder/src/decoder.rs index 85944b203..75ab81c39 100644 --- a/crates/pecos-uf-decoder/src/decoder.rs +++ b/crates/pecos-uf-decoder/src/decoder.rs @@ -1115,8 +1115,13 @@ impl UfDecoder { // === Trait implementations === impl pecos_decoder_core::ObservableDecoder for UfDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { - Ok(self.decode_syndrome(syndrome)) + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64( + self.decode_syndrome(syndrome), + )) } } diff --git a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs index 2a41f3b35..90674fba3 100644 --- a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs +++ b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs @@ -39,6 +39,7 @@ use pecos_decoder_core::logical_subgraph::window_plan::LogicalSubgraphWindowPlan use pecos_decoder_core::logical_subgraph::{ MaxTimeRadius, StabCoords, partition_dem_by_logical_windowed, }; +use pecos_decoder_core::obs_mask::ObsMask; use crate::decoder::{UfDecoder, UfDecoderConfig}; use crate::windowed::{OverlappingWindowedDecoder, WindowedConfig}; @@ -129,7 +130,7 @@ impl WindowedLogicalSubgraphDecoder { } impl ObservableDecoder for WindowedLogicalSubgraphDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { let mut obs_mask = 0u64; for sg in &mut self.subgraphs { let n = sg.num_local; @@ -154,6 +155,6 @@ impl ObservableDecoder for WindowedLogicalSubgraphDecoder { obs_mask |= 1u64 << sg.observable_idx; } } - Ok(obs_mask) + Ok(ObsMask::from_u64(obs_mask)) } } diff --git a/crates/pecos-uf-decoder/tests/cross_decoder_tests.rs b/crates/pecos-uf-decoder/tests/cross_decoder_tests.rs index 1f09aec70..d7bec84d7 100644 --- a/crates/pecos-uf-decoder/tests/cross_decoder_tests.rs +++ b/crates/pecos-uf-decoder/tests/cross_decoder_tests.rs @@ -30,11 +30,13 @@ const D3_DEM: &str = fn test_ensemble_of_identical_decoders_matches_single() { let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); - let mut single = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); + let mut single = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); let members: Vec> = (0..3) .map(|_| { - Box::new(UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap()) - as Box + Box::new(UfDecoder::from_matching_graph( + &graph, + UfDecoderConfig::default(), + )) as Box }) .collect(); let mut ensemble = EnsembleDecoder::new(members); @@ -62,8 +64,8 @@ fn test_ensemble_of_identical_decoders_matches_single() { fn test_deterministic_results() { let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); - let mut dec1 = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); - let mut dec2 = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); + let mut dec1 = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec2 = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); let mut rng = fastrand::Rng::with_seed(999); for _ in 0..200 { @@ -87,7 +89,7 @@ fn test_matching_agrees_with_observable() { use pecos_decoder_core::correlated_decoder::MatchingDecoder; let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); let mut rng = fastrand::Rng::with_seed(777); for _ in 0..200 { diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index cdf484fa5..f19be757d 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -5929,9 +5929,7 @@ impl PyLogicalAlgorithmDecoder { // Parse stab_coords from the first segment (original orientation) let first_seg = seg_list.first().ok_or_else(|| { - PyErr::new::( - "algorithm descriptor has no segments", - ) + PyErr::new::("algorithm descriptor has no segments") })?; let sc_list: Vec> = first_seg .get_item("stab_coords")? @@ -6184,9 +6182,7 @@ impl PyLogicalCircuitDecoder { // Parse stab_coords from first segment let first_seg = seg_list.first().ok_or_else(|| { - PyErr::new::( - "algorithm descriptor has no segments", - ) + PyErr::new::("algorithm descriptor has no segments") })?; let sc_list: Vec> = first_seg .get_item("stab_coords")?