From 19f9ead16afa38d9b2a5f57657bc13d1c62974b6 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 12:46:29 -0600 Subject: [PATCH 01/41] Expose the Rust stabilizer-code distance search and verification workflow to Python --- crates/pecos-qec/src/stabilizer_code_spec.rs | 62 +- python/pecos-rslib/pecos_rslib.pyi | 88 ++ python/pecos-rslib/src/lib.rs | 2 + python/pecos-rslib/src/pauli_bindings.rs | 25 + .../src/stabilizer_code_bindings.rs | 2 +- .../src/stabilizer_code_spec_bindings.rs | 339 +++++ .../src/pecos/quantum/__init__.py | 14 + .../src/pecos/tools/fault_tolerance_checks.py | 457 ------- .../pecos/tools/stabilizer_verification.py | 1117 ----------------- .../test_stabilizer_code_spec_bindings.py | 148 +++ ...t_stabilizer_code_verification_bindings.py | 139 ++ 11 files changed, 817 insertions(+), 1576 deletions(-) create mode 100644 python/pecos-rslib/src/stabilizer_code_spec_bindings.rs delete mode 100644 python/quantum-pecos/src/pecos/tools/fault_tolerance_checks.py delete mode 100644 python/quantum-pecos/src/pecos/tools/stabilizer_verification.py create mode 100644 python/quantum-pecos/tests/pecos/test_stabilizer_code_spec_bindings.py create mode 100644 python/quantum-pecos/tests/pecos/test_stabilizer_code_verification_bindings.py diff --git a/crates/pecos-qec/src/stabilizer_code_spec.rs b/crates/pecos-qec/src/stabilizer_code_spec.rs index e1d753106..9bba186bf 100644 --- a/crates/pecos-qec/src/stabilizer_code_spec.rs +++ b/crates/pecos-qec/src/stabilizer_code_spec.rs @@ -75,6 +75,42 @@ pub struct StabilizerCodeSpec { distance: Option, } +impl std::fmt::Display for StabilizerCodeSpec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "[[{}, {}]]", self.num_qubits, self.num_logical_qubits())?; + + writeln!(f, "Stabilizer generators:")?; + for stabilizer in &self.stabilizers { + writeln!(f, "{}", stabilizer.to_dense_str(Some(self.num_qubits)))?; + } + + writeln!(f, "Destabilizer generators:")?; + for destabilizer in &self.destabilizers { + writeln!(f, "{}", destabilizer.to_dense_str(Some(self.num_qubits)))?; + } + + writeln!(f, "Logical operators:")?; + for (index, (logical_z, logical_x)) in + self.logical_zs.iter().zip(&self.logical_xs).enumerate() + { + writeln!( + f, + "Z{}: {}", + index + 1, + logical_z.to_dense_str(Some(self.num_qubits)) + )?; + writeln!( + f, + "X{}: {}", + index + 1, + logical_x.to_dense_str(Some(self.num_qubits)) + )?; + } + + Ok(()) + } +} + /// Column-based index for efficient commutation checking. /// /// For each qubit, tracks which operators have X or Z on that qubit. @@ -1056,7 +1092,7 @@ impl StabilizerCodeSpecBuilder { #[cfg(test)] mod tests { use super::*; - use pecos_core::Pauli; + use pecos_core::{Pauli, Zs}; /// Helper to create a `PauliString` from a simple specification. fn pauli_string(paulis: &[(Pauli, usize)]) -> PauliString { @@ -1067,6 +1103,30 @@ mod tests { ) } + #[test] + fn display_includes_generators_and_paired_logicals_in_order() { + let code = StabilizerCodeSpecBuilder::new(3) + .check(Zs([0, 1])) + .check(Zs([1, 2])) + .build_with_discovered_logicals() + .unwrap(); + + let rendered = code.to_string(); + assert!(rendered.starts_with("[[3, 1]]\nStabilizer generators:\n")); + + let stabilizer_section = rendered.find("Stabilizer generators:").unwrap(); + let destabilizer_section = rendered.find("Destabilizer generators:").unwrap(); + let logical_section = rendered.find("Logical operators:").unwrap(); + assert!(stabilizer_section < destabilizer_section); + assert!(destabilizer_section < logical_section); + + for operator in code.stabilizers().iter().chain(code.destabilizers()) { + assert!(rendered.contains(&operator.to_dense_str(Some(3)))); + } + assert!(rendered.contains("Z1: ")); + assert!(rendered.contains("X1: ")); + } + #[test] fn test_three_qubit_bit_flip_code() { // 3-qubit bit flip code: [[3, 1, 1]] diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index f5a792e9f..9954b41f8 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -1060,14 +1060,102 @@ class PauliString: def __eq__(self, other: object) -> bool: ... def X(qubit: int) -> PauliString: ... +def Xs(qubits: Sequence[int]) -> PauliString: ... def Y(qubit: int) -> PauliString: ... +def Ys(qubits: Sequence[int]) -> PauliString: ... def Z(qubit: int) -> PauliString: ... +def Zs(qubits: Sequence[int]) -> PauliString: ... class PauliStabilizerGroup: """A group of commuting Pauli operators with real phases.""" ... +class StabilizerCode: + """A stabilizer group with an explicit physical-qubit count.""" + + def __init__(self, group: PauliStabilizerGroup, num_qubits: int | None = None) -> None: ... + @staticmethod + def repetition(n: int) -> StabilizerCode: ... + @staticmethod + def steane() -> StabilizerCode: ... + @staticmethod + def five_qubit() -> StabilizerCode: ... + @staticmethod + def shor() -> StabilizerCode: ... + @staticmethod + def four_two_two() -> StabilizerCode: ... + @staticmethod + def toric(l: int) -> StabilizerCode: ... + def num_qubits(self) -> int: ... + def num_logical_qubits(self) -> int: ... + def code_parameters(self) -> str: ... + def distance(self) -> int | None: ... + def syndrome(self, error: PauliString) -> list[bool]: ... + def logical_operators(self) -> list[PauliString]: ... + def group(self) -> PauliStabilizerGroup: ... + +class DistanceResult: + """A code distance and one minimum-weight logical operator.""" + + @property + def distance(self) -> int: ... + @property + def min_weight_operator(self) -> PauliString: ... + +class LogicalOperatorInfo: + """A minimum-weight logical operator and its logical equivalence.""" + + @property + def operator(self) -> PauliString: ... + @property + def weight(self) -> int: ... + @property + def equivalent_logicals(self) -> list[tuple[str, int]]: ... + def equivalence_string(self) -> str: ... + +class StabilizerCodeSpecBuilder: + """Mutable Python wrapper around the consuming Rust specification builder.""" + + def check(self, op: PauliString) -> None: ... + def logical_z(self, op: PauliString) -> None: ... + def logical_x(self, op: PauliString) -> None: ... + def build(self) -> StabilizerCodeSpec: ... + def build_verified(self) -> StabilizerCodeSpec: ... + def build_with_discovered_logicals(self) -> StabilizerCodeSpec: ... + +class StabilizerCodeSpec: + """A complete stabilizer-code specification with paired logical operators.""" + + def __init__( + self, + num_qubits: int, + stabilizers: list[PauliString], + logical_zs: list[PauliString], + logical_xs: list[PauliString], + ) -> None: ... + @staticmethod + def builder(num_qubits: int) -> StabilizerCodeSpecBuilder: ... + @classmethod + def from_stabilizer_code(cls, code: StabilizerCode) -> StabilizerCodeSpec: ... + @property + def num_qubits(self) -> int: ... + @property + def num_logical_qubits(self) -> int: ... + @property + def stabilizers(self) -> list[PauliString]: ... + @property + def destabilizers(self) -> list[PauliString]: ... + @property + def logical_zs(self) -> list[PauliString]: ... + @property + def logical_xs(self) -> list[PauliString]: ... + def verify(self) -> None: ... + def distance(self, max_weight: int | None = None, css: bool = False) -> DistanceResult | None: ... + def min_weight_logicals(self, max_weight: int | None = None, css: bool = False) -> list[LogicalOperatorInfo]: ... + def __str__(self) -> str: ... + def __repr__(self) -> str: ... + class PauliSequence: """Ordered sequence of Pauli operators with symplectic analysis.""" diff --git a/python/pecos-rslib/src/lib.rs b/python/pecos-rslib/src/lib.rs index 0de139841..dd5062923 100644 --- a/python/pecos-rslib/src/lib.rs +++ b/python/pecos-rslib/src/lib.rs @@ -71,6 +71,7 @@ mod sparse_stab_engine_bindings; mod stab_bindings; mod stab_vec_bindings; mod stabilizer_code_bindings; +mod stabilizer_code_spec_bindings; mod stabilizer_group_bindings; mod state_vec_bindings; mod state_vec_engine_bindings; @@ -303,6 +304,7 @@ fn pecos_rslib(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { // Register stabilizer group, Pauli sequence, and Clifford types stabilizer_group_bindings::register_stabilizer_group_types(m)?; stabilizer_code_bindings::register_stabilizer_code_types(m)?; + stabilizer_code_spec_bindings::register_stabilizer_code_spec_types(m)?; pauli_sequence_bindings::register_pauli_sequence_types(m)?; clifford_rep_bindings::register_clifford_types(m)?; diff --git a/python/pecos-rslib/src/pauli_bindings.rs b/python/pecos-rslib/src/pauli_bindings.rs index 07a685d4c..d9475fc4b 100644 --- a/python/pecos-rslib/src/pauli_bindings.rs +++ b/python/pecos-rslib/src/pauli_bindings.rs @@ -24,6 +24,7 @@ use std::hash::{Hash, Hasher}; use crate::prelude::{ Pauli as RustPauli, PauliOperator, PauliString as RustPauliString, QuarterPhase, QubitId, }; +use pecos_core::{Xs as RustXs, Ys as RustYs, Zs as RustZs}; use pyo3::prelude::*; /// Single-qubit Pauli operator (I, X, Y, Z) @@ -595,6 +596,27 @@ pub fn Z(qubit: usize) -> PauliString { } } +/// Create a multi-qubit X `PauliString`: `Xs([0, 2, 5])`. +#[pyfunction] +#[allow(non_snake_case)] +pub fn Xs(qubits: Vec) -> PauliString { + PauliString::from_rust(RustXs(qubits)) +} + +/// Create a multi-qubit Y `PauliString`: `Ys([0, 2, 5])`. +#[pyfunction] +#[allow(non_snake_case)] +pub fn Ys(qubits: Vec) -> PauliString { + PauliString::from_rust(RustYs(qubits)) +} + +/// Create a multi-qubit Z `PauliString`: `Zs([0, 2, 5])`. +#[pyfunction] +#[allow(non_snake_case)] +pub fn Zs(qubits: Vec) -> PauliString { + PauliString::from_rust(RustZs(qubits)) +} + /// Register Pauli types with Python module pub fn register_pauli_types(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; @@ -602,5 +624,8 @@ pub fn register_pauli_types(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(pyo3::wrap_pyfunction!(X, m)?)?; m.add_function(pyo3::wrap_pyfunction!(Y, m)?)?; m.add_function(pyo3::wrap_pyfunction!(Z, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(Xs, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(Ys, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(Zs, m)?)?; Ok(()) } diff --git a/python/pecos-rslib/src/stabilizer_code_bindings.rs b/python/pecos-rslib/src/stabilizer_code_bindings.rs index d6a0f2781..40545e5e8 100644 --- a/python/pecos-rslib/src/stabilizer_code_bindings.rs +++ b/python/pecos-rslib/src/stabilizer_code_bindings.rs @@ -41,7 +41,7 @@ use crate::stabilizer_group_bindings::PyPauliStabilizerGroup; #[pyclass(name = "StabilizerCode", module = "pecos_rslib", from_py_object)] #[derive(Debug, Clone)] pub struct PyStabilizerCode { - inner: RustCode, + pub(crate) inner: RustCode, } unsafe impl Send for PyStabilizerCode {} diff --git a/python/pecos-rslib/src/stabilizer_code_spec_bindings.rs b/python/pecos-rslib/src/stabilizer_code_spec_bindings.rs new file mode 100644 index 000000000..52dfe0a6b --- /dev/null +++ b/python/pecos-rslib/src/stabilizer_code_spec_bindings.rs @@ -0,0 +1,339 @@ +// 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. + +//! Python bindings for stabilizer-code specifications and distance search. + +use pecos_qec::{ + DistanceResult as RustDistanceResult, DistanceSearchConfig, + LogicalOperatorInfo as RustLogicalOperatorInfo, StabilizerCodeSpec as RustCodeSpec, + StabilizerCodeSpecBuilder as RustCodeSpecBuilder, calculate_distance, + find_min_weight_logicals_with_info, +}; +use pyo3::prelude::*; +use pyo3::types::PyType; + +use crate::pauli_bindings::PauliString; +use crate::stabilizer_code_bindings::PyStabilizerCode; + +/// Result of a stabilizer-code distance search. +#[pyclass(name = "DistanceResult", module = "pecos_rslib", skip_from_py_object)] +#[derive(Clone, Debug)] +pub struct PyDistanceResult { + inner: RustDistanceResult, +} + +#[pymethods] +impl PyDistanceResult { + /// The code distance. + #[getter] + fn distance(&self) -> usize { + self.inner.distance + } + + /// A logical operator achieving the code distance. + #[getter] + fn min_weight_operator(&self) -> PauliString { + PauliString::from_rust(self.inner.min_weight_operator.clone()) + } +} + +impl From for PyDistanceResult { + fn from(inner: RustDistanceResult) -> Self { + Self { inner } + } +} + +/// A minimum-weight logical operator and its logical equivalence information. +#[pyclass( + name = "LogicalOperatorInfo", + module = "pecos_rslib", + skip_from_py_object +)] +#[derive(Clone, Debug)] +pub struct PyLogicalOperatorInfo { + inner: RustLogicalOperatorInfo, +} + +#[pymethods] +impl PyLogicalOperatorInfo { + /// The physical Pauli operator. + #[getter] + fn operator(&self) -> PauliString { + PauliString::from_rust(self.inner.operator.clone()) + } + + /// The physical weight of the operator. + #[getter] + fn weight(&self) -> usize { + self.inner.weight + } + + /// Logical operations implemented by the operator. + #[getter] + fn equivalent_logicals(&self) -> Vec<(String, usize)> { + self.inner + .equivalent_logicals + .iter() + .map(|(logical_type, index)| (logical_type.to_string(), *index)) + .collect() + } + + /// Return the logical equivalence as a compact string such as ``X0*Z1``. + fn equivalence_string(&self) -> String { + self.inner.equivalence_string() + } +} + +impl From for PyLogicalOperatorInfo { + fn from(inner: RustLogicalOperatorInfo) -> Self { + Self { inner } + } +} + +/// Builder for a stabilizer-code specification. +#[pyclass( + name = "StabilizerCodeSpecBuilder", + module = "pecos_rslib", + skip_from_py_object +)] +pub struct PyStabilizerCodeSpecBuilder { + inner: Option, +} + +impl PyStabilizerCodeSpecBuilder { + fn new(num_qubits: usize) -> Self { + Self { + inner: Some(RustCodeSpecBuilder::new(num_qubits)), + } + } + + fn take_inner(&mut self) -> PyResult { + self.inner.take().ok_or_else(|| { + pyo3::exceptions::PyRuntimeError::new_err( + "StabilizerCodeSpecBuilder has already been consumed", + ) + }) + } +} + +#[pymethods] +impl PyStabilizerCodeSpecBuilder { + /// Add a stabilizer generator. + fn check(&mut self, op: &PauliString) -> PyResult<()> { + let builder = self.take_inner()?; + self.inner = Some(builder.check(op.to_rust())); + Ok(()) + } + + /// Add a logical Z operator. + fn logical_z(&mut self, op: &PauliString) -> PyResult<()> { + let builder = self.take_inner()?; + self.inner = Some(builder.logical_z(op.to_rust())); + Ok(()) + } + + /// Add a logical X operator. + fn logical_x(&mut self, op: &PauliString) -> PyResult<()> { + let builder = self.take_inner()?; + self.inner = Some(builder.logical_x(op.to_rust())); + Ok(()) + } + + /// Build with count validation only. + fn build(&mut self) -> PyResult { + let inner = self + .take_inner()? + .build() + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))?; + Ok(PyStabilizerCodeSpec { inner }) + } + + /// Build and fully verify all commutation relations. + fn build_verified(&mut self) -> PyResult { + let inner = self + .take_inner()? + .build_verified() + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))?; + Ok(PyStabilizerCodeSpec { inner }) + } + + /// Build and automatically discover paired logical operators. + fn build_with_discovered_logicals(&mut self) -> PyResult { + let inner = self + .take_inner()? + .build_with_discovered_logicals() + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))?; + Ok(PyStabilizerCodeSpec { inner }) + } +} + +/// A complete stabilizer-code specification with paired logical operators. +#[pyclass(name = "StabilizerCodeSpec", module = "pecos_rslib", from_py_object)] +#[derive(Clone, Debug)] +pub struct PyStabilizerCodeSpec { + inner: RustCodeSpec, +} + +#[pymethods] +impl PyStabilizerCodeSpec { + /// Create a builder for a code with the specified number of qubits. + #[staticmethod] + fn builder(num_qubits: usize) -> PyStabilizerCodeSpecBuilder { + PyStabilizerCodeSpecBuilder::new(num_qubits) + } + + /// Create a stabilizer-code specification. + #[new] + fn new( + num_qubits: usize, + stabilizers: Vec, + logical_zs: Vec, + logical_xs: Vec, + ) -> PyResult { + let stabilizers = stabilizers + .into_iter() + .map(|pauli| pauli.to_rust()) + .collect(); + let logical_zs = logical_zs + .into_iter() + .map(|pauli| pauli.to_rust()) + .collect(); + let logical_xs = logical_xs + .into_iter() + .map(|pauli| pauli.to_rust()) + .collect(); + let inner = RustCodeSpec::new(num_qubits, stabilizers, logical_zs, logical_xs) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))?; + Ok(Self { inner }) + } + + /// Create a full specification from a ``StabilizerCode``. + #[classmethod] + fn from_stabilizer_code(_cls: &Bound<'_, PyType>, code: &PyStabilizerCode) -> PyResult { + let inner = RustCodeSpec::from_stabilizer_code(&code.inner) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))?; + Ok(Self { inner }) + } + + /// Number of physical qubits. + #[getter] + fn num_qubits(&self) -> usize { + self.inner.num_qubits() + } + + /// Number of encoded logical qubits. + #[getter] + fn num_logical_qubits(&self) -> usize { + self.inner.num_logical_qubits() + } + + /// Stabilizer generators. + #[getter] + fn stabilizers(&self) -> Vec { + self.inner + .stabilizers() + .iter() + .cloned() + .map(PauliString::from_rust) + .collect() + } + + /// Destabilizer generators. + #[getter] + fn destabilizers(&self) -> Vec { + self.inner + .destabilizers() + .iter() + .cloned() + .map(PauliString::from_rust) + .collect() + } + + /// Logical Z operators. + #[getter] + fn logical_zs(&self) -> Vec { + self.inner + .logical_zs() + .iter() + .cloned() + .map(PauliString::from_rust) + .collect() + } + + /// Logical X operators. + #[getter] + fn logical_xs(&self) -> Vec { + self.inner + .logical_xs() + .iter() + .cloned() + .map(PauliString::from_rust) + .collect() + } + + /// Verify all stabilizer and logical commutation relations. + fn verify(&self) -> PyResult<()> { + self.inner + .verify() + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) + } + + /// Find the code distance and one minimum-weight logical operator. + #[pyo3(signature = (max_weight=None, css=false))] + fn distance(&self, max_weight: Option, css: bool) -> Option { + let config = DistanceSearchConfig { + max_weight, + css_only: css, + verbose: false, + }; + calculate_distance(&self.inner, &config).map(PyDistanceResult::from) + } + + /// Find all logical operators at the minimum weight searched. + #[pyo3(signature = (max_weight=None, css=false))] + fn min_weight_logicals( + &self, + max_weight: Option, + css: bool, + ) -> Vec { + let config = DistanceSearchConfig { + max_weight, + css_only: css, + verbose: false, + }; + find_min_weight_logicals_with_info(&self.inner, &config) + .into_iter() + .map(PyLogicalOperatorInfo::from) + .collect() + } + + fn __str__(&self) -> String { + self.inner.to_string() + } + + fn __repr__(&self) -> String { + format!( + "StabilizerCodeSpec([[{}, {}]])", + self.inner.num_qubits(), + self.inner.num_logical_qubits() + ) + } +} + +/// Register stabilizer-code specification and distance-result types. +pub fn register_stabilizer_code_spec_types(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/python/quantum-pecos/src/pecos/quantum/__init__.py b/python/quantum-pecos/src/pecos/quantum/__init__.py index 6cd99b993..e66de2863 100644 --- a/python/quantum-pecos/src/pecos/quantum/__init__.py +++ b/python/quantum-pecos/src/pecos/quantum/__init__.py @@ -108,6 +108,7 @@ SZ, SZZ, CliffordRep, + DistanceResult, F, F2dg, F3dg, @@ -118,11 +119,14 @@ GateDefBuilder, GateRegistry, H, + LogicalOperatorInfo, Pauli, PauliSequence, PauliStabilizerGroup, PauliString, StabilizerCode, + StabilizerCodeSpec, + StabilizerCodeSpecBuilder, SXdg, SXXdg, SYdg, @@ -131,8 +135,11 @@ SZZdg, TableauWrapper, X, + Xs, Y, + Ys, Z, + Zs, adjust_tableau_string, sparse_stab, ) @@ -290,6 +297,7 @@ def pauli_string( "CliffordRep", "DagCircuit", "DagCircuitWouldCycleError", + "DistanceResult", "F", "F2dg", "F3dg", @@ -305,6 +313,7 @@ def pauli_string( "HostedGateRecord", "HostedOperationBinding", "HugrConversionError", + "LogicalOperatorInfo", "Pauli", "PauliSequence", "PauliStabilizerGroup", @@ -318,6 +327,8 @@ def pauli_string( "SZZdg", "SZdg", "StabilizerCode", + "StabilizerCodeSpec", + "StabilizerCodeSpecBuilder", "TableauWrapper", "Tick", "TickCircuit", @@ -325,8 +336,11 @@ def pauli_string( "TickMeasureHandle", "TickPrepHandle", "X", + "Xs", "Y", + "Ys", "Z", + "Zs", "adjust_tableau_string", "commute", "gate_groups", diff --git a/python/quantum-pecos/src/pecos/tools/fault_tolerance_checks.py b/python/quantum-pecos/src/pecos/tools/fault_tolerance_checks.py deleted file mode 100644 index fc1fa13fa..000000000 --- a/python/quantum-pecos/src/pecos/tools/fault_tolerance_checks.py +++ /dev/null @@ -1,457 +0,0 @@ -"""Fault tolerance verification for quantum error correction.""" - -# Copyright 2018 The PECOS Developers -# Copyright 2018 National Technology & Engineering Solutions of Sandia, LLC (NTESS). Under the terms of Contract -# DE-NA0003525 with NTESS, the U.S. Government retains certain rights in this software. -# -# 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. -from __future__ import annotations - -import itertools as it -from itertools import combinations, product -from typing import TYPE_CHECKING, TypeVar - -import pecos as pc -from pecos.analysis.stabilizer_funcs import circ2set, find_stab, op_commutes, remove_stab -from pecos.circuits import LogicalCircuit, QuantumCircuit -from pecos.decoders import MWPM2D -from pecos.engines.circuit_runners import Standard -from pecos.noise.parent_class_error_gen import ErrorCircuits -from pecos.simulators import SparseStabPy - -if TYPE_CHECKING: - from collections.abc import Iterable - - from pecos.protocols import Decoder, QECCProtocol, SimulatorProtocol - -T = TypeVar("T") - - -def powerset( - iterable: Iterable[T], - bound: int | None = None, -) -> it.chain[tuple[T, ...]]: - """Returns the power set of an iterable.""" - powerlist = list(iterable) - if bound is None: - bound = len(powerlist) - return it.chain.from_iterable(it.combinations(powerlist, t) for t in range(bound + 1)) - - -def t_errors_check( - qecc: QECCProtocol, - logical_gate: QuantumCircuit | LogicalCircuit | None = None, - syn_extract: QuantumCircuit | LogicalCircuit | None = None, - decoder: Decoder | None = None, - t_weight: int | None = None, - error_set: Iterable[tuple[set[int], set[int]]] | None = None, - *, - verbose: bool = True, - data_errors: bool = True, - ancilla_errors: bool = False, -) -> tuple[bool, int]: - """Check exRec conditions for fault-free error correction or logical gate. - - This checks that the exRec conditions for a fault-free error correction (EC) or logical gate (Ga) as described in - arXiv:quant-ph/0504218. - - For fault-free EC, weight <= t errors in produce no errors out. - - For fault-free Ga, weight <= t errors in produce weight <= errors out. - - - Fault-free EC: - ------------------ - error wt <= t -> |EC (fault free) | -> no errors => No syndrome in subsequent fault-free EC (+ no logical faults) - ------------------ - - - Fault-free Ga: - ------------------ - error wt <= t -> |Ga (fault free) | ->error wt <= t => A following fault-free EC + Recovery will result in a state - ------------------ - with no logical fault. - - - Args: - ---- - qecc: The quantum error correcting code instance. - logical_gate(QuantumCircuit): The logical gate circuit to test (None for error correction only). - syn_extract(QuantumCircuit): The syndrome extraction circuit to use. - decoder: The decoder instance for error correction. - t_weight: The maximum weight of errors to check (typically pc.floor((distance-1)/2)). - error_set: Custom set of errors to check (if None, all Pauli errors are checked). - verbose: If True, prints detailed information about failures. - data_errors: If True, includes errors on data qubits. - ancilla_errors: If True, includes errors on ancilla qubits. - - Returns: - ------- - tuple (bool, int): The bool is whether the check is passed. The int is the weight of error last checked. If the - bool is True then int == t_weight. If bool == False, int == weight of error that caused a logical error. - - """ - qudit_set = set() - - if data_errors: - qudit_set.update(qecc.data_qudit_set) - - if ancilla_errors: - qudit_set.update(qecc.ancilla_qudit_set) - - if t_weight is None: - t_weight = pc.floor((qecc.distance - 1) / 2) - - if error_set is None: - error_set = {"X", "Y", "Z"} - - circ_sim = Standard() - - # init |0> circuit - initzero = LogicalCircuit(suppress_warning=True) - initzero.append(qecc.gate("ideal init |0>")) - - # init |+> circuit - initplus = LogicalCircuit(suppress_warning=True) - initplus.append(qecc.gate("ideal init |+>")) - - if syn_extract is not None and logical_gate is not None: - msg = "Both syn_extract and logical_gate cannot be set (not None)." - raise Exception(msg) - - if syn_extract is None: - # Syndrome extraction - syn_extract = LogicalCircuit(suppress_warning=True) - syn_extract.append(qecc.gate("I", num_syn_extract=1, forced_outcome=1)) - - logic = syn_extract if logical_gate is None else logical_gate - - logical_ops_zero = qecc.instruction("instr_init_zero").logical_stabs[0] - logical_ops_plus = qecc.instruction("instr_init_plus").logical_stabs[0] - - if decoder is None: - decoder = MWPM2D(qecc) - - for qubit_comb in powerset(qudit_set): - if len(qubit_comb) > t_weight: - break - - error_combinations = product(error_set, repeat=len(qubit_comb)) - - for error_comb in error_combinations: - error_circ = QuantumCircuit(1) - errors = ErrorCircuits() - - errors.simple_add(0, 0, 0, before_errors=error_circ) - - for e, q in zip(error_comb, qubit_comb, strict=False): - error_circ.update(e, {q}) - - state_zero = SparseStabPy(qecc.num_qudits) - state_plus = SparseStabPy(qecc.num_qudits) - - circ_sim.run(state_zero, initzero) - circ_sim.run(state_plus, initplus) - - output, _ = circ_sim.run(state_zero, logic, error_circuits=errors) - circ_sim.run(state_plus, logic, error_circuits=errors) - - syn = output.simplified(last=True) - - if syn: - # Recovery operation - recovery = decoder.decode(syn) - circ_sim.run(state_zero, recovery) - circ_sim.run(state_plus, recovery) - - sign_zero = state_zero.logical_sign(*logical_ops_zero) - sign_plus = state_plus.logical_sign(*logical_ops_plus) - - if sign_zero or sign_plus: - if verbose: - print(errors) - return False, len(error_comb) - - if logical_gate is None: # The following is only required for EC. - # Any remaining syndromes? - output, _ = circ_sim.run(state_zero, syn_extract) - syn = output.simplified(last=True) - - if syn: - if verbose: - print(f"syndromes = {syn}") - print(errors) - return False, len(error_comb) - - return True, int(t_weight) - - -def fault_check( - qecc: QECCProtocol, - logical_gate: QuantumCircuit | LogicalCircuit | None = None, - decoder: Decoder | None = None, - t_weight: int | None = None, - error_set: Iterable[tuple[set[int], set[int]]] | None = None, - *, - verbose: bool = True, - data_errors: bool = True, - ancilla_errors: bool = False, -) -> tuple[bool, int]: - """Check exRec conditions for faulty error correction or logical gate. - - This checks that the exRec conditions for a faulty error correction (EC) or logical gate (Ga) as described in - arXiv:quant-ph/0504218. - - For fault-free EC, weight <= t errors in produce no errors out. - - For fault-free Ga, weight <= t errors in produce weight <= errors out. - - - Fault-free EC: - ------------------ - error wt <= t -> |EC (fault free) | -> no errors => No syndrome in subsequent fault-free EC (+ no logical faults) - ------------------ - - - Fault-free Ga: - ------------------ - error wt <= t -> |Ga (fault free) | ->error wt <= t => A following fault-free EC + Recovery will result in a state - ------------------ - with no logical fault. - - - Args: - ---- - qecc: The quantum error correcting code instance. - logical_gate(QuantumCircuit): The logical gate circuit to test (None for error correction only). - decoder: The decoder instance for error correction. - t_weight: The maximum weight of errors to check (typically pc.floor((distance-1)/2)). - error_set: Custom set of errors to check (if None, all Pauli errors are checked). - verbose: If True, prints detailed information about failures. - data_errors: If True, includes errors on data qubits. - ancilla_errors: If True, includes errors on ancilla qubits. - - Returns: - ------- - tuple (bool, int): The bool is whether the check is passed. The int is the weight of error last checked. If the - bool is True then int == t_weight. If bool == False, int == weight of error that caused a logical error. - - """ - qudit_set = set() - - if data_errors: - qudit_set.update(qecc.data_qudit_set) - - if ancilla_errors: - qudit_set.update(qecc.ancilla_qudit_set) - - if t_weight is None: - t_weight = pc.floor((qecc.distance - 1) / 2) - - if error_set is None: - error_set = {"X", "Y", "Z"} - - circ_sim = Standard() - - # init |0> circuit - initzero = LogicalCircuit(suppress_warning=True) - initzero.append(qecc.gate("ideal init |0>")) - - # init |+> circuit - initplus = LogicalCircuit(suppress_warning=True) - initplus.append(qecc.gate("ideal init |+>")) - - if logical_gate is None: - # Syndrome extraction - syn_extract = LogicalCircuit(suppress_warning=True) - syn_extract.append(qecc.gate("I", num_syn_extract=1, forced_outcome=1)) - logic = syn_extract - else: - logic = logical_gate - - logical_ops_zero = qecc.instruction("instr_init_zero").logical_stabs[0] - logical_ops_plus = qecc.instruction("instr_init_plus").logical_stabs[0] - - if decoder is None: - decoder = MWPM2D(qecc) - - for qubit_comb in powerset(qudit_set): - if len(qubit_comb) > t_weight: - break - - error_combinations = product(error_set, repeat=len(qubit_comb)) - - for error_comb in error_combinations: - error_circ = QuantumCircuit(1) - errors = ErrorCircuits() - - errors.simple_add(0, 0, 0, before_errors=error_circ) - - for e, q in zip(error_comb, qubit_comb, strict=False): - error_circ.update(e, {q}) - - state_zero = SparseStabPy(qecc.num_qudits) - state_plus = SparseStabPy(qecc.num_qudits) - - circ_sim.run(state_zero, initzero) - circ_sim.run(state_plus, initplus) - - output, _ = circ_sim.run(state_zero, logic, error_circuits=errors) - circ_sim.run(state_plus, logic, error_circuits=errors) - - syn = output.simplified(last=True) - - if syn: - # Recovery operation - recovery = decoder.decode(syn) - circ_sim.run(state_zero, recovery) - circ_sim.run(state_plus, recovery) - - sign_zero = state_zero.logical_sign(*logical_ops_zero) - sign_plus = state_plus.logical_sign(*logical_ops_plus) - - if sign_zero or sign_plus: - if verbose: - print(errors) - return False, len(error_comb) - - return True, int(t_weight) - - -def distance_check( - qecc: QECCProtocol, - mode: str | None = None, - dist_mode: str | None = None, -) -> int: - """Determines the distance of the code by looking for the smallest logical errors. - - Args: - ---- - qecc: The quantum error correcting code instance. - mode: The mode for distance checking ('X', 'x', 'Z', 'z', or None for automatic). - dist_mode: The specific distance checking mode to use (if None, uses default based on mode). - - Returns: - ------- - Tuple (bool, int). The bool is whether the check is passed. The int is the weight of error last checked. If the - bool is True then int == t_weight. If bool == False, int == weight of error that caused a logical error. - - """ - qudit_set = qecc.data_qudit_set - - circ_sim = Standard() - state = SparseStabPy(qecc.num_qudits) - - ideal_initlogic = LogicalCircuit(suppress_warning=True) - ideal_initlogic.append(qecc.gate("ideal init |0>")) - - circ_sim.run(state, ideal_initlogic) - - logical_op, delogical_op = qecc.instruction("instr_init_zero").logical_stabs[0] - - destab_xs, destab_zs = circ2set(delogical_op.items(params=False)) - stab_xs, stab_zs = circ2set(logical_op.items(params=False)) - - remove_stab(state, stab_xs, stab_zs, destab_xs, destab_zs) - - if dist_mode is None: - if mode in {"X", "x"}: - print("x") - return dist_mode_x(state, qudit_set) - if mode in {"Z", "z"}: - print("z") - return dist_mode_z(state, qudit_set) - if mode == "power": - return dist_mode_powerset(state, qudit_set) - return dist_mode_smallest(state, qudit_set) - - return dist_mode(state, qudit_set) - - -def dist_mode_powerset(state: SimulatorProtocol, qudit_set: set[int]) -> str | bool: - """Check for logical errors using powerset of all possible X and Z errors. - - Args: - state: Stabilizer state to check. - qudit_set: Set of qudit/qubit indices to check for errors. - """ - for x_errors in powerset(qudit_set): - for z_errors in powerset(qudit_set): - if op_commutes(x_errors, z_errors, state.stabs) and not find_stab( - state, - x_errors, - z_errors, - ): - return f"Logical error found: Xs - {x_errors} Zs - {z_errors}" - - return False - - -def dist_mode_smallest(state: SimulatorProtocol, qudit_set: set[int]) -> str | bool: - """Find smallest logical error by checking errors in increasing size. - - Args: - ---- - state: Stabilizer state to check. - qudit_set: Set of qudit/qubit indices to check for errors. - """ - for lenq in range(len(qudit_set) + 1): - for qs in combinations(qudit_set, lenq): - if op_commutes(qs, qs, state.stabs) and not find_stab(state, qs, qs): - return f"Logical error found: Xs - {qs} Zs - {qs}" - - for qs2 in powerset(qudit_set, len(qs) - 1): - if op_commutes(qs2, qs, state.stabs) and not find_stab(state, qs2, qs): - return f"Logical error found: Xs - {qs2} Zs - {qs}" - - if op_commutes(qs, qs2, state.stabs) and not find_stab(state, qs, qs2): - return f"Logical error found: Xs - {qs} Zs - {qs2}" - - return False - - -def dist_mode_x(state: SimulatorProtocol, qudit_set: set[int]) -> str | bool: - """Check for X-type logical errors only. - - Args: - ---- - state: Stabilizer state to check. - qudit_set: Set of qudit/qubit indices to check for errors. - """ - z_errors = () - for x_errors in powerset(qudit_set): - if op_commutes(x_errors, z_errors, state.stabs) and not find_stab( - state, - x_errors, - z_errors, - ): - return f"Logical error found: Xs - {x_errors} Zs - {z_errors}" - - return False - - -def dist_mode_z(state: SimulatorProtocol, qudit_set: set[int]) -> str | bool: - """Check for Z-type logical errors only. - - Args: - ---- - state: Stabilizer state to check. - qudit_set: Set of qudit/qubit indices to check for errors. - """ - x_errors = () - for z_errors in powerset(qudit_set): - if op_commutes(x_errors, z_errors, state.stabs) and not find_stab( - state, - x_errors, - z_errors, - ): - return f"Logical error found: Xs - {x_errors} Zs - {z_errors}" - - return False diff --git a/python/quantum-pecos/src/pecos/tools/stabilizer_verification.py b/python/quantum-pecos/src/pecos/tools/stabilizer_verification.py deleted file mode 100644 index 0feb0974f..000000000 --- a/python/quantum-pecos/src/pecos/tools/stabilizer_verification.py +++ /dev/null @@ -1,1117 +0,0 @@ -"""Stabilizer verification tools for quantum error correction. - -This module provides utilities for verifying stabilizer codes and analyzing -their properties, including stabilizer group verification, code distance -calculation, and logical operator validation. -""" - -# Copyright 2018 The PECOS Developers -# Copyright 2018 National Technology & Engineering Solutions of Sandia, LLC (NTESS). Under the terms of Contract -# DE-NA0003525 with NTESS, the U.S. Government retains certain rights in this software. -# -# 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. - -from __future__ import annotations - -from itertools import combinations, product -from typing import TYPE_CHECKING - -import pecos as pc -from pecos.circuits import QuantumCircuit - -if TYPE_CHECKING: - from collections.abc import Generator, Sequence - - from pecos.protocols import SimulatorProtocol - from pecos.typing import LogicalOpInfo, StabilizerVerificationResult - -# TODO: NEED TO ADD SIGN TRACKING TO DESTABILIZERS TO GET THE RIGHT SIGN FOR LOGICAL Xs - - -class VerifyStabilizers: - """Used to define a stabilizer QECC.""" - - def __init__(self) -> None: - """Initialize the VerifyStabilizers instance. - - Sets up the circuit simulator and initializes empty data structures - for stabilizer checks, logical operators, and qubit tracking. - """ - self.circ_sim = pc.simulators.SparseStabPy - - self.checks = [] - self.logical_zs = [] - self.logical_xs = [] - self.logical_zs_defined = [] # User chosen logical Zs TODO: ... - self.logical_xs_defined = [] # User chosen logical Xs TODO: ... - self.logical_zs_reference = {} - self.logical_xs_reference = {} - - self.data_qubits = set() - self.ancilla_qubits = set() - self.circuit = None - - self.check_row_x = None - self.check_row_z = None - self.check_col_x = None - self.check_col_z = None - - # stabilizer ids: - self.data_gens = None - self.ancilla_gens = None - self.logical_gens = None - - # distance - self.dist = None - - self.state = None - - def check(self, paulis: str | Sequence[str], qubits: Sequence[int]) -> None: - """Check if the given Pauli operators stabilize the state. - - Args: - paulis: Sequence of Pauli operators to check (e.g., ['X', 'Z', 'Y']). - qubits: Sequence of qubit indices corresponding to the Pauli operators. - - Returns: None - - """ - if not qubits: - msg = "No qubit ids given." - raise Exception(msg) - - check_string = "check(" + str(paulis) + ", " + str(qubits) + ")" - - if isinstance(paulis, str): - if paulis not in {"X", "x", "Y", "y", "Z", "z"}: - msg = 'Paulis should be "X", "Y" or "Z"!' - raise Exception(msg) - - paulis_new = [paulis for _ in qubits] - paulis = paulis_new - - if not isinstance(paulis, str) and len(paulis) != len(qubits): - msg = "Number of Paulis and qubits do not match!!!" - raise Exception(msg) - - self.checks.append((paulis, qubits, check_string)) - self.data_qubits.update(qubits) - - def logicalz(self, paulis: str | Sequence[str], qubits: Sequence[int]) -> None: - """Used to define logical Z. - - Args: - ---- - paulis: Either a single Pauli string ('X', 'Y', or 'Z') or a list of Pauli operators. - qubits: List of qubit indices where the logical Z operator acts. - """ - if not qubits: - msg = "No qubit ids given." - raise Exception(msg) - - logical_string = "check(" + str(paulis) + ", " + str(qubits) + ")" - - if isinstance(paulis, str): - if paulis not in {"X", "x", "Y", "y", "Z", "z"}: - msg = 'Paulis should be "X", "Y" or "Z"!' - raise Exception(msg) - - paulis_new = [paulis for _ in qubits] - paulis = paulis_new - - if not isinstance(paulis, str) and len(paulis) != len(qubits): - msg = "Number of Paulis and qubits do not match!!!" - raise Exception(msg) - - self.logical_zs.append((paulis, qubits, logical_string)) - - def logicalx(self, paulis: str | Sequence[str], qubits: Sequence[int]) -> None: - """Used to define logical X. - - Args: - ---- - paulis: Either a single Pauli string ('X', 'Y', or 'Z') or a list of Pauli operators. - qubits: List of qubit indices where the logical X operator acts. - """ - if not qubits: - msg = "No qubit ids given." - raise Exception(msg) - - logical_string = "check(" + str(paulis) + ", " + str(qubits) + ")" - - if isinstance(paulis, str): - if paulis not in {"X", "x", "Y", "y", "Z", "z"}: - msg = 'Paulis should be "X", "Y" or "Z"!' - raise Exception(msg) - - paulis_new = [paulis for _ in qubits] - paulis = paulis_new - - if not isinstance(paulis, str) and len(paulis) != len(qubits): - msg = "Number of Paulis and qubits do not match!!!" - raise Exception(msg) - - self.logical_xs.append((paulis, qubits, logical_string)) - - def num_logical_qubits(self) -> int: - """Calculate the number of logical qubits in the stabilizer code. - - Returns: - Number of logical qubits (data qubits minus stabilizer checks). - """ - return len(self.data_qubits) - len(self.checks) - - def generators( - self, - *, - print_y: bool = True, - verbose: bool = True, - ) -> tuple[ - list[dict[str, set[int]]], - list[dict[str, set[int]]], - list[str], - list[str], - ]: - """Evaluates the stabilizer generators that have been supplied via the `check` method. - - Args: - ---- - print_y: If True, includes Y operators in the output (otherwise converts to X and Z). - verbose: If True, prints detailed information about the generators. - """ - if self.circuit is None: - msg = "Must compile circuits first!" - raise Exception(msg) - - state = self.state - z, x, stab_strings, destab_strings = self.get_info( - state, - print_y=print_y, - verbose=verbose, - ) - - return z, x, stab_strings, destab_strings - - def _check_all_labels(self) -> None: - """This checks to see that all the consecutive qubit ids have been used and none are missing.""" - qubit_labels = set() - - checks = self.checks - for check in checks: - _, qs, _ = check - qubit_labels.update(qs) - - largest_labels = max(qubit_labels) - labels_should_have = set(range(largest_labels + 1)) - - dont_have = labels_should_have - qubit_labels - - if dont_have: - msg = f"Qubit ids missing: {dont_have}" - raise Exception(msg) - - def _check2rowcol(self) -> None: - """Creates row and column matrices.""" - checks = self.checks - - num_checks = len(checks) - row_x = [set() for _ in range(num_checks)] - row_z = [set() for _ in range(num_checks)] - col_x = [set() for _ in range(self.num_data_qubits)] - col_z = [set() for _ in range(self.num_data_qubits)] - - for stab_id, check in enumerate(checks): - ps, qs, _ = check - - for p, q in zip(ps, qs, strict=False): - if p in {"X", "x"}: - row_x[stab_id].add(q) - col_x[q].add(stab_id) - - elif p in {"Z", "z"}: - row_z[stab_id].add(q) - col_z[q].add(stab_id) - - elif p in {"Y", "y"}: - row_x[stab_id].add(q) - row_z[stab_id].add(q) - col_x[q].add(stab_id) - col_z[q].add(stab_id) - - self.check_row_x = row_x - self.check_row_z = row_z - self.check_col_x = col_x - self.check_col_z = col_z - - def _check_commute(self) -> bool: - """Checks to see that all the stabilizer generators commute. - - Returns: - Returns bool value if all the checks commute or not. - """ - row_x = self.check_row_x - row_z = self.check_row_z - col_x = self.check_col_x - col_z = self.check_col_z - - for stab_id in range(len(self.checks)): - anti_zs = set() - for q in row_x[stab_id]: - anti_zs ^= col_z[q] - - anti_xs = set() - for q in row_z[stab_id]: - anti_xs ^= col_x[q] - - anti = anti_xs ^ anti_zs - anti.discard(stab_id) - - if anti: - print("\nChecks anticommute!") - print("\nCheck:") - for s in anti: - print(self.checks[s][2]) - print("\nanticommutes with:") - print(self.checks[stab_id][2]) - - msg = "Checks anticommute!" - raise Exception(msg) - return True - - def compile(self) -> None: - """Checks commutation relations and creates a circuit to measure the checks.""" - if self.circuit: - msg = "Measurement encoding-circuit has already been compiled!" - raise Exception(msg) - - # Check the qubit ids - self._check_all_labels() - - # Create row and column matrices. - self._check2rowcol() - - # Checks that all the stabilizer generators (checks) commute. - self._check_commute() - - # Create check circuits: - # ---------------------- - ancilla_qubits = set() - qc = QuantumCircuit() - - ancilla_id = sorted(self.data_qubits)[-1] - - for ps, qs, _ in self.checks: - ancilla_id += 1 - ancilla_qubits.add(ancilla_id) - qc.append("init |+>", {ancilla_id}) - - for p, q in zip(ps, qs, strict=False): - symbol = None - - if p in {"X", "x"}: - symbol = "CNOT" - elif p in {"Z", "z"}: - symbol = "CZ" - elif p in {"Y", "y"}: - symbol = "CY" - - qc.append(symbol, {(ancilla_id, q)}) - - qc.append("measure X", {ancilla_id}, random_outcome=0) - - self.ancilla_qubits = ancilla_qubits - - self.circuit = qc - - # Run circuits - # ------------ - # Separate the checks, logical stabilizers, and ancilla stabilizers. - circuit = self.circuit - state = pc.simulators.SparseStabPy(self.num_qubits) - state.run_circuit(circuit) - self.get_info(state, verbose=False) - self.state = state - - self._verify_checks() - self._check_logical_commute() - - def _check_logical_commute(self) -> None: - logical_z_col_x = [set() for _ in range(self.num_data_qubits)] - logical_z_col_z = [set() for _ in range(self.num_data_qubits)] - logical_z_row_x = [set() for _ in range(len(self.logical_zs))] - logical_z_row_z = [set() for _ in range(len(self.logical_zs))] - - logical_x_col_x = [set() for _ in range(self.num_data_qubits)] - logical_x_col_z = [set() for _ in range(self.num_data_qubits)] - logical_x_row_x = [set() for _ in range(len(self.logical_xs))] - logical_x_row_z = [set() for _ in range(len(self.logical_xs))] - - for i, (ps, qs, _) in enumerate(self.logical_zs): - for p, q in zip(ps, qs, strict=False): - if p in {"X", "Y"}: - logical_z_col_x[q].add(i) - logical_z_row_x[i].add(q) - - if p in {"Z", "Y"}: - logical_z_col_z[q].add(i) - logical_z_row_z[i].add(q) - - for i, (ps, qs, _) in enumerate(self.logical_xs): - for p, q in zip(ps, qs, strict=False): - if p in {"X", "Y"}: - logical_x_col_x[q].add(i) - logical_x_row_x[i].add(q) - - if p in {"Z", "Y"}: - logical_x_col_z[q].add(i) - logical_x_row_z[i].add(q) - - for s in range(len(self.logical_zs)): - anti_zs = set() - for q in logical_z_row_x[s]: - anti_zs ^= logical_z_col_z[q] - - anti_xs = set() - for q in logical_z_row_z[s]: - anti_xs ^= logical_z_col_x[q] - - anti = anti_xs ^ anti_zs - anti.discard(s) - - if anti: - print("\nLogical Zs anticommute!") - print("\nLogical Zs:") - for i in anti: - print(self.logical_zs[i][2]) - print("\nanticommutes with:") - print(self.logical_zs[s][2]) - - msg = "Logical Zs anticommute!" - raise Exception(msg) - - for s in range(len(self.logical_xs)): - anti_zs = set() - for q in logical_x_row_x[s]: - anti_zs ^= logical_x_col_z[q] - - anti_xs = set() - for q in logical_x_row_z[s]: - anti_xs ^= logical_x_col_x[q] - - anti = anti_xs ^ anti_zs - anti.discard(s) - - if anti: - print("\nLogical Xs anticommute!") - print("\nLogical Xs:") - for i in anti: - print(self.logical_xs[i][2]) - print("\nanticommutes with:") - print(self.logical_xs[s][2]) - - msg = "Logical Xs anticommute!" - raise Exception(msg) - - # So far checked that all the logical Zs and logical Xs commute with themselves... - # - Next check that they commute with the stabilizers... - # - Then find if the there are anti-commuting pairs of logical Zs and Xs - # - Then search for the logical operators and refactor... - # This step might require switching logical Xs and Zs... Might be a bit complicated... as we can modify - # "logical Xs" with destabilizers and swap those... So need to do a search through all stabilizers and - # destabilizers and then determine if the required multiplication is valid with fix stabiliziers and whatever - # has been fixed for the logical operators... - - def _verify_checks(self) -> bool: - # Stabilizers: - checks = [] - - for strings, qids, _ in self.checks: - check_dict = {} - for pauli, q in zip(strings, qids, strict=False): - if pauli == "X": - qset = check_dict.setdefault("X", set()) - elif pauli == "Z": - qset = check_dict.setdefault("Z", set()) - else: - qset = check_dict.setdefault("Y", set()) - qset.add(q) - checks.append(check_dict) - - checks2 = [] - for i in range(len(self.checks)): - xs = self.check_row_x[i] - zs = self.check_row_z[i] - - stab_dict = {} - - if xs - zs: - stab_dict["X"] = xs - zs - - if zs - xs: - stab_dict["Z"] = zs - xs - - if xs & zs: - stab_dict["Y"] = xs & zs - - checks2.append(stab_dict) - - if checks != checks2: - print( - "WARNING: PECOS didn't refactor the stabilizers into the checks supplied!", - ) - - return checks != checks2 - - def eval(self, *, verbose: bool = False) -> StabilizerVerificationResult: - """Evaluate the stabilizer code verification. - - Args: - verbose: Whether to print detailed output during evaluation. - - Returns: - Verification result containing success status and error details. - """ - if self.circuit is None: - self.compile() - - z, x, _, destab_strings = self.generators(verbose=verbose) - - if self.dist is None: - self.distance(verbose=verbose) - - # Stabilizers: - checks = [] - - for strings, qids, _ in self.checks: - check_dict = {} - for pauli, q in zip(strings, qids, strict=False): - if pauli == "X": - qset = check_dict.setdefault("X", set()) - elif pauli == "Z": - qset = check_dict.setdefault("Z", set()) - else: - qset = check_dict.setdefault("Y", set()) - qset.add(q) - checks.append(check_dict) - - # Destabilizers: - destabs = [] - for i in self.data_gens: - destab_dict = {} - for j in range(len(destab_strings[i]) - self.num_ancilla_qubits): - pauli = destab_strings[i][j] - if pauli == "X": - qset = destab_dict.setdefault("X", set()) - elif pauli == "Z": - qset = destab_dict.setdefault("Z", set()) - elif pauli == "Y": - qset = destab_dict.setdefault("Y", set()) - else: - continue - qset.add(j - 2) - destabs.append(destab_dict) - - output_dict = { - "num_datas": self.num_data_qubits, - "num_logical_qubits": self.num_logical_qubits(), - "distance": self.dist, - "[[n, k, d]]": f"[[{self.num_data_qubits}, {self.num_logical_qubits()}, {self.dist}]]", - "checks": checks, - "destabilizers": destabs, - "logical_xs": x, - "logical_zs": z, - } - - self.logical_xs_reference = {} - self.logical_zs_reference = {} - - for i, xi in enumerate(x): - self.logical_xs_reference["X" + str(i)] = xi - - for i, zi in enumerate(z): - self.logical_zs_reference["Z" + str(i)] = zi - - return output_dict - - @property - def num_data_qubits(self) -> int: - """Get the number of data qubits. - - Returns: - Number of data qubits in the stabilizer code. - """ - return len(self.data_qubits) - - @property - def num_ancilla_qubits(self) -> int: - """Get the number of ancilla qubits. - - Returns: - Number of ancilla qubits in the stabilizer code. - """ - return len(self.ancilla_qubits) - - @property - def num_qubits(self) -> int: - """Get the total number of qubits. - - Returns: - Total number of qubits (data + ancilla). - """ - return len(self.data_qubits) + len(self.ancilla_qubits) - - def refactor(self, state: SimulatorProtocol) -> None: - """Refactor the stabilizer state to match the expected generators. - - Args: - state: Simulator state to refactor. - """ - found_stab_ids = set() - - refactor_things = list(self.checks) - refactor_things.extend(self.logical_zs) - # TODO: NEED TO REFACTOR THE DESTABILIZER OF LOGICAL Z TO GET THE RIGHT LOGICAL X..... - - for ps, qs, _ in refactor_things: - xs = set() - zs = set() - - for p, q in zip(ps, qs, strict=False): - if p in {"X", "x"}: - xs.add(q) - elif p in {"Z", "z"}: - zs.add(q) - elif p in {"Y", "y"}: - xs.add(q) - zs.add(q) - - try: - found, stab_id = state.refactor( - xs, - zs, - choose=0, - protected=found_stab_ids, - ) - except IndexError: - xonly = xs - zs - zonly = zs - xs - ys = xs & zs - msg = f"IndexError.\nThe stabilizer {{'X': {xonly}, 'Y': {ys}, 'Z': {zonly}}} is likely redundant!" - raise Exception(msg) from IndexError - - found_stab_ids.add(stab_id) - - if not found: - msg = "Could not find check:" - raise Exception(msg, (ps, qs)) - - for q in self.ancilla_qubits: - found, stab_id = state.refactor( - {q}, - set(), - choose=-1, - protected=found_stab_ids, - ) - found_stab_ids.add(stab_id) - - if not found: - msg = f"Could not find ancilla {q}" - raise Exception(msg) - - def get_check_ancilla( - self, - ) -> tuple[list[tuple[set[int], set[int]]], list[tuple[set[int], set[int]]]]: - """Get check and ancilla operator sets. - - Returns: - Tuple containing lists of (X set, Z set) tuples for checks and ancillas. - """ - check_tuples = [] - ancilla_tuples = [] - - for ps, qs, _ in self.checks: - xs = set() - zs = set() - - for p, q in zip(ps, qs, strict=False): - if p in {"X", "x"}: - xs.add(q) - elif p in {"Z", "z"}: - zs.add(q) - elif p in {"Y", "y"}: - xs.add(q) - zs.add(q) - - check_tuples.append((xs, zs)) - - ancilla_tuples.extend(({q}, set()) for q in self.ancilla_qubits) - - return check_tuples, ancilla_tuples - - def get_info( - self, - state: SimulatorProtocol, - stop_search: int = 1000, - *, - verbose: bool = True, - print_y: bool = False, - ) -> tuple[ - list[dict[str, set[int]]], - list[dict[str, set[int]]], - list[str], - list[str], - ]: - """Get stabilizer information from the quantum state. - - Args: - state: Simulator state to analyze. - stop_search: Maximum number of refactoring attempts. - verbose: Whether to print detailed information. - print_y: Whether to include Y operators in output. - - Returns: - Tuple of logical Z operators, logical X operators, stabilizer strings, destabilizer strings. - """ - if self.circuit is None: - return Exception("Must run `compile()` first!") - - self.refactor(state) - stab_strs, destab_strs = state.print_stabs( - verbose=False, - print_y=print_y, - print_destabs=True, - ) - - num_ancillas = len(self.ancilla_qubits) - - num_logical = self.num_logical_qubits() - num_checks = len(self.checks) - - if verbose: - print(f"Number of data qubits: {self.num_data_qubits}") - print(f"Number of checks: {num_checks}") - print(f"Number of logical qubits: {num_logical}") - - check_tuples, ancilla_tuples = self.get_check_ancilla() - # determine the gen_id of the checks and logicals - check_gens = [] - logical_gens = [] - ancilla_gens = [] - - missing_checks = list(check_tuples) - missing_ancillas = list(ancilla_tuples) - notmatched_gens = list(range(state.num_qubits)) - - found_all = False - search_count = 0 - while not found_all: - if verbose: - print("----") - for g, gtuple in enumerate( - zip(state.stabs.row_x, state.stabs.row_z, strict=False), - ): - if gtuple in missing_checks: - missing_checks.remove(gtuple) - try: - notmatched_gens.remove(g) - except ValueError: - msg = f"list.remove(x): x not in list.\nThe stabilizer {gtuple!s} is likely redundant!" - raise Exception( - msg, - ) from ValueError - - check_gens.append(g) - elif gtuple in missing_ancillas: - missing_ancillas.remove(gtuple) - notmatched_gens.remove(g) - ancilla_gens.append(g) - - if len(notmatched_gens) == num_logical: - logical_gens = notmatched_gens - found_all = True - else: - for xs, zs in missing_checks: - state.refactor(xs, zs, choose=0, prefer=notmatched_gens) - state.print_stabs(verbose=False, print_y=print_y, print_destabs=True) - - if search_count == stop_search: - msg = "Can not refactor properly!" - raise Exception(msg) - search_count += 1 - - self.data_gens = set(check_gens) - self.ancilla_gens = set(ancilla_gens) - self.logical_gens = set(logical_gens) - - if verbose: - if len(check_gens) != num_checks: - print("Found:", check_gens) - print("Want:", check_tuples) - msg = f"Did not find the correct number of stabilizer generators. {len(check_gens)}/{num_checks}" - raise Exception(msg) - - if len(logical_gens) != num_logical: - print("Found:", logical_gens) - msg = f"Did not find the correct number of logical generators. {len(logical_gens)}/{num_logical}" - raise Exception(msg) - - print("\nStabilizer generators:") - for gen in check_gens: - print(stab_strs[gen][: len(stab_strs[gen]) - num_ancillas]) - - print("\nDestabilizer generators:") - for gen in check_gens: - print(destab_strs[gen][: len(destab_strs[gen]) - num_ancillas]) - - print("\nLogical operators:") - - for i, gen in enumerate(logical_gens): - print(f"\n. Logical Z #{i + 1!s}:") - print(stab_strs[gen][: len(stab_strs[gen]) - num_ancillas]) - print(f". Logical X #{i + 1!s}:") - print(destab_strs[gen][: len(destab_strs[gen]) - num_ancillas]) - - logical_z_strings = [] - logical_x_strings = [] - - for gen in logical_gens: - z_string = stab_strs[gen][: len(stab_strs[gen]) - num_ancillas] - x_string = destab_strs[gen][: len(destab_strs[gen]) - num_ancillas] - - check_dict = {} - for q, pauli in enumerate(z_string): - if pauli == "X": - qset = check_dict.setdefault("X", set()) - elif pauli == "Z": - qset = check_dict.setdefault("Z", set()) - elif pauli == "Y": - qset = check_dict.setdefault("Y", set()) - else: - continue - qset.add(q - 2) - logical_z_strings.append(check_dict) - - check_dict = {} - for q, pauli in enumerate(x_string): - if pauli == "X": - qset = check_dict.setdefault("X", set()) - elif pauli == "Z": - qset = check_dict.setdefault("Z", set()) - elif pauli == "Y": - qset = check_dict.setdefault("Y", set()) - else: - continue - - qset.add(q - 2) - logical_x_strings.append(check_dict) - - return logical_z_strings, logical_x_strings, stab_strs, destab_strs - - def distance( - self, - *, - css: bool = False, - verbose: bool = True, - ) -> tuple[set[int], set[int]] | None: - """Checks the distance of the code.""" - if self.circuit is None: - msg = "Must compile circuits first!" - raise Exception(msg) - - qudit_set = self.data_qubits - - state = self.state - found = self._dist_mode_smallest(state, qudit_set, css=css, verbose=verbose) - - if verbose and found: - xs, zs = found - distance = len(xs | zs) - - print( - f"\nThis is a [[{self.num_data_qubits}, {self.num_logical_qubits()}, {distance}]] code.", - ) - - if not found: - print( - "No logical errors found... Checks might describe a stabilizer state.", - ) - return None - xs, zs = found - self.dist = len(xs | zs) - return found - - def _dist_mode_smallest( - self, - state: SimulatorProtocol, - qudit_set: set[int], - *, - css: bool = False, - verbose: bool = True, - start_len: int | None = None, - end_len: int | None = None, - list_ops: bool = False, - ) -> Generator[tuple[set, set], None, None]: - """Determine if a logical error can be found by starting with the smallest weight errors. - - Args: - ---- - state: The quantum state to check for logical errors. - qudit_set: Set of qudit indices to consider for errors. - css: If True, only checks CSS (Calderbank-Shor-Steane) type errors. - verbose: If True, prints progress and found logical operators. - start_len: Starting weight of errors to check (default: 1). - end_len: Maximum weight of errors to check (default: number of qudits). - list_ops: If True, returns a list of all logical operators found. - - """ - ops = [] - - if start_len is None: - start_len = 1 - - if end_len is None: - end_len = len(qudit_set) - - for lenq in range(start_len, end_len + 1): - if verbose: - print(f"Checking Paulis of weight {lenq}...") - - for xs, zs in self.gen_errors(qudit_set, lenq, lenq, css=css): - if self._is_logical_error(state, xs, zs): - if verbose: - print(f"Logical operator found: Xs - {xs} Zs - {zs}") - - if list_ops: - ops.append({"X": xs, "Z": zs}) - else: - return xs, zs - - return ops - - def gen_errors( - self, - qubits: set[int] | Sequence[int], - min_errors: int = 1, - *, - max_errors: bool | int = False, - css: bool = False, - ) -> Generator[tuple[set[int], set[int]], None, None]: - """Generate error patterns for testing stabilizer codes. - - Args: - ---- - qubits (set of int): Set of qubit indices to generate errors on. - min_errors (int): Minimum number of errors to generate. - max_errors (bool, int): Maximum number of errors to generate. False for no limit. - css (bool): If True, generate only CSS-compatible errors (X and Z only). - - """ - paulis = ("X", "Z", "Y") - - num_qubits = len(qubits) - - for i in range(min_errors, num_qubits + 1): - if max_errors and i > max_errors: - break - - xs = next(product(("X",), repeat=i)) - zs = next(product(("Z",), repeat=i)) - - xzs = [xs, zs] - - for b in combinations(qubits, i): - for ps in xzs: - x_set = set() - z_set = set() - for p, q in zip(ps, b, strict=False): - if p == "X": - x_set.add(q) - else: - z_set.add(q) - yield x_set, z_set - - if not css: - for a in product(paulis, repeat=i): - if a in {xs, zs}: - continue - - for b in combinations(qubits, i): - x_set = set() - z_set = set() - for p, q in zip(a, b, strict=False): - if p == "X": - x_set.add(q) - elif p == "Z": - z_set.add(q) - else: - x_set.add(q) - z_set.add(q) - yield x_set, z_set - - def _is_logical_error( - self, - state: SimulatorProtocol, - xs: set[int], - zs: set[int], - ) -> bool: - # A trivial error anticommutes with the checks. (Might or might not anticommute with the logical stabilizers) - # A logical error commutes with the checks and is not a product of checks. - - # Does the error anticommute with the checks? - x_anticoms = set() - z_anticoms = set() - for q in xs: - x_anticoms ^= state.stabs.col_z[q] - - for q in zs: - z_anticoms ^= state.stabs.col_x[q] - - anticoms = x_anticoms ^ z_anticoms - anticom_logical_zs = self.logical_gens & anticoms - anticoms -= self.logical_gens - - if anticoms: - return False - if anticom_logical_zs: - # So the error commutes with all the stabilizers - # Did it anticommute with any logical Z operations? If so... It is a product of logical Xs! - # (and possibly other things) - return True - # Let's see if the error anticommuted with any logical X operators: - - x_anticoms_destabs = set() - z_anticoms_destabs = set() - - for q in xs: - x_anticoms_destabs ^= state.destabs.col_z[q] - - for q in zs: - z_anticoms_destabs ^= state.destabs.col_x[q] - - anticoms_destabs = x_anticoms_destabs ^ z_anticoms_destabs - anticom_logical_xs = self.logical_gens & anticoms_destabs - - # The error is a product of logical Zs - return bool(anticom_logical_xs) - - def shortest_logicals( - self, - start_weight: int | None = None, - delta: int = 0, - *, - verbose: bool = True, - css: bool = False, - ) -> tuple[ - list[LogicalOpInfo], - dict[str, dict[str, set[int]]], - dict[str, dict[str, set[int]]], - ]: - """Find the shortest logical operators. - - Args: - start_weight (int): Weight of operators to begin searching. - delta (int): Method will look for all logical ops with weight =< minimum weight + `delta`. - verbose (bool): If True, print progress information during the search. - css (bool): If True, restrict search to CSS-compatible operators (X and Z only). - - Returns: - ------- - Dictionary of logical ops... - - """ - # if not self.logical_xs_reference and not self.logical_zs_reference: - - if start_weight is None: - start_weight = self.dist if self.dist is not None else 1 - - end_weight = start_weight + delta - - if self.circuit is None: - msg = "Must compile circuits first!" - raise Exception(msg) - - qudit_set = self.data_qubits - - end_weight = min(end_weight, len(qudit_set)) - - state = self.state - found = self._dist_mode_smallest( - state, - qudit_set, - css=css, - verbose=False, - start_len=start_weight, - end_len=end_weight, - list_ops=True, - ) - - xs_labels = sorted(self.logical_xs_reference.keys()) - zs_labels = sorted(self.logical_zs_reference.keys()) - - oplist = [] - - if found: - for paulis in found: - op_product = [] - for xi, op_label in enumerate(xs_labels): - if self.op_anticommute(paulis, self.logical_xs_reference[op_label]): - op_product.append(zs_labels[xi]) - - for zi, op_label in enumerate(zs_labels): - if self.op_anticommute(paulis, self.logical_zs_reference[op_label]): - op_product.append(xs_labels[zi]) - - op_product = sorted(op_product) - - oplist.append( - { - "X": paulis["X"], - "Z": paulis["Z"], - "equiv_ops": tuple(op_product), - }, - ) - - if verbose: - print("Reference Logical Operators:") - print("\nLogical Xs:") - for op_label in xs_labels: - op = self.logical_xs_reference[op_label] - print(op_label, op) - print("\nLogical Zs:") - for op_label in zs_labels: - op = self.logical_zs_reference[op_label] - print(op_label, op) - - print("\nLogical Ops Found:\n") - for foundop in oplist: - print( - "X - {} Z - {} Equiv Ops - {}".format( - foundop["X"], - foundop["Z"], - foundop["equiv_ops"], - ), - ) - - return oplist, self.logical_xs_reference, self.logical_zs_reference - - @staticmethod - def op_anticommute(op1: dict[str, set[int]], op2: dict[str, set[int]]) -> bool: - """Check if two Pauli operators anticommute. - - Args: - op1: First Pauli operator as dictionary with X, Y, Z keys and qubit sets. - op2: Second Pauli operator as dictionary with X, Y, Z keys and qubit sets. - - Returns: - True if the operators anticommute, False otherwise. - """ - return bool( - (len(op1.get("X", set()) & op2.get("Z", set())) + len(op2.get("X", set()) & op1.get("Z", set()))) % 2, - ) diff --git a/python/quantum-pecos/tests/pecos/test_stabilizer_code_spec_bindings.py b/python/quantum-pecos/tests/pecos/test_stabilizer_code_spec_bindings.py new file mode 100644 index 000000000..f421f78a2 --- /dev/null +++ b/python/quantum-pecos/tests/pecos/test_stabilizer_code_spec_bindings.py @@ -0,0 +1,148 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 + +from collections.abc import Callable + +import pytest +from pecos.quantum import ( + DistanceResult, + LogicalOperatorInfo, + PauliString, + StabilizerCode, + StabilizerCodeSpec, +) + + +def _five_qubit_spec() -> StabilizerCodeSpec: + return StabilizerCodeSpec( + 5, + [ + PauliString.from_dense_str("XZZXI"), + PauliString.from_dense_str("IXZZX"), + PauliString.from_dense_str("XIXZZ"), + PauliString.from_dense_str("ZXIXZ"), + ], + [PauliString.from_dense_str("ZZZZZ")], + [PauliString.from_dense_str("XXXXX")], + ) + + +def _repetition_spec() -> StabilizerCodeSpec: + return StabilizerCodeSpec( + 3, + [ + PauliString.from_dense_str("ZZI"), + PauliString.from_dense_str("IZZ"), + ], + [PauliString.from_dense_str("ZZZ")], + [PauliString.from_dense_str("XXX")], + ) + + +def test_five_qubit_hand_built_spec_finds_genuine_weight_three_logical() -> None: + spec = _five_qubit_spec() + spec.verify() + + assert spec.num_qubits == 5 + assert spec.num_logical_qubits == 1 + assert len(spec.stabilizers) == 4 + assert spec.logical_zs == [PauliString.from_dense_str("ZZZZZ")] + assert spec.logical_xs == [PauliString.from_dense_str("XXXXX")] + + result = spec.distance() + + assert isinstance(result, DistanceResult) + assert result.distance == 3 + assert result.min_weight_operator.weight() == 3 + assert StabilizerCode.five_qubit().syndrome(result.min_weight_operator) == [False] * 4 + + +def test_steane_css_and_general_searches_both_find_distance_three() -> None: + spec = StabilizerCodeSpec.from_stabilizer_code(StabilizerCode.steane()) + + general = spec.distance() + css = spec.distance(css=True) + + assert general is not None + assert css is not None + assert general.distance == css.distance == 3 + + +def test_repetition_min_weight_logicals_expose_equivalence_information() -> None: + spec = _repetition_spec() + result = spec.distance() + logicals = spec.min_weight_logicals() + + assert result is not None + assert result.distance == 1 + assert logicals + assert all(isinstance(info, LogicalOperatorInfo) for info in logicals) + assert all(info.weight == info.operator.weight() == 1 for info in logicals) + assert all(info.equivalent_logicals == [("Z", 0)] for info in logicals) + assert {info.equivalence_string() for info in logicals} <= {"X0", "Z0"} + + +def test_from_stabilizer_code_steane_round_trip_finds_distance_three() -> None: + code = StabilizerCode.steane() + spec = StabilizerCodeSpec.from_stabilizer_code(code) + + spec.verify() + result = spec.distance() + + assert spec.num_qubits == code.num_qubits() + assert spec.num_logical_qubits == code.num_logical_qubits() + assert result is not None + assert result.distance == 3 + + +def test_max_weight_below_true_distance_returns_no_results() -> None: + spec = _five_qubit_spec() + + assert spec.distance(max_weight=2) is None + assert spec.min_weight_logicals(max_weight=2) == [] + + +@pytest.mark.parametrize( + "constructor", + [StabilizerCode.steane, StabilizerCode.five_qubit, StabilizerCode.shor], +) +def test_spec_distance_matches_stabilizer_code_oracle( + constructor: Callable[[], StabilizerCode], +) -> None: + code = constructor() + result = StabilizerCodeSpec.from_stabilizer_code(code).distance() + + assert result is not None + assert result.distance == code.distance() + + +def test_noncommuting_stabilizers_raise_python_exception_on_verify() -> None: + spec = StabilizerCodeSpec( + 1, + [PauliString.from_dense_str("X"), PauliString.from_dense_str("Z")], + [], + [], + ) + + with pytest.raises(ValueError, match="Stabilizer generators 0 and 1 anticommute"): + spec.verify() + + +def test_constructor_errors_are_python_exceptions() -> None: + with pytest.raises(ValueError, match="Number of logical X and Z operators must match"): + StabilizerCodeSpec( + 1, + [], + [PauliString.from_dense_str("Z")], + [], + ) + + +def test_quantum_namespace_exports_distance_search_types() -> None: + import pecos.quantum as quantum + + assert quantum.StabilizerCodeSpec is StabilizerCodeSpec + assert quantum.DistanceResult is DistanceResult + assert quantum.LogicalOperatorInfo is LogicalOperatorInfo + assert {"StabilizerCodeSpec", "DistanceResult", "LogicalOperatorInfo"} <= set(quantum.__all__) diff --git a/python/quantum-pecos/tests/pecos/test_stabilizer_code_verification_bindings.py b/python/quantum-pecos/tests/pecos/test_stabilizer_code_verification_bindings.py new file mode 100644 index 000000000..e57fee001 --- /dev/null +++ b/python/quantum-pecos/tests/pecos/test_stabilizer_code_verification_bindings.py @@ -0,0 +1,139 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 + +import re + +import pytest +from pecos.quantum import ( + PauliString, + StabilizerCodeSpec, + StabilizerCodeSpecBuilder, + X, + Xs, + Y, + Ys, + Z, + Zs, + pauli_string, +) + + +def _original_checks() -> list[PauliString]: + return [ + Xs([3, 4, 7, 8]), + Xs([5, 6, 7, 9]), + Zs([2, 4, 5, 7]), + Zs([7, 8, 9]), + Zs([0, 1]) * Y(2), + pauli_string("X0 X2 Z3 Y4"), + pauli_string("X1 X2 Z6 Y5"), + ] + + +def _fixed_checks() -> list[PauliString]: + checks = _original_checks() + checks[4] = Zs([0, 1, 2]) + return checks + + +def _final_checks() -> list[PauliString]: + return [ + Zs([2, 4, 5, 7]), + Xs([3, 4, 7, 8]), + Xs([5, 6, 7, 9]), + pauli_string("X0 X2 Z3 Y4"), + pauli_string("X1 X2 Z6 Y5"), + Zs([0, 1, 2]), + Xs([0, 1]), + Zs([3, 8]), + Zs([6, 9]), + ] + + +def _builder_with_checks(checks: list[PauliString]) -> StabilizerCodeSpecBuilder: + builder = StabilizerCodeSpec.builder(10) + for check in checks: + builder.check(check) + return builder + + +def test_original_doc_checks_report_a_real_anticommuting_pair() -> None: + checks = _original_checks() + + with pytest.raises( + ValueError, + match=r"Stabilizer generators \d+ and \d+ anticommute", + ) as exc_info: + _builder_with_checks(checks).build_verified() + + pair = re.search(r"generators (\d+) and (\d+) anticommute", str(exc_info.value)) + assert pair is not None + first, second = (int(index) for index in pair.groups()) + assert checks[first].anticommutes_with(checks[second]) + + with pytest.raises(ValueError, match="Stabilizers do not all commute with each other"): + _builder_with_checks(checks).build_with_discovered_logicals() + + +def test_fixed_doc_checks_build_a_distance_two_code() -> None: + spec = _builder_with_checks(_fixed_checks()).build_with_discovered_logicals() + result = spec.distance() + + assert spec.num_logical_qubits == 3 + assert result is not None + assert result.distance == 2 + assert result.min_weight_operator.weight() == 2 + + +def test_final_doc_checks_build_a_distance_three_code() -> None: + spec = _builder_with_checks(_final_checks()).build_with_discovered_logicals() + result = spec.distance() + + assert spec.num_logical_qubits == 1 + assert len(spec.destabilizers) == 9 + assert result is not None + assert result.distance == 3 + + +def test_multi_qubit_pauli_helpers_match_single_qubit_composition() -> None: + assert Xs([0, 2, 5]) == X(0) & X(2) & X(5) + assert Ys((1, 3)) == Y(1) & Y(3) + assert Zs([]) == PauliString.I() + assert Zs(range(3)) == Z(0) & Z(1) & Z(2) + + +def test_builder_is_consumed_and_validates_logical_counts() -> None: + builder = StabilizerCodeSpec.builder(1) + spec = builder.build() + + assert spec.num_logical_qubits == 1 + with pytest.raises(RuntimeError, match="already been consumed"): + builder.check(Z(0)) + + builder = StabilizerCodeSpec.builder(1) + builder.logical_z(Z(0)) + builder.logical_x(X(0)) + spec = builder.build_verified() + + assert spec.num_logical_qubits == 1 + assert spec.logical_zs == [Z(0)] + assert spec.logical_xs == [X(0)] + + mismatched = StabilizerCodeSpec.builder(1) + mismatched.logical_x(X(0)) + with pytest.raises(ValueError, match="Number of logical X and Z operators must match"): + mismatched.build() + + +def test_string_summary_lists_final_code_generators() -> None: + spec = _builder_with_checks(_final_checks()).build_with_discovered_logicals() + summary = str(spec) + + assert "[[10, 1]]" in summary + assert "Stabilizer generators:" in summary + assert "Destabilizer generators:" in summary + assert "Z1:" in summary + assert "X1:" in summary + assert repr(spec) == "StabilizerCodeSpec([[10, 1]])" + assert all(operator.to_dense_str(10) in summary for operator in spec.stabilizers) From 94174a81cd46a3a6e1fd04f4ca9a47ef67e3e101 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 15:46:20 -0600 Subject: [PATCH 02/41] Add shortest_logicals, result reprs, and typed parity-check/symplectic matrix input for code specification --- crates/pecos-qec/src/distance.rs | 114 ++++-- crates/pecos-qec/src/lib.rs | 3 + crates/pecos-qec/src/parity_check_matrix.rs | 188 ++++++++++ crates/pecos-qec/src/stabilizer_code_spec.rs | 292 ++++++++++++--- crates/pecos-quantum/src/lib.rs | 2 + crates/pecos-quantum/src/symplectic_matrix.rs | 348 ++++++++++++++++++ python/pecos-rslib/pecos_rslib.pyi | 52 ++- .../pecos-rslib/src/code_matrix_bindings.rs | 200 ++++++++++ python/pecos-rslib/src/lib.rs | 2 + python/pecos-rslib/src/pauli_bindings.rs | 2 +- .../src/stabilizer_code_spec_bindings.rs | 84 ++++- .../src/pecos/quantum/__init__.py | 4 + .../test_stabilizer_code_spec_bindings.py | 169 ++++++++- 13 files changed, 1371 insertions(+), 89 deletions(-) create mode 100644 crates/pecos-qec/src/parity_check_matrix.rs create mode 100644 crates/pecos-quantum/src/symplectic_matrix.rs create mode 100644 python/pecos-rslib/src/code_matrix_bindings.rs diff --git a/crates/pecos-qec/src/distance.rs b/crates/pecos-qec/src/distance.rs index 511deb4d8..a954d69a2 100644 --- a/crates/pecos-qec/src/distance.rs +++ b/crates/pecos-qec/src/distance.rs @@ -344,19 +344,33 @@ pub fn find_min_weight_logicals( pub fn find_min_weight_logicals_with_info( code: &StabilizerCodeSpec, config: &DistanceSearchConfig, +) -> Vec { + find_shortest_logicals(code, config, 0) +} + +/// Find all logical operators from the minimum weight through `delta` weights above it. +/// +/// The search always starts at weight 1. Once the minimum logical weight is found, +/// collection continues through `minimum_weight + delta`, subject to +/// `config.max_weight`. +#[must_use] +pub fn find_shortest_logicals( + code: &StabilizerCodeSpec, + config: &DistanceSearchConfig, + delta: usize, ) -> Vec { let max_weight = config.max_weight.unwrap_or(code.num_qubits()); let mut results = Vec::new(); - let mut found_distance = None; + let mut found_distance: Option = None; // Build indices once for O(weight) lookups instead of O(num_stabilizers * weight) let stab_index = code.build_stabilizer_index(); let log_index = code.build_logical_index(); for weight in 1..=max_weight { - // If we've found logical operators and this weight is larger, stop + // If we've searched through the requested range above the minimum, stop. if let Some(d) = found_distance - && weight > d + && weight > d.saturating_add(delta) { break; } @@ -437,6 +451,39 @@ mod tests { ) } + fn five_qubit_code() -> StabilizerCodeSpec { + // The [[5,1,3]] perfect code + // Stabilizers: XZZXI, IXZZX, XIXZZ, ZXIXZ + let stab1 = pauli_string(&[(Pauli::X, 0), (Pauli::Z, 1), (Pauli::Z, 2), (Pauli::X, 3)]); + let stab2 = pauli_string(&[(Pauli::X, 1), (Pauli::Z, 2), (Pauli::Z, 3), (Pauli::X, 4)]); + let stab3 = pauli_string(&[(Pauli::X, 0), (Pauli::X, 2), (Pauli::Z, 3), (Pauli::Z, 4)]); + let stab4 = pauli_string(&[(Pauli::Z, 0), (Pauli::X, 1), (Pauli::X, 3), (Pauli::Z, 4)]); + + // Logical operators for [[5,1,3]]: Z = ZZZZZ, X = XXXXX + let logical_z = pauli_string(&[ + (Pauli::Z, 0), + (Pauli::Z, 1), + (Pauli::Z, 2), + (Pauli::Z, 3), + (Pauli::Z, 4), + ]); + let logical_x = pauli_string(&[ + (Pauli::X, 0), + (Pauli::X, 1), + (Pauli::X, 2), + (Pauli::X, 3), + (Pauli::X, 4), + ]); + + StabilizerCodeSpec::new( + 5, + vec![stab1, stab2, stab3, stab4], + vec![logical_z], + vec![logical_x], + ) + .unwrap() + } + #[test] fn test_weighted_pauli_iterator_weight_1() { let iter = WeightedPauliIterator::new(3, 1, false); @@ -533,36 +580,7 @@ mod tests { #[test] fn test_five_qubit_code_distance() { - // The [[5,1,3]] perfect code - // Stabilizers: XZZXI, IXZZX, XIXZZ, ZXIXZ - let stab1 = pauli_string(&[(Pauli::X, 0), (Pauli::Z, 1), (Pauli::Z, 2), (Pauli::X, 3)]); - let stab2 = pauli_string(&[(Pauli::X, 1), (Pauli::Z, 2), (Pauli::Z, 3), (Pauli::X, 4)]); - let stab3 = pauli_string(&[(Pauli::X, 0), (Pauli::X, 2), (Pauli::Z, 3), (Pauli::Z, 4)]); - let stab4 = pauli_string(&[(Pauli::Z, 0), (Pauli::X, 1), (Pauli::X, 3), (Pauli::Z, 4)]); - - // Logical operators for [[5,1,3]]: Z = ZZZZZ, X = XXXXX - let logical_z = pauli_string(&[ - (Pauli::Z, 0), - (Pauli::Z, 1), - (Pauli::Z, 2), - (Pauli::Z, 3), - (Pauli::Z, 4), - ]); - let logical_x = pauli_string(&[ - (Pauli::X, 0), - (Pauli::X, 1), - (Pauli::X, 2), - (Pauli::X, 3), - (Pauli::X, 4), - ]); - - let code = StabilizerCodeSpec::new( - 5, - vec![stab1, stab2, stab3, stab4], - vec![logical_z], - vec![logical_x], - ) - .unwrap(); + let code = five_qubit_code(); // Verify the code is valid assert!(code.verify().is_ok()); @@ -576,6 +594,36 @@ mod tests { assert_eq!(result.distance, 3); } + #[test] + fn test_five_qubit_shortest_logicals_respect_logical_weight_spectrum() { + let code = five_qubit_code(); + let config = DistanceSearchConfig::default(); + let minimum = find_min_weight_logicals_with_info(&code, &config); + let delta_one = find_shortest_logicals(&code, &config, 1); + let delta_two = find_shortest_logicals(&code, &config, 2); + + assert_eq!(minimum.len(), 30); + assert_eq!(delta_one.len(), 30); + assert!( + delta_one + .iter() + .map(|info| &info.operator) + .eq(minimum.iter().map(|info| &info.operator)) + ); + + assert_eq!(delta_two.len(), 48); + assert_eq!(delta_two.iter().filter(|info| info.weight == 3).count(), 30); + assert_eq!(delta_two.iter().filter(|info| info.weight == 5).count(), 18); + assert!(delta_two.iter().all(|info| matches!(info.weight, 3 | 5))); + assert!( + delta_two + .iter() + .take(minimum.len()) + .map(|info| &info.operator) + .eq(minimum.iter().map(|info| &info.operator)) + ); + } + #[test] fn test_logical_equivalence_tracking() { // 3-qubit bit flip code diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index 50100c2e2..77823cfcf 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -69,16 +69,19 @@ pub mod fault_tolerance; pub mod geometry; pub mod logical_discovery; pub mod mem_stab; +pub mod parity_check_matrix; pub mod stabilizer_code; pub mod stabilizer_code_spec; pub mod surface; pub use dem_stab::{DemStabError, DemStabShotBatch, DemStabSim, DemStabSimBuilder}; pub use mem_stab::{MemStabError, MemStabSim, MemStabSimBuilder}; +pub use parity_check_matrix::{ParityCheckMatrix, ParityCheckMatrixError}; pub use distance::{ DistanceResult, DistanceSearchConfig, LogicalOperatorInfo, WeightedPauliIterator, calculate_distance, find_min_weight_logicals, find_min_weight_logicals_with_info, + find_shortest_logicals, }; pub use fault_tolerance::dem_builder::{ DecomposedFault, DemBuilder, DemBuilderError, DemOutput, DetectorDef, DetectorErrorModel, diff --git a/crates/pecos-qec/src/parity_check_matrix.rs b/crates/pecos-qec/src/parity_check_matrix.rs new file mode 100644 index 000000000..2b02a379c --- /dev/null +++ b/crates/pecos-qec/src/parity_check_matrix.rs @@ -0,0 +1,188 @@ +// 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. + +//! Role-neutral binary parity-check matrices for QEC codes. + +use pecos_core::{Pauli, PauliString, QuarterPhase, QubitId}; +use pecos_quantum::F2Matrix; +use thiserror::Error; + +/// Errors that can occur when constructing a [`ParityCheckMatrix`]. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ParityCheckMatrixError { + /// No rows were supplied, so the matrix width cannot be inferred. + #[error( + "cannot infer parity-check matrix width from empty input; use ParityCheckMatrix::zeros" + )] + EmptyRows, + /// A row has a different width from the first row. + #[error("parity-check matrix row {row} has {actual} columns, expected {expected}")] + RaggedRows { + /// Index of the mismatched row. + row: usize, + /// Width inferred from the first row. + expected: usize, + /// Actual width of the mismatched row. + actual: usize, + }, + /// A dense entry was not binary. + #[error("parity-check matrix entry at row {row}, column {column} is {value}, expected 0 or 1")] + InvalidEntry { + /// Row containing the invalid entry. + row: usize, + /// Column containing the invalid entry. + column: usize, + /// Invalid value. + value: u8, + }, +} + +/// A role-neutral binary matrix whose rows are checks and columns are qubits. +/// +/// Whether rows become X-type or Z-type stabilizers is chosen only when +/// converting the matrix; that role is not stored in this type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParityCheckMatrix { + matrix: F2Matrix, +} + +impl ParityCheckMatrix { + /// Constructs a validated parity-check matrix from dense binary rows. + /// + /// # Errors + /// + /// Returns an error for empty input, ragged rows, or non-binary entries. + pub fn from_dense(rows: Vec>) -> Result { + let Some(first) = rows.first() else { + return Err(ParityCheckMatrixError::EmptyRows); + }; + let num_qubits = first.len(); + for (row_index, row) in rows.iter().enumerate() { + if row.len() != num_qubits { + return Err(ParityCheckMatrixError::RaggedRows { + row: row_index, + expected: num_qubits, + actual: row.len(), + }); + } + for (column, &value) in row.iter().enumerate() { + if value > 1 { + return Err(ParityCheckMatrixError::InvalidEntry { + row: row_index, + column, + value, + }); + } + } + } + Ok(Self { + matrix: F2Matrix::from_rows(rows), + }) + } + + /// Constructs an all-zero matrix with an explicit number of qubits. + #[must_use] + pub fn zeros(num_checks: usize, num_qubits: usize) -> Self { + Self { + matrix: F2Matrix::zeros(num_checks, num_qubits), + } + } + + /// Returns the number of checks (rows). + #[must_use] + pub fn num_checks(&self) -> usize { + self.matrix.num_rows() + } + + /// Returns the number of qubits (columns). + #[must_use] + pub fn num_qubits(&self) -> usize { + self.matrix.num_cols() + } + + /// Returns the rank over GF(2). + #[must_use] + pub fn rank(&self) -> usize { + self.matrix.row_reduce().1.len() + } + + /// Returns dense copies of all rows. + #[must_use] + pub fn rows(&self) -> Vec> { + self.matrix.rows() + } + + /// Returns a dense copy of one row, or `None` if the index is out of range. + #[must_use] + pub fn row(&self, index: usize) -> Option> { + (index < self.num_checks()).then(|| self.matrix.row(index)) + } + + /// Converts rows to stabilizers made of Pauli X operators, with phase `+1`. + /// + /// “X stabilizers” means stabilizers made of X, not stabilizers that detect + /// X errors. + #[must_use] + pub fn to_x_stabilizers(&self) -> Vec { + self.to_stabilizers(Pauli::X) + } + + /// Converts rows to stabilizers made of Pauli Z operators, with phase `+1`. + /// + /// “Z stabilizers” means stabilizers made of Z, not stabilizers that detect + /// Z errors. + #[must_use] + pub fn to_z_stabilizers(&self) -> Vec { + self.to_stabilizers(Pauli::Z) + } + + pub(crate) fn matrix(&self) -> &F2Matrix { + &self.matrix + } + + fn to_stabilizers(&self, pauli: Pauli) -> Vec { + (0..self.num_checks()) + .map(|row| { + let paulis = (0..self.num_qubits()) + .filter(|&qubit| self.matrix.get(row, qubit) == 1) + .map(|qubit| (pauli, QubitId::new(qubit))) + .collect(); + PauliString::with_phase_and_paulis(QuarterPhase::PlusOne, paulis) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rank_detects_dependent_rows() { + let matrix = + ParityCheckMatrix::from_dense(vec![vec![1, 1, 0], vec![0, 1, 1], vec![1, 0, 1]]) + .unwrap(); + + assert_eq!(matrix.num_checks(), 3); + assert_eq!(matrix.rank(), 2); + } + + #[test] + fn zero_row_matrix_preserves_width() { + let matrix = ParityCheckMatrix::zeros(0, 9); + assert_eq!(matrix.num_checks(), 0); + assert_eq!(matrix.num_qubits(), 9); + assert!(matrix.rows().is_empty()); + } +} diff --git a/crates/pecos-qec/src/stabilizer_code_spec.rs b/crates/pecos-qec/src/stabilizer_code_spec.rs index 9bba186bf..44ce78f04 100644 --- a/crates/pecos-qec/src/stabilizer_code_spec.rs +++ b/crates/pecos-qec/src/stabilizer_code_spec.rs @@ -17,7 +17,9 @@ // Allow similar names for logical_xs/logical_zs - these are intentional and meaningful #![allow(clippy::similar_names)] +use crate::parity_check_matrix::ParityCheckMatrix; use pecos_core::{PauliOperator, PauliString}; +use pecos_quantum::{PauliSequence, SymplecticMatrix}; use std::collections::BTreeSet; use thiserror::Error; @@ -44,6 +46,22 @@ pub enum StabilizerCodeSpecError { #[error("Logical X{0} and Z{1} anticommute (should commute for different logical qubits)")] CrossLogicalAnticommute(usize, usize), + /// Stabilizer generators are linearly dependent over GF(2). + #[error("Stabilizer generators are dependent: rank {rank}, count {count}")] + DependentStabilizers { rank: usize, count: usize }, + + /// A typed matrix width does not match the builder width. + #[error("{matrix} matrix has {actual} qubits, expected {expected}")] + MatrixWidthMismatch { + matrix: &'static str, + expected: usize, + actual: usize, + }, + + /// A CSS X row and Z row are not orthogonal over GF(2). + #[error("CSS X row {x_row} and Z row {z_row} are not orthogonal")] + CssRowsNotOrthogonal { x_row: usize, z_row: usize }, + /// Invalid code parameters. #[error("Invalid code: {0}")] InvalidCode(String), @@ -203,7 +221,8 @@ impl StabilizerCodeSpec { /// - `logical_xs`: Logical X operators (one per logical qubit) /// /// # Errors - /// Returns an error if the logical X and Z vectors have different lengths. + /// Returns an error if the logical X and Z vectors have different lengths, + /// or if the stabilizer generators are linearly dependent. pub fn new( num_qubits: usize, stabilizers: Vec, @@ -216,6 +235,15 @@ impl StabilizerCodeSpec { )); } + let stabilizer_count = stabilizers.len(); + let stabilizer_rank = PauliSequence::new(stabilizers.clone()).rank(); + if stabilizer_rank != stabilizer_count { + return Err(StabilizerCodeSpecError::DependentStabilizers { + rank: stabilizer_rank, + count: stabilizer_count, + }); + } + Ok(Self { num_qubits, stabilizers, @@ -236,7 +264,8 @@ impl StabilizerCodeSpec { /// - `logical_xs`: Logical X operators (one per logical qubit) /// /// # Errors - /// Returns an error if the logical X and Z vectors have different lengths. + /// Returns an error if the logical X and Z vectors have different lengths, + /// or if the stabilizer generators are linearly dependent. pub fn with_destabilizers( num_qubits: usize, stabilizers: Vec, @@ -244,35 +273,20 @@ impl StabilizerCodeSpec { logical_zs: Vec, logical_xs: Vec, ) -> Result { - if logical_zs.len() != logical_xs.len() { - return Err(StabilizerCodeSpecError::InvalidCode( - "Number of logical X and Z operators must match".to_string(), - )); - } - - Ok(Self { - num_qubits, - stabilizers, - destabilizers, - logical_zs, - logical_xs, - distance: None, - }) + let mut code = Self::new(num_qubits, stabilizers, logical_zs, logical_xs)?; + code.destabilizers = destabilizers; + Ok(code) } /// Creates a stabilizer code from just the stabilizers. /// /// The logical operators can be added later. - #[must_use] - pub fn from_stabilizers(num_qubits: usize, stabilizers: Vec) -> Self { - Self { - num_qubits, - stabilizers, - destabilizers: Vec::new(), - logical_zs: Vec::new(), - logical_xs: Vec::new(), - distance: None, - } + /// + /// # Errors + /// + /// Returns an error if the stabilizer generators are linearly dependent. + pub fn from_stabilizers(num_qubits: usize, stabilizers: Vec) -> Result { + Self::new(num_qubits, stabilizers, Vec::new(), Vec::new()) } /// Creates a builder for constructing a stabilizer code. @@ -752,7 +766,7 @@ impl StabilizerCodeSpec { /// let mut code = StabilizerCodeSpec::from_stabilizers(3, vec![ /// Zs([0, 1]), // ZZI /// Zs([1, 2]), // IZZ - /// ]); + /// ]).unwrap(); /// /// // Discover logical operators /// code.discover_logicals().unwrap(); @@ -811,16 +825,12 @@ impl StabilizerCodeSpec { /// The resulting code has stabilizer generators but no logical operators /// or destabilizers. Use [`discover_logicals`](Self::discover_logicals) /// to compute them. - #[must_use] - pub fn from_stabilizer_group(group: &pecos_quantum::PauliStabilizerGroup) -> Self { - Self { - num_qubits: group.num_qubits(), - stabilizers: group.stabilizers().to_vec(), - destabilizers: Vec::new(), - logical_zs: Vec::new(), - logical_xs: Vec::new(), - distance: None, - } + /// + /// # Errors + /// + /// Returns an error if the stabilizer generators are linearly dependent. + pub fn from_stabilizer_group(group: &pecos_quantum::PauliStabilizerGroup) -> Result { + Self::from_stabilizers(group.num_qubits(), group.stabilizers().to_vec()) } /// Creates a `StabilizerCodeSpec` from a [`StabilizerCode`](crate::StabilizerCode), @@ -849,7 +859,8 @@ impl StabilizerCodeSpec { code: &crate::StabilizerCode, ) -> std::result::Result { let mut spec = - Self::from_stabilizers(code.num_qubits(), code.group().stabilizers().to_vec()); + Self::from_stabilizers(code.num_qubits(), code.group().stabilizers().to_vec()) + .map_err(|_| crate::LogicalDiscoveryError::StabilizersNotIndependent)?; spec.discover_logicals()?; Ok(spec) } @@ -995,6 +1006,76 @@ impl StabilizerCodeSpecBuilder { self } + /// Appends X-type and Z-type stabilizers from role-neutral CSS matrices. + /// + /// # Errors + /// + /// Returns an error if either matrix width differs from the builder width, + /// or if the first non-orthogonal X/Z row pair is found. + pub fn checks_from_css( + mut self, + x_stabilizers: &ParityCheckMatrix, + z_stabilizers: &ParityCheckMatrix, + ) -> Result { + if x_stabilizers.num_qubits() != self.num_qubits { + return Err(StabilizerCodeSpecError::MatrixWidthMismatch { + matrix: "X parity-check", + expected: self.num_qubits, + actual: x_stabilizers.num_qubits(), + }); + } + if z_stabilizers.num_qubits() != self.num_qubits { + return Err(StabilizerCodeSpecError::MatrixWidthMismatch { + matrix: "Z parity-check", + expected: self.num_qubits, + actual: z_stabilizers.num_qubits(), + }); + } + + let overlaps = x_stabilizers + .matrix() + .mul(&z_stabilizers.matrix().transpose()); + for x_row in 0..overlaps.num_rows() { + for z_row in 0..overlaps.num_cols() { + if overlaps.get(x_row, z_row) == 1 { + return Err(StabilizerCodeSpecError::CssRowsNotOrthogonal { x_row, z_row }); + } + } + } + + self.stabilizers.extend(x_stabilizers.to_x_stabilizers()); + self.stabilizers.extend(z_stabilizers.to_z_stabilizers()); + Ok(self) + } + + /// Appends mutually commuting stabilizers from symplectic rows. + /// + /// # Errors + /// + /// Returns an error if the matrix width differs from the builder width, or + /// if the first anticommuting row pair is found. + pub fn checks_from_symplectic(mut self, matrix: &SymplecticMatrix) -> Result { + if matrix.num_qubits() != self.num_qubits { + return Err(StabilizerCodeSpecError::MatrixWidthMismatch { + matrix: "Symplectic", + expected: self.num_qubits, + actual: matrix.num_qubits(), + }); + } + + let stabilizers = matrix.to_positive_paulis(); + for i in 0..stabilizers.len() { + for j in (i + 1)..stabilizers.len() { + if !stabilizers[i].commutes_with(&stabilizers[j]) { + return Err(StabilizerCodeSpecError::StabilizersAnticommute(i, j)); + } + } + } + + self.stabilizers.extend(stabilizers); + Ok(self) + } + /// Adds a logical Z operator from a `PauliString` directly. #[must_use] pub fn logical_z_pauli(mut self, pauli: PauliString) -> Self { @@ -1083,7 +1164,8 @@ impl StabilizerCodeSpecBuilder { pub fn build_with_discovered_logicals( self, ) -> std::result::Result { - let mut code = StabilizerCodeSpec::from_stabilizers(self.num_qubits, self.stabilizers); + let mut code = StabilizerCodeSpec::from_stabilizers(self.num_qubits, self.stabilizers) + .map_err(|_| crate::LogicalDiscoveryError::StabilizersNotIndependent)?; code.discover_logicals()?; Ok(code) } @@ -1174,7 +1256,7 @@ mod tests { let stab1 = pauli_string(&[(Pauli::X, 0)]); let stab2 = pauli_string(&[(Pauli::Z, 0)]); - let code = StabilizerCodeSpec::from_stabilizers(1, vec![stab1, stab2]); + let code = StabilizerCodeSpec::from_stabilizers(1, vec![stab1, stab2]).unwrap(); let result = code.verify_stabilizers_commute(); assert!(matches!( @@ -1183,6 +1265,36 @@ mod tests { )); } + #[test] + fn new_rejects_dependent_stabilizers() { + let result = StabilizerCodeSpec::new( + 3, + vec![Zs([0, 1]), Zs([1, 2]), Zs([0, 2])], + Vec::new(), + Vec::new(), + ); + + assert!(matches!( + &result, + Err(StabilizerCodeSpecError::DependentStabilizers { rank: 2, count: 3 }) + )); + assert_eq!( + result.unwrap_err().to_string(), + "Stabilizer generators are dependent: rank 2, count 3" + ); + } + + #[test] + fn from_stabilizers_rejects_dependent_stabilizers() { + let result = + StabilizerCodeSpec::from_stabilizers(3, vec![Zs([0, 1]), Zs([1, 2]), Zs([0, 2])]); + + assert!(matches!( + result, + Err(StabilizerCodeSpecError::DependentStabilizers { rank: 2, count: 3 }) + )); + } + #[test] fn test_logical_pair_must_anticommute() { // Create a code where logical X and Z commute (invalid) @@ -1205,7 +1317,7 @@ mod tests { // 3-qubit bit flip code let stab1 = pauli_string(&[(Pauli::Z, 0), (Pauli::Z, 1)]); let stab2 = pauli_string(&[(Pauli::Z, 1), (Pauli::Z, 2)]); - let code = StabilizerCodeSpec::from_stabilizers(3, vec![stab1, stab2]); + let code = StabilizerCodeSpec::from_stabilizers(3, vec![stab1, stab2]).unwrap(); // X error on qubit 0 should trigger stabilizer 0 only let x0 = pauli_string(&[(Pauli::X, 0)]); @@ -1228,7 +1340,7 @@ mod tests { fn test_code_parameters_string() { let stab1 = pauli_string(&[(Pauli::Z, 0), (Pauli::Z, 1)]); let stab2 = pauli_string(&[(Pauli::Z, 1), (Pauli::Z, 2)]); - let mut code = StabilizerCodeSpec::from_stabilizers(3, vec![stab1, stab2]); + let mut code = StabilizerCodeSpec::from_stabilizers(3, vec![stab1, stab2]).unwrap(); assert_eq!(code.code_parameters(), "[[3, 1, ?]]"); @@ -1320,7 +1432,7 @@ mod tests { // 3-qubit bit flip code let stab1 = pauli_string(&[(Pauli::Z, 0), (Pauli::Z, 1)]); let stab2 = pauli_string(&[(Pauli::Z, 1), (Pauli::Z, 2)]); - let code = StabilizerCodeSpec::from_stabilizers(3, vec![stab1, stab2]); + let code = StabilizerCodeSpec::from_stabilizers(3, vec![stab1, stab2]).unwrap(); let index = code.build_stabilizer_index(); // X error on qubit 0 should trigger stabilizer 0 only @@ -1501,6 +1613,81 @@ mod tests { // Builder tests // ======================================================================== + #[test] + fn builder_css_checks_validate_orthogonality() { + let x = ParityCheckMatrix::from_dense(vec![vec![1, 0]]).unwrap(); + let z = ParityCheckMatrix::from_dense(vec![vec![1, 0]]).unwrap(); + + let result = StabilizerCodeSpecBuilder::new(2).checks_from_css(&x, &z); + assert!(matches!( + &result, + Err(StabilizerCodeSpecError::CssRowsNotOrthogonal { x_row: 0, z_row: 0 }) + )); + assert_eq!( + result.unwrap_err().to_string(), + "CSS X row 0 and Z row 0 are not orthogonal" + ); + } + + #[test] + fn builder_css_checks_construct_steane_code() { + let h = ParityCheckMatrix::from_dense(vec![ + vec![1, 0, 1, 0, 1, 0, 1], + vec![0, 1, 1, 0, 0, 1, 1], + vec![0, 0, 0, 1, 1, 1, 1], + ]) + .unwrap(); + + let code = StabilizerCodeSpecBuilder::new(7) + .checks_from_css(&h, &h) + .unwrap() + .build_with_discovered_logicals() + .unwrap(); + + assert_eq!(code.num_logical_qubits(), 1); + assert_eq!( + crate::calculate_distance(&code, &crate::DistanceSearchConfig::default()) + .unwrap() + .distance, + 3 + ); + } + + #[test] + fn builder_symplectic_checks_construct_five_qubit_code() { + let matrix = SymplecticMatrix::from_dense(vec![ + vec![1, 0, 0, 1, 0, 0, 1, 1, 0, 0], + vec![0, 1, 0, 0, 1, 0, 0, 1, 1, 0], + vec![1, 0, 1, 0, 0, 0, 0, 0, 1, 1], + vec![0, 1, 0, 1, 0, 1, 0, 0, 0, 1], + ]) + .unwrap(); + + let code = StabilizerCodeSpecBuilder::new(5) + .checks_from_symplectic(&matrix) + .unwrap() + .build_with_discovered_logicals() + .unwrap(); + + assert_eq!( + crate::calculate_distance(&code, &crate::DistanceSearchConfig::default()) + .unwrap() + .distance, + 3 + ); + } + + #[test] + fn builder_symplectic_checks_reject_anticommuting_rows() { + let matrix = SymplecticMatrix::from_dense(vec![vec![1, 0], vec![0, 1]]).unwrap(); + + let result = StabilizerCodeSpecBuilder::new(1).checks_from_symplectic(&matrix); + assert!(matches!( + result, + Err(StabilizerCodeSpecError::StabilizersAnticommute(0, 1)) + )); + } + #[test] fn test_builder_three_qubit_bit_flip() { use pecos_core::{Xs, Zs}; @@ -1628,7 +1815,8 @@ mod tests { Zs([0, 1]), // ZZI Zs([1, 2]), // IZZ ], - ); + ) + .unwrap(); assert!(!code.has_logicals()); @@ -1691,7 +1879,7 @@ mod tests { #[test] fn test_from_stabilizer_group() { let steane = crate::StabilizerCode::steane(); - let code = StabilizerCodeSpec::from_stabilizer_group(steane.group()); + let code = StabilizerCodeSpec::from_stabilizer_group(steane.group()).unwrap(); assert_eq!(code.num_qubits(), 7); assert_eq!(code.num_stabilizers(), 6); @@ -1714,13 +1902,25 @@ mod tests { .unwrap(); let group = original.to_stabilizer_group().unwrap(); - let roundtripped = StabilizerCodeSpec::from_stabilizer_group(&group); + let roundtripped = StabilizerCodeSpec::from_stabilizer_group(&group).unwrap(); assert_eq!(roundtripped.num_qubits(), original.num_qubits()); assert_eq!(roundtripped.num_stabilizers(), original.num_stabilizers()); assert!(roundtripped.verify_stabilizers_commute().is_ok()); } + #[test] + fn from_stabilizer_group_rejects_dependent_stabilizers() { + let group = + pecos_quantum::PauliStabilizerGroup::new(vec![Zs([0, 1]), Zs([1, 2]), Zs([0, 2])]) + .unwrap(); + + assert!(matches!( + StabilizerCodeSpec::from_stabilizer_group(&group), + Err(StabilizerCodeSpecError::DependentStabilizers { rank: 2, count: 3 }) + )); + } + #[test] fn test_stabilizer_group_algebraic_analysis() { use pecos_core::pauli::*; diff --git a/crates/pecos-quantum/src/lib.rs b/crates/pecos-quantum/src/lib.rs index 3a59ea966..031c70683 100644 --- a/crates/pecos-quantum/src/lib.rs +++ b/crates/pecos-quantum/src/lib.rs @@ -74,6 +74,7 @@ pub mod pauli_group; pub mod pauli_sequence; pub mod pauli_set; pub mod stabilizer_group; +pub mod symplectic_matrix; mod tick_circuit; pub mod unitary_matrix; @@ -127,6 +128,7 @@ pub use pauli_group::{PauliGroup, PauliGroupError}; pub use pauli_sequence::{F2Matrix, PauliSequence}; pub use pauli_set::PauliSet; pub use stabilizer_group::{PauliStabilizerGroup, PauliStabilizerGroupError}; +pub use symplectic_matrix::{SymplecticMatrix, SymplecticMatrixError}; // Re-export HUGR types when the feature is enabled #[cfg(feature = "hugr")] diff --git a/crates/pecos-quantum/src/symplectic_matrix.rs b/crates/pecos-quantum/src/symplectic_matrix.rs new file mode 100644 index 000000000..605865137 --- /dev/null +++ b/crates/pecos-quantum/src/symplectic_matrix.rs @@ -0,0 +1,348 @@ +// 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. + +//! Validated binary symplectic matrices for Pauli operators. + +use crate::{F2Matrix, PauliSequence}; +use pecos_core::{Pauli, PauliString, QuarterPhase, QubitId}; +use std::fmt; + +/// Errors that can occur when constructing a [`SymplecticMatrix`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SymplecticMatrixError { + /// No rows were supplied, so the matrix width cannot be inferred. + EmptyRows, + /// A row has a different width from the first row. + RaggedRows { + /// Index of the mismatched row. + row: usize, + /// Width inferred from the first row. + expected: usize, + /// Actual width of the mismatched row. + actual: usize, + }, + /// A dense entry was not binary. + InvalidEntry { + /// Row containing the invalid entry. + row: usize, + /// Column containing the invalid entry. + column: usize, + /// Invalid value. + value: u8, + }, + /// A symplectic matrix must have equally sized X and Z blocks. + OddColumnCount { + /// Number of supplied columns. + columns: usize, + }, + /// A Pauli operator acts beyond the requested explicit width. + OperatorExceedsWidth { + /// Index of the offending operator. + row: usize, + /// Offending qubit index. + qubit: usize, + /// Requested number of qubits. + num_qubits: usize, + }, +} + +impl fmt::Display for SymplecticMatrixError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyRows => write!( + f, + "cannot infer symplectic matrix width from empty input; use SymplecticMatrix::zeros" + ), + Self::RaggedRows { + row, + expected, + actual, + } => write!( + f, + "symplectic matrix row {row} has {actual} columns, expected {expected}" + ), + Self::InvalidEntry { row, column, value } => write!( + f, + "symplectic matrix entry at row {row}, column {column} is {value}, expected 0 or 1" + ), + Self::OddColumnCount { columns } => write!( + f, + "symplectic matrix has {columns} columns, expected an even column count" + ), + Self::OperatorExceedsWidth { + row, + qubit, + num_qubits, + } => write!( + f, + "Pauli operator at row {row} acts on qubit {qubit}, outside the explicit width of {num_qubits} qubits" + ), + } + } +} + +impl std::error::Error for SymplecticMatrixError {} + +/// A binary symplectic matrix whose rows are Pauli operators. +/// +/// For `n` qubits, columns are ordered as +/// `[x_0, ..., x_{n-1} | z_0, ..., z_{n-1}]`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SymplecticMatrix { + matrix: F2Matrix, +} + +impl SymplecticMatrix { + /// Constructs a validated symplectic matrix from dense binary rows. + /// + /// # Errors + /// + /// Returns an error for empty input, ragged rows, non-binary entries, or + /// an odd number of columns. + pub fn from_dense(rows: Vec>) -> Result { + let Some(first) = rows.first() else { + return Err(SymplecticMatrixError::EmptyRows); + }; + let num_cols = first.len(); + if num_cols % 2 != 0 { + return Err(SymplecticMatrixError::OddColumnCount { columns: num_cols }); + } + for (row_index, row) in rows.iter().enumerate() { + if row.len() != num_cols { + return Err(SymplecticMatrixError::RaggedRows { + row: row_index, + expected: num_cols, + actual: row.len(), + }); + } + for (column, &value) in row.iter().enumerate() { + if value > 1 { + return Err(SymplecticMatrixError::InvalidEntry { + row: row_index, + column, + value, + }); + } + } + } + Ok(Self { + matrix: F2Matrix::from_rows(rows), + }) + } + + /// Constructs an all-zero matrix with an explicit number of qubits. + #[must_use] + pub fn zeros(num_rows: usize, num_qubits: usize) -> Self { + Self { + matrix: F2Matrix::zeros(num_rows, 2 * num_qubits), + } + } + + /// Converts a Pauli sequence to symplectic form at an explicit width. + /// + /// Pauli phases are deliberately ignored. Use [`to_positive_paulis`](Self::to_positive_paulis) + /// to recover operators with phase `+1`. + /// + /// # Errors + /// + /// Returns an error if any operator acts on a qubit outside `num_qubits`. + pub fn from_pauli_sequence_ignoring_phase( + sequence: &PauliSequence, + num_qubits: usize, + ) -> Result { + for (row, operator) in sequence.iter().enumerate() { + if let Some(qubit) = operator + .qubits() + .into_iter() + .find(|&qubit| qubit >= num_qubits) + { + return Err(SymplecticMatrixError::OperatorExceedsWidth { + row, + qubit, + num_qubits, + }); + } + } + + let inferred_num_qubits = sequence.num_qubits(); + let inferred = sequence.to_symplectic_matrix(); + let mut matrix = F2Matrix::zeros(sequence.len(), 2 * num_qubits); + for row in 0..sequence.len() { + for qubit in 0..inferred_num_qubits { + matrix.set(row, qubit, inferred.get(row, qubit)); + matrix.set( + row, + num_qubits + qubit, + inferred.get(row, inferred_num_qubits + qubit), + ); + } + } + Ok(Self { matrix }) + } + + /// Converts each row to a phase-`+1` Pauli operator. + /// + /// Symplectic matrices contain no sign or quarter-phase information, so + /// every returned operator necessarily has positive phase. + #[must_use] + pub fn to_positive_paulis(&self) -> Vec { + let num_qubits = self.num_qubits(); + (0..self.num_rows()) + .map(|row| { + let mut paulis = Vec::new(); + for qubit in 0..num_qubits { + let x = self.matrix.get(row, qubit); + let z = self.matrix.get(row, num_qubits + qubit); + let pauli = match (x, z) { + (1, 0) => Some(Pauli::X), + (0, 1) => Some(Pauli::Z), + (1, 1) => Some(Pauli::Y), + _ => None, + }; + if let Some(pauli) = pauli { + paulis.push((pauli, QubitId::new(qubit))); + } + } + PauliString::with_phase_and_paulis(QuarterPhase::PlusOne, paulis) + }) + .collect() + } + + /// Returns the number of matrix rows. + #[must_use] + pub fn num_rows(&self) -> usize { + self.matrix.num_rows() + } + + /// Returns the number of represented qubits. + #[must_use] + pub fn num_qubits(&self) -> usize { + self.matrix.num_cols() / 2 + } + + /// Returns a copy of the X block. + #[must_use] + pub fn x_block(&self) -> F2Matrix { + let num_qubits = self.num_qubits(); + let mut block = F2Matrix::zeros(self.num_rows(), num_qubits); + for row in 0..self.num_rows() { + for qubit in 0..num_qubits { + block.set(row, qubit, self.matrix.get(row, qubit)); + } + } + block + } + + /// Returns a copy of the Z block. + #[must_use] + pub fn z_block(&self) -> F2Matrix { + let num_qubits = self.num_qubits(); + let mut block = F2Matrix::zeros(self.num_rows(), num_qubits); + for row in 0..self.num_rows() { + for qubit in 0..num_qubits { + block.set(row, qubit, self.matrix.get(row, num_qubits + qubit)); + } + } + block + } + + /// Returns the rank over GF(2). + #[must_use] + pub fn rank(&self) -> usize { + self.matrix.row_reduce().1.len() + } + + /// Returns dense copies of all rows. + #[must_use] + pub fn rows(&self) -> Vec> { + self.matrix.rows() + } + + /// Returns a dense copy of one row, or `None` if the index is out of range. + #[must_use] + pub fn row(&self, index: usize) -> Option> { + (index < self.num_rows()).then(|| self.matrix.row(index)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pauli_round_trip_ignores_phase_and_preserves_explicit_width() { + let negative_y: PauliString = "-Y0".parse().unwrap(); + let imaginary_xz: PauliString = "+i X1 Z3".parse().unwrap(); + let sequence = PauliSequence::new(vec![negative_y, imaginary_xz]); + + let matrix = SymplecticMatrix::from_pauli_sequence_ignoring_phase(&sequence, 5).unwrap(); + assert_eq!(matrix.num_rows(), 2); + assert_eq!(matrix.num_qubits(), 5); + assert_eq!(matrix.x_block().rows()[0], vec![1, 0, 0, 0, 0]); + assert_eq!(matrix.z_block().rows()[0], vec![1, 0, 0, 0, 0]); + + let positive = matrix.to_positive_paulis(); + assert_eq!(positive[0].get_phase(), QuarterPhase::PlusOne); + assert_eq!(positive[1].get_phase(), QuarterPhase::PlusOne); + assert_eq!(positive[0].to_dense_str(Some(5)), "+YIIII"); + assert_eq!(positive[1].to_dense_str(Some(5)), "+IXIZI"); + } + + #[test] + fn empty_dense_input_requires_explicit_zeros_constructor() { + assert_eq!( + SymplecticMatrix::from_dense(Vec::new()).unwrap_err(), + SymplecticMatrixError::EmptyRows + ); + assert_eq!(SymplecticMatrix::zeros(0, 7).num_qubits(), 7); + } + + #[test] + fn dense_input_rejects_ragged_rows() { + assert_eq!( + SymplecticMatrix::from_dense(vec![vec![1, 0], vec![1]]).unwrap_err(), + SymplecticMatrixError::RaggedRows { + row: 1, + expected: 2, + actual: 1, + } + ); + } + + #[test] + fn dense_input_rejects_non_binary_entries() { + assert_eq!( + SymplecticMatrix::from_dense(vec![vec![0, 2]]).unwrap_err(), + SymplecticMatrixError::InvalidEntry { + row: 0, + column: 1, + value: 2, + } + ); + } + + #[test] + fn pauli_sequence_rejects_operator_beyond_explicit_width() { + let sequence = PauliSequence::new(vec![PauliString::x(9)]); + + assert_eq!( + SymplecticMatrix::from_pauli_sequence_ignoring_phase(&sequence, 3).unwrap_err(), + SymplecticMatrixError::OperatorExceedsWidth { + row: 0, + qubit: 9, + num_qubits: 3, + } + ); + } +} diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index 9954b41f8..313c683a1 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -1102,6 +1102,7 @@ class DistanceResult: def distance(self) -> int: ... @property def min_weight_operator(self) -> PauliString: ... + def __repr__(self) -> str: ... class LogicalOperatorInfo: """A minimum-weight logical operator and its logical equivalence.""" @@ -1113,11 +1114,47 @@ class LogicalOperatorInfo: @property def equivalent_logicals(self) -> list[tuple[str, int]]: ... def equivalence_string(self) -> str: ... + def __repr__(self) -> str: ... + +class ParityCheckMatrix: + """A role-neutral binary parity-check matrix.""" + + def __init__(self, rows: Sequence[Sequence[int]]) -> None: ... + @classmethod + def from_dense(cls, rows: Sequence[Sequence[int]]) -> ParityCheckMatrix: ... + @classmethod + def zeros(cls, num_checks: int, num_qubits: int) -> ParityCheckMatrix: ... + def num_checks(self) -> int: ... + def num_qubits(self) -> int: ... + def rank(self) -> int: ... + def rows(self) -> list[list[int]]: ... + def to_x_stabilizers(self) -> list[PauliString]: ... + def to_z_stabilizers(self) -> list[PauliString]: ... + def __repr__(self) -> str: ... + +class SymplecticMatrix: + """A binary symplectic matrix whose rows represent Pauli operators.""" + + def __init__(self, rows: Sequence[Sequence[int]]) -> None: ... + @classmethod + def from_dense(cls, rows: Sequence[Sequence[int]]) -> SymplecticMatrix: ... + @classmethod + def zeros(cls, num_rows: int, num_qubits: int) -> SymplecticMatrix: ... + def num_rows(self) -> int: ... + def num_qubits(self) -> int: ... + def rank(self) -> int: ... + def rows(self) -> list[list[int]]: ... + def x_block(self) -> list[list[int]]: ... + def z_block(self) -> list[list[int]]: ... + def to_positive_paulis(self) -> list[PauliString]: ... + def __repr__(self) -> str: ... class StabilizerCodeSpecBuilder: """Mutable Python wrapper around the consuming Rust specification builder.""" def check(self, op: PauliString) -> None: ... + def checks_from_css(self, x_stabilizers: ParityCheckMatrix, z_stabilizers: ParityCheckMatrix) -> None: ... + def checks_from_symplectic(self, matrix: SymplecticMatrix) -> None: ... def logical_z(self, op: PauliString) -> None: ... def logical_x(self, op: PauliString) -> None: ... def build(self) -> StabilizerCodeSpec: ... @@ -1151,8 +1188,19 @@ class StabilizerCodeSpec: @property def logical_xs(self) -> list[PauliString]: ... def verify(self) -> None: ... - def distance(self, max_weight: int | None = None, css: bool = False) -> DistanceResult | None: ... - def min_weight_logicals(self, max_weight: int | None = None, css: bool = False) -> list[LogicalOperatorInfo]: ... + def distance( + self, max_weight: int | None = None, css: bool = False, verbose: bool = False + ) -> DistanceResult | None: ... + def min_weight_logicals( + self, max_weight: int | None = None, css: bool = False, verbose: bool = False + ) -> list[LogicalOperatorInfo]: ... + def shortest_logicals( + self, + delta: int = 0, + max_weight: int | None = None, + css: bool = False, + verbose: bool = False, + ) -> list[LogicalOperatorInfo]: ... def __str__(self) -> str: ... def __repr__(self) -> str: ... diff --git a/python/pecos-rslib/src/code_matrix_bindings.rs b/python/pecos-rslib/src/code_matrix_bindings.rs new file mode 100644 index 000000000..cdc8578ed --- /dev/null +++ b/python/pecos-rslib/src/code_matrix_bindings.rs @@ -0,0 +1,200 @@ +// 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. + +//! Python bindings for binary QEC code matrices. + +use pecos_qec::ParityCheckMatrix as RustParityCheckMatrix; +use pecos_quantum::SymplecticMatrix as RustSymplecticMatrix; +use pyo3::prelude::*; +use pyo3::types::PyType; + +use crate::pauli_bindings::PauliString; + +fn validated_binary_rows(rows: Vec>, name: &str) -> PyResult>> { + rows.into_iter() + .enumerate() + .map(|(row_index, row)| { + row.into_iter() + .map(|value| match value { + 0 | 1 => Ok(u8::from(value == 1)), + _ => Err(pyo3::exceptions::PyValueError::new_err(format!( + "{name} row {row_index} contains invalid value {value}; expected 0 or 1" + ))), + }) + .collect() + }) + .collect() +} + +fn python_binary_rows(rows: Vec>) -> Vec> { + rows.into_iter() + .map(|row| row.into_iter().map(usize::from).collect()) + .collect() +} + +/// A role-neutral binary parity-check matrix. +#[pyclass(name = "ParityCheckMatrix", module = "pecos_rslib", from_py_object)] +#[derive(Clone, Debug)] +pub struct PyParityCheckMatrix { + pub(crate) inner: RustParityCheckMatrix, +} + +#[pymethods] +impl PyParityCheckMatrix { + #[new] + fn new(rows: Vec>) -> PyResult { + Self::from_rows(rows) + } + + #[classmethod] + fn from_dense(_cls: &Bound<'_, PyType>, rows: Vec>) -> PyResult { + Self::from_rows(rows) + } + + #[classmethod] + fn zeros(_cls: &Bound<'_, PyType>, num_checks: usize, num_qubits: usize) -> Self { + Self { + inner: RustParityCheckMatrix::zeros(num_checks, num_qubits), + } + } + + fn num_checks(&self) -> usize { + self.inner.num_checks() + } + + fn num_qubits(&self) -> usize { + self.inner.num_qubits() + } + + fn rank(&self) -> usize { + self.inner.rank() + } + + fn rows(&self) -> Vec> { + python_binary_rows(self.inner.rows()) + } + + fn to_x_stabilizers(&self) -> Vec { + self.inner + .to_x_stabilizers() + .into_iter() + .map(PauliString::from_rust) + .collect() + } + + fn to_z_stabilizers(&self) -> Vec { + self.inner + .to_z_stabilizers() + .into_iter() + .map(PauliString::from_rust) + .collect() + } + + fn __repr__(&self) -> String { + format!( + "ParityCheckMatrix(shape=({}, {}))", + self.inner.num_checks(), + self.inner.num_qubits() + ) + } +} + +impl PyParityCheckMatrix { + fn from_rows(rows: Vec>) -> PyResult { + let rows = validated_binary_rows(rows, "parity-check matrix")?; + let inner = RustParityCheckMatrix::from_dense(rows) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))?; + Ok(Self { inner }) + } +} + +/// A binary symplectic matrix whose rows represent Pauli operators. +#[pyclass(name = "SymplecticMatrix", module = "pecos_rslib", from_py_object)] +#[derive(Clone, Debug)] +pub struct PySymplecticMatrix { + pub(crate) inner: RustSymplecticMatrix, +} + +#[pymethods] +impl PySymplecticMatrix { + #[new] + fn new(rows: Vec>) -> PyResult { + Self::from_rows(rows) + } + + #[classmethod] + fn from_dense(_cls: &Bound<'_, PyType>, rows: Vec>) -> PyResult { + Self::from_rows(rows) + } + + #[classmethod] + fn zeros(_cls: &Bound<'_, PyType>, num_rows: usize, num_qubits: usize) -> Self { + Self { + inner: RustSymplecticMatrix::zeros(num_rows, num_qubits), + } + } + + fn num_rows(&self) -> usize { + self.inner.num_rows() + } + + fn num_qubits(&self) -> usize { + self.inner.num_qubits() + } + + fn rank(&self) -> usize { + self.inner.rank() + } + + fn rows(&self) -> Vec> { + python_binary_rows(self.inner.rows()) + } + + fn x_block(&self) -> Vec> { + python_binary_rows(self.inner.x_block().rows()) + } + + fn z_block(&self) -> Vec> { + python_binary_rows(self.inner.z_block().rows()) + } + + fn to_positive_paulis(&self) -> Vec { + self.inner + .to_positive_paulis() + .into_iter() + .map(PauliString::from_rust) + .collect() + } + + fn __repr__(&self) -> String { + format!( + "SymplecticMatrix(shape=({}, {}))", + self.inner.num_rows(), + self.inner.num_qubits() + ) + } +} + +impl PySymplecticMatrix { + fn from_rows(rows: Vec>) -> PyResult { + let rows = validated_binary_rows(rows, "symplectic matrix")?; + let inner = RustSymplecticMatrix::from_dense(rows) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))?; + Ok(Self { inner }) + } +} + +pub fn register_code_matrix_types(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/python/pecos-rslib/src/lib.rs b/python/pecos-rslib/src/lib.rs index dd5062923..f2931443e 100644 --- a/python/pecos-rslib/src/lib.rs +++ b/python/pecos-rslib/src/lib.rs @@ -37,6 +37,7 @@ mod bit_int_bindings; mod bit_uint_bindings; mod byte_message_bindings; mod clifford_rep_bindings; +mod code_matrix_bindings; mod coin_toss_bindings; mod dag_circuit_bindings; mod decoder_bindings; @@ -303,6 +304,7 @@ fn pecos_rslib(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { // Register stabilizer group, Pauli sequence, and Clifford types stabilizer_group_bindings::register_stabilizer_group_types(m)?; + code_matrix_bindings::register_code_matrix_types(m)?; stabilizer_code_bindings::register_stabilizer_code_types(m)?; stabilizer_code_spec_bindings::register_stabilizer_code_spec_types(m)?; pauli_sequence_bindings::register_pauli_sequence_types(m)?; diff --git a/python/pecos-rslib/src/pauli_bindings.rs b/python/pecos-rslib/src/pauli_bindings.rs index d9475fc4b..9356207fb 100644 --- a/python/pecos-rslib/src/pauli_bindings.rs +++ b/python/pecos-rslib/src/pauli_bindings.rs @@ -313,7 +313,7 @@ impl PauliString { } /// String representation - fn __str__(&self) -> String { + pub(crate) fn __str__(&self) -> String { // Build string representation let phase_str = match self.inner.get_phase() { QuarterPhase::PlusOne => "", diff --git a/python/pecos-rslib/src/stabilizer_code_spec_bindings.rs b/python/pecos-rslib/src/stabilizer_code_spec_bindings.rs index 52dfe0a6b..b893b6dd5 100644 --- a/python/pecos-rslib/src/stabilizer_code_spec_bindings.rs +++ b/python/pecos-rslib/src/stabilizer_code_spec_bindings.rs @@ -16,11 +16,12 @@ use pecos_qec::{ DistanceResult as RustDistanceResult, DistanceSearchConfig, LogicalOperatorInfo as RustLogicalOperatorInfo, StabilizerCodeSpec as RustCodeSpec, StabilizerCodeSpecBuilder as RustCodeSpecBuilder, calculate_distance, - find_min_weight_logicals_with_info, + find_min_weight_logicals_with_info, find_shortest_logicals, }; use pyo3::prelude::*; use pyo3::types::PyType; +use crate::code_matrix_bindings::{PyParityCheckMatrix, PySymplecticMatrix}; use crate::pauli_bindings::PauliString; use crate::stabilizer_code_bindings::PyStabilizerCode; @@ -44,6 +45,15 @@ impl PyDistanceResult { fn min_weight_operator(&self) -> PauliString { PauliString::from_rust(self.inner.min_weight_operator.clone()) } + + fn __repr__(&self) -> String { + let operator = PauliString::from_rust(self.inner.min_weight_operator.clone()); + format!( + "DistanceResult(distance={}, min_weight_operator={})", + self.inner.distance, + operator.__str__() + ) + } } impl From for PyDistanceResult { @@ -91,6 +101,16 @@ impl PyLogicalOperatorInfo { fn equivalence_string(&self) -> String { self.inner.equivalence_string() } + + fn __repr__(&self) -> String { + let operator = PauliString::from_rust(self.inner.operator.clone()); + format!( + "LogicalOperatorInfo(operator={}, weight={}, equivalence={})", + operator.__str__(), + self.inner.weight, + self.inner.equivalence_string() + ) + } } impl From for PyLogicalOperatorInfo { @@ -134,6 +154,32 @@ impl PyStabilizerCodeSpecBuilder { Ok(()) } + /// Add X-type and Z-type stabilizers from CSS parity-check matrices. + fn checks_from_css( + &mut self, + x_stabilizers: &PyParityCheckMatrix, + z_stabilizers: &PyParityCheckMatrix, + ) -> PyResult<()> { + let builder = self.take_inner()?; + self.inner = Some( + builder + .checks_from_css(&x_stabilizers.inner, &z_stabilizers.inner) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))?, + ); + Ok(()) + } + + /// Add stabilizers from the rows of a symplectic matrix. + fn checks_from_symplectic(&mut self, matrix: &PySymplecticMatrix) -> PyResult<()> { + let builder = self.take_inner()?; + self.inner = Some( + builder + .checks_from_symplectic(&matrix.inner) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))?, + ); + Ok(()) + } + /// Add a logical Z operator. fn logical_z(&mut self, op: &PauliString) -> PyResult<()> { let builder = self.take_inner()?; @@ -288,27 +334,33 @@ impl PyStabilizerCodeSpec { } /// Find the code distance and one minimum-weight logical operator. - #[pyo3(signature = (max_weight=None, css=false))] - fn distance(&self, max_weight: Option, css: bool) -> Option { + #[pyo3(signature = (max_weight=None, css=false, verbose=false))] + fn distance( + &self, + max_weight: Option, + css: bool, + verbose: bool, + ) -> Option { let config = DistanceSearchConfig { max_weight, css_only: css, - verbose: false, + verbose, }; calculate_distance(&self.inner, &config).map(PyDistanceResult::from) } /// Find all logical operators at the minimum weight searched. - #[pyo3(signature = (max_weight=None, css=false))] + #[pyo3(signature = (max_weight=None, css=false, verbose=false))] fn min_weight_logicals( &self, max_weight: Option, css: bool, + verbose: bool, ) -> Vec { let config = DistanceSearchConfig { max_weight, css_only: css, - verbose: false, + verbose, }; find_min_weight_logicals_with_info(&self.inner, &config) .into_iter() @@ -316,6 +368,26 @@ impl PyStabilizerCodeSpec { .collect() } + /// Find logical operators through ``delta`` weights above the minimum. + #[pyo3(signature = (delta=0, max_weight=None, css=false, verbose=false))] + fn shortest_logicals( + &self, + delta: usize, + max_weight: Option, + css: bool, + verbose: bool, + ) -> Vec { + let config = DistanceSearchConfig { + max_weight, + css_only: css, + verbose, + }; + find_shortest_logicals(&self.inner, &config, delta) + .into_iter() + .map(PyLogicalOperatorInfo::from) + .collect() + } + fn __str__(&self) -> String { self.inner.to_string() } diff --git a/python/quantum-pecos/src/pecos/quantum/__init__.py b/python/quantum-pecos/src/pecos/quantum/__init__.py index e66de2863..ba00c26c1 100644 --- a/python/quantum-pecos/src/pecos/quantum/__init__.py +++ b/python/quantum-pecos/src/pecos/quantum/__init__.py @@ -120,6 +120,7 @@ GateRegistry, H, LogicalOperatorInfo, + ParityCheckMatrix, Pauli, PauliSequence, PauliStabilizerGroup, @@ -130,6 +131,7 @@ SXdg, SXXdg, SYdg, + SymplecticMatrix, SYYdg, SZdg, SZZdg, @@ -314,6 +316,7 @@ def pauli_string( "HostedOperationBinding", "HugrConversionError", "LogicalOperatorInfo", + "ParityCheckMatrix", "Pauli", "PauliSequence", "PauliStabilizerGroup", @@ -329,6 +332,7 @@ def pauli_string( "StabilizerCode", "StabilizerCodeSpec", "StabilizerCodeSpecBuilder", + "SymplecticMatrix", "TableauWrapper", "Tick", "TickCircuit", diff --git a/python/quantum-pecos/tests/pecos/test_stabilizer_code_spec_bindings.py b/python/quantum-pecos/tests/pecos/test_stabilizer_code_spec_bindings.py index f421f78a2..0d3b6d539 100644 --- a/python/quantum-pecos/tests/pecos/test_stabilizer_code_spec_bindings.py +++ b/python/quantum-pecos/tests/pecos/test_stabilizer_code_spec_bindings.py @@ -4,15 +4,25 @@ from collections.abc import Callable +import numpy as np import pytest from pecos.quantum import ( DistanceResult, LogicalOperatorInfo, + ParityCheckMatrix, PauliString, StabilizerCode, StabilizerCodeSpec, + SymplecticMatrix, + Zs, ) +_HAMMING_H = [ + [1, 0, 1, 0, 1, 0, 1], + [0, 1, 1, 0, 0, 1, 1], + [0, 0, 0, 1, 1, 1, 1], +] + def _five_qubit_spec() -> StabilizerCodeSpec: return StabilizerCodeSpec( @@ -55,6 +65,8 @@ def test_five_qubit_hand_built_spec_finds_genuine_weight_three_logical() -> None assert isinstance(result, DistanceResult) assert result.distance == 3 assert result.min_weight_operator.weight() == 3 + assert "distance=" in repr(result) + assert str(result.min_weight_operator) in repr(result) assert StabilizerCode.five_qubit().syndrome(result.min_weight_operator) == [False] * 4 @@ -81,6 +93,20 @@ def test_repetition_min_weight_logicals_expose_equivalence_information() -> None assert all(info.weight == info.operator.weight() == 1 for info in logicals) assert all(info.equivalent_logicals == [("Z", 0)] for info in logicals) assert {info.equivalence_string() for info in logicals} <= {"X0", "Z0"} + assert all(str(info.operator) in repr(info) for info in logicals) + assert all(info.equivalence_string() in repr(info) for info in logicals) + + +def test_verbose_distance_searches_write_progress_to_stderr( + capfd: pytest.CaptureFixture[str], +) -> None: + spec = _repetition_spec() + + assert spec.distance(verbose=True) is not None + assert "Checking weight" in capfd.readouterr().err + + assert spec.min_weight_logicals(verbose=True) + assert "Checking weight" in capfd.readouterr().err def test_from_stabilizer_code_steane_round_trip_finds_distance_three() -> None: @@ -101,6 +127,35 @@ def test_max_weight_below_true_distance_returns_no_results() -> None: assert spec.distance(max_weight=2) is None assert spec.min_weight_logicals(max_weight=2) == [] + assert spec.shortest_logicals(delta=1, max_weight=2) == [] + + +def test_five_qubit_shortest_logicals_include_requested_weight_range() -> None: + spec = _five_qubit_spec() + minimum = spec.min_weight_logicals() + delta_zero = spec.shortest_logicals() + delta_one = spec.shortest_logicals(delta=1) + delta_two = spec.shortest_logicals(delta=2) + + minimum_operators = [info.operator for info in minimum] + delta_zero_operators = [info.operator for info in delta_zero] + delta_one_operators = [info.operator for info in delta_one] + delta_two_operators = [info.operator for info in delta_two] + + assert len(minimum_operators) == 30 + assert delta_zero_operators == minimum_operators + assert delta_one_operators == minimum_operators + assert delta_two_operators[: len(minimum_operators)] == minimum_operators + assert sum(info.weight == 5 for info in delta_two) == 18 + assert {info.weight for info in delta_two} == {3, 5} + assert all(StabilizerCode.five_qubit().syndrome(info.operator) == [False] * 4 for info in delta_two) + + +def test_repetition_shortest_logicals_exclude_non_logicals_in_range() -> None: + logicals = _repetition_spec().shortest_logicals(delta=1) + + assert logicals + assert {info.weight for info in logicals} == {1} @pytest.mark.parametrize( @@ -139,10 +194,122 @@ def test_constructor_errors_are_python_exceptions() -> None: ) +@pytest.mark.parametrize( + "rows", + [ + _HAMMING_H, + np.asarray(_HAMMING_H, dtype=np.int64), + np.asarray(_HAMMING_H, dtype=np.uint8), + ], + ids=["lists", "numpy-int64", "numpy-uint8"], +) +def test_parity_check_matrix_builds_steane_code_from_dense_inputs(rows: object) -> None: + matrix = ParityCheckMatrix(rows) + builder = StabilizerCodeSpec.builder(7) + builder.checks_from_css(matrix, matrix) + spec = builder.build_with_discovered_logicals() + result = spec.distance() + + assert matrix.num_checks() == matrix.rank() == 3 + assert matrix.num_qubits() == 7 + assert matrix.rows() == _HAMMING_H + assert len(matrix.to_x_stabilizers()) == len(matrix.to_z_stabilizers()) == 3 + assert repr(matrix) == "ParityCheckMatrix(shape=(3, 7))" + assert result is not None + assert result.distance == StabilizerCode.steane().distance() == 3 + + +def test_symplectic_matrix_builds_five_qubit_code() -> None: + rows = [ + [1, 0, 0, 1, 0, 0, 1, 1, 0, 0], + [0, 1, 0, 0, 1, 0, 0, 1, 1, 0], + [1, 0, 1, 0, 0, 0, 0, 0, 1, 1], + [0, 1, 0, 1, 0, 1, 0, 0, 0, 1], + ] + matrix = SymplecticMatrix.from_dense(rows) + builder = StabilizerCodeSpec.builder(5) + builder.checks_from_symplectic(matrix) + spec = builder.build_with_discovered_logicals() + result = spec.distance() + + assert matrix.num_rows() == matrix.rank() == 4 + assert matrix.num_qubits() == 5 + assert matrix.rows() == rows + assert matrix.x_block() == [row[:5] for row in rows] + assert matrix.z_block() == [row[5:] for row in rows] + assert matrix.to_positive_paulis() == spec.stabilizers + assert repr(matrix) == "SymplecticMatrix(shape=(4, 5))" + assert result is not None + assert result.distance == 3 + + +def test_code_matrix_entry_and_shape_errors_are_value_errors() -> None: + with pytest.raises(ValueError, match=r"row 0.*value 2"): + ParityCheckMatrix([[0, 2]]) + with pytest.raises(ValueError, match=r"row 0.*value -1"): + SymplecticMatrix([[0, -1]]) + with pytest.raises(ValueError, match=r"row 1.*columns"): + ParityCheckMatrix([[1, 0], [1]]) + with pytest.raises(ValueError, match="even column count"): + SymplecticMatrix.from_dense([[1, 0, 1]]) + + +def test_code_matrix_builder_validation_errors_preserve_diagnostics() -> None: + width_mismatch = StabilizerCodeSpec.builder(2) + with pytest.raises(ValueError, match=r"3 qubits, expected 2"): + width_mismatch.checks_from_css( + ParityCheckMatrix([[1, 0, 0]]), + ParityCheckMatrix.zeros(0, 2), + ) + + nonorthogonal = StabilizerCodeSpec.builder(2) + with pytest.raises(ValueError, match=r"X row 0 and Z row 0"): + nonorthogonal.checks_from_css( + ParityCheckMatrix([[1, 0]]), + ParityCheckMatrix([[1, 0]]), + ) + + symplectic_width_mismatch = StabilizerCodeSpec.builder(2) + with pytest.raises(ValueError, match=r"3 qubits, expected 2"): + symplectic_width_mismatch.checks_from_symplectic(SymplecticMatrix.zeros(0, 3)) + + +def test_spec_constructor_rejects_dependent_stabilizers() -> None: + with pytest.raises(ValueError, match=r"rank 2, count 3"): + StabilizerCodeSpec( + 3, + [Zs([0, 1]), Zs([1, 2]), Zs([0, 2])], + [], + [], + ) + + +def test_zero_row_parity_check_matrix_preserves_width_for_x_only_code() -> None: + x_checks = ParityCheckMatrix([[1, 1]]) + z_checks = ParityCheckMatrix.zeros(0, 2) + builder = StabilizerCodeSpec.builder(2) + builder.checks_from_css(x_checks, z_checks) + spec = builder.build_with_discovered_logicals() + + assert z_checks.num_checks() == 0 + assert z_checks.num_qubits() == 2 + assert z_checks.rows() == [] + assert spec.num_logical_qubits == 1 + assert spec.stabilizers == x_checks.to_x_stabilizers() + + def test_quantum_namespace_exports_distance_search_types() -> None: import pecos.quantum as quantum assert quantum.StabilizerCodeSpec is StabilizerCodeSpec assert quantum.DistanceResult is DistanceResult assert quantum.LogicalOperatorInfo is LogicalOperatorInfo - assert {"StabilizerCodeSpec", "DistanceResult", "LogicalOperatorInfo"} <= set(quantum.__all__) + assert quantum.ParityCheckMatrix is ParityCheckMatrix + assert quantum.SymplecticMatrix is SymplecticMatrix + assert { + "StabilizerCodeSpec", + "DistanceResult", + "LogicalOperatorInfo", + "ParityCheckMatrix", + "SymplecticMatrix", + } <= set(quantum.__all__) From d857f92ae88c3b4c43939a6b9d5544df7cafe9c2 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 17:54:17 -0600 Subject: [PATCH 03/41] Add stabilizer-code verification user guide with executable examples --- .../stabilizer-code-verification.md | 388 ++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 389 insertions(+) create mode 100644 docs/user-guide/stabilizer-code-verification.md diff --git a/docs/user-guide/stabilizer-code-verification.md b/docs/user-guide/stabilizer-code-verification.md new file mode 100644 index 000000000..e2040476f --- /dev/null +++ b/docs/user-guide/stabilizer-code-verification.md @@ -0,0 +1,388 @@ +# Stabilizer-Code Verification + +This guide covers designing, verifying, and analyzing stabilizer codes with the +Rust-backed types in `pecos.quantum`. The workflow starts with Pauli checks, +discovers a compatible logical basis, and searches for low-weight logical +operators. + +## What You'll Learn + +- Building a `StabilizerCodeSpec` from Pauli checks +- Diagnosing anticommuting and dependent generators +- Discovering logical operators and calculating code distance +- Searching a range of low-weight logical operators +- Importing CSS and symplectic check matrices +- Choosing between the two exact distance methods + +```hidden-python +import re + +import numpy as np +from pecos.quantum import ( + ParityCheckMatrix, + StabilizerCode, + StabilizerCodeSpec, + SymplecticMatrix, + X, + Xs, + Y, + Ys, + Z, + Zs, + pauli_string, +) + + +def add_checks(builder, checks): + for check in checks: + builder.check(check) + return builder + + +def original_checks(): + return [ + Xs([3, 4, 7, 8]), + Xs([5, 6, 7, 9]), + Zs([2, 4, 5, 7]), + Zs([7, 8, 9]), + Zs([0, 1]) * Y(2), + pauli_string("X0 X2 Z3 Y4"), + pauli_string("X1 X2 Z6 Y5"), + ] + + +def fixed_checks(): + checks = original_checks() + checks[4] = Zs([0, 1, 2]) + return checks + + +def final_checks(): + return [ + Zs([2, 4, 5, 7]), + Xs([3, 4, 7, 8]), + Xs([5, 6, 7, 9]), + pauli_string("X0 X2 Z3 Y4"), + pauli_string("X1 X2 Z6 Y5"), + Zs([0, 1, 2]), + Xs([0, 1]), + Zs([3, 8]), + Zs([6, 9]), + ] +``` + +## Overview + +`StabilizerCodeSpec.builder(num_qubits)` collects stabilizer checks and, +optionally, explicitly chosen logical operators. A check is a `PauliString`. +The single-qubit `X`, `Y`, and `Z` constructors compose with `&`; `Xs`, `Ys`, +and `Zs` construct one Pauli type on several qubits at once. Multiplication +with `*` provides Pauli multiplication, while `pauli_string` parses sparse +text. + +```python +first = Xs([3, 4, 7, 8]) +assert first == X(3) & X(4) & X(7) & X(8) + +mixed = Zs([0, 1]) * Y(2) +assert mixed == Z(0) & Z(1) & Y(2) +assert Ys([1, 5]) == Y(1) & Y(5) +assert pauli_string("X1 X2 Z6 Y5") == X(1) & X(2) & Z(6) & Y(5) + +builder = StabilizerCodeSpec.builder(10) +builder.check(first) +builder.check(mixed) +``` + +Use `build()` when only count and independence validation is needed, +`build_verified()` to validate a supplied stabilizer and logical basis, or +`build_with_discovered_logicals()` to verify the checks and discover paired +logical operators and destabilizers. + +## Developing a Ten-Qubit Code + +Consider a ten-qubit design with seven proposed checks. The fifth check has a +`Y` on qubit 2: + +```python +checks = original_checks() +builder = add_checks(StabilizerCodeSpec.builder(10), checks) + +try: + builder.build_verified() +except ValueError as error: + message = str(error) +else: + raise AssertionError("the original checks should not verify") + +pair = re.search(r"generators (\d+) and (\d+) anticommute", message) +assert pair is not None +first_index, second_index = (int(index) for index in pair.groups()) +assert checks[first_index].anticommutes_with(checks[second_index]) +print(message) +``` + +```text +Stabilizer generators 2 and 4 anticommute +``` + +The indices identify the offending entries in insertion order. Replacing that +mixed check with `Zs([0, 1, 2])` produces a valid `[[10, 3]]` code. Building +with discovered logicals also supplies the destabilizer and paired-logical +generators displayed by `print(spec)`: + +```python +builder = add_checks(StabilizerCodeSpec.builder(10), fixed_checks()) +spec = builder.build_with_discovered_logicals() +result = spec.distance() + +assert spec.num_logical_qubits == 3 +assert result is not None +assert result.distance == 2 +assert result.min_weight_operator.weight() == 2 +print(spec) +print(result) +``` + +```text +[[10, 3]] +Stabilizer generators: ++IIIXXIIXXI ++IIIIIXXXIX ++IIZIZZIZII ++IIIIIIIZZZ ++ZZZIIIIIII ++XIXZYIIIII ++IXXIIYZIII +Destabilizer generators: ++IIIZIIIIII ++IZIIIZIIII ++ZIIIXIIIII ++IIIIIIIIXI ++ZIXIXIIIII ++ZIIIIIIIII ++IZIIIIIIII +Logical operators: +Z1: +IZIIIZZIII +X1: +IZIIIIXIII +Z2: +IZIZIZIZII +X2: +ZIIIXIIXXI +Z3: +IZIIIZIIIZ +X3: +IIIIIIIIXX + +DistanceResult(distance=2, min_weight_operator=X_0 X_1) +``` + +The parameters line is `[[n, k]]`; the distance result adds the minimum +logical weight and one operator attaining it. Adding checks that detect the +weight-two logicals, while removing the original `Zs([7, 8, 9])` check, gives +the final nine-check design: + +```python +builder = add_checks(StabilizerCodeSpec.builder(10), final_checks()) +spec = builder.build_with_discovered_logicals() +result = spec.distance() + +assert spec.num_logical_qubits == 1 +assert result is not None +assert result.distance == 3 +assert result.min_weight_operator.weight() == 3 +assert str(spec).splitlines()[0] == "[[10, 1]]" +print(str(spec).splitlines()[0]) +print(result) +``` + +```text +[[10, 1]] +DistanceResult(distance=3, min_weight_operator=X_0 X_2 X_7) +``` + +This is a `[[10, 1, 3]]` code: it encodes one logical qubit into ten physical +qubits and has distance three. + +## Exploring Low-Weight Logicals + +`min_weight_logicals()` returns every logical operator found at the minimum +weight. Each `LogicalOperatorInfo` records the Pauli operator, its weight, and +which chosen logical generators it is equivalent to modulo stabilizers. +`equivalence_string()` formats that last field compactly. + +`shortest_logicals(delta)` continues through `delta` weights above the minimum. +For the five-qubit code there are 30 weight-three logical operators. No +weight-four logicals exist, and `delta=2` exposes another 18 at weight five: + +```python +spec = StabilizerCodeSpec.from_stabilizer_code(StabilizerCode.five_qubit()) +minimum = spec.min_weight_logicals() +spectrum = spec.shortest_logicals(delta=2) + +assert len(minimum) == 30 +assert [info.operator for info in spectrum[: len(minimum)]] == [ + info.operator for info in minimum +] +assert {info.weight for info in spectrum} == {3, 5} +assert sum(info.weight == 5 for info in spectrum) == 18 +assert len(spectrum) == 48 +assert all(info.equivalence_string() for info in spectrum) + +first = minimum[0] +print(first.operator, first.weight, first.equivalence_string()) +``` + +```text +X_0 Y_1 X_2 3 X0*Z0 +``` + +Only genuine logical operators are returned; stabilizers and detected +operators are excluded even when their weights fall inside the requested +range. + +## Matrix Input + +For CSS codes, `ParityCheckMatrix` represents a role-neutral binary +checks-by-qubits matrix. `checks_from_css(x_stabilizers, z_stabilizers)` chooses +the role: rows in the first matrix become X-type stabilizers, and rows in the +second become Z-type stabilizers. + +### CSS Parity-Check Matrices + +The Steane code uses the classical Hamming parity-check matrix for both +blocks. Nested Python sequences and NumPy integer arrays are accepted: + +```python +hamming_h = [ + [1, 0, 1, 0, 1, 0, 1], + [0, 1, 1, 0, 0, 1, 1], + [0, 0, 0, 1, 1, 1, 1], +] + +plain = ParityCheckMatrix(hamming_h) +int64_matrix = ParityCheckMatrix(np.asarray(hamming_h, dtype=np.int64)) +uint8_matrix = ParityCheckMatrix(np.asarray(hamming_h, dtype=np.uint8)) +assert plain.rows() == int64_matrix.rows() == uint8_matrix.rows() == hamming_h + +builder = StabilizerCodeSpec.builder(7) +builder.checks_from_css(int64_matrix, uint8_matrix) +steane = builder.build_with_discovered_logicals() +result = steane.distance(css=True) + +assert steane.num_logical_qubits == 1 +assert result is not None +assert result.distance == StabilizerCode.steane().distance() == 3 +``` + +The builder checks CSS orthogonality before appending rows. It reports the +first X-row and Z-row pair with an odd overlap. The code-spec constructor then +protects the independent-generator invariant and reports both rank and count: + +```python +builder = StabilizerCodeSpec.builder(2) +try: + builder.checks_from_css( + ParityCheckMatrix([[1, 0]]), + ParityCheckMatrix([[1, 0]]), + ) +except ValueError as error: + orthogonality_message = str(error) +else: + raise AssertionError("non-orthogonal CSS rows should be rejected") +assert "X row 0 and Z row 0" in orthogonality_message + +dependent = ParityCheckMatrix( + [ + [1, 1, 0], + [0, 1, 1], + [1, 0, 1], + ] +) +builder = StabilizerCodeSpec.builder(3) +builder.checks_from_css(dependent, ParityCheckMatrix.zeros(0, 3)) +try: + builder.build() +except ValueError as error: + dependence_message = str(error) +else: + raise AssertionError("dependent stabilizers should be rejected") +assert "rank 2, count 3" in dependence_message +``` + +`ParityCheckMatrix.zeros(0, n)` carries the width that an empty nested list +cannot express. It is useful for a code with only one stabilizer type: + +```python +x_stabilizers = ParityCheckMatrix([[1, 1]]) +z_stabilizers = ParityCheckMatrix.zeros(0, 2) +assert z_stabilizers.rows() == [] +assert z_stabilizers.num_qubits() == 2 + +builder = StabilizerCodeSpec.builder(2) +builder.checks_from_css(x_stabilizers, z_stabilizers) +spec = builder.build_with_discovered_logicals() +assert spec.stabilizers == x_stabilizers.to_x_stabilizers() +assert spec.num_logical_qubits == 1 +``` + +### Symplectic Matrices + +`SymplecticMatrix` stores each Pauli row as `[X block | Z block]`. A set bit in +both blocks represents `Y`; phase information is not present, so +`to_positive_paulis()` always returns positive-phase operators. + +These are the four stabilizer rows of the five-qubit code: + +```python +rows = [ + [1, 0, 0, 1, 0, 0, 1, 1, 0, 0], + [0, 1, 0, 0, 1, 0, 0, 1, 1, 0], + [1, 0, 1, 0, 0, 0, 0, 0, 1, 1], + [0, 1, 0, 1, 0, 1, 0, 0, 0, 1], +] +matrix = SymplecticMatrix.from_dense(rows) +assert matrix.x_block() == [row[:5] for row in rows] +assert matrix.z_block() == [row[5:] for row in rows] + +builder = StabilizerCodeSpec.builder(5) +builder.checks_from_symplectic(matrix) +five_qubit = builder.build_with_discovered_logicals() +result = five_qubit.distance() + +assert matrix.to_positive_paulis() == five_qubit.stabilizers +assert result is not None +assert result.distance == 3 +``` + +As with CSS ingestion, width mismatches and anticommuting rows raise +`ValueError` before a spec is built. + +## Choosing a Distance Method + +Two exact distance calculations serve different regimes: + +| Method | Search strategy | Best use | +|--------|-----------------|----------| +| `StabilizerCode.distance()` | Enumerates stabilizer/logical cosets | Tiny codes with small generator counts | +| `StabilizerCodeSpec.distance()` | Enumerates Paulis by increasing weight | Codes whose distance is small relative to their length | + +The coset method is a useful oracle for tiny built-in codes. The spec method +supports `max_weight` as a search budget and `verbose=True` to print +`Checking weight N...` progress to standard error. It returns `None` if no +logical operator is found within the budget: + +```python +code = StabilizerCode.five_qubit() +spec = StabilizerCodeSpec.from_stabilizer_code(code) + +result = spec.distance() +assert result is not None +assert result.distance == code.distance() == 3 +assert spec.distance(max_weight=2, verbose=True) is None +``` + +The same `max_weight`, `css`, and `verbose` controls are available on +`min_weight_logicals()` and `shortest_logicals()`. + +## Next Steps + +- **[Pauli Algebra and QEC in Python](python-pauli-qec.md)** - Work with Pauli strings, sequences, and stabilizer groups +- **[Stabilizer Codes](stabilizer-codes.md)** - Understand the Rust stabilizer-code model +- **[QEC Geometry](qec-geometry.md)** - Describe layouts and check supports for code families diff --git a/mkdocs.yml b/mkdocs.yml index b58629fb6..c57c08b11 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -67,6 +67,7 @@ nav: - Quantum Information Primitives: user-guide/quantum-info.md - Stabilizer Codes: user-guide/stabilizer-codes.md - Pauli Algebra and QEC in Python: user-guide/python-pauli-qec.md + - Stabilizer-Code Verification: user-guide/stabilizer-code-verification.md - Fault Tolerance Analysis: user-guide/fault-tolerance.md - Fault Catalog Tutorial: user-guide/fault-catalog.md - QEC Geometry: user-guide/qec-geometry.md From 70a698dc6bbd353d4e0bcf60b01ad83176b10b97 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 4 Aug 2026 19:23:36 -0600 Subject: [PATCH 04/41] Apply blackdoc formatting to the verification guide --- docs/user-guide/stabilizer-code-verification.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/user-guide/stabilizer-code-verification.md b/docs/user-guide/stabilizer-code-verification.md index e2040476f..7e2da558d 100644 --- a/docs/user-guide/stabilizer-code-verification.md +++ b/docs/user-guide/stabilizer-code-verification.md @@ -217,9 +217,7 @@ minimum = spec.min_weight_logicals() spectrum = spec.shortest_logicals(delta=2) assert len(minimum) == 30 -assert [info.operator for info in spectrum[: len(minimum)]] == [ - info.operator for info in minimum -] +assert [info.operator for info in spectrum[: len(minimum)]] == [info.operator for info in minimum] assert {info.weight for info in spectrum} == {3, 5} assert sum(info.weight == 5 for info in spectrum) == 18 assert len(spectrum) == 48 From 6c070c84ecd627e64d77fc9c20f346ded93de752 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 5 Aug 2026 01:38:04 -0600 Subject: [PATCH 05/41] Consolidate duplicate distance searches and parallelize the search engine --- crates/benchmarks/benches/benchmarks.rs | 6 +- .../benches/modules/code_distance.rs | 89 ++++ crates/pecos-qec/src/distance.rs | 491 ++++++++++++++++-- .../stabilizer_flip_checker.rs | 37 +- crates/pecos-qec/src/lib.rs | 2 +- 5 files changed, 576 insertions(+), 49 deletions(-) create mode 100644 crates/benchmarks/benches/modules/code_distance.rs diff --git a/crates/benchmarks/benches/benchmarks.rs b/crates/benchmarks/benches/benchmarks.rs index d496fa89b..d20390bd2 100644 --- a/crates/benchmarks/benches/benchmarks.rs +++ b/crates/benchmarks/benches/benchmarks.rs @@ -17,6 +17,7 @@ use criterion::{Criterion, criterion_group, criterion_main}; mod modules { pub mod allocation_overhead; + pub mod code_distance; pub mod cpu_stabilizer_comparison; pub mod dem_builder; pub mod dem_sampler; @@ -58,14 +59,15 @@ use modules::sparse_stab_vs_cpp; #[cfg(feature = "stab-tn")] use modules::stab_mps_vs_stab_vec; use modules::{ - allocation_overhead, cpu_stabilizer_comparison, dem_builder, dem_sampler, dod_statevec, - fault_catalog, measurement_sampling, native_statevec_comparison, noise_models, + allocation_overhead, code_distance, cpu_stabilizer_comparison, dem_builder, dem_sampler, + dod_statevec, fault_catalog, measurement_sampling, native_statevec_comparison, noise_models, pecos_neo_comparison, quizx_eval, rng, set_ops, sparse_stab_w_vs_y, sparse_state_vec, stab_vec, stabilizer_sims, state_vec_sims, surface_code, tick_circuit_layout, trig, }; fn all_benchmarks(c: &mut Criterion) { allocation_overhead::benchmarks(c); + code_distance::benchmarks(c); stab_vec::benchmarks(c); cpu_stabilizer_comparison::benchmarks(c); quizx_eval::benchmarks(c); diff --git a/crates/benchmarks/benches/modules/code_distance.rs b/crates/benchmarks/benches/modules/code_distance.rs new file mode 100644 index 000000000..87a9a1117 --- /dev/null +++ b/crates/benchmarks/benches/modules/code_distance.rs @@ -0,0 +1,89 @@ +// 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. + +//! Exhaustive code-distance search benchmarks. + +use criterion::{Criterion, measurement::Measurement}; +use pecos_core::{Xs, Zs}; +use pecos_qec::{ + DistanceSearchConfig, StabilizerCode, StabilizerCodeSpec, calculate_distance, + find_shortest_logicals, +}; +use std::hint::black_box; + +pub fn benchmarks(c: &mut Criterion) { + eprintln!( + "code-distance benchmark available parallelism: {}", + std::thread::available_parallelism().map_or(1, std::num::NonZero::get) + ); + + let five_qubit = standard_code_spec(&StabilizerCode::five_qubit()); + let steane = standard_code_spec(&StabilizerCode::steane()); + let toric_3 = standard_code_spec(&StabilizerCode::toric(3)); + let color_17 = color_code_17(); + let config = DistanceSearchConfig::default(); + + let mut group = c.benchmark_group("code_distance/calculate_distance"); + group.sample_size(10); + group.bench_function("five_qubit_5_1_3", |b| { + b.iter(|| calculate_distance(black_box(&five_qubit), black_box(&config))); + }); + group.bench_function("steane_7_1_3", |b| { + b.iter(|| calculate_distance(black_box(&steane), black_box(&config))); + }); + group.bench_function("toric_3_18_2_3", |b| { + b.iter(|| calculate_distance(black_box(&toric_3), black_box(&config))); + }); + group.bench_function("color_17_1_5", |b| { + b.iter(|| calculate_distance(black_box(&color_17), black_box(&config))); + }); + group.finish(); + + let mut group = c.benchmark_group("code_distance/find_shortest_logicals"); + group.sample_size(10); + group.bench_function("color_17_1_5_delta_1", |b| { + b.iter(|| find_shortest_logicals(black_box(&color_17), black_box(&config), 1)); + }); + group.finish(); +} + +fn standard_code_spec(code: &StabilizerCode) -> StabilizerCodeSpec { + StabilizerCodeSpec::from_stabilizer_code(code) + .expect("standard stabilizer code should have discoverable logicals") +} + +fn color_code_17() -> StabilizerCodeSpec { + const SUPPORTS: [&[usize]; 8] = [ + &[0, 9, 12, 15], + &[1, 9, 12, 16], + &[2, 11, 13, 14], + &[3, 8, 9, 12], + &[4, 8, 10, 12, 13, 14, 15, 16], + &[5, 10, 11, 13], + &[6, 8, 9, 10, 13, 14, 15, 16], + &[7, 10, 11, 14], + ]; + + let mut builder = StabilizerCodeSpec::builder(17); + for support in SUPPORTS { + builder = builder.check(Xs(support)); + } + for support in SUPPORTS { + builder = builder.check(Zs(support)); + } + + builder + .logical_x(Xs(0..17)) + .logical_z(Zs(0..17)) + .build() + .expect("[[17,1,5]] color code should be valid") +} diff --git a/crates/pecos-qec/src/distance.rs b/crates/pecos-qec/src/distance.rs index a954d69a2..1ca33124c 100644 --- a/crates/pecos-qec/src/distance.rs +++ b/crates/pecos-qec/src/distance.rs @@ -16,7 +16,25 @@ //! by exhaustively searching for minimum weight logical operators. use crate::StabilizerCodeSpec; +use crate::stabilizer_code_spec::CodeIndices; use pecos_core::{Pauli, PauliString, QubitId}; +use rayon::prelude::*; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// Minimum candidate count at a single weight before that weight is searched in parallel. +/// +/// Rayon's fixed per-weight overhead is worth paying only once a weight carries enough +/// candidates to amortize it, so the decision is made per weight rather than per code: a +/// large code's low weights are still cheap and stay serial. +/// +/// The value sits inside a measured window (see `benches/modules/code_distance.rs`). +/// Below roughly 22k candidates parallelism loses: forcing the toric [[18, 2, 3]] weight-3 +/// tier (22,032 candidates) parallel made that search 4.6x slower than serial. Above +/// roughly 193k it stops engaging where it matters: the color [[17, 1, 5]] search spends +/// most of its time in the weight-4 tier (192,780 candidates), and a threshold past that +/// erased its speedup entirely. 65,536 is near the geometric centre of that window, so +/// both bounds keep margin. +const PARALLEL_CANDIDATE_THRESHOLD: usize = 65_536; /// Result of a distance calculation, including the minimum weight logical operator found. #[derive(Clone, Debug)] @@ -111,6 +129,16 @@ pub struct WeightedPauliIterator { css_pauli_type: usize, } +/// Position in the serial candidate enumeration. +/// +/// General searches order by support and then Pauli assignment. CSS searches order +/// all X supports before all Z supports, so the fields hold Pauli type and support. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct CandidateIndex { + outer: usize, + inner: usize, +} + impl WeightedPauliIterator { /// Create a new iterator for Pauli operators of the given weight. /// @@ -269,27 +297,41 @@ pub fn calculate_distance( let max_weight = config.max_weight.unwrap_or(code.num_qubits()); // Build indices once for O(weight) lookups instead of O(num_stabilizers * weight) - let stab_index = code.build_stabilizer_index(); - let log_index = code.build_logical_index(); + let indices = code.build_indices(); for weight in 1..=max_weight { if config.verbose { eprintln!("Checking weight {weight}..."); } - for pauli in WeightedPauliIterator::new(code.num_qubits(), weight, config.css_only) { - if code.is_logical_error_indexed(&pauli, &stab_index, &log_index) { - return Some(DistanceResult { - distance: weight, - min_weight_operator: pauli, - }); - } + if let Some(pauli) = first_logical_error_at_weight(code, weight, config, &indices) { + return Some(DistanceResult { + distance: weight, + min_weight_operator: pauli, + }); } } None } +/// Check whether a code has a logical error at exactly one physical weight. +/// +/// Stabilizer and logical column indices are built once for the complete scan. +#[must_use] +pub fn has_logical_error_at_weight( + code: &StabilizerCodeSpec, + weight: usize, + config: &DistanceSearchConfig, +) -> bool { + if config.verbose { + eprintln!("Checking weight {weight}..."); + } + + let indices = code.build_indices(); + first_logical_error_at_weight(code, weight, config, &indices).is_some() +} + /// Find all minimum weight logical operators. /// /// Unlike `calculate_distance`, this returns all logical operators of the minimum weight, @@ -364,8 +406,7 @@ pub fn find_shortest_logicals( let mut found_distance: Option = None; // Build indices once for O(weight) lookups instead of O(num_stabilizers * weight) - let stab_index = code.build_stabilizer_index(); - let log_index = code.build_logical_index(); + let indices = code.build_indices(); for weight in 1..=max_weight { // If we've searched through the requested range above the minimum, stop. @@ -379,31 +420,331 @@ pub fn find_shortest_logicals( eprintln!("Checking weight {weight}..."); } - for pauli in WeightedPauliIterator::new(code.num_qubits(), weight, config.css_only) { - if code.is_logical_error_indexed(&pauli, &stab_index, &log_index) { - if found_distance.is_none() { - found_distance = Some(weight); - } + let weight_matches = logical_errors_at_weight(code, weight, config, &indices); + if !weight_matches.is_empty() && found_distance.is_none() { + found_distance = Some(weight); + } - // Determine which logical operators this is equivalent to - let equivalent_logicals = classify_logical_equivalence_indexed( - &log_index, - code.num_logical_qubits(), - &pauli, - ); - - results.push(LogicalOperatorInfo { - operator: pauli, - weight, - equivalent_logicals, - }); - } + for pauli in weight_matches { + // Determine which logical operators this is equivalent to + let equivalent_logicals = classify_logical_equivalence_indexed( + &indices.logical, + code.num_logical_qubits(), + &pauli, + ); + + results.push(LogicalOperatorInfo { + operator: pauli, + weight, + equivalent_logicals, + }); } } results } +fn first_logical_error_at_weight( + code: &StabilizerCodeSpec, + weight: usize, + config: &DistanceSearchConfig, + indices: &CodeIndices, +) -> Option { + if should_parallelize(code.num_qubits(), weight, config.css_only) { + first_logical_error_at_weight_parallel(code, weight, config, indices) + } else { + first_logical_error_at_weight_serial(code, weight, config, indices) + } +} + +fn first_logical_error_at_weight_serial( + code: &StabilizerCodeSpec, + weight: usize, + config: &DistanceSearchConfig, + indices: &CodeIndices, +) -> Option { + WeightedPauliIterator::new(code.num_qubits(), weight, config.css_only) + .find(|pauli| code.is_logical_error_indexed(pauli, &indices.stabilizer, &indices.logical)) +} + +fn first_logical_error_at_weight_parallel( + code: &StabilizerCodeSpec, + weight: usize, + config: &DistanceSearchConfig, + indices: &CodeIndices, +) -> Option { + let best_support = AtomicUsize::new(usize::MAX); + + support_combinations_at_weight(code.num_qubits(), weight) + .filter_map(|(support_index, support)| { + if support_index > best_support.load(Ordering::Relaxed) { + return None; + } + + let candidate = first_matching_pauli_for_support( + code, + indices, + support_index, + &support.qubits(), + config.css_only, + &best_support, + ); + if candidate + .as_ref() + .is_some_and(|(index, _)| !config.css_only || index.outer == 0) + { + best_support.fetch_min(support_index, Ordering::Relaxed); + } + candidate + }) + .min_by_key(|(index, _)| *index) + .map(|(_, pauli)| pauli) +} + +fn logical_errors_at_weight( + code: &StabilizerCodeSpec, + weight: usize, + config: &DistanceSearchConfig, + indices: &CodeIndices, +) -> Vec { + if should_parallelize(code.num_qubits(), weight, config.css_only) { + logical_errors_at_weight_parallel(code, weight, config.css_only, indices) + } else { + logical_errors_at_weight_serial(code, weight, config.css_only, indices) + } +} + +fn logical_errors_at_weight_serial( + code: &StabilizerCodeSpec, + weight: usize, + css_only: bool, + indices: &CodeIndices, +) -> Vec { + WeightedPauliIterator::new(code.num_qubits(), weight, css_only) + .filter(|pauli| code.is_logical_error_indexed(pauli, &indices.stabilizer, &indices.logical)) + .collect() +} + +fn logical_errors_at_weight_parallel( + code: &StabilizerCodeSpec, + weight: usize, + css_only: bool, + indices: &CodeIndices, +) -> Vec { + let mut matches: Vec<_> = matching_paulis_at_weight(code, weight, css_only, indices).collect(); + matches.sort_unstable_by_key(|(index, _)| *index); + matches.into_iter().map(|(_, pauli)| pauli).collect() +} + +fn should_parallelize(num_qubits: usize, weight: usize, css_only: bool) -> bool { + candidate_count_at_weight(num_qubits, weight, css_only) > PARALLEL_CANDIDATE_THRESHOLD +} + +fn candidate_count_at_weight(num_qubits: usize, weight: usize, css_only: bool) -> usize { + let support_count = saturating_binomial(num_qubits, weight); + let assignments_per_support = if css_only { + 2 + } else { + 3usize.saturating_pow(u32::try_from(weight).unwrap_or(u32::MAX)) + }; + + support_count.saturating_mul(assignments_per_support) +} + +fn saturating_binomial(n: usize, k: usize) -> usize { + if k > n { + return 0; + } + + let k = k.min(n - k); + let mut result = 1_u128; + for i in 1..=k { + result = result * (n - k + i) as u128 / i as u128; + if result > usize::MAX as u128 { + return usize::MAX; + } + } + + usize::try_from(result).unwrap_or(usize::MAX) +} + +fn matching_paulis_at_weight<'a>( + code: &'a StabilizerCodeSpec, + weight: usize, + css_only: bool, + indices: &'a CodeIndices, +) -> impl ParallelIterator + 'a { + support_combinations_at_weight(code.num_qubits(), weight).flat_map_iter( + move |(support_index, support)| { + matching_paulis_for_support(code, indices, support_index, &support.qubits(), css_only) + .into_iter() + }, + ) +} + +fn support_combinations_at_weight( + num_qubits: usize, + weight: usize, +) -> impl ParallelIterator { + // Bridge only support combinations into Rayon. Each task enumerates all Pauli + // assignments for its support serially, amortizing synchronization overhead. + WeightedPauliIterator::new(num_qubits, weight, true) + .take_while(|pauli| { + pauli + .paulis() + .first() + .is_some_and(|(pauli, _)| *pauli == Pauli::X) + }) + .enumerate() + .par_bridge() +} + +fn first_matching_pauli_for_support( + code: &StabilizerCodeSpec, + indices: &CodeIndices, + support_index: usize, + positions: &[usize], + css_only: bool, + best_support: &AtomicUsize, +) -> Option<(CandidateIndex, PauliString)> { + if css_only { + let x = PauliString::xs(positions); + if code.is_logical_error_indexed(&x, &indices.stabilizer, &indices.logical) { + return Some(( + CandidateIndex { + outer: 0, + inner: support_index, + }, + x, + )); + } + + // Any X match sorts before every Z match. Once another task has found an + // X candidate, a Z candidate cannot improve the reduction. + if best_support.load(Ordering::Relaxed) != usize::MAX { + return None; + } + + let z = PauliString::zs(positions); + return code + .is_logical_error_indexed(&z, &indices.stabilizer, &indices.logical) + .then_some(( + CandidateIndex { + outer: 1, + inner: support_index, + }, + z, + )); + } + + let mut paulis = vec![0; positions.len()]; + let mut assignment_index = 0; + + loop { + let pauli = pauli_string_for_assignment(positions, &paulis); + if code.is_logical_error_indexed(&pauli, &indices.stabilizer, &indices.logical) { + return Some(( + CandidateIndex { + outer: support_index, + inner: assignment_index, + }, + pauli, + )); + } + + if !increment_pauli_assignment(&mut paulis) { + return None; + } + assignment_index += 1; + } +} + +fn matching_paulis_for_support( + code: &StabilizerCodeSpec, + indices: &CodeIndices, + support_index: usize, + positions: &[usize], + css_only: bool, +) -> Vec<(CandidateIndex, PauliString)> { + if css_only { + let candidates = [ + ( + CandidateIndex { + outer: 0, + inner: support_index, + }, + PauliString::xs(positions), + ), + ( + CandidateIndex { + outer: 1, + inner: support_index, + }, + PauliString::zs(positions), + ), + ]; + + return candidates + .into_iter() + .filter(|(_, pauli)| { + code.is_logical_error_indexed(pauli, &indices.stabilizer, &indices.logical) + }) + .collect(); + } + + let mut matches = Vec::new(); + let mut paulis = vec![0; positions.len()]; + let mut assignment_index = 0; + + loop { + let pauli = pauli_string_for_assignment(positions, &paulis); + if code.is_logical_error_indexed(&pauli, &indices.stabilizer, &indices.logical) { + matches.push(( + CandidateIndex { + outer: support_index, + inner: assignment_index, + }, + pauli, + )); + } + + if !increment_pauli_assignment(&mut paulis) { + break; + } + assignment_index += 1; + } + + matches +} + +fn pauli_string_for_assignment(positions: &[usize], paulis: &[usize]) -> PauliString { + let paulis = positions + .iter() + .zip(paulis) + .map(|(&position, &pauli)| { + let pauli = match pauli { + 0 => Pauli::X, + 1 => Pauli::Y, + _ => Pauli::Z, + }; + (pauli, QubitId::new(position)) + }) + .collect(); + + PauliString::with_phase_and_paulis(pecos_core::QuarterPhase::PlusOne, paulis) +} + +fn increment_pauli_assignment(paulis: &mut [usize]) -> bool { + for index in (0..paulis.len()).rev() { + if paulis[index] < 2 { + paulis[index] += 1; + paulis[index + 1..].fill(0); + return true; + } + } + false +} + /// Classify which logical operators a given Pauli operator is equivalent to. /// /// Uses precomputed column indices for O(weight) performance instead of @@ -442,7 +783,7 @@ fn classify_logical_equivalence_indexed( #[cfg(test)] mod tests { use super::*; - use pecos_core::{Pauli, PauliOperator}; + use pecos_core::{Pauli, PauliOperator, Xs, Zs}; fn pauli_string(paulis: &[(Pauli, usize)]) -> PauliString { PauliString::with_phase_and_paulis( @@ -484,6 +825,33 @@ mod tests { .unwrap() } + fn color_code_17() -> StabilizerCodeSpec { + const SUPPORTS: [&[usize]; 8] = [ + &[0, 9, 12, 15], + &[1, 9, 12, 16], + &[2, 11, 13, 14], + &[3, 8, 9, 12], + &[4, 8, 10, 12, 13, 14, 15, 16], + &[5, 10, 11, 13], + &[6, 8, 9, 10, 13, 14, 15, 16], + &[7, 10, 11, 14], + ]; + + let mut builder = StabilizerCodeSpec::builder(17); + for support in SUPPORTS { + builder = builder.check(Xs(support)); + } + for support in SUPPORTS { + builder = builder.check(Zs(support)); + } + + builder + .logical_x(Xs(0..17)) + .logical_z(Zs(0..17)) + .build() + .expect("[[17,1,5]] color code should be valid") + } + #[test] fn test_weighted_pauli_iterator_weight_1() { let iter = WeightedPauliIterator::new(3, 1, false); @@ -624,6 +992,69 @@ mod tests { ); } + #[test] + fn test_serial_branch_preserves_candidate_order() { + let code = five_qubit_code(); + let config = DistanceSearchConfig::default(); + let indices = code.build_indices(); + assert!(!(1..=5).any(|weight| should_parallelize(5, weight, false))); + let expected: Vec<_> = (1..=5) + .flat_map(|weight| { + WeightedPauliIterator::new(code.num_qubits(), weight, config.css_only) + .filter(|pauli| { + code.is_logical_error_indexed(pauli, &indices.stabilizer, &indices.logical) + }) + .map(move |pauli| (weight, pauli)) + }) + .collect(); + + let distance = calculate_distance(&code, &config).unwrap(); + assert_eq!(distance.min_weight_operator, expected[0].1); + + let actual = find_shortest_logicals(&code, &config, 2); + assert!( + actual + .iter() + .map(|info| (info.weight, &info.operator)) + .eq(expected.iter().map(|(weight, pauli)| (*weight, pauli))) + ); + + let css_config = DistanceSearchConfig::css(); + let expected_css = (1..=code.num_qubits()) + .flat_map(|weight| { + WeightedPauliIterator::new(code.num_qubits(), weight, true) + .filter(|pauli| { + code.is_logical_error_indexed(pauli, &indices.stabilizer, &indices.logical) + }) + .map(move |pauli| (weight, pauli)) + }) + .next() + .unwrap(); + let actual_css = calculate_distance(&code, &css_config).unwrap(); + assert_eq!(actual_css.distance, expected_css.0); + assert_eq!(actual_css.min_weight_operator, expected_css.1); + } + + #[test] + fn test_parallel_branch_preserves_serial_candidate_order() { + let code = color_code_17(); + let config = DistanceSearchConfig::with_max_weight(5); + let indices = code.build_indices(); + assert!(should_parallelize(17, 5, false)); + + let expected: Vec<_> = WeightedPauliIterator::new(17, 5, false) + .filter(|pauli| { + code.is_logical_error_indexed(pauli, &indices.stabilizer, &indices.logical) + }) + .collect(); + let actual = logical_errors_at_weight(&code, 5, &config, &indices); + assert_eq!(actual, expected); + + let distance = calculate_distance(&code, &config).unwrap(); + assert_eq!(distance.distance, 5); + assert_eq!(distance.min_weight_operator, expected[0]); + } + #[test] fn test_logical_equivalence_tracking() { // 3-qubit bit flip code diff --git a/crates/pecos-qec/src/fault_tolerance/stabilizer_flip_checker.rs b/crates/pecos-qec/src/fault_tolerance/stabilizer_flip_checker.rs index 85c7ea21e..d4043b97b 100644 --- a/crates/pecos-qec/src/fault_tolerance/stabilizer_flip_checker.rs +++ b/crates/pecos-qec/src/fault_tolerance/stabilizer_flip_checker.rs @@ -473,21 +473,11 @@ impl<'a> StabilizerFlipChecker<'a> { /// Returns early on first failure, more efficient than full analysis. #[must_use] pub fn has_undetectable_logical(&self, weight: usize) -> bool { - let n = self.code.num_qubits(); - let pauli_types = [1u8, 2, 3]; // X, Y, Z - - for positions in combinations(n, weight) { - for paulis in pauli_product(&pauli_types, weight) { - let error = build_pauli_string(&positions, &paulis); - let flips = self.compute_flips(&error); - - if flips.is_undetectable() && flips.has_logical_error() { - return true; - } - } - } - - false + crate::distance::has_logical_error_at_weight( + self.code, + weight, + &crate::DistanceSearchConfig::default(), + ) } /// Compute the distance of the code. @@ -496,7 +486,11 @@ impl<'a> StabilizerFlipChecker<'a> { /// Returns None if no undetectable logical error is found up to `max_weight`. #[must_use] pub fn compute_distance(&self, max_weight: usize) -> Option { - (1..=max_weight).find(|&w| self.has_undetectable_logical(w)) + crate::calculate_distance( + self.code, + &crate::DistanceSearchConfig::with_max_weight(max_weight), + ) + .map(|result| result.distance) } } @@ -957,6 +951,17 @@ mod tests { assert_eq!(distance, Some(3), "Steane code distance should be 3"); } + #[test] + fn test_compute_distance_matches_calculate_distance() { + let code = steane_code(); + let checker_distance = StabilizerFlipChecker::new(&code).compute_distance(5); + let engine_distance = + crate::calculate_distance(&code, &crate::DistanceSearchConfig::with_max_weight(5)) + .map(|result| result.distance); + + assert_eq!(checker_distance, engine_distance); + } + #[test] fn test_steane_code_parameters() { let code = steane_code(); diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index 77823cfcf..0dadd95f8 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -81,7 +81,7 @@ pub use parity_check_matrix::{ParityCheckMatrix, ParityCheckMatrixError}; pub use distance::{ DistanceResult, DistanceSearchConfig, LogicalOperatorInfo, WeightedPauliIterator, calculate_distance, find_min_weight_logicals, find_min_weight_logicals_with_info, - find_shortest_logicals, + find_shortest_logicals, has_logical_error_at_weight, }; pub use fault_tolerance::dem_builder::{ DecomposedFault, DemBuilder, DemBuilderError, DemOutput, DetectorDef, DetectorErrorModel, From c44d21185a1df492f4b891bba559412fc11c641b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 5 Aug 2026 09:24:37 -0600 Subject: [PATCH 06/41] Add detector-error-model fault distance with graphlike and exhaustive methods --- crates/pecos-qec/src/fault_tolerance.rs | 4 + .../src/fault_tolerance/fault_distance.rs | 423 ++++++++++++++++++ crates/pecos-qec/src/lib.rs | 17 +- python/pecos-rslib/pecos_rslib/qec.pyi | 35 ++ .../src/fault_tolerance_bindings.rs | 56 +++ .../quantum-pecos/src/pecos/qec/__init__.py | 2 + .../tests/qec/test_fault_distance.py | 60 +++ 7 files changed, 589 insertions(+), 8 deletions(-) create mode 100644 crates/pecos-qec/src/fault_tolerance/fault_distance.rs create mode 100644 python/pecos-rslib/pecos_rslib/qec.pyi create mode 100644 python/quantum-pecos/tests/qec/test_fault_distance.py diff --git a/crates/pecos-qec/src/fault_tolerance.rs b/crates/pecos-qec/src/fault_tolerance.rs index 5f2147c6f..a185cdedc 100644 --- a/crates/pecos-qec/src/fault_tolerance.rs +++ b/crates/pecos-qec/src/fault_tolerance.rs @@ -21,6 +21,7 @@ pub mod circuit_runner; pub mod correlation; pub mod decoder_integration; pub mod dem_builder; +pub mod fault_distance; pub mod fault_sampler; pub mod gadget_checker; pub mod influence_builder; @@ -43,6 +44,9 @@ pub use decoder_integration::{ CorrectionResult, ErrorCorrectionChecker, ErrorCorrectionConfig, ErrorCorrectionResult, LookupTableDecoder, apply_recovery, extract_syndrome, run_correction_cycle, }; +pub use fault_distance::{ + FaultDistanceError, FaultDistanceResult, exhaustive_fault_distance, graphlike_fault_distance, +}; pub use gadget_checker::{ GadgetAnalysis, GadgetChecker, GadgetConfig, GadgetDecoderAnalysis, GadgetFaultClass, GadgetFaultResult, GadgetFollowUpConfig, GadgetHistoryAnalysis, GadgetHistoryPattern, diff --git a/crates/pecos-qec/src/fault_tolerance/fault_distance.rs b/crates/pecos-qec/src/fault_tolerance/fault_distance.rs new file mode 100644 index 000000000..3fa589132 --- /dev/null +++ b/crates/pecos-qec/src/fault_tolerance/fault_distance.rs @@ -0,0 +1,423 @@ +// 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. + +//! Fault-distance searches for detector error models. + +use super::dem_builder::{DetectorErrorModel, FaultMechanism}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::fmt; + +/// Result of a fault-distance calculation, including one minimum-size witness. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FaultDistanceResult { + /// Minimum number of fault mechanisms in an undetectable logical error. + pub distance: usize, + /// Witnessing indices into [`DetectorErrorModel::to_mechanisms`], sorted ascending. + pub mechanism_indices: Vec, +} + +/// Error returned when a requested fault-distance algorithm does not apply. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FaultDistanceError { + /// The graphlike search was given mechanisms that flip more than two detectors. + HyperedgesPresent { + /// Number of hyperedge mechanisms in the detector error model. + count: usize, + }, +} + +impl fmt::Display for FaultDistanceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::HyperedgesPresent { count } => write!( + f, + "graphlike fault-distance search requires every mechanism to flip at most 2 detectors; found {count} hyperedge mechanism(s)" + ), + } + } +} + +impl std::error::Error for FaultDistanceError {} + +fn mechanisms_from_dem(dem: &DetectorErrorModel) -> Vec { + let (mechanisms, _coordinates) = dem.to_mechanisms(); + mechanisms + .into_iter() + .map(|(_probability, detectors, observables)| { + // Fault distance is unit-weight: mechanism probabilities are deliberately ignored. + FaultMechanism::from_unsorted(detectors, observables) + }) + .collect() +} + +#[derive(Clone, Copy)] +struct GraphEdge { + neighbor: usize, + mechanism_index: usize, +} + +fn update_best(best: &mut Option, mut mechanism_indices: Vec) { + mechanism_indices.sort_unstable(); + let candidate = FaultDistanceResult { + distance: mechanism_indices.len(), + mechanism_indices, + }; + if best.as_ref().is_none_or(|current| { + (candidate.distance, &candidate.mechanism_indices) + < (current.distance, ¤t.mechanism_indices) + }) { + *best = Some(candidate); + } +} + +/// Computes the exact fault distance of a graphlike detector error model. +/// +/// A graphlike mechanism flips at most two detectors. The search returns an error when any +/// hyperedge is present; it never drops unsupported mechanisms or silently changes algorithms. +/// Probabilities are ignored because distance counts mechanisms with unit weight. +/// +/// A mechanism with no detectors and at least one observable is handled first because it proves +/// distance one without requiring any graph construction. +/// +/// # Errors +/// +/// Returns [`FaultDistanceError::HyperedgesPresent`] with the number of mechanisms that flip more +/// than two detectors. +pub fn graphlike_fault_distance( + dem: &DetectorErrorModel, +) -> Result, FaultDistanceError> { + let mechanisms = mechanisms_from_dem(dem); + + if let Some(mechanism_index) = mechanisms + .iter() + .position(|mechanism| mechanism.detectors.is_empty() && !mechanism.dem_outputs.is_empty()) + { + return Ok(Some(FaultDistanceResult { + distance: 1, + mechanism_indices: vec![mechanism_index], + })); + } + + let hyperedge_count = mechanisms + .iter() + .filter(|mechanism| mechanism.is_hyperedge()) + .count(); + if hyperedge_count != 0 { + return Err(FaultDistanceError::HyperedgesPresent { + count: hyperedge_count, + }); + } + + let detector_ids: BTreeSet = mechanisms + .iter() + .flat_map(|mechanism| mechanism.detectors.iter().copied()) + .collect(); + let detector_nodes: BTreeMap = detector_ids + .into_iter() + .enumerate() + .map(|(node, detector)| (detector, node)) + .collect(); + let boundary = detector_nodes.len(); + let mut adjacency = vec![Vec::new(); boundary + 1]; + + for (mechanism_index, mechanism) in mechanisms.iter().enumerate() { + let endpoints = match mechanism.detectors.as_slice() { + [] => continue, + [detector] => (detector_nodes[detector], boundary), + [first, second] => (detector_nodes[first], detector_nodes[second]), + _ => unreachable!("hyperedges were rejected before graph construction"), + }; + adjacency[endpoints.0].push(GraphEdge { + neighbor: endpoints.1, + mechanism_index, + }); + adjacency[endpoints.1].push(GraphEdge { + neighbor: endpoints.0, + mechanism_index, + }); + } + + let observables: BTreeSet = mechanisms + .iter() + .flat_map(|mechanism| mechanism.dem_outputs.iter().copied()) + .collect(); + let num_states = adjacency.len() * 2; + let mut best = None; + + for observable in observables { + for start_node in 0..adjacency.len() { + let start = start_node * 2; + let target = start + 1; + + // `pecos-num::Graph` stores f64 weights, while its path APIs return either distances + // or a path, not both. These nodes are also synthetic parity states, so local + // unweighted BFS gives the exact distance and witness directly without materializing + // a weighted graph. Every detector and the boundary must be a root: detector-rooted + // searches find odd-parity cycles in components with no boundary edge. + let mut distance = vec![usize::MAX; num_states]; + let mut predecessor: Vec> = vec![None; num_states]; + let mut queue = VecDeque::from([start]); + distance[start] = 0; + + while let Some(state) = queue.pop_front() { + if state == target { + break; + } + let node = state / 2; + let parity = state % 2; + for edge in &adjacency[node] { + let toggles_observable = mechanisms[edge.mechanism_index] + .dem_outputs + .binary_search(&observable) + .is_ok(); + let next_parity = parity ^ usize::from(toggles_observable); + let next_state = edge.neighbor * 2 + next_parity; + if distance[next_state] == usize::MAX { + distance[next_state] = distance[state] + 1; + predecessor[next_state] = Some((state, edge.mechanism_index)); + queue.push_back(next_state); + } + } + } + + if distance[target] == usize::MAX { + continue; + } + + let mut witness = Vec::with_capacity(distance[target]); + let mut state = target; + while state != start { + let Some((previous, mechanism_index)) = predecessor[state] else { + break; + }; + witness.push(mechanism_index); + state = previous; + } + if state != start { + continue; + } + debug_assert_eq!(witness.len(), distance[target]); + update_best(&mut best, witness); + } + } + + Ok(best) +} + +fn first_witness_of_weight(mechanisms: &[FaultMechanism], weight: usize) -> Option> { + if weight == 0 || weight > mechanisms.len() { + return None; + } + + let mut indices: Vec = (0..weight).collect(); + loop { + let effect = indices + .iter() + .fold(FaultMechanism::new(), |effect, &index| { + effect.xor(&mechanisms[index]) + }); + if effect.detectors.is_empty() && !effect.dem_outputs.is_empty() { + return Some(indices); + } + + let position = (0..weight) + .rev() + .find(|&position| indices[position] < mechanisms.len() - weight + position)?; + indices[position] += 1; + for next in (position + 1)..weight { + indices[next] = indices[next - 1] + 1; + } + } +} + +/// Exhaustively computes fault distance up to `max_weight` for any detector error model. +/// +/// This method supports hyperedges and ignores mechanism probabilities. It examines mechanism +/// subsets in increasing size and returns the first detector-free subset whose XOR flips at least +/// one observable. Its cost is combinatorial: in the worst case it checks +/// `sum(binomial(num_mechanisms, weight), weight=1..max_weight)` subsets, so callers must choose an +/// explicit search budget. +#[must_use] +pub fn exhaustive_fault_distance( + dem: &DetectorErrorModel, + max_weight: usize, +) -> Option { + let mechanisms = mechanisms_from_dem(dem); + for weight in 1..=max_weight.min(mechanisms.len()) { + if let Some(mechanism_indices) = first_witness_of_weight(&mechanisms, weight) { + return Some(FaultDistanceResult { + distance: weight, + mechanism_indices, + }); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dem_from_effects(effects: &[(Vec, Vec)]) -> DetectorErrorModel { + let mut dem = DetectorErrorModel::new(); + for (detectors, observables) in effects { + dem.add_direct_contribution( + FaultMechanism::from_unsorted( + detectors.iter().copied(), + observables.iter().copied(), + ), + 0.01, + ); + } + dem + } + + #[test] + fn distance_one_detector_free_mechanism() { + let dem = dem_from_effects(&[(vec![], vec![0])]); + let expected = FaultDistanceResult { + distance: 1, + mechanism_indices: vec![0], + }; + + assert_eq!(graphlike_fault_distance(&dem), Ok(Some(expected.clone()))); + assert_eq!(exhaustive_fault_distance(&dem, 1), Some(expected)); + } + + #[test] + fn repetition_code_triad_has_distance_three_and_same_witness() { + let dem = dem_from_effects(&[(vec![0, 1], vec![0]), (vec![0], vec![]), (vec![1], vec![])]); + let expected = FaultDistanceResult { + distance: 3, + mechanism_indices: vec![0, 1, 2], + }; + + assert_eq!(graphlike_fault_distance(&dem), Ok(Some(expected.clone()))); + assert_eq!(exhaustive_fault_distance(&dem, 3), Some(expected)); + } + + #[test] + fn detector_only_cycle_has_distance_three_and_same_witness() { + // There are no boundary edges. All three mechanisms form the unique detector-free + // logical cycle: D0 D1 L0 ^ D1 D2 ^ D0 D2 = L0. + let dem = dem_from_effects(&[ + (vec![0, 1], vec![0]), + (vec![1, 2], vec![]), + (vec![0, 2], vec![]), + ]); + let expected = FaultDistanceResult { + distance: 3, + mechanism_indices: vec![0, 1, 2], + }; + + assert_eq!(graphlike_fault_distance(&dem), Ok(Some(expected.clone()))); + assert_eq!(exhaustive_fault_distance(&dem, 3), Some(expected)); + } + + #[test] + fn no_undetectable_logical_error_returns_none() { + let dem = dem_from_effects(&[(vec![0], vec![0]), (vec![1], vec![])]); + + assert_eq!(graphlike_fault_distance(&dem), Ok(None)); + assert_eq!(exhaustive_fault_distance(&dem, 8), None); + } + + #[test] + fn exhaustive_budget_below_distance_returns_none() { + let dem = dem_from_effects(&[(vec![0, 1], vec![0]), (vec![0], vec![]), (vec![1], vec![])]); + + assert_eq!(exhaustive_fault_distance(&dem, 2), None); + } + + #[test] + fn graphlike_rejects_hyperedges_and_exhaustive_uses_them() { + // The only logical witness is all three mechanisms. It necessarily includes the + // hyperedge D0 D1 D2 L0, whose detectors cancel against D0 D1 and D2. + let dem = dem_from_effects(&[ + (vec![0, 1, 2], vec![0]), + (vec![0, 1], vec![]), + (vec![2], vec![]), + ]); + + assert_eq!( + graphlike_fault_distance(&dem), + Err(FaultDistanceError::HyperedgesPresent { count: 1 }) + ); + assert_eq!( + exhaustive_fault_distance(&dem, 3), + Some(FaultDistanceResult { + distance: 3, + mechanism_indices: vec![0, 1, 2], + }) + ); + assert_eq!(exhaustive_fault_distance(&dem, 2), None); + } + + #[test] + fn searches_every_observable() { + // L0 has no detector-free witness. The two D0 mechanisms cancel their detector and flip + // L1, so a search restricted to L0 would incorrectly return None. + let dem = dem_from_effects(&[(vec![1], vec![0]), (vec![0], vec![1]), (vec![0], vec![])]); + let expected = FaultDistanceResult { + distance: 2, + mechanism_indices: vec![0, 1], + }; + + assert_eq!(graphlike_fault_distance(&dem), Ok(Some(expected.clone()))); + assert_eq!(exhaustive_fault_distance(&dem, 3), Some(expected)); + } + + #[test] + fn graphlike_matches_exhaustive_on_seeded_small_random_dems() { + use rand::rngs::SmallRng; + use rand::{RngExt, SeedableRng}; + + const NUM_CASES: usize = 512; + const MAX_MECHANISMS: usize = 6; + const NUM_DETECTORS: u32 = 4; + const NUM_OBSERVABLES: u32 = 2; + + let mut rng = SmallRng::seed_from_u64(0x000D_157A_11CE_5EED); + for case_index in 0..NUM_CASES { + let num_mechanisms = rng.random_range(0..=MAX_MECHANISMS); + let mut effects = Vec::with_capacity(num_mechanisms); + for _ in 0..num_mechanisms { + let mut detectors = Vec::with_capacity(2); + for detector in 0..NUM_DETECTORS { + if detectors.len() < 2 && rng.random_bool(0.5) { + detectors.push(detector); + } + } + let observables = (0..NUM_OBSERVABLES) + .filter(|_| rng.random_bool(0.5)) + .collect(); + effects.push((detectors, observables)); + } + + let dem = dem_from_effects(&effects); + let graphlike = graphlike_fault_distance(&dem) + .expect("the generator creates only graphlike mechanisms"); + let exhaustive = exhaustive_fault_distance(&dem, MAX_MECHANISMS); + + assert_eq!( + graphlike.is_some(), + exhaustive.is_some(), + "solution existence differed for seeded case {case_index}: {effects:?}" + ); + assert_eq!( + graphlike.as_ref().map(|result| result.distance), + exhaustive.as_ref().map(|result| result.distance), + "distance differed for seeded case {case_index}: {effects:?}" + ); + } + } +} diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index 50100c2e2..d5572e142 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -87,14 +87,15 @@ pub use fault_tolerance::dem_builder::{ pub use fault_tolerance::{ CorrectionResult, DecoderAnalysis, DemOutputKind, DemOutputMetadata, ErrorClass, ErrorCorrectionChecker, ErrorCorrectionConfig, ErrorCorrectionResult, FaultCheckConfig, - FaultCheckResult, FaultChecker, FaultClass, FaultConfiguration, FaultToleranceAnalysis, - FaultToleranceFailure, LookupTableDecoder, MeasurementRound, PauliFault, PauliFaultIterator, - PauliPropChecker, PropagationResult, SpacetimeLocation, StabilizerFlipAnalysis, - StabilizerFlipChecker, StabilizerFlips, SyndromeAnalysis, SyndromeClass, SyndromeHistory, - SyndromeHistoryAnalysis, SyndromeHistoryResult, anticommutes_with_logical, apply_recovery, - classify_fault, extract_measurement_rounds, extract_spacetime_locations, extract_syndrome, - get_syndrome_flips, has_syndrome, propagate_fault, propagate_faults, run_circuit_with_faults, - run_correction_cycle, + FaultCheckResult, FaultChecker, FaultClass, FaultConfiguration, FaultDistanceError, + FaultDistanceResult, FaultToleranceAnalysis, FaultToleranceFailure, LookupTableDecoder, + MeasurementRound, PauliFault, PauliFaultIterator, PauliPropChecker, PropagationResult, + SpacetimeLocation, StabilizerFlipAnalysis, StabilizerFlipChecker, StabilizerFlips, + SyndromeAnalysis, SyndromeClass, SyndromeHistory, SyndromeHistoryAnalysis, + SyndromeHistoryResult, anticommutes_with_logical, apply_recovery, classify_fault, + exhaustive_fault_distance, extract_measurement_rounds, extract_spacetime_locations, + extract_syndrome, get_syndrome_flips, graphlike_fault_distance, has_syndrome, propagate_fault, + propagate_faults, run_circuit_with_faults, run_correction_cycle, }; pub use geometry::{CheckSchedule, LogicalOperator, PauliOp, StabilizerCheck, StabilizerColor}; pub use logical_discovery::{ diff --git a/python/pecos-rslib/pecos_rslib/qec.pyi b/python/pecos-rslib/pecos_rslib/qec.pyi new file mode 100644 index 000000000..1c7d9d2ae --- /dev/null +++ b/python/pecos-rslib/pecos_rslib/qec.pyi @@ -0,0 +1,35 @@ +# 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. + +"""Typed surface for the dynamically registered ``pecos_rslib.qec`` module.""" + +from typing import Any + +class FaultDistanceResult: + """A fault distance and one witnessing set of DEM mechanism indices.""" + + @property + def distance(self) -> int: ... + @property + def mechanism_indices(self) -> list[int]: ... + def __repr__(self) -> str: ... + +class DetectorErrorModel: + """Rust-backed detector error model.""" + + def graphlike_fault_distance(self) -> FaultDistanceResult | None: ... + def exhaustive_fault_distance(self, max_weight: int) -> FaultDistanceResult | None: ... + def __getattr__(self, name: str) -> Any: ... + +# The native QEC module predates this focused stub. Preserve the untyped behavior of its other +# classes and functions until that complete API is migrated rather than falsely narrowing them. +def __getattr__(name: str) -> Any: ... diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index fbb855d8b..6270a2f1c 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -62,6 +62,11 @@ use pecos_qec::fault_tolerance::dem_builder::{ compare_dems_statistical as rust_compare_dems_statistical, verify_dem_equivalence as rust_verify_dem_equivalence, }; +use pecos_qec::fault_tolerance::fault_distance::{ + FaultDistanceResult as RustFaultDistanceResult, + exhaustive_fault_distance as rust_exhaustive_fault_distance, + graphlike_fault_distance as rust_graphlike_fault_distance, +}; use pecos_qec::fault_tolerance::influence_builder::InfluenceBuilder as RustInfluenceBuilder; use pecos_qec::fault_tolerance::propagator::{ DagFaultAnalyzer as RustDagFaultAnalyzer, DagFaultInfluenceMap as RustDagFaultInfluenceMap, @@ -1250,6 +1255,39 @@ where // Detector Error Model // ============================================================================= +/// Result of a detector-error-model fault-distance search. +#[pyclass( + name = "FaultDistanceResult", + module = "pecos_rslib.qec", + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyFaultDistanceResult { + #[pyo3(get)] + distance: usize, + #[pyo3(get)] + mechanism_indices: Vec, +} + +impl From for PyFaultDistanceResult { + fn from(result: RustFaultDistanceResult) -> Self { + Self { + distance: result.distance, + mechanism_indices: result.mechanism_indices, + } + } +} + +#[pymethods] +impl PyFaultDistanceResult { + fn __repr__(&self) -> String { + format!( + "FaultDistanceResult(distance={}, mechanism_indices={:?})", + self.distance, self.mechanism_indices + ) + } +} + /// A Detector Error Model (DEM) in standard DEM text format. /// /// This represents the error model of a quantum circuit, mapping error @@ -1642,6 +1680,23 @@ impl PyDetectorErrorModel { self.inner.num_tracked_paulis() } + /// Compute exact fault distance when every mechanism is graphlike. + /// + /// Raises: + /// `ValueError`: If any mechanism flips more than two detectors. + fn graphlike_fault_distance(&self) -> PyResult> { + rust_graphlike_fault_distance(&self.inner) + .map(|result| result.map(PyFaultDistanceResult::from)) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) + } + + /// Exhaustively compute fault distance up to an explicit mechanism-count budget. + /// + /// This supports hyperedges but has combinatorial cost in the number of mechanisms. + fn exhaustive_fault_distance(&self, max_weight: usize) -> Option { + rust_exhaustive_fault_distance(&self.inner, max_weight).map(PyFaultDistanceResult::from) + } + /// Convert the DEM to a string in standard DEM format. /// /// Each error mechanism is output with its total probability, with no @@ -6937,6 +6992,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/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index 1662aa7e7..40dbecf71 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -35,6 +35,7 @@ DemSampler, DemSamplerBuilder, EquivalenceResult, + FaultDistanceResult, FaultLocation, InfluenceBuilder, ParsedDem, @@ -131,6 +132,7 @@ "DetectorErrorModel", "Detector", "EquivalenceResult", + "FaultDistanceResult", "FaultLocation", "InfluenceBuilder", "PauliFrameLookup", diff --git a/python/quantum-pecos/tests/qec/test_fault_distance.py b/python/quantum-pecos/tests/qec/test_fault_distance.py new file mode 100644 index 000000000..5d5b2d5ff --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_fault_distance.py @@ -0,0 +1,60 @@ +# 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. + +"""Python coverage for detector-error-model fault distance.""" + +import pytest + + +def test_distance_three_rotated_surface_memory_cross_method_agreement() -> None: + from pecos.qec import DetectorErrorModel, FaultDistanceResult + from pecos.qec.surface import build_memory_circuit + + circuit = build_memory_circuit(distance=3, rounds=3, basis="Z") + dem = DetectorErrorModel.from_circuit( + circuit, + p1=0.0, + p2=0.0, + p_meas=0.01, + p_prep=0.0, + ) + + graphlike = dem.graphlike_fault_distance() + exhaustive = dem.exhaustive_fault_distance(3) + + assert isinstance(graphlike, FaultDistanceResult) + assert isinstance(exhaustive, FaultDistanceResult) + assert graphlike.distance == exhaustive.distance == 3 + assert graphlike.mechanism_indices == exhaustive.mechanism_indices + assert graphlike.mechanism_indices == sorted(graphlike.mechanism_indices) + assert repr(graphlike).startswith("FaultDistanceResult(distance=3, mechanism_indices=[") + + +def test_graphlike_fault_distance_reports_hyperedge_count() -> None: + from pecos.qec import DetectorErrorModel + from pecos.quantum import TickCircuit + + circuit = TickCircuit() + circuit.tick().mz([0]) + for _ in range(3): + circuit.add_detector(records=[-1]) + + dem = DetectorErrorModel.from_circuit( + circuit, + p1=0.0, + p2=0.0, + p_meas=0.1, + p_prep=0.0, + ) + + with pytest.raises(ValueError, match=r"found 1 hyperedge mechanism\(s\)"): + dem.graphlike_fault_distance() From 551e97f2f224fd870945f8d40238514e49909299 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 5 Aug 2026 15:38:32 -0600 Subject: [PATCH 07/41] Inject each fault at its own tick and before/after position when propagating multiple faults --- .../src/fault_tolerance/pauli_prop_checker.rs | 389 +++++++++++------- 1 file changed, 233 insertions(+), 156 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs b/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs index 121ef707c..87278a07d 100644 --- a/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs +++ b/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs @@ -252,12 +252,8 @@ impl CircuitIO { } } -/// Initializes a `PauliProp` simulator with a fault configuration. -/// -/// This sets up the initial Pauli error that will be propagated through the circuit. -fn init_pauli_prop_with_fault(fault: &PauliFault) -> PauliProp { - let mut prop = PauliProp::new(); - +/// Injects a Pauli fault into an existing propagation frame. +fn inject_fault(prop: &mut PauliProp, fault: &PauliFault) { for (qubit, &pauli) in fault.location.qubits.iter().zip(&fault.paulis) { let q = qubit.index(); match pauli { @@ -267,6 +263,14 @@ fn init_pauli_prop_with_fault(fault: &PauliFault) -> PauliProp { _ => {} // Identity } } +} + +/// Initializes a `PauliProp` simulator with a fault configuration. +/// +/// This sets up the initial Pauli error that will be propagated through the circuit. +fn init_pauli_prop_with_fault(fault: &PauliFault) -> PauliProp { + let mut prop = PauliProp::new(); + inject_fault(&mut prop, fault); prop } @@ -347,38 +351,30 @@ pub fn propagate_fault(circuit: &TickCircuit, fault: &PauliFault) -> PauliProp { /// Propagates multiple faults through a circuit. /// -/// Faults are combined (`XORed`) and then propagated. +/// Each fault is injected at its own tick, either before or after that tick's gates. #[must_use] pub fn propagate_faults(circuit: &TickCircuit, faults: &FaultConfiguration) -> PauliProp { let mut prop = PauliProp::new(); + let faults_by_tick = faults.by_tick(); - // Combine all faults into initial state - for fault in &faults.faults { - for (qubit, &pauli) in fault.location.qubits.iter().zip(&fault.paulis) { - let q = qubit.index(); - match pauli { - 1 => prop.track_x(&[q]), - 2 => prop.track_y(&[q]), - 3 => prop.track_z(&[q]), - _ => {} - } + for (tick_idx, tick) in circuit.iter_ticks() { + let empty: &[&PauliFault] = &[]; + let (before_faults, after_faults) = faults_by_tick + .get(&tick_idx) + .map_or((empty, empty), |(before, after)| { + (before.as_slice(), after.as_slice()) + }); + + for fault in before_faults { + inject_fault(&mut prop, fault); } - } - // Find the minimum fault tick to know where to start propagating - let min_tick = faults - .faults - .iter() - .map(|f| f.location.tick) - .min() - .unwrap_or(0); + for gate in tick.iter_gate_batches() { + apply_gate_flip_ledger(&mut prop, gate.as_gate()); + } - // Propagate through the circuit from the minimum tick onward - for (tick_idx, tick) in circuit.iter_ticks() { - if tick_idx >= min_tick { - for gate in tick.iter_gate_batches() { - apply_gate_flip_ledger(&mut prop, gate.as_gate()); - } + for fault in after_faults { + inject_fault(&mut prop, fault); } } @@ -2525,6 +2521,21 @@ fn pauli_product(choices: &[u8], count: usize) -> Vec> { mod tests { use super::*; use pecos_simulators::CliffordGateable; + use rand::rngs::SmallRng; + use rand::{RngExt, SeedableRng}; + + fn assert_same_pauli_frame(actual: &PauliProp, expected: &PauliProp, context: &str) { + assert_eq!( + actual.get_x_qubits(), + expected.get_x_qubits(), + "X components differ: {context}" + ); + assert_eq!( + actual.get_z_qubits(), + expected.get_z_qubits(), + "Z components differ: {context}" + ); + } #[test] fn test_init_pauli_prop_with_fault() { @@ -2539,6 +2550,137 @@ mod tests { assert!(prop.contains_z(1)); } + #[test] + fn propagate_faults_matches_single_fault_after_its_tick() { + let mut circuit = TickCircuit::new(); + circuit.tick().h(&[0]); + + let location = SpacetimeLocation::new(0, vec![QubitId(0)], false, GateType::H, 0); + let fault = PauliFault::new(location, vec![1]); + let faults = FaultConfiguration::with_faults(vec![fault.clone()]); + + let single = propagate_fault(&circuit, &fault); + let multiple = propagate_faults(&circuit, &faults); + + assert_same_pauli_frame(&multiple, &single, "X fault after H at tick 0"); + } + + #[test] + fn propagate_faults_matches_single_fault_before_its_tick() { + let mut circuit = TickCircuit::new(); + circuit.tick().h(&[0]); + + let location = SpacetimeLocation::new(0, vec![QubitId(0)], true, GateType::H, 0); + let fault = PauliFault::new(location, vec![1]); + let faults = FaultConfiguration::with_faults(vec![fault.clone()]); + + let single = propagate_fault(&circuit, &fault); + let multiple = propagate_faults(&circuit, &faults); + + assert_same_pauli_frame(&multiple, &single, "X fault before H at tick 0"); + } + + #[test] + fn propagate_faults_injects_each_fault_at_its_own_tick() { + let mut circuit = TickCircuit::new(); + circuit.tick().h(&[1]); + circuit.tick().cx(&[(1, 2)]); + + let first_location = SpacetimeLocation::new(0, vec![QubitId(0)], true, GateType::H, 0); + let second_location = SpacetimeLocation::new(1, vec![QubitId(1)], true, GateType::CX, 0); + let faults = FaultConfiguration::with_faults(vec![ + PauliFault::new(first_location, vec![1]), + PauliFault::new(second_location, vec![1]), + ]); + + let prop = propagate_faults(&circuit, &faults); + + assert_eq!(prop.get_x_qubits(), vec![0, 1, 2]); + assert!(prop.get_z_qubits().is_empty()); + } + + #[test] + fn seeded_single_fault_propagation_agrees_for_all_locations() { + const NUM_QUBITS: usize = 3; + const NUM_TICKS: usize = 5; + const NUM_CIRCUITS: usize = 8; + + let mut rng = SmallRng::seed_from_u64(0x5EED_F4A7); + let mut covered_gate_on_fault_qubit = false; + let mut covered_no_gate_on_fault_qubit = false; + + for circuit_index in 0..NUM_CIRCUITS { + let mut circuit = TickCircuit::new(); + let mut active_qubits = [[false; NUM_QUBITS]; NUM_TICKS]; + + for active in &mut active_qubits { + let choice = rng.random_range(0..6); + let first = rng.random_range(0..NUM_QUBITS); + let second = (first + rng.random_range(1..NUM_QUBITS)) % NUM_QUBITS; + let mut tick = circuit.tick(); + + match choice { + 0 => {} + 1 => { + tick.h(&[first]); + active[first] = true; + } + 2 => { + tick.sz(&[first]); + active[first] = true; + } + 3 => { + tick.cx(&[(first, second)]); + active[first] = true; + active[second] = true; + } + 4 => { + tick.cz(&[(first, second)]); + active[first] = true; + active[second] = true; + } + _ => { + tick.swap(&[(first, second)]); + active[first] = true; + active[second] = true; + } + } + } + + for (tick, active) in active_qubits.iter().enumerate() { + for (qubit, &has_gate) in active.iter().enumerate() { + covered_gate_on_fault_qubit |= has_gate; + covered_no_gate_on_fault_qubit |= !has_gate; + + for before in [false, true] { + for pauli in 1..=3 { + let location = SpacetimeLocation::new( + tick, + vec![QubitId(qubit)], + before, + GateType::I, + 0, + ); + let fault = PauliFault::new(location, vec![pauli]); + let faults = FaultConfiguration::with_faults(vec![fault.clone()]); + let single = propagate_fault(&circuit, &fault); + let multiple = propagate_faults(&circuit, &faults); + let context = format!( + "circuit={circuit_index}, tick={tick}, qubit={qubit}, \ + before={before}, pauli={pauli}, has_gate={has_gate}" + ); + + assert_same_pauli_frame(&multiple, &single, &context); + } + } + } + } + } + + assert!(covered_gate_on_fault_qubit); + assert!(covered_no_gate_on_fault_qubit); + } + #[test] fn test_propagate_x_through_cx() { // X on control propagates to target: XI -> XX @@ -3035,32 +3177,11 @@ mod tests { circuit.tick().cx(&[(2, 4)]); circuit.tick().mz(&[3, 4]); - let config = FaultCheckConfig::new() - .with_weight(1) - .x_only() - .stop_on_first(false); - - let checker = PauliPropChecker::new(&circuit).with_config(config); - // Logical Z spans all data qubits let logicals: &[(&[usize], &[usize])] = &[(&[], &[0, 1, 2])]; let z_ancillas = &[3usize, 4]; let x_ancillas: &[usize] = &[]; - let analysis = checker.analyze_fault_tolerance(z_ancillas, x_ancillas, logicals, true); - - println!("3-qubit code fault tolerance analysis:"); - println!(" Total tested: {}", analysis.total_tested); - println!( - " Undetectable logical errors: {}", - analysis.undetectable_logical_errors - ); - println!( - " Undetectable stabilizers: {}", - analysis.undetectable_stabilizers - ); - println!(" Detectable errors: {}", analysis.detectable_errors); - // This naive syndrome extraction circuit is NOT fault tolerant! // An X error on a data qubit that occurs AFTER its entangling gate // won't produce a syndrome but will still cause a logical error. @@ -3070,24 +3191,31 @@ mod tests { // // This demonstrates exactly the kind of circuit vulnerability that // fault classification is designed to detect. + // Multi-qubit iterator locations assign a non-identity Pauli to every + // qubit, so they cannot generate this single-leg CX fault. That + // enumerator gap is tracked separately; construct the intended fault. + let fault = PauliFault::new( + SpacetimeLocation::new(4, vec![QubitId(2)], false, GateType::CX, 0), + vec![1], + ); + let faults = FaultConfiguration::with_faults(vec![fault]); + let prop = propagate_faults(&circuit, &faults); + assert!( - !analysis.is_fault_tolerant(), - "Naive syndrome extraction should NOT be 1-fault tolerant" + !has_syndrome(&prop, z_ancillas, x_ancillas), + "X on data after its final CX should not produce a syndrome" ); assert!( - analysis.undetectable_logical_errors > 0, - "Should have undetectable logical errors" + logicals + .iter() + .any(|(xs, zs)| anticommutes_with_logical(&prop, xs, zs)), + "X on data should anticommute with logical Z" + ); + assert_eq!( + classify_fault(&prop, z_ancillas, x_ancillas, logicals), + FaultClass::UndetectableLogicalError, + "Naive syndrome extraction should NOT be 1-fault tolerant" ); - - // Print the failure details to understand the vulnerabilities - for (fault, result) in &analysis.failure_details { - println!( - " Vulnerability: {} at tick {} -> {:?}", - fault.faults[0].pauli_string(), - fault.faults[0].location.tick, - result.classify() - ); - } } #[test] @@ -3302,86 +3430,35 @@ mod tests { circuit.tick().cx(&[(2, 4)]); circuit.tick().mz(&[3, 4]); - let config = FaultCheckConfig::new() - .with_weight(1) - .x_only() - .stop_on_first(false); - - let checker = PauliPropChecker::new(&circuit).with_config(config); - let logicals: &[(&[usize], &[usize])] = &[(&[], &[0, 1, 2])]; let z_ancillas = &[3usize, 4]; let x_ancillas: &[usize] = &[]; - let analysis = checker.analyze_decoder_requirements(z_ancillas, x_ancillas, logicals); - - println!("Decoder Analysis for 3-qubit code:"); - println!( - " Correctable syndromes: {}", - analysis.correctable_syndromes - ); - println!( - " Detected uncorrectable syndromes: {}", - analysis.detected_uncorrectable_syndromes - ); - println!(" Ambiguous syndromes: {}", analysis.ambiguous_syndromes); - println!(" Correctable faults: {}", analysis.correctable_faults); - println!( - " Detected uncorrectable faults: {}", - analysis.detected_uncorrectable_faults - ); - println!(" Ambiguous faults: {}", analysis.ambiguous_faults); - println!( - " Undetectable logical errors: {}", - analysis.undetectable_logical_errors - ); - println!( - " Undetectable stabilizers: {}", - analysis.undetectable_stabilizers + // Multi-qubit iterator locations assign a non-identity Pauli to every + // qubit, so they cannot generate this single-leg CX fault. That + // enumerator gap is tracked separately; construct the intended fault. + let fault = PauliFault::new( + SpacetimeLocation::new(4, vec![QubitId(2)], false, GateType::CX, 0), + vec![1], ); + let faults = FaultConfiguration::with_faults(vec![fault]); + let prop = propagate_faults(&circuit, &faults); - // Print each syndrome's details - for syn in &analysis.syndromes { - println!( - " Syndrome {:?}: {} correctable, {} uncorrectable -> {:?}", - syn.syndrome, syn.correctable_count, syn.uncorrectable_count, syn.class - ); - } - - // The 3-qubit code should have unique syndromes for weight-1 data errors - // So there should be NO ambiguous syndromes for the detectable faults - // (but there ARE undetectable logical errors from faults after gates) - - let total = analysis.total_faults(); - println!("\nFailure rate analysis:"); - println!( - " Best case: {:.1}%", - analysis.best_case_failure_rate(total) * 100.0 + assert!( + !has_syndrome(&prop, z_ancillas, x_ancillas), + "the decoder receives no syndrome for the late data fault" ); - println!( - " Worst case: {:.1}%", - analysis.worst_case_failure_rate(total) * 100.0 + assert!( + logicals + .iter() + .any(|(xs, zs)| anticommutes_with_logical(&prop, xs, zs)), + "the undetected fault causes a logical error" + ); + assert_eq!( + classify_fault(&prop, z_ancillas, x_ancillas, logicals), + FaultClass::UndetectableLogicalError, + "Should have undetectable logical errors" ); - println!(" Is fault tolerant: {}", analysis.is_ft()); - - // The naive circuit is NOT fault tolerant due to undetectable logical errors - match analysis.is_fault_tolerant() { - Ok(()) => panic!("Naive circuit should NOT be fault tolerant"), - Err(failures) => { - println!("\n Failures:"); - for failure in &failures { - println!(" - {}", failure.description()); - } - // Should have undetectable logical errors - assert!( - failures.iter().any(|f| matches!( - f, - FaultToleranceFailure::UndetectableLogicalErrors { .. } - )), - "Should have undetectable logical errors" - ); - } - } } #[test] @@ -3447,34 +3524,34 @@ mod tests { circuit.tick().cx(&[(2, 6)]); circuit.tick().mz(&[5, 6]); - let config = FaultCheckConfig::new() - .with_weight(1) - .x_only() - .stop_on_first(false); - - let checker = PauliPropChecker::new(&circuit).with_config(config); - let logicals: &[(&[usize], &[usize])] = &[(&[], &[0, 1, 2])]; let z_ancillas = &[5usize, 6]; let x_ancillas: &[usize] = &[]; - let analysis = checker.analyze_decoder_requirements(z_ancillas, x_ancillas, logicals); - - println!("\nTwo-round syndrome extraction:"); - println!(" Total faults: {}", analysis.total_faults()); - println!( - " Undetectable logical errors: {}", - analysis.undetectable_logical_errors - ); - println!(" Is FT (by single-shot analysis): {}", analysis.is_ft()); - // Two-round still has undetectable errors at the END of round 2. // Real FT requires decoder to use syndrome history, not single-shot. // This test documents that limitation. + // Multi-qubit iterator locations assign a non-identity Pauli to every + // qubit, so they cannot generate this single-leg CX fault. That + // enumerator gap is tracked separately; construct the intended fault. + let fault = PauliFault::new( + SpacetimeLocation::new(10, vec![QubitId(2)], false, GateType::CX, 0), + vec![1], + ); + let faults = FaultConfiguration::with_faults(vec![fault]); + let prop = propagate_faults(&circuit, &faults); + assert!( - analysis.undetectable_logical_errors > 0, + !has_syndrome(&prop, z_ancillas, x_ancillas) + && logicals + .iter() + .any(|(xs, zs)| anticommutes_with_logical(&prop, xs, zs)), "Two-round still has undetectable errors at circuit end" ); + assert_eq!( + classify_fault(&prop, z_ancillas, x_ancillas, logicals), + FaultClass::UndetectableLogicalError + ); } #[test] From 341f110d1a19ce9628774ea7e071220c2265bfc0 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 5 Aug 2026 18:38:43 -0600 Subject: [PATCH 08/41] Enumerate single-leg Pauli faults at multi-qubit gate locations --- crates/pecos-qec/src/fault_tolerance.rs | 129 +++++++++++++++--- .../src/fault_tolerance/pauli_prop_checker.rs | 42 ++++-- 2 files changed, 145 insertions(+), 26 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance.rs b/crates/pecos-qec/src/fault_tolerance.rs index a185cdedc..1193d94e8 100644 --- a/crates/pecos-qec/src/fault_tolerance.rs +++ b/crates/pecos-qec/src/fault_tolerance.rs @@ -381,8 +381,8 @@ pub struct PauliFaultIterator { pauli_indices: Vec>, /// Whether we've finished iterating. done: bool, - /// Available Pauli types based on config. - pauli_types: Vec, + /// Available per-qubit Pauli choices (identity plus configured Pauli types). + pauli_choices: Vec, } impl PauliFaultIterator { @@ -404,20 +404,25 @@ impl PauliFaultIterator { location_indices: Vec::new(), pauli_indices: Vec::new(), done: true, - pauli_types, + pauli_choices: pauli_types, }; } + let mut pauli_choices = Vec::with_capacity(pauli_types.len() + 1); + pauli_choices.push(0); + pauli_choices.extend(pauli_types); + // Initialize with first `weight` locations let location_indices: Vec = (0..weight.min(locations.len())).collect(); // Initialize Pauli indices for each location - // For each qubit at each location, start with the first non-identity Pauli + // For each qubit at each location, start with identity. The all-identity + // assignment for each selected location is skipped by `is_nontrivial`. let pauli_indices: Vec> = location_indices .iter() .map(|&loc_idx| { let num_qubits = locations[loc_idx].num_qubits(); - vec![0; num_qubits] // Start with first Pauli type for each qubit + vec![0; num_qubits] // Start with identity on each qubit }) .collect(); @@ -429,7 +434,7 @@ impl PauliFaultIterator { location_indices, pauli_indices, done, - pauli_types, + pauli_choices, } } @@ -441,7 +446,7 @@ impl PauliFaultIterator { let num_qubits = self.pauli_indices[loc_idx].len(); for qubit_idx in (0..num_qubits).rev() { self.pauli_indices[loc_idx][qubit_idx] += 1; - if self.pauli_indices[loc_idx][qubit_idx] < self.pauli_types.len() { + if self.pauli_indices[loc_idx][qubit_idx] < self.pauli_choices.len() { return true; } self.pauli_indices[loc_idx][qubit_idx] = 0; @@ -487,7 +492,10 @@ impl PauliFaultIterator { .zip(&self.pauli_indices) .map(|(&loc_idx, pauli_idx)| { let location = self.locations[loc_idx].clone(); - let paulis: Vec = pauli_idx.iter().map(|&idx| self.pauli_types[idx]).collect(); + let paulis: Vec = pauli_idx + .iter() + .map(|&idx| self.pauli_choices[idx]) + .collect(); PauliFault::new(location, paulis) }) .collect(); @@ -496,15 +504,11 @@ impl PauliFaultIterator { /// Checks if current configuration is non-trivial (not all identity on any location). fn is_nontrivial(&self) -> bool { - // For weight > 0, we always have at least one non-I Pauli since we only - // iterate over non-identity Paulis. But we should check that each - // location has at least one non-identity Pauli. - for pauli_idx in &self.pauli_indices { - if pauli_idx.iter().all(|&idx| self.pauli_types[idx] == 0) { - return false; - } - } - true + self.pauli_indices.iter().all(|pauli_indices| { + pauli_indices + .iter() + .any(|&idx| self.pauli_choices[idx] != 0) + }) } } @@ -652,6 +656,97 @@ mod tests { assert_eq!(configs.len(), 3); } + #[test] + fn test_pauli_fault_iterator_two_qubit_location() { + let locations = vec![SpacetimeLocation::new( + 0, + vec![QubitId(0), QubitId(1)], + false, + GateType::CX, + 0, + )]; + + let configs: Vec<_> = + PauliFaultIterator::new(locations, 1, FaultCheckConfig::new().all_paulis()).collect(); + let paulis: BTreeSet<_> = configs + .iter() + .map(|config| config.faults[0].pauli_string()) + .collect(); + + assert_eq!(configs.len(), 15); + assert_eq!(paulis.len(), 15); + assert!(paulis.contains("IX")); + assert!(paulis.contains("XI")); + assert!(paulis.contains("XX")); + } + + #[test] + fn test_pauli_fault_iterator_two_qubit_x_only() { + let locations = vec![SpacetimeLocation::new( + 0, + vec![QubitId(0), QubitId(1)], + false, + GateType::CX, + 0, + )]; + + let configs = PauliFaultIterator::new(locations, 1, FaultCheckConfig::new().x_only()); + let paulis: BTreeSet<_> = configs + .map(|config| config.faults[0].pauli_string()) + .collect(); + + assert_eq!( + paulis, + BTreeSet::from(["IX".to_string(), "XI".to_string(), "XX".to_string()]) + ); + } + + #[test] + fn test_pauli_fault_iterator_never_selects_an_identity_location() { + let locations = vec![ + SpacetimeLocation::new(0, vec![QubitId(0), QubitId(1)], false, GateType::CX, 0), + SpacetimeLocation::new(1, vec![QubitId(1), QubitId(2)], false, GateType::CX, 0), + ]; + + for weight in [1, 2] { + for config in PauliFaultIterator::new( + locations.clone(), + weight, + FaultCheckConfig::new().all_paulis(), + ) { + assert_eq!(config.len(), weight); + assert!(config.faults.iter().all(PauliFault::is_nontrivial)); + } + } + } + + #[test] + fn test_pauli_fault_iterator_preserves_location_weight_convention() { + let locations = vec![SpacetimeLocation::new( + 0, + vec![QubitId(0), QubitId(1)], + false, + GateType::CX, + 0, + )]; + let configs: Vec<_> = + PauliFaultIterator::new(locations, 1, FaultCheckConfig::new().all_paulis()).collect(); + + let xi = configs + .iter() + .find(|config| config.faults[0].pauli_string() == "XI") + .expect("iterator should generate XI"); + assert_eq!(xi.len(), 1); + assert_eq!(xi.total_weight(), 1); + + let xx = configs + .iter() + .find(|config| config.faults[0].pauli_string() == "XX") + .expect("iterator should generate XX"); + assert_eq!(xx.len(), 1); + assert_eq!(xx.total_weight(), 2); + } + #[test] fn test_pauli_fault_iterator_weight_zero() { let locations = vec![SpacetimeLocation::new( diff --git a/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs b/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs index 87278a07d..7cdf60ac6 100644 --- a/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs +++ b/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs @@ -3191,9 +3191,8 @@ mod tests { // // This demonstrates exactly the kind of circuit vulnerability that // fault classification is designed to detect. - // Multi-qubit iterator locations assign a non-identity Pauli to every - // qubit, so they cannot generate this single-leg CX fault. That - // enumerator gap is tracked separately; construct the intended fault. + // The iterator can generate this single-leg CX fault; construct it + // directly here to keep the propagation example focused and clear. let fault = PauliFault::new( SpacetimeLocation::new(4, vec![QubitId(2)], false, GateType::CX, 0), vec![1], @@ -3218,6 +3217,33 @@ mod tests { ); } + #[test] + fn test_iterator_generates_single_leg_fault_at_cx_location() { + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&[3, 4]); + circuit.tick().cx(&[(0, 3)]); + circuit.tick().cx(&[(1, 3)]); + circuit.tick().cx(&[(1, 4)]); + circuit.tick().cx(&[(2, 4)]); + circuit.tick().mz(&[3, 4]); + + let checker = PauliPropChecker::new(&circuit); + let expected_location = + SpacetimeLocation::new(4, vec![QubitId(2), QubitId(4)], false, GateType::CX, 0); + let generated = PauliFaultIterator::new( + checker.locations().to_vec(), + 1, + FaultCheckConfig::new().all_paulis(), + ) + .any(|configuration| { + configuration.faults.len() == 1 + && configuration.faults[0].location == expected_location + && configuration.faults[0].paulis == [1, 0] + }); + + assert!(generated, "iterator should generate X on only the data leg"); + } + #[test] fn test_fault_tolerance_analysis_methods() { let analysis = FaultToleranceAnalysis { @@ -3434,9 +3460,8 @@ mod tests { let z_ancillas = &[3usize, 4]; let x_ancillas: &[usize] = &[]; - // Multi-qubit iterator locations assign a non-identity Pauli to every - // qubit, so they cannot generate this single-leg CX fault. That - // enumerator gap is tracked separately; construct the intended fault. + // The iterator can generate this single-leg CX fault; construct it + // directly here to keep the propagation example focused and clear. let fault = PauliFault::new( SpacetimeLocation::new(4, vec![QubitId(2)], false, GateType::CX, 0), vec![1], @@ -3531,9 +3556,8 @@ mod tests { // Two-round still has undetectable errors at the END of round 2. // Real FT requires decoder to use syndrome history, not single-shot. // This test documents that limitation. - // Multi-qubit iterator locations assign a non-identity Pauli to every - // qubit, so they cannot generate this single-leg CX fault. That - // enumerator gap is tracked separately; construct the intended fault. + // The iterator can generate this single-leg CX fault; construct it + // directly here to keep the propagation example focused and clear. let fault = PauliFault::new( SpacetimeLocation::new(10, vec![QubitId(2)], false, GateType::CX, 0), vec![1], From 125e6c981369e2582a7336bde836e28126584a9a Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 5 Aug 2026 22:21:23 -0600 Subject: [PATCH 09/41] Add hook-error diagnosis naming the gate responsible for fault amplification --- crates/pecos-qec/src/fault_tolerance.rs | 2 + .../src/fault_tolerance/hook_errors.rs | 280 ++++++++++++++++++ crates/pecos-qec/src/lib.rs | 8 +- 3 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 crates/pecos-qec/src/fault_tolerance/hook_errors.rs diff --git a/crates/pecos-qec/src/fault_tolerance.rs b/crates/pecos-qec/src/fault_tolerance.rs index 1193d94e8..e64f8e918 100644 --- a/crates/pecos-qec/src/fault_tolerance.rs +++ b/crates/pecos-qec/src/fault_tolerance.rs @@ -24,6 +24,7 @@ pub mod dem_builder; pub mod fault_distance; pub mod fault_sampler; pub mod gadget_checker; +pub mod hook_errors; pub mod influence_builder; pub mod lookup_decoder; pub mod pauli_frame; @@ -52,6 +53,7 @@ pub use gadget_checker::{ GadgetFaultResult, GadgetFollowUpConfig, GadgetHistoryAnalysis, GadgetHistoryPattern, GadgetSyndromeAnalysis, }; +pub use hook_errors::{HookError, HookErrorReport}; pub use influence_builder::InfluenceBuilder; pub use pauli_frame::{PauliFrameLookup, PauliFrameLookupError}; pub use pauli_prop_checker::{ diff --git a/crates/pecos-qec/src/fault_tolerance/hook_errors.rs b/crates/pecos-qec/src/fault_tolerance/hook_errors.rs new file mode 100644 index 000000000..867d6fbcd --- /dev/null +++ b/crates/pecos-qec/src/fault_tolerance/hook_errors.rs @@ -0,0 +1,280 @@ +// 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. + +//! Diagnosis of single-qubit faults that amplify into multi-qubit data errors. + +use super::{PauliPropChecker, SpacetimeLocation, anticommutes_with_logical, has_syndrome}; +use pecos_simulators::PauliProp; + +/// A single-qubit fault that amplifies into an error on multiple data qubits. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HookError { + /// The gate location responsible for the amplified error. + pub location: SpacetimeLocation, + /// The injected Pauli operators, with one non-identity entry. + pub fault_paulis: Vec, + /// Sorted support of the propagated error on the caller-supplied data qubits. + pub data_support: Vec, + /// Number of data qubits in [`Self::data_support`]. + pub data_weight: usize, + /// Whether the propagated error triggers a syndrome on the supplied ancillas. + pub detected: bool, + /// Whether the propagated error anticommutes with any supplied logical operator. + pub causes_logical_error: bool, +} + +/// Summary of hook-error diagnosis across the checker's configured fault set. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HookErrorReport { + /// Single-qubit faults whose propagated data weight reaches the requested threshold. + pub hook_errors: Vec, + /// Number of fault configurations returned by the existing fault analyzer. + pub total_faults_examined: usize, + /// Largest propagated data weight among the single-qubit faults examined. + pub max_data_weight: usize, +} + +fn propagated_data_support(prop: &PauliProp, data_qubits: &[usize]) -> Vec { + let mut support: Vec = data_qubits + .iter() + .copied() + .filter(|&qubit| prop.contains_x(qubit) || prop.contains_z(qubit)) + .collect(); + support.sort_unstable(); + support.dedup(); + support +} + +impl PauliPropChecker<'_> { + /// Finds single-qubit faults that amplify on the caller-supplied data block. + /// + /// A fault is reported only when its injected non-identity Pauli weight is exactly one and + /// its propagated support on `data_qubits` has weight at least `min_data_weight`. A threshold + /// of 2 is standard for hook-error diagnosis; it is explicit here so callers can request + /// higher-weight amplification. + /// + /// The returned hook errors are sorted by location tick, gate index, qubits, and injected + /// Paulis. Syndrome and logical classifications use the same helpers as the existing fault + /// analysis machinery. + #[must_use] + pub fn diagnose_hook_errors( + &self, + data_qubits: &[usize], + z_ancillas: &[usize], + x_ancillas: &[usize], + logicals: &[(&[usize], &[usize])], + min_data_weight: usize, + ) -> HookErrorReport { + let analyses = self.analyze_all_faults(z_ancillas, x_ancillas, logicals); + let total_faults_examined = analyses.len(); + let mut hook_errors = Vec::new(); + let mut max_data_weight = 0; + + for (fault_configuration, result) in analyses { + let [fault] = fault_configuration.faults.as_slice() else { + continue; + }; + if fault.weight() != 1 { + continue; + } + + let data_support = propagated_data_support(&result.propagated_error, data_qubits); + let data_weight = data_support.len(); + max_data_weight = max_data_weight.max(data_weight); + + if data_weight < min_data_weight { + continue; + } + + hook_errors.push(HookError { + location: fault.location.clone(), + fault_paulis: fault.paulis.clone(), + data_support, + data_weight, + detected: has_syndrome(&result.propagated_error, z_ancillas, x_ancillas), + causes_logical_error: logicals + .iter() + .any(|(xs, zs)| anticommutes_with_logical(&result.propagated_error, xs, zs)), + }); + } + + hook_errors.sort_by(|left, right| { + left.location + .tick + .cmp(&right.location.tick) + .then_with(|| left.location.gate_index.cmp(&right.location.gate_index)) + .then_with(|| left.location.qubits.cmp(&right.location.qubits)) + .then_with(|| left.fault_paulis.cmp(&right.fault_paulis)) + }); + + HookErrorReport { + hook_errors, + total_faults_examined, + max_data_weight, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fault_tolerance::FaultCheckConfig; + use pecos_core::QubitId; + use pecos_core::gate_type::GateType; + use pecos_quantum::TickCircuit; + + fn control_ancilla_ladder() -> TickCircuit { + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&[3]); + circuit.tick().cx(&[(3, 0)]); + circuit.tick().cx(&[(3, 1)]); + circuit.tick().cx(&[(3, 2)]); + circuit.tick().mz(&[3]); + circuit + } + + fn find_hook<'a>( + report: &'a HookErrorReport, + tick: usize, + fault_paulis: &[u8], + ) -> Option<&'a HookError> { + report + .hook_errors + .iter() + .find(|hook| hook.location.tick == tick && hook.fault_paulis == fault_paulis) + } + + #[test] + fn amplifying_ancilla_fault_reports_responsible_cx_and_data_support() { + let circuit = control_ancilla_ladder(); + let checker = PauliPropChecker::new(&circuit); + let report = checker.diagnose_hook_errors(&[2, 0, 1], &[3], &[], &[], 2); + + let hook = find_hook(&report, 1, &[1, 0]) + .expect("X on the ancilla after the first CX should amplify"); + assert_eq!(hook.location.gate_type, GateType::CX); + assert_eq!(hook.location.gate_index, 0); + assert_eq!(hook.location.qubits, [QubitId(3), QubitId(0)]); + assert_eq!(hook.data_support, [1, 2]); + assert_eq!(hook.data_weight, 2); + assert_eq!(hook.data_weight, hook.data_support.len()); + assert_eq!(report.max_data_weight, 3); + assert_eq!( + report.total_faults_examined, + checker.analyze_all_faults(&[3], &[], &[]).len() + ); + } + + #[test] + fn ancilla_fault_after_final_cx_is_not_reported() { + let circuit = control_ancilla_ladder(); + let checker = PauliPropChecker::new(&circuit); + let report = checker.diagnose_hook_errors(&[0, 1, 2], &[3], &[], &[], 2); + + assert!(find_hook(&report, 3, &[1, 0]).is_none()); + } + + #[test] + fn weight_two_fault_with_weight_two_data_support_is_not_a_hook() { + let circuit = control_ancilla_ladder(); + let checker = PauliPropChecker::new(&circuit); + + let analyzed_xx = checker + .analyze_all_faults(&[3], &[], &[]) + .into_iter() + .find(|(configuration, _)| { + configuration.faults.len() == 1 + && configuration.faults[0].location.tick == 2 + && configuration.faults[0].paulis == [1, 1] + }) + .expect("the existing enumerator should include XX at the second CX"); + assert_eq!(analyzed_xx.0.total_weight(), 2); + assert_eq!( + propagated_data_support(&analyzed_xx.1.propagated_error, &[0, 1, 2]), + [1, 2] + ); + + let report = checker.diagnose_hook_errors(&[0, 1, 2], &[3], &[], &[], 2); + assert!(find_hook(&report, 2, &[1, 1]).is_none()); + } + + #[test] + fn detected_and_logical_fields_use_propagated_error() { + let detected_circuit = control_ancilla_ladder(); + let detected_checker = PauliPropChecker::new(&detected_circuit); + let detected_logicals: &[(&[usize], &[usize])] = &[(&[], &[1, 2])]; + let detected_report = + detected_checker.diagnose_hook_errors(&[0, 1, 2], &[3], &[], detected_logicals, 2); + let detected = find_hook(&detected_report, 1, &[1, 0]).unwrap(); + assert!(detected.detected); + assert!(!detected.causes_logical_error); + + let mut undetected_circuit = TickCircuit::new(); + undetected_circuit.tick().pz(&[3]); + undetected_circuit.tick().cx(&[(0, 3)]); + undetected_circuit.tick().cx(&[(1, 3)]); + undetected_circuit.tick().cx(&[(2, 3)]); + undetected_circuit.tick().mz(&[3]); + let undetected_checker = PauliPropChecker::new(&undetected_circuit); + let undetected_logicals: &[(&[usize], &[usize])] = &[(&[1], &[])]; + let undetected_report = + undetected_checker.diagnose_hook_errors(&[0, 1, 2], &[3], &[], undetected_logicals, 2); + let undetected = find_hook(&undetected_report, 1, &[0, 3]).unwrap(); + assert!(!undetected.detected); + assert!(undetected.causes_logical_error); + assert_eq!(undetected.data_support, [1, 2]); + } + + #[test] + fn diagnosis_order_is_deterministic() { + let circuit = control_ancilla_ladder(); + let checker = PauliPropChecker::new(&circuit); + + let first = checker.diagnose_hook_errors(&[0, 1, 2], &[3], &[], &[], 2); + let second = checker.diagnose_hook_errors(&[0, 1, 2], &[3], &[], &[], 2); + + assert_eq!(first, second); + assert!(first.hook_errors.windows(2).all(|pair| { + let left = &pair[0]; + let right = &pair[1]; + ( + left.location.tick, + left.location.gate_index, + &left.location.qubits, + &left.fault_paulis, + ) <= ( + right.location.tick, + right.location.gate_index, + &right.location.qubits, + &right.fault_paulis, + ) + })); + } + + #[test] + fn higher_minimum_data_weight_reports_strictly_fewer_hooks() { + let circuit = control_ancilla_ladder(); + let checker = PauliPropChecker::new(&circuit) + .with_config(FaultCheckConfig::new().with_weight(1).all_paulis()); + + let weight_two = checker.diagnose_hook_errors(&[0, 1, 2], &[3], &[], &[], 2); + let weight_three = checker.diagnose_hook_errors(&[0, 1, 2], &[3], &[], &[], 3); + + assert!(weight_three.hook_errors.len() < weight_two.hook_errors.len()); + assert!( + weight_three + .hook_errors + .iter() + .all(|hook| hook.data_weight >= 3) + ); + } +} diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index 100e06119..f1b451da9 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -91,10 +91,10 @@ pub use fault_tolerance::{ CorrectionResult, DecoderAnalysis, DemOutputKind, DemOutputMetadata, ErrorClass, ErrorCorrectionChecker, ErrorCorrectionConfig, ErrorCorrectionResult, FaultCheckConfig, FaultCheckResult, FaultChecker, FaultClass, FaultConfiguration, FaultDistanceError, - FaultDistanceResult, FaultToleranceAnalysis, FaultToleranceFailure, LookupTableDecoder, - MeasurementRound, PauliFault, PauliFaultIterator, PauliPropChecker, PropagationResult, - SpacetimeLocation, StabilizerFlipAnalysis, StabilizerFlipChecker, StabilizerFlips, - SyndromeAnalysis, SyndromeClass, SyndromeHistory, SyndromeHistoryAnalysis, + FaultDistanceResult, FaultToleranceAnalysis, FaultToleranceFailure, HookError, HookErrorReport, + LookupTableDecoder, MeasurementRound, PauliFault, PauliFaultIterator, PauliPropChecker, + PropagationResult, SpacetimeLocation, StabilizerFlipAnalysis, StabilizerFlipChecker, + StabilizerFlips, SyndromeAnalysis, SyndromeClass, SyndromeHistory, SyndromeHistoryAnalysis, SyndromeHistoryResult, anticommutes_with_logical, apply_recovery, classify_fault, exhaustive_fault_distance, extract_measurement_rounds, extract_spacetime_locations, extract_syndrome, get_syndrome_flips, graphlike_fault_distance, has_syndrome, propagate_fault, From 1abd8364955f962108f0f3ea319b0526a66702a4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 5 Aug 2026 23:25:53 -0600 Subject: [PATCH 10/41] Report circuit fault distance as a number, per logical operator --- crates/pecos-qec/src/fault_tolerance.rs | 3 +- .../src/fault_tolerance/circuit_runner.rs | 261 +++++++++++++++++- crates/pecos-qec/src/lib.rs | 23 +- 3 files changed, 274 insertions(+), 13 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance.rs b/crates/pecos-qec/src/fault_tolerance.rs index e64f8e918..f8cd97caf 100644 --- a/crates/pecos-qec/src/fault_tolerance.rs +++ b/crates/pecos-qec/src/fault_tolerance.rs @@ -39,7 +39,8 @@ use pecos_core::gate_type::GateType; use std::collections::BTreeSet; pub use circuit_runner::{ - FaultCategoryAnalysis, FaultChecker, extract_spacetime_locations, run_circuit_with_faults, + CircuitDistanceResult, FaultCategoryAnalysis, FaultChecker, extract_spacetime_locations, + run_circuit_with_faults, }; pub use decoder_integration::{ CorrectionResult, ErrorCorrectionChecker, ErrorCorrectionConfig, ErrorCorrectionResult, diff --git a/crates/pecos-qec/src/fault_tolerance/circuit_runner.rs b/crates/pecos-qec/src/fault_tolerance/circuit_runner.rs index f825373b9..3bb5549ac 100644 --- a/crates/pecos-qec/src/fault_tolerance/circuit_runner.rs +++ b/crates/pecos-qec/src/fault_tolerance/circuit_runner.rs @@ -15,7 +15,10 @@ //! This module provides integration between the fault tolerance checking framework //! and `TickCircuit` / `pecos-simulators` simulators. -use super::pauli_prop_checker::{CircuitIO, FaultClass, classify_fault, propagate_faults}; +use super::pauli_prop_checker::{ + CircuitIO, FaultClass, PropagationResult, anticommutes_with_logical, classify_fault, + get_syndrome_flips, propagate_faults, +}; use super::{ FaultCheckConfig, FaultCheckResult, FaultConfiguration, PauliFault, SpacetimeLocation, }; @@ -488,6 +491,23 @@ impl FaultCategoryAnalysis { } } +/// The minimum circuit-level fault weight and one configuration attaining it. +#[derive(Debug, Clone)] +pub struct CircuitDistanceResult { + /// Minimum number of fault locations in an undetectable logical error. + pub distance: usize, + /// First minimum-weight failing configuration in fault-iterator order. + pub witness: FaultConfiguration, + /// Index of the supplied logical operator flipped by `witness`. + pub logical_index: usize, +} + +#[derive(Clone, Copy)] +enum CircuitDistanceStoppingRule { + FirstLogical, + AllLogicals, +} + /// A fault checker that tests a circuit for fault tolerance. /// /// This provides a high-level API for checking whether a circuit is @@ -620,6 +640,129 @@ impl<'a> FaultChecker<'a> { ) } + fn circuit_fault_distances_with_stopping_rule( + &self, + z_ancillas: &[usize], + x_ancillas: &[usize], + logicals: &[(&[usize], &[usize])], + max_weight: usize, + stopping_rule: CircuitDistanceStoppingRule, + ) -> Vec> { + let mut distances: Vec> = + (0..logicals.len()).map(|_| None).collect(); + if logicals.is_empty() { + return distances; + } + + for weight in 1..=max_weight { + let mut config = self.config.clone(); + config.max_weight = weight; + let fault_iter = super::PauliFaultIterator::new(self.locations.clone(), weight, config); + + for fault_config in fault_iter { + let prop = propagate_faults(self.circuit, &fault_config); + let (z_syndrome_flips, x_syndrome_flips) = + get_syndrome_flips(&prop, z_ancillas, x_ancillas); + let logical_errors = logicals + .iter() + .map(|(xs, zs)| anticommutes_with_logical(&prop, xs, zs)) + .collect(); + let propagation_result = PropagationResult { + propagated_error: prop, + z_syndrome_flips, + x_syndrome_flips, + logical_errors, + }; + + if propagation_result.has_syndrome() { + continue; + } + + for (logical_index, logical_error) in + propagation_result.logical_errors.into_iter().enumerate() + { + if logical_error && distances[logical_index].is_none() { + distances[logical_index] = Some(CircuitDistanceResult { + distance: fault_config.len(), + witness: fault_config.clone(), + logical_index, + }); + } + } + + let should_stop = match stopping_rule { + CircuitDistanceStoppingRule::FirstLogical => { + distances.iter().any(Option::is_some) + } + CircuitDistanceStoppingRule::AllLogicals => { + distances.iter().all(Option::is_some) + } + }; + if should_stop { + return distances; + } + } + } + + distances + } + + /// Finds the circuit-level fault distance up to an explicit weight budget. + /// + /// The distance counts fault locations ([`FaultConfiguration::len`]), not the number of + /// non-identity single-qubit Paulis within those locations. Configurations are searched in + /// increasing location weight, and the first undetectable logical error in iterator order is + /// returned. + /// + /// This search is combinatorial in `max_weight`: it builds and exhausts a fresh exact-weight + /// fault iterator for each weight until it finds a witness. Callers must choose the budget + /// explicitly. + #[must_use] + pub fn circuit_fault_distance( + &self, + z_ancillas: &[usize], + x_ancillas: &[usize], + logicals: &[(&[usize], &[usize])], + max_weight: usize, + ) -> Option { + self.circuit_fault_distances_with_stopping_rule( + z_ancillas, + x_ancillas, + logicals, + max_weight, + CircuitDistanceStoppingRule::FirstLogical, + ) + .into_iter() + .flatten() + .next() + } + + /// Finds a separate circuit-level fault distance for each supplied logical operator. + /// + /// Each entry is the first iterator-ordered witness at the smallest searched location weight + /// that flips that logical without producing a syndrome. An entry is `None` when no such + /// witness exists through `max_weight`. + /// + /// This search is combinatorial in `max_weight`: it builds and exhausts a fresh exact-weight + /// fault iterator for each weight until every logical has a result or the explicit caller-owned + /// budget is exhausted. + #[must_use] + pub fn per_logical_circuit_fault_distances( + &self, + z_ancillas: &[usize], + x_ancillas: &[usize], + logicals: &[(&[usize], &[usize])], + max_weight: usize, + ) -> Vec> { + self.circuit_fault_distances_with_stopping_rule( + z_ancillas, + x_ancillas, + logicals, + max_weight, + CircuitDistanceStoppingRule::AllLogicals, + ) + } + /// Runs the fault tolerance check using the specified simulator type. /// /// # Arguments @@ -1069,6 +1212,22 @@ mod tests { circuit } + /// Build two independent logical sectors for which X-only location faults have distances one + /// and two. Logical 0 is flipped by an X fault before its final H. In the other sector, the two + /// prep faults propagate respectively to Z0 Z1 and Z1, so each is detected alone but together + /// they cancel the syndrome while flipping logical 1 on qubit 0. + fn unequal_logical_distance_circuit() -> TickCircuit { + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&[0]); + circuit.tick().pz(&[1]); + circuit.tick().h(&[0, 1]); + circuit.tick().cx(&[(1, 0)]); + + circuit.tick().pz(&[2]); + circuit.tick().h(&[2]); + circuit + } + #[test] fn test_three_qubit_code_syndrome_extraction() { let circuit = three_qubit_bitflip_syndrome_circuit(); @@ -1872,6 +2031,106 @@ mod tests { ); } + #[test] + fn circuit_fault_distance_is_one_for_three_qubit_syndrome_extraction() { + let circuit = three_qubit_bitflip_syndrome_circuit(); + let checker = FaultChecker::new(&circuit).with_config(FaultCheckConfig::new().all_paulis()); + let logicals: &[(&[usize], &[usize])] = &[(&[], &[0, 1, 2])]; + + let result = checker + .circuit_fault_distance(&[3, 4], &[], logicals, 1) + .expect("the three-qubit syndrome circuit has a weight-one logical fault"); + + assert_eq!(result.distance, 1); + assert_eq!(result.witness.len(), 1); + assert_eq!(result.logical_index, 0); + } + + #[test] + fn circuit_fault_distance_returns_none_without_a_logical_fault_in_budget() { + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&[0]); + let checker = FaultChecker::new(&circuit).with_config(FaultCheckConfig::new().x_only()); + let logicals: &[(&[usize], &[usize])] = &[(&[0], &[])]; + + assert!( + checker + .circuit_fault_distance(&[], &[], logicals, 2) + .is_none() + ); + } + + #[test] + fn circuit_fault_distance_respects_a_budget_below_the_true_distance() { + let circuit = unequal_logical_distance_circuit(); + let checker = FaultChecker::new(&circuit).with_config(FaultCheckConfig::new().x_only()); + let logicals: &[(&[usize], &[usize])] = &[(&[0], &[])]; + + assert!( + checker + .circuit_fault_distance(&[], &[1], logicals, 1) + .is_none() + ); + assert_eq!( + checker + .circuit_fault_distance(&[], &[1], logicals, 2) + .expect("the second logical sector has a weight-two fault") + .distance, + 2 + ); + } + + #[test] + fn per_logical_circuit_fault_distances_discriminate_and_bound_overall_distance() { + let circuit = unequal_logical_distance_circuit(); + let checker = FaultChecker::new(&circuit).with_config(FaultCheckConfig::new().x_only()); + let logicals: &[(&[usize], &[usize])] = &[(&[2], &[]), (&[0], &[])]; + + let per_logical = checker.per_logical_circuit_fault_distances(&[], &[1], logicals, 2); + let distances: Vec> = per_logical + .iter() + .map(|result| result.as_ref().map(|result| result.distance)) + .collect(); + assert_eq!(distances, vec![Some(1), Some(2)]); + assert_ne!(distances[0], distances[1]); + for (logical_index, result) in per_logical.iter().enumerate() { + let result = result + .as_ref() + .expect("both logical sectors have a fault within the budget"); + assert_eq!(result.logical_index, logical_index); + assert_eq!(result.witness.len(), result.distance); + } + + let overall = checker + .circuit_fault_distance(&[], &[1], logicals, 2) + .expect("at least one supplied logical has a fault within the budget"); + let minimum = per_logical + .iter() + .flatten() + .map(|result| result.distance) + .min() + .expect("both per-logical distances are present"); + assert_eq!(overall.distance, minimum); + } + + #[test] + fn circuit_fault_distance_is_deterministic() { + let circuit = unequal_logical_distance_circuit(); + let checker = FaultChecker::new(&circuit).with_config(FaultCheckConfig::new().x_only()); + let logicals: &[(&[usize], &[usize])] = &[(&[2], &[]), (&[0], &[])]; + + let first = checker + .circuit_fault_distance(&[], &[1], logicals, 2) + .expect("the circuit has a logical fault within the budget"); + let second = checker + .circuit_fault_distance(&[], &[1], logicals, 2) + .expect("the circuit has a logical fault within the budget"); + + assert_eq!(first.distance, second.distance); + assert_eq!(first.witness.faults, second.witness.faults); + assert_eq!(first.logical_index, second.logical_index); + } + #[test] fn test_check_undetectable_errors() { let circuit = three_qubit_bitflip_syndrome_circuit(); diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index f1b451da9..6452b318f 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -88,17 +88,18 @@ pub use fault_tolerance::dem_builder::{ FaultMechanism, NoiseConfig, PecosDemMetadataError, combine_probabilities, }; pub use fault_tolerance::{ - CorrectionResult, DecoderAnalysis, DemOutputKind, DemOutputMetadata, ErrorClass, - ErrorCorrectionChecker, ErrorCorrectionConfig, ErrorCorrectionResult, FaultCheckConfig, - FaultCheckResult, FaultChecker, FaultClass, FaultConfiguration, FaultDistanceError, - FaultDistanceResult, FaultToleranceAnalysis, FaultToleranceFailure, HookError, HookErrorReport, - LookupTableDecoder, MeasurementRound, PauliFault, PauliFaultIterator, PauliPropChecker, - PropagationResult, SpacetimeLocation, StabilizerFlipAnalysis, StabilizerFlipChecker, - StabilizerFlips, SyndromeAnalysis, SyndromeClass, SyndromeHistory, SyndromeHistoryAnalysis, - SyndromeHistoryResult, anticommutes_with_logical, apply_recovery, classify_fault, - exhaustive_fault_distance, extract_measurement_rounds, extract_spacetime_locations, - extract_syndrome, get_syndrome_flips, graphlike_fault_distance, has_syndrome, propagate_fault, - propagate_faults, run_circuit_with_faults, run_correction_cycle, + CircuitDistanceResult, CorrectionResult, DecoderAnalysis, DemOutputKind, DemOutputMetadata, + ErrorClass, ErrorCorrectionChecker, ErrorCorrectionConfig, ErrorCorrectionResult, + FaultCheckConfig, FaultCheckResult, FaultChecker, FaultClass, FaultConfiguration, + FaultDistanceError, FaultDistanceResult, FaultToleranceAnalysis, FaultToleranceFailure, + HookError, HookErrorReport, LookupTableDecoder, MeasurementRound, PauliFault, + PauliFaultIterator, PauliPropChecker, PropagationResult, SpacetimeLocation, + StabilizerFlipAnalysis, StabilizerFlipChecker, StabilizerFlips, SyndromeAnalysis, + SyndromeClass, SyndromeHistory, SyndromeHistoryAnalysis, SyndromeHistoryResult, + anticommutes_with_logical, apply_recovery, classify_fault, exhaustive_fault_distance, + extract_measurement_rounds, extract_spacetime_locations, extract_syndrome, get_syndrome_flips, + graphlike_fault_distance, has_syndrome, propagate_fault, propagate_faults, + run_circuit_with_faults, run_correction_cycle, }; pub use geometry::{CheckSchedule, LogicalOperator, PauliOp, StabilizerCheck, StabilizerColor}; pub use logical_discovery::{ From 7f793ba36c06da5d6b41801c29d06bbaf01a4994 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 5 Aug 2026 23:39:18 -0600 Subject: [PATCH 11/41] Prune the DEM fault-distance search with connected clusters and unique-detector peeling --- .../examples/fault_distance_timing.rs | 134 +++++++ crates/pecos-qec/src/fault_tolerance.rs | 3 +- .../src/fault_tolerance/fault_distance.rs | 374 +++++++++++++++--- crates/pecos-qec/src/lib.rs | 8 +- 4 files changed, 470 insertions(+), 49 deletions(-) create mode 100644 crates/pecos-qec/examples/fault_distance_timing.rs diff --git a/crates/pecos-qec/examples/fault_distance_timing.rs b/crates/pecos-qec/examples/fault_distance_timing.rs new file mode 100644 index 000000000..c30da9786 --- /dev/null +++ b/crates/pecos-qec/examples/fault_distance_timing.rs @@ -0,0 +1,134 @@ +// 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 + +use pecos_qec::fault_tolerance::dem_builder::DemBuilder; +use pecos_qec::{ + SurfaceCode, connected_cluster_fault_distance, exhaustive_fault_distance, +}; +use pecos_quantum::{Attribute, DagCircuit, TickCircuit, TickMeasRef}; +use std::time::Instant; + +fn main() { + let args: Vec = std::env::args().collect(); + let distance: usize = args[1].parse().unwrap(); + let rounds: usize = args[2].parse().unwrap(); + let max_weight: usize = args[3].parse().unwrap(); + let run_exhaustive = args.get(4).is_some_and(|arg| arg == "blind"); + + let tick = build_surface_memory(distance, rounds); + let dag = DagCircuit::try_from(&tick).unwrap(); + let dem = DemBuilder::from_circuit(&dag, 0.001, 0.001, 0.001, 0.001); + let mechanism_count = dem.to_mechanisms().0.len(); + + let started = Instant::now(); + let connected = connected_cluster_fault_distance(&dem, max_weight); + let connected_elapsed = started.elapsed(); + + println!( + "d={distance} rounds={rounds} mechanisms={mechanism_count} max_weight={max_weight} connected={connected:?} connected_ns={}", + connected_elapsed.as_nanos() + ); + + if run_exhaustive { + let started = Instant::now(); + let exhaustive = exhaustive_fault_distance(&dem, max_weight); + let exhaustive_elapsed = started.elapsed(); + println!( + "exhaustive={exhaustive:?} exhaustive_ns={}", + exhaustive_elapsed.as_nanos() + ); + assert_eq!(connected, exhaustive); + } +} + +fn build_surface_memory(distance: usize, rounds: usize) -> TickCircuit { + let code = SurfaceCode::rotated(distance).unwrap(); + let num_data = code.num_data_qubits(); + let x_ancilla = |index: usize| num_data + index; + let z_ancilla = |index: usize| num_data + code.num_x_stabilizers() + index; + let data_qubits: Vec = (0..num_data).collect(); + let x_ancillas: Vec = (0..code.num_x_stabilizers()).map(x_ancilla).collect(); + let z_ancillas: Vec = (0..code.num_z_stabilizers()).map(z_ancilla).collect(); + + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&data_qubits); + let mut x_rounds = Vec::with_capacity(rounds); + let mut z_rounds = Vec::with_capacity(rounds); + for _ in 0..rounds { + circuit.tick().pz(&x_ancillas); + circuit.tick().pz(&z_ancillas); + circuit.tick().h(&x_ancillas); + for check in code.x_stabilizers() { + for data in check.qubits() { + circuit.tick().cx(&[(x_ancilla(check.index), data)]); + } + } + for check in code.z_stabilizers() { + for data in check.qubits() { + circuit.tick().cx(&[(data, z_ancilla(check.index))]); + } + } + circuit.tick().h(&x_ancillas); + x_rounds.push(circuit.tick().mz(&x_ancillas)); + z_rounds.push(circuit.tick().mz(&z_ancillas)); + } + + let final_data = circuit.tick().mz(&data_qubits); + let mut detectors = Vec::new(); + for &measurement in &z_rounds[0] { + detectors.push(measurement_ids(&[measurement])); + } + for round in 1..rounds { + for (¤t, &previous) in x_rounds[round].iter().zip(&x_rounds[round - 1]) { + detectors.push(measurement_ids(&[current, previous])); + } + for (¤t, &previous) in z_rounds[round].iter().zip(&z_rounds[round - 1]) { + detectors.push(measurement_ids(&[current, previous])); + } + } + for check in code.z_stabilizers() { + let mut measurements = vec![z_rounds[rounds - 1][check.index]]; + measurements.extend(check.qubits().into_iter().map(|qubit| final_data[qubit])); + detectors.push(measurement_ids(&measurements)); + } + + let observable = code + .logical_z() + .data_qubits + .iter() + .map(|&qubit| final_data[qubit]) + .collect::>(); + circuit.set_meta( + "num_measurements", + Attribute::String(circuit.num_measurements().to_string()), + ); + circuit.set_meta("detectors", Attribute::String(annotations_json(&detectors))); + circuit.set_meta( + "observables", + Attribute::String(annotations_json(&[measurement_ids(&observable)])), + ); + circuit +} + +fn measurement_ids(measurements: &[TickMeasRef]) -> Vec { + measurements + .iter() + .map(|measurement| measurement.meas_id.index()) + .collect() +} + +fn annotations_json(annotations: &[Vec]) -> String { + let entries = annotations + .iter() + .map(|ids| { + let ids = ids.iter().map(usize::to_string).collect::>().join(","); + format!(r#"{{"meas_ids":[{ids}]}}"#) + }) + .collect::>() + .join(","); + format!("[{entries}]") +} diff --git a/crates/pecos-qec/src/fault_tolerance.rs b/crates/pecos-qec/src/fault_tolerance.rs index f8cd97caf..275487ff1 100644 --- a/crates/pecos-qec/src/fault_tolerance.rs +++ b/crates/pecos-qec/src/fault_tolerance.rs @@ -47,7 +47,8 @@ pub use decoder_integration::{ LookupTableDecoder, apply_recovery, extract_syndrome, run_correction_cycle, }; pub use fault_distance::{ - FaultDistanceError, FaultDistanceResult, exhaustive_fault_distance, graphlike_fault_distance, + FaultDistanceError, FaultDistanceResult, connected_cluster_fault_distance, + exhaustive_fault_distance, graphlike_fault_distance, }; pub use gadget_checker::{ GadgetAnalysis, GadgetChecker, GadgetConfig, GadgetDecoderAnalysis, GadgetFaultClass, diff --git a/crates/pecos-qec/src/fault_tolerance/fault_distance.rs b/crates/pecos-qec/src/fault_tolerance/fault_distance.rs index 3fa589132..14de70877 100644 --- a/crates/pecos-qec/src/fault_tolerance/fault_distance.rs +++ b/crates/pecos-qec/src/fault_tolerance/fault_distance.rs @@ -11,6 +11,12 @@ // limitations under the License. //! Fault-distance searches for detector error models. +//! +//! The general-purpose search uses the published Connected Cluster approach +//! ([arXiv:2603.22532](https://arxiv.org/abs/2603.22532)): a minimum-weight +//! undetectable observable-flipping set is connected in the graph whose nodes are fault +//! mechanisms and whose edges join mechanisms that share a detector. Growing only connected +//! clusters avoids the blind subset enumeration used by the reference implementation. use super::dem_builder::{DetectorErrorModel, FaultMechanism}; use std::collections::{BTreeMap, BTreeSet, VecDeque}; @@ -59,6 +65,168 @@ fn mechanisms_from_dem(dem: &DetectorErrorModel) -> Vec { .collect() } +fn detector_incidence(mechanisms: &[FaultMechanism]) -> BTreeMap> { + let mut incidence: BTreeMap> = BTreeMap::new(); + for (mechanism_index, mechanism) in mechanisms.iter().enumerate() { + for &detector in &mechanism.detectors { + incidence + .entry(detector) + .or_default() + .push(mechanism_index); + } + } + incidence +} + +/// Removes mechanisms that contain a detector unique among the remaining mechanisms. +/// +/// Once such a mechanism is removed, its other detectors may become unique, so the queue runs to +/// a fixpoint. The returned mask uses the original mechanism indices. +fn peel_unique_detector_mechanisms( + mechanisms: &[FaultMechanism], + incidence: &BTreeMap>, +) -> Vec { + let mut active = vec![true; mechanisms.len()]; + let mut remaining_counts: BTreeMap = incidence + .iter() + .map(|(&detector, mechanism_indices)| (detector, mechanism_indices.len())) + .collect(); + let mut unique_detectors: VecDeque = remaining_counts + .iter() + .filter_map(|(&detector, &count)| (count == 1).then_some(detector)) + .collect(); + + while let Some(detector) = unique_detectors.pop_front() { + if remaining_counts.get(&detector) != Some(&1) { + continue; + } + let Some(mechanism_index) = incidence[&detector] + .iter() + .copied() + .find(|&index| active[index]) + else { + continue; + }; + + active[mechanism_index] = false; + for &affected_detector in &mechanisms[mechanism_index].detectors { + let count = remaining_counts + .get_mut(&affected_detector) + .expect("every mechanism detector is present in the incidence map"); + *count -= 1; + if *count == 1 { + unique_detectors.push_back(affected_detector); + } + } + } + + active +} + +struct ConnectedClusterSearch<'a> { + mechanisms: &'a [FaultMechanism], + incidence: &'a BTreeMap>, + active: &'a [bool], + target_weight: usize, + best: Option>, +} + +impl ConnectedClusterSearch<'_> { + fn run(mut self) -> Option> { + for seed in 0..self.mechanisms.len() { + if !self.active[seed] { + continue; + } + + let mut cluster = vec![seed]; + let mut members = BTreeSet::from([seed]); + let effect = self.mechanisms[seed].clone(); + if self.target_weight == 1 { + self.consider_witness(&cluster, &effect); + continue; + } + + let extension = self.neighbors(seed, seed, &members, &BTreeSet::new()); + self.extend( + seed, + &mut cluster, + &mut members, + &effect, + extension, + BTreeSet::new(), + ); + } + self.best + } + + fn extend( + &mut self, + seed: usize, + cluster: &mut Vec, + members: &mut BTreeSet, + effect: &FaultMechanism, + mut extension: BTreeSet, + mut excluded: BTreeSet, + ) { + while let Some(candidate) = extension.pop_first() { + cluster.push(candidate); + members.insert(candidate); + let next_effect = effect.xor(&self.mechanisms[candidate]); + + if cluster.len() == self.target_weight { + self.consider_witness(cluster, &next_effect); + } else { + let mut next_extension = extension.clone(); + next_extension.extend(self.neighbors(candidate, seed, members, &excluded)); + self.extend( + seed, + cluster, + members, + &next_effect, + next_extension, + excluded.clone(), + ); + } + + members.remove(&candidate); + cluster.pop(); + excluded.insert(candidate); + } + } + + fn neighbors( + &self, + mechanism_index: usize, + seed: usize, + members: &BTreeSet, + excluded: &BTreeSet, + ) -> BTreeSet { + self.mechanisms[mechanism_index] + .detectors + .iter() + .flat_map(|detector| &self.incidence[detector]) + .copied() + .filter(|&neighbor| { + neighbor > seed + && self.active[neighbor] + && !members.contains(&neighbor) + && !excluded.contains(&neighbor) + }) + .collect() + } + + fn consider_witness(&mut self, cluster: &[usize], effect: &FaultMechanism) { + if !effect.detectors.is_empty() || effect.dem_outputs.is_empty() { + return; + } + let mut candidate = cluster.to_vec(); + candidate.sort_unstable(); + if self.best.as_ref().is_none_or(|current| candidate < *current) { + self.best = Some(candidate); + } + } +} + #[derive(Clone, Copy)] struct GraphEdge { neighbor: usize, @@ -83,7 +251,9 @@ fn update_best(best: &mut Option, mut mechanism_indices: Ve /// /// A graphlike mechanism flips at most two detectors. The search returns an error when any /// hyperedge is present; it never drops unsupported mechanisms or silently changes algorithms. -/// Probabilities are ignored because distance counts mechanisms with unit weight. +/// This check is unconditional and happens before any distance-one shortcut, so a DEM containing +/// a hyperedge is rejected predictably even when it also contains a detector-free logical +/// mechanism. Probabilities are ignored because distance counts mechanisms with unit weight. /// /// A mechanism with no detectors and at least one observable is handled first because it proves /// distance one without requiring any graph construction. @@ -96,6 +266,15 @@ pub fn graphlike_fault_distance( dem: &DetectorErrorModel, ) -> Result, FaultDistanceError> { let mechanisms = mechanisms_from_dem(dem); + let hyperedge_count = mechanisms + .iter() + .filter(|mechanism| mechanism.is_hyperedge()) + .count(); + if hyperedge_count != 0 { + return Err(FaultDistanceError::HyperedgesPresent { + count: hyperedge_count, + }); + } if let Some(mechanism_index) = mechanisms .iter() @@ -107,16 +286,6 @@ pub fn graphlike_fault_distance( })); } - let hyperedge_count = mechanisms - .iter() - .filter(|mechanism| mechanism.is_hyperedge()) - .count(); - if hyperedge_count != 0 { - return Err(FaultDistanceError::HyperedgesPresent { - count: hyperedge_count, - }); - } - let detector_ids: BTreeSet = mechanisms .iter() .flat_map(|mechanism| mechanism.detectors.iter().copied()) @@ -241,9 +410,11 @@ fn first_witness_of_weight(mechanisms: &[FaultMechanism], weight: usize) -> Opti /// Exhaustively computes fault distance up to `max_weight` for any detector error model. /// -/// This method supports hyperedges and ignores mechanism probabilities. It examines mechanism -/// subsets in increasing size and returns the first detector-free subset whose XOR flips at least -/// one observable. Its cost is combinatorial: in the worst case it checks +/// This simple reference implementation is used to validate +/// [`connected_cluster_fault_distance`]. It supports hyperedges and ignores mechanism +/// probabilities. It examines mechanism subsets in increasing size and returns the first +/// detector-free subset whose XOR flips at least one observable. Its cost is combinatorial: in the +/// worst case it checks /// `sum(binomial(num_mechanisms, weight), weight=1..max_weight)` subsets, so callers must choose an /// explicit search budget. #[must_use] @@ -263,6 +434,47 @@ pub fn exhaustive_fault_distance( None } +/// Computes exact fault distance up to `max_weight` using connected-cluster pruning. +/// +/// This is the preferred general-purpose search for real detector error models. It supports +/// hyperedges and ignores mechanism probabilities. Before searching, it repeatedly peels every +/// mechanism containing a detector that occurs in no other remaining mechanism, because such a +/// detector cannot cancel in an undetectable set. +/// +/// Connected subsets are enumerated without duplicates using a deterministic Redelmeier-style +/// scheme. The smallest mechanism index is the cluster seed, extensions are restricted to larger +/// indices, and a candidate skipped at one recursion level is excluded from later sibling +/// branches. Thus every connected set has exactly one seed and one construction branch. Search is +/// by increasing weight, with the lexicographically smallest original-index witness retained at +/// each weight, matching [`exhaustive_fault_distance`]. +#[must_use] +pub fn connected_cluster_fault_distance( + dem: &DetectorErrorModel, + max_weight: usize, +) -> Option { + let mechanisms = mechanisms_from_dem(dem); + let incidence = detector_incidence(&mechanisms); + let active = peel_unique_detector_mechanisms(&mechanisms, &incidence); + + for weight in 1..=max_weight.min(mechanisms.len()) { + let mechanism_indices = ConnectedClusterSearch { + mechanisms: &mechanisms, + incidence: &incidence, + active: &active, + target_weight: weight, + best: None, + } + .run(); + if let Some(mechanism_indices) = mechanism_indices { + return Some(FaultDistanceResult { + distance: weight, + mechanism_indices, + }); + } + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -290,7 +502,8 @@ mod tests { }; assert_eq!(graphlike_fault_distance(&dem), Ok(Some(expected.clone()))); - assert_eq!(exhaustive_fault_distance(&dem, 1), Some(expected)); + assert_eq!(exhaustive_fault_distance(&dem, 1), Some(expected.clone())); + assert_eq!(connected_cluster_fault_distance(&dem, 1), Some(expected)); } #[test] @@ -302,7 +515,8 @@ mod tests { }; assert_eq!(graphlike_fault_distance(&dem), Ok(Some(expected.clone()))); - assert_eq!(exhaustive_fault_distance(&dem, 3), Some(expected)); + assert_eq!(exhaustive_fault_distance(&dem, 3), Some(expected.clone())); + assert_eq!(connected_cluster_fault_distance(&dem, 3), Some(expected)); } #[test] @@ -320,7 +534,8 @@ mod tests { }; assert_eq!(graphlike_fault_distance(&dem), Ok(Some(expected.clone()))); - assert_eq!(exhaustive_fault_distance(&dem, 3), Some(expected)); + assert_eq!(exhaustive_fault_distance(&dem, 3), Some(expected.clone())); + assert_eq!(connected_cluster_fault_distance(&dem, 3), Some(expected)); } #[test] @@ -329,13 +544,15 @@ mod tests { assert_eq!(graphlike_fault_distance(&dem), Ok(None)); assert_eq!(exhaustive_fault_distance(&dem, 8), None); + assert_eq!(connected_cluster_fault_distance(&dem, 8), None); } #[test] - fn exhaustive_budget_below_distance_returns_none() { + fn search_budget_below_distance_returns_none() { let dem = dem_from_effects(&[(vec![0, 1], vec![0]), (vec![0], vec![]), (vec![1], vec![])]); assert_eq!(exhaustive_fault_distance(&dem, 2), None); + assert_eq!(connected_cluster_fault_distance(&dem, 2), None); } #[test] @@ -352,14 +569,14 @@ mod tests { graphlike_fault_distance(&dem), Err(FaultDistanceError::HyperedgesPresent { count: 1 }) ); - assert_eq!( - exhaustive_fault_distance(&dem, 3), - Some(FaultDistanceResult { - distance: 3, - mechanism_indices: vec![0, 1, 2], - }) - ); + let expected = FaultDistanceResult { + distance: 3, + mechanism_indices: vec![0, 1, 2], + }; + assert_eq!(exhaustive_fault_distance(&dem, 3), Some(expected.clone())); + assert_eq!(connected_cluster_fault_distance(&dem, 3), Some(expected)); assert_eq!(exhaustive_fault_distance(&dem, 2), None); + assert_eq!(connected_cluster_fault_distance(&dem, 2), None); } #[test] @@ -373,11 +590,12 @@ mod tests { }; assert_eq!(graphlike_fault_distance(&dem), Ok(Some(expected.clone()))); - assert_eq!(exhaustive_fault_distance(&dem, 3), Some(expected)); + assert_eq!(exhaustive_fault_distance(&dem, 3), Some(expected.clone())); + assert_eq!(connected_cluster_fault_distance(&dem, 3), Some(expected)); } #[test] - fn graphlike_matches_exhaustive_on_seeded_small_random_dems() { + fn all_searches_agree_on_seeded_small_random_dems_with_hyperedges() { use rand::rngs::SmallRng; use rand::{RngExt, SeedableRng}; @@ -388,15 +606,26 @@ mod tests { let mut rng = SmallRng::seed_from_u64(0x000D_157A_11CE_5EED); for case_index in 0..NUM_CASES { - let num_mechanisms = rng.random_range(0..=MAX_MECHANISMS); + let graphlike_case = case_index % 2 == 0; + let num_mechanisms = if graphlike_case { + rng.random_range(0..=MAX_MECHANISMS) + } else { + rng.random_range(1..=MAX_MECHANISMS) + }; let mut effects = Vec::with_capacity(num_mechanisms); - for _ in 0..num_mechanisms { - let mut detectors = Vec::with_capacity(2); - for detector in 0..NUM_DETECTORS { - if detectors.len() < 2 && rng.random_bool(0.5) { - detectors.push(detector); + for mechanism_index in 0..num_mechanisms { + let detectors = if !graphlike_case && mechanism_index == 0 { + vec![0, 1, 2] + } else { + let detector_limit = if graphlike_case { 2 } else { 4 }; + let mut detectors = Vec::with_capacity(detector_limit); + for detector in 0..NUM_DETECTORS { + if detectors.len() < detector_limit && rng.random_bool(0.5) { + detectors.push(detector); + } } - } + detectors + }; let observables = (0..NUM_OBSERVABLES) .filter(|_| rng.random_bool(0.5)) .collect(); @@ -404,20 +633,77 @@ mod tests { } let dem = dem_from_effects(&effects); - let graphlike = graphlike_fault_distance(&dem) - .expect("the generator creates only graphlike mechanisms"); let exhaustive = exhaustive_fault_distance(&dem, MAX_MECHANISMS); + let connected = connected_cluster_fault_distance(&dem, MAX_MECHANISMS); assert_eq!( - graphlike.is_some(), - exhaustive.is_some(), - "solution existence differed for seeded case {case_index}: {effects:?}" - ); - assert_eq!( - graphlike.as_ref().map(|result| result.distance), - exhaustive.as_ref().map(|result| result.distance), - "distance differed for seeded case {case_index}: {effects:?}" + connected, exhaustive, + "general searches differed for seeded case {case_index}: {effects:?}" ); + + if graphlike_case { + let graphlike = graphlike_fault_distance(&dem) + .expect("the graphlike half of the generator has no hyperedges"); + assert_eq!( + graphlike.is_some(), + exhaustive.is_some(), + "solution existence differed for seeded case {case_index}: {effects:?}" + ); + assert_eq!( + graphlike.as_ref().map(|result| result.distance), + exhaustive.as_ref().map(|result| result.distance), + "distance differed for seeded case {case_index}: {effects:?}" + ); + } else { + assert!( + matches!( + graphlike_fault_distance(&dem), + Err(FaultDistanceError::HyperedgesPresent { .. }) + ), + "hyperedge case {case_index} did not contain a hyperedge: {effects:?}" + ); + } } } + + #[test] + fn peeling_unique_detectors_reaches_a_fixpoint_without_changing_distance() { + // D0 is initially unique. Peeling its mechanism makes D1 unique, which makes D2 unique. + // The two D10 mechanisms remain and form the only logical witness. + let dem = dem_from_effects(&[ + (vec![0, 1], vec![]), + (vec![1, 2], vec![]), + (vec![2], vec![]), + (vec![10], vec![0]), + (vec![10], vec![]), + ]); + let mechanisms = mechanisms_from_dem(&dem); + let incidence = detector_incidence(&mechanisms); + let active = peel_unique_detector_mechanisms(&mechanisms, &incidence); + + for (mechanism, &is_active) in mechanisms.iter().zip(&active) { + assert_eq!(is_active, mechanism.detectors.as_slice() == [10]); + } + + let exhaustive = exhaustive_fault_distance(&dem, mechanisms.len()); + let connected = connected_cluster_fault_distance(&dem, mechanisms.len()); + assert_eq!(connected, exhaustive); + let witness = connected.expect("the shared D10 pair is a logical witness"); + assert!(witness.mechanism_indices.iter().all(|&index| { + mechanisms[index].detectors.as_slice() == [10] + })); + } + + #[test] + fn peeling_preserves_a_witness_whose_detectors_are_all_shared() { + let dem = dem_from_effects(&[(vec![0], vec![0]), (vec![0], vec![])]); + let expected = exhaustive_fault_distance(&dem, 2); + + assert_eq!( + connected_cluster_fault_distance(&dem, 2), + expected, + "both mechanisms sharing D0 must survive peeling" + ); + assert_eq!(expected.as_ref().map(|result| result.distance), Some(2)); + } } diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index 6452b318f..0b0bf8da1 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -96,10 +96,10 @@ pub use fault_tolerance::{ PauliFaultIterator, PauliPropChecker, PropagationResult, SpacetimeLocation, StabilizerFlipAnalysis, StabilizerFlipChecker, StabilizerFlips, SyndromeAnalysis, SyndromeClass, SyndromeHistory, SyndromeHistoryAnalysis, SyndromeHistoryResult, - anticommutes_with_logical, apply_recovery, classify_fault, exhaustive_fault_distance, - extract_measurement_rounds, extract_spacetime_locations, extract_syndrome, get_syndrome_flips, - graphlike_fault_distance, has_syndrome, propagate_fault, propagate_faults, - run_circuit_with_faults, run_correction_cycle, + anticommutes_with_logical, apply_recovery, classify_fault, connected_cluster_fault_distance, + exhaustive_fault_distance, extract_measurement_rounds, extract_spacetime_locations, + extract_syndrome, get_syndrome_flips, graphlike_fault_distance, has_syndrome, propagate_fault, + propagate_faults, run_circuit_with_faults, run_correction_cycle, }; pub use geometry::{CheckSchedule, LogicalOperator, PauliOp, StabilizerCheck, StabilizerColor}; pub use logical_discovery::{ From 70ec6ec351a8bee8cdfa7a3d9194fc16ca304859 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 00:35:43 -0600 Subject: [PATCH 12/41] Verify the propagated-fault half of the Chao-Reichardt t-flag condition --- .../examples/fault_distance_timing.rs | 10 +- crates/pecos-qec/src/fault_tolerance.rs | 2 + .../src/fault_tolerance/fault_distance.rs | 20 +- .../src/fault_tolerance/flag_verification.rs | 321 ++++++++++++++++++ .../src/fault_tolerance/pauli_prop_checker.rs | 10 + crates/pecos-qec/src/lib.rs | 16 +- 6 files changed, 359 insertions(+), 20 deletions(-) create mode 100644 crates/pecos-qec/src/fault_tolerance/flag_verification.rs diff --git a/crates/pecos-qec/examples/fault_distance_timing.rs b/crates/pecos-qec/examples/fault_distance_timing.rs index c30da9786..aeab04020 100644 --- a/crates/pecos-qec/examples/fault_distance_timing.rs +++ b/crates/pecos-qec/examples/fault_distance_timing.rs @@ -6,9 +6,7 @@ // https://www.apache.org/licenses/LICENSE-2.0 use pecos_qec::fault_tolerance::dem_builder::DemBuilder; -use pecos_qec::{ - SurfaceCode, connected_cluster_fault_distance, exhaustive_fault_distance, -}; +use pecos_qec::{SurfaceCode, connected_cluster_fault_distance, exhaustive_fault_distance}; use pecos_quantum::{Attribute, DagCircuit, TickCircuit, TickMeasRef}; use std::time::Instant; @@ -125,7 +123,11 @@ fn annotations_json(annotations: &[Vec]) -> String { let entries = annotations .iter() .map(|ids| { - let ids = ids.iter().map(usize::to_string).collect::>().join(","); + let ids = ids + .iter() + .map(usize::to_string) + .collect::>() + .join(","); format!(r#"{{"meas_ids":[{ids}]}}"#) }) .collect::>() diff --git a/crates/pecos-qec/src/fault_tolerance.rs b/crates/pecos-qec/src/fault_tolerance.rs index 275487ff1..fc32a6fa4 100644 --- a/crates/pecos-qec/src/fault_tolerance.rs +++ b/crates/pecos-qec/src/fault_tolerance.rs @@ -23,6 +23,7 @@ pub mod decoder_integration; pub mod dem_builder; pub mod fault_distance; pub mod fault_sampler; +pub mod flag_verification; pub mod gadget_checker; pub mod hook_errors; pub mod influence_builder; @@ -50,6 +51,7 @@ pub use fault_distance::{ FaultDistanceError, FaultDistanceResult, connected_cluster_fault_distance, exhaustive_fault_distance, graphlike_fault_distance, }; +pub use flag_verification::{FlagFaultToleranceReport, FlagViolation}; pub use gadget_checker::{ GadgetAnalysis, GadgetChecker, GadgetConfig, GadgetDecoderAnalysis, GadgetFaultClass, GadgetFaultResult, GadgetFollowUpConfig, GadgetHistoryAnalysis, GadgetHistoryPattern, diff --git a/crates/pecos-qec/src/fault_tolerance/fault_distance.rs b/crates/pecos-qec/src/fault_tolerance/fault_distance.rs index 14de70877..e307d1519 100644 --- a/crates/pecos-qec/src/fault_tolerance/fault_distance.rs +++ b/crates/pecos-qec/src/fault_tolerance/fault_distance.rs @@ -69,10 +69,7 @@ fn detector_incidence(mechanisms: &[FaultMechanism]) -> BTreeMap let mut incidence: BTreeMap> = BTreeMap::new(); for (mechanism_index, mechanism) in mechanisms.iter().enumerate() { for &detector in &mechanism.detectors { - incidence - .entry(detector) - .or_default() - .push(mechanism_index); + incidence.entry(detector).or_default().push(mechanism_index); } } incidence @@ -221,7 +218,11 @@ impl ConnectedClusterSearch<'_> { } let mut candidate = cluster.to_vec(); candidate.sort_unstable(); - if self.best.as_ref().is_none_or(|current| candidate < *current) { + if self + .best + .as_ref() + .is_none_or(|current| candidate < *current) + { self.best = Some(candidate); } } @@ -689,9 +690,12 @@ mod tests { let connected = connected_cluster_fault_distance(&dem, mechanisms.len()); assert_eq!(connected, exhaustive); let witness = connected.expect("the shared D10 pair is a logical witness"); - assert!(witness.mechanism_indices.iter().all(|&index| { - mechanisms[index].detectors.as_slice() == [10] - })); + assert!( + witness + .mechanism_indices + .iter() + .all(|&index| { mechanisms[index].detectors.as_slice() == [10] }) + ); } #[test] diff --git a/crates/pecos-qec/src/fault_tolerance/flag_verification.rs b/crates/pecos-qec/src/fault_tolerance/flag_verification.rs new file mode 100644 index 000000000..5b6a42620 --- /dev/null +++ b/crates/pecos-qec/src/fault_tolerance/flag_verification.rs @@ -0,0 +1,321 @@ +// 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. + +//! Verification of the propagated-fault condition for flag circuits. +//! +//! Chao and Reichardt define a t-flag circuit by requiring that every set of `v <= t` faults +//! producing a data error `E` with `min(wt(E), wt(E * P)) > v` raises a flag, and additionally +//! requiring that a fault-free run does not flag; see arXiv:1708.02246. This module verifies the +//! first requirement by Pauli-frame propagation. It does **not** check the fault-free requirement: +//! that is a property of the ideal measurement outcomes, not of the propagated deviation frame, +//! and must be established separately. + +use super::{FaultConfiguration, PauliPropChecker, has_syndrome}; +use pecos_simulators::PauliProp; +use std::collections::BTreeSet; + +/// A fault configuration that violates the propagated-fault condition for a flag circuit. +#[derive(Debug, Clone)] +pub struct FlagViolation { + /// The counterexample fault configuration. + pub faults: FaultConfiguration, + /// Number of faulty circuit locations in `faults`. + pub num_faults: usize, + /// `min(wt(E), wt(E * P))`, restricted to the caller-supplied data qubits. + pub error_weight: usize, +} + +/// Result of checking the propagated-fault condition through fault weight `t`. +#[derive(Debug, Clone)] +pub struct FlagFaultToleranceReport { + /// True when no fault configuration of weight at most `t` violates the t-flag condition. + /// + /// The full definition in arXiv:1708.02246 additionally requires a fault-free run not to flag. + /// Pauli propagation tracks deviations from an ideal run and cannot check that ideal-outcome + /// property, so callers must establish it separately. + pub fault_condition_satisfied: bool, + /// Maximum number of faulty circuit locations checked. + pub t: usize, + /// Counterexamples in ascending fault weight and deterministic iterator order. + pub violations: Vec, + /// Number of nonempty fault configurations propagated. + pub total_configurations_tested: usize, +} + +fn stabilizer_equivalent_error_weight( + prop: &PauliProp, + data_qubits: &[usize], + measured_stabilizer: (&[usize], &[usize]), +) -> usize { + let data_qubits: BTreeSet<_> = data_qubits.iter().copied().collect(); + let (stabilizer_xs, stabilizer_zs) = measured_stabilizer; + let mut error_weight = 0; + let mut equivalent_error_weight = 0; + + for qubit in data_qubits { + let has_x = prop.contains_x(qubit); + let has_z = prop.contains_z(qubit); + error_weight += usize::from(has_x || has_z); + + let equivalent_has_x = has_x ^ stabilizer_xs.contains(&qubit); + let equivalent_has_z = has_z ^ stabilizer_zs.contains(&qubit); + equivalent_error_weight += usize::from(equivalent_has_x || equivalent_has_z); + } + + error_weight.min(equivalent_error_weight) +} + +impl PauliPropChecker<'_> { + /// Checks the propagated-fault part of the Chao-Reichardt t-flag condition. + /// + /// For every exact fault-location weight `v` from 1 through `t`, this method propagates each + /// configured Pauli fault assignment. A configuration violates the condition when no supplied + /// flag qubit's Z-basis measurement is flipped and + /// `min(wt(E), wt(E * P)) > v`, with both weights restricted to `data_qubits`. + /// + /// Fault-free flag behavior is outside this Pauli-frame check and must be verified separately. + #[must_use] + pub fn verify_flag_fault_tolerance( + &self, + data_qubits: &[usize], + flag_qubits: &[usize], + measured_stabilizer: (&[usize], &[usize]), + t: usize, + ) -> FlagFaultToleranceReport { + let mut violations = Vec::new(); + let mut total_configurations_tested = 0; + + for weight in 1..=t { + let fault_iter = self.fault_iterator_for_weight(weight); + + for faults in fault_iter { + total_configurations_tested += 1; + let prop = self.propagate_fault_configuration(&faults); + if has_syndrome(&prop, flag_qubits, &[]) { + continue; + } + + let error_weight = + stabilizer_equivalent_error_weight(&prop, data_qubits, measured_stabilizer); + let num_faults = faults.len(); + if error_weight > num_faults { + violations.push(FlagViolation { + faults, + num_faults, + error_weight, + }); + } + } + } + + FlagFaultToleranceReport { + fault_condition_satisfied: violations.is_empty(), + t, + violations, + total_configurations_tested, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fault_tolerance::propagate_faults; + use pecos_quantum::TickCircuit; + + const DATA_QUBITS: &[usize] = &[0, 1, 2, 3]; + const STABILIZER_XS: &[usize] = &[0, 1, 2, 3]; + const MEASUREMENT_ANCILLA: usize = 4; + const FLAG_ANCILLA: usize = 5; + + fn weight_four_x_measurement(with_flag: bool) -> TickCircuit { + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&[MEASUREMENT_ANCILLA]); + circuit.tick().h(&[MEASUREMENT_ANCILLA]); + if with_flag { + circuit.tick().pz(&[FLAG_ANCILLA]); + } + circuit.tick().cx(&[(MEASUREMENT_ANCILLA, 0)]); + if with_flag { + circuit.tick().cx(&[(MEASUREMENT_ANCILLA, FLAG_ANCILLA)]); + } + circuit.tick().cx(&[(MEASUREMENT_ANCILLA, 1)]); + circuit.tick().cx(&[(MEASUREMENT_ANCILLA, 2)]); + if with_flag { + circuit.tick().cx(&[(MEASUREMENT_ANCILLA, FLAG_ANCILLA)]); + } + circuit.tick().cx(&[(MEASUREMENT_ANCILLA, 3)]); + circuit.tick().h(&[MEASUREMENT_ANCILLA]); + circuit.tick().mz(&[MEASUREMENT_ANCILLA]); + if with_flag { + circuit.tick().mz(&[FLAG_ANCILLA]); + } + circuit + } + + fn weight_six_single_flag_x_measurement() -> TickCircuit { + const MEASUREMENT: usize = 6; + const FLAG: usize = 7; + + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&[MEASUREMENT]); + circuit.tick().h(&[MEASUREMENT]); + circuit.tick().pz(&[FLAG]); + circuit.tick().cx(&[(MEASUREMENT, 0)]); + circuit.tick().cx(&[(MEASUREMENT, FLAG)]); + for data_qubit in 1..=4 { + circuit.tick().cx(&[(MEASUREMENT, data_qubit)]); + } + circuit.tick().cx(&[(MEASUREMENT, FLAG)]); + circuit.tick().cx(&[(MEASUREMENT, 5)]); + circuit.tick().h(&[MEASUREMENT]); + circuit.tick().mz(&[MEASUREMENT]); + circuit.tick().mz(&[FLAG]); + circuit + } + + fn verify( + circuit: &TickCircuit, + data_qubits: &[usize], + flag_qubits: &[usize], + t: usize, + ) -> FlagFaultToleranceReport { + let checker = PauliPropChecker::new(circuit); + checker.verify_flag_fault_tolerance(data_qubits, flag_qubits, (STABILIZER_XS, &[]), t) + } + + #[test] + fn standard_single_flag_weight_four_measurement_satisfies_one_fault_condition() { + let circuit = weight_four_x_measurement(true); + let report = verify(&circuit, DATA_QUBITS, &[FLAG_ANCILLA], 1); + + assert!(report.fault_condition_satisfied); + assert!(report.violations.is_empty()); + assert!(report.total_configurations_tested > 0); + assert_eq!(report.t, 1); + } + + #[test] + fn unflagged_weight_four_measurement_has_weight_two_hook() { + let circuit = weight_four_x_measurement(false); + let report = verify(&circuit, DATA_QUBITS, &[], 1); + + assert!(!report.fault_condition_satisfied); + assert!( + report + .violations + .iter() + .any(|violation| violation.num_faults == 1 && violation.error_weight == 2) + ); + } + + #[test] + fn stabilizer_equivalent_weight_prevents_false_violation() { + let circuit = weight_four_x_measurement(false); + let checker = PauliPropChecker::new(&circuit); + let fault = checker + .locations() + .iter() + .find(|location| location.tick == 2) + .expect("the first data CX should be at tick 2") + .clone(); + let faults = + FaultConfiguration::with_faults(vec![super::super::PauliFault::new(fault, vec![1, 0])]); + let prop = propagate_faults(&circuit, &faults); + + // The X fault on the measurement ancilla after CX(a, 0) propagates through the remaining + // data couplings, so E = X1 X2 X3 and wt(E) = 3. For P = X0 X1 X2 X3, + // E * P = X0 and wt(E * P) = 1. Thus min(3, 1) = v = 1: this is not a violation. + assert_eq!( + DATA_QUBITS + .iter() + .filter(|&&qubit| prop.contains_x(qubit) || prop.contains_z(qubit)) + .count(), + 3 + ); + assert_eq!( + stabilizer_equivalent_error_weight(&prop, DATA_QUBITS, (STABILIZER_XS, &[])), + 1 + ); + + let report = verify(&circuit, DATA_QUBITS, &[], 1); + assert!(report.violations.iter().all(|violation| { + let [reported_fault] = violation.faults.faults.as_slice() else { + return true; + }; + reported_fault.location.tick != 2 || reported_fault.paulis != [1, 0] + })); + } + + #[test] + fn single_flag_circuit_does_not_satisfy_two_fault_condition() { + const WEIGHT_SIX_DATA: &[usize] = &[0, 1, 2, 3, 4, 5]; + const WEIGHT_SIX_STABILIZER: &[usize] = &[0, 1, 2, 3, 4, 5]; + const WEIGHT_SIX_FLAG: &[usize] = &[7]; + + let circuit = weight_six_single_flag_x_measurement(); + let checker = PauliPropChecker::new(&circuit); + let one_fault_report = checker.verify_flag_fault_tolerance( + WEIGHT_SIX_DATA, + WEIGHT_SIX_FLAG, + (WEIGHT_SIX_STABILIZER, &[]), + 1, + ); + let report = checker.verify_flag_fault_tolerance( + WEIGHT_SIX_DATA, + WEIGHT_SIX_FLAG, + (WEIGHT_SIX_STABILIZER, &[]), + 2, + ); + + assert!(one_fault_report.fault_condition_satisfied); + assert!(!report.fault_condition_satisfied); + assert!( + report + .violations + .iter() + .any(|violation| violation.num_faults == 2) + ); + } + + #[test] + fn verification_is_deterministic() { + let circuit = weight_four_x_measurement(false); + let first = verify(&circuit, DATA_QUBITS, &[], 2); + let second = verify(&circuit, DATA_QUBITS, &[], 2); + + assert_eq!( + first.fault_condition_satisfied, + second.fault_condition_satisfied + ); + assert_eq!(first.t, second.t); + assert_eq!( + first.total_configurations_tested, + second.total_configurations_tested + ); + assert_eq!(first.violations.len(), second.violations.len()); + for (left, right) in first.violations.iter().zip(&second.violations) { + assert_eq!(left.num_faults, right.num_faults); + assert_eq!(left.error_weight, right.error_weight); + assert_eq!(left.faults.faults, right.faults.faults); + } + } + + #[test] + fn error_weight_is_restricted_to_caller_supplied_data_qubits() { + let circuit = weight_four_x_measurement(false); + let report = verify(&circuit, &[0], &[], 1); + + assert!(report.fault_condition_satisfied); + assert!(report.violations.is_empty()); + } +} diff --git a/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs b/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs index 7cdf60ac6..7b892c176 100644 --- a/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs +++ b/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs @@ -1370,6 +1370,16 @@ impl<'a> PauliPropChecker<'a> { &self.locations } + pub(crate) fn fault_iterator_for_weight(&self, weight: usize) -> PauliFaultIterator { + let mut config = self.config.clone(); + config.max_weight = weight; + PauliFaultIterator::new(self.locations.clone(), weight, config) + } + + pub(crate) fn propagate_fault_configuration(&self, faults: &FaultConfiguration) -> PauliProp { + propagate_faults(self.circuit, faults) + } + /// Returns the detected input qubits. /// /// These are qubits used by the circuit but never prepared, meaning they diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index 0b0bf8da1..2f4fe5643 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -92,14 +92,14 @@ pub use fault_tolerance::{ ErrorClass, ErrorCorrectionChecker, ErrorCorrectionConfig, ErrorCorrectionResult, FaultCheckConfig, FaultCheckResult, FaultChecker, FaultClass, FaultConfiguration, FaultDistanceError, FaultDistanceResult, FaultToleranceAnalysis, FaultToleranceFailure, - HookError, HookErrorReport, LookupTableDecoder, MeasurementRound, PauliFault, - PauliFaultIterator, PauliPropChecker, PropagationResult, SpacetimeLocation, - StabilizerFlipAnalysis, StabilizerFlipChecker, StabilizerFlips, SyndromeAnalysis, - SyndromeClass, SyndromeHistory, SyndromeHistoryAnalysis, SyndromeHistoryResult, - anticommutes_with_logical, apply_recovery, classify_fault, connected_cluster_fault_distance, - exhaustive_fault_distance, extract_measurement_rounds, extract_spacetime_locations, - extract_syndrome, get_syndrome_flips, graphlike_fault_distance, has_syndrome, propagate_fault, - propagate_faults, run_circuit_with_faults, run_correction_cycle, + FlagFaultToleranceReport, FlagViolation, HookError, HookErrorReport, LookupTableDecoder, + MeasurementRound, PauliFault, PauliFaultIterator, PauliPropChecker, PropagationResult, + SpacetimeLocation, StabilizerFlipAnalysis, StabilizerFlipChecker, StabilizerFlips, + SyndromeAnalysis, SyndromeClass, SyndromeHistory, SyndromeHistoryAnalysis, + SyndromeHistoryResult, anticommutes_with_logical, apply_recovery, classify_fault, + connected_cluster_fault_distance, exhaustive_fault_distance, extract_measurement_rounds, + extract_spacetime_locations, extract_syndrome, get_syndrome_flips, graphlike_fault_distance, + has_syndrome, propagate_fault, propagate_faults, run_circuit_with_faults, run_correction_cycle, }; pub use geometry::{CheckSchedule, LogicalOperator, PauliOp, StabilizerCheck, StabilizerColor}; pub use logical_discovery::{ From a50915bea2b7277cb7f6f1db493fca3b5ea89ab6 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 12:41:11 -0600 Subject: [PATCH 13/41] Add SAT/MaxSAT distance encoding with native witness certification --- crates/pecos-qec/src/distance_problem.rs | 1191 ++++++++++++++++++++++ crates/pecos-qec/src/lib.rs | 5 + 2 files changed, 1196 insertions(+) create mode 100644 crates/pecos-qec/src/distance_problem.rs diff --git a/crates/pecos-qec/src/distance_problem.rs b/crates/pecos-qec/src/distance_problem.rs new file mode 100644 index 000000000..e6260c025 --- /dev/null +++ b/crates/pecos-qec/src/distance_problem.rs @@ -0,0 +1,1191 @@ +// 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. + +//! Solver-independent SAT and `MaxSAT` encodings of code- and fault-distance problems. +//! +//! The encoding choices follow the qLDPC distance study in +//! [arXiv:2606.12445](https://arxiv.org/abs/2606.12445): parity constraints use Tseitin XOR +//! chains, while weight bounds use a Sinz sequential counter. The module emits standard text +//! formats and deliberately has no solver dependency. +//! +//! Certification has an asymmetric trust boundary. A solver's SAT witness is checked here using +//! native GF(2) arithmetic, so the SAT half needs no solver trust. UNSAT answers cannot be checked +//! without a proof checker; exactness therefore rests on trusting every solver UNSAT answer below +//! the returned distance. + +use crate::{DetectorErrorModel, ParityCheckMatrix, StabilizerCodeSpec}; +use pecos_core::PauliOperator; +use pecos_quantum::F2Matrix; +use std::fmt::Write as _; +use thiserror::Error; + +/// A binary distance problem: find a nonzero logical effect in the kernel of the checks. +/// +/// Columns are candidate qubits or fault mechanisms. `H e = 0` enforces undetectability and +/// `L e != 0` enforces a nontrivial logical or observable effect. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DistanceProblem { + h: F2Matrix, + l: F2Matrix, + num_vars: usize, +} + +/// Errors constructing a [`DistanceProblem`]. +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum DistanceProblemError { + /// The check and logical matrices describe different numbers of variables. + #[error("distance matrices have different widths: H has {h_width}, L has {l_width}")] + MatrixWidthMismatch { + /// Number of columns in the check matrix. + h_width: usize, + /// Number of columns in the logical matrix. + l_width: usize, + }, + /// A stabilizer or logical operator is not in the required CSS form. + #[error("stabilizer code spec is not CSS: {component} {index} contains both X and Z support")] + NonCssOperator { + /// Collection containing the mixed operator. + component: &'static str, + /// Index within that collection. + index: usize, + }, + /// An operator addresses a qubit outside the spec width. + #[error( + "stabilizer code spec {component} {index} addresses qubit {qubit}, but the code has {num_qubits} qubits" + )] + QubitOutOfRange { + /// Collection containing the invalid operator. + component: &'static str, + /// Index within that collection. + index: usize, + /// Invalid qubit index. + qubit: usize, + /// Declared number of qubits. + num_qubits: usize, + }, + /// A named logical collection has the wrong Pauli type for a CSS-form spec. + #[error("stabilizer code spec is not CSS: {component} {index} has the wrong Pauli type")] + WrongCssLogicalType { + /// Logical collection with the wrong type. + component: &'static str, + /// Index within that collection. + index: usize, + }, +} + +/// A solver's answer for one bounded SAT decision problem. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SolverAnswer { + /// A satisfying assignment for the original problem variables (not DIMACS auxiliaries). + Sat(Vec), + /// The bounded problem is unsatisfiable. + Unsat, + /// The solver could not decide the bounded problem. + Unknown, +} + +/// A natively verified SAT witness plus the trusted UNSAT prefix establishing exactness. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CertifiedDistance { + /// Weight of the natively verified witness. + pub distance: usize, + /// Assignment of the original problem variables. + pub witness: Vec, + /// Always true: the returned SAT half was checked natively. + pub sat_certified: bool, + /// All smaller queried bounds were reported UNSAT by the solver. + pub unsat_trusted_below: usize, +} + +/// Reasons a proposed SAT witness fails native verification. +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum WitnessError { + /// The assignment does not contain exactly one bit per problem column. + #[error("witness length is {actual}, expected {expected}")] + LengthMismatch { + /// Required assignment length. + expected: usize, + /// Supplied assignment length. + actual: usize, + }, + /// A check row has odd overlap with the assignment. + #[error("witness violates H row {row}: overlap is odd")] + OddCheck { + /// Index of the failed check row. + row: usize, + }, + /// Every logical/observable row has even overlap with the assignment. + #[error("witness has zero logical effect: L e = 0")] + ZeroLogicalEffect, +} + +/// Errors in the incremental distance-certification loop. +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum DistanceCertificationError { + /// The solver returned an assignment rejected by native verification. + #[error("solver returned invalid witness at weight {weight}: {reason}")] + InvalidWitness { + /// Bound at which the solver returned SAT. + weight: usize, + /// Native verification failure. + #[source] + reason: WitnessError, + }, + /// The assignment is valid but exceeds the bound used for that SAT call. + #[error( + "solver returned invalid witness at weight {weight}: verified weight {actual} exceeds the bound" + )] + WitnessExceedsBound { + /// Bound at which the solver returned SAT. + weight: usize, + /// Native weight of the assignment. + actual: usize, + }, + /// The solver stopped without deciding a bound. + #[error("solver returned unknown at weight {weight}")] + Unknown { + /// First undecided weight. + weight: usize, + }, +} + +#[derive(Clone, Debug)] +struct ClauseGroup { + description: &'static str, + clauses: Vec>, +} + +#[derive(Clone, Debug)] +struct Encoding { + num_vars: usize, + aux_ranges: Vec<(usize, usize, &'static str)>, + groups: Vec, +} + +#[derive(Debug)] +struct EncodingBuilder { + next_var: usize, + aux_ranges: Vec<(usize, usize, &'static str)>, + parity_clauses: Vec>, + nontriviality_clauses: Vec>, + cardinality_clauses: Vec>, +} + +impl EncodingBuilder { + fn new(num_primary_vars: usize) -> Self { + Self { + next_var: num_primary_vars + 1, + aux_ranges: Vec::new(), + parity_clauses: Vec::new(), + nontriviality_clauses: Vec::new(), + cardinality_clauses: Vec::new(), + } + } + + fn allocate_range(&mut self, count: usize, role: &'static str) -> Vec { + if count == 0 { + return Vec::new(); + } + let start = self.next_var; + let end = start + count - 1; + self.next_var = end + 1; + self.aux_ranges.push((start, end, role)); + (start..=end).collect() + } + + fn finish(self, include_cardinality: bool) -> Encoding { + let mut groups = vec![ClauseGroup { + description: "parity constraints (Tseitin XOR chains)", + clauses: self.parity_clauses, + }]; + groups.push(ClauseGroup { + description: "logical nontriviality", + clauses: self.nontriviality_clauses, + }); + if include_cardinality { + groups.push(ClauseGroup { + description: "weight bound (Sinz sequential counter)", + clauses: self.cardinality_clauses, + }); + } + Encoding { + num_vars: self.next_var - 1, + aux_ranges: self.aux_ranges, + groups, + } + } +} + +impl DistanceProblem { + /// Constructs a problem from check and logical matrices with matching widths. + /// + /// # Errors + /// + /// Returns [`DistanceProblemError::MatrixWidthMismatch`] when the matrices have different + /// numbers of columns. + pub fn from_css_checks( + h: &ParityCheckMatrix, + l: &ParityCheckMatrix, + ) -> Result { + if h.num_qubits() != l.num_qubits() { + return Err(DistanceProblemError::MatrixWidthMismatch { + h_width: h.num_qubits(), + l_width: l.num_qubits(), + }); + } + Ok(Self { + h: h.matrix().clone(), + l: l.matrix().clone(), + num_vars: h.num_qubits(), + }) + } + + /// Constructs the pure-X distance problem for a CSS-form stabilizer code spec. + /// + /// Z stabilizers form `H` and logical Z operators form `L`, since those are the operators + /// whose overlap detects a pure-X support vector. + /// + /// # Errors + /// + /// Returns an error instead of projecting a non-CSS or incorrectly typed spec. + pub fn from_css_code_x_distance( + code: &StabilizerCodeSpec, + ) -> Result { + Self::from_css_code(code, false) + } + + /// Constructs the pure-Z distance problem for a CSS-form stabilizer code spec. + /// + /// X stabilizers form `H` and logical X operators form `L`, since those are the operators + /// whose overlap detects a pure-Z support vector. + /// + /// # Errors + /// + /// Returns an error instead of projecting a non-CSS or incorrectly typed spec. + pub fn from_css_code_z_distance( + code: &StabilizerCodeSpec, + ) -> Result { + Self::from_css_code(code, true) + } + + fn from_css_code( + code: &StabilizerCodeSpec, + use_x_operators: bool, + ) -> Result { + let num_qubits = code.num_qubits(); + let mut x_checks = Vec::new(); + let mut z_checks = Vec::new(); + for (index, operator) in code.stabilizers().iter().enumerate() { + let x = operator.x_positions(); + let z = operator.z_positions(); + Self::validate_positions(&x, "stabilizer", index, num_qubits)?; + Self::validate_positions(&z, "stabilizer", index, num_qubits)?; + if !x.is_empty() && !z.is_empty() { + return Err(DistanceProblemError::NonCssOperator { + component: "stabilizer", + index, + }); + } + if !x.is_empty() { + x_checks.push(Self::support_row(num_qubits, &x)); + } else if !z.is_empty() { + z_checks.push(Self::support_row(num_qubits, &z)); + } + } + + let logical_x = Self::css_logical_rows(code.logical_xs(), "logical X", true, num_qubits)?; + let logical_z = Self::css_logical_rows(code.logical_zs(), "logical Z", false, num_qubits)?; + let (checks, logicals) = if use_x_operators { + (x_checks, logical_x) + } else { + (z_checks, logical_z) + }; + Ok(Self { + h: Self::matrix_from_rows(checks, num_qubits), + l: Self::matrix_from_rows(logicals, num_qubits), + num_vars: num_qubits, + }) + } + + fn css_logical_rows( + operators: &[pecos_core::PauliString], + component: &'static str, + expect_x: bool, + num_qubits: usize, + ) -> Result>, DistanceProblemError> { + operators + .iter() + .enumerate() + .map(|(index, operator)| { + let x = operator.x_positions(); + let z = operator.z_positions(); + Self::validate_positions(&x, component, index, num_qubits)?; + Self::validate_positions(&z, component, index, num_qubits)?; + if !x.is_empty() && !z.is_empty() { + return Err(DistanceProblemError::NonCssOperator { component, index }); + } + let wrong_type = if expect_x { + !z.is_empty() + } else { + !x.is_empty() + }; + if wrong_type { + return Err(DistanceProblemError::WrongCssLogicalType { component, index }); + } + Ok(Self::support_row( + num_qubits, + if expect_x { &x } else { &z }, + )) + }) + .collect() + } + + fn validate_positions( + positions: &[usize], + component: &'static str, + index: usize, + num_qubits: usize, + ) -> Result<(), DistanceProblemError> { + if let Some(&qubit) = positions.iter().find(|&&qubit| qubit >= num_qubits) { + return Err(DistanceProblemError::QubitOutOfRange { + component, + index, + qubit, + num_qubits, + }); + } + Ok(()) + } + + fn support_row(num_qubits: usize, support: &[usize]) -> Vec { + let mut row = vec![0; num_qubits]; + for &column in support { + row[column] = 1; + } + row + } + + fn matrix_from_rows(rows: Vec>, num_cols: usize) -> F2Matrix { + if rows.is_empty() { + F2Matrix::zeros(0, num_cols) + } else { + F2Matrix::from_rows(rows) + } + } + + /// Constructs a fault-distance problem from a detector error model. + /// + /// Columns follow [`DetectorErrorModel::to_mechanisms`] order. Detector incidence forms `H` + /// and observable incidence forms `L`; mechanism probabilities are deliberately ignored. + #[must_use] + pub fn from_dem(dem: &DetectorErrorModel) -> Self { + let (mechanisms, _coordinates) = dem.to_mechanisms(); + let num_vars = mechanisms.len(); + let detector_rows = mechanisms + .iter() + .flat_map(|(_, detectors, _)| detectors) + .map(|&id| id as usize + 1) + .max() + .unwrap_or(0) + .max(dem.num_detectors()); + let logical_rows = mechanisms + .iter() + .flat_map(|(_, _, observables)| observables) + .map(|&id| id as usize + 1) + .max() + .unwrap_or(0) + .max(dem.num_observables()); + let mut h = F2Matrix::zeros(detector_rows, num_vars); + let mut l = F2Matrix::zeros(logical_rows, num_vars); + for (column, (_, detectors, observables)) in mechanisms.iter().enumerate() { + for &detector in detectors { + h.set(detector as usize, column, 1); + } + for &observable in observables { + l.set(observable as usize, column, 1); + } + } + Self { h, l, num_vars } + } + + /// Returns the number of original decision variables. + #[must_use] + pub fn num_vars(&self) -> usize { + self.num_vars + } + + /// Emits the DIMACS CNF decision problem with `|e| <= max_weight`. + /// + /// Comment lines identify primary and auxiliary ranges and separate clause groups for audit. + #[must_use] + pub fn to_dimacs(&self, max_weight: usize) -> String { + let encoding = self.encode(Some(max_weight)); + let clause_count: usize = encoding + .groups + .iter() + .map(|group| group.clauses.len()) + .sum(); + let mut output = String::new(); + writeln!(output, "c PECOS distance decision encoding") + .expect("writing to String cannot fail"); + Self::write_range_comment(&mut output, 1, self.num_vars, "primary support variables"); + for &(start, end, role) in &encoding.aux_ranges { + Self::write_range_comment(&mut output, start, end, role); + } + writeln!(output, "p cnf {} {clause_count}", encoding.num_vars) + .expect("writing to String cannot fail"); + for group in &encoding.groups { + writeln!(output, "c clause group: {}", group.description) + .expect("writing to String cannot fail"); + Self::write_clauses(&mut output, &group.clauses, None); + } + output + } + + /// Emits a new-format WCNF `MaxSAT` problem minimizing `|e|`. + /// + /// Hard clauses enforce parity and nontriviality. There is one weight-1 soft clause `not x_i` + /// per original variable. The output uses the modern `h` hard-clause prefix rather than a + /// synthetic top weight, keeping hard/soft intent explicit and avoiding top-weight overflow. + #[must_use] + pub fn to_wcnf(&self) -> String { + let encoding = self.encode(None); + let hard_count: usize = encoding + .groups + .iter() + .map(|group| group.clauses.len()) + .sum(); + let clause_count = hard_count + self.num_vars; + let mut output = String::new(); + writeln!( + output, + "c new-format WCNF: hard clauses use the h prefix (no synthetic top weight)" + ) + .expect("writing to String cannot fail"); + Self::write_range_comment(&mut output, 1, self.num_vars, "primary support variables"); + for &(start, end, role) in &encoding.aux_ranges { + Self::write_range_comment(&mut output, start, end, role); + } + writeln!(output, "p wcnf {} {clause_count}", encoding.num_vars) + .expect("writing to String cannot fail"); + for group in &encoding.groups { + writeln!(output, "c hard clause group: {}", group.description) + .expect("writing to String cannot fail"); + Self::write_clauses(&mut output, &group.clauses, Some("h")); + } + writeln!( + output, + "c soft clause group: unit penalties for selected variables" + ) + .expect("writing to String cannot fail"); + for variable in 1..=self.num_vars { + writeln!(output, "1 -{variable} 0").expect("writing to String cannot fail"); + } + output + } + + fn write_range_comment(output: &mut String, start: usize, end: usize, role: &str) { + if start <= end { + writeln!(output, "c variables {start}..{end}: {role}") + .expect("writing to String cannot fail"); + } else { + writeln!(output, "c variables none: {role}").expect("writing to String cannot fail"); + } + } + + fn write_clauses(output: &mut String, clauses: &[Vec], prefix: Option<&str>) { + for clause in clauses { + if let Some(prefix) = prefix { + write!(output, "{prefix} ").expect("writing to String cannot fail"); + } + for literal in clause { + write!(output, "{literal} ").expect("writing to String cannot fail"); + } + writeln!(output, "0").expect("writing to String cannot fail"); + } + } + + fn encode(&self, max_weight: Option) -> Encoding { + let mut builder = EncodingBuilder::new(self.num_vars); + self.encode_checks(&mut builder); + self.encode_logical_nontriviality(&mut builder); + if let Some(max_weight) = max_weight { + self.encode_sequential_counter(&mut builder, max_weight); + } + builder.finish(max_weight.is_some()) + } + + fn row_support(matrix: &F2Matrix, row: usize) -> Vec { + (0..matrix.num_cols()) + .filter(|&column| matrix.get(row, column) == 1) + .map(|column| column + 1) + .collect() + } + + fn literal(variable: usize) -> i32 { + i32::try_from(variable).expect("DIMACS variable count exceeds i32::MAX") + } + + fn encode_checks(&self, builder: &mut EncodingBuilder) { + let aux_count: usize = (0..self.h.num_rows()) + .map(|row| Self::row_support(&self.h, row).len().saturating_sub(1)) + .sum(); + let auxiliaries = builder.allocate_range(aux_count, "H-row XOR-chain auxiliaries"); + let mut aux_iter = auxiliaries.into_iter(); + for row in 0..self.h.num_rows() { + let support = Self::row_support(&self.h, row); + match support.as_slice() { + [] => {} + &[variable] => builder.parity_clauses.push(vec![-Self::literal(variable)]), + &[first, second, ref rest @ ..] => { + let mut output = aux_iter.next().expect("pre-counted XOR auxiliary"); + Self::push_xor(&mut builder.parity_clauses, first, second, output); + for &variable in rest { + let next = aux_iter.next().expect("pre-counted XOR auxiliary"); + Self::push_xor(&mut builder.parity_clauses, output, variable, next); + output = next; + } + builder.parity_clauses.push(vec![-Self::literal(output)]); + } + } + } + } + + fn encode_logical_nontriviality(&self, builder: &mut EncodingBuilder) { + let outputs = builder.allocate_range(self.l.num_rows(), "logical XOR outputs y_j"); + let intermediate_count: usize = (0..self.l.num_rows()) + .map(|row| Self::row_support(&self.l, row).len().saturating_sub(2)) + .sum(); + let intermediates = + builder.allocate_range(intermediate_count, "logical XOR-chain intermediates"); + let mut intermediate_iter = intermediates.into_iter(); + for (row, &output) in outputs.iter().enumerate() { + let support = Self::row_support(&self.l, row); + match support.as_slice() { + [] => builder.parity_clauses.push(vec![-Self::literal(output)]), + &[variable] => { + builder + .parity_clauses + .push(vec![-Self::literal(variable), Self::literal(output)]); + builder + .parity_clauses + .push(vec![Self::literal(variable), -Self::literal(output)]); + } + &[first, second] => { + Self::push_xor(&mut builder.parity_clauses, first, second, output); + } + &[first, second, ref rest @ ..] => { + let mut previous = intermediate_iter + .next() + .expect("pre-counted logical XOR auxiliary"); + Self::push_xor(&mut builder.parity_clauses, first, second, previous); + for (offset, &variable) in rest.iter().enumerate() { + let next = if offset + 1 == rest.len() { + output + } else { + intermediate_iter + .next() + .expect("pre-counted logical XOR auxiliary") + }; + Self::push_xor(&mut builder.parity_clauses, previous, variable, next); + previous = next; + } + } + } + } + builder + .nontriviality_clauses + .push(outputs.into_iter().map(Self::literal).collect()); + } + + fn push_xor(clauses: &mut Vec>, left: usize, right: usize, output: usize) { + let left = Self::literal(left); + let right = Self::literal(right); + let output = Self::literal(output); + clauses.push(vec![left, right, -output]); + clauses.push(vec![-left, -right, -output]); + clauses.push(vec![left, -right, output]); + clauses.push(vec![-left, right, output]); + } + + fn encode_sequential_counter(&self, builder: &mut EncodingBuilder, max_weight: usize) { + if max_weight >= self.num_vars { + return; + } + if max_weight == 0 { + for variable in 1..=self.num_vars { + builder + .cardinality_clauses + .push(vec![-Self::literal(variable)]); + } + return; + } + + // s[i, j] means that at least j of x_1..=x_i are true. These are the sequential unary + // counter variables of Sinz (2005). Both directions of each recurrence are emitted, making + // every auxiliary functionally determined by the primary variables. + let threshold = max_weight + 1; + let variables = builder.allocate_range( + self.num_vars * threshold, + "Sinz sequential-counter prefix thresholds", + ); + let counter = |i: usize, j: usize| variables[(i - 1) * threshold + (j - 1)]; + + // First prefix: s[1,1] <-> x_1; all unreachable higher thresholds are false. + let first = counter(1, 1); + builder + .cardinality_clauses + .push(vec![-1, Self::literal(first)]); + builder + .cardinality_clauses + .push(vec![1, -Self::literal(first)]); + for j in 2..=threshold { + builder + .cardinality_clauses + .push(vec![-Self::literal(counter(1, j))]); + } + + for i in 2..=self.num_vars { + let x = Self::literal(i); + // s[i,1] <-> (s[i-1,1] OR x_i). + let previous = Self::literal(counter(i - 1, 1)); + let current = Self::literal(counter(i, 1)); + builder.cardinality_clauses.push(vec![-previous, current]); + builder.cardinality_clauses.push(vec![-x, current]); + builder + .cardinality_clauses + .push(vec![-current, previous, x]); + + for j in 2..=threshold { + // s[i,j] <-> (s[i-1,j] OR (x_i AND s[i-1,j-1])). + let same = Self::literal(counter(i - 1, j)); + let lower = Self::literal(counter(i - 1, j - 1)); + let current = Self::literal(counter(i, j)); + builder.cardinality_clauses.push(vec![-same, current]); + builder.cardinality_clauses.push(vec![-x, -lower, current]); + builder.cardinality_clauses.push(vec![-current, same, x]); + builder + .cardinality_clauses + .push(vec![-current, same, lower]); + } + } + builder + .cardinality_clauses + .push(vec![-Self::literal(counter(self.num_vars, threshold))]); + } + + /// Checks a candidate witness with native GF(2) arithmetic and returns its Hamming weight. + /// + /// This check does not trust the SAT solver: it independently verifies every `H` row and the + /// complete `L e != 0` predicate. + /// + /// # Errors + /// + /// Returns a length error, identifies the first odd `H` row, or reports that `L e` is zero. + pub fn verify_witness(&self, assignment: &[bool]) -> Result { + if assignment.len() != self.num_vars { + return Err(WitnessError::LengthMismatch { + expected: self.num_vars, + actual: assignment.len(), + }); + } + for row in 0..self.h.num_rows() { + let odd = assignment + .iter() + .enumerate() + .filter(|&(column, selected)| *selected && self.h.get(row, column) == 1) + .count() + % 2 + == 1; + if odd { + return Err(WitnessError::OddCheck { row }); + } + } + let logical_nonzero = (0..self.l.num_rows()).any(|row| { + assignment + .iter() + .enumerate() + .filter(|&(column, selected)| *selected && self.l.get(row, column) == 1) + .count() + % 2 + == 1 + }); + if !logical_nonzero { + return Err(WitnessError::ZeroLogicalEffect); + } + Ok(assignment.iter().filter(|&&selected| selected).count()) + } + + /// Incrementally certifies distance through `max_weight` using a pluggable SAT solver. + /// + /// The solver is called once per bound from 1 upward. Its SAT assignment must contain only + /// the original variables and is checked natively, so SAT soundness does not rely on the + /// solver. Every preceding UNSAT result is trusted; that trust is what turns the verified upper + /// bound into an exact distance. `Ok(None)` means all bounds through `max_weight` were reported + /// UNSAT, establishing only a solver-trusted lower bound greater than `max_weight`. + /// + /// # Errors + /// + /// Returns immediately for an invalid or overweight SAT witness or an `Unknown` answer. + pub fn certify_distance_with( + &self, + max_weight: usize, + mut solver: S, + ) -> Result, DistanceCertificationError> + where + S: FnMut(&str, usize) -> SolverAnswer, + { + for weight in 1..=max_weight { + let dimacs = self.to_dimacs(weight); + match solver(&dimacs, weight) { + SolverAnswer::Unsat => {} + SolverAnswer::Unknown => { + return Err(DistanceCertificationError::Unknown { weight }); + } + SolverAnswer::Sat(witness) => { + let actual = self.verify_witness(&witness).map_err(|reason| { + DistanceCertificationError::InvalidWitness { weight, reason } + })?; + if actual > weight { + return Err(DistanceCertificationError::WitnessExceedsBound { + weight, + actual, + }); + } + return Ok(Some(CertifiedDistance { + distance: actual, + witness, + sat_certified: true, + unsat_trusted_below: weight, + })); + } + } + } + Ok(None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + DistanceSearchConfig, FaultMechanism, StabilizerCode, calculate_distance, + connected_cluster_fault_distance, exhaustive_fault_distance, + }; + use pecos_core::pauli::{Xs, Zs}; + + #[derive(Debug)] + struct ParsedCnf { + num_vars: usize, + clauses: Vec>, + } + + fn parse_dimacs(text: &str) -> ParsedCnf { + let mut num_vars = None; + let mut declared_clauses = None; + let mut clauses = Vec::new(); + for line in text.lines() { + let fields: Vec<_> = line.split_whitespace().collect(); + match fields.as_slice() { + [] | ["c", ..] => {} + ["p", "cnf", variables, count] => { + num_vars = Some(variables.parse().unwrap()); + declared_clauses = Some(count.parse().unwrap()); + } + _ => { + let mut clause: Vec = + fields.iter().map(|field| field.parse().unwrap()).collect(); + assert_eq!(clause.pop(), Some(0)); + assert!(!clause.contains(&0)); + clauses.push(clause); + } + } + } + assert_eq!(declared_clauses, Some(clauses.len())); + ParsedCnf { + num_vars: num_vars.unwrap(), + clauses, + } + } + + fn cnf_satisfied_with_primary(cnf: &ParsedCnf, primary: &[bool]) -> bool { + assert!(primary.len() <= cnf.num_vars); + let mut values = vec![None; cnf.num_vars + 1]; + for (index, &value) in primary.iter().enumerate() { + values[index + 1] = Some(value); + } + + // Every emitted auxiliary is the output of an equivalence encoding. Unit propagation + // therefore computes it from the fixed primary assignment without searching. + loop { + let mut changed = false; + for clause in &cnf.clauses { + let mut unresolved = None; + let mut unresolved_count = 0; + let mut satisfied = false; + for &literal in clause { + let variable = literal.unsigned_abs() as usize; + if let Some(value) = values[variable] { + if value == (literal > 0) { + satisfied = true; + break; + } + } else { + unresolved = Some(literal); + unresolved_count += 1; + } + } + if satisfied { + continue; + } + if unresolved_count == 0 { + return false; + } + if unresolved_count == 1 { + let literal = unresolved.unwrap(); + let variable = literal.unsigned_abs() as usize; + let required = literal > 0; + match values[variable] { + Some(value) if value != required => return false, + Some(_) => {} + None => { + values[variable] = Some(required); + changed = true; + } + } + } + } + if !changed { + break; + } + } + + assert!( + values[1..].iter().all(Option::is_some), + "encoding left an auxiliary variable underdetermined" + ); + cnf.clauses.iter().all(|clause| { + clause + .iter() + .any(|&literal| values[literal.unsigned_abs() as usize].unwrap() == (literal > 0)) + }) + } + + fn assignment(mask: usize, num_vars: usize) -> Vec { + (0..num_vars) + .map(|column| mask & (1 << column) != 0) + .collect() + } + + fn exhaustive_minimum(problem: &DistanceProblem) -> Option { + (0..1 << problem.num_vars()) + .filter_map(|mask| { + problem + .verify_witness(&assignment(mask, problem.num_vars())) + .ok() + }) + .min() + } + + fn exhaustive_dimacs_minimum(problem: &DistanceProblem) -> Option { + (0..=problem.num_vars()).find(|&bound| { + let cnf = parse_dimacs(&problem.to_dimacs(bound)); + (0..1 << problem.num_vars()) + .any(|mask| cnf_satisfied_with_primary(&cnf, &assignment(mask, problem.num_vars()))) + }) + } + + fn exhaustive_dimacs_answer(problem: &DistanceProblem, dimacs: &str) -> SolverAnswer { + let cnf = parse_dimacs(dimacs); + (0..1 << problem.num_vars()) + .map(|mask| assignment(mask, problem.num_vars())) + .find(|candidate| cnf_satisfied_with_primary(&cnf, candidate)) + .map_or(SolverAnswer::Unsat, SolverAnswer::Sat) + } + + fn repetition_triad_dem() -> DetectorErrorModel { + let mut dem = DetectorErrorModel::new(); + for (detectors, observables) in + [(vec![0, 1], vec![0]), (vec![0], vec![]), (vec![1], vec![])] + { + dem.add_direct_contribution( + FaultMechanism::from_unsorted(detectors, observables), + 0.01, + ); + } + dem + } + + fn steane_hamming_matrix() -> ParityCheckMatrix { + ParityCheckMatrix::from_dense(vec![ + vec![1, 0, 1, 0, 1, 0, 1], + vec![0, 1, 1, 0, 0, 1, 1], + vec![0, 0, 0, 1, 1, 1, 1], + ]) + .unwrap() + } + + fn steane_distance_problem() -> DistanceProblem { + let h = steane_hamming_matrix(); + let logical = ParityCheckMatrix::from_dense(vec![vec![1; 7]]).unwrap(); + DistanceProblem::from_css_checks(&h, &logical).unwrap() + } + + #[test] + fn dimacs_encoding_matches_native_predicate_for_every_small_assignment() { + let cases = [ + DistanceProblem::from_css_checks( + &ParityCheckMatrix::from_dense(vec![vec![1, 1, 0, 0]]).unwrap(), + &ParityCheckMatrix::from_dense(vec![vec![0, 1, 1, 0]]).unwrap(), + ) + .unwrap(), + DistanceProblem::from_css_checks( + &ParityCheckMatrix::from_dense(vec![vec![1, 1, 1, 1, 0]]).unwrap(), + &ParityCheckMatrix::from_dense(vec![vec![1, 0, 1, 0, 1], vec![0, 1, 0, 1, 0]]) + .unwrap(), + ) + .unwrap(), + DistanceProblem::from_css_checks( + &ParityCheckMatrix::zeros(0, 6), + &ParityCheckMatrix::from_dense(vec![vec![0, 0, 0, 0, 0, 0]]).unwrap(), + ) + .unwrap(), + ]; + + for problem in &cases { + assert!(problem.num_vars() <= 12); + for bound in 0..=problem.num_vars() { + let cnf = parse_dimacs(&problem.to_dimacs(bound)); + for mask in 0..1 << problem.num_vars() { + let candidate = assignment(mask, problem.num_vars()); + let direct = problem + .verify_witness(&candidate) + .is_ok_and(|weight| weight <= bound); + assert_eq!( + cnf_satisfied_with_primary(&cnf, &candidate), + direct, + "assignment {mask:#b} at bound {bound}" + ); + } + } + } + } + + #[test] + fn steane_hamming_problem_matches_existing_distance_search() { + let h = steane_hamming_matrix(); + let spec = StabilizerCodeSpec::builder(7) + .checks_from_css(&h, &h) + .unwrap() + .logical_x(Xs([0, 1, 2, 3, 4, 5, 6])) + .logical_z(Zs([0, 1, 2, 3, 4, 5, 6])) + .build_verified() + .unwrap(); + let oracle = calculate_distance(&spec, &DistanceSearchConfig::css()).unwrap(); + let problem = steane_distance_problem(); + + assert_eq!(oracle.distance, 3); + assert_eq!(exhaustive_minimum(&problem), Some(oracle.distance)); + assert_eq!(exhaustive_dimacs_minimum(&problem), Some(oracle.distance)); + assert_eq!( + exhaustive_minimum(&DistanceProblem::from_css_code_x_distance(&spec).unwrap()), + Some(oracle.distance) + ); + assert_eq!( + exhaustive_minimum(&DistanceProblem::from_css_code_z_distance(&spec).unwrap()), + Some(oracle.distance) + ); + } + + #[test] + fn non_css_spec_is_rejected_without_projection() { + let spec = StabilizerCodeSpec::from_stabilizer_code(&StabilizerCode::five_qubit()).unwrap(); + assert!(matches!( + DistanceProblem::from_css_code_x_distance(&spec), + Err(DistanceProblemError::NonCssOperator { .. }) + )); + assert!(matches!( + DistanceProblem::from_css_code_z_distance(&spec), + Err(DistanceProblemError::NonCssOperator { .. }) + )); + } + + #[test] + fn dem_problem_matches_both_existing_fault_distance_searches() { + let dem = repetition_triad_dem(); + let problem = DistanceProblem::from_dem(&dem); + assert_eq!(exhaustive_minimum(&problem), Some(3)); + assert_eq!(exhaustive_dimacs_minimum(&problem), Some(3)); + assert_eq!(exhaustive_fault_distance(&dem, 3).unwrap().distance, 3); + assert_eq!( + connected_cluster_fault_distance(&dem, 3).unwrap().distance, + 3 + ); + } + + #[test] + fn sequential_counter_is_exact_on_both_sides_of_distance() { + let problem = DistanceProblem::from_dem(&repetition_triad_dem()); + for (bound, expected) in [(2, false), (3, true)] { + let cnf = parse_dimacs(&problem.to_dimacs(bound)); + let any_satisfying = (0..1 << problem.num_vars()).any(|mask| { + cnf_satisfied_with_primary(&cnf, &assignment(mask, problem.num_vars())) + }); + assert_eq!(any_satisfying, expected, "bound {bound}"); + } + } + + #[test] + fn certification_rejects_witness_violating_a_check() { + let problem = DistanceProblem::from_dem(&repetition_triad_dem()); + let result = + problem.certify_distance_with(3, |_, _| SolverAnswer::Sat(vec![true, false, false])); + assert_eq!( + result, + Err(DistanceCertificationError::InvalidWitness { + weight: 1, + reason: WitnessError::OddCheck { row: 0 }, + }) + ); + } + + #[test] + fn certification_rejects_valid_witness_above_solver_bound() { + let problem = DistanceProblem::from_css_checks( + &ParityCheckMatrix::zeros(0, 2), + &ParityCheckMatrix::from_dense(vec![vec![1, 0]]).unwrap(), + ) + .unwrap(); + let result = problem.certify_distance_with(1, |_, _| SolverAnswer::Sat(vec![true, true])); + assert_eq!( + result, + Err(DistanceCertificationError::WitnessExceedsBound { + weight: 1, + actual: 2, + }) + ); + } + + #[test] + fn certification_returns_none_after_trusted_unsat_bound() { + let problem = steane_distance_problem(); + let mut weights = Vec::new(); + let result = problem + .certify_distance_with(4, |_, weight| { + weights.push(weight); + SolverAnswer::Unsat + }) + .unwrap(); + assert_eq!(result, None); + assert_eq!(weights, vec![1, 2, 3, 4]); + } + + #[test] + fn honest_mock_certifies_steane_and_repetition_triad() { + for (problem, expected) in [ + (steane_distance_problem(), 3), + (DistanceProblem::from_dem(&repetition_triad_dem()), 3), + ] { + let certified = problem + .certify_distance_with(expected, |dimacs, _| { + exhaustive_dimacs_answer(&problem, dimacs) + }) + .unwrap() + .unwrap(); + assert_eq!(certified.distance, expected); + assert_eq!(certified.unsat_trusted_below, expected); + assert!(certified.sat_certified); + assert_eq!(problem.verify_witness(&certified.witness), Ok(expected)); + } + } + + #[test] + fn unknown_answer_names_the_reached_weight() { + let problem = steane_distance_problem(); + let result = problem.certify_distance_with(3, |_, weight| { + if weight < 2 { + SolverAnswer::Unsat + } else { + SolverAnswer::Unknown + } + }); + assert_eq!( + result, + Err(DistanceCertificationError::Unknown { weight: 2 }) + ); + } + + #[test] + fn wcnf_has_exact_hard_encoding_and_one_soft_unit_per_primary() { + let problem = steane_distance_problem(); + let text = problem.to_wcnf(); + let mut header = None; + let mut hard = Vec::new(); + let mut soft = Vec::new(); + for line in text.lines() { + let fields: Vec<_> = line.split_whitespace().collect(); + match fields.as_slice() { + [] | ["c", ..] => {} + ["p", "wcnf", variables, clauses] => { + header = Some(( + variables.parse::().unwrap(), + clauses.parse::().unwrap(), + )); + } + ["h", literals @ .., "0"] => hard.push( + literals + .iter() + .map(|literal| literal.parse::().unwrap()) + .collect::>(), + ), + ["1", literal, "0"] => soft.push(literal.parse::().unwrap()), + _ => panic!("unrecognized WCNF line: {line}"), + } + } + + let encoding = problem.encode(None); + let expected_hard: Vec<_> = encoding + .groups + .into_iter() + .flat_map(|group| group.clauses) + .collect(); + assert_eq!(hard, expected_hard); + assert_eq!( + soft, + (1..=problem.num_vars()) + .map(|x| -i32::try_from(x).unwrap()) + .collect::>() + ); + assert_eq!(header, Some((encoding.num_vars, hard.len() + soft.len()))); + } + + #[test] + fn matrix_width_mismatch_and_witness_length_are_explicit() { + assert_eq!( + DistanceProblem::from_css_checks( + &ParityCheckMatrix::zeros(0, 2), + &ParityCheckMatrix::zeros(0, 3), + ), + Err(DistanceProblemError::MatrixWidthMismatch { + h_width: 2, + l_width: 3, + }) + ); + assert_eq!( + steane_distance_problem().verify_witness(&[false; 6]), + Err(WitnessError::LengthMismatch { + expected: 7, + actual: 6, + }) + ); + } +} diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index 2f4fe5643..d0104659e 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -65,6 +65,7 @@ pub mod dem_stab; pub mod distance; +pub mod distance_problem; pub mod fault_tolerance; pub mod geometry; pub mod logical_discovery; @@ -83,6 +84,10 @@ pub use distance::{ calculate_distance, find_min_weight_logicals, find_min_weight_logicals_with_info, find_shortest_logicals, has_logical_error_at_weight, }; +pub use distance_problem::{ + CertifiedDistance, DistanceCertificationError, DistanceProblem, DistanceProblemError, + SolverAnswer, WitnessError, +}; pub use fault_tolerance::dem_builder::{ DecomposedFault, DemBuilder, DemBuilderError, DemOutput, DetectorDef, DetectorErrorModel, FaultMechanism, NoiseConfig, PecosDemMetadataError, combine_probabilities, From d7f848cbc79a8f4931fb86e9bb1b69bf38ddb9a3 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 12:52:43 -0600 Subject: [PATCH 14/41] Certify exact distance in-process with a batsat backend --- Cargo.lock | 41 +++-- Cargo.toml | 1 + crates/pecos-qec/Cargo.toml | 1 + crates/pecos-qec/src/distance_problem.rs | 203 ++++++++++++++++++++++- crates/pecos-qec/src/lib.rs | 2 +- 5 files changed, 232 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index caf09cea5..dd9ee85d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -441,6 +441,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "batsat" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c46275d5d413790e96e7a74bab90591ba0aad8117662fadee8ba266685e5908a" +dependencies = [ + "bit-vec 0.5.1", +] + [[package]] name = "beef" version = "0.5.2" @@ -531,6 +540,12 @@ dependencies = [ "bit-vec 0.9.1", ] +[[package]] +name = "bit-vec" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f59bbe95d4e52a6398ec21238d31577f2b28a9d86807f06ca59d191d8440d0bb" + [[package]] name = "bit-vec" version = "0.6.3" @@ -950,7 +965,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", - "unicode-width 0.1.14", + "unicode-width 0.2.2", ] [[package]] @@ -1890,7 +1905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2373,7 +2388,6 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.1.5", - "rayon", ] [[package]] @@ -2393,7 +2407,10 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", + "rayon", "serde", "serde_core", ] @@ -2823,7 +2840,6 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", - "rayon", "serde", ] @@ -2954,7 +2970,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi 0.5.2", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4633,6 +4649,7 @@ dependencies = [ name = "pecos-qec" version = "0.2.0-dev.0" dependencies = [ + "batsat", "ndarray 0.17.2", "pecos-core", "pecos-decoder-core", @@ -6149,7 +6166,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6205,7 +6222,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6296,8 +6313,8 @@ checksum = "4b213df2cbfa5f580ed2c0ac3619eda06692a4ff0211f0aebbb4008eaa9724a6" dependencies = [ "fixedbitset 0.5.7", "foldhash 0.1.5", - "hashbrown 0.15.5", - "indexmap 1.9.3", + "hashbrown 0.17.1", + "indexmap 2.14.0", "ndarray 0.17.2", "num-traits", "petgraph 0.8.3", @@ -7003,10 +7020,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8398,7 +8415,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d3078c698..061124bf9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -112,6 +112,7 @@ bitflags = "2" bitvec = { version = "1", features = ["serde"] } bytemuck = { version = "1", features = ["derive"] } dyn-clone = "1" +batsat = "0.5" smallvec = "1" # --- Concurrency --- diff --git a/crates/pecos-qec/Cargo.toml b/crates/pecos-qec/Cargo.toml index e11bfaf2c..51d9a81ef 100644 --- a/crates/pecos-qec/Cargo.toml +++ b/crates/pecos-qec/Cargo.toml @@ -24,6 +24,7 @@ pecos-neo = { workspace = true, optional = true } rand.workspace = true rand_core.workspace = true rayon.workspace = true +batsat.workspace = true serde_json.workspace = true smallvec.workspace = true thiserror.workspace = true diff --git a/crates/pecos-qec/src/distance_problem.rs b/crates/pecos-qec/src/distance_problem.rs index e6260c025..435a52965 100644 --- a/crates/pecos-qec/src/distance_problem.rs +++ b/crates/pecos-qec/src/distance_problem.rs @@ -15,7 +15,7 @@ //! The encoding choices follow the qLDPC distance study in //! [arXiv:2606.12445](https://arxiv.org/abs/2606.12445): parity constraints use Tseitin XOR //! chains, while weight bounds use a Sinz sequential counter. The module emits standard text -//! formats and deliberately has no solver dependency. +//! formats for external solvers and can also feed the internal clauses directly to batsat. //! //! Certification has an asymmetric trust boundary. A solver's SAT witness is checked here using //! native GF(2) arithmetic, so the SAT half needs no solver trust. UNSAT answers cannot be checked @@ -23,6 +23,7 @@ //! the returned distance. use crate::{DetectorErrorModel, ParityCheckMatrix, StabilizerCodeSpec}; +use batsat::{BasicSolver, Lit, SolverInterface, lbool}; use pecos_core::PauliOperator; use pecos_quantum::F2Matrix; use std::fmt::Write as _; @@ -743,10 +744,23 @@ impl DistanceProblem { ) -> Result, DistanceCertificationError> where S: FnMut(&str, usize) -> SolverAnswer, + { + self.certify_distance_by(max_weight, |problem, weight| { + let dimacs = problem.to_dimacs(weight); + solver(&dimacs, weight) + }) + } + + fn certify_distance_by( + &self, + max_weight: usize, + mut solver: S, + ) -> Result, DistanceCertificationError> + where + S: FnMut(&Self, usize) -> SolverAnswer, { for weight in 1..=max_weight { - let dimacs = self.to_dimacs(weight); - match solver(&dimacs, weight) { + match solver(self, weight) { SolverAnswer::Unsat => {} SolverAnswer::Unknown => { return Err(DistanceCertificationError::Unknown { weight }); @@ -774,6 +788,62 @@ impl DistanceProblem { } } +/// Certifies distance through `max_weight` using the in-process batsat SAT solver. +/// +/// A fresh deterministic solver instance is built for each weight from the internal clause +/// encoding. SAT answers are certified natively with [`DistanceProblem::verify_witness`] before +/// they are accepted. UNSAT answers, and therefore the exactness of a returned distance, rest on +/// trusting the solver. `Ok(None)` means batsat reported every weight through `max_weight` UNSAT. +/// +/// Incremental assumptions could avoid rebuilding the solver in a future implementation, but are +/// deliberately not used here. +/// +/// # Errors +/// +/// Returns an error if batsat produces an invalid or overweight model, or does not decide a bound. +pub fn certified_distance( + problem: &DistanceProblem, + max_weight: usize, +) -> Result, DistanceCertificationError> { + problem.certify_distance_by(max_weight, |problem, weight| { + solve_with_batsat(&problem.encode(Some(weight)), problem.num_vars) + }) +} + +fn solve_with_batsat(encoding: &Encoding, num_primary_vars: usize) -> SolverAnswer { + let mut solver = BasicSolver::default(); + let variables: Vec<_> = (0..encoding.num_vars) + .map(|_| solver.new_var_default()) + .collect(); + + for clause in encoding.groups.iter().flat_map(|group| &group.clauses) { + let mut literals: Vec<_> = clause + .iter() + .map(|&literal| { + let index = literal.unsigned_abs() as usize - 1; + Lit::new(variables[index], literal > 0) + }) + .collect(); + if !solver.add_clause_reuse(&mut literals) { + return SolverAnswer::Unsat; + } + } + + let answer = solver.solve_limited(&[]); + if answer == lbool::TRUE { + SolverAnswer::Sat( + variables[..num_primary_vars] + .iter() + .map(|&variable| solver.value_var(variable) == lbool::TRUE) + .collect(), + ) + } else if answer == lbool::FALSE { + SolverAnswer::Unsat + } else { + SolverAnswer::Unknown + } +} + #[cfg(test)] mod tests { use super::*; @@ -782,6 +852,7 @@ mod tests { connected_cluster_fault_distance, exhaustive_fault_distance, }; use pecos_core::pauli::{Xs, Zs}; + use std::time::Instant; #[derive(Debug)] struct ParsedCnf { @@ -1108,6 +1179,132 @@ mod tests { } } + #[test] + fn batsat_certifies_steane_x_and_z_against_existing_oracle() { + let mut spec = StabilizerCodeSpec::from_stabilizer_code(&StabilizerCode::steane()).unwrap(); + let oracle = spec.calculate_distance().unwrap().distance; + assert_eq!(spec.distance(), Some(oracle)); + + for problem in [ + DistanceProblem::from_css_code_x_distance(&spec).unwrap(), + DistanceProblem::from_css_code_z_distance(&spec).unwrap(), + ] { + let certified = certified_distance(&problem, oracle).unwrap().unwrap(); + assert_eq!(certified.distance, oracle); + assert_eq!(certified.unsat_trusted_below, oracle); + assert!(certified.sat_certified); + assert_eq!(problem.verify_witness(&certified.witness), Ok(oracle)); + } + } + + #[test] + fn batsat_certifies_repetition_triad_against_exhaustive_oracle() { + let dem = repetition_triad_dem(); + let oracle = exhaustive_fault_distance(&dem, 3).unwrap().distance; + let problem = DistanceProblem::from_dem(&dem); + let certified = certified_distance(&problem, oracle).unwrap().unwrap(); + + assert_eq!(certified.distance, oracle); + assert_eq!(certified.unsat_trusted_below, oracle); + assert!(certified.sat_certified); + assert_eq!(problem.verify_witness(&certified.witness), Ok(oracle)); + } + + #[test] + fn batsat_is_deterministic_and_respects_max_weight() { + let problem = steane_distance_problem(); + assert_eq!(certified_distance(&problem, 2), Ok(None)); + + let first = certified_distance(&problem, 3).unwrap().unwrap(); + let second = certified_distance(&problem, 3).unwrap().unwrap(); + assert_eq!(first.witness, second.witness); + assert_eq!(problem.verify_witness(&first.witness), Ok(3)); + assert_eq!(problem.verify_witness(&second.witness), Ok(3)); + } + + fn bb_circulant(l: usize, m: usize, terms: &[(usize, usize)]) -> F2Matrix { + let size = l * m; + let mut matrix = F2Matrix::zeros(size, size); + for row_x in 0..l { + for row_y in 0..m { + let row = row_x * m + row_y; + for &(x_power, y_power) in terms { + let column = ((row_x + x_power) % l) * m + (row_y + y_power) % m; + matrix.set(row, column, matrix.get(row, column) ^ 1); + } + } + } + matrix + } + + #[test] + #[ignore = "timing probe for the batsat backend"] + fn batsat_bivariate_bicycle_72_12_6_timing_probe() { + let (l, m) = (6, 6); + let block_size = l * m; + let n = 2 * block_size; + let a = bb_circulant(l, m, &[(3, 0), (0, 1), (0, 2)]); + let b = bb_circulant(l, m, &[(0, 3), (1, 0), (2, 0)]); + let mut hx = F2Matrix::zeros(block_size, n); + let mut hz = F2Matrix::zeros(block_size, n); + for row in 0..block_size { + for column in 0..block_size { + hx.set(row, column, a.get(row, column)); + hx.set(row, block_size + column, b.get(row, column)); + hz.set(row, column, b.get(column, row)); + hz.set(row, block_size + column, a.get(column, row)); + } + } + + assert_eq!(n, 72); + assert_eq!( + hx.mul(&hz.transpose()), + F2Matrix::zeros(block_size, block_size) + ); + let hx_rank = hx.row_reduce().1.len(); + let hz_rank = hz.row_reduce().1.len(); + assert_eq!(n - hx_rank - hz_rank, 12); + + let (hx_rref, hx_pivots) = hx.row_reduce(); + let logical_candidates = hz.kernel().into_iter().filter_map(|mut vector| { + for (row, &pivot) in hx_pivots.iter().enumerate() { + if vector[pivot] == 1 { + for (column, bit) in vector.iter_mut().enumerate() { + *bit ^= hx_rref.get(row, column); + } + } + } + vector.iter().any(|&bit| bit != 0).then_some(vector) + }); + let (logical_rref, _) = F2Matrix::from_rows(logical_candidates.collect()).row_reduce(); + let logical_rows: Vec<_> = logical_rref + .rows() + .into_iter() + .filter(|row| row.iter().any(|&bit| bit != 0)) + .collect(); + assert_eq!(logical_rows.len(), 12); + + let hx_checks = ParityCheckMatrix::from_dense(hx.rows()).unwrap(); + let logical_checks = ParityCheckMatrix::from_dense(logical_rows).unwrap(); + let problem = DistanceProblem::from_css_checks(&hx_checks, &logical_checks).unwrap(); + let total_started = Instant::now(); + let certified = problem + .certify_distance_by(6, |problem, weight| { + let started = Instant::now(); + let answer = solve_with_batsat(&problem.encode(Some(weight)), problem.num_vars); + println!( + "BB [[72,12,6]] weight {weight}: {:?} ({answer:?})", + started.elapsed() + ); + answer + }) + .unwrap() + .unwrap(); + println!("BB [[72,12,6]] total: {:?}", total_started.elapsed()); + assert_eq!(certified.distance, 6); + assert_eq!(problem.verify_witness(&certified.witness), Ok(6)); + } + #[test] fn unknown_answer_names_the_reached_weight() { let problem = steane_distance_problem(); diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index d0104659e..da7194abd 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -86,7 +86,7 @@ pub use distance::{ }; pub use distance_problem::{ CertifiedDistance, DistanceCertificationError, DistanceProblem, DistanceProblemError, - SolverAnswer, WitnessError, + SolverAnswer, WitnessError, certified_distance, }; pub use fault_tolerance::dem_builder::{ DecomposedFault, DemBuilder, DemBuilderError, DemOutput, DetectorDef, DetectorErrorModel, From 48908e3a0fdb2742d9904f649018a678ba39bc20 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 14:39:54 -0600 Subject: [PATCH 15/41] Expose fault-tolerance analysis and distance certification to Python --- python/pecos-rslib/pecos_rslib/qec.pyi | 171 +++++ .../src/fault_tolerance_bindings.rs | 703 +++++++++++++++++- .../src/stabilizer_code_spec_bindings.rs | 2 +- .../quantum-pecos/src/pecos/qec/__init__.py | 20 + ..._fault_tolerance_certification_bindings.py | 191 +++++ 5 files changed, 1085 insertions(+), 2 deletions(-) create mode 100644 python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py diff --git a/python/pecos-rslib/pecos_rslib/qec.pyi b/python/pecos-rslib/pecos_rslib/qec.pyi index 1c7d9d2ae..ebb48acf7 100644 --- a/python/pecos-rslib/pecos_rslib/qec.pyi +++ b/python/pecos-rslib/pecos_rslib/qec.pyi @@ -12,8 +12,12 @@ """Typed surface for the dynamically registered ``pecos_rslib.qec`` module.""" +from __future__ import annotations + from typing import Any +from pecos_rslib import ParityCheckMatrix, StabilizerCodeSpec, TickCircuit + class FaultDistanceResult: """A fault distance and one witnessing set of DEM mechanism indices.""" @@ -30,6 +34,173 @@ class DetectorErrorModel: def exhaustive_fault_distance(self, max_weight: int) -> FaultDistanceResult | None: ... def __getattr__(self, name: str) -> Any: ... +class CircuitFaultLocation: + """A Pauli-fault location in a tick circuit.""" + + @property + def tick(self) -> int: ... + @property + def gate_type(self) -> str: ... + @property + def qubits(self) -> list[int]: ... + @property + def gate_index(self) -> int: ... + @property + def before(self) -> bool: ... + def __repr__(self) -> str: ... + +class HookError: + """A single-location fault that amplifies across the data block.""" + + @property + def location(self) -> CircuitFaultLocation: ... + @property + def fault_paulis(self) -> list[int]: ... + @property + def data_support(self) -> list[int]: ... + @property + def data_weight(self) -> int: ... + @property + def detected(self) -> bool: ... + @property + def causes_logical_error(self) -> bool: ... + def __repr__(self) -> str: ... + +class HookErrorReport: + """Summary of hook-error diagnosis over a selected Pauli fault set.""" + + @property + def hook_errors(self) -> list[HookError]: ... + @property + def total_faults_examined(self) -> int: ... + @property + def max_data_weight(self) -> int: ... + def __repr__(self) -> str: ... + +class FlagViolation: + """A counterexample to the propagated-fault condition for a flag circuit.""" + + @property + def faults(self) -> list[tuple[CircuitFaultLocation, list[int]]]: ... + @property + def num_faults(self) -> int: ... + @property + def error_weight(self) -> int: ... + def __repr__(self) -> str: ... + +class FlagFaultToleranceReport: + """Result of checking the propagated-fault condition through fault weight ``t``.""" + + @property + def fault_condition_satisfied(self) -> bool: ... + @property + def t(self) -> int: ... + @property + def violations(self) -> list[FlagViolation]: ... + @property + def total_configurations_tested(self) -> int: ... + def __repr__(self) -> str: ... + +class CircuitDistanceResult: + """A circuit fault distance and its first iterator-ordered witness.""" + + @property + def distance(self) -> int: ... + @property + def witness(self) -> list[tuple[CircuitFaultLocation, list[int]]]: ... + @property + def logical_index(self) -> int: ... + def __repr__(self) -> str: ... + +class CircuitFaultAnalyzer: + """Fault-tolerance diagnostics and distance searches for a tick circuit.""" + + def __init__(self, circuit: TickCircuit) -> None: ... + def hook_errors( + self, + data_qubits: list[int], + z_ancillas: list[int], + x_ancillas: list[int], + logicals: list[tuple[list[int], list[int]]], + min_data_weight: int, + *, + x_only: bool = False, + y_only: bool = False, + z_only: bool = False, + ) -> HookErrorReport: ... + def flag_fault_condition( + self, + data_qubits: list[int], + flag_qubits: list[int], + measured_stabilizer: tuple[list[int], list[int]], + t: int, + *, + x_only: bool = False, + y_only: bool = False, + z_only: bool = False, + ) -> FlagFaultToleranceReport: ... + def fault_distance( + self, + z_ancillas: list[int], + x_ancillas: list[int], + logicals: list[tuple[list[int], list[int]]], + max_weight: int, + *, + x_only: bool = False, + y_only: bool = False, + z_only: bool = False, + ) -> CircuitDistanceResult | None: ... + def per_logical_fault_distances( + self, + z_ancillas: list[int], + x_ancillas: list[int], + logicals: list[tuple[list[int], list[int]]], + max_weight: int, + *, + x_only: bool = False, + y_only: bool = False, + z_only: bool = False, + ) -> list[CircuitDistanceResult | None]: ... + def __repr__(self) -> str: ... + +class CertifiedDistance: + """A natively checked SAT witness and solver-trusted UNSAT prefix.""" + + @property + def distance(self) -> int: ... + @property + def witness(self) -> list[bool]: ... + @property + def sat_certified(self) -> bool: ... + @property + def unsat_trusted_below(self) -> int: ... + def __repr__(self) -> str: ... + +class DistanceProblem: + """A binary undetectable-error problem with nonzero logical effect.""" + + @classmethod + def from_css_checks( + cls, hx: ParityCheckMatrix, lx: ParityCheckMatrix + ) -> DistanceProblem: ... + @classmethod + def from_css_code_x_distance(cls, spec: StabilizerCodeSpec) -> DistanceProblem: ... + @classmethod + def from_css_code_z_distance(cls, spec: StabilizerCodeSpec) -> DistanceProblem: ... + @classmethod + def from_dem(cls, dem: DetectorErrorModel) -> DistanceProblem: ... + @property + def num_vars(self) -> int: ... + def to_dimacs(self, max_weight: int) -> str: ... + def to_wcnf(self) -> str: ... + def verify_witness(self, witness: list[bool]) -> int: ... + def certified_distance(self, max_weight: int) -> CertifiedDistance | None: ... + def __repr__(self) -> str: ... + +def certified_distance( + problem: DistanceProblem, max_weight: int +) -> CertifiedDistance | None: ... + # The native QEC module predates this focused stub. Preserve the untyped behavior of its other # classes and functions until that complete API is migrated rather than falsely narrowing them. def __getattr__(name: str) -> Any: ... diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 6270a2f1c..a18f09a91 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -42,9 +42,11 @@ //! has_syndrome, causes_logical = influence_map.classify_fault(0, 1) # loc 0, X fault //! ``` +use crate::code_matrix_bindings::PyParityCheckMatrix; +use crate::dag_circuit_bindings::PyTickCircuit; use crate::pecos_array::{Array, ArrayData}; +use crate::stabilizer_code_spec_bindings::PyStabilizerCodeSpec; use pecos_core::gate_type::GateType; -use pecos_qec::fault_tolerance::PauliFrameLookup as RustPauliFrameLookup; use pecos_qec::fault_tolerance::dem_builder::{ ComparisonMethod as RustComparisonMethod, ContributionEffectSummary as RustContributionEffectSummary, @@ -72,6 +74,17 @@ use pecos_qec::fault_tolerance::propagator::{ DagFaultAnalyzer as RustDagFaultAnalyzer, DagFaultInfluenceMap as RustDagFaultInfluenceMap, DagSpacetimeLocation, Pauli, }; +use pecos_qec::fault_tolerance::{ + CircuitDistanceResult as RustCircuitDistanceResult, FaultCheckConfig, FaultChecker, + FaultConfiguration, FlagFaultToleranceReport as RustFlagFaultToleranceReport, + FlagViolation as RustFlagViolation, HookError as RustHookError, + HookErrorReport as RustHookErrorReport, PauliFrameLookup as RustPauliFrameLookup, + PauliPropChecker, SpacetimeLocation, +}; +use pecos_qec::{ + CertifiedDistance as RustCertifiedDistance, DistanceProblem as RustDistanceProblem, + certified_distance as rust_certified_distance, +}; use pecos_quantum::DagCircuit; use pecos_quantum::QubitId; use pyo3::Py; @@ -6979,6 +6992,684 @@ fn decoder_dem_requirement(decoder_type: &str) -> PyResult { } } +// ============================================================================= +// Circuit fault-tolerance diagnosis and distance certification +// ============================================================================= + +/// A gate location in a tick circuit where a Pauli fault is injected. +#[pyclass( + name = "CircuitFaultLocation", + module = "pecos_rslib.qec", + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyCircuitFaultLocation { + tick: usize, + gate_type: String, + qubits: Vec, + gate_index: usize, + before: bool, +} + +impl From<&SpacetimeLocation> for PyCircuitFaultLocation { + fn from(location: &SpacetimeLocation) -> Self { + Self { + tick: location.tick, + gate_type: format!("{:?}", location.gate_type), + qubits: location.qubits.iter().map(QubitId::index).collect(), + gate_index: location.gate_index, + before: location.before, + } + } +} + +#[pymethods] +impl PyCircuitFaultLocation { + #[getter] + fn tick(&self) -> usize { + self.tick + } + + #[getter] + fn gate_type(&self) -> String { + self.gate_type.clone() + } + + #[getter] + fn qubits(&self) -> Vec { + self.qubits.clone() + } + + #[getter] + fn gate_index(&self) -> usize { + self.gate_index + } + + #[getter] + fn before(&self) -> bool { + self.before + } + + fn __repr__(&self) -> String { + format!( + "CircuitFaultLocation(tick={}, gate_type={:?}, qubits={:?}, gate_index={}, before={})", + self.tick, self.gate_type, self.qubits, self.gate_index, self.before + ) + } +} + +type PyCircuitFault = (PyCircuitFaultLocation, Vec); + +fn python_faults(configuration: &FaultConfiguration) -> Vec { + configuration + .faults + .iter() + .map(|fault| { + ( + PyCircuitFaultLocation::from(&fault.location), + fault.paulis.iter().copied().map(usize::from).collect(), + ) + }) + .collect() +} + +/// A single-location fault that amplifies into a multi-qubit data error. +#[pyclass(name = "HookError", module = "pecos_rslib.qec", skip_from_py_object)] +#[derive(Clone)] +pub struct PyHookError { + location: PyCircuitFaultLocation, + fault_paulis: Vec, + data_support: Vec, + data_weight: usize, + detected: bool, + causes_logical_error: bool, +} + +impl From for PyHookError { + fn from(error: RustHookError) -> Self { + Self { + location: PyCircuitFaultLocation::from(&error.location), + fault_paulis: error.fault_paulis.into_iter().map(usize::from).collect(), + data_support: error.data_support, + data_weight: error.data_weight, + detected: error.detected, + causes_logical_error: error.causes_logical_error, + } + } +} + +#[pymethods] +impl PyHookError { + #[getter] + fn location(&self) -> PyCircuitFaultLocation { + self.location.clone() + } + + #[getter] + fn fault_paulis(&self) -> Vec { + self.fault_paulis.clone() + } + + #[getter] + fn data_support(&self) -> Vec { + self.data_support.clone() + } + + #[getter] + fn data_weight(&self) -> usize { + self.data_weight + } + + #[getter] + fn detected(&self) -> bool { + self.detected + } + + #[getter] + fn causes_logical_error(&self) -> bool { + self.causes_logical_error + } + + fn __repr__(&self) -> String { + format!( + "HookError(location={}, fault_paulis={:?}, data_support={:?}, data_weight={}, detected={}, causes_logical_error={})", + self.location.__repr__(), + self.fault_paulis, + self.data_support, + self.data_weight, + self.detected, + self.causes_logical_error + ) + } +} + +/// Summary of hook-error diagnosis over the selected Pauli fault set. +#[pyclass( + name = "HookErrorReport", + module = "pecos_rslib.qec", + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyHookErrorReport { + hook_errors: Vec, + total_faults_examined: usize, + max_data_weight: usize, +} + +impl From for PyHookErrorReport { + fn from(report: RustHookErrorReport) -> Self { + Self { + hook_errors: report + .hook_errors + .into_iter() + .map(PyHookError::from) + .collect(), + total_faults_examined: report.total_faults_examined, + max_data_weight: report.max_data_weight, + } + } +} + +#[pymethods] +impl PyHookErrorReport { + #[getter] + fn hook_errors(&self) -> Vec { + self.hook_errors.clone() + } + + #[getter] + fn total_faults_examined(&self) -> usize { + self.total_faults_examined + } + + #[getter] + fn max_data_weight(&self) -> usize { + self.max_data_weight + } + + fn __repr__(&self) -> String { + format!( + "HookErrorReport(hook_errors={}, total_faults_examined={}, max_data_weight={})", + self.hook_errors.len(), + self.total_faults_examined, + self.max_data_weight + ) + } +} + +/// A counterexample to the propagated-fault condition for a flag circuit. +#[pyclass( + name = "FlagViolation", + module = "pecos_rslib.qec", + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyFlagViolation { + faults: Vec, + num_faults: usize, + error_weight: usize, +} + +impl From for PyFlagViolation { + fn from(violation: RustFlagViolation) -> Self { + Self { + faults: python_faults(&violation.faults), + num_faults: violation.num_faults, + error_weight: violation.error_weight, + } + } +} + +#[pymethods] +impl PyFlagViolation { + #[getter] + fn faults(&self) -> Vec { + self.faults.clone() + } + + #[getter] + fn num_faults(&self) -> usize { + self.num_faults + } + + #[getter] + fn error_weight(&self) -> usize { + self.error_weight + } + + fn __repr__(&self) -> String { + format!( + "FlagViolation(num_faults={}, error_weight={}, faults={})", + self.num_faults, + self.error_weight, + self.faults.len() + ) + } +} + +/// Result of checking the propagated-fault condition through fault weight ``t``. +#[pyclass( + name = "FlagFaultToleranceReport", + module = "pecos_rslib.qec", + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyFlagFaultToleranceReport { + fault_condition_satisfied: bool, + t: usize, + violations: Vec, + total_configurations_tested: usize, +} + +impl From for PyFlagFaultToleranceReport { + fn from(report: RustFlagFaultToleranceReport) -> Self { + Self { + fault_condition_satisfied: report.fault_condition_satisfied, + t: report.t, + violations: report + .violations + .into_iter() + .map(PyFlagViolation::from) + .collect(), + total_configurations_tested: report.total_configurations_tested, + } + } +} + +#[pymethods] +impl PyFlagFaultToleranceReport { + #[getter] + fn fault_condition_satisfied(&self) -> bool { + self.fault_condition_satisfied + } + + #[getter] + fn t(&self) -> usize { + self.t + } + + #[getter] + fn violations(&self) -> Vec { + self.violations.clone() + } + + #[getter] + fn total_configurations_tested(&self) -> usize { + self.total_configurations_tested + } + + fn __repr__(&self) -> String { + format!( + "FlagFaultToleranceReport(fault_condition_satisfied={}, t={}, violations={}, total_configurations_tested={})", + self.fault_condition_satisfied, + self.t, + self.violations.len(), + self.total_configurations_tested + ) + } +} + +/// A minimum circuit fault distance and its first iterator-ordered witness. +#[pyclass( + name = "CircuitDistanceResult", + module = "pecos_rslib.qec", + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyCircuitDistanceResult { + distance: usize, + witness: Vec, + logical_index: usize, +} + +impl From for PyCircuitDistanceResult { + fn from(result: RustCircuitDistanceResult) -> Self { + Self { + distance: result.distance, + witness: python_faults(&result.witness), + logical_index: result.logical_index, + } + } +} + +#[pymethods] +impl PyCircuitDistanceResult { + #[getter] + fn distance(&self) -> usize { + self.distance + } + + #[getter] + fn witness(&self) -> Vec { + self.witness.clone() + } + + #[getter] + fn logical_index(&self) -> usize { + self.logical_index + } + + fn __repr__(&self) -> String { + format!( + "CircuitDistanceResult(distance={}, logical_index={}, witness_faults={})", + self.distance, + self.logical_index, + self.witness.len() + ) + } +} + +fn selected_fault_config(x_only: bool, y_only: bool, z_only: bool) -> FaultCheckConfig { + let restricted = x_only || y_only || z_only; + FaultCheckConfig { + include_x: !restricted || x_only, + include_y: !restricted || y_only, + include_z: !restricted || z_only, + ..FaultCheckConfig::default() + } +} + +fn logical_slices(logicals: &[(Vec, Vec)]) -> Vec<(&[usize], &[usize])> { + logicals + .iter() + .map(|(xs, zs)| (xs.as_slice(), zs.as_slice())) + .collect() +} + +/// Fault-tolerance diagnostics and circuit distance searches for a tick circuit. +/// +/// The analyzer owns a clone of the supplied circuit. Rust's ``PauliPropChecker`` and +/// ``FaultChecker`` intentionally borrow a circuit, so each method constructs a fresh checker; +/// checker construction only extracts circuit locations and is cheap at these analysis scales. +#[pyclass(name = "CircuitFaultAnalyzer", module = "pecos_rslib.qec")] +pub struct PyCircuitFaultAnalyzer { + circuit: pecos_quantum::TickCircuit, +} + +#[pymethods] +impl PyCircuitFaultAnalyzer { + #[new] + fn new(circuit: &PyTickCircuit) -> Self { + Self { + circuit: circuit.inner.clone(), + } + } + + /// Diagnose single-location faults that amplify across the data block. + /// + /// With no Pauli-selection keyword, X, Y, and Z faults are all included. Setting any of + /// ``x_only``, ``y_only``, or ``z_only`` restricts enumeration to the selected union. + #[pyo3(signature = (data_qubits, z_ancillas, x_ancillas, logicals, min_data_weight, *, x_only=false, y_only=false, z_only=false))] + fn hook_errors( + &self, + data_qubits: Vec, + z_ancillas: Vec, + x_ancillas: Vec, + logicals: Vec<(Vec, Vec)>, + min_data_weight: usize, + x_only: bool, + y_only: bool, + z_only: bool, + ) -> PyHookErrorReport { + // Checkers borrow TickCircuit by design. The Python owner retains a clone and checker + // construction (location extraction) is cheap enough to repeat for each method call. + let checker = PauliPropChecker::new(&self.circuit) + .with_config(selected_fault_config(x_only, y_only, z_only)); + let logicals = logical_slices(&logicals); + checker + .diagnose_hook_errors( + &data_qubits, + &z_ancillas, + &x_ancillas, + &logicals, + min_data_weight, + ) + .into() + } + + /// Verify the propagated-fault part of the Chao-Reichardt t-flag condition. + #[pyo3(signature = (data_qubits, flag_qubits, measured_stabilizer, t, *, x_only=false, y_only=false, z_only=false))] + fn flag_fault_condition( + &self, + data_qubits: Vec, + flag_qubits: Vec, + measured_stabilizer: (Vec, Vec), + t: usize, + x_only: bool, + y_only: bool, + z_only: bool, + ) -> PyFlagFaultToleranceReport { + let checker = PauliPropChecker::new(&self.circuit) + .with_config(selected_fault_config(x_only, y_only, z_only)); + checker + .verify_flag_fault_tolerance( + &data_qubits, + &flag_qubits, + (&measured_stabilizer.0, &measured_stabilizer.1), + t, + ) + .into() + } + + /// Find the minimum undetectable logical fault weight through ``max_weight``. + #[pyo3(signature = (z_ancillas, x_ancillas, logicals, max_weight, *, x_only=false, y_only=false, z_only=false))] + fn fault_distance( + &self, + z_ancillas: Vec, + x_ancillas: Vec, + logicals: Vec<(Vec, Vec)>, + max_weight: usize, + x_only: bool, + y_only: bool, + z_only: bool, + ) -> Option { + let checker = FaultChecker::new(&self.circuit) + .with_config(selected_fault_config(x_only, y_only, z_only)); + let logicals = logical_slices(&logicals); + checker + .circuit_fault_distance(&z_ancillas, &x_ancillas, &logicals, max_weight) + .map(PyCircuitDistanceResult::from) + } + + /// Find one fault distance result for each supplied logical operator. + #[pyo3(signature = (z_ancillas, x_ancillas, logicals, max_weight, *, x_only=false, y_only=false, z_only=false))] + fn per_logical_fault_distances( + &self, + z_ancillas: Vec, + x_ancillas: Vec, + logicals: Vec<(Vec, Vec)>, + max_weight: usize, + x_only: bool, + y_only: bool, + z_only: bool, + ) -> Vec> { + let checker = FaultChecker::new(&self.circuit) + .with_config(selected_fault_config(x_only, y_only, z_only)); + let logicals = logical_slices(&logicals); + checker + .per_logical_circuit_fault_distances(&z_ancillas, &x_ancillas, &logicals, max_weight) + .into_iter() + .map(|result| result.map(PyCircuitDistanceResult::from)) + .collect() + } + + fn __repr__(&self) -> String { + format!( + "CircuitFaultAnalyzer(ticks={}, gates={})", + self.circuit.num_ticks(), + self.circuit.gate_count() + ) + } +} + +/// A natively checked SAT witness and the solver-trusted UNSAT prefix below it. +#[pyclass( + name = "CertifiedDistance", + module = "pecos_rslib.qec", + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyCertifiedDistance { + distance: usize, + witness: Vec, + sat_certified: bool, + unsat_trusted_below: usize, +} + +impl From for PyCertifiedDistance { + fn from(result: RustCertifiedDistance) -> Self { + Self { + distance: result.distance, + witness: result.witness, + sat_certified: result.sat_certified, + unsat_trusted_below: result.unsat_trusted_below, + } + } +} + +#[pymethods] +impl PyCertifiedDistance { + #[getter] + fn distance(&self) -> usize { + self.distance + } + + #[getter] + fn witness(&self) -> Vec { + self.witness.clone() + } + + #[getter] + fn sat_certified(&self) -> bool { + self.sat_certified + } + + #[getter] + fn unsat_trusted_below(&self) -> usize { + self.unsat_trusted_below + } + + fn __repr__(&self) -> String { + format!( + "CertifiedDistance(distance={}, witness_weight={}, sat_certified={}, unsat_trusted_below={})", + self.distance, + self.witness.iter().filter(|&&selected| selected).count(), + self.sat_certified, + self.unsat_trusted_below + ) + } +} + +fn certify_python_problem( + problem: &RustDistanceProblem, + max_weight: usize, +) -> PyResult> { + rust_certified_distance(problem, max_weight) + .map(|result| result.map(PyCertifiedDistance::from)) + .map_err(|error| pyo3::exceptions::PyRuntimeError::new_err(error.to_string())) +} + +/// A binary problem whose solutions are undetectable with nonzero logical effect. +#[pyclass( + name = "DistanceProblem", + module = "pecos_rslib.qec", + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyDistanceProblem { + inner: RustDistanceProblem, +} + +#[pymethods] +impl PyDistanceProblem { + #[classmethod] + fn from_css_checks( + _cls: &Bound<'_, pyo3::types::PyType>, + hx: &PyParityCheckMatrix, + lx: &PyParityCheckMatrix, + ) -> PyResult { + RustDistanceProblem::from_css_checks(&hx.inner, &lx.inner) + .map(|inner| Self { inner }) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) + } + + #[classmethod] + fn from_css_code_x_distance( + _cls: &Bound<'_, pyo3::types::PyType>, + spec: &PyStabilizerCodeSpec, + ) -> PyResult { + RustDistanceProblem::from_css_code_x_distance(&spec.inner) + .map(|inner| Self { inner }) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) + } + + #[classmethod] + fn from_css_code_z_distance( + _cls: &Bound<'_, pyo3::types::PyType>, + spec: &PyStabilizerCodeSpec, + ) -> PyResult { + RustDistanceProblem::from_css_code_z_distance(&spec.inner) + .map(|inner| Self { inner }) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) + } + + #[classmethod] + fn from_dem(_cls: &Bound<'_, pyo3::types::PyType>, dem: &PyDetectorErrorModel) -> Self { + Self { + inner: RustDistanceProblem::from_dem(&dem.inner), + } + } + + #[getter] + fn num_vars(&self) -> usize { + self.inner.num_vars() + } + + fn to_dimacs(&self, max_weight: usize) -> String { + self.inner.to_dimacs(max_weight) + } + + fn to_wcnf(&self) -> String { + self.inner.to_wcnf() + } + + fn verify_witness(&self, witness: Vec) -> PyResult { + self.inner + .verify_witness(&witness) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) + } + + /// Certifies distance through ``max_weight`` using the in-process batsat SAT solver. + /// + /// A fresh deterministic solver instance is built for each weight from the internal clause + /// encoding. SAT answers are certified natively with ``DistanceProblem.verify_witness`` before + /// they are accepted. UNSAT answers, and therefore the exactness of a returned distance, rest + /// on trusting the solver. ``None`` means batsat reported every weight through ``max_weight`` + /// UNSAT. + fn certified_distance(&self, max_weight: usize) -> PyResult> { + certify_python_problem(&self.inner, max_weight) + } + + fn __repr__(&self) -> String { + format!("DistanceProblem(num_vars={})", self.inner.num_vars()) + } +} + +/// Certifies distance through ``max_weight`` using the in-process batsat SAT solver. +/// +/// A fresh deterministic solver instance is built for each weight from the internal clause +/// encoding. SAT answers are certified natively with ``DistanceProblem.verify_witness`` before +/// they are accepted. UNSAT answers, and therefore the exactness of a returned distance, rest +/// on trusting the solver. ``None`` means batsat reported every weight through ``max_weight`` +/// UNSAT. +#[pyfunction] +fn certified_distance( + problem: &PyDistanceProblem, + max_weight: usize, +) -> PyResult> { + certify_python_problem(&problem.inner, max_weight) +} + // ============================================================================= // Module Registration // ============================================================================= @@ -7006,6 +7697,15 @@ 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::()?; + qec.add_class::()?; + qec.add_class::()?; // Add DEM equivalence functions qec.add_function(wrap_pyfunction!(compare_dems_exact, &qec)?)?; @@ -7023,6 +7723,7 @@ pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { qec.add_function(wrap_pyfunction!(fit_dem_to_marginals, &qec)?)?; qec.add_function(wrap_pyfunction!(mechanisms_to_dem_string, &qec)?)?; qec.add_function(wrap_pyfunction!(decoder_dem_requirement, &qec)?)?; + qec.add_function(wrap_pyfunction!(certified_distance, &qec)?)?; // Add Pauli constants qec.add("PAULI_I", 0u8)?; diff --git a/python/pecos-rslib/src/stabilizer_code_spec_bindings.rs b/python/pecos-rslib/src/stabilizer_code_spec_bindings.rs index b893b6dd5..12dd2355e 100644 --- a/python/pecos-rslib/src/stabilizer_code_spec_bindings.rs +++ b/python/pecos-rslib/src/stabilizer_code_spec_bindings.rs @@ -226,7 +226,7 @@ impl PyStabilizerCodeSpecBuilder { #[pyclass(name = "StabilizerCodeSpec", module = "pecos_rslib", from_py_object)] #[derive(Clone, Debug)] pub struct PyStabilizerCodeSpec { - inner: RustCodeSpec, + pub(crate) inner: RustCodeSpec, } #[pymethods] diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index 40dbecf71..3ffb9806f 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -29,18 +29,28 @@ PAULI_X, PAULI_Y, PAULI_Z, + CertifiedDistance, + CircuitDistanceResult, + CircuitFaultAnalyzer, + CircuitFaultLocation, DagFaultAnalyzer, DagFaultInfluenceMap, DemBuilder, DemSampler, DemSamplerBuilder, + DistanceProblem, EquivalenceResult, FaultDistanceResult, FaultLocation, + FlagFaultToleranceReport, + FlagViolation, + HookError, + HookErrorReport, InfluenceBuilder, ParsedDem, PauliFrameLookup, assert_dems_equivalent, + certified_distance, compare_dems_exact, compare_dems_statistical, verify_dem_equivalence, @@ -126,20 +136,30 @@ # DEM generation and analysis "DagFaultAnalyzer", "DagFaultInfluenceMap", + "CertifiedDistance", + "CircuitDistanceResult", + "CircuitFaultAnalyzer", + "CircuitFaultLocation", "DemBuilder", "DemSampler", "DemSamplerBuilder", "DetectorErrorModel", "Detector", + "DistanceProblem", "EquivalenceResult", "FaultDistanceResult", "FaultLocation", + "FlagFaultToleranceReport", + "FlagViolation", + "HookError", + "HookErrorReport", "InfluenceBuilder", "PauliFrameLookup", "ParsedDem", "GuppyDemBuild", "Observable", "assert_dems_equivalent", + "certified_distance", "compare_dems_exact", "compare_dems_statistical", "verify_dem_equivalence", diff --git a/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py b/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py new file mode 100644 index 000000000..4ab2a2176 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py @@ -0,0 +1,191 @@ +# 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. + +"""Discriminating Python cases for circuit fault tooling and distance certification.""" + +import pytest +from pecos.qec import ( + CircuitFaultAnalyzer, + DetectorErrorModel, + DistanceProblem, + HookError, +) +from pecos.quantum import ParityCheckMatrix, StabilizerCode, StabilizerCodeSpec, TickCircuit + + +def _hook_ladder() -> TickCircuit: + circuit = TickCircuit() + circuit.tick().pz([3]) + circuit.tick().cx([(3, 0)]) + circuit.tick().cx([(3, 1)]) + circuit.tick().cx([(3, 2)]) + circuit.tick().mz([3]) + return circuit + + +def _weight_four_x_measurement(*, with_flag: bool) -> TickCircuit: + circuit = TickCircuit() + circuit.tick().pz([4]) + circuit.tick().h([4]) + if with_flag: + circuit.tick().pz([5]) + circuit.tick().cx([(4, 0)]) + if with_flag: + circuit.tick().cx([(4, 5)]) + circuit.tick().cx([(4, 1)]) + circuit.tick().cx([(4, 2)]) + if with_flag: + circuit.tick().cx([(4, 5)]) + circuit.tick().cx([(4, 3)]) + circuit.tick().h([4]) + circuit.tick().mz([4]) + if with_flag: + circuit.tick().mz([5]) + return circuit + + +def _three_qubit_extraction() -> TickCircuit: + circuit = TickCircuit() + circuit.tick().pz([3, 4]) + circuit.tick().cx([(0, 3)]) + circuit.tick().cx([(1, 3)]) + circuit.tick().cx([(1, 4)]) + circuit.tick().cx([(2, 4)]) + circuit.tick().mz([3, 4]) + return circuit + + +def _unequal_logical_distance_circuit() -> TickCircuit: + circuit = TickCircuit() + circuit.tick().pz([0]) + circuit.tick().pz([1]) + circuit.tick().h([0, 1]) + circuit.tick().cx([(1, 0)]) + circuit.tick().pz([2]) + circuit.tick().h([2]) + return circuit + + +def _steane_problem() -> DistanceProblem: + hamming = ParityCheckMatrix( + [ + [1, 0, 1, 0, 1, 0, 1], + [0, 1, 1, 0, 0, 1, 1], + [0, 0, 0, 1, 1, 1, 1], + ], + ) + logical = ParityCheckMatrix([[1, 1, 1, 1, 1, 1, 1]]) + return DistanceProblem.from_css_checks(hamming, logical) + + +def _triad_dem() -> DetectorErrorModel: + circuit = TickCircuit() + circuit.tick().mz([0, 1, 2]) + circuit.add_detector(records=[-3, -2]) + circuit.add_detector(records=[-3, -1]) + circuit.add_observable(records=[-3]) + return DetectorErrorModel.from_circuit( + circuit, + p1=0.0, + p2=0.0, + p_meas=0.01, + p_prep=0.0, + ) + + +def test_hook_ladder_reports_only_single_qubit_amplifying_faults() -> None: + report = CircuitFaultAnalyzer(_hook_ladder()).hook_errors([0, 1, 2], [3], [], [], 2) + + hook = next(error for error in report.hook_errors if error.location.tick == 1 and error.fault_paulis == [1, 0]) + assert isinstance(hook, HookError) + assert hook.location.gate_type == "CX" + assert hook.location.gate_index == 0 + assert hook.location.qubits == [3, 0] + assert "tick=1" in repr(hook) + assert 'gate_type="CX"' in repr(hook) + assert not any(error.location.tick == 3 and error.fault_paulis == [1, 0] for error in report.hook_errors) + assert not any(error.location.tick == 2 and error.fault_paulis == [1, 1] for error in report.hook_errors) + + +def test_flagged_pair_satisfies_condition_and_unflagged_pair_exposes_hook() -> None: + flagged = CircuitFaultAnalyzer(_weight_four_x_measurement(with_flag=True)) + flagged_report = flagged.flag_fault_condition([0, 1, 2, 3], [5], ([0, 1, 2, 3], []), 1) + assert flagged_report.fault_condition_satisfied + assert flagged_report.violations == [] + + unflagged = CircuitFaultAnalyzer(_weight_four_x_measurement(with_flag=False)) + unflagged_report = unflagged.flag_fault_condition([0, 1, 2, 3], [], ([0, 1, 2, 3], []), 1) + assert not unflagged_report.fault_condition_satisfied + assert any(violation.num_faults == 1 and violation.error_weight == 2 for violation in unflagged_report.violations) + + +def test_circuit_distance_and_per_logical_distances() -> None: + extraction_result = CircuitFaultAnalyzer(_three_qubit_extraction()).fault_distance([3, 4], [], [([], [0, 1, 2])], 1) + assert extraction_result is not None + assert extraction_result.distance == 1 + + analyzer = CircuitFaultAnalyzer(_unequal_logical_distance_circuit()) + logicals = [([2], []), ([0], [])] + per_logical = analyzer.per_logical_fault_distances([], [1], logicals, 2, x_only=True) + assert [result.distance if result is not None else None for result in per_logical] == [1, 2] + + overall = analyzer.fault_distance([], [1], logicals, 2, x_only=True) + assert overall is not None + assert overall.distance == min(result.distance for result in per_logical if result is not None) + + +def test_steane_certification_from_checks_and_code_spec() -> None: + from_checks = _steane_problem() + spec = StabilizerCodeSpec.from_stabilizer_code(StabilizerCode.steane()) + from_spec = DistanceProblem.from_css_code_x_distance(spec) + + for problem in (from_checks, from_spec): + certified = problem.certified_distance(3) + assert certified is not None + assert certified.distance == 3 + assert certified.sat_certified + assert certified.unsat_trusted_below == 3 + assert problem.verify_witness(certified.witness) == 3 + + assert from_checks.certified_distance(2) is None + + certified = from_checks.certified_distance(3) + assert certified is not None + corrupted = certified.witness.copy() + corrupted[0] = not corrupted[0] + with pytest.raises(ValueError, match="witness violates H row 0"): + from_checks.verify_witness(corrupted) + + +def test_triad_dem_certification_agrees_with_exhaustive_distance() -> None: + dem = _triad_dem() + problem = DistanceProblem.from_dem(dem) + certified = problem.certified_distance(3) + exhaustive = dem.exhaustive_fault_distance(3) + + assert certified is not None + assert exhaustive is not None + assert certified.distance == exhaustive.distance == 3 + assert problem.verify_witness(certified.witness) == 3 + assert problem.certified_distance(2) is None + + +def test_distance_problem_text_formats_have_expected_headers_and_soft_clauses() -> None: + problem = _steane_problem() + dimacs = problem.to_dimacs(3) + dimacs_header = next(line for line in dimacs.splitlines() if not line.startswith("c ")) + assert dimacs_header.startswith("p cnf ") + + wcnf = problem.to_wcnf() + wcnf_header = next(line for line in wcnf.splitlines() if not line.startswith("c ")) + assert wcnf_header.startswith("p wcnf ") + assert sum(line.startswith("1 -") for line in wcnf.splitlines()) == problem.num_vars From 375193b35ce73986942491843e39686e5449d519 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 14:40:19 -0600 Subject: [PATCH 16/41] Apply black formatting to the qec type stubs --- python/pecos-rslib/pecos_rslib/qec.pyi | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/python/pecos-rslib/pecos_rslib/qec.pyi b/python/pecos-rslib/pecos_rslib/qec.pyi index ebb48acf7..e72e9a20f 100644 --- a/python/pecos-rslib/pecos_rslib/qec.pyi +++ b/python/pecos-rslib/pecos_rslib/qec.pyi @@ -180,9 +180,7 @@ class DistanceProblem: """A binary undetectable-error problem with nonzero logical effect.""" @classmethod - def from_css_checks( - cls, hx: ParityCheckMatrix, lx: ParityCheckMatrix - ) -> DistanceProblem: ... + def from_css_checks(cls, hx: ParityCheckMatrix, lx: ParityCheckMatrix) -> DistanceProblem: ... @classmethod def from_css_code_x_distance(cls, spec: StabilizerCodeSpec) -> DistanceProblem: ... @classmethod @@ -197,9 +195,7 @@ class DistanceProblem: def certified_distance(self, max_weight: int) -> CertifiedDistance | None: ... def __repr__(self) -> str: ... -def certified_distance( - problem: DistanceProblem, max_weight: int -) -> CertifiedDistance | None: ... +def certified_distance(problem: DistanceProblem, max_weight: int) -> CertifiedDistance | None: ... # The native QEC module predates this focused stub. Preserve the untyped behavior of its other # classes and functions until that complete API is migrated rather than falsely narrowing them. From 5d16fe2c9858e718c8c5fce3ae5e91a63b975e21 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 15:30:15 -0600 Subject: [PATCH 17/41] Extract fault locations per gate instance instead of per batch --- crates/pecos-qec/src/fault_tolerance.rs | 5 +- .../src/fault_tolerance/circuit_runner.rs | 114 ++++++++++++++++-- .../src/fault_tolerance/gadget_checker.rs | 7 +- .../src/fault_tolerance/pauli_prop_checker.rs | 57 +++++++-- 4 files changed, 156 insertions(+), 27 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance.rs b/crates/pecos-qec/src/fault_tolerance.rs index fc32a6fa4..2220ff174 100644 --- a/crates/pecos-qec/src/fault_tolerance.rs +++ b/crates/pecos-qec/src/fault_tolerance.rs @@ -97,7 +97,10 @@ pub struct SpacetimeLocation { pub before: bool, /// The type of gate at this location. pub gate_type: GateType, - /// Index of the gate within the tick (for circuits with multiple gates per tick). + /// Index of this individual gate application within the tick. + /// + /// Stored gate batches are expanded first, so this is the flattened per-tick + /// instance ordinal rather than the parent batch index. pub gate_index: usize, } diff --git a/crates/pecos-qec/src/fault_tolerance/circuit_runner.rs b/crates/pecos-qec/src/fault_tolerance/circuit_runner.rs index 3bb5549ac..09ab61e74 100644 --- a/crates/pecos-qec/src/fault_tolerance/circuit_runner.rs +++ b/crates/pecos-qec/src/fault_tolerance/circuit_runner.rs @@ -63,10 +63,10 @@ pub fn extract_spacetime_locations( // Iterate through all ticks for (tick_idx, tick) in circuit.iter_ticks() { - for gate in tick.iter_gate_batches() { - let qubits: Vec = gate.qubits.iter().copied().collect(); + for (gate_index, gate) in tick.iter_gate_instances().enumerate() { + let qubits: Vec = gate.qubits().to_vec(); let is_measurement = matches!( - gate.gate_type, + gate.gate_type(), GateType::MZ | GateType::MeasureFree | GateType::MPZ ); @@ -74,8 +74,8 @@ pub fn extract_spacetime_locations( tick_idx, qubits, is_measurement, // Measurements get "before" errors - gate.gate_type, - gate.batch_index(), + gate.gate_type(), + gate_index, )); } } @@ -1083,6 +1083,7 @@ impl<'a> FaultChecker<'a> { #[allow(clippy::cast_precision_loss)] // statistical tests use count as f64 mod tests { use super::*; + use crate::fault_tolerance::PauliFaultIterator; use pecos_simulators::SparseStab; #[test] @@ -1112,6 +1113,93 @@ mod tests { assert_eq!(locations[1].gate_type, GateType::CX); } + #[test] + fn test_extract_spacetime_locations_uses_gate_instance_granularity() { + let mut single_qubit_batch = TickCircuit::new(); + single_qubit_batch.tick().pz(&[0, 1, 2]); + + let locations = extract_spacetime_locations(&single_qubit_batch, false); + assert_eq!(locations.len(), 3); + assert_eq!( + locations + .iter() + .map(|location| location.qubits.clone()) + .collect::>(), + vec![vec![QubitId(0)], vec![QubitId(1)], vec![QubitId(2)]] + ); + assert_eq!( + locations + .iter() + .map(|location| location.gate_index) + .collect::>(), + vec![0, 1, 2] + ); + + let mut two_qubit_batch = TickCircuit::new(); + two_qubit_batch.tick().cx(&[(0, 1), (2, 3)]); + + let locations = extract_spacetime_locations(&two_qubit_batch, false); + assert_eq!(locations.len(), 2); + assert_eq!( + locations + .iter() + .map(|location| location.qubits.clone()) + .collect::>(), + vec![vec![QubitId(0), QubitId(1)], vec![QubitId(2), QubitId(3)],] + ); + + let mut mixed_tick = TickCircuit::new(); + mixed_tick.tick().h(&[0, 1]).x(&[2]).cx(&[(3, 4), (5, 6)]); + + let locations = extract_spacetime_locations(&mixed_tick, false); + assert_eq!(locations.len(), 5); + assert_eq!( + locations + .iter() + .map(|location| location.qubits.clone()) + .collect::>(), + vec![ + vec![QubitId(0)], + vec![QubitId(1)], + vec![QubitId(2)], + vec![QubitId(3), QubitId(4)], + vec![QubitId(5), QubitId(6)], + ] + ); + } + + #[test] + fn test_weight_one_fault_cannot_span_parallel_cx_instances() { + let mut circuit = TickCircuit::new(); + circuit.tick().cx(&[(0, 1), (2, 3)]); + + let locations = extract_spacetime_locations(&circuit, false); + let fault_iter = PauliFaultIterator::new(locations, 1, FaultCheckConfig::new().x_only()); + + for configuration in fault_iter { + let [fault] = configuration.faults.as_slice() else { + panic!("weight-one iteration must select exactly one location"); + }; + let touches_first_pair = fault + .location + .qubits + .iter() + .zip(&fault.paulis) + .any(|(qubit, pauli)| qubit.index() < 2 && *pauli != 0); + let touches_second_pair = fault + .location + .qubits + .iter() + .zip(&fault.paulis) + .any(|(qubit, pauli)| qubit.index() >= 2 && *pauli != 0); + + assert!( + !(touches_first_pair && touches_second_pair), + "a weight-one fault crossed CX instances: {configuration:?}" + ); + } + } + #[test] fn test_fault_checker_creation() { let mut circuit = TickCircuit::new(); @@ -1235,16 +1323,18 @@ mod tests { // Check we have the expected number of locations let locations = extract_spacetime_locations(&circuit, false); - // 1 prep (2 qubits) + 4 CX gates + 1 measure (2 qubits) = 6 gate operations - assert_eq!(locations.len(), 6); + // 2 preparations + 4 CX gates + 2 measurements = 8 gate applications + assert_eq!(locations.len(), 8); // Verify gate types assert_eq!(locations[0].gate_type, GateType::PZ); - assert_eq!(locations[1].gate_type, GateType::CX); + assert_eq!(locations[1].gate_type, GateType::PZ); assert_eq!(locations[2].gate_type, GateType::CX); assert_eq!(locations[3].gate_type, GateType::CX); assert_eq!(locations[4].gate_type, GateType::CX); - assert_eq!(locations[5].gate_type, GateType::MZ); + assert_eq!(locations[5].gate_type, GateType::CX); + assert_eq!(locations[6].gate_type, GateType::MZ); + assert_eq!(locations[7].gate_type, GateType::MZ); } #[test] @@ -1431,10 +1521,10 @@ mod tests { .filter(|l| l.gate_type == GateType::MZ) .count(); - assert_eq!(preps, 1); // One bulk prep - assert_eq!(hadamards, 2); // Two bulk H operations + assert_eq!(preps, 3); // Three ancilla preparations + assert_eq!(hadamards, 6); // Three initial and three final H gates assert_eq!(cnots, 12); // 12 individual CX gates - assert_eq!(measures, 1); // One bulk measure + assert_eq!(measures, 3); // Three ancilla measurements } #[test] diff --git a/crates/pecos-qec/src/fault_tolerance/gadget_checker.rs b/crates/pecos-qec/src/fault_tolerance/gadget_checker.rs index 794cf541a..1f8af4a75 100644 --- a/crates/pecos-qec/src/fault_tolerance/gadget_checker.rs +++ b/crates/pecos-qec/src/fault_tolerance/gadget_checker.rs @@ -2572,6 +2572,7 @@ mod tests { println!(" Total tested: {}", analysis.total_tested); println!(" Is FT: {}", analysis.is_fault_tolerant()); println!(" Syndrome patterns: {}", analysis.num_syndrome_patterns()); + assert!(analysis.is_fault_tolerant()); } #[test] @@ -2623,6 +2624,10 @@ mod tests { analysis.never_detected_logical_errors ); println!(" Is FT: {}", analysis.is_fault_tolerant()); + assert!( + !analysis.is_fault_tolerant(), + "the final extraction round still has undetectable post-CX data faults" + ); } #[test] @@ -2645,7 +2650,7 @@ mod tests { let analysis = checker.analyze_decoder_requirements(1); // Test helper methods - let _ = analysis.is_fault_tolerant(); + assert!(!analysis.is_fault_tolerant()); let _ = analysis.num_syndrome_patterns(); let problematic = analysis.problematic_syndromes(); diff --git a/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs b/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs index 7b892c176..63682596b 100644 --- a/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs +++ b/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs @@ -3003,8 +3003,12 @@ mod tests { } #[test] - fn test_syndrome_detection_three_qubit_code() { - // 3-qubit bit-flip code: X errors on data qubits should produce syndromes + fn test_single_round_misses_exactly_post_final_cx_data_faults() { + // A single 3-qubit bit-flip extraction round misses an X fault placed on + // a data qubit after that qubit's final CX: the completed round has no + // later interaction through which the fault could reach an ancilla. + // `test_repeated_syndrome_measurement_concept` documents how a second + // extraction round catches these persistent data errors. // Data qubits: 0, 1, 2 // Ancilla qubits: 3, 4 (Z-basis measurement) // Stabilizers: Z0Z1 (ancilla 3), Z1Z2 (ancilla 4) @@ -3027,13 +3031,30 @@ mod tests { // Check that weight-1 X errors produce syndromes let result = checker.check_syndrome_detection(&[3, 4], &[], true); - println!( - "3-qubit code syndrome detection: {} faults don't produce syndrome (should be 0)", - result.num_failures() - ); + let missing_faults: Vec<_> = result + .failures + .iter() + .map(|configuration| { + let [fault] = configuration.faults.as_slice() else { + panic!("weight-one iteration must select exactly one location"); + }; + ( + fault.location.tick, + fault.location.gate_type, + fault.location.qubits.clone(), + fault.paulis.clone(), + ) + }) + .collect(); - // All weight-1 X errors on data qubits should produce a syndrome - // (faults on ancillas might not, but data qubit errors should) + assert_eq!( + missing_faults, + vec![ + (1, GateType::CX, vec![QubitId(0), QubitId(3)], vec![1, 0],), + (3, GateType::CX, vec![QubitId(1), QubitId(4)], vec![1, 0],), + (4, GateType::CX, vec![QubitId(2), QubitId(4)], vec![1, 0],), + ] + ); } #[test] @@ -3273,7 +3294,8 @@ mod tests { #[test] fn test_is_fault_tolerant_method() { - // Simple circuit that IS fault tolerant for weight-1 X errors + // This circuit is not fault tolerant for weight-1 X errors: an X on data + // qubit 0 after the CX is invisible when only qubit 1 is measured. let mut circuit = TickCircuit::new(); circuit.tick().cx(&[(0, 1)]); circuit.tick().mz(&[1]); @@ -3290,12 +3312,13 @@ mod tests { let x_ancillas: &[usize] = &[]; // Logical Z = Z0 (just for testing - single qubit) - // X on qubit 0 -> XX after CX -> X on qubit 1 detected + // X on qubit 0 after CX -> undetected logical error // X on qubit 1 after CX -> detected let logicals: &[(&[usize], &[usize])] = &[(&[], &[0])]; let is_ft = checker.is_fault_tolerant(z_ancillas, x_ancillas, logicals); println!("Simple circuit is 1-fault tolerant for X errors: {is_ft}"); + assert!(!is_ft); } #[test] @@ -3641,6 +3664,10 @@ mod tests { println!(" Ambiguous faults: {}", result.ambiguous_faults); println!(" Unique syndrome histories: {}", result.histories.len()); println!(" Is FT: {}", result.is_ft()); + assert!( + !result.is_ft(), + "the final extraction round still has undetectable post-CX data faults" + ); // We should have found 2 measurement rounds assert_eq!(result.rounds.len(), 2, "Should find 2 measurement rounds"); @@ -4188,9 +4215,12 @@ mod tests { analysis_with_follow_up.ambiguous_syndromes ); - // With follow-up, the output error provides additional syndrome information - // This should help disambiguate (or at least not make things worse) - // The key insight: different output errors produce different follow-up syndromes + assert!( + analysis_no_follow_up.ambiguous_syndromes > 0, + "without follow-up, the gadget syndrome alone must be ambiguous" + ); + assert_eq!(analysis_with_follow_up.ambiguous_syndromes, 0); + assert_eq!(analysis_with_follow_up.undetectable_logical_errors, 0); } #[test] @@ -4218,6 +4248,7 @@ mod tests { let is_ft = checker.is_gadget_fault_tolerant(z_ancillas, x_ancillas, logicals, &follow_up); println!("Gadget is fault tolerant with follow-up: {is_ft}"); + assert!(is_ft); } #[test] From 646c251f2a8b232b4c56b5dddccfbba9300c2a88 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 15:40:46 -0600 Subject: [PATCH 18/41] Retain learned clauses across the certification weight loop via assumption-gated bounds --- crates/pecos-qec/src/distance_problem.rs | 255 ++++++++++++++++++----- 1 file changed, 203 insertions(+), 52 deletions(-) diff --git a/crates/pecos-qec/src/distance_problem.rs b/crates/pecos-qec/src/distance_problem.rs index 435a52965..6b60591db 100644 --- a/crates/pecos-qec/src/distance_problem.rs +++ b/crates/pecos-qec/src/distance_problem.rs @@ -23,7 +23,7 @@ //! the returned distance. use crate::{DetectorErrorModel, ParityCheckMatrix, StabilizerCodeSpec}; -use batsat::{BasicSolver, Lit, SolverInterface, lbool}; +use batsat::{BasicSolver, Lit, SolverInterface, Var, lbool}; use pecos_core::PauliOperator; use pecos_quantum::F2Matrix; use std::fmt::Write as _; @@ -172,6 +172,27 @@ struct Encoding { groups: Vec, } +#[derive(Clone, Copy, Debug)] +enum CounterOutput { + FixedBound, + Assumptions, +} + +#[derive(Debug, Default)] +struct FinalCounterRow { + /// Positive literals for s[n,1], s[n,2], ... in threshold order. + threshold_literals: Vec, +} + +impl FinalCounterRow { + fn assumption_for_bound(&self, max_weight: usize) -> Option { + // Index max_weight is s[n,max_weight+1], meaning the count exceeds max_weight. + self.threshold_literals + .get(max_weight) + .map(|&literal| -literal) + } +} + #[derive(Debug)] struct EncodingBuilder { next_var: usize, @@ -520,11 +541,20 @@ impl DistanceProblem { self.encode_checks(&mut builder); self.encode_logical_nontriviality(&mut builder); if let Some(max_weight) = max_weight { - self.encode_sequential_counter(&mut builder, max_weight); + self.encode_sequential_counter(&mut builder, max_weight, CounterOutput::FixedBound); } builder.finish(max_weight.is_some()) } + fn encode_for_assumptions(&self, max_weight: usize) -> (Encoding, FinalCounterRow) { + let mut builder = EncodingBuilder::new(self.num_vars); + self.encode_checks(&mut builder); + self.encode_logical_nontriviality(&mut builder); + let final_row = + self.encode_sequential_counter(&mut builder, max_weight, CounterOutput::Assumptions); + (builder.finish(true), final_row) + } + fn row_support(matrix: &F2Matrix, row: usize) -> Vec { (0..matrix.num_cols()) .filter(|&column| matrix.get(row, column) == 1) @@ -618,23 +648,35 @@ impl DistanceProblem { clauses.push(vec![-left, right, output]); } - fn encode_sequential_counter(&self, builder: &mut EncodingBuilder, max_weight: usize) { - if max_weight >= self.num_vars { - return; - } - if max_weight == 0 { - for variable in 1..=self.num_vars { - builder - .cardinality_clauses - .push(vec![-Self::literal(variable)]); + fn encode_sequential_counter( + &self, + builder: &mut EncodingBuilder, + max_weight: usize, + output: CounterOutput, + ) -> FinalCounterRow { + match output { + CounterOutput::FixedBound if max_weight >= self.num_vars => { + return FinalCounterRow::default(); } - return; + CounterOutput::FixedBound if max_weight == 0 => { + for variable in 1..=self.num_vars { + builder + .cardinality_clauses + .push(vec![-Self::literal(variable)]); + } + return FinalCounterRow::default(); + } + CounterOutput::Assumptions if max_weight == 0 || self.num_vars <= 1 => { + return FinalCounterRow::default(); + } + CounterOutput::FixedBound | CounterOutput::Assumptions => {} } // s[i, j] means that at least j of x_1..=x_i are true. These are the sequential unary // counter variables of Sinz (2005). Both directions of each recurrence are emitted, making - // every auxiliary functionally determined by the primary variables. - let threshold = max_weight + 1; + // every auxiliary functionally determined by the primary variables. Assumption mode needs + // s[n,w+1] for every queried w, including max_weight, but thresholds above n are impossible. + let threshold = max_weight.saturating_add(1).min(self.num_vars); let variables = builder.allocate_range( self.num_vars * threshold, "Sinz sequential-counter prefix thresholds", @@ -679,9 +721,17 @@ impl DistanceProblem { .push(vec![-current, same, lower]); } } - builder - .cardinality_clauses - .push(vec![-Self::literal(counter(self.num_vars, threshold))]); + let final_row = FinalCounterRow { + threshold_literals: (1..=threshold) + .map(|j| Self::literal(counter(self.num_vars, j))) + .collect(), + }; + if matches!(output, CounterOutput::FixedBound) { + builder + .cardinality_clauses + .push(vec![-final_row.threshold_literals[threshold - 1]]); + } + final_row } /// Checks a candidate witness with native GF(2) arithmetic and returns its Hamming weight. @@ -790,13 +840,12 @@ impl DistanceProblem { /// Certifies distance through `max_weight` using the in-process batsat SAT solver. /// -/// A fresh deterministic solver instance is built for each weight from the internal clause -/// encoding. SAT answers are certified natively with [`DistanceProblem::verify_witness`] before -/// they are accepted. UNSAT answers, and therefore the exactness of a returned distance, rest on -/// trusting the solver. `Ok(None)` means batsat reported every weight through `max_weight` UNSAT. -/// -/// Incremental assumptions could avoid rebuilding the solver in a future implementation, but are -/// deliberately not used here. +/// One deterministic solver instance contains the parity, nontriviality, and sequential-counter +/// clauses. Each weight is selected by an assumption on the counter's final row, retaining learned +/// clauses between bounds. SAT answers are certified natively with +/// [`DistanceProblem::verify_witness`] before they are accepted. UNSAT answers, and therefore the +/// exactness of a returned distance, rest on trusting the solver. `Ok(None)` means batsat reported +/// every weight through `max_weight` UNSAT. /// /// # Errors /// @@ -805,42 +854,78 @@ pub fn certified_distance( problem: &DistanceProblem, max_weight: usize, ) -> Result, DistanceCertificationError> { - problem.certify_distance_by(max_weight, |problem, weight| { - solve_with_batsat(&problem.encode(Some(weight)), problem.num_vars) + let (encoding, final_row) = problem.encode_for_assumptions(max_weight); + let mut solver = BatsatDistanceSolver::new(&encoding, problem.num_vars); + problem.certify_distance_by(max_weight, |_, weight| { + solver.solve(final_row.assumption_for_bound(weight)) }) } +#[cfg(test)] // reference fresh-per-weight path, retained for cross-path tests and probes fn solve_with_batsat(encoding: &Encoding, num_primary_vars: usize) -> SolverAnswer { - let mut solver = BasicSolver::default(); - let variables: Vec<_> = (0..encoding.num_vars) - .map(|_| solver.new_var_default()) - .collect(); + BatsatDistanceSolver::new(encoding, num_primary_vars).solve(None) +} - for clause in encoding.groups.iter().flat_map(|group| &group.clauses) { - let mut literals: Vec<_> = clause - .iter() - .map(|&literal| { - let index = literal.unsigned_abs() as usize - 1; - Lit::new(variables[index], literal > 0) - }) +struct BatsatDistanceSolver { + solver: BasicSolver, + variables: Vec, + num_primary_vars: usize, + clauses_consistent: bool, +} + +impl BatsatDistanceSolver { + fn new(encoding: &Encoding, num_primary_vars: usize) -> Self { + let mut solver = BasicSolver::default(); + let variables: Vec<_> = (0..encoding.num_vars) + .map(|_| solver.new_var_default()) .collect(); - if !solver.add_clause_reuse(&mut literals) { - return SolverAnswer::Unsat; + let mut clauses_consistent = true; + + for clause in encoding.groups.iter().flat_map(|group| &group.clauses) { + let mut literals: Vec<_> = clause + .iter() + .map(|&literal| Self::to_batsat_literal(&variables, literal)) + .collect(); + if !solver.add_clause_reuse(&mut literals) { + clauses_consistent = false; + break; + } + } + + Self { + solver, + variables, + num_primary_vars, + clauses_consistent, } } - let answer = solver.solve_limited(&[]); - if answer == lbool::TRUE { - SolverAnswer::Sat( - variables[..num_primary_vars] - .iter() - .map(|&variable| solver.value_var(variable) == lbool::TRUE) - .collect(), - ) - } else if answer == lbool::FALSE { - SolverAnswer::Unsat - } else { - SolverAnswer::Unknown + fn to_batsat_literal(variables: &[Var], literal: i32) -> Lit { + let index = literal.unsigned_abs() as usize - 1; + Lit::new(variables[index], literal > 0) + } + + fn solve(&mut self, assumption: Option) -> SolverAnswer { + if !self.clauses_consistent { + return SolverAnswer::Unsat; + } + let assumptions: Vec<_> = assumption + .map(|literal| Self::to_batsat_literal(&self.variables, literal)) + .into_iter() + .collect(); + let answer = self.solver.solve_limited(&assumptions); + if answer == lbool::TRUE { + SolverAnswer::Sat( + self.variables[..self.num_primary_vars] + .iter() + .map(|&variable| self.solver.value_var(variable) == lbool::TRUE) + .collect(), + ) + } else if answer == lbool::FALSE { + SolverAnswer::Unsat + } else { + SolverAnswer::Unknown + } } } @@ -1222,6 +1307,60 @@ mod tests { assert_eq!(problem.verify_witness(&second.witness), Ok(3)); } + #[test] + fn incremental_matches_reference_path_on_seeded_small_problems() { + use rand::rngs::SmallRng; + use rand::{RngExt, SeedableRng}; + + const NUM_CASES: usize = 128; + const NUM_VARS: usize = 8; + + let mut rng = SmallRng::seed_from_u64(0x19C0_5EED); + for case_index in 0..NUM_CASES { + let random_rows = |rng: &mut SmallRng, count: usize| -> Vec> { + (0..count) + .map(|_| { + (0..NUM_VARS) + .map(|_| u8::from(rng.random_bool(0.4))) + .collect() + }) + .collect() + }; + let h_count = rng.random_range(1..=3); + let l_count = rng.random_range(1..=2); + let h_rows = random_rows(&mut rng, h_count); + let l_rows = random_rows(&mut rng, l_count); + let (Ok(h), Ok(l)) = ( + ParityCheckMatrix::from_dense(h_rows.clone()), + ParityCheckMatrix::from_dense(l_rows.clone()), + ) else { + continue; + }; + let problem = DistanceProblem::from_css_checks(&h, &l).unwrap(); + + for max_weight in [2usize, NUM_VARS] { + let incremental = certified_distance(&problem, max_weight).unwrap(); + let reference = problem + .certify_distance_with(max_weight, |dimacs, _| { + exhaustive_dimacs_answer(&problem, dimacs) + }) + .unwrap(); + assert_eq!( + incremental.as_ref().map(|c| c.distance), + reference.as_ref().map(|c| c.distance), + "distance diverged for seeded case {case_index} at max_weight {max_weight}: H {h_rows:?} L {l_rows:?}" + ); + if let Some(certified) = &incremental { + assert_eq!( + problem.verify_witness(&certified.witness), + Ok(certified.distance), + "incremental witness failed native verification for case {case_index}" + ); + } + } + } + } + fn bb_circulant(l: usize, m: usize, terms: &[(usize, usize)]) -> F2Matrix { let size = l * m; let mut matrix = F2Matrix::zeros(size, size); @@ -1300,9 +1439,21 @@ mod tests { }) .unwrap() .unwrap(); - println!("BB [[72,12,6]] total: {:?}", total_started.elapsed()); + println!( + "BB [[72,12,6]] fresh-path total: {:?}", + total_started.elapsed() + ); assert_eq!(certified.distance, 6); assert_eq!(problem.verify_witness(&certified.witness), Ok(6)); + + let incremental_started = Instant::now(); + let incremental = certified_distance(&problem, 6).unwrap().unwrap(); + println!( + "BB [[72,12,6]] incremental total: {:?}", + incremental_started.elapsed() + ); + assert_eq!(incremental.distance, 6); + assert_eq!(problem.verify_witness(&incremental.witness), Ok(6)); } #[test] From d211e5bcf1c2b45832bdb41575275297fb4b9d31 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 16:15:49 -0600 Subject: [PATCH 19/41] Revert "Retain learned clauses across the certification weight loop via assumption-gated bounds" This reverts commit 646c251f2a8b232b4c56b5dddccfbba9300c2a88. --- crates/pecos-qec/src/distance_problem.rs | 255 +++++------------------ 1 file changed, 52 insertions(+), 203 deletions(-) diff --git a/crates/pecos-qec/src/distance_problem.rs b/crates/pecos-qec/src/distance_problem.rs index 6b60591db..435a52965 100644 --- a/crates/pecos-qec/src/distance_problem.rs +++ b/crates/pecos-qec/src/distance_problem.rs @@ -23,7 +23,7 @@ //! the returned distance. use crate::{DetectorErrorModel, ParityCheckMatrix, StabilizerCodeSpec}; -use batsat::{BasicSolver, Lit, SolverInterface, Var, lbool}; +use batsat::{BasicSolver, Lit, SolverInterface, lbool}; use pecos_core::PauliOperator; use pecos_quantum::F2Matrix; use std::fmt::Write as _; @@ -172,27 +172,6 @@ struct Encoding { groups: Vec, } -#[derive(Clone, Copy, Debug)] -enum CounterOutput { - FixedBound, - Assumptions, -} - -#[derive(Debug, Default)] -struct FinalCounterRow { - /// Positive literals for s[n,1], s[n,2], ... in threshold order. - threshold_literals: Vec, -} - -impl FinalCounterRow { - fn assumption_for_bound(&self, max_weight: usize) -> Option { - // Index max_weight is s[n,max_weight+1], meaning the count exceeds max_weight. - self.threshold_literals - .get(max_weight) - .map(|&literal| -literal) - } -} - #[derive(Debug)] struct EncodingBuilder { next_var: usize, @@ -541,20 +520,11 @@ impl DistanceProblem { self.encode_checks(&mut builder); self.encode_logical_nontriviality(&mut builder); if let Some(max_weight) = max_weight { - self.encode_sequential_counter(&mut builder, max_weight, CounterOutput::FixedBound); + self.encode_sequential_counter(&mut builder, max_weight); } builder.finish(max_weight.is_some()) } - fn encode_for_assumptions(&self, max_weight: usize) -> (Encoding, FinalCounterRow) { - let mut builder = EncodingBuilder::new(self.num_vars); - self.encode_checks(&mut builder); - self.encode_logical_nontriviality(&mut builder); - let final_row = - self.encode_sequential_counter(&mut builder, max_weight, CounterOutput::Assumptions); - (builder.finish(true), final_row) - } - fn row_support(matrix: &F2Matrix, row: usize) -> Vec { (0..matrix.num_cols()) .filter(|&column| matrix.get(row, column) == 1) @@ -648,35 +618,23 @@ impl DistanceProblem { clauses.push(vec![-left, right, output]); } - fn encode_sequential_counter( - &self, - builder: &mut EncodingBuilder, - max_weight: usize, - output: CounterOutput, - ) -> FinalCounterRow { - match output { - CounterOutput::FixedBound if max_weight >= self.num_vars => { - return FinalCounterRow::default(); - } - CounterOutput::FixedBound if max_weight == 0 => { - for variable in 1..=self.num_vars { - builder - .cardinality_clauses - .push(vec![-Self::literal(variable)]); - } - return FinalCounterRow::default(); - } - CounterOutput::Assumptions if max_weight == 0 || self.num_vars <= 1 => { - return FinalCounterRow::default(); + fn encode_sequential_counter(&self, builder: &mut EncodingBuilder, max_weight: usize) { + if max_weight >= self.num_vars { + return; + } + if max_weight == 0 { + for variable in 1..=self.num_vars { + builder + .cardinality_clauses + .push(vec![-Self::literal(variable)]); } - CounterOutput::FixedBound | CounterOutput::Assumptions => {} + return; } // s[i, j] means that at least j of x_1..=x_i are true. These are the sequential unary // counter variables of Sinz (2005). Both directions of each recurrence are emitted, making - // every auxiliary functionally determined by the primary variables. Assumption mode needs - // s[n,w+1] for every queried w, including max_weight, but thresholds above n are impossible. - let threshold = max_weight.saturating_add(1).min(self.num_vars); + // every auxiliary functionally determined by the primary variables. + let threshold = max_weight + 1; let variables = builder.allocate_range( self.num_vars * threshold, "Sinz sequential-counter prefix thresholds", @@ -721,17 +679,9 @@ impl DistanceProblem { .push(vec![-current, same, lower]); } } - let final_row = FinalCounterRow { - threshold_literals: (1..=threshold) - .map(|j| Self::literal(counter(self.num_vars, j))) - .collect(), - }; - if matches!(output, CounterOutput::FixedBound) { - builder - .cardinality_clauses - .push(vec![-final_row.threshold_literals[threshold - 1]]); - } - final_row + builder + .cardinality_clauses + .push(vec![-Self::literal(counter(self.num_vars, threshold))]); } /// Checks a candidate witness with native GF(2) arithmetic and returns its Hamming weight. @@ -840,12 +790,13 @@ impl DistanceProblem { /// Certifies distance through `max_weight` using the in-process batsat SAT solver. /// -/// One deterministic solver instance contains the parity, nontriviality, and sequential-counter -/// clauses. Each weight is selected by an assumption on the counter's final row, retaining learned -/// clauses between bounds. SAT answers are certified natively with -/// [`DistanceProblem::verify_witness`] before they are accepted. UNSAT answers, and therefore the -/// exactness of a returned distance, rest on trusting the solver. `Ok(None)` means batsat reported -/// every weight through `max_weight` UNSAT. +/// A fresh deterministic solver instance is built for each weight from the internal clause +/// encoding. SAT answers are certified natively with [`DistanceProblem::verify_witness`] before +/// they are accepted. UNSAT answers, and therefore the exactness of a returned distance, rest on +/// trusting the solver. `Ok(None)` means batsat reported every weight through `max_weight` UNSAT. +/// +/// Incremental assumptions could avoid rebuilding the solver in a future implementation, but are +/// deliberately not used here. /// /// # Errors /// @@ -854,78 +805,42 @@ pub fn certified_distance( problem: &DistanceProblem, max_weight: usize, ) -> Result, DistanceCertificationError> { - let (encoding, final_row) = problem.encode_for_assumptions(max_weight); - let mut solver = BatsatDistanceSolver::new(&encoding, problem.num_vars); - problem.certify_distance_by(max_weight, |_, weight| { - solver.solve(final_row.assumption_for_bound(weight)) + problem.certify_distance_by(max_weight, |problem, weight| { + solve_with_batsat(&problem.encode(Some(weight)), problem.num_vars) }) } -#[cfg(test)] // reference fresh-per-weight path, retained for cross-path tests and probes fn solve_with_batsat(encoding: &Encoding, num_primary_vars: usize) -> SolverAnswer { - BatsatDistanceSolver::new(encoding, num_primary_vars).solve(None) -} - -struct BatsatDistanceSolver { - solver: BasicSolver, - variables: Vec, - num_primary_vars: usize, - clauses_consistent: bool, -} + let mut solver = BasicSolver::default(); + let variables: Vec<_> = (0..encoding.num_vars) + .map(|_| solver.new_var_default()) + .collect(); -impl BatsatDistanceSolver { - fn new(encoding: &Encoding, num_primary_vars: usize) -> Self { - let mut solver = BasicSolver::default(); - let variables: Vec<_> = (0..encoding.num_vars) - .map(|_| solver.new_var_default()) + for clause in encoding.groups.iter().flat_map(|group| &group.clauses) { + let mut literals: Vec<_> = clause + .iter() + .map(|&literal| { + let index = literal.unsigned_abs() as usize - 1; + Lit::new(variables[index], literal > 0) + }) .collect(); - let mut clauses_consistent = true; - - for clause in encoding.groups.iter().flat_map(|group| &group.clauses) { - let mut literals: Vec<_> = clause - .iter() - .map(|&literal| Self::to_batsat_literal(&variables, literal)) - .collect(); - if !solver.add_clause_reuse(&mut literals) { - clauses_consistent = false; - break; - } - } - - Self { - solver, - variables, - num_primary_vars, - clauses_consistent, + if !solver.add_clause_reuse(&mut literals) { + return SolverAnswer::Unsat; } } - fn to_batsat_literal(variables: &[Var], literal: i32) -> Lit { - let index = literal.unsigned_abs() as usize - 1; - Lit::new(variables[index], literal > 0) - } - - fn solve(&mut self, assumption: Option) -> SolverAnswer { - if !self.clauses_consistent { - return SolverAnswer::Unsat; - } - let assumptions: Vec<_> = assumption - .map(|literal| Self::to_batsat_literal(&self.variables, literal)) - .into_iter() - .collect(); - let answer = self.solver.solve_limited(&assumptions); - if answer == lbool::TRUE { - SolverAnswer::Sat( - self.variables[..self.num_primary_vars] - .iter() - .map(|&variable| self.solver.value_var(variable) == lbool::TRUE) - .collect(), - ) - } else if answer == lbool::FALSE { - SolverAnswer::Unsat - } else { - SolverAnswer::Unknown - } + let answer = solver.solve_limited(&[]); + if answer == lbool::TRUE { + SolverAnswer::Sat( + variables[..num_primary_vars] + .iter() + .map(|&variable| solver.value_var(variable) == lbool::TRUE) + .collect(), + ) + } else if answer == lbool::FALSE { + SolverAnswer::Unsat + } else { + SolverAnswer::Unknown } } @@ -1307,60 +1222,6 @@ mod tests { assert_eq!(problem.verify_witness(&second.witness), Ok(3)); } - #[test] - fn incremental_matches_reference_path_on_seeded_small_problems() { - use rand::rngs::SmallRng; - use rand::{RngExt, SeedableRng}; - - const NUM_CASES: usize = 128; - const NUM_VARS: usize = 8; - - let mut rng = SmallRng::seed_from_u64(0x19C0_5EED); - for case_index in 0..NUM_CASES { - let random_rows = |rng: &mut SmallRng, count: usize| -> Vec> { - (0..count) - .map(|_| { - (0..NUM_VARS) - .map(|_| u8::from(rng.random_bool(0.4))) - .collect() - }) - .collect() - }; - let h_count = rng.random_range(1..=3); - let l_count = rng.random_range(1..=2); - let h_rows = random_rows(&mut rng, h_count); - let l_rows = random_rows(&mut rng, l_count); - let (Ok(h), Ok(l)) = ( - ParityCheckMatrix::from_dense(h_rows.clone()), - ParityCheckMatrix::from_dense(l_rows.clone()), - ) else { - continue; - }; - let problem = DistanceProblem::from_css_checks(&h, &l).unwrap(); - - for max_weight in [2usize, NUM_VARS] { - let incremental = certified_distance(&problem, max_weight).unwrap(); - let reference = problem - .certify_distance_with(max_weight, |dimacs, _| { - exhaustive_dimacs_answer(&problem, dimacs) - }) - .unwrap(); - assert_eq!( - incremental.as_ref().map(|c| c.distance), - reference.as_ref().map(|c| c.distance), - "distance diverged for seeded case {case_index} at max_weight {max_weight}: H {h_rows:?} L {l_rows:?}" - ); - if let Some(certified) = &incremental { - assert_eq!( - problem.verify_witness(&certified.witness), - Ok(certified.distance), - "incremental witness failed native verification for case {case_index}" - ); - } - } - } - } - fn bb_circulant(l: usize, m: usize, terms: &[(usize, usize)]) -> F2Matrix { let size = l * m; let mut matrix = F2Matrix::zeros(size, size); @@ -1439,21 +1300,9 @@ mod tests { }) .unwrap() .unwrap(); - println!( - "BB [[72,12,6]] fresh-path total: {:?}", - total_started.elapsed() - ); + println!("BB [[72,12,6]] total: {:?}", total_started.elapsed()); assert_eq!(certified.distance, 6); assert_eq!(problem.verify_witness(&certified.witness), Ok(6)); - - let incremental_started = Instant::now(); - let incremental = certified_distance(&problem, 6).unwrap().unwrap(); - println!( - "BB [[72,12,6]] incremental total: {:?}", - incremental_started.elapsed() - ); - assert_eq!(incremental.distance, 6); - assert_eq!(problem.verify_witness(&incremental.witness), Ok(6)); } #[test] From c1bfb99d7c356a13b683b5e7226855420e114dc9 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 16:22:06 -0600 Subject: [PATCH 20/41] Document the fault-tolerance analysis and distance certification tooling --- docs/user-guide/fault-tolerance-analysis.md | 371 ++++++++++++++++++ .../stabilizer-code-verification.md | 7 +- mkdocs.yml | 1 + 3 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 docs/user-guide/fault-tolerance-analysis.md diff --git a/docs/user-guide/fault-tolerance-analysis.md b/docs/user-guide/fault-tolerance-analysis.md new file mode 100644 index 000000000..fd0b84568 --- /dev/null +++ b/docs/user-guide/fault-tolerance-analysis.md @@ -0,0 +1,371 @@ + + +# Fault-Tolerance Analysis and Distance Certification + +This guide covers fault-distance analysis for detector error models and +circuits, together with SAT-backed exact-distance certification. These tools +answer different questions from code-distance searches, so the first step is +choosing the level that matches the object being analyzed. + +## What You'll Learn + +- Distinguishing code, circuit, and detector-error-model distance +- Finding graphlike and general DEM fault distance +- Diagnosing hook errors and measuring circuit fault distance +- Checking the propagated-fault half of the t-flag condition +- Certifying CSS-code and DEM distance with an independently checked witness +- Exporting SAT and MaxSAT instances for external solvers + +```hidden-python +from pecos.qec import ( + CircuitFaultAnalyzer, + DetectorErrorModel, + DistanceProblem, + certified_distance, +) +from pecos.quantum import ParityCheckMatrix, TickCircuit + + +def repetition_triad_dem(): + circuit = TickCircuit() + circuit.tick().mz([0, 1, 2]) + circuit.add_detector(records=[-3, -2]) + circuit.add_detector(records=[-3, -1]) + circuit.add_observable(records=[-3]) + return DetectorErrorModel.from_circuit( + circuit, + p1=0.0, + p2=0.0, + p_meas=0.01, + p_prep=0.0, + ) + + +def hook_ladder(): + circuit = TickCircuit() + circuit.tick().pz([3]) + circuit.tick().cx([(3, 0)]) + circuit.tick().cx([(3, 1)]) + circuit.tick().cx([(3, 2)]) + circuit.tick().mz([3]) + return circuit + + +def unequal_logical_distance_circuit(): + circuit = TickCircuit() + circuit.tick().pz([0]) + circuit.tick().pz([1]) + circuit.tick().h([0, 1]) + circuit.tick().cx([(1, 0)]) + circuit.tick().pz([2]) + circuit.tick().h([2]) + return circuit + + +def weight_four_x_measurement(*, with_flag): + circuit = TickCircuit() + circuit.tick().pz([4]) + circuit.tick().h([4]) + if with_flag: + circuit.tick().pz([5]) + circuit.tick().cx([(4, 0)]) + if with_flag: + circuit.tick().cx([(4, 5)]) + circuit.tick().cx([(4, 1)]) + circuit.tick().cx([(4, 2)]) + if with_flag: + circuit.tick().cx([(4, 5)]) + circuit.tick().cx([(4, 3)]) + circuit.tick().h([4]) + circuit.tick().mz([4]) + if with_flag: + circuit.tick().mz([5]) + return circuit + + +def steane_distance_problem(): + hamming = ParityCheckMatrix( + [ + [1, 0, 1, 0, 1, 0, 1], + [0, 1, 1, 0, 0, 1, 1], + [0, 0, 0, 1, 1, 1, 1], + ] + ) + logical = ParityCheckMatrix([[1, 1, 1, 1, 1, 1, 1]]) + return DistanceProblem.from_css_checks(hamming, logical) +``` + +## Three Levels of Distance + +Distance depends on what counts as one fault and what information is retained: + +| Level | Minimum counted object | PECOS tool | Question answered | +|-------|------------------------|------------|-------------------| +| Code | Data-qubit Pauli weight | `StabilizerCodeSpec.distance()` or `DistanceProblem.from_css_checks()` | What is the minimum weight of an undetectable logical Pauli? | +| Circuit | Faulty gate locations | `CircuitFaultAnalyzer.fault_distance()` | How many faults in this implementation can cause an undetected logical error? | +| DEM | Error mechanisms | `DetectorErrorModel.graphlike_fault_distance()`, `exhaustive_fault_distance()`, or `DistanceProblem.from_dem()` | How many modeled mechanisms produce no detector events but flip an observable? | + +Code distance is not circuit distance. A single ancilla fault can propagate +through later two-qubit gates into a multi-qubit data error called a *hook +error*. Code distance counts the resulting data error's weight; circuit +distance counts the one faulty location that created it. Use code-level tools +to study the stabilizer code, `CircuitFaultAnalyzer` to study a concrete gate +schedule, and DEM tools to study the detector-and-observable abstraction +produced by a noise model. + +## Detector-Error-Model Fault Distance + +A `DetectorErrorModel` (DEM) describes independent error mechanisms by the +detectors and logical observables they flip. The following three measurement +faults form a repetition-triad model: each pair is distinguishable by +detectors, but all three together cancel the detectors and flip the logical +observable. + +```python +dem = repetition_triad_dem() +graphlike = dem.graphlike_fault_distance() +exhaustive = dem.exhaustive_fault_distance(3) + +assert graphlike is not None +assert exhaustive is not None +assert graphlike.distance == exhaustive.distance == 3 +assert graphlike.mechanism_indices == exhaustive.mechanism_indices +assert len(graphlike.mechanism_indices) == 3 +assert graphlike.mechanism_indices == sorted(graphlike.mechanism_indices) +``` + +`mechanism_indices` is a witness: selecting those DEM mechanisms produces an +undetectable logical failure. `graphlike_fault_distance()` is specialized to +models in which every mechanism touches at most two detectors. +`exhaustive_fault_distance(max_weight)` handles general mechanisms and returns +`None` when it finds no logical failure through the requested weight. + +The graphlike method fails fast instead of silently approximating a model with +hyperedges. Here one measurement fault flips three detectors: + +```python +circuit = TickCircuit() +circuit.tick().mz([0]) +for _ in range(3): + circuit.add_detector(records=[-1]) + +hypergraph_dem = DetectorErrorModel.from_circuit( + circuit, + p1=0.0, + p2=0.0, + p_meas=0.1, + p_prep=0.0, +) + +try: + hypergraph_dem.graphlike_fault_distance() +except ValueError as error: + message = str(error) +else: + raise AssertionError("graphlike distance should reject a hyperedge") +assert "found 1 hyperedge mechanism(s)" in message +``` + +Use the exhaustive method or the SAT formulation below when hyperedges are +part of the intended model. + +## Circuit Fault Analysis + +`CircuitFaultAnalyzer` injects Pauli faults at circuit locations and propagates +them through a `TickCircuit`. Its methods accept ancilla and logical supports +as qubit-index lists. A logical is an `(x_support, z_support)` pair. + +### Diagnosing Hook Errors + +In this three-data-qubit CX ladder, an X fault on the ancilla output of the +first CX propagates through the remaining CX gates. The report identifies the +responsible gate, tick, and qubits: + +```python +report = CircuitFaultAnalyzer(hook_ladder()).hook_errors( + [0, 1, 2], + [3], + [], + [], + 2, +) +hook = next(error for error in report.hook_errors if error.location.tick == 1 and error.fault_paulis == [1, 0]) + +assert hook.location.gate_type == "CX" +assert hook.location.gate_index == 0 +assert hook.location.qubits == [3, 0] +assert hook.data_weight >= 2 +print(f"tick={hook.location.tick}, gate={hook.location.gate_type}, qubits={hook.location.qubits}") +``` + +```text +tick=1, gate=CX, qubits=[3, 0] +``` + +The integer Pauli labels in `fault_paulis` describe the injected Pauli on each +gate operand. `data_support`, `data_weight`, `detected`, and +`causes_logical_error` describe its propagated effect. + +### Overall and Per-Logical Fault Distance + +`fault_distance()` returns the first minimum-weight witness across all supplied +logicals. `per_logical_fault_distances()` preserves the distinction between +logical observables. In this small circuit, the two logicals have fault +distances one and two: + +```python +analyzer = CircuitFaultAnalyzer(unequal_logical_distance_circuit()) +logicals = [([2], []), ([0], [])] + +per_logical = analyzer.per_logical_fault_distances( + [], + [1], + logicals, + 2, + x_only=True, +) +overall = analyzer.fault_distance([], [1], logicals, 2, x_only=True) + +assert [result.distance if result is not None else None for result in per_logical] == [1, 2] +assert overall is not None +assert overall.distance == 1 +assert overall.logical_index == 0 +assert len(overall.witness) == 1 +``` + +A `None` result means no qualifying fault combination was found through +`max_weight`; it is not a proof that no higher-weight combination exists. + +### Checking the Flag-Fault Condition + +For a weight-four X measurement, placing a flag interaction pair around the +middle data interactions catches the propagated hook that the unflagged +circuit permits: + +```python +flagged = CircuitFaultAnalyzer(weight_four_x_measurement(with_flag=True)) +flagged_report = flagged.flag_fault_condition( + [0, 1, 2, 3], + [5], + ([0, 1, 2, 3], []), + 1, +) + +unflagged = CircuitFaultAnalyzer(weight_four_x_measurement(with_flag=False)) +unflagged_report = unflagged.flag_fault_condition( + [0, 1, 2, 3], + [], + ([0, 1, 2, 3], []), + 1, +) + +assert flagged_report.fault_condition_satisfied +assert flagged_report.violations == [] +assert not unflagged_report.fault_condition_satisfied +assert any(violation.num_faults == 1 and violation.error_weight == 2 for violation in unflagged_report.violations) +``` + +This method checks only the **propagated-fault half** of the t-flag condition: +dangerous propagated data errors must raise a flag. It does not establish the +separate fault-free behavior. You must verify independently that the flag does +not fire when the circuit has no faults. + +## Certified Exact Distance + +`DistanceProblem` represents a binary selection problem: choose mechanisms or +qubits that satisfy every detection check and have a nonzero logical effect, +while minimizing the number selected. + +### Certifying CSS Distance + +For CSS distance, `from_css_checks(hx, lx)` takes the detection-check matrix +and logical-effect matrix. The Steane code uses the Hamming matrix for the +checks and an all-ones logical row: + +```python +problem = steane_distance_problem() +result = problem.certified_distance(3) + +assert result is not None +assert result.distance == 3 +assert result.sat_certified +assert result.unsat_trusted_below == 3 +assert problem.verify_witness(result.witness) == 3 +assert problem.certified_distance(2) is None +``` + +The trust split is deliberate. The SAT half is natively verified: +`verify_witness()` independently checks that the returned assignment obeys the +parity constraints, has logical effect, and has the reported weight. The UNSAT +half is solver-trusted: excluding every lower weight, and therefore the claim +of exact minimality, relies on the in-process SAT solver's UNSAT answers. + +`verify_witness()` also rejects malformed or invalid assignments: + +```python +problem = steane_distance_problem() +result = certified_distance(problem, 3) +assert result is not None + +corrupted = result.witness.copy() +corrupted[0] = not corrupted[0] +try: + problem.verify_witness(corrupted) +except ValueError as error: + message = str(error) +else: + raise AssertionError("a corrupted witness should be rejected") +assert "witness violates H row 0" in message +``` + +The module-level `certified_distance(problem, max_weight)` function and the +method on `DistanceProblem` are equivalent entry points. + +### Certifying DEM Distance + +`from_dem()` applies the same exact formulation to DEM mechanisms, including +hyperedges: + +```python +dem = repetition_triad_dem() +problem = DistanceProblem.from_dem(dem) +result = problem.certified_distance(3) +exhaustive = dem.exhaustive_fault_distance(3) + +assert result is not None +assert exhaustive is not None +assert result.distance == exhaustive.distance == 3 +assert problem.verify_witness(result.witness) == 3 +assert problem.certified_distance(2) is None +``` + +### Exporting Solver Instances + +`to_dimacs(max_weight)` exports a bounded CNF decision problem. `to_wcnf()` +exports the unbounded minimization problem as weighted CNF, with one soft +clause per selected variable: + +```python +problem = steane_distance_problem() +dimacs = problem.to_dimacs(3) +wcnf = problem.to_wcnf() + +dimacs_header = next(line for line in dimacs.splitlines() if not line.startswith("c ")) +wcnf_header = next(line for line in wcnf.splitlines() if not line.startswith("c ")) + +assert dimacs_header.startswith("p cnf ") +assert wcnf_header.startswith("p wcnf ") +assert sum(line.startswith("1 -") for line in wcnf.splitlines()) == problem.num_vars +``` + +These strings can be written to files and passed to external SAT or MaxSAT +solvers. Measured in-process capability is strong for medium instances: the +bivariate bicycle `[[72,12,6]]` code certifies in under a second, while +`[[144,12,12]]` takes roughly 15 minutes. Larger instances warrant exporting +WCNF to a branch-and-bound MaxSAT solver. + +## Next Steps + +- **[Stabilizer-Code Verification](stabilizer-code-verification.md)** - Build codes and search directly for low-weight logical Paulis +- **[Fault Tolerance Analysis](fault-tolerance.md)** - Explore the lower-level Rust checkers and gadget analysis +- **[Fault Catalog Tutorial](fault-catalog.md)** - Construct and inspect circuit fault catalogs diff --git a/docs/user-guide/stabilizer-code-verification.md b/docs/user-guide/stabilizer-code-verification.md index 7e2da558d..8eec1a24d 100644 --- a/docs/user-guide/stabilizer-code-verification.md +++ b/docs/user-guide/stabilizer-code-verification.md @@ -12,7 +12,7 @@ operators. - Discovering logical operators and calculating code distance - Searching a range of low-weight logical operators - Importing CSS and symplectic check matrices -- Choosing between the two exact distance methods +- Choosing among direct, DEM, and SAT-certified distance methods ```hidden-python import re @@ -354,12 +354,15 @@ As with CSS ingestion, width mismatches and anticommuting rows raise ## Choosing a Distance Method -Two exact distance calculations serve different regimes: +Distance calculations serve different objects and regimes: | Method | Search strategy | Best use | |--------|-----------------|----------| | `StabilizerCode.distance()` | Enumerates stabilizer/logical cosets | Tiny codes with small generator counts | | `StabilizerCodeSpec.distance()` | Enumerates Paulis by increasing weight | Codes whose distance is small relative to their length | +| [`DistanceProblem.certified_distance()`](fault-tolerance-analysis.md#certified-exact-distance) | SAT search with a natively checked witness | Exact CSS-code or DEM distance with an auditable SAT witness | +| [`DetectorErrorModel.graphlike_fault_distance()`](fault-tolerance-analysis.md#detector-error-model-fault-distance) | Shortest path through graphlike mechanisms | Fast DEM distance when every mechanism touches at most two detectors | +| [`DetectorErrorModel.exhaustive_fault_distance()`](fault-tolerance-analysis.md#detector-error-model-fault-distance) | Enumerates mechanism combinations by weight | Small DEMs with hyperedges or a tight search bound | The coset method is a useful oracle for tiny built-in codes. The spec method supports `max_weight` as a search budget and `verbose=True` to print diff --git a/mkdocs.yml b/mkdocs.yml index c57c08b11..9d1a27459 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -68,6 +68,7 @@ nav: - Stabilizer Codes: user-guide/stabilizer-codes.md - Pauli Algebra and QEC in Python: user-guide/python-pauli-qec.md - Stabilizer-Code Verification: user-guide/stabilizer-code-verification.md + - Fault-Tolerance Analysis and Distance Certification: user-guide/fault-tolerance-analysis.md - Fault Tolerance Analysis: user-guide/fault-tolerance.md - Fault Catalog Tutorial: user-guide/fault-catalog.md - QEC Geometry: user-guide/qec-geometry.md From ac9e73e8c455acb50e32be8d8d6c339be980794f Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 17:10:12 -0600 Subject: [PATCH 21/41] Add per-observable DEM fault distances and non-CSS symplectic distance certification --- crates/pecos-qec/src/distance_problem.rs | 245 ++++++++++++++++-- crates/pecos-qec/src/fault_tolerance.rs | 2 +- .../src/fault_tolerance/fault_distance.rs | 142 ++++++++-- crates/pecos-qec/src/lib.rs | 3 +- docs/user-guide/fault-tolerance-analysis.md | 9 +- .../stabilizer-code-verification.md | 1 + python/pecos-rslib/pecos_rslib/qec.pyi | 2 + .../src/fault_tolerance_bindings.rs | 20 ++ .../tests/qec/test_fault_distance.py | 68 ++++- 9 files changed, 447 insertions(+), 45 deletions(-) diff --git a/crates/pecos-qec/src/distance_problem.rs b/crates/pecos-qec/src/distance_problem.rs index 435a52965..bf0855a36 100644 --- a/crates/pecos-qec/src/distance_problem.rs +++ b/crates/pecos-qec/src/distance_problem.rs @@ -38,6 +38,13 @@ pub struct DistanceProblem { h: F2Matrix, l: F2Matrix, num_vars: usize, + weight_mode: WeightMode, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WeightMode { + Bit, + QubitSupport { num_qubits: usize }, } /// Errors constructing a [`DistanceProblem`]. @@ -170,6 +177,7 @@ struct Encoding { num_vars: usize, aux_ranges: Vec<(usize, usize, &'static str)>, groups: Vec, + objective_variables: Vec, } #[derive(Debug)] @@ -178,6 +186,7 @@ struct EncodingBuilder { aux_ranges: Vec<(usize, usize, &'static str)>, parity_clauses: Vec>, nontriviality_clauses: Vec>, + support_clauses: Vec>, cardinality_clauses: Vec>, } @@ -188,6 +197,7 @@ impl EncodingBuilder { aux_ranges: Vec::new(), parity_clauses: Vec::new(), nontriviality_clauses: Vec::new(), + support_clauses: Vec::new(), cardinality_clauses: Vec::new(), } } @@ -203,7 +213,7 @@ impl EncodingBuilder { (start..=end).collect() } - fn finish(self, include_cardinality: bool) -> Encoding { + fn finish(self, include_cardinality: bool, objective_variables: Vec) -> Encoding { let mut groups = vec![ClauseGroup { description: "parity constraints (Tseitin XOR chains)", clauses: self.parity_clauses, @@ -212,6 +222,12 @@ impl EncodingBuilder { description: "logical nontriviality", clauses: self.nontriviality_clauses, }); + if !self.support_clauses.is_empty() { + groups.push(ClauseGroup { + description: "qubit-support indicators (Tseitin OR gates)", + clauses: self.support_clauses, + }); + } if include_cardinality { groups.push(ClauseGroup { description: "weight bound (Sinz sequential counter)", @@ -222,6 +238,7 @@ impl EncodingBuilder { num_vars: self.next_var - 1, aux_ranges: self.aux_ranges, groups, + objective_variables, } } } @@ -247,6 +264,7 @@ impl DistanceProblem { h: h.matrix().clone(), l: l.matrix().clone(), num_vars: h.num_qubits(), + weight_mode: WeightMode::Bit, }) } @@ -314,9 +332,76 @@ impl DistanceProblem { h: Self::matrix_from_rows(checks, num_qubits), l: Self::matrix_from_rows(logicals, num_qubits), num_vars: num_qubits, + weight_mode: WeightMode::Bit, + }) + } + + /// Constructs the full symplectic distance problem for an arbitrary stabilizer code spec. + /// + /// The `2n` primary variables use `[X|Z]` order. Each `H` row is the symplectic product with + /// one stabilizer, and the `L` rows are the symplectic products with every logical Z followed + /// by every logical X. Weight is physical-qubit support: a qubit contributes once when either + /// or both of its X and Z variables are selected. + /// + /// # Errors + /// + /// Returns [`DistanceProblemError::QubitOutOfRange`] if a stabilizer or logical operator acts + /// outside the code width. + pub fn from_stabilizer_spec(code: &StabilizerCodeSpec) -> Result { + let num_qubits = code.num_qubits(); + let checks = code + .stabilizers() + .iter() + .enumerate() + .map(|(index, operator)| { + Self::symplectic_commutation_row(operator, "stabilizer", index, num_qubits) + }) + .collect::, _>>()?; + let logicals = code + .logical_zs() + .iter() + .enumerate() + .map(|(index, operator)| { + Self::symplectic_commutation_row(operator, "logical Z", index, num_qubits) + }) + .chain( + code.logical_xs() + .iter() + .enumerate() + .map(|(index, operator)| { + Self::symplectic_commutation_row(operator, "logical X", index, num_qubits) + }), + ) + .collect::, _>>()?; + let num_vars = 2 * num_qubits; + Ok(Self { + h: Self::matrix_from_rows(checks, num_vars), + l: Self::matrix_from_rows(logicals, num_vars), + num_vars, + weight_mode: WeightMode::QubitSupport { num_qubits }, }) } + fn symplectic_commutation_row( + operator: &pecos_core::PauliString, + component: &'static str, + index: usize, + num_qubits: usize, + ) -> Result, DistanceProblemError> { + let x = operator.x_positions(); + let z = operator.z_positions(); + Self::validate_positions(&x, component, index, num_qubits)?; + Self::validate_positions(&z, component, index, num_qubits)?; + let mut row = vec![0; 2 * num_qubits]; + for qubit in z { + row[qubit] = 1; + } + for qubit in x { + row[num_qubits + qubit] = 1; + } + Ok(row) + } + fn css_logical_rows( operators: &[pecos_core::PauliString], component: &'static str, @@ -415,7 +500,12 @@ impl DistanceProblem { l.set(observable as usize, column, 1); } } - Self { h, l, num_vars } + Self { + h, + l, + num_vars, + weight_mode: WeightMode::Bit, + } } /// Returns the number of original decision variables. @@ -465,7 +555,7 @@ impl DistanceProblem { .iter() .map(|group| group.clauses.len()) .sum(); - let clause_count = hard_count + self.num_vars; + let clause_count = hard_count + encoding.objective_variables.len(); let mut output = String::new(); writeln!( output, @@ -488,7 +578,7 @@ impl DistanceProblem { "c soft clause group: unit penalties for selected variables" ) .expect("writing to String cannot fail"); - for variable in 1..=self.num_vars { + for &variable in &encoding.objective_variables { writeln!(output, "1 -{variable} 0").expect("writing to String cannot fail"); } output @@ -519,10 +609,38 @@ impl DistanceProblem { let mut builder = EncodingBuilder::new(self.num_vars); self.encode_checks(&mut builder); self.encode_logical_nontriviality(&mut builder); + let objective_variables = self.encode_weight_variables(&mut builder); if let Some(max_weight) = max_weight { - self.encode_sequential_counter(&mut builder, max_weight); + Self::encode_sequential_counter(&mut builder, max_weight, &objective_variables); + } + builder.finish(max_weight.is_some(), objective_variables) + } + + fn encode_weight_variables(&self, builder: &mut EncodingBuilder) -> Vec { + match self.weight_mode { + WeightMode::Bit => (1..=self.num_vars).collect(), + WeightMode::QubitSupport { num_qubits } => { + let indicators = + builder.allocate_range(num_qubits, "physical-qubit support indicators"); + for (qubit, &indicator) in indicators.iter().enumerate() { + let x = qubit + 1; + let z = num_qubits + qubit + 1; + let indicator = Self::literal(indicator); + builder + .support_clauses + .push(vec![-Self::literal(x), indicator]); + builder + .support_clauses + .push(vec![-Self::literal(z), indicator]); + builder.support_clauses.push(vec![ + Self::literal(x), + Self::literal(z), + -indicator, + ]); + } + indicators + } } - builder.finish(max_weight.is_some()) } fn row_support(matrix: &F2Matrix, row: usize) -> Vec { @@ -618,12 +736,16 @@ impl DistanceProblem { clauses.push(vec![-left, right, output]); } - fn encode_sequential_counter(&self, builder: &mut EncodingBuilder, max_weight: usize) { - if max_weight >= self.num_vars { + fn encode_sequential_counter( + builder: &mut EncodingBuilder, + max_weight: usize, + inputs: &[usize], + ) { + if max_weight >= inputs.len() { return; } if max_weight == 0 { - for variable in 1..=self.num_vars { + for &variable in inputs { builder .cardinality_clauses .push(vec![-Self::literal(variable)]); @@ -636,27 +758,28 @@ impl DistanceProblem { // every auxiliary functionally determined by the primary variables. let threshold = max_weight + 1; let variables = builder.allocate_range( - self.num_vars * threshold, + inputs.len() * threshold, "Sinz sequential-counter prefix thresholds", ); let counter = |i: usize, j: usize| variables[(i - 1) * threshold + (j - 1)]; // First prefix: s[1,1] <-> x_1; all unreachable higher thresholds are false. let first = counter(1, 1); + let first_input = Self::literal(inputs[0]); builder .cardinality_clauses - .push(vec![-1, Self::literal(first)]); + .push(vec![-first_input, Self::literal(first)]); builder .cardinality_clauses - .push(vec![1, -Self::literal(first)]); + .push(vec![first_input, -Self::literal(first)]); for j in 2..=threshold { builder .cardinality_clauses .push(vec![-Self::literal(counter(1, j))]); } - for i in 2..=self.num_vars { - let x = Self::literal(i); + for i in 2..=inputs.len() { + let x = Self::literal(inputs[i - 1]); // s[i,1] <-> (s[i-1,1] OR x_i). let previous = Self::literal(counter(i - 1, 1)); let current = Self::literal(counter(i, 1)); @@ -681,7 +804,7 @@ impl DistanceProblem { } builder .cardinality_clauses - .push(vec![-Self::literal(counter(self.num_vars, threshold))]); + .push(vec![-Self::literal(counter(inputs.len(), threshold))]); } /// Checks a candidate witness with native GF(2) arithmetic and returns its Hamming weight. @@ -723,7 +846,12 @@ impl DistanceProblem { if !logical_nonzero { return Err(WitnessError::ZeroLogicalEffect); } - Ok(assignment.iter().filter(|&&selected| selected).count()) + Ok(match self.weight_mode { + WeightMode::Bit => assignment.iter().filter(|&&selected| selected).count(), + WeightMode::QubitSupport { num_qubits } => (0..num_qubits) + .filter(|&qubit| assignment[qubit] || assignment[num_qubits + qubit]) + .count(), + }) } /// Incrementally certifies distance through `max_weight` using a pluggable SAT solver. @@ -848,10 +976,11 @@ fn solve_with_batsat(encoding: &Encoding, num_primary_vars: usize) -> SolverAnsw mod tests { use super::*; use crate::{ - DistanceSearchConfig, FaultMechanism, StabilizerCode, calculate_distance, + DemOutput, DistanceSearchConfig, FaultMechanism, StabilizerCode, calculate_distance, connected_cluster_fault_distance, exhaustive_fault_distance, }; - use pecos_core::pauli::{Xs, Zs}; + use pecos_core::pauli::{X, Xs, Ys, Z, Zs}; + use pecos_quantum::SymplecticMatrix; use std::time::Instant; #[derive(Debug)] @@ -985,6 +1114,7 @@ mod tests { fn repetition_triad_dem() -> DetectorErrorModel { let mut dem = DetectorErrorModel::new(); + dem.add_observable(DemOutput::new(0)); for (detectors, observables) in [(vec![0, 1], vec![0]), (vec![0], vec![]), (vec![1], vec![])] { @@ -1011,6 +1141,15 @@ mod tests { DistanceProblem::from_css_checks(&h, &logical).unwrap() } + fn tiny_non_css_spec() -> StabilizerCodeSpec { + StabilizerCodeSpec::builder(2) + .check(Ys([0, 1])) + .logical_z(Zs([0, 1])) + .logical_x(X(0) & Z(1)) + .build_verified() + .unwrap() + } + #[test] fn dimacs_encoding_matches_native_predicate_for_every_small_assignment() { let cases = [ @@ -1051,6 +1190,38 @@ mod tests { } } + #[test] + fn symplectic_dimacs_encoding_matches_qubit_support_predicate() { + let problem = DistanceProblem::from_stabilizer_spec(&tiny_non_css_spec()).unwrap(); + assert_eq!(problem.num_vars(), 4); + + for bound in 0..=2 { + let cnf = parse_dimacs(&problem.to_dimacs(bound)); + for mask in 0..1 << problem.num_vars() { + let candidate = assignment(mask, problem.num_vars()); + let direct = problem + .verify_witness(&candidate) + .is_ok_and(|weight| weight <= bound); + assert_eq!( + cnf_satisfied_with_primary(&cnf, &candidate), + direct, + "assignment {mask:#b} at qubit-support bound {bound}" + ); + } + } + + // Y on qubit 0 selects both symplectic bits but has physical support weight one. + assert_eq!(problem.verify_witness(&[true, false, true, false]), Ok(1)); + assert_eq!( + problem + .to_wcnf() + .lines() + .filter(|line| line.starts_with("1 -")) + .count(), + 2 + ); + } + #[test] fn steane_hamming_problem_matches_existing_distance_search() { let h = steane_hamming_matrix(); @@ -1078,8 +1249,10 @@ mod tests { } #[test] - fn non_css_spec_is_rejected_without_projection() { + fn css_projection_constructors_reject_non_css_spec() { let spec = StabilizerCodeSpec::from_stabilizer_code(&StabilizerCode::five_qubit()).unwrap(); + // Full non-CSS codes are supported by `from_stabilizer_spec`; only the CSS projections + // reject mixed operators instead of silently dropping half of their support. assert!(matches!( DistanceProblem::from_css_code_x_distance(&spec), Err(DistanceProblemError::NonCssOperator { .. }) @@ -1090,6 +1263,40 @@ mod tests { )); } + #[test] + fn batsat_certifies_five_qubit_symplectic_distance_and_logical_witness() { + let mut spec = + StabilizerCodeSpec::from_stabilizer_code(&StabilizerCode::five_qubit()).unwrap(); + let calculated = spec.calculate_distance().unwrap().distance; + let oracle = spec.distance().unwrap(); + assert_eq!(calculated, oracle); + assert_eq!(oracle, 3); + + let problem = DistanceProblem::from_stabilizer_spec(&spec).unwrap(); + let certified = certified_distance(&problem, oracle).unwrap().unwrap(); + assert_eq!(certified.distance, oracle); + assert_eq!(problem.verify_witness(&certified.witness), Ok(oracle)); + + let mut witness_paulis = SymplecticMatrix::from_dense(vec![ + certified.witness.iter().map(|&bit| u8::from(bit)).collect(), + ]) + .unwrap() + .to_positive_paulis(); + let witness = witness_paulis.pop().unwrap(); + assert!( + spec.stabilizers() + .iter() + .all(|stabilizer| witness.commutes_with(stabilizer)) + ); + assert!( + spec.logical_zs() + .iter() + .chain(spec.logical_xs()) + .any(|logical| !witness.commutes_with(logical)) + ); + assert!(spec.is_logical_error(&witness)); + } + #[test] fn dem_problem_matches_both_existing_fault_distance_searches() { let dem = repetition_triad_dem(); diff --git a/crates/pecos-qec/src/fault_tolerance.rs b/crates/pecos-qec/src/fault_tolerance.rs index 2220ff174..6ae230d37 100644 --- a/crates/pecos-qec/src/fault_tolerance.rs +++ b/crates/pecos-qec/src/fault_tolerance.rs @@ -49,7 +49,7 @@ pub use decoder_integration::{ }; pub use fault_distance::{ FaultDistanceError, FaultDistanceResult, connected_cluster_fault_distance, - exhaustive_fault_distance, graphlike_fault_distance, + exhaustive_fault_distance, graphlike_fault_distance, per_observable_fault_distances, }; pub use flag_verification::{FlagFaultToleranceReport, FlagViolation}; pub use gadget_checker::{ diff --git a/crates/pecos-qec/src/fault_tolerance/fault_distance.rs b/crates/pecos-qec/src/fault_tolerance/fault_distance.rs index e307d1519..aa968c094 100644 --- a/crates/pecos-qec/src/fault_tolerance/fault_distance.rs +++ b/crates/pecos-qec/src/fault_tolerance/fault_distance.rs @@ -124,6 +124,7 @@ struct ConnectedClusterSearch<'a> { mechanisms: &'a [FaultMechanism], incidence: &'a BTreeMap>, active: &'a [bool], + observable: u32, target_weight: usize, best: Option>, } @@ -213,7 +214,9 @@ impl ConnectedClusterSearch<'_> { } fn consider_witness(&mut self, cluster: &[usize], effect: &FaultMechanism) { - if !effect.detectors.is_empty() || effect.dem_outputs.is_empty() { + if !effect.detectors.is_empty() + || effect.dem_outputs.binary_search(&self.observable).is_err() + { return; } let mut candidate = cluster.to_vec(); @@ -435,7 +438,8 @@ pub fn exhaustive_fault_distance( None } -/// Computes exact fault distance up to `max_weight` using connected-cluster pruning. +/// Computes per-observable exact fault distances up to `max_weight` using connected-cluster +/// pruning. /// /// This is the preferred general-purpose search for real detector error models. It supports /// hyperedges and ignores mechanism probabilities. Before searching, it repeatedly peels every @@ -447,41 +451,76 @@ pub fn exhaustive_fault_distance( /// indices, and a candidate skipped at one recursion level is excluded from later sibling /// branches. Thus every connected set has exactly one seed and one construction branch. Search is /// by increasing weight, with the lexicographically smallest original-index witness retained at -/// each weight, matching [`exhaustive_fault_distance`]. +/// each weight. Entry `i` is restricted to witnesses that flip observable `i`, and is `None` when +/// no such witness is found through `max_weight`. #[must_use] -pub fn connected_cluster_fault_distance( +pub fn per_observable_fault_distances( dem: &DetectorErrorModel, max_weight: usize, -) -> Option { +) -> Vec> { let mechanisms = mechanisms_from_dem(dem); let incidence = detector_incidence(&mechanisms); let active = peel_unique_detector_mechanisms(&mechanisms, &incidence); - for weight in 1..=max_weight.min(mechanisms.len()) { - let mechanism_indices = ConnectedClusterSearch { - mechanisms: &mechanisms, - incidence: &incidence, - active: &active, - target_weight: weight, - best: None, - } - .run(); - if let Some(mechanism_indices) = mechanism_indices { - return Some(FaultDistanceResult { - distance: weight, - mechanism_indices, - }); - } - } - None + (0..dem.num_observables()) + .map(|observable| { + for weight in 1..=max_weight.min(mechanisms.len()) { + let mechanism_indices = ConnectedClusterSearch { + mechanisms: &mechanisms, + incidence: &incidence, + active: &active, + observable: observable as u32, + target_weight: weight, + best: None, + } + .run(); + if let Some(mechanism_indices) = mechanism_indices { + return Some(FaultDistanceResult { + distance: weight, + mechanism_indices, + }); + } + } + None + }) + .collect() +} + +/// Computes exact fault distance up to `max_weight` using connected-cluster pruning. +/// +/// The result is the minimum of [`per_observable_fault_distances`], ordered first by distance and +/// then by the lexicographic mechanism-index witness. It is `None` only when no observable has a +/// witness through `max_weight`. +#[must_use] +pub fn connected_cluster_fault_distance( + dem: &DetectorErrorModel, + max_weight: usize, +) -> Option { + per_observable_fault_distances(dem, max_weight) + .into_iter() + .flatten() + .min_by(|left, right| { + (left.distance, &left.mechanism_indices) + .cmp(&(right.distance, &right.mechanism_indices)) + }) } #[cfg(test)] mod tests { use super::*; + use crate::DemOutput; fn dem_from_effects(effects: &[(Vec, Vec)]) -> DetectorErrorModel { let mut dem = DetectorErrorModel::new(); + let num_observables = effects + .iter() + .flat_map(|(_, observables)| observables) + .map(|&observable| observable as usize + 1) + .max() + .unwrap_or(0); + for observable in 0..num_observables { + dem.add_observable(DemOutput::new(observable as u32)); + } for (detectors, observables) in effects { dem.add_direct_contribution( FaultMechanism::from_unsorted( @@ -494,6 +533,65 @@ mod tests { dem } + fn observable_slice(dem: &DetectorErrorModel, observable: u32) -> DetectorErrorModel { + let (mechanisms, _coordinates) = dem.to_mechanisms(); + let mut sliced = DetectorErrorModel::new(); + sliced.add_observable(DemOutput::new(0)); + for (probability, detectors, observables) in mechanisms { + let outputs = observables + .binary_search(&observable) + .is_ok() + .then_some(0) + .into_iter(); + sliced.add_direct_contribution( + FaultMechanism::from_unsorted(detectors, outputs), + probability, + ); + } + sliced + } + + #[test] + fn per_observable_distances_differ_and_minimize_to_overall() { + let dem = dem_from_effects(&[ + (vec![0, 1], vec![0]), + (vec![0], vec![]), + (vec![1], vec![]), + (vec![2], vec![1]), + (vec![2], vec![]), + ]); + + let per_observable = per_observable_fault_distances(&dem, 3); + assert_eq!( + per_observable, + vec![ + Some(FaultDistanceResult { + distance: 3, + mechanism_indices: vec![0, 1, 2], + }), + Some(FaultDistanceResult { + distance: 2, + mechanism_indices: vec![3, 4], + }), + ] + ); + assert_eq!( + connected_cluster_fault_distance(&dem, 3), + per_observable + .into_iter() + .flatten() + .min_by_key(|result| result.distance) + ); + + for observable in 0..2 { + let sliced = observable_slice(&dem, observable); + assert_eq!( + per_observable_fault_distances(&sliced, 3)[0], + exhaustive_fault_distance(&sliced, 3), + ); + } + } + #[test] fn distance_one_detector_free_mechanism() { let dem = dem_from_effects(&[(vec![], vec![0])]); diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index da7194abd..50cbfbe34 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -104,7 +104,8 @@ pub use fault_tolerance::{ SyndromeHistoryResult, anticommutes_with_logical, apply_recovery, classify_fault, connected_cluster_fault_distance, exhaustive_fault_distance, extract_measurement_rounds, extract_spacetime_locations, extract_syndrome, get_syndrome_flips, graphlike_fault_distance, - has_syndrome, propagate_fault, propagate_faults, run_circuit_with_faults, run_correction_cycle, + has_syndrome, per_observable_fault_distances, propagate_fault, propagate_faults, + run_circuit_with_faults, run_correction_cycle, }; pub use geometry::{CheckSchedule, LogicalOperator, PauliOp, StabilizerCheck, StabilizerColor}; pub use logical_discovery::{ diff --git a/docs/user-guide/fault-tolerance-analysis.md b/docs/user-guide/fault-tolerance-analysis.md index fd0b84568..0c90b16ff 100644 --- a/docs/user-guide/fault-tolerance-analysis.md +++ b/docs/user-guide/fault-tolerance-analysis.md @@ -124,12 +124,15 @@ observable. ```python dem = repetition_triad_dem() graphlike = dem.graphlike_fault_distance() +connected = dem.connected_cluster_fault_distance(3) exhaustive = dem.exhaustive_fault_distance(3) assert graphlike is not None +assert connected is not None assert exhaustive is not None -assert graphlike.distance == exhaustive.distance == 3 +assert graphlike.distance == connected.distance == exhaustive.distance == 3 assert graphlike.mechanism_indices == exhaustive.mechanism_indices +assert connected.mechanism_indices == exhaustive.mechanism_indices assert len(graphlike.mechanism_indices) == 3 assert graphlike.mechanism_indices == sorted(graphlike.mechanism_indices) ``` @@ -137,6 +140,10 @@ assert graphlike.mechanism_indices == sorted(graphlike.mechanism_indices) `mechanism_indices` is a witness: selecting those DEM mechanisms produces an undetectable logical failure. `graphlike_fault_distance()` is specialized to models in which every mechanism touches at most two detectors. +`connected_cluster_fault_distance(max_weight)` supports hyperedges and prunes +the search to connected mechanism clusters. Its companion +`per_observable_fault_distances(max_weight)` returns one result per observable, +which is useful when logical observables have different effective distances. `exhaustive_fault_distance(max_weight)` handles general mechanisms and returns `None` when it finds no logical failure through the requested weight. diff --git a/docs/user-guide/stabilizer-code-verification.md b/docs/user-guide/stabilizer-code-verification.md index 8eec1a24d..b70c7a33b 100644 --- a/docs/user-guide/stabilizer-code-verification.md +++ b/docs/user-guide/stabilizer-code-verification.md @@ -362,6 +362,7 @@ Distance calculations serve different objects and regimes: | `StabilizerCodeSpec.distance()` | Enumerates Paulis by increasing weight | Codes whose distance is small relative to their length | | [`DistanceProblem.certified_distance()`](fault-tolerance-analysis.md#certified-exact-distance) | SAT search with a natively checked witness | Exact CSS-code or DEM distance with an auditable SAT witness | | [`DetectorErrorModel.graphlike_fault_distance()`](fault-tolerance-analysis.md#detector-error-model-fault-distance) | Shortest path through graphlike mechanisms | Fast DEM distance when every mechanism touches at most two detectors | +| [`DetectorErrorModel.connected_cluster_fault_distance()`](fault-tolerance-analysis.md#detector-error-model-fault-distance) | Enumerates connected mechanism clusters by weight | General DEMs with hyperedges where exhaustive subset search is too costly | | [`DetectorErrorModel.exhaustive_fault_distance()`](fault-tolerance-analysis.md#detector-error-model-fault-distance) | Enumerates mechanism combinations by weight | Small DEMs with hyperedges or a tight search bound | The coset method is a useful oracle for tiny built-in codes. The spec method diff --git a/python/pecos-rslib/pecos_rslib/qec.pyi b/python/pecos-rslib/pecos_rslib/qec.pyi index e72e9a20f..6e38d481e 100644 --- a/python/pecos-rslib/pecos_rslib/qec.pyi +++ b/python/pecos-rslib/pecos_rslib/qec.pyi @@ -31,6 +31,8 @@ class DetectorErrorModel: """Rust-backed detector error model.""" def graphlike_fault_distance(self) -> FaultDistanceResult | None: ... + def connected_cluster_fault_distance(self, max_weight: int) -> FaultDistanceResult | None: ... + def per_observable_fault_distances(self, max_weight: int) -> list[FaultDistanceResult | None]: ... def exhaustive_fault_distance(self, max_weight: int) -> FaultDistanceResult | None: ... def __getattr__(self, name: str) -> Any: ... diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index a18f09a91..60a6d40ba 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -66,8 +66,10 @@ use pecos_qec::fault_tolerance::dem_builder::{ }; use pecos_qec::fault_tolerance::fault_distance::{ FaultDistanceResult as RustFaultDistanceResult, + connected_cluster_fault_distance as rust_connected_cluster_fault_distance, exhaustive_fault_distance as rust_exhaustive_fault_distance, graphlike_fault_distance as rust_graphlike_fault_distance, + per_observable_fault_distances as rust_per_observable_fault_distances, }; use pecos_qec::fault_tolerance::influence_builder::InfluenceBuilder as RustInfluenceBuilder; use pecos_qec::fault_tolerance::propagator::{ @@ -1703,6 +1705,24 @@ impl PyDetectorErrorModel { .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) } + /// Compute exact fault distance up to an explicit mechanism-count budget using + /// connected-cluster pruning. + fn connected_cluster_fault_distance(&self, max_weight: usize) -> Option { + rust_connected_cluster_fault_distance(&self.inner, max_weight) + .map(PyFaultDistanceResult::from) + } + + /// Compute one connected-cluster fault distance per observable. + fn per_observable_fault_distances( + &self, + max_weight: usize, + ) -> Vec> { + rust_per_observable_fault_distances(&self.inner, max_weight) + .into_iter() + .map(|result| result.map(PyFaultDistanceResult::from)) + .collect() + } + /// Exhaustively compute fault distance up to an explicit mechanism-count budget. /// /// This supports hyperedges but has combinatorial cost in the number of mechanisms. diff --git a/python/quantum-pecos/tests/qec/test_fault_distance.py b/python/quantum-pecos/tests/qec/test_fault_distance.py index 5d5b2d5ff..865772661 100644 --- a/python/quantum-pecos/tests/qec/test_fault_distance.py +++ b/python/quantum-pecos/tests/qec/test_fault_distance.py @@ -15,6 +15,58 @@ import pytest +def _two_observable_dem(): + from pecos.qec import DetectorErrorModel + from pecos.quantum import TickCircuit + + circuit = TickCircuit() + circuit.tick().mz([0, 1, 2, 3, 4]) + circuit.add_detector(records=[-5, -4]) + circuit.add_detector(records=[-5, -3]) + circuit.add_detector(records=[-2, -1]) + circuit.add_observable(records=[-5]) + circuit.add_observable(records=[-2]) + return DetectorErrorModel.from_circuit( + circuit, + p1=0.0, + p2=0.0, + p_meas=0.01, + p_prep=0.0, + ) + + +def _triad_dem(): + from pecos.qec import DetectorErrorModel + from pecos.quantum import TickCircuit + + circuit = TickCircuit() + circuit.tick().mz([0, 1, 2]) + circuit.add_detector(records=[-3, -2]) + circuit.add_detector(records=[-3, -1]) + circuit.add_observable(records=[-3]) + return DetectorErrorModel.from_circuit( + circuit, + p1=0.0, + p2=0.0, + p_meas=0.01, + p_prep=0.0, + ) + + +def test_repetition_triad_agrees_across_all_fault_distance_methods() -> None: + dem = _triad_dem() + + graphlike = dem.graphlike_fault_distance() + connected = dem.connected_cluster_fault_distance(3) + exhaustive = dem.exhaustive_fault_distance(3) + assert graphlike is not None + assert connected is not None + assert exhaustive is not None + assert graphlike.distance == connected.distance == exhaustive.distance == 3 + assert graphlike.mechanism_indices == connected.mechanism_indices + assert connected.mechanism_indices == exhaustive.mechanism_indices + + def test_distance_three_rotated_surface_memory_cross_method_agreement() -> None: from pecos.qec import DetectorErrorModel, FaultDistanceResult from pecos.qec.surface import build_memory_circuit @@ -29,16 +81,30 @@ def test_distance_three_rotated_surface_memory_cross_method_agreement() -> None: ) graphlike = dem.graphlike_fault_distance() + connected = dem.connected_cluster_fault_distance(3) exhaustive = dem.exhaustive_fault_distance(3) assert isinstance(graphlike, FaultDistanceResult) + assert isinstance(connected, FaultDistanceResult) assert isinstance(exhaustive, FaultDistanceResult) - assert graphlike.distance == exhaustive.distance == 3 + assert graphlike.distance == connected.distance == exhaustive.distance == 3 assert graphlike.mechanism_indices == exhaustive.mechanism_indices + assert connected.mechanism_indices == exhaustive.mechanism_indices assert graphlike.mechanism_indices == sorted(graphlike.mechanism_indices) assert repr(graphlike).startswith("FaultDistanceResult(distance=3, mechanism_indices=[") +def test_two_observable_connected_cluster_distances_differ() -> None: + dem = _two_observable_dem() + + per_observable = dem.per_observable_fault_distances(3) + assert [result.distance if result is not None else None for result in per_observable] == [3, 2] + + overall = dem.connected_cluster_fault_distance(3) + assert overall is not None + assert overall.distance == 2 + + def test_graphlike_fault_distance_reports_hyperedge_count() -> None: from pecos.qec import DetectorErrorModel from pecos.quantum import TickCircuit From 3a50101c38f9419f367445952312b9cdb47b180c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 17:21:01 -0600 Subject: [PATCH 22/41] Deprecate VerifyStabilizers in favor of the StabilizerCodeSpec workflow --- .../docs/examples/stab_code_verification.rst | 9 +++++++++ .../pecos/analysis/stabilizer_verification.py | 9 +++++++++ .../test_verify_stabilizers_deprecation.py | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 python/quantum-pecos/tests/qec/test_verify_stabilizers_deprecation.py diff --git a/python/quantum-pecos/docs/examples/stab_code_verification.rst b/python/quantum-pecos/docs/examples/stab_code_verification.rst index 236b4c1c3..8f28797ca 100644 --- a/python/quantum-pecos/docs/examples/stab_code_verification.rst +++ b/python/quantum-pecos/docs/examples/stab_code_verification.rst @@ -1,6 +1,15 @@ Verifying a Stabilizer Code =========================== +.. note:: + + This example uses the deprecated ``VerifyStabilizers`` workflow. The + supported replacement is ``pecos.quantum.StabilizerCodeSpec`` — see the + "Stabilizer-Code Verification" page in the current user guide + (``docs/user-guide/stabilizer-code-verification.md``), which retells this + example on the maintained API. + + In this example we will see how ``VerifyStabilizers`` can be used to develop a simple, distance-three code. We begin by considering the generators in: diff --git a/python/quantum-pecos/src/pecos/analysis/stabilizer_verification.py b/python/quantum-pecos/src/pecos/analysis/stabilizer_verification.py index 0feb0974f..cd06c0997 100644 --- a/python/quantum-pecos/src/pecos/analysis/stabilizer_verification.py +++ b/python/quantum-pecos/src/pecos/analysis/stabilizer_verification.py @@ -20,6 +20,7 @@ from __future__ import annotations +import warnings from itertools import combinations, product from typing import TYPE_CHECKING @@ -44,6 +45,14 @@ def __init__(self) -> None: Sets up the circuit simulator and initializes empty data structures for stabilizer checks, logical operators, and qubit tracking. """ + warnings.warn( + "VerifyStabilizers is deprecated and will be removed in a future release. " + "Use pecos.quantum.StabilizerCodeSpec.builder(...) with " + "build_with_discovered_logicals() and spec.distance() instead; see the " + "'Stabilizer-Code Verification' page in the user guide.", + DeprecationWarning, + stacklevel=2, + ) self.circ_sim = pc.simulators.SparseStabPy self.checks = [] diff --git a/python/quantum-pecos/tests/qec/test_verify_stabilizers_deprecation.py b/python/quantum-pecos/tests/qec/test_verify_stabilizers_deprecation.py new file mode 100644 index 000000000..467a61045 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_verify_stabilizers_deprecation.py @@ -0,0 +1,19 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 + +"""VerifyStabilizers emits a DeprecationWarning pointing at the replacement.""" + +import pytest + + +def test_verify_stabilizers_warns_and_still_works() -> None: + from pecos.analysis import VerifyStabilizers + + with pytest.warns(DeprecationWarning, match="StabilizerCodeSpec"): + qecc = VerifyStabilizers() + + # The deprecated workflow must keep functioning during the cycle. + qecc.check("Z", (0, 1)) + qecc.check("Z", (1, 2)) + qecc.compile() From 74bcf8bf007be2331f3f7d8550d599621d49cc79 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 21:13:26 -0600 Subject: [PATCH 23/41] Range observable ids as u32 instead of casting --- crates/pecos-qec/src/fault_tolerance/fault_distance.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/fault_distance.rs b/crates/pecos-qec/src/fault_tolerance/fault_distance.rs index aa968c094..61a616ad0 100644 --- a/crates/pecos-qec/src/fault_tolerance/fault_distance.rs +++ b/crates/pecos-qec/src/fault_tolerance/fault_distance.rs @@ -462,14 +462,14 @@ pub fn per_observable_fault_distances( let incidence = detector_incidence(&mechanisms); let active = peel_unique_detector_mechanisms(&mechanisms, &incidence); - (0..dem.num_observables()) + (0..u32::try_from(dem.num_observables()).expect("observable count fits in the u32 id space")) .map(|observable| { for weight in 1..=max_weight.min(mechanisms.len()) { let mechanism_indices = ConnectedClusterSearch { mechanisms: &mechanisms, incidence: &incidence, active: &active, - observable: observable as u32, + observable, target_weight: weight, best: None, } From 41e7ec936959be6caf77cfad691674f9e18c7b43 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 21:15:17 -0600 Subject: [PATCH 24/41] Document the panic condition and remove the remaining test-helper cast --- crates/pecos-qec/src/fault_tolerance/fault_distance.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/pecos-qec/src/fault_tolerance/fault_distance.rs b/crates/pecos-qec/src/fault_tolerance/fault_distance.rs index 61a616ad0..bde0e9676 100644 --- a/crates/pecos-qec/src/fault_tolerance/fault_distance.rs +++ b/crates/pecos-qec/src/fault_tolerance/fault_distance.rs @@ -453,6 +453,10 @@ pub fn exhaustive_fault_distance( /// by increasing weight, with the lexicographically smallest original-index witness retained at /// each weight. Entry `i` is restricted to witnesses that flip observable `i`, and is `None` when /// no such witness is found through `max_weight`. +/// +/// # Panics +/// +/// Panics if the observable count exceeds the `u32` id space. #[must_use] pub fn per_observable_fault_distances( dem: &DetectorErrorModel, @@ -519,7 +523,9 @@ mod tests { .max() .unwrap_or(0); for observable in 0..num_observables { - dem.add_observable(DemOutput::new(observable as u32)); + dem.add_observable(DemOutput::new( + u32::try_from(observable).expect("test observable id fits in u32"), + )); } for (detectors, observables) in effects { dem.add_direct_contribution( From d345ea0752adae47dd116d0f2bcfd20f867e98d2 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 21:21:11 -0600 Subject: [PATCH 25/41] Keep the neo stub signature under the new clippy release --- crates/pecos/src/unified_sim.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/pecos/src/unified_sim.rs b/crates/pecos/src/unified_sim.rs index 89caf04dc..3227007ca 100644 --- a/crates/pecos/src/unified_sim.rs +++ b/crates/pecos/src/unified_sim.rs @@ -363,7 +363,10 @@ impl ProgrammedSimBuilder { } /// Stub when pecos is built without the `neo` feature. + // `self` is required for signature parity with the feature-enabled variant: + // the shared call site invokes `self.run_neo(shots)` under both cfgs. #[cfg(not(feature = "neo"))] + #[expect(clippy::unused_self)] fn run_neo(self, _shots: usize) -> Result { Err(PecosError::Input( "pecos was built without the 'neo' cargo feature; rebuild with features = [\"neo\"] \ From f1142e2eb64b6c82af792f4040ac9171d63b71cf Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 6 Aug 2026 22:07:32 -0600 Subject: [PATCH 26/41] Add code-level connected-cluster distance as a third independent exact method --- crates/pecos-qec/src/code_distance.rs | 449 ++++++++++++++++++ crates/pecos-qec/src/distance_problem.rs | 51 +- .../src/fault_tolerance/fault_distance.rs | 141 ++++-- crates/pecos-qec/src/lib.rs | 5 + python/pecos-rslib/pecos_rslib/qec.pyi | 12 +- .../src/fault_tolerance_bindings.rs | 55 ++- .../quantum-pecos/src/pecos/qec/__init__.py | 8 + ..._fault_tolerance_certification_bindings.py | 35 ++ 8 files changed, 716 insertions(+), 40 deletions(-) create mode 100644 crates/pecos-qec/src/code_distance.rs diff --git a/crates/pecos-qec/src/code_distance.rs b/crates/pecos-qec/src/code_distance.rs new file mode 100644 index 000000000..749919f52 --- /dev/null +++ b/crates/pecos-qec/src/code_distance.rs @@ -0,0 +1,449 @@ +// 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. + +//! Connected-cluster distance searches for stabilizer codes. +//! +//! The reduction uses the Connected Cluster method of +//! [arXiv:2603.22532](https://arxiv.org/abs/2603.22532). A column of a binary check/logical pair +//! becomes a unit-weight mechanism: its check-row support is the detector set and its logical-row +//! support is the output set. The fault-distance engine can therefore search code distance without +//! duplicating the connected-cluster enumeration. + +use crate::fault_tolerance::dem_builder::FaultMechanism; +use crate::fault_tolerance::fault_distance::connected_cluster_mechanism_distance; +#[cfg(test)] +use crate::fault_tolerance::fault_distance::connected_cluster_mechanism_distance_at_weight; +use crate::{ + DistanceProblem, DistanceProblemError, DistanceResult, FaultDistanceResult, ParityCheckMatrix, + StabilizerCodeSpec, +}; +use pecos_core::{Pauli, PauliOperator, PauliString, QuarterPhase, QubitId}; +use pecos_quantum::F2Matrix; + +fn mechanisms_from_matrices(h: &F2Matrix, l: &F2Matrix) -> Vec { + assert_eq!( + h.num_cols(), + l.num_cols(), + "code-distance matrices must have matching widths" + ); + let num_detectors = + u32::try_from(h.num_rows()).expect("check row count fits in the u32 id space"); + let num_outputs = + u32::try_from(l.num_rows()).expect("logical row count fits in the u32 id space"); + + (0..h.num_cols()) + .map(|column| { + FaultMechanism::from_unsorted( + (0..num_detectors).filter(|&row| h.get(row as usize, column) == 1), + (0..num_outputs).filter(|&row| l.get(row as usize, column) == 1), + ) + }) + .collect() +} + +fn matrix_distance(h: &F2Matrix, l: &F2Matrix, max_weight: usize) -> Option { + let mechanisms = mechanisms_from_matrices(h, l); + let num_outputs = + u32::try_from(l.num_rows()).expect("logical row count fits in the u32 id space"); + connected_cluster_mechanism_distance(&mechanisms, num_outputs, max_weight) +} + +/// Computes connected-cluster distance for a binary `(H, L)` pair. +/// +/// Column `j` is one unit-weight mechanism, so returned mechanism indices are qubit indices. +/// `H e = 0` enforces an undetectable support and `L e != 0` enforces a nontrivial logical effect. +/// +/// # Panics +/// +/// Panics if the matrices have different widths or either row count exceeds the `u32` id space. +#[must_use] +pub fn connected_cluster_code_distance( + h: &ParityCheckMatrix, + l: &ParityCheckMatrix, + max_weight: usize, +) -> Option { + matrix_distance(h.matrix(), l.matrix(), max_weight) +} + +/// Computes pure-X distance for a CSS-form stabilizer code. +/// +/// # Errors +/// +/// Returns the same CSS-form and bounds errors as +/// [`DistanceProblem::from_css_code_x_distance`]. +pub fn x_distance( + code: &StabilizerCodeSpec, + max_weight: usize, +) -> Result, DistanceProblemError> { + let problem = DistanceProblem::from_css_code_x_distance(code)?; + let (h, l) = problem.matrices(); + Ok(matrix_distance(h, l, max_weight)) +} + +/// Computes pure-Z distance for a CSS-form stabilizer code. +/// +/// # Errors +/// +/// Returns the same CSS-form and bounds errors as +/// [`DistanceProblem::from_css_code_z_distance`]. +pub fn z_distance( + code: &StabilizerCodeSpec, + max_weight: usize, +) -> Result, DistanceProblemError> { + let problem = DistanceProblem::from_css_code_z_distance(code)?; + let (h, l) = problem.matrices(); + Ok(matrix_distance(h, l, max_weight)) +} + +fn single_qubit_pauli(pauli: Pauli, qubit: usize) -> PauliString { + PauliString::with_phase_and_paulis(QuarterPhase::PlusOne, vec![(pauli, QubitId::new(qubit))]) +} + +fn mechanisms_from_stabilizer_code( + code: &StabilizerCodeSpec, +) -> Result, DistanceProblemError> { + // Reuse the symplectic problem constructor for its established out-of-range validation. + DistanceProblem::from_stabilizer_spec(code)?; + let logicals: Vec<_> = code.logical_zs().iter().chain(code.logical_xs()).collect(); + + Ok((0..code.num_qubits()) + .flat_map(|qubit| { + [Pauli::X, Pauli::Y, Pauli::Z].map(|pauli| { + let error = single_qubit_pauli(pauli, qubit); + FaultMechanism::from_unsorted( + code.stabilizers() + .iter() + .enumerate() + .filter(|(_, stabilizer)| error.anticommutes_with(stabilizer)) + .map(|(index, _)| { + u32::try_from(index).expect("stabilizer count fits in the u32 id space") + }), + logicals + .iter() + .enumerate() + .filter(|(_, logical)| error.anticommutes_with(logical)) + .map(|(index, _)| { + u32::try_from(index).expect("logical count fits in the u32 id space") + }), + ) + }) + }) + .collect()) +} + +fn xor_pauli(left: Pauli, right: Pauli) -> Pauli { + match (left, right) { + (Pauli::I, pauli) | (pauli, Pauli::I) => pauli, + (Pauli::X, Pauli::X) | (Pauli::Y, Pauli::Y) | (Pauli::Z, Pauli::Z) => Pauli::I, + (Pauli::X, Pauli::Y) | (Pauli::Y, Pauli::X) => Pauli::Z, + (Pauli::X, Pauli::Z) | (Pauli::Z, Pauli::X) => Pauli::Y, + (Pauli::Y, Pauli::Z) | (Pauli::Z, Pauli::Y) => Pauli::X, + } +} + +fn mechanism_witness_to_pauli(num_qubits: usize, mechanism_indices: &[usize]) -> PauliString { + let mut paulis = vec![Pauli::I; num_qubits]; + for &mechanism_index in mechanism_indices { + let qubit = mechanism_index / 3; + let pauli = [Pauli::X, Pauli::Y, Pauli::Z][mechanism_index % 3]; + paulis[qubit] = xor_pauli(paulis[qubit], pauli); + } + PauliString::with_phase_and_paulis( + QuarterPhase::PlusOne, + paulis + .into_iter() + .enumerate() + .filter(|(_, pauli)| *pauli != Pauli::I) + .map(|(qubit, pauli)| (pauli, QubitId::new(qubit))) + .collect(), + ) +} + +/// Computes connected-cluster distance for any stabilizer code specification. +/// +/// Each qubit supplies X, Y, and Z mechanisms whose detector and output sets are their +/// anticommuting stabilizers and logicals. Although the engine counts mechanisms, a minimum +/// solution never needs two mechanisms on one qubit: the binary effect rows for X and Z XOR +/// exactly to the Y row (and likewise for the other pairs), so replacing either pair by the third +/// mechanism gives the same effect at strictly lower weight. Thus a physical-support minimum +/// exists among the mechanism-count minima, and the connected-component theorem of +/// [arXiv:2603.22532](https://arxiv.org/abs/2603.22532) applies. +/// +/// # Errors +/// +/// Returns [`DistanceProblemError::QubitOutOfRange`] if an operator addresses a qubit outside the +/// declared code width. +/// +/// # Panics +/// +/// Panics if the stabilizer or logical count exceeds the `u32` id space. +pub fn stabilizer_code_distance( + code: &StabilizerCodeSpec, + max_weight: usize, +) -> Result, DistanceProblemError> { + let mechanisms = mechanisms_from_stabilizer_code(code)?; + let num_outputs = u32::try_from(code.logical_zs().len() + code.logical_xs().len()) + .expect("logical count fits in the u32 id space"); + let Some(result) = connected_cluster_mechanism_distance(&mechanisms, num_outputs, max_weight) + else { + return Ok(None); + }; + let min_weight_operator = + mechanism_witness_to_pauli(code.num_qubits(), &result.mechanism_indices); + debug_assert_eq!(min_weight_operator.weight(), result.distance); + Ok(Some(DistanceResult { + distance: result.distance, + min_weight_operator, + })) +} + +#[cfg(test)] +fn matrix_distance_at_weight( + h: &F2Matrix, + l: &F2Matrix, + weight: usize, +) -> Option { + let mechanisms = mechanisms_from_matrices(h, l); + let num_outputs = + u32::try_from(l.num_rows()).expect("logical row count fits in the u32 id space"); + connected_cluster_mechanism_distance_at_weight(&mechanisms, num_outputs, weight) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{StabilizerCode, certified_distance}; + use pecos_core::{PauliOperator, X, Y, Ys, Z}; + use std::time::Instant; + + fn steane_spec() -> StabilizerCodeSpec { + StabilizerCodeSpec::from_stabilizer_code(&StabilizerCode::steane()).unwrap() + } + + fn yy_code() -> StabilizerCodeSpec { + // Y0 commutes with Y0Y1. X0Z1 also commutes because it anticommutes once with Y on + // each qubit, while Y0 and X0Z1 anticommute on qubit 0. Thus these are a valid logical + // pair. Every single-qubit X or Z anticommutes with Y0Y1, whereas Y0 and Y1 commute and + // anticommute with X0Z1, so every minimum logical is a single-qubit Y. + StabilizerCodeSpec::builder(2) + .check(Ys([0, 1])) + .logical_z(Y(0)) + .logical_x(X(0) & Z(1)) + .build_verified() + .unwrap() + } + + #[test] + fn steane_css_distances_agree_with_sat_and_weight_search() { + let mut spec = steane_spec(); + let searched = spec.calculate_distance().unwrap(); + assert_eq!(searched.distance, 3); + assert_eq!(spec.distance(), Some(3)); + + let cases = [ + ( + x_distance(&spec, 3).unwrap(), + DistanceProblem::from_css_code_x_distance(&spec).unwrap(), + ), + ( + z_distance(&spec, 3).unwrap(), + DistanceProblem::from_css_code_z_distance(&spec).unwrap(), + ), + ]; + for (connected, problem) in cases { + let connected = connected.unwrap(); + let certified = certified_distance(&problem, 3).unwrap().unwrap(); + assert_eq!(connected.distance, 3); + assert_eq!(certified.distance, connected.distance); + } + } + + #[test] + fn css_conveniences_reject_non_css_codes() { + let spec = StabilizerCodeSpec::from_stabilizer_code(&StabilizerCode::five_qubit()).unwrap(); + assert!(matches!( + x_distance(&spec, 3), + Err(DistanceProblemError::NonCssOperator { .. }) + )); + assert!(matches!( + z_distance(&spec, 3), + Err(DistanceProblemError::NonCssOperator { .. }) + )); + } + + #[test] + fn five_qubit_distance_agrees_with_sat_and_weight_search() { + let mut spec = + StabilizerCodeSpec::from_stabilizer_code(&StabilizerCode::five_qubit()).unwrap(); + let searched = spec.calculate_distance().unwrap(); + let connected = stabilizer_code_distance(&spec, 3).unwrap().unwrap(); + let problem = DistanceProblem::from_stabilizer_spec(&spec).unwrap(); + let certified = certified_distance(&problem, 3).unwrap().unwrap(); + + assert_eq!(searched.distance, 3); + assert_eq!(spec.distance(), Some(3)); + assert_eq!(connected.distance, searched.distance); + assert_eq!(certified.distance, connected.distance); + assert_eq!(connected.min_weight_operator.weight(), 3); + assert!(spec.commutes_with_all_stabilizers(&connected.min_weight_operator)); + assert!(spec.anticommutes_with_logical(&connected.min_weight_operator)); + assert!(spec.is_logical_error(&connected.min_weight_operator)); + } + + #[test] + fn yy_code_requires_a_y_mechanism_at_distance_one() { + let spec = yy_code(); + let connected = stabilizer_code_distance(&spec, 1).unwrap().unwrap(); + + assert_eq!(connected.distance, 1); + assert!( + connected.min_weight_operator == Y(0) || connected.min_weight_operator == Y(1), + "minimum witness must be Y on one qubit: {:?}", + connected.min_weight_operator + ); + assert!(spec.is_logical_error(&connected.min_weight_operator)); + } + + #[test] + fn y_mechanism_effects_are_exact_for_each_qubit() { + let mechanisms = mechanisms_from_stabilizer_code(&yy_code()).unwrap(); + assert_eq!(mechanisms.len(), 6); + + let y0 = &mechanisms[1]; + assert!(y0.detectors.is_empty()); + assert_eq!(y0.dem_outputs.as_slice(), &[1]); + + let y1 = &mechanisms[4]; + assert!(y1.detectors.is_empty()); + assert_eq!(y1.dem_outputs.as_slice(), &[1]); + } + + #[test] + fn matrix_search_is_deterministic_bounded_and_peels_unique_detectors() { + let h = ParityCheckMatrix::from_dense(vec![vec![1, 0, 0], vec![0, 1, 1]]).unwrap(); + let l = ParityCheckMatrix::from_dense(vec![vec![1, 1, 0]]).unwrap(); + + assert_eq!(connected_cluster_code_distance(&h, &l, 1), None); + let first = connected_cluster_code_distance(&h, &l, 2).unwrap(); + let second = connected_cluster_code_distance(&h, &l, 2).unwrap(); + assert_eq!(first, second); + assert_eq!(first.distance, 2); + assert_eq!(first.mechanism_indices, vec![1, 2]); + assert!(!first.mechanism_indices.contains(&0)); + } + + fn bb_circulant(l: usize, m: usize, terms: &[(usize, usize)]) -> F2Matrix { + let size = l * m; + let mut matrix = F2Matrix::zeros(size, size); + for row_x in 0..l { + for row_y in 0..m { + let row = row_x * m + row_y; + for &(x_power, y_power) in terms { + let column = ((row_x + x_power) % l) * m + (row_y + y_power) % m; + matrix.set(row, column, matrix.get(row, column) ^ 1); + } + } + } + matrix + } + + fn bb_code_pair(l: usize, m: usize) -> (ParityCheckMatrix, ParityCheckMatrix, usize) { + let block_size = l * m; + let num_qubits = 2 * block_size; + let a = bb_circulant(l, m, &[(3, 0), (0, 1), (0, 2)]); + let b = bb_circulant(l, m, &[(0, 3), (1, 0), (2, 0)]); + let mut hx = F2Matrix::zeros(block_size, num_qubits); + let mut hz = F2Matrix::zeros(block_size, num_qubits); + for row in 0..block_size { + for column in 0..block_size { + hx.set(row, column, a.get(row, column)); + hx.set(row, block_size + column, b.get(row, column)); + hz.set(row, column, b.get(column, row)); + hz.set(row, block_size + column, a.get(column, row)); + } + } + assert_eq!( + hx.mul(&hz.transpose()), + F2Matrix::zeros(block_size, block_size) + ); + + let hx_rank = hx.row_reduce().1.len(); + let hz_rank = hz.row_reduce().1.len(); + let num_logical_qubits = num_qubits - hx_rank - hz_rank; + let (hx_rref, hx_pivots) = hx.row_reduce(); + let logical_candidates = hz.kernel().into_iter().filter_map(|mut vector| { + for (row, &pivot) in hx_pivots.iter().enumerate() { + if vector[pivot] == 1 { + for (column, bit) in vector.iter_mut().enumerate() { + *bit ^= hx_rref.get(row, column); + } + } + } + vector.iter().any(|&bit| bit != 0).then_some(vector) + }); + let (logical_rref, _) = F2Matrix::from_rows(logical_candidates.collect()).row_reduce(); + let logical_rows: Vec<_> = logical_rref + .rows() + .into_iter() + .filter(|row| row.iter().any(|&bit| bit != 0)) + .collect(); + assert_eq!(logical_rows.len(), num_logical_qubits); + + ( + ParityCheckMatrix::from_dense(hx.rows()).unwrap(), + ParityCheckMatrix::from_dense(logical_rows).unwrap(), + num_logical_qubits, + ) + } + + #[test] + fn bb_72_distance_agrees_with_sat() { + let (h, logicals, num_logical_qubits) = bb_code_pair(6, 6); + assert_eq!(h.num_qubits(), 72); + assert_eq!(num_logical_qubits, 12); + + let connected = connected_cluster_code_distance(&h, &logicals, 6).unwrap(); + let problem = DistanceProblem::from_css_checks(&h, &logicals).unwrap(); + let certified = certified_distance(&problem, 6).unwrap().unwrap(); + assert_eq!(connected.distance, 6); + assert_eq!(certified.distance, connected.distance); + } + + fn run_bb_timing_probe(l: usize, m: usize, expected_distance: usize, label: &str) { + let (h, logicals, num_logical_qubits) = bb_code_pair(l, m); + assert_eq!(num_logical_qubits, 12); + let total_started = Instant::now(); + for weight in 1..=expected_distance { + let started = Instant::now(); + let result = matrix_distance_at_weight(h.matrix(), logicals.matrix(), weight); + println!("{label} CC weight {weight}: {:?}", started.elapsed()); + if weight < expected_distance { + assert_eq!(result, None); + } else { + assert_eq!(result.unwrap().distance, expected_distance); + } + } + println!("{label} CC total: {:?}", total_started.elapsed()); + } + + #[test] + #[ignore = "timing probe for connected-cluster code distance"] + fn connected_cluster_bb_72_12_6_timing_probe() { + run_bb_timing_probe(6, 6, 6, "BB [[72,12,6]]"); + } + + #[test] + #[ignore = "timing probe for connected-cluster code distance"] + fn connected_cluster_gross_144_12_12_timing_probe() { + run_bb_timing_probe(12, 6, 12, "gross [[144,12,12]]"); + } +} diff --git a/crates/pecos-qec/src/distance_problem.rs b/crates/pecos-qec/src/distance_problem.rs index bf0855a36..fd61c29da 100644 --- a/crates/pecos-qec/src/distance_problem.rs +++ b/crates/pecos-qec/src/distance_problem.rs @@ -244,6 +244,10 @@ impl EncodingBuilder { } impl DistanceProblem { + pub(crate) fn matrices(&self) -> (&F2Matrix, &F2Matrix) { + (&self.h, &self.l) + } + /// Constructs a problem from check and logical matrices with matching widths. /// /// # Errors @@ -977,10 +981,13 @@ mod tests { use super::*; use crate::{ DemOutput, DistanceSearchConfig, FaultMechanism, StabilizerCode, calculate_distance, - connected_cluster_fault_distance, exhaustive_fault_distance, + connected_cluster_code_distance, connected_cluster_fault_distance, + exhaustive_fault_distance, }; use pecos_core::pauli::{X, Xs, Ys, Z, Zs}; use pecos_quantum::SymplecticMatrix; + use rand::rngs::SmallRng; + use rand::{RngExt, SeedableRng}; use std::time::Instant; #[derive(Debug)] @@ -1112,6 +1119,48 @@ mod tests { .map_or(SolverAnswer::Unsat, SolverAnswer::Sat) } + #[test] + fn seeded_matrix_pairs_match_exhaustive_dimacs_minimum() { + let mut rng = SmallRng::seed_from_u64(0xC0DE_D157_A11C_E5E0); + for case in 0..64 { + let num_qubits = rng.random_range(1..=8); + let num_checks = rng.random_range(1..=4); + let num_logicals = rng.random_range(1..=3); + let h = ParityCheckMatrix::from_dense( + (0..num_checks) + .map(|_| { + (0..num_qubits) + .map(|_| u8::from(rng.random_bool(0.5))) + .collect() + }) + .collect(), + ) + .unwrap(); + let l = ParityCheckMatrix::from_dense( + (0..num_logicals) + .map(|_| { + (0..num_qubits) + .map(|_| u8::from(rng.random_bool(0.5))) + .collect() + }) + .collect(), + ) + .unwrap(); + let problem = DistanceProblem::from_css_checks(&h, &l).unwrap(); + let exhaustive = exhaustive_dimacs_minimum(&problem); + let connected = connected_cluster_code_distance(&h, &l, num_qubits); + + assert_eq!( + connected.as_ref().map(|result| result.distance), + exhaustive, + "distance mismatch in seeded case {case}: H={:?}, L={:?}", + h.rows(), + l.rows() + ); + assert_eq!(connected.is_some(), exhaustive.is_some()); + } + } + fn repetition_triad_dem() -> DetectorErrorModel { let mut dem = DetectorErrorModel::new(); dem.add_observable(DemOutput::new(0)); diff --git a/crates/pecos-qec/src/fault_tolerance/fault_distance.rs b/crates/pecos-qec/src/fault_tolerance/fault_distance.rs index bde0e9676..b92a3c89b 100644 --- a/crates/pecos-qec/src/fault_tolerance/fault_distance.rs +++ b/crates/pecos-qec/src/fault_tolerance/fault_distance.rs @@ -22,12 +22,15 @@ use super::dem_builder::{DetectorErrorModel, FaultMechanism}; use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::fmt; -/// Result of a fault-distance calculation, including one minimum-size witness. +/// Result of a unit-weight mechanism-distance calculation, including one minimum-size witness. #[derive(Clone, Debug, PartialEq, Eq)] pub struct FaultDistanceResult { /// Minimum number of fault mechanisms in an undetectable logical error. pub distance: usize, - /// Witnessing indices into [`DetectorErrorModel::to_mechanisms`], sorted ascending. + /// Witnessing mechanism indices, sorted ascending. + /// + /// For a detector error model these index [`DetectorErrorModel::to_mechanisms`]; for a binary + /// code-distance problem they are qubit-column indices. pub mechanism_indices: Vec, } @@ -124,7 +127,7 @@ struct ConnectedClusterSearch<'a> { mechanisms: &'a [FaultMechanism], incidence: &'a BTreeMap>, active: &'a [bool], - observable: u32, + logical_target: LogicalTarget, target_weight: usize, best: Option>, } @@ -214,9 +217,7 @@ impl ConnectedClusterSearch<'_> { } fn consider_witness(&mut self, cluster: &[usize], effect: &FaultMechanism) { - if !effect.detectors.is_empty() - || effect.dem_outputs.binary_search(&self.observable).is_err() - { + if !effect.detectors.is_empty() || !self.logical_target.matches(effect) { return; } let mut candidate = cluster.to_vec(); @@ -231,6 +232,95 @@ impl ConnectedClusterSearch<'_> { } } +#[derive(Clone, Copy)] +enum LogicalTarget { + AnyBelow(u32), + Observable(u32), +} + +impl LogicalTarget { + fn matches(self, effect: &FaultMechanism) -> bool { + match self { + Self::AnyBelow(limit) => effect.dem_outputs.iter().any(|&output| output < limit), + Self::Observable(observable) => effect.dem_outputs.binary_search(&observable).is_ok(), + } + } +} + +struct ConnectedClusterProblem<'a> { + mechanisms: &'a [FaultMechanism], + incidence: BTreeMap>, + active: Vec, +} + +impl<'a> ConnectedClusterProblem<'a> { + fn new(mechanisms: &'a [FaultMechanism]) -> Self { + let incidence = detector_incidence(mechanisms); + let active = peel_unique_detector_mechanisms(mechanisms, &incidence); + Self { + mechanisms, + incidence, + active, + } + } + + fn first_witness_at_weight( + &self, + weight: usize, + logical_target: LogicalTarget, + ) -> Option> { + (weight != 0 && weight <= self.mechanisms.len()).then_some(())?; + ConnectedClusterSearch { + mechanisms: self.mechanisms, + incidence: &self.incidence, + active: &self.active, + logical_target, + target_weight: weight, + best: None, + } + .run() + } + + fn distance( + &self, + max_weight: usize, + logical_target: LogicalTarget, + ) -> Option { + for weight in 1..=max_weight.min(self.mechanisms.len()) { + if let Some(mechanism_indices) = self.first_witness_at_weight(weight, logical_target) { + return Some(FaultDistanceResult { + distance: weight, + mechanism_indices, + }); + } + } + None + } +} + +pub(crate) fn connected_cluster_mechanism_distance( + mechanisms: &[FaultMechanism], + num_outputs: u32, + max_weight: usize, +) -> Option { + ConnectedClusterProblem::new(mechanisms) + .distance(max_weight, LogicalTarget::AnyBelow(num_outputs)) +} + +#[cfg(test)] +pub(crate) fn connected_cluster_mechanism_distance_at_weight( + mechanisms: &[FaultMechanism], + num_outputs: u32, + weight: usize, +) -> Option { + ConnectedClusterProblem::new(mechanisms) + .first_witness_at_weight(weight, LogicalTarget::AnyBelow(num_outputs)) + .map(|mechanism_indices| FaultDistanceResult { + distance: weight, + mechanism_indices, + }) +} + #[derive(Clone, Copy)] struct GraphEdge { neighbor: usize, @@ -463,30 +553,10 @@ pub fn per_observable_fault_distances( max_weight: usize, ) -> Vec> { let mechanisms = mechanisms_from_dem(dem); - let incidence = detector_incidence(&mechanisms); - let active = peel_unique_detector_mechanisms(&mechanisms, &incidence); + let problem = ConnectedClusterProblem::new(&mechanisms); (0..u32::try_from(dem.num_observables()).expect("observable count fits in the u32 id space")) - .map(|observable| { - for weight in 1..=max_weight.min(mechanisms.len()) { - let mechanism_indices = ConnectedClusterSearch { - mechanisms: &mechanisms, - incidence: &incidence, - active: &active, - observable, - target_weight: weight, - best: None, - } - .run(); - if let Some(mechanism_indices) = mechanism_indices { - return Some(FaultDistanceResult { - distance: weight, - mechanism_indices, - }); - } - } - None - }) + .map(|observable| problem.distance(max_weight, LogicalTarget::Observable(observable))) .collect() } @@ -495,18 +565,19 @@ pub fn per_observable_fault_distances( /// The result is the minimum of [`per_observable_fault_distances`], ordered first by distance and /// then by the lexicographic mechanism-index witness. It is `None` only when no observable has a /// witness through `max_weight`. +/// +/// # Panics +/// +/// Panics if the observable count exceeds the `u32` id space. #[must_use] pub fn connected_cluster_fault_distance( dem: &DetectorErrorModel, max_weight: usize, ) -> Option { - per_observable_fault_distances(dem, max_weight) - .into_iter() - .flatten() - .min_by(|left, right| { - (left.distance, &left.mechanism_indices) - .cmp(&(right.distance, &right.mechanism_indices)) - }) + let mechanisms = mechanisms_from_dem(dem); + let num_outputs = + u32::try_from(dem.num_observables()).expect("observable count fits in the u32 id space"); + connected_cluster_mechanism_distance(&mechanisms, num_outputs, max_weight) } #[cfg(test)] diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index 50cbfbe34..92b6920e7 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -63,6 +63,7 @@ //! assert_eq!(analysis.undetectable_logical, 0); //! ``` +pub mod code_distance; pub mod dem_stab; pub mod distance; pub mod distance_problem; @@ -79,6 +80,10 @@ pub use dem_stab::{DemStabError, DemStabShotBatch, DemStabSim, DemStabSimBuilder pub use mem_stab::{MemStabError, MemStabSim, MemStabSimBuilder}; pub use parity_check_matrix::{ParityCheckMatrix, ParityCheckMatrixError}; +pub use code_distance::{ + connected_cluster_code_distance, stabilizer_code_distance, x_distance, z_distance, +}; + pub use distance::{ DistanceResult, DistanceSearchConfig, LogicalOperatorInfo, WeightedPauliIterator, calculate_distance, find_min_weight_logicals, find_min_weight_logicals_with_info, diff --git a/python/pecos-rslib/pecos_rslib/qec.pyi b/python/pecos-rslib/pecos_rslib/qec.pyi index 6e38d481e..e088d6a61 100644 --- a/python/pecos-rslib/pecos_rslib/qec.pyi +++ b/python/pecos-rslib/pecos_rslib/qec.pyi @@ -16,10 +16,10 @@ from __future__ import annotations from typing import Any -from pecos_rslib import ParityCheckMatrix, StabilizerCodeSpec, TickCircuit +from pecos_rslib import DistanceResult, ParityCheckMatrix, StabilizerCodeSpec, TickCircuit class FaultDistanceResult: - """A fault distance and one witnessing set of DEM mechanism indices.""" + """A unit-weight mechanism distance and one witnessing index set.""" @property def distance(self) -> int: ... @@ -198,6 +198,14 @@ class DistanceProblem: def __repr__(self) -> str: ... def certified_distance(problem: DistanceProblem, max_weight: int) -> CertifiedDistance | None: ... +def connected_cluster_code_distance( + h: ParityCheckMatrix, + l: ParityCheckMatrix, + max_weight: int, +) -> FaultDistanceResult | None: ... +def x_distance(spec: StabilizerCodeSpec, max_weight: int) -> FaultDistanceResult | None: ... +def z_distance(spec: StabilizerCodeSpec, max_weight: int) -> FaultDistanceResult | None: ... +def stabilizer_code_distance(spec: StabilizerCodeSpec, max_weight: int) -> DistanceResult | None: ... # The native QEC module predates this focused stub. Preserve the untyped behavior of its other # classes and functions until that complete API is migrated rather than falsely narrowing them. diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 60a6d40ba..793b039e3 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -45,7 +45,7 @@ use crate::code_matrix_bindings::PyParityCheckMatrix; use crate::dag_circuit_bindings::PyTickCircuit; use crate::pecos_array::{Array, ArrayData}; -use crate::stabilizer_code_spec_bindings::PyStabilizerCodeSpec; +use crate::stabilizer_code_spec_bindings::{PyDistanceResult, PyStabilizerCodeSpec}; use pecos_core::gate_type::GateType; use pecos_qec::fault_tolerance::dem_builder::{ ComparisonMethod as RustComparisonMethod, @@ -86,6 +86,9 @@ use pecos_qec::fault_tolerance::{ use pecos_qec::{ CertifiedDistance as RustCertifiedDistance, DistanceProblem as RustDistanceProblem, certified_distance as rust_certified_distance, + connected_cluster_code_distance as rust_connected_cluster_code_distance, + stabilizer_code_distance as rust_stabilizer_code_distance, x_distance as rust_x_distance, + z_distance as rust_z_distance, }; use pecos_quantum::DagCircuit; use pecos_quantum::QubitId; @@ -1270,7 +1273,7 @@ where // Detector Error Model // ============================================================================= -/// Result of a detector-error-model fault-distance search. +/// Result of a unit-weight mechanism-distance search. #[pyclass( name = "FaultDistanceResult", module = "pecos_rslib.qec", @@ -7690,6 +7693,50 @@ fn certified_distance( certify_python_problem(&problem.inner, max_weight) } +/// Computes connected-cluster distance for a binary check/logical matrix pair. +#[pyfunction] +fn connected_cluster_code_distance( + h: &PyParityCheckMatrix, + l: &PyParityCheckMatrix, + max_weight: usize, +) -> Option { + rust_connected_cluster_code_distance(&h.inner, &l.inner, max_weight) + .map(PyFaultDistanceResult::from) +} + +/// Computes pure-X connected-cluster distance for a CSS stabilizer code. +#[pyfunction] +fn x_distance( + code: &PyStabilizerCodeSpec, + max_weight: usize, +) -> PyResult> { + rust_x_distance(&code.inner, max_weight) + .map(|result| result.map(PyFaultDistanceResult::from)) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) +} + +/// Computes pure-Z connected-cluster distance for a CSS stabilizer code. +#[pyfunction] +fn z_distance( + code: &PyStabilizerCodeSpec, + max_weight: usize, +) -> PyResult> { + rust_z_distance(&code.inner, max_weight) + .map(|result| result.map(PyFaultDistanceResult::from)) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) +} + +/// Computes connected-cluster distance for any stabilizer code. +#[pyfunction] +fn stabilizer_code_distance( + code: &PyStabilizerCodeSpec, + max_weight: usize, +) -> PyResult> { + rust_stabilizer_code_distance(&code.inner, max_weight) + .map(|result| result.map(PyDistanceResult::from)) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) +} + // ============================================================================= // Module Registration // ============================================================================= @@ -7732,6 +7779,10 @@ pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { qec.add_function(wrap_pyfunction!(compare_dems_statistical, &qec)?)?; qec.add_function(wrap_pyfunction!(verify_dem_equivalence, &qec)?)?; qec.add_function(wrap_pyfunction!(assert_dems_equivalent, &qec)?)?; + qec.add_function(wrap_pyfunction!(connected_cluster_code_distance, &qec)?)?; + qec.add_function(wrap_pyfunction!(x_distance, &qec)?)?; + qec.add_function(wrap_pyfunction!(z_distance, &qec)?)?; + qec.add_function(wrap_pyfunction!(stabilizer_code_distance, &qec)?)?; // Correlation analysis qec.add_function(wrap_pyfunction!(detector_flip_matrix, &qec)?)?; diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index 3ffb9806f..cf1f033f1 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -53,7 +53,11 @@ certified_distance, compare_dems_exact, compare_dems_statistical, + connected_cluster_code_distance, + stabilizer_code_distance, verify_dem_equivalence, + x_distance, + z_distance, ) from pecos.qec import analysis, color, protocols, surface @@ -160,9 +164,13 @@ "Observable", "assert_dems_equivalent", "certified_distance", + "connected_cluster_code_distance", "compare_dems_exact", "compare_dems_statistical", + "stabilizer_code_distance", "verify_dem_equivalence", + "x_distance", + "z_distance", "build_dem_from_guppy", "rec", "result_ref", diff --git a/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py b/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py index 4ab2a2176..4e6f42fe6 100644 --- a/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py +++ b/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py @@ -18,6 +18,10 @@ DetectorErrorModel, DistanceProblem, HookError, + connected_cluster_code_distance, + stabilizer_code_distance, + x_distance, + z_distance, ) from pecos.quantum import ParityCheckMatrix, StabilizerCode, StabilizerCodeSpec, TickCircuit @@ -156,6 +160,26 @@ def test_steane_certification_from_checks_and_code_spec() -> None: assert certified.unsat_trusted_below == 3 assert problem.verify_witness(certified.witness) == 3 + matrix_result = connected_cluster_code_distance( + ParityCheckMatrix( + [ + [1, 0, 1, 0, 1, 0, 1], + [0, 1, 1, 0, 0, 1, 1], + [0, 0, 0, 1, 1, 1, 1], + ], + ), + ParityCheckMatrix([[1, 1, 1, 1, 1, 1, 1]]), + 3, + ) + assert matrix_result is not None + assert matrix_result.distance == 3 + assert len(matrix_result.mechanism_indices) == 3 + + x_result = x_distance(spec, 3) + z_result = z_distance(spec, 3) + assert x_result is not None and x_result.distance == 3 + assert z_result is not None and z_result.distance == 3 + assert from_checks.certified_distance(2) is None certified = from_checks.certified_distance(3) @@ -166,6 +190,17 @@ def test_steane_certification_from_checks_and_code_spec() -> None: from_checks.verify_witness(corrupted) +def test_non_css_connected_cluster_binding_returns_logical_pauli() -> None: + spec = StabilizerCodeSpec.from_stabilizer_code(StabilizerCode.five_qubit()) + result = stabilizer_code_distance(spec, 3) + + assert result is not None + assert result.distance == 3 + assert result.min_weight_operator.weight() == 3 + assert all(result.min_weight_operator.commutes_with(stabilizer) for stabilizer in spec.stabilizers) + assert any(result.min_weight_operator.anticommutes_with(logical) for logical in spec.logical_zs + spec.logical_xs) + + def test_triad_dem_certification_agrees_with_exhaustive_distance() -> None: dem = _triad_dem() problem = DistanceProblem.from_dem(dem) From fa05b244f6567f5073e1c212f67a340ab3a57ef6 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 7 Aug 2026 00:13:00 -0600 Subject: [PATCH 27/41] Build the bivariate-bicycle depth-8 syndrome cycle with native X-basis primitives --- crates/pecos-core/src/gate_type.rs | 24 +- crates/pecos-core/src/gates.rs | 24 +- .../src/noise/biased_depolarizing.rs | 9 +- .../pecos-engines/src/noise/depolarizing.rs | 14 +- crates/pecos-engines/src/quantum.rs | 31 + crates/pecos-qasm/src/engine.rs | 25 +- crates/pecos-qec/src/bivariate_bicycle.rs | 1012 +++++++++++++++++ crates/pecos-qec/src/code_distance.rs | 69 +- crates/pecos-qec/src/distance_problem.rs | 71 +- .../src/fault_tolerance/circuit_runner.rs | 18 +- .../fault_tolerance/dem_builder/builder.rs | 63 +- .../dem_builder/dem_sampler.rs | 24 + .../dem_builder/mem_builder.rs | 6 + .../fault_tolerance/dem_builder/sampler.rs | 4 +- .../src/fault_tolerance/fault_distance.rs | 4 +- .../src/fault_tolerance/fault_sampler.rs | 56 +- .../src/fault_tolerance/influence_builder.rs | 38 +- .../src/fault_tolerance/pauli_frame.rs | 15 +- .../src/fault_tolerance/pauli_prop_checker.rs | 47 +- .../src/fault_tolerance/propagator.rs | 5 +- .../src/fault_tolerance/propagator/dag.rs | 39 +- .../src/fault_tolerance/propagator/pauli.rs | 18 +- .../src/fault_tolerance/propagator/tick.rs | 7 +- crates/pecos-qec/src/lib.rs | 4 + crates/pecos-quantum/src/circuit_display.rs | 6 +- crates/pecos-quantum/src/dag_circuit.rs | 20 +- crates/pecos-quantum/src/tick_circuit.rs | 69 +- crates/pecos-quantum/src/unitary_matrix.rs | 4 +- .../pecos-simulators/src/circuit_executor.rs | 8 + exp/pecos-experimental/src/hugr_executor.rs | 18 +- python/pecos-rslib/pecos_rslib.pyi | 8 + python/pecos-rslib/pecos_rslib/qec.pyi | 34 + .../pecos-rslib/src/dag_circuit_bindings.rs | 80 ++ .../src/fault_tolerance_bindings.rs | 113 ++ .../quantum-pecos/src/pecos/qec/__init__.py | 4 + .../tests/qec/test_bivariate_bicycle.py | 47 + ..._fault_tolerance_certification_bindings.py | 6 +- 37 files changed, 1835 insertions(+), 209 deletions(-) create mode 100644 crates/pecos-qec/src/bivariate_bicycle.rs create mode 100644 python/quantum-pecos/tests/qec/test_bivariate_bicycle.py diff --git a/crates/pecos-core/src/gate_type.rs b/crates/pecos-core/src/gate_type.rs index 57628943f..11cac2759 100644 --- a/crates/pecos-core/src/gate_type.rs +++ b/crates/pecos-core/src/gate_type.rs @@ -84,7 +84,8 @@ pub enum GateType { /// Toffoli gate (CCX, 3 qubits) CCX = 90, - // MX = 100 + /// Measure in the X basis. + MX = 100, // MnX = 101 // MY = 102 // MnY = 103 @@ -97,8 +98,8 @@ pub enum GateType { /// Measure +Z, then prepare |0> (measure-and-prepare; the MP* family) MPZ = 107, // TODO: MPauli instead of the other variants? - - // PX = 130 + /// Prepare the +1 eigenstate of X. + PX = 130, // PNX = 131 // PY = 132 // PNY = 133 @@ -173,10 +174,12 @@ impl From for GateType { 83 => GateType::RXXRYYRZZ, 84 => GateType::U2q, 90 => GateType::CCX, + 100 => GateType::MX, 104 => GateType::MZ, 105 => GateType::MeasureLeaked, 106 => GateType::MeasureFree, 107 => GateType::MPZ, + 130 => GateType::PX, 134 => GateType::PZ, 135 => GateType::QAlloc, 136 => GateType::QFree, @@ -220,7 +223,10 @@ impl GateType { /// Deciding otherwise means changing this function and nothing else. #[must_use] pub const fn consumes_measurement_record(self) -> bool { - matches!(self, GateType::MZ | GateType::MeasureFree | GateType::MPZ) + matches!( + self, + GateType::MX | GateType::MZ | GateType::MeasureFree | GateType::MPZ + ) } /// Returns the number of angle parameters this gate type requires @@ -259,6 +265,7 @@ impl GateType { | GateType::SZZdg | GateType::SWAP | GateType::CCX + | GateType::MX | GateType::MZ | GateType::MeasureLeaked | GateType::MeasureFree @@ -266,6 +273,7 @@ impl GateType { | GateType::MeasCrosstalkGlobalPayload | GateType::MeasCrosstalkLocalPayload | GateType::Channel + | GateType::PX | GateType::PZ | GateType::QAlloc | GateType::QFree @@ -324,10 +332,12 @@ impl GateType { | GateType::Tdg | GateType::R1XY | GateType::U + | GateType::MX | GateType::MZ | GateType::MeasureLeaked | GateType::MeasureFree | GateType::MPZ + | GateType::PX | GateType::PZ | GateType::QAlloc | GateType::QFree @@ -480,10 +490,12 @@ impl fmt::Display for GateType { GateType::RXXRYYRZZ => write!(f, "RXXRYYRZZ"), GateType::U2q => write!(f, "U2q"), GateType::CCX => write!(f, "CCX"), + GateType::MX => write!(f, "MX"), GateType::MZ => write!(f, "MZ"), GateType::MeasureLeaked => write!(f, "MeasureLeaked"), GateType::MeasureFree => write!(f, "MeasureFree"), GateType::MPZ => write!(f, "MPZ"), + GateType::PX => write!(f, "PX"), GateType::PZ => write!(f, "PZ"), GateType::QAlloc => write!(f, "QAlloc"), GateType::QFree => write!(f, "QFree"), @@ -503,7 +515,9 @@ impl std::str::FromStr for GateType { fn from_str(s: &str) -> Result { // Try exact match first for multi-word aliases with specific casing match s { + "init |+>" | "Init |+>" => return Ok(GateType::PX), "init |0>" | "Init |0>" => return Ok(GateType::PZ), + "measure X" => return Ok(GateType::MX), "measure Z" => return Ok(GateType::MZ), _ => {} } @@ -549,10 +563,12 @@ impl std::str::FromStr for GateType { "CRZ" => Ok(GateType::CRZ), "CCX" | "TOFFOLI" => Ok(GateType::CCX), "SWAP" => Ok(GateType::SWAP), + "MX" | "MEASURE X" => Ok(GateType::MX), "MEASURE" | "MZ" | "MEASURE Z" => Ok(GateType::MZ), "MEASUREFREE" | "MZFREE" => Ok(GateType::MeasureFree), "MEASURELEAKED" => Ok(GateType::MeasureLeaked), "MPZ" => Ok(GateType::MPZ), + "PX" | "INIT |+>" => Ok(GateType::PX), "PREP" | "PZ" | "INIT" | "INIT |0>" | "RESET" => Ok(GateType::PZ), "QALLOC" => Ok(GateType::QAlloc), "QFREE" => Ok(GateType::QFree), diff --git a/crates/pecos-core/src/gates.rs b/crates/pecos-core/src/gates.rs index 86c839926..2b55b32fb 100644 --- a/crates/pecos-core/src/gates.rs +++ b/crates/pecos-core/src/gates.rs @@ -904,6 +904,15 @@ impl Gate { ) } + /// Create an X-basis measurement gate on multiple qubits. + #[must_use] + pub fn mx(qubits: &[impl Into + Copy]) -> Self { + Self::simple( + GateType::MX, + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + /// Create `MeasureLeaked` gate on multiple qubits #[must_use] pub fn measure_leaked(qubits: &[impl Into + Copy]) -> Self { @@ -922,6 +931,15 @@ impl Gate { ) } + /// Create an X-basis preparation gate on multiple qubits. + #[must_use] + pub fn px(qubits: &[impl Into + Copy]) -> Self { + Self::simple( + GateType::PX, + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + /// Create `QAlloc` gate to allocate qubits in the |0⟩ state #[must_use] pub fn qalloc(qubits: &[impl Into + Copy]) -> Self { @@ -1183,7 +1201,11 @@ impl Gate { } let is_measurement = matches!( self.gate_type, - GateType::MZ | GateType::MeasureLeaked | GateType::MeasureFree | GateType::MPZ + GateType::MX + | GateType::MZ + | GateType::MeasureLeaked + | GateType::MeasureFree + | GateType::MPZ ); if is_measurement { if !self.meas_ids.is_empty() && self.meas_ids.len() != self.qubits.len() { diff --git a/crates/pecos-engines/src/noise/biased_depolarizing.rs b/crates/pecos-engines/src/noise/biased_depolarizing.rs index bf9e9fd4f..988ee3a8a 100644 --- a/crates/pecos-engines/src/noise/biased_depolarizing.rs +++ b/crates/pecos-engines/src/noise/biased_depolarizing.rs @@ -221,13 +221,13 @@ impl BiasedDepolarizingNoiseModel { GateType::MPZ => { NoiseUtils::add_gate_to_builder(&mut builder, gate); } - GateType::MZ | GateType::MeasureLeaked | GateType::MeasureFree => { + GateType::MX | GateType::MZ | GateType::MeasureLeaked | GateType::MeasureFree => { trace!("Applying measurement. Will apply bias after engine returns results."); // we apply biased measurement after the engine // returns the results, rather than before measurement NoiseUtils::add_gate_to_builder(&mut builder, gate); } - GateType::PZ | GateType::QAlloc => { + GateType::PX | GateType::PZ | GateType::QAlloc => { NoiseUtils::add_gate_to_builder(&mut builder, gate); trace!("Applying preparation with possible fault"); self.apply_prep_faults(&mut builder, gate); @@ -322,7 +322,10 @@ impl BiasedDepolarizingNoiseModel { fn apply_prep_faults(&mut self, builder: &mut ByteMessageBuilder, gate: &Gate) { if self.rng.occurs(self.p_prep) { trace!("Applying prep fault on qubits {:?}", gate.qubits); - NoiseUtils::apply_x(builder, *gate.qubits[0]); + match gate.gate_type { + GateType::PX => NoiseUtils::apply_z(builder, *gate.qubits[0]), + _ => NoiseUtils::apply_x(builder, *gate.qubits[0]), + } } } diff --git a/crates/pecos-engines/src/noise/depolarizing.rs b/crates/pecos-engines/src/noise/depolarizing.rs index cfa7d9ac6..3f5da7566 100644 --- a/crates/pecos-engines/src/noise/depolarizing.rs +++ b/crates/pecos-engines/src/noise/depolarizing.rs @@ -236,12 +236,12 @@ impl DepolarizingNoiseModel { Self::apply_meas_faults(rng, p_meas_threshold, builder, gate); NoiseUtils::add_gate_to_builder(builder, gate); } - GateType::MZ | GateType::MeasureLeaked | GateType::MeasureFree => { + GateType::MX | GateType::MZ | GateType::MeasureLeaked | GateType::MeasureFree => { trace!("Applying measurement with possible fault"); Self::apply_meas_faults(rng, p_meas_threshold, builder, gate); NoiseUtils::add_gate_to_builder(builder, gate); } - GateType::PZ | GateType::QAlloc => { + GateType::PX | GateType::PZ | GateType::QAlloc => { NoiseUtils::add_gate_to_builder(builder, gate); trace!("Applying preparation with possible fault"); Self::apply_prep_faults(rng, p_prep_threshold, builder, gate); @@ -268,7 +268,10 @@ impl DepolarizingNoiseModel { // Use precomputed threshold for fast probability check if rng.inner_mut().check_probability(p_prep_threshold) { trace!("Applying prep fault on qubits {:?}", gate.qubits); - NoiseUtils::apply_x(builder, *gate.qubits[0]); + match gate.gate_type { + GateType::PX => NoiseUtils::apply_z(builder, *gate.qubits[0]), + _ => NoiseUtils::apply_x(builder, *gate.qubits[0]), + } } } @@ -281,7 +284,10 @@ impl DepolarizingNoiseModel { // Use precomputed threshold for fast probability check if rng.inner_mut().check_probability(p_meas_threshold) { trace!("Applying meas fault on qubits {:?}", gate.qubits); - NoiseUtils::apply_x(builder, *gate.qubits[0]); + match gate.gate_type { + GateType::MX => NoiseUtils::apply_z(builder, *gate.qubits[0]), + _ => NoiseUtils::apply_x(builder, *gate.qubits[0]), + } } } diff --git a/crates/pecos-engines/src/quantum.rs b/crates/pecos-engines/src/quantum.rs index 546108b64..13504811a 100644 --- a/crates/pecos-engines/src/quantum.rs +++ b/crates/pecos-engines/src/quantum.rs @@ -201,6 +201,12 @@ fn process_clifford_message { + sim.h(&cmd.qubits); + for meas_id in sim.mz(&cmd.qubits) { + measurements.push(usize::from(meas_id.outcome)); + } + } // Batch consecutive MZ commands GateType::MZ | GateType::MeasureLeaked => { mz_qubits.clear(); @@ -226,6 +232,10 @@ fn process_clifford_message { + sim.pz(&cmd.qubits); + sim.h(&cmd.qubits); + } GateType::PZ => { sim.pz(&cmd.qubits); } @@ -567,6 +577,12 @@ fn process_general_message< } } + GateType::MX => { + sim.h(&cmd.qubits); + for meas_id in sim.mz(&cmd.qubits) { + measurements.push(usize::from(meas_id.outcome)); + } + } // Batch consecutive MZ commands into one simulator call GateType::MZ | GateType::MeasureLeaked => { mz_qubits.clear(); @@ -605,6 +621,10 @@ fn process_general_message< } // State preparation + GateType::PX => { + sim.pz(&cmd.qubits); + sim.h(&cmd.qubits); + } GateType::PZ | GateType::QAlloc => { sim.pz(&cmd.qubits); } @@ -1147,6 +1167,13 @@ where // Batch consecutive MZ commands into one simulator call. // This enables joint-sampling optimizations (fewer state vector passes). + GateType::MX => { + self.simulator.h(&cmd.qubits); + let meas_ids = self.simulator.mz(&cmd.qubits); + for meas_id in meas_ids { + measurements.push(usize::from(meas_id.outcome)); + } + } GateType::MZ | GateType::MeasureLeaked => { // Collect qubits from consecutive MZ/MeasureLeaked commands let mut mz_qubits: Vec = cmd.qubits.to_vec(); @@ -1169,6 +1196,10 @@ where measurements.push(usize::from(meas_id.outcome)); } } + GateType::PX => { + self.simulator.pz(&cmd.qubits); + self.simulator.h(&cmd.qubits); + } GateType::PZ => { debug!("Processing Prep gate on qubits {:?}", cmd.qubits); self.simulator.pz(&cmd.qubits); diff --git a/crates/pecos-qasm/src/engine.rs b/crates/pecos-qasm/src/engine.rs index 779d684fd..26f24456b 100644 --- a/crates/pecos-qasm/src/engine.rs +++ b/crates/pecos-qasm/src/engine.rs @@ -639,7 +639,6 @@ impl QASMEngine { | GateType::Fdg | GateType::T | GateType::Tdg - | GateType::PZ | GateType::QAlloc => self.process_single_qubit_gate(gate.gate_type, &qubits), GateType::CX | GateType::CY @@ -668,16 +667,24 @@ impl QASMEngine { | GateType::R1XY | GateType::U => { // Convert angles to radians for process_parameterized_gate - let angles_as_radians: Vec = - gate.angles.iter().map(pecos_core::Angle::to_radians).collect(); + let angles_as_radians: Vec = gate + .angles + .iter() + .map(pecos_core::Angle::to_radians) + .collect(); self.process_parameterized_gate(gate.gate_type, &qubits, &angles_as_radians) } - GateType::MZ | GateType::MeasureLeaked | GateType::MeasureFree | GateType::MPZ => { - Err(PecosError::Processing( - "Measure, MeasureLeaked, and MeasureFree gates should be handled by MeasureWithMapping operation" - .to_string(), - )) - } + GateType::MX + | GateType::MZ + | GateType::MeasureLeaked + | GateType::MeasureFree + | GateType::MPZ => Err(PecosError::Processing( + "measurement gates should be handled by MeasureWithMapping operation".to_string(), + )), + GateType::PX | GateType::PZ => Err(PecosError::Processing(format!( + "Gate type {:?} is not yet supported in the QASM engine", + gate.gate_type + ))), } } diff --git a/crates/pecos-qec/src/bivariate_bicycle.rs b/crates/pecos-qec/src/bivariate_bicycle.rs new file mode 100644 index 000000000..f77c153ac --- /dev/null +++ b/crates/pecos-qec/src/bivariate_bicycle.rs @@ -0,0 +1,1012 @@ +// 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. + +//! Bivariate-bicycle codes and their depth-eight syndrome-extraction circuit. +//! +//! The construction and circuit schedule follow Tables 4 and 5 of +//! [Bravyi et al., arXiv:2308.07915](https://arxiv.org/abs/2308.07915). + +use pecos_quantum::{AnnotationKind, Attribute, F2Matrix, TickCircuit, TickMeasRef}; +use thiserror::Error; + +use crate::ParityCheckMatrix; + +/// One monomial `x^a y^b` in `F_2[x, y] / (x^l - 1, y^m - 1)`. +pub type BbMonomial = (usize, usize); + +/// Memory-experiment preparation and final-measurement basis. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BbMemoryBasis { + /// Prepare and measure the encoded state in the X basis. + X, + /// Prepare and measure the encoded state in the Z basis. + Z, +} + +/// Errors reported while constructing a bivariate-bicycle code or memory circuit. +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum BivariateBicycleError { + /// A torus dimension was zero. + #[error("bivariate-bicycle dimensions must be positive, got l={l}, m={m}")] + ZeroDimension { l: usize, m: usize }, + /// The block or circuit size overflowed `usize`. + #[error("bivariate-bicycle dimensions l={l}, m={m} overflow the supported size")] + SizeOverflow { l: usize, m: usize }, + /// A weight-three polynomial did not contain exactly three terms. + #[error("{polynomial} must contain exactly three monomials, got {actual}")] + WrongTermCount { + polynomial: &'static str, + actual: usize, + }, + /// A monomial exponent did not name a canonical torus shift. + #[error( + "{polynomial} monomial {term_index} has exponent ({x_power}, {y_power}) outside Z_{l} x Z_{m}" + )] + ExponentOutOfRange { + polynomial: &'static str, + term_index: usize, + x_power: usize, + y_power: usize, + l: usize, + m: usize, + }, + /// Two terms describe the same permutation matrix. + #[error("{polynomial} monomials {first} and {second} describe the same permutation matrix")] + DuplicateMonomial { + polynomial: &'static str, + first: usize, + second: usize, + }, + /// A generated monomial was not a permutation matrix. + #[error("{polynomial} monomial {term_index} is not a permutation matrix")] + NonPermutationMonomial { + polynomial: &'static str, + term_index: usize, + }, + /// The CSS commutation condition failed. + #[error("bivariate-bicycle checks do not commute: Hx * Hz^T is nonzero")] + NonCommutingChecks, + /// At least one syndrome cycle is required. + #[error("bivariate-bicycle memory experiment requires at least one syndrome cycle")] + ZeroRounds, + /// An internally produced measurement reference could not be annotated. + #[error("invalid bivariate-bicycle measurement annotation: {0}")] + InvalidAnnotation(String), +} + +/// A validated bivariate-bicycle CSS code `QC(A, B)`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BivariateBicycleCode { + l: usize, + m: usize, + a_terms: [BbMonomial; 3], + b_terms: [BbMonomial; 3], + hx: ParityCheckMatrix, + hz: ParityCheckMatrix, + logical_x: ParityCheckMatrix, + logical_z: ParityCheckMatrix, +} + +impl BivariateBicycleCode { + /// Construct `QC(A, B)` from two weight-three bivariate polynomials. + /// + /// A monomial `(a, b)` denotes the permutation whose one in row `(i, j)` + /// is at column `(i + a mod l, j + b mod m)`. Exponents must use the + /// canonical ranges `0..l` and `0..m`; rejecting rather than reducing them + /// catches malformed specifications. The constructor validates all six + /// permutation matrices and `Hx * Hz^T = 0` before returning. + /// + /// # Errors + /// + /// Returns an error for invalid dimensions, non-weight-three polynomials, + /// non-canonical or duplicate terms, size overflow, or noncommuting checks. + /// + /// # Panics + /// + /// Panics only if the internally generated rectangular binary matrices are + /// rejected by [`ParityCheckMatrix`], which would violate this module's + /// construction invariant. + pub fn new( + l: usize, + m: usize, + a_terms: &[BbMonomial], + b_terms: &[BbMonomial], + ) -> Result { + let block_size = validated_block_size(l, m)?; + let a_terms = validate_terms("A", l, m, a_terms)?; + let b_terms = validate_terms("B", l, m, b_terms)?; + let a_monomials = monomial_matrices("A", l, m, &a_terms)?; + let b_monomials = monomial_matrices("B", l, m, &b_terms)?; + let a = sum_matrices(&a_monomials); + let b = sum_matrices(&b_monomials); + let num_qubits = block_size + .checked_mul(2) + .ok_or(BivariateBicycleError::SizeOverflow { l, m })?; + let mut hx = F2Matrix::zeros(block_size, num_qubits); + let mut hz = F2Matrix::zeros(block_size, num_qubits); + for row in 0..block_size { + for column in 0..block_size { + hx.set(row, column, a.get(row, column)); + hx.set(row, block_size + column, b.get(row, column)); + hz.set(row, column, b.get(column, row)); + hz.set(row, block_size + column, a.get(column, row)); + } + } + if hx.mul(&hz.transpose()) != F2Matrix::zeros(block_size, block_size) { + return Err(BivariateBicycleError::NonCommutingChecks); + } + + let logical_x = quotient_basis(&hz, &hx, num_qubits); + let logical_z = quotient_basis(&hx, &hz, num_qubits); + let hx = ParityCheckMatrix::from_dense(hx.rows()) + .expect("a nonempty rectangular binary matrix was generated"); + let hz = ParityCheckMatrix::from_dense(hz.rows()) + .expect("a nonempty rectangular binary matrix was generated"); + let logical_x = parity_matrix_from_rows(logical_x, num_qubits); + let logical_z = parity_matrix_from_rows(logical_z, num_qubits); + + Ok(Self { + l, + m, + a_terms, + b_terms, + hx, + hz, + logical_x, + logical_z, + }) + } + + /// Number of data qubits, `n = 2lm`. + #[must_use] + pub fn num_qubits(&self) -> usize { + self.hx.num_qubits() + } + + /// Number of encoded logical qubits, `k = n - rank(Hx) - rank(Hz)`. + #[must_use] + pub fn num_logical_qubits(&self) -> usize { + self.num_qubits() - self.hx.rank() - self.hz.rank() + } + + /// X-type stabilizer parity-check matrix `Hx = [A | B]`. + #[must_use] + pub fn hx(&self) -> &ParityCheckMatrix { + &self.hx + } + + /// Z-type stabilizer parity-check matrix `Hz = [B^T | A^T]`. + #[must_use] + pub fn hz(&self) -> &ParityCheckMatrix { + &self.hz + } + + /// A basis of logical X operators, represented as binary rows. + #[must_use] + pub fn logical_x(&self) -> &ParityCheckMatrix { + &self.logical_x + } + + /// A basis of logical Z operators, represented as binary rows. + #[must_use] + pub fn logical_z(&self) -> &ParityCheckMatrix { + &self.logical_z + } + + /// Torus dimensions `(l, m)`. + #[must_use] + pub fn dimensions(&self) -> (usize, usize) { + (self.l, self.m) + } + + /// The three canonical exponent pairs of `A`. + #[must_use] + pub fn a_terms(&self) -> &[BbMonomial; 3] { + &self.a_terms + } + + /// The three canonical exponent pairs of `B`. + #[must_use] + pub fn b_terms(&self) -> &[BbMonomial; 3] { + &self.b_terms + } +} + +/// Construct the depth-eight bivariate-bicycle memory experiment. +/// +/// The four physical registers are ordered `q(X), q(L), q(R), q(Z)`. The +/// initial tick prepares the data and `q(Z)` registers. Each syndrome cycle is +/// then exactly the eight depth-one rounds of Table 5 in arXiv:2308.07915. +/// A final data-measurement tick closes the matching boundary detectors and +/// defines all `k` logical observables. +/// +/// # Errors +/// +/// Returns any code-structure error and rejects zero syndrome cycles. +pub fn bb_memory_circuit( + l: usize, + m: usize, + a_terms: &[BbMonomial], + b_terms: &[BbMonomial], + rounds: usize, + basis: BbMemoryBasis, +) -> Result { + if rounds == 0 { + return Err(BivariateBicycleError::ZeroRounds); + } + let code = BivariateBicycleCode::new(l, m, a_terms, b_terms)?; + build_memory_circuit(&code, rounds, basis) +} + +fn build_memory_circuit( + code: &BivariateBicycleCode, + rounds: usize, + basis: BbMemoryBasis, +) -> Result { + let block_size = code.l * code.m; + block_size + .checked_mul(4) + .ok_or(BivariateBicycleError::SizeOverflow { + l: code.l, + m: code.m, + })?; + let qx = |i| i; + let ql = |i| block_size + i; + let qr = |i| 2 * block_size + i; + let qz = |i| 3 * block_size + i; + let x_qubits: Vec<_> = (0..block_size).map(qx).collect(); + let left_qubits: Vec<_> = (0..block_size).map(ql).collect(); + let right_qubits: Vec<_> = (0..block_size).map(qr).collect(); + let z_qubits: Vec<_> = (0..block_size).map(qz).collect(); + let data_qubits: Vec<_> = left_qubits.iter().chain(&right_qubits).copied().collect(); + + let mut circuit = TickCircuit::new(); + { + let tick = circuit.tick(); + tick.pz(&z_qubits); + } + // Data preparation shares the pre-cycle layer with the disjoint q(Z) reset. + let initial_tick = circuit.get_tick_mut(0).expect("the initial tick exists"); + let prep = match basis { + BbMemoryBasis::X => pecos_core::Gate::px(&data_qubits), + BbMemoryBasis::Z => pecos_core::Gate::pz(&data_qubits), + }; + initial_tick.add_gate(prep); + + let mut x_measurements: Vec> = Vec::with_capacity(rounds); + let mut z_measurements: Vec> = Vec::with_capacity(rounds); + for _ in 0..rounds { + // Round 1. + circuit.tick().px(&x_qubits); + let r1 = (0..block_size) + .map(|i| { + ( + qr(transpose_shift(i, code.l, code.m, code.a_terms[0])), + qz(i), + ) + }) + .collect::>(); + circuit + .get_tick_mut(circuit.num_ticks() - 1) + .unwrap() + .add_gate(pecos_core::Gate::cx(&r1)); + circuit + .get_tick_mut(circuit.num_ticks() - 1) + .unwrap() + .add_gate(pecos_core::Gate::idle( + 1.0, + left_qubits + .iter() + .copied() + .map(Into::into) + .collect::(), + )); + + // Round 2. + let x_left = (0..block_size) + .map(|i| (qx(i), ql(forward_shift(i, code.l, code.m, code.a_terms[1])))) + .collect::>(); + let right_z = (0..block_size) + .map(|i| { + ( + qr(transpose_shift(i, code.l, code.m, code.a_terms[2])), + qz(i), + ) + }) + .collect::>(); + circuit.tick().cx(&x_left).cx(&right_z); + + // Round 3. + let x_right = (0..block_size) + .map(|i| (qx(i), qr(forward_shift(i, code.l, code.m, code.b_terms[1])))) + .collect::>(); + let left_z = (0..block_size) + .map(|i| { + ( + ql(transpose_shift(i, code.l, code.m, code.b_terms[0])), + qz(i), + ) + }) + .collect::>(); + circuit.tick().cx(&x_right).cx(&left_z); + + // Round 4. + let x_right = (0..block_size) + .map(|i| (qx(i), qr(forward_shift(i, code.l, code.m, code.b_terms[0])))) + .collect::>(); + let left_z = (0..block_size) + .map(|i| { + ( + ql(transpose_shift(i, code.l, code.m, code.b_terms[1])), + qz(i), + ) + }) + .collect::>(); + circuit.tick().cx(&x_right).cx(&left_z); + + // Round 5. + let x_right = (0..block_size) + .map(|i| (qx(i), qr(forward_shift(i, code.l, code.m, code.b_terms[2])))) + .collect::>(); + let left_z = (0..block_size) + .map(|i| { + ( + ql(transpose_shift(i, code.l, code.m, code.b_terms[2])), + qz(i), + ) + }) + .collect::>(); + circuit.tick().cx(&x_right).cx(&left_z); + + // Round 6. + let x_left = (0..block_size) + .map(|i| (qx(i), ql(forward_shift(i, code.l, code.m, code.a_terms[0])))) + .collect::>(); + let right_z = (0..block_size) + .map(|i| { + ( + qr(transpose_shift(i, code.l, code.m, code.a_terms[1])), + qz(i), + ) + }) + .collect::>(); + circuit.tick().cx(&x_left).cx(&right_z); + + // Round 7. + let x_left = (0..block_size) + .map(|i| (qx(i), ql(forward_shift(i, code.l, code.m, code.a_terms[2])))) + .collect::>(); + let mut tick = circuit.tick(); + tick.cx(&x_left).idle(1, &right_qubits); + z_measurements.push(tick.mz(&z_qubits)); + + // Round 8. + let x_refs = circuit.tick().mx(&x_qubits); + let tick = circuit.get_tick_mut(circuit.num_ticks() - 1).unwrap(); + tick.add_gate(pecos_core::Gate::pz(&z_qubits)); + tick.add_gate(pecos_core::Gate::idle( + 1.0, + data_qubits + .iter() + .copied() + .map(Into::into) + .collect::(), + )); + x_measurements.push(x_refs); + } + + add_cycle_detectors(&mut circuit, &x_measurements, &z_measurements, basis)?; + + let final_data = match basis { + BbMemoryBasis::X => circuit.tick().mx(&data_qubits), + BbMemoryBasis::Z => circuit.tick().mz(&data_qubits), + }; + let (closing_checks, final_logicals, last_syndrome, label) = match basis { + BbMemoryBasis::X => ( + code.hx(), + code.logical_x(), + &x_measurements[rounds - 1], + "X", + ), + BbMemoryBasis::Z => ( + code.hz(), + code.logical_z(), + &z_measurements[rounds - 1], + "Z", + ), + }; + for (check, &syndrome) in last_syndrome.iter().enumerate() { + let mut refs = vec![syndrome]; + for (data, &bit) in closing_checks.row(check).unwrap().iter().enumerate() { + if bit == 1 { + refs.push(final_data[data]); + } + } + annotate_detector(&mut circuit, &format!("{label}{check}_final"), &refs)?; + } + for logical in 0..final_logicals.num_checks() { + let refs = final_logicals + .row(logical) + .unwrap() + .iter() + .enumerate() + .filter_map(|(data, &bit)| (bit == 1).then_some(final_data[data])) + .collect::>(); + circuit + .observable_labeled(&format!("L{logical}"), &refs) + .map_err(|error| BivariateBicycleError::InvalidAnnotation(error.to_string()))?; + } + + let num_detectors = 2 * rounds * block_size; + let (detectors_json, observables_json) = annotation_metadata_json(&circuit); + circuit.set_meta( + "num_measurements", + Attribute::String(circuit.num_measurements().to_string()), + ); + circuit.set_meta("detectors", Attribute::String(detectors_json)); + circuit.set_meta("observables", Attribute::String(observables_json)); + circuit.set_meta( + "num_detectors", + Attribute::String(num_detectors.to_string()), + ); + circuit.set_meta( + "num_observables", + Attribute::String(code.num_logical_qubits().to_string()), + ); + circuit.set_meta( + "num_data_qubits", + Attribute::String(code.num_qubits().to_string()), + ); + circuit.set_meta( + "num_logical_qubits", + Attribute::String(code.num_logical_qubits().to_string()), + ); + circuit.set_meta("syndrome_cycles", Attribute::String(rounds.to_string())); + circuit.set_meta( + "syndrome_extraction_depth", + Attribute::String((8 * rounds + 1).to_string()), + ); + circuit.set_meta( + "circuit_type", + Attribute::String("bivariate_bicycle_memory".to_string()), + ); + Ok(circuit) +} + +fn annotation_metadata_json(circuit: &TickCircuit) -> (String, String) { + let mut detectors = Vec::new(); + let mut observables = Vec::new(); + for annotation in circuit.annotations() { + match &annotation.kind { + AnnotationKind::Detector { + measurement_ids, + coords: _, + } => { + let id = detectors.len(); + detectors.push(serde_json::json!({ + "id": id, + "meas_ids": measurement_ids.iter().map(|id| id.index()).collect::>(), + "label": annotation.label, + })); + } + AnnotationKind::Observable { measurement_ids } => { + let id = observables.len(); + observables.push(serde_json::json!({ + "id": id, + "meas_ids": measurement_ids.iter().map(|id| id.index()).collect::>(), + "label": annotation.label, + })); + } + AnnotationKind::TrackedPauli => {} + } + } + ( + serde_json::to_string(&detectors).expect("annotation metadata is JSON-serializable"), + serde_json::to_string(&observables).expect("annotation metadata is JSON-serializable"), + ) +} + +fn add_cycle_detectors( + circuit: &mut TickCircuit, + x_measurements: &[Vec], + z_measurements: &[Vec], + basis: BbMemoryBasis, +) -> Result<(), BivariateBicycleError> { + let block_size = x_measurements[0].len(); + for round in 0..x_measurements.len() { + for check in 0..block_size { + if round > 0 { + annotate_detector( + circuit, + &format!("X{check}_r{round}"), + &[ + x_measurements[round - 1][check], + x_measurements[round][check], + ], + )?; + annotate_detector( + circuit, + &format!("Z{check}_r{round}"), + &[ + z_measurements[round - 1][check], + z_measurements[round][check], + ], + )?; + } else { + let (label, reference) = match basis { + BbMemoryBasis::X => ("X", x_measurements[0][check]), + BbMemoryBasis::Z => ("Z", z_measurements[0][check]), + }; + annotate_detector(circuit, &format!("{label}{check}_r0"), &[reference])?; + } + } + } + Ok(()) +} + +fn annotate_detector( + circuit: &mut TickCircuit, + label: &str, + measurements: &[TickMeasRef], +) -> Result<(), BivariateBicycleError> { + circuit + .detector_labeled(label, measurements) + .map(|_| ()) + .map_err(|error| BivariateBicycleError::InvalidAnnotation(error.to_string())) +} + +fn validated_block_size(l: usize, m: usize) -> Result { + if l == 0 || m == 0 { + return Err(BivariateBicycleError::ZeroDimension { l, m }); + } + l.checked_mul(m) + .ok_or(BivariateBicycleError::SizeOverflow { l, m }) +} + +fn validate_terms( + polynomial: &'static str, + l: usize, + m: usize, + terms: &[BbMonomial], +) -> Result<[BbMonomial; 3], BivariateBicycleError> { + if terms.len() != 3 { + return Err(BivariateBicycleError::WrongTermCount { + polynomial, + actual: terms.len(), + }); + } + for (term_index, &(x_power, y_power)) in terms.iter().enumerate() { + if x_power >= l || y_power >= m { + return Err(BivariateBicycleError::ExponentOutOfRange { + polynomial, + term_index, + x_power, + y_power, + l, + m, + }); + } + } + for first in 0..terms.len() { + for second in first + 1..terms.len() { + if terms[first] == terms[second] { + return Err(BivariateBicycleError::DuplicateMonomial { + polynomial, + first, + second, + }); + } + } + } + Ok([terms[0], terms[1], terms[2]]) +} + +fn monomial_matrices( + polynomial: &'static str, + l: usize, + m: usize, + terms: &[BbMonomial; 3], +) -> Result<[F2Matrix; 3], BivariateBicycleError> { + let matrices = terms.map(|term| monomial_matrix(l, m, term)); + for (term_index, matrix) in matrices.iter().enumerate() { + if !is_permutation_matrix(matrix) { + return Err(BivariateBicycleError::NonPermutationMonomial { + polynomial, + term_index, + }); + } + } + Ok(matrices) +} + +fn monomial_matrix(l: usize, m: usize, term: BbMonomial) -> F2Matrix { + let size = l * m; + let mut matrix = F2Matrix::zeros(size, size); + for row in 0..size { + matrix.set(row, forward_shift(row, l, m, term), 1); + } + matrix +} + +fn is_permutation_matrix(matrix: &F2Matrix) -> bool { + let rows = matrix.num_rows(); + rows == matrix.num_cols() + && (0..rows).all(|row| { + (0..rows) + .filter(|&column| matrix.get(row, column) == 1) + .count() + == 1 + }) + && (0..rows).all(|column| { + (0..rows) + .filter(|&row| matrix.get(row, column) == 1) + .count() + == 1 + }) +} + +fn sum_matrices(matrices: &[F2Matrix; 3]) -> F2Matrix { + let size = matrices[0].num_rows(); + let mut sum = F2Matrix::zeros(size, size); + for matrix in matrices { + for row in 0..size { + for column in 0..size { + sum.set(row, column, sum.get(row, column) ^ matrix.get(row, column)); + } + } + } + sum +} + +fn quotient_basis(kernel_matrix: &F2Matrix, stabilizers: &F2Matrix, width: usize) -> Vec> { + let (stabilizer_rref, pivots) = stabilizers.row_reduce(); + let reduced = kernel_matrix.kernel().into_iter().filter_map(|mut vector| { + for (row, &pivot) in pivots.iter().enumerate() { + if vector[pivot] == 1 { + for (column, bit) in vector.iter_mut().enumerate() { + *bit ^= stabilizer_rref.get(row, column); + } + } + } + vector.iter().any(|&bit| bit != 0).then_some(vector) + }); + let candidates: Vec<_> = reduced.collect(); + if candidates.is_empty() { + return Vec::new(); + } + let (rref, _) = F2Matrix::from_rows(candidates).row_reduce(); + let rows = rref + .rows() + .into_iter() + .filter(|row| row.iter().any(|&bit| bit != 0)) + .collect::>(); + debug_assert!(rows.iter().all(|row| row.len() == width)); + rows +} + +fn parity_matrix_from_rows(rows: Vec>, width: usize) -> ParityCheckMatrix { + if rows.is_empty() { + ParityCheckMatrix::zeros(0, width) + } else { + ParityCheckMatrix::from_dense(rows) + .expect("a rectangular logical-operator matrix was generated") + } +} + +fn forward_shift(index: usize, l: usize, m: usize, term: BbMonomial) -> usize { + let (x_power, y_power) = term; + let x = index / m; + let y = index % m; + ((x + x_power) % l) * m + (y + y_power) % m +} + +fn transpose_shift(index: usize, l: usize, m: usize, term: BbMonomial) -> usize { + let (x_power, y_power) = term; + let x = index / m; + let y = index % m; + ((x + l - x_power) % l) * m + (y + m - y_power) % m +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::connected_cluster_code_distance; + use crate::fault_tolerance::dem_builder::{DemBuilder, DemSampler, NoiseConfig}; + use crate::fault_tolerance::{ + connected_cluster_fault_distance, graphlike_fault_distance, per_observable_fault_distances, + }; + use pecos_quantum::GateType; + use pecos_random::PecosRng; + use pecos_simulators::{CircuitExecutor, SparseStab}; + use std::collections::BTreeSet; + use std::time::Instant; + + const A: [BbMonomial; 3] = [(3, 0), (0, 1), (0, 2)]; + const B: [BbMonomial; 3] = [(0, 3), (1, 0), (2, 0)]; + + fn code_72() -> BivariateBicycleCode { + BivariateBicycleCode::new(6, 6, &A, &B).expect("the paper's [[72,12,6]] code is valid") + } + + fn circuit_72(rounds: usize, basis: BbMemoryBasis) -> TickCircuit { + bb_memory_circuit(6, 6, &A, &B, rounds, basis) + .expect("the paper's [[72,12,6]] memory circuit is valid") + } + + fn sample_annotation_parities(circuit: &TickCircuit) -> (Vec, Vec) { + let mut sim = SparseStab::new(144); + let measurements = CircuitExecutor::new(circuit).run(&mut sim); + let parity = |ids: &[pecos_core::MeasId]| { + ids.iter() + .fold(false, |value, id| value ^ measurements[id.index()].outcome) + }; + let mut detectors = Vec::new(); + let mut observables = Vec::new(); + for annotation in circuit.annotations() { + match &annotation.kind { + AnnotationKind::Detector { + measurement_ids, .. + } => detectors.push(parity(measurement_ids)), + AnnotationKind::Observable { measurement_ids } => { + observables.push(parity(measurement_ids)); + } + AnnotationKind::TrackedPauli => {} + } + } + (detectors, observables) + } + + #[test] + fn code_72_has_the_reported_parameters_and_distance() { + let code = code_72(); + assert_eq!(code.num_qubits(), 72); + assert_eq!(code.num_logical_qubits(), 12); + assert_eq!(code.logical_x().num_checks(), 12); + assert_eq!(code.logical_z().num_checks(), 12); + assert_eq!( + connected_cluster_code_distance(code.hx(), code.logical_x(), 6) + .expect("distance is at most six") + .distance, + 6 + ); + } + + #[test] + fn schedule_has_exact_round_shape_and_tanner_edges() { + let code = code_72(); + let circuit = circuit_72(2, BbMemoryBasis::Z); + let s = 36; + assert_eq!(circuit.num_ticks(), 8 * 2 + 2); + + let expected_idle: [BTreeSet; 8] = [ + (s..2 * s).collect(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + (2 * s..3 * s).collect(), + (s..3 * s).collect(), + ]; + let expected_counts = [ + vec![(GateType::PX, s), (GateType::CX, s), (GateType::Idle, s)], + vec![(GateType::CX, 2 * s)], + vec![(GateType::CX, 2 * s)], + vec![(GateType::CX, 2 * s)], + vec![(GateType::CX, 2 * s)], + vec![(GateType::CX, 2 * s)], + vec![(GateType::CX, s), (GateType::MZ, s), (GateType::Idle, s)], + vec![ + (GateType::MX, s), + (GateType::PZ, s), + (GateType::Idle, 2 * s), + ], + ]; + + for cycle in 0..2 { + for round in 0..8 { + let tick = circuit.get_tick(1 + 8 * cycle + round).unwrap(); + for &(gate_type, expected) in &expected_counts[round] { + let actual = tick + .iter_gate_instances() + .filter(|gate| gate.gate_type() == gate_type) + .count(); + assert_eq!(actual, expected, "cycle {cycle}, round {}", round + 1); + } + let idle = tick + .iter_gate_instances() + .filter(|gate| gate.gate_type() == GateType::Idle) + .map(|gate| gate.qubits()[0].index()) + .collect::>(); + assert_eq!(idle, expected_idle[round], "round {} idles", round + 1); + + for gate in tick + .iter_gate_instances() + .filter(|gate| gate.gate_type() == GateType::CX) + { + let control = gate.qubits()[0].index(); + let target = gate.qubits()[1].index(); + assert!(target >= s, "q(X) is never a CNOT target"); + assert!(control < 3 * s, "q(Z) is never a CNOT control"); + if control < s { + assert!(target < 3 * s); + assert_eq!(code.hx().matrix().get(control, target - s), 1); + } else { + assert!(target >= 3 * s); + assert_eq!(code.hz().matrix().get(target - 3 * s, control - s), 1); + } + } + } + } + } + + #[test] + fn noiseless_memory_has_empty_syndrome_and_deterministic_observables() { + for basis in [BbMemoryBasis::X, BbMemoryBasis::Z] { + let circuit = circuit_72(2, basis); + let dem = DemBuilder::try_from_tick_circuit(&circuit, 0.0, 0.0, 0.0, 0.0) + .expect("fault-free circuit has a DEM"); + assert_eq!(dem.num_detectors(), 144); + assert_eq!(dem.num_observables(), 12); + assert!(dem.to_mechanisms().0.is_empty()); + + let zero_noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0); + let sampler = DemSampler::from_tick_circuit(&circuit, &zero_noise) + .expect("all annotated detector parities are deterministic"); + let mut rng = PecosRng::seed_from_u64(23); + let (detectors, observables) = sampler.sample(&mut rng); + assert_eq!(detectors, vec![false; 144]); + assert_eq!(observables, vec![false; 12]); + + for _ in 0..8 { + let (detectors, observables) = sample_annotation_parities(&circuit); + assert_eq!(detectors, vec![false; 144]); + assert_eq!(observables, vec![false; 12]); + } + } + } + + #[test] + fn swapping_rounds_three_and_six_breaks_fault_free_detectors() { + let mut circuit = circuit_72(2, BbMemoryBasis::Z); + for cycle in 0..2 { + circuit + .ticks_mut() + .swap(1 + 8 * cycle + 2, 1 + 8 * cycle + 5); + } + let caught = (0..16).any(|_| { + let (detectors, _) = sample_annotation_parities(&circuit); + detectors.into_iter().any(|event| event) + }); + assert!( + caught, + "the swapped schedule must not pass fault-free validation" + ); + } + + #[test] + fn builder_output_is_deterministic() { + assert_eq!( + format!("{:?}", circuit_72(2, BbMemoryBasis::Z)), + format!("{:?}", circuit_72(2, BbMemoryBasis::Z)) + ); + } + + #[test] + fn noncanonical_monomial_exponent_is_rejected() { + let broken_a = [(6, 0), (0, 1), (0, 2)]; + assert!(matches!( + BivariateBicycleCode::new(6, 6, &broken_a, &B), + Err(BivariateBicycleError::ExponentOutOfRange { + polynomial: "A", + term_index: 0, + .. + }) + )); + } + + #[test] + fn every_observable_has_a_weight_six_circuit_fault_witness() { + const WITNESSES: [[usize; 6]; 3] = [ + [4176, 4186, 4197, 4199, 4208, 4219], + [3478, 3486, 3554, 3639, 3757, 3769], + [4203, 4209, 4233, 4241, 4268, 4283], + ]; + + let circuit = circuit_72(2, BbMemoryBasis::Z); + let dem = DemBuilder::try_from_tick_circuit(&circuit, 0.001, 0.001, 0.001, 0.001) + .expect("uniform circuit noise has a DEM"); + let (mechanisms, _) = dem.to_mechanisms(); + let mut covered_observables = BTreeSet::new(); + + for witness in WITNESSES { + let mut detectors = BTreeSet::new(); + let mut observables = BTreeSet::new(); + for index in witness { + let (_, mechanism_detectors, mechanism_observables) = &mechanisms[index]; + for &detector in mechanism_detectors { + if !detectors.insert(detector) { + detectors.remove(&detector); + } + } + for &observable in mechanism_observables { + if !observables.insert(observable) { + observables.remove(&observable); + } + } + } + assert!( + detectors.is_empty(), + "a stored weight-six circuit witness must be detector-free" + ); + assert!(!observables.is_empty(), "a stored witness must be logical"); + covered_observables.extend(observables); + } + + assert_eq!( + covered_observables, + (0_u32..12).collect(), + "the stored witnesses must cover every encoded observable" + ); + } + + #[test] + #[ignore = "exact 4,284-mechanism [[72,12,6]] hypergraph search is too slow for normal tests"] + fn circuit_distance_72_two_cycles() { + let started = Instant::now(); + let circuit = circuit_72(2, BbMemoryBasis::Z); + let dem = DemBuilder::try_from_tick_circuit(&circuit, 0.001, 0.001, 0.001, 0.001) + .expect("uniform circuit noise has a DEM"); + let build_elapsed = started.elapsed(); + let (mechanisms, _) = dem.to_mechanisms(); + println!( + "BB [[72,12,6]] DEM: {} mechanisms, build {build_elapsed:?}", + mechanisms.len() + ); + + let distance_started = Instant::now(); + let (method, overall) = match graphlike_fault_distance(&dem) { + Ok(result) => ("graphlike", result), + Err(error) => { + println!("graphlike unavailable: {error}"); + ( + "connected_cluster", + connected_cluster_fault_distance(&dem, 6), + ) + } + }; + let distance_elapsed = distance_started.elapsed(); + println!("{method} overall: {overall:?}, search {distance_elapsed:?}"); + + let per_started = Instant::now(); + let per_observable = per_observable_fault_distances(&dem, 6); + println!( + "per-observable: {per_observable:?}, search {:?}", + per_started.elapsed() + ); + + let overall = overall.expect("a logical fault exists through weight six"); + if overall.distance < 6 { + println!("FULL SUB-DISTANCE WITNESS:"); + for &index in &overall.mechanism_indices { + println!("mechanism[{index}] = {:?}", mechanisms[index]); + } + } + assert_eq!(overall.distance, 6); + assert!( + per_observable + .iter() + .all(|result| result.as_ref().is_some_and(|result| result.distance == 6)), + "every encoded observable must retain circuit fault distance six" + ); + } +} diff --git a/crates/pecos-qec/src/code_distance.rs b/crates/pecos-qec/src/code_distance.rs index 749919f52..89d3fc93a 100644 --- a/crates/pecos-qec/src/code_distance.rs +++ b/crates/pecos-qec/src/code_distance.rs @@ -341,67 +341,18 @@ mod tests { assert!(!first.mechanism_indices.contains(&0)); } - fn bb_circulant(l: usize, m: usize, terms: &[(usize, usize)]) -> F2Matrix { - let size = l * m; - let mut matrix = F2Matrix::zeros(size, size); - for row_x in 0..l { - for row_y in 0..m { - let row = row_x * m + row_y; - for &(x_power, y_power) in terms { - let column = ((row_x + x_power) % l) * m + (row_y + y_power) % m; - matrix.set(row, column, matrix.get(row, column) ^ 1); - } - } - } - matrix - } - fn bb_code_pair(l: usize, m: usize) -> (ParityCheckMatrix, ParityCheckMatrix, usize) { - let block_size = l * m; - let num_qubits = 2 * block_size; - let a = bb_circulant(l, m, &[(3, 0), (0, 1), (0, 2)]); - let b = bb_circulant(l, m, &[(0, 3), (1, 0), (2, 0)]); - let mut hx = F2Matrix::zeros(block_size, num_qubits); - let mut hz = F2Matrix::zeros(block_size, num_qubits); - for row in 0..block_size { - for column in 0..block_size { - hx.set(row, column, a.get(row, column)); - hx.set(row, block_size + column, b.get(row, column)); - hz.set(row, column, b.get(column, row)); - hz.set(row, block_size + column, a.get(column, row)); - } - } - assert_eq!( - hx.mul(&hz.transpose()), - F2Matrix::zeros(block_size, block_size) - ); - - let hx_rank = hx.row_reduce().1.len(); - let hz_rank = hz.row_reduce().1.len(); - let num_logical_qubits = num_qubits - hx_rank - hz_rank; - let (hx_rref, hx_pivots) = hx.row_reduce(); - let logical_candidates = hz.kernel().into_iter().filter_map(|mut vector| { - for (row, &pivot) in hx_pivots.iter().enumerate() { - if vector[pivot] == 1 { - for (column, bit) in vector.iter_mut().enumerate() { - *bit ^= hx_rref.get(row, column); - } - } - } - vector.iter().any(|&bit| bit != 0).then_some(vector) - }); - let (logical_rref, _) = F2Matrix::from_rows(logical_candidates.collect()).row_reduce(); - let logical_rows: Vec<_> = logical_rref - .rows() - .into_iter() - .filter(|row| row.iter().any(|&bit| bit != 0)) - .collect(); - assert_eq!(logical_rows.len(), num_logical_qubits); - + let code = crate::BivariateBicycleCode::new( + l, + m, + &[(3, 0), (0, 1), (0, 2)], + &[(0, 3), (1, 0), (2, 0)], + ) + .unwrap(); ( - ParityCheckMatrix::from_dense(hx.rows()).unwrap(), - ParityCheckMatrix::from_dense(logical_rows).unwrap(), - num_logical_qubits, + code.hx().clone(), + code.logical_x().clone(), + code.num_logical_qubits(), ) } diff --git a/crates/pecos-qec/src/distance_problem.rs b/crates/pecos-qec/src/distance_problem.rs index fd61c29da..5288db483 100644 --- a/crates/pecos-qec/src/distance_problem.rs +++ b/crates/pecos-qec/src/distance_problem.rs @@ -1478,71 +1478,20 @@ mod tests { assert_eq!(problem.verify_witness(&second.witness), Ok(3)); } - fn bb_circulant(l: usize, m: usize, terms: &[(usize, usize)]) -> F2Matrix { - let size = l * m; - let mut matrix = F2Matrix::zeros(size, size); - for row_x in 0..l { - for row_y in 0..m { - let row = row_x * m + row_y; - for &(x_power, y_power) in terms { - let column = ((row_x + x_power) % l) * m + (row_y + y_power) % m; - matrix.set(row, column, matrix.get(row, column) ^ 1); - } - } - } - matrix - } - #[test] #[ignore = "timing probe for the batsat backend"] fn batsat_bivariate_bicycle_72_12_6_timing_probe() { - let (l, m) = (6, 6); - let block_size = l * m; - let n = 2 * block_size; - let a = bb_circulant(l, m, &[(3, 0), (0, 1), (0, 2)]); - let b = bb_circulant(l, m, &[(0, 3), (1, 0), (2, 0)]); - let mut hx = F2Matrix::zeros(block_size, n); - let mut hz = F2Matrix::zeros(block_size, n); - for row in 0..block_size { - for column in 0..block_size { - hx.set(row, column, a.get(row, column)); - hx.set(row, block_size + column, b.get(row, column)); - hz.set(row, column, b.get(column, row)); - hz.set(row, block_size + column, a.get(column, row)); - } - } - - assert_eq!(n, 72); - assert_eq!( - hx.mul(&hz.transpose()), - F2Matrix::zeros(block_size, block_size) - ); - let hx_rank = hx.row_reduce().1.len(); - let hz_rank = hz.row_reduce().1.len(); - assert_eq!(n - hx_rank - hz_rank, 12); - - let (hx_rref, hx_pivots) = hx.row_reduce(); - let logical_candidates = hz.kernel().into_iter().filter_map(|mut vector| { - for (row, &pivot) in hx_pivots.iter().enumerate() { - if vector[pivot] == 1 { - for (column, bit) in vector.iter_mut().enumerate() { - *bit ^= hx_rref.get(row, column); - } - } - } - vector.iter().any(|&bit| bit != 0).then_some(vector) - }); - let (logical_rref, _) = F2Matrix::from_rows(logical_candidates.collect()).row_reduce(); - let logical_rows: Vec<_> = logical_rref - .rows() - .into_iter() - .filter(|row| row.iter().any(|&bit| bit != 0)) - .collect(); - assert_eq!(logical_rows.len(), 12); + let code = crate::BivariateBicycleCode::new( + 6, + 6, + &[(3, 0), (0, 1), (0, 2)], + &[(0, 3), (1, 0), (2, 0)], + ) + .expect("the paper's [[72,12,6]] code is valid"); + assert_eq!(code.num_qubits(), 72); + assert_eq!(code.num_logical_qubits(), 12); - let hx_checks = ParityCheckMatrix::from_dense(hx.rows()).unwrap(); - let logical_checks = ParityCheckMatrix::from_dense(logical_rows).unwrap(); - let problem = DistanceProblem::from_css_checks(&hx_checks, &logical_checks).unwrap(); + let problem = DistanceProblem::from_css_checks(code.hx(), code.logical_x()).unwrap(); let total_started = Instant::now(); let certified = problem .certify_distance_by(6, |problem, weight| { diff --git a/crates/pecos-qec/src/fault_tolerance/circuit_runner.rs b/crates/pecos-qec/src/fault_tolerance/circuit_runner.rs index 09ab61e74..538c2b76a 100644 --- a/crates/pecos-qec/src/fault_tolerance/circuit_runner.rs +++ b/crates/pecos-qec/src/fault_tolerance/circuit_runner.rs @@ -67,7 +67,7 @@ pub fn extract_spacetime_locations( let qubits: Vec = gate.qubits().to_vec(); let is_measurement = matches!( gate.gate_type(), - GateType::MZ | GateType::MeasureFree | GateType::MPZ + GateType::MX | GateType::MZ | GateType::MeasureFree | GateType::MPZ ); locations.push(SpacetimeLocation::new( @@ -210,8 +210,16 @@ fn apply_tick_gates(sim: &mut S, tick: &pecos_quantum::Tick swap_pairs.push((c[0], c[1])); } } + GateType::MX => { + sim.h(&gate.qubits); + sim.mz(&gate.qubits); + } GateType::MZ | GateType::MeasureFree => mz_qubits.extend(gate.qubits.iter()), GateType::MPZ => mpz_qubits.extend(gate.qubits.iter()), + GateType::PX => { + sim.pz(&gate.qubits); + sim.h(&gate.qubits); + } GateType::PZ => pz_qubits.extend(gate.qubits.iter()), GateType::I => {} _ => { @@ -391,12 +399,20 @@ fn apply_gate(sim: &mut S, gate: &pecos_core::Gate) { _ => unreachable!(), } } + GateType::MX => { + sim.h(&qubits); + sim.mz(&qubits); + } GateType::MZ | GateType::MeasureFree => { sim.mz(&qubits); } GateType::MPZ => { sim.mpz(&qubits); } + GateType::PX => { + sim.pz(&qubits); + sim.h(&qubits); + } GateType::PZ => { sim.pz(&qubits); } diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 08133f20e..1213a83ca 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -1220,6 +1220,11 @@ impl<'a> DemBuilder<'a> { }; match gate_type { + GateType::MX => { + require(1)?; + sim.h(qubits); + sim.mz(qubits); + } GateType::MZ | GateType::MeasureFree => { require(1)?; sim.mz(qubits); @@ -1231,6 +1236,13 @@ impl<'a> DemBuilder<'a> { sim.pz(qubit); } } + GateType::PX => { + require(1)?; + for &qubit in qubits { + sim.pz(qubit); + sim.h(&[qubit]); + } + } GateType::PZ | GateType::QAlloc => { require(1)?; for &qubit in qubits { @@ -1418,7 +1430,7 @@ impl<'a> DemBuilder<'a> { for (loc_idx, loc) in locations.iter().enumerate() { match loc.gate_type { - GateType::PZ | GateType::QAlloc + GateType::PX | GateType::PZ | GateType::QAlloc if !loc.before && self.init_rate_for_loc(loc) > 0.0 => { self.process_prep_fault_source_tracked( @@ -1431,7 +1443,7 @@ impl<'a> DemBuilder<'a> { // MPZ takes its measurement-half fault here; the prepare-half // needs an after-location the location model does not yet give // measurements (tracked on the MP* issue). - GateType::MZ | GateType::MeasureFree | GateType::MPZ + GateType::MX | GateType::MZ | GateType::MeasureFree | GateType::MPZ if loc.before && self.measurement_rate_for_loc(loc) > 0.0 => { self.process_meas_fault_source_tracked( @@ -1572,14 +1584,19 @@ impl<'a> DemBuilder<'a> { ) { let loc = &self.influence_map.locations[loc_idx]; let p = self.init_rate_for_loc(loc); - // For Z-basis prep, X error matters - this is a direct source + // A preparation fault anticommutes with the prepared basis. + let pauli = if loc.gate_type == GateType::PX { + Pauli::Z + } else { + Pauli::X + }; let mechanism = - self.compute_mechanism(loc_idx, Pauli::X, meas_to_detectors, meas_to_observables); + self.compute_mechanism(loc_idx, pauli, 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]), + SourceMetadata::new(&[loc_idx], &[pauli], &[loc.gate_type], &[loc.before]), ); } } @@ -1700,14 +1717,19 @@ impl<'a> DemBuilder<'a> { ) { let loc = &self.influence_map.locations[loc_idx]; let p = self.measurement_rate_for_loc(loc); - // Measurement error is a bit flip (X error) - this is a direct source + // A measurement fault anticommutes with the measured basis. + let pauli = if loc.gate_type == GateType::MX { + Pauli::Z + } else { + Pauli::X + }; let mechanism = - self.compute_mechanism(loc_idx, Pauli::X, meas_to_detectors, meas_to_observables); + self.compute_mechanism(loc_idx, pauli, 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]), + SourceMetadata::new(&[loc_idx], &[pauli], &[loc.gate_type], &[loc.before]), ); } } @@ -3693,6 +3715,31 @@ impl std::error::Error for DemBuilderError {} mod tests { use super::*; + #[test] + fn z_error_before_mx_produces_a_detector_mechanism() { + use pecos_num::graph::Attribute; + + let mut circuit = pecos_quantum::TickCircuit::new(); + circuit.tick().px(&[0]); + let measurement = circuit.tick().mx(&[0]); + circuit + .detector(&measurement) + .expect("the MX reference belongs to the circuit"); + circuit.set_meta("num_measurements", Attribute::String("1".to_string())); + circuit.set_meta( + "detectors", + Attribute::String(r#"[{"id":0,"meas_ids":[0]}]"#.to_string()), + ); + circuit.set_meta("observables", Attribute::String("[]".to_string())); + + let dem = DemBuilder::try_from_tick_circuit(&circuit, 0.0, 0.0, 1.0, 0.0) + .expect("a native MX circuit is supported by DEM construction"); + let (mechanisms, _) = dem.to_mechanisms(); + assert_eq!(mechanisms.len(), 1); + assert_eq!(mechanisms[0].1, vec![0]); + assert!(mechanisms[0].2.is_empty()); + } + #[test] fn test_szz_source_frame_components_pull_post_error_to_pre_generators() { fn dets(indices: &[u32]) -> FaultMechanism { diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs index ce77e4336..0c69869bf 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 @@ -2057,6 +2057,18 @@ impl<'a> SamplingEngineBuilder<'a> { ); } } + GateType::PX if !loc.before => { + let p = self.init_rate_for_location(loc); + if p > 0.0 { + self.process_single_pauli_fault( + loc_idx, + Pauli::Z, + p, + &mechanism_context, + &mut aggregated, + ); + } + } GateType::MZ | GateType::MeasureFree | GateType::MPZ // Measurement errors: only "before" locations (X error = bit flip) if loc.before => @@ -2072,6 +2084,18 @@ impl<'a> SamplingEngineBuilder<'a> { ); } } + GateType::MX if loc.before => { + let p = self.measurement_rate_for_location(loc); + if p > 0.0 { + self.process_single_pauli_fault( + loc_idx, + Pauli::Z, + p, + &mechanism_context, + &mut aggregated, + ); + } + } GateType::CX | GateType::CZ | GateType::CY diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs index 48dbbbdaf..0af7f543c 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 @@ -83,6 +83,12 @@ impl<'a> MemBuilder<'a> { GateType::PZ | GateType::QAlloc if self.noise.p_prep > 0.0 && !loc.before => { self.process_single_pauli_fault(loc_idx, Pauli::X, self.noise.p_prep, &mut mem); } + GateType::PX if self.noise.p_prep > 0.0 && !loc.before => { + self.process_single_pauli_fault(loc_idx, Pauli::Z, self.noise.p_prep, &mut mem); + } + GateType::MX if self.noise.p_meas > 0.0 && loc.before => { + self.process_single_pauli_fault(loc_idx, Pauli::Z, self.noise.p_meas, &mut mem); + } GateType::MZ | GateType::MeasureFree | GateType::MPZ if self.noise.p_meas > 0.0 && loc.before => { diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs index 9ec0d40d0..44aa8e610 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -1508,8 +1508,8 @@ pub(crate) fn compute_location_probs_from_noise( .map(|loc| { #[allow(clippy::match_same_arms)] match loc.gate_type { - GateType::PZ | GateType::QAlloc => noise.p_prep, - GateType::MZ | GateType::MeasureFree | GateType::MPZ => noise.p_meas, + GateType::PX | GateType::PZ | GateType::QAlloc => noise.p_prep, + GateType::MX | GateType::MZ | GateType::MeasureFree | GateType::MPZ => noise.p_meas, GateType::CX | GateType::CZ | GateType::CY diff --git a/crates/pecos-qec/src/fault_tolerance/fault_distance.rs b/crates/pecos-qec/src/fault_tolerance/fault_distance.rs index b92a3c89b..0e282ccda 100644 --- a/crates/pecos-qec/src/fault_tolerance/fault_distance.rs +++ b/crates/pecos-qec/src/fault_tolerance/fault_distance.rs @@ -208,8 +208,8 @@ impl ConnectedClusterSearch<'_> { .flat_map(|detector| &self.incidence[detector]) .copied() .filter(|&neighbor| { - neighbor > seed - && self.active[neighbor] + self.active[neighbor] + && neighbor > seed && !members.contains(&neighbor) && !excluded.contains(&neighbor) }) diff --git a/crates/pecos-qec/src/fault_tolerance/fault_sampler.rs b/crates/pecos-qec/src/fault_tolerance/fault_sampler.rs index 58211aea6..07c59497c 100644 --- a/crates/pecos-qec/src/fault_tolerance/fault_sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/fault_sampler.rs @@ -111,13 +111,17 @@ fn is_standard_2q_clifford_gate(gate_type: GateType) -> bool { fn is_supported_measurement_gate(gate_type: GateType) -> bool { matches!( gate_type, - GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked | GateType::MPZ + GateType::MX + | GateType::MZ + | GateType::MeasureFree + | GateType::MeasureLeaked + | GateType::MPZ ) } #[inline] fn is_supported_prep_gate(gate_type: GateType) -> bool { - matches!(gate_type, GateType::PZ | GateType::QAlloc) + matches!(gate_type, GateType::PX | GateType::PZ | GateType::QAlloc) } #[inline] @@ -517,21 +521,28 @@ fn propagate_forward( let pair = [(QubitId(loc.qubits[0]), QubitId(loc.qubits[1]))]; prop.swap(&pair); } - // PZ/QAlloc absorbs propagating errors on the reset qubit - GateType::PZ | GateType::QAlloc if !loc.qubits.is_empty() => { + // Preparation absorbs propagating errors on the reset qubit. + GateType::PX | GateType::PZ | GateType::QAlloc if !loc.qubits.is_empty() => { prop.clear_qubit(loc.qubits[0]); } // The X component flips the measurement and then survives the // collapse -- a non-destructive measurement absorbs only the Z // component. A discarded (`MeasureFree`) or reset (`MPZ`) qubit // clears fully. - GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked | GateType::MPZ + GateType::MX + | GateType::MZ + | GateType::MeasureFree + | GateType::MeasureLeaked + | GateType::MPZ if !loc.qubits.is_empty() => { let q = loc.qubits[0]; - if prop.contains_x(q) - && let Some(&meas_idx) = meas_positions.get(&loc_idx) - { + let flips_result = if loc.gate_type == GateType::MX { + prop.contains_z(q) + } else { + prop.contains_x(q) + }; + if flips_result && let Some(&meas_idx) = meas_positions.get(&loc_idx) { affected.insert(meas_idx); } super::propagator::cross_measurement( @@ -1217,10 +1228,15 @@ fn build_structural_fault_catalog(tc: &TickCircuit) -> Result { + GateType::PX | GateType::PZ | GateType::QAlloc if !loc.qubits.is_empty() => { let q = loc.qubits[0]; + let prep_fault = if gate_type == GateType::PX { + PauliType::Z + } else { + PauliType::X + }; let effect = effect_cache.single( - PauliType::X, + prep_fault, q, loc_idx + 1, &gates, @@ -1251,7 +1267,11 @@ fn build_structural_fault_catalog(tc: &TickCircuit) -> Result { + GateType::MX + | GateType::MZ + | GateType::MeasureFree + | GateType::MeasureLeaked + | GateType::MPZ => { if let Some(&meas_idx) = meas_positions.get(&loc_idx) { let affected = vec![meas_idx]; let dets = record_effect_index.detectors_for_measurements(&affected); @@ -1556,9 +1576,12 @@ pub fn symbolic_measurement_history( let qs: Vec = gate.qubits.iter().map(pecos_core::QubitId::index).collect(); match gate.gate_type { - GateType::PZ | GateType::QAlloc => { + GateType::PX | GateType::PZ | GateType::QAlloc => { for &q in &qs { sim.pz(q); + if gate.gate_type == GateType::PX { + sim.h(&[q]); + } } } GateType::H => { @@ -1638,10 +1661,17 @@ pub fn symbolic_measurement_history( // every reference to it. Representing that needs a notion of a // hidden random source this history does not have, so say so // rather than return a history whose dependencies dangle. - GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked | GateType::MPZ => { + GateType::MX + | GateType::MZ + | GateType::MeasureFree + | GateType::MeasureLeaked + | GateType::MPZ => { // Every measurement collapses its qubit, so all of them run. // Only the record-bearing ones earn a column. let records_a_result = gate.gate_type.consumes_measurement_record(); + if gate.gate_type == GateType::MX { + sim.h(&qs); + } for result in sim.mz(&qs) { if records_a_result { let column = column_of.len(); diff --git a/crates/pecos-qec/src/fault_tolerance/influence_builder.rs b/crates/pecos-qec/src/fault_tolerance/influence_builder.rs index ab7f3e917..4449a8944 100644 --- a/crates/pecos-qec/src/fault_tolerance/influence_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/influence_builder.rs @@ -429,7 +429,8 @@ impl<'a> InfluenceBuilder<'a> { } match op.gate_type { - pecos_quantum::GateType::MZ + pecos_quantum::GateType::MX + | pecos_quantum::GateType::MZ | pecos_quantum::GateType::MeasureFree | pecos_quantum::GateType::MPZ => { if qubits.len() > 1 { @@ -438,6 +439,9 @@ impl<'a> InfluenceBuilder<'a> { count: qubits.len(), }); } + if op.gate_type == pecos_quantum::GateType::MX { + sim.h(&[qubits[0]]); + } sim.mz(&[qubits[0]]); if op.gate_type == pecos_quantum::GateType::MPZ { // Measure-and-prepare resets after the readout. @@ -460,9 +464,14 @@ impl<'a> InfluenceBuilder<'a> { } // Resets project onto |0>. Skipping them treated a reused // qubit as still carrying its pre-reset correlations. - pecos_quantum::GateType::PZ | pecos_quantum::GateType::QAlloc => { + pecos_quantum::GateType::PX + | pecos_quantum::GateType::PZ + | pecos_quantum::GateType::QAlloc => { for &q in &qubits { sim.pz(q); + if op.gate_type == pecos_quantum::GateType::PX { + sim.h(&[q]); + } } } // No effect on stabilizer correlations. @@ -636,7 +645,8 @@ impl<'a> InfluenceBuilder<'a> { let is_measurement = matches!( gate.gate_type, - pecos_quantum::GateType::MZ + pecos_quantum::GateType::MX + | pecos_quantum::GateType::MZ | pecos_quantum::GateType::MeasureFree | pecos_quantum::GateType::MPZ ); @@ -668,7 +678,9 @@ impl<'a> InfluenceBuilder<'a> { } if matches!( gate.gate_type, - pecos_quantum::GateType::PZ | pecos_quantum::GateType::QAlloc + pecos_quantum::GateType::PX + | pecos_quantum::GateType::PZ + | pecos_quantum::GateType::QAlloc ) { prepared_qubits.extend(qubits.iter().copied()); } @@ -714,8 +726,11 @@ impl<'a> InfluenceBuilder<'a> { { let mut seed = PauliProp::new(); for qubit in &gate.qubits { - // Z-basis measurement means we propagate Z - seed.track_z(&[qubit.index()]); + if gate.gate_type == pecos_quantum::GateType::MX { + seed.track_x(&[qubit.index()]); + } else { + seed.track_z(&[qubit.index()]); + } } Self::propagate_observable( propagator, @@ -873,7 +888,9 @@ impl<'a> InfluenceBuilder<'a> { // cannot propagate past it. let is_prep = matches!( gate.gate_type, - pecos_quantum::GateType::PZ | pecos_quantum::GateType::QAlloc + pecos_quantum::GateType::PX + | pecos_quantum::GateType::PZ + | pecos_quantum::GateType::QAlloc ); if is_prep { for q in &gate.qubits { @@ -948,7 +965,8 @@ impl<'a> InfluenceBuilder<'a> { let is_measurement = matches!( gate.gate_type, - pecos_quantum::GateType::MZ + pecos_quantum::GateType::MX + | pecos_quantum::GateType::MZ | pecos_quantum::GateType::MeasureFree | pecos_quantum::GateType::MPZ ); @@ -974,7 +992,9 @@ impl<'a> InfluenceBuilder<'a> { } if matches!( gate.gate_type, - pecos_quantum::GateType::PZ | pecos_quantum::GateType::QAlloc + pecos_quantum::GateType::PX + | pecos_quantum::GateType::PZ + | pecos_quantum::GateType::QAlloc ) { prepared_qubits.extend(gate.qubits.iter().copied()); } diff --git a/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs b/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs index bf0b24a35..acd40eee2 100644 --- a/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs +++ b/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs @@ -492,10 +492,19 @@ fn propagate_tracked_pauli_forward( }; match gate.gate_type { GateType::TrackedPauliMeta => {} - GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked | GateType::MPZ => { + GateType::MX + | GateType::MZ + | GateType::MeasureFree + | GateType::MeasureLeaked + | GateType::MPZ => { if let Some(entries) = measurement_records.get(&node) { for &(qubit, record) in entries { - if prop.contains_x(qubit) { + let flips = if gate.gate_type == GateType::MX { + prop.contains_z(qubit) + } else { + prop.contains_x(qubit) + }; + if flips { affected_measurements.insert(record); } } @@ -513,7 +522,7 @@ fn propagate_tracked_pauli_forward( ); } } - GateType::PZ | GateType::QAlloc => { + GateType::PX | GateType::PZ | GateType::QAlloc => { for qubit in &gate.qubits { clear_qubit(&mut prop, qubit.index()); } diff --git a/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs b/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs index 63682596b..372ae0f10 100644 --- a/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs +++ b/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs @@ -277,9 +277,9 @@ fn init_pauli_prop_with_fault(fault: &PauliFault) -> PauliProp { /// Apply a gate to an end-read flip ledger. /// -/// The checker classifies faults by reading `contains_x` on measurement -/// qubits *after* full propagation, so a measurement's outcome flip must stay -/// readable on the wire until a reset clears it -- including for +/// The checker classifies faults by reading the anticommuting ledger component +/// on measurement qubits *after* full propagation, so a measurement's outcome +/// flip must stay readable on the wire until a reset clears it -- including for /// `MeasureFree`, whose collapse-accurate crossing would discard the flip /// together with the qubit. The Z component is still absorbed at every /// measurement: left in the ledger it could rotate into X through later @@ -291,10 +291,15 @@ fn apply_gate_flip_ledger(prop: &mut PauliProp, gate: &pecos_core::Gate) { // phantom -- and would disagree with the gate's own `mz; pz` lowering, // whose explicit PZ clears. Its mid-circuit flips are invisible to // end-reads, exactly as the lowering's always were. - GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked => { + GateType::MX | GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked => { + let x_basis = gate.gate_type == GateType::MX; for q in &gate.qubits { let qubit = q.index(); - if prop.contains_z(qubit) { + if x_basis { + if prop.contains_x(qubit) { + prop.track_x(&[qubit]); + } + } else if prop.contains_z(qubit) { prop.track_z(&[qubit]); } } @@ -302,7 +307,7 @@ fn apply_gate_flip_ledger(prop: &mut PauliProp, gate: &pecos_core::Gate) { // The shared dispatcher keeps preps transparent and expects walkers // to clear at their own call sites; without this arm a stale ledger // entry would survive a re-preparation and read as a phantom flip. - GateType::PZ | GateType::QAlloc | GateType::MPZ => { + GateType::PX | GateType::PZ | GateType::QAlloc | GateType::MPZ => { for q in &gate.qubits { prop.clear_qubit(q.index()); } @@ -2814,6 +2819,36 @@ mod tests { ); } + #[test] + fn mx_and_px_flip_ledger_semantics_swap_x_and_z() { + let mx = pecos_core::Gate::mx(&[0]); + + let mut x_before_mx = PauliProp::new(); + x_before_mx.track_x(&[0]); + apply_gate_flip_ledger(&mut x_before_mx, &mx); + assert!( + !x_before_mx.contains_x(0) && !x_before_mx.contains_z(0), + "an X commutes with an X-basis measurement and is invisible" + ); + + let mut z_before_mx = PauliProp::new(); + z_before_mx.track_z(&[0]); + apply_gate_flip_ledger(&mut z_before_mx, &mx); + assert!( + z_before_mx.contains_z(0) && !z_before_mx.contains_x(0), + "a Z anticommutes with the measured X and remains as the readout flip" + ); + + let mut before_px = PauliProp::new(); + before_px.track_x(&[0]); + before_px.track_z(&[0]); + apply_gate_flip_ledger(&mut before_px, &pecos_core::Gate::px(&[0])); + assert!( + !before_px.contains_x(0) && !before_px.contains_z(0), + "PX is a preparation and clears both ledger components" + ); + } + /// The per-round walker shares the ledger crossing: a Y that crossed an /// earlier measurement arrives at the later round as the rotated X /// survivor only. The old identity dispatch carried the whole Y through. diff --git a/crates/pecos-qec/src/fault_tolerance/propagator.rs b/crates/pecos-qec/src/fault_tolerance/propagator.rs index 4d0f6a325..9b3d29527 100644 --- a/crates/pecos-qec/src/fault_tolerance/propagator.rs +++ b/crates/pecos-qec/src/fault_tolerance/propagator.rs @@ -601,7 +601,10 @@ impl<'a> DagPropagator<'a> { if touches_active { // Handle prep gates specially - they kill the Pauli - if matches!(gate.gate_type, GateType::PZ | GateType::QAlloc) { + if matches!( + gate.gate_type, + GateType::PX | GateType::PZ | GateType::QAlloc + ) { for q in &gate.qubits { let idx = q.index(); if prop.contains_x(idx) { diff --git a/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs b/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs index 2be7a51a4..a45d46ae8 100644 --- a/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs +++ b/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs @@ -1239,6 +1239,8 @@ impl GateFaultLocation<'_> { | GateType::MZ | GateType::MeasureFree | GateType::MPZ => &[pecos_core::Pauli::X], + // X-basis prep/measurement: only Z (phase-flip) fault. + GateType::PX | GateType::MX => &[pecos_core::Pauli::Z], // Unitary gates: all single-qubit Paulis. _ => &[ pecos_core::Pauli::X, @@ -1311,6 +1313,8 @@ impl GateFaultLocation<'_> { | GateType::MZ | GateType::MeasureFree | GateType::MPZ => &[Pauli::X], + // X-basis prep/measurement: only Z (phase-flip) fault. + GateType::PX | GateType::MX => &[Pauli::Z], // Unitary gates: all single-qubit Paulis. _ => &[Pauli::X, Pauli::Y, Pauli::Z], }; @@ -1853,7 +1857,7 @@ impl<'a> DagFaultAnalyzer<'a> { let is_measurement = matches!( gate.gate_type, - GateType::MZ | GateType::MeasureFree | GateType::MPZ + GateType::MZ | GateType::MX | GateType::MeasureFree | GateType::MPZ ); // Convert QubitId to usize @@ -1886,7 +1890,10 @@ impl<'a> DagFaultAnalyzer<'a> { 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) { + if matches!( + gate.gate_type, + GateType::PZ | GateType::PX | GateType::QAlloc + ) { prepared_qubits.extend(qubits.iter().copied()); } } @@ -1972,6 +1979,7 @@ impl<'a> DagFaultAnalyzer<'a> { if let Some(gate) = self.propagator.gate(node) { let basis = match gate.gate_type { GateType::MZ | GateType::MeasureFree | GateType::MPZ => 0, // Z-basis + GateType::MX => 1, // X-basis _ => continue, }; @@ -2088,7 +2096,10 @@ impl<'a> DagFaultAnalyzer<'a> { // Handle prep gates specially - they kill the Pauli and stop propagation // on their qubits. Errors before a prep don't affect measurements after it. - if matches!(gate.gate_type, GateType::PZ | GateType::QAlloc) { + if matches!( + gate.gate_type, + GateType::PZ | GateType::PX | GateType::QAlloc + ) { for q in &gate.qubits { let idx = q.index(); if idx <= self.max_qubit() { @@ -2229,7 +2240,10 @@ impl<'a> DagFaultAnalyzer<'a> { self.record_at_node_generic(node, prop, request.detector_idx, recorder, false); - if matches!(gate.gate_type, GateType::PZ | GateType::QAlloc) { + if matches!( + gate.gate_type, + GateType::PZ | GateType::PX | GateType::QAlloc + ) { let pz_topo = self.propagator.topo_position(node); for q in &gate.qubits { let idx = q.index(); @@ -2301,7 +2315,10 @@ impl<'a> DagFaultAnalyzer<'a> { self.record_at_node_generic(node, prop, detector_idx, recorder, false); visited_nodes.push(node); - if matches!(gate.gate_type, GateType::PZ | GateType::QAlloc) { + if matches!( + gate.gate_type, + GateType::PZ | GateType::PX | GateType::QAlloc + ) { for q in &gate.qubits { let idx = q.index(); if idx <= self.max_qubit() { @@ -2365,7 +2382,10 @@ impl<'a> DagFaultAnalyzer<'a> { if let Some(gate) = self.propagator.gate(node) { self.record_at_node_generic(node, prop, detector_idx, recorder, false); - if matches!(gate.gate_type, GateType::PZ | GateType::QAlloc) { + if matches!( + gate.gate_type, + GateType::PZ | GateType::PX | GateType::QAlloc + ) { for q in &gate.qubits { let idx = q.index(); if idx <= self.max_qubit() { @@ -2887,7 +2907,7 @@ mod tests { dag.gate(n).is_none_or(|g| { !matches!( g.gate_type, - GateType::MZ | GateType::MeasureFree | GateType::MPZ + GateType::MZ | GateType::MX | GateType::MeasureFree | GateType::MPZ ) }) }) { @@ -3487,7 +3507,10 @@ mod tests { if !has_any_flip { // Only locations after measurements or before preps might have no flips assert!( - matches!(loc.gate_type, GateType::PZ | GateType::QAlloc) || !loc.before, + matches!( + loc.gate_type, + GateType::PZ | GateType::PX | GateType::QAlloc + ) || !loc.before, "Multi-qubit location {loc:?} has no detector flips" ); } diff --git a/crates/pecos-qec/src/fault_tolerance/propagator/pauli.rs b/crates/pecos-qec/src/fault_tolerance/propagator/pauli.rs index b363bdbe5..aecdc7e6a 100644 --- a/crates/pecos-qec/src/fault_tolerance/propagator/pauli.rs +++ b/crates/pecos-qec/src/fault_tolerance/propagator/pauli.rs @@ -195,6 +195,18 @@ pub fn cross_measurement( } } }, + GateType::MX => match direction { + Direction::Forward => { + if prop.contains_x(qubit) { + prop.toggle_x(qubit); + } + } + Direction::Backward => { + if prop.contains_z(qubit) { + prop.toggle_z(qubit); + } + } + }, // `MeasureFree` discards the qubit; `MPZ` resets it. Either way the // record flip is taken by the walker before the crossing and nothing // propagates across. @@ -211,7 +223,11 @@ fn apply_named_gate( direction: Direction, ) -> bool { match gate_type { - GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked | GateType::MPZ => { + GateType::MX + | GateType::MZ + | GateType::MeasureFree + | GateType::MeasureLeaked + | GateType::MPZ => { for qid in qubits { cross_measurement(prop, qid.index(), gate_type, direction); } diff --git a/crates/pecos-qec/src/fault_tolerance/propagator/tick.rs b/crates/pecos-qec/src/fault_tolerance/propagator/tick.rs index 9ea139377..d8da1c201 100644 --- a/crates/pecos-qec/src/fault_tolerance/propagator/tick.rs +++ b/crates/pecos-qec/src/fault_tolerance/propagator/tick.rs @@ -152,8 +152,8 @@ impl<'a> TickFaultAnalyzer<'a> { for (tick_idx, tick) in self.circuit.iter_ticks() { for gate in tick.iter_gate_batches() { - // Currently only Z-basis measurements are supported let basis = match gate.gate_type { + GateType::MX => 1, // X-basis GateType::MZ | GateType::MeasureFree | GateType::MPZ => 0, // Z-basis _ => continue, }; @@ -466,7 +466,10 @@ impl<'a> TickFaultAnalyzer<'a> { fn apply_gate_backward(prop: &mut PauliProp, gate: &pecos_core::Gate) { let qubits = &gate.qubits; - if matches!(gate.gate_type, GateType::PZ | GateType::QAlloc) { + if matches!( + gate.gate_type, + GateType::PX | GateType::PZ | GateType::QAlloc + ) { // Preparation resets the qubit - backward propagation stops here // Any Pauli on a prepared qubit doesn't propagate further back // Toggle off both X and Z if present diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index 92b6920e7..cedc23dce 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -63,6 +63,7 @@ //! assert_eq!(analysis.undetectable_logical, 0); //! ``` +pub mod bivariate_bicycle; pub mod code_distance; pub mod dem_stab; pub mod distance; @@ -76,6 +77,9 @@ pub mod stabilizer_code; pub mod stabilizer_code_spec; pub mod surface; +pub use bivariate_bicycle::{ + BbMemoryBasis, BbMonomial, BivariateBicycleCode, BivariateBicycleError, bb_memory_circuit, +}; pub use dem_stab::{DemStabError, DemStabShotBatch, DemStabSim, DemStabSimBuilder}; pub use mem_stab::{MemStabError, MemStabSim, MemStabSimBuilder}; pub use parity_check_matrix::{ParityCheckMatrix, ParityCheckMatrixError}; diff --git a/crates/pecos-quantum/src/circuit_display.rs b/crates/pecos-quantum/src/circuit_display.rs index c33bb90c6..3629e850e 100644 --- a/crates/pecos-quantum/src/circuit_display.rs +++ b/crates/pecos-quantum/src/circuit_display.rs @@ -66,10 +66,12 @@ fn gate_symbol(gate_type: GateType) -> &'static str { GateType::RXXRYYRZZ => "RXXRYYRZZ", GateType::U2q => "U2q", GateType::CCX => "CCX", + GateType::MX => "MX", GateType::MZ => "MZ", GateType::MeasureLeaked => "ML", GateType::MeasureFree => "MF", GateType::MPZ => "MPZ", + GateType::PX => "PX", GateType::PZ => "PZ", GateType::QAlloc => "QA", GateType::QFree => "QF", @@ -180,7 +182,9 @@ fn full_gate_symbol(gate: &Gate, unit: AngleUnit) -> String { /// Map a `GateType` to its diagram color using the PECOS axis color algebra. fn gate_color(gate_type: GateType) -> CellColor { match gate_type { - GateType::X | GateType::RX | GateType::RXX => CellColor::XAxis, + GateType::X | GateType::RX | GateType::RXX | GateType::MX | GateType::PX => { + CellColor::XAxis + } GateType::Y | GateType::RY | GateType::RYY => CellColor::YAxis, GateType::Z | GateType::RZ diff --git a/crates/pecos-quantum/src/dag_circuit.rs b/crates/pecos-quantum/src/dag_circuit.rs index d81d07569..3477620be 100644 --- a/crates/pecos-quantum/src/dag_circuit.rs +++ b/crates/pecos-quantum/src/dag_circuit.rs @@ -2231,13 +2231,14 @@ impl DagCircuit { } /// Validate each reference against the gate it names and derive the ids - /// and Z-Pauli. Validation is a direct lookup per reference -- linear only + /// and basis-matched Pauli. Validation is a direct lookup per reference -- linear only /// in the named gate's batch width, never in the circuit: the gate must /// exist, consume measurement records, and hold the (qubit, id) pair. fn validated_ids_and_pauli( &self, measurements: &[MeasRef], ) -> Result<(Vec, pecos_core::PauliString), AnnotationRefError> { + let mut paulis = Vec::with_capacity(measurements.len()); for m in measurements { let Some(gate) = self.gate(m.node) else { return Err(AnnotationRefError::NoSuchNode { node: m.node }); @@ -2257,10 +2258,23 @@ impl DagCircuit { meas_id: m.meas_id, }); } + paulis.push(( + if gate.gate_type == GateType::MX { + pecos_core::Pauli::X + } else { + pecos_core::Pauli::Z + }, + m.qubit, + )); } let ids = measurements.iter().map(|m| m.meas_id).collect(); - let qubits: Vec = measurements.iter().map(|m| m.qubit.index()).collect(); - Ok((ids, pecos_core::PauliString::zs(&qubits))) + Ok(( + ids, + pecos_core::PauliString::with_phase_and_paulis( + pecos_core::QuarterPhase::PlusOne, + paulis, + ), + )) } /// Place a tracked-Pauli meta-gate at this point in the circuit. diff --git a/crates/pecos-quantum/src/tick_circuit.rs b/crates/pecos-quantum/src/tick_circuit.rs index 954bb0d87..5106bf31e 100644 --- a/crates/pecos-quantum/src/tick_circuit.rs +++ b/crates/pecos-quantum/src/tick_circuit.rs @@ -1530,7 +1530,7 @@ impl TickCircuit { let gate = batch.as_gate(); if !matches!( gate.gate_type, - GateType::MZ | GateType::MeasureFree | GateType::MPZ + GateType::MX | GateType::MZ | GateType::MeasureFree | GateType::MPZ ) { return None; } @@ -2362,12 +2362,13 @@ impl TickCircuit { } /// Validate each reference against the batch it names and derive the ids - /// and Z-Pauli. Validation re-runs the [`Self::meas_ref`] lookup: the + /// and basis-matched Pauli. Validation re-runs the [`Self::meas_ref`] lookup: the /// reference must round-trip to the same id. fn validated_ids_and_pauli( &self, measurements: &[TickMeasRef], ) -> Result<(Vec, pecos_core::PauliString), TickAnnotationRefError> { + let mut paulis = Vec::with_capacity(measurements.len()); for m in measurements { let held = self .meas_ref(m.tick, m.gate_idx, m.qubit) @@ -2380,10 +2381,24 @@ impl TickCircuit { meas_id: m.meas_id, }); } + let gate_type = self.ticks[m.tick].gate_batches()[m.gate_idx].gate_type; + paulis.push(( + if gate_type == GateType::MX { + pecos_core::Pauli::X + } else { + pecos_core::Pauli::Z + }, + m.qubit, + )); } let ids = measurements.iter().map(|m| m.meas_id).collect(); - let qubits: Vec = measurements.iter().map(|m| m.qubit.index()).collect(); - Ok((ids, pecos_core::PauliString::zs(&qubits))) + Ok(( + ids, + pecos_core::PauliString::with_phase_and_paulis( + pecos_core::QuarterPhase::PlusOne, + paulis, + ), + )) } /// Place a tracked-Pauli annotation. @@ -3153,6 +3168,18 @@ impl<'a> TickHandle<'a> { } } + /// Prepare qubit(s) in the |+> state. + pub fn px(mut self, qubits: &[impl Into + Copy]) -> TickPrepHandle<'a> { + let gate_piece = self.add_gate_get_piece(Gate::px(qubits)); + self.last_gate_idx = None; + self.last_gate_piece = None; + TickPrepHandle { + circuit: self.circuit, + tick_idx: self.tick_idx, + gate_piece, + } + } + /// Measure qubit(s) in the Z basis. /// /// Returns a [`TickMeasureHandle`] that allows attaching metadata via `.meta()`. @@ -3205,6 +3232,40 @@ impl<'a> TickHandle<'a> { .collect() } + /// Measure qubit(s) in the X basis. + /// + /// # Panics + /// + /// Panics if the circuit has no measurement records left below + /// [`usize::MAX`]. + pub fn mx(mut self, qubits: &[impl Into + Copy]) -> Vec { + let mut gate = Gate::mx(qubits); + let mut refs = Vec::with_capacity(qubits.len()); + let base = self + .circuit + .try_advance_meas_counter(qubits.len()) + .unwrap_or_else(|err| panic!("{err}")); + for (offset, &q) in qubits.iter().enumerate() { + let mr = MeasId::from_raw(base + offset); + gate.meas_ids.push(mr); + refs.push(TickMeasRef { + tick: self.tick_idx, + gate_idx: 0, + qubit: q.into(), + meas_id: mr, + }); + } + let gate_idx = self.add_gate_get_idx(gate); + self.last_gate_idx = None; + self.last_gate_piece = None; + refs.into_iter() + .map(|mut reference| { + reference.gate_idx = gate_idx; + reference + }) + .collect() + } + /// Measure +Z and prepare |0> (measure-and-prepare). /// /// Returns one [`TickMeasRef`] per qubit, like `mz()`. diff --git a/crates/pecos-quantum/src/unitary_matrix.rs b/crates/pecos-quantum/src/unitary_matrix.rs index e05fe680b..1fbbc9d32 100644 --- a/crates/pecos-quantum/src/unitary_matrix.rs +++ b/crates/pecos-quantum/src/unitary_matrix.rs @@ -1945,10 +1945,12 @@ fn gate_to_matrix(gate_type: GateType, qubits: &[usize], num_qubits: usize) -> D } // Non-unitary operations - GateType::MZ + GateType::MX + | GateType::MZ | GateType::MeasureLeaked | GateType::MeasureFree | GateType::MPZ + | GateType::PX | GateType::PZ | GateType::QAlloc | GateType::QFree => { diff --git a/crates/pecos-simulators/src/circuit_executor.rs b/crates/pecos-simulators/src/circuit_executor.rs index 0cef59305..199ecac6d 100644 --- a/crates/pecos-simulators/src/circuit_executor.rs +++ b/crates/pecos-simulators/src/circuit_executor.rs @@ -188,9 +188,17 @@ fn execute_gate_command( let pairs = flat_to_pairs(qubits); sim.swap(&pairs); } + GateType::PX => { + sim.pz(qubits); + sim.h(qubits); + } GateType::PZ | GateType::QAlloc => { sim.pz(qubits); } + GateType::MX => { + sim.h(qubits); + measurements.extend(sim.mz(qubits)); + } GateType::MZ | GateType::MeasureFree => { measurements.extend(sim.mz(qubits)); } diff --git a/exp/pecos-experimental/src/hugr_executor.rs b/exp/pecos-experimental/src/hugr_executor.rs index 7a8897999..b90cacc60 100644 --- a/exp/pecos-experimental/src/hugr_executor.rs +++ b/exp/pecos-experimental/src/hugr_executor.rs @@ -208,7 +208,6 @@ where match gate.gate_type { // No-op gates: identity, prep/alloc (qubits start in |0⟩), dealloc, idle, crosstalk GateType::I - | GateType::PZ | GateType::QAlloc | GateType::QFree | GateType::Idle @@ -216,6 +215,17 @@ where | GateType::MeasCrosstalkLocalPayload | GateType::TrackedPauliMeta => {} + GateType::PZ => { + validate_qubit_count(gate.gate_type, gate_idx, 1, gate.qubits.len())?; + sim.pz(gate.qubits[0].index()); + } + GateType::PX => { + validate_qubit_count(gate.gate_type, gate_idx, 1, gate.qubits.len())?; + let q = gate.qubits[0].index(); + sim.pz(q); + sim.h(&[q]); + } + // Single-qubit Clifford gates GateType::X => { validate_qubit_count(gate.gate_type, gate_idx, 1, gate.qubits.len())?; @@ -283,6 +293,12 @@ where } // Measurements (including leaked measurement, treated as regular) + GateType::MX => { + validate_qubit_count(gate.gate_type, gate_idx, 1, gate.qubits.len())?; + let q = gate.qubits[0].index(); + sim.h(&[q]); + sim.mz(&[q]); + } GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked => { validate_qubit_count(gate.gate_type, gate_idx, 1, gate.qubits.len())?; let q = gate.qubits[0].index(); diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index 313c683a1..519690674 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -1574,8 +1574,10 @@ class GateType: CH: GateType CRZ: GateType CCX: GateType + MeasureX: GateType Measure: GateType MeasureFree: GateType + PrepX: GateType Prep: GateType QAlloc: GateType QFree: GateType @@ -1625,8 +1627,12 @@ class Gate: @staticmethod def cz(pairs: Sequence[tuple[int, int]]) -> Gate: ... @staticmethod + def mx(qubits: Sequence[int]) -> Gate: ... + @staticmethod def mz(qubits: Sequence[int]) -> Gate: ... @staticmethod + def px(qubits: Sequence[int]) -> Gate: ... + @staticmethod def pz(qubits: Sequence[int]) -> Gate: ... class DagCircuit: @@ -1721,7 +1727,9 @@ class TickHandle: qubits: Sequence[int], angles: Sequence[float] | None = None, ) -> TickHandle: ... + def px(self, qubits: Sequence[int]) -> TickPrepHandle: ... def pz(self, qubits: Sequence[int]) -> TickPrepHandle: ... + def mx(self, qubits: Sequence[int]) -> list[tuple[int, int, int]]: ... def mz(self, qubits: Sequence[int]) -> list[tuple[int, int, int]]: ... def mz_with_ids( self, diff --git a/python/pecos-rslib/pecos_rslib/qec.pyi b/python/pecos-rslib/pecos_rslib/qec.pyi index e088d6a61..916e49463 100644 --- a/python/pecos-rslib/pecos_rslib/qec.pyi +++ b/python/pecos-rslib/pecos_rslib/qec.pyi @@ -18,6 +18,40 @@ from typing import Any from pecos_rslib import DistanceResult, ParityCheckMatrix, StabilizerCodeSpec, TickCircuit +class BivariateBicycleCode: + """A validated bivariate-bicycle CSS code.""" + + def __init__( + self, + l: int, + m: int, + a_terms: list[tuple[int, int]], + b_terms: list[tuple[int, int]], + ) -> None: ... + @property + def l(self) -> int: ... + @property + def m(self) -> int: ... + @property + def hx(self) -> ParityCheckMatrix: ... + @property + def hz(self) -> ParityCheckMatrix: ... + @property + def logical_x(self) -> ParityCheckMatrix: ... + @property + def logical_z(self) -> ParityCheckMatrix: ... + def num_qubits(self) -> int: ... + def num_logical_qubits(self) -> int: ... + +def bb_memory_circuit( + l: int, + m: int, + a_terms: list[tuple[int, int]], + b_terms: list[tuple[int, int]], + rounds: int, + basis: str, +) -> TickCircuit: ... + class FaultDistanceResult: """A unit-weight mechanism distance and one witnessing index set.""" diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index 1f010c837..f5ec05853 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -567,6 +567,14 @@ impl PyGateType { } } + #[classattr] + #[pyo3(name = "MeasureX")] + fn mx() -> Self { + Self { + inner: GateType::MX, + } + } + #[classattr] #[pyo3(name = "Measure")] fn mz() -> Self { @@ -583,6 +591,14 @@ impl PyGateType { } } + #[classattr] + #[pyo3(name = "PrepX")] + fn px() -> Self { + Self { + inner: GateType::PX, + } + } + #[classattr] #[pyo3(name = "Prep")] fn pz() -> Self { @@ -917,6 +933,14 @@ impl PyGate { } } + /// Create a Measure gate. + #[staticmethod] + fn mx(qubits: Vec) -> Self { + Self { + inner: Gate::mx(&qubits), + } + } + /// Create a Measure gate. #[staticmethod] fn mz(qubits: Vec) -> Self { @@ -949,6 +973,14 @@ impl PyGate { } } + /// Create a PZ (preparation/reset) gate. + #[staticmethod] + fn px(qubits: Vec) -> Self { + Self { + inner: Gate::px(&qubits), + } + } + /// Create a PZ (preparation/reset) gate. #[staticmethod] fn pz(qubits: Vec) -> Self { @@ -3799,6 +3831,20 @@ impl PyTickHandle { // --- State preparation and measurement --- + /// Prepare qubits in the |+> state. + fn px(slf: Py, py: Python<'_>, qubits: Vec) -> PyResult { + let (circuit, tick_idx, gate_idx) = { + let mut handle = slf.borrow_mut(py); + let gate_idx = handle.add_gate_get_idx(py, Gate::px(&qubits))?; + (handle.circuit.clone_ref(py), handle.tick_idx, gate_idx) + }; + Ok(PyTickPrepHandle { + circuit, + tick_idx, + gate_idx, + }) + } + /// Prepare qubits in the |0> state. /// /// Returns a `TickPrepHandle` that allows attaching metadata via `.meta()`. @@ -3857,6 +3903,40 @@ impl PyTickHandle { Ok(qubits.iter().map(|&q| (tick_idx, gate_idx, q)).collect()) } + /// Measure qubits in the X basis. + fn mx( + slf: Py, + py: Python<'_>, + qubits: Vec, + ) -> PyResult> { + let mut handle = slf.borrow_mut(py); + let mut gate = Gate::mx(&qubits); + let base = { + let circuit = handle.circuit.borrow(py); + let base = circuit.inner.num_measurements(); + base.checked_add(qubits.len()).ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err(format!( + "cannot reserve {} more measurement records; only {} remain below usize::MAX", + qubits.len(), + usize::MAX - base + )) + })?; + base + }; + for (i, _) in qubits.iter().enumerate() { + gate.meas_ids.push(pecos_core::MeasId::from_raw(base + i)); + } + let gate_idx = handle.add_gate_get_idx(py, gate)?; + handle + .circuit + .borrow_mut(py) + .inner + .try_advance_meas_counter(qubits.len()) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + let tick_idx = handle.tick_idx; + Ok(qubits.iter().map(|&q| (tick_idx, gate_idx, q)).collect()) + } + /// Measure qubits with explicit MeasIds. /// /// Like ``mz()`` but assigns the given MeasIds instead of auto-assigning. diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 793b039e3..809258691 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -83,6 +83,10 @@ use pecos_qec::fault_tolerance::{ HookErrorReport as RustHookErrorReport, PauliFrameLookup as RustPauliFrameLookup, PauliPropChecker, SpacetimeLocation, }; +use pecos_qec::{ + BbMemoryBasis as RustBbMemoryBasis, BivariateBicycleCode as RustBivariateBicycleCode, + bb_memory_circuit as rust_bb_memory_circuit, +}; use pecos_qec::{ CertifiedDistance as RustCertifiedDistance, DistanceProblem as RustDistanceProblem, certified_distance as rust_certified_distance, @@ -7741,6 +7745,113 @@ fn stabilizer_code_distance( // Module Registration // ============================================================================= +/// A validated bivariate-bicycle CSS code. +#[pyclass( + name = "BivariateBicycleCode", + module = "pecos_rslib.qec", + skip_from_py_object +)] +#[derive(Clone, Debug)] +pub struct PyBivariateBicycleCode { + inner: RustBivariateBicycleCode, +} + +#[pymethods] +impl PyBivariateBicycleCode { + /// Construct `QC(A, B)` from canonical `(x_power, y_power)` exponent lists. + #[new] + fn new( + l: usize, + m: usize, + a_terms: Vec<(usize, usize)>, + b_terms: Vec<(usize, usize)>, + ) -> PyResult { + let inner = RustBivariateBicycleCode::new(l, m, &a_terms, &b_terms) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))?; + Ok(Self { inner }) + } + + #[getter] + fn l(&self) -> usize { + self.inner.dimensions().0 + } + + #[getter] + fn m(&self) -> usize { + self.inner.dimensions().1 + } + + #[getter] + fn hx(&self) -> PyParityCheckMatrix { + PyParityCheckMatrix { + inner: self.inner.hx().clone(), + } + } + + #[getter] + fn hz(&self) -> PyParityCheckMatrix { + PyParityCheckMatrix { + inner: self.inner.hz().clone(), + } + } + + #[getter] + fn logical_x(&self) -> PyParityCheckMatrix { + PyParityCheckMatrix { + inner: self.inner.logical_x().clone(), + } + } + + #[getter] + fn logical_z(&self) -> PyParityCheckMatrix { + PyParityCheckMatrix { + inner: self.inner.logical_z().clone(), + } + } + + fn num_qubits(&self) -> usize { + self.inner.num_qubits() + } + + fn num_logical_qubits(&self) -> usize { + self.inner.num_logical_qubits() + } + + fn __repr__(&self) -> String { + format!( + "BivariateBicycleCode(l={}, m={}, n={}, k={})", + self.l(), + self.m(), + self.num_qubits(), + self.num_logical_qubits() + ) + } +} + +/// Build the Table 5 bivariate-bicycle memory circuit. +#[pyfunction] +fn bb_memory_circuit( + l: usize, + m: usize, + a_terms: Vec<(usize, usize)>, + b_terms: Vec<(usize, usize)>, + rounds: usize, + basis: &str, +) -> PyResult { + let basis = match basis.to_ascii_uppercase().as_str() { + "X" => RustBbMemoryBasis::X, + "Z" => RustBbMemoryBasis::Z, + _ => { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "basis must be 'X' or 'Z', got {basis:?}" + ))); + } + }; + let inner = rust_bb_memory_circuit(l, m, &a_terms, &b_terms, rounds, basis) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))?; + Ok(PyTickCircuit { inner }) +} + /// Register the QEC fault tolerance module. pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { let qec = PyModule::new(m.py(), "qec")?; @@ -7773,6 +7884,7 @@ pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; + qec.add_class::()?; // Add DEM equivalence functions qec.add_function(wrap_pyfunction!(compare_dems_exact, &qec)?)?; @@ -7795,6 +7907,7 @@ pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { qec.add_function(wrap_pyfunction!(mechanisms_to_dem_string, &qec)?)?; qec.add_function(wrap_pyfunction!(decoder_dem_requirement, &qec)?)?; qec.add_function(wrap_pyfunction!(certified_distance, &qec)?)?; + qec.add_function(wrap_pyfunction!(bb_memory_circuit, &qec)?)?; // Add Pauli constants qec.add("PAULI_I", 0u8)?; diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index cf1f033f1..310dbd2ac 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -29,6 +29,7 @@ PAULI_X, PAULI_Y, PAULI_Z, + BivariateBicycleCode, CertifiedDistance, CircuitDistanceResult, CircuitFaultAnalyzer, @@ -50,6 +51,7 @@ ParsedDem, PauliFrameLookup, assert_dems_equivalent, + bb_memory_circuit, certified_distance, compare_dems_exact, compare_dems_statistical, @@ -148,6 +150,7 @@ "DemSampler", "DemSamplerBuilder", "DetectorErrorModel", + "BivariateBicycleCode", "Detector", "DistanceProblem", "EquivalenceResult", @@ -163,6 +166,7 @@ "GuppyDemBuild", "Observable", "assert_dems_equivalent", + "bb_memory_circuit", "certified_distance", "connected_cluster_code_distance", "compare_dems_exact", diff --git a/python/quantum-pecos/tests/qec/test_bivariate_bicycle.py b/python/quantum-pecos/tests/qec/test_bivariate_bicycle.py new file mode 100644 index 000000000..47f7d27a6 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_bivariate_bicycle.py @@ -0,0 +1,47 @@ +# 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. + +"""Python bindings for the bivariate-bicycle code and memory builder.""" + +import pytest +from pecos.qec import BivariateBicycleCode, bb_memory_circuit + +A_TERMS = [(3, 0), (0, 1), (0, 2)] +B_TERMS = [(0, 3), (1, 0), (2, 0)] + + +def test_bb_72_code_constructor_reports_n_and_k() -> None: + code = BivariateBicycleCode(6, 6, A_TERMS, B_TERMS) + + assert code.num_qubits() == 72 + assert code.num_logical_qubits() == 12 + assert code.hx.num_checks() == code.hz.num_checks() == 36 + assert code.hx.num_qubits() == code.hz.num_qubits() == 72 + assert code.logical_x.num_checks() == code.logical_z.num_checks() == 12 + + +def test_bb_memory_binding_is_deterministic_and_exports_metadata() -> None: + first = bb_memory_circuit(6, 6, A_TERMS, B_TERMS, 2, "Z") + second = bb_memory_circuit(6, 6, A_TERMS, B_TERMS, 2, "Z") + + assert repr(first) == repr(second) + assert first.num_ticks() == 18 + assert first.get_meta("syndrome_extraction_depth") == "17" + assert first.get_meta("num_detectors") == "144" + assert first.get_meta("num_observables") == "12" + + +def test_bb_bindings_reject_invalid_basis_and_exponent() -> None: + with pytest.raises(ValueError, match="basis must be"): + bb_memory_circuit(6, 6, A_TERMS, B_TERMS, 2, "Y") + with pytest.raises(ValueError, match="outside Z_6 x Z_6"): + BivariateBicycleCode(6, 6, [(6, 0), (0, 1), (0, 2)], B_TERMS) diff --git a/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py b/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py index 4e6f42fe6..69266e425 100644 --- a/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py +++ b/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py @@ -177,8 +177,10 @@ def test_steane_certification_from_checks_and_code_spec() -> None: x_result = x_distance(spec, 3) z_result = z_distance(spec, 3) - assert x_result is not None and x_result.distance == 3 - assert z_result is not None and z_result.distance == 3 + assert x_result is not None + assert x_result.distance == 3 + assert z_result is not None + assert z_result.distance == 3 assert from_checks.certified_distance(2) is None From 45c4404ca5fd766ab1e32d0fb33ed60457d7b07c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 7 Aug 2026 05:03:33 -0600 Subject: [PATCH 28/41] Contain solver panics at the certification boundary --- crates/pecos-qec/src/distance_problem.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/pecos-qec/src/distance_problem.rs b/crates/pecos-qec/src/distance_problem.rs index 5288db483..7efadce17 100644 --- a/crates/pecos-qec/src/distance_problem.rs +++ b/crates/pecos-qec/src/distance_problem.rs @@ -943,6 +943,17 @@ pub fn certified_distance( } fn solve_with_batsat(encoding: &Encoding, num_primary_vars: usize) -> SolverAnswer { + // The solver is an external dependency; an internal panic (observed: an + // arithmetic overflow under debug assertions on instances with thousands of + // variables) must not cross the FFI boundary. A fresh solver is built per + // call and discarded on unwind, so no shared state can be poisoned. + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + solve_with_batsat_inner(encoding, num_primary_vars) + })) + .unwrap_or(SolverAnswer::Unknown) +} + +fn solve_with_batsat_inner(encoding: &Encoding, num_primary_vars: usize) -> SolverAnswer { let mut solver = BasicSolver::default(); let variables: Vec<_> = (0..encoding.num_vars) .map(|_| solver.new_var_default()) From d842c167577624f6600c3e36aef165de7b58d058 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 7 Aug 2026 08:27:52 -0600 Subject: [PATCH 29/41] Restore QASM-engine PZ support, disambiguate BB order getters, run batsat at upstream arithmetic in debug --- Cargo.toml | 7 +++++++ crates/pecos-qasm/src/engine.rs | 3 ++- python/pecos-rslib/pecos_rslib/qec.pyi | 4 ++-- python/pecos-rslib/src/fault_tolerance_bindings.rs | 8 ++++---- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 061124bf9..ce892b732 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -302,3 +302,10 @@ opt-level = 2 opt-level = 2 [profile.dev.package.fusion-blossom] opt-level = 2 + +# batsat's solver core is tested and shipped by upstream with release arithmetic; +# under this workspace's debug profile its internal activity/restart arithmetic +# overflows on instances with thousands of variables. Run the dependency at its +# supported semantics; PECOS code keeps full debug overflow checks. +[profile.dev.package.batsat] +overflow-checks = false diff --git a/crates/pecos-qasm/src/engine.rs b/crates/pecos-qasm/src/engine.rs index 26f24456b..90d8b54ed 100644 --- a/crates/pecos-qasm/src/engine.rs +++ b/crates/pecos-qasm/src/engine.rs @@ -639,6 +639,7 @@ impl QASMEngine { | GateType::Fdg | GateType::T | GateType::Tdg + | GateType::PZ | GateType::QAlloc => self.process_single_qubit_gate(gate.gate_type, &qubits), GateType::CX | GateType::CY @@ -681,7 +682,7 @@ impl QASMEngine { | GateType::MPZ => Err(PecosError::Processing( "measurement gates should be handled by MeasureWithMapping operation".to_string(), )), - GateType::PX | GateType::PZ => Err(PecosError::Processing(format!( + GateType::PX => Err(PecosError::Processing(format!( "Gate type {:?} is not yet supported in the QASM engine", gate.gate_type ))), diff --git a/python/pecos-rslib/pecos_rslib/qec.pyi b/python/pecos-rslib/pecos_rslib/qec.pyi index 916e49463..4a7c73b3d 100644 --- a/python/pecos-rslib/pecos_rslib/qec.pyi +++ b/python/pecos-rslib/pecos_rslib/qec.pyi @@ -29,9 +29,9 @@ class BivariateBicycleCode: b_terms: list[tuple[int, int]], ) -> None: ... @property - def l(self) -> int: ... + def l_order(self) -> int: ... @property - def m(self) -> int: ... + def m_order(self) -> int: ... @property def hx(self) -> ParityCheckMatrix: ... @property diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 809258691..37606730f 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -7772,12 +7772,12 @@ impl PyBivariateBicycleCode { } #[getter] - fn l(&self) -> usize { + fn l_order(&self) -> usize { self.inner.dimensions().0 } #[getter] - fn m(&self) -> usize { + fn m_order(&self) -> usize { self.inner.dimensions().1 } @@ -7820,8 +7820,8 @@ impl PyBivariateBicycleCode { fn __repr__(&self) -> String { format!( "BivariateBicycleCode(l={}, m={}, n={}, k={})", - self.l(), - self.m(), + self.l_order(), + self.m_order(), self.num_qubits(), self.num_logical_qubits() ) From 171998238c6f3c2f7a9d003e2712696fa23ada00 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 7 Aug 2026 09:36:59 -0600 Subject: [PATCH 30/41] Drive the connected-cluster search by unsatisfied detectors --- .../src/fault_tolerance/fault_distance.rs | 90 ++++++++----------- 1 file changed, 38 insertions(+), 52 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/fault_distance.rs b/crates/pecos-qec/src/fault_tolerance/fault_distance.rs index 0e282ccda..2e9fd3b11 100644 --- a/crates/pecos-qec/src/fault_tolerance/fault_distance.rs +++ b/crates/pecos-qec/src/fault_tolerance/fault_distance.rs @@ -142,20 +142,13 @@ impl ConnectedClusterSearch<'_> { let mut cluster = vec![seed]; let mut members = BTreeSet::from([seed]); let effect = self.mechanisms[seed].clone(); - if self.target_weight == 1 { - self.consider_witness(&cluster, &effect); - continue; - } + self.extend(seed, &mut cluster, &mut members, &effect); - let extension = self.neighbors(seed, seed, &members, &BTreeSet::new()); - self.extend( - seed, - &mut cluster, - &mut members, - &effect, - extension, - BTreeSet::new(), - ); + // Every member added to a cluster is larger than its seed. Once the first seed with a + // witness has been exhausted, later seeds cannot improve the sorted witness. + if self.best.is_some() { + break; + } } self.best } @@ -166,56 +159,49 @@ impl ConnectedClusterSearch<'_> { cluster: &mut Vec, members: &mut BTreeSet, effect: &FaultMechanism, - mut extension: BTreeSet, - mut excluded: BTreeSet, ) { - while let Some(candidate) = extension.pop_first() { + if effect.detectors.is_empty() { + if cluster.len() == self.target_weight { + self.consider_witness(cluster, effect); + } + + // In the exact-weight search, a detector-free prefix below the target is already a + // closed component. The remaining mechanisms would also have to be detector-free, + // producing a proper detector-free component of the witness. One of the two parts + // carries the nonzero observable parity, contradicting minimum connectedness. The + // Connected Cluster method therefore rejects this prefix instead of reopening it + // (arXiv:2603.22532). + return; + } + + if cluster.len() == self.target_weight { + return; + } + + // Any completion must toggle the lowest unsatisfied detector again. Branching over every + // incident mechanism is therefore exhaustive, while immediately excluding mechanisms + // that cannot close this syndrome. The incidence vectors and detector parity are sorted, + // so both the detector choice and candidate order are deterministic. Requiring candidates + // larger than the seed preserves the minimum-index-root convention of the Connected + // Cluster method (arXiv:2603.22532). + let detector = effect.detectors[0]; + let candidates = self.incidence[&detector].clone(); + for candidate in candidates { + if !self.active[candidate] || candidate <= seed || members.contains(&candidate) { + continue; + } + cluster.push(candidate); members.insert(candidate); let next_effect = effect.xor(&self.mechanisms[candidate]); - if cluster.len() == self.target_weight { - self.consider_witness(cluster, &next_effect); - } else { - let mut next_extension = extension.clone(); - next_extension.extend(self.neighbors(candidate, seed, members, &excluded)); - self.extend( - seed, - cluster, - members, - &next_effect, - next_extension, - excluded.clone(), - ); - } + self.extend(seed, cluster, members, &next_effect); members.remove(&candidate); cluster.pop(); - excluded.insert(candidate); } } - fn neighbors( - &self, - mechanism_index: usize, - seed: usize, - members: &BTreeSet, - excluded: &BTreeSet, - ) -> BTreeSet { - self.mechanisms[mechanism_index] - .detectors - .iter() - .flat_map(|detector| &self.incidence[detector]) - .copied() - .filter(|&neighbor| { - self.active[neighbor] - && neighbor > seed - && !members.contains(&neighbor) - && !excluded.contains(&neighbor) - }) - .collect() - } - fn consider_witness(&mut self, cluster: &[usize], effect: &FaultMechanism) { if !effect.detectors.is_empty() || !self.logical_target.matches(effect) { return; From 3f5a7327d8f5cf5001cac95017b2c2819b8b8b93 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 7 Aug 2026 10:47:17 -0600 Subject: [PATCH 31/41] Add a generic edge-coloration syndrome-extraction builder and measure its distance cost --- crates/pecos-num/src/graph.rs | 6 + .../src/graph/bipartite_edge_coloring.rs | 379 ++++++++++++ crates/pecos-qec/src/bivariate_bicycle.rs | 232 +------- crates/pecos-qec/src/coloration.rs | 546 ++++++++++++++++++ crates/pecos-qec/src/lib.rs | 4 + crates/pecos-qec/src/memory_circuit.rs | 258 +++++++++ python/pecos-rslib/pecos_rslib/qec.pyi | 6 + .../src/fault_tolerance_bindings.rs | 24 + .../quantum-pecos/src/pecos/qec/__init__.py | 2 + .../tests/qec/test_coloration_memory.py | 54 ++ 10 files changed, 1304 insertions(+), 207 deletions(-) create mode 100644 crates/pecos-num/src/graph/bipartite_edge_coloring.rs create mode 100644 crates/pecos-qec/src/coloration.rs create mode 100644 crates/pecos-qec/src/memory_circuit.rs create mode 100644 python/quantum-pecos/tests/qec/test_coloration_memory.py diff --git a/crates/pecos-num/src/graph.rs b/crates/pecos-num/src/graph.rs index 21e550374..bc1d04afb 100644 --- a/crates/pecos-num/src/graph.rs +++ b/crates/pecos-num/src/graph.rs @@ -19,6 +19,12 @@ //! //! Built on top of rustworkx-core and petgraph, providing both Rust and Python APIs. +mod bipartite_edge_coloring; + +pub use bipartite_edge_coloring::{ + BipartiteEdgeColoring, BipartiteEdgeColoringError, bipartite_edge_coloring, +}; + // Re-export petgraph from rustworkx-core to ensure version consistency pub use rustworkx_core::petgraph; diff --git a/crates/pecos-num/src/graph/bipartite_edge_coloring.rs b/crates/pecos-num/src/graph/bipartite_edge_coloring.rs new file mode 100644 index 000000000..e9f3f57e9 --- /dev/null +++ b/crates/pecos-num/src/graph/bipartite_edge_coloring.rs @@ -0,0 +1,379 @@ +// 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. + +//! Exact edge coloring of bipartite multigraphs. + +/// An exact edge coloring of a bipartite multigraph. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BipartiteEdgeColoring { + colors: Vec, + num_colors: usize, +} + +impl BipartiteEdgeColoring { + /// The color assigned to each input edge, in input order. + #[must_use] + pub fn colors(&self) -> &[usize] { + &self.colors + } + + /// The number of colors, equal to the graph's maximum degree. + #[must_use] + pub fn num_colors(&self) -> usize { + self.num_colors + } +} + +/// Errors from [`bipartite_edge_coloring`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BipartiteEdgeColoringError { + /// An edge names a left vertex outside `0..num_left`. + LeftEndpointOutOfRange { + /// Input edge index. + edge: usize, + /// Invalid vertex index. + vertex: usize, + /// Size of the left vertex set. + num_left: usize, + }, + /// An edge names a right vertex outside `0..num_right`. + RightEndpointOutOfRange { + /// Input edge index. + edge: usize, + /// Invalid vertex index. + vertex: usize, + /// Size of the right vertex set. + num_right: usize, + }, + /// An internal perfect matching was unexpectedly absent. + MissingPerfectMatching { + /// Color whose matching could not be found. + color: usize, + }, +} + +impl std::fmt::Display for BipartiteEdgeColoringError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::LeftEndpointOutOfRange { + edge, + vertex, + num_left, + } => write!( + formatter, + "bipartite edge {edge} has left endpoint {vertex}, but there are {num_left} left vertices" + ), + Self::RightEndpointOutOfRange { + edge, + vertex, + num_right, + } => write!( + formatter, + "bipartite edge {edge} has right endpoint {vertex}, but there are {num_right} right vertices" + ), + Self::MissingPerfectMatching { color } => write!( + formatter, + "regularized bipartite graph has no perfect matching for color {color}" + ), + } + } +} + +impl std::error::Error for BipartiteEdgeColoringError {} + +#[derive(Clone, Copy, Debug)] +struct Edge { + left: usize, + right: usize, + original: Option, +} + +/// Color every edge of a bipartite multigraph with exactly its maximum degree colors. +/// +/// Parallel edges are distinct and are colored independently. The implementation pads both +/// vertex sets to the same size, deterministically adds dummy edges until the graph is +/// Delta-regular, and removes one deterministic perfect matching per color. A regular bipartite +/// multigraph has a perfect matching, and repeating the construction decomposes all edges into +/// Delta matchings. This is the constructive form of Konig's bipartite edge-coloring theorem. +/// Input edge order and ascending vertex order are the only tie breakers. +/// +/// # Errors +/// +/// Returns an error if an endpoint is out of range. A missing perfect matching reports an internal +/// invariant failure instead of returning a partial coloring. +pub fn bipartite_edge_coloring( + num_left: usize, + num_right: usize, + input_edges: &[(usize, usize)], +) -> Result { + let mut left_degrees = vec![0_usize; num_left]; + let mut right_degrees = vec![0_usize; num_right]; + let mut edges = Vec::with_capacity(input_edges.len()); + for (edge_index, &(left, right)) in input_edges.iter().enumerate() { + if left >= num_left { + return Err(BipartiteEdgeColoringError::LeftEndpointOutOfRange { + edge: edge_index, + vertex: left, + num_left, + }); + } + if right >= num_right { + return Err(BipartiteEdgeColoringError::RightEndpointOutOfRange { + edge: edge_index, + vertex: right, + num_right, + }); + } + left_degrees[left] += 1; + right_degrees[right] += 1; + edges.push(Edge { + left, + right, + original: Some(edge_index), + }); + } + + let delta = left_degrees + .iter() + .chain(&right_degrees) + .copied() + .max() + .unwrap_or(0); + if delta == 0 { + return Ok(BipartiteEdgeColoring { + colors: Vec::new(), + num_colors: 0, + }); + } + + let side_size = num_left.max(num_right); + left_degrees.resize(side_size, 0); + right_degrees.resize(side_size, 0); + + // Pair the left and right deficits in stable vertex order. Multiple dummy edges are allowed, + // just as multiple input edges are allowed. + let mut left = 0; + let mut right = 0; + while left < side_size && right < side_size { + while left < side_size && left_degrees[left] == delta { + left += 1; + } + while right < side_size && right_degrees[right] == delta { + right += 1; + } + if left == side_size || right == side_size { + break; + } + edges.push(Edge { + left, + right, + original: None, + }); + left_degrees[left] += 1; + right_degrees[right] += 1; + } + debug_assert!(left_degrees.iter().all(|°ree| degree == delta)); + debug_assert!(right_degrees.iter().all(|°ree| degree == delta)); + + let mut adjacency = vec![Vec::new(); side_size]; + for (edge_index, edge) in edges.iter().enumerate() { + adjacency[edge.left].push(edge_index); + } + let mut active = vec![true; edges.len()]; + let mut colors = vec![usize::MAX; input_edges.len()]; + + for color in 0..delta { + let matching = perfect_matching(side_size, &edges, &adjacency, &active) + .ok_or(BipartiteEdgeColoringError::MissingPerfectMatching { color })?; + for edge_index in matching { + active[edge_index] = false; + if let Some(original) = edges[edge_index].original { + colors[original] = color; + } + } + } + debug_assert!(colors.iter().all(|&color| color < delta)); + + Ok(BipartiteEdgeColoring { + colors, + num_colors: delta, + }) +} + +fn perfect_matching( + side_size: usize, + edges: &[Edge], + adjacency: &[Vec], + active: &[bool], +) -> Option> { + let mut matched_right = vec![None; side_size]; + for left in 0..side_size { + let mut visited_right = vec![false; side_size]; + if !augment( + left, + edges, + adjacency, + active, + &mut visited_right, + &mut matched_right, + ) { + return None; + } + } + matched_right.into_iter().collect() +} + +fn augment( + left: usize, + edges: &[Edge], + adjacency: &[Vec], + active: &[bool], + visited_right: &mut [bool], + matched_right: &mut [Option], +) -> bool { + for &edge_index in &adjacency[left] { + if !active[edge_index] { + continue; + } + let right = edges[edge_index].right; + if visited_right[right] { + continue; + } + visited_right[right] = true; + let can_reassign = matched_right[right].is_none_or(|matched_edge| { + augment( + edges[matched_edge].left, + edges, + adjacency, + active, + visited_right, + matched_right, + ) + }); + if can_reassign { + matched_right[right] = Some(edge_index); + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::rngs::StdRng; + use rand::{RngExt, SeedableRng}; + use std::collections::BTreeSet; + + fn verify_coloring( + num_left: usize, + num_right: usize, + edges: &[(usize, usize)], + coloring: &BipartiteEdgeColoring, + ) -> Result<(), String> { + if coloring.colors().len() != edges.len() { + return Err("not every edge has exactly one color".to_string()); + } + let mut left_degrees = vec![0_usize; num_left]; + let mut right_degrees = vec![0_usize; num_right]; + for &(left, right) in edges { + left_degrees[left] += 1; + right_degrees[right] += 1; + } + let delta = left_degrees + .iter() + .chain(&right_degrees) + .copied() + .max() + .unwrap_or(0); + if coloring.num_colors() != delta { + return Err(format!( + "used {} colors for maximum degree {delta}", + coloring.num_colors() + )); + } + let mut seen = BTreeSet::new(); + for (edge, (&(left, right), &color)) in edges.iter().zip(coloring.colors()).enumerate() { + if color >= delta { + return Err(format!("edge {edge} has out-of-range color {color}")); + } + if !seen.insert((true, left, color)) { + return Err(format!("two color-{color} edges share left vertex {left}")); + } + if !seen.insert((false, right, color)) { + return Err(format!( + "two color-{color} edges share right vertex {right}" + )); + } + } + Ok(()) + } + + #[test] + fn colors_parallel_edges_and_unbalanced_vertex_sets() { + let edges = vec![(0, 0), (0, 0), (0, 1), (1, 0), (2, 1)]; + let coloring = bipartite_edge_coloring(4, 2, &edges).unwrap(); + + verify_coloring(4, 2, &edges, &coloring).unwrap(); + assert_eq!(coloring.num_colors(), 3); + assert_eq!( + coloring, + bipartite_edge_coloring(4, 2, &edges).unwrap(), + "stable input order must produce a deterministic coloring" + ); + } + + #[test] + fn seeded_random_multigraphs_have_exact_delta_colorings() { + let mut rng = StdRng::seed_from_u64(0x25_ec01_0a71); + for case in 0..256 { + let num_left = rng.random_range(1..=12); + let num_right = rng.random_range(1..=12); + let num_edges = rng.random_range(0..=80); + let edges = (0..num_edges) + .map(|_| { + ( + rng.random_range(0..num_left), + rng.random_range(0..num_right), + ) + }) + .collect::>(); + + let coloring = bipartite_edge_coloring(num_left, num_right, &edges) + .unwrap_or_else(|error| panic!("random case {case}: {error}")); + verify_coloring(num_left, num_right, &edges, &coloring) + .unwrap_or_else(|error| panic!("random case {case}: {error}")); + } + } + + #[test] + fn mutation_shared_vertex_same_color_is_caught() { + let edges = vec![(0, 0), (0, 1), (1, 0)]; + let mut coloring = bipartite_edge_coloring(2, 2, &edges).unwrap(); + coloring.colors[1] = coloring.colors[0]; + + let error = verify_coloring(2, 2, &edges, &coloring).unwrap_err(); + assert!(error.contains("share left vertex 0"), "{error}"); + } + + #[test] + fn rejects_out_of_range_endpoints() { + assert!(matches!( + bipartite_edge_coloring(2, 1, &[(2, 0)]), + Err(BipartiteEdgeColoringError::LeftEndpointOutOfRange { edge: 0, .. }) + )); + assert!(matches!( + bipartite_edge_coloring(2, 1, &[(0, 1)]), + Err(BipartiteEdgeColoringError::RightEndpointOutOfRange { edge: 0, .. }) + )); + } +} diff --git a/crates/pecos-qec/src/bivariate_bicycle.rs b/crates/pecos-qec/src/bivariate_bicycle.rs index f77c153ac..dfbc15ee9 100644 --- a/crates/pecos-qec/src/bivariate_bicycle.rs +++ b/crates/pecos-qec/src/bivariate_bicycle.rs @@ -15,22 +15,19 @@ //! The construction and circuit schedule follow Tables 4 and 5 of //! [Bravyi et al., arXiv:2308.07915](https://arxiv.org/abs/2308.07915). -use pecos_quantum::{AnnotationKind, Attribute, F2Matrix, TickCircuit, TickMeasRef}; +use pecos_quantum::{F2Matrix, TickCircuit, TickMeasRef}; use thiserror::Error; -use crate::ParityCheckMatrix; +use crate::memory_circuit::{ + CssMemoryCircuitFinish, discover_css_logical_operators, finish_css_memory_circuit, +}; +use crate::{MemoryBasis, ParityCheckMatrix}; /// One monomial `x^a y^b` in `F_2[x, y] / (x^l - 1, y^m - 1)`. pub type BbMonomial = (usize, usize); -/// Memory-experiment preparation and final-measurement basis. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum BbMemoryBasis { - /// Prepare and measure the encoded state in the X basis. - X, - /// Prepare and measure the encoded state in the Z basis. - Z, -} +/// Memory-experiment basis accepted by the bivariate-bicycle builder. +pub type BbMemoryBasis = MemoryBasis; /// Errors reported while constructing a bivariate-bicycle code or memory circuit. #[derive(Clone, Debug, PartialEq, Eq, Error)] @@ -145,14 +142,11 @@ impl BivariateBicycleCode { return Err(BivariateBicycleError::NonCommutingChecks); } - let logical_x = quotient_basis(&hz, &hx, num_qubits); - let logical_z = quotient_basis(&hx, &hz, num_qubits); let hx = ParityCheckMatrix::from_dense(hx.rows()) .expect("a nonempty rectangular binary matrix was generated"); let hz = ParityCheckMatrix::from_dense(hz.rows()) .expect("a nonempty rectangular binary matrix was generated"); - let logical_x = parity_matrix_from_rows(logical_x, num_qubits); - let logical_z = parity_matrix_from_rows(logical_z, num_qubits); + let (logical_x, logical_z) = discover_css_logical_operators(&hx, &hz); Ok(Self { l, @@ -404,164 +398,23 @@ fn build_memory_circuit( x_measurements.push(x_refs); } - add_cycle_detectors(&mut circuit, &x_measurements, &z_measurements, basis)?; - - let final_data = match basis { - BbMemoryBasis::X => circuit.tick().mx(&data_qubits), - BbMemoryBasis::Z => circuit.tick().mz(&data_qubits), - }; - let (closing_checks, final_logicals, last_syndrome, label) = match basis { - BbMemoryBasis::X => ( - code.hx(), - code.logical_x(), - &x_measurements[rounds - 1], - "X", - ), - BbMemoryBasis::Z => ( - code.hz(), - code.logical_z(), - &z_measurements[rounds - 1], - "Z", - ), - }; - for (check, &syndrome) in last_syndrome.iter().enumerate() { - let mut refs = vec![syndrome]; - for (data, &bit) in closing_checks.row(check).unwrap().iter().enumerate() { - if bit == 1 { - refs.push(final_data[data]); - } - } - annotate_detector(&mut circuit, &format!("{label}{check}_final"), &refs)?; - } - for logical in 0..final_logicals.num_checks() { - let refs = final_logicals - .row(logical) - .unwrap() - .iter() - .enumerate() - .filter_map(|(data, &bit)| (bit == 1).then_some(final_data[data])) - .collect::>(); - circuit - .observable_labeled(&format!("L{logical}"), &refs) - .map_err(|error| BivariateBicycleError::InvalidAnnotation(error.to_string()))?; - } - - let num_detectors = 2 * rounds * block_size; - let (detectors_json, observables_json) = annotation_metadata_json(&circuit); - circuit.set_meta( - "num_measurements", - Attribute::String(circuit.num_measurements().to_string()), - ); - circuit.set_meta("detectors", Attribute::String(detectors_json)); - circuit.set_meta("observables", Attribute::String(observables_json)); - circuit.set_meta( - "num_detectors", - Attribute::String(num_detectors.to_string()), - ); - circuit.set_meta( - "num_observables", - Attribute::String(code.num_logical_qubits().to_string()), - ); - circuit.set_meta( - "num_data_qubits", - Attribute::String(code.num_qubits().to_string()), - ); - circuit.set_meta( - "num_logical_qubits", - Attribute::String(code.num_logical_qubits().to_string()), - ); - circuit.set_meta("syndrome_cycles", Attribute::String(rounds.to_string())); - circuit.set_meta( - "syndrome_extraction_depth", - Attribute::String((8 * rounds + 1).to_string()), - ); - circuit.set_meta( - "circuit_type", - Attribute::String("bivariate_bicycle_memory".to_string()), - ); - Ok(circuit) -} - -fn annotation_metadata_json(circuit: &TickCircuit) -> (String, String) { - let mut detectors = Vec::new(); - let mut observables = Vec::new(); - for annotation in circuit.annotations() { - match &annotation.kind { - AnnotationKind::Detector { - measurement_ids, - coords: _, - } => { - let id = detectors.len(); - detectors.push(serde_json::json!({ - "id": id, - "meas_ids": measurement_ids.iter().map(|id| id.index()).collect::>(), - "label": annotation.label, - })); - } - AnnotationKind::Observable { measurement_ids } => { - let id = observables.len(); - observables.push(serde_json::json!({ - "id": id, - "meas_ids": measurement_ids.iter().map(|id| id.index()).collect::>(), - "label": annotation.label, - })); - } - AnnotationKind::TrackedPauli => {} - } - } - ( - serde_json::to_string(&detectors).expect("annotation metadata is JSON-serializable"), - serde_json::to_string(&observables).expect("annotation metadata is JSON-serializable"), + finish_css_memory_circuit( + &mut circuit, + CssMemoryCircuitFinish { + data_qubits: &data_qubits, + hx: code.hx(), + hz: code.hz(), + logical_x: code.logical_x(), + logical_z: code.logical_z(), + x_measurements: &x_measurements, + z_measurements: &z_measurements, + rounds, + basis, + circuit_type: "bivariate_bicycle_memory", + }, ) -} - -fn add_cycle_detectors( - circuit: &mut TickCircuit, - x_measurements: &[Vec], - z_measurements: &[Vec], - basis: BbMemoryBasis, -) -> Result<(), BivariateBicycleError> { - let block_size = x_measurements[0].len(); - for round in 0..x_measurements.len() { - for check in 0..block_size { - if round > 0 { - annotate_detector( - circuit, - &format!("X{check}_r{round}"), - &[ - x_measurements[round - 1][check], - x_measurements[round][check], - ], - )?; - annotate_detector( - circuit, - &format!("Z{check}_r{round}"), - &[ - z_measurements[round - 1][check], - z_measurements[round][check], - ], - )?; - } else { - let (label, reference) = match basis { - BbMemoryBasis::X => ("X", x_measurements[0][check]), - BbMemoryBasis::Z => ("Z", z_measurements[0][check]), - }; - annotate_detector(circuit, &format!("{label}{check}_r0"), &[reference])?; - } - } - } - Ok(()) -} - -fn annotate_detector( - circuit: &mut TickCircuit, - label: &str, - measurements: &[TickMeasRef], -) -> Result<(), BivariateBicycleError> { - circuit - .detector_labeled(label, measurements) - .map(|_| ()) - .map_err(|error| BivariateBicycleError::InvalidAnnotation(error.to_string())) + .map_err(BivariateBicycleError::InvalidAnnotation)?; + Ok(circuit) } fn validated_block_size(l: usize, m: usize) -> Result { @@ -667,41 +520,6 @@ fn sum_matrices(matrices: &[F2Matrix; 3]) -> F2Matrix { sum } -fn quotient_basis(kernel_matrix: &F2Matrix, stabilizers: &F2Matrix, width: usize) -> Vec> { - let (stabilizer_rref, pivots) = stabilizers.row_reduce(); - let reduced = kernel_matrix.kernel().into_iter().filter_map(|mut vector| { - for (row, &pivot) in pivots.iter().enumerate() { - if vector[pivot] == 1 { - for (column, bit) in vector.iter_mut().enumerate() { - *bit ^= stabilizer_rref.get(row, column); - } - } - } - vector.iter().any(|&bit| bit != 0).then_some(vector) - }); - let candidates: Vec<_> = reduced.collect(); - if candidates.is_empty() { - return Vec::new(); - } - let (rref, _) = F2Matrix::from_rows(candidates).row_reduce(); - let rows = rref - .rows() - .into_iter() - .filter(|row| row.iter().any(|&bit| bit != 0)) - .collect::>(); - debug_assert!(rows.iter().all(|row| row.len() == width)); - rows -} - -fn parity_matrix_from_rows(rows: Vec>, width: usize) -> ParityCheckMatrix { - if rows.is_empty() { - ParityCheckMatrix::zeros(0, width) - } else { - ParityCheckMatrix::from_dense(rows) - .expect("a rectangular logical-operator matrix was generated") - } -} - fn forward_shift(index: usize, l: usize, m: usize, term: BbMonomial) -> usize { let (x_power, y_power) = term; let x = index / m; @@ -724,7 +542,7 @@ mod tests { use crate::fault_tolerance::{ connected_cluster_fault_distance, graphlike_fault_distance, per_observable_fault_distances, }; - use pecos_quantum::GateType; + use pecos_quantum::{AnnotationKind, GateType}; use pecos_random::PecosRng; use pecos_simulators::{CircuitExecutor, SparseStab}; use std::collections::BTreeSet; diff --git a/crates/pecos-qec/src/coloration.rs b/crates/pecos-qec/src/coloration.rs new file mode 100644 index 000000000..ebd5bdf14 --- /dev/null +++ b/crates/pecos-qec/src/coloration.rs @@ -0,0 +1,546 @@ +// 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. + +//! Generic CSS syndrome extraction scheduled by Tanner-graph edge coloration. +//! +//! The coloration construction follows arXiv:2308.08648. Konig's theorem gives an exact +//! Delta-edge-coloring of each bipartite Tanner graph, so every color is a depth-one matching. +//! This guarantees a valid syndrome-extraction schedule; it does not imply that the resulting +//! circuit preserves the distance of the underlying code. + +use pecos_num::graph::{BipartiteEdgeColoringError, bipartite_edge_coloring}; +use pecos_quantum::{Attribute, TickCircuit, TickMeasRef}; +use thiserror::Error; + +use crate::memory_circuit::{ + CssMemoryCircuitFinish, discover_css_logical_operators, finish_css_memory_circuit, +}; +use crate::{MemoryBasis, ParityCheckMatrix, StabilizerCodeSpec}; + +/// Errors reported while constructing a coloration-scheduled CSS memory circuit. +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum ColorationMemoryError { + /// The matrices do not describe any data qubits. + #[error("coloration memory experiment requires at least one data qubit")] + ZeroQubits, + /// The two parity-check matrices have different widths. + #[error("CSS parity-check matrices have different widths: Hx has {hx} qubits, Hz has {hz}")] + MismatchedQubitCount { + /// Width of Hx. + hx: usize, + /// Width of Hz. + hz: usize, + }, + /// Existing CSS validation rejected the matrices. + #[error("invalid CSS check structure: {0}")] + InvalidCssStructure(String), + /// At least one syndrome cycle is required. + #[error("coloration memory experiment requires at least one syndrome cycle")] + ZeroRounds, + /// The data and ancilla register sizes overflowed `usize`. + #[error("coloration memory circuit size overflows usize")] + SizeOverflow, + /// Exact coloring of a Tanner graph failed. + #[error(transparent)] + EdgeColoring(#[from] BipartiteEdgeColoringError), + /// A supposedly matching color layer violated a tick-circuit invariant. + #[error("invalid coloration CNOT layer: {0}")] + InvalidSchedule(String), + /// A measurement reference could not be annotated. + #[error("invalid coloration memory annotation: {0}")] + InvalidAnnotation(String), +} + +#[derive(Clone, Copy)] +enum CnotDirection { + DataToAncilla, + AncillaToData, +} + +/// Build a memory experiment for any validated CSS check pair using exact edge coloration. +/// +/// Each syndrome cycle resets one ancilla per check, applies all Z-check colors as CNOTs from +/// data to Z ancillas, applies all X-check colors as CNOTs from X ancillas to data, and measures +/// the ancillas. The entangling depth per cycle is `Delta(Hz) + Delta(Hx)`. Detectors compare +/// consecutive syndrome cycles and close the prepared-basis checks against final data +/// measurements. Logical observables are discovered from the CSS check spaces. +/// +/// The schedule is deterministic for a fixed pair of matrices. Its matching layers guarantee +/// circuit validity, but no distance-preservation claim is made. +/// +/// # Errors +/// +/// Returns an error for zero rounds, incompatible or nonorthogonal CSS matrices, size overflow, +/// edge-coloring failure, invalid CNOT layers, or invalid measurement annotations. +pub fn coloration_memory_circuit( + hx: &ParityCheckMatrix, + hz: &ParityCheckMatrix, + rounds: usize, + basis: MemoryBasis, +) -> Result { + if rounds == 0 { + return Err(ColorationMemoryError::ZeroRounds); + } + if hx.num_qubits() != hz.num_qubits() { + return Err(ColorationMemoryError::MismatchedQubitCount { + hx: hx.num_qubits(), + hz: hz.num_qubits(), + }); + } + let num_data = hx.num_qubits(); + if num_data == 0 { + return Err(ColorationMemoryError::ZeroQubits); + } + StabilizerCodeSpec::builder(num_data) + .checks_from_css(hx, hz) + .map_err(|error| ColorationMemoryError::InvalidCssStructure(error.to_string()))?; + + let num_x_checks = hx.num_checks(); + let num_z_checks = hz.num_checks(); + let x_ancilla_start = 0; + let data_start = num_x_checks; + let z_ancilla_start = data_start + .checked_add(num_data) + .ok_or(ColorationMemoryError::SizeOverflow)?; + z_ancilla_start + .checked_add(num_z_checks) + .ok_or(ColorationMemoryError::SizeOverflow)?; + + let x_edges = tanner_edges(hx); + let z_edges = tanner_edges(hz); + let x_coloring = bipartite_edge_coloring(num_data, num_x_checks, &x_edges)?; + let z_coloring = bipartite_edge_coloring(num_data, num_z_checks, &z_edges)?; + let entangling_depth = x_coloring + .num_colors() + .checked_add(z_coloring.num_colors()) + .ok_or(ColorationMemoryError::SizeOverflow)?; + let cycle_depth = entangling_depth + .checked_add(3) + .ok_or(ColorationMemoryError::SizeOverflow)?; + + let data_qubits = (data_start..z_ancilla_start).collect::>(); + let x_ancillas = (x_ancilla_start..data_start).collect::>(); + let z_ancillas = (z_ancilla_start..z_ancilla_start + num_z_checks).collect::>(); + let (logical_x, logical_z) = discover_css_logical_operators(hx, hz); + + let mut circuit = TickCircuit::new(); + let mut x_measurements: Vec> = Vec::with_capacity(rounds); + let mut z_measurements: Vec> = Vec::with_capacity(rounds); + for round in 0..rounds { + let mut init = circuit.tick(); + if !x_ancillas.is_empty() { + init.try_add_gate(pecos_core::Gate::px(&x_ancillas)) + .map_err(|error| ColorationMemoryError::InvalidSchedule(error.to_string()))?; + } + if !z_ancillas.is_empty() { + init.try_add_gate(pecos_core::Gate::pz(&z_ancillas)) + .map_err(|error| ColorationMemoryError::InvalidSchedule(error.to_string()))?; + } + if round == 0 { + let preparation = match basis { + MemoryBasis::X => pecos_core::Gate::px(&data_qubits), + MemoryBasis::Z => pecos_core::Gate::pz(&data_qubits), + }; + init.try_add_gate(preparation) + .map_err(|error| ColorationMemoryError::InvalidSchedule(error.to_string()))?; + } + + append_colored_cnot_layers( + &mut circuit, + &z_edges, + z_coloring.colors(), + z_coloring.num_colors(), + data_start, + z_ancilla_start, + CnotDirection::DataToAncilla, + )?; + append_colored_cnot_layers( + &mut circuit, + &x_edges, + x_coloring.colors(), + x_coloring.num_colors(), + data_start, + x_ancilla_start, + CnotDirection::AncillaToData, + )?; + + let z_refs = if z_ancillas.is_empty() { + circuit.tick(); + Vec::new() + } else { + circuit.tick().mz(&z_ancillas) + }; + let x_refs = if x_ancillas.is_empty() { + circuit.tick(); + Vec::new() + } else { + circuit.tick().mx(&x_ancillas) + }; + z_measurements.push(z_refs); + x_measurements.push(x_refs); + } + + finish_css_memory_circuit( + &mut circuit, + CssMemoryCircuitFinish { + data_qubits: &data_qubits, + hx, + hz, + logical_x: &logical_x, + logical_z: &logical_z, + x_measurements: &x_measurements, + z_measurements: &z_measurements, + rounds, + basis, + circuit_type: "coloration_css_memory", + }, + ) + .map_err(ColorationMemoryError::InvalidAnnotation)?; + circuit.set_meta( + "num_ancilla_qubits", + Attribute::String((num_x_checks + num_z_checks).to_string()), + ); + circuit.set_meta( + "num_x_ancillas", + Attribute::String(num_x_checks.to_string()), + ); + circuit.set_meta( + "num_z_ancillas", + Attribute::String(num_z_checks.to_string()), + ); + circuit.set_meta( + "x_coloration_depth", + Attribute::String(x_coloring.num_colors().to_string()), + ); + circuit.set_meta( + "z_coloration_depth", + Attribute::String(z_coloring.num_colors().to_string()), + ); + circuit.set_meta( + "entangling_depth_per_cycle", + Attribute::String(entangling_depth.to_string()), + ); + circuit.set_meta( + "syndrome_cycle_depth", + Attribute::String(cycle_depth.to_string()), + ); + Ok(circuit) +} + +fn tanner_edges(matrix: &ParityCheckMatrix) -> Vec<(usize, usize)> { + let mut edges = Vec::new(); + for (check, row) in matrix.rows().iter().enumerate() { + for (data, &bit) in row.iter().enumerate() { + if bit == 1 { + edges.push((data, check)); + } + } + } + edges +} + +fn append_colored_cnot_layers( + circuit: &mut TickCircuit, + edges: &[(usize, usize)], + colors: &[usize], + num_colors: usize, + data_start: usize, + ancilla_start: usize, + direction: CnotDirection, +) -> Result<(), ColorationMemoryError> { + if edges.len() != colors.len() { + return Err(ColorationMemoryError::InvalidSchedule(format!( + "{} Tanner edges have {} colors", + edges.len(), + colors.len() + ))); + } + for color in 0..num_colors { + let pairs = edges + .iter() + .zip(colors) + .filter(|&(_, &edge_color)| edge_color == color) + .map(|(&(data, check), _)| match direction { + CnotDirection::DataToAncilla => (data_start + data, ancilla_start + check), + CnotDirection::AncillaToData => (ancilla_start + check, data_start + data), + }) + .collect::>(); + if pairs.is_empty() { + return Err(ColorationMemoryError::InvalidSchedule(format!( + "color {color} has no Tanner edges" + ))); + } + circuit + .tick() + .try_add_gate(pecos_core::Gate::cx(&pairs)) + .map_err(|error| ColorationMemoryError::InvalidSchedule(error.to_string()))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SurfaceCode; + use crate::bivariate_bicycle::{BbMonomial, BivariateBicycleCode, bb_memory_circuit}; + use crate::fault_tolerance::dem_builder::DemBuilder; + use crate::fault_tolerance::{ + connected_cluster_fault_distance, graphlike_fault_distance, per_observable_fault_distances, + }; + use crate::geometry::StabilizerCheck; + use pecos_quantum::{AnnotationKind, GateType}; + use pecos_simulators::{CircuitExecutor, DenseStab}; + use std::time::Instant; + + const A: [BbMonomial; 3] = [(3, 0), (0, 1), (0, 2)]; + const B: [BbMonomial; 3] = [(0, 3), (1, 0), (2, 0)]; + + fn steane_checks() -> (ParityCheckMatrix, ParityCheckMatrix) { + let h = ParityCheckMatrix::from_dense(vec![ + vec![1, 0, 1, 0, 1, 0, 1], + vec![0, 1, 1, 0, 0, 1, 1], + vec![0, 0, 0, 1, 1, 1, 1], + ]) + .unwrap(); + (h.clone(), h) + } + + fn checks_from_surface(checks: &[StabilizerCheck], num_data: usize) -> ParityCheckMatrix { + let rows = checks + .iter() + .map(|check| { + let mut row = vec![0_u8; num_data]; + for qubit in check.qubits() { + row[qubit] = 1; + } + row + }) + .collect(); + ParityCheckMatrix::from_dense(rows).unwrap() + } + + fn surface_checks() -> (ParityCheckMatrix, ParityCheckMatrix) { + let code = SurfaceCode::rotated(3).unwrap(); + ( + checks_from_surface(code.x_stabilizers(), code.num_data_qubits()), + checks_from_surface(code.z_stabilizers(), code.num_data_qubits()), + ) + } + + fn sample_annotation_parities(circuit: &TickCircuit, num_qubits: usize) -> Vec { + let mut simulator = DenseStab::new(num_qubits); + let measurements = CircuitExecutor::new(circuit).run(&mut simulator); + circuit + .annotations() + .iter() + .filter_map(|annotation| match &annotation.kind { + AnnotationKind::Detector { + measurement_ids, .. + } + | AnnotationKind::Observable { measurement_ids } => { + Some(measurement_ids.iter().fold(false, |parity, id| { + parity ^ measurements[id.index()].outcome + })) + } + AnnotationKind::TrackedPauli => None, + }) + .collect() + } + + fn assert_fault_free( + hx: &ParityCheckMatrix, + hz: &ParityCheckMatrix, + basis: MemoryBasis, + expected_detectors: usize, + ) { + let circuit = coloration_memory_circuit(hx, hz, 2, basis).unwrap(); + let dem = DemBuilder::try_from_tick_circuit(&circuit, 0.0, 0.0, 0.0, 0.0) + .expect("fault-free circuit has a DEM"); + assert_eq!(dem.num_detectors(), expected_detectors); + assert!(dem.to_mechanisms().0.is_empty()); + + let total_qubits = hx.num_qubits() + hx.num_checks() + hz.num_checks(); + for _ in 0..4 { + let samples = sample_annotation_parities(&circuit, total_qubits); + assert_eq!( + samples, + vec![false; expected_detectors + hx.num_qubits() - hx.rank() - hz.rank()] + ); + } + } + + #[test] + fn surface_and_steane_fault_free_memory_is_clean() { + let (surface_hx, surface_hz) = surface_checks(); + let surface_detectors = surface_hx.num_checks() + 3 * surface_hz.num_checks(); + assert_fault_free(&surface_hx, &surface_hz, MemoryBasis::Z, surface_detectors); + + let (steane_hx, steane_hz) = steane_checks(); + for basis in [MemoryBasis::X, MemoryBasis::Z] { + let basis_checks = match basis { + MemoryBasis::X => steane_hx.num_checks(), + MemoryBasis::Z => steane_hz.num_checks(), + }; + let expected_detectors = + steane_hx.num_checks() + steane_hz.num_checks() + 2 * basis_checks; + assert_fault_free(&steane_hx, &steane_hz, basis, expected_detectors); + } + } + + #[test] + fn bb_72_coloration_is_clean_and_depth_is_measured() { + let code = BivariateBicycleCode::new(6, 6, &A, &B).unwrap(); + let coloration = + coloration_memory_circuit(code.hx(), code.hz(), 2, MemoryBasis::Z).unwrap(); + let specialized = bb_memory_circuit(6, 6, &A, &B, 2, MemoryBasis::Z).unwrap(); + + assert_eq!( + coloration.get_meta("z_coloration_depth"), + Some(&Attribute::String("6".to_string())) + ); + assert_eq!( + coloration.get_meta("x_coloration_depth"), + Some(&Attribute::String("6".to_string())) + ); + assert_eq!( + coloration.get_meta("entangling_depth_per_cycle"), + Some(&Attribute::String("12".to_string())) + ); + assert_eq!( + coloration.get_meta("syndrome_cycle_depth"), + Some(&Attribute::String("15".to_string())) + ); + assert_eq!(coloration.num_ticks(), 31); + assert_eq!(specialized.num_ticks(), 18); + let coloration_entangling_layers = coloration + .ticks() + .iter() + .take(15) + .filter(|tick| { + tick.iter_gate_instances() + .any(|gate| gate.gate_type() == GateType::CX) + }) + .count(); + let specialized_entangling_layers = specialized + .ticks() + .iter() + .skip(1) + .take(8) + .filter(|tick| { + tick.iter_gate_instances() + .any(|gate| gate.gate_type() == GateType::CX) + }) + .count(); + assert_eq!(coloration_entangling_layers, 12); + assert_eq!(specialized_entangling_layers, 7); + assert_eq!((coloration.num_ticks() - 1) / 2, 15); + assert_eq!((specialized.num_ticks() - 2) / 2, 8); + assert_eq!( + specialized.get_meta("syndrome_extraction_depth"), + Some(&Attribute::String("17".to_string())) + ); + + let dem = DemBuilder::try_from_tick_circuit(&coloration, 0.0, 0.0, 0.0, 0.0).unwrap(); + assert_eq!(dem.num_detectors(), 144); + assert!(dem.to_mechanisms().0.is_empty()); + } + + #[test] + fn builder_is_deterministic() { + let (hx, hz) = steane_checks(); + let first = coloration_memory_circuit(&hx, &hz, 2, MemoryBasis::Z).unwrap(); + let second = coloration_memory_circuit(&hx, &hz, 2, MemoryBasis::Z).unwrap(); + assert_eq!(format!("{first:?}"), format!("{second:?}")); + } + + #[test] + fn corrupted_coloring_is_rejected_as_an_overlapping_cnot_layer() { + let edges = vec![(0, 0), (0, 1)]; + let corrupted_colors = vec![0, 0]; + let mut circuit = TickCircuit::new(); + + let error = append_colored_cnot_layers( + &mut circuit, + &edges, + &corrupted_colors, + 1, + 0, + 2, + CnotDirection::DataToAncilla, + ) + .unwrap_err(); + assert!( + matches!(error, ColorationMemoryError::InvalidSchedule(_)), + "the TickCircuit qubit-conflict invariant must reject two same-layer CNOTs on data 0" + ); + } + + #[test] + fn rejects_nonorthogonal_or_mismatched_css_checks() { + let hx = ParityCheckMatrix::from_dense(vec![vec![1, 0]]).unwrap(); + let hz = ParityCheckMatrix::from_dense(vec![vec![1, 0]]).unwrap(); + assert!(matches!( + coloration_memory_circuit(&hx, &hz, 1, MemoryBasis::Z), + Err(ColorationMemoryError::InvalidCssStructure(_)) + )); + let short = ParityCheckMatrix::from_dense(vec![vec![0]]).unwrap(); + assert!(matches!( + coloration_memory_circuit(&hx, &short, 1, MemoryBasis::Z), + Err(ColorationMemoryError::MismatchedQubitCount { .. }) + )); + } + + #[test] + fn steane_two_cycle_circuit_fault_distance_is_measured() { + let (hx, hz) = steane_checks(); + let started = Instant::now(); + let circuit = coloration_memory_circuit(&hx, &hz, 2, MemoryBasis::Z).unwrap(); + let dem = DemBuilder::try_from_tick_circuit(&circuit, 0.001, 0.001, 0.001, 0.001) + .expect("uniform circuit noise has a DEM"); + let build_elapsed = started.elapsed(); + let (mechanisms, _) = dem.to_mechanisms(); + + let search_started = Instant::now(); + let (method, overall) = match graphlike_fault_distance(&dem) { + Ok(result) => ("graphlike", result), + Err(error) => { + println!("graphlike unavailable: {error}"); + ( + "connected_cluster", + connected_cluster_fault_distance(&dem, 3), + ) + } + }; + let search_elapsed = search_started.elapsed(); + let per_started = Instant::now(); + let per_observable = per_observable_fault_distances(&dem, 3); + let per_elapsed = per_started.elapsed(); + println!( + "Steane coloration DEM: {} mechanisms, build {build_elapsed:?}; {method} {overall:?}, search {search_elapsed:?}; per-observable {per_observable:?}, search {per_elapsed:?}", + mechanisms.len() + ); + + let overall = overall.expect("a logical circuit fault exists through weight three"); + println!("Steane coloration witness:"); + for &index in &overall.mechanism_indices { + println!("mechanism[{index}] = {:?}", mechanisms[index]); + } + // This assertion records a circuit measurement, not a preservation guarantee. A result + // below the Steane code distance three is evidence of hook errors in naive coloration. + assert_eq!(overall.distance, 2); + assert_eq!(per_observable.len(), 1); + assert_eq!( + per_observable[0].as_ref().map(|result| result.distance), + Some(2) + ); + } +} diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index cedc23dce..502e8c5b6 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -65,6 +65,7 @@ pub mod bivariate_bicycle; pub mod code_distance; +pub mod coloration; pub mod dem_stab; pub mod distance; pub mod distance_problem; @@ -72,6 +73,7 @@ pub mod fault_tolerance; pub mod geometry; pub mod logical_discovery; pub mod mem_stab; +mod memory_circuit; pub mod parity_check_matrix; pub mod stabilizer_code; pub mod stabilizer_code_spec; @@ -80,8 +82,10 @@ pub mod surface; pub use bivariate_bicycle::{ BbMemoryBasis, BbMonomial, BivariateBicycleCode, BivariateBicycleError, bb_memory_circuit, }; +pub use coloration::{ColorationMemoryError, coloration_memory_circuit}; pub use dem_stab::{DemStabError, DemStabShotBatch, DemStabSim, DemStabSimBuilder}; pub use mem_stab::{MemStabError, MemStabSim, MemStabSimBuilder}; +pub use memory_circuit::MemoryBasis; pub use parity_check_matrix::{ParityCheckMatrix, ParityCheckMatrixError}; pub use code_distance::{ diff --git a/crates/pecos-qec/src/memory_circuit.rs b/crates/pecos-qec/src/memory_circuit.rs new file mode 100644 index 000000000..547ebe528 --- /dev/null +++ b/crates/pecos-qec/src/memory_circuit.rs @@ -0,0 +1,258 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the +// License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +// either express or implied. See the License for the specific language governing permissions and +// limitations under the License. + +//! Shared construction machinery for CSS memory experiments. + +use pecos_quantum::{AnnotationKind, Attribute, F2Matrix, TickCircuit, TickMeasRef}; + +use crate::ParityCheckMatrix; + +/// Memory-experiment preparation and final-measurement basis. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MemoryBasis { + /// Prepare and measure the encoded state in the X basis. + X, + /// Prepare and measure the encoded state in the Z basis. + Z, +} + +#[derive(Clone, Copy)] +pub(crate) struct CssMemoryCircuitFinish<'a> { + pub data_qubits: &'a [usize], + pub hx: &'a ParityCheckMatrix, + pub hz: &'a ParityCheckMatrix, + pub logical_x: &'a ParityCheckMatrix, + pub logical_z: &'a ParityCheckMatrix, + pub x_measurements: &'a [Vec], + pub z_measurements: &'a [Vec], + pub rounds: usize, + pub basis: MemoryBasis, + pub circuit_type: &'a str, +} + +pub(crate) fn discover_css_logical_operators( + hx: &ParityCheckMatrix, + hz: &ParityCheckMatrix, +) -> (ParityCheckMatrix, ParityCheckMatrix) { + let num_qubits = hx.num_qubits(); + let logical_x = quotient_basis(hz.matrix(), hx.matrix(), num_qubits); + let logical_z = quotient_basis(hx.matrix(), hz.matrix(), num_qubits); + ( + parity_matrix_from_rows(logical_x, num_qubits), + parity_matrix_from_rows(logical_z, num_qubits), + ) +} + +pub(crate) fn finish_css_memory_circuit( + circuit: &mut TickCircuit, + finish: CssMemoryCircuitFinish<'_>, +) -> Result<(), String> { + let CssMemoryCircuitFinish { + data_qubits, + hx, + hz, + logical_x, + logical_z, + x_measurements, + z_measurements, + rounds, + basis, + circuit_type, + } = finish; + add_cycle_detectors(circuit, x_measurements, z_measurements, basis)?; + + let final_data = match basis { + MemoryBasis::X => circuit.tick().mx(data_qubits), + MemoryBasis::Z => circuit.tick().mz(data_qubits), + }; + let (closing_checks, final_logicals, last_syndrome, label) = match basis { + MemoryBasis::X => (hx, logical_x, &x_measurements[rounds - 1], "X"), + MemoryBasis::Z => (hz, logical_z, &z_measurements[rounds - 1], "Z"), + }; + for (check, &syndrome) in last_syndrome.iter().enumerate() { + let mut refs = vec![syndrome]; + for (data, &bit) in closing_checks + .row(check) + .expect("the check index comes from its measurement list") + .iter() + .enumerate() + { + if bit == 1 { + refs.push(final_data[data]); + } + } + annotate_detector(circuit, &format!("{label}{check}_final"), &refs)?; + } + for logical in 0..final_logicals.num_checks() { + let refs = final_logicals + .row(logical) + .expect("the logical index is in range") + .iter() + .enumerate() + .filter_map(|(data, &bit)| (bit == 1).then_some(final_data[data])) + .collect::>(); + circuit + .observable_labeled(&format!("L{logical}"), &refs) + .map_err(|error| error.to_string())?; + } + + let (detectors_json, observables_json, num_detectors, num_observables) = + annotation_metadata_json(circuit); + circuit.set_meta( + "num_measurements", + Attribute::String(circuit.num_measurements().to_string()), + ); + circuit.set_meta("detectors", Attribute::String(detectors_json)); + circuit.set_meta("observables", Attribute::String(observables_json)); + circuit.set_meta( + "num_detectors", + Attribute::String(num_detectors.to_string()), + ); + circuit.set_meta( + "num_observables", + Attribute::String(num_observables.to_string()), + ); + circuit.set_meta( + "num_data_qubits", + Attribute::String(data_qubits.len().to_string()), + ); + circuit.set_meta( + "num_logical_qubits", + Attribute::String(final_logicals.num_checks().to_string()), + ); + circuit.set_meta("syndrome_cycles", Attribute::String(rounds.to_string())); + circuit.set_meta( + "syndrome_extraction_depth", + Attribute::String((circuit.num_ticks() - 1).to_string()), + ); + circuit.set_meta("circuit_type", Attribute::String(circuit_type.to_string())); + Ok(()) +} + +fn add_cycle_detectors( + circuit: &mut TickCircuit, + x_measurements: &[Vec], + z_measurements: &[Vec], + basis: MemoryBasis, +) -> Result<(), String> { + for round in 0..x_measurements.len() { + if round == 0 { + let (label, measurements) = match basis { + MemoryBasis::X => ("X", &x_measurements[0]), + MemoryBasis::Z => ("Z", &z_measurements[0]), + }; + for (check, &measurement) in measurements.iter().enumerate() { + annotate_detector(circuit, &format!("{label}{check}_r0"), &[measurement])?; + } + continue; + } + let num_checks = x_measurements[round].len().max(z_measurements[round].len()); + for check in 0..num_checks { + if let (Some(&previous), Some(¤t)) = ( + x_measurements[round - 1].get(check), + x_measurements[round].get(check), + ) { + annotate_detector(circuit, &format!("X{check}_r{round}"), &[previous, current])?; + } + if let (Some(&previous), Some(¤t)) = ( + z_measurements[round - 1].get(check), + z_measurements[round].get(check), + ) { + annotate_detector(circuit, &format!("Z{check}_r{round}"), &[previous, current])?; + } + } + } + Ok(()) +} + +fn annotate_detector( + circuit: &mut TickCircuit, + label: &str, + measurements: &[TickMeasRef], +) -> Result<(), String> { + circuit + .detector_labeled(label, measurements) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +fn annotation_metadata_json(circuit: &TickCircuit) -> (String, String, usize, usize) { + let mut detectors = Vec::new(); + let mut observables = Vec::new(); + for annotation in circuit.annotations() { + match &annotation.kind { + AnnotationKind::Detector { + measurement_ids, + coords: _, + } => { + let id = detectors.len(); + detectors.push(serde_json::json!({ + "id": id, + "meas_ids": measurement_ids.iter().map(|id| id.index()).collect::>(), + "label": annotation.label, + })); + } + AnnotationKind::Observable { measurement_ids } => { + let id = observables.len(); + observables.push(serde_json::json!({ + "id": id, + "meas_ids": measurement_ids.iter().map(|id| id.index()).collect::>(), + "label": annotation.label, + })); + } + AnnotationKind::TrackedPauli => {} + } + } + let num_detectors = detectors.len(); + let num_observables = observables.len(); + ( + serde_json::to_string(&detectors).expect("annotation metadata is JSON-serializable"), + serde_json::to_string(&observables).expect("annotation metadata is JSON-serializable"), + num_detectors, + num_observables, + ) +} + +fn quotient_basis(kernel_matrix: &F2Matrix, stabilizers: &F2Matrix, width: usize) -> Vec> { + let (stabilizer_rref, pivots) = stabilizers.row_reduce(); + let reduced = kernel_matrix.kernel().into_iter().filter_map(|mut vector| { + for (row, &pivot) in pivots.iter().enumerate() { + if vector[pivot] == 1 { + for (column, bit) in vector.iter_mut().enumerate() { + *bit ^= stabilizer_rref.get(row, column); + } + } + } + vector.iter().any(|&bit| bit != 0).then_some(vector) + }); + let candidates: Vec<_> = reduced.collect(); + if candidates.is_empty() { + return Vec::new(); + } + let (rref, _) = F2Matrix::from_rows(candidates).row_reduce(); + let rows = rref + .rows() + .into_iter() + .filter(|row| row.iter().any(|&bit| bit != 0)) + .collect::>(); + debug_assert!(rows.iter().all(|row| row.len() == width)); + rows +} + +fn parity_matrix_from_rows(rows: Vec>, width: usize) -> ParityCheckMatrix { + if rows.is_empty() { + ParityCheckMatrix::zeros(0, width) + } else { + ParityCheckMatrix::from_dense(rows) + .expect("a rectangular logical-operator matrix was generated") + } +} diff --git a/python/pecos-rslib/pecos_rslib/qec.pyi b/python/pecos-rslib/pecos_rslib/qec.pyi index 4a7c73b3d..bc508c885 100644 --- a/python/pecos-rslib/pecos_rslib/qec.pyi +++ b/python/pecos-rslib/pecos_rslib/qec.pyi @@ -51,6 +51,12 @@ def bb_memory_circuit( rounds: int, basis: str, ) -> TickCircuit: ... +def coloration_memory_circuit( + hx: ParityCheckMatrix, + hz: ParityCheckMatrix, + rounds: int, + basis: str, +) -> TickCircuit: ... class FaultDistanceResult: """A unit-weight mechanism distance and one witnessing index set.""" diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 37606730f..7805c9bfc 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -86,6 +86,7 @@ use pecos_qec::fault_tolerance::{ use pecos_qec::{ BbMemoryBasis as RustBbMemoryBasis, BivariateBicycleCode as RustBivariateBicycleCode, bb_memory_circuit as rust_bb_memory_circuit, + coloration_memory_circuit as rust_coloration_memory_circuit, }; use pecos_qec::{ CertifiedDistance as RustCertifiedDistance, DistanceProblem as RustDistanceProblem, @@ -7852,6 +7853,28 @@ fn bb_memory_circuit( Ok(PyTickCircuit { inner }) } +/// Build a generic CSS memory circuit from exact Tanner-graph edge colorings. +#[pyfunction] +fn coloration_memory_circuit( + hx: &PyParityCheckMatrix, + hz: &PyParityCheckMatrix, + rounds: usize, + basis: &str, +) -> PyResult { + let basis = match basis.to_ascii_uppercase().as_str() { + "X" => RustBbMemoryBasis::X, + "Z" => RustBbMemoryBasis::Z, + _ => { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "basis must be 'X' or 'Z', got {basis:?}" + ))); + } + }; + let inner = rust_coloration_memory_circuit(&hx.inner, &hz.inner, rounds, basis) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))?; + Ok(PyTickCircuit { inner }) +} + /// Register the QEC fault tolerance module. pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { let qec = PyModule::new(m.py(), "qec")?; @@ -7908,6 +7931,7 @@ pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { qec.add_function(wrap_pyfunction!(decoder_dem_requirement, &qec)?)?; qec.add_function(wrap_pyfunction!(certified_distance, &qec)?)?; qec.add_function(wrap_pyfunction!(bb_memory_circuit, &qec)?)?; + qec.add_function(wrap_pyfunction!(coloration_memory_circuit, &qec)?)?; // Add Pauli constants qec.add("PAULI_I", 0u8)?; diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index 310dbd2ac..08277d6c9 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -53,6 +53,7 @@ assert_dems_equivalent, bb_memory_circuit, certified_distance, + coloration_memory_circuit, compare_dems_exact, compare_dems_statistical, connected_cluster_code_distance, @@ -168,6 +169,7 @@ "assert_dems_equivalent", "bb_memory_circuit", "certified_distance", + "coloration_memory_circuit", "connected_cluster_code_distance", "compare_dems_exact", "compare_dems_statistical", diff --git a/python/quantum-pecos/tests/qec/test_coloration_memory.py b/python/quantum-pecos/tests/qec/test_coloration_memory.py new file mode 100644 index 000000000..f0b7ddb37 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_coloration_memory.py @@ -0,0 +1,54 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the +# License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +# either express or implied. See the License for the specific language governing permissions and +# limitations under the License. + +"""Python binding tests for generic coloration-scheduled CSS memory circuits.""" + +import pytest +from pecos.qec import coloration_memory_circuit +from pecos.quantum import ParityCheckMatrix + +STEANE_H = [ + [1, 0, 1, 0, 1, 0, 1], + [0, 1, 1, 0, 0, 1, 1], + [0, 0, 0, 1, 1, 1, 1], +] + + +def test_coloration_memory_binding_is_deterministic_and_reports_depth() -> None: + h = ParityCheckMatrix(STEANE_H) + + first = coloration_memory_circuit(h, h, 2, "Z") + second = coloration_memory_circuit(h, h, 2, "z") + + assert repr(first) == repr(second) + assert first.get_meta("circuit_type") == "coloration_css_memory" + assert first.get_meta("num_data_qubits") == "7" + assert first.get_meta("num_ancilla_qubits") == "6" + assert first.get_meta("num_detectors") == "12" + assert first.get_meta("num_observables") == "1" + assert first.get_meta("x_coloration_depth") == "4" + assert first.get_meta("z_coloration_depth") == "4" + assert first.get_meta("entangling_depth_per_cycle") == "8" + assert first.get_meta("syndrome_cycle_depth") == "11" + assert first.num_ticks() == 23 + + +def test_coloration_memory_binding_rejects_invalid_inputs() -> None: + h = ParityCheckMatrix(STEANE_H) + nonorthogonal = ParityCheckMatrix([[1, 0, 0, 0, 0, 0, 0]]) + + with pytest.raises(ValueError, match="basis must be"): + coloration_memory_circuit(h, h, 2, "Y") + with pytest.raises(ValueError, match="at least one syndrome cycle"): + coloration_memory_circuit(h, h, 0, "Z") + with pytest.raises(ValueError, match="not orthogonal"): + coloration_memory_circuit(h, nonorthogonal, 2, "Z") From 3d079ed7988307ddfbd964d9945a94061f60d723 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 7 Aug 2026 11:40:35 -0600 Subject: [PATCH 32/41] Add self-certifying bounded-enumeration distance for the dense-code regime --- .../src/bounded_enumeration_distance.rs | 765 ++++++++++++++++++ crates/pecos-qec/src/code_distance.rs | 2 +- crates/pecos-qec/src/distance_problem.rs | 21 +- crates/pecos-qec/src/lib.rs | 6 + .../src/fault_tolerance_bindings.rs | 164 ++++ .../quantum-pecos/src/pecos/qec/__init__.py | 10 + ..._fault_tolerance_certification_bindings.py | 49 ++ 7 files changed, 1013 insertions(+), 4 deletions(-) create mode 100644 crates/pecos-qec/src/bounded_enumeration_distance.rs diff --git a/crates/pecos-qec/src/bounded_enumeration_distance.rs b/crates/pecos-qec/src/bounded_enumeration_distance.rs new file mode 100644 index 000000000..c7da5c3d4 --- /dev/null +++ b/crates/pecos-qec/src/bounded_enumeration_distance.rs @@ -0,0 +1,765 @@ +// 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. + +//! Exact binary code distance by bounded generator-row enumeration. +//! +//! This is the information-set method treated for quantum codes in +//! [arXiv:2408.10743](https://arxiv.org/abs/2408.10743). Its initial upper bound also tries a +//! fixed number of seeded random information sets, following the upper-bound idea in +//! [arXiv:2308.15140](https://arxiv.org/abs/2308.15140). Randomness can only improve the upper +//! bound; exactness follows entirely from the deterministic information-set lower bound. + +use crate::code_distance::mechanisms_from_stabilizer_code; +use crate::{DistanceProblem, DistanceProblemError, ParityCheckMatrix, StabilizerCodeSpec}; +use pecos_quantum::F2Matrix; +use rand::SeedableRng; +use rand::rngs::SmallRng; +use rand::seq::SliceRandom; + +const RANDOM_INFORMATION_SET_TRIALS: usize = 64; +const RANDOM_INFORMATION_SET_SEED: u64 = 0xB0A0_3E12_1A11_5EED; + +/// Result of a bounded generator-row enumeration. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BoundedEnumerationDistance { + /// Matching native lower and upper bounds certify the exact distance. + CertifiedByBounds { + /// Exact minimum Hamming weight. + distance: usize, + /// Binary assignment attaining `distance`. + witness: Vec, + /// Native lower bound at termination. + lower_bound: usize, + /// Last fully enumerated generator-row level. + level: usize, + /// Always true: no external solver is trusted for the lower bound. + lb_certified: bool, + }, + /// The level budget ended before the bounds met. + LevelLimitReached { + /// Proven native lower bound on the distance. + lower_bound: usize, + /// Weight of the best natively verified witness found. + upper_bound: usize, + /// Binary assignment attaining `upper_bound`. + witness: Vec, + /// Requested maximum generator-row level. + max_level: usize, + /// Always true: the lower bound is established by native enumeration. + lb_certified: bool, + }, +} + +impl BoundedEnumerationDistance { + /// Returns the proven lower bound. + #[must_use] + pub fn lower_bound(&self) -> usize { + match self { + Self::CertifiedByBounds { lower_bound, .. } + | Self::LevelLimitReached { lower_bound, .. } => *lower_bound, + } + } + + /// Returns the best natively verified upper bound. + #[must_use] + pub fn upper_bound(&self) -> usize { + match self { + Self::CertifiedByBounds { distance, .. } => *distance, + Self::LevelLimitReached { upper_bound, .. } => *upper_bound, + } + } + + /// Returns the witness attaining [`Self::upper_bound`]. + #[must_use] + pub fn witness(&self) -> &[bool] { + match self { + Self::CertifiedByBounds { witness, .. } | Self::LevelLimitReached { witness, .. } => { + witness + } + } + } + + /// Returns whether the lower and upper bounds prove exact distance. + #[must_use] + pub fn is_certified(&self) -> bool { + matches!(self, Self::CertifiedByBounds { .. }) + } +} + +#[derive(Clone, Debug)] +struct SystematicGenerator { + rows: Vec>, + rank: usize, +} + +fn restricted_systematic_generator( + generator: &F2Matrix, + columns: &[usize], +) -> (SystematicGenerator, Vec) { + let restricted_width = columns.len(); + let full_width = generator.num_cols(); + let mut augmented = F2Matrix::zeros(generator.num_rows(), restricted_width + full_width); + for row in 0..generator.num_rows() { + for (restricted_column, &full_column) in columns.iter().enumerate() { + augmented.set(row, restricted_column, generator.get(row, full_column)); + } + for column in 0..full_width { + augmented.set(row, restricted_width + column, generator.get(row, column)); + } + } + + // The restricted block comes first, so its pivots are chosen before the full generator block. + // Later pivots can change full rows but are zero on the restricted block, preserving its RREF. + let (reduced, pivots) = augmented.row_reduce(); + let restricted_pivots: Vec<_> = pivots + .into_iter() + .take_while(|&pivot| pivot < restricted_width) + .collect(); + let rows = (0..generator.num_rows()) + .map(|row| { + (0..full_width) + .map(|column| reduced.get(row, restricted_width + column)) + .collect() + }) + .collect(); + ( + SystematicGenerator { + rows, + rank: restricted_pivots.len(), + }, + restricted_pivots, + ) +} + +fn peel_information_sets(generator: &F2Matrix) -> Vec { + let mut active_columns: Vec<_> = (0..generator.num_cols()).collect(); + let mut systematic_generators = Vec::new(); + loop { + let (systematic, restricted_pivots) = + restricted_systematic_generator(generator, &active_columns); + if systematic.rank == 0 { + break; + } + let mut is_pivot = vec![false; active_columns.len()]; + for pivot in restricted_pivots { + is_pivot[pivot] = true; + } + active_columns = active_columns + .into_iter() + .enumerate() + .filter_map(|(index, column)| (!is_pivot[index]).then_some(column)) + .collect(); + systematic_generators.push(systematic); + } + systematic_generators +} + +fn row_weight(row: &[u8]) -> usize { + row.iter().map(|&bit| usize::from(bit)).sum() +} + +fn has_logical_effect(row: &[u8], logicals: &F2Matrix) -> bool { + (0..logicals.num_rows()).any(|logical| { + row.iter() + .enumerate() + .filter(|&(column, &bit)| bit == 1 && logicals.get(logical, column) == 1) + .count() + % 2 + == 1 + }) +} + +fn update_upper_bound(candidate: &[u8], logicals: &F2Matrix, best: &mut Option>) { + if !has_logical_effect(candidate, logicals) { + return; + } + let candidate_weight = row_weight(candidate); + if best + .as_ref() + .is_none_or(|current| candidate_weight < row_weight(current)) + { + *best = Some(candidate.to_vec()); + } +} + +fn seeded_upper_bound( + generator: &F2Matrix, + systematic_generators: &[SystematicGenerator], + logicals: &F2Matrix, +) -> Option> { + let mut best = None; + for systematic in systematic_generators { + for row in &systematic.rows { + update_upper_bound(row, logicals, &mut best); + } + } + + let mut rng = SmallRng::seed_from_u64(RANDOM_INFORMATION_SET_SEED); + let mut columns: Vec<_> = (0..generator.num_cols()).collect(); + for _ in 0..RANDOM_INFORMATION_SET_TRIALS { + columns.shuffle(&mut rng); + let (systematic, _) = restricted_systematic_generator(generator, &columns); + for row in &systematic.rows { + update_upper_bound(row, logicals, &mut best); + } + } + best +} + +fn for_each_combination(count: usize, choose: usize, mut visit: impl FnMut(&[usize])) { + if choose > count { + return; + } + if choose == 0 { + visit(&[]); + return; + } + let mut combination: Vec<_> = (0..choose).collect(); + loop { + visit(&combination); + let Some(position) = (0..choose) + .rev() + .find(|&position| combination[position] < count - choose + position) + else { + break; + }; + combination[position] += 1; + for index in position + 1..choose { + combination[index] = combination[index - 1] + 1; + } + } +} + +fn combination_codeword(rows: &[Vec], combination: &[usize]) -> Vec { + let mut codeword = vec![0; rows[0].len()]; + for &row in combination { + for (bit, &generator_bit) in codeword.iter_mut().zip(&rows[row]) { + *bit ^= generator_bit; + } + } + codeword +} + +fn lower_bound_after_level( + level: usize, + dimension: usize, + systematic_generators: &[SystematicGenerator], + even_code: bool, +) -> usize { + let mut lower_bound = systematic_generators + .iter() + .map(|systematic| (level + 1).saturating_sub(dimension - systematic.rank)) + .filter(|&contribution| contribution != 0) + .sum(); + if even_code && lower_bound % 2 == 1 { + lower_bound += 1; + } + lower_bound +} + +fn verify_result_witness( + h: &ParityCheckMatrix, + l: &ParityCheckMatrix, + candidate: &[u8], +) -> Vec { + let witness: Vec<_> = candidate.iter().map(|&bit| bit == 1).collect(); + let problem = DistanceProblem::from_css_checks(h, l) + .expect("bounded-enumeration matrices were width-checked before search"); + let verified_weight = problem + .verify_witness(&witness) + .expect("bounded enumeration produced an invalid witness"); + assert_eq!(verified_weight, row_weight(candidate)); + witness +} + +fn parity_check_from_matrix(matrix: &F2Matrix) -> ParityCheckMatrix { + if matrix.num_rows() == 0 { + ParityCheckMatrix::zeros(0, matrix.num_cols()) + } else { + ParityCheckMatrix::from_dense(matrix.rows()).expect("F2Matrix entries are binary") + } +} + +fn matrix_distance( + h: &ParityCheckMatrix, + l: &ParityCheckMatrix, + max_level: usize, +) -> Option { + assert_eq!( + h.num_qubits(), + l.num_qubits(), + "code-distance matrices must have matching widths" + ); + let generator = F2Matrix::from_rows(h.matrix().kernel()); + let dimension = generator.num_rows(); + if dimension == 0 { + return None; + } + let systematic_generators = peel_information_sets(&generator); + let mut best = seeded_upper_bound(&generator, &systematic_generators, l.matrix())?; + // The first systematic generator is a basis of the whole code. Weight parity is linear over + // GF(2), so even basis rows prove that every generated codeword has even weight. + let even_code = generator + .rows() + .iter() + .all(|row| row_weight(row).is_multiple_of(2)); + let mut lower_bound = lower_bound_after_level(0, dimension, &systematic_generators, even_code); + if lower_bound >= row_weight(&best) { + let witness = verify_result_witness(h, l, &best); + return Some(BoundedEnumerationDistance::CertifiedByBounds { + distance: row_weight(&best), + witness, + lower_bound, + level: 0, + lb_certified: true, + }); + } + + for level in 1..=dimension { + if level > max_level { + let witness = verify_result_witness(h, l, &best); + return Some(BoundedEnumerationDistance::LevelLimitReached { + lower_bound, + upper_bound: row_weight(&best), + witness, + max_level, + lb_certified: true, + }); + } + for systematic in &systematic_generators { + let contribution = (level + 1).saturating_sub(dimension - systematic.rank); + if contribution == 0 { + // This information set adds zero to the current lower bound, so omitting its + // candidates cannot weaken the certificate. It could only improve the optional + // upper bound, which the full-rank first information set still updates. + continue; + } + for_each_combination(dimension, level, |combination| { + let candidate = combination_codeword(&systematic.rows, combination); + if has_logical_effect(&candidate, l.matrix()) + && row_weight(&candidate) < row_weight(&best) + { + best = candidate; + } + }); + } + lower_bound = lower_bound_after_level(level, dimension, &systematic_generators, even_code); + if lower_bound >= row_weight(&best) { + let witness = verify_result_witness(h, l, &best); + return Some(BoundedEnumerationDistance::CertifiedByBounds { + distance: row_weight(&best), + witness, + lower_bound, + level, + lb_certified: true, + }); + } + } + unreachable!("enumerating every generator row must exhaust a finite binary code") +} + +/// Computes binary `(H, L)` distance by bounded generator-row enumeration. +/// +/// `H e = 0` enforces undetectability and `L e != 0` enforces a nontrivial effect. `None` means +/// no nontrivial vector exists in `ker(H)`. Exceeding `max_level` returns a certified interval, +/// not an absent result. +/// +/// # Panics +/// +/// Panics if the matrices have different widths. +#[must_use] +pub fn bounded_enumeration_code_distance( + h: &ParityCheckMatrix, + l: &ParityCheckMatrix, + max_level: usize, +) -> Option { + matrix_distance(h, l, max_level) +} + +/// Computes pure-X bounded-enumeration distance for a CSS-form stabilizer code. +/// +/// # Errors +/// +/// Returns the same CSS-form and bounds errors as +/// [`crate::DistanceProblem::from_css_code_x_distance`]. +pub fn bounded_enumeration_x_distance( + code: &StabilizerCodeSpec, + max_level: usize, +) -> Result, DistanceProblemError> { + let problem = DistanceProblem::from_css_code_x_distance(code)?; + let (h, l) = problem.matrices(); + let h = parity_check_from_matrix(h); + let l = parity_check_from_matrix(l); + Ok(matrix_distance(&h, &l, max_level)) +} + +/// Computes pure-Z bounded-enumeration distance for a CSS-form stabilizer code. +/// +/// # Errors +/// +/// Returns the same CSS-form and bounds errors as +/// [`crate::DistanceProblem::from_css_code_z_distance`]. +pub fn bounded_enumeration_z_distance( + code: &StabilizerCodeSpec, + max_level: usize, +) -> Result, DistanceProblemError> { + let problem = DistanceProblem::from_css_code_z_distance(code)?; + let (h, l) = problem.matrices(); + let h = parity_check_from_matrix(h); + let l = parity_check_from_matrix(l); + Ok(matrix_distance(&h, &l, max_level)) +} + +/// Computes bounded-enumeration distance for any stabilizer code specification. +/// +/// Each qubit contributes X, Y, and Z binary columns, in that order. As in +/// [`crate::stabilizer_code_distance`], two selected columns on one qubit can always be replaced +/// by the third with the same `(H, L)` effect and lower weight, so the exact binary minimum equals +/// physical Pauli weight. +/// +/// # Errors +/// +/// Returns [`DistanceProblemError::QubitOutOfRange`] if an operator addresses a qubit outside the +/// declared code width. +pub fn bounded_enumeration_stabilizer_distance( + code: &StabilizerCodeSpec, + max_level: usize, +) -> Result, DistanceProblemError> { + let mechanisms = mechanisms_from_stabilizer_code(code)?; + let mut h = F2Matrix::zeros(code.stabilizers().len(), mechanisms.len()); + let mut l = F2Matrix::zeros( + code.logical_zs().len() + code.logical_xs().len(), + mechanisms.len(), + ); + for (column, mechanism) in mechanisms.iter().enumerate() { + for &detector in &mechanism.detectors { + h.set(detector as usize, column, 1); + } + for &output in &mechanism.dem_outputs { + l.set(output as usize, column, 1); + } + } + let h = parity_check_from_matrix(&h); + let l = parity_check_from_matrix(&l); + Ok(matrix_distance(&h, &l, max_level)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + BivariateBicycleCode, StabilizerCode, certified_distance, connected_cluster_code_distance, + stabilizer_code_distance, + }; + use rand::{RngExt, SeedableRng}; + use std::time::Instant; + + fn matrix_from_f2(matrix: &F2Matrix) -> ParityCheckMatrix { + parity_check_from_matrix(matrix) + } + + fn checks_for_generator(rows: Vec>) -> ParityCheckMatrix { + let generator = F2Matrix::from_rows(rows); + matrix_from_f2(&F2Matrix::from_rows(generator.kernel())) + } + + fn assert_certified_distance( + result: &BoundedEnumerationDistance, + expected: usize, + ) -> (&[bool], usize) { + match result { + BoundedEnumerationDistance::CertifiedByBounds { + distance, + witness, + lower_bound, + level, + lb_certified, + } => { + assert_eq!(*distance, expected); + assert!(*lower_bound >= *distance); + assert!(*lb_certified); + (witness, *level) + } + BoundedEnumerationDistance::LevelLimitReached { .. } => { + panic!("expected exact distance, got a bounded interval") + } + } + } + + fn steane_pair() -> (ParityCheckMatrix, ParityCheckMatrix) { + ( + ParityCheckMatrix::from_dense(vec![ + vec![1, 0, 1, 0, 1, 0, 1], + vec![0, 1, 1, 0, 0, 1, 1], + vec![0, 0, 0, 1, 1, 1, 1], + ]) + .unwrap(), + ParityCheckMatrix::from_dense(vec![vec![1; 7]]).unwrap(), + ) + } + + #[test] + fn steane_agrees_with_connected_cluster_and_sat() { + let (h, l) = steane_pair(); + let bounded = bounded_enumeration_code_distance(&h, &l, 4).unwrap(); + let connected = connected_cluster_code_distance(&h, &l, 3).unwrap(); + let problem = DistanceProblem::from_css_checks(&h, &l).unwrap(); + let sat = certified_distance(&problem, 3).unwrap().unwrap(); + let (witness, _) = assert_certified_distance(&bounded, 3); + + assert_eq!(connected.distance, 3); + assert_eq!(sat.distance, 3); + assert_eq!(problem.verify_witness(witness), Ok(3)); + } + + #[test] + fn five_qubit_three_mechanism_reduction_agrees_with_other_searches() { + let spec = StabilizerCodeSpec::from_stabilizer_code(&StabilizerCode::five_qubit()).unwrap(); + let bounded = bounded_enumeration_stabilizer_distance(&spec, 5) + .unwrap() + .unwrap(); + let connected = stabilizer_code_distance(&spec, 3).unwrap().unwrap(); + let symplectic = DistanceProblem::from_stabilizer_spec(&spec).unwrap(); + let sat = certified_distance(&symplectic, 3).unwrap().unwrap(); + + let (witness, _) = assert_certified_distance(&bounded, 3); + assert_eq!(connected.distance, 3); + assert_eq!(sat.distance, 3); + assert_eq!(witness.iter().filter(|&&selected| selected).count(), 3); + assert_eq!(witness.len(), 3 * spec.num_qubits()); + } + + #[test] + fn yy_code_uses_a_single_y_mechanism() { + use pecos_core::{X, Y, Ys, Z}; + + let spec = StabilizerCodeSpec::builder(2) + .check(Ys([0, 1])) + .logical_z(Y(0)) + .logical_x(X(0) & Z(1)) + .build_verified() + .unwrap(); + let bounded = bounded_enumeration_stabilizer_distance(&spec, 2) + .unwrap() + .unwrap(); + let (witness, _) = assert_certified_distance(&bounded, 1); + let selected = witness.iter().position(|&bit| bit).unwrap(); + + assert_eq!(selected % 3, 1, "the minimum mechanism must be Y"); + } + + #[test] + fn bb_72_agrees_with_connected_cluster_and_sat() { + let code = + BivariateBicycleCode::new(6, 6, &[(3, 0), (0, 1), (0, 2)], &[(0, 3), (1, 0), (2, 0)]) + .unwrap(); + let bounded = bounded_enumeration_code_distance(code.hx(), code.logical_x(), 6).unwrap(); + let connected = connected_cluster_code_distance(code.hx(), code.logical_x(), 6).unwrap(); + let problem = DistanceProblem::from_css_checks(code.hx(), code.logical_x()).unwrap(); + let sat = certified_distance(&problem, 6).unwrap().unwrap(); + let (witness, _) = assert_certified_distance(&bounded, 6); + + assert_eq!(connected.distance, 6); + assert_eq!(sat.distance, 6); + assert_eq!(problem.verify_witness(witness), Ok(6)); + } + + #[test] + fn level_budget_returns_an_honest_interval() { + let (h, l) = steane_pair(); + let result = bounded_enumeration_code_distance(&h, &l, 0).unwrap(); + let problem = DistanceProblem::from_css_checks(&h, &l).unwrap(); + + match result { + BoundedEnumerationDistance::LevelLimitReached { + lower_bound, + upper_bound, + witness, + max_level, + lb_certified, + } => { + assert_eq!(lower_bound, 1); + assert_eq!(upper_bound, 3); + assert_eq!(max_level, 0); + assert!(lb_certified); + assert_eq!(problem.verify_witness(&witness), Ok(upper_bound)); + } + BoundedEnumerationDistance::CertifiedByBounds { .. } => { + panic!("Steane must not certify before row enumeration") + } + } + } + + #[test] + fn lower_bound_off_by_one_changes_the_hand_analyzed_termination_level() { + // This [6,2,4] code has three disjoint full information sets. After enumerating level one, + // the required (d+1) formula gives LB=6 and terminates at UB=4. Replacing d+1 by d gives + // only LB=3 and therefore changes the asserted termination level. + let h = checks_for_generator(vec![vec![1, 1, 1, 1, 0, 0], vec![0, 0, 1, 1, 1, 1]]); + let l = ParityCheckMatrix::from_dense(vec![vec![1, 0, 0, 0, 0, 0]]).unwrap(); + let result = bounded_enumeration_code_distance(&h, &l, 2).unwrap(); + let (witness, level) = assert_certified_distance(&result, 4); + + assert_eq!(level, 1); + assert_eq!( + DistanceProblem::from_css_checks(&h, &l) + .unwrap() + .verify_witness(witness), + Ok(4) + ); + } + + #[test] + fn even_weight_rounding_tightens_the_initial_certificate() { + // The code generated by 1010 and 1001 is even. Its peeled ranks are [2,1], so the raw + // level-zero bound is one; evenness raises the honest bound to the exact value two. + let h = checks_for_generator(vec![vec![1, 0, 1, 0], vec![1, 0, 0, 1]]); + let l = ParityCheckMatrix::from_dense(vec![vec![1, 0, 0, 0]]).unwrap(); + let result = bounded_enumeration_code_distance(&h, &l, 0).unwrap(); + let (_, level) = assert_certified_distance(&result, 2); + + assert_eq!(result.lower_bound(), 2); + assert_eq!(level, 0); + } + + fn exhaustive_distance(problem: &DistanceProblem) -> Option { + (0..1usize << problem.num_vars()) + .filter_map(|mask| { + let witness: Vec<_> = (0..problem.num_vars()) + .map(|bit| mask & (1 << bit) != 0) + .collect(); + problem.verify_witness(&witness).ok() + }) + .min() + } + + #[test] + fn seeded_random_pairs_agree_with_exhaustive_minimum() { + let mut rng = SmallRng::seed_from_u64(0xB0A0_DED0_5EED_0026); + for _case in 0..96 { + let width = rng.random_range(1..=9); + let h = ParityCheckMatrix::from_dense( + (0..rng.random_range(1..=width)) + .map(|_| (0..width).map(|_| u8::from(rng.random_bool(0.5))).collect()) + .collect(), + ) + .unwrap(); + let l = ParityCheckMatrix::from_dense( + (0..rng.random_range(1..=3)) + .map(|_| (0..width).map(|_| u8::from(rng.random_bool(0.5))).collect()) + .collect(), + ) + .unwrap(); + let problem = DistanceProblem::from_css_checks(&h, &l).unwrap(); + let exhaustive = exhaustive_distance(&problem); + let bounded = bounded_enumeration_code_distance(&h, &l, width); + + assert_eq!( + bounded + .as_ref() + .map(BoundedEnumerationDistance::upper_bound), + exhaustive + ); + if let Some(result) = bounded { + let (witness, _) = assert_certified_distance(&result, exhaustive.unwrap()); + assert_eq!(problem.verify_witness(witness), Ok(exhaustive.unwrap())); + } + } + } + + fn independent_dense_rows(rng: &mut SmallRng, row_count: usize, width: usize) -> Vec> { + let mut rows = Vec::with_capacity(row_count); + while rows.len() < row_count { + let candidate: Vec<_> = (0..width).map(|_| u8::from(rng.random_bool(0.5))).collect(); + let mut extended = rows.clone(); + extended.push(candidate.clone()); + if F2Matrix::from_rows(extended).row_reduce().1.len() > rows.len() { + rows.push(candidate); + } + } + rows + } + + fn dense_css_pair(seed: u64) -> (ParityCheckMatrix, ParityCheckMatrix) { + const NUM_QUBITS: usize = 40; + const NUM_LOGICALS: usize = 8; + const X_CHECKS: usize = 8; + const Z_CHECKS: usize = NUM_QUBITS - NUM_LOGICALS - X_CHECKS; + + let mut rng = SmallRng::seed_from_u64(seed); + let hz_rows = independent_dense_rows(&mut rng, Z_CHECKS, NUM_QUBITS); + let hz = F2Matrix::from_rows(hz_rows.clone()); + let hz_kernel = hz.kernel(); + assert_eq!(hz_kernel.len(), X_CHECKS + NUM_LOGICALS); + + // Choosing Hx from ker(Hz) makes Hx Hz^T=0 by construction. Taking only eight of the + // sixteen kernel rows leaves eight quantum logical qubits in this seeded [[40,8]] CSS + // code; the complement of row(Hz) inside ker(Hx) supplies logical-Z detector rows. + let hx = F2Matrix::from_rows(hz_kernel[..X_CHECKS].to_vec()); + assert_eq!(hx.mul(&hz.transpose()), F2Matrix::zeros(X_CHECKS, Z_CHECKS)); + let mut span = hz_rows; + let mut span_rank = Z_CHECKS; + let mut logical_z = Vec::with_capacity(NUM_LOGICALS); + for candidate in hx.kernel() { + let mut extended = span.clone(); + extended.push(candidate.clone()); + let rank = F2Matrix::from_rows(extended).row_reduce().1.len(); + if rank > span_rank { + span.push(candidate.clone()); + logical_z.push(candidate); + span_rank = rank; + } + } + assert_eq!(logical_z.len(), NUM_LOGICALS); + ( + ParityCheckMatrix::from_dense(hz.rows()).unwrap(), + ParityCheckMatrix::from_dense(logical_z).unwrap(), + ) + } + + fn time_three_methods(label: &str, h: &ParityCheckMatrix, l: &ParityCheckMatrix) { + let started = Instant::now(); + let bounded = bounded_enumeration_code_distance(h, l, h.num_qubits()).unwrap(); + let bounded_elapsed = started.elapsed(); + let distance = bounded.upper_bound(); + assert!(bounded.is_certified()); + println!("{label}: distance={distance}, BZ-style={bounded_elapsed:?}"); + + let started = Instant::now(); + let connected = connected_cluster_code_distance(h, l, distance).unwrap(); + let connected_elapsed = started.elapsed(); + assert_eq!(connected.distance, distance); + println!("{label}: CC={connected_elapsed:?}"); + + let problem = DistanceProblem::from_css_checks(h, l).unwrap(); + let started = Instant::now(); + let sat = certified_distance(&problem, distance).unwrap().unwrap(); + let sat_elapsed = started.elapsed(); + assert_eq!(sat.distance, distance); + println!("{label}: SAT={sat_elapsed:?}"); + } + + #[test] + #[ignore = "CPU timing probe for bounded-enumeration code distance"] + fn three_method_timing_probe() { + let dense = dense_css_pair(0xDE05_EC55_0026_0004); + time_three_methods("dense seeded CSS [[40,8]] X side", &dense.0, &dense.1); + + let steane = steane_pair(); + time_three_methods("Steane sparse X side", &steane.0, &steane.1); + + let bb = + BivariateBicycleCode::new(6, 6, &[(3, 0), (0, 1), (0, 2)], &[(0, 3), (1, 0), (2, 0)]) + .unwrap(); + time_three_methods("BB [[72,12,6]] sparse X side", bb.hx(), bb.logical_x()); + } +} diff --git a/crates/pecos-qec/src/code_distance.rs b/crates/pecos-qec/src/code_distance.rs index 89d3fc93a..1a6f6073b 100644 --- a/crates/pecos-qec/src/code_distance.rs +++ b/crates/pecos-qec/src/code_distance.rs @@ -108,7 +108,7 @@ fn single_qubit_pauli(pauli: Pauli, qubit: usize) -> PauliString { PauliString::with_phase_and_paulis(QuarterPhase::PlusOne, vec![(pauli, QubitId::new(qubit))]) } -fn mechanisms_from_stabilizer_code( +pub(crate) fn mechanisms_from_stabilizer_code( code: &StabilizerCodeSpec, ) -> Result, DistanceProblemError> { // Reuse the symplectic problem constructor for its established out-of-range validation. diff --git a/crates/pecos-qec/src/distance_problem.rs b/crates/pecos-qec/src/distance_problem.rs index 7efadce17..40e799ab3 100644 --- a/crates/pecos-qec/src/distance_problem.rs +++ b/crates/pecos-qec/src/distance_problem.rs @@ -991,9 +991,9 @@ fn solve_with_batsat_inner(encoding: &Encoding, num_primary_vars: usize) -> Solv mod tests { use super::*; use crate::{ - DemOutput, DistanceSearchConfig, FaultMechanism, StabilizerCode, calculate_distance, - connected_cluster_code_distance, connected_cluster_fault_distance, - exhaustive_fault_distance, + DemOutput, DistanceSearchConfig, FaultMechanism, StabilizerCode, + bounded_enumeration_code_distance, calculate_distance, connected_cluster_code_distance, + connected_cluster_fault_distance, exhaustive_fault_distance, }; use pecos_core::pauli::{X, Xs, Ys, Z, Zs}; use pecos_quantum::SymplecticMatrix; @@ -1160,6 +1160,7 @@ mod tests { let problem = DistanceProblem::from_css_checks(&h, &l).unwrap(); let exhaustive = exhaustive_dimacs_minimum(&problem); let connected = connected_cluster_code_distance(&h, &l, num_qubits); + let bounded = bounded_enumeration_code_distance(&h, &l, num_qubits); assert_eq!( connected.as_ref().map(|result| result.distance), @@ -1168,6 +1169,20 @@ mod tests { h.rows(), l.rows() ); + assert_eq!( + bounded.as_ref().map(super::super::bounded_enumeration_distance::BoundedEnumerationDistance::upper_bound), + exhaustive, + "bounded-enumeration mismatch in seeded case {case}: H={:?}, L={:?}", + h.rows(), + l.rows() + ); + if let Some(result) = bounded { + assert!(result.is_certified()); + assert_eq!( + problem.verify_witness(result.witness()), + Ok(exhaustive.unwrap()) + ); + } assert_eq!(connected.is_some(), exhaustive.is_some()); } } diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index 502e8c5b6..6415d9d85 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -64,6 +64,7 @@ //! ``` pub mod bivariate_bicycle; +pub mod bounded_enumeration_distance; pub mod code_distance; pub mod coloration; pub mod dem_stab; @@ -82,6 +83,11 @@ pub mod surface; pub use bivariate_bicycle::{ BbMemoryBasis, BbMonomial, BivariateBicycleCode, BivariateBicycleError, bb_memory_circuit, }; +pub use bounded_enumeration_distance::{ + BoundedEnumerationDistance, bounded_enumeration_code_distance, + bounded_enumeration_stabilizer_distance, bounded_enumeration_x_distance, + bounded_enumeration_z_distance, +}; pub use coloration::{ColorationMemoryError, coloration_memory_circuit}; pub use dem_stab::{DemStabError, DemStabShotBatch, DemStabSim, DemStabSimBuilder}; pub use mem_stab::{MemStabError, MemStabSim, MemStabSimBuilder}; diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 7805c9bfc..bf2f596f5 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -89,7 +89,12 @@ use pecos_qec::{ coloration_memory_circuit as rust_coloration_memory_circuit, }; use pecos_qec::{ + BoundedEnumerationDistance as RustBoundedEnumerationDistance, CertifiedDistance as RustCertifiedDistance, DistanceProblem as RustDistanceProblem, + bounded_enumeration_code_distance as rust_bounded_enumeration_code_distance, + bounded_enumeration_stabilizer_distance as rust_bounded_enumeration_stabilizer_distance, + bounded_enumeration_x_distance as rust_bounded_enumeration_x_distance, + bounded_enumeration_z_distance as rust_bounded_enumeration_z_distance, certified_distance as rust_certified_distance, connected_cluster_code_distance as rust_connected_cluster_code_distance, stabilizer_code_distance as rust_stabilizer_code_distance, x_distance as rust_x_distance, @@ -7588,6 +7593,113 @@ impl PyCertifiedDistance { } } +/// Native lower and upper bounds from bounded generator-row enumeration. +#[pyclass( + name = "BoundedEnumerationDistance", + module = "pecos_rslib.qec", + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyBoundedEnumerationDistance { + lower_bound: usize, + upper_bound: usize, + witness: Vec, + certified: bool, + level: Option, + max_level: Option, + lb_certified: bool, +} + +impl From for PyBoundedEnumerationDistance { + fn from(result: RustBoundedEnumerationDistance) -> Self { + match result { + RustBoundedEnumerationDistance::CertifiedByBounds { + distance, + witness, + lower_bound, + level, + lb_certified, + } => Self { + lower_bound, + upper_bound: distance, + witness, + certified: true, + level: Some(level), + max_level: None, + lb_certified, + }, + RustBoundedEnumerationDistance::LevelLimitReached { + lower_bound, + upper_bound, + witness, + max_level, + lb_certified, + } => Self { + lower_bound, + upper_bound, + witness, + certified: false, + level: None, + max_level: Some(max_level), + lb_certified, + }, + } + } +} + +#[pymethods] +impl PyBoundedEnumerationDistance { + #[getter] + fn lower_bound(&self) -> usize { + self.lower_bound + } + + #[getter] + fn upper_bound(&self) -> usize { + self.upper_bound + } + + #[getter] + fn distance(&self) -> Option { + self.certified.then_some(self.upper_bound) + } + + #[getter] + fn witness(&self) -> Vec { + self.witness.clone() + } + + #[getter] + fn certified(&self) -> bool { + self.certified + } + + #[getter] + fn level(&self) -> Option { + self.level + } + + #[getter] + fn max_level(&self) -> Option { + self.max_level + } + + #[getter] + fn lb_certified(&self) -> bool { + self.lb_certified + } + + fn __repr__(&self) -> String { + format!( + "BoundedEnumerationDistance(lower_bound={}, upper_bound={}, certified={}, witness_weight={})", + self.lower_bound, + self.upper_bound, + self.certified, + self.witness.iter().filter(|&&selected| selected).count() + ) + } +} + fn certify_python_problem( problem: &RustDistanceProblem, max_weight: usize, @@ -7698,6 +7810,50 @@ fn certified_distance( certify_python_problem(&problem.inner, max_weight) } +/// Computes binary ``(H, L)`` distance using native bounded row enumeration. +#[pyfunction] +fn bounded_enumeration_code_distance( + h: &PyParityCheckMatrix, + l: &PyParityCheckMatrix, + max_level: usize, +) -> Option { + rust_bounded_enumeration_code_distance(&h.inner, &l.inner, max_level) + .map(PyBoundedEnumerationDistance::from) +} + +/// Computes pure-X bounded-enumeration distance for a CSS stabilizer code. +#[pyfunction] +fn bounded_enumeration_x_distance( + code: &PyStabilizerCodeSpec, + max_level: usize, +) -> PyResult> { + rust_bounded_enumeration_x_distance(&code.inner, max_level) + .map(|result| result.map(PyBoundedEnumerationDistance::from)) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) +} + +/// Computes pure-Z bounded-enumeration distance for a CSS stabilizer code. +#[pyfunction] +fn bounded_enumeration_z_distance( + code: &PyStabilizerCodeSpec, + max_level: usize, +) -> PyResult> { + rust_bounded_enumeration_z_distance(&code.inner, max_level) + .map(|result| result.map(PyBoundedEnumerationDistance::from)) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) +} + +/// Computes bounded-enumeration distance for any stabilizer code. +#[pyfunction] +fn bounded_enumeration_stabilizer_distance( + code: &PyStabilizerCodeSpec, + max_level: usize, +) -> PyResult> { + rust_bounded_enumeration_stabilizer_distance(&code.inner, max_level) + .map(|result| result.map(PyBoundedEnumerationDistance::from)) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) +} + /// Computes connected-cluster distance for a binary check/logical matrix pair. #[pyfunction] fn connected_cluster_code_distance( @@ -7906,6 +8062,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::()?; @@ -7915,6 +8072,13 @@ pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { qec.add_function(wrap_pyfunction!(verify_dem_equivalence, &qec)?)?; qec.add_function(wrap_pyfunction!(assert_dems_equivalent, &qec)?)?; qec.add_function(wrap_pyfunction!(connected_cluster_code_distance, &qec)?)?; + qec.add_function(wrap_pyfunction!(bounded_enumeration_code_distance, &qec)?)?; + qec.add_function(wrap_pyfunction!(bounded_enumeration_x_distance, &qec)?)?; + qec.add_function(wrap_pyfunction!(bounded_enumeration_z_distance, &qec)?)?; + qec.add_function(wrap_pyfunction!( + bounded_enumeration_stabilizer_distance, + &qec + )?)?; qec.add_function(wrap_pyfunction!(x_distance, &qec)?)?; qec.add_function(wrap_pyfunction!(z_distance, &qec)?)?; qec.add_function(wrap_pyfunction!(stabilizer_code_distance, &qec)?)?; diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index 08277d6c9..83b7fb46f 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -30,6 +30,7 @@ PAULI_Y, PAULI_Z, BivariateBicycleCode, + BoundedEnumerationDistance, CertifiedDistance, CircuitDistanceResult, CircuitFaultAnalyzer, @@ -52,6 +53,10 @@ PauliFrameLookup, assert_dems_equivalent, bb_memory_circuit, + bounded_enumeration_code_distance, + bounded_enumeration_stabilizer_distance, + bounded_enumeration_x_distance, + bounded_enumeration_z_distance, certified_distance, coloration_memory_circuit, compare_dems_exact, @@ -152,6 +157,7 @@ "DemSamplerBuilder", "DetectorErrorModel", "BivariateBicycleCode", + "BoundedEnumerationDistance", "Detector", "DistanceProblem", "EquivalenceResult", @@ -168,6 +174,10 @@ "Observable", "assert_dems_equivalent", "bb_memory_circuit", + "bounded_enumeration_code_distance", + "bounded_enumeration_stabilizer_distance", + "bounded_enumeration_x_distance", + "bounded_enumeration_z_distance", "certified_distance", "coloration_memory_circuit", "connected_cluster_code_distance", diff --git a/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py b/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py index 69266e425..c8d7eeb0d 100644 --- a/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py +++ b/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py @@ -18,6 +18,10 @@ DetectorErrorModel, DistanceProblem, HookError, + bounded_enumeration_code_distance, + bounded_enumeration_stabilizer_distance, + bounded_enumeration_x_distance, + bounded_enumeration_z_distance, connected_cluster_code_distance, stabilizer_code_distance, x_distance, @@ -192,6 +196,51 @@ def test_steane_certification_from_checks_and_code_spec() -> None: from_checks.verify_witness(corrupted) +def test_bounded_enumeration_bindings_certify_and_return_intervals() -> None: + h = ParityCheckMatrix( + [ + [1, 0, 1, 0, 1, 0, 1], + [0, 1, 1, 0, 0, 1, 1], + [0, 0, 0, 1, 1, 1, 1], + ], + ) + l = ParityCheckMatrix([[1, 1, 1, 1, 1, 1, 1]]) + problem = DistanceProblem.from_css_checks(h, l) + + exact = bounded_enumeration_code_distance(h, l, 4) + assert exact is not None + assert exact.certified + assert exact.distance == exact.upper_bound == 3 + assert exact.lower_bound >= exact.distance + assert exact.lb_certified + assert exact.level is not None + assert exact.max_level is None + assert problem.verify_witness(exact.witness) == 3 + + interval = bounded_enumeration_code_distance(h, l, 0) + assert interval is not None + assert not interval.certified + assert interval.distance is None + assert (interval.lower_bound, interval.upper_bound) == (1, 3) + assert interval.max_level == 0 + assert problem.verify_witness(interval.witness) == interval.upper_bound + + spec = StabilizerCodeSpec.from_stabilizer_code(StabilizerCode.steane()) + assert bounded_enumeration_x_distance(spec, 4).distance == 3 + assert bounded_enumeration_z_distance(spec, 4).distance == 3 + + +def test_five_qubit_bounded_enumeration_binding_uses_three_mechanisms_per_qubit() -> None: + spec = StabilizerCodeSpec.from_stabilizer_code(StabilizerCode.five_qubit()) + result = bounded_enumeration_stabilizer_distance(spec, 5) + + assert result is not None + assert result.certified + assert result.distance == 3 + assert len(result.witness) == 3 * spec.num_qubits + assert sum(result.witness) == 3 + + def test_non_css_connected_cluster_binding_returns_logical_pauli() -> None: spec = StabilizerCodeSpec.from_stabilizer_code(StabilizerCode.five_qubit()) result = stabilizer_code_distance(spec, 3) From 78e5e4a289685a48dbf055c844d976f6d58787e8 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 7 Aug 2026 12:55:18 -0600 Subject: [PATCH 33/41] Add a wgpu backend for bounded-enumeration levels behind a packed CPU seam --- crates/pecos-gpu-sims/Cargo.toml | 2 +- .../examples/benchmark_bounded_enumeration.rs | 97 ++++ .../src/bounded_enumeration_shader.wgsl | 201 +++++++ .../src/gpu_bounded_enumeration.rs | 523 ++++++++++++++++++ crates/pecos-gpu-sims/src/lib.rs | 6 + .../tests/bounded_enumeration_audit.rs | 105 ++++ .../src/bounded_enumeration_distance.rs | 404 +++++++++++++- crates/pecos-qec/src/lib.rs | 10 +- 8 files changed, 1314 insertions(+), 34 deletions(-) create mode 100644 crates/pecos-gpu-sims/examples/benchmark_bounded_enumeration.rs create mode 100644 crates/pecos-gpu-sims/src/bounded_enumeration_shader.wgsl create mode 100644 crates/pecos-gpu-sims/src/gpu_bounded_enumeration.rs create mode 100644 crates/pecos-gpu-sims/tests/bounded_enumeration_audit.rs diff --git a/crates/pecos-gpu-sims/Cargo.toml b/crates/pecos-gpu-sims/Cargo.toml index b1e1905b1..beffcf7c7 100644 --- a/crates/pecos-gpu-sims/Cargo.toml +++ b/crates/pecos-gpu-sims/Cargo.toml @@ -27,6 +27,7 @@ num-complex.workspace = true # PECOS integration pecos-core.workspace = true +pecos-qec.workspace = true pecos-simulators.workspace = true pecos-random.workspace = true @@ -34,7 +35,6 @@ pecos-random.workspace = true approx.workspace = true paste.workspace = true env_logger.workspace = true -pecos-qec.workspace = true pecos-quantum.workspace = true [lints] diff --git a/crates/pecos-gpu-sims/examples/benchmark_bounded_enumeration.rs b/crates/pecos-gpu-sims/examples/benchmark_bounded_enumeration.rs new file mode 100644 index 000000000..ef2ded988 --- /dev/null +++ b/crates/pecos-gpu-sims/examples/benchmark_bounded_enumeration.rs @@ -0,0 +1,97 @@ +// 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 + +use pecos_gpu_sims::GpuBoundedEnumerationBackend; +use pecos_qec::{ + ParityCheckMatrix, bounded_enumeration_code_distance, + bounded_enumeration_code_distance_with_backend, +}; +use pecos_quantum::F2Matrix; +use rand::rngs::SmallRng; +use rand::{RngExt, SeedableRng}; +use std::time::Instant; + +fn independent_dense_rows(rng: &mut SmallRng, row_count: usize, width: usize) -> Vec> { + let mut rows = Vec::with_capacity(row_count); + while rows.len() < row_count { + let candidate: Vec<_> = (0..width).map(|_| u8::from(rng.random_bool(0.5))).collect(); + let mut extended = rows.clone(); + extended.push(candidate.clone()); + if F2Matrix::from_rows(extended).row_reduce().1.len() > rows.len() { + rows.push(candidate); + } + } + rows +} + +fn dense_css_pair( + num_qubits: usize, + num_logicals: usize, + seed: u64, +) -> (ParityCheckMatrix, ParityCheckMatrix) { + let x_checks = num_logicals; + let z_checks = num_qubits - num_logicals - x_checks; + let mut rng = SmallRng::seed_from_u64(seed); + let hz_rows = independent_dense_rows(&mut rng, z_checks, num_qubits); + let hz = F2Matrix::from_rows(hz_rows.clone()); + let hz_kernel = hz.kernel(); + assert_eq!(hz_kernel.len(), x_checks + num_logicals); + + let hx = F2Matrix::from_rows(hz_kernel[..x_checks].to_vec()); + let mut span = hz_rows; + let mut span_rank = z_checks; + let mut logical_z = Vec::with_capacity(num_logicals); + for candidate in hx.kernel() { + let mut extended = span.clone(); + extended.push(candidate.clone()); + let rank = F2Matrix::from_rows(extended).row_reduce().1.len(); + if rank > span_rank { + span.push(candidate.clone()); + logical_z.push(candidate); + span_rank = rank; + } + } + assert_eq!(logical_z.len(), num_logicals); + ( + ParityCheckMatrix::from_dense(hz.rows()).unwrap(), + ParityCheckMatrix::from_dense(logical_z).unwrap(), + ) +} + +fn main() { + let arguments: Vec<_> = std::env::args().collect(); + assert_eq!( + arguments.len(), + 4, + "usage: " + ); + let num_qubits: usize = arguments[1].parse().unwrap(); + let num_logicals: usize = arguments[2].parse().unwrap(); + let mode = &arguments[3]; + let seed = 0xDE05_EC55_0026_0004; + let (h, l) = dense_css_pair(num_qubits, num_logicals, seed); + + let started = Instant::now(); + let result = match mode.as_str() { + "cpu" => bounded_enumeration_code_distance(&h, &l, num_qubits).unwrap(), + "gpu" => { + let mut backend = GpuBoundedEnumerationBackend::try_new().unwrap(); + println!("adapter={:?}", backend.adapter_info()); + bounded_enumeration_code_distance_with_backend(&h, &l, num_qubits, &mut backend) + .unwrap() + .unwrap() + } + _ => panic!("mode must be cpu or gpu"), + }; + println!( + "dense [[{num_qubits},{num_logicals}]] {mode}: upper_bound={}, lower_bound={}, certified={}, elapsed={:?}", + result.upper_bound(), + result.lower_bound(), + result.is_certified(), + started.elapsed(), + ); +} diff --git a/crates/pecos-gpu-sims/src/bounded_enumeration_shader.wgsl b/crates/pecos-gpu-sims/src/bounded_enumeration_shader.wgsl new file mode 100644 index 000000000..4d405a949 --- /dev/null +++ b/crates/pecos-gpu-sims/src/bounded_enumeration_shader.wgsl @@ -0,0 +1,201 @@ +// 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 + +struct Params { + dimension: u32, + level: u32, + row_stride: u32, + logical_count: u32, + table_width: u32, + rank_offset_lo: u32, + rank_offset_hi: u32, + rank_count: u32, + ranks_per_thread: u32, + invocation_count: u32, + _padding_0: u32, + _padding_1: u32, +} + +struct U64Parts { + lo: u32, + hi: u32, +} + +struct Minimum { + weight: atomic, +} + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var generator_rows: array; +@group(0) @binding(2) var logical_rows: array; +@group(0) @binding(3) var binomial_table: array; +@group(0) @binding(4) var minimum: Minimum; + +fn less_u64(left: U64Parts, right: U64Parts) -> bool { + return left.hi < right.hi || (left.hi == right.hi && left.lo < right.lo); +} + +fn subtract_u64(left: U64Parts, right: U64Parts) -> U64Parts { + let borrow = select(0u, 1u, left.lo < right.lo); + return U64Parts(left.lo - right.lo, left.hi - right.hi - borrow); +} + +fn add_u32(value: U64Parts, increment: u32) -> U64Parts { + let lo = value.lo + increment; + let carry = select(0u, 1u, lo < value.lo); + return U64Parts(lo, value.hi + carry); +} + +fn binomial(n: u32, k: u32) -> U64Parts { + return binomial_table[n * params.table_width + k]; +} + +fn unrank_lexicographic(rank_value: U64Parts, combination: ptr>) { + var rank = rank_value; + var position = 0u; + var candidate = 0u; + loop { + if position >= params.level { + break; + } + let remaining_k = params.level - position - 1u; + loop { + let remaining_n = params.dimension - candidate - 1u; + let block_size = binomial(remaining_n, remaining_k); + if less_u64(rank, block_size) { + (*combination)[position] = candidate; + candidate += 1u; + break; + } + rank = subtract_u64(rank, block_size); + candidate += 1u; + } + position += 1u; + } +} + +fn advance_lexicographic(combination: ptr>) -> bool { + var position = i32(params.level) - 1; + loop { + if position < 0 { + return false; + } + let unsigned_position = u32(position); + let limit = params.dimension - params.level + unsigned_position; + if (*combination)[unsigned_position] < limit { + (*combination)[unsigned_position] += 1u; + var following = unsigned_position + 1u; + loop { + if following >= params.level { + break; + } + (*combination)[following] = (*combination)[following - 1u] + 1u; + following += 1u; + } + return true; + } + position -= 1; + } + return false; +} + +fn examine_combination(combination: ptr>) { + var codeword: array; + var word = 0u; + loop { + if word >= params.row_stride { + break; + } + codeword[word] = 0u; + word += 1u; + } + + var selected = 0u; + loop { + if selected >= params.level { + break; + } + let row = (*combination)[selected]; + word = 0u; + loop { + if word >= params.row_stride { + break; + } + codeword[word] ^= generator_rows[row * params.row_stride + word]; + word += 1u; + } + selected += 1u; + } + + var triggers_logical = false; + var logical = 0u; + loop { + if logical >= params.logical_count { + break; + } + var parity = 0u; + word = 0u; + loop { + if word >= params.row_stride { + break; + } + parity ^= countOneBits( + codeword[word] & logical_rows[logical * params.row_stride + word] + ) & 1u; + word += 1u; + } + if parity != 0u { + triggers_logical = true; + break; + } + logical += 1u; + } + + if triggers_logical { + var weight = 0u; + word = 0u; + loop { + if word >= params.row_stride { + break; + } + weight += countOneBits(codeword[word]); + word += 1u; + } + atomicMin(&minimum.weight, weight); + } +} + +@compute @workgroup_size(64) +fn enumerate_level(@builtin(global_invocation_id) global_id: vec3) { + let invocation = global_id.x; + if invocation >= params.invocation_count { + return; + } + let local_start = invocation * params.ranks_per_thread; + if local_start >= params.rank_count { + return; + } + let local_end = min(local_start + params.ranks_per_thread, params.rank_count); + let first_rank = add_u32( + U64Parts(params.rank_offset_lo, params.rank_offset_hi), + local_start, + ); + var combination: array; + unrank_lexicographic(first_rank, &combination); + + var local_rank = local_start; + loop { + if local_rank >= local_end { + break; + } + examine_combination(&combination); + local_rank += 1u; + if local_rank < local_end && !advance_lexicographic(&combination) { + break; + } + } +} diff --git a/crates/pecos-gpu-sims/src/gpu_bounded_enumeration.rs b/crates/pecos-gpu-sims/src/gpu_bounded_enumeration.rs new file mode 100644 index 000000000..7400b62b7 --- /dev/null +++ b/crates/pecos-gpu-sims/src/gpu_bounded_enumeration.rs @@ -0,0 +1,523 @@ +// 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 + +//! GPU level enumeration for exact bounded-enumeration code distance. +//! +//! The information-set enumeration follows +//! [arXiv:2408.10743](https://arxiv.org/abs/2408.10743). The GPU returns only a level's minimum +//! weight. When that improves the upper bound, `pecos-qec` deterministically re-scans the level on +//! the CPU to reconstruct the first witness in native enumeration order. + +use crate::gpu_probe::{GpuAdapterInfo, GpuStartupError, gpu_context}; +use bytemuck::{Pod, Zeroable}; +use pecos_qec::{ + BoundedEnumerationBackendError, BoundedEnumerationDistance, DistanceProblemError, + LevelEnumerationBackend, LevelEnumerationInput, LevelEnumerationMinimum, ParityCheckMatrix, + StabilizerCodeSpec, bounded_enumeration_code_distance_with_backend, + bounded_enumeration_stabilizer_distance_with_backend, + bounded_enumeration_x_distance_with_backend, bounded_enumeration_z_distance_with_backend, +}; +use wgpu::util::DeviceExt; + +const MAX_ROW_STRIDE_WORDS: usize = 8; +const MAX_DIMENSION: usize = 256; +const WORKGROUP_SIZE: u32 = 64; +const RANKS_PER_THREAD: u32 = 64; + +#[repr(C)] +#[derive(Clone, Copy, Debug, Pod, Zeroable)] +struct EnumerationParams { + dimension: u32, + level: u32, + row_stride: u32, + logical_count: u32, + table_width: u32, + rank_offset_lo: u32, + rank_offset_hi: u32, + rank_count: u32, + ranks_per_thread: u32, + invocation_count: u32, + _padding_0: u32, + _padding_1: u32, +} + +/// Failure from GPU-accelerated bounded enumeration. +#[derive(Debug)] +pub enum GpuBoundedEnumerationError { + /// No usable hardware adapter, or device creation failed. + Startup(GpuStartupError), + /// The QEC distance problem is invalid. + DistanceProblem(DistanceProblemError), + /// Packed codewords exceed the kernel's initial eight-word limit. + CodewordTooWide { columns: usize, maximum: usize }, + /// The generator dimension exceeds the kernel's combination-array limit. + DimensionTooLarge { dimension: usize, maximum: usize }, + /// The number of combinations cannot be represented by the kernel's 64-bit rank encoding. + CombinationCountOverflow { dimension: usize, level: usize }, + /// A backend input could not be represented by the WGSL `u32` interface. + IntegerConversion { field: &'static str, value: usize }, + /// A GPU result buffer could not be mapped. + BufferMap(String), + /// Waiting for GPU completion failed. + DevicePoll(String), +} + +impl std::fmt::Display for GpuBoundedEnumerationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Startup(error) => error.fmt(formatter), + Self::DistanceProblem(error) => error.fmt(formatter), + Self::CodewordTooWide { columns, maximum } => write!( + formatter, + "GPU bounded enumeration supports at most {maximum} columns ({MAX_ROW_STRIDE_WORDS} packed u32 words), got {columns}" + ), + Self::DimensionTooLarge { dimension, maximum } => write!( + formatter, + "GPU bounded enumeration supports generator dimension at most {maximum}, got {dimension}" + ), + Self::CombinationCountOverflow { dimension, level } => write!( + formatter, + "C({dimension}, {level}) exceeds the GPU backend's 64-bit combination-rank range" + ), + Self::IntegerConversion { field, value } => write!( + formatter, + "GPU bounded-enumeration {field} value {value} does not fit in u32" + ), + Self::BufferMap(error) => { + write!(formatter, "GPU result buffer mapping failed: {error}") + } + Self::DevicePoll(error) => { + write!(formatter, "waiting for GPU completion failed: {error}") + } + } + } +} + +impl std::error::Error for GpuBoundedEnumerationError {} + +impl From for GpuBoundedEnumerationError { + fn from(error: GpuStartupError) -> Self { + Self::Startup(error) + } +} + +/// Explicit wgpu backend for bounded-enumeration levels. +/// +/// Construction fails when the repository's shared GPU probe cannot select a hardware adapter; +/// callers receive that error and no CPU fallback is attempted. +pub struct GpuBoundedEnumerationBackend { + adapter_info: GpuAdapterInfo, + device: wgpu::Device, + queue: wgpu::Queue, + bind_group_layout: wgpu::BindGroupLayout, + pipeline: wgpu::ComputePipeline, +} + +impl GpuBoundedEnumerationBackend { + /// Detects a hardware adapter and creates the reusable level-enumeration pipeline. + /// + /// # Errors + /// + /// Returns the shared GPU probe's explicit startup error when no supported adapter exists. + pub fn try_new() -> Result { + let context = gpu_context()?; + let shader = context + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("PECOS bounded-enumeration shader"), + source: wgpu::ShaderSource::Wgsl( + include_str!("bounded_enumeration_shader.wgsl").into(), + ), + }); + let bind_group_layout = + context + .device + .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("PECOS bounded-enumeration bind-group layout"), + entries: &[ + Self::buffer_layout_entry(0, wgpu::BufferBindingType::Uniform), + Self::buffer_layout_entry( + 1, + wgpu::BufferBindingType::Storage { read_only: true }, + ), + Self::buffer_layout_entry( + 2, + wgpu::BufferBindingType::Storage { read_only: true }, + ), + Self::buffer_layout_entry( + 3, + wgpu::BufferBindingType::Storage { read_only: true }, + ), + Self::buffer_layout_entry( + 4, + wgpu::BufferBindingType::Storage { read_only: false }, + ), + ], + }); + let pipeline_layout = + context + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("PECOS bounded-enumeration pipeline layout"), + bind_group_layouts: &[Some(&bind_group_layout)], + ..Default::default() + }); + let pipeline = context + .device + .create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("PECOS bounded-enumeration pipeline"), + layout: Some(&pipeline_layout), + module: &shader, + entry_point: Some("enumerate_level"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + cache: None, + }); + Ok(Self { + adapter_info: context.info, + device: context.device, + queue: context.queue, + bind_group_layout, + pipeline, + }) + } + + /// Returns details of the hardware adapter selected by the shared GPU probe. + #[must_use] + pub fn adapter_info(&self) -> &GpuAdapterInfo { + &self.adapter_info + } + + fn buffer_layout_entry( + binding: u32, + ty: wgpu::BufferBindingType, + ) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + } + } + + fn checked_u32(field: &'static str, value: usize) -> Result { + u32::try_from(value) + .map_err(|_| GpuBoundedEnumerationError::IntegerConversion { field, value }) + } + + fn binomial(dimension: usize, level: usize) -> Option { + let choose = level.min(dimension - level); + let mut result = 1u128; + for index in 1..=choose { + result = result * (dimension - choose + index) as u128 / index as u128; + if result > u128::from(u64::MAX) { + return None; + } + } + u64::try_from(result).ok() + } + + fn binomial_clamped(dimension: usize, level: usize) -> u64 { + if level > dimension { + return 0; + } + let choose = level.min(dimension - level); + let mut result = 1u128; + for index in 1..=choose { + result = result * (dimension - choose + index) as u128 / index as u128; + if result > u128::from(u64::MAX) { + return u64::MAX; + } + } + u64::try_from(result).unwrap_or(u64::MAX) + } + + fn split_u64(value: u64) -> (u32, u32) { + let bytes = value.to_le_bytes(); + ( + u32::from_le_bytes(bytes[..4].try_into().expect("four-byte low rank half")), + u32::from_le_bytes(bytes[4..].try_into().expect("four-byte high rank half")), + ) + } + + fn binomial_table(dimension: usize, level: usize) -> Vec { + let mut table = Vec::with_capacity((dimension + 1) * (level + 1) * 2); + for n in 0..=dimension { + for k in 0..=level { + let value = Self::binomial_clamped(n, k); + let (low, high) = Self::split_u64(value); + table.push(low); + table.push(high); + } + } + table + } + + fn create_storage_buffer(&self, label: &str, data: &[u32]) -> wgpu::Buffer { + self.device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some(label), + contents: bytemuck::cast_slice(data), + usage: wgpu::BufferUsages::STORAGE, + }) + } + + fn enumerate( + &self, + input: LevelEnumerationInput<'_>, + ) -> Result { + if input.row_stride_words > MAX_ROW_STRIDE_WORDS { + return Err(GpuBoundedEnumerationError::CodewordTooWide { + columns: input.codeword_bits, + maximum: MAX_ROW_STRIDE_WORDS * 32, + }); + } + if input.dimension > MAX_DIMENSION { + return Err(GpuBoundedEnumerationError::DimensionTooLarge { + dimension: input.dimension, + maximum: MAX_DIMENSION, + }); + } + let combination_count = Self::binomial(input.dimension, input.level).ok_or( + GpuBoundedEnumerationError::CombinationCountOverflow { + dimension: input.dimension, + level: input.level, + }, + )?; + let dimension = Self::checked_u32("dimension", input.dimension)?; + let level = Self::checked_u32("level", input.level)?; + let row_stride = Self::checked_u32("row stride", input.row_stride_words)?; + let logical_count = Self::checked_u32("logical count", input.logical_count)?; + let table_width = Self::checked_u32("binomial table width", input.level + 1)?; + + let logical_buffer = self + .create_storage_buffer("PECOS bounded-enumeration logical rows", input.logical_rows); + let binomial_values = Self::binomial_table(input.dimension, input.level); + let binomial_buffer = self + .create_storage_buffer("PECOS bounded-enumeration binomial table", &binomial_values); + let minimum_buffer = self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("PECOS bounded-enumeration minimum"), + contents: bytemuck::bytes_of(&u32::MAX), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + }); + let readback = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("PECOS bounded-enumeration minimum readback"), + size: size_of::() as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + let max_invocations = u64::from(self.device.limits().max_compute_workgroups_per_dimension) + * u64::from(WORKGROUP_SIZE); + let max_ranks_per_dispatch = + (max_invocations * u64::from(RANKS_PER_THREAD)).min(u64::from(u32::MAX)); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("PECOS bounded-enumeration command encoder"), + }); + + for &systematic_index in input.active_systematic_indices { + let generator_buffer = self.create_storage_buffer( + "PECOS bounded-enumeration generator rows", + &input.systematic_generators[systematic_index].rows, + ); + let mut rank_offset = 0u64; + while rank_offset < combination_count { + let rank_count = (combination_count - rank_offset).min(max_ranks_per_dispatch); + let invocation_count = rank_count.div_ceil(u64::from(RANKS_PER_THREAD)); + let (rank_offset_lo, rank_offset_hi) = Self::split_u64(rank_offset); + let rank_count = + u32::try_from(rank_count).expect("dispatch rank count was capped at u32::MAX"); + let invocation_count = u32::try_from(invocation_count) + .expect("dispatch invocation count fits the device workgroup limit"); + let params = EnumerationParams { + dimension, + level, + row_stride, + logical_count, + table_width, + rank_offset_lo, + rank_offset_hi, + rank_count, + ranks_per_thread: RANKS_PER_THREAD, + invocation_count, + _padding_0: 0, + _padding_1: 0, + }; + let params_buffer = + self.device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("PECOS bounded-enumeration parameters"), + contents: bytemuck::bytes_of(¶ms), + usage: wgpu::BufferUsages::UNIFORM, + }); + let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("PECOS bounded-enumeration bind group"), + layout: &self.bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: params_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: generator_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: logical_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: binomial_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: minimum_buffer.as_entire_binding(), + }, + ], + }); + { + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("PECOS bounded-enumeration level pass"), + timestamp_writes: None, + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &bind_group, &[]); + pass.dispatch_workgroups(invocation_count.div_ceil(WORKGROUP_SIZE), 1, 1); + } + rank_offset += u64::from(rank_count); + } + } + encoder.copy_buffer_to_buffer(&minimum_buffer, 0, &readback, 0, size_of::() as u64); + self.queue.submit(std::iter::once(encoder.finish())); + + let slice = readback.slice(..); + let (sender, receiver) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |result| { + let _ = sender.send(result); + }); + self.device + .poll(wgpu::PollType::wait_indefinitely()) + .map_err(|error| GpuBoundedEnumerationError::DevicePoll(error.to_string()))?; + receiver + .recv() + .map_err(|error| GpuBoundedEnumerationError::BufferMap(error.to_string()))? + .map_err(|error| GpuBoundedEnumerationError::BufferMap(error.to_string()))?; + let mapped = slice.get_mapped_range(); + let weight = bytemuck::from_bytes::(&mapped).to_owned(); + drop(mapped); + readback.unmap(); + + Ok(LevelEnumerationMinimum { + weight: (weight != u32::MAX) + .then_some(usize::try_from(weight).expect("u32 codeword weight always fits usize")), + witness: None, + }) + } +} + +impl LevelEnumerationBackend for GpuBoundedEnumerationBackend { + type Error = GpuBoundedEnumerationError; + + fn enumerate_level( + &mut self, + input: LevelEnumerationInput<'_>, + ) -> Result { + self.enumerate(input) + } +} + +fn flatten_backend_error( + error: BoundedEnumerationBackendError, +) -> GpuBoundedEnumerationError { + match error { + BoundedEnumerationBackendError::DistanceProblem(error) => { + GpuBoundedEnumerationError::DistanceProblem(error) + } + BoundedEnumerationBackendError::Backend(error) => error, + } +} + +/// Computes binary `(H, L)` distance with explicit GPU level enumeration. +/// +/// # Errors +/// +/// Returns adapter, kernel-limit, dispatch, or readback failures. It never silently falls back to +/// CPU level enumeration. +pub fn gpu_bounded_enumeration_code_distance( + h: &ParityCheckMatrix, + l: &ParityCheckMatrix, + max_level: usize, +) -> Result, GpuBoundedEnumerationError> { + let mut backend = GpuBoundedEnumerationBackend::try_new()?; + bounded_enumeration_code_distance_with_backend(h, l, max_level, &mut backend) +} + +/// Computes pure-X CSS distance with explicit GPU level enumeration. +/// +/// # Errors +/// +/// Returns invalid-code, adapter, kernel-limit, dispatch, or readback failures. +pub fn gpu_bounded_enumeration_x_distance( + code: &StabilizerCodeSpec, + max_level: usize, +) -> Result, GpuBoundedEnumerationError> { + let mut backend = GpuBoundedEnumerationBackend::try_new()?; + bounded_enumeration_x_distance_with_backend(code, max_level, &mut backend) + .map_err(flatten_backend_error) +} + +/// Computes pure-Z CSS distance with explicit GPU level enumeration. +/// +/// # Errors +/// +/// Returns invalid-code, adapter, kernel-limit, dispatch, or readback failures. +pub fn gpu_bounded_enumeration_z_distance( + code: &StabilizerCodeSpec, + max_level: usize, +) -> Result, GpuBoundedEnumerationError> { + let mut backend = GpuBoundedEnumerationBackend::try_new()?; + bounded_enumeration_z_distance_with_backend(code, max_level, &mut backend) + .map_err(flatten_backend_error) +} + +/// Computes general stabilizer-code distance with explicit GPU level enumeration. +/// +/// # Errors +/// +/// Returns invalid-code, adapter, kernel-limit, dispatch, or readback failures. +pub fn gpu_bounded_enumeration_stabilizer_distance( + code: &StabilizerCodeSpec, + max_level: usize, +) -> Result, GpuBoundedEnumerationError> { + let mut backend = GpuBoundedEnumerationBackend::try_new()?; + bounded_enumeration_stabilizer_distance_with_backend(code, max_level, &mut backend) + .map_err(flatten_backend_error) +} + +#[cfg(test)] +mod tests { + #[test] + fn shader_parses_and_validates_without_an_adapter() { + let module = + wgpu::naga::front::wgsl::parse_str(include_str!("bounded_enumeration_shader.wgsl")) + .expect("bounded-enumeration WGSL must parse"); + wgpu::naga::valid::Validator::new( + wgpu::naga::valid::ValidationFlags::all(), + wgpu::naga::valid::Capabilities::all(), + ) + .validate(&module) + .expect("bounded-enumeration WGSL must validate"); + } +} diff --git a/crates/pecos-gpu-sims/src/lib.rs b/crates/pecos-gpu-sims/src/lib.rs index 126ac27bf..cacf13a48 100644 --- a/crates/pecos-gpu-sims/src/lib.rs +++ b/crates/pecos-gpu-sims/src/lib.rs @@ -39,6 +39,7 @@ mod clifford_fusion; mod gpu; mod gpu64; mod gpu_auto; +mod gpu_bounded_enumeration; mod gpu_density_matrix; mod gpu_influence_sampler; mod gpu_noisy_sampler; @@ -56,6 +57,11 @@ mod gpu_sampler_validation; pub use circuit_compiler::{CompiledCircuit, Gate as CompiledGate, GateType}; pub use gpu::{GpuError, GpuStateVec32, RequiredFeature}; pub use gpu_auto::GpuStateVecAuto; +pub use gpu_bounded_enumeration::{ + GpuBoundedEnumerationBackend, GpuBoundedEnumerationError, + gpu_bounded_enumeration_code_distance, gpu_bounded_enumeration_stabilizer_distance, + gpu_bounded_enumeration_x_distance, gpu_bounded_enumeration_z_distance, +}; pub use gpu_density_matrix::{ GpuDensityMatrix, GpuDensityMatrix32, GpuDensityMatrix64, GpuStateVecBackend, }; diff --git a/crates/pecos-gpu-sims/tests/bounded_enumeration_audit.rs b/crates/pecos-gpu-sims/tests/bounded_enumeration_audit.rs new file mode 100644 index 000000000..b699d42f6 --- /dev/null +++ b/crates/pecos-gpu-sims/tests/bounded_enumeration_audit.rs @@ -0,0 +1,105 @@ +// 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 + +use pecos_gpu_sims::{GpuBoundedEnumerationBackend, gpu_bounded_enumeration_code_distance}; +use pecos_qec::{ + BoundedEnumerationDistance, CpuLevelEnumerationBackend, LevelEnumerationBackend, + LevelEnumerationInput, PackedSystematicGenerator, ParityCheckMatrix, + bounded_enumeration_code_distance, bounded_enumeration_code_distance_with_backend, +}; +use pecos_quantum::F2Matrix; +use rand::rngs::StdRng; +use rand::{RngExt, SeedableRng}; + +fn checks_for_generator(rows: Vec>) -> ParityCheckMatrix { + let generator = F2Matrix::from_rows(rows); + ParityCheckMatrix::from_dense(generator.kernel()).unwrap() +} + +#[test] +fn seeded_small_pairs_are_bit_identical() { + let Ok(mut gpu) = GpuBoundedEnumerationBackend::try_new() else { + return; + }; + let mut rng = StdRng::seed_from_u64(0xB0A0_DED0_5EED_0027); + + for case in 0..64 { + let width = rng.random_range(1..=10); + let h = ParityCheckMatrix::from_dense( + (0..rng.random_range(1..=width)) + .map(|_| (0..width).map(|_| u8::from(rng.random_bool(0.5))).collect()) + .collect(), + ) + .unwrap(); + let l = ParityCheckMatrix::from_dense( + (0..rng.random_range(1..=3)) + .map(|_| (0..width).map(|_| u8::from(rng.random_bool(0.5))).collect()) + .collect(), + ) + .unwrap(); + let max_level = rng.random_range(0..=width); + let cpu = bounded_enumeration_code_distance(&h, &l, max_level); + let accelerated = + bounded_enumeration_code_distance_with_backend(&h, &l, max_level, &mut gpu).unwrap(); + + assert_eq!(accelerated, cpu, "CPU/GPU mismatch in seeded case {case}"); + } +} + +#[test] +fn kernel_handles_first_and_last_level_boundaries() { + let Ok(mut gpu) = GpuBoundedEnumerationBackend::try_new() else { + return; + }; + let systematic_generators = vec![PackedSystematicGenerator { + rows: vec![0b001, 0b010, 0b100], + }]; + let active = vec![0]; + let logical_rows = vec![0b111]; + let make_input = |level| LevelEnumerationInput { + level, + dimension: 3, + codeword_bits: 3, + row_stride_words: 1, + systematic_generators: &systematic_generators, + active_systematic_indices: &active, + logical_rows: &logical_rows, + logical_count: 1, + }; + let mut cpu = CpuLevelEnumerationBackend; + + for (level, expected_weight) in [(1, 1), (3, 3)] { + let cpu_minimum = cpu.enumerate_level(make_input(level)).unwrap(); + let gpu_minimum = gpu.enumerate_level(make_input(level)).unwrap(); + assert_eq!(cpu_minimum.weight, Some(expected_weight)); + assert_eq!(gpu_minimum.weight, cpu_minimum.weight); + assert!(gpu_minimum.witness.is_none()); + } +} + +#[test] +fn hand_analyzed_six_two_four_terminates_identically() { + let Ok(_) = GpuBoundedEnumerationBackend::try_new() else { + return; + }; + let h = checks_for_generator(vec![vec![1, 1, 1, 1, 0, 0], vec![0, 0, 1, 1, 1, 1]]); + let l = ParityCheckMatrix::from_dense(vec![vec![1, 0, 0, 0, 0, 0]]).unwrap(); + let cpu = bounded_enumeration_code_distance(&h, &l, 2).unwrap(); + let gpu = gpu_bounded_enumeration_code_distance(&h, &l, 2) + .unwrap() + .unwrap(); + + assert_eq!(gpu, cpu); + assert!(matches!( + gpu, + BoundedEnumerationDistance::CertifiedByBounds { + distance: 4, + level: 1, + .. + } + )); +} diff --git a/crates/pecos-qec/src/bounded_enumeration_distance.rs b/crates/pecos-qec/src/bounded_enumeration_distance.rs index c7da5c3d4..106f6856f 100644 --- a/crates/pecos-qec/src/bounded_enumeration_distance.rs +++ b/crates/pecos-qec/src/bounded_enumeration_distance.rs @@ -95,6 +95,97 @@ impl BoundedEnumerationDistance { } } +/// Packed rows from one information set supplied to a level-enumeration backend. +/// +/// Each row occupies [`LevelEnumerationInput::row_stride_words`] consecutive `u32` words, with +/// column zero in the least-significant bit of the first word. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PackedSystematicGenerator { + /// Concatenated packed generator rows. + pub rows: Vec, +} + +/// One bounded-enumeration level presented to a backend. +/// +/// `active_systematic_indices` preserves the information-set order used by the exact CPU search. +/// A backend must examine every `level`-combination of the `dimension` rows for each listed +/// systematic generator and minimize full-codeword weight among combinations with a nonzero +/// logical effect. +#[derive(Clone, Copy, Debug)] +pub struct LevelEnumerationInput<'a> { + /// Generator-row combination size for this level. + pub level: usize, + /// Number of rows in every systematic generator. + pub dimension: usize, + /// Number of binary columns in a codeword. + pub codeword_bits: usize, + /// Packed words occupied by one generator or logical row. + pub row_stride_words: usize, + /// All peeled systematic generators. + pub systematic_generators: &'a [PackedSystematicGenerator], + /// Indices of the systematic generators active at this level. + pub active_systematic_indices: &'a [usize], + /// Concatenated packed logical rows. + pub logical_rows: &'a [u32], + /// Number of packed logical rows. + pub logical_count: usize, +} + +/// Minimum found by a level-enumeration backend. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LevelEnumerationMinimum { + /// Minimum full-codeword weight with a nonzero logical effect, or `None` if none exists. + pub weight: Option, + /// Optional packed witness attaining `weight`. + /// + /// If supplied, this must be the first minimum in the systematic-generator and lexicographic + /// combination order described by [`LevelEnumerationInput`]. Backends may omit it; the host + /// then deterministically reconstructs it with a CPU re-scan when the minimum improves the + /// current upper bound. + pub witness: Option>, +} + +/// Backend seam for the branchless combination loop in bounded enumeration. +/// +/// The enumeration structure follows +/// [arXiv:2408.10743](https://arxiv.org/abs/2408.10743). Implementations replace only a level's +/// combination enumeration; bounds, termination, witness verification, and tie-breaking remain +/// under the native host algorithm's control. +pub trait LevelEnumerationBackend { + /// Error returned when a level cannot be enumerated. + type Error; + + /// Returns the minimum logical codeword weight over all combinations in `input`. + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the backend cannot completely enumerate the requested level. + fn enumerate_level( + &mut self, + input: LevelEnumerationInput<'_>, + ) -> Result; +} + +/// Error from bounded enumeration using an external level backend. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BoundedEnumerationBackendError { + /// The requested distance problem is invalid. + DistanceProblem(DistanceProblemError), + /// The level backend failed. + Backend(E), +} + +impl std::fmt::Display for BoundedEnumerationBackendError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DistanceProblem(error) => error.fmt(formatter), + Self::Backend(error) => write!(formatter, "level-enumeration backend failed: {error}"), + } + } +} + +impl std::error::Error for BoundedEnumerationBackendError {} + #[derive(Clone, Debug)] struct SystematicGenerator { rows: Vec>, @@ -239,16 +330,104 @@ fn for_each_combination(count: usize, choose: usize, mut visit: impl FnMut(&[usi } } -fn combination_codeword(rows: &[Vec], combination: &[usize]) -> Vec { - let mut codeword = vec![0; rows[0].len()]; +fn pack_rows(rows: &[Vec], row_stride_words: usize) -> Vec { + let mut packed = vec![0; rows.len() * row_stride_words]; + for (row_index, row) in rows.iter().enumerate() { + for (column, &bit) in row.iter().enumerate() { + if bit != 0 { + packed[row_index * row_stride_words + column / 32] |= 1 << (column % 32); + } + } + } + packed +} + +fn pack_matrix(matrix: &F2Matrix, row_stride_words: usize) -> Vec { + pack_rows(&matrix.rows(), row_stride_words) +} + +fn packed_combination_codeword( + rows: &[u32], + row_stride_words: usize, + combination: &[usize], +) -> Vec { + let mut codeword = vec![0; row_stride_words]; for &row in combination { - for (bit, &generator_bit) in codeword.iter_mut().zip(&rows[row]) { - *bit ^= generator_bit; + for (word, &generator_word) in codeword + .iter_mut() + .zip(&rows[row * row_stride_words..][..row_stride_words]) + { + *word ^= generator_word; } } codeword } +fn packed_weight(row: &[u32]) -> usize { + row.iter().map(|word| word.count_ones() as usize).sum() +} + +fn packed_has_logical_effect( + row: &[u32], + logical_rows: &[u32], + logical_count: usize, + row_stride_words: usize, +) -> bool { + (0..logical_count).any(|logical| { + row.iter() + .zip(&logical_rows[logical * row_stride_words..][..row_stride_words]) + .map(|(&word, &logical_word)| (word & logical_word).count_ones()) + .sum::() + % 2 + == 1 + }) +} + +fn unpack_codeword(row: &[u32], codeword_bits: usize) -> Vec { + (0..codeword_bits) + .map(|column| ((row[column / 32] >> (column % 32)) & 1) as u8) + .collect() +} + +/// Native CPU implementation of [`LevelEnumerationBackend`]. +#[derive(Clone, Copy, Debug, Default)] +pub struct CpuLevelEnumerationBackend; + +impl LevelEnumerationBackend for CpuLevelEnumerationBackend { + type Error = std::convert::Infallible; + + fn enumerate_level( + &mut self, + input: LevelEnumerationInput<'_>, + ) -> Result { + let mut best_weight = None; + let mut best_witness = None; + for &systematic_index in input.active_systematic_indices { + let rows = &input.systematic_generators[systematic_index].rows; + for_each_combination(input.dimension, input.level, |combination| { + let candidate = + packed_combination_codeword(rows, input.row_stride_words, combination); + if packed_has_logical_effect( + &candidate, + input.logical_rows, + input.logical_count, + input.row_stride_words, + ) { + let weight = packed_weight(&candidate); + if best_weight.is_none_or(|current| weight < current) { + best_weight = Some(weight); + best_witness = Some(candidate); + } + } + }); + } + Ok(LevelEnumerationMinimum { + weight: best_weight, + witness: best_witness, + }) + } +} + fn lower_bound_after_level( level: usize, dimension: usize, @@ -289,11 +468,39 @@ fn parity_check_from_matrix(matrix: &F2Matrix) -> ParityCheckMatrix { } } -fn matrix_distance( +fn reconstruct_level_witness(input: LevelEnumerationInput<'_>, target_weight: usize) -> Vec { + for &systematic_index in input.active_systematic_indices { + let rows = &input.systematic_generators[systematic_index].rows; + let mut witness = None; + for_each_combination(input.dimension, input.level, |combination| { + if witness.is_some() { + return; + } + let candidate = packed_combination_codeword(rows, input.row_stride_words, combination); + if packed_weight(&candidate) == target_weight + && packed_has_logical_effect( + &candidate, + input.logical_rows, + input.logical_count, + input.row_stride_words, + ) + { + witness = Some(unpack_codeword(&candidate, input.codeword_bits)); + } + }); + if let Some(witness) = witness { + return witness; + } + } + panic!("level-enumeration backend returned a minimum with no matching witness") +} + +fn matrix_distance_with_backend( h: &ParityCheckMatrix, l: &ParityCheckMatrix, max_level: usize, -) -> Option { + backend: &mut B, +) -> Result, B::Error> { assert_eq!( h.num_qubits(), l.num_qubits(), @@ -302,10 +509,20 @@ fn matrix_distance( let generator = F2Matrix::from_rows(h.matrix().kernel()); let dimension = generator.num_rows(); if dimension == 0 { - return None; + return Ok(None); } let systematic_generators = peel_information_sets(&generator); - let mut best = seeded_upper_bound(&generator, &systematic_generators, l.matrix())?; + let Some(mut best) = seeded_upper_bound(&generator, &systematic_generators, l.matrix()) else { + return Ok(None); + }; + let row_stride_words = generator.num_cols().div_ceil(32); + let packed_systematic_generators: Vec<_> = systematic_generators + .iter() + .map(|systematic| PackedSystematicGenerator { + rows: pack_rows(&systematic.rows, row_stride_words), + }) + .collect(); + let logical_rows = pack_matrix(l.matrix(), row_stride_words); // The first systematic generator is a basis of the whole code. Weight parity is linear over // GF(2), so even basis rows prove that every generated codeword has even weight. let even_code = generator @@ -315,58 +532,95 @@ fn matrix_distance( let mut lower_bound = lower_bound_after_level(0, dimension, &systematic_generators, even_code); if lower_bound >= row_weight(&best) { let witness = verify_result_witness(h, l, &best); - return Some(BoundedEnumerationDistance::CertifiedByBounds { + return Ok(Some(BoundedEnumerationDistance::CertifiedByBounds { distance: row_weight(&best), witness, lower_bound, level: 0, lb_certified: true, - }); + })); } for level in 1..=dimension { if level > max_level { let witness = verify_result_witness(h, l, &best); - return Some(BoundedEnumerationDistance::LevelLimitReached { + return Ok(Some(BoundedEnumerationDistance::LevelLimitReached { lower_bound, upper_bound: row_weight(&best), witness, max_level, lb_certified: true, - }); + })); } - for systematic in &systematic_generators { - let contribution = (level + 1).saturating_sub(dimension - systematic.rank); - if contribution == 0 { - // This information set adds zero to the current lower bound, so omitting its - // candidates cannot weaken the certificate. It could only improve the optional - // upper bound, which the full-rank first information set still updates. - continue; - } - for_each_combination(dimension, level, |combination| { - let candidate = combination_codeword(&systematic.rows, combination); - if has_logical_effect(&candidate, l.matrix()) - && row_weight(&candidate) < row_weight(&best) - { - best = candidate; - } - }); + let active_systematic_indices: Vec<_> = systematic_generators + .iter() + .enumerate() + .filter_map(|(index, systematic)| { + let contribution = (level + 1).saturating_sub(dimension - systematic.rank); + (contribution != 0).then_some(index) + }) + .collect(); + // Information sets omitted here add zero to this level's lower bound. Their candidates + // cannot weaken the certificate, while the full-rank first information set still makes + // the upper-bound search complete. + let input = LevelEnumerationInput { + level, + dimension, + codeword_bits: generator.num_cols(), + row_stride_words, + systematic_generators: &packed_systematic_generators, + active_systematic_indices: &active_systematic_indices, + logical_rows: &logical_rows, + logical_count: l.matrix().num_rows(), + }; + let level_minimum = backend.enumerate_level(input)?; + if let Some(weight) = level_minimum.weight + && weight < row_weight(&best) + { + best = if let Some(witness) = level_minimum.witness { + assert_eq!(witness.len(), row_stride_words); + assert_eq!(packed_weight(&witness), weight); + assert!(packed_has_logical_effect( + &witness, + &logical_rows, + l.matrix().num_rows(), + row_stride_words, + )); + unpack_codeword(&witness, generator.num_cols()) + } else { + // A minimum-only backend (including the GPU backend) intentionally pays for this + // bounded CPU replay only after improving the upper bound. It preserves the exact + // witness and tie-breaking behavior of the native systematic/lexicographic loop. + reconstruct_level_witness(input, weight) + }; } lower_bound = lower_bound_after_level(level, dimension, &systematic_generators, even_code); if lower_bound >= row_weight(&best) { let witness = verify_result_witness(h, l, &best); - return Some(BoundedEnumerationDistance::CertifiedByBounds { + return Ok(Some(BoundedEnumerationDistance::CertifiedByBounds { distance: row_weight(&best), witness, lower_bound, level, lb_certified: true, - }); + })); } } unreachable!("enumerating every generator row must exhaust a finite binary code") } +fn matrix_distance( + h: &ParityCheckMatrix, + l: &ParityCheckMatrix, + max_level: usize, +) -> Option { + let mut backend = CpuLevelEnumerationBackend; + match matrix_distance_with_backend(h, l, max_level, &mut backend) { + Ok(result) => result, + Err(error) => match error {}, + } +} + /// Computes binary `(H, L)` distance by bounded generator-row enumeration. /// /// `H e = 0` enforces undetectability and `L e != 0` enforces a nontrivial effect. `None` means @@ -385,6 +639,27 @@ pub fn bounded_enumeration_code_distance( matrix_distance(h, l, max_level) } +/// Computes binary `(H, L)` distance using an external level-enumeration backend. +/// +/// Bounds, deterministic witness selection, and termination remain identical to +/// [`bounded_enumeration_code_distance`]. +/// +/// # Errors +/// +/// Returns an error if `backend` cannot enumerate a required level. +/// +/// # Panics +/// +/// Panics if the matrices have different widths. +pub fn bounded_enumeration_code_distance_with_backend( + h: &ParityCheckMatrix, + l: &ParityCheckMatrix, + max_level: usize, + backend: &mut B, +) -> Result, B::Error> { + matrix_distance_with_backend(h, l, max_level, backend) +} + /// Computes pure-X bounded-enumeration distance for a CSS-form stabilizer code. /// /// # Errors @@ -402,6 +677,25 @@ pub fn bounded_enumeration_x_distance( Ok(matrix_distance(&h, &l, max_level)) } +/// Computes pure-X bounded-enumeration distance using an external level backend. +/// +/// # Errors +/// +/// Returns CSS problem errors or errors reported by `backend`. +pub fn bounded_enumeration_x_distance_with_backend( + code: &StabilizerCodeSpec, + max_level: usize, + backend: &mut B, +) -> Result, BoundedEnumerationBackendError> { + let problem = DistanceProblem::from_css_code_x_distance(code) + .map_err(BoundedEnumerationBackendError::DistanceProblem)?; + let (h, l) = problem.matrices(); + let h = parity_check_from_matrix(h); + let l = parity_check_from_matrix(l); + matrix_distance_with_backend(&h, &l, max_level, backend) + .map_err(BoundedEnumerationBackendError::Backend) +} + /// Computes pure-Z bounded-enumeration distance for a CSS-form stabilizer code. /// /// # Errors @@ -419,6 +713,25 @@ pub fn bounded_enumeration_z_distance( Ok(matrix_distance(&h, &l, max_level)) } +/// Computes pure-Z bounded-enumeration distance using an external level backend. +/// +/// # Errors +/// +/// Returns CSS problem errors or errors reported by `backend`. +pub fn bounded_enumeration_z_distance_with_backend( + code: &StabilizerCodeSpec, + max_level: usize, + backend: &mut B, +) -> Result, BoundedEnumerationBackendError> { + let problem = DistanceProblem::from_css_code_z_distance(code) + .map_err(BoundedEnumerationBackendError::DistanceProblem)?; + let (h, l) = problem.matrices(); + let h = parity_check_from_matrix(h); + let l = parity_check_from_matrix(l); + matrix_distance_with_backend(&h, &l, max_level, backend) + .map_err(BoundedEnumerationBackendError::Backend) +} + /// Computes bounded-enumeration distance for any stabilizer code specification. /// /// Each qubit contributes X, Y, and Z binary columns, in that order. As in @@ -453,6 +766,37 @@ pub fn bounded_enumeration_stabilizer_distance( Ok(matrix_distance(&h, &l, max_level)) } +/// Computes general stabilizer-code distance using an external level backend. +/// +/// # Errors +/// +/// Returns stabilizer problem errors or errors reported by `backend`. +pub fn bounded_enumeration_stabilizer_distance_with_backend( + code: &StabilizerCodeSpec, + max_level: usize, + backend: &mut B, +) -> Result, BoundedEnumerationBackendError> { + let mechanisms = mechanisms_from_stabilizer_code(code) + .map_err(BoundedEnumerationBackendError::DistanceProblem)?; + let mut h = F2Matrix::zeros(code.stabilizers().len(), mechanisms.len()); + let mut l = F2Matrix::zeros( + code.logical_zs().len() + code.logical_xs().len(), + mechanisms.len(), + ); + for (column, mechanism) in mechanisms.iter().enumerate() { + for &detector in &mechanism.detectors { + h.set(detector as usize, column, 1); + } + for &output in &mechanism.dem_outputs { + l.set(output as usize, column, 1); + } + } + let h = parity_check_from_matrix(&h); + let l = parity_check_from_matrix(&l); + matrix_distance_with_backend(&h, &l, max_level, backend) + .map_err(BoundedEnumerationBackendError::Backend) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index 6415d9d85..27ee8268a 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -84,9 +84,13 @@ pub use bivariate_bicycle::{ BbMemoryBasis, BbMonomial, BivariateBicycleCode, BivariateBicycleError, bb_memory_circuit, }; pub use bounded_enumeration_distance::{ - BoundedEnumerationDistance, bounded_enumeration_code_distance, - bounded_enumeration_stabilizer_distance, bounded_enumeration_x_distance, - bounded_enumeration_z_distance, + BoundedEnumerationBackendError, BoundedEnumerationDistance, CpuLevelEnumerationBackend, + LevelEnumerationBackend, LevelEnumerationInput, LevelEnumerationMinimum, + PackedSystematicGenerator, bounded_enumeration_code_distance, + bounded_enumeration_code_distance_with_backend, bounded_enumeration_stabilizer_distance, + bounded_enumeration_stabilizer_distance_with_backend, bounded_enumeration_x_distance, + bounded_enumeration_x_distance_with_backend, bounded_enumeration_z_distance, + bounded_enumeration_z_distance_with_backend, }; pub use coloration::{ColorationMemoryError, coloration_memory_circuit}; pub use dem_stab::{DemStabError, DemStabShotBatch, DemStabSim, DemStabSimBuilder}; From f6194b78abdaa3e05266c7e6b70e411d0f978e14 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 7 Aug 2026 18:00:10 -0600 Subject: [PATCH 34/41] Add certified coset weights, logical weight profiles, and classical distance --- crates/pecos-qec/src/distance_problem.rs | 412 +++++++++++++++++- crates/pecos-qec/src/lib.rs | 6 +- python/pecos-rslib/pecos_rslib/qec.pyi | 8 + .../src/fault_tolerance_bindings.rs | 58 +++ .../quantum-pecos/src/pecos/qec/__init__.py | 8 + 5 files changed, 485 insertions(+), 7 deletions(-) diff --git a/crates/pecos-qec/src/distance_problem.rs b/crates/pecos-qec/src/distance_problem.rs index 40e799ab3..8633bdf3d 100644 --- a/crates/pecos-qec/src/distance_problem.rs +++ b/crates/pecos-qec/src/distance_problem.rs @@ -39,6 +39,13 @@ pub struct DistanceProblem { l: F2Matrix, num_vars: usize, weight_mode: WeightMode, + /// Target parity bit per H row. All-zero for homogeneous (distance) problems; + /// nonzero targets arise from affine coset-membership constraints. + parity_targets: Vec, + /// Whether a witness must flip at least one L row. Distance problems require + /// it; coset-weight problems have no nontriviality requirement (weight 0 + /// legitimately means the representative lies in the group). + require_logical_effect: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -268,6 +275,8 @@ impl DistanceProblem { h: h.matrix().clone(), l: l.matrix().clone(), num_vars: h.num_qubits(), + parity_targets: vec![0; h.num_checks()], + require_logical_effect: true, weight_mode: WeightMode::Bit, }) } @@ -333,9 +342,11 @@ impl DistanceProblem { (z_checks, logical_z) }; Ok(Self { + parity_targets: vec![0; checks.len()], h: Self::matrix_from_rows(checks, num_qubits), l: Self::matrix_from_rows(logicals, num_qubits), num_vars: num_qubits, + require_logical_effect: true, weight_mode: WeightMode::Bit, }) } @@ -379,9 +390,11 @@ impl DistanceProblem { .collect::, _>>()?; let num_vars = 2 * num_qubits; Ok(Self { + parity_targets: vec![0; checks.len()], h: Self::matrix_from_rows(checks, num_vars), l: Self::matrix_from_rows(logicals, num_vars), num_vars, + require_logical_effect: true, weight_mode: WeightMode::QubitSupport { num_qubits }, }) } @@ -505,9 +518,11 @@ impl DistanceProblem { } } Self { + parity_targets: vec![0; h.num_rows()], h, l, num_vars, + require_logical_effect: true, weight_mode: WeightMode::Bit, } } @@ -612,7 +627,9 @@ impl DistanceProblem { fn encode(&self, max_weight: Option) -> Encoding { let mut builder = EncodingBuilder::new(self.num_vars); self.encode_checks(&mut builder); - self.encode_logical_nontriviality(&mut builder); + if self.require_logical_effect { + self.encode_logical_nontriviality(&mut builder); + } let objective_variables = self.encode_weight_variables(&mut builder); if let Some(max_weight) = max_weight { Self::encode_sequential_counter(&mut builder, max_weight, &objective_variables); @@ -668,7 +685,12 @@ impl DistanceProblem { let support = Self::row_support(&self.h, row); match support.as_slice() { [] => {} - &[variable] => builder.parity_clauses.push(vec![-Self::literal(variable)]), + &[variable] => { + let sign = if self.parity_targets[row] == 1 { 1 } else { -1 }; + builder + .parity_clauses + .push(vec![sign * Self::literal(variable)]); + } &[first, second, ref rest @ ..] => { let mut output = aux_iter.next().expect("pre-counted XOR auxiliary"); Self::push_xor(&mut builder.parity_clauses, first, second, output); @@ -677,7 +699,11 @@ impl DistanceProblem { Self::push_xor(&mut builder.parity_clauses, output, variable, next); output = next; } - builder.parity_clauses.push(vec![-Self::literal(output)]); + // The chain output must equal the row's target parity. + let sign = if self.parity_targets[row] == 1 { 1 } else { -1 }; + builder + .parity_clauses + .push(vec![sign * Self::literal(output)]); } } } @@ -834,7 +860,7 @@ impl DistanceProblem { .count() % 2 == 1; - if odd { + if odd != (self.parity_targets[row] == 1) { return Err(WitnessError::OddCheck { row }); } } @@ -847,7 +873,7 @@ impl DistanceProblem { % 2 == 1 }); - if !logical_nonzero { + if self.require_logical_effect && !logical_nonzero { return Err(WitnessError::ZeroLogicalEffect); } Ok(match self.weight_mode { @@ -920,6 +946,200 @@ impl DistanceProblem { } } +impl DistanceProblem { + /// Builds the affine problem "minimum weight of `representative + rowspan(group)`". + /// + /// Membership in the coset is expressed through the orthogonal complement: with `D` a basis + /// of `rowspan(G)^perp` (the kernel of `G`), `e` lies in `p + rowspan(G)` iff `D e = D p`. + /// There is no nontriviality requirement: weight 0 means the representative is in the group. + /// + /// # Errors + /// + /// Returns an error if the representative width does not match the group. + pub fn coset_weight_problem( + group: &ParityCheckMatrix, + representative: &[u8], + ) -> Result { + let num_vars = group.num_qubits(); + if representative.len() != num_vars { + return Err(DistanceProblemError::MatrixWidthMismatch { + h_width: num_vars, + l_width: representative.len(), + }); + } + let dual_rows = group.matrix().kernel(); + let parity_targets: Vec = dual_rows + .iter() + .map(|dual| { + u8::from( + dual.iter() + .zip(representative) + .filter(|&(&d, &r)| d == 1 && r == 1) + .count() + % 2 + == 1, + ) + }) + .collect(); + Ok(Self { + parity_targets, + h: Self::matrix_from_rows(dual_rows, num_vars), + l: F2Matrix::zeros(0, num_vars), + num_vars, + require_logical_effect: false, + weight_mode: WeightMode::Bit, + }) + } + + /// True when every affine target is zero, i.e. the zero assignment is a valid witness. + fn zero_assignment_satisfies(&self) -> bool { + !self.require_logical_effect && self.parity_targets.iter().all(|&target| target == 0) + } +} + +/// Certified minimum weight of `representative + rowspan(group)` over the binary alphabet. +/// +/// Weight 0 (the representative lies in the group) is certified without any solver call. +/// +/// # Errors +/// +/// Returns an error on width mismatch or if the solver misbehaves. +pub fn certified_coset_weight( + group: &ParityCheckMatrix, + representative: &[u8], + max_weight: usize, +) -> Result, CosetWeightError> { + let problem = DistanceProblem::coset_weight_problem(group, representative)?; + if problem.zero_assignment_satisfies() { + return Ok(Some(CertifiedDistance { + distance: 0, + witness: vec![false; problem.num_vars], + sat_certified: true, + unsat_trusted_below: 0, + })); + } + certified_distance(&problem, max_weight).map_err(CosetWeightError::Certification) +} + +/// Certified minimum qubit-support weight of `operator * stabilizer group` for any code. +/// +/// Uses the plain symplectic representation `[X | Z]` (phases are irrelevant to weight and to +/// GF(2) span membership) with the per-qubit-support weight mode, so a `Y` costs one. +/// +/// # Errors +/// +/// Returns an error on width mismatch or solver misbehavior. +pub fn certified_stabilizer_coset_weight( + code: &StabilizerCodeSpec, + operator: &pecos_core::PauliString, + max_weight: usize, +) -> Result, CosetWeightError> { + let num_qubits = code.num_qubits(); + let group = pecos_quantum::SymplecticMatrix::from_pauli_sequence_ignoring_phase( + &pecos_quantum::PauliSequence::new(code.stabilizers().to_vec()), + num_qubits, + ) + .map_err(|error| CosetWeightError::Symplectic(error.to_string()))?; + let representative_rows = pecos_quantum::SymplecticMatrix::from_pauli_sequence_ignoring_phase( + &pecos_quantum::PauliSequence::new(vec![operator.clone()]), + num_qubits, + ) + .map_err(|error| CosetWeightError::Symplectic(error.to_string()))?; + let Some(representative) = representative_rows.rows().into_iter().next() else { + return Err(CosetWeightError::Symplectic( + "operator produced no symplectic row".to_string(), + )); + }; + let group_rows = group.rows(); + let num_vars = 2 * num_qubits; + let dual_rows = F2Matrix::from_rows(group_rows).kernel(); + let parity_targets: Vec = dual_rows + .iter() + .map(|dual| { + u8::from( + dual.iter() + .zip(&representative) + .filter(|&(&d, &r)| d == 1 && r == 1) + .count() + % 2 + == 1, + ) + }) + .collect(); + let problem = DistanceProblem { + parity_targets, + h: DistanceProblem::matrix_from_rows(dual_rows, num_vars), + l: F2Matrix::zeros(0, num_vars), + num_vars, + require_logical_effect: false, + weight_mode: WeightMode::QubitSupport { num_qubits }, + }; + if problem.zero_assignment_satisfies() { + return Ok(Some(CertifiedDistance { + distance: 0, + witness: vec![false; num_vars], + sat_certified: true, + unsat_trusted_below: 0, + })); + } + certified_distance(&problem, max_weight).map_err(CosetWeightError::Certification) +} + +/// Certified coset weight of every logical basis operator of a code (Z basis, then X basis). +/// +/// # Errors +/// +/// Propagates the first failure from [`certified_stabilizer_coset_weight`]. +pub fn logical_coset_weight_profile( + code: &StabilizerCodeSpec, + max_weight: usize, +) -> Result>, CosetWeightError> { + code.logical_zs() + .iter() + .chain(code.logical_xs()) + .map(|logical| certified_stabilizer_coset_weight(code, logical, max_weight)) + .collect() +} + +/// Certified minimum weight of a nonzero kernel element of a classical parity-check matrix. +/// +/// Nontriviality is expressed as "some coordinate is set" via an identity logical block. +/// +/// # Errors +/// +/// Returns an error on solver misbehavior. +pub fn certified_classical_distance( + h: &ParityCheckMatrix, + max_weight: usize, +) -> Result, CosetWeightError> { + let n = h.num_qubits(); + let identity_rows = (0..n) + .map(|column| { + let mut row = vec![0u8; n]; + row[column] = 1; + row + }) + .collect(); + let identity = ParityCheckMatrix::from_dense(identity_rows) + .map_err(|error| CosetWeightError::Symplectic(error.to_string()))?; + let problem = DistanceProblem::from_css_checks(h, &identity)?; + certified_distance(&problem, max_weight).map_err(CosetWeightError::Certification) +} + +/// Errors from the coset-weight and classical-distance entry points. +#[derive(Debug, Error)] +pub enum CosetWeightError { + /// Problem construction failed. + #[error(transparent)] + Problem(#[from] DistanceProblemError), + /// Symplectic conversion failed. + #[error("symplectic conversion failed: {0}")] + Symplectic(String), + /// The certification loop failed. + #[error(transparent)] + Certification(DistanceCertificationError), +} + /// Certifies distance through `max_weight` using the in-process batsat SAT solver. /// /// A fresh deterministic solver instance is built for each weight from the internal clause @@ -1616,4 +1836,186 @@ mod tests { }) ); } + + fn five_qubit_spec() -> StabilizerCodeSpec { + use pecos_core::{Pauli, PauliString, QuarterPhase, QubitId}; + let pauli = |terms: &[(Pauli, usize)]| { + PauliString::with_phase_and_paulis( + QuarterPhase::PlusOne, + terms.iter().map(|&(p, q)| (p, QubitId::new(q))).collect(), + ) + }; + StabilizerCodeSpec::new( + 5, + vec![ + pauli(&[(Pauli::X, 0), (Pauli::Z, 1), (Pauli::Z, 2), (Pauli::X, 3)]), + pauli(&[(Pauli::X, 1), (Pauli::Z, 2), (Pauli::Z, 3), (Pauli::X, 4)]), + pauli(&[(Pauli::X, 0), (Pauli::X, 2), (Pauli::Z, 3), (Pauli::Z, 4)]), + pauli(&[(Pauli::Z, 0), (Pauli::X, 1), (Pauli::X, 3), (Pauli::Z, 4)]), + ], + vec![pauli(&[ + (Pauli::Z, 0), + (Pauli::Z, 1), + (Pauli::Z, 2), + (Pauli::Z, 3), + (Pauli::Z, 4), + ])], + vec![pauli(&[ + (Pauli::X, 0), + (Pauli::X, 1), + (Pauli::X, 2), + (Pauli::X, 3), + (Pauli::X, 4), + ])], + ) + .unwrap() + } + + #[test] + fn classical_distance_matches_known_codes() { + let hamming = steane_hamming_matrix(); + let certified = certified_classical_distance(&hamming, 4).unwrap().unwrap(); + assert_eq!(certified.distance, 3); + + let repetition = ParityCheckMatrix::from_dense(vec![vec![1, 1, 0], vec![0, 1, 1]]).unwrap(); + let certified = certified_classical_distance(&repetition, 3) + .unwrap() + .unwrap(); + assert_eq!(certified.distance, 3); + + let enumerated = bounded_enumeration_classical_agreement(&hamming, 3); + assert_eq!(enumerated, 3); + } + + fn bounded_enumeration_classical_agreement(h: &ParityCheckMatrix, expected: usize) -> usize { + let n = h.num_qubits(); + let identity = ParityCheckMatrix::from_dense( + (0..n) + .map(|column| { + let mut row = vec![0u8; n]; + row[column] = 1; + row + }) + .collect(), + ) + .unwrap(); + match bounded_enumeration_code_distance(h, &identity, n).unwrap() { + crate::BoundedEnumerationDistance::CertifiedByBounds { distance, .. } => { + assert_eq!(distance, expected); + distance + } + other @ crate::BoundedEnumerationDistance::LevelLimitReached { .. } => { + panic!("expected certified classical distance, got {other:?}") + } + } + } + + #[test] + fn coset_weight_of_group_element_is_zero_and_of_all_ones_is_three() { + let hamming = steane_hamming_matrix(); + let member = hamming.rows()[0].clone(); + let zero = certified_coset_weight(&hamming, &member, 7) + .unwrap() + .unwrap(); + assert_eq!(zero.distance, 0); + + let all_ones = vec![1u8; 7]; + let three = certified_coset_weight(&hamming, &all_ones, 7) + .unwrap() + .unwrap(); + assert_eq!(three.distance, 3); + // Native re-verification of the witness against the coset condition. + let problem = DistanceProblem::coset_weight_problem(&hamming, &all_ones).unwrap(); + assert_eq!(problem.verify_witness(&three.witness), Ok(3)); + } + + #[test] + fn coset_weight_agrees_with_brute_force_on_seeded_groups() { + use rand::rngs::SmallRng; + use rand::{RngExt, SeedableRng}; + let mut rng = SmallRng::seed_from_u64(0xC05E_75EE_D000_0001); + for _ in 0..48 { + let n = rng.random_range(3..=9); + let row_count = rng.random_range(1..=3); + let rows: Vec> = (0..row_count) + .map(|_| (0..n).map(|_| u8::from(rng.random_bool(0.5))).collect()) + .collect(); + let Ok(group) = ParityCheckMatrix::from_dense(rows.clone()) else { + continue; + }; + let representative: Vec = (0..n).map(|_| u8::from(rng.random_bool(0.5))).collect(); + let certified = certified_coset_weight(&group, &representative, n) + .unwrap() + .expect("coset always has an element within weight n"); + let mut brute = usize::MAX; + for mask in 0..(1usize << row_count) { + let mut candidate = representative.clone(); + for (index, row) in rows.iter().enumerate() { + if mask & (1 << index) != 0 { + for (bit, r) in candidate.iter_mut().zip(row) { + *bit ^= r; + } + } + } + brute = brute.min(candidate.iter().map(|&bit| usize::from(bit)).sum::()); + } + assert_eq!(certified.distance, brute); + } + } + + #[test] + fn five_qubit_logical_x_coset_weight_is_three_by_qubit_support() { + use pecos_core::{Pauli, PauliString, QuarterPhase, QubitId}; + let spec = five_qubit_spec(); + let logical_x = PauliString::with_phase_and_paulis( + QuarterPhase::PlusOne, + (0..5).map(|q| (Pauli::X, QubitId::new(q))).collect(), + ); + let certified = certified_stabilizer_coset_weight(&spec, &logical_x, 5) + .unwrap() + .unwrap(); + // Raw weight is 5; XXXXX times XZZXI equals IYYIX of qubit-support weight 3. + assert_eq!(certified.distance, 3); + } + + #[test] + fn steane_logical_profile_is_all_threes_and_stabilizer_costs_zero() { + let hamming = steane_hamming_matrix(); + let mut builder = crate::StabilizerCodeSpecBuilder::new(7); + builder = builder.checks_from_css(&hamming, &hamming).unwrap(); + let spec = builder.build_with_discovered_logicals().unwrap(); + + let profile = logical_coset_weight_profile(&spec, 7).unwrap(); + assert_eq!(profile.len(), 2); + for entry in profile { + assert_eq!(entry.unwrap().distance, 3); + } + let stabilizer = spec.stabilizers()[0].clone(); + let zero = certified_stabilizer_coset_weight(&spec, &stabilizer, 7) + .unwrap() + .unwrap(); + assert_eq!(zero.distance, 0); + } + + #[test] + fn yy_code_y_logical_coset_weight_is_one() { + use pecos_core::{Pauli, PauliString, QuarterPhase, QubitId}; + let pauli = |terms: &[(Pauli, usize)]| { + PauliString::with_phase_and_paulis( + QuarterPhase::PlusOne, + terms.iter().map(|&(p, q)| (p, QubitId::new(q))).collect(), + ) + }; + let spec = StabilizerCodeSpec::new( + 2, + vec![pauli(&[(Pauli::Y, 0), (Pauli::Y, 1)])], + vec![pauli(&[(Pauli::Y, 0)])], + vec![pauli(&[(Pauli::X, 0), (Pauli::Z, 1)])], + ) + .unwrap(); + let certified = certified_stabilizer_coset_weight(&spec, &pauli(&[(Pauli::Y, 0)]), 2) + .unwrap() + .unwrap(); + assert_eq!(certified.distance, 1); + } } diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index 27ee8268a..fba6db0c9 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -108,8 +108,10 @@ pub use distance::{ find_shortest_logicals, has_logical_error_at_weight, }; pub use distance_problem::{ - CertifiedDistance, DistanceCertificationError, DistanceProblem, DistanceProblemError, - SolverAnswer, WitnessError, certified_distance, + CertifiedDistance, CosetWeightError, DistanceCertificationError, DistanceProblem, + DistanceProblemError, SolverAnswer, WitnessError, certified_classical_distance, + certified_coset_weight, certified_distance, certified_stabilizer_coset_weight, + logical_coset_weight_profile, }; pub use fault_tolerance::dem_builder::{ DecomposedFault, DemBuilder, DemBuilderError, DemOutput, DetectorDef, DetectorErrorModel, diff --git a/python/pecos-rslib/pecos_rslib/qec.pyi b/python/pecos-rslib/pecos_rslib/qec.pyi index bc508c885..5639ca466 100644 --- a/python/pecos-rslib/pecos_rslib/qec.pyi +++ b/python/pecos-rslib/pecos_rslib/qec.pyi @@ -250,3 +250,11 @@ def stabilizer_code_distance(spec: StabilizerCodeSpec, max_weight: int) -> Dista # The native QEC module predates this focused stub. Preserve the untyped behavior of its other # classes and functions until that complete API is migrated rather than falsely narrowing them. def __getattr__(name: str) -> Any: ... +def certified_coset_weight( + group: ParityCheckMatrix, representative: list[int], max_weight: int +) -> CertifiedDistance | None: ... +def certified_stabilizer_coset_weight( + code: StabilizerCodeSpec, operator: Any, max_weight: int +) -> CertifiedDistance | None: ... +def logical_coset_weight_profile(code: StabilizerCodeSpec, max_weight: int) -> list[CertifiedDistance | None]: ... +def certified_classical_distance(h: ParityCheckMatrix, max_weight: int) -> CertifiedDistance | None: ... diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index bf2f596f5..7d6a6d3aa 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -7810,6 +7810,60 @@ fn certified_distance( certify_python_problem(&problem.inner, max_weight) } +/// Certified minimum weight of ``representative + rowspan(group)`` over GF(2). +/// +/// Weight 0 means the representative is in the group; certified without a solver call. +/// SAT answers are natively verified; UNSAT answers are solver-trusted. +#[pyfunction] +fn certified_coset_weight( + group: &PyParityCheckMatrix, + representative: Vec, + max_weight: usize, +) -> PyResult> { + pecos_qec::certified_coset_weight(&group.inner, &representative, max_weight) + .map(|result| result.map(PyCertifiedDistance::from)) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) +} + +/// Certified minimum qubit-support weight of ``operator * stabilizer group`` for any code. +#[pyfunction] +fn certified_stabilizer_coset_weight( + code: &PyStabilizerCodeSpec, + operator: &crate::pauli_bindings::PauliString, + max_weight: usize, +) -> PyResult> { + pecos_qec::certified_stabilizer_coset_weight(&code.inner, &operator.to_rust(), max_weight) + .map(|result| result.map(PyCertifiedDistance::from)) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) +} + +/// Certified coset weight of every logical basis operator (Z basis, then X basis). +#[pyfunction] +fn logical_coset_weight_profile( + code: &PyStabilizerCodeSpec, + max_weight: usize, +) -> PyResult>> { + pecos_qec::logical_coset_weight_profile(&code.inner, max_weight) + .map(|profile| { + profile + .into_iter() + .map(|entry| entry.map(PyCertifiedDistance::from)) + .collect() + }) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) +} + +/// Certified minimum weight of a nonzero kernel element of a classical parity-check matrix. +#[pyfunction] +fn certified_classical_distance( + h: &PyParityCheckMatrix, + max_weight: usize, +) -> PyResult> { + pecos_qec::certified_classical_distance(&h.inner, max_weight) + .map(|result| result.map(PyCertifiedDistance::from)) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) +} + /// Computes binary ``(H, L)`` distance using native bounded row enumeration. #[pyfunction] fn bounded_enumeration_code_distance( @@ -8094,6 +8148,10 @@ pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { qec.add_function(wrap_pyfunction!(mechanisms_to_dem_string, &qec)?)?; qec.add_function(wrap_pyfunction!(decoder_dem_requirement, &qec)?)?; qec.add_function(wrap_pyfunction!(certified_distance, &qec)?)?; + qec.add_function(wrap_pyfunction!(certified_coset_weight, &qec)?)?; + qec.add_function(wrap_pyfunction!(certified_stabilizer_coset_weight, &qec)?)?; + qec.add_function(wrap_pyfunction!(logical_coset_weight_profile, &qec)?)?; + qec.add_function(wrap_pyfunction!(certified_classical_distance, &qec)?)?; qec.add_function(wrap_pyfunction!(bb_memory_circuit, &qec)?)?; qec.add_function(wrap_pyfunction!(coloration_memory_circuit, &qec)?)?; diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index 83b7fb46f..92aad1ef3 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -57,11 +57,15 @@ bounded_enumeration_stabilizer_distance, bounded_enumeration_x_distance, bounded_enumeration_z_distance, + certified_classical_distance, + certified_coset_weight, certified_distance, + certified_stabilizer_coset_weight, coloration_memory_circuit, compare_dems_exact, compare_dems_statistical, connected_cluster_code_distance, + logical_coset_weight_profile, stabilizer_code_distance, verify_dem_equivalence, x_distance, @@ -179,6 +183,10 @@ "bounded_enumeration_x_distance", "bounded_enumeration_z_distance", "certified_distance", + "certified_classical_distance", + "certified_coset_weight", + "certified_stabilizer_coset_weight", + "logical_coset_weight_profile", "coloration_memory_circuit", "connected_cluster_code_distance", "compare_dems_exact", From 2d9e186f4b87db9b307aa9f620a6077265021762 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 7 Aug 2026 18:25:36 -0600 Subject: [PATCH 35/41] Add hypergraph-product code construction with a GF(2) Kronecker primitive --- crates/pecos-qec/src/hypergraph_product.rs | 241 ++++++++++++++++++ crates/pecos-qec/src/lib.rs | 2 + crates/pecos-quantum/src/pauli_sequence.rs | 40 +++ python/pecos-rslib/pecos_rslib/qec.pyi | 15 ++ .../src/fault_tolerance_bindings.rs | 62 +++++ .../quantum-pecos/src/pecos/qec/__init__.py | 2 + 6 files changed, 362 insertions(+) create mode 100644 crates/pecos-qec/src/hypergraph_product.rs diff --git a/crates/pecos-qec/src/hypergraph_product.rs b/crates/pecos-qec/src/hypergraph_product.rs new file mode 100644 index 000000000..adae55822 --- /dev/null +++ b/crates/pecos-qec/src/hypergraph_product.rs @@ -0,0 +1,241 @@ +// 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. + +//! Hypergraph-product CSS codes built from two classical parity-check matrices. +//! +//! The construction follows the hypergraph product of Tillich and Zemor +//! (arXiv:0903.0566): from classical checks `H1` (`r1 x n1`) and `H2` +//! (`r2 x n2`), +//! +//! ```text +//! Hx = [ H1 (x) I_n2 | I_r1 (x) H2^T ] +//! Hz = [ I_n1 (x) H2 | H1^T (x) I_r2 ] +//! ``` +//! +//! on `n = n1*n2 + r1*r2` data qubits. CSS orthogonality holds identically; +//! the constructor asserts it anyway to fail fast on implementation error. + +use crate::memory_circuit::discover_css_logical_operators; +use crate::parity_check_matrix::ParityCheckMatrix; +use pecos_quantum::F2Matrix; +use thiserror::Error; + +/// Errors constructing a [`HypergraphProductCode`]. +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum HypergraphProductError { + /// A classical input has no rows or no columns. + #[error("classical input {which} must be nonempty")] + EmptyInput { + /// Which input was empty. + which: &'static str, + }, +} + +/// A hypergraph-product CSS code with discovered logical bases. +#[derive(Clone, Debug)] +pub struct HypergraphProductCode { + hx: ParityCheckMatrix, + hz: ParityCheckMatrix, + logical_x: ParityCheckMatrix, + logical_z: ParityCheckMatrix, +} + +impl HypergraphProductCode { + /// Builds the hypergraph product of two classical parity-check matrices. + /// + /// # Errors + /// + /// Returns an error if either classical input is empty. + /// + /// # Panics + /// + /// Panics only if the internally generated rectangular binary matrices are + /// rejected by [`ParityCheckMatrix`] or violate CSS orthogonality, either + /// of which would break this module's construction invariant. + pub fn new( + h1: &ParityCheckMatrix, + h2: &ParityCheckMatrix, + ) -> Result { + let (r1, n1) = (h1.num_checks(), h1.num_qubits()); + let (r2, n2) = (h2.num_checks(), h2.num_qubits()); + if r1 == 0 || n1 == 0 { + return Err(HypergraphProductError::EmptyInput { which: "H1" }); + } + if r2 == 0 || n2 == 0 { + return Err(HypergraphProductError::EmptyInput { which: "H2" }); + } + + let h1m = h1.matrix(); + let h2m = h2.matrix(); + let hx_left = h1m.kronecker(&F2Matrix::identity(n2)); + let hx_right = F2Matrix::identity(r1).kronecker(&h2m.transpose()); + let hz_left = F2Matrix::identity(n1).kronecker(h2m); + let hz_right = h1m.transpose().kronecker(&F2Matrix::identity(r2)); + + let num_qubits = n1 * n2 + r1 * r2; + let mut hx = F2Matrix::zeros(r1 * n2, num_qubits); + let mut hz = F2Matrix::zeros(n1 * r2, num_qubits); + for row in 0..r1 * n2 { + for col in 0..n1 * n2 { + hx.set(row, col, hx_left.get(row, col)); + } + for col in 0..r1 * r2 { + hx.set(row, n1 * n2 + col, hx_right.get(row, col)); + } + } + for row in 0..n1 * r2 { + for col in 0..n1 * n2 { + hz.set(row, col, hz_left.get(row, col)); + } + for col in 0..r1 * r2 { + hz.set(row, n1 * n2 + col, hz_right.get(row, col)); + } + } + assert_eq!( + hx.mul(&hz.transpose()), + F2Matrix::zeros(r1 * n2, n1 * r2), + "hypergraph-product orthogonality is guaranteed by construction", + ); + + let hx = ParityCheckMatrix::from_dense(hx.rows()) + .expect("a nonempty rectangular binary matrix was generated"); + let hz = ParityCheckMatrix::from_dense(hz.rows()) + .expect("a nonempty rectangular binary matrix was generated"); + let (logical_x, logical_z) = discover_css_logical_operators(&hx, &hz); + + Ok(Self { + hx, + hz, + logical_x, + logical_z, + }) + } + + /// Number of data qubits, `n1*n2 + r1*r2`. + #[must_use] + pub fn num_qubits(&self) -> usize { + self.hx.num_qubits() + } + + /// Number of logical qubits, `n - rank(Hx) - rank(Hz)`. + #[must_use] + pub fn num_logical_qubits(&self) -> usize { + self.num_qubits() - self.hx.rank() - self.hz.rank() + } + + /// The X-type check matrix. + #[must_use] + pub fn hx(&self) -> &ParityCheckMatrix { + &self.hx + } + + /// The Z-type check matrix. + #[must_use] + pub fn hz(&self) -> &ParityCheckMatrix { + &self.hz + } + + /// A basis of X-type logical representatives. + #[must_use] + pub fn logical_x(&self) -> &ParityCheckMatrix { + &self.logical_x + } + + /// A basis of Z-type logical representatives. + #[must_use] + pub fn logical_z(&self) -> &ParityCheckMatrix { + &self.logical_z + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + BoundedEnumerationDistance, bounded_enumeration_code_distance, + connected_cluster_code_distance, + }; + + fn repetition3() -> ParityCheckMatrix { + ParityCheckMatrix::from_dense(vec![vec![1, 1, 0], vec![0, 1, 1]]).unwrap() + } + + fn hamming() -> ParityCheckMatrix { + ParityCheckMatrix::from_dense(vec![ + vec![1, 0, 1, 0, 1, 0, 1], + vec![0, 1, 1, 0, 0, 1, 1], + vec![0, 0, 0, 1, 1, 1, 1], + ]) + .unwrap() + } + + #[test] + fn repetition_square_is_the_thirteen_qubit_surface_type_code() { + let code = HypergraphProductCode::new(&repetition3(), &repetition3()).unwrap(); + // n = 3*3 + 2*2 = 13; k = k1*k2 + k1T*k2T = 1*1 + 0*0 = 1. + assert_eq!(code.num_qubits(), 13); + assert_eq!(code.num_logical_qubits(), 1); + + let cc = connected_cluster_code_distance(code.hx(), code.logical_x(), 13) + .expect("distance within budget"); + assert_eq!(cc.distance, 3); + match bounded_enumeration_code_distance(code.hx(), code.logical_x(), 13) + .expect("kernel is nonempty") + { + BoundedEnumerationDistance::CertifiedByBounds { distance, .. } => { + assert_eq!(distance, 3); + } + BoundedEnumerationDistance::LevelLimitReached { .. } => { + panic!("thirteen-qubit search must certify") + } + } + } + + #[test] + fn hamming_times_repetition_has_expected_parameters_and_distance() { + let code = HypergraphProductCode::new(&hamming(), &repetition3()).unwrap(); + // n = 7*3 + 3*2 = 27; k = k1*k2 + k1T*k2T = 4*1 + 0*0 = 4 + // (both transpose codes are trivial: the inputs have full row rank). + assert_eq!(code.num_qubits(), 27); + assert_eq!(code.num_logical_qubits(), 4); + + // Expected distance: min(d1, d2) = min(3, 3) = 3 for full-rank inputs; + // measured rather than assumed. + let z_side = connected_cluster_code_distance(code.hx(), code.logical_x(), 27) + .expect("distance within budget"); + let x_side = connected_cluster_code_distance(code.hz(), code.logical_z(), 27) + .expect("distance within budget"); + assert_eq!(z_side.distance.min(x_side.distance), 3); + } + + #[test] + fn empty_inputs_are_rejected() { + let ok = repetition3(); + let empty = ParityCheckMatrix::zeros(0, 3); + assert_eq!( + HypergraphProductCode::new(&empty, &ok).unwrap_err(), + HypergraphProductError::EmptyInput { which: "H1" } + ); + assert_eq!( + HypergraphProductCode::new(&ok, &empty).unwrap_err(), + HypergraphProductError::EmptyInput { which: "H2" } + ); + } + + #[test] + fn construction_is_deterministic() { + let a = HypergraphProductCode::new(&hamming(), &repetition3()).unwrap(); + let b = HypergraphProductCode::new(&hamming(), &repetition3()).unwrap(); + assert_eq!(a.hx().rows(), b.hx().rows()); + assert_eq!(a.logical_z().rows(), b.logical_z().rows()); + } +} diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index fba6db0c9..7a38f46f6 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -72,6 +72,7 @@ pub mod distance; pub mod distance_problem; pub mod fault_tolerance; pub mod geometry; +pub mod hypergraph_product; pub mod logical_discovery; pub mod mem_stab; mod memory_circuit; @@ -94,6 +95,7 @@ pub use bounded_enumeration_distance::{ }; pub use coloration::{ColorationMemoryError, coloration_memory_circuit}; pub use dem_stab::{DemStabError, DemStabShotBatch, DemStabSim, DemStabSimBuilder}; +pub use hypergraph_product::{HypergraphProductCode, HypergraphProductError}; pub use mem_stab::{MemStabError, MemStabSim, MemStabSimBuilder}; pub use memory_circuit::MemoryBasis; pub use parity_check_matrix::{ParityCheckMatrix, ParityCheckMatrixError}; diff --git a/crates/pecos-quantum/src/pauli_sequence.rs b/crates/pecos-quantum/src/pauli_sequence.rs index 355d5fe87..8d8a9c679 100644 --- a/crates/pecos-quantum/src/pauli_sequence.rs +++ b/crates/pecos-quantum/src/pauli_sequence.rs @@ -383,6 +383,32 @@ impl F2Matrix { result } + /// Computes the Kronecker (tensor) product of two GF(2) matrices. + /// + /// The result has `self.num_rows() * other.num_rows()` rows and + /// `self.num_cols() * other.num_cols()` columns, with block `(i, j)` equal + /// to `other` where `self[i, j] = 1` and zero otherwise. + #[must_use] + pub fn kronecker(&self, other: &Self) -> Self { + let rows = self.num_rows() * other.num_rows(); + let cols = self.num_cols * other.num_cols; + let mut result = Self::zeros(rows, cols); + for i in 0..self.num_rows() { + for j in 0..self.num_cols { + if self.get(i, j) == 1 { + for r in 0..other.num_rows() { + for c in 0..other.num_cols { + if other.get(r, c) == 1 { + result.set(i * other.num_rows() + r, j * other.num_cols + c, 1); + } + } + } + } + } + } + result + } + /// Computes the (right) null space of this matrix over GF(2). /// /// Returns a set of column vectors `v` such that `self * v = 0` (mod 2). @@ -1845,4 +1871,18 @@ mod tests { assert_eq!(m.mul(&inv), F2Matrix::identity(4)); assert_eq!(inv.mul(&m), F2Matrix::identity(4)); } + + #[test] + fn kronecker_places_blocks_by_left_entries() { + let left = F2Matrix::from_rows(vec![vec![1, 1], vec![0, 1]]); + let right = F2Matrix::from_rows(vec![vec![1, 0], vec![1, 1]]); + let product = left.kronecker(&right); + assert_eq!(product.num_rows(), 4); + assert_eq!(product.num_cols(), 4); + // Block (0,0) and (0,1) are copies of `right`; block (1,0) is zero. + assert_eq!(product.row(0), vec![1, 0, 1, 0]); + assert_eq!(product.row(1), vec![1, 1, 1, 1]); + assert_eq!(product.row(2), vec![0, 0, 1, 0]); + assert_eq!(product.row(3), vec![0, 0, 1, 1]); + } } diff --git a/python/pecos-rslib/pecos_rslib/qec.pyi b/python/pecos-rslib/pecos_rslib/qec.pyi index 5639ca466..e31974b03 100644 --- a/python/pecos-rslib/pecos_rslib/qec.pyi +++ b/python/pecos-rslib/pecos_rslib/qec.pyi @@ -258,3 +258,18 @@ def certified_stabilizer_coset_weight( ) -> CertifiedDistance | None: ... def logical_coset_weight_profile(code: StabilizerCodeSpec, max_weight: int) -> list[CertifiedDistance | None]: ... def certified_classical_distance(h: ParityCheckMatrix, max_weight: int) -> CertifiedDistance | None: ... + +class HypergraphProductCode: + """A hypergraph-product CSS code built from two classical parity-check matrices.""" + + def __init__(self, h1: ParityCheckMatrix, h2: ParityCheckMatrix) -> None: ... + @property + def hx(self) -> ParityCheckMatrix: ... + @property + def hz(self) -> ParityCheckMatrix: ... + @property + def logical_x(self) -> ParityCheckMatrix: ... + @property + def logical_z(self) -> ParityCheckMatrix: ... + def num_qubits(self) -> int: ... + def num_logical_qubits(self) -> int: ... diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 7d6a6d3aa..64011464c 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -7956,6 +7956,67 @@ fn stabilizer_code_distance( // Module Registration // ============================================================================= +/// A hypergraph-product CSS code built from two classical parity-check matrices. +#[pyclass(name = "HypergraphProductCode", module = "pecos_rslib.qec")] +pub struct PyHypergraphProductCode { + inner: pecos_qec::HypergraphProductCode, +} + +#[pymethods] +impl PyHypergraphProductCode { + /// Build the hypergraph product of two classical parity-check matrices. + #[new] + fn new(h1: &PyParityCheckMatrix, h2: &PyParityCheckMatrix) -> PyResult { + pecos_qec::HypergraphProductCode::new(&h1.inner, &h2.inner) + .map(|inner| Self { inner }) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) + } + + #[getter] + fn hx(&self) -> PyParityCheckMatrix { + PyParityCheckMatrix { + inner: self.inner.hx().clone(), + } + } + + #[getter] + fn hz(&self) -> PyParityCheckMatrix { + PyParityCheckMatrix { + inner: self.inner.hz().clone(), + } + } + + #[getter] + fn logical_x(&self) -> PyParityCheckMatrix { + PyParityCheckMatrix { + inner: self.inner.logical_x().clone(), + } + } + + #[getter] + fn logical_z(&self) -> PyParityCheckMatrix { + PyParityCheckMatrix { + inner: self.inner.logical_z().clone(), + } + } + + fn num_qubits(&self) -> usize { + self.inner.num_qubits() + } + + fn num_logical_qubits(&self) -> usize { + self.inner.num_logical_qubits() + } + + fn __repr__(&self) -> String { + format!( + "HypergraphProductCode(n={}, k={})", + self.inner.num_qubits(), + self.inner.num_logical_qubits() + ) + } +} + /// A validated bivariate-bicycle CSS code. #[pyclass( name = "BivariateBicycleCode", @@ -8119,6 +8180,7 @@ pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; + qec.add_class::()?; // Add DEM equivalence functions qec.add_function(wrap_pyfunction!(compare_dems_exact, &qec)?)?; diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index 92aad1ef3..5d46a5b49 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -48,6 +48,7 @@ FlagViolation, HookError, HookErrorReport, + HypergraphProductCode, InfluenceBuilder, ParsedDem, PauliFrameLookup, @@ -161,6 +162,7 @@ "DemSamplerBuilder", "DetectorErrorModel", "BivariateBicycleCode", + "HypergraphProductCode", "BoundedEnumerationDistance", "Detector", "DistanceProblem", From 03a377a58721042b5720e3f4e61df6570b4bae7f Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 7 Aug 2026 18:35:45 -0600 Subject: [PATCH 36/41] Add subsystem-code dressed distance over the stabilizer-only reduction --- crates/pecos-qec/src/lib.rs | 2 + crates/pecos-qec/src/subsystem_distance.rs | 206 +++++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 crates/pecos-qec/src/subsystem_distance.rs diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index 7a38f46f6..14e1fc991 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -79,6 +79,7 @@ mod memory_circuit; pub mod parity_check_matrix; pub mod stabilizer_code; pub mod stabilizer_code_spec; +pub mod subsystem_distance; pub mod surface; pub use bivariate_bicycle::{ @@ -99,6 +100,7 @@ pub use hypergraph_product::{HypergraphProductCode, HypergraphProductError}; pub use mem_stab::{MemStabError, MemStabSim, MemStabSimBuilder}; pub use memory_circuit::MemoryBasis; pub use parity_check_matrix::{ParityCheckMatrix, ParityCheckMatrixError}; +pub use subsystem_distance::{SubsystemCodeError, subsystem_dressed_distance}; pub use code_distance::{ connected_cluster_code_distance, stabilizer_code_distance, x_distance, z_distance, diff --git a/crates/pecos-qec/src/subsystem_distance.rs b/crates/pecos-qec/src/subsystem_distance.rs new file mode 100644 index 000000000..6cf11c40c --- /dev/null +++ b/crates/pecos-qec/src/subsystem_distance.rs @@ -0,0 +1,206 @@ +// 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. + +//! Dressed distance of subsystem (gauge) codes. +//! +//! A subsystem code's dressed distance is the minimum weight of an error that +//! commutes with every STABILIZER while acting nontrivially on the logical +//! subsystem — gauge operators are free. In the `(H, L)` formulation this is +//! exactly the stabilizer-code distance problem with `H` built from the +//! stabilizer generators only and `L` from the bare logical representatives: +//! gauge-group elements commute with the stabilizers and with the bare +//! logicals, so they are excluded from witnesses automatically, and dressed +//! representatives (bare logicals times gauge operators) are reachable because +//! gauge factors cost weight but violate nothing. + +use crate::code_distance::stabilizer_code_distance; +use crate::distance::DistanceResult; +use crate::distance_problem::DistanceProblemError; +use crate::stabilizer_code_spec::{StabilizerCodeSpec, StabilizerCodeSpecError}; +use pecos_core::{PauliOperator, PauliString}; +use thiserror::Error; + +/// Errors validating a subsystem-code specification. +#[derive(Debug, Error)] +pub enum SubsystemCodeError { + /// A gauge generator anticommutes with a stabilizer. + #[error("gauge generator {gauge} anticommutes with stabilizer {stabilizer}")] + GaugeAnticommutesWithStabilizer { + /// Index of the offending gauge generator. + gauge: usize, + /// Index of the stabilizer it fails against. + stabilizer: usize, + }, + /// A bare logical anticommutes with a gauge generator. + #[error("bare logical {logical} anticommutes with gauge generator {gauge}")] + LogicalAnticommutesWithGauge { + /// Index of the offending logical (Z basis first, then X basis). + logical: usize, + /// Index of the gauge generator it fails against. + gauge: usize, + }, + /// The underlying stabilizer specification was rejected. + #[error(transparent)] + Spec(#[from] StabilizerCodeSpecError), + /// The distance search rejected the specification. + #[error(transparent)] + Distance(DistanceProblemError), +} + +/// Computes the dressed distance of a subsystem code by qubit-support weight. +/// +/// `stabilizers` must be the stabilizer generators (the center of the gauge +/// group up to phases), `gauge_generators` the remaining gauge generators, and +/// the logicals BARE representatives (commuting with the full gauge group). +/// Validation enforces both commutation families before searching; the search +/// itself is the check-driven cluster engine over the stabilizer-only +/// specification. +/// +/// # Errors +/// +/// Returns an error if validation fails or the specification is rejected. +pub fn subsystem_dressed_distance( + num_qubits: usize, + stabilizers: Vec, + gauge_generators: &[PauliString], + logical_zs: Vec, + logical_xs: Vec, + max_weight: usize, +) -> Result, SubsystemCodeError> { + for (g, gauge) in gauge_generators.iter().enumerate() { + for (s, stabilizer) in stabilizers.iter().enumerate() { + if !gauge.commutes_with(stabilizer) { + return Err(SubsystemCodeError::GaugeAnticommutesWithStabilizer { + gauge: g, + stabilizer: s, + }); + } + } + } + for (index, logical) in logical_zs.iter().chain(&logical_xs).enumerate() { + for (g, gauge) in gauge_generators.iter().enumerate() { + if !logical.commutes_with(gauge) { + return Err(SubsystemCodeError::LogicalAnticommutesWithGauge { + logical: index, + gauge: g, + }); + } + } + } + let spec = StabilizerCodeSpec::new(num_qubits, stabilizers, logical_zs, logical_xs)?; + stabilizer_code_distance(&spec, max_weight).map_err(SubsystemCodeError::Distance) +} + +#[cfg(test)] +mod tests { + use super::*; + use pecos_core::{Pauli, QuarterPhase, QubitId}; + + fn pauli(terms: &[(Pauli, usize)]) -> PauliString { + PauliString::with_phase_and_paulis( + QuarterPhase::PlusOne, + terms.iter().map(|&(p, q)| (p, QubitId::new(q))).collect(), + ) + } + + /// Bacon-Shor on an `m x n` grid: qubit (r, c) at index `r * n + c`. + fn bacon_shor( + m: usize, + n: usize, + ) -> (Vec, Vec, PauliString, PauliString) { + let q = |r: usize, c: usize| r * n + c; + let mut gauges = Vec::new(); + for r in 0..m { + for c in 0..n - 1 { + gauges.push(pauli(&[(Pauli::Z, q(r, c)), (Pauli::Z, q(r, c + 1))])); + } + } + for r in 0..m - 1 { + for c in 0..n { + gauges.push(pauli(&[(Pauli::X, q(r, c)), (Pauli::X, q(r + 1, c))])); + } + } + let mut stabilizers = Vec::new(); + for r in 0..m - 1 { + let terms: Vec<_> = (0..n) + .flat_map(|c| [(Pauli::X, q(r, c)), (Pauli::X, q(r + 1, c))]) + .collect(); + stabilizers.push(pauli(&terms)); + } + for c in 0..n - 1 { + let terms: Vec<_> = (0..m) + .flat_map(|r| [(Pauli::Z, q(r, c)), (Pauli::Z, q(r, c + 1))]) + .collect(); + stabilizers.push(pauli(&terms)); + } + let logical_x = pauli(&(0..n).map(|c| (Pauli::X, q(0, c))).collect::>()); + let logical_z = pauli(&(0..m).map(|r| (Pauli::Z, q(r, 0))).collect::>()); + (stabilizers, gauges, logical_z, logical_x) + } + + #[test] + fn square_bacon_shor_dressed_distance_is_three() { + let (stabilizers, gauges, lz, lx) = bacon_shor(3, 3); + let result = subsystem_dressed_distance( + 9, + stabilizers.clone(), + &gauges, + vec![lz.clone()], + vec![lx.clone()], + 9, + ) + .unwrap() + .expect("distance within budget"); + assert_eq!(result.distance, 3); + + // Independent engine: the certified SAT path over the same stabilizer-only spec. + let spec = StabilizerCodeSpec::new(9, stabilizers, vec![lz], vec![lx]).unwrap(); + let problem = crate::DistanceProblem::from_stabilizer_spec(&spec).unwrap(); + let certified = crate::certified_distance(&problem, 3).unwrap().unwrap(); + assert_eq!(certified.distance, 3); + } + + #[test] + fn rectangular_bacon_shor_dressed_distance_is_the_short_side() { + let (stabilizers, gauges, lz, lx) = bacon_shor(2, 3); + let result = subsystem_dressed_distance(6, stabilizers, &gauges, vec![lz], vec![lx], 6) + .unwrap() + .expect("distance within budget"); + assert_eq!(result.distance, 2); + } + + #[test] + fn validation_rejects_a_logical_that_anticommutes_with_a_gauge() { + let (stabilizers, gauges, lz, _lx) = bacon_shor(3, 3); + // X on a single column anticommutes with a horizontal ZZ gauge pair. + let bad_logical_x = pauli(&[(Pauli::X, 0), (Pauli::X, 3), (Pauli::X, 6)]); + let error = + subsystem_dressed_distance(9, stabilizers, &gauges, vec![lz], vec![bad_logical_x], 9) + .unwrap_err(); + assert!(matches!( + error, + SubsystemCodeError::LogicalAnticommutesWithGauge { .. } + )); + } + + #[test] + fn validation_rejects_a_gauge_that_anticommutes_with_a_stabilizer() { + let (stabilizers, mut gauges, lz, lx) = bacon_shor(3, 3); + gauges.push(pauli(&[(Pauli::Z, 0)])); + let error = + subsystem_dressed_distance(9, stabilizers, &gauges, vec![lz], vec![lx], 9).unwrap_err(); + assert!(matches!( + error, + SubsystemCodeError::GaugeAnticommutesWithStabilizer { .. } + )); + } +} From 672c9ada88c2257211037ecceb8e0eb46857b3ea Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 8 Aug 2026 01:03:13 -0600 Subject: [PATCH 37/41] Rename the ambiguous logical-matrix variable in the certification tests --- .../qec/test_fault_tolerance_certification_bindings.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py b/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py index c8d7eeb0d..609294d97 100644 --- a/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py +++ b/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py @@ -204,10 +204,10 @@ def test_bounded_enumeration_bindings_certify_and_return_intervals() -> None: [0, 0, 0, 1, 1, 1, 1], ], ) - l = ParityCheckMatrix([[1, 1, 1, 1, 1, 1, 1]]) - problem = DistanceProblem.from_css_checks(h, l) + logicals = ParityCheckMatrix([[1, 1, 1, 1, 1, 1, 1]]) + problem = DistanceProblem.from_css_checks(h, logicals) - exact = bounded_enumeration_code_distance(h, l, 4) + exact = bounded_enumeration_code_distance(h, logicals, 4) assert exact is not None assert exact.certified assert exact.distance == exact.upper_bound == 3 @@ -217,7 +217,7 @@ def test_bounded_enumeration_bindings_certify_and_return_intervals() -> None: assert exact.max_level is None assert problem.verify_witness(exact.witness) == 3 - interval = bounded_enumeration_code_distance(h, l, 0) + interval = bounded_enumeration_code_distance(h, logicals, 0) assert interval is not None assert not interval.certified assert interval.distance is None From 057b93492a977c301d556a0572f23d0b1f9b2f64 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 8 Aug 2026 17:07:17 -0600 Subject: [PATCH 38/41] Add randomized decoder-based upper bounds on DEM fault distance --- Cargo.lock | 1 + crates/pecos-qec/Cargo.toml | 1 + crates/pecos-qec/src/fault_tolerance.rs | 7 + .../fault_distance_upper_bound.rs | 464 ++++++++++++++++++ crates/pecos-qec/src/lib.rs | 23 +- python/pecos-rslib/pecos_rslib/qec.pyi | 39 ++ .../src/fault_tolerance_bindings.rs | 190 +++++++ .../quantum-pecos/src/pecos/qec/__init__.py | 4 + .../tests/qec/test_fault_distance.py | 90 ++++ 9 files changed, 809 insertions(+), 10 deletions(-) create mode 100644 crates/pecos-qec/src/fault_tolerance/fault_distance_upper_bound.rs diff --git a/Cargo.lock b/Cargo.lock index dd9ee85d1..75a488b85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4653,6 +4653,7 @@ dependencies = [ "ndarray 0.17.2", "pecos-core", "pecos-decoder-core", + "pecos-ldpc-decoders", "pecos-neo", "pecos-num", "pecos-quantum", diff --git a/crates/pecos-qec/Cargo.toml b/crates/pecos-qec/Cargo.toml index 51d9a81ef..e9af84a4c 100644 --- a/crates/pecos-qec/Cargo.toml +++ b/crates/pecos-qec/Cargo.toml @@ -15,6 +15,7 @@ readme = "README.md" ndarray.workspace = true pecos-core.workspace = true pecos-decoder-core.workspace = true +pecos-ldpc-decoders.workspace = true pecos-num.workspace = true pecos-quantum.workspace = true pecos-simulators.workspace = true diff --git a/crates/pecos-qec/src/fault_tolerance.rs b/crates/pecos-qec/src/fault_tolerance.rs index 6ae230d37..1586dc72e 100644 --- a/crates/pecos-qec/src/fault_tolerance.rs +++ b/crates/pecos-qec/src/fault_tolerance.rs @@ -22,6 +22,7 @@ pub mod correlation; pub mod decoder_integration; pub mod dem_builder; pub mod fault_distance; +pub mod fault_distance_upper_bound; pub mod fault_sampler; pub mod flag_verification; pub mod gadget_checker; @@ -51,6 +52,12 @@ pub use fault_distance::{ FaultDistanceError, FaultDistanceResult, connected_cluster_fault_distance, exhaustive_fault_distance, graphlike_fault_distance, per_observable_fault_distances, }; +pub use fault_distance_upper_bound::{ + FaultDistanceBoundKind, FaultDistanceBpMethod, FaultDistanceBpSchedule, + FaultDistanceObservableSubsetStrategy, FaultDistanceOsdMethod, FaultDistanceUpperBoundConfig, + FaultDistanceUpperBoundError, FaultDistanceUpperBoundResult, + randomized_fault_distance_upper_bound, +}; pub use flag_verification::{FlagFaultToleranceReport, FlagViolation}; pub use gadget_checker::{ GadgetAnalysis, GadgetChecker, GadgetConfig, GadgetDecoderAnalysis, GadgetFaultClass, diff --git a/crates/pecos-qec/src/fault_tolerance/fault_distance_upper_bound.rs b/crates/pecos-qec/src/fault_tolerance/fault_distance_upper_bound.rs new file mode 100644 index 000000000..b186f6722 --- /dev/null +++ b/crates/pecos-qec/src/fault_tolerance/fault_distance_upper_bound.rs @@ -0,0 +1,464 @@ +// 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. + +//! Randomized decoder-based upper bounds for detector-error-model fault distance. +//! +//! Each sample augments the detector incidence matrix with an enforced nonempty observable +//! parity, then applies BP-OSD ([arXiv:1904.02703](https://arxiv.org/abs/1904.02703)). Sampling +//! observable combinations follows the randomized upper-bound idea of +//! [arXiv:2308.15140](https://arxiv.org/abs/2308.15140). Every returned decoder vector is checked +//! natively before it can tighten the upper bound. The result is never an exactness claim. + +use super::dem_builder::DetectorErrorModel; +use crate::DistanceProblem; +use ndarray::Array1; +use pecos_ldpc_decoders::{BpOsdDecoder, InputVectorType, SparseMatrix}; +use rand::rngs::SmallRng; +use rand::{RngExt, SeedableRng}; + +pub use pecos_ldpc_decoders::{ + BpMethod as FaultDistanceBpMethod, BpSchedule as FaultDistanceBpSchedule, + OsdMethod as FaultDistanceOsdMethod, +}; + +/// Identifies the mathematical status of a randomized fault-distance result. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FaultDistanceBoundKind { + /// A witnessed upper bound, with no exactness claim. + UpperBound, +} + +/// Selects observable subsets for randomized fault-distance upper-bound samples. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FaultDistanceObservableSubsetStrategy { + /// Try every singleton in index order, then seeded random nonempty subsets. + EachSingleThenRandom, + /// Use seeded random nonempty subsets for every sample. + RandomNonempty, +} + +/// Fully explicit configuration for randomized fault-distance upper-bound sampling. +/// +/// No clock-derived randomness or implicit decoder parameter is used. Repeating a call with the +/// same detector error model and configuration produces the same sample sequence and result. +#[derive(Clone, Debug, PartialEq)] +pub struct FaultDistanceUpperBoundConfig { + /// Maximum number of observable-subset samples to run. + pub samples: usize, + /// Seed for observable-subset sampling. + pub seed: u64, + /// Observable-subset sampling strategy. + pub observable_subset_strategy: FaultDistanceObservableSubsetStrategy, + /// Uniform independent mechanism prior passed to BP-OSD. + pub error_rate: f64, + /// Maximum BP iterations; zero is rejected instead of selecting an implicit adaptive value. + pub max_iterations: usize, + /// BP update method. + pub bp_method: FaultDistanceBpMethod, + /// BP update schedule. + pub bp_schedule: FaultDistanceBpSchedule, + /// Minimum-sum scaling factor. + pub min_sum_scaling_factor: f64, + /// Ordered-statistics postprocessing method. + pub osd_method: FaultDistanceOsdMethod, + /// Ordered-statistics postprocessing order. + pub osd_order: usize, + /// OpenMP thread count passed to the decoder; zero is rejected. + pub omp_threads: usize, +} + +/// A natively verified randomized fault-distance upper bound and its witness. +/// +/// This result is only an upper bound. It does not certify that a lighter undetectable logical +/// fault is absent. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FaultDistanceUpperBoundResult { + /// Hamming weight of the witnessed undetectable logical fault. + pub weight: usize, + /// Witnessing mechanism indices in [`DetectorErrorModel::to_mechanisms`] order. + pub mechanism_indices: Vec, + /// Number of observable-subset samples attempted. + pub samples_run: usize, + /// Always [`FaultDistanceBoundKind::UpperBound`]; this is not an exactness claim. + pub bound_kind: FaultDistanceBoundKind, +} + +/// Error from configuring or running randomized fault-distance upper-bound sampling. +#[derive(Debug, thiserror::Error)] +pub enum FaultDistanceUpperBoundError { + /// The uniform decoder prior must be finite and strictly between zero and one. + #[error("error_rate must be finite and strictly between 0 and 1, got {0}")] + InvalidErrorRate(f64), + /// The BP iteration limit must be explicit and nonzero. + #[error("max_iterations must be greater than zero")] + ZeroMaxIterations, + /// The decoder thread count must be explicit and nonzero. + #[error("omp_threads must be greater than zero")] + ZeroOmpThreads, + /// The minimum-sum scale must be finite and positive. + #[error("min_sum_scaling_factor must be finite and positive, got {0}")] + InvalidMinSumScalingFactor(f64), + /// The mechanism count cannot be represented by the decoder's sparse matrix. + #[error("detector error model has too many mechanisms for the decoder: {0}")] + TooManyMechanisms(usize), + /// Sparse augmented-system construction failed. + #[error("invalid augmented parity system: {0}")] + SparseMatrix(String), + /// BP-OSD construction or decoding failed. + #[error(transparent)] + Decoder(#[from] pecos_ldpc_decoders::LdpcError), +} + +fn validate_config( + config: &FaultDistanceUpperBoundConfig, +) -> Result<(), FaultDistanceUpperBoundError> { + if !config.error_rate.is_finite() || !(0.0..1.0).contains(&config.error_rate) { + return Err(FaultDistanceUpperBoundError::InvalidErrorRate( + config.error_rate, + )); + } + if config.max_iterations == 0 { + return Err(FaultDistanceUpperBoundError::ZeroMaxIterations); + } + if config.omp_threads == 0 { + return Err(FaultDistanceUpperBoundError::ZeroOmpThreads); + } + if !config.min_sum_scaling_factor.is_finite() || config.min_sum_scaling_factor <= 0.0 { + return Err(FaultDistanceUpperBoundError::InvalidMinSumScalingFactor( + config.min_sum_scaling_factor, + )); + } + Ok(()) +} + +fn random_nonempty_subset(rng: &mut SmallRng, observable_count: usize) -> Vec { + let mut subset: Vec<_> = (0..observable_count) + .map(|_| rng.random_bool(0.5)) + .collect(); + if !subset.iter().any(|&selected| selected) { + subset[rng.random_range(0..observable_count)] = true; + } + subset +} + +fn sampled_subset( + sample: usize, + strategy: FaultDistanceObservableSubsetStrategy, + observable_count: usize, + rng: &mut SmallRng, +) -> Vec { + if strategy == FaultDistanceObservableSubsetStrategy::EachSingleThenRandom + && sample < observable_count + { + let mut subset = vec![false; observable_count]; + subset[sample] = true; + subset + } else { + random_nonempty_subset(rng, observable_count) + } +} + +fn verified_mechanism_indices(problem: &DistanceProblem, candidate: &[u8]) -> Option> { + if candidate.iter().any(|&bit| bit > 1) { + return None; + } + let assignment: Vec<_> = candidate.iter().map(|&bit| bit == 1).collect(); + problem.verify_witness(&assignment).ok()?; + Some( + assignment + .iter() + .enumerate() + .filter_map(|(index, &selected)| selected.then_some(index)) + .collect(), + ) +} + +fn update_verified_upper_bound( + problem: &DistanceProblem, + candidate: &[u8], + best: &mut Option>, +) { + let Some(indices) = verified_mechanism_indices(problem, candidate) else { + return; + }; + if best + .as_ref() + .is_none_or(|current| (indices.len(), &indices) < (current.len(), current)) + { + *best = Some(indices); + } +} + +fn detector_entries( + dem: &DetectorErrorModel, + mechanisms: &[(f64, Vec, Vec)], +) -> Result<(usize, Vec, Vec), FaultDistanceUpperBoundError> { + let detector_rows = mechanisms + .iter() + .flat_map(|(_, detectors, _)| detectors) + .map(|&detector| detector as usize + 1) + .max() + .unwrap_or(0) + .max(dem.num_detectors()); + let mut row_indices = Vec::new(); + let mut column_indices = Vec::new(); + for (column, (_, detectors, _)) in mechanisms.iter().enumerate() { + let column = u32::try_from(column) + .map_err(|_| FaultDistanceUpperBoundError::TooManyMechanisms(mechanisms.len()))?; + for &detector in detectors { + row_indices.push(detector); + column_indices.push(column); + } + } + Ok((detector_rows, row_indices, column_indices)) +} + +fn augmented_matrix( + mechanisms: &[(f64, Vec, Vec)], + detector_rows: usize, + detector_row_indices: &[u32], + detector_column_indices: &[u32], + observable_subset: &[bool], +) -> Result, FaultDistanceUpperBoundError> { + let logical_row = u32::try_from(detector_rows).map_err(|_| { + FaultDistanceUpperBoundError::SparseMatrix( + "detector row count exceeds the u32 index space".to_string(), + ) + })?; + let mut row_indices = detector_row_indices.to_vec(); + let mut column_indices = detector_column_indices.to_vec(); + let mut logical_row_nonempty = false; + for (column, (_, _, observables)) in mechanisms.iter().enumerate() { + let odd = observables + .iter() + .filter(|&&observable| observable_subset[observable as usize]) + .count() + % 2 + == 1; + if odd { + logical_row_nonempty = true; + row_indices.push(logical_row); + column_indices.push( + u32::try_from(column).map_err(|_| { + FaultDistanceUpperBoundError::TooManyMechanisms(mechanisms.len()) + })?, + ); + } + } + if !logical_row_nonempty { + return Ok(None); + } + SparseMatrix::from_coo( + detector_rows + 1, + mechanisms.len(), + row_indices, + column_indices, + ) + .map(Some) + .map_err(FaultDistanceUpperBoundError::SparseMatrix) +} + +/// Samples natively verified decoder witnesses to obtain a fault-distance upper bound. +/// +/// For a selected nonempty observable subset, this solves `[H; l_S] e = [0; 1]`, where `l_S` is +/// the XOR of the selected observable rows. A decoded vector can tighten the result only after +/// [`DistanceProblem::verify_witness`] independently checks every detector parity and the full +/// nonzero-observable predicate. Consequently, an invalid decoder output is ignored. +/// +/// `Ok(None)` means that no verified witness was found; it is not a lower bound or an exactness +/// claim. In particular, a zero-sample configuration returns `Ok(None)`. +/// +/// # Errors +/// +/// Returns an error for invalid explicit decoder parameters, an unrepresentable augmented sparse +/// system, or a decoder construction/decoding failure. +pub fn randomized_fault_distance_upper_bound( + dem: &DetectorErrorModel, + config: &FaultDistanceUpperBoundConfig, +) -> Result, FaultDistanceUpperBoundError> { + if config.samples == 0 { + return Ok(None); + } + validate_config(config)?; + + let (mechanisms, _coordinates) = dem.to_mechanisms(); + let observable_count = mechanisms + .iter() + .flat_map(|(_, _, observables)| observables) + .map(|&observable| observable as usize + 1) + .max() + .unwrap_or(0) + .max(dem.num_observables()); + if observable_count == 0 || mechanisms.is_empty() { + return Ok(None); + } + let (detector_rows, detector_row_indices, detector_column_indices) = + detector_entries(dem, &mechanisms)?; + let problem = DistanceProblem::from_dem(dem); + let mut rng = SmallRng::seed_from_u64(config.seed); + let mut best = None; + + for sample in 0..config.samples { + let subset = sampled_subset( + sample, + config.observable_subset_strategy, + observable_count, + &mut rng, + ); + let Some(pcm) = augmented_matrix( + &mechanisms, + detector_rows, + &detector_row_indices, + &detector_column_indices, + &subset, + )? + else { + continue; + }; + let mut decoder = BpOsdDecoder::builder(&pcm) + .error_rate(config.error_rate) + .max_iter(config.max_iterations) + .bp_method(config.bp_method) + .bp_schedule(config.bp_schedule) + .ms_scaling_factor(config.min_sum_scaling_factor) + .osd_method(config.osd_method) + .osd_order(config.osd_order) + .input_vector_type(InputVectorType::Syndrome) + .omp_threads(config.omp_threads) + .serial_schedule_order(Vec::new()) + .random_schedule_seed(-1) + .build()?; + let mut syndrome = Array1::zeros(detector_rows + 1); + syndrome[detector_rows] = 1; + let decoded = decoder.decode(&syndrome.view())?; + update_verified_upper_bound( + &problem, + decoded.decoding.as_slice().unwrap_or(&[]), + &mut best, + ); + } + + Ok(best.map(|mechanism_indices| FaultDistanceUpperBoundResult { + weight: mechanism_indices.len(), + mechanism_indices, + samples_run: config.samples, + bound_kind: FaultDistanceBoundKind::UpperBound, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{DemOutput, FaultMechanism}; + + fn dem_from_effects(effects: &[(Vec, Vec)]) -> DetectorErrorModel { + let mut dem = DetectorErrorModel::new(); + let observable_count = effects + .iter() + .flat_map(|(_, observables)| observables) + .map(|&observable| observable as usize + 1) + .max() + .unwrap_or(0); + for observable in 0..observable_count { + dem.add_observable(DemOutput::new( + u32::try_from(observable).expect("test observable id fits in u32"), + )); + } + for (detectors, observables) in effects { + dem.add_direct_contribution( + FaultMechanism::from_unsorted( + detectors.iter().copied(), + observables.iter().copied(), + ), + 0.01, + ); + } + dem + } + + fn config(samples: usize, seed: u64) -> FaultDistanceUpperBoundConfig { + FaultDistanceUpperBoundConfig { + samples, + seed, + observable_subset_strategy: FaultDistanceObservableSubsetStrategy::EachSingleThenRandom, + error_rate: 0.1, + max_iterations: 100, + bp_method: FaultDistanceBpMethod::ProductSum, + bp_schedule: FaultDistanceBpSchedule::Parallel, + min_sum_scaling_factor: 1.0, + osd_method: FaultDistanceOsdMethod::Osd0, + osd_order: 0, + omp_threads: 1, + } + } + + #[test] + fn repetition_triad_upper_bound_reaches_three() { + let dem = dem_from_effects(&[(vec![0, 1], vec![0]), (vec![0], vec![]), (vec![1], vec![])]); + let result = randomized_fault_distance_upper_bound(&dem, &config(8, 7)) + .expect("valid decoder configuration") + .expect("triad has an undetectable logical fault"); + assert!(result.weight >= 3); + assert_eq!(result.weight, 3, "sampled upper bound reached exact value"); + assert_eq!(result.bound_kind, FaultDistanceBoundKind::UpperBound); + } + + #[test] + fn same_seed_gives_identical_upper_bound_and_witness() { + let dem = dem_from_effects(&[ + (vec![0, 1], vec![0]), + (vec![0], vec![]), + (vec![1], vec![]), + (vec![2], vec![1]), + (vec![2], vec![]), + ]); + let first = randomized_fault_distance_upper_bound(&dem, &config(16, 91)).unwrap(); + let second = randomized_fault_distance_upper_bound(&dem, &config(16, 91)).unwrap(); + assert_eq!(first, second); + } + + #[test] + fn native_verifier_rejects_invalid_decoder_vector() { + let dem = dem_from_effects(&[(vec![0, 1], vec![0]), (vec![0], vec![]), (vec![1], vec![])]); + let problem = DistanceProblem::from_dem(&dem); + let mut best = Some(vec![0, 1, 2]); + + update_verified_upper_bound(&problem, &[1, 0, 0], &mut best); + + assert_eq!(best, Some(vec![0, 1, 2])); + } + + #[test] + fn zero_samples_returns_none() { + let dem = dem_from_effects(&[(vec![], vec![0])]); + assert_eq!( + randomized_fault_distance_upper_bound(&dem, &config(0, 5)).unwrap(), + None + ); + } + + #[test] + fn no_undetectable_logical_fault_returns_none() { + let dem = dem_from_effects(&[(vec![0], vec![0]), (vec![1], vec![])]); + assert_eq!( + randomized_fault_distance_upper_bound(&dem, &config(32, 11)).unwrap(), + None + ); + + let mut empty = DetectorErrorModel::new(); + empty.add_observable(DemOutput::new(0)); + assert_eq!( + randomized_fault_distance_upper_bound(&empty, &config(32, 11)).unwrap(), + None + ); + } +} diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index 14e1fc991..eb3431a53 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -125,16 +125,19 @@ pub use fault_tolerance::{ CircuitDistanceResult, CorrectionResult, DecoderAnalysis, DemOutputKind, DemOutputMetadata, ErrorClass, ErrorCorrectionChecker, ErrorCorrectionConfig, ErrorCorrectionResult, FaultCheckConfig, FaultCheckResult, FaultChecker, FaultClass, FaultConfiguration, - FaultDistanceError, FaultDistanceResult, FaultToleranceAnalysis, FaultToleranceFailure, - FlagFaultToleranceReport, FlagViolation, HookError, HookErrorReport, LookupTableDecoder, - MeasurementRound, PauliFault, PauliFaultIterator, PauliPropChecker, PropagationResult, - SpacetimeLocation, StabilizerFlipAnalysis, StabilizerFlipChecker, StabilizerFlips, - SyndromeAnalysis, SyndromeClass, SyndromeHistory, SyndromeHistoryAnalysis, - SyndromeHistoryResult, anticommutes_with_logical, apply_recovery, classify_fault, - connected_cluster_fault_distance, exhaustive_fault_distance, extract_measurement_rounds, - extract_spacetime_locations, extract_syndrome, get_syndrome_flips, graphlike_fault_distance, - has_syndrome, per_observable_fault_distances, propagate_fault, propagate_faults, - run_circuit_with_faults, run_correction_cycle, + FaultDistanceBoundKind, FaultDistanceBpMethod, FaultDistanceBpSchedule, FaultDistanceError, + FaultDistanceObservableSubsetStrategy, FaultDistanceOsdMethod, FaultDistanceResult, + FaultDistanceUpperBoundConfig, FaultDistanceUpperBoundError, FaultDistanceUpperBoundResult, + FaultToleranceAnalysis, FaultToleranceFailure, FlagFaultToleranceReport, FlagViolation, + HookError, HookErrorReport, LookupTableDecoder, MeasurementRound, PauliFault, + PauliFaultIterator, PauliPropChecker, PropagationResult, SpacetimeLocation, + StabilizerFlipAnalysis, StabilizerFlipChecker, StabilizerFlips, SyndromeAnalysis, + SyndromeClass, SyndromeHistory, SyndromeHistoryAnalysis, SyndromeHistoryResult, + anticommutes_with_logical, apply_recovery, classify_fault, connected_cluster_fault_distance, + exhaustive_fault_distance, extract_measurement_rounds, extract_spacetime_locations, + extract_syndrome, get_syndrome_flips, graphlike_fault_distance, has_syndrome, + per_observable_fault_distances, propagate_fault, propagate_faults, + randomized_fault_distance_upper_bound, run_circuit_with_faults, run_correction_cycle, }; pub use geometry::{CheckSchedule, LogicalOperator, PauliOp, StabilizerCheck, StabilizerColor}; pub use logical_discovery::{ diff --git a/python/pecos-rslib/pecos_rslib/qec.pyi b/python/pecos-rslib/pecos_rslib/qec.pyi index e31974b03..ddddaecc4 100644 --- a/python/pecos-rslib/pecos_rslib/qec.pyi +++ b/python/pecos-rslib/pecos_rslib/qec.pyi @@ -67,6 +67,42 @@ class FaultDistanceResult: def mechanism_indices(self) -> list[int]: ... def __repr__(self) -> str: ... +class FaultDistanceUpperBoundConfig: + """Fully explicit randomized fault-distance upper-bound configuration.""" + + def __init__( + self, + samples: int, + seed: int, + observable_subset_strategy: str, + error_rate: float, + max_iterations: int, + bp_method: str, + bp_schedule: str, + min_sum_scaling_factor: float, + osd_method: str, + osd_order: int, + omp_threads: int, + ) -> None: ... + @property + def samples(self) -> int: ... + @property + def seed(self) -> int: ... + def __repr__(self) -> str: ... + +class FaultDistanceUpperBoundResult: + """A natively verified upper bound, never an exact fault-distance claim.""" + + @property + def weight(self) -> int: ... + @property + def mechanism_indices(self) -> list[int]: ... + @property + def samples_run(self) -> int: ... + @property + def bound_kind(self) -> str: ... + def __repr__(self) -> str: ... + class DetectorErrorModel: """Rust-backed detector error model.""" @@ -74,6 +110,9 @@ class DetectorErrorModel: def connected_cluster_fault_distance(self, max_weight: int) -> FaultDistanceResult | None: ... def per_observable_fault_distances(self, max_weight: int) -> list[FaultDistanceResult | None]: ... def exhaustive_fault_distance(self, max_weight: int) -> FaultDistanceResult | None: ... + def randomized_fault_distance_upper_bound( + self, config: FaultDistanceUpperBoundConfig + ) -> FaultDistanceUpperBoundResult | None: ... def __getattr__(self, name: str) -> Any: ... class CircuitFaultLocation: diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 64011464c..fb6b45f97 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -71,6 +71,15 @@ use pecos_qec::fault_tolerance::fault_distance::{ graphlike_fault_distance as rust_graphlike_fault_distance, per_observable_fault_distances as rust_per_observable_fault_distances, }; +use pecos_qec::fault_tolerance::fault_distance_upper_bound::{ + FaultDistanceBpMethod as RustFaultDistanceBpMethod, + FaultDistanceBpSchedule as RustFaultDistanceBpSchedule, + FaultDistanceObservableSubsetStrategy as RustFaultDistanceObservableSubsetStrategy, + FaultDistanceOsdMethod as RustFaultDistanceOsdMethod, + FaultDistanceUpperBoundConfig as RustFaultDistanceUpperBoundConfig, + FaultDistanceUpperBoundResult as RustFaultDistanceUpperBoundResult, + randomized_fault_distance_upper_bound as rust_randomized_fault_distance_upper_bound, +}; use pecos_qec::fault_tolerance::influence_builder::InfluenceBuilder as RustInfluenceBuilder; use pecos_qec::fault_tolerance::propagator::{ DagFaultAnalyzer as RustDagFaultAnalyzer, DagFaultInfluenceMap as RustDagFaultInfluenceMap, @@ -1316,6 +1325,172 @@ impl PyFaultDistanceResult { } } +/// Fully explicit randomized fault-distance upper-bound configuration. +#[pyclass( + frozen, + name = "FaultDistanceUpperBoundConfig", + module = "pecos_rslib.qec", + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyFaultDistanceUpperBoundConfig { + inner: RustFaultDistanceUpperBoundConfig, +} + +fn parse_fault_distance_subset_strategy( + value: &str, +) -> PyResult { + match value { + "each_single_then_random" => { + Ok(RustFaultDistanceObservableSubsetStrategy::EachSingleThenRandom) + } + "random_nonempty" => Ok(RustFaultDistanceObservableSubsetStrategy::RandomNonempty), + _ => Err(pyo3::exceptions::PyValueError::new_err(format!( + "observable_subset_strategy must be 'each_single_then_random' or 'random_nonempty', got {value:?}" + ))), + } +} + +fn parse_fault_distance_bp_method(value: &str) -> PyResult { + match value { + "product_sum" => Ok(RustFaultDistanceBpMethod::ProductSum), + "minimum_sum" => Ok(RustFaultDistanceBpMethod::MinimumSum), + _ => Err(pyo3::exceptions::PyValueError::new_err(format!( + "bp_method must be 'product_sum' or 'minimum_sum', got {value:?}" + ))), + } +} + +fn parse_fault_distance_bp_schedule(value: &str) -> PyResult { + match value { + "serial" => Ok(RustFaultDistanceBpSchedule::Serial), + "parallel" => Ok(RustFaultDistanceBpSchedule::Parallel), + "serial_relative" => Ok(RustFaultDistanceBpSchedule::SerialRelative), + _ => Err(pyo3::exceptions::PyValueError::new_err(format!( + "bp_schedule must be 'serial', 'parallel', or 'serial_relative', got {value:?}" + ))), + } +} + +fn parse_fault_distance_osd_method(value: &str) -> PyResult { + match value { + "off" => Ok(RustFaultDistanceOsdMethod::Off), + "osd_0" => Ok(RustFaultDistanceOsdMethod::Osd0), + "osd_e" => Ok(RustFaultDistanceOsdMethod::OsdE), + "osd_cs" => Ok(RustFaultDistanceOsdMethod::OsdCs), + _ => Err(pyo3::exceptions::PyValueError::new_err(format!( + "osd_method must be 'off', 'osd_0', 'osd_e', or 'osd_cs', got {value:?}" + ))), + } +} + +#[pymethods] +impl PyFaultDistanceUpperBoundConfig { + #[new] + #[pyo3(signature = (samples, seed, observable_subset_strategy, error_rate, max_iterations, bp_method, bp_schedule, min_sum_scaling_factor, osd_method, osd_order, omp_threads))] + #[allow(clippy::too_many_arguments)] + fn new( + samples: usize, + seed: u64, + observable_subset_strategy: &str, + error_rate: f64, + max_iterations: usize, + bp_method: &str, + bp_schedule: &str, + min_sum_scaling_factor: f64, + osd_method: &str, + osd_order: usize, + omp_threads: usize, + ) -> PyResult { + Ok(Self { + inner: RustFaultDistanceUpperBoundConfig { + samples, + seed, + observable_subset_strategy: parse_fault_distance_subset_strategy( + observable_subset_strategy, + )?, + error_rate, + max_iterations, + bp_method: parse_fault_distance_bp_method(bp_method)?, + bp_schedule: parse_fault_distance_bp_schedule(bp_schedule)?, + min_sum_scaling_factor, + osd_method: parse_fault_distance_osd_method(osd_method)?, + osd_order, + omp_threads, + }, + }) + } + + #[getter] + fn samples(&self) -> usize { + self.inner.samples + } + + #[getter] + fn seed(&self) -> u64 { + self.inner.seed + } + + fn __repr__(&self) -> String { + format!( + "FaultDistanceUpperBoundConfig(samples={}, seed={}, observable_subset_strategy={:?}, error_rate={}, max_iterations={}, bp_method={:?}, bp_schedule={:?}, min_sum_scaling_factor={}, osd_method={:?}, osd_order={}, omp_threads={})", + self.inner.samples, + self.inner.seed, + self.inner.observable_subset_strategy, + self.inner.error_rate, + self.inner.max_iterations, + self.inner.bp_method, + self.inner.bp_schedule, + self.inner.min_sum_scaling_factor, + self.inner.osd_method, + self.inner.osd_order, + self.inner.omp_threads, + ) + } +} + +/// Natively verified randomized fault-distance upper bound. +#[pyclass( + frozen, + name = "FaultDistanceUpperBoundResult", + module = "pecos_rslib.qec", + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyFaultDistanceUpperBoundResult { + #[pyo3(get)] + weight: usize, + #[pyo3(get)] + mechanism_indices: Vec, + #[pyo3(get)] + samples_run: usize, +} + +impl From for PyFaultDistanceUpperBoundResult { + fn from(result: RustFaultDistanceUpperBoundResult) -> Self { + Self { + weight: result.weight, + mechanism_indices: result.mechanism_indices, + samples_run: result.samples_run, + } + } +} + +#[pymethods] +impl PyFaultDistanceUpperBoundResult { + #[getter] + fn bound_kind(&self) -> &'static str { + "upper_bound" + } + + fn __repr__(&self) -> String { + format!( + "FaultDistanceUpperBoundResult(weight={}, mechanism_indices={:?}, samples_run={}, bound_kind='upper_bound')", + self.weight, self.mechanism_indices, self.samples_run + ) + } +} + /// A Detector Error Model (DEM) in standard DEM text format. /// /// This represents the error model of a quantum circuit, mapping error @@ -1743,6 +1918,19 @@ impl PyDetectorErrorModel { rust_exhaustive_fault_distance(&self.inner, max_weight).map(PyFaultDistanceResult::from) } + /// Sample natively verified decoder witnesses for a fault-distance upper bound. + /// + /// A return value is only an upper bound and never certifies exactness. Invalid decoder + /// vectors are discarded by native detector and observable parity checks. + fn randomized_fault_distance_upper_bound( + &self, + config: &PyFaultDistanceUpperBoundConfig, + ) -> PyResult> { + rust_randomized_fault_distance_upper_bound(&self.inner, &config.inner) + .map(|result| result.map(PyFaultDistanceUpperBoundResult::from)) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) + } + /// Convert the DEM to a string in standard DEM format. /// /// Each error mechanism is output with its total probability, with no @@ -8156,6 +8344,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::()?; diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index 5d46a5b49..dee2a4fd6 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -43,6 +43,8 @@ DistanceProblem, EquivalenceResult, FaultDistanceResult, + FaultDistanceUpperBoundConfig, + FaultDistanceUpperBoundResult, FaultLocation, FlagFaultToleranceReport, FlagViolation, @@ -168,6 +170,8 @@ "DistanceProblem", "EquivalenceResult", "FaultDistanceResult", + "FaultDistanceUpperBoundConfig", + "FaultDistanceUpperBoundResult", "FaultLocation", "FlagFaultToleranceReport", "FlagViolation", diff --git a/python/quantum-pecos/tests/qec/test_fault_distance.py b/python/quantum-pecos/tests/qec/test_fault_distance.py index 865772661..79df4cdfc 100644 --- a/python/quantum-pecos/tests/qec/test_fault_distance.py +++ b/python/quantum-pecos/tests/qec/test_fault_distance.py @@ -15,6 +15,24 @@ import pytest +def _upper_bound_config(samples: int, seed: int): + from pecos.qec import FaultDistanceUpperBoundConfig + + return FaultDistanceUpperBoundConfig( + samples=samples, + seed=seed, + observable_subset_strategy="each_single_then_random", + error_rate=0.01, + max_iterations=100, + bp_method="product_sum", + bp_schedule="parallel", + min_sum_scaling_factor=1.0, + osd_method="osd_0", + osd_order=0, + omp_threads=1, + ) + + def _two_observable_dem(): from pecos.qec import DetectorErrorModel from pecos.quantum import TickCircuit @@ -67,6 +85,20 @@ def test_repetition_triad_agrees_across_all_fault_distance_methods() -> None: assert connected.mechanism_indices == exhaustive.mechanism_indices +def test_repetition_triad_randomized_upper_bound_is_sound_and_tight() -> None: + dem = _triad_dem() + exact = dem.connected_cluster_fault_distance(3) + sampled = dem.randomized_fault_distance_upper_bound(_upper_bound_config(8, 17)) + + assert exact is not None + assert sampled is not None + assert sampled.weight >= exact.distance + equality_reached = sampled.weight == exact.distance + assert equality_reached, f"sampled upper bound equality reached: {equality_reached}" + assert sampled.bound_kind == "upper_bound" + assert sampled.samples_run == 8 + + def test_distance_three_rotated_surface_memory_cross_method_agreement() -> None: from pecos.qec import DetectorErrorModel, FaultDistanceResult from pecos.qec.surface import build_memory_circuit @@ -94,6 +126,64 @@ def test_distance_three_rotated_surface_memory_cross_method_agreement() -> None: assert repr(graphlike).startswith("FaultDistanceResult(distance=3, mechanism_indices=[") +def test_distance_three_surface_memory_randomized_upper_bound_is_sound_and_tight() -> None: + from pecos.qec import DetectorErrorModel, FaultDistanceUpperBoundResult + from pecos.qec.surface import build_memory_circuit + + circuit = build_memory_circuit(distance=3, rounds=3, basis="Z") + dem = DetectorErrorModel.from_circuit( + circuit, + p1=0.0, + p2=0.0, + p_meas=0.01, + p_prep=0.0, + ) + exact = dem.graphlike_fault_distance() + sampled = dem.randomized_fault_distance_upper_bound(_upper_bound_config(64, 23)) + + assert exact is not None + assert isinstance(sampled, FaultDistanceUpperBoundResult) + assert sampled.weight >= exact.distance + equality_reached = sampled.weight == exact.distance + assert equality_reached, f"sampled upper bound equality reached: {equality_reached}" + + +def test_randomized_upper_bound_is_deterministic_for_same_seed() -> None: + dem = _two_observable_dem() + config = _upper_bound_config(32, 991) + + first = dem.randomized_fault_distance_upper_bound(config) + second = dem.randomized_fault_distance_upper_bound(config) + + assert first is not None + assert second is not None + assert first.weight == second.weight + assert first.mechanism_indices == second.mechanism_indices + + +def test_randomized_upper_bound_zero_samples_returns_none() -> None: + assert _triad_dem().randomized_fault_distance_upper_bound(_upper_bound_config(0, 1)) is None + + +def test_randomized_upper_bound_returns_none_without_undetectable_logical_fault() -> None: + from pecos.qec import DetectorErrorModel + from pecos.quantum import TickCircuit + + circuit = TickCircuit() + circuit.tick().mz([0]) + circuit.add_detector(records=[-1]) + circuit.add_observable(records=[-1]) + dem = DetectorErrorModel.from_circuit( + circuit, + p1=0.0, + p2=0.0, + p_meas=0.01, + p_prep=0.0, + ) + + assert dem.randomized_fault_distance_upper_bound(_upper_bound_config(32, 5)) is None + + def test_two_observable_connected_cluster_distances_differ() -> None: dem = _two_observable_dem() From c9c484bd5cf127f407001936c695cbd8184f98ad Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 8 Aug 2026 20:00:34 -0600 Subject: [PATCH 39/41] Fix coset-weight width loss, input validation, and zero-weight certification edge cases --- crates/pecos-qec/src/distance_problem.rs | 141 +++++++++++++++--- .../src/fault_tolerance_bindings.rs | 5 +- ..._fault_tolerance_certification_bindings.py | 19 ++- 3 files changed, 136 insertions(+), 29 deletions(-) diff --git a/crates/pecos-qec/src/distance_problem.rs b/crates/pecos-qec/src/distance_problem.rs index 8633bdf3d..46d514dc8 100644 --- a/crates/pecos-qec/src/distance_problem.rs +++ b/crates/pecos-qec/src/distance_problem.rs @@ -65,6 +65,14 @@ pub enum DistanceProblemError { /// Number of columns in the logical matrix. l_width: usize, }, + /// A coset representative contains an entry outside the binary alphabet. + #[error("coset representative entry at index {index} is {value}, expected 0 or 1")] + NonBinaryRepresentative { + /// Index of the invalid entry. + index: usize, + /// Invalid value. + value: u8, + }, /// A stabilizer or logical operator is not in the required CSS form. #[error("stabilizer code spec is not CSS: {component} {index} contains both X and Z support")] NonCssOperator { @@ -684,7 +692,11 @@ impl DistanceProblem { for row in 0..self.h.num_rows() { let support = Self::row_support(&self.h, row); match support.as_slice() { - [] => {} + [] => { + if self.parity_targets[row] == 1 { + builder.parity_clauses.push(Vec::new()); + } + } &[variable] => { let sign = if self.parity_targets[row] == 1 { 1 } else { -1 }; builder @@ -886,11 +898,12 @@ impl DistanceProblem { /// Incrementally certifies distance through `max_weight` using a pluggable SAT solver. /// - /// The solver is called once per bound from 1 upward. Its SAT assignment must contain only - /// the original variables and is checked natively, so SAT soundness does not rely on the - /// solver. Every preceding UNSAT result is trusted; that trust is what turns the verified upper - /// bound into an exact distance. `Ok(None)` means all bounds through `max_weight` were reported - /// UNSAT, establishing only a solver-trusted lower bound greater than `max_weight`. + /// A valid all-zero assignment is returned directly. Otherwise, the solver is called once per + /// bound from 1 upward. Its SAT assignment must contain only the original variables and is + /// checked natively, so SAT soundness does not rely on the solver. Every preceding UNSAT result + /// is trusted; that trust is what turns the verified upper bound into an exact distance. + /// `Ok(None)` means all bounds through `max_weight` were reported UNSAT, establishing only a + /// solver-trusted lower bound greater than `max_weight`. /// /// # Errors /// @@ -917,6 +930,14 @@ impl DistanceProblem { where S: FnMut(&Self, usize) -> SolverAnswer, { + if self.zero_assignment_satisfies() { + return Ok(Some(CertifiedDistance { + distance: 0, + witness: vec![false; self.num_vars], + sat_certified: true, + unsat_trusted_below: 0, + })); + } for weight in 1..=max_weight { match solver(self, weight) { SolverAnswer::Unsat => {} @@ -955,7 +976,8 @@ impl DistanceProblem { /// /// # Errors /// - /// Returns an error if the representative width does not match the group. + /// Returns an error if the representative width does not match the group or if an entry is not + /// binary. pub fn coset_weight_problem( group: &ParityCheckMatrix, representative: &[u8], @@ -967,6 +989,13 @@ impl DistanceProblem { l_width: representative.len(), }); } + if let Some((index, &value)) = representative + .iter() + .enumerate() + .find(|&(_, &value)| value > 1) + { + return Err(DistanceProblemError::NonBinaryRepresentative { index, value }); + } let dual_rows = group.matrix().kernel(); let parity_targets: Vec = dual_rows .iter() @@ -1010,14 +1039,6 @@ pub fn certified_coset_weight( max_weight: usize, ) -> Result, CosetWeightError> { let problem = DistanceProblem::coset_weight_problem(group, representative)?; - if problem.zero_assignment_satisfies() { - return Ok(Some(CertifiedDistance { - distance: 0, - witness: vec![false; problem.num_vars], - sat_certified: true, - unsat_trusted_below: 0, - })); - } certified_distance(&problem, max_weight).map_err(CosetWeightError::Certification) } @@ -1052,7 +1073,7 @@ pub fn certified_stabilizer_coset_weight( }; let group_rows = group.rows(); let num_vars = 2 * num_qubits; - let dual_rows = F2Matrix::from_rows(group_rows).kernel(); + let dual_rows = DistanceProblem::matrix_from_rows(group_rows, num_vars).kernel(); let parity_targets: Vec = dual_rows .iter() .map(|dual| { @@ -1074,14 +1095,6 @@ pub fn certified_stabilizer_coset_weight( require_logical_effect: false, weight_mode: WeightMode::QubitSupport { num_qubits }, }; - if problem.zero_assignment_satisfies() { - return Ok(Some(CertifiedDistance { - distance: 0, - witness: vec![false; num_vars], - sat_certified: true, - unsat_trusted_below: 0, - })); - } certified_distance(&problem, max_weight).map_err(CosetWeightError::Certification) } @@ -1485,6 +1498,29 @@ mod tests { } } + #[test] + fn empty_affine_row_with_target_one_is_encoded_unsatisfiable() { + let problem = DistanceProblem { + h: F2Matrix::zeros(1, 1), + l: F2Matrix::zeros(0, 1), + num_vars: 1, + weight_mode: WeightMode::Bit, + parity_targets: vec![1], + require_logical_effect: false, + }; + let encoding = problem.encode(Some(1)); + + assert_eq!(encoding.groups[0].clauses, vec![Vec::::new()]); + assert_eq!( + solve_with_batsat(&encoding, problem.num_vars), + SolverAnswer::Unsat + ); + assert_eq!( + problem.verify_witness(&[false]), + Err(WitnessError::OddCheck { row: 0 }) + ); + } + #[test] fn symplectic_dimacs_encoding_matches_qubit_support_predicate() { let problem = DistanceProblem::from_stabilizer_spec(&tiny_non_css_spec()).unwrap(); @@ -1662,6 +1698,24 @@ mod tests { assert_eq!(weights, vec![1, 2, 3, 4]); } + #[test] + fn certification_handles_valid_zero_weight_problem_at_zero_bound() { + let problem = + DistanceProblem::coset_weight_problem(&ParityCheckMatrix::zeros(0, 1), &[0]).unwrap(); + let expected = CertifiedDistance { + distance: 0, + witness: vec![false], + sat_certified: true, + unsat_trusted_below: 0, + }; + + assert_eq!(certified_distance(&problem, 0), Ok(Some(expected.clone()))); + assert_eq!( + problem.certify_distance_with(0, |_, _| panic!("zero-weight problem called solver")), + Ok(Some(expected)) + ); + } + #[test] fn honest_mock_certifies_steane_and_repetition_triad() { for (problem, expected) in [ @@ -1929,6 +1983,26 @@ mod tests { assert_eq!(problem.verify_witness(&three.witness), Ok(3)); } + #[test] + fn coset_weight_rejects_non_binary_representative() { + let error = certified_coset_weight(&ParityCheckMatrix::zeros(0, 1), &[3], 1).unwrap_err(); + + assert!( + matches!( + &error, + CosetWeightError::Problem(DistanceProblemError::NonBinaryRepresentative { + index: 0, + value: 3 + }) + ), + "unexpected error: {error}" + ); + assert_eq!( + error.to_string(), + "coset representative entry at index 0 is 3, expected 0 or 1" + ); + } + #[test] fn coset_weight_agrees_with_brute_force_on_seeded_groups() { use rand::rngs::SmallRng; @@ -1978,6 +2052,25 @@ mod tests { assert_eq!(certified.distance, 3); } + #[test] + fn empty_stabilizer_group_preserves_symplectic_width() { + let spec = StabilizerCodeSpec::new(1, Vec::new(), vec![Z(0)], vec![X(0)]).unwrap(); + + let certified = certified_stabilizer_coset_weight(&spec, &X(0), 1) + .unwrap() + .unwrap(); + assert_eq!(certified.distance, 1); + + let profile = logical_coset_weight_profile(&spec, 1).unwrap(); + assert_eq!( + profile + .into_iter() + .map(|entry| entry.unwrap().distance) + .collect::>(), + vec![1, 1] + ); + } + #[test] fn steane_logical_profile_is_all_threes_and_stabilizer_costs_zero() { let hamming = steane_hamming_matrix(); diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index fb6b45f97..4b3034425 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -7773,10 +7773,7 @@ impl PyCertifiedDistance { fn __repr__(&self) -> String { format!( "CertifiedDistance(distance={}, witness_weight={}, sat_certified={}, unsat_trusted_below={})", - self.distance, - self.witness.iter().filter(|&&selected| selected).count(), - self.sat_certified, - self.unsat_trusted_below + self.distance, self.distance, self.sat_certified, self.unsat_trusted_below ) } } diff --git a/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py b/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py index 609294d97..a899e7c11 100644 --- a/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py +++ b/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py @@ -22,12 +22,13 @@ bounded_enumeration_stabilizer_distance, bounded_enumeration_x_distance, bounded_enumeration_z_distance, + certified_stabilizer_coset_weight, connected_cluster_code_distance, stabilizer_code_distance, x_distance, z_distance, ) -from pecos.quantum import ParityCheckMatrix, StabilizerCode, StabilizerCodeSpec, TickCircuit +from pecos.quantum import ParityCheckMatrix, PauliString, StabilizerCode, StabilizerCodeSpec, TickCircuit def _hook_ladder() -> TickCircuit: @@ -196,6 +197,22 @@ def test_steane_certification_from_checks_and_code_spec() -> None: from_checks.verify_witness(corrupted) +def test_certified_distance_repr_uses_verified_qubit_support_weight() -> None: + code = StabilizerCodeSpec( + 2, + [PauliString.from_dense_str("YY")], + [PauliString.from_dense_str("YI")], + [PauliString.from_dense_str("XZ")], + ) + + certified = certified_stabilizer_coset_weight(code, PauliString.from_dense_str("YI"), 2) + + assert certified is not None + assert certified.distance == 1 + assert sum(certified.witness) == 2 + assert "witness_weight=1" in repr(certified) + + def test_bounded_enumeration_bindings_certify_and_return_intervals() -> None: h = ParityCheckMatrix( [ From dd1cfdf290c2d3f113820aeff389980483aa4184 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 8 Aug 2026 20:41:20 -0600 Subject: [PATCH 40/41] Enforce logical-basis completeness and the gauge-center premise in distance searches --- .../src/bounded_enumeration_distance.rs | 4 + crates/pecos-qec/src/code_distance.rs | 75 ++++++- crates/pecos-qec/src/distance_problem.rs | 37 +++- crates/pecos-qec/src/stabilizer_code_spec.rs | 36 +++- crates/pecos-qec/src/subsystem_distance.rs | 204 +++++++++++++++++- 5 files changed, 344 insertions(+), 12 deletions(-) diff --git a/crates/pecos-qec/src/bounded_enumeration_distance.rs b/crates/pecos-qec/src/bounded_enumeration_distance.rs index 106f6856f..88e1878a3 100644 --- a/crates/pecos-qec/src/bounded_enumeration_distance.rs +++ b/crates/pecos-qec/src/bounded_enumeration_distance.rs @@ -747,6 +747,7 @@ pub fn bounded_enumeration_stabilizer_distance( code: &StabilizerCodeSpec, max_level: usize, ) -> Result, DistanceProblemError> { + code.verify_logical_completeness()?; let mechanisms = mechanisms_from_stabilizer_code(code)?; let mut h = F2Matrix::zeros(code.stabilizers().len(), mechanisms.len()); let mut l = F2Matrix::zeros( @@ -776,6 +777,9 @@ pub fn bounded_enumeration_stabilizer_distance_with_backend Result, BoundedEnumerationBackendError> { + code.verify_logical_completeness() + .map_err(DistanceProblemError::from) + .map_err(BoundedEnumerationBackendError::DistanceProblem)?; let mechanisms = mechanisms_from_stabilizer_code(code) .map_err(BoundedEnumerationBackendError::DistanceProblem)?; let mut h = F2Matrix::zeros(code.stabilizers().len(), mechanisms.len()); diff --git a/crates/pecos-qec/src/code_distance.rs b/crates/pecos-qec/src/code_distance.rs index 1a6f6073b..c718501fc 100644 --- a/crates/pecos-qec/src/code_distance.rs +++ b/crates/pecos-qec/src/code_distance.rs @@ -111,8 +111,10 @@ fn single_qubit_pauli(pauli: Pauli, qubit: usize) -> PauliString { pub(crate) fn mechanisms_from_stabilizer_code( code: &StabilizerCodeSpec, ) -> Result, DistanceProblemError> { - // Reuse the symplectic problem constructor for its established out-of-range validation. - DistanceProblem::from_stabilizer_spec(code)?; + // Reuse the symplectic constructor's established out-of-range validation. Logical + // completeness is enforced by each ordinary-code entry point; subsystem distance applies + // its gauge-aware count before reaching this shared mechanism construction. + DistanceProblem::from_stabilizer_spec_without_logical_completeness(code)?; let logicals: Vec<_> = code.logical_zs().iter().chain(code.logical_xs()).collect(); Ok((0..code.num_qubits()) @@ -189,6 +191,15 @@ fn mechanism_witness_to_pauli(num_qubits: usize, mechanism_indices: &[usize]) -> pub fn stabilizer_code_distance( code: &StabilizerCodeSpec, max_weight: usize, +) -> Result, DistanceProblemError> { + code.verify_logical_completeness()?; + stabilizer_code_distance_without_logical_completeness(code, max_weight) +} + +/// Searches after a caller has validated either ordinary or subsystem logical completeness. +pub(crate) fn stabilizer_code_distance_without_logical_completeness( + code: &StabilizerCodeSpec, + max_weight: usize, ) -> Result, DistanceProblemError> { let mechanisms = mechanisms_from_stabilizer_code(code)?; let num_outputs = u32::try_from(code.logical_zs().len() + code.logical_xs().len()) @@ -221,8 +232,11 @@ fn matrix_distance_at_weight( #[cfg(test)] mod tests { use super::*; - use crate::{StabilizerCode, certified_distance}; - use pecos_core::{PauliOperator, X, Y, Ys, Z}; + use crate::{ + StabilizerCode, StabilizerCodeSpecError, bounded_enumeration_stabilizer_distance, + bounded_enumeration_x_distance, bounded_enumeration_z_distance, certified_distance, + }; + use pecos_core::{PauliOperator, X, Xs, Y, Ys, Z, Zs}; use std::time::Instant; fn steane_spec() -> StabilizerCodeSpec { @@ -242,6 +256,59 @@ mod tests { .unwrap() } + fn incomplete_five_qubit_spec() -> StabilizerCodeSpec { + StabilizerCodeSpec::new( + 5, + vec![Xs(1..=4), Zs(1..=4)], + vec![Zs([1, 2])], + vec![Xs([2, 3])], + ) + .unwrap() + } + + fn incomplete_basis_error() -> DistanceProblemError { + DistanceProblemError::StabilizerSpec(StabilizerCodeSpecError::IncompleteLogicalBasis { + supplied_logical_pairs: 1, + num_logical_qubits: 3, + }) + } + + #[test] + fn incomplete_logical_basis_is_rejected_by_all_spec_distance_entry_points() { + let spec = incomplete_five_qubit_spec(); + + assert_eq!( + stabilizer_code_distance(&spec, 5).unwrap_err(), + incomplete_basis_error() + ); + assert_eq!( + DistanceProblem::from_stabilizer_spec(&spec).unwrap_err(), + incomplete_basis_error() + ); + assert_eq!( + DistanceProblem::from_css_code_x_distance(&spec).unwrap_err(), + incomplete_basis_error() + ); + assert_eq!( + DistanceProblem::from_css_code_z_distance(&spec).unwrap_err(), + incomplete_basis_error() + ); + assert_eq!(x_distance(&spec, 5).unwrap_err(), incomplete_basis_error()); + assert_eq!(z_distance(&spec, 5).unwrap_err(), incomplete_basis_error()); + assert_eq!( + bounded_enumeration_x_distance(&spec, 5).unwrap_err(), + incomplete_basis_error() + ); + assert_eq!( + bounded_enumeration_z_distance(&spec, 5).unwrap_err(), + incomplete_basis_error() + ); + assert_eq!( + bounded_enumeration_stabilizer_distance(&spec, 5).unwrap_err(), + incomplete_basis_error() + ); + } + #[test] fn steane_css_distances_agree_with_sat_and_weight_search() { let mut spec = steane_spec(); diff --git a/crates/pecos-qec/src/distance_problem.rs b/crates/pecos-qec/src/distance_problem.rs index 46d514dc8..e6becbe50 100644 --- a/crates/pecos-qec/src/distance_problem.rs +++ b/crates/pecos-qec/src/distance_problem.rs @@ -22,7 +22,7 @@ //! without a proof checker; exactness therefore rests on trusting every solver UNSAT answer below //! the returned distance. -use crate::{DetectorErrorModel, ParityCheckMatrix, StabilizerCodeSpec}; +use crate::{DetectorErrorModel, ParityCheckMatrix, StabilizerCodeSpec, StabilizerCodeSpecError}; use batsat::{BasicSolver, Lit, SolverInterface, lbool}; use pecos_core::PauliOperator; use pecos_quantum::F2Matrix; @@ -57,6 +57,10 @@ enum WeightMode { /// Errors constructing a [`DistanceProblem`]. #[derive(Clone, Debug, PartialEq, Eq, Error)] pub enum DistanceProblemError { + /// The stabilizer specification is not valid for an ordinary-code distance problem. + #[error(transparent)] + StabilizerSpec(#[from] StabilizerCodeSpecError), + /// The check and logical matrices describe different numbers of variables. #[error("distance matrices have different widths: H has {h_width}, L has {l_width}")] MatrixWidthMismatch { @@ -321,6 +325,7 @@ impl DistanceProblem { code: &StabilizerCodeSpec, use_x_operators: bool, ) -> Result { + code.verify_logical_completeness()?; let num_qubits = code.num_qubits(); let mut x_checks = Vec::new(); let mut z_checks = Vec::new(); @@ -371,6 +376,17 @@ impl DistanceProblem { /// Returns [`DistanceProblemError::QubitOutOfRange`] if a stabilizer or logical operator acts /// outside the code width. pub fn from_stabilizer_spec(code: &StabilizerCodeSpec) -> Result { + code.verify_logical_completeness()?; + Self::from_stabilizer_spec_without_logical_completeness(code) + } + + /// Constructs the symplectic problem after a caller has applied its own logical-count rule. + /// + /// Subsystem codes use this only after validating the gauge-aware counting relation, since + /// their protected logical count is smaller than `n - rank(S)` by the gauge-qubit count. + pub(crate) fn from_stabilizer_spec_without_logical_completeness( + code: &StabilizerCodeSpec, + ) -> Result { let num_qubits = code.num_qubits(); let checks = code .stabilizers() @@ -1046,6 +1062,8 @@ pub fn certified_coset_weight( /// /// Uses the plain symplectic representation `[X | Z]` (phases are irrelevant to weight and to /// GF(2) span membership) with the per-qubit-support weight mode, so a `Y` costs one. +/// This deliberately does not require a complete logical basis: the operation measures one +/// supplied representative against the stabilizer group and does not use the code's logicals. /// /// # Errors /// @@ -2052,6 +2070,23 @@ mod tests { assert_eq!(certified.distance, 3); } + #[test] + fn stabilizer_coset_weight_does_not_require_a_complete_logical_basis() { + let spec = StabilizerCodeSpec::new(2, Vec::new(), vec![Z(0)], vec![X(0)]).unwrap(); + assert_eq!( + spec.verify_logical_completeness(), + Err(StabilizerCodeSpecError::IncompleteLogicalBasis { + supplied_logical_pairs: 1, + num_logical_qubits: 2, + }) + ); + + let certified = certified_stabilizer_coset_weight(&spec, &X(0), 2) + .unwrap() + .unwrap(); + assert_eq!(certified.distance, 1); + } + #[test] fn empty_stabilizer_group_preserves_symplectic_width() { let spec = StabilizerCodeSpec::new(1, Vec::new(), vec![Z(0)], vec![X(0)]).unwrap(); diff --git a/crates/pecos-qec/src/stabilizer_code_spec.rs b/crates/pecos-qec/src/stabilizer_code_spec.rs index 44ce78f04..da3f4ea2d 100644 --- a/crates/pecos-qec/src/stabilizer_code_spec.rs +++ b/crates/pecos-qec/src/stabilizer_code_spec.rs @@ -24,7 +24,7 @@ use std::collections::BTreeSet; use thiserror::Error; /// Errors that can occur during stabilizer code verification. -#[derive(Debug, Error)] +#[derive(Clone, Debug, PartialEq, Eq, Error)] pub enum StabilizerCodeSpecError { /// Two stabilizer generators anticommute. #[error("Stabilizer generators {0} and {1} anticommute")] @@ -50,6 +50,17 @@ pub enum StabilizerCodeSpecError { #[error("Stabilizer generators are dependent: rank {rank}, count {count}")] DependentStabilizers { rank: usize, count: usize }, + /// The supplied logical pairs do not form a complete ordinary-code basis. + #[error( + "incomplete logical basis: supplied {supplied_logical_pairs} logical pairs, expected {num_logical_qubits}" + )] + IncompleteLogicalBasis { + /// Number of supplied logical X/Z pairs. + supplied_logical_pairs: usize, + /// Number of logical qubits implied by `n - rank(S)`. + num_logical_qubits: usize, + }, + /// A typed matrix width does not match the builder width. #[error("{matrix} matrix has {actual} qubits, expected {expected}")] MatrixWidthMismatch { @@ -410,6 +421,28 @@ impl StabilizerCodeSpec { // Verification methods // ======================================================================== + /// Verifies that the supplied logical pairs form a complete ordinary stabilizer-code basis. + /// + /// This check is intentionally separate from construction and [`verify`](Self::verify): + /// stabilizer-only specs are valid inputs to logical discovery, and subsystem codes have + /// gauge qubits for which `n - rank(S)` is not the number of protected logical qubits. + /// + /// # Errors + /// + /// Returns [`StabilizerCodeSpecError::IncompleteLogicalBasis`] when the number of supplied + /// logical pairs differs from `n - rank(S)`. + pub fn verify_logical_completeness(&self) -> Result<()> { + let supplied_logical_pairs = self.logical_zs.len(); + let num_logical_qubits = self.num_logical_qubits(); + if supplied_logical_pairs != num_logical_qubits { + return Err(StabilizerCodeSpecError::IncompleteLogicalBasis { + supplied_logical_pairs, + num_logical_qubits, + }); + } + Ok(()) + } + /// Verifies that all stabilizer generators commute with each other. /// /// Returns `Ok(())` if all stabilizers commute. @@ -1230,6 +1263,7 @@ mod tests { // Verify the code assert!(code.verify().is_ok()); + assert_eq!(code.verify_logical_completeness(), Ok(())); } #[test] diff --git a/crates/pecos-qec/src/subsystem_distance.rs b/crates/pecos-qec/src/subsystem_distance.rs index 6cf11c40c..318959231 100644 --- a/crates/pecos-qec/src/subsystem_distance.rs +++ b/crates/pecos-qec/src/subsystem_distance.rs @@ -22,11 +22,12 @@ //! representatives (bare logicals times gauge operators) are reachable because //! gauge factors cost weight but violate nothing. -use crate::code_distance::stabilizer_code_distance; +use crate::code_distance::stabilizer_code_distance_without_logical_completeness; use crate::distance::DistanceResult; use crate::distance_problem::DistanceProblemError; use crate::stabilizer_code_spec::{StabilizerCodeSpec, StabilizerCodeSpecError}; use pecos_core::{PauliOperator, PauliString}; +use pecos_quantum::{PauliSequence, SymplecticMatrix}; use thiserror::Error; /// Errors validating a subsystem-code specification. @@ -48,6 +49,52 @@ pub enum SubsystemCodeError { /// Index of the gauge generator it fails against. gauge: usize, }, + /// The symplectic representation could not be constructed at the declared width. + #[error("invalid subsystem symplectic representation: {details}")] + InvalidSymplecticRepresentation { + /// Underlying explicit-width conversion error. + details: String, + }, + /// The noncentral gauge rank cannot describe paired gauge qubits. + #[error( + "odd gauge rank difference: rank(G)={gauge_rank}, rank(S)={stabilizer_rank}, difference={rank_difference}" + )] + OddGaugeRankDifference { + /// Rank of the full gauge group. + gauge_rank: usize, + /// Rank of the supplied stabilizer group. + stabilizer_rank: usize, + /// `rank(G) - rank(S)`. + rank_difference: usize, + }, + /// The supplied stabilizers do not span the full gauge-group center. + #[error( + "gauge center rank mismatch: rank(center(G))={center_rank}, rank(S)={stabilizer_rank}, rank(G)={gauge_rank}" + )] + GaugeCenterRankMismatch { + /// Rank of the center of the full gauge group. + center_rank: usize, + /// Rank of the supplied stabilizer group. + stabilizer_rank: usize, + /// Rank of the full gauge group. + gauge_rank: usize, + }, + /// The protected logical count is inconsistent with the gauge and stabilizer ranks. + #[error( + "subsystem logical count mismatch: logical_pairs={logical_pairs}, gauge_qubits={gauge_qubits}, n={num_qubits}, rank(S)={stabilizer_rank}; expected logical_pairs + gauge_qubits = n - rank(S) = {stabilizer_complement_rank}" + )] + LogicalCountMismatch { + /// Number of supplied protected logical pairs. + logical_pairs: usize, + /// Gauge-qubit count computed from the gauge-rank difference. + gauge_qubits: usize, + /// Number of physical qubits. + num_qubits: usize, + /// Rank of the supplied stabilizer group. + stabilizer_rank: usize, + /// `n - rank(S)`. + stabilizer_complement_rank: usize, + }, /// The underlying stabilizer specification was rejected. #[error(transparent)] Spec(#[from] StabilizerCodeSpecError), @@ -61,9 +108,9 @@ pub enum SubsystemCodeError { /// `stabilizers` must be the stabilizer generators (the center of the gauge /// group up to phases), `gauge_generators` the remaining gauge generators, and /// the logicals BARE representatives (commuting with the full gauge group). -/// Validation enforces both commutation families before searching; the search -/// itself is the check-driven cluster engine over the stabilizer-only -/// specification. +/// Validation enforces both commutation families, the gauge-center split, and +/// the subsystem logical-count relation before searching. The search itself is +/// the check-driven cluster engine over the stabilizer-only specification. /// /// # Errors /// @@ -97,7 +144,76 @@ pub fn subsystem_dressed_distance( } } let spec = StabilizerCodeSpec::new(num_qubits, stabilizers, logical_zs, logical_xs)?; - stabilizer_code_distance(&spec, max_weight).map_err(SubsystemCodeError::Distance) + spec.verify_stabilizers_commute()?; + + let stabilizer_sequence = PauliSequence::new(spec.stabilizers().to_vec()); + let stabilizer_matrix = + SymplecticMatrix::from_pauli_sequence_ignoring_phase(&stabilizer_sequence, num_qubits) + .map_err( + |error| SubsystemCodeError::InvalidSymplecticRepresentation { + details: error.to_string(), + }, + )?; + let stabilizer_rank = stabilizer_matrix.rank(); + + let full_gauge_sequence = PauliSequence::new( + spec.stabilizers() + .iter() + .chain(gauge_generators) + .cloned() + .collect(), + ); + let full_gauge_matrix = + SymplecticMatrix::from_pauli_sequence_ignoring_phase(&full_gauge_sequence, num_qubits) + .map_err( + |error| SubsystemCodeError::InvalidSymplecticRepresentation { + details: error.to_string(), + }, + )?; + let gauge_rank = full_gauge_matrix.rank(); + let rank_difference = gauge_rank - stabilizer_rank; + if rank_difference % 2 != 0 { + return Err(SubsystemCodeError::OddGaugeRankDifference { + gauge_rank, + stabilizer_rank, + rank_difference, + }); + } + + // A coefficient vector is in the kernel of the generator commutation matrix exactly when + // its product lies in center(G). The coefficient kernel also contains every linear relation + // among the supplied generators, so subtracting that relation-space dimension gives the rank + // of the center inside span(G), including when the gauge list is redundant. + let center_coefficient_nullity = full_gauge_sequence.commutation_matrix().kernel().len(); + let generator_relation_nullity = full_gauge_sequence.len() - gauge_rank; + let center_rank = center_coefficient_nullity - generator_relation_nullity; + if center_rank != stabilizer_rank { + return Err(SubsystemCodeError::GaugeCenterRankMismatch { + center_rank, + stabilizer_rank, + gauge_rank, + }); + } + // Stabilizers commute mutually and with every remaining gauge generator, so S is contained + // in center(G). Equal ranks therefore upgrade containment to equality of the two spans. + + let gauge_qubits = rank_difference / 2; + let logical_pairs = spec.logical_zs().len(); + let stabilizer_complement_rank = num_qubits.saturating_sub(stabilizer_rank); + if logical_pairs + gauge_qubits != stabilizer_complement_rank { + return Err(SubsystemCodeError::LogicalCountMismatch { + logical_pairs, + gauge_qubits, + num_qubits, + stabilizer_rank, + stabilizer_complement_rank, + }); + } + + // Ordinary completeness would reject every subsystem code with a gauge qubit. The + // gauge-aware relation above is the appropriate precondition for this internal search. + stabilizer_code_distance_without_logical_completeness(&spec, max_weight) + .map_err(SubsystemCodeError::Distance) } #[cfg(test)] @@ -164,7 +280,9 @@ mod tests { // Independent engine: the certified SAT path over the same stabilizer-only spec. let spec = StabilizerCodeSpec::new(9, stabilizers, vec![lz], vec![lx]).unwrap(); - let problem = crate::DistanceProblem::from_stabilizer_spec(&spec).unwrap(); + let problem = + crate::DistanceProblem::from_stabilizer_spec_without_logical_completeness(&spec) + .unwrap(); let certified = crate::certified_distance(&problem, 3).unwrap().unwrap(); assert_eq!(certified.distance, 3); } @@ -203,4 +321,78 @@ mod tests { SubsystemCodeError::GaugeAnticommutesWithStabilizer { .. } )); } + + #[test] + fn validation_rejects_the_central_gauge_counterexample_by_odd_rank_first() { + // The five-qubit code is shifted onto qubits 1..=5. Z0 is supplied as a remaining + // gauge generator, while the bare logical Z is Z0 times the five-qubit logical Z. + let stabilizers = vec![ + pauli(&[(Pauli::X, 1), (Pauli::Z, 2), (Pauli::Z, 3), (Pauli::X, 4)]), + pauli(&[(Pauli::X, 2), (Pauli::Z, 3), (Pauli::Z, 4), (Pauli::X, 5)]), + pauli(&[(Pauli::X, 1), (Pauli::X, 3), (Pauli::Z, 4), (Pauli::Z, 5)]), + pauli(&[(Pauli::Z, 1), (Pauli::X, 2), (Pauli::X, 4), (Pauli::Z, 5)]), + ]; + let gauges = vec![pauli(&[(Pauli::Z, 0)])]; + let logical_z = pauli(&(0..=5).map(|qubit| (Pauli::Z, qubit)).collect::>()); + let logical_x = pauli(&(1..=5).map(|qubit| (Pauli::X, qubit)).collect::>()); + + let error = subsystem_dressed_distance( + 6, + stabilizers, + &gauges, + vec![logical_z], + vec![logical_x], + 6, + ) + .unwrap_err(); + // Parity is the cheapest decisive structural check. The center also has rank five rather + // than four, but the odd rank difference is reported first. + assert!(matches!( + error, + SubsystemCodeError::OddGaugeRankDifference { + gauge_rank: 5, + stabilizer_rank: 4, + rank_difference: 1, + } + )); + } + + #[test] + fn validation_rejects_an_even_rank_split_with_an_oversized_center() { + let stabilizers = vec![pauli(&[(Pauli::Z, 0)])]; + let gauges = vec![ + pauli(&[(Pauli::Z, 1)]), + pauli(&[(Pauli::Z, 3)]), + pauli(&[(Pauli::X, 2)]), + pauli(&[(Pauli::Z, 2)]), + ]; + + let error = subsystem_dressed_distance(4, stabilizers, &gauges, Vec::new(), Vec::new(), 4) + .unwrap_err(); + assert!(matches!( + error, + SubsystemCodeError::GaugeCenterRankMismatch { + center_rank: 3, + stabilizer_rank: 1, + gauge_rank: 5, + } + )); + } + + #[test] + fn validation_rejects_an_incomplete_subsystem_logical_basis() { + let (stabilizers, gauges, _lz, _lx) = bacon_shor(2, 3); + let error = subsystem_dressed_distance(6, stabilizers, &gauges, Vec::new(), Vec::new(), 6) + .unwrap_err(); + assert!(matches!( + error, + SubsystemCodeError::LogicalCountMismatch { + logical_pairs: 0, + gauge_qubits: 2, + num_qubits: 6, + stabilizer_rank: 3, + stabilizer_complement_rank: 3, + } + )); + } } From b9dd8cbe3fc2e44aa95e72b67390261394d86132 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 8 Aug 2026 21:34:01 -0600 Subject: [PATCH 41/41] Make distance search outcomes explicit and validate completeness in the enumeration APIs --- .../src/bounded_enumeration_distance.rs | 7 +- crates/pecos-qec/src/code_distance.rs | 59 +++++- crates/pecos-qec/src/distance.rs | 147 ++++++------- crates/pecos-qec/src/distance_problem.rs | 108 ++++++++-- .../stabilizer_flip_checker.rs | 32 ++- crates/pecos-qec/src/lib.rs | 15 +- crates/pecos-qec/src/stabilizer_code_spec.rs | 42 ++-- docs/user-guide/fault-tolerance.md | 8 +- python/pecos-rslib/pecos_rslib/qec.pyi | 40 +++- .../src/fault_tolerance_bindings.rs | 194 +++++++++++++++++- .../src/stabilizer_code_spec_bindings.rs | 35 ++-- .../quantum-pecos/src/pecos/qec/__init__.py | 8 +- .../tests/user_guide_fault_tolerance.rs | 8 +- .../test_stabilizer_code_spec_bindings.py | 28 +++ ..._fault_tolerance_certification_bindings.py | 66 +++++- 15 files changed, 637 insertions(+), 160 deletions(-) diff --git a/crates/pecos-qec/src/bounded_enumeration_distance.rs b/crates/pecos-qec/src/bounded_enumeration_distance.rs index 88e1878a3..205b7ec54 100644 --- a/crates/pecos-qec/src/bounded_enumeration_distance.rs +++ b/crates/pecos-qec/src/bounded_enumeration_distance.rs @@ -875,7 +875,12 @@ mod tests { let bounded = bounded_enumeration_stabilizer_distance(&spec, 5) .unwrap() .unwrap(); - let connected = stabilizer_code_distance(&spec, 3).unwrap().unwrap(); + let connected = match stabilizer_code_distance(&spec, 3).unwrap() { + crate::StabilizerDistanceSearchOutcome::Certified(result) => result, + crate::StabilizerDistanceSearchOutcome::BudgetExhausted { max_weight } => { + panic!("expected certified distance, exhausted weight {max_weight}") + } + }; let symplectic = DistanceProblem::from_stabilizer_spec(&spec).unwrap(); let sat = certified_distance(&symplectic, 3).unwrap().unwrap(); diff --git a/crates/pecos-qec/src/code_distance.rs b/crates/pecos-qec/src/code_distance.rs index c718501fc..b7c686553 100644 --- a/crates/pecos-qec/src/code_distance.rs +++ b/crates/pecos-qec/src/code_distance.rs @@ -29,6 +29,18 @@ use crate::{ use pecos_core::{Pauli, PauliOperator, PauliString, QuarterPhase, QubitId}; use pecos_quantum::F2Matrix; +/// Outcome of a budgeted connected-cluster stabilizer-code distance search. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StabilizerDistanceSearchOutcome { + /// The exact distance and a minimum-weight logical operator were found. + Certified(DistanceResult), + /// No logical operator was found through the requested physical weight. + BudgetExhausted { + /// Largest physical weight included in the search. + max_weight: usize, + }, +} + fn mechanisms_from_matrices(h: &F2Matrix, l: &F2Matrix) -> Vec { assert_eq!( h.num_cols(), @@ -182,8 +194,9 @@ fn mechanism_witness_to_pauli(num_qubits: usize, mechanism_indices: &[usize]) -> /// /// # Errors /// -/// Returns [`DistanceProblemError::QubitOutOfRange`] if an operator addresses a qubit outside the -/// declared code width. +/// Returns a stabilizer-spec error if the logical basis is incomplete or the code encodes no +/// logical qubits. Returns [`DistanceProblemError::QubitOutOfRange`] if an operator addresses a +/// qubit outside the declared code width. /// /// # Panics /// @@ -191,9 +204,14 @@ fn mechanism_witness_to_pauli(num_qubits: usize, mechanism_indices: &[usize]) -> pub fn stabilizer_code_distance( code: &StabilizerCodeSpec, max_weight: usize, -) -> Result, DistanceProblemError> { +) -> Result { code.verify_logical_completeness()?; - stabilizer_code_distance_without_logical_completeness(code, max_weight) + Ok( + match stabilizer_code_distance_without_logical_completeness(code, max_weight)? { + Some(result) => StabilizerDistanceSearchOutcome::Certified(result), + None => StabilizerDistanceSearchOutcome::BudgetExhausted { max_weight }, + }, + ) } /// Searches after a caller has validated either ordinary or subsystem logical completeness. @@ -273,6 +291,15 @@ mod tests { }) } + fn certified(outcome: StabilizerDistanceSearchOutcome) -> DistanceResult { + match outcome { + StabilizerDistanceSearchOutcome::Certified(result) => result, + StabilizerDistanceSearchOutcome::BudgetExhausted { max_weight } => { + panic!("expected certified distance, exhausted weight {max_weight}") + } + } + } + #[test] fn incomplete_logical_basis_is_rejected_by_all_spec_distance_entry_points() { let spec = incomplete_five_qubit_spec(); @@ -309,10 +336,26 @@ mod tests { ); } + #[test] + fn stabilizer_distance_distinguishes_budget_exhaustion_and_no_logical_qubits() { + let five_qubit = + StabilizerCodeSpec::from_stabilizer_code(&StabilizerCode::five_qubit()).unwrap(); + assert_eq!( + stabilizer_code_distance(&five_qubit, 2).unwrap(), + StabilizerDistanceSearchOutcome::BudgetExhausted { max_weight: 2 } + ); + + let stabilizer_state = StabilizerCodeSpec::builder(1).check(Z(0)).build().unwrap(); + assert_eq!( + stabilizer_code_distance(&stabilizer_state, 1).unwrap_err(), + DistanceProblemError::StabilizerSpec(StabilizerCodeSpecError::NoLogicalQubits) + ); + } + #[test] fn steane_css_distances_agree_with_sat_and_weight_search() { let mut spec = steane_spec(); - let searched = spec.calculate_distance().unwrap(); + let searched = spec.calculate_distance().unwrap().unwrap(); assert_eq!(searched.distance, 3); assert_eq!(spec.distance(), Some(3)); @@ -351,8 +394,8 @@ mod tests { fn five_qubit_distance_agrees_with_sat_and_weight_search() { let mut spec = StabilizerCodeSpec::from_stabilizer_code(&StabilizerCode::five_qubit()).unwrap(); - let searched = spec.calculate_distance().unwrap(); - let connected = stabilizer_code_distance(&spec, 3).unwrap().unwrap(); + let searched = spec.calculate_distance().unwrap().unwrap(); + let connected = certified(stabilizer_code_distance(&spec, 3).unwrap()); let problem = DistanceProblem::from_stabilizer_spec(&spec).unwrap(); let certified = certified_distance(&problem, 3).unwrap().unwrap(); @@ -369,7 +412,7 @@ mod tests { #[test] fn yy_code_requires_a_y_mechanism_at_distance_one() { let spec = yy_code(); - let connected = stabilizer_code_distance(&spec, 1).unwrap().unwrap(); + let connected = certified(stabilizer_code_distance(&spec, 1).unwrap()); assert_eq!(connected.distance, 1); assert!( diff --git a/crates/pecos-qec/src/distance.rs b/crates/pecos-qec/src/distance.rs index 1ca33124c..d5168e634 100644 --- a/crates/pecos-qec/src/distance.rs +++ b/crates/pecos-qec/src/distance.rs @@ -15,8 +15,8 @@ //! This module provides algorithms for computing the distance of a stabilizer code //! by exhaustively searching for minimum weight logical operators. -use crate::StabilizerCodeSpec; use crate::stabilizer_code_spec::CodeIndices; +use crate::{StabilizerCodeSpec, StabilizerCodeSpecError}; use pecos_core::{Pauli, PauliString, QubitId}; use rayon::prelude::*; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -37,7 +37,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; const PARALLEL_CANDIDATE_THRESHOLD: usize = 65_536; /// Result of a distance calculation, including the minimum weight logical operator found. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct DistanceResult { /// The code distance (minimum weight of any logical operator). pub distance: usize, @@ -46,7 +46,7 @@ pub struct DistanceResult { } /// A logical operator with information about which logical operations it implements. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct LogicalOperatorInfo { /// The Pauli operator. pub operator: PauliString, @@ -289,11 +289,16 @@ impl Iterator for WeightedPauliIterator { /// # Warning /// This is an exponential-time algorithm. For codes with many qubits, it may take /// a very long time to complete. Use `config.max_weight` to limit the search. -#[must_use] +/// +/// # Errors +/// +/// Returns an error if the supplied logical basis is incomplete or the code encodes no logical +/// qubits. pub fn calculate_distance( code: &StabilizerCodeSpec, config: &DistanceSearchConfig, -) -> Option { +) -> Result, StabilizerCodeSpecError> { + code.verify_logical_completeness()?; let max_weight = config.max_weight.unwrap_or(code.num_qubits()); // Build indices once for O(weight) lookups instead of O(num_stabilizers * weight) @@ -305,89 +310,53 @@ pub fn calculate_distance( } if let Some(pauli) = first_logical_error_at_weight(code, weight, config, &indices) { - return Some(DistanceResult { + return Ok(Some(DistanceResult { distance: weight, min_weight_operator: pauli, - }); + })); } } - None + Ok(None) } /// Check whether a code has a logical error at exactly one physical weight. /// /// Stabilizer and logical column indices are built once for the complete scan. -#[must_use] +/// +/// # Errors +/// +/// Returns an error if the supplied logical basis is incomplete or the code encodes no logical +/// qubits. pub fn has_logical_error_at_weight( code: &StabilizerCodeSpec, weight: usize, config: &DistanceSearchConfig, -) -> bool { +) -> Result { + code.verify_logical_completeness()?; if config.verbose { eprintln!("Checking weight {weight}..."); } let indices = code.build_indices(); - first_logical_error_at_weight(code, weight, config, &indices).is_some() + Ok(first_logical_error_at_weight(code, weight, config, &indices).is_some()) } /// Find all minimum weight logical operators. /// /// Unlike `calculate_distance`, this returns all logical operators of the minimum weight, /// not just one. -#[must_use] -pub fn find_min_weight_logicals( - code: &StabilizerCodeSpec, - config: &DistanceSearchConfig, -) -> Vec { - find_min_weight_logicals_with_info(code, config) - .into_iter() - .map(|info| info.operator) - .collect() -} - -/// Find all minimum weight logical operators with equivalence information. -/// -/// This returns detailed information about each found operator, including which -/// logical operators it's equivalent to (e.g., X0, Z1, X0*Z1, etc.). -/// -/// # Example -/// -/// ``` -/// use pecos_qec::{StabilizerCodeSpec, DistanceSearchConfig, find_min_weight_logicals_with_info}; -/// use pecos_core::{Pauli, PauliString, QubitId, QuarterPhase}; -/// -/// fn pauli_string(paulis: &[(Pauli, usize)]) -> PauliString { -/// PauliString::with_phase_and_paulis( -/// QuarterPhase::PlusOne, -/// paulis.iter().map(|&(p, q)| (p, QubitId::new(q))).collect(), -/// ) -/// } -/// -/// // 3-qubit bit flip code -/// let stab1 = pauli_string(&[(Pauli::Z, 0), (Pauli::Z, 1)]); -/// let stab2 = pauli_string(&[(Pauli::Z, 1), (Pauli::Z, 2)]); -/// let logical_z = pauli_string(&[(Pauli::Z, 0), (Pauli::Z, 1), (Pauli::Z, 2)]); -/// let logical_x = pauli_string(&[(Pauli::X, 0), (Pauli::X, 1), (Pauli::X, 2)]); -/// -/// let code = StabilizerCodeSpec::new(3, vec![stab1, stab2], vec![logical_z], vec![logical_x]).unwrap(); /// -/// let config = DistanceSearchConfig::with_max_weight(2); -/// let logicals = find_min_weight_logicals_with_info(&code, &config); +/// # Errors /// -/// // Each found operator has equivalence info -/// for info in &logicals { -/// println!("Found {} with weight {}, equivalent to {}", -/// info.operator, info.weight, info.equivalence_string()); -/// } -/// ``` -#[must_use] -pub fn find_min_weight_logicals_with_info( +/// Returns an error if the supplied logical basis is incomplete or the code encodes no logical +/// qubits. +pub fn find_min_weight_logicals( code: &StabilizerCodeSpec, config: &DistanceSearchConfig, -) -> Vec { +) -> Result, StabilizerCodeSpecError> { find_shortest_logicals(code, config, 0) + .map(|logicals| logicals.into_iter().map(|info| info.operator).collect()) } /// Find all logical operators from the minimum weight through `delta` weights above it. @@ -395,12 +364,17 @@ pub fn find_min_weight_logicals_with_info( /// The search always starts at weight 1. Once the minimum logical weight is found, /// collection continues through `minimum_weight + delta`, subject to /// `config.max_weight`. -#[must_use] +/// +/// # Errors +/// +/// Returns an error if the supplied logical basis is incomplete or the code encodes no logical +/// qubits. pub fn find_shortest_logicals( code: &StabilizerCodeSpec, config: &DistanceSearchConfig, delta: usize, -) -> Vec { +) -> Result, StabilizerCodeSpecError> { + code.verify_logical_completeness()?; let max_weight = config.max_weight.unwrap_or(code.num_qubits()); let mut results = Vec::new(); let mut found_distance: Option = None; @@ -441,7 +415,7 @@ pub fn find_shortest_logicals( } } - results + Ok(results) } fn first_logical_error_at_weight( @@ -852,6 +826,37 @@ mod tests { .expect("[[17,1,5]] color code should be valid") } + fn incomplete_five_qubit_spec() -> StabilizerCodeSpec { + StabilizerCodeSpec::new( + 5, + vec![Xs(1..=4), Zs(1..=4)], + vec![Zs([1, 2])], + vec![Xs([2, 3])], + ) + .unwrap() + } + + #[test] + fn incomplete_logical_basis_is_rejected_by_exhaustive_distance_apis() { + let code = incomplete_five_qubit_spec(); + let config = DistanceSearchConfig::default(); + let expected = StabilizerCodeSpecError::IncompleteLogicalBasis { + supplied_logical_pairs: 1, + num_logical_qubits: 3, + }; + + assert_eq!(calculate_distance(&code, &config), Err(expected.clone())); + assert_eq!( + has_logical_error_at_weight(&code, 1, &config), + Err(expected.clone()) + ); + assert_eq!( + find_min_weight_logicals(&code, &config), + Err(expected.clone()) + ); + assert_eq!(find_shortest_logicals(&code, &config, 1), Err(expected)); + } + #[test] fn test_weighted_pauli_iterator_weight_1() { let iter = WeightedPauliIterator::new(3, 1, false); @@ -937,7 +942,7 @@ mod tests { .unwrap(); let config = DistanceSearchConfig::default(); - let result = calculate_distance(&code, &config); + let result = calculate_distance(&code, &config).unwrap(); // The minimum weight logical operator for this code is a single Z // (Z on any qubit commutes with ZZ stabilizers and anticommutes with XXX) @@ -954,7 +959,7 @@ mod tests { assert!(code.verify().is_ok()); let config = DistanceSearchConfig::with_max_weight(3); - let result = calculate_distance(&code, &config); + let result = calculate_distance(&code, &config).unwrap(); // The [[5,1,3]] code has distance 3 assert!(result.is_some()); @@ -966,9 +971,9 @@ mod tests { fn test_five_qubit_shortest_logicals_respect_logical_weight_spectrum() { let code = five_qubit_code(); let config = DistanceSearchConfig::default(); - let minimum = find_min_weight_logicals_with_info(&code, &config); - let delta_one = find_shortest_logicals(&code, &config, 1); - let delta_two = find_shortest_logicals(&code, &config, 2); + let minimum = find_shortest_logicals(&code, &config, 0).unwrap(); + let delta_one = find_shortest_logicals(&code, &config, 1).unwrap(); + let delta_two = find_shortest_logicals(&code, &config, 2).unwrap(); assert_eq!(minimum.len(), 30); assert_eq!(delta_one.len(), 30); @@ -1008,10 +1013,10 @@ mod tests { }) .collect(); - let distance = calculate_distance(&code, &config).unwrap(); + let distance = calculate_distance(&code, &config).unwrap().unwrap(); assert_eq!(distance.min_weight_operator, expected[0].1); - let actual = find_shortest_logicals(&code, &config, 2); + let actual = find_shortest_logicals(&code, &config, 2).unwrap(); assert!( actual .iter() @@ -1030,7 +1035,7 @@ mod tests { }) .next() .unwrap(); - let actual_css = calculate_distance(&code, &css_config).unwrap(); + let actual_css = calculate_distance(&code, &css_config).unwrap().unwrap(); assert_eq!(actual_css.distance, expected_css.0); assert_eq!(actual_css.min_weight_operator, expected_css.1); } @@ -1050,7 +1055,7 @@ mod tests { let actual = logical_errors_at_weight(&code, 5, &config, &indices); assert_eq!(actual, expected); - let distance = calculate_distance(&code, &config).unwrap(); + let distance = calculate_distance(&code, &config).unwrap().unwrap(); assert_eq!(distance.distance, 5); assert_eq!(distance.min_weight_operator, expected[0]); } @@ -1067,7 +1072,7 @@ mod tests { .unwrap(); let config = DistanceSearchConfig::with_max_weight(2); - let logicals = find_min_weight_logicals_with_info(&code, &config); + let logicals = find_shortest_logicals(&code, &config, 0).unwrap(); // Should find single-qubit Z errors (equivalent to Z0) // and single-qubit X errors (equivalent to X0) diff --git a/crates/pecos-qec/src/distance_problem.rs b/crates/pecos-qec/src/distance_problem.rs index e6becbe50..382de2ee2 100644 --- a/crates/pecos-qec/src/distance_problem.rs +++ b/crates/pecos-qec/src/distance_problem.rs @@ -133,6 +133,20 @@ pub struct CertifiedDistance { pub unsat_trusted_below: usize, } +/// Outcome of a budgeted classical-code distance certification. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ClassicalDistanceSearchOutcome { + /// The exact minimum weight and a nonzero kernel witness were certified. + Certified(CertifiedDistance), + /// No nonzero kernel element exists because the parity-check matrix has full column rank. + NoNonzeroCodeword, + /// Every weight through the requested bound was certified absent. + BudgetExhausted { + /// Largest Hamming weight included in the search. + max_weight: usize, + }, +} + /// Reasons a proposed SAT witness fails native verification. #[derive(Clone, Debug, PartialEq, Eq, Error)] pub enum WitnessError { @@ -1116,12 +1130,17 @@ pub fn certified_stabilizer_coset_weight( certified_distance(&problem, max_weight).map_err(CosetWeightError::Certification) } -/// Certified coset weight of every logical basis operator of a code (Z basis, then X basis). +/// Certified coset weight of every supplied logical generator (Z generators, then X generators). +/// +/// The minimum of this list is **not** the code distance: for example, two weight-two supplied +/// generators can have a weight-one product in a distinct logical coset. +/// This intentionally operates on the supplied generators and does not require them to form a +/// complete logical basis. /// /// # Errors /// /// Propagates the first failure from [`certified_stabilizer_coset_weight`]. -pub fn logical_coset_weight_profile( +pub fn logical_generator_coset_weights( code: &StabilizerCodeSpec, max_weight: usize, ) -> Result>, CosetWeightError> { @@ -1142,8 +1161,11 @@ pub fn logical_coset_weight_profile( pub fn certified_classical_distance( h: &ParityCheckMatrix, max_weight: usize, -) -> Result, CosetWeightError> { +) -> Result { let n = h.num_qubits(); + if h.rank() == n { + return Ok(ClassicalDistanceSearchOutcome::NoNonzeroCodeword); + } let identity_rows = (0..n) .map(|column| { let mut row = vec![0u8; n]; @@ -1154,7 +1176,12 @@ pub fn certified_classical_distance( let identity = ParityCheckMatrix::from_dense(identity_rows) .map_err(|error| CosetWeightError::Symplectic(error.to_string()))?; let problem = DistanceProblem::from_css_checks(h, &identity)?; - certified_distance(&problem, max_weight).map_err(CosetWeightError::Certification) + certified_distance(&problem, max_weight) + .map(|result| match result { + Some(certified) => ClassicalDistanceSearchOutcome::Certified(certified), + None => ClassicalDistanceSearchOutcome::BudgetExhausted { max_weight }, + }) + .map_err(CosetWeightError::Certification) } /// Errors from the coset-weight and classical-distance entry points. @@ -1246,7 +1273,7 @@ mod tests { bounded_enumeration_code_distance, calculate_distance, connected_cluster_code_distance, connected_cluster_fault_distance, exhaustive_fault_distance, }; - use pecos_core::pauli::{X, Xs, Ys, Z, Zs}; + use pecos_core::pauli::{X, Xs, Y, Ys, Z, Zs}; use pecos_quantum::SymplecticMatrix; use rand::rngs::SmallRng; use rand::{RngExt, SeedableRng}; @@ -1581,7 +1608,9 @@ mod tests { .logical_z(Zs([0, 1, 2, 3, 4, 5, 6])) .build_verified() .unwrap(); - let oracle = calculate_distance(&spec, &DistanceSearchConfig::css()).unwrap(); + let oracle = calculate_distance(&spec, &DistanceSearchConfig::css()) + .unwrap() + .unwrap(); let problem = steane_distance_problem(); assert_eq!(oracle.distance, 3); @@ -1616,7 +1645,7 @@ mod tests { fn batsat_certifies_five_qubit_symplectic_distance_and_logical_witness() { let mut spec = StabilizerCodeSpec::from_stabilizer_code(&StabilizerCode::five_qubit()).unwrap(); - let calculated = spec.calculate_distance().unwrap().distance; + let calculated = spec.calculate_distance().unwrap().unwrap().distance; let oracle = spec.distance().unwrap(); assert_eq!(calculated, oracle); assert_eq!(oracle, 3); @@ -1756,7 +1785,7 @@ mod tests { #[test] fn batsat_certifies_steane_x_and_z_against_existing_oracle() { let mut spec = StabilizerCodeSpec::from_stabilizer_code(&StabilizerCode::steane()).unwrap(); - let oracle = spec.calculate_distance().unwrap().distance; + let oracle = spec.calculate_distance().unwrap().unwrap().distance; assert_eq!(spec.distance(), Some(oracle)); for problem in [ @@ -1946,19 +1975,40 @@ mod tests { #[test] fn classical_distance_matches_known_codes() { let hamming = steane_hamming_matrix(); - let certified = certified_classical_distance(&hamming, 4).unwrap().unwrap(); + let certified = match certified_classical_distance(&hamming, 4).unwrap() { + ClassicalDistanceSearchOutcome::Certified(certified) => certified, + other => panic!("expected certified Hamming distance, got {other:?}"), + }; assert_eq!(certified.distance, 3); let repetition = ParityCheckMatrix::from_dense(vec![vec![1, 1, 0], vec![0, 1, 1]]).unwrap(); - let certified = certified_classical_distance(&repetition, 3) - .unwrap() - .unwrap(); + let certified = match certified_classical_distance(&repetition, 3).unwrap() { + ClassicalDistanceSearchOutcome::Certified(certified) => certified, + other => panic!("expected certified repetition distance, got {other:?}"), + }; assert_eq!(certified.distance, 3); let enumerated = bounded_enumeration_classical_agreement(&hamming, 3); assert_eq!(enumerated, 3); } + #[test] + fn classical_distance_distinguishes_full_rank_from_budget_exhaustion() { + let full_rank = + ParityCheckMatrix::from_dense(vec![vec![1, 0, 0], vec![0, 1, 0], vec![0, 0, 1]]) + .unwrap(); + assert_eq!( + certified_classical_distance(&full_rank, 2).unwrap(), + ClassicalDistanceSearchOutcome::NoNonzeroCodeword + ); + + let repetition = ParityCheckMatrix::from_dense(vec![vec![1, 1, 0], vec![0, 1, 1]]).unwrap(); + assert_eq!( + certified_classical_distance(&repetition, 2).unwrap(), + ClassicalDistanceSearchOutcome::BudgetExhausted { max_weight: 2 } + ); + } + fn bounded_enumeration_classical_agreement(h: &ParityCheckMatrix, expected: usize) -> usize { let n = h.num_qubits(); let identity = ParityCheckMatrix::from_dense( @@ -2096,7 +2146,7 @@ mod tests { .unwrap(); assert_eq!(certified.distance, 1); - let profile = logical_coset_weight_profile(&spec, 1).unwrap(); + let profile = logical_generator_coset_weights(&spec, 1).unwrap(); assert_eq!( profile .into_iter() @@ -2106,6 +2156,36 @@ mod tests { ); } + #[test] + fn generator_coset_minimum_can_exceed_code_distance() { + let spec = StabilizerCodeSpec::new( + 2, + Vec::new(), + vec![Xs([0, 1]), Y(0) & Z(1)], + vec![X(0) & Y(1), Zs([0, 1])], + ) + .unwrap(); + spec.verify().unwrap(); + + let profile = logical_generator_coset_weights(&spec, 2).unwrap(); + assert_eq!( + profile + .into_iter() + .map(|entry| entry.unwrap().distance) + .min(), + Some(2) + ); + let distance = crate::stabilizer_code_distance(&spec, 1).unwrap(); + match distance { + crate::StabilizerDistanceSearchOutcome::Certified(result) => { + assert_eq!(result.distance, 1); + } + other @ crate::StabilizerDistanceSearchOutcome::BudgetExhausted { .. } => { + panic!("expected certified code distance, got {other:?}") + } + } + } + #[test] fn steane_logical_profile_is_all_threes_and_stabilizer_costs_zero() { let hamming = steane_hamming_matrix(); @@ -2113,7 +2193,7 @@ mod tests { builder = builder.checks_from_css(&hamming, &hamming).unwrap(); let spec = builder.build_with_discovered_logicals().unwrap(); - let profile = logical_coset_weight_profile(&spec, 7).unwrap(); + let profile = logical_generator_coset_weights(&spec, 7).unwrap(); assert_eq!(profile.len(), 2); for entry in profile { assert_eq!(entry.unwrap().distance, 3); diff --git a/crates/pecos-qec/src/fault_tolerance/stabilizer_flip_checker.rs b/crates/pecos-qec/src/fault_tolerance/stabilizer_flip_checker.rs index d4043b97b..448eb8c8a 100644 --- a/crates/pecos-qec/src/fault_tolerance/stabilizer_flip_checker.rs +++ b/crates/pecos-qec/src/fault_tolerance/stabilizer_flip_checker.rs @@ -471,8 +471,15 @@ impl<'a> StabilizerFlipChecker<'a> { /// Quick check if any weight-t error causes an undetectable logical error. /// /// Returns early on first failure, more efficient than full analysis. - #[must_use] - pub fn has_undetectable_logical(&self, weight: usize) -> bool { + /// + /// # Errors + /// + /// Returns an error if the code's supplied logical basis is incomplete or it encodes no + /// logical qubits. + pub fn has_undetectable_logical( + &self, + weight: usize, + ) -> Result { crate::distance::has_logical_error_at_weight( self.code, weight, @@ -484,13 +491,20 @@ impl<'a> StabilizerFlipChecker<'a> { /// /// The distance is the minimum weight of an undetectable logical error. /// Returns None if no undetectable logical error is found up to `max_weight`. - #[must_use] - pub fn compute_distance(&self, max_weight: usize) -> Option { + /// + /// # Errors + /// + /// Returns an error if the code's supplied logical basis is incomplete or it encodes no + /// logical qubits. + pub fn compute_distance( + &self, + max_weight: usize, + ) -> Result, crate::StabilizerCodeSpecError> { crate::calculate_distance( self.code, &crate::DistanceSearchConfig::with_max_weight(max_weight), ) - .map(|result| result.distance) + .map(|result| result.map(|distance| distance.distance)) } } @@ -797,7 +811,7 @@ mod tests { // The overall distance is 1 (single Z error is undetectable logical) // because this code doesn't protect against Z errors. - let distance = checker.compute_distance(5); + let distance = checker.compute_distance(5).unwrap(); assert_eq!(distance, Some(1)); } @@ -876,7 +890,7 @@ mod tests { let checker = StabilizerFlipChecker::new(&code); // The [[5,1,3]] code has distance 3 - let distance = checker.compute_distance(5); + let distance = checker.compute_distance(5).unwrap(); assert_eq!(distance, Some(3), "5-qubit code distance should be 3"); } @@ -947,7 +961,7 @@ mod tests { let code = steane_code(); let checker = StabilizerFlipChecker::new(&code); - let distance = checker.compute_distance(5); + let distance = checker.compute_distance(5).unwrap(); assert_eq!(distance, Some(3), "Steane code distance should be 3"); } @@ -957,7 +971,7 @@ mod tests { let checker_distance = StabilizerFlipChecker::new(&code).compute_distance(5); let engine_distance = crate::calculate_distance(&code, &crate::DistanceSearchConfig::with_max_weight(5)) - .map(|result| result.distance); + .map(|result| result.map(|distance| distance.distance)); assert_eq!(checker_distance, engine_distance); } diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index eb3431a53..4bfb3fac0 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -103,19 +103,20 @@ pub use parity_check_matrix::{ParityCheckMatrix, ParityCheckMatrixError}; pub use subsystem_distance::{SubsystemCodeError, subsystem_dressed_distance}; pub use code_distance::{ - connected_cluster_code_distance, stabilizer_code_distance, x_distance, z_distance, + StabilizerDistanceSearchOutcome, connected_cluster_code_distance, stabilizer_code_distance, + x_distance, z_distance, }; pub use distance::{ DistanceResult, DistanceSearchConfig, LogicalOperatorInfo, WeightedPauliIterator, - calculate_distance, find_min_weight_logicals, find_min_weight_logicals_with_info, - find_shortest_logicals, has_logical_error_at_weight, + calculate_distance, find_min_weight_logicals, find_shortest_logicals, + has_logical_error_at_weight, }; pub use distance_problem::{ - CertifiedDistance, CosetWeightError, DistanceCertificationError, DistanceProblem, - DistanceProblemError, SolverAnswer, WitnessError, certified_classical_distance, - certified_coset_weight, certified_distance, certified_stabilizer_coset_weight, - logical_coset_weight_profile, + CertifiedDistance, ClassicalDistanceSearchOutcome, CosetWeightError, + DistanceCertificationError, DistanceProblem, DistanceProblemError, SolverAnswer, WitnessError, + certified_classical_distance, certified_coset_weight, certified_distance, + certified_stabilizer_coset_weight, logical_generator_coset_weights, }; pub use fault_tolerance::dem_builder::{ DecomposedFault, DemBuilder, DemBuilderError, DemOutput, DetectorDef, DetectorErrorModel, diff --git a/crates/pecos-qec/src/stabilizer_code_spec.rs b/crates/pecos-qec/src/stabilizer_code_spec.rs index da3f4ea2d..af0bf1d27 100644 --- a/crates/pecos-qec/src/stabilizer_code_spec.rs +++ b/crates/pecos-qec/src/stabilizer_code_spec.rs @@ -61,6 +61,10 @@ pub enum StabilizerCodeSpecError { num_logical_qubits: usize, }, + /// The stabilizer code encodes no logical qubits, so its distance is undefined. + #[error("stabilizer code encodes no logical qubits, so code distance is undefined")] + NoLogicalQubits, + /// A typed matrix width does not match the builder width. #[error("{matrix} matrix has {actual} qubits, expected {expected}")] MatrixWidthMismatch { @@ -430,7 +434,8 @@ impl StabilizerCodeSpec { /// # Errors /// /// Returns [`StabilizerCodeSpecError::IncompleteLogicalBasis`] when the number of supplied - /// logical pairs differs from `n - rank(S)`. + /// logical pairs differs from `n - rank(S)`, or [`StabilizerCodeSpecError::NoLogicalQubits`] + /// when that complete count is zero and code distance is therefore undefined. pub fn verify_logical_completeness(&self) -> Result<()> { let supplied_logical_pairs = self.logical_zs.len(); let num_logical_qubits = self.num_logical_qubits(); @@ -440,6 +445,9 @@ impl StabilizerCodeSpec { num_logical_qubits, }); } + if num_logical_qubits == 0 { + return Err(StabilizerCodeSpecError::NoLogicalQubits); + } Ok(()) } @@ -749,9 +757,13 @@ impl StabilizerCodeSpec { /// # Returns /// /// A [`crate::DistanceResult`] containing the distance and the first logical error found. - /// Returns `None` if no logical error exists (stabilizer state). - #[must_use] - pub fn calculate_distance(&mut self) -> Option { + /// Returns `None` if no logical error exists within the search budget. + /// + /// # Errors + /// + /// Returns an error if the supplied logical basis is incomplete or the code encodes no + /// logical qubits. + pub fn calculate_distance(&mut self) -> Result> { self.calculate_distance_with_options(&crate::DistanceSearchConfig::default()) } @@ -767,16 +779,20 @@ impl StabilizerCodeSpec { /// /// A [`crate::DistanceResult`] containing the distance and the first logical error found. /// Returns `None` if no logical error exists up to `max_weight`. - #[must_use] + /// + /// # Errors + /// + /// Returns an error if the supplied logical basis is incomplete or the code encodes no + /// logical qubits. pub fn calculate_distance_with_options( &mut self, config: &crate::DistanceSearchConfig, - ) -> Option { - let result = crate::calculate_distance(self, config); + ) -> Result> { + let result = crate::calculate_distance(self, config)?; if let Some(ref r) = result { self.distance = Some(r.distance); } - result + Ok(result) } // ======================================================================== @@ -1508,7 +1524,7 @@ mod tests { StabilizerCodeSpec::new(3, vec![stab1, stab2], vec![logical_z], vec![logical_x]) .unwrap(); - let result = code.calculate_distance(); + let result = code.calculate_distance().unwrap(); assert!(result.is_some()); let result = result.unwrap(); @@ -1556,7 +1572,7 @@ mod tests { ) .unwrap(); - let result = code.calculate_distance(); + let result = code.calculate_distance().unwrap(); assert!(result.is_some()); let result = result.unwrap(); @@ -1602,7 +1618,7 @@ mod tests { // CSS mode should find the same distance for CSS codes let config = crate::DistanceSearchConfig::css(); - let result = code.calculate_distance_with_options(&config); + let result = code.calculate_distance_with_options(&config).unwrap(); assert!(result.is_some()); assert_eq!(result.unwrap().distance, 3); } @@ -1635,7 +1651,7 @@ mod tests { StabilizerCodeSpec::new(5, vec![s1, s2, s3, s4], vec![logical_z], vec![logical_x]) .unwrap(); - let result = code.calculate_distance(); + let result = code.calculate_distance().unwrap(); assert!(result.is_some()); let result = result.unwrap(); @@ -1681,6 +1697,7 @@ mod tests { assert_eq!(code.num_logical_qubits(), 1); assert_eq!( crate::calculate_distance(&code, &crate::DistanceSearchConfig::default()) + .unwrap() .unwrap() .distance, 3 @@ -1705,6 +1722,7 @@ mod tests { assert_eq!( crate::calculate_distance(&code, &crate::DistanceSearchConfig::default()) + .unwrap() .unwrap() .distance, 3 diff --git a/docs/user-guide/fault-tolerance.md b/docs/user-guide/fault-tolerance.md index 2fff540cf..c67360073 100644 --- a/docs/user-guide/fault-tolerance.md +++ b/docs/user-guide/fault-tolerance.md @@ -310,7 +310,7 @@ let code = StabilizerCodeSpec::builder(7) .unwrap(); // Basic distance calculation -let result = calculate_distance(&code, &DistanceSearchConfig::default()); +let result = calculate_distance(&code, &DistanceSearchConfig::default()).unwrap(); if let Some(r) = result { println!("Distance: {}", r.distance); println!("Min-weight operator: {}", r.min_weight_operator); @@ -326,7 +326,7 @@ let result = calculate_distance(&code, &DistanceSearchConfig::with_max_weight(5) ### Finding All Minimum-Weight Logicals ```rust -use pecos_qec::{StabilizerCodeSpec, find_min_weight_logicals_with_info, DistanceSearchConfig}; +use pecos_qec::{StabilizerCodeSpec, find_shortest_logicals, DistanceSearchConfig}; use pecos_core::pauli::{Xs, Zs}; let code = StabilizerCodeSpec::builder(7) @@ -341,7 +341,7 @@ let code = StabilizerCodeSpec::builder(7) .build() .unwrap(); -let logicals = find_min_weight_logicals_with_info(&code, &DistanceSearchConfig::default()); +let logicals = find_shortest_logicals(&code, &DistanceSearchConfig::default(), 0).unwrap(); for op in &logicals { println!("Weight {}: {} (equivalent to {})", op.weight, op.operator, op.equivalence_string()); @@ -398,7 +398,7 @@ let spec = StabilizerCodeSpec::from_stabilizer_code(&code).unwrap(); spec.verify().unwrap(); // 4. Compute distance -let dist = calculate_distance(&spec, &DistanceSearchConfig::default()); +let dist = calculate_distance(&spec, &DistanceSearchConfig::default()).unwrap(); println!("Distance: {:?}", dist.as_ref().map(|r| r.distance)); // 5. Check fault tolerance at weight 1 diff --git a/python/pecos-rslib/pecos_rslib/qec.pyi b/python/pecos-rslib/pecos_rslib/qec.pyi index ddddaecc4..ff7b5e988 100644 --- a/python/pecos-rslib/pecos_rslib/qec.pyi +++ b/python/pecos-rslib/pecos_rslib/qec.pyi @@ -16,7 +16,7 @@ from __future__ import annotations from typing import Any -from pecos_rslib import DistanceResult, ParityCheckMatrix, StabilizerCodeSpec, TickCircuit +from pecos_rslib import ParityCheckMatrix, StabilizerCodeSpec, TickCircuit class BivariateBicycleCode: """A validated bivariate-bicycle CSS code.""" @@ -257,6 +257,38 @@ class CertifiedDistance: def unsat_trusted_below(self) -> int: ... def __repr__(self) -> str: ... +class StabilizerDistanceSearchResult: + """Certified stabilizer distance or a proven lower bound after budget exhaustion.""" + + @property + def certified(self) -> bool: ... + @property + def distance(self) -> int | None: ... + @property + def min_weight_operator(self) -> Any | None: ... + @property + def lower_bound(self) -> int: ... + @property + def max_weight(self) -> int | None: ... + def __repr__(self) -> str: ... + +class ClassicalDistanceSearchResult: + """Certified classical distance, empty kernel, or a budget-exhausted lower bound.""" + + @property + def certified(self) -> bool: ... + @property + def distance(self) -> int | None: ... + @property + def witness(self) -> list[bool] | None: ... + @property + def lower_bound(self) -> int | None: ... + @property + def max_weight(self) -> int | None: ... + @property + def no_nonzero_codeword(self) -> bool: ... + def __repr__(self) -> str: ... + class DistanceProblem: """A binary undetectable-error problem with nonzero logical effect.""" @@ -284,7 +316,7 @@ def connected_cluster_code_distance( ) -> FaultDistanceResult | None: ... def x_distance(spec: StabilizerCodeSpec, max_weight: int) -> FaultDistanceResult | None: ... def z_distance(spec: StabilizerCodeSpec, max_weight: int) -> FaultDistanceResult | None: ... -def stabilizer_code_distance(spec: StabilizerCodeSpec, max_weight: int) -> DistanceResult | None: ... +def stabilizer_code_distance(spec: StabilizerCodeSpec, max_weight: int) -> StabilizerDistanceSearchResult: ... # The native QEC module predates this focused stub. Preserve the untyped behavior of its other # classes and functions until that complete API is migrated rather than falsely narrowing them. @@ -295,8 +327,8 @@ def certified_coset_weight( def certified_stabilizer_coset_weight( code: StabilizerCodeSpec, operator: Any, max_weight: int ) -> CertifiedDistance | None: ... -def logical_coset_weight_profile(code: StabilizerCodeSpec, max_weight: int) -> list[CertifiedDistance | None]: ... -def certified_classical_distance(h: ParityCheckMatrix, max_weight: int) -> CertifiedDistance | None: ... +def logical_generator_coset_weights(code: StabilizerCodeSpec, max_weight: int) -> list[CertifiedDistance | None]: ... +def certified_classical_distance(h: ParityCheckMatrix, max_weight: int) -> ClassicalDistanceSearchResult: ... class HypergraphProductCode: """A hypergraph-product CSS code built from two classical parity-check matrices.""" diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 4b3034425..07f8e450b 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -45,7 +45,7 @@ use crate::code_matrix_bindings::PyParityCheckMatrix; use crate::dag_circuit_bindings::PyTickCircuit; use crate::pecos_array::{Array, ArrayData}; -use crate::stabilizer_code_spec_bindings::{PyDistanceResult, PyStabilizerCodeSpec}; +use crate::stabilizer_code_spec_bindings::PyStabilizerCodeSpec; use pecos_core::gate_type::GateType; use pecos_qec::fault_tolerance::dem_builder::{ ComparisonMethod as RustComparisonMethod, @@ -99,7 +99,10 @@ use pecos_qec::{ }; use pecos_qec::{ BoundedEnumerationDistance as RustBoundedEnumerationDistance, - CertifiedDistance as RustCertifiedDistance, DistanceProblem as RustDistanceProblem, + CertifiedDistance as RustCertifiedDistance, + ClassicalDistanceSearchOutcome as RustClassicalDistanceSearchOutcome, + DistanceProblem as RustDistanceProblem, DistanceResult as RustDistanceResult, + StabilizerDistanceSearchOutcome as RustStabilizerDistanceSearchOutcome, bounded_enumeration_code_distance as rust_bounded_enumeration_code_distance, bounded_enumeration_stabilizer_distance as rust_bounded_enumeration_stabilizer_distance, bounded_enumeration_x_distance as rust_bounded_enumeration_x_distance, @@ -7778,6 +7781,171 @@ impl PyCertifiedDistance { } } +/// Result of a budgeted stabilizer-code distance search. +#[pyclass( + name = "StabilizerDistanceSearchResult", + module = "pecos_rslib.qec", + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyStabilizerDistanceSearchResult { + result: Option, + max_weight: Option, +} + +impl From for PyStabilizerDistanceSearchResult { + fn from(outcome: RustStabilizerDistanceSearchOutcome) -> Self { + match outcome { + RustStabilizerDistanceSearchOutcome::Certified(result) => Self { + result: Some(result), + max_weight: None, + }, + RustStabilizerDistanceSearchOutcome::BudgetExhausted { max_weight } => Self { + result: None, + max_weight: Some(max_weight), + }, + } + } +} + +#[pymethods] +impl PyStabilizerDistanceSearchResult { + #[getter] + fn certified(&self) -> bool { + self.result.is_some() + } + + #[getter] + fn distance(&self) -> Option { + self.result.as_ref().map(|result| result.distance) + } + + #[getter] + fn min_weight_operator(&self) -> Option { + self.result.as_ref().map(|result| { + crate::pauli_bindings::PauliString::from_rust(result.min_weight_operator.clone()) + }) + } + + #[getter] + fn lower_bound(&self) -> usize { + self.result.as_ref().map_or_else( + || self.max_weight.unwrap_or(0).saturating_add(1), + |result| result.distance, + ) + } + + #[getter] + fn max_weight(&self) -> Option { + self.max_weight + } + + fn __repr__(&self) -> String { + match &self.result { + Some(result) => format!( + "StabilizerDistanceSearchResult(certified=True, distance={})", + result.distance + ), + None => format!( + "StabilizerDistanceSearchResult(certified=False, lower_bound={}, max_weight={})", + self.lower_bound(), + self.max_weight.unwrap_or(0) + ), + } + } +} + +/// Result of a budgeted classical-code distance certification. +#[pyclass( + name = "ClassicalDistanceSearchResult", + module = "pecos_rslib.qec", + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyClassicalDistanceSearchResult { + outcome: RustClassicalDistanceSearchOutcome, +} + +impl From for PyClassicalDistanceSearchResult { + fn from(outcome: RustClassicalDistanceSearchOutcome) -> Self { + Self { outcome } + } +} + +#[pymethods] +impl PyClassicalDistanceSearchResult { + #[getter] + fn certified(&self) -> bool { + !matches!( + self.outcome, + RustClassicalDistanceSearchOutcome::BudgetExhausted { .. } + ) + } + + #[getter] + fn distance(&self) -> Option { + match &self.outcome { + RustClassicalDistanceSearchOutcome::Certified(result) => Some(result.distance), + RustClassicalDistanceSearchOutcome::NoNonzeroCodeword + | RustClassicalDistanceSearchOutcome::BudgetExhausted { .. } => None, + } + } + + #[getter] + fn witness(&self) -> Option> { + match &self.outcome { + RustClassicalDistanceSearchOutcome::Certified(result) => Some(result.witness.clone()), + RustClassicalDistanceSearchOutcome::NoNonzeroCodeword + | RustClassicalDistanceSearchOutcome::BudgetExhausted { .. } => None, + } + } + + #[getter] + fn lower_bound(&self) -> Option { + match &self.outcome { + RustClassicalDistanceSearchOutcome::Certified(result) => Some(result.distance), + RustClassicalDistanceSearchOutcome::BudgetExhausted { max_weight } => { + Some(max_weight.saturating_add(1)) + } + RustClassicalDistanceSearchOutcome::NoNonzeroCodeword => None, + } + } + + #[getter] + fn max_weight(&self) -> Option { + match self.outcome { + RustClassicalDistanceSearchOutcome::BudgetExhausted { max_weight } => Some(max_weight), + RustClassicalDistanceSearchOutcome::Certified(_) + | RustClassicalDistanceSearchOutcome::NoNonzeroCodeword => None, + } + } + + #[getter] + fn no_nonzero_codeword(&self) -> bool { + matches!( + self.outcome, + RustClassicalDistanceSearchOutcome::NoNonzeroCodeword + ) + } + + fn __repr__(&self) -> String { + match &self.outcome { + RustClassicalDistanceSearchOutcome::Certified(result) => format!( + "ClassicalDistanceSearchResult(certified=True, distance={})", + result.distance + ), + RustClassicalDistanceSearchOutcome::NoNonzeroCodeword => { + "ClassicalDistanceSearchResult(certified=True, no_nonzero_codeword=True)" + .to_string() + } + RustClassicalDistanceSearchOutcome::BudgetExhausted { max_weight } => format!( + "ClassicalDistanceSearchResult(certified=False, lower_bound={}, max_weight={max_weight})", + max_weight.saturating_add(1) + ), + } + } +} + /// Native lower and upper bounds from bounded generator-row enumeration. #[pyclass( name = "BoundedEnumerationDistance", @@ -8022,13 +8190,17 @@ fn certified_stabilizer_coset_weight( .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) } -/// Certified coset weight of every logical basis operator (Z basis, then X basis). +/// Certified coset weight of every supplied logical generator (Z generators, then X generators). +/// +/// The minimum of this list is not the code distance: two weight-two supplied generators can, +/// for example, have a weight-one product in another logical coset. +/// The supplied generators are measured as given; logical-basis completeness is not required. #[pyfunction] -fn logical_coset_weight_profile( +fn logical_generator_coset_weights( code: &PyStabilizerCodeSpec, max_weight: usize, ) -> PyResult>> { - pecos_qec::logical_coset_weight_profile(&code.inner, max_weight) + pecos_qec::logical_generator_coset_weights(&code.inner, max_weight) .map(|profile| { profile .into_iter() @@ -8043,9 +8215,9 @@ fn logical_coset_weight_profile( fn certified_classical_distance( h: &PyParityCheckMatrix, max_weight: usize, -) -> PyResult> { +) -> PyResult { pecos_qec::certified_classical_distance(&h.inner, max_weight) - .map(|result| result.map(PyCertifiedDistance::from)) + .map(PyClassicalDistanceSearchResult::from) .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) } @@ -8131,9 +8303,9 @@ fn z_distance( fn stabilizer_code_distance( code: &PyStabilizerCodeSpec, max_weight: usize, -) -> PyResult> { +) -> PyResult { rust_stabilizer_code_distance(&code.inner, max_weight) - .map(|result| result.map(PyDistanceResult::from)) + .map(PyStabilizerDistanceSearchResult::from) .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) } @@ -8364,6 +8536,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::()?; @@ -8399,7 +8573,7 @@ pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { qec.add_function(wrap_pyfunction!(certified_distance, &qec)?)?; qec.add_function(wrap_pyfunction!(certified_coset_weight, &qec)?)?; qec.add_function(wrap_pyfunction!(certified_stabilizer_coset_weight, &qec)?)?; - qec.add_function(wrap_pyfunction!(logical_coset_weight_profile, &qec)?)?; + qec.add_function(wrap_pyfunction!(logical_generator_coset_weights, &qec)?)?; qec.add_function(wrap_pyfunction!(certified_classical_distance, &qec)?)?; qec.add_function(wrap_pyfunction!(bb_memory_circuit, &qec)?)?; qec.add_function(wrap_pyfunction!(coloration_memory_circuit, &qec)?)?; diff --git a/python/pecos-rslib/src/stabilizer_code_spec_bindings.rs b/python/pecos-rslib/src/stabilizer_code_spec_bindings.rs index 12dd2355e..c76cd125c 100644 --- a/python/pecos-rslib/src/stabilizer_code_spec_bindings.rs +++ b/python/pecos-rslib/src/stabilizer_code_spec_bindings.rs @@ -15,8 +15,7 @@ use pecos_qec::{ DistanceResult as RustDistanceResult, DistanceSearchConfig, LogicalOperatorInfo as RustLogicalOperatorInfo, StabilizerCodeSpec as RustCodeSpec, - StabilizerCodeSpecBuilder as RustCodeSpecBuilder, calculate_distance, - find_min_weight_logicals_with_info, find_shortest_logicals, + StabilizerCodeSpecBuilder as RustCodeSpecBuilder, calculate_distance, find_shortest_logicals, }; use pyo3::prelude::*; use pyo3::types::PyType; @@ -340,13 +339,15 @@ impl PyStabilizerCodeSpec { max_weight: Option, css: bool, verbose: bool, - ) -> Option { + ) -> PyResult> { let config = DistanceSearchConfig { max_weight, css_only: css, verbose, }; - calculate_distance(&self.inner, &config).map(PyDistanceResult::from) + calculate_distance(&self.inner, &config) + .map(|result| result.map(PyDistanceResult::from)) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) } /// Find all logical operators at the minimum weight searched. @@ -356,16 +357,20 @@ impl PyStabilizerCodeSpec { max_weight: Option, css: bool, verbose: bool, - ) -> Vec { + ) -> PyResult> { let config = DistanceSearchConfig { max_weight, css_only: css, verbose, }; - find_min_weight_logicals_with_info(&self.inner, &config) - .into_iter() - .map(PyLogicalOperatorInfo::from) - .collect() + find_shortest_logicals(&self.inner, &config, 0) + .map(|logicals| { + logicals + .into_iter() + .map(PyLogicalOperatorInfo::from) + .collect() + }) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) } /// Find logical operators through ``delta`` weights above the minimum. @@ -376,16 +381,20 @@ impl PyStabilizerCodeSpec { max_weight: Option, css: bool, verbose: bool, - ) -> Vec { + ) -> PyResult> { let config = DistanceSearchConfig { max_weight, css_only: css, verbose, }; find_shortest_logicals(&self.inner, &config, delta) - .into_iter() - .map(PyLogicalOperatorInfo::from) - .collect() + .map(|logicals| { + logicals + .into_iter() + .map(PyLogicalOperatorInfo::from) + .collect() + }) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) } fn __str__(&self) -> String { diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index dee2a4fd6..2d1854d5c 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -35,6 +35,7 @@ CircuitDistanceResult, CircuitFaultAnalyzer, CircuitFaultLocation, + ClassicalDistanceSearchResult, DagFaultAnalyzer, DagFaultInfluenceMap, DemBuilder, @@ -54,6 +55,7 @@ InfluenceBuilder, ParsedDem, PauliFrameLookup, + StabilizerDistanceSearchResult, assert_dems_equivalent, bb_memory_circuit, bounded_enumeration_code_distance, @@ -68,7 +70,7 @@ compare_dems_exact, compare_dems_statistical, connected_cluster_code_distance, - logical_coset_weight_profile, + logical_generator_coset_weights, stabilizer_code_distance, verify_dem_equivalence, x_distance, @@ -156,6 +158,7 @@ "DagFaultAnalyzer", "DagFaultInfluenceMap", "CertifiedDistance", + "ClassicalDistanceSearchResult", "CircuitDistanceResult", "CircuitFaultAnalyzer", "CircuitFaultLocation", @@ -180,6 +183,7 @@ "InfluenceBuilder", "PauliFrameLookup", "ParsedDem", + "StabilizerDistanceSearchResult", "GuppyDemBuild", "Observable", "assert_dems_equivalent", @@ -192,7 +196,7 @@ "certified_classical_distance", "certified_coset_weight", "certified_stabilizer_coset_weight", - "logical_coset_weight_profile", + "logical_generator_coset_weights", "coloration_memory_circuit", "connected_cluster_code_distance", "compare_dems_exact", diff --git a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_fault_tolerance.rs b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_fault_tolerance.rs index 2de380d96..892086922 100644 --- a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_fault_tolerance.rs +++ b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_fault_tolerance.rs @@ -241,7 +241,7 @@ let code = StabilizerCodeSpec::builder(7) .unwrap(); // Basic distance calculation -let result = calculate_distance(&code, &DistanceSearchConfig::default()); +let result = calculate_distance(&code, &DistanceSearchConfig::default()).unwrap(); if let Some(r) = result { println!("Distance: {}", r.distance); println!("Min-weight operator: {}", r.min_weight_operator); @@ -259,7 +259,7 @@ let result = calculate_distance(&code, &DistanceSearchConfig::with_max_weight(5) #[test] fn test_user_guide_fault_tolerance_rust_10() -> Result<(), Box> { use pecos_core::pauli::{Xs, Zs}; - use pecos_qec::{DemBuilder, DistanceSearchConfig, StabilizerCodeSpec, find_min_weight_logicals_with_info}; + use pecos_qec::{DemBuilder, DistanceSearchConfig, StabilizerCodeSpec, find_shortest_logicals}; use pecos_qec::fault_tolerance::propagator::DagFaultAnalyzer; use pecos_quantum::DagCircuit; // Build a simple parity check circuit @@ -290,7 +290,7 @@ let code = StabilizerCodeSpec::builder(7) .build() .unwrap(); -let logicals = find_min_weight_logicals_with_info(&code, &DistanceSearchConfig::default()); +let logicals = find_shortest_logicals(&code, &DistanceSearchConfig::default(), 0).unwrap(); for op in &logicals { println!("Weight {}: {} (equivalent to {})", op.weight, op.operator, op.equivalence_string()); @@ -370,7 +370,7 @@ let spec = StabilizerCodeSpec::from_stabilizer_code(&code).unwrap(); spec.verify().unwrap(); // 4. Compute distance -let dist = calculate_distance(&spec, &DistanceSearchConfig::default()); +let dist = calculate_distance(&spec, &DistanceSearchConfig::default()).unwrap(); println!("Distance: {:?}", dist.as_ref().map(|r| r.distance)); // 5. Check fault tolerance at weight 1 diff --git a/python/quantum-pecos/tests/pecos/test_stabilizer_code_spec_bindings.py b/python/quantum-pecos/tests/pecos/test_stabilizer_code_spec_bindings.py index 0d3b6d539..2e308a2c2 100644 --- a/python/quantum-pecos/tests/pecos/test_stabilizer_code_spec_bindings.py +++ b/python/quantum-pecos/tests/pecos/test_stabilizer_code_spec_bindings.py @@ -50,6 +50,15 @@ def _repetition_spec() -> StabilizerCodeSpec: ) +def _incomplete_five_qubit_spec() -> StabilizerCodeSpec: + return StabilizerCodeSpec( + 5, + [PauliString.from_dense_str("IZZZZ"), PauliString.from_dense_str("IXXXX")], + [PauliString.from_dense_str("IZZII")], + [PauliString.from_dense_str("IIXXI")], + ) + + def test_five_qubit_hand_built_spec_finds_genuine_weight_three_logical() -> None: spec = _five_qubit_spec() spec.verify() @@ -130,6 +139,25 @@ def test_max_weight_below_true_distance_returns_no_results() -> None: assert spec.shortest_logicals(delta=1, max_weight=2) == [] +@pytest.mark.parametrize( + "search", + [ + lambda spec: spec.distance(), + lambda spec: spec.min_weight_logicals(), + lambda spec: spec.shortest_logicals(delta=1), + ], + ids=["distance", "min-weight-logicals", "shortest-logicals"], +) +def test_incomplete_logical_basis_is_rejected_by_spec_distance_searches( + search: Callable[[StabilizerCodeSpec], object], +) -> None: + with pytest.raises( + ValueError, + match=r"incomplete logical basis: supplied 1 logical pairs, expected 3", + ): + search(_incomplete_five_qubit_spec()) + + def test_five_qubit_shortest_logicals_include_requested_weight_range() -> None: spec = _five_qubit_spec() minimum = spec.min_weight_logicals() diff --git a/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py b/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py index a899e7c11..e96ca8e43 100644 --- a/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py +++ b/python/quantum-pecos/tests/qec/test_fault_tolerance_certification_bindings.py @@ -22,8 +22,10 @@ bounded_enumeration_stabilizer_distance, bounded_enumeration_x_distance, bounded_enumeration_z_distance, + certified_classical_distance, certified_stabilizer_coset_weight, connected_cluster_code_distance, + logical_generator_coset_weights, stabilizer_code_distance, x_distance, z_distance, @@ -262,13 +264,75 @@ def test_non_css_connected_cluster_binding_returns_logical_pauli() -> None: spec = StabilizerCodeSpec.from_stabilizer_code(StabilizerCode.five_qubit()) result = stabilizer_code_distance(spec, 3) - assert result is not None + assert result.certified assert result.distance == 3 assert result.min_weight_operator.weight() == 3 assert all(result.min_weight_operator.commutes_with(stabilizer) for stabilizer in spec.stabilizers) assert any(result.min_weight_operator.anticommutes_with(logical) for logical in spec.logical_zs + spec.logical_xs) +def test_stabilizer_distance_binding_reports_budget_exhaustion_and_no_logicals() -> None: + spec = StabilizerCodeSpec.from_stabilizer_code(StabilizerCode.five_qubit()) + exhausted = stabilizer_code_distance(spec, 2) + + assert not exhausted.certified + assert exhausted.distance is None + assert exhausted.min_weight_operator is None + assert exhausted.lower_bound == 3 + assert exhausted.max_weight == 2 + + stabilizer_state = StabilizerCodeSpec( + 1, + [PauliString.from_dense_str("Z")], + [], + [], + ) + with pytest.raises(ValueError, match="encodes no logical qubits"): + stabilizer_code_distance(stabilizer_state, 1) + + +def test_classical_distance_binding_distinguishes_empty_kernel_from_budget() -> None: + full_rank = ParityCheckMatrix( + [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ], + ) + nonexistent = certified_classical_distance(full_rank, 2) + assert nonexistent.certified + assert nonexistent.no_nonzero_codeword + assert nonexistent.distance is None + assert nonexistent.witness is None + assert nonexistent.lower_bound is None + assert nonexistent.max_weight is None + + repetition = ParityCheckMatrix([[1, 1, 0], [0, 1, 1]]) + exhausted = certified_classical_distance(repetition, 2) + assert not exhausted.certified + assert not exhausted.no_nonzero_codeword + assert exhausted.distance is None + assert exhausted.witness is None + assert exhausted.lower_bound == 3 + assert exhausted.max_weight == 2 + + +def test_logical_generator_profile_minimum_is_not_code_distance() -> None: + spec = StabilizerCodeSpec( + 2, + [], + [PauliString.from_dense_str("XX"), PauliString.from_dense_str("YZ")], + [PauliString.from_dense_str("XY"), PauliString.from_dense_str("ZZ")], + ) + spec.verify() + + profile = logical_generator_coset_weights(spec, 2) + assert min(entry.distance for entry in profile if entry is not None) == 2 + distance = stabilizer_code_distance(spec, 1) + assert distance.certified + assert distance.distance == 1 + + def test_triad_dem_certification_agrees_with_exhaustive_distance() -> None: dem = _triad_dem() problem = DistanceProblem.from_dem(dem)