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()