From 80f63c71c00d1b4c69e51ebddb9e496980736a91 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 14:54:35 -0600 Subject: [PATCH 01/10] Add pecos-frontier: native Frontier approximate logical-ML decoder --- Cargo.lock | 10 + Cargo.toml | 1 + crates/pecos-decoders/Cargo.toml | 4 +- crates/pecos-decoders/src/lib.rs | 5 + crates/pecos-frontier/Cargo.toml | 25 ++ crates/pecos-frontier/README.md | 3 + crates/pecos-frontier/src/lib.rs | 495 ++++++++++++++++++++++++ crates/pecos-frontier/tests/frontier.rs | 400 +++++++++++++++++++ 8 files changed, 942 insertions(+), 1 deletion(-) create mode 100644 crates/pecos-frontier/Cargo.toml create mode 100644 crates/pecos-frontier/README.md create mode 100644 crates/pecos-frontier/src/lib.rs create mode 100644 crates/pecos-frontier/tests/frontier.rs diff --git a/Cargo.lock b/Cargo.lock index caf09cea5..c323b0459 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4284,6 +4284,7 @@ version = "0.2.0-dev.0" dependencies = [ "pecos-chromobius", "pecos-decoder-core", + "pecos-frontier", "pecos-fusion-blossom", "pecos-ldpc-decoders", "pecos-mwpf", @@ -4363,6 +4364,15 @@ dependencies = [ "pecos-simulators", ] +[[package]] +name = "pecos-frontier" +version = "0.2.0-dev.0" +dependencies = [ + "pecos-decoder-core", + "rand 0.10.2", + "rand_xoshiro 0.8.1", +] + [[package]] name = "pecos-fusion-blossom" version = "0.2.0-dev.0" diff --git a/Cargo.toml b/Cargo.toml index d3078c698..aa1c3dff8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -202,6 +202,7 @@ pecos-eeg = { version = "0.2.0-dev.0", path = "exp/pecos-eeg" } pecos-stab-tn = { version = "0.2.0-dev.0", path = "exp/pecos-stab-tn" } pecos-experimental = { version = "0.2.0-dev.0", path = "exp/pecos-experimental" } pecos-foreign = { version = "0.2.0-dev.0", path = "crates/pecos-foreign" } +pecos-frontier = { version = "0.2.0-dev.0", path = "crates/pecos-frontier" } pecos-fusion-blossom = { version = "0.2.0-dev.0", path = "crates/pecos-fusion-blossom" } pecos-gpu-sims = { version = "0.2.0-dev.0", path = "crates/pecos-gpu-sims" } pecos-hugr = { version = "0.2.0-dev.0", path = "crates/pecos-hugr" } diff --git a/crates/pecos-decoders/Cargo.toml b/crates/pecos-decoders/Cargo.toml index 4e219d9dd..2b21e272a 100644 --- a/crates/pecos-decoders/Cargo.toml +++ b/crates/pecos-decoders/Cargo.toml @@ -13,6 +13,7 @@ description = "Unified decoder meta-crate for PECOS" [dependencies] pecos-decoder-core.workspace = true +pecos-frontier = { workspace = true, optional = true } pecos-ldpc-decoders = { workspace = true, optional = true } pecos-fusion-blossom = { workspace = true, optional = true } pecos-mwpf = { workspace = true, optional = true } @@ -24,6 +25,7 @@ pecos-uf-decoder = { workspace = true, optional = true } [features] default = [] +frontier = ["dep:pecos-frontier"] ldpc = ["dep:pecos-ldpc-decoders"] fusion-blossom = ["dep:pecos-fusion-blossom"] mwpf = ["dep:pecos-mwpf"] @@ -32,7 +34,7 @@ tesseract = ["dep:pecos-tesseract"] chromobius = ["dep:pecos-chromobius"] relay-bp = ["dep:pecos-relay-bp"] uf = ["dep:pecos-uf-decoder"] -all = ["ldpc", "fusion-blossom", "mwpf", "pymatching", "tesseract", "chromobius", "relay-bp", "uf"] +all = ["frontier", "ldpc", "fusion-blossom", "mwpf", "pymatching", "tesseract", "chromobius", "relay-bp", "uf"] [lints] workspace = true diff --git a/crates/pecos-decoders/src/lib.rs b/crates/pecos-decoders/src/lib.rs index 31b60cf46..68d809ad7 100644 --- a/crates/pecos-decoders/src/lib.rs +++ b/crates/pecos-decoders/src/lib.rs @@ -5,6 +5,7 @@ //! //! ## Features //! +//! - `frontier` - Frontier approximate logical maximum-likelihood decoder (pure Rust) //! - `ldpc` - LDPC decoders (BP-OSD, BP-LSD, Union-Find, etc.) //! - `fusion-blossom` - Fusion Blossom MWPM decoder (pure Rust) //! - `pymatching` - `PyMatching` MWPM decoder (C++ FFI) @@ -26,6 +27,10 @@ pub use pecos_decoder_core::logical_subgraph::{ QubitStabCoords, StabCoords, StabType, partition_dem_by_logical, }; +// Re-export Frontier decoder when feature is enabled +#[cfg(feature = "frontier")] +pub use pecos_frontier::{FrontierConfig, FrontierDecoder, FrontierLogicalMass, FrontierResult}; + // Re-export LDPC decoders when feature is enabled #[cfg(feature = "ldpc")] pub use pecos_ldpc_decoders::{ diff --git a/crates/pecos-frontier/Cargo.toml b/crates/pecos-frontier/Cargo.toml new file mode 100644 index 000000000..035f06df6 --- /dev/null +++ b/crates/pecos-frontier/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "pecos-frontier" +version.workspace = true +edition.workspace = true +readme = "README.md" +authors.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true +keywords.workspace = true +categories.workspace = true +description = "Frontier approximate logical maximum-likelihood decoder for PECOS" + +[dependencies] +pecos-decoder-core.workspace = true + +[lib] +name = "pecos_frontier" + +[dev-dependencies] +rand.workspace = true +rand_xoshiro.workspace = true + +[lints] +workspace = true diff --git a/crates/pecos-frontier/README.md b/crates/pecos-frontier/README.md new file mode 100644 index 000000000..c15b38023 --- /dev/null +++ b/crates/pecos-frontier/README.md @@ -0,0 +1,3 @@ +# PECOS Frontier Decoder + +Native Rust implementation of the Frontier approximate logical maximum-likelihood decoder. diff --git a/crates/pecos-frontier/src/lib.rs b/crates/pecos-frontier/src/lib.rs new file mode 100644 index 000000000..8b3c2bc14 --- /dev/null +++ b/crates/pecos-frontier/src/lib.rs @@ -0,0 +1,495 @@ +// 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. + +//! Frontier approximate logical maximum-likelihood decoding. +//! +//! The decoder performs ordered dynamic programming over independent binary +//! fault mechanisms. Prefixes with identical active detector boundary and +//! logical labels are merged by log-sum-exp, preserving degeneracy mass. The +//! configured frontier width and log-mass window provide deterministic pruning. + +use pecos_decoder_core::ObservableDecoder; +use pecos_decoder_core::dem::SparseDem; +use pecos_decoder_core::errors::DecoderError; +use pecos_decoder_core::obs_mask::ObsMask; +use std::cmp::Ordering; +use std::collections::BTreeMap; + +const WORD_BITS: usize = u64::BITS as usize; + +/// Frontier pruning and column-order configuration. +/// +/// The [`Default`] pruning values are provisional pending benchmarking. +#[derive(Clone, Debug, PartialEq)] +pub struct FrontierConfig { + /// Maximum number of boundary states retained after each column. + pub k: usize, + /// Log-mass window below the best boundary state retained after each column. + pub delta: f64, + /// Optional permutation of the DEM mechanism indices. + pub column_order: Option>, +} + +impl Default for FrontierConfig { + fn default() -> Self { + // Provisional defaults pending benchmarking. + Self { + k: 64, + delta: 50.0, + column_order: None, + } + } +} + +/// Retained posterior log mass for one logical label. +#[derive(Clone, Debug, PartialEq)] +pub struct FrontierLogicalMass { + /// Logical-observable flip label. + pub logical: ObsMask, + /// Natural logarithm of the retained probability mass for this label. + pub log_mass: f64, +} + +/// Result of one Frontier decode. +#[derive(Clone, Debug, PartialEq)] +pub struct FrontierResult { + /// Predicted logical-observable flip mask. + pub predicted: ObsMask, + /// Natural logarithm of the retained probability mass of `predicted`. + pub log_evidence: f64, + /// Difference between the winning and runner-up log masses, if one exists. + pub runner_up_gap: Option, + /// Largest retained frontier size, including the initial boundary state. + pub peak_retained_states: usize, + /// Number of nonzero-probability columns processed. + pub processed_columns: usize, + /// Retained terminal masses, ordered by mass descending and label ascending. + pub logical_masses: Vec, +} + +#[derive(Clone, Debug)] +struct Column { + detector_toggle: Vec, + logical_toggle: Vec, + close_mask: Vec, + active_mask: Vec, + log_odds: f64, + log_one_minus_probability: f64, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct StateKey { + active_syndrome: Vec, + logical: Vec, +} + +#[derive(Clone, Debug)] +struct Candidate { + key: StateKey, + log_mass: f64, +} + +/// Ordered, pruned dynamic-programming decoder for sparse detector error models. +#[derive(Clone, Debug)] +pub struct FrontierDecoder { + config: FrontierConfig, + columns: Vec, + num_detectors: usize, + detector_words: usize, + logical_words: usize, + touched_detectors: Vec, +} + +impl FrontierDecoder { + /// Construct a decoder from a sparse detector error model. + /// + /// Zero-probability mechanisms are discarded after validating the optional + /// ordering permutation. All indices and nonzero probabilities are checked. + /// + /// # Errors + /// + /// Returns [`DecoderError::InvalidConfiguration`] for invalid probabilities, + /// indices, or column order. + pub fn from_sparse_dem(dem: &SparseDem, config: FrontierConfig) -> Result { + validate_column_order(&config, dem.mechanisms.len())?; + + let detector_words = words_for(dem.num_detectors); + let logical_words = words_for(dem.num_observables); + let order = config + .column_order + .clone() + .unwrap_or_else(|| (0..dem.mechanisms.len()).collect()); + let mut raw_columns = Vec::with_capacity(dem.mechanisms.len()); + + for mechanism_index in order { + let (probability, detectors, observables) = &dem.mechanisms[mechanism_index]; + validate_probability(*probability, mechanism_index)?; + validate_indices(detectors, dem.num_detectors, "detector", mechanism_index)?; + validate_indices( + observables, + dem.num_observables, + "observable", + mechanism_index, + )?; + if *probability == 0.0 { + continue; + } + + raw_columns.push(( + indices_to_words(detectors, detector_words), + indices_to_words(observables, logical_words), + *probability, + )); + } + + let mut touched_detectors = vec![0; detector_words]; + let mut last_touch = vec![None; dem.num_detectors]; + for (column_index, (detectors, _, _)) in raw_columns.iter().enumerate() { + or_assign(&mut touched_detectors, detectors); + for detector in set_bits(detectors) { + last_touch[detector] = Some(column_index); + } + } + + let mut open_detectors = vec![0; detector_words]; + let mut columns = Vec::with_capacity(raw_columns.len()); + for (column_index, (detector_toggle, logical_toggle, probability)) in + raw_columns.into_iter().enumerate() + { + or_assign(&mut open_detectors, &detector_toggle); + let mut close_mask = vec![0; detector_words]; + for (detector, &last) in last_touch.iter().enumerate() { + if last == Some(column_index) { + set_bit(&mut close_mask, detector); + } + } + and_not_assign(&mut open_detectors, &close_mask); + + columns.push(Column { + detector_toggle, + logical_toggle, + close_mask, + active_mask: open_detectors.clone(), + log_odds: (probability / (1.0 - probability)).ln(), + log_one_minus_probability: (1.0 - probability).ln(), + }); + } + + Ok(Self { + config, + columns, + num_detectors: dem.num_detectors, + detector_words, + logical_words, + touched_detectors, + }) + } + + /// Parse a Stim-format detector error model and construct a decoder. + /// + /// # Errors + /// + /// Returns [`DecoderError`] if parsing or decoder validation fails. + pub fn from_dem_str(dem_str: &str, config: FrontierConfig) -> Result { + let dem = SparseDem::from_dem_str(dem_str)?; + Self::from_sparse_dem(&dem, config) + } + + /// Decode a dense detector syndrome. + /// + /// Every nonzero byte is treated as a fired detector. + /// + /// # Errors + /// + /// Returns [`DecoderError`] for a dimension mismatch or when the syndrome + /// is unexplainable with the retained frontier. + pub fn decode(&mut self, syndrome: &[u8]) -> Result { + if syndrome.len() != self.num_detectors { + return Err(DecoderError::InvalidDimensions { + expected: self.num_detectors, + actual: syndrome.len(), + }); + } + + let observed = syndrome_to_words(syndrome, self.detector_words); + if observed + .iter() + .zip(&self.touched_detectors) + .any(|(&seen, &touched)| seen & !touched != 0) + { + return Err(unexplainable_error()); + } + + let initial = StateKey { + active_syndrome: vec![0; self.detector_words], + logical: vec![0; self.logical_words], + }; + let mut frontier = BTreeMap::from([(initial, 0.0)]); + let mut peak_retained_states = frontier.len(); + + for column in &self.columns { + let mut merged = BTreeMap::new(); + for (state, &log_mass) in &frontier { + let branch_base = log_mass + column.log_one_minus_probability; + merge_branch(&mut merged, state.clone(), branch_base, column, &observed); + + let mut taken = state.clone(); + xor_assign(&mut taken.active_syndrome, &column.detector_toggle); + xor_assign(&mut taken.logical, &column.logical_toggle); + merge_branch( + &mut merged, + taken, + branch_base + column.log_odds, + column, + &observed, + ); + } + + if merged.is_empty() { + return Err(unexplainable_error()); + } + frontier = prune(merged, self.config.k, self.config.delta); + if frontier.is_empty() { + return Err(unexplainable_error()); + } + peak_retained_states = peak_retained_states.max(frontier.len()); + } + + let mut terminal: Vec = frontier + .into_iter() + .map(|(key, log_mass)| Candidate { key, log_mass }) + .collect(); + sort_candidates(&mut terminal); + let winner = &terminal[0]; + let logical_masses = terminal + .iter() + .map(|candidate| FrontierLogicalMass { + logical: ObsMask::from_words(&candidate.key.logical), + log_mass: candidate.log_mass, + }) + .collect(); + + Ok(FrontierResult { + predicted: ObsMask::from_words(&winner.key.logical), + log_evidence: winner.log_mass, + runner_up_gap: terminal + .get(1) + .map(|runner_up| winner.log_mass - runner_up.log_mass), + peak_retained_states, + processed_columns: self.columns.len(), + logical_masses, + }) + } +} + +impl ObservableDecoder for FrontierDecoder { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + Ok(self.decode(syndrome)?.predicted) + } + + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + if self.logical_words > 1 { + return Err(DecoderError::InvalidConfiguration( + "decoder has more than 64 observables; use decode_obs() for the wide mask".into(), + )); + } + let decoded = self.decode(syndrome)?.predicted; + Ok(decoded.words().first().copied().unwrap_or(0)) + } +} + +fn validate_column_order( + config: &FrontierConfig, + mechanism_count: usize, +) -> Result<(), DecoderError> { + if let Some(order) = &config.column_order { + if order.len() != mechanism_count { + return Err(DecoderError::InvalidConfiguration(format!( + "column_order must be a permutation of 0..{mechanism_count}" + ))); + } + let mut seen = vec![false; mechanism_count]; + for &index in order { + if index >= mechanism_count || seen[index] { + return Err(DecoderError::InvalidConfiguration(format!( + "column_order must be a permutation of 0..{mechanism_count}" + ))); + } + seen[index] = true; + } + } + Ok(()) +} + +fn validate_probability(probability: f64, index: usize) -> Result<(), DecoderError> { + if !(0.0..1.0).contains(&probability) { + return Err(DecoderError::InvalidConfiguration(format!( + "mechanism {index} probability must satisfy 0 <= p < 1, got {probability}" + ))); + } + Ok(()) +} + +fn validate_indices( + indices: &[u32], + upper_bound: usize, + kind: &str, + mechanism_index: usize, +) -> Result<(), DecoderError> { + if let Some(&index) = indices.iter().find(|&&index| index as usize >= upper_bound) { + return Err(DecoderError::InvalidConfiguration(format!( + "mechanism {mechanism_index} {kind} index {index} is out of range 0..{upper_bound}" + ))); + } + Ok(()) +} + +fn merge_branch( + merged: &mut BTreeMap, + mut state: StateKey, + log_mass: f64, + column: &Column, + observed: &[u64], +) { + if state + .active_syndrome + .iter() + .zip(observed) + .zip(&column.close_mask) + .any(|((&accumulated, &expected), &closing)| (accumulated ^ expected) & closing != 0) + { + return; + } + and_assign(&mut state.active_syndrome, &column.active_mask); + merged + .entry(state) + .and_modify(|mass| *mass = logaddexp(*mass, log_mass)) + .or_insert(log_mass); +} + +fn prune(frontier: BTreeMap, k: usize, delta: f64) -> BTreeMap { + let mut candidates: Vec = frontier + .into_iter() + .map(|(key, log_mass)| Candidate { key, log_mass }) + .collect(); + sort_candidates(&mut candidates); + let cutoff = candidates[0].log_mass - delta; + candidates + .into_iter() + .take(k) + .take_while(|candidate| candidate.log_mass >= cutoff) + .map(|candidate| (candidate.key, candidate.log_mass)) + .collect() +} + +fn sort_candidates(candidates: &mut [Candidate]) { + candidates.sort_by(|left, right| { + right + .log_mass + .total_cmp(&left.log_mass) + .then_with(|| left.key.cmp(&right.key)) + }); +} + +fn logaddexp(left: f64, right: f64) -> f64 { + if left == f64::NEG_INFINITY { + return right; + } + if right == f64::NEG_INFINITY { + return left; + } + let (high, low) = if left.total_cmp(&right) == Ordering::Less { + (right, left) + } else { + (left, right) + }; + high + (low - high).exp().ln_1p() +} + +fn unexplainable_error() -> DecoderError { + DecoderError::DecodingFailed("syndrome is unexplainable at the given pruning parameters".into()) +} + +const fn words_for(bits: usize) -> usize { + bits.div_ceil(WORD_BITS) +} + +fn indices_to_words(indices: &[u32], word_count: usize) -> Vec { + let mut words = vec![0; word_count]; + for &index in indices { + set_bit(&mut words, index as usize); + } + words +} + +fn syndrome_to_words(syndrome: &[u8], word_count: usize) -> Vec { + let mut words = vec![0; word_count]; + for (index, &value) in syndrome.iter().enumerate() { + if value != 0 { + set_bit(&mut words, index); + } + } + words +} + +fn set_bit(words: &mut [u64], index: usize) { + words[index / WORD_BITS] |= 1 << (index % WORD_BITS); +} + +fn set_bits(words: &[u64]) -> impl Iterator + '_ { + words.iter().enumerate().flat_map(|(word_index, &word)| { + (0..WORD_BITS) + .filter(move |&bit| word & (1 << bit) != 0) + .map(move |bit| word_index * WORD_BITS + bit) + }) +} + +fn xor_assign(left: &mut [u64], right: &[u64]) { + for (left_word, &right_word) in left.iter_mut().zip(right) { + *left_word ^= right_word; + } +} + +fn or_assign(left: &mut [u64], right: &[u64]) { + for (left_word, &right_word) in left.iter_mut().zip(right) { + *left_word |= right_word; + } +} + +fn and_assign(left: &mut [u64], right: &[u64]) { + for (left_word, &right_word) in left.iter_mut().zip(right) { + *left_word &= right_word; + } +} + +fn and_not_assign(left: &mut [u64], right: &[u64]) { + for (left_word, &right_word) in left.iter_mut().zip(right) { + *left_word &= !right_word; + } +} + +#[cfg(test)] +mod tests { + use super::logaddexp; + + #[test] + fn logaddexp_handles_negative_infinity_on_either_side() { + assert_eq!( + logaddexp(f64::NEG_INFINITY, -2.5).to_bits(), + (-2.5_f64).to_bits() + ); + assert_eq!( + logaddexp(-2.5, f64::NEG_INFINITY).to_bits(), + (-2.5_f64).to_bits() + ); + } +} diff --git a/crates/pecos-frontier/tests/frontier.rs b/crates/pecos-frontier/tests/frontier.rs new file mode 100644 index 000000000..664df868c --- /dev/null +++ b/crates/pecos-frontier/tests/frontier.rs @@ -0,0 +1,400 @@ +// 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. + +use pecos_decoder_core::dem::SparseDem; +use pecos_decoder_core::obs_mask::ObsMask; +use pecos_decoder_core::{DecoderError, ObservableDecoder}; +use pecos_frontier::{FrontierConfig, FrontierDecoder, FrontierResult}; +use rand::{RngExt, SeedableRng}; +use rand_xoshiro::Xoshiro256PlusPlus; +use std::collections::BTreeMap; + +fn sparse_dem( + mechanisms: Vec<(f64, Vec, Vec)>, + num_detectors: usize, + num_observables: usize, +) -> SparseDem { + SparseDem { + mechanisms, + detector_coords: BTreeMap::new(), + num_detectors, + num_observables, + } +} + +fn exact_config() -> FrontierConfig { + FrontierConfig { + k: usize::MAX, + delta: f64::INFINITY, + column_order: None, + } +} + +fn independent_logaddexp(left: f64, right: f64) -> f64 { + if left == f64::NEG_INFINITY { + return right; + } + let high = left.max(right); + let low = left.min(right); + high + (low - high).exp().ln_1p() +} + +fn independent_enumeration(dem: &SparseDem, observed: &[u8]) -> BTreeMap, f64> { + let logical_words = dem.num_observables.div_ceil(u64::BITS as usize); + let mut masses = BTreeMap::new(); + for subset in 0..(1usize << dem.mechanisms.len()) { + let mut detectors = vec![0_u8; dem.num_detectors]; + let mut logical = vec![0_u64; logical_words]; + let mut log_mass = 0.0; + for (index, (probability, detector_set, observable_set)) in + dem.mechanisms.iter().enumerate() + { + if subset & (1 << index) == 0 { + log_mass += (1.0 - probability).ln(); + } else { + log_mass += probability.ln(); + for &detector in detector_set { + detectors[detector as usize] ^= 1; + } + for &observable in observable_set { + logical[observable as usize / 64] ^= 1 << (observable % 64); + } + } + } + if detectors == observed { + masses + .entry(logical) + .and_modify(|mass| *mass = independent_logaddexp(*mass, log_mass)) + .or_insert(log_mass); + } + } + masses +} + +fn independent_winner(masses: &BTreeMap, f64>) -> Vec { + masses + .iter() + .min_by(|(left_label, left_mass), (right_label, right_mass)| { + right_mass + .total_cmp(left_mass) + .then_with(|| left_label.cmp(right_label)) + }) + .map(|(label, _)| label.clone()) + .expect("generated syndrome must have at least one explanation") +} + +fn result_mass_map(result: &FrontierResult) -> BTreeMap, f64> { + result + .logical_masses + .iter() + .map(|entry| (entry.logical.words().to_vec(), entry.log_mass)) + .collect() +} + +#[test] +fn unpruned_matches_independent_brute_force_on_seeded_random_dems() { + let mut rng = Xoshiro256PlusPlus::seed_from_u64(0x4652_4f4e_5449_4552); + + for case_index in 0..33 { + let mechanism_count = 4 + case_index % 11; + let num_detectors = rng.random_range(1..=10); + let num_observables = rng.random_range(1..=3); + let mut mechanisms = Vec::with_capacity(mechanism_count); + let mut sampled_subset = Vec::with_capacity(mechanism_count); + let mut observed = vec![0_u8; num_detectors]; + + for mechanism_index in 0..mechanism_count { + let probability = rng.random_range(0.01..0.4); + let max_weight = num_detectors.min(3); + let detector_count = if mechanism_index % 3 == 0 && max_weight >= 2 { + rng.random_range(2..=max_weight) + } else { + 1 + }; + let mut detectors = Vec::with_capacity(detector_count); + while detectors.len() < detector_count { + let detector = u32::try_from(rng.random_range(0..num_detectors)) + .expect("random detector index is at most 9"); + if !detectors.contains(&detector) { + detectors.push(detector); + } + } + detectors.sort_unstable(); + + let observable_count = rng.random_range(0..=num_observables.min(2)); + let mut observables = Vec::with_capacity(observable_count); + while observables.len() < observable_count { + let observable = u32::try_from(rng.random_range(0..num_observables)) + .expect("random observable index is at most 2"); + if !observables.contains(&observable) { + observables.push(observable); + } + } + observables.sort_unstable(); + + let taken = rng.random_bool(0.35); + if taken { + for &detector in &detectors { + observed[detector as usize] ^= 1; + } + } + sampled_subset.push(taken); + mechanisms.push((probability, detectors, observables)); + } + + let dem = sparse_dem(mechanisms, num_detectors, num_observables); + let mut config = exact_config(); + if case_index % 2 == 1 { + config.column_order = Some((0..mechanism_count).rev().collect()); + } + let mut decoder = FrontierDecoder::from_sparse_dem(&dem, config).unwrap(); + let result = decoder.decode(&observed).unwrap(); + let expected = independent_enumeration(&dem, &observed); + let actual = result_mass_map(&result); + + assert_eq!(actual.len(), expected.len(), "case {case_index}"); + for (label, expected_mass) in &expected { + let actual_mass = actual.get(label).expect("logical label must be retained"); + assert!( + (actual_mass - expected_mass).abs() <= 1e-9, + "case {case_index}, label {label:?}: expected {expected_mass}, got {actual_mass}" + ); + } + assert_eq!( + result.predicted.words(), + independent_winner(&expected), + "case {case_index}, sampled subset {sampled_subset:?}" + ); + } +} + +#[test] +fn degeneracy_mass_beats_the_single_most_likely_error() { + // For syndrome D0=1, q=0.30's lone L0-flipping fault is the most likely + // single configuration: q(1-p)^2 = 0.192 versus p(1-p)(1-q) = 0.112. + // The no-logical-flip coset nevertheless wins by degeneracy: + // (1-q)2p(1-p) = 0.224 versus q((1-p)^2+p^2) = 0.204. + let dem = sparse_dem( + vec![ + (0.30, vec![0], vec![0]), + (0.20, vec![0], vec![]), + (0.20, vec![0], vec![]), + ], + 1, + 1, + ); + let mut decoder = FrontierDecoder::from_sparse_dem(&dem, exact_config()).unwrap(); + let result = decoder.decode(&[1]).unwrap(); + + assert!(result.predicted.is_zero()); + assert!((result.log_evidence.exp() - 0.224).abs() < 1e-12); + assert!((result.runner_up_gap.unwrap() - (0.224_f64 / 0.204).ln()).abs() < 1e-12); +} + +#[test] +fn supports_wide_detectors_and_observables_without_truncation() { + let dem = sparse_dem(vec![(0.2, vec![69], vec![69])], 70, 70); + let mut decoder = FrontierDecoder::from_sparse_dem(&dem, exact_config()).unwrap(); + let mut syndrome = vec![0; 70]; + syndrome[69] = 7; + + let mask = decoder.decode_obs(&syndrome).unwrap(); + assert!(mask.get(69)); + assert_eq!(mask.count_ones(), 1); + assert!(matches!( + decoder.decode_to_observables(&syndrome), + Err(DecoderError::InvalidConfiguration(_)) + )); +} + +#[test] +fn fails_loud_for_untouched_and_unachievable_syndromes() { + let untouched_dem = sparse_dem(vec![(0.2, vec![0], vec![])], 2, 0); + let mut untouched = FrontierDecoder::from_sparse_dem(&untouched_dem, exact_config()).unwrap(); + let untouched_error = untouched.decode(&[0, 1]).unwrap_err(); + assert!(untouched_error.to_string().contains("unexplainable")); + + let parity_locked_dem = sparse_dem(vec![(0.2, vec![0, 1], vec![])], 2, 0); + let mut parity_locked = + FrontierDecoder::from_sparse_dem(&parity_locked_dem, exact_config()).unwrap(); + let impossible_error = parity_locked.decode(&[1, 0]).unwrap_err(); + assert!(impossible_error.to_string().contains("pruning parameters")); +} + +#[test] +fn overpruning_can_remove_the_only_eventually_feasible_prefix() { + let dem = sparse_dem( + vec![(0.4, vec![0], vec![]), (0.1, vec![0, 1], vec![0])], + 2, + 1, + ); + let tight = FrontierConfig { + k: 1, + delta: 0.01, + column_order: None, + }; + let mut overpruned = FrontierDecoder::from_sparse_dem(&dem, tight).unwrap(); + assert!(overpruned.decode(&[0, 1]).is_err()); + + let mut default_decoder = + FrontierDecoder::from_sparse_dem(&dem, FrontierConfig::default()).unwrap(); + assert_eq!( + default_decoder.decode(&[0, 1]).unwrap().predicted, + ObsMask::from_u64(1) + ); +} + +#[test] +fn width_and_delta_pruning_can_change_the_logical_answer() { + let dem = sparse_dem( + vec![ + (0.20, vec![0], vec![]), + (0.20, vec![0], vec![]), + (0.30, vec![0], vec![0]), + ], + 1, + 1, + ); + let mut exact = FrontierDecoder::from_sparse_dem(&dem, exact_config()).unwrap(); + assert!(exact.decode(&[1]).unwrap().predicted.is_zero()); + + let mut greedy = FrontierDecoder::from_sparse_dem( + &dem, + FrontierConfig { + k: 1, + delta: f64::INFINITY, + column_order: None, + }, + ) + .unwrap(); + assert_eq!(greedy.decode(&[1]).unwrap().predicted, ObsMask::from_u64(1)); + + let mut delta_pruned = FrontierDecoder::from_sparse_dem( + &dem, + FrontierConfig { + k: usize::MAX, + delta: 0.1, + column_order: None, + }, + ) + .unwrap(); + assert_eq!( + delta_pruned.decode(&[1]).unwrap().predicted, + ObsMask::from_u64(1) + ); +} + +#[test] +fn decoding_is_bitwise_deterministic_including_a_tie() { + let dem = sparse_dem(vec![(0.5, vec![0], vec![0]), (0.5, vec![0], vec![1])], 1, 2); + let mut first_decoder = FrontierDecoder::from_sparse_dem(&dem, exact_config()).unwrap(); + let first = first_decoder.decode(&[1]).unwrap(); + let second = first_decoder.decode(&[1]).unwrap(); + let mut fresh_decoder = FrontierDecoder::from_sparse_dem(&dem, exact_config()).unwrap(); + let fresh = fresh_decoder.decode(&[1]).unwrap(); + + assert_eq!(first, second); + assert_eq!(first, fresh); + assert_eq!(first.log_evidence.to_bits(), second.log_evidence.to_bits()); + assert_eq!(first.log_evidence.to_bits(), fresh.log_evidence.to_bits()); + assert_eq!(first.predicted, ObsMask::from_u64(1)); + assert_eq!(first.runner_up_gap, Some(0.0)); +} + +#[test] +fn works_through_observable_decoder_trait_object() { + let dem = sparse_dem(vec![(0.2, vec![0], vec![0])], 1, 1); + let decoder = FrontierDecoder::from_sparse_dem(&dem, exact_config()).unwrap(); + let mut boxed: Box = Box::new(decoder); + + assert_eq!(boxed.decode_obs(&[1]).unwrap(), ObsMask::from_u64(1)); +} + +#[test] +fn parses_a_stim_dem_string() { + let dem_text = "\ + detector(0, 0, 0) D0\n\ + detector(1, 0, 0) D1\n\ + logical_observable L0\n\ + error(0.1) D0 D1 L0\n\ + error(0.2) D1\n"; + let mut decoder = FrontierDecoder::from_dem_str(dem_text, exact_config()).unwrap(); + + assert_eq!(decoder.decode_obs(&[1, 1]).unwrap(), ObsMask::from_u64(1)); +} + +#[test] +fn validates_probabilities_indices_order_and_pruning_configuration() { + for probability in [1.0, 1.1, -0.1, f64::NAN, f64::INFINITY] { + let dem = sparse_dem(vec![(probability, vec![], vec![])], 0, 0); + assert!(matches!( + FrontierDecoder::from_sparse_dem(&dem, FrontierConfig::default()), + Err(DecoderError::InvalidConfiguration(_)) + )); + } + + let zero_dem = sparse_dem(vec![(0.0, vec![], vec![])], 0, 0); + let mut zero_decoder = FrontierDecoder::from_sparse_dem(&zero_dem, exact_config()).unwrap(); + let zero_result = zero_decoder.decode(&[]).unwrap(); + assert_eq!(zero_result.processed_columns, 0); + assert_eq!(zero_result.log_evidence.to_bits(), 0.0_f64.to_bits()); + + let invalid_zero_dem = sparse_dem(vec![(0.0, vec![0], vec![])], 0, 0); + assert!(FrontierDecoder::from_sparse_dem(&invalid_zero_dem, exact_config()).is_err()); + + let bad_detector = sparse_dem(vec![(0.1, vec![1], vec![])], 1, 0); + assert!(FrontierDecoder::from_sparse_dem(&bad_detector, exact_config()).is_err()); + let bad_observable = sparse_dem(vec![(0.1, vec![], vec![1])], 0, 1); + assert!(FrontierDecoder::from_sparse_dem(&bad_observable, exact_config()).is_err()); + + let two_columns = sparse_dem(vec![(0.1, vec![], vec![]); 2], 0, 0); + for bad_order in [vec![0], vec![0, 0], vec![0, 2]] { + let config = FrontierConfig { + column_order: Some(bad_order), + ..FrontierConfig::default() + }; + assert!(FrontierDecoder::from_sparse_dem(&two_columns, config).is_err()); + } + + let one_column = sparse_dem(vec![(0.1, vec![], vec![])], 0, 0); + for config in [ + FrontierConfig { + k: 0, + ..FrontierConfig::default() + }, + FrontierConfig { + delta: -0.1, + ..FrontierConfig::default() + }, + FrontierConfig { + delta: f64::NAN, + ..FrontierConfig::default() + }, + ] { + let mut decoder = FrontierDecoder::from_sparse_dem(&one_column, config).unwrap(); + let error = decoder.decode(&[]).unwrap_err(); + assert!(error.to_string().contains("pruning parameters")); + } +} + +#[test] +fn rejects_wrong_syndrome_length() { + let dem = sparse_dem(vec![(0.2, vec![0], vec![])], 1, 0); + let mut decoder = FrontierDecoder::from_sparse_dem(&dem, exact_config()).unwrap(); + assert!(matches!( + decoder.decode(&[]), + Err(DecoderError::InvalidDimensions { + expected: 1, + actual: 0 + }) + )); +} From fc6d9e5fb4a0e79fe95d03f31712809149d8ac77 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 15:00:52 -0600 Subject: [PATCH 02/10] Align log_evidence with upstream total-mass semantics, fail-fast config validation, and upstream golden-fixture parity tests --- Cargo.lock | 2 + crates/pecos-frontier/Cargo.toml | 2 + crates/pecos-frontier/src/lib.rs | 34 +- .../fixtures/generate_upstream_fixtures.py | 179 ++ .../tests/fixtures/upstream_fixtures.json | 1488 +++++++++++++++++ crates/pecos-frontier/tests/frontier.rs | 27 +- .../pecos-frontier/tests/upstream_fixtures.rs | 192 +++ 7 files changed, 1907 insertions(+), 17 deletions(-) create mode 100644 crates/pecos-frontier/tests/fixtures/generate_upstream_fixtures.py create mode 100644 crates/pecos-frontier/tests/fixtures/upstream_fixtures.json create mode 100644 crates/pecos-frontier/tests/upstream_fixtures.rs diff --git a/Cargo.lock b/Cargo.lock index c323b0459..404e4c12d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4371,6 +4371,8 @@ dependencies = [ "pecos-decoder-core", "rand 0.10.2", "rand_xoshiro 0.8.1", + "serde", + "serde_json", ] [[package]] diff --git a/crates/pecos-frontier/Cargo.toml b/crates/pecos-frontier/Cargo.toml index 035f06df6..2848ffbb0 100644 --- a/crates/pecos-frontier/Cargo.toml +++ b/crates/pecos-frontier/Cargo.toml @@ -20,6 +20,8 @@ name = "pecos_frontier" [dev-dependencies] rand.workspace = true rand_xoshiro.workspace = true +serde.workspace = true +serde_json.workspace = true [lints] workspace = true diff --git a/crates/pecos-frontier/src/lib.rs b/crates/pecos-frontier/src/lib.rs index 8b3c2bc14..9a01aa842 100644 --- a/crates/pecos-frontier/src/lib.rs +++ b/crates/pecos-frontier/src/lib.rs @@ -64,7 +64,11 @@ pub struct FrontierLogicalMass { pub struct FrontierResult { /// Predicted logical-observable flip mask. pub predicted: ObsMask, - /// Natural logarithm of the retained probability mass of `predicted`. + /// Natural logarithm of the total retained probability mass over all + /// terminal logical labels. + /// + /// The winning label's own log mass is [`Self::logical_masses`]'s first + /// entry. pub log_evidence: f64, /// Difference between the winning and runner-up log masses, if one exists. pub runner_up_gap: Option, @@ -73,6 +77,7 @@ pub struct FrontierResult { /// Number of nonzero-probability columns processed. pub processed_columns: usize, /// Retained terminal masses, ordered by mass descending and label ascending. + /// The first entry is the winning label and its retained log mass. pub logical_masses: Vec, } @@ -117,10 +122,10 @@ impl FrontierDecoder { /// /// # Errors /// - /// Returns [`DecoderError::InvalidConfiguration`] for invalid probabilities, - /// indices, or column order. + /// Returns [`DecoderError::InvalidConfiguration`] for invalid pruning + /// parameters, probabilities, indices, or column order. pub fn from_sparse_dem(dem: &SparseDem, config: FrontierConfig) -> Result { - validate_column_order(&config, dem.mechanisms.len())?; + validate_config(&config, dem.mechanisms.len())?; let detector_words = words_for(dem.num_detectors); let logical_words = words_for(dem.num_observables); @@ -270,6 +275,9 @@ impl FrontierDecoder { .collect(); sort_candidates(&mut terminal); let winner = &terminal[0]; + let log_evidence = terminal.iter().fold(f64::NEG_INFINITY, |total, candidate| { + logaddexp(total, candidate.log_mass) + }); let logical_masses = terminal .iter() .map(|candidate| FrontierLogicalMass { @@ -280,7 +288,7 @@ impl FrontierDecoder { Ok(FrontierResult { predicted: ObsMask::from_words(&winner.key.logical), - log_evidence: winner.log_mass, + log_evidence, runner_up_gap: terminal .get(1) .map(|runner_up| winner.log_mass - runner_up.log_mass), @@ -307,10 +315,18 @@ impl ObservableDecoder for FrontierDecoder { } } -fn validate_column_order( - config: &FrontierConfig, - mechanism_count: usize, -) -> Result<(), DecoderError> { +fn validate_config(config: &FrontierConfig, mechanism_count: usize) -> Result<(), DecoderError> { + if config.k == 0 { + return Err(DecoderError::InvalidConfiguration( + "FrontierConfig.k must be at least 1".into(), + )); + } + if config.delta.is_nan() || config.delta < 0.0 { + return Err(DecoderError::InvalidConfiguration(format!( + "FrontierConfig.delta must be non-negative and not NaN, got {}", + config.delta + ))); + } if let Some(order) = &config.column_order { if order.len() != mechanism_count { return Err(DecoderError::InvalidConfiguration(format!( diff --git a/crates/pecos-frontier/tests/fixtures/generate_upstream_fixtures.py b/crates/pecos-frontier/tests/fixtures/generate_upstream_fixtures.py new file mode 100644 index 000000000..b36992257 --- /dev/null +++ b/crates/pecos-frontier/tests/fixtures/generate_upstream_fixtures.py @@ -0,0 +1,179 @@ +# Golden-fixture generator for pecos-frontier, run against the upstream +# frontier package (github.com/aleverrier/frontier, arXiv:2606.20513). +# +# Orchestrator-owned oracle: this script and its JSON output are authored and +# committed by the reviewer, not by the implementation. The implementation must +# never edit them. +# +# Usage (from a clone of the upstream repo with its venv built): +# .venv/bin/python generate_upstream_fixtures.py > upstream_fixtures.json +# +# Each fixture: a binary-fault model given as mechanisms [p, [detectors], +# [observables]] in processing order (identical to the column order PECOS uses), +# decoded for every syndrome bitmask in `syndromes`, unpruned (K=10^9, +# Delta=inf) and pruned (K/Delta from the fixture). Expected values come from +# upstream FrontierResult: status, logical_hat, log_evidence, and +# terminal_log_masses (logical label -> log mass, unnormalized). + +from __future__ import annotations + +import json +import math +import random +import sys + +from frontier import FrontierModel, decode_frontier +from frontier.progressive import ( + FactorTransition, + OutcomeTransition, + build_frontier_layout, + columns_from_factor_transitions, +) + +UNPRUNED_K = 10**9 + + +def build_model(mechanisms, num_detectors, num_observables): + factors = [] + for idx, (p, dets, obs) in enumerate(mechanisms): + det_mask = 0 + for d in dets: + det_mask |= 1 << d + log_mask = 0 + for o in obs: + log_mask |= 1 << o + factors.append( + FactorTransition( + factor_id=idx, + outcomes=( + OutcomeTransition(probability=1.0 - p, detector_mask=0, logical_mask=0), + OutcomeTransition(probability=p, detector_mask=det_mask, logical_mask=log_mask), + ), + instruction_offset=idx, + label=f"f{idx}", + ) + ) + columns = tuple(columns_from_factor_transitions(tuple(factors))) + return FrontierModel( + columns=columns, + layout=build_frontier_layout(list(columns), num_detectors=num_detectors), + num_detectors=num_detectors, + num_observables=num_observables, + ) + + +def decode_all(model, syndromes, k, delta): + out = [] + for syndrome in syndromes: + r = decode_frontier(model, syndrome, K=k, Delta=delta) + # Keep the JSON strictly standard: no-path decodes report -inf + # log_evidence upstream; emit null instead (allow_nan=False enforces). + out.append( + { + "syndrome": syndrome, + "status": r.status, + "logical_hat": r.logical_hat, + "log_evidence": r.log_evidence if math.isfinite(r.log_evidence) else None, + "terminal_log_masses": { + str(k_): v for k_, v in sorted(r.terminal_log_masses.items()) if math.isfinite(v) + }, + "engine": r.engine, + } + ) + return out + + +def random_mechanisms(rng, num_mechs, num_detectors, num_observables): + mechs = [] + for _ in range(num_mechs): + n_d = rng.choice([1, 1, 2, 2, 3]) + dets = sorted(rng.sample(range(num_detectors), min(n_d, num_detectors))) + obs = sorted(rng.sample(range(num_observables), rng.choice([0, 0, 1, 1, 2]))) + p = rng.uniform(0.01, 0.4) + mechs.append((round(p, 6), dets, obs)) + return mechs + + +def main(): + fixtures = [] + + # F1: hand-built degeneracy case -- logical-ML differs from most-likely-error. + # Mechanism 0: p=0.20, flips detector 0, flips observable 0. + # Mechanisms 1,2: p=0.15 each, flip detector 0, no observable flip. + # Syndrome {det0}: MLE representative is mech 0 (0.20 > 0.15), but coset mass + # of label 0 (mech1 alone + mech2 alone + all three) exceeds label 1's mass. + fixtures.append( + { + "name": "degeneracy_ml_vs_mle", + "mechanisms": [[0.20, [0], [0]], [0.15, [0], []], [0.15, [0], []]], + "num_detectors": 1, + "num_observables": 1, + "syndromes": [0, 1], + "pruned": {"k": 2, "delta": 100.0}, + } + ) + + # F2: repetition-code-like chain with hyperedge, two observables. + fixtures.append( + { + "name": "rep_chain_hyperedge", + "mechanisms": [ + [0.05, [0], [0]], + [0.04, [0, 1], []], + [0.03, [1, 2], [1]], + [0.06, [2, 3], []], + [0.02, [3], [0, 1]], + [0.07, [1, 2, 3], []], + ], + "num_detectors": 4, + "num_observables": 2, + "syndromes": list(range(16)), + "pruned": {"k": 4, "delta": 30.0}, + } + ) + + # F3: wide observables -- winning label flips observable index 70. + fixtures.append( + { + "name": "wide_observable_70", + "mechanisms": [ + [0.10, [0], [70]], + [0.02, [0], [3]], + [0.05, [1], [0, 70]], + [0.03, [0, 1], []], + ], + "num_detectors": 2, + "num_observables": 71, + "syndromes": [0, 1, 2, 3], + "pruned": {"k": 8, "delta": 100.0}, + } + ) + + # F4/F5: seeded random models. + for seed, num_mechs, num_dets, num_obs in ((11, 8, 5, 3), (23, 12, 6, 2)): + rng = random.Random(seed) + fixtures.append( + { + "name": f"random_seed{seed}", + "mechanisms": random_mechanisms(rng, num_mechs, num_dets, num_obs), + "num_detectors": num_dets, + "num_observables": num_obs, + "syndromes": sorted(rng.sample(range(1 << num_dets), 12)), + "pruned": {"k": 3, "delta": 20.0}, + } + ) + + for fx in fixtures: + model = build_model(fx["mechanisms"], fx["num_detectors"], fx["num_observables"]) + fx["expected_unpruned"] = decode_all(model, fx["syndromes"], UNPRUNED_K, math.inf) + fx["expected_pruned"] = decode_all( + model, fx["syndromes"], fx["pruned"]["k"], fx["pruned"]["delta"] + ) + + json.dump({"generator": "generate_upstream_fixtures.py", "fixtures": fixtures}, sys.stdout, indent=1, allow_nan=False) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/crates/pecos-frontier/tests/fixtures/upstream_fixtures.json b/crates/pecos-frontier/tests/fixtures/upstream_fixtures.json new file mode 100644 index 000000000..82027a65b --- /dev/null +++ b/crates/pecos-frontier/tests/fixtures/upstream_fixtures.json @@ -0,0 +1,1488 @@ +{ + "generator": "generate_upstream_fixtures.py", + "fixtures": [ + { + "name": "degeneracy_ml_vs_mle", + "mechanisms": [ + [ + 0.2, + [ + 0 + ], + [ + 0 + ] + ], + [ + 0.15, + [ + 0 + ], + [] + ], + [ + 0.15, + [ + 0 + ], + [] + ] + ], + "num_detectors": 1, + "num_observables": 1, + "syndromes": [ + 0, + 1 + ], + "pruned": { + "k": 2, + "delta": 100.0 + }, + "expected_unpruned": [ + { + "syndrome": 0, + "status": "ok", + "logical_hat": 0, + "log_evidence": -0.4354089844812365, + "terminal_log_masses": { + "0": -0.5175146119167873, + "1": -2.9759296462578115 + }, + "engine": "native_binary" + }, + { + "syndrome": 1, + "status": "ok", + "logical_hat": 0, + "log_evidence": -1.0412872220488403, + "terminal_log_masses": { + "0": -1.5896352851379207, + "1": -1.9038089730366778 + }, + "engine": "native_binary" + } + ], + "expected_pruned": [ + { + "syndrome": 0, + "status": "ok", + "logical_hat": 0, + "log_evidence": -0.5050092384445508, + "terminal_log_masses": { + "0": -0.5481814103097595, + "1": -3.6690768268177565 + }, + "engine": "native_binary" + }, + { + "syndrome": 1, + "status": "ok", + "logical_hat": 1, + "log_evidence": -1.4003932854993923, + "terminal_log_masses": { + "0": -2.282782465697866, + "1": -1.93447577142965 + }, + "engine": "native_binary" + } + ] + }, + { + "name": "rep_chain_hyperedge", + "mechanisms": [ + [ + 0.05, + [ + 0 + ], + [ + 0 + ] + ], + [ + 0.04, + [ + 0, + 1 + ], + [] + ], + [ + 0.03, + [ + 1, + 2 + ], + [ + 1 + ] + ], + [ + 0.06, + [ + 2, + 3 + ], + [] + ], + [ + 0.02, + [ + 3 + ], + [ + 0, + 1 + ] + ], + [ + 0.07, + [ + 1, + 2, + 3 + ], + [] + ] + ], + "num_detectors": 4, + "num_observables": 2, + "syndromes": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15 + ], + "pruned": { + "k": 4, + "delta": 30.0 + }, + "expected_unpruned": [ + { + "syndrome": 0, + "status": "ok", + "logical_hat": 0, + "log_evidence": -0.27716516945765746, + "terminal_log_masses": { + "0": -0.27722321191185384, + "1": -10.031530036817655 + }, + "engine": "native_binary" + }, + { + "syndrome": 1, + "status": "ok", + "logical_hat": 1, + "log_evidence": -3.217786917942595, + "terminal_log_masses": { + "0": -8.78108842722251, + "1": -3.221630385188459 + }, + "engine": "native_binary" + }, + { + "syndrome": 2, + "status": "ok", + "logical_hat": 0, + "log_evidence": -5.233687599573494, + "terminal_log_masses": { + "0": -5.615426272349639, + "1": -6.381511448991767 + }, + "engine": "native_binary" + }, + { + "syndrome": 3, + "status": "ok", + "logical_hat": 0, + "log_evidence": -3.4491289645180316, + "terminal_log_masses": { + "0": -3.455226241668234, + "1": -8.552089030335141 + }, + "engine": "native_binary" + }, + { + "syndrome": 4, + "status": "ok", + "logical_hat": 3, + "log_evidence": -6.764670168798568, + "terminal_log_masses": { + "2": -9.069129045682928, + "3": -6.86982270281349 + }, + "engine": "native_binary" + }, + { + "syndrome": 5, + "status": "ok", + "logical_hat": 2, + "log_evidence": -6.82797229734218, + "terminal_log_masses": { + "2": -6.879539903005537, + "3": -9.818506902744613 + }, + "engine": "native_binary" + }, + { + "syndrome": 6, + "status": "ok", + "logical_hat": 2, + "log_evidence": -3.7047508205521926, + "terminal_log_masses": { + "2": -3.7532296281899296, + "3": -6.7555208339606345 + }, + "engine": "native_binary" + }, + { + "syndrome": 7, + "status": "ok", + "logical_hat": 3, + "log_evidence": -6.6145097115094025, + "terminal_log_masses": { + "2": -9.626383227722268, + "3": -6.664960641265254 + }, + "engine": "native_binary" + }, + { + "syndrome": 8, + "status": "ok", + "logical_hat": 3, + "log_evidence": -4.0608263079955025, + "terminal_log_masses": { + "2": -6.339918972287872, + "3": -4.168831489862692 + }, + "engine": "native_binary" + }, + { + "syndrome": 9, + "status": "ok", + "logical_hat": 2, + "log_evidence": -6.9357967928880235, + "terminal_log_masses": { + "2": -7.039693883624326, + "3": -9.251649985363196 + }, + "engine": "native_binary" + }, + { + "syndrome": 10, + "status": "ok", + "logical_hat": 2, + "log_evidence": -6.432607404086188, + "terminal_log_masses": { + "2": -6.482439701584985, + "3": -9.456512046911433 + }, + "engine": "native_binary" + }, + { + "syndrome": 11, + "status": "ok", + "logical_hat": 3, + "log_evidence": -7.130114138247697, + "terminal_log_masses": { + "2": -9.46622924710348, + "3": -7.231817558646671 + }, + "engine": "native_binary" + }, + { + "syndrome": 12, + "status": "ok", + "logical_hat": 0, + "log_evidence": -3.026106948529199, + "terminal_log_masses": { + "0": -3.028736928251697, + "1": -8.968200793089709 + }, + "engine": "native_binary" + }, + { + "syndrome": 13, + "status": "ok", + "logical_hat": 1, + "log_evidence": -5.309778798539666, + "terminal_log_masses": { + "0": -6.041915585766176, + "1": -5.965399686237199 + }, + "engine": "native_binary" + }, + { + "syndrome": 14, + "status": "ok", + "logical_hat": 0, + "log_evidence": -2.8537193014310196, + "terminal_log_masses": { + "0": -2.8639125560097964, + "1": -7.444840692719713 + }, + "engine": "native_binary" + }, + { + "syndrome": 15, + "status": "ok", + "logical_hat": 1, + "log_evidence": -5.289694649257151, + "terminal_log_masses": { + "0": -6.194399083124567, + "1": -5.808319729286401 + }, + "engine": "native_binary" + } + ], + "expected_pruned": [ + { + "syndrome": 0, + "status": "ok", + "logical_hat": 0, + "log_evidence": -0.27722330026295683, + "terminal_log_masses": { + "0": -0.27722330026295683 + }, + "engine": "native_binary" + }, + { + "syndrome": 1, + "status": "ok", + "logical_hat": 1, + "log_evidence": -3.221662279429397, + "terminal_log_masses": { + "1": -3.221662279429397 + }, + "engine": "native_binary" + }, + { + "syndrome": 2, + "status": "ok", + "logical_hat": 1, + "log_evidence": -6.381511448991767, + "terminal_log_masses": { + "1": -6.381511448991767 + }, + "engine": "native_binary" + }, + { + "syndrome": 3, + "status": "ok", + "logical_hat": 0, + "log_evidence": -3.4552771306109022, + "terminal_log_masses": { + "0": -3.4552771306109022 + }, + "engine": "native_binary" + }, + { + "syndrome": 4, + "status": "ok", + "logical_hat": 3, + "log_evidence": -6.86982270281349, + "terminal_log_masses": { + "3": -6.86982270281349 + }, + "engine": "native_binary" + }, + { + "syndrome": 5, + "status": "ok", + "logical_hat": 2, + "log_evidence": -6.879539903005537, + "terminal_log_masses": { + "2": -6.879539903005537 + }, + "engine": "native_binary" + }, + { + "syndrome": 6, + "status": "ok", + "logical_hat": 2, + "log_evidence": -3.75332199009823, + "terminal_log_masses": { + "2": -3.75332199009823 + }, + "engine": "native_binary" + }, + { + "syndrome": 7, + "status": "ok", + "logical_hat": 3, + "log_evidence": -6.664960641265254, + "terminal_log_masses": { + "3": -6.664960641265254 + }, + "engine": "native_binary" + }, + { + "syndrome": 8, + "status": "ok", + "logical_hat": 3, + "log_evidence": -4.169043598373583, + "terminal_log_masses": { + "3": -4.169043598373583 + }, + "engine": "native_binary" + }, + { + "syndrome": 9, + "status": "ok", + "logical_hat": 2, + "log_evidence": -7.039693883624326, + "terminal_log_masses": { + "2": -7.039693883624326 + }, + "engine": "native_binary" + }, + { + "syndrome": 10, + "status": "ok", + "logical_hat": 2, + "log_evidence": -6.504857303140179, + "terminal_log_masses": { + "2": -6.504857303140179 + }, + "engine": "native_binary" + }, + { + "syndrome": 11, + "status": "ok", + "logical_hat": 3, + "log_evidence": -7.231817558646671, + "terminal_log_masses": { + "3": -7.231817558646671 + }, + "engine": "native_binary" + }, + { + "syndrome": 12, + "status": "ok", + "logical_hat": 0, + "log_evidence": -3.028758613304906, + "terminal_log_masses": { + "0": -3.028758613304906 + }, + "engine": "native_binary" + }, + { + "syndrome": 13, + "status": "ok", + "logical_hat": 1, + "log_evidence": -5.965399686237199, + "terminal_log_masses": { + "1": -5.965399686237199 + }, + "engine": "native_binary" + }, + { + "syndrome": 14, + "status": "ok", + "logical_hat": 0, + "log_evidence": -2.855561927630394, + "terminal_log_masses": { + "0": -2.8639126443608993, + "1": -7.645142288208857 + }, + "engine": "native_binary" + }, + { + "syndrome": 15, + "status": "ok", + "logical_hat": 0, + "log_evidence": -6.194399083124567, + "terminal_log_masses": { + "0": -6.194399083124567 + }, + "engine": "native_binary" + } + ] + }, + { + "name": "wide_observable_70", + "mechanisms": [ + [ + 0.1, + [ + 0 + ], + [ + 70 + ] + ], + [ + 0.02, + [ + 0 + ], + [ + 3 + ] + ], + [ + 0.05, + [ + 1 + ], + [ + 0, + 70 + ] + ], + [ + 0.03, + [ + 0, + 1 + ], + [] + ] + ], + "num_detectors": 2, + "num_observables": 71, + "syndromes": [ + 0, + 1, + 2, + 3 + ], + "pruned": { + "k": 8, + "delta": 100.0 + }, + "expected_unpruned": [ + { + "syndrome": 0, + "status": "ok", + "logical_hat": 0, + "log_evidence": -0.20483714083157648, + "terminal_log_masses": { + "0": -0.20731572484760488, + "1": -8.825077971185538, + "1180591620717411303432": -6.2963606002944505, + "1180591620717411303433": -10.519673691959945 + }, + "engine": "native_binary" + }, + { + "syndrome": 1, + "status": "ok", + "logical_hat": 1180591620717411303424, + "log_evidence": -2.2235890867675474, + "terminal_log_masses": { + "8": -4.099136022958231, + "9": -12.716898269296165, + "1180591620717411303424": -2.404540302183824, + "1180591620717411303425": -6.6278533938493185 + }, + "engine": "native_binary" + }, + { + "syndrome": 2, + "status": "ok", + "logical_hat": 1180591620717411303425, + "log_evidence": -3.0752084750557875, + "terminal_log_masses": { + "8": -7.575234712793504, + "9": -9.24079957946089, + "1180591620717411303424": -5.880638992019097, + "1180591620717411303425": -3.1517547040140452 + }, + "engine": "native_binary" + }, + { + "syndrome": 3, + "status": "ok", + "logical_hat": 0, + "log_evidence": -3.4795914490901674, + "terminal_log_masses": { + "0": -3.6834144146828782, + "1": -5.348979281350265, + "1180591620717411303432": -9.772459290129724, + "1180591620717411303433": -7.043575002124672 + }, + "engine": "native_binary" + } + ], + "expected_pruned": [ + { + "syndrome": 0, + "status": "ok", + "logical_hat": 0, + "log_evidence": -0.20483714083157648, + "terminal_log_masses": { + "0": -0.20731572484760488, + "1": -8.825077971185538, + "1180591620717411303432": -6.2963606002944505, + "1180591620717411303433": -10.519673691959945 + }, + "engine": "native_binary" + }, + { + "syndrome": 1, + "status": "ok", + "logical_hat": 1180591620717411303424, + "log_evidence": -2.2235890867675474, + "terminal_log_masses": { + "8": -4.099136022958231, + "9": -12.716898269296165, + "1180591620717411303424": -2.404540302183824, + "1180591620717411303425": -6.6278533938493185 + }, + "engine": "native_binary" + }, + { + "syndrome": 2, + "status": "ok", + "logical_hat": 1180591620717411303425, + "log_evidence": -3.0752084750557875, + "terminal_log_masses": { + "8": -7.575234712793504, + "9": -9.24079957946089, + "1180591620717411303424": -5.880638992019097, + "1180591620717411303425": -3.1517547040140452 + }, + "engine": "native_binary" + }, + { + "syndrome": 3, + "status": "ok", + "logical_hat": 0, + "log_evidence": -3.4795914490901674, + "terminal_log_masses": { + "0": -3.6834144146828782, + "1": -5.348979281350265, + "1180591620717411303432": -9.772459290129724, + "1180591620717411303433": -7.043575002124672 + }, + "engine": "native_binary" + } + ] + }, + { + "name": "random_seed11", + "mechanisms": [ + [ + 0.343468, + [ + 3, + 4 + ], + [ + 2 + ] + ], + [ + 0.046708, + [ + 1 + ], + [ + 0, + 1 + ] + ], + [ + 0.24222, + [ + 0, + 1 + ], + [ + 0, + 2 + ] + ], + [ + 0.033225, + [ + 1, + 3 + ], + [ + 0, + 2 + ] + ], + [ + 0.137254, + [ + 1 + ], + [ + 0, + 1 + ] + ], + [ + 0.011792, + [ + 1, + 2, + 4 + ], + [ + 1 + ] + ], + [ + 0.399086, + [ + 3 + ], + [ + 1 + ] + ], + [ + 0.210025, + [ + 2 + ], + [ + 0 + ] + ] + ], + "num_detectors": 5, + "num_observables": 3, + "syndromes": [ + 0, + 1, + 2, + 3, + 9, + 12, + 18, + 21, + 24, + 26, + 27, + 30 + ], + "pruned": { + "k": 3, + "delta": 20.0 + }, + "expected_unpruned": [ + { + "syndrome": 0, + "status": "ok", + "logical_hat": 0, + "log_evidence": -1.671574535204913, + "terminal_log_masses": { + "0": -1.6765593657035884, + "2": -11.448346632924705, + "4": -7.034064799488613, + "6": -10.064525695922914 + }, + "engine": "native_binary" + }, + { + "syndrome": 1, + "status": "ok", + "logical_hat": 6, + "log_evidence": -4.284979715475963, + "terminal_log_masses": { + "0": -9.627505046429114, + "2": -6.597044149994812, + "4": -14.166460686329314, + "6": -4.394673419108197 + }, + "engine": "native_binary" + }, + { + "syndrome": 2, + "status": "ok", + "logical_hat": 3, + "log_evidence": -3.144433013520559, + "terminal_log_masses": { + "1": -13.02591398437391, + "3": -3.2541267171527934, + "5": -8.48695834447371, + "7": -5.456497448039408 + }, + "engine": "native_binary" + }, + { + "syndrome": 3, + "status": "ok", + "logical_hat": 5, + "log_evidence": -2.812121237160317, + "terminal_log_masses": { + "1": -8.174611501444016, + "3": -11.205072397878318, + "5": -2.8171060676589925, + "7": -12.588893334880108 + }, + "engine": "native_binary" + }, + { + "syndrome": 9, + "status": "ok", + "logical_hat": 4, + "log_evidence": -4.570633149175533, + "terminal_log_masses": { + "0": -6.187769253052399, + "2": -9.218230149486702, + "4": -4.80394831605061, + "6": -14.575735583271726 + }, + "engine": "native_binary" + }, + { + "syndrome": 12, + "status": "ok", + "logical_hat": 3, + "log_evidence": -3.391991752771167, + "terminal_log_masses": { + "1": -10.532846801727313, + "3": -3.4106089907858057, + "5": -8.330476070840698, + "7": -7.949564630686005 + }, + "engine": "native_binary" + }, + { + "syndrome": 18, + "status": "ok", + "logical_hat": 5, + "log_evidence": -4.052729957929209, + "terminal_log_masses": { + "1": -5.695100038325779, + "3": -7.429805960302515, + "5": -4.311279101323989, + "7": -12.787311394087538 + }, + "engine": "native_binary" + }, + { + "syndrome": 21, + "status": "ok", + "logical_hat": 1, + "log_evidence": -6.145749443548663, + "terminal_log_masses": { + "1": -6.776600531419197, + "3": -12.603083367903139, + "5": -8.160421468420987, + "7": -7.245577934118114 + }, + "engine": "native_binary" + }, + { + "syndrome": 24, + "status": "ok", + "logical_hat": 4, + "log_evidence": -2.318700728115651, + "terminal_log_masses": { + "0": -7.681942286717397, + "2": -9.41664820869413, + "4": -2.3244368529323713, + "6": -10.800469145695923 + }, + "engine": "native_binary" + }, + { + "syndrome": 26, + "status": "ok", + "logical_hat": 7, + "log_evidence": -3.779561516643309, + "terminal_log_masses": { + "1": -7.839080857244927, + "3": -6.104374935268192, + "5": -12.378036497145127, + "7": -3.9020042043815764 + }, + "engine": "native_binary" + }, + { + "syndrome": 27, + "status": "ok", + "logical_hat": 1, + "log_evidence": -3.459247430071055, + "terminal_log_masses": { + "1": -3.4649835548877754, + "3": -11.941015847651325, + "5": -8.8224889886728, + "7": -10.557194910649535 + }, + "engine": "native_binary" + }, + { + "syndrome": 30, + "status": "ok", + "logical_hat": 6, + "log_evidence": -4.89787704322171, + "terminal_log_masses": { + "0": -6.5143061291051225, + "2": -7.429149663407996, + "4": -11.053261769005323, + "6": -5.2267789325213805 + }, + "engine": "native_binary" + } + ], + "expected_pruned": [ + { + "syndrome": 0, + "status": "ok", + "logical_hat": 0, + "log_evidence": -1.6765593657035884, + "terminal_log_masses": { + "0": -1.6765593657035884 + }, + "engine": "native_binary" + }, + { + "syndrome": 1, + "status": "ok", + "logical_hat": 6, + "log_evidence": -4.290596505646497, + "terminal_log_masses": { + "2": -6.604808770272863, + "6": -4.394673419108197 + }, + "engine": "native_binary" + }, + { + "syndrome": 2, + "status": "ok", + "logical_hat": 3, + "log_evidence": -3.150049803691093, + "terminal_log_masses": { + "3": -3.2541267171527934, + "7": -5.464262068317459 + }, + "engine": "native_binary" + }, + { + "syndrome": 3, + "status": "ok", + "logical_hat": 5, + "log_evidence": -2.8171060676589925, + "terminal_log_masses": { + "5": -2.8171060676589925 + }, + "engine": "native_binary" + }, + { + "syndrome": 9, + "status": "ok", + "logical_hat": 4, + "log_evidence": -4.581860766597815, + "terminal_log_masses": { + "0": -6.19553387333045, + "4": -4.80394831605061 + }, + "engine": "native_binary" + }, + { + "syndrome": 12, + "status": "ok", + "logical_hat": 3, + "log_evidence": -3.4106089907858057, + "terminal_log_masses": { + "3": -3.4106089907858057 + }, + "engine": "native_binary" + }, + { + "syndrome": 18, + "status": "ok", + "logical_hat": 5, + "log_evidence": -4.0891915518711945, + "terminal_log_masses": { + "1": -5.70286465860383, + "5": -4.311279101323989 + }, + "engine": "native_binary" + }, + { + "syndrome": 21, + "status": "ok", + "logical_hat": 1, + "log_evidence": -6.554512981966402, + "terminal_log_masses": { + "1": -6.776600531419197, + "5": -8.168186088699038 + }, + "engine": "native_binary" + }, + { + "syndrome": 24, + "status": "ok", + "logical_hat": 4, + "log_evidence": -2.3244368529323713, + "terminal_log_masses": { + "4": -2.3244368529323713 + }, + "engine": "native_binary" + }, + { + "syndrome": 26, + "status": "ok", + "logical_hat": 7, + "log_evidence": -3.7979272909198762, + "terminal_log_masses": { + "3": -6.112139555546243, + "7": -3.9020042043815764 + }, + "engine": "native_binary" + }, + { + "syndrome": 27, + "status": "ok", + "logical_hat": 1, + "log_evidence": -3.4649835548877754, + "terminal_log_masses": { + "1": -3.4649835548877754 + }, + "engine": "native_binary" + }, + { + "syndrome": 30, + "status": "ok", + "logical_hat": 6, + "log_evidence": -5.12270201905968, + "terminal_log_masses": { + "2": -7.436914283686047, + "6": -5.2267789325213805 + }, + "engine": "native_binary" + } + ] + }, + { + "name": "random_seed23", + "mechanisms": [ + [ + 0.157882, + [ + 0, + 5 + ], + [ + 0, + 1 + ] + ], + [ + 0.01549, + [ + 1, + 5 + ], + [ + 1 + ] + ], + [ + 0.392697, + [ + 0, + 3, + 4 + ], + [] + ], + [ + 0.310602, + [ + 0, + 3 + ], + [ + 0, + 1 + ] + ], + [ + 0.252187, + [ + 0 + ], + [ + 0, + 1 + ] + ], + [ + 0.233464, + [ + 2, + 4 + ], + [ + 0, + 1 + ] + ], + [ + 0.080991, + [ + 1, + 3 + ], + [ + 0, + 1 + ] + ], + [ + 0.237096, + [ + 4 + ], + [ + 0 + ] + ], + [ + 0.231524, + [ + 0 + ], + [ + 0 + ] + ], + [ + 0.154165, + [ + 2, + 4, + 5 + ], + [] + ], + [ + 0.110783, + [ + 2, + 4 + ], + [ + 0 + ] + ], + [ + 0.285786, + [ + 4 + ], + [] + ] + ], + "num_detectors": 6, + "num_observables": 2, + "syndromes": [ + 6, + 9, + 10, + 15, + 21, + 24, + 28, + 34, + 37, + 47, + 58, + 63 + ], + "pruned": { + "k": 3, + "delta": 20.0 + }, + "expected_unpruned": [ + { + "syndrome": 6, + "status": "ok", + "logical_hat": 3, + "log_evidence": -6.261360364568913, + "terminal_log_masses": { + "0": -8.022112546944442, + "1": -7.381200831217981, + "2": -8.108959728041507, + "3": -7.328074193125911 + }, + "engine": "native_binary" + }, + { + "syndrome": 9, + "status": "ok", + "logical_hat": 3, + "log_evidence": -2.746146123465367, + "terminal_log_masses": { + "0": -4.161166730740183, + "1": -4.178207023817416, + "2": -5.201547393913861, + "3": -3.5844707493527204 + }, + "engine": "native_binary" + }, + { + "syndrome": 10, + "status": "ok", + "logical_hat": 3, + "log_evidence": -4.825670397095194, + "terminal_log_masses": { + "0": -7.181705090376861, + "1": -6.69623585114158, + "2": -7.093662900641675, + "3": -5.260077866992471 + }, + "engine": "native_binary" + }, + { + "syndrome": 15, + "status": "ok", + "logical_hat": 2, + "log_evidence": -6.267659716409388, + "terminal_log_masses": { + "0": -7.536940959893618, + "1": -7.801949941948513, + "2": -7.502800422375863, + "3": -7.816172145726686 + }, + "engine": "native_binary" + }, + { + "syndrome": 21, + "status": "ok", + "logical_hat": 0, + "log_evidence": -3.728576225544649, + "terminal_log_masses": { + "0": -4.705768715947511, + "1": -6.048612948850653, + "2": -4.742088593348227, + "3": -5.546134838095753 + }, + "engine": "native_binary" + }, + { + "syndrome": 24, + "status": "ok", + "logical_hat": 3, + "log_evidence": -3.195049731702389, + "terminal_log_masses": { + "0": -5.198501326106871, + "1": -4.225594619504352, + "2": -5.2657461414212445, + "3": -4.156807045414695 + }, + "engine": "native_binary" + }, + { + "syndrome": 28, + "status": "ok", + "logical_hat": 3, + "log_evidence": -4.048424295677088, + "terminal_log_masses": { + "0": -5.663367805631611, + "1": -5.241754487058921, + "2": -5.818941255520636, + "3": -5.164254047244065 + }, + "engine": "native_binary" + }, + { + "syndrome": 34, + "status": "ok", + "logical_hat": 2, + "log_evidence": -5.919368393148876, + "terminal_log_masses": { + "0": -7.509757989395482, + "1": -7.781149574063392, + "2": -6.8199852871255935, + "3": -7.369926971975649 + }, + "engine": "native_binary" + }, + { + "syndrome": 37, + "status": "ok", + "logical_hat": 0, + "log_evidence": -4.376752960041338, + "terminal_log_masses": { + "0": -5.645368208329156, + "1": -5.805270421594858, + "2": -5.838870051154233, + "3": -5.773622533026615 + }, + "engine": "native_binary" + }, + { + "syndrome": 47, + "status": "ok", + "logical_hat": 1, + "log_evidence": -6.454195312203719, + "terminal_log_masses": { + "0": -8.016944210315367, + "1": -7.677035952817707, + "2": -7.997361477046367, + "3": -7.718822085051365 + }, + "engine": "native_binary" + }, + { + "syndrome": 58, + "status": "ok", + "logical_hat": 1, + "log_evidence": -6.346280908592142, + "terminal_log_masses": { + "0": -7.840804515838283, + "1": -7.5791547012495855, + "2": -7.971308624305485, + "3": -7.593563081590824 + }, + "engine": "native_binary" + }, + { + "syndrome": 63, + "status": "ok", + "logical_hat": 2, + "log_evidence": -6.293149510399605, + "terminal_log_masses": { + "0": -7.548704846288836, + "1": -7.991352744805838, + "2": -7.53198768208597, + "3": -7.710522924736072 + }, + "engine": "native_binary" + } + ], + "expected_pruned": [ + { + "syndrome": 6, + "status": "no_path", + "logical_hat": null, + "log_evidence": null, + "terminal_log_masses": {}, + "engine": "native_binary" + }, + { + "syndrome": 9, + "status": "ok", + "logical_hat": 3, + "log_evidence": -2.9484790481067105, + "terminal_log_masses": { + "0": -4.206361122627545, + "1": -4.459088846121866, + "3": -3.6517386468059914 + }, + "engine": "native_binary" + }, + { + "syndrome": 10, + "status": "no_path", + "logical_hat": null, + "log_evidence": null, + "terminal_log_masses": {}, + "engine": "native_binary" + }, + { + "syndrome": 15, + "status": "no_path", + "logical_hat": null, + "log_evidence": null, + "terminal_log_masses": {}, + "engine": "native_binary" + }, + { + "syndrome": 21, + "status": "ok", + "logical_hat": 2, + "log_evidence": -4.618721109597032, + "terminal_log_masses": { + "0": -6.136926003885628, + "2": -4.866035499364936 + }, + "engine": "native_binary" + }, + { + "syndrome": 24, + "status": "ok", + "logical_hat": 3, + "log_evidence": -3.615451514982718, + "terminal_log_masses": { + "1": -4.490147557262374, + "2": -5.767403798098758, + "3": -4.377403835207338 + }, + "engine": "native_binary" + }, + { + "syndrome": 28, + "status": "ok", + "logical_hat": 2, + "log_evidence": -5.970649988579859, + "terminal_log_masses": { + "0": -7.488854882868454, + "2": -6.217964378347763 + }, + "engine": "native_binary" + }, + { + "syndrome": 34, + "status": "ok", + "logical_hat": 2, + "log_evidence": -6.8891711710562795, + "terminal_log_masses": { + "2": -7.006381644683862, + "3": -9.09098808483612 + }, + "engine": "native_binary" + }, + { + "syndrome": 37, + "status": "ok", + "logical_hat": 0, + "log_evidence": -5.770110042340254, + "terminal_log_masses": { + "0": -6.6332976046584315, + "1": -6.672398242645703, + "2": -7.527211797628485 + }, + "engine": "native_binary" + }, + { + "syndrome": 47, + "status": "no_path", + "logical_hat": null, + "log_evidence": null, + "terminal_log_masses": {}, + "engine": "native_binary" + }, + { + "syndrome": 58, + "status": "no_path", + "logical_hat": null, + "log_evidence": null, + "terminal_log_masses": {}, + "engine": "native_binary" + }, + { + "syndrome": 63, + "status": "no_path", + "logical_hat": null, + "log_evidence": null, + "terminal_log_masses": {}, + "engine": "native_binary" + } + ] + } + ] +} diff --git a/crates/pecos-frontier/tests/frontier.rs b/crates/pecos-frontier/tests/frontier.rs index 664df868c..a5791e879 100644 --- a/crates/pecos-frontier/tests/frontier.rs +++ b/crates/pecos-frontier/tests/frontier.rs @@ -196,7 +196,8 @@ fn degeneracy_mass_beats_the_single_most_likely_error() { let result = decoder.decode(&[1]).unwrap(); assert!(result.predicted.is_zero()); - assert!((result.log_evidence.exp() - 0.224).abs() < 1e-12); + assert!((result.log_evidence.exp() - (0.224 + 0.204)).abs() < 1e-12); + assert!((result.logical_masses[0].log_mass.exp() - 0.224).abs() < 1e-12); assert!((result.runner_up_gap.unwrap() - (0.224_f64 / 0.204).ln()).abs() < 1e-12); } @@ -365,25 +366,35 @@ fn validates_probabilities_indices_order_and_pruning_configuration() { assert!(FrontierDecoder::from_sparse_dem(&two_columns, config).is_err()); } - let one_column = sparse_dem(vec![(0.1, vec![], vec![])], 0, 0); - for config in [ + let k_error = FrontierDecoder::from_sparse_dem( + &zero_dem, FrontierConfig { k: 0, ..FrontierConfig::default() }, + ) + .unwrap_err(); + assert!(k_error.to_string().contains('k')); + + let negative_delta_error = FrontierDecoder::from_sparse_dem( + &zero_dem, FrontierConfig { delta: -0.1, ..FrontierConfig::default() }, + ) + .unwrap_err(); + assert!(negative_delta_error.to_string().contains("delta")); + + let nan_delta_error = FrontierDecoder::from_sparse_dem( + &zero_dem, FrontierConfig { delta: f64::NAN, ..FrontierConfig::default() }, - ] { - let mut decoder = FrontierDecoder::from_sparse_dem(&one_column, config).unwrap(); - let error = decoder.decode(&[]).unwrap_err(); - assert!(error.to_string().contains("pruning parameters")); - } + ) + .unwrap_err(); + assert!(nan_delta_error.to_string().contains("delta")); } #[test] diff --git a/crates/pecos-frontier/tests/upstream_fixtures.rs b/crates/pecos-frontier/tests/upstream_fixtures.rs new file mode 100644 index 000000000..97a601701 --- /dev/null +++ b/crates/pecos-frontier/tests/upstream_fixtures.rs @@ -0,0 +1,192 @@ +// 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. + +use pecos_decoder_core::dem::SparseDem; +use pecos_decoder_core::obs_mask::ObsMask; +use pecos_frontier::{FrontierConfig, FrontierDecoder, FrontierResult}; +use serde::Deserialize; +use std::collections::BTreeMap; + +const FIXTURES_JSON: &str = include_str!("fixtures/upstream_fixtures.json"); + +#[derive(Debug, Deserialize)] +struct FixtureFile { + fixtures: Vec, +} + +#[derive(Debug, Deserialize)] +struct Fixture { + name: String, + mechanisms: Vec<(f64, Vec, Vec)>, + num_detectors: usize, + num_observables: usize, + syndromes: Vec, + expected_unpruned: Vec, +} + +#[derive(Debug, Deserialize)] +struct ExpectedResult { + syndrome: u128, + status: String, + logical_hat: Option, + log_evidence: Option, + terminal_log_masses: BTreeMap, +} + +fn parse_fixtures() -> FixtureFile { + serde_json::from_str(FIXTURES_JSON).expect("upstream fixture file must parse") +} + +fn dense_syndrome(mask: u128, num_detectors: usize) -> Vec { + (0..num_detectors) + .map(|bit| u8::from(mask & (1_u128 << bit) != 0)) + .collect() +} + +fn mask_as_u128(mask: &ObsMask) -> u128 { + assert!( + mask.words().iter().skip(2).all(|&word| word == 0), + "fixture labels must fit in u128" + ); + u128::from(mask.words().first().copied().unwrap_or(0)) + | (u128::from(mask.words().get(1).copied().unwrap_or(0)) << 64) +} + +fn actual_masses(result: &FrontierResult) -> BTreeMap { + result + .logical_masses + .iter() + .map(|entry| (mask_as_u128(&entry.logical), entry.log_mass)) + .collect() +} + +#[test] +fn unpruned_results_match_upstream_golden_fixtures() { + let fixture_file = parse_fixtures(); + + for fixture in fixture_file.fixtures { + let dem = SparseDem { + mechanisms: fixture.mechanisms, + detector_coords: BTreeMap::new(), + num_detectors: fixture.num_detectors, + num_observables: fixture.num_observables, + }; + let config = FrontierConfig { + k: usize::MAX, + delta: f64::INFINITY, + column_order: None, + }; + let mut decoder = FrontierDecoder::from_sparse_dem(&dem, config).unwrap_or_else(|error| { + panic!("{}: decoder construction failed: {error}", fixture.name) + }); + + let mut expected_by_syndrome = BTreeMap::new(); + for expected in fixture.expected_unpruned { + assert!( + expected_by_syndrome + .insert(expected.syndrome, expected) + .is_none(), + "{}: duplicate expected syndrome", + fixture.name + ); + } + assert_eq!( + expected_by_syndrome.len(), + fixture.syndromes.len(), + "{}: expected result count differs from syndrome count", + fixture.name + ); + + for syndrome_mask in fixture.syndromes { + let expected = expected_by_syndrome + .remove(&syndrome_mask) + .unwrap_or_else(|| panic!("{}: missing expected result", fixture.name)); + let syndrome = dense_syndrome(syndrome_mask, fixture.num_detectors); + let decoded = decoder.decode(&syndrome); + + match expected.status.as_str() { + "ok" => { + let result = decoded.unwrap_or_else(|error| { + panic!( + "{} syndrome {syndrome_mask}: expected success, got {error}", + fixture.name + ) + }); + let logical_hat = expected.logical_hat.expect("ok result needs logical_hat"); + assert_eq!( + mask_as_u128(&result.predicted), + logical_hat, + "{} syndrome {syndrome_mask}: predicted label", + fixture.name + ); + + let expected_masses: BTreeMap = expected + .terminal_log_masses + .into_iter() + .map(|(label, mass)| { + (label.parse().expect("logical label must fit in u128"), mass) + }) + .collect(); + let actual_masses = actual_masses(&result); + assert_eq!( + actual_masses.len(), + expected_masses.len(), + "{} syndrome {syndrome_mask}: terminal-label count", + fixture.name + ); + for (label, expected_mass) in expected_masses { + let actual_mass = actual_masses.get(&label).unwrap_or_else(|| { + panic!( + "{} syndrome {syndrome_mask}: missing label {label}", + fixture.name + ) + }); + assert!( + (actual_mass - expected_mass).abs() <= 1e-9, + "{} syndrome {syndrome_mask}, label {label}: expected {expected_mass}, got {actual_mass}", + fixture.name + ); + } + + let expected_evidence = expected + .log_evidence + .expect("ok result needs finite log_evidence"); + assert!( + (result.log_evidence - expected_evidence).abs() <= 1e-9, + "{} syndrome {syndrome_mask}: expected evidence {expected_evidence}, got {}", + fixture.name, + result.log_evidence + ); + } + "no_path" => assert!( + decoded.is_err(), + "{} syndrome {syndrome_mask}: expected no path", + fixture.name + ), + status => panic!( + "{} syndrome {syndrome_mask}: unknown fixture status {status}", + fixture.name + ), + } + } + + assert!( + expected_by_syndrome.is_empty(), + "{}: expected results contain extra syndromes", + fixture.name + ); + } + + // `expected_pruned` is intentionally ignored: upstream pruning includes a + // score_alpha mixing parameter that v1 deliberately did not port, so + // pruned-path parity is out of scope. +} From 9fc312a4d977a0e577b5d071366afb32b75c6461 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 15:20:23 -0600 Subject: [PATCH 03/10] Fix wide-label tie-break word order, reject duplicate mechanism indices, support p=1 forced mechanisms, disclose prefix-mass-only pruning --- crates/pecos-frontier/README.md | 9 + crates/pecos-frontier/src/lib.rs | 112 +++++++++--- crates/pecos-frontier/tests/frontier.rs | 159 +++++++++++++++++- .../pecos-frontier/tests/upstream_fixtures.rs | 32 ++++ 4 files changed, 280 insertions(+), 32 deletions(-) diff --git a/crates/pecos-frontier/README.md b/crates/pecos-frontier/README.md index c15b38023..9cf16d416 100644 --- a/crates/pecos-frontier/README.md +++ b/crates/pecos-frontier/README.md @@ -1,3 +1,12 @@ # PECOS Frontier Decoder Native Rust implementation of the Frontier approximate logical maximum-likelihood decoder. + +`k` and `delta` operate on prefix log mass only (upstream's `score_alpha` +suffix-compatibility scoring is a planned follow-up); pruned results and K/Delta +values are not directly comparable to upstream until then. Unpruned results are +exact and upstream-verified. + +Deterministic ordering and tie-breaking are bitwise reproducible for a fixed +build and platform. The platform's `ln` and `exp` implementations may differ +across platforms. diff --git a/crates/pecos-frontier/src/lib.rs b/crates/pecos-frontier/src/lib.rs index 9a01aa842..3b3d14131 100644 --- a/crates/pecos-frontier/src/lib.rs +++ b/crates/pecos-frontier/src/lib.rs @@ -15,7 +15,14 @@ //! The decoder performs ordered dynamic programming over independent binary //! fault mechanisms. Prefixes with identical active detector boundary and //! logical labels are merged by log-sum-exp, preserving degeneracy mass. The -//! configured frontier width and log-mass window provide deterministic pruning. +//! configured frontier width and log-mass window provide deterministic pruning +//! for a fixed build and platform; underlying `ln`/`exp` implementations may +//! differ across platforms. +//! +//! `k` and `delta` operate on prefix log mass only (upstream's `score_alpha` +//! suffix-compatibility scoring is a planned follow-up); pruned results and +//! K/Delta values are not directly comparable to upstream until then. Unpruned +//! results are exact and upstream-verified. use pecos_decoder_core::ObservableDecoder; use pecos_decoder_core::dem::SparseDem; @@ -29,6 +36,10 @@ const WORD_BITS: usize = u64::BITS as usize; /// Frontier pruning and column-order configuration. /// /// The [`Default`] pruning values are provisional pending benchmarking. +/// `k` and `delta` operate on prefix log mass only (upstream's `score_alpha` +/// suffix-compatibility scoring is a planned follow-up); pruned results and +/// K/Delta values are not directly comparable to upstream until then. Unpruned +/// results are exact and upstream-verified. #[derive(Clone, Debug, PartialEq)] pub struct FrontierConfig { /// Maximum number of boundary states retained after each column. @@ -50,12 +61,14 @@ impl Default for FrontierConfig { } } -/// Retained posterior log mass for one logical label. +/// Retained unnormalized joint log mass for one logical label. #[derive(Clone, Debug, PartialEq)] pub struct FrontierLogicalMass { /// Logical-observable flip label. pub logical: ObsMask, - /// Natural logarithm of the retained probability mass for this label. + /// Unnormalized joint mass `ln P(logical class, observed syndrome)`. + /// Subtract [`FrontierResult::log_evidence`] to obtain the label's log + /// posterior probability within the retained terminal mass. pub log_mass: f64, } @@ -64,20 +77,23 @@ pub struct FrontierLogicalMass { pub struct FrontierResult { /// Predicted logical-observable flip mask. pub predicted: ObsMask, - /// Natural logarithm of the total retained probability mass over all - /// terminal logical labels. + /// Log evidence: the logarithm of the total retained joint mass over all + /// terminal logical labels, approximating `ln P(observed syndrome)` when + /// pruning is enabled. /// /// The winning label's own log mass is [`Self::logical_masses`]'s first /// entry. pub log_evidence: f64, - /// Difference between the winning and runner-up log masses, if one exists. + /// Difference between the winning and runner-up unnormalized joint log + /// masses, if a runner-up exists. pub runner_up_gap: Option, /// Largest retained frontier size, including the initial boundary state. pub peak_retained_states: usize, - /// Number of nonzero-probability columns processed. + /// Number of probabilistic columns processed (`0 < p < 1`). pub processed_columns: usize, - /// Retained terminal masses, ordered by mass descending and label ascending. - /// The first entry is the winning label and its retained log mass. + /// Retained unnormalized joint terminal masses, ordered by mass descending + /// and numeric label ascending. The first entry is the winning label and + /// its retained log mass. pub logical_masses: Vec, } @@ -91,12 +107,25 @@ struct Column { log_one_minus_probability: f64, } -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +#[derive(Clone, Debug, Eq, PartialEq)] struct StateKey { active_syndrome: Vec, logical: Vec, } +impl Ord for StateKey { + fn cmp(&self, other: &Self) -> Ordering { + compare_words_as_unsigned(&self.active_syndrome, &other.active_syndrome) + .then_with(|| compare_words_as_unsigned(&self.logical, &other.logical)) + } +} + +impl PartialOrd for StateKey { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + #[derive(Clone, Debug)] struct Candidate { key: StateKey, @@ -112,13 +141,16 @@ pub struct FrontierDecoder { detector_words: usize, logical_words: usize, touched_detectors: Vec, + forced_syndrome: Vec, + forced_logical: Vec, } impl FrontierDecoder { /// Construct a decoder from a sparse detector error model. /// - /// Zero-probability mechanisms are discarded after validating the optional - /// ordering permutation. All indices and nonzero probabilities are checked. + /// Zero-probability mechanisms are discarded and probability-one mechanisms + /// are folded into the initial state after validating the optional ordering + /// permutation. All indices and probabilities are checked. /// /// # Errors /// @@ -134,6 +166,8 @@ impl FrontierDecoder { .clone() .unwrap_or_else(|| (0..dem.mechanisms.len()).collect()); let mut raw_columns = Vec::with_capacity(dem.mechanisms.len()); + let mut forced_syndrome = vec![0; detector_words]; + let mut forced_logical = vec![0; logical_words]; for mechanism_index in order { let (probability, detectors, observables) = &dem.mechanisms[mechanism_index]; @@ -149,11 +183,15 @@ impl FrontierDecoder { continue; } - raw_columns.push(( - indices_to_words(detectors, detector_words), - indices_to_words(observables, logical_words), - *probability, - )); + let detector_toggle = indices_to_words(detectors, detector_words); + let logical_toggle = indices_to_words(observables, logical_words); + if probability.to_bits() == 1.0_f64.to_bits() { + xor_assign(&mut forced_syndrome, &detector_toggle); + xor_assign(&mut forced_logical, &logical_toggle); + continue; + } + + raw_columns.push((detector_toggle, logical_toggle, *probability)); } let mut touched_detectors = vec![0; detector_words]; @@ -165,7 +203,10 @@ impl FrontierDecoder { } } - let mut open_detectors = vec![0; detector_words]; + // Seed with the forced contribution: detectors carrying a forced bit + // must stay in every active mask until their closing column, or the + // per-step projection would erase the bit before that column arrives. + let mut open_detectors = forced_syndrome.clone(); let mut columns = Vec::with_capacity(raw_columns.len()); for (column_index, (detector_toggle, logical_toggle, probability)) in raw_columns.into_iter().enumerate() @@ -196,6 +237,8 @@ impl FrontierDecoder { detector_words, logical_words, touched_detectors, + forced_syndrome, + forced_logical, }) } @@ -228,15 +271,18 @@ impl FrontierDecoder { let observed = syndrome_to_words(syndrome, self.detector_words); if observed .iter() + .zip(&self.forced_syndrome) .zip(&self.touched_detectors) - .any(|(&seen, &touched)| seen & !touched != 0) + .any(|((&seen, &forced), &touched)| (seen ^ forced) & !touched != 0) { return Err(unexplainable_error()); } + let mut initial_syndrome = self.forced_syndrome.clone(); + and_assign(&mut initial_syndrome, &self.touched_detectors); let initial = StateKey { - active_syndrome: vec![0; self.detector_words], - logical: vec![0; self.logical_words], + active_syndrome: initial_syndrome, + logical: self.forced_logical.clone(), }; let mut frontier = BTreeMap::from([(initial, 0.0)]); let mut peak_retained_states = frontier.len(); @@ -347,9 +393,9 @@ fn validate_config(config: &FrontierConfig, mechanism_count: usize) -> Result<() } fn validate_probability(probability: f64, index: usize) -> Result<(), DecoderError> { - if !(0.0..1.0).contains(&probability) { + if !(0.0..=1.0).contains(&probability) { return Err(DecoderError::InvalidConfiguration(format!( - "mechanism {index} probability must satisfy 0 <= p < 1, got {probability}" + "mechanism {index} probability must satisfy 0 <= p <= 1, got {probability}" ))); } Ok(()) @@ -361,14 +407,26 @@ fn validate_indices( kind: &str, mechanism_index: usize, ) -> Result<(), DecoderError> { - if let Some(&index) = indices.iter().find(|&&index| index as usize >= upper_bound) { - return Err(DecoderError::InvalidConfiguration(format!( - "mechanism {mechanism_index} {kind} index {index} is out of range 0..{upper_bound}" - ))); + let mut seen = std::collections::BTreeSet::new(); + for &index in indices { + if index as usize >= upper_bound { + return Err(DecoderError::InvalidConfiguration(format!( + "mechanism {mechanism_index} {kind} index {index} is out of range 0..{upper_bound}" + ))); + } + if !seen.insert(index) { + return Err(DecoderError::InvalidConfiguration(format!( + "mechanism {mechanism_index} repeats {kind} index {index}" + ))); + } } Ok(()) } +fn compare_words_as_unsigned(left: &[u64], right: &[u64]) -> Ordering { + left.iter().rev().cmp(right.iter().rev()) +} + fn merge_branch( merged: &mut BTreeMap, mut state: StateKey, diff --git a/crates/pecos-frontier/tests/frontier.rs b/crates/pecos-frontier/tests/frontier.rs index a5791e879..cd1826d76 100644 --- a/crates/pecos-frontier/tests/frontier.rs +++ b/crates/pecos-frontier/tests/frontier.rs @@ -43,6 +43,9 @@ fn independent_logaddexp(left: f64, right: f64) -> f64 { if left == f64::NEG_INFINITY { return right; } + if right == f64::NEG_INFINITY { + return left; + } let high = left.max(right); let low = left.min(right); high + (low - high).exp().ln_1p() @@ -70,7 +73,7 @@ fn independent_enumeration(dem: &SparseDem, observed: &[u8]) -> BTreeMap BTreeMap std::cmp::Ordering { + left.iter().rev().cmp(right.iter().rev()) +} + fn independent_winner(masses: &BTreeMap, f64>) -> Vec { masses .iter() .min_by(|(left_label, left_mass), (right_label, right_mass)| { right_mass .total_cmp(left_mass) - .then_with(|| left_label.cmp(right_label)) + .then_with(|| numeric_words_cmp(left_label, right_label)) }) .map(|(label, _)| label.clone()) .expect("generated syndrome must have at least one explanation") @@ -103,6 +110,7 @@ fn result_mass_map(result: &FrontierResult) -> BTreeMap, f64> { #[test] fn unpruned_matches_independent_brute_force_on_seeded_random_dems() { let mut rng = Xoshiro256PlusPlus::seed_from_u64(0x4652_4f4e_5449_4552); + let mut forced_mechanism_count = 0; for case_index in 0..33 { let mechanism_count = 4 + case_index % 11; @@ -113,7 +121,13 @@ fn unpruned_matches_independent_brute_force_on_seeded_random_dems() { let mut observed = vec![0_u8; num_detectors]; for mechanism_index in 0..mechanism_count { - let probability = rng.random_range(0.01..0.4); + let forced = rng.random_bool(0.08); + let probability = if forced { + forced_mechanism_count += 1; + 1.0 + } else { + rng.random_range(0.01..0.4) + }; let max_weight = num_detectors.min(3); let detector_count = if mechanism_index % 3 == 0 && max_weight >= 2 { rng.random_range(2..=max_weight) @@ -141,7 +155,7 @@ fn unpruned_matches_independent_brute_force_on_seeded_random_dems() { } observables.sort_unstable(); - let taken = rng.random_bool(0.35); + let taken = forced || rng.random_bool(0.35); if taken { for &detector in &detectors { observed[detector as usize] ^= 1; @@ -175,6 +189,11 @@ fn unpruned_matches_independent_brute_force_on_seeded_random_dems() { "case {case_index}, sampled subset {sampled_subset:?}" ); } + + assert!( + forced_mechanism_count > 0, + "seeded models must exercise forced mechanisms" + ); } #[test] @@ -217,6 +236,133 @@ fn supports_wide_detectors_and_observables_without_truncation() { )); } +#[test] +fn wide_logical_ties_use_numeric_label_order() { + let dem = sparse_dem( + vec![(0.5, vec![0], vec![0]), (0.5, vec![0], vec![64])], + 1, + 65, + ); + let mut decoder = FrontierDecoder::from_sparse_dem(&dem, exact_config()).unwrap(); + let result = decoder.decode(&[1]).unwrap(); + + assert_eq!(result.predicted, ObsMask::from_u64(1)); + assert_eq!(result.runner_up_gap, Some(0.0)); + assert_eq!(result.logical_masses[0].logical, ObsMask::from_u64(1)); +} + +#[test] +fn rejects_duplicate_detector_and_observable_indices() { + let duplicate_detector = sparse_dem(vec![(0.1, vec![0, 0], vec![])], 1, 0); + let detector_error = + FrontierDecoder::from_sparse_dem(&duplicate_detector, exact_config()).unwrap_err(); + let detector_message = detector_error.to_string(); + assert!(detector_message.contains("mechanism 0")); + assert!(detector_message.contains("detector index 0")); + + let duplicate_observable = sparse_dem(vec![(0.1, vec![], vec![1, 1])], 0, 2); + let observable_error = + FrontierDecoder::from_sparse_dem(&duplicate_observable, exact_config()).unwrap_err(); + let observable_message = observable_error.to_string(); + assert!(observable_message.contains("mechanism 0")); + assert!(observable_message.contains("observable index 1")); + + let parsed_error = + FrontierDecoder::from_dem_str("error(0.1) D0 D0\n", FrontierConfig::default()).unwrap_err(); + assert!(parsed_error.to_string().contains("detector index 0")); +} + +#[test] +fn forced_only_detector_requires_its_deterministic_syndrome() { + let dem = sparse_dem(vec![(1.0, vec![0], vec![])], 1, 0); + let mut decoder = FrontierDecoder::from_sparse_dem(&dem, exact_config()).unwrap(); + + let matching = decoder.decode(&[1]).unwrap(); + assert!(matching.predicted.is_zero()); + assert_eq!(matching.log_evidence.to_bits(), 0.0_f64.to_bits()); + assert_eq!(matching.processed_columns, 0); + assert!(decoder.decode(&[0]).is_err()); +} + +#[test] +fn forced_syndrome_shifts_shared_probabilistic_detector() { + let dem = sparse_dem(vec![(1.0, vec![0], vec![0]), (0.2, vec![0], vec![1])], 1, 2); + let mut decoder = FrontierDecoder::from_sparse_dem(&dem, exact_config()).unwrap(); + + let probabilistic_skip = decoder.decode(&[1]).unwrap(); + assert_eq!(probabilistic_skip.predicted, ObsMask::from_u64(1)); + assert!((probabilistic_skip.log_evidence.exp() - 0.8).abs() < 1e-12); + + let probabilistic_take = decoder.decode(&[0]).unwrap(); + assert_eq!(probabilistic_take.predicted, ObsMask::from_u64(3)); + assert!((probabilistic_take.log_evidence.exp() - 0.2).abs() < 1e-12); +} + +#[test] +fn forced_logical_flip_seeds_the_winning_label() { + let dem = sparse_dem(vec![(1.0, vec![], vec![1]), (0.1, vec![], vec![0])], 0, 2); + let mut decoder = FrontierDecoder::from_sparse_dem(&dem, exact_config()).unwrap(); + let result = decoder.decode(&[]).unwrap(); + + assert_eq!(result.predicted, ObsMask::from_u64(2)); + assert!((result.logical_masses[0].log_mass.exp() - 0.9).abs() < 1e-12); +} + +#[test] +fn truly_empty_dem_decodes_to_empty_label_with_unit_evidence() { + let dem = sparse_dem(Vec::new(), 0, 0); + let mut decoder = FrontierDecoder::from_sparse_dem(&dem, exact_config()).unwrap(); + let result = decoder.decode(&[]).unwrap(); + + assert!(result.predicted.is_zero()); + assert_eq!(result.log_evidence.to_bits(), 0.0_f64.to_bits()); + assert_eq!(result.logical_masses.len(), 1); +} + +#[test] +fn interleaved_syndromes_do_not_retain_decode_state() { + let dem = sparse_dem(vec![(0.2, vec![0], vec![0]), (0.3, vec![1], vec![1])], 2, 2); + let mut decoder = FrontierDecoder::from_sparse_dem(&dem, exact_config()).unwrap(); + + let first_a = decoder.decode(&[1, 0]).unwrap(); + let b = decoder.decode(&[0, 1]).unwrap(); + let second_a = decoder.decode(&[1, 0]).unwrap(); + + assert_eq!(first_a, second_a); + assert_eq!(first_a.predicted, ObsMask::from_u64(1)); + assert_eq!(b.predicted, ObsMask::from_u64(2)); + assert_ne!(first_a.predicted, b.predicted); +} + +#[test] +fn default_batch_decode_matches_individual_shots() { + let dem = sparse_dem(vec![(0.2, vec![0], vec![0]), (0.3, vec![1], vec![1])], 2, 2); + let mut decoder = FrontierDecoder::from_sparse_dem(&dem, exact_config()).unwrap(); + let expected = vec![ + decoder.decode_to_observables(&[1, 0]).unwrap(), + decoder.decode_to_observables(&[0, 1]).unwrap(), + decoder.decode_to_observables(&[1, 1]).unwrap(), + ]; + + let batched = decoder + .decode_batch_to_observables(&[1, 0, 0, 1, 1, 1], 3, 2) + .unwrap(); + assert_eq!(batched, expected); +} + +#[test] +fn observable_only_mechanism_has_both_terminal_labels() { + let dem = sparse_dem(vec![(0.2, vec![], vec![0])], 0, 1); + let mut decoder = FrontierDecoder::from_sparse_dem(&dem, exact_config()).unwrap(); + let result = decoder.decode(&[]).unwrap(); + + assert!(result.predicted.is_zero()); + assert_eq!(result.logical_masses.len(), 2); + assert!((result.logical_masses[0].log_mass.exp() - 0.8).abs() < 1e-12); + assert!((result.logical_masses[1].log_mass.exp() - 0.2).abs() < 1e-12); + assert!(result.log_evidence.abs() < 1e-12); +} + #[test] fn fails_loud_for_untouched_and_unachievable_syndromes() { let untouched_dem = sparse_dem(vec![(0.2, vec![0], vec![])], 2, 0); @@ -335,7 +481,10 @@ fn parses_a_stim_dem_string() { #[test] fn validates_probabilities_indices_order_and_pruning_configuration() { - for probability in [1.0, 1.1, -0.1, f64::NAN, f64::INFINITY] { + let probability_one_dem = sparse_dem(vec![(1.0, vec![], vec![])], 0, 0); + assert!(FrontierDecoder::from_sparse_dem(&probability_one_dem, exact_config()).is_ok()); + + for probability in [1.000_000_1, 1.1, -0.1, f64::NAN, f64::INFINITY] { let dem = sparse_dem(vec![(probability, vec![], vec![])], 0, 0); assert!(matches!( FrontierDecoder::from_sparse_dem(&dem, FrontierConfig::default()), diff --git a/crates/pecos-frontier/tests/upstream_fixtures.rs b/crates/pecos-frontier/tests/upstream_fixtures.rs index 97a601701..c82289c9f 100644 --- a/crates/pecos-frontier/tests/upstream_fixtures.rs +++ b/crates/pecos-frontier/tests/upstream_fixtures.rs @@ -19,27 +19,41 @@ use std::collections::BTreeMap; const FIXTURES_JSON: &str = include_str!("fixtures/upstream_fixtures.json"); #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct FixtureFile { + generator: String, fixtures: Vec, } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct Fixture { name: String, mechanisms: Vec<(f64, Vec, Vec)>, num_detectors: usize, num_observables: usize, syndromes: Vec, + pruned: PruningConfig, expected_unpruned: Vec, + expected_pruned: Vec, } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PruningConfig { + k: usize, + delta: f64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct ExpectedResult { syndrome: u128, status: String, logical_hat: Option, log_evidence: Option, terminal_log_masses: BTreeMap, + engine: String, } fn parse_fixtures() -> FixtureFile { @@ -72,8 +86,21 @@ fn actual_masses(result: &FrontierResult) -> BTreeMap { #[test] fn unpruned_results_match_upstream_golden_fixtures() { let fixture_file = parse_fixtures(); + assert_eq!(fixture_file.generator, "generate_upstream_fixtures.py"); for fixture in fixture_file.fixtures { + assert!(fixture.pruned.k > 0, "{}: invalid pruned K", fixture.name); + assert!( + fixture.pruned.delta >= 0.0, + "{}: invalid pruned Delta", + fixture.name + ); + assert_eq!( + fixture.expected_pruned.len(), + fixture.syndromes.len(), + "{}: pruned result count differs from syndrome count", + fixture.name + ); let dem = SparseDem { mechanisms: fixture.mechanisms, detector_coords: BTreeMap::new(), @@ -110,6 +137,11 @@ fn unpruned_results_match_upstream_golden_fixtures() { let expected = expected_by_syndrome .remove(&syndrome_mask) .unwrap_or_else(|| panic!("{}: missing expected result", fixture.name)); + assert_eq!( + expected.engine, "native_binary", + "{} syndrome {syndrome_mask}: unexpected engine", + fixture.name + ); let syndrome = dense_syndrome(syndrome_mask, fixture.num_detectors); let decoded = decoder.decode(&syndrome); From 160a6c4fa87a1fdd9fc812467bb5bf48b9223f2b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 16:43:27 -0600 Subject: [PATCH 04/10] Port upstream score_alpha suffix-compatibility pruning with golden-fixture pruned parity --- crates/pecos-frontier/README.md | 6 +- crates/pecos-frontier/src/lib.rs | 136 +++++++-- crates/pecos-frontier/tests/frontier.rs | 58 ++++ .../pecos-frontier/tests/upstream_fixtures.rs | 263 ++++++++++-------- 4 files changed, 326 insertions(+), 137 deletions(-) diff --git a/crates/pecos-frontier/README.md b/crates/pecos-frontier/README.md index 9cf16d416..01f9c145b 100644 --- a/crates/pecos-frontier/README.md +++ b/crates/pecos-frontier/README.md @@ -2,10 +2,8 @@ Native Rust implementation of the Frontier approximate logical maximum-likelihood decoder. -`k` and `delta` operate on prefix log mass only (upstream's `score_alpha` -suffix-compatibility scoring is a planned follow-up); pruned results and K/Delta -values are not directly comparable to upstream until then. Unpruned results are -exact and upstream-verified. +Pruning ranks accumulated prefix log mass plus a `score_alpha`-weighted +suffix-compatibility estimate. Unpruned results are exact and upstream-verified. Deterministic ordering and tie-breaking are bitwise reproducible for a fixed build and platform. The platform's `ln` and `exp` implementations may differ diff --git a/crates/pecos-frontier/src/lib.rs b/crates/pecos-frontier/src/lib.rs index 3b3d14131..103451941 100644 --- a/crates/pecos-frontier/src/lib.rs +++ b/crates/pecos-frontier/src/lib.rs @@ -19,10 +19,9 @@ //! for a fixed build and platform; underlying `ln`/`exp` implementations may //! differ across platforms. //! -//! `k` and `delta` operate on prefix log mass only (upstream's `score_alpha` -//! suffix-compatibility scoring is a planned follow-up); pruned results and -//! K/Delta values are not directly comparable to upstream until then. Unpruned -//! results are exact and upstream-verified. +//! Pruning ranks accumulated prefix log mass plus a `score_alpha`-weighted +//! suffix-compatibility estimate. Unpruned results are exact and +//! upstream-verified. use pecos_decoder_core::ObservableDecoder; use pecos_decoder_core::dem::SparseDem; @@ -36,16 +35,18 @@ const WORD_BITS: usize = u64::BITS as usize; /// Frontier pruning and column-order configuration. /// /// The [`Default`] pruning values are provisional pending benchmarking. -/// `k` and `delta` operate on prefix log mass only (upstream's `score_alpha` -/// suffix-compatibility scoring is a planned follow-up); pruned results and -/// K/Delta values are not directly comparable to upstream until then. Unpruned -/// results are exact and upstream-verified. +/// Pruning ranks accumulated prefix log mass plus a `score_alpha`-weighted +/// suffix-compatibility estimate. Unpruned results are exact and +/// upstream-verified. #[derive(Clone, Debug, PartialEq)] pub struct FrontierConfig { /// Maximum number of boundary states retained after each column. pub k: usize, /// Log-mass window below the best boundary state retained after each column. pub delta: f64, + /// Weight applied to the suffix-compatibility score during pruning. + /// Defaults to `0.8`, matching upstream Frontier. + pub score_alpha: f64, /// Optional permutation of the DEM mechanism indices. pub column_order: Option>, } @@ -56,6 +57,7 @@ impl Default for FrontierConfig { Self { k: 64, delta: 50.0, + score_alpha: 0.8, column_order: None, } } @@ -103,10 +105,19 @@ struct Column { logical_toggle: Vec, close_mask: Vec, active_mask: Vec, + suffix_compatibility: Vec, log_odds: f64, log_one_minus_probability: f64, } +#[derive(Clone, Debug)] +struct SuffixCompatibility { + word_index: usize, + bit_mask: u64, + log_probability_zero: f64, + log_probability_one: f64, +} + #[derive(Clone, Debug, Eq, PartialEq)] struct StateKey { active_syndrome: Vec, @@ -132,6 +143,12 @@ struct Candidate { log_mass: f64, } +#[derive(Clone, Debug)] +struct ScoredCandidate { + candidate: Candidate, + score: f64, +} + /// Ordered, pruned dynamic-programming decoder for sparse detector error models. #[derive(Clone, Debug)] pub struct FrontierDecoder { @@ -208,6 +225,7 @@ impl FrontierDecoder { // per-step projection would erase the bit before that column arrives. let mut open_detectors = forced_syndrome.clone(); let mut columns = Vec::with_capacity(raw_columns.len()); + let mut column_moments = Vec::with_capacity(raw_columns.len()); for (column_index, (detector_toggle, logical_toggle, probability)) in raw_columns.into_iter().enumerate() { @@ -220,16 +238,40 @@ impl FrontierDecoder { } and_not_assign(&mut open_detectors, &close_mask); + column_moments.push(1.0 - 2.0 * probability); columns.push(Column { detector_toggle, logical_toggle, close_mask, active_mask: open_detectors.clone(), + suffix_compatibility: Vec::new(), log_odds: (probability / (1.0 - probability)).ln(), log_one_minus_probability: (1.0 - probability).ln(), }); } + let mut row_moments: Vec = vec![1.0; dem.num_detectors]; + for (column, moment) in columns + .iter_mut() + .rev() + .zip(column_moments.into_iter().rev()) + { + column.suffix_compatibility = set_bits(&column.active_mask) + .map(|detector| { + let eta = row_moments[detector]; + SuffixCompatibility { + word_index: detector / WORD_BITS, + bit_mask: 1 << (detector % WORD_BITS), + log_probability_zero: 1.0_f64.midpoint(eta).ln(), + log_probability_one: 1.0_f64.midpoint(-eta).ln(), + } + }) + .collect(); + for detector in set_bits(&column.detector_toggle) { + row_moments[detector] *= moment; + } + } + Ok(Self { config, columns, @@ -308,7 +350,14 @@ impl FrontierDecoder { if merged.is_empty() { return Err(unexplainable_error()); } - frontier = prune(merged, self.config.k, self.config.delta); + frontier = prune( + merged, + self.config.k, + self.config.delta, + self.config.score_alpha, + &column.suffix_compatibility, + &observed, + ); if frontier.is_empty() { return Err(unexplainable_error()); } @@ -373,6 +422,12 @@ fn validate_config(config: &FrontierConfig, mechanism_count: usize) -> Result<() config.delta ))); } + if !config.score_alpha.is_finite() || config.score_alpha < 0.0 { + return Err(DecoderError::InvalidConfiguration(format!( + "FrontierConfig.score_alpha must be finite and non-negative, got {}", + config.score_alpha + ))); + } if let Some(order) = &config.column_order { if order.len() != mechanism_count { return Err(DecoderError::InvalidConfiguration(format!( @@ -450,21 +505,70 @@ fn merge_branch( .or_insert(log_mass); } -fn prune(frontier: BTreeMap, k: usize, delta: f64) -> BTreeMap { - let mut candidates: Vec = frontier +fn prune( + frontier: BTreeMap, + k: usize, + delta: f64, + score_alpha: f64, + suffix_compatibility: &[SuffixCompatibility], + observed: &[u64], +) -> BTreeMap { + if k == usize::MAX && delta.is_infinite() { + return frontier; + } + + let mut candidates: Vec = frontier .into_iter() - .map(|(key, log_mass)| Candidate { key, log_mass }) + .map(|(key, log_mass)| { + let score = if score_alpha == 0.0 { + log_mass + } else { + log_mass + + score_alpha + * suffix_compatibility_score( + &key.active_syndrome, + observed, + suffix_compatibility, + ) + }; + ScoredCandidate { + candidate: Candidate { key, log_mass }, + score, + } + }) .collect(); - sort_candidates(&mut candidates); - let cutoff = candidates[0].log_mass - delta; + candidates.sort_by(|left, right| { + right + .score + .total_cmp(&left.score) + .then_with(|| left.candidate.key.cmp(&right.candidate.key)) + }); + let cutoff = candidates[0].score - delta; candidates .into_iter() .take(k) - .take_while(|candidate| candidate.log_mass >= cutoff) - .map(|candidate| (candidate.key, candidate.log_mass)) + .take_while(|candidate| candidate.score >= cutoff) + .map(|scored| (scored.candidate.key, scored.candidate.log_mass)) .collect() } +fn suffix_compatibility_score( + active_syndrome: &[u64], + observed: &[u64], + suffix_compatibility: &[SuffixCompatibility], +) -> f64 { + suffix_compatibility + .iter() + .map(|row| { + if (active_syndrome[row.word_index] ^ observed[row.word_index]) & row.bit_mask == 0 { + row.log_probability_zero + } else { + row.log_probability_one + } + }) + .sum() +} + fn sort_candidates(candidates: &mut [Candidate]) { candidates.sort_by(|left, right| { right diff --git a/crates/pecos-frontier/tests/frontier.rs b/crates/pecos-frontier/tests/frontier.rs index cd1826d76..4b63947b3 100644 --- a/crates/pecos-frontier/tests/frontier.rs +++ b/crates/pecos-frontier/tests/frontier.rs @@ -35,6 +35,7 @@ fn exact_config() -> FrontierConfig { FrontierConfig { k: usize::MAX, delta: f64::INFINITY, + score_alpha: 0.8, column_order: None, } } @@ -387,6 +388,7 @@ fn overpruning_can_remove_the_only_eventually_feasible_prefix() { let tight = FrontierConfig { k: 1, delta: 0.01, + score_alpha: 0.0, column_order: None, }; let mut overpruned = FrontierDecoder::from_sparse_dem(&dem, tight).unwrap(); @@ -419,6 +421,7 @@ fn width_and_delta_pruning_can_change_the_logical_answer() { FrontierConfig { k: 1, delta: f64::INFINITY, + score_alpha: 0.0, column_order: None, }, ) @@ -430,6 +433,7 @@ fn width_and_delta_pruning_can_change_the_logical_answer() { FrontierConfig { k: usize::MAX, delta: 0.1, + score_alpha: 0.0, column_order: None, }, ) @@ -440,6 +444,44 @@ fn width_and_delta_pruning_can_change_the_logical_answer() { ); } +#[test] +fn suffix_compatibility_changes_the_greedy_survivor() { + // After column 0, prefix-only scoring prefers skip (mass 0.6) over take + // (mass 0.4). For observed D0=1, the future p=0.1 column gives residual + // compatibility rho=0.1 after skip and rho=0.9 after take. At alpha=0.8: + // skip: ln(0.6) + 0.8 ln(0.1) = -2.353 + // take: ln(0.4) + 0.8 ln(0.9) = -1.001 + // Taking column 0 then skipping column 1 is the correct logical class; + // prefix-only K=1 instead keeps skip and must take logical-flipping column 1. + let dem = sparse_dem(vec![(0.4, vec![0], vec![]), (0.1, vec![0], vec![0])], 1, 1); + let mut prefix_only = FrontierDecoder::from_sparse_dem( + &dem, + FrontierConfig { + k: 1, + delta: f64::INFINITY, + score_alpha: 0.0, + column_order: None, + }, + ) + .unwrap(); + let mut suffix_scored = FrontierDecoder::from_sparse_dem( + &dem, + FrontierConfig { + k: 1, + delta: f64::INFINITY, + score_alpha: 0.8, + column_order: None, + }, + ) + .unwrap(); + + assert_eq!( + prefix_only.decode(&[1]).unwrap().predicted, + ObsMask::from_u64(1) + ); + assert!(suffix_scored.decode(&[1]).unwrap().predicted.is_zero()); +} + #[test] fn decoding_is_bitwise_deterministic_including_a_tie() { let dem = sparse_dem(vec![(0.5, vec![0], vec![0]), (0.5, vec![0], vec![1])], 1, 2); @@ -481,6 +523,10 @@ fn parses_a_stim_dem_string() { #[test] fn validates_probabilities_indices_order_and_pruning_configuration() { + assert_eq!( + FrontierConfig::default().score_alpha.to_bits(), + 0.8_f64.to_bits() + ); let probability_one_dem = sparse_dem(vec![(1.0, vec![], vec![])], 0, 0); assert!(FrontierDecoder::from_sparse_dem(&probability_one_dem, exact_config()).is_ok()); @@ -544,6 +590,18 @@ fn validates_probabilities_indices_order_and_pruning_configuration() { ) .unwrap_err(); assert!(nan_delta_error.to_string().contains("delta")); + + for score_alpha in [-0.1, f64::NAN, f64::INFINITY] { + let alpha_error = FrontierDecoder::from_sparse_dem( + &zero_dem, + FrontierConfig { + score_alpha, + ..FrontierConfig::default() + }, + ) + .unwrap_err(); + assert!(alpha_error.to_string().contains("score_alpha")); + } } #[test] diff --git a/crates/pecos-frontier/tests/upstream_fixtures.rs b/crates/pecos-frontier/tests/upstream_fixtures.rs index c82289c9f..43ef7e580 100644 --- a/crates/pecos-frontier/tests/upstream_fixtures.rs +++ b/crates/pecos-frontier/tests/upstream_fixtures.rs @@ -83,142 +83,171 @@ fn actual_masses(result: &FrontierResult) -> BTreeMap { .collect() } -#[test] -fn unpruned_results_match_upstream_golden_fixtures() { - let fixture_file = parse_fixtures(); - assert_eq!(fixture_file.generator, "generate_upstream_fixtures.py"); +fn assert_expected_results( + fixture: &Fixture, + config: FrontierConfig, + expected_results: &[ExpectedResult], + regime: &str, +) { + let dem = SparseDem { + mechanisms: fixture.mechanisms.clone(), + detector_coords: BTreeMap::new(), + num_detectors: fixture.num_detectors, + num_observables: fixture.num_observables, + }; + let mut decoder = FrontierDecoder::from_sparse_dem(&dem, config).unwrap_or_else(|error| { + panic!( + "{} {regime}: decoder construction failed: {error}", + fixture.name + ) + }); - for fixture in fixture_file.fixtures { - assert!(fixture.pruned.k > 0, "{}: invalid pruned K", fixture.name); + let mut expected_by_syndrome = BTreeMap::new(); + for expected in expected_results { assert!( - fixture.pruned.delta >= 0.0, - "{}: invalid pruned Delta", - fixture.name - ); - assert_eq!( - fixture.expected_pruned.len(), - fixture.syndromes.len(), - "{}: pruned result count differs from syndrome count", + expected_by_syndrome + .insert(expected.syndrome, expected) + .is_none(), + "{} {regime}: duplicate expected syndrome", fixture.name ); - let dem = SparseDem { - mechanisms: fixture.mechanisms, - detector_coords: BTreeMap::new(), - num_detectors: fixture.num_detectors, - num_observables: fixture.num_observables, - }; - let config = FrontierConfig { - k: usize::MAX, - delta: f64::INFINITY, - column_order: None, - }; - let mut decoder = FrontierDecoder::from_sparse_dem(&dem, config).unwrap_or_else(|error| { - panic!("{}: decoder construction failed: {error}", fixture.name) - }); - - let mut expected_by_syndrome = BTreeMap::new(); - for expected in fixture.expected_unpruned { - assert!( - expected_by_syndrome - .insert(expected.syndrome, expected) - .is_none(), - "{}: duplicate expected syndrome", - fixture.name - ); - } + } + assert_eq!( + expected_by_syndrome.len(), + fixture.syndromes.len(), + "{} {regime}: expected result count differs from syndrome count", + fixture.name + ); + + for &syndrome_mask in &fixture.syndromes { + let expected = expected_by_syndrome + .remove(&syndrome_mask) + .unwrap_or_else(|| panic!("{} {regime}: missing expected result", fixture.name)); assert_eq!( - expected_by_syndrome.len(), - fixture.syndromes.len(), - "{}: expected result count differs from syndrome count", + expected.engine, "native_binary", + "{} {regime} syndrome {syndrome_mask}: unexpected engine", fixture.name ); - - for syndrome_mask in fixture.syndromes { - let expected = expected_by_syndrome - .remove(&syndrome_mask) - .unwrap_or_else(|| panic!("{}: missing expected result", fixture.name)); - assert_eq!( - expected.engine, "native_binary", - "{} syndrome {syndrome_mask}: unexpected engine", - fixture.name - ); - let syndrome = dense_syndrome(syndrome_mask, fixture.num_detectors); - let decoded = decoder.decode(&syndrome); - - match expected.status.as_str() { - "ok" => { - let result = decoded.unwrap_or_else(|error| { + let syndrome = dense_syndrome(syndrome_mask, fixture.num_detectors); + let decoded = decoder.decode(&syndrome); + + match expected.status.as_str() { + "ok" => { + let result = decoded.unwrap_or_else(|error| { + panic!( + "{} {regime} syndrome {syndrome_mask}: expected success, got {error}", + fixture.name + ) + }); + let logical_hat = expected.logical_hat.expect("ok result needs logical_hat"); + assert_eq!( + mask_as_u128(&result.predicted), + logical_hat, + "{} {regime} syndrome {syndrome_mask}: predicted label", + fixture.name + ); + + let expected_masses: BTreeMap = expected + .terminal_log_masses + .iter() + .map(|(label, &mass)| { + (label.parse().expect("logical label must fit in u128"), mass) + }) + .collect(); + let actual_masses = actual_masses(&result); + assert_eq!( + actual_masses.len(), + expected_masses.len(), + "{} {regime} syndrome {syndrome_mask}: terminal-label count", + fixture.name + ); + for (label, expected_mass) in expected_masses { + let actual_mass = actual_masses.get(&label).unwrap_or_else(|| { panic!( - "{} syndrome {syndrome_mask}: expected success, got {error}", + "{} {regime} syndrome {syndrome_mask}: missing label {label}", fixture.name ) }); - let logical_hat = expected.logical_hat.expect("ok result needs logical_hat"); - assert_eq!( - mask_as_u128(&result.predicted), - logical_hat, - "{} syndrome {syndrome_mask}: predicted label", - fixture.name - ); - - let expected_masses: BTreeMap = expected - .terminal_log_masses - .into_iter() - .map(|(label, mass)| { - (label.parse().expect("logical label must fit in u128"), mass) - }) - .collect(); - let actual_masses = actual_masses(&result); - assert_eq!( - actual_masses.len(), - expected_masses.len(), - "{} syndrome {syndrome_mask}: terminal-label count", - fixture.name - ); - for (label, expected_mass) in expected_masses { - let actual_mass = actual_masses.get(&label).unwrap_or_else(|| { - panic!( - "{} syndrome {syndrome_mask}: missing label {label}", - fixture.name - ) - }); - assert!( - (actual_mass - expected_mass).abs() <= 1e-9, - "{} syndrome {syndrome_mask}, label {label}: expected {expected_mass}, got {actual_mass}", - fixture.name - ); - } - - let expected_evidence = expected - .log_evidence - .expect("ok result needs finite log_evidence"); assert!( - (result.log_evidence - expected_evidence).abs() <= 1e-9, - "{} syndrome {syndrome_mask}: expected evidence {expected_evidence}, got {}", - fixture.name, - result.log_evidence + (actual_mass - expected_mass).abs() <= 1e-9, + "{} {regime} syndrome {syndrome_mask}, label {label}: expected {expected_mass}, got {actual_mass}", + fixture.name ); } - "no_path" => assert!( - decoded.is_err(), - "{} syndrome {syndrome_mask}: expected no path", + + let expected_evidence = expected + .log_evidence + .expect("ok result needs finite log_evidence"); + assert!( + (result.log_evidence - expected_evidence).abs() <= 1e-9, + "{} {regime} syndrome {syndrome_mask}: expected evidence {expected_evidence}, got {}", + fixture.name, + result.log_evidence + ); + } + "no_path" => { + assert!( + expected.logical_hat.is_none(), + "{} {regime} syndrome {syndrome_mask}: no-path logical_hat must be null", fixture.name - ), - status => panic!( - "{} syndrome {syndrome_mask}: unknown fixture status {status}", + ); + assert!( + expected.log_evidence.is_none(), + "{} {regime} syndrome {syndrome_mask}: no-path log_evidence must be null", fixture.name - ), + ); + assert!( + expected.terminal_log_masses.is_empty(), + "{} {regime} syndrome {syndrome_mask}: no-path masses must be empty", + fixture.name + ); + assert!( + decoded.is_err(), + "{} {regime} syndrome {syndrome_mask}: expected no path", + fixture.name + ); } + status => panic!( + "{} {regime} syndrome {syndrome_mask}: unknown fixture status {status}", + fixture.name + ), } + } - assert!( - expected_by_syndrome.is_empty(), - "{}: expected results contain extra syndromes", - fixture.name + assert!( + expected_by_syndrome.is_empty(), + "{} {regime}: expected results contain extra syndromes", + fixture.name + ); +} + +#[test] +fn unpruned_and_pruned_results_match_upstream_golden_fixtures() { + let fixture_file = parse_fixtures(); + assert_eq!(fixture_file.generator, "generate_upstream_fixtures.py"); + + for fixture in fixture_file.fixtures { + assert_expected_results( + &fixture, + FrontierConfig { + k: usize::MAX, + delta: f64::INFINITY, + score_alpha: 0.8, + column_order: None, + }, + &fixture.expected_unpruned, + "unpruned", + ); + assert_expected_results( + &fixture, + FrontierConfig { + k: fixture.pruned.k, + delta: fixture.pruned.delta, + score_alpha: 0.8, + column_order: None, + }, + &fixture.expected_pruned, + "pruned", ); } - - // `expected_pruned` is intentionally ignored: upstream pruning includes a - // score_alpha mixing parameter that v1 deliberately did not port, so - // pruned-path parity is out of scope. } From d0c2a8c08e29278dee995819bafa0b0c3eab3c72 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 16:46:09 -0600 Subject: [PATCH 05/10] Add bridge_ab example for cross-implementation A/B against upstream frontier --- crates/pecos-frontier/examples/bridge_ab.rs | 106 ++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 crates/pecos-frontier/examples/bridge_ab.rs diff --git a/crates/pecos-frontier/examples/bridge_ab.rs b/crates/pecos-frontier/examples/bridge_ab.rs new file mode 100644 index 000000000..73e7be2e3 --- /dev/null +++ b/crates/pecos-frontier/examples/bridge_ab.rs @@ -0,0 +1,106 @@ +// 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. + +//! Cross-implementation A/B harness: decode upstream-frontier sample shots +//! with `FrontierDecoder` on the identical model and column order. +//! +//! Input JSON (produced by an external extraction script from the upstream +//! `frontier` package): `{num_detectors, num_observables, mechanisms: +//! [[p, [detectors], [observables]], ...], shots: [{syndrome, truth_logical}]}` +//! where mechanism order IS the processing order and `syndrome` packs detector +//! `i` into bit `i`. +//! +//! Usage: `bridge_ab ` +//! Prints one `shot,predicted,truth,status` line per shot plus a summary line. + +use pecos_decoder_core::dem::SparseDem; +use pecos_frontier::{FrontierConfig, FrontierDecoder}; +use serde::Deserialize; +use std::collections::BTreeMap; + +#[derive(Deserialize)] +struct BridgeModel { + num_detectors: usize, + num_observables: usize, + mechanisms: Vec<(f64, Vec, Vec)>, + shots: Vec, +} + +#[derive(Deserialize)] +struct Shot { + syndrome: u128, + truth_logical: u128, +} + +fn main() { + let mut args = std::env::args().skip(1); + let path = args + .next() + .expect("usage: bridge_ab "); + let k: usize = args.next().expect("missing k").parse().expect("k"); + let delta: f64 = args.next().expect("missing delta").parse().expect("delta"); + let score_alpha: f64 = args + .next() + .expect("missing score_alpha") + .parse() + .expect("score_alpha"); + + let model: BridgeModel = + serde_json::from_str(&std::fs::read_to_string(&path).expect("read model json")) + .expect("parse model json"); + let dem = SparseDem { + mechanisms: model.mechanisms, + detector_coords: BTreeMap::new(), + num_detectors: model.num_detectors, + num_observables: model.num_observables, + }; + let config = FrontierConfig { + k, + delta, + score_alpha, + column_order: None, + }; + let mut decoder = FrontierDecoder::from_sparse_dem(&dem, config).expect("build decoder"); + + let mut failures = 0_u32; + let mut no_path = 0_u32; + let started = std::time::Instant::now(); + for (shot, entry) in model.shots.iter().enumerate() { + let syndrome: Vec = (0..model.num_detectors) + .map(|bit| u8::from(entry.syndrome & (1_u128 << bit) != 0)) + .collect(); + if let Ok(result) = decoder.decode(&syndrome) { + let words = result.predicted.words(); + assert!(words.iter().skip(2).all(|&w| w == 0), "label fits u128"); + let predicted = u128::from(words.first().copied().unwrap_or(0)) + | (u128::from(words.get(1).copied().unwrap_or(0)) << 64); + let status = if predicted == entry.truth_logical { + "ok" + } else { + failures += 1; + "logical_fail" + }; + println!("{shot},{predicted},{},{status}", entry.truth_logical); + } else { + failures += 1; + no_path += 1; + println!("{shot},,{},no_path", entry.truth_logical); + } + } + let elapsed = started.elapsed().as_secs_f64(); + let trials = u32::try_from(model.shots.len()).expect("shot count fits u32"); + println!( + "SUMMARY trials={trials} fail={failures} no_path={no_path} fer={} k={k} delta={delta} alpha={score_alpha} decode_s_mean={}", + f64::from(failures) / f64::from(trials), + elapsed / f64::from(trials), + ); +} From 9655dff3a271eb7bc9a5adfb403f8d229ce3fd9e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 16:49:15 -0600 Subject: [PATCH 06/10] Clarify frontier feature is a native implementation, not an upstream wrap --- crates/pecos-decoders/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/pecos-decoders/src/lib.rs b/crates/pecos-decoders/src/lib.rs index 68d809ad7..4f6ac3c78 100644 --- a/crates/pecos-decoders/src/lib.rs +++ b/crates/pecos-decoders/src/lib.rs @@ -5,7 +5,8 @@ //! //! ## Features //! -//! - `frontier` - Frontier approximate logical maximum-likelihood decoder (pure Rust) +//! - `frontier` - Frontier approximate logical maximum-likelihood decoder (pure Rust; +//! native implementation of arXiv:2606.20513, not a wrap of the upstream package) //! - `ldpc` - LDPC decoders (BP-OSD, BP-LSD, Union-Find, etc.) //! - `fusion-blossom` - Fusion Blossom MWPM decoder (pure Rust) //! - `pymatching` - `PyMatching` MWPM decoder (C++ FFI) From 7b6cc7dbf9731d033258093df21e049ecc1f6711 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 16:54:20 -0600 Subject: [PATCH 07/10] Move pecos-frontier to exp/ pending broader validation; drop meta-crate registration for now --- Cargo.lock | 1 - Cargo.toml | 2 +- crates/pecos-decoders/Cargo.toml | 4 +--- crates/pecos-decoders/src/lib.rs | 6 ------ crates/pecos-frontier/README.md | 10 ---------- {crates => exp}/pecos-frontier/Cargo.toml | 1 + exp/pecos-frontier/README.md | 18 ++++++++++++++++++ .../pecos-frontier/examples/bridge_ab.rs | 0 {crates => exp}/pecos-frontier/src/lib.rs | 0 .../fixtures/generate_upstream_fixtures.py | 0 .../tests/fixtures/upstream_fixtures.json | 0 .../pecos-frontier/tests/frontier.rs | 0 .../pecos-frontier/tests/upstream_fixtures.rs | 0 13 files changed, 21 insertions(+), 21 deletions(-) delete mode 100644 crates/pecos-frontier/README.md rename {crates => exp}/pecos-frontier/Cargo.toml (97%) create mode 100644 exp/pecos-frontier/README.md rename {crates => exp}/pecos-frontier/examples/bridge_ab.rs (100%) rename {crates => exp}/pecos-frontier/src/lib.rs (100%) rename {crates => exp}/pecos-frontier/tests/fixtures/generate_upstream_fixtures.py (100%) rename {crates => exp}/pecos-frontier/tests/fixtures/upstream_fixtures.json (100%) rename {crates => exp}/pecos-frontier/tests/frontier.rs (100%) rename {crates => exp}/pecos-frontier/tests/upstream_fixtures.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 404e4c12d..1f99f7f9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4284,7 +4284,6 @@ version = "0.2.0-dev.0" dependencies = [ "pecos-chromobius", "pecos-decoder-core", - "pecos-frontier", "pecos-fusion-blossom", "pecos-ldpc-decoders", "pecos-mwpf", diff --git a/Cargo.toml b/Cargo.toml index aa1c3dff8..3642aae22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -202,7 +202,7 @@ pecos-eeg = { version = "0.2.0-dev.0", path = "exp/pecos-eeg" } pecos-stab-tn = { version = "0.2.0-dev.0", path = "exp/pecos-stab-tn" } pecos-experimental = { version = "0.2.0-dev.0", path = "exp/pecos-experimental" } pecos-foreign = { version = "0.2.0-dev.0", path = "crates/pecos-foreign" } -pecos-frontier = { version = "0.2.0-dev.0", path = "crates/pecos-frontier" } +pecos-frontier = { version = "0.2.0-dev.0", path = "exp/pecos-frontier" } pecos-fusion-blossom = { version = "0.2.0-dev.0", path = "crates/pecos-fusion-blossom" } pecos-gpu-sims = { version = "0.2.0-dev.0", path = "crates/pecos-gpu-sims" } pecos-hugr = { version = "0.2.0-dev.0", path = "crates/pecos-hugr" } diff --git a/crates/pecos-decoders/Cargo.toml b/crates/pecos-decoders/Cargo.toml index 2b21e272a..4e219d9dd 100644 --- a/crates/pecos-decoders/Cargo.toml +++ b/crates/pecos-decoders/Cargo.toml @@ -13,7 +13,6 @@ description = "Unified decoder meta-crate for PECOS" [dependencies] pecos-decoder-core.workspace = true -pecos-frontier = { workspace = true, optional = true } pecos-ldpc-decoders = { workspace = true, optional = true } pecos-fusion-blossom = { workspace = true, optional = true } pecos-mwpf = { workspace = true, optional = true } @@ -25,7 +24,6 @@ pecos-uf-decoder = { workspace = true, optional = true } [features] default = [] -frontier = ["dep:pecos-frontier"] ldpc = ["dep:pecos-ldpc-decoders"] fusion-blossom = ["dep:pecos-fusion-blossom"] mwpf = ["dep:pecos-mwpf"] @@ -34,7 +32,7 @@ tesseract = ["dep:pecos-tesseract"] chromobius = ["dep:pecos-chromobius"] relay-bp = ["dep:pecos-relay-bp"] uf = ["dep:pecos-uf-decoder"] -all = ["frontier", "ldpc", "fusion-blossom", "mwpf", "pymatching", "tesseract", "chromobius", "relay-bp", "uf"] +all = ["ldpc", "fusion-blossom", "mwpf", "pymatching", "tesseract", "chromobius", "relay-bp", "uf"] [lints] workspace = true diff --git a/crates/pecos-decoders/src/lib.rs b/crates/pecos-decoders/src/lib.rs index 4f6ac3c78..31b60cf46 100644 --- a/crates/pecos-decoders/src/lib.rs +++ b/crates/pecos-decoders/src/lib.rs @@ -5,8 +5,6 @@ //! //! ## Features //! -//! - `frontier` - Frontier approximate logical maximum-likelihood decoder (pure Rust; -//! native implementation of arXiv:2606.20513, not a wrap of the upstream package) //! - `ldpc` - LDPC decoders (BP-OSD, BP-LSD, Union-Find, etc.) //! - `fusion-blossom` - Fusion Blossom MWPM decoder (pure Rust) //! - `pymatching` - `PyMatching` MWPM decoder (C++ FFI) @@ -28,10 +26,6 @@ pub use pecos_decoder_core::logical_subgraph::{ QubitStabCoords, StabCoords, StabType, partition_dem_by_logical, }; -// Re-export Frontier decoder when feature is enabled -#[cfg(feature = "frontier")] -pub use pecos_frontier::{FrontierConfig, FrontierDecoder, FrontierLogicalMass, FrontierResult}; - // Re-export LDPC decoders when feature is enabled #[cfg(feature = "ldpc")] pub use pecos_ldpc_decoders::{ diff --git a/crates/pecos-frontier/README.md b/crates/pecos-frontier/README.md deleted file mode 100644 index 01f9c145b..000000000 --- a/crates/pecos-frontier/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# PECOS Frontier Decoder - -Native Rust implementation of the Frontier approximate logical maximum-likelihood decoder. - -Pruning ranks accumulated prefix log mass plus a `score_alpha`-weighted -suffix-compatibility estimate. Unpruned results are exact and upstream-verified. - -Deterministic ordering and tie-breaking are bitwise reproducible for a fixed -build and platform. The platform's `ln` and `exp` implementations may differ -across platforms. diff --git a/crates/pecos-frontier/Cargo.toml b/exp/pecos-frontier/Cargo.toml similarity index 97% rename from crates/pecos-frontier/Cargo.toml rename to exp/pecos-frontier/Cargo.toml index 2848ffbb0..0953489f7 100644 --- a/crates/pecos-frontier/Cargo.toml +++ b/exp/pecos-frontier/Cargo.toml @@ -10,6 +10,7 @@ license.workspace = true keywords.workspace = true categories.workspace = true description = "Frontier approximate logical maximum-likelihood decoder for PECOS" +publish = false [dependencies] pecos-decoder-core.workspace = true diff --git a/exp/pecos-frontier/README.md b/exp/pecos-frontier/README.md new file mode 100644 index 000000000..398bf41fc --- /dev/null +++ b/exp/pecos-frontier/README.md @@ -0,0 +1,18 @@ +# PECOS Frontier Decoder + +Native Rust implementation of the Frontier approximate logical maximum-likelihood +decoder (Leverrier & Urbanke, arXiv:2606.20513). Not a wrap of the upstream +`frontier` package; the upstream implementation is used as a verification oracle. + +**Experimental** (`exp/`): the algorithm core is enumeration- and upstream-verified +(per-shot parity on matched models), but the crate has not yet accumulated real-user +mileage. Graduation to `crates/` and registration in the `pecos-decoders` meta-crate +are planned once it has been exercised more broadly (larger code families, Python +bindings, human users). + +Pruning ranks accumulated prefix log mass plus a `score_alpha`-weighted +suffix-compatibility estimate. Unpruned results are exact and upstream-verified. + +Deterministic ordering and tie-breaking are bitwise reproducible for a fixed +build and platform. The platform's `ln` and `exp` implementations may differ +across platforms. diff --git a/crates/pecos-frontier/examples/bridge_ab.rs b/exp/pecos-frontier/examples/bridge_ab.rs similarity index 100% rename from crates/pecos-frontier/examples/bridge_ab.rs rename to exp/pecos-frontier/examples/bridge_ab.rs diff --git a/crates/pecos-frontier/src/lib.rs b/exp/pecos-frontier/src/lib.rs similarity index 100% rename from crates/pecos-frontier/src/lib.rs rename to exp/pecos-frontier/src/lib.rs diff --git a/crates/pecos-frontier/tests/fixtures/generate_upstream_fixtures.py b/exp/pecos-frontier/tests/fixtures/generate_upstream_fixtures.py similarity index 100% rename from crates/pecos-frontier/tests/fixtures/generate_upstream_fixtures.py rename to exp/pecos-frontier/tests/fixtures/generate_upstream_fixtures.py diff --git a/crates/pecos-frontier/tests/fixtures/upstream_fixtures.json b/exp/pecos-frontier/tests/fixtures/upstream_fixtures.json similarity index 100% rename from crates/pecos-frontier/tests/fixtures/upstream_fixtures.json rename to exp/pecos-frontier/tests/fixtures/upstream_fixtures.json diff --git a/crates/pecos-frontier/tests/frontier.rs b/exp/pecos-frontier/tests/frontier.rs similarity index 100% rename from crates/pecos-frontier/tests/frontier.rs rename to exp/pecos-frontier/tests/frontier.rs diff --git a/crates/pecos-frontier/tests/upstream_fixtures.rs b/exp/pecos-frontier/tests/upstream_fixtures.rs similarity index 100% rename from crates/pecos-frontier/tests/upstream_fixtures.rs rename to exp/pecos-frontier/tests/upstream_fixtures.rs From 919530a088d849988cea0febc1319cd419768359 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 17:27:11 -0600 Subject: [PATCH 08/10] Add orchestrator-owned ordering and committee golden fixtures from upstream --- .../generate_upstream_order_fixtures.py | 162 ++++ .../fixtures/upstream_order_fixtures.json | 721 ++++++++++++++++++ 2 files changed, 883 insertions(+) create mode 100644 exp/pecos-frontier/tests/fixtures/generate_upstream_order_fixtures.py create mode 100644 exp/pecos-frontier/tests/fixtures/upstream_order_fixtures.json diff --git a/exp/pecos-frontier/tests/fixtures/generate_upstream_order_fixtures.py b/exp/pecos-frontier/tests/fixtures/generate_upstream_order_fixtures.py new file mode 100644 index 000000000..c23d07600 --- /dev/null +++ b/exp/pecos-frontier/tests/fixtures/generate_upstream_order_fixtures.py @@ -0,0 +1,162 @@ +# Ordering + committee golden-fixture generator for pecos-frontier, run against +# the upstream frontier package (github.com/aleverrier/frontier, arXiv:2606.20513). +# +# Orchestrator-owned oracle: this script and its JSON output are authored and +# committed by the reviewer, not by the implementation. The implementation must +# never edit them. +# +# Usage (from a clone of the upstream repo with its venv built): +# .venv/bin/python generate_upstream_order_fixtures.py > upstream_order_fixtures.json +# +# Per fixture: mechanisms in TIME order. Expected values from upstream: +# - forward_ordering: optimize_column_order permutation over the time-ordered +# columns (deadline reorder). ordering[target] = source index. +# - backward_ordering: reverse the forward-ordered columns, then +# optimize_column_order again (build_backward_deadline_ordered_family +# semantics), composed back to original mechanism indices. +# - committee: decode_frontier_committee on the forward-ordered model per +# syndrome: status, logical_hat, direction, log_evidence. + +from __future__ import annotations + +import json +import math +import random +import sys + +from frontier import FrontierModel, decode_frontier_committee +from frontier.progressive import ( + FactorTransition, + OutcomeTransition, + build_frontier_layout, + columns_from_factor_transitions, + optimize_column_order, +) +from tools.frontier_progressive import _reverse_progressive_columns + + +def build_columns(mechanisms): + factors = [] + for idx, (p, dets, obs) in enumerate(mechanisms): + det_mask = 0 + for d in dets: + det_mask |= 1 << d + log_mask = 0 + for o in obs: + log_mask |= 1 << o + factors.append( + FactorTransition( + factor_id=idx, + outcomes=( + OutcomeTransition(probability=1.0 - p, detector_mask=0, logical_mask=0), + OutcomeTransition(probability=p, detector_mask=det_mask, logical_mask=log_mask), + ), + instruction_offset=idx, + label=f"f{idx}", + ) + ) + return list(columns_from_factor_transitions(tuple(factors))) + + +def random_mechanisms(rng, num_mechs, num_detectors, num_observables): + mechs = [] + for _ in range(num_mechs): + n_d = rng.choice([0, 1, 1, 2, 2, 3]) + dets = sorted(rng.sample(range(num_detectors), min(n_d, num_detectors))) + obs = sorted(rng.sample(range(num_observables), rng.choice([0, 0, 1, 1, 2]))) + p = rng.uniform(0.01, 0.4) + mechs.append((round(p, 6), dets, obs)) + return mechs + + +def main(): + fixtures = [] + + # F1: hand-built chain where time order != deadline order. + fixtures.append( + { + "name": "chain_reorder", + "mechanisms": [ + [0.05, [3], [0]], + [0.04, [0, 1], []], + [0.03, [0], [1]], + [0.06, [2, 3], []], + [0.02, [1, 2], [0, 1]], + [0.07, [], [0]], + [0.08, [3], []], + ], + "num_detectors": 4, + "num_observables": 2, + "syndromes": list(range(16)), + "pruned": {"k": 3, "delta": 25.0}, + } + ) + + # F2/F3: seeded random models (include detector-free mechanisms). + for seed, num_mechs, num_dets, num_obs in ((7, 10, 5, 2), (41, 14, 6, 3)): + rng = random.Random(seed) + fixtures.append( + { + "name": f"order_random_seed{seed}", + "mechanisms": random_mechanisms(rng, num_mechs, num_dets, num_obs), + "num_detectors": num_dets, + "num_observables": num_obs, + "syndromes": sorted(rng.sample(range(1 << num_dets), 10)), + "pruned": {"k": 3, "delta": 25.0}, + } + ) + + for fx in fixtures: + time_columns = build_columns(fx["mechanisms"]) + forward_columns, forward_ordering = optimize_column_order( + list(time_columns), num_detectors=fx["num_detectors"] + ) + fx["forward_ordering"] = [int(v) for v in forward_ordering] + + reversed_columns = _reverse_progressive_columns(forward_columns) + _backward_columns, backward_ordering_local = optimize_column_order( + list(reversed_columns), num_detectors=fx["num_detectors"] + ) + # Compose back to original mechanism indices: reversed[i] came from + # forward position len-1-i, which came from mechanism forward_ordering[...]. + n = len(forward_ordering) + backward_in_original = [ + int(forward_ordering[n - 1 - int(local)]) for local in backward_ordering_local + ] + fx["backward_ordering"] = backward_in_original + + model = FrontierModel( + columns=tuple(forward_columns), + layout=build_frontier_layout(list(forward_columns), num_detectors=fx["num_detectors"]), + num_detectors=fx["num_detectors"], + num_observables=fx["num_observables"], + ) + committee = [] + for syndrome in fx["syndromes"]: + r = decode_frontier_committee( + model, syndrome, K=fx["pruned"]["k"], Delta=fx["pruned"]["delta"] + ) + committee.append( + { + "syndrome": syndrome, + "status": r.status, + "logical_hat": r.logical_hat, + "direction": r.direction, + "log_evidence": r.log_evidence if math.isfinite(r.log_evidence) else None, + "engine": r.engine, + } + ) + fx["expected_committee"] = committee + + json.dump( + {"generator": "generate_upstream_order_fixtures.py", "fixtures": fixtures}, + sys.stdout, + indent=1, + allow_nan=False, + ) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/exp/pecos-frontier/tests/fixtures/upstream_order_fixtures.json b/exp/pecos-frontier/tests/fixtures/upstream_order_fixtures.json new file mode 100644 index 000000000..393f52993 --- /dev/null +++ b/exp/pecos-frontier/tests/fixtures/upstream_order_fixtures.json @@ -0,0 +1,721 @@ +{ + "generator": "generate_upstream_order_fixtures.py", + "fixtures": [ + { + "name": "chain_reorder", + "mechanisms": [ + [ + 0.05, + [ + 3 + ], + [ + 0 + ] + ], + [ + 0.04, + [ + 0, + 1 + ], + [] + ], + [ + 0.03, + [ + 0 + ], + [ + 1 + ] + ], + [ + 0.06, + [ + 2, + 3 + ], + [] + ], + [ + 0.02, + [ + 1, + 2 + ], + [ + 0, + 1 + ] + ], + [ + 0.07, + [], + [ + 0 + ] + ], + [ + 0.08, + [ + 3 + ], + [] + ] + ], + "num_detectors": 4, + "num_observables": 2, + "syndromes": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15 + ], + "pruned": { + "k": 3, + "delta": 25.0 + }, + "forward_ordering": [ + 2, + 1, + 4, + 3, + 0, + 6, + 5 + ], + "backward_ordering": [ + 0, + 6, + 3, + 4, + 1, + 2, + 5 + ], + "expected_committee": [ + { + "syndrome": 0, + "status": "ok", + "logical_hat": 0, + "direction": "forward", + "log_evidence": -0.28346776513264615, + "engine": "native_binary" + }, + { + "syndrome": 1, + "status": "ok", + "logical_hat": 2, + "direction": "forward", + "log_evidence": -3.759322862835824, + "engine": "native_binary" + }, + { + "syndrome": 2, + "status": "ok", + "logical_hat": 2, + "direction": "forward", + "log_evidence": -6.806188349883318, + "engine": "native_binary" + }, + { + "syndrome": 3, + "status": "ok", + "logical_hat": 0, + "direction": "forward", + "log_evidence": -3.4613874828963334, + "engine": "native_binary" + }, + { + "syndrome": 4, + "status": "ok", + "logical_hat": 0, + "direction": "forward", + "log_evidence": -5.005668051298697, + "engine": "native_binary" + }, + { + "syndrome": 5, + "status": "ok", + "logical_hat": 3, + "direction": "forward", + "log_evidence": -7.073753194315811, + "engine": "native_binary" + }, + { + "syndrome": 6, + "status": "ok", + "logical_hat": 3, + "direction": "forward", + "log_evidence": -4.174728408076651, + "engine": "native_binary" + }, + { + "syndrome": 7, + "status": "ok", + "logical_hat": 1, + "direction": "forward", + "log_evidence": -7.1904896117099835, + "engine": "native_binary" + }, + { + "syndrome": 8, + "status": "ok", + "logical_hat": 0, + "direction": "forward", + "log_evidence": -2.2570814664348506, + "engine": "native_binary" + }, + { + "syndrome": 9, + "status": "ok", + "logical_hat": 2, + "direction": "forward", + "log_evidence": -5.720641390924243, + "engine": "native_binary" + }, + { + "syndrome": 10, + "status": "ok", + "logical_hat": 3, + "direction": "forward", + "log_evidence": -6.7980259206306055, + "engine": "native_binary" + }, + { + "syndrome": 11, + "status": "ok", + "logical_hat": 0, + "direction": "forward", + "log_evidence": -5.42821286277637, + "engine": "native_binary" + }, + { + "syndrome": 12, + "status": "ok", + "logical_hat": 0, + "direction": "forward", + "log_evidence": -3.0349460619312487, + "engine": "native_binary" + }, + { + "syndrome": 13, + "status": "ok", + "logical_hat": 2, + "direction": "forward", + "log_evidence": -6.452971892213298, + "engine": "native_binary" + }, + { + "syndrome": 14, + "status": "ok", + "logical_hat": 3, + "direction": "forward", + "log_evidence": -6.120320259211788, + "engine": "native_binary" + }, + { + "syndrome": 15, + "status": "ok", + "logical_hat": 0, + "direction": "forward", + "log_evidence": -6.180612551164233, + "engine": "native_binary" + } + ] + }, + { + "name": "order_random_seed7", + "mechanisms": [ + [ + 0.03825, + [ + 1 + ], + [ + 0 + ] + ], + [ + 0.024623, + [ + 0, + 2 + ], + [ + 0, + 1 + ] + ], + [ + 0.045378, + [ + 0, + 3 + ], + [] + ], + [ + 0.255944, + [ + 0, + 4 + ], + [] + ], + [ + 0.390739, + [ + 0, + 3 + ], + [] + ], + [ + 0.173464, + [], + [ + 0, + 1 + ] + ], + [ + 0.236824, + [ + 0, + 2 + ], + [ + 0, + 1 + ] + ], + [ + 0.251414, + [ + 0, + 1, + 2 + ], + [ + 0, + 1 + ] + ], + [ + 0.238369, + [ + 3, + 4 + ], + [ + 1 + ] + ], + [ + 0.319808, + [ + 2, + 4 + ], + [] + ] + ], + "num_detectors": 5, + "num_observables": 2, + "syndromes": [ + 2, + 9, + 10, + 14, + 15, + 16, + 18, + 23, + 28, + 31 + ], + "pruned": { + "k": 3, + "delta": 25.0 + }, + "forward_ordering": [ + 0, + 2, + 4, + 7, + 1, + 3, + 6, + 8, + 9, + 5 + ], + "backward_ordering": [ + 9, + 3, + 8, + 1, + 6, + 7, + 2, + 4, + 0, + 5 + ], + "expected_committee": [ + { + "syndrome": 2, + "status": "ok", + "logical_hat": 0, + "direction": "forward", + "log_evidence": -3.7718451171394514, + "engine": "native_binary" + }, + { + "syndrome": 9, + "status": "ok", + "logical_hat": 0, + "direction": "forward", + "log_evidence": -2.356365085425516, + "engine": "native_binary" + }, + { + "syndrome": 10, + "status": "no_path", + "logical_hat": null, + "direction": "forward", + "log_evidence": null, + "engine": "native_binary" + }, + { + "syndrome": 14, + "status": "ok", + "logical_hat": 3, + "direction": "forward", + "log_evidence": -3.447450196392692, + "engine": "native_binary" + }, + { + "syndrome": 15, + "status": "no_path", + "logical_hat": null, + "direction": "forward", + "log_evidence": null, + "engine": "native_binary" + }, + { + "syndrome": 16, + "status": "no_path", + "logical_hat": null, + "direction": "forward", + "log_evidence": null, + "engine": "native_binary" + }, + { + "syndrome": 18, + "status": "no_path", + "logical_hat": null, + "direction": "forward", + "log_evidence": null, + "engine": "native_binary" + }, + { + "syndrome": 23, + "status": "no_path", + "logical_hat": null, + "direction": "forward", + "log_evidence": null, + "engine": "native_binary" + }, + { + "syndrome": 28, + "status": "no_path", + "logical_hat": null, + "direction": "forward", + "log_evidence": null, + "engine": "native_binary" + }, + { + "syndrome": 31, + "status": "ok", + "logical_hat": 1, + "direction": "forward", + "log_evidence": -3.7142361581954058, + "engine": "native_binary" + } + ] + }, + { + "name": "order_random_seed41", + "mechanisms": [ + [ + 0.361782, + [ + 1, + 2 + ], + [] + ], + [ + 0.342266, + [ + 2, + 4 + ], + [ + 1, + 2 + ] + ], + [ + 0.181364, + [ + 0, + 1 + ], + [] + ], + [ + 0.11043, + [ + 1 + ], + [ + 0 + ] + ], + [ + 0.315883, + [ + 0, + 4, + 5 + ], + [] + ], + [ + 0.338008, + [], + [ + 1 + ] + ], + [ + 0.195322, + [ + 0 + ], + [ + 2 + ] + ], + [ + 0.048489, + [ + 0, + 5 + ], + [] + ], + [ + 0.030877, + [ + 5 + ], + [] + ], + [ + 0.17961, + [ + 1 + ], + [ + 0, + 2 + ] + ], + [ + 0.066904, + [], + [ + 1 + ] + ], + [ + 0.099857, + [ + 0, + 3, + 5 + ], + [ + 1 + ] + ], + [ + 0.040368, + [], + [] + ], + [ + 0.265496, + [ + 2, + 3, + 5 + ], + [ + 0, + 2 + ] + ] + ], + "num_detectors": 6, + "num_observables": 3, + "syndromes": [ + 2, + 4, + 8, + 11, + 41, + 43, + 45, + 46, + 51, + 55 + ], + "pruned": { + "k": 3, + "delta": 25.0 + }, + "forward_ordering": [ + 1, + 4, + 3, + 9, + 2, + 0, + 6, + 7, + 11, + 13, + 8, + 5, + 10, + 12 + ], + "backward_ordering": [ + 11, + 13, + 3, + 9, + 2, + 0, + 7, + 8, + 6, + 4, + 1, + 5, + 10, + 12 + ], + "expected_committee": [ + { + "syndrome": 2, + "status": "ok", + "logical_hat": 5, + "direction": "forward", + "log_evidence": -3.6936105673212936, + "engine": "native_binary" + }, + { + "syndrome": 4, + "status": "ok", + "logical_hat": 5, + "direction": "forward", + "log_evidence": -4.552330763901034, + "engine": "native_binary" + }, + { + "syndrome": 8, + "status": "ok", + "logical_hat": 0, + "direction": "forward", + "log_evidence": -8.710128457552232, + "engine": "native_binary" + }, + { + "syndrome": 11, + "status": "ok", + "logical_hat": 6, + "direction": "backward", + "log_evidence": -6.883257276707938, + "engine": "native_binary" + }, + { + "syndrome": 41, + "status": "ok", + "logical_hat": 2, + "direction": "forward", + "log_evidence": -4.658431103478807, + "engine": "native_binary" + }, + { + "syndrome": 43, + "status": "ok", + "logical_hat": 1, + "direction": "forward", + "log_evidence": -5.475933441779121, + "engine": "native_binary" + }, + { + "syndrome": 45, + "status": "ok", + "logical_hat": 1, + "direction": "forward", + "log_evidence": -4.908295344487828, + "engine": "native_binary" + }, + { + "syndrome": 46, + "status": "ok", + "logical_hat": 0, + "direction": "forward", + "log_evidence": -4.898928838303473, + "engine": "native_binary" + }, + { + "syndrome": 51, + "status": "ok", + "logical_hat": 5, + "direction": "forward", + "log_evidence": -4.7528127098070145, + "engine": "native_binary" + }, + { + "syndrome": 55, + "status": "ok", + "logical_hat": 0, + "direction": "backward", + "log_evidence": -3.815302120068774, + "engine": "native_binary" + } + ] + } + ] +} From 78958a915cefc7793d62c73ba100b5a704bda55e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 17:34:42 -0600 Subject: [PATCH 09/10] Add deadline ordering generation and forward/backward committee decoding with upstream parity fixtures --- exp/pecos-frontier/src/lib.rs | 328 +++++++++++++++++++- exp/pecos-frontier/tests/order_committee.rs | 291 +++++++++++++++++ 2 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 exp/pecos-frontier/tests/order_committee.rs diff --git a/exp/pecos-frontier/src/lib.rs b/exp/pecos-frontier/src/lib.rs index 103451941..5f472b25f 100644 --- a/exp/pecos-frontier/src/lib.rs +++ b/exp/pecos-frontier/src/lib.rs @@ -63,6 +63,38 @@ impl Default for FrontierConfig { } } +/// Generate the deadline-optimized processing order for a sparse DEM. +/// +/// The input mechanism order is treated as time order. Mechanisms that can +/// close detectors earlier are placed first; detector-free mechanisms sort +/// last. The returned permutation maps target positions to source mechanism +/// indices and can be assigned directly to [`FrontierConfig::column_order`]. +/// +/// # Errors +/// +/// Returns [`DecoderError::InvalidConfiguration`] if a mechanism contains an +/// out-of-range or duplicate detector index. +pub fn deadline_column_order(dem: &SparseDem) -> Result, DecoderError> { + let time_order: Vec = (0..dem.mechanisms.len()).collect(); + deadline_order_for_sequence(dem, &time_order) +} + +/// Generate the backward deadline-optimized processing order for a sparse DEM. +/// +/// This first computes the forward deadline order, reverses that ordered +/// sequence, reruns deadline optimization in the reversed time coordinates, +/// and composes the result back to original mechanism indices. +/// +/// # Errors +/// +/// Returns [`DecoderError::InvalidConfiguration`] if a mechanism contains an +/// out-of-range or duplicate detector index. +pub fn backward_deadline_column_order(dem: &SparseDem) -> Result, DecoderError> { + let mut reversed_forward = deadline_column_order(dem)?; + reversed_forward.reverse(); + deadline_order_for_sequence(dem, &reversed_forward) +} + /// Retained unnormalized joint log mass for one logical label. #[derive(Clone, Debug, PartialEq)] pub struct FrontierLogicalMass { @@ -99,6 +131,46 @@ pub struct FrontierResult { pub logical_masses: Vec, } +/// Direction selected by [`FrontierCommittee`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CommitteeDirection { + /// The configured processing order. + Forward, + /// The plain reverse of the configured processing order. + Backward, +} + +/// Decode status for one committee leg. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CommitteeStatus { + /// The leg retained at least one terminal state. + Ok, + /// The leg found no retained path for the syndrome. + NoPath, +} + +/// Summary of one committee leg. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CommitteeMember { + /// Decode status for this leg. + pub status: CommitteeStatus, + /// Total retained log evidence, or negative infinity for no path. + pub log_evidence: f64, +} + +/// Result selected from the forward/backward committee. +#[derive(Clone, Debug, PartialEq)] +pub struct FrontierCommitteeResult { + /// Full result from the selected leg. + pub selected: FrontierResult, + /// Direction of the selected leg. + pub direction: CommitteeDirection, + /// Forward-leg status and evidence. + pub forward: CommitteeMember, + /// Backward-leg status and evidence. + pub backward: CommitteeMember, +} + #[derive(Clone, Debug)] struct Column { detector_toggle: Vec, @@ -162,6 +234,13 @@ pub struct FrontierDecoder { forced_logical: Vec, } +/// Two-leg Frontier decoder using a processing order and its plain reverse. +#[derive(Clone, Debug)] +pub struct FrontierCommittee { + forward: FrontierDecoder, + backward: FrontierDecoder, +} + impl FrontierDecoder { /// Construct a decoder from a sparse detector error model. /// @@ -394,6 +473,89 @@ impl FrontierDecoder { } } +impl FrontierCommittee { + /// Construct a forward/backward committee from a sparse DEM. + /// + /// The forward leg uses `config.column_order` (or DEM order when absent). + /// The backward leg uses the plain reverse of that same sequence. + /// + /// # Errors + /// + /// Returns [`DecoderError::InvalidConfiguration`] when the configuration or + /// DEM is invalid. + pub fn from_sparse_dem(dem: &SparseDem, config: FrontierConfig) -> Result { + let FrontierConfig { + k, + delta, + score_alpha, + column_order, + } = config; + let mut forward_order = column_order.unwrap_or_else(|| (0..dem.mechanisms.len()).collect()); + let forward = FrontierDecoder::from_sparse_dem( + dem, + FrontierConfig { + k, + delta, + score_alpha, + column_order: Some(forward_order.clone()), + }, + )?; + forward_order.reverse(); + let backward_config = FrontierConfig { + k, + delta, + score_alpha, + column_order: Some(forward_order), + }; + let backward = FrontierDecoder::from_sparse_dem(dem, backward_config)?; + Ok(Self { forward, backward }) + } + + /// Parse a Stim-format detector error model and construct a committee. + /// + /// # Errors + /// + /// Returns [`DecoderError`] if parsing or committee construction fails. + pub fn from_dem_str(dem_str: &str, config: FrontierConfig) -> Result { + let dem = SparseDem::from_dem_str(dem_str)?; + Self::from_sparse_dem(&dem, config) + } + + /// Decode with both processing directions and select the stronger result. + /// + /// # Errors + /// + /// Returns the standard unexplainable-syndrome error if both legs find no + /// retained path. + pub fn decode(&mut self, syndrome: &[u8]) -> Result { + let forward_result = self.forward.decode(syndrome); + let backward_result = self.backward.decode(syndrome); + let forward = committee_member(&forward_result); + let backward = committee_member(&backward_result); + let (selected, direction) = match (forward_result, backward_result) { + (Err(_), Err(_)) => return Err(unexplainable_error()), + (Ok(selected), Err(_)) => (selected, CommitteeDirection::Forward), + (Err(_), Ok(selected)) => (selected, CommitteeDirection::Backward), + (Ok(forward_result), Ok(backward_result)) => { + if compare_committee_legs(Some(&forward_result), Some(&backward_result)) + == Ordering::Less + { + (backward_result, CommitteeDirection::Backward) + } else { + (forward_result, CommitteeDirection::Forward) + } + } + }; + + Ok(FrontierCommitteeResult { + selected, + direction, + forward, + backward, + }) + } +} + impl ObservableDecoder for FrontierDecoder { fn decode_obs(&mut self, syndrome: &[u8]) -> Result { Ok(self.decode(syndrome)?.predicted) @@ -410,6 +572,134 @@ impl ObservableDecoder for FrontierDecoder { } } +impl ObservableDecoder for FrontierCommittee { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + Ok(self.decode(syndrome)?.selected.predicted) + } +} + +type DeadlineKey = (usize, usize, usize, usize, usize); + +fn deadline_order_for_sequence( + dem: &SparseDem, + sequence: &[usize], +) -> Result, DecoderError> { + let sentinel = dem.mechanisms.len() + 1; + let mut first_touch = vec![sentinel; dem.num_detectors]; + let mut last_touch = vec![sentinel; dem.num_detectors]; + + for (position, &mechanism_index) in sequence.iter().enumerate() { + let detectors = &dem.mechanisms[mechanism_index].1; + validate_indices(detectors, dem.num_detectors, "detector", mechanism_index)?; + for &detector in detectors { + let detector = detector as usize; + first_touch[detector] = first_touch[detector].min(position); + last_touch[detector] = position; + } + } + + let mut positions: Vec = (0..sequence.len()).collect(); + positions.sort_by_key(|&position| -> DeadlineKey { + let mechanism_index = sequence[position]; + let detectors = &dem.mechanisms[mechanism_index].1; + if detectors.is_empty() { + return (sentinel, sentinel, sentinel, mechanism_index, position); + } + + let (earliest_last, latest_last, earliest_first) = detectors.iter().fold( + (sentinel, 0, sentinel), + |(min_last, max_last, min_first), &detector| { + let detector = detector as usize; + ( + min_last.min(last_touch[detector]), + max_last.max(last_touch[detector]), + min_first.min(first_touch[detector]), + ) + }, + ); + ( + earliest_last, + latest_last, + earliest_first, + mechanism_index, + position, + ) + }); + Ok(positions + .into_iter() + .map(|position| sequence[position]) + .collect()) +} + +fn committee_member(result: &Result) -> CommitteeMember { + match result { + Ok(decoded) => CommitteeMember { + status: CommitteeStatus::Ok, + log_evidence: decoded.log_evidence, + }, + Err(_) => CommitteeMember { + status: CommitteeStatus::NoPath, + log_evidence: f64::NEG_INFINITY, + }, + } +} + +fn compare_committee_legs( + forward: Option<&FrontierResult>, + backward: Option<&FrontierResult>, +) -> Ordering { + let forward_rank = committee_rank(forward, true); + let backward_rank = committee_rank(backward, false); + forward_rank + .iter() + .zip(backward_rank) + .find_map(|(forward_component, backward_component)| { + let ordering = forward_component.total_cmp(&backward_component); + (ordering != Ordering::Equal).then_some(ordering) + }) + .unwrap_or(Ordering::Equal) +} + +fn committee_rank(result: Option<&FrontierResult>, is_forward: bool) -> [f64; 6] { + let forward_bonus = if is_forward { 1.0 } else { 0.0 }; + let Some(result) = result else { + return [ + 1.0, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + 0.0, + forward_bonus, + ]; + }; + + let terminal_gap = result.runner_up_gap.unwrap_or(f64::INFINITY); + let terminal_gap = if terminal_gap.is_nan() { + f64::NEG_INFINITY + } else { + terminal_gap + }; + let top_one_posterior = result + .logical_masses + .first() + .map_or(f64::NEG_INFINITY, |winner| { + winner.log_mass - result.log_evidence + }); + let top_one_posterior = if top_one_posterior.is_finite() { + top_one_posterior + } else { + f64::NEG_INFINITY + }; + [ + 2.0, + result.log_evidence, + terminal_gap, + top_one_posterior, + 0.0, + forward_bonus, + ] +} + fn validate_config(config: &FrontierConfig, mechanism_count: usize) -> Result<(), DecoderError> { if config.k == 0 { return Err(DecoderError::InvalidConfiguration( @@ -657,7 +947,8 @@ fn and_not_assign(left: &mut [u64], right: &[u64]) { #[cfg(test)] mod tests { - use super::logaddexp; + use super::{FrontierLogicalMass, FrontierResult, committee_rank, logaddexp}; + use pecos_decoder_core::obs_mask::ObsMask; #[test] fn logaddexp_handles_negative_infinity_on_either_side() { @@ -670,4 +961,39 @@ mod tests { (-2.5_f64).to_bits() ); } + + #[test] + fn committee_rank_maps_special_terminal_statistics() { + let no_runner_up = FrontierResult { + predicted: ObsMask::new(), + log_evidence: -1.0, + runner_up_gap: None, + peak_retained_states: 1, + processed_columns: 0, + logical_masses: vec![FrontierLogicalMass { + logical: ObsMask::new(), + log_mass: f64::NAN, + }], + }; + let rank = committee_rank(Some(&no_runner_up), true); + assert_eq!(rank[2].to_bits(), f64::INFINITY.to_bits()); + assert_eq!(rank[3].to_bits(), f64::NEG_INFINITY.to_bits()); + assert_eq!(rank[5].to_bits(), 1.0_f64.to_bits()); + + let nan_gap = FrontierResult { + runner_up_gap: Some(f64::NAN), + logical_masses: vec![FrontierLogicalMass { + logical: ObsMask::new(), + log_mass: -1.5, + }], + ..no_runner_up + }; + let rank = committee_rank(Some(&nan_gap), false); + assert_eq!(rank[2].to_bits(), f64::NEG_INFINITY.to_bits()); + assert_eq!(rank[3].to_bits(), (-0.5_f64).to_bits()); + + let no_path_rank = committee_rank(None, false); + assert_eq!(no_path_rank[0].to_bits(), 1.0_f64.to_bits()); + assert_eq!(no_path_rank[1].to_bits(), f64::NEG_INFINITY.to_bits()); + } } diff --git a/exp/pecos-frontier/tests/order_committee.rs b/exp/pecos-frontier/tests/order_committee.rs new file mode 100644 index 000000000..78dfe2777 --- /dev/null +++ b/exp/pecos-frontier/tests/order_committee.rs @@ -0,0 +1,291 @@ +// 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. + +use pecos_decoder_core::ObservableDecoder; +use pecos_decoder_core::dem::SparseDem; +use pecos_decoder_core::obs_mask::ObsMask; +use pecos_frontier::{ + CommitteeDirection, CommitteeStatus, FrontierCommittee, FrontierConfig, + backward_deadline_column_order, deadline_column_order, +}; +use serde::Deserialize; +use std::collections::BTreeMap; + +const FIXTURES_JSON: &str = include_str!("fixtures/upstream_order_fixtures.json"); + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FixtureFile { + generator: String, + fixtures: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Fixture { + name: String, + mechanisms: Vec<(f64, Vec, Vec)>, + num_detectors: usize, + num_observables: usize, + syndromes: Vec, + pruned: PruningConfig, + forward_ordering: Vec, + backward_ordering: Vec, + expected_committee: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PruningConfig { + k: usize, + delta: f64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedCommitteeResult { + syndrome: u128, + status: String, + logical_hat: Option, + direction: String, + log_evidence: Option, + engine: String, +} + +fn sparse_dem( + mechanisms: Vec<(f64, Vec, Vec)>, + num_detectors: usize, + num_observables: usize, +) -> SparseDem { + SparseDem { + mechanisms, + detector_coords: BTreeMap::new(), + num_detectors, + num_observables, + } +} + +fn dense_syndrome(mask: u128, num_detectors: usize) -> Vec { + (0..num_detectors) + .map(|bit| u8::from(mask & (1_u128 << bit) != 0)) + .collect() +} + +fn mask_as_u128(mask: &ObsMask) -> u128 { + assert!( + mask.words().iter().skip(2).all(|&word| word == 0), + "fixture labels must fit in u128" + ); + u128::from(mask.words().first().copied().unwrap_or(0)) + | (u128::from(mask.words().get(1).copied().unwrap_or(0)) << 64) +} + +fn expected_direction(direction: &str) -> CommitteeDirection { + match direction { + "forward" => CommitteeDirection::Forward, + "backward" => CommitteeDirection::Backward, + unknown => panic!("unknown committee direction {unknown}"), + } +} + +#[test] +fn ordering_and_committee_match_upstream_fixtures() { + let fixture_file: FixtureFile = + serde_json::from_str(FIXTURES_JSON).expect("order fixtures must parse"); + assert_eq!( + fixture_file.generator, + "generate_upstream_order_fixtures.py" + ); + let mut backward_selections = 0; + let mut single_leg_selections = 0; + + for fixture in fixture_file.fixtures { + let dem = sparse_dem( + fixture.mechanisms, + fixture.num_detectors, + fixture.num_observables, + ); + assert_eq!( + deadline_column_order(&dem).unwrap(), + fixture.forward_ordering, + "{}: forward ordering", + fixture.name + ); + assert_eq!( + backward_deadline_column_order(&dem).unwrap(), + fixture.backward_ordering, + "{}: backward ordering", + fixture.name + ); + + // The fixture mechanisms are in time order. Passing forward_ordering + // builds the same forward-ordered model used by the upstream generator; + // FrontierCommittee then makes its second leg by plain reversal. + let mut committee = FrontierCommittee::from_sparse_dem( + &dem, + FrontierConfig { + k: fixture.pruned.k, + delta: fixture.pruned.delta, + score_alpha: 0.8, + column_order: Some(fixture.forward_ordering.clone()), + }, + ) + .unwrap(); + + let mut expected_by_syndrome = BTreeMap::new(); + for expected in fixture.expected_committee { + assert!( + expected_by_syndrome + .insert(expected.syndrome, expected) + .is_none(), + "{}: duplicate expected syndrome", + fixture.name + ); + } + assert_eq!( + expected_by_syndrome.len(), + fixture.syndromes.len(), + "{}: committee result count", + fixture.name + ); + + for syndrome_mask in fixture.syndromes { + let expected = expected_by_syndrome + .remove(&syndrome_mask) + .unwrap_or_else(|| panic!("{}: missing expected syndrome", fixture.name)); + assert_eq!(expected.engine, "native_binary"); + let syndrome = dense_syndrome(syndrome_mask, fixture.num_detectors); + let decoded = committee.decode(&syndrome); + + match expected.status.as_str() { + "ok" => { + let result = decoded.unwrap_or_else(|error| { + panic!( + "{} syndrome {syndrome_mask}: expected success, got {error}", + fixture.name + ) + }); + let direction = expected_direction(&expected.direction); + assert_eq!( + result.direction, direction, + "{} syndrome {syndrome_mask}: direction", + fixture.name + ); + assert_eq!( + mask_as_u128(&result.selected.predicted), + expected.logical_hat.expect("ok result needs logical_hat"), + "{} syndrome {syndrome_mask}: logical label", + fixture.name + ); + let expected_evidence = + expected.log_evidence.expect("ok result needs log_evidence"); + assert!( + (result.selected.log_evidence - expected_evidence).abs() <= 1e-9, + "{} syndrome {syndrome_mask}: expected evidence {expected_evidence}, got {}", + fixture.name, + result.selected.log_evidence + ); + let selected_member = match result.direction { + CommitteeDirection::Forward => result.forward, + CommitteeDirection::Backward => result.backward, + }; + assert_eq!(selected_member.status, CommitteeStatus::Ok); + assert_eq!( + selected_member.log_evidence.to_bits(), + result.selected.log_evidence.to_bits() + ); + if result.direction == CommitteeDirection::Backward { + backward_selections += 1; + } + if result.forward.status != result.backward.status { + single_leg_selections += 1; + let failed_member = if result.forward.status == CommitteeStatus::NoPath { + result.forward + } else { + result.backward + }; + assert_eq!( + failed_member.log_evidence.to_bits(), + f64::NEG_INFINITY.to_bits() + ); + } + } + "no_path" => { + assert!(expected.logical_hat.is_none()); + assert!(expected.log_evidence.is_none()); + assert!( + decoded.is_err(), + "{} syndrome {syndrome_mask}", + fixture.name + ); + } + status => panic!("{}: unknown committee status {status}", fixture.name), + } + } + assert!(expected_by_syndrome.is_empty()); + } + + assert!(backward_selections > 0, "fixtures must select backward"); + assert!( + single_leg_selections > 0, + "fixtures must exercise one-leg no-path selection" + ); +} + +#[test] +fn deadline_order_closes_earlier_rows_first() { + // D0 has first/last touches 0/2; D1 has 1/3. The keys therefore place + // both D0 columns before both D1 columns, preserving original-index ties: + // [0(D0), 2(D0), 1(D1), 3(D1)]. + let dem = sparse_dem( + vec![ + (0.1, vec![0], vec![]), + (0.1, vec![1], vec![]), + (0.1, vec![0], vec![]), + (0.1, vec![1], vec![]), + ], + 2, + 0, + ); + assert_eq!(deadline_column_order(&dem).unwrap(), vec![0, 2, 1, 3]); +} + +#[test] +fn detector_free_mechanisms_sort_last_and_empty_dem_stays_empty() { + let dem = sparse_dem(vec![(0.1, vec![], vec![0]), (0.1, vec![0], vec![])], 1, 1); + assert_eq!(deadline_column_order(&dem).unwrap(), vec![1, 0]); + + let empty = sparse_dem(Vec::new(), 0, 0); + assert!(deadline_column_order(&empty).unwrap().is_empty()); + assert!(backward_deadline_column_order(&empty).unwrap().is_empty()); +} + +#[test] +fn ordering_rejects_invalid_and_duplicate_detector_indices() { + let out_of_range = sparse_dem(vec![(0.1, vec![1], vec![])], 1, 0); + assert!(deadline_column_order(&out_of_range).is_err()); + + let duplicate = sparse_dem(vec![(0.1, vec![0, 0], vec![])], 1, 0); + assert!(backward_deadline_column_order(&duplicate).is_err()); +} + +#[test] +fn committee_ties_select_forward_and_trait_uses_selected_mask() { + let mut committee = FrontierCommittee::from_dem_str("", FrontierConfig::default()).unwrap(); + let result = committee.decode(&[]).unwrap(); + assert_eq!(result.direction, CommitteeDirection::Forward); + assert_eq!(result.forward.status, CommitteeStatus::Ok); + assert_eq!(result.backward.status, CommitteeStatus::Ok); + + let mut boxed: Box = Box::new(committee); + assert!(boxed.decode_obs(&[]).unwrap().is_zero()); +} From dcff3714c11b144df7d814587d562fa5d6089f0f Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 3 Aug 2026 17:50:37 -0600 Subject: [PATCH 10/10] Expose FrontierDecoder and FrontierCommitteeDecoder in pecos-rslib-exp; lint-conform fixture generators --- Cargo.lock | 1 + exp/pecos-frontier/src/lib.rs | 6 +- .../fixtures/generate_upstream_fixtures.py | 77 ++-- .../generate_upstream_order_fixtures.py | 73 ++-- python/pecos-rslib-exp/Cargo.toml | 1 + .../pecos-rslib-exp/src/frontier_bindings.rs | 392 ++++++++++++++++++ python/pecos-rslib-exp/src/lib.rs | 9 +- .../tests/qec/test_frontier_decoder.py | 109 +++++ ruff.toml | 4 + 9 files changed, 608 insertions(+), 64 deletions(-) create mode 100644 python/pecos-rslib-exp/src/frontier_bindings.rs create mode 100644 python/quantum-pecos/tests/qec/test_frontier_decoder.py diff --git a/Cargo.lock b/Cargo.lock index 1f99f7f9d..0488cff2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4816,6 +4816,7 @@ dependencies = [ "num-complex 0.4.6", "pecos-core", "pecos-eeg", + "pecos-frontier", "pecos-neo", "pecos-qec", "pecos-quantum", diff --git a/exp/pecos-frontier/src/lib.rs b/exp/pecos-frontier/src/lib.rs index 5f472b25f..e7ab9f8a4 100644 --- a/exp/pecos-frontier/src/lib.rs +++ b/exp/pecos-frontier/src/lib.rs @@ -24,9 +24,9 @@ //! upstream-verified. use pecos_decoder_core::ObservableDecoder; -use pecos_decoder_core::dem::SparseDem; -use pecos_decoder_core::errors::DecoderError; -use pecos_decoder_core::obs_mask::ObsMask; +pub use pecos_decoder_core::dem::SparseDem; +pub use pecos_decoder_core::errors::DecoderError; +pub use pecos_decoder_core::obs_mask::ObsMask; use std::cmp::Ordering; use std::collections::BTreeMap; diff --git a/exp/pecos-frontier/tests/fixtures/generate_upstream_fixtures.py b/exp/pecos-frontier/tests/fixtures/generate_upstream_fixtures.py index b36992257..1ffda19d2 100644 --- a/exp/pecos-frontier/tests/fixtures/generate_upstream_fixtures.py +++ b/exp/pecos-frontier/tests/fixtures/generate_upstream_fixtures.py @@ -1,19 +1,19 @@ -# Golden-fixture generator for pecos-frontier, run against the upstream -# frontier package (github.com/aleverrier/frontier, arXiv:2606.20513). -# -# Orchestrator-owned oracle: this script and its JSON output are authored and -# committed by the reviewer, not by the implementation. The implementation must -# never edit them. -# -# Usage (from a clone of the upstream repo with its venv built): -# .venv/bin/python generate_upstream_fixtures.py > upstream_fixtures.json -# -# Each fixture: a binary-fault model given as mechanisms [p, [detectors], -# [observables]] in processing order (identical to the column order PECOS uses), -# decoded for every syndrome bitmask in `syndromes`, unpruned (K=10^9, -# Delta=inf) and pruned (K/Delta from the fixture). Expected values come from -# upstream FrontierResult: status, logical_hat, log_evidence, and -# terminal_log_masses (logical label -> log mass, unnormalized). +"""Golden-fixture generator for pecos-frontier, run against the upstream frontier package. + +Orchestrator-owned oracle: this script and its JSON output are authored and +committed by the reviewer, not by the implementation. The implementation must +never edit them. + +Usage (from a clone of the upstream repo with its venv built): + .venv/bin/python generate_upstream_fixtures.py > upstream_fixtures.json + +Each fixture: a binary-fault model given as mechanisms [p, [detectors], +[observables]] in processing order (identical to the column order PECOS uses), +decoded for every syndrome bitmask in `syndromes`, unpruned (K=10^9, +Delta=inf) and pruned (K/Delta from the fixture). Expected values come from +upstream FrontierResult: status, logical_hat, log_evidence, and +terminal_log_masses (logical label -> log mass, unnormalized). +""" from __future__ import annotations @@ -33,7 +33,12 @@ UNPRUNED_K = 10**9 -def build_model(mechanisms, num_detectors, num_observables): +def build_model( + mechanisms: list, + num_detectors: int, + num_observables: int, +) -> FrontierModel: + """Build an upstream FrontierModel from binary (p, detectors, observables) mechanisms.""" factors = [] for idx, (p, dets, obs) in enumerate(mechanisms): det_mask = 0 @@ -51,7 +56,7 @@ def build_model(mechanisms, num_detectors, num_observables): ), instruction_offset=idx, label=f"f{idx}", - ) + ), ) columns = tuple(columns_from_factor_transitions(tuple(factors))) return FrontierModel( @@ -62,7 +67,8 @@ def build_model(mechanisms, num_detectors, num_observables): ) -def decode_all(model, syndromes, k, delta): +def decode_all(model: FrontierModel, syndromes: list, k: int, delta: float) -> list: + """Decode each syndrome and record upstream expected results as JSON-safe dicts.""" out = [] for syndrome in syndromes: r = decode_frontier(model, syndrome, K=k, Delta=delta) @@ -78,12 +84,18 @@ def decode_all(model, syndromes, k, delta): str(k_): v for k_, v in sorted(r.terminal_log_masses.items()) if math.isfinite(v) }, "engine": r.engine, - } + }, ) return out -def random_mechanisms(rng, num_mechs, num_detectors, num_observables): +def random_mechanisms( + rng: random.Random, + num_mechs: int, + num_detectors: int, + num_observables: int, +) -> list: + """Sample a seeded random binary-mechanism list.""" mechs = [] for _ in range(num_mechs): n_d = rng.choice([1, 1, 2, 2, 3]) @@ -94,7 +106,8 @@ def random_mechanisms(rng, num_mechs, num_detectors, num_observables): return mechs -def main(): +def main() -> int: + """Emit the fixture JSON to stdout.""" fixtures = [] # F1: hand-built degeneracy case -- logical-ML differs from most-likely-error. @@ -110,7 +123,7 @@ def main(): "num_observables": 1, "syndromes": [0, 1], "pruned": {"k": 2, "delta": 100.0}, - } + }, ) # F2: repetition-code-like chain with hyperedge, two observables. @@ -129,7 +142,7 @@ def main(): "num_observables": 2, "syndromes": list(range(16)), "pruned": {"k": 4, "delta": 30.0}, - } + }, ) # F3: wide observables -- winning label flips observable index 70. @@ -146,7 +159,7 @@ def main(): "num_observables": 71, "syndromes": [0, 1, 2, 3], "pruned": {"k": 8, "delta": 100.0}, - } + }, ) # F4/F5: seeded random models. @@ -160,17 +173,25 @@ def main(): "num_observables": num_obs, "syndromes": sorted(rng.sample(range(1 << num_dets), 12)), "pruned": {"k": 3, "delta": 20.0}, - } + }, ) for fx in fixtures: model = build_model(fx["mechanisms"], fx["num_detectors"], fx["num_observables"]) fx["expected_unpruned"] = decode_all(model, fx["syndromes"], UNPRUNED_K, math.inf) fx["expected_pruned"] = decode_all( - model, fx["syndromes"], fx["pruned"]["k"], fx["pruned"]["delta"] + model, + fx["syndromes"], + fx["pruned"]["k"], + fx["pruned"]["delta"], ) - json.dump({"generator": "generate_upstream_fixtures.py", "fixtures": fixtures}, sys.stdout, indent=1, allow_nan=False) + json.dump( + {"generator": "generate_upstream_fixtures.py", "fixtures": fixtures}, + sys.stdout, + indent=1, + allow_nan=False, + ) sys.stdout.write("\n") return 0 diff --git a/exp/pecos-frontier/tests/fixtures/generate_upstream_order_fixtures.py b/exp/pecos-frontier/tests/fixtures/generate_upstream_order_fixtures.py index c23d07600..edd1eab5e 100644 --- a/exp/pecos-frontier/tests/fixtures/generate_upstream_order_fixtures.py +++ b/exp/pecos-frontier/tests/fixtures/generate_upstream_order_fixtures.py @@ -1,21 +1,21 @@ -# Ordering + committee golden-fixture generator for pecos-frontier, run against -# the upstream frontier package (github.com/aleverrier/frontier, arXiv:2606.20513). -# -# Orchestrator-owned oracle: this script and its JSON output are authored and -# committed by the reviewer, not by the implementation. The implementation must -# never edit them. -# -# Usage (from a clone of the upstream repo with its venv built): -# .venv/bin/python generate_upstream_order_fixtures.py > upstream_order_fixtures.json -# -# Per fixture: mechanisms in TIME order. Expected values from upstream: -# - forward_ordering: optimize_column_order permutation over the time-ordered -# columns (deadline reorder). ordering[target] = source index. -# - backward_ordering: reverse the forward-ordered columns, then -# optimize_column_order again (build_backward_deadline_ordered_family -# semantics), composed back to original mechanism indices. -# - committee: decode_frontier_committee on the forward-ordered model per -# syndrome: status, logical_hat, direction, log_evidence. +"""Ordering + committee golden-fixture generator, run against the upstream frontier package. + +Orchestrator-owned oracle: this script and its JSON output are authored and +committed by the reviewer, not by the implementation. The implementation must +never edit them. + +Usage (from a clone of the upstream repo with its venv built): + .venv/bin/python generate_upstream_order_fixtures.py > upstream_order_fixtures.json + +Per fixture: mechanisms in TIME order. Expected values from upstream: +- forward_ordering: optimize_column_order permutation over the time-ordered + columns (deadline reorder). ordering[target] = source index. +- backward_ordering: reverse the forward-ordered columns, then + optimize_column_order again (build_backward_deadline_ordered_family + semantics), composed back to original mechanism indices. +- committee: decode_frontier_committee on the forward-ordered model per + syndrome: status, logical_hat, direction, log_evidence. +""" from __future__ import annotations @@ -35,7 +35,8 @@ from tools.frontier_progressive import _reverse_progressive_columns -def build_columns(mechanisms): +def build_columns(mechanisms: list) -> list: + """Build upstream progressive columns from binary mechanisms in time order.""" factors = [] for idx, (p, dets, obs) in enumerate(mechanisms): det_mask = 0 @@ -53,12 +54,18 @@ def build_columns(mechanisms): ), instruction_offset=idx, label=f"f{idx}", - ) + ), ) return list(columns_from_factor_transitions(tuple(factors))) -def random_mechanisms(rng, num_mechs, num_detectors, num_observables): +def random_mechanisms( + rng: random.Random, + num_mechs: int, + num_detectors: int, + num_observables: int, +) -> list: + """Sample a seeded random binary-mechanism list (detector-free allowed).""" mechs = [] for _ in range(num_mechs): n_d = rng.choice([0, 1, 1, 2, 2, 3]) @@ -69,7 +76,8 @@ def random_mechanisms(rng, num_mechs, num_detectors, num_observables): return mechs -def main(): +def main() -> int: + """Emit the ordering/committee fixture JSON to stdout.""" fixtures = [] # F1: hand-built chain where time order != deadline order. @@ -89,7 +97,7 @@ def main(): "num_observables": 2, "syndromes": list(range(16)), "pruned": {"k": 3, "delta": 25.0}, - } + }, ) # F2/F3: seeded random models (include detector-free mechanisms). @@ -103,26 +111,26 @@ def main(): "num_observables": num_obs, "syndromes": sorted(rng.sample(range(1 << num_dets), 10)), "pruned": {"k": 3, "delta": 25.0}, - } + }, ) for fx in fixtures: time_columns = build_columns(fx["mechanisms"]) forward_columns, forward_ordering = optimize_column_order( - list(time_columns), num_detectors=fx["num_detectors"] + list(time_columns), + num_detectors=fx["num_detectors"], ) fx["forward_ordering"] = [int(v) for v in forward_ordering] reversed_columns = _reverse_progressive_columns(forward_columns) _backward_columns, backward_ordering_local = optimize_column_order( - list(reversed_columns), num_detectors=fx["num_detectors"] + list(reversed_columns), + num_detectors=fx["num_detectors"], ) # Compose back to original mechanism indices: reversed[i] came from # forward position len-1-i, which came from mechanism forward_ordering[...]. n = len(forward_ordering) - backward_in_original = [ - int(forward_ordering[n - 1 - int(local)]) for local in backward_ordering_local - ] + backward_in_original = [int(forward_ordering[n - 1 - int(local)]) for local in backward_ordering_local] fx["backward_ordering"] = backward_in_original model = FrontierModel( @@ -134,7 +142,10 @@ def main(): committee = [] for syndrome in fx["syndromes"]: r = decode_frontier_committee( - model, syndrome, K=fx["pruned"]["k"], Delta=fx["pruned"]["delta"] + model, + syndrome, + K=fx["pruned"]["k"], + Delta=fx["pruned"]["delta"], ) committee.append( { @@ -144,7 +155,7 @@ def main(): "direction": r.direction, "log_evidence": r.log_evidence if math.isfinite(r.log_evidence) else None, "engine": r.engine, - } + }, ) fx["expected_committee"] = committee diff --git a/python/pecos-rslib-exp/Cargo.toml b/python/pecos-rslib-exp/Cargo.toml index bddcee29a..fe1dd707a 100644 --- a/python/pecos-rslib-exp/Cargo.toml +++ b/python/pecos-rslib-exp/Cargo.toml @@ -27,6 +27,7 @@ extension-module = [ [dependencies] pecos-core.workspace = true pecos-eeg.workspace = true +pecos-frontier.workspace = true pecos-neo.workspace = true pecos-qec.workspace = true pecos-quantum.workspace = true diff --git a/python/pecos-rslib-exp/src/frontier_bindings.rs b/python/pecos-rslib-exp/src/frontier_bindings.rs new file mode 100644 index 000000000..129ebc44f --- /dev/null +++ b/python/pecos-rslib-exp/src/frontier_bindings.rs @@ -0,0 +1,392 @@ +// 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. + +use pecos_frontier::{ + CommitteeDirection, CommitteeMember, CommitteeStatus, DecoderError, + FrontierCommittee as RustFrontierCommittee, + FrontierCommitteeResult as RustFrontierCommitteeResult, FrontierConfig as RustFrontierConfig, + FrontierDecoder as RustFrontierDecoder, FrontierResult as RustFrontierResult, ObsMask, + SparseDem, backward_deadline_column_order, deadline_column_order, +}; +use pyo3::Borrowed; +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyBytes, PyInt}; + +enum ColumnOrderArgument { + Name(String), + Explicit(Vec), +} + +impl<'a, 'py> FromPyObject<'a, 'py> for ColumnOrderArgument { + type Error = PyErr; + + fn extract(object: Borrowed<'a, 'py, PyAny>) -> PyResult { + if let Ok(name) = object.extract::() { + return Ok(Self::Name(name)); + } + if let Ok(order) = object.extract::>() { + return Ok(Self::Explicit(order)); + } + Err(PyValueError::new_err( + "column_order must be 'deadline_reorder', 'time_order', \ + 'backward_deadline_reorder', or a list of mechanism indices", + )) + } +} + +impl Default for ColumnOrderArgument { + fn default() -> Self { + Self::Name("deadline_reorder".to_owned()) + } +} + +fn runtime_error(error: &DecoderError) -> PyErr { + PyRuntimeError::new_err(error.to_string()) +} + +fn sparse_index_error(index: u64, num_detectors: usize) -> PyErr { + PyRuntimeError::new_err(format!( + "Invalid node index {index}: must be < {num_detectors}" + )) +} + +fn sparse_to_dense(indices: &[u64], num_detectors: usize) -> PyResult> { + let mut syndrome = vec![0; num_detectors]; + for &index in indices { + let detector = + usize::try_from(index).map_err(|_| sparse_index_error(index, num_detectors))?; + let bit = syndrome + .get_mut(detector) + .ok_or_else(|| sparse_index_error(index, num_detectors))?; + *bit = 1; + } + Ok(syndrome) +} + +fn resolve_column_order( + dem: &SparseDem, + column_order: ColumnOrderArgument, +) -> PyResult>> { + match column_order { + ColumnOrderArgument::Name(name) => match name.as_str() { + "deadline_reorder" => deadline_column_order(dem) + .map(Some) + .map_err(|e| runtime_error(&e)), + "time_order" => Ok(None), + "backward_deadline_reorder" => backward_deadline_column_order(dem) + .map(Some) + .map_err(|e| runtime_error(&e)), + _ => Err(PyValueError::new_err(format!( + "invalid column_order {name:?}; expected 'deadline_reorder', 'time_order', \ + 'backward_deadline_reorder', or a list of mechanism indices" + ))), + }, + ColumnOrderArgument::Explicit(order) => Ok(Some(order)), + } +} + +fn parse_dem_and_config( + dem_str: &str, + k: usize, + delta: f64, + score_alpha: f64, + column_order: ColumnOrderArgument, +) -> PyResult<(SparseDem, RustFrontierConfig)> { + let dem = SparseDem::from_dem_str(dem_str).map_err(|e| runtime_error(&e))?; + let column_order = resolve_column_order(&dem, column_order)?; + Ok(( + dem, + RustFrontierConfig { + k, + delta, + score_alpha, + column_order, + }, + )) +} + +fn obs_mask_to_py(py: Python<'_>, mask: &ObsMask) -> PyResult> { + if let Some(value) = mask.to_u64() { + return Ok(value.into_pyobject(py)?.into_any().unbind()); + } + + let mut bytes = Vec::with_capacity(std::mem::size_of_val(mask.words())); + for &word in mask.words() { + bytes.extend_from_slice(&word.to_le_bytes()); + } + let py_bytes = PyBytes::new(py, &bytes); + Ok(py + .get_type::() + .call_method1("from_bytes", (py_bytes, "little"))? + .unbind()) +} + +fn observable_bits(mask: &ObsMask, num_observables: usize) -> Vec { + (0..num_observables) + .map(|observable| i32::from(mask.get(observable))) + .collect() +} + +fn logical_masses(py: Python<'_>, result: &RustFrontierResult) -> PyResult, f64)>> { + result + .logical_masses + .iter() + .map(|mass| Ok((obs_mask_to_py(py, &mass.logical)?, mass.log_mass))) + .collect() +} + +fn member_log_evidence(member: CommitteeMember) -> Option { + match member.status { + CommitteeStatus::Ok => Some(member.log_evidence), + CommitteeStatus::NoPath => None, + } +} + +/// Result returned by the native experimental Frontier decoder. +#[pyclass(name = "FrontierResult", module = "pecos_rslib_exp")] +pub struct PyFrontierResult { + inner: RustFrontierResult, +} + +#[pymethods] +impl PyFrontierResult { + /// Predicted observable flips as an arbitrary-precision Python integer. + #[getter] + fn observables_mask(&self, py: Python<'_>) -> PyResult> { + obs_mask_to_py(py, &self.inner.predicted) + } + + /// Total retained log evidence. + #[getter] + fn log_evidence(&self) -> f64 { + self.inner.log_evidence + } + + /// Winner-to-runner-up terminal log-mass gap, if a runner-up exists. + #[getter] + fn runner_up_gap(&self) -> Option { + self.inner.runner_up_gap + } + + /// Peak number of retained dynamic-programming states. + #[getter] + fn peak_retained_states(&self) -> usize { + self.inner.peak_retained_states + } + + /// Number of probabilistic columns processed. + #[getter] + fn processed_columns(&self) -> usize { + self.inner.processed_columns + } + + /// Terminal logical labels and their unnormalized joint log masses. + #[getter] + fn logical_masses(&self, py: Python<'_>) -> PyResult, f64)>> { + logical_masses(py, &self.inner) + } + + /// Return the predicted observable flips as a dense bit vector. + fn observable_bits(&self, num_observables: usize) -> Vec { + observable_bits(&self.inner.predicted, num_observables) + } +} + +/// Result returned by the native experimental forward/backward committee. +#[pyclass(name = "FrontierCommitteeResult", module = "pecos_rslib_exp")] +pub struct PyFrontierCommitteeResult { + inner: RustFrontierCommitteeResult, +} + +#[pymethods] +impl PyFrontierCommitteeResult { + /// Predicted observable flips from the selected leg. + #[getter] + fn observables_mask(&self, py: Python<'_>) -> PyResult> { + obs_mask_to_py(py, &self.inner.selected.predicted) + } + + /// Total retained log evidence from the selected leg. + #[getter] + fn log_evidence(&self) -> f64 { + self.inner.selected.log_evidence + } + + /// Winner-to-runner-up terminal log-mass gap from the selected leg. + #[getter] + fn runner_up_gap(&self) -> Option { + self.inner.selected.runner_up_gap + } + + /// Peak number of states retained by the selected leg. + #[getter] + fn peak_retained_states(&self) -> usize { + self.inner.selected.peak_retained_states + } + + /// Number of probabilistic columns processed by the selected leg. + #[getter] + fn processed_columns(&self) -> usize { + self.inner.selected.processed_columns + } + + /// Terminal logical labels and log masses from the selected leg. + #[getter] + fn logical_masses(&self, py: Python<'_>) -> PyResult, f64)>> { + logical_masses(py, &self.inner.selected) + } + + /// Selected committee direction: `"forward"` or `"backward"`. + #[getter] + fn direction(&self) -> &'static str { + match self.inner.direction { + CommitteeDirection::Forward => "forward", + CommitteeDirection::Backward => "backward", + } + } + + /// Forward-leg evidence, or `None` when that leg found no path. + #[getter] + fn forward_log_evidence(&self) -> Option { + member_log_evidence(self.inner.forward) + } + + /// Backward-leg evidence, or `None` when that leg found no path. + #[getter] + fn backward_log_evidence(&self) -> Option { + member_log_evidence(self.inner.backward) + } + + /// Return the selected observable flips as a dense bit vector. + fn observable_bits(&self, num_observables: usize) -> Vec { + observable_bits(&self.inner.selected.predicted, num_observables) + } +} + +/// Native implementation of the Frontier decoder (Leverrier & Urbanke, +/// arXiv:2606.20513). This experimental decoder is implemented in Rust and does +/// not wrap the upstream `frontier` package. +#[pyclass(name = "FrontierDecoder", module = "pecos_rslib_exp", unsendable)] +pub struct PyFrontierDecoder { + inner: RustFrontierDecoder, + num_detectors: usize, +} + +#[pymethods] +impl PyFrontierDecoder { + /// Construct a native Frontier decoder from a Stim-format DEM string. + #[staticmethod] + #[pyo3( + signature = (dem, *, k=64, delta=50.0, score_alpha=0.8, column_order=ColumnOrderArgument::default()), + text_signature = "(dem, *, k=64, delta=50.0, score_alpha=0.8, column_order='deadline_reorder')" + )] + fn from_dem( + dem: &str, + k: usize, + delta: f64, + score_alpha: f64, + column_order: ColumnOrderArgument, + ) -> PyResult { + let (dem, config) = parse_dem_and_config(dem, k, delta, score_alpha, column_order)?; + let num_detectors = dem.num_detectors; + let inner = + RustFrontierDecoder::from_sparse_dem(&dem, config).map_err(|e| runtime_error(&e))?; + Ok(Self { + inner, + num_detectors, + }) + } + + /// Decode sparse fired-detector indices. + fn decode(&mut self, detection_events: Vec) -> PyResult { + let syndrome = sparse_to_dense(&detection_events, self.num_detectors)?; + self.decode_syndrome(syndrome) + } + + /// Decode one dense detector syndrome. + fn decode_syndrome(&mut self, syndrome: Vec) -> PyResult { + self.inner + .decode(&syndrome) + .map(|inner| PyFrontierResult { inner }) + .map_err(|e| runtime_error(&e)) + } + + /// Decode a batch of dense detector syndromes in input order. + fn decode_batch(&mut self, shots: Vec>) -> PyResult> { + shots + .into_iter() + .map(|syndrome| self.decode_syndrome(syndrome)) + .collect() + } +} + +/// Native experimental forward/backward committee of two Frontier decoders. +/// The implementation is Rust-native and does not wrap the upstream +/// `frontier` package (Leverrier & Urbanke, arXiv:2606.20513). +#[pyclass( + name = "FrontierCommitteeDecoder", + module = "pecos_rslib_exp", + unsendable +)] +pub struct PyFrontierCommitteeDecoder { + inner: RustFrontierCommittee, + num_detectors: usize, +} + +#[pymethods] +impl PyFrontierCommitteeDecoder { + /// Construct a native forward/backward committee from a Stim-format DEM. + #[staticmethod] + #[pyo3( + signature = (dem, *, k=64, delta=50.0, score_alpha=0.8, column_order=ColumnOrderArgument::default()), + text_signature = "(dem, *, k=64, delta=50.0, score_alpha=0.8, column_order='deadline_reorder')" + )] + fn from_dem( + dem: &str, + k: usize, + delta: f64, + score_alpha: f64, + column_order: ColumnOrderArgument, + ) -> PyResult { + let (dem, config) = parse_dem_and_config(dem, k, delta, score_alpha, column_order)?; + let num_detectors = dem.num_detectors; + let inner = + RustFrontierCommittee::from_sparse_dem(&dem, config).map_err(|e| runtime_error(&e))?; + Ok(Self { + inner, + num_detectors, + }) + } + + /// Decode sparse fired-detector indices with both committee legs. + fn decode(&mut self, detection_events: Vec) -> PyResult { + let syndrome = sparse_to_dense(&detection_events, self.num_detectors)?; + self.decode_syndrome(syndrome) + } + + /// Decode one dense detector syndrome with both committee legs. + fn decode_syndrome(&mut self, syndrome: Vec) -> PyResult { + self.inner + .decode(&syndrome) + .map(|inner| PyFrontierCommitteeResult { inner }) + .map_err(|e| runtime_error(&e)) + } + + /// Decode a batch of dense detector syndromes in input order. + fn decode_batch(&mut self, shots: Vec>) -> PyResult> { + shots + .into_iter() + .map(|syndrome| self.decode_syndrome(syndrome)) + .collect() + } +} diff --git a/python/pecos-rslib-exp/src/lib.rs b/python/pecos-rslib-exp/src/lib.rs index e3ea3b0e5..2e78b6c3c 100644 --- a/python/pecos-rslib-exp/src/lib.rs +++ b/python/pecos-rslib-exp/src/lib.rs @@ -24,13 +24,14 @@ clippy::unused_self )] -//! Python bindings for experimental PECOS simulators. +//! Python bindings for experimental PECOS components. //! //! Exposes `StabMps` (stabilizer + MPS hybrid) and `Mast` (magic state -//! injection) from `pecos-stab-tn` via `PyO3`. +//! injection) from `pecos-stab-tn`, plus native Frontier decoders, via `PyO3`. mod coherent_idle_channel; mod eeg_bindings; +mod frontier_bindings; mod mast_bindings; mod sim_neo_bindings; mod stab_mps_bindings; @@ -64,6 +65,10 @@ pub(crate) fn extract_angle( #[pymodule] fn pecos_rslib_exp(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/python/quantum-pecos/tests/qec/test_frontier_decoder.py b/python/quantum-pecos/tests/qec/test_frontier_decoder.py new file mode 100644 index 000000000..454639698 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_frontier_decoder.py @@ -0,0 +1,109 @@ +# 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. + +"""Tests for the experimental native Frontier decoder bindings.""" + +from __future__ import annotations + +import pytest + +pecos_rslib_exp = pytest.importorskip("pecos_rslib_exp") + +from pecos_rslib_exp import ( # noqa: E402 + FrontierCommitteeDecoder, + FrontierDecoder, +) + + +SMALL_DEM = """\ +error(0.1) D0 L0 +error(0.2) D1 +""" + + +def test_sparse_and_dense_decode_agree() -> None: + decoder = FrontierDecoder.from_dem(SMALL_DEM) + + # Syndrome D0=1, D1=0 forces the first mechanism on and the second off, + # so logical observable L0 flips and the expected mask is 1. + dense = decoder.decode_syndrome([1, 0]) + sparse = decoder.decode([0]) + + assert dense.observables_mask == 1 + assert sparse.observables_mask == dense.observables_mask + assert sparse.log_evidence == dense.log_evidence + assert sparse.logical_masses == dense.logical_masses + assert dense.observable_bits(2) == [1, 0] + + +def test_wide_observable_mask_is_a_python_int() -> None: + decoder = FrontierDecoder.from_dem("error(0.9) D0 L64\n") + + result = decoder.decode_syndrome([1]) + + assert isinstance(result.observables_mask, int) + assert result.observables_mask == 1 << 64 + assert result.observable_bits(65)[64] == 1 + assert result.logical_masses[0][0] == 1 << 64 + + +def test_column_order_variants_and_validation() -> None: + expected = 1 + for column_order in ( + "deadline_reorder", + "time_order", + "backward_deadline_reorder", + [1, 0], + ): + decoder = FrontierDecoder.from_dem(SMALL_DEM, column_order=column_order) + assert decoder.decode_syndrome([1, 0]).observables_mask == expected + + with pytest.raises(ValueError, match="invalid column_order"): + FrontierDecoder.from_dem(SMALL_DEM, column_order="not_an_order") + with pytest.raises(ValueError, match="column_order must be"): + FrontierDecoder.from_dem(SMALL_DEM, column_order=object()) + with pytest.raises(RuntimeError, match="permutation"): + FrontierDecoder.from_dem(SMALL_DEM, column_order=[0, 0]) + + +def test_committee_easy_tie_selects_forward() -> None: + committee = FrontierCommitteeDecoder.from_dem("error(0.1) D0 L0\n", column_order="time_order") + + result = committee.decode([0]) + + assert result.observables_mask == 1 + assert result.direction == "forward" + assert result.forward_log_evidence == result.log_evidence + assert result.backward_log_evidence == result.log_evidence + + +def test_unexplainable_and_out_of_range_syndromes_raise_runtime_error() -> None: + decoder = FrontierDecoder.from_dem("error(0.1) D0 D1\n") + + with pytest.raises(RuntimeError, match="unexplainable"): + decoder.decode_syndrome([1, 0]) + with pytest.raises(RuntimeError, match="Invalid node index"): + decoder.decode([2]) + + +def test_decode_batch_matches_individual_decodes() -> None: + shots = [[0, 0], [1, 0], [0, 1], [1, 1]] + batch_decoder = FrontierDecoder.from_dem(SMALL_DEM) + individual_decoder = FrontierDecoder.from_dem(SMALL_DEM) + + batch = batch_decoder.decode_batch(shots) + individual = [individual_decoder.decode_syndrome(shot) for shot in shots] + + assert [result.observables_mask for result in batch] == [result.observables_mask for result in individual] + assert [result.log_evidence for result in batch] == [result.log_evidence for result in individual] + assert [result.logical_masses for result in batch] == [result.logical_masses for result in individual] diff --git a/ruff.toml b/ruff.toml index 909707189..18d8ca2e5 100644 --- a/ruff.toml +++ b/ruff.toml @@ -108,6 +108,10 @@ ignore = [ "INP001", # Implicit namespace package - standalone scripts, not a package "S311", # Standard pseudo-random - fixed-seed case sampling, not crypto ] +"exp/*/tests/fixtures/*.py" = [ + "INP001", # Implicit namespace package - standalone scripts, not a package + "S311", # Standard pseudo-random - fixed-seed case sampling, not crypto +] # Main pecos __init__.py - special case for module initialization "python/quantum-pecos/src/pecos/__init__.py" = [