diff --git a/crates/pecos-decoder-core/src/logical_algorithm.rs b/crates/pecos-decoder-core/src/logical_algorithm.rs index 5a8e4f685..a57150c51 100644 --- a/crates/pecos-decoder-core/src/logical_algorithm.rs +++ b/crates/pecos-decoder-core/src/logical_algorithm.rs @@ -25,7 +25,6 @@ use crate::ObservableDecoder; use crate::errors::DecoderError; -use crate::obs_mask::ObsMask; /// One segment of a logical algorithm. pub struct SegmentDescriptor { @@ -175,18 +174,15 @@ impl LogicalAlgorithmDecoder { /// Apply boundary gate to a Pauli frame. /// Used when consuming the frame at logical operations. - /// - /// # Errors - /// Returns [`DecoderError::ObservableBitOutOfRange`] if any of the gate's - /// observable bits is `>= 64`. All bits index the `u64` observable frame, so - /// each must be `< 64`; the Python descriptor binding rejects this at - /// construction, and this runtime check guards the same invariant for direct - /// Rust callers (a shift by `>= 64` is otherwise an overflow panic in debug / - /// unspecified in release). - pub fn apply_boundary_gate(frame: &mut u64, gate: &BoundaryGate) -> Result<(), DecoderError> { - if let Some(&bit) = gate.obs_bits().iter().find(|&&b| b >= 64) { - return Err(DecoderError::ObservableBitOutOfRange { bit }); - } + pub fn apply_boundary_gate(frame: &mut u64, gate: &BoundaryGate) { + // All bits index the u64 observable frame, so each must be < 64. The + // Python descriptor binding enforces this fail-loud at construction; this + // documents and guards the invariant for direct Rust callers (a shift by + // >= 64 is otherwise an overflow panic in debug / unspecified in release). + debug_assert!( + gate.obs_bits().iter().all(|&b| b < 64), + "boundary gate observable bit >= 64" + ); match gate { BoundaryGate::Hadamard { x_obs_bit, @@ -236,17 +232,14 @@ impl LogicalAlgorithmDecoder { } } } - Ok(()) } } impl ObservableDecoder for LogicalAlgorithmDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { - self.decode_shot(syndrome) - } - - fn decode_obs(&mut self, syndrome: &[u8]) -> Result { - self.full_decoder.decode_obs(syndrome) + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + Ok(crate::obs_mask::ObsMask::from_u64( + self.decode_shot(syndrome)?, + )) } } @@ -267,10 +260,10 @@ impl ObservableDecoder for LogicalAlgorithmDecoder { /// /// ``` /// use pecos_decoder_core::{DecoderError, ObservableDecoder}; -/// use pecos_decoder_core::obs_mask::ObsMask; /// use pecos_decoder_core::logical_algorithm::{ /// AlgorithmDescriptor, LogicalAlgorithmDecoder, SegmentDescriptor, StreamingLogicalDecoder, /// }; +/// use pecos_decoder_core::obs_mask::ObsMask; /// /// struct AnyDetectionDecoder; /// @@ -359,18 +352,6 @@ impl StreamingLogicalDecoder { Ok(obs) } - /// Wide variant of [`Self::flush`]: returns an [`ObsMask`] supporting more - /// than 64 observables (no truncation). - pub fn flush_obs(&mut self) -> Result { - self.inner.decode_obs(&self.syndrome) - } - - /// Wide variant of [`Self::decode_shot`]: feed + flush as an [`ObsMask`]. - pub fn decode_shot_obs(&mut self, syndrome: &[u8]) -> Result { - self.feed_dense(syndrome); - self.flush_obs() - } - /// Decode a full syndrome at once (convenience for batch mode). pub fn decode_shot(&mut self, syndrome: &[u8]) -> Result { self.feed_dense(syndrome); @@ -402,11 +383,8 @@ impl StreamingLogicalDecoder { } /// Apply boundary gate to a Pauli frame (delegates to inner). - /// - /// # Errors - /// Propagates [`DecoderError::ObservableBitOutOfRange`] from the inner apply. - pub fn apply_boundary_gate(frame: &mut u64, gate: &BoundaryGate) -> Result<(), DecoderError> { - LogicalAlgorithmDecoder::apply_boundary_gate(frame, gate) + pub fn apply_boundary_gate(frame: &mut u64, gate: &BoundaryGate) { + LogicalAlgorithmDecoder::apply_boundary_gate(frame, gate); } /// Reset for the next shot. @@ -601,15 +579,10 @@ impl LogicalCircuitDecoder { } impl ObservableDecoder for LogicalCircuitDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { - self.decode_shot(syndrome) - } - - fn decode_obs(&mut self, syndrome: &[u8]) -> Result { - self.reset(); - let len = syndrome.len().min(self.total_detectors); - self.syndrome[..len].copy_from_slice(&syndrome[..len]); - self.strategy.decode_obs(&self.syndrome) + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + Ok(crate::obs_mask::ObsMask::from_u64( + self.decode_shot(syndrome)?, + )) } } @@ -639,10 +612,6 @@ impl DecodeStrategy for FullCircuitStrategy { self.inner.decode_to_observables(syndrome) } - fn decode_obs(&mut self, syndrome: &[u8]) -> Result { - self.inner.decode_obs(syndrome) - } - fn commit(&mut self, _region: &DetectorRegion) -> Result { // Full circuit doesn't commit incrementally Ok(0) @@ -717,6 +686,13 @@ impl WindowedLogicalSubgraphStrategy { observable_indices.len(), ))); } + if let Some(&bad) = observable_indices.iter().find(|&&o| o >= 64) { + return Err(DecoderError::InvalidConfiguration(format!( + "WindowedLogicalSubgraphStrategy: observable index {bad} >= 64 \ + exceeds the u64 observable-mask capacity" + ))); + } + let mut decoders = Vec::with_capacity(num); let mut sub_syndromes = Vec::with_capacity(num); for (i, dem_str) in subgraph_dems.iter().enumerate() { @@ -736,17 +712,8 @@ impl WindowedLogicalSubgraphStrategy { } impl DecodeStrategy for WindowedLogicalSubgraphStrategy { - /// Narrowing wrapper over [`Self::decode_obs`]; errors above 64 observables. fn decode(&mut self, syndrome: &[u8]) -> Result { - self.decode_obs(syndrome)?.to_u64().ok_or_else(|| { - DecoderError::InvalidConfiguration( - "decoder has more than 64 observables; use decode_obs() for the wide mask".into(), - ) - }) - } - - fn decode_obs(&mut self, syndrome: &[u8]) -> Result { - let mut obs_mask = ObsMask::new(); + let mut obs_mask = 0u64; for (i, (dec, dmap)) in self .subgraph_decoders @@ -774,7 +741,7 @@ impl DecodeStrategy for WindowedLogicalSubgraphStrategy { // position `i`, which differs once empty observables are filtered). let sub_obs = dec.decode_to_observables(&buf[..n])?; if sub_obs & 1 != 0 { - obs_mask.set(self.observable_indices[i]); + obs_mask |= 1u64 << self.observable_indices[i]; } } @@ -835,7 +802,7 @@ mod tests { // regions and were filtered out. Each reports its observable as local // bit 0; the strategy must flip the GLOBAL bit, not the list position. // (The pre-fix `1 << i` would have produced bits {0,1} = 0b0011.) - let mut strategy = WindowedLogicalSubgraphStrategy::new( + let mut strat = WindowedLogicalSubgraphStrategy::new( vec![ "error(0.1) D0 L0".to_string(), "error(0.1) D0 L0".to_string(), @@ -845,27 +812,19 @@ mod tests { |_dem| Ok(Box::new(FixedDecoder(1)) as Box), ) .unwrap(); - let obs = strategy.decode(&[1, 1]).unwrap(); + let obs = strat.decode(&[1, 1]).unwrap(); assert_eq!(obs, (1u64 << 1) | (1u64 << 3)); } #[test] - fn windowed_strategy_supports_observable_index_over_63() { - use crate::decode_budget::DecodeStrategy; - // Observable index 64 was previously rejected; it now constructs and the - // wide `decode_obs` represents bit 64 with no truncation. - let mut s = WindowedLogicalSubgraphStrategy::new( + fn windowed_strategy_rejects_observable_index_over_63() { + let r = WindowedLogicalSubgraphStrategy::new( vec!["error(0.1) D0 L0".to_string()], vec![vec![0usize]], vec![64usize], |_dem| Ok(Box::new(FixedDecoder(1)) as Box), - ) - .unwrap(); - let wide = s.decode_obs(&[1]).unwrap(); - assert!(wide.get(64)); - assert_eq!(wide.to_u64(), None); - // The narrowing u64 path errors rather than truncating. - assert!(s.decode(&[1]).is_err()); + ); + assert!(r.is_err()); } #[test] @@ -888,31 +847,8 @@ mod tests { x_obs_bit: 0, z_obs_bit: 1, }, - ) - .expect("boundary observable bits < 64"); - assert_eq!(frame, 0b10); // X became Z - } - - #[test] - fn test_apply_boundary_gate_rejects_obs_bit_ge_64() { - // A boundary bit >= 64 cannot index the u64 frame; apply must fail loud - // (not panic in debug / shift-overflow in release) for direct Rust callers. - let mut frame = 0u64; - let result = LogicalAlgorithmDecoder::apply_boundary_gate( - &mut frame, - &BoundaryGate::Hadamard { - x_obs_bit: 64, - z_obs_bit: 1, - }, - ); - assert!(matches!( - result, - Err(DecoderError::ObservableBitOutOfRange { bit: 64 }) - )); - assert_eq!( - frame, 0, - "frame must be untouched when the gate is rejected" ); + assert_eq!(frame, 0b10); // X became Z } #[test] @@ -966,8 +902,7 @@ mod tests { tgt_x_bit: 2, tgt_z_bit: 3, }, - ) - .expect("boundary observable bits < 64"); + ); assert_eq!(frame, 0b0101); // X propagated to target } @@ -1011,8 +946,7 @@ mod tests { tgt_x_bit: 2, tgt_z_bit: 3, }, - ) - .expect("boundary observable bits < 64"); + ); assert_eq!(frame, 0b1010); // Z propagated back to control Z (bit 1) } @@ -1028,8 +962,7 @@ mod tests { tgt_x_bit: 2, tgt_z_bit: 3, }, - ) - .expect("boundary observable bits < 64"); + ); // X ctrl -> X tgt (bit 2), Z tgt -> Z ctrl (bit 1) assert_eq!(frame, 0b1111); } @@ -1044,8 +977,7 @@ mod tests { x_obs_bit: 0, z_obs_bit: 1, }, - ) - .expect("boundary observable bits < 64"); + ); assert_eq!(frame, 0b11); // X stays, Z also set } @@ -1059,8 +991,7 @@ mod tests { x_obs_bit: 0, z_obs_bit: 1, }, - ) - .expect("boundary observable bits < 64"); + ); assert_eq!(frame, 0b10); // Z stays, no X induced } @@ -1073,8 +1004,7 @@ mod tests { x_obs_bit: 0, z_obs_bit: 1, }, - ) - .expect("boundary observable bits < 64"); + ); assert_eq!(frame, 0); // No correction, no change } @@ -1088,8 +1018,7 @@ mod tests { z_obs_bit: 1, // data Z ancilla_z_bit: 3, // ancilla Z }, - ) - .expect("boundary observable bits < 64"); + ); assert_eq!(frame, 0b1010); // data Z (bit 1) flipped } @@ -1103,8 +1032,7 @@ mod tests { z_obs_bit: 1, ancilla_z_bit: 3, }, - ) - .expect("boundary observable bits < 64"); + ); assert_eq!(frame, 0b1000); // data Z cancelled, ancilla unchanged } @@ -1118,8 +1046,7 @@ mod tests { z_obs_bit: 1, ancilla_z_bit: 3, }, - ) - .expect("boundary observable bits < 64"); + ); assert_eq!(frame, 0b0010); // unchanged } @@ -1133,8 +1060,7 @@ mod tests { x_obs_bit: 0, z_obs_bit: 1, }, - ) - .expect("boundary observable bits < 64"); + ); assert_eq!(frame, 0b11); // Swap of (1,1) is still (1,1) } @@ -1147,8 +1073,7 @@ mod tests { x_obs_bit: 0, z_obs_bit: 1, }, - ) - .expect("boundary observable bits < 64"); + ); assert_eq!(frame, 0b01); // Z became X } diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index 3a68a335d..e34a206b5 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -57,7 +57,6 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::ObservableDecoder; use crate::dem::{DemMatchingGraph, MatchingEdge, SparseDem}; use crate::errors::DecoderError; -use crate::obs_mask::ObsMask; // ============================================================================ // Stabilizer coordinate mapping @@ -332,14 +331,21 @@ pub fn coordinate_membership_from_dem( /// /// # Errors /// -/// Supports arbitrarily many observables: flips are packed into a wide -/// [`ObsMask`] by [`ObservableDecoder::decode_obs`]. (The `u64` -/// [`ObservableDecoder::decode_to_observables`] convenience errors above 64; the -/// windowed paths still cap at 64 — see their constructors.) +/// Returns an error if `membership` has more than 64 entries: observable flips +/// are packed into a u64 mask (bit `i` = observable `i`), so any region source +/// feeding this extractor inherits the 64-observable limit. pub fn subgraphs_from_membership( sdem: &SparseDem, membership: &[Vec], ) -> Result, DecoderError> { + if membership.len() > 64 { + return Err(DecoderError::InvalidConfiguration(format!( + "LogicalSubgraphDecoder packs observable flips into a u64 mask and \ + supports at most 64 observables, but got membership for {}", + membership.len(), + ))); + } + let mut subgraphs = Vec::with_capacity(membership.len()); for (obs_idx, detectors) in membership.iter().enumerate() { @@ -594,16 +600,15 @@ impl LogicalSubgraphDecoder { pub fn decode_count_batched( &mut self, syndromes: &[Vec], - expected_masks: &[ObsMask], + expected_masks: &[u64], ) -> Result { let num_shots = syndromes.len(); if num_shots == 0 { return Ok(0); } - // Per-shot observable predictions, accumulated across subgraphs. Wide - // (`ObsMask`) so >64 observables are not truncated. - let mut shot_obs: Vec = vec![ObsMask::new(); num_shots]; + // Per-shot observable predictions, accumulated across subgraphs. + let mut shot_obs: Vec = vec![0u64; num_shots]; for (sg, dec) in self.subgraphs.iter().zip(self.decoders.iter_mut()) { let n = sg.detector_map.len(); @@ -629,7 +634,9 @@ impl LogicalSubgraphDecoder { for (shot_idx, &sub_obs) in sub_masks.iter().enumerate() { if sub_obs & 1 != 0 { - shot_obs[shot_idx].set(sg.observable_idx); + // Flip the subgraph's GLOBAL observable bit, not the list + // position: construction guarantees < 64 observables. + shot_obs[shot_idx] |= 1u64 << sg.observable_idx; } } } @@ -646,22 +653,8 @@ impl LogicalSubgraphDecoder { } impl ObservableDecoder for LogicalSubgraphDecoder { - /// Narrowing wrapper over [`Self::decode_obs`]. Errors (rather than - /// truncating) if the decoder has more than 64 observables; callers that may - /// exceed 64 should use [`ObservableDecoder::decode_obs`]. - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { - self.decode_obs(syndrome)?.to_u64().ok_or_else(|| { - DecoderError::InvalidConfiguration( - "decoder has more than 64 observables; use decode_obs() for the wide mask".into(), - ) - }) - } - - /// Decode every per-observable subgraph and pack the flips into a wide - /// [`ObsMask`], mapping each subgraph's local result to its GLOBAL observable - /// index. Supports more than 64 observables with no truncation. - fn decode_obs(&mut self, syndrome: &[u8]) -> Result { - let mut obs_mask = ObsMask::new(); + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + let mut obs_mask = 0u64; for (i, (sg, dec)) in self .subgraphs @@ -686,11 +679,13 @@ impl ObservableDecoder for LogicalSubgraphDecoder { let sub_obs = dec.decode_to_observables(&buf[..n])?; if sub_obs & 1 != 0 { - obs_mask.set(sg.observable_idx); + // Flip the subgraph's GLOBAL observable bit, not the list + // position: construction guarantees < 64 observables. + obs_mask |= 1u64 << sg.observable_idx; } } - Ok(obs_mask) + Ok(crate::obs_mask::ObsMask::from_u64(obs_mask)) } } @@ -760,19 +755,15 @@ impl ParallelLogicalSubgraphDecoder { }) .collect(); - let mut obs_mask = ObsMask::new(); + let mut obs_mask = 0u64; for (sg, result) in self.subgraphs.iter().zip(results) { if result? { - obs_mask.set(sg.observable_idx); + // Flip the subgraph's GLOBAL observable bit, not the list + // position: construction guarantees < 64 observables. + obs_mask |= 1u64 << sg.observable_idx; } } - // This convenience returns a u64; error (don't truncate) above 64. - obs_mask.to_u64().ok_or_else(|| { - DecoderError::InvalidConfiguration( - "decoder has more than 64 observables; the parallel u64 path supports at most 64" - .into(), - ) - }) + Ok(obs_mask) } } @@ -787,7 +778,7 @@ mod tests { struct NullDecoder; impl ObservableDecoder for NullDecoder { fn decode_obs(&mut self, _: &[u8]) -> Result { - Ok(crate::obs_mask::ObsMask::new()) + Ok(crate::obs_mask::ObsMask::from_u64(0)) } } @@ -800,7 +791,7 @@ mod tests { if syndrome.iter().any(|&v| v != 0) { Ok(crate::obs_mask::ObsMask::from_u64(self.0)) } else { - Ok(crate::obs_mask::ObsMask::new()) + Ok(crate::obs_mask::ObsMask::from_u64(0)) } } } @@ -912,9 +903,12 @@ mod tests { assert_eq!(sgs.len(), 1); assert_eq!(sgs[0].detector_map, vec![0, 1]); - // >64 observable membership is now SUPPORTED (wide ObsMask), not rejected. + // >64 observable membership is rejected by the extractor regardless of source. let big: Vec> = (0..65).map(|_| Vec::new()).collect(); - assert_eq!(subgraphs_from_membership(&sdem, &big).unwrap().len(), 65); + assert!(matches!( + subgraphs_from_membership(&sdem, &big), + Err(DecoderError::InvalidConfiguration(_)) + )); // An out-of-range membership detector id must error, not panic // (the DEM has 2 detectors D0,D1; detector 5 is past `inverse_map`). @@ -1056,55 +1050,18 @@ mod tests { } #[test] - fn test_more_than_64_observables_decode_wide() { - // 65 observables: construction now SUCCEEDS (no >64 reject), and the wide - // `decode_obs` / `decode_count_batched` paths represent observable 64 - // without truncation. The `u64` convenience method errors instead. + fn test_too_many_observables_errors() { + // 65 observables exceeds the u64 mask capacity. use std::fmt::Write; let mut dem = String::from("detector(1, 0, 0) D0\n"); for l in 0..65 { writeln!(dem, "error(0.01) D0 L{l}").unwrap(); } let sc = simple_stab_coords(); - - // Every per-observable subgraph flips its observable when D0 fires. - let mut dec = LogicalSubgraphDecoder::from_dem(&dem, &sc, |_| { - Ok(Box::new(FixedDecoder(1)) as Box) - }) - .unwrap(); - - // Wide decode: all 65 observables flip when D0 is set; bit 64 is present. - let wide = dec.decode_obs(&[1]).unwrap(); - assert_eq!(wide.count_ones(), 65); - assert!(wide.get(64), "observable 64 must be representable"); - assert_eq!(wide.to_u64(), None, "65 bits does not fit a u64"); - - // The u64 convenience errors rather than truncating. - assert!(matches!( - dec.decode_to_observables(&[1]), - Err(DecoderError::InvalidConfiguration(_)) - )); - - // Batch decode-count compares wide masks: an all-flip expected mask - // matches the all-flip prediction (zero errors); a narrower expected - // mask (missing observable 64) is counted as an error. - let mut all_flip = ObsMask::new(); - for l in 0..65 { - all_flip.set(l); - } - let mut missing_64 = ObsMask::new(); - for l in 0..64 { - missing_64.set(l); - } - assert_eq!( - dec.decode_count_batched(&[vec![1]], std::slice::from_ref(&all_flip)) - .unwrap(), - 0 - ); - assert_eq!( - dec.decode_count_batched(&[vec![1]], std::slice::from_ref(&missing_64)) - .unwrap(), - 1 + let err = partition_dem_by_logical(&dem, &sc).unwrap_err(); + assert!( + matches!(err, DecoderError::InvalidConfiguration(_)), + "expected InvalidConfiguration, got {err:?}" ); } diff --git a/crates/pecos-engines/src/classical.rs b/crates/pecos-engines/src/classical.rs index e906c0314..0403e7418 100644 --- a/crates/pecos-engines/src/classical.rs +++ b/crates/pecos-engines/src/classical.rs @@ -21,11 +21,10 @@ pub trait ClassicalEngine: Engine + DynClone + Send + /// Whether this engine's qubit count is only known after execution because it /// allocates qubits dynamically. /// - /// For such engines a [`Self::num_qubits`] of 0 before execution means "not - /// yet known", not "genuinely zero qubits". Static engines that parse their - /// whole program up front (e.g. QASM) know their exact count and return - /// `false` (the default); dynamic runtimes (e.g. the QIS/Selene runtime, - /// which discovers allocations during execution) return `true`. + /// For such engines, [`Self::num_qubits`] returning 0 before execution means + /// "not yet known", not "genuinely zero qubits". Static engines that parse + /// their whole program up front know their exact count and return `false` + /// (the default); dynamic runtimes return `true`. fn has_dynamic_qubit_count(&self) -> bool { false } diff --git a/crates/pecos-engines/src/sim_builder.rs b/crates/pecos-engines/src/sim_builder.rs index bcd1b9142..c2f5fc3f0 100644 --- a/crates/pecos-engines/src/sim_builder.rs +++ b/crates/pecos-engines/src/sim_builder.rs @@ -285,12 +285,9 @@ impl SimBuilder { })?; // Forward a qubit-count hint only when it is meaningful. An explicit - // count is the caller's choice and is always forwarded (even 0). But an - // inferred 0 from a dynamic classical engine (e.g. the QIS/Selene - // runtime, which reports 0 qubits until program execution discovers its - // allocations) means "unknown", not "zero qubits": freezing it would - // override the runtime's own capacity discovery and initialize the - // plugin with no qubits, so every qalloc fails. Keep that distinction. + // count is the caller's choice and is always forwarded, even 0. But an + // inferred 0 from a dynamic classical engine means "unknown", not "zero + // qubits"; forwarding it would freeze dynamic allocation at 0. match self.explicit_num_qubits { Some(explicit) => classical_engine.set_num_qubits_hint(explicit), None if num_qubits > 0 => classical_engine.set_num_qubits_hint(num_qubits), @@ -303,16 +300,9 @@ impl SimBuilder { builder.set_qubits_if_needed(num_qubits); builder.build_boxed()? } else { - // Default: fixed-size sparse stabilizer. It does NOT grow, so a - // 0-qubit default is only correct for a program that genuinely uses - // zero qubits (e.g. a classical-only QASM program, which reports a - // STATIC 0). A dynamic engine (e.g. QIS) instead reports 0 *before - // execution* and then allocates qubits at runtime; building a 0-qubit - // fixed engine for it would panic on the first allocation. Reject only - // that specific case -- no explicit count, no quantum engine, and a - // dynamic-unknown-zero classical engine -- and ask for an explicit - // `.qubits(n)` or a `.quantum(...)` engine. Genuinely-0-qubit and - // explicit-count programs are unaffected. + // Default: fixed-size sparse stabilizer. Dynamic classical engines + // may report 0 before execution, but the default quantum engine + // cannot grow to fit runtime qalloc operations. if self.explicit_num_qubits.is_none() && num_qubits == 0 && classical_engine.has_dynamic_qubit_count() diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index f24bc02dc..b13b10bf4 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -37,15 +37,14 @@ enum ExactlyOneError { } use tket::hugr::envelope::EnvelopeConfig; use tket::hugr::llvm::extension::int::IntCodegenExtension; -use tket::hugr::llvm::inkwell::AddressSpace; use tket::hugr::llvm::inkwell::OptimizationLevel; -use tket::hugr::llvm::inkwell::context::{Context, ContextRef}; +use tket::hugr::llvm::inkwell::context::Context; use tket::hugr::llvm::inkwell::module::Module; use tket::hugr::llvm::inkwell::passes::PassBuilderOptions; use tket::hugr::llvm::inkwell::targets::{ CodeModel, InitializationConfig, RelocMode, Target, TargetMachine, TargetTriple, }; -use tket::hugr::llvm::inkwell::types::FunctionType; +use tket::hugr::llvm::inkwell::values::FunctionValue; use tket::hugr::llvm::utils::fat::FatExt as _; use tket::hugr::llvm::{ CodegenExtsBuilder, @@ -75,6 +74,16 @@ const TRACE_METADATA_QUBIT_HUGR_SYMBOL: &str = "pecos_qis_trace_metadata_qubit_h const RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL: &str = "pecos_qis_runtime_barrier_qubit_hugr"; const RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL: &str = "pecos_qis_runtime_barrier_qubits2_hugr"; +const PECOS_HELPER_ABIS: &[(&str, &str)] = &[ + (TRACE_METADATA_HUGR_SYMBOL, "void (ptr, ptr)"), + (TRACE_METADATA_QUBIT_HUGR_SYMBOL, "i64 (i64, ptr, ptr)"), + (RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL, "i64 (i64)"), + ( + RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL, + "{ i64, i64 } (i64, i64)", + ), +]; + // Extension registry is defined in the parent module /// Compilation arguments @@ -90,13 +99,7 @@ pub struct CompileArgs { pub target_triple: Option, /// Optimization level pub opt_level: OptimizationLevel, - /// Target `QSystem` platform for lowering and codegen. - /// - /// PECOS targets the Quantinuum Helios QIS runtime (the Selene Helios - /// plugin), so this defaults to [`QSystemPlatform::Helios`]. Set it - /// explicitly to select another supported platform such as - /// [`QSystemPlatform::Sol`]; unsupported platforms are rejected with a - /// clear error when compilation starts. + /// Target QSystem platform pub platform: QSystemPlatform, } @@ -108,28 +111,11 @@ impl Default for CompileArgs { save_hugr: None, target_triple: None, opt_level: OptimizationLevel::Default, - // PECOS targets the Selene Helios QIS runtime by default. platform: QSystemPlatform::Helios, } } } -/// Reject `QSystem` platforms that PECOS has not wired through its QIS pipeline. -/// -/// [`QSystemPlatform`] is `#[non_exhaustive]`; fail loudly on any future variant -/// rather than silently lowering for a platform PECOS has not validated -/// end-to-end (codegen extensions + Selene runtime). -fn ensure_supported_platform(platform: QSystemPlatform) -> Result<()> { - match platform { - QSystemPlatform::Helios | QSystemPlatform::Sol => Ok(()), - other => Err(anyhow!( - "Unsupported QSystem platform {other:?}: pecos-hugr-qis supports Helios and Sol. \ - Wire a newer tket-qsystem platform through the QIS codegen and Selene runtime \ - before selecting it." - )), - } -} - /// Process HUGR by applying required passes. /// /// Note: `QSystemPass` internally calls `inline_constant_functions` when the @@ -154,7 +140,7 @@ fn codegen_extensions(platform: QSystemPlatform) -> CodegenExtsMap<'static, Hugr .add_default_static_array_extensions() .add_default_borrow_array_extensions(pcg.clone()) .add_extension(FuturesCodegenExtension) - .add_extension(QSystemCodegenExtension::new(platform, pcg.clone())) + .add_extension(QSystemCodegenExtension::from((platform, pcg.clone()))) .add_extension(RandomCodegenExtension) .add_extension(ResultsCodegenExtension::new( SeleneHeapArrayCodegen::LOWERING, @@ -358,6 +344,92 @@ fn optimize_module( Ok(()) } +fn pecos_helper_public_name(symbol: &str) -> Option<&'static str> { + if PECOS_HELPER_ABIS.iter().any(|(name, _)| *name == symbol) { + return Some(match symbol { + TRACE_METADATA_HUGR_SYMBOL => TRACE_METADATA_HUGR_SYMBOL, + TRACE_METADATA_QUBIT_HUGR_SYMBOL => TRACE_METADATA_QUBIT_HUGR_SYMBOL, + RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL => RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL, + RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL => RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL, + _ => unreachable!("checked helper symbol must be mapped"), + }); + } + + let rest = symbol.strip_prefix(HUGR_SYMBOL_PREFIX)?; + let mut parts = rest.rsplit('.'); + let _suffix = parts.next()?; + let helper_name = parts.next()?; + PECOS_HELPER_ABIS + .iter() + .find_map(|(name, _)| (*name == helper_name).then_some(*name)) +} + +fn expected_helper_abi(public_name: &str) -> &'static str { + PECOS_HELPER_ABIS + .iter() + .find_map(|(name, abi)| (*name == public_name).then_some(*abi)) + .expect("recognized helper must have ABI") +} + +fn function_abi_string(function: FunctionValue<'_>) -> String { + function.get_type().print_to_string().to_string() +} + +fn validate_pecos_helper_abi(function: FunctionValue<'_>, public_name: &str) -> Result<()> { + let actual = function_abi_string(function); + let expected = expected_helper_abi(public_name); + if actual != expected { + return Err(anyhow!( + "PECOS helper '{public_name}' has LLVM type '{actual}', but pecos-qis-ffi ABI expects '{expected}'" + )); + } + Ok(()) +} + +/// Normalize PECOS-owned helper declarations inside the LLVM module before +/// verification/bitcode emission, so both text and bitcode expose the runtime +/// ABI symbols exported by pecos-qis-ffi. +fn normalize_pecos_helper_symbols_in_module(module: &Module) -> Result<()> { + for &(public_name, _) in PECOS_HELPER_ABIS { + let candidates = module + .get_functions() + .filter_map(|function| { + let name = function.get_name().to_str().ok()?; + (pecos_helper_public_name(name) == Some(public_name)).then_some(function) + }) + .collect::>(); + + if candidates.is_empty() { + continue; + } + + for &candidate in &candidates { + validate_pecos_helper_abi(candidate, public_name)?; + } + + let canonical = candidates + .iter() + .copied() + .find(|function| function.get_name().to_str().ok() == Some(public_name)) + .unwrap_or(candidates[0]); + + if canonical.get_name().to_str().ok() != Some(public_name) { + canonical.as_global_value().set_name(public_name); + } + + for candidate in candidates { + if candidate == canonical { + continue; + } + candidate.replace_all_uses_with(canonical); + // SAFETY: all uses were rewired to the canonical declaration above. + unsafe { candidate.delete() }; + } + } + + Ok(()) +} + /// Compile the given HUGR to an LLVM module /// This function is the primary entry point for the compiler fn compile<'c, 'hugr: 'c>( @@ -365,9 +437,6 @@ fn compile<'c, 'hugr: 'c>( ctx: &'c Context, hugr: &'hugr mut Hugr, ) -> Result> { - // Fail fast before any expensive work if the platform is unsupported. - ensure_supported_platform(args.platform)?; - log::debug!("starting primary compilation"); let namer = Rc::new(Namer::new("__hugr__.", true)); @@ -380,11 +449,6 @@ fn compile<'c, 'hugr: 'c>( // Create a new LLVM module using hugr-llvm let module = get_module_with_std_exts(args, ctx, namer, hugr)?; - // Rewrite PECOS helper declarations to their public ABI symbols in the module - // itself, so both the text and bitcode outputs link against pecos-qis-ffi's - // `pecos_qis_*` exports (a text-only rewrite would miss the bitcode path). - normalize_pecos_helper_symbols_in_module(&module)?; - // Get the target machine let target_machine = if let Some(ref triple) = args.target_triple { get_target_machine_from_triple(triple, args.opt_level)? @@ -411,6 +475,8 @@ fn compile<'c, 'hugr: 'c>( .map_err(|e| anyhow!("Failed to add metadata: {e}"))?; } + normalize_pecos_helper_symbols_in_module(&module)?; + // Optimize optimize_module(&module, &target_machine, args.opt_level)?; @@ -431,134 +497,114 @@ fn compile<'c, 'hugr: 'c>( Ok(module) } -/// PECOS-owned QIS runtime ABI helpers. Guppy/HUGR lowers these under a private -/// `__hugr__.*` symbol; they must be rewritten to their stable public names so the -/// compiled module links against the `pecos_qis_*` symbols `pecos-qis-ffi` exports. -const PECOS_HELPER_SYMBOLS: &[&str] = &[ - TRACE_METADATA_HUGR_SYMBOL, - TRACE_METADATA_QUBIT_HUGR_SYMBOL, - RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL, - RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL, -]; - -/// If `symbol` is a PECOS-owned helper lowered under the private `__hugr__.*` -/// namespace, return its stable public name; otherwise `None`. -/// -/// Guppy qualifies the symbol with the defining scope (module/function, plus a -/// `` segment for function-local declarations) and tket appends a numeric -/// node id, so the helper name sits second-from-last: -/// `__hugr__...`. -fn pecos_helper_public_name<'a>(symbol: &str, helper_symbols: &[&'a str]) -> Option<&'a str> { - let rest = symbol.strip_prefix(HUGR_SYMBOL_PREFIX)?; - let mut parts = rest.rsplit('.'); - let _suffix = parts.next()?; - let helper_name = parts.next()?; - helper_symbols - .iter() - .copied() - .find(|helper| *helper == helper_name) +fn is_llvm_symbol_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '$' | '-') } -/// The fixed LLVM signature each PECOS helper must have, matching the -/// `pecos-qis-ffi` ABI export (`crates/pecos-qis-ffi/src/ffi.rs`): -/// -/// - `pecos_qis_trace_metadata_hugr(*const u8, *const u8)` -> `void (ptr, ptr)` -/// - `pecos_qis_trace_metadata_qubit_hugr(i64, *const u8, *const u8) -> i64` -/// -> `i64 (i64, ptr, ptr)` -/// - `pecos_qis_runtime_barrier_qubit_hugr(i64) -> i64` -> `i64 (i64)` -/// - `pecos_qis_runtime_barrier_qubits2_hugr(i64, i64) -> QubitPair{i64,i64}` -/// -> `{ i64, i64 } (i64, i64)` +/// Normalize PECOS-owned helper declarations that Guppy/HUGR lowers under a +/// private `__hugr__.*` symbol name. /// -/// Returns `None` only if `helper` is not a recognized PECOS helper (a programming -/// error here, since callers pass a name already matched against `PECOS_HELPER_SYMBOLS`). -fn expected_pecos_helper_type<'ctx>( - ctx: ContextRef<'ctx>, - helper: &str, -) -> Option> { - let i64t = ctx.i64_type(); - let ptr = ctx.ptr_type(AddressSpace::default()); - let ty = match helper { - TRACE_METADATA_HUGR_SYMBOL => ctx.void_type().fn_type(&[ptr.into(), ptr.into()], false), - TRACE_METADATA_QUBIT_HUGR_SYMBOL => { - i64t.fn_type(&[i64t.into(), ptr.into(), ptr.into()], false) - } - RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL => i64t.fn_type(&[i64t.into()], false), - RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL => ctx - .struct_type(&[i64t.into(), i64t.into()], false) - .fn_type(&[i64t.into(), i64t.into()], false), - _ => return None, - }; - Some(ty) -} - -/// Rewrite PECOS helper declarations from their private `__hugr__.*` symbol to the -/// stable public `pecos_qis_*` name, in the LLVM module itself. -/// -/// Renaming the `FunctionValue`s (rather than rewriting printed text) means every -/// output format -- LLVM IR text AND bitcode -- carries the public ABI symbol that -/// `pecos-qis-ffi` exports. It also sidesteps text-only quoting: a function-local -/// Guppy declaration's raw module symbol -/// (`__hugr__....pecos_qis_..._hugr.`) is unquoted here, so the -/// same `pecos_helper_public_name` match applies. Renaming a function updates all of -/// its call sites automatically (they reference the value, not the name). -/// -/// Because this code claims the public ABI symbol, it also enforces the ABI: every -/// recognized helper declaration is validated against its fixed `pecos-qis-ffi` -/// signature. LLVM 21 opaque pointers let a call disagree with its callee declaration's -/// type without failing `module.verify()`, so a helper declared with the wrong -/// signature -- even a lone, self-consistent one -- would otherwise silently emit a -/// call to the public symbol with the wrong ABI. We fail loud instead. This also makes -/// duplicate declarations safe to merge: any two that pass the ABI check are identical, -/// so the duplicate's uses can be redirected to the first (avoiding LLVM uniquifying a -/// second declaration to `.1`, which `pecos-qis-ffi` does not export). -fn normalize_pecos_helper_symbols_in_module(module: &Module<'_>) -> Result<()> { - let ctx = module.get_context(); - // Collect first: we rename and (on collision) delete functions below, so we must - // not hold the module's function iterator while mutating the function list. - let funcs: Vec<_> = module.get_functions().collect(); - for func in funcs { - let Some(public) = func - .get_name() - .to_str() - .ok() - .and_then(|name| pecos_helper_public_name(name, PECOS_HELPER_SYMBOLS)) - else { +/// These helpers are part of PECOS's runtime ABI, not ordinary user functions, +/// so they need stable external symbols for dynamic linking. Keep this rewrite +/// deliberately narrow: only PECOS-owned QIS helper declarations receive this +/// treatment. +fn normalize_pecos_helper_symbols_in_llvm(llvm_ir: String) -> String { + let helper_symbols = [ + TRACE_METADATA_HUGR_SYMBOL, + TRACE_METADATA_QUBIT_HUGR_SYMBOL, + RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL, + RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL, + ]; + let mut normalized = String::with_capacity(llvm_ir.len()); + let mut cursor = 0; + + while let Some(relative_start) = llvm_ir[cursor..].find('@') { + let start = cursor + relative_start; + normalized.push_str(&llvm_ir[cursor..start]); + + let symbol_start = start + 1; + let symbol_len = llvm_ir[symbol_start..] + .chars() + .take_while(|ch| is_llvm_symbol_char(*ch)) + .map(char::len_utf8) + .sum::(); + if symbol_len == 0 { + normalized.push('@'); + cursor = symbol_start; continue; - }; - - // Enforce the fixed PECOS helper ABI on every recognized declaration before - // claiming the public symbol. - let Some(expected) = expected_pecos_helper_type(ctx, public) else { - return Err(anyhow!( - "internal error: no ABI signature registered for PECOS helper `{public}`" - )); - }; - if func.get_type() != expected { - return Err(anyhow!( - "PECOS helper `{public}` is declared as `{}`, but the pecos-qis-ffi ABI is \ - `{}`. Declare it with the exported signature.", - func.get_type(), - expected, - )); } - match module.get_function(public) { - // The public ABI symbol is already owned by another declaration. Both passed - // the ABI check above, so they are identical: fold this duplicate into it so - // the module keeps a single unsuffixed public symbol. - Some(canonical) if canonical != func => { - func.replace_all_uses_with(canonical); - // SAFETY: all uses were just redirected to `canonical`, so this - // declaration is now unreferenced and safe to remove. - unsafe { func.delete() }; + let symbol_end = symbol_start + symbol_len; + let symbol = &llvm_ir[symbol_start..symbol_end]; + if let Some(rest) = symbol.strip_prefix(HUGR_SYMBOL_PREFIX) { + let mut parts = rest.rsplit('.'); + let suffix = parts.next(); + let helper_name = parts.next(); + if let (Some(_suffix), Some(helper_name)) = (suffix, helper_name) { + if helper_symbols.iter().any(|helper| helper == &helper_name) { + normalized.push('@'); + normalized.push_str(helper_name); + cursor = symbol_end; + continue; + } } - // Already named with the public symbol (idempotent). - Some(_) => {} - // First occurrence: claim the public name. - None => func.as_global_value().set_name(public), } + + normalized.push('@'); + normalized.push_str(symbol); + cursor = symbol_end; + } + + normalized.push_str(&llvm_ir[cursor..]); + normalized +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_trace_metadata_helper_symbol() { + let llvm = concat!( + "declare void @__hugr__.pecos_qis_trace_metadata_hugr.16(i8*, i8*)\n", + "call void @__hugr__.pecos_qis_trace_metadata_hugr.16(i8* %0, i8* %1)\n", + "call void @__hugr__.__main__.pecos_qis_trace_metadata_hugr.18(i8* %0, i8* %1)\n", + "declare i64 @__hugr__.pecos_qis_trace_metadata_qubit_hugr.19(i64, i8*, i8*)\n", + "%q = call i64 @__hugr__.pecos_qis_trace_metadata_qubit_hugr.19(i64 %0, i8* %1, i8* %2)\n", + "%q2 = call i64 @__hugr__.__main__.pecos_qis_trace_metadata_qubit_hugr.21(i64 %0, i8* %1, i8* %2)\n", + "%q3 = call i64 @__hugr__.pecos_qis_runtime_barrier_qubit_hugr.22(i64 %0)\n", + "%q4 = call i64 @__hugr__.__main__.pecos_qis_runtime_barrier_qubit_hugr.23(i64 %0)\n", + "%q5 = call { i64, i64 } @__hugr__.pecos_qis_runtime_barrier_qubits2_hugr.24(i64 %0, i64 %1)\n", + "%q6 = call { i64, i64 } @__hugr__.__main__.pecos_qis_runtime_barrier_qubits2_hugr.25(i64 %0, i64 %1)\n", + "call void @__hugr__.other_helper.16()\n", + ) + .to_string(); + let normalized = normalize_pecos_helper_symbols_in_llvm(llvm); + assert!(normalized.contains("declare void @pecos_qis_trace_metadata_hugr(i8*, i8*)")); + assert!(normalized.contains("call void @pecos_qis_trace_metadata_hugr(i8* %0, i8* %1)")); + assert!( + normalized.contains("declare i64 @pecos_qis_trace_metadata_qubit_hugr(i64, i8*, i8*)") + ); + assert!(normalized.contains( + "%q = call i64 @pecos_qis_trace_metadata_qubit_hugr(i64 %0, i8* %1, i8* %2)" + )); + assert!(normalized.contains( + "%q2 = call i64 @pecos_qis_trace_metadata_qubit_hugr(i64 %0, i8* %1, i8* %2)" + )); + assert!( + normalized.contains("%q3 = call i64 @pecos_qis_runtime_barrier_qubit_hugr(i64 %0)") + ); + assert!( + normalized.contains("%q4 = call i64 @pecos_qis_runtime_barrier_qubit_hugr(i64 %0)") + ); + assert!(normalized.contains( + "%q5 = call { i64, i64 } @pecos_qis_runtime_barrier_qubits2_hugr(i64 %0, i64 %1)" + )); + assert!(normalized.contains( + "%q6 = call { i64, i64 } @pecos_qis_runtime_barrier_qubits2_hugr(i64 %0, i64 %1)" + )); + assert!(normalized.contains("@__hugr__.other_helper.16")); } - Ok(()) } /// Compile HUGR bytes to LLVM IR string @@ -592,9 +638,9 @@ pub fn compile_hugr_bytes_to_string_with_options( let module = compile(args, &context, &mut hugr) .map_err(|e| PecosError::Generic(format!("Compilation failed: {e}")))?; - // Get the module string (PECOS helper symbols are already normalized to their - // public names in `compile`). + // Get the module string let mut llvm_str = module.to_string(); + llvm_str = normalize_pecos_helper_symbols_in_llvm(llvm_str); // Workaround: Manually add the EntryPoint attribute if it's missing // This is needed because inkwell sometimes doesn't properly serialize string attributes @@ -667,70 +713,7 @@ pub fn compile_hugr_bytes_to_bitcode_with_options( let module = compile(args, &context, &mut hugr) .map_err(|e| PecosError::Generic(format!("Compilation failed: {e}")))?; - // Write to memory buffer and get bitcode. `as_slice()` includes LLVM's - // trailing C-string NUL, which is not part of the bitcode stream. + // Write to memory buffer and get bitcode let buffer = module.write_bitcode_to_memory(); - let bitcode = buffer.as_slice(); - Ok(bitcode[..bitcode.len().saturating_sub(1)].to_vec()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn pecos_helper_public_name_matches_private_helper_symbols() { - // Bare, module-level declaration. - assert_eq!( - pecos_helper_public_name( - "__hugr__.pecos_qis_trace_metadata_hugr.16", - PECOS_HELPER_SYMBOLS, - ), - Some("pecos_qis_trace_metadata_hugr"), - ); - // Module-qualified (e.g. `__main__`) declaration. - assert_eq!( - pecos_helper_public_name( - "__hugr__.__main__.pecos_qis_trace_metadata_qubit_hugr.21", - PECOS_HELPER_SYMBOLS, - ), - Some("pecos_qis_trace_metadata_qubit_hugr"), - ); - // Function-local declaration: the raw module symbol carries a `` - // segment (this is the case guppylang 0.21.11 produces; in LLVM text it is - // additionally quoted, but the module symbol seen here is unquoted). - assert_eq!( - pecos_helper_public_name( - "__hugr__.test_mod.test_fn..pecos_qis_runtime_barrier_qubits2_hugr.23", - PECOS_HELPER_SYMBOLS, - ), - Some("pecos_qis_runtime_barrier_qubits2_hugr"), - ); - assert_eq!( - pecos_helper_public_name( - "__hugr__.m.f..pecos_qis_runtime_barrier_qubit_hugr.7", - PECOS_HELPER_SYMBOLS, - ), - Some("pecos_qis_runtime_barrier_qubit_hugr"), - ); - } - - #[test] - fn pecos_helper_public_name_ignores_non_helpers() { - // A non-PECOS helper under the private prefix is left alone. - assert_eq!( - pecos_helper_public_name("__hugr__.m.f..other_helper.9", PECOS_HELPER_SYMBOLS), - None, - ); - // A symbol that is not under the private prefix is not a candidate. - assert_eq!( - pecos_helper_public_name("pecos_qis_trace_metadata_hugr", PECOS_HELPER_SYMBOLS), - None, - ); - // Too few components to carry a `.` tail. - assert_eq!( - pecos_helper_public_name("__hugr__.foo", PECOS_HELPER_SYMBOLS), - None, - ); - } + Ok(buffer.as_slice().to_vec()) } diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 2d70667c6..81d38f1d2 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -751,12 +751,6 @@ impl<'a> DemBuilder<'a> { /// /// This does **not** validate metadata refs; callers ingesting /// circuit-derived metadata must use [`Self::try_build`] instead. - /// - /// # Panics - /// - /// Panics if the configured replacement-branch approximation or measurement - /// crosstalk DEM mode is invalid. Use [`Self::try_build`] to receive those as - /// errors instead of panicking. #[must_use] pub fn build(&self) -> DetectorErrorModel { self.validate_replacement_branch_approximation() @@ -834,7 +828,7 @@ impl<'a> DemBuilder<'a> { .noise .p2_weights .as_ref() - .is_some_and(super::types::PauliWeights::has_replacement_entries); + .is_some_and(|weights| weights.has_replacement_entries()); if self.noise.p2_replacement_approximation == ReplacementBranchApproximation::ExactBranchReplay && has_replacement_branches @@ -948,7 +942,7 @@ impl<'a> DemBuilder<'a> { { continue; } - let result = Self::hidden_mz_result_before_crosstalk_payload(context, loc)?; + let result = self.hidden_mz_result_before_crosstalk_payload(context, loc)?; if !result.is_deterministic || !result.outcome.is_empty() { return Err(DemBuilderError::ConfigurationError(format!( "exact deterministic measurement crosstalk DEM replay requires a state-independent hidden MZ result at location {loc_idx} (node {}, qubit {:?}); got deterministic={}, dependencies={:?}", @@ -964,6 +958,7 @@ impl<'a> DemBuilder<'a> { } fn hidden_mz_result_before_crosstalk_payload( + &self, context: ExactBranchReplayContext<'_>, loc: &DagSpacetimeLocation, ) -> Result { @@ -1140,7 +1135,7 @@ impl<'a> DemBuilder<'a> { }; let pairs = || -> Result, DemBuilderError> { require(2)?; - if !qubits.len().is_multiple_of(2) { + if qubits.len() % 2 != 0 { return Err(DemBuilderError::ConfigurationError(format!( "measurement crosstalk replay expected gate {:?} at node {} to have an even number of qubits, got {}", gate_type, @@ -1253,7 +1248,8 @@ impl<'a> DemBuilder<'a> { | GateType::TrackedPauliMeta => {} _ => { return Err(DemBuilderError::ConfigurationError(format!( - "measurement crosstalk exact deterministic replay does not support gate {gate_type:?} before payload node {node}" + "measurement crosstalk exact deterministic replay does not support gate {:?} before payload node {}", + gate_type, node ))); } } @@ -1613,7 +1609,7 @@ impl<'a> DemBuilder<'a> { continue; }; let prob = self.noise.p2 * impact.relative_probability; - Self::add_two_qubit_pauli_contribution( + self.add_two_qubit_pauli_contribution( loc1, loc2, p1, @@ -1738,7 +1734,8 @@ impl<'a> DemBuilder<'a> { "measurement crosstalk exact deterministic mode was validated with context", ); let loc = &self.influence_map.locations[loc_idx]; - let hidden = Self::hidden_mz_result_before_crosstalk_payload(context, loc) + let hidden = self + .hidden_mz_result_before_crosstalk_payload(context, loc) .expect( "measurement crosstalk exact deterministic hidden result was validated", ); @@ -1957,7 +1954,7 @@ impl<'a> DemBuilder<'a> { if prob == 0.0 { continue; } - Self::add_two_qubit_pauli_contribution( + self.add_two_qubit_pauli_contribution( loc1, loc2, p1, p2, prob, &effects, loc1_meta, loc2_meta, dem, None, ); } @@ -2056,6 +2053,7 @@ impl<'a> DemBuilder<'a> { #[allow(clippy::too_many_arguments)] fn add_two_qubit_pauli_contribution( + &self, loc1: usize, loc2: usize, p1: u8, @@ -3108,7 +3106,7 @@ fn two_qubit_after_location_pairs(locations: &[DagSpacetimeLocation]) -> Vec<[us impl ExactBranchReplayContext<'_> { fn replacement_branch_requests( - self, + &self, locations: &[DagSpacetimeLocation], ) -> Result, DemBuilderError> { let mut requests = Vec::new(); @@ -3155,7 +3153,7 @@ impl ExactBranchReplayContext<'_> { #[cfg(test)] fn omitted_branch_location_pair( - self, + &self, request: ExactBranchReplayRequest, original_locations: &[DagSpacetimeLocation], ) -> Result<[usize; 2], DemBuilderError> { @@ -3194,7 +3192,8 @@ fn measurement_parity_differs_from_histories( let branch = measurement_parity_expression(branch_history, measurement_indices, "branch")?; if ideal.dependencies != branch.dependencies { return Err(DemBuilderError::ConfigurationError(format!( - "{context} changes measurement dependencies for parity {measurement_indices:?}; this branch is not representable as a single deterministic DEM event" + "{context} changes measurement dependencies for parity {:?}; this branch is not representable as a single deterministic DEM event", + measurement_indices ))); } Ok(ideal.flip ^ branch.flip) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs index 8f90260c1..637f60cf4 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs @@ -1917,8 +1917,8 @@ impl<'a> SamplingEngineBuilder<'a> { self.p2_gate_rates = noise.p2_gate_rates.clone(); self.p_meas = noise.p_meas; self.p_prep = noise.p_prep; - self.p1_weights.clone_from(&noise.p1_weights); - self.p2_weights.clone_from(&noise.p2_weights); + self.p1_weights = noise.p1_weights.clone(); + self.p2_weights = noise.p2_weights.clone(); self.p2_replacement_approximation = noise.p2_replacement_approximation; self.idle_noise = noise.uses_dedicated_idle_noise().then_some(noise); self @@ -2012,7 +2012,7 @@ impl<'a> SamplingEngineBuilder<'a> { && self .p2_weights .as_ref() - .is_some_and(super::types::PauliWeights::has_replacement_entries) + .is_some_and(|weights| weights.has_replacement_entries()) { panic!( "exact_branch_replay for starred p2 replacement branches requires a circuit-aware exact branch provider; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations" diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index 703f6fb04..ad2c2a58d 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -247,6 +247,10 @@ impl<'a> DirectSourceComponents<'a> { components: components.iter().collect(), } } + + fn as_slice(&self) -> &[&'a FaultMechanism] { + &self.components + } } impl FaultContribution { @@ -345,9 +349,6 @@ impl FaultContribution { debug_assert_eq!(source.location_indices.len(), source.paulis.len()); debug_assert_eq!(source.location_indices.len(), source.gate_types.len()); debug_assert_eq!(source.location_indices.len(), source.before_flags.len()); - // This constructor takes ownership of its inputs (like `effect`/`source`); - // unpack the component view into the underlying slice of references. - let DirectSourceComponents { components } = components; let component_refs = components.as_slice(); let non_empty_components: SmallVec<[&FaultMechanism; 4]> = component_refs .iter() @@ -1140,6 +1141,7 @@ impl GraphlikeDecompositionIndex { continue; } match candidate.detectors.as_slice() { + [] => {} [det] => { Self::add_path_edge( &mut path_adjacency, @@ -1176,7 +1178,7 @@ impl GraphlikeDecompositionIndex { }); } Self { - graphlike_set, + graphlike_set: graphlike_set.clone(), primitive_graphlike_set, derived_origins, candidates_by_detector, @@ -1486,7 +1488,7 @@ impl GraphlikeDecompositionIndex { .entry(outputs.clone()) .and_modify(|existing| { if prefer_graph_path_parts(&parts, existing) { - existing.clone_from(&parts); + *existing = parts.clone(); } }) .or_insert_with(|| parts.clone()); @@ -3641,111 +3643,147 @@ impl PerGateTypeNoise { } self.rate_2q(gate, pair_idx) } -} - -/// Permutation from [`PAULI_2Q_ORDER`] indices to pecos-neo's -/// `TWO_QUBIT_PAULIS` ordering, derived from the two canonical constants -/// so it cannot silently drift. -#[cfg(feature = "neo")] -fn qec_to_neo_2q_rates(qec_rates: &[f64; 15]) -> [f64; 15] { - fn pauli_label(gate: pecos_neo::command::GateType) -> &'static str { - match gate { - pecos_neo::command::GateType::I => "I", - pecos_neo::command::GateType::X => "X", - pecos_neo::command::GateType::Y => "Y", - pecos_neo::command::GateType::Z => "Z", - other => unreachable!("TWO_QUBIT_PAULIS contains only Paulis, got {other:?}"), - } - } - let mut neo_rates = [0.0; 15]; - for (neo_idx, &(first, second)) in pecos_neo::noise::TWO_QUBIT_PAULIS.iter().enumerate() { - let label = format!("{}{}", pauli_label(first), pauli_label(second)); - let qec_idx = PAULI_2Q_ORDER - .iter() - .position(|&entry| entry == label) - .expect("every non-identity Pauli pair appears in PAULI_2Q_ORDER"); - neo_rates[neo_idx] = qec_rates[qec_idx]; - } - neo_rates -} - -#[cfg(feature = "neo")] -impl PerGateTypeNoise { - /// Convert to a pecos-neo [`PerGatePauliChannel`] so circuit-level - /// Monte Carlo can run the same noise that drives DEM generation. - /// - /// Gate types translate via the canonical `From` impl between the two - /// `GateType` enums; two-qubit rate arrays are permuted from - /// [`PAULI_2Q_ORDER`] into neo's `TWO_QUBIT_PAULIS` ordering. + /// Convert this QEC per-gate noise model into the event-driven NEO + /// [`pecos_neo::noise::PerGatePauliChannel`]. /// - /// The neo stack models idle noise through its `IdleChannel`/time - /// events, not gate events, so idle noise cannot be carried by this - /// conversion. Rather than silently dropping noise that the DEM built - /// from the same configuration WOULD include, any idle configuration - /// is rejected: compose a neo `IdleChannel` explicitly and zero the - /// idle settings here. - /// - /// # Panics - /// - /// Panics if the configuration carries idle noise in any form: - /// `Idle` entries in the per-gate or per-qubit rate maps, or a base - /// `NoiseConfig` with nonzero `p_idle`/`idle_rz` or `t1`/`t2` set. - /// - /// [`PerGatePauliChannel`]: pecos_neo::noise::PerGatePauliChannel + /// Idle noise is intentionally rejected here: this Pauli channel represents + /// gate/prep/measurement faults and cannot carry duration-dependent idle + /// models without changing the simulated physics. + #[cfg(feature = "neo")] #[must_use] pub fn to_neo_channel(&self) -> pecos_neo::noise::PerGatePauliChannel { - use pecos_neo::command::GateType as NeoGateType; - - let has_idle_entries = self.rates_1q.contains_key(&GateType::Idle) - || self - .rates_1q_per_qubit - .keys() - .any(|&(gate, _)| gate == GateType::Idle); assert!( - !has_idle_entries - && self.base.p_idle == 0.0 - && self.base.idle_rz == 0.0 - && self.base.t1.is_none() - && self.base.t2.is_none(), - "PerGateTypeNoise::to_neo_channel cannot carry idle noise (the neo stack models \ - idle through IdleChannel/time events, not gate events); the DEM built from this \ - configuration would include idle contributions, so converting would silently \ - change the physics. Zero p_idle/idle_rz/t1/t2 and remove Idle rate entries, then \ - compose a neo IdleChannel explicitly if idle noise is needed." + !self.base.uses_dedicated_idle_noise() + && !self.rates_1q.contains_key(&GateType::Idle) + && !self + .rates_1q_per_qubit + .keys() + .any(|(gate, _)| *gate == GateType::Idle), + "PerGateTypeNoise::to_neo_channel cannot carry idle noise; compose an idle channel separately" ); let mut channel = pecos_neo::noise::PerGatePauliChannel::new() .with_base(self.base.p1, self.base.p2) .with_meas_init(self.p_meas, self.p_init); - for (&gate, &rates) in &self.rates_1q { - channel = channel.with_1q_rates(NeoGateType::from(gate), rates); + for (&gate, &total) in &self.base.p1_gate_rates { + if self.rates_1q.contains_key(&gate) { + continue; + } + channel = channel.with_1q_rates(core_gate_to_neo(gate), [total / 3.0; 3]); } - for (&gate, &rates) in &self.rates_2q { - channel = channel.with_2q_rates(NeoGateType::from(gate), qec_to_neo_2q_rates(&rates)); + for (&gate, &rates) in &self.rates_1q { + channel = channel.with_1q_rates(core_gate_to_neo(gate), rates); } for (&(gate, qubit), &rates) in &self.rates_1q_per_qubit { - channel = channel.with_1q_rates_for_qubit(NeoGateType::from(gate), qubit, rates); + channel = channel.with_1q_rates_for_qubit(core_gate_to_neo(gate), qubit, rates); + } + + for (&gate, &total) in &self.base.p2_gate_rates { + if self.rates_2q.contains_key(&gate) { + continue; + } + channel = channel.with_2q_rates( + core_gate_to_neo(gate), + qec_2q_rates_to_neo([total / 15.0; 15]), + ); + } + for (&gate, &rates) in &self.rates_2q { + channel = channel.with_2q_rates(core_gate_to_neo(gate), qec_2q_rates_to_neo(rates)); } - for (&(gate, q_control, q_target), &rates) in &self.rates_2q_per_qubits { + for (&(gate, first, second), &rates) in &self.rates_2q_per_qubits { channel = channel.with_2q_rates_for_qubits( - NeoGateType::from(gate), - q_control, - q_target, - qec_to_neo_2q_rates(&rates), + core_gate_to_neo(gate), + first, + second, + qec_2q_rates_to_neo(rates), ); } + for (&qubit, &p) in &self.measurement_rates { channel = channel.with_meas_rate_for_qubit(qubit, p); } for (&qubit, &p) in &self.init_rates { channel = channel.with_init_rate_for_qubit(qubit, p); } + channel } } +#[cfg(feature = "neo")] +fn core_gate_to_neo(gate: GateType) -> pecos_neo::GateType { + match gate { + GateType::I => pecos_neo::GateType::I, + GateType::X => pecos_neo::GateType::X, + GateType::Y => pecos_neo::GateType::Y, + GateType::Z => pecos_neo::GateType::Z, + GateType::SX => pecos_neo::GateType::SX, + GateType::SXdg => pecos_neo::GateType::SXdg, + GateType::SY => pecos_neo::GateType::SY, + GateType::SYdg => pecos_neo::GateType::SYdg, + GateType::SZ => pecos_neo::GateType::SZ, + GateType::SZdg => pecos_neo::GateType::SZdg, + GateType::H => pecos_neo::GateType::H, + GateType::F => pecos_neo::GateType::F, + GateType::Fdg => pecos_neo::GateType::Fdg, + GateType::RX => pecos_neo::GateType::RX, + GateType::RY => pecos_neo::GateType::RY, + GateType::RZ => pecos_neo::GateType::RZ, + GateType::T => pecos_neo::GateType::T, + GateType::Tdg => pecos_neo::GateType::Tdg, + GateType::U => pecos_neo::GateType::U, + GateType::R1XY => pecos_neo::GateType::R1XY, + GateType::CX => pecos_neo::GateType::CX, + GateType::CY => pecos_neo::GateType::CY, + GateType::CZ => pecos_neo::GateType::CZ, + GateType::SXX => pecos_neo::GateType::SXX, + GateType::SXXdg => pecos_neo::GateType::SXXdg, + GateType::SYY => pecos_neo::GateType::SYY, + GateType::SYYdg => pecos_neo::GateType::SYYdg, + GateType::SZZ => pecos_neo::GateType::SZZ, + GateType::SZZdg => pecos_neo::GateType::SZZdg, + GateType::SWAP => pecos_neo::GateType::SWAP, + GateType::CRZ => pecos_neo::GateType::CRZ, + GateType::RXX => pecos_neo::GateType::RXX, + GateType::RYY => pecos_neo::GateType::RYY, + GateType::RZZ => pecos_neo::GateType::RZZ, + GateType::CCX => pecos_neo::GateType::CCX, + GateType::MZ => pecos_neo::GateType::MZ, + GateType::MeasureLeaked => pecos_neo::GateType::MeasureLeaked, + GateType::MeasureFree => pecos_neo::GateType::MeasureFree, + GateType::PZ => pecos_neo::GateType::PZ, + GateType::QAlloc => pecos_neo::GateType::QAlloc, + GateType::QFree => pecos_neo::GateType::QFree, + GateType::Idle => pecos_neo::GateType::Idle, + unsupported => { + panic!("unsupported gate type for NEO per-gate noise conversion: {unsupported:?}") + } + } +} + +#[cfg(feature = "neo")] +fn qec_2q_rates_to_neo(qec: [f64; 15]) -> [f64; 15] { + [ + qec[3], // XI + qec[7], // YI + qec[11], // ZI + qec[0], // IX + qec[1], // IY + qec[2], // IZ + qec[4], // XX + qec[5], // XY + qec[6], // XZ + qec[8], // YX + qec[9], // YY + qec[10], // YZ + qec[12], // ZX + qec[13], // ZY + qec[14], // ZZ + ] +} + // ============================================================================ // Measurement Noise Model (MNM) // ============================================================================ @@ -4078,10 +4116,6 @@ pub type MechanismTuple = (f64, Vec, Vec); /// Detector-coordinate tuple: `(detector_id, coordinates)`. pub type DetectorCoordinateTuple = (u32, Vec); -/// A coordinate-distance matching solution: -/// `(total_cost, paired_terminals, unpaired_singletons)`. -type CoordinateMatchSolution = (f64, Vec<(u32, u32)>, Vec); - impl DetectorErrorModel { /// Creates a new empty DEM. #[must_use] @@ -5162,12 +5196,16 @@ impl DetectorErrorModel { detectors: &[u32], detector_coords: &BTreeMap, ) -> (Vec<(u32, u32)>, Vec) { + if detectors.len() > 20 { + return Self::greedy_coordinate_terminal_pairs(detectors, detector_coords); + } + fn solve( mask: u64, detectors: &[u32], detector_coords: &BTreeMap, - memo: &mut BTreeMap, - ) -> CoordinateMatchSolution { + memo: &mut BTreeMap, Vec)>, + ) -> (f64, Vec<(u32, u32)>, Vec) { if let Some(cached) = memo.get(&mask) { return cached.clone(); } @@ -5179,7 +5217,7 @@ impl DetectorErrorModel { let index = mask.trailing_zeros() as usize; (0.0, Vec::new(), vec![detectors[index]]) } else if count % 2 == 1 { - let mut best: Option = None; + let mut best: Option<(f64, Vec<(u32, u32)>, Vec)> = None; for index in 0..detectors.len() { if mask & (1_u64 << index) == 0 { continue; @@ -5198,7 +5236,7 @@ impl DetectorErrorModel { } else { let first = mask.trailing_zeros() as usize; let rest_without_first = mask & !(1_u64 << first); - let mut best: Option = None; + let mut best: Option<(f64, Vec<(u32, u32)>, Vec)> = None; for second in first + 1..detectors.len() { if rest_without_first & (1_u64 << second) == 0 { continue; @@ -5228,10 +5266,6 @@ impl DetectorErrorModel { result } - if detectors.len() > 20 { - return Self::greedy_coordinate_terminal_pairs(detectors, detector_coords); - } - let mut memo = BTreeMap::new(); let mask = (1_u64 << detectors.len()) - 1; let (_, pairs, singles) = solve(mask, detectors, detector_coords, &mut memo); @@ -5321,7 +5355,7 @@ impl DetectorErrorModel { let last = parts .last_mut() .expect("non-empty parts checked before attaching observables"); - last.dem_outputs.clone_from(&effect.dem_outputs); + last.dem_outputs = effect.dem_outputs.clone(); } parts @@ -5438,16 +5472,16 @@ impl DetectorErrorModel { return Self::maybe_maximally_decompose_parts(vec![effect.clone()], singleton_set); } - if let Some(index) = source_graphlike_index - && let Some(parts) = index.find_hyperedge_decomposition_with_remnants_for_origin_cached( + if let Some(index) = source_graphlike_index { + if let Some(parts) = index.find_hyperedge_decomposition_with_remnants_for_origin_cached( effect, Some(effect), source_graphlike_path_cache, - ) - { - let parts = Self::maybe_maximally_decompose_parts(parts, singleton_set); - if parts_are_detectable_graphlike(&parts) { - return parts; + ) { + let parts = Self::maybe_maximally_decompose_parts(parts, singleton_set); + if parts_are_detectable_graphlike(&parts) { + return parts; + } } } @@ -5641,11 +5675,6 @@ impl DetectorErrorModel { .join(" ^ ") } - // Irreducible rendering inputs: the contribution plus several read-only - // decomposition indices/policies and two distinct `&mut` caches (path-search - // and render). A params struct would only relocate the same set behind a - // lifetime-laden wrapper without making any call site clearer. - #[allow(clippy::too_many_arguments)] fn contribution_render_details( contrib: &FaultContribution, graphlike_index: &GraphlikeDecompositionIndex, @@ -5912,9 +5941,6 @@ impl DetectorErrorModel { (targets, strategy, recorded_component_targets) } - // Thin forwarder to `contribution_render_details`; carries the same - // irreducible parameter set (see that method's note). - #[allow(clippy::too_many_arguments)] fn contribution_targets( contrib: &FaultContribution, graphlike_index: &GraphlikeDecompositionIndex, @@ -6073,7 +6099,7 @@ impl DetectorErrorModel { .and_modify(|p| *p = combine_independent_probs(*p, contrib.probability)) .or_insert(contrib.probability); } - if profile_enabled && rendered_contribs.is_multiple_of(5000) { + if profile_enabled && rendered_contribs % 5000 == 0 { let now = std::time::Instant::now(); eprintln!( "[pecos-dem-render] rendered_contributions={} render_cache={} path_cache={} target_buckets={} total={:.3}s", diff --git a/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs b/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs index dc1b4e873..f2d7f5213 100644 --- a/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs +++ b/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs @@ -29,10 +29,6 @@ use thiserror::Error; type MeasurementRecordMap = BTreeMap>; -/// Per-shot detector and observable XOR patterns: `(det_xor, obs_xor)`, where -/// row `i` of each is the mask-induced frame flip for shot `i`. -type DetectorObservableXor = (Vec>, Vec>); - /// Errors returned while building or applying a Pauli-frame lookup. #[derive(Debug, Error, Clone, PartialEq, Eq)] pub enum PauliFrameLookupError { @@ -155,7 +151,7 @@ impl PauliFrameLookup { } let tracked: Vec<(&pecos_quantum::PauliAnnotation, usize)> = tracked_annotations.into_iter().zip(meta_nodes).collect(); - if !tracked.len().is_multiple_of(3) { + if tracked.len() % 3 != 0 { return Err(PauliFrameLookupError::NonTripletTrackedPaulis { num_tracked_paulis: tracked.len(), }); @@ -177,9 +173,9 @@ impl PauliFrameLookup { let mut observable_rows = Vec::with_capacity(tracked.len()); for (ann, meta_node) in &tracked { - if dag + if !dag .gate(*meta_node) - .is_none_or(|gate| gate.gate_type != GateType::TrackedPauliMeta) + .is_some_and(|gate| gate.gate_type == GateType::TrackedPauliMeta) { return Err(PauliFrameLookupError::MetaNodeNotTrackedPauliMeta { meta_node: *meta_node, @@ -288,7 +284,7 @@ impl PauliFrameLookup { masks: &[u8], rows: usize, cols: usize, - ) -> Result { + ) -> Result<(Vec>, Vec>), PauliFrameLookupError> { let mut det_xor = vec![vec![false; self.num_detectors]; rows]; let mut obs_xor = vec![vec![false; self.num_observables]; rows]; self.apply_mask_values(masks, rows, cols, &mut det_xor, &mut obs_xor)?; diff --git a/crates/pecos-qec/tests/idle_noise_tests.rs b/crates/pecos-qec/tests/idle_noise_tests.rs index 7f994a40c..52fa340cb 100644 --- a/crates/pecos-qec/tests/idle_noise_tests.rs +++ b/crates/pecos-qec/tests/idle_noise_tests.rs @@ -221,10 +221,6 @@ fn linear_memory_z_noise_uses_idle_duration_in_dem() { ); } -// px and py must be *exactly* zero for these Z-only memory models: the X/Y idle -// rates are 0, so the composed channel introduces no X/Y probability. An epsilon -// check would weaken that invariant, so compare against the exact 0.0 constant. -#[allow(clippy::float_cmp)] #[test] fn idle_memory_pauli_probabilities_match_linear_and_quadratic_model() { let linear = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) @@ -250,10 +246,6 @@ fn idle_memory_pauli_probabilities_match_linear_and_quadratic_model() { assert!((pauli.pz - 0.06).abs() < 1e-15); } -// px and py must be *exactly* zero for this Z-only sine model: the X/Y idle rates -// are 0, so no X/Y probability is introduced. An epsilon check would weaken that -// invariant, so compare against the exact 0.0 constant. -#[allow(clippy::float_cmp)] #[test] fn idle_memory_pauli_probabilities_support_quadratic_sine_model() { let z_sine = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index f40c65ac1..0b53ca3a1 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -55,11 +55,12 @@ unsafe fn read_tket_string_arg( // The pointer references the length byte, so skip it to read the payload. let data_ptr = unsafe { ptr.add(1) }; let bytes = unsafe { std::slice::from_raw_parts(data_ptr, len) }; - if let Ok(value) = std::str::from_utf8(bytes) { - Some(value.to_string()) - } else { - log::error!("{func_name}: invalid UTF-8 in {arg_name}"); - None + match std::str::from_utf8(bytes) { + Ok(value) => Some(value.to_string()), + Err(_) => { + log::error!("{func_name}: invalid UTF-8 in {arg_name}"); + None + } } } @@ -79,11 +80,12 @@ unsafe fn read_direct_string_arg( } let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }; - if let Ok(value) = std::str::from_utf8(bytes) { - Some(value.to_string()) - } else { - log::error!("{func_name}: invalid UTF-8 in {arg_name}"); - None + match std::str::from_utf8(bytes) { + Ok(value) => Some(value.to_string()), + Err(_) => { + log::error!("{func_name}: invalid UTF-8 in {arg_name}"); + None + } } } @@ -1605,9 +1607,9 @@ mod tests { unsafe { pecos_qis_trace_metadata_direct( key.as_ptr(), - i64::try_from(key.len()).expect("test key length fits in i64"), + key.len() as i64, value.as_ptr(), - i64::try_from(value.len()).expect("test value length fits in i64"), + value.len() as i64, ); } @@ -1706,16 +1708,11 @@ mod tests { fn test_trace_metadata_qubit_hugr_expands_packed_json_metadata() { setup_test(); let mut key = Vec::with_capacity(PACKED_TRACE_METADATA_JSON_KEY.len() + 1); - // tket "pascal string" layout: a single-byte length prefix, so the key - // length must fit in u8 (< 256). - key.push( - u8::try_from(PACKED_TRACE_METADATA_JSON_KEY.len()).expect("key length fits in u8"), - ); + key.push(PACKED_TRACE_METADATA_JSON_KEY.len() as u8); key.extend_from_slice(PACKED_TRACE_METADATA_JSON_KEY.as_bytes()); let value = br#"{"host_id":"probe:host","source_kind":"szz_host"}"#; let mut packed = Vec::with_capacity(value.len() + 1); - // Single-byte length prefix (tket pascal-string layout): value length < 256. - packed.push(u8::try_from(value.len()).expect("value length fits in u8")); + packed.push(value.len() as u8); packed.extend_from_slice(value); let returned = diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index a7c2d02c9..f56601b3e 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -1386,12 +1386,6 @@ impl ClassicalEngine for QisEngine { num_qubits } - /// QIS programs allocate qubits dynamically during execution, so a - /// pre-execution count of 0 means "not yet discovered", not "zero qubits". - fn has_dynamic_qubit_count(&self) -> bool { - true - } - fn set_num_qubits_hint(&mut self, num_qubits: usize) { self.num_qubits_hint = Some(num_qubits); self.runtime.set_num_qubits(num_qubits); diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index c33d7a80f..931d61b10 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -67,10 +67,6 @@ struct SourceTraceMetadata { metadata: TraceMetadata, } -// `_fn` postfix is intentional: this is a `#[repr(C)]` vtable of Selene runtime -// callback function pointers, where the suffix marks each field as a function -// pointer. Bare names like `reset`/`custom`/`measure` would be ambiguous here. -#[allow(clippy::struct_field_names)] #[repr(C)] struct SeleneRuntimeGetOperationInterface { rzz_fn: extern "C" fn(RuntimeGetOperationInstance, u64, u64, f64), @@ -418,7 +414,7 @@ impl SeleneRuntime { &raw mut instance, plugin_num_qubits as u64, 0, // start time - u32::try_from(arg_ptrs.len()).expect("plugin argument count fits in u32"), + arg_ptrs.len() as u32, argv, ); @@ -448,17 +444,14 @@ impl SeleneRuntime { } debug!( - "Reinitializing Selene plugin capacity from {initialized_num_qubits} to {plugin_num_qubits} qubits" + "Reinitializing Selene plugin capacity from {} to {} qubits", + initialized_num_qubits, plugin_num_qubits ); self.reset_plugin_instance()?; self.load_plugin() } fn plugin_num_qubits(&self) -> usize { - // An explicit hint is authoritative and caps the plugin capacity (a - // program that exceeds it fails loudly at qalloc by design). The bogus - // "0 inferred before execution" case is handled upstream in SimBuilder, - // which no longer freezes that 0 as a hint. self.num_qubits_hint.unwrap_or(self.num_qubits) } @@ -1267,8 +1260,7 @@ impl SeleneRuntime { } ( QuantumOp::ZZ(source_qubit_1, source_qubit_2), - QuantumOp::ZZ(lowered_qubit_1, lowered_qubit_2) - | QuantumOp::RZZ(_, lowered_qubit_1, lowered_qubit_2), + QuantumOp::ZZ(lowered_qubit_1, lowered_qubit_2), ) => Self::same_unordered_pair( *source_qubit_1, *source_qubit_2, @@ -1287,6 +1279,15 @@ impl SeleneRuntime { *lowered_qubit_2, ) } + ( + QuantumOp::ZZ(source_qubit_1, source_qubit_2), + QuantumOp::RZZ(_, lowered_qubit_1, lowered_qubit_2), + ) => Self::same_unordered_pair( + *source_qubit_1, + *source_qubit_2, + *lowered_qubit_1, + *lowered_qubit_2, + ), ( QuantumOp::Measure(source_qubit, source_result), QuantumOp::Measure(lowered_qubit, lowered_result), @@ -1615,7 +1616,7 @@ fn operation_capacity_with_mode( for op in operations { match op { Operation::Quantum(qop) if !uses_explicit_allocations => { - include_quantum_op_capacity(qop, &mut num_qubits, &mut num_results); + include_quantum_op_capacity(qop, &mut num_qubits, &mut num_results) } Operation::Quantum(qop) => { include_quantum_result_capacity(qop, &mut num_results); @@ -1631,7 +1632,8 @@ fn operation_capacity_with_mode( Operation::RecordOutput { result_id, .. } => { include_result(&mut num_results, *result_id); } - Operation::TraceMetadata { .. } | Operation::Barrier => {} + Operation::TraceMetadata { .. } => {} + Operation::Barrier => {} } } if uses_explicit_allocations { diff --git a/crates/pecos-quantum/src/pass.rs b/crates/pecos-quantum/src/pass.rs index 6b74064da..879ab67ab 100644 --- a/crates/pecos-quantum/src/pass.rs +++ b/crates/pecos-quantum/src/pass.rs @@ -1306,7 +1306,7 @@ fn canonical_single_qubit_clifford_sequence(clifford: Clifford) -> Vec } fn flush_single_qubit_clifford_chain( - chain: &SingleQubitCliffordChain, + chain: SingleQubitCliffordChain, replacements: &mut BTreeMap<(usize, usize), GateType>, to_remove: &mut HashSet<(usize, usize)>, ) { @@ -1367,18 +1367,14 @@ impl CircuitPass for SimplifySingleQubitCliffordChains { for &qubit in &gate.qubits { if let Some(chain) = pending.remove(&qubit) { - flush_single_qubit_clifford_chain( - &chain, - &mut replacements, - &mut to_remove, - ); + flush_single_qubit_clifford_chain(chain, &mut replacements, &mut to_remove); } } } } for (_, chain) in pending { - flush_single_qubit_clifford_chain(&chain, &mut replacements, &mut to_remove); + flush_single_qubit_clifford_chain(chain, &mut replacements, &mut to_remove); } for (&(ti, gi), &gate_type) in &replacements { @@ -3170,11 +3166,7 @@ mod tests { SimplifySingleQubitCliffordChains.apply_tick(&mut tc); - assert!( - tc.ticks() - .iter() - .all(super::super::tick_circuit::Tick::is_empty) - ); + assert!(tc.ticks().iter().all(|tick| tick.is_empty())); } #[test] diff --git a/crates/pecos-uf-decoder/examples/profile_decode.rs b/crates/pecos-uf-decoder/examples/profile_decode.rs index 7bcd30ff5..78bcd54fa 100644 --- a/crates/pecos-uf-decoder/examples/profile_decode.rs +++ b/crates/pecos-uf-decoder/examples/profile_decode.rs @@ -16,7 +16,7 @@ fn shots_as_f64(num_shots: usize) -> f64 { fn profile_decoder(name: &str, dem: &str, num_shots: usize) { let graph = DemMatchingGraph::from_dem_str(dem).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::fast()).unwrap(); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::fast()); let num_det = graph.num_detectors; // Generate random syndromes @@ -59,7 +59,7 @@ fn profile_phases(name: &str, dem: &str, num_shots: usize) { .collect(); // Phase 1: measure reset + syndrome loading only - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::fast()).unwrap(); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::fast()); let t0 = Instant::now(); for syn in &syndromes { dec.syndrome_validate(syn); // reset + grow (no peel) @@ -126,7 +126,7 @@ fn main() { println!(); println!("=== Balanced (Prim MST) ==="); let graph = DemMatchingGraph::from_dem_str(D5_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::balanced()).unwrap(); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::balanced()); let num_det = graph.num_detectors; let mut rng = fastrand::Rng::with_seed(42); diff --git a/crates/pecos-uf-decoder/src/bp_uf.rs b/crates/pecos-uf-decoder/src/bp_uf.rs index 4ba969e84..ac24baaf7 100644 --- a/crates/pecos-uf-decoder/src/bp_uf.rs +++ b/crates/pecos-uf-decoder/src/bp_uf.rs @@ -151,9 +151,8 @@ impl BpUfDecoder { let dcm = DemCheckMatrix::from_dem_str(dem) .map_err(|e| DecoderError::InvalidConfiguration(e.to_string()))?; let graph = DemMatchingGraph::from_dem_str(dem)?; - graph.ensure_observables_fit_u64()?; UfDecoder::check_non_negative_weights(&graph)?; - let uf = UfDecoder::from_matching_graph(&graph, config.uf_config)?; + let uf = UfDecoder::from_matching_graph(&graph, config.uf_config); // Build mechanism → edge mapping. // Each mechanism in the check matrix corresponds to a column. @@ -254,9 +253,8 @@ impl BpUfDecoder { // Matching graph and UF from the decomposed DEM. let match_graph = DemMatchingGraph::from_dem_str(matching_dem)?; - match_graph.ensure_observables_fit_u64()?; UfDecoder::check_non_negative_weights(&match_graph)?; - let uf = UfDecoder::from_matching_graph(&match_graph, config.uf_config)?; + let uf = UfDecoder::from_matching_graph(&match_graph, config.uf_config); // Map BP mechanisms (non-decomposed) → matching graph edges (decomposed). let mut mechanism_to_edge = vec![None; bp_dcm.num_mechanisms]; diff --git a/crates/pecos-uf-decoder/src/css_decoder.rs b/crates/pecos-uf-decoder/src/css_decoder.rs index 4658cb382..29cdfc5c3 100644 --- a/crates/pecos-uf-decoder/src/css_decoder.rs +++ b/crates/pecos-uf-decoder/src/css_decoder.rs @@ -113,8 +113,6 @@ impl CssUfDecoder { ) -> Result { let x_graph = DemMatchingGraph::from_dem_str(x_dem)?; let z_graph = DemMatchingGraph::from_dem_str(z_dem)?; - x_graph.ensure_observables_fit_u64()?; - z_graph.ensure_observables_fit_u64()?; UfDecoder::check_non_negative_weights(&x_graph)?; UfDecoder::check_non_negative_weights(&z_graph)?; @@ -122,8 +120,8 @@ impl CssUfDecoder { let qubit_map = Self::build_qubit_mapping(&x_graph, &z_graph); let x_num_detectors = x_graph.num_detectors; - let x_decoder = UfDecoder::from_matching_graph(&x_graph, config)?; - let z_decoder = UfDecoder::from_matching_graph(&z_graph, config)?; + let x_decoder = UfDecoder::from_matching_graph(&x_graph, config); + let z_decoder = UfDecoder::from_matching_graph(&z_graph, config); Ok(Self { x_decoder, diff --git a/crates/pecos-uf-decoder/src/decoder.rs b/crates/pecos-uf-decoder/src/decoder.rs index 53ce80700..75ab81c39 100644 --- a/crates/pecos-uf-decoder/src/decoder.rs +++ b/crates/pecos-uf-decoder/src/decoder.rs @@ -230,12 +230,6 @@ impl UfDecoder { /// Build from a `DemMatchingGraph`. /// - /// # Errors - /// - /// Returns [`DecoderError`] if the matching graph carries more than 64 - /// observables: this decoder packs observable flips into a `u64`, so wider - /// observable sets are rejected rather than silently truncated. - /// /// # Panics /// /// Panics if any edge weight is negative or NaN: the predecoder's @@ -243,14 +237,8 @@ impl UfDecoder { /// premise must fail loudly rather than silently mis-decode. Callers /// holding untrusted DEM input should pre-validate with /// [`Self::check_non_negative_weights`] to get an error instead. - pub fn from_matching_graph( - graph: &DemMatchingGraph, - config: UfDecoderConfig, - ) -> Result { - // Fail loud rather than overflow-panic at the `1 << o` packing below: this - // decoder packs observable flips into a u64 and supports at most 64. - graph.ensure_observables_fit_u64()?; - + #[must_use] + pub fn from_matching_graph(graph: &DemMatchingGraph, config: UfDecoderConfig) -> Self { let num_detectors = graph.num_detectors; let num_nodes = num_detectors + 1; let boundary_node = num_detectors as u32; @@ -310,7 +298,7 @@ impl UfDecoder { } adj_offset.push(adj_data.len() as u32); - Ok(Self { + Self { edges, adj_data, adj_offset, @@ -332,7 +320,7 @@ impl UfDecoder { subtree_parity: vec![false; num_nodes], correction_edges: Vec::new(), weight_swap: Vec::new(), - }) + } } /// Build from a DEM string. @@ -345,8 +333,7 @@ impl UfDecoder { pub fn from_dem(dem: &str, config: UfDecoderConfig) -> Result { let graph = DemMatchingGraph::from_dem_str(dem)?; Self::check_non_negative_weights(&graph)?; - // `from_matching_graph` performs the >64-observable guard. - Self::from_matching_graph(&graph, config) + Ok(Self::from_matching_graph(&graph, config)) } /// Reset per-shot state. Uses bulk fill operations for cache efficiency. diff --git a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs index c5c071879..90674fba3 100644 --- a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs +++ b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs @@ -130,20 +130,8 @@ impl WindowedLogicalSubgraphDecoder { } impl ObservableDecoder for WindowedLogicalSubgraphDecoder { - /// Narrowing wrapper over [`Self::decode_obs`]; errors (rather than - /// truncating) above 64 observables. - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { - self.decode_obs(syndrome)?.to_u64().ok_or_else(|| { - DecoderError::InvalidConfiguration( - "decoder has more than 64 observables; use decode_obs() for the wide mask".into(), - ) - }) - } - - /// Decode every windowed per-observable subgraph and pack the flips into a - /// wide [`ObsMask`] at each subgraph's GLOBAL observable index (no >64 cap). fn decode_obs(&mut self, syndrome: &[u8]) -> Result { - let mut obs_mask = ObsMask::new(); + let mut obs_mask = 0u64; for sg in &mut self.subgraphs { let n = sg.num_local; for (local, &global) in sg.detector_map.iter().enumerate() { @@ -154,12 +142,19 @@ impl ObservableDecoder for WindowedLogicalSubgraphDecoder { }; } // The subgraph decodes a single observable as its local bit 0; map - // that back to this observable's global bit. + // that back to this observable's global bit. `observable_idx < 64` is + // guaranteed upstream (`subgraphs_from_membership` rejects >64 + // observables); assert it locally where the u64 shift consumes it. + debug_assert!( + sg.observable_idx < 64, + "observable index {} exceeds u64 observable-mask capacity", + sg.observable_idx + ); let sub_obs = sg.decoder.decode_to_observables(&self.local_syn[..n])?; if sub_obs & 1 != 0 { - obs_mask.set(sg.observable_idx); + obs_mask |= 1u64 << sg.observable_idx; } } - Ok(obs_mask) + Ok(ObsMask::from_u64(obs_mask)) } } diff --git a/crates/pecos-uf-decoder/tests/cross_decoder_tests.rs b/crates/pecos-uf-decoder/tests/cross_decoder_tests.rs index 1f09aec70..d7bec84d7 100644 --- a/crates/pecos-uf-decoder/tests/cross_decoder_tests.rs +++ b/crates/pecos-uf-decoder/tests/cross_decoder_tests.rs @@ -30,11 +30,13 @@ const D3_DEM: &str = fn test_ensemble_of_identical_decoders_matches_single() { let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); - let mut single = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); + let mut single = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); let members: Vec> = (0..3) .map(|_| { - Box::new(UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap()) - as Box + Box::new(UfDecoder::from_matching_graph( + &graph, + UfDecoderConfig::default(), + )) as Box }) .collect(); let mut ensemble = EnsembleDecoder::new(members); @@ -62,8 +64,8 @@ fn test_ensemble_of_identical_decoders_matches_single() { fn test_deterministic_results() { let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); - let mut dec1 = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); - let mut dec2 = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); + let mut dec1 = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec2 = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); let mut rng = fastrand::Rng::with_seed(999); for _ in 0..200 { @@ -87,7 +89,7 @@ fn test_matching_agrees_with_observable() { use pecos_decoder_core::correlated_decoder::MatchingDecoder; let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); let mut rng = fastrand::Rng::with_seed(777); for _ in 0..200 { diff --git a/crates/pecos-uf-decoder/tests/integration_tests.rs b/crates/pecos-uf-decoder/tests/integration_tests.rs index ba4b9f845..fc75d7ae1 100644 --- a/crates/pecos-uf-decoder/tests/integration_tests.rs +++ b/crates/pecos-uf-decoder/tests/integration_tests.rs @@ -37,7 +37,7 @@ fn test_real_dem_construction() { graph.edges.len() ); - let dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); + let dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); assert_eq!(dec.num_detectors(), graph.num_detectors); assert_eq!(dec.num_edges(), graph.edges.len()); } @@ -46,7 +46,7 @@ fn test_real_dem_construction() { #[test] fn test_real_dem_no_errors() { let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); let syndrome = vec![0u8; graph.num_detectors]; assert_eq!(dec.decode_syndrome(&syndrome), 0); } @@ -56,7 +56,7 @@ fn test_real_dem_no_errors() { #[test] fn test_real_dem_single_defects() { let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); for d in 0..graph.num_detectors { let mut syndrome = vec![0u8; graph.num_detectors]; @@ -75,7 +75,7 @@ fn test_real_dem_single_defects() { #[test] fn test_real_dem_adjacent_pairs() { let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); for edge in &graph.edges { let mut syndrome = vec![0u8; graph.num_detectors]; @@ -93,7 +93,7 @@ fn test_real_dem_adjacent_pairs() { #[test] fn test_real_dem_random_syndromes() { let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); let mut rng = fastrand::Rng::with_seed(42); for _ in 0..1000 { @@ -121,7 +121,7 @@ fn test_real_dem_random_syndromes() { #[test] fn test_observable_decoder_trait_real_dem() { let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); let syndrome = vec![0u8; graph.num_detectors]; let result = dec.decode_to_observables(&syndrome); assert!(result.is_ok()); @@ -133,7 +133,7 @@ fn test_observable_decoder_trait_real_dem() { fn test_matching_decoder_trait_real_dem() { use pecos_decoder_core::correlated_decoder::MatchingDecoder; let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); // Two adjacent defects. let edge = &graph.edges[0]; @@ -154,7 +154,7 @@ fn test_matching_decoder_trait_real_dem() { #[test] fn test_buffer_reuse_correctness() { let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); let zero_syndrome = vec![0u8; graph.num_detectors]; let mut defect_syndrome = vec![0u8; graph.num_detectors]; @@ -195,7 +195,7 @@ fn test_from_dem_rejects_negative_weight_priors() { fn test_from_matching_graph_asserts_non_negative_weights() { let dem = "error(0.6) D0 L0\n"; let graph = DemMatchingGraph::from_dem_str(dem).unwrap(); - let _ = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::balanced()).unwrap(); + let _ = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::balanced()); } /// Weight-zero edges (p = 0.5) are benign: the shortcut proofs hold as ties. diff --git a/docs/development/noise-event-replay.md b/docs/development/noise-event-replay.md index 02ec873c5..3b8ed9955 100644 --- a/docs/development/noise-event-replay.md +++ b/docs/development/noise-event-replay.md @@ -143,3 +143,4 @@ Recommended defaults: which should be explicitly tied to the lowered/traced circuit version? - How should traces link back to DEM contribution/source records when one sampled event corresponds to multiple detector/observable effects? + diff --git a/examples/surface/compare_surface_sweep_json.py b/examples/surface/compare_surface_sweep_json.py index c7407bd7c..5bbf8b137 100644 --- a/examples/surface/compare_surface_sweep_json.py +++ b/examples/surface/compare_surface_sweep_json.py @@ -97,7 +97,11 @@ def jeffreys_interval(errors: int, shots: int, confidence: float = 0.95) -> tupl alpha = (1.0 - confidence) / 2.0 lower = 0.0 if errors == 0 else float(beta.ppf(alpha, errors + 0.5, shots - errors + 0.5)) - upper = 1.0 if errors == shots else float(beta.ppf(1.0 - alpha, errors + 0.5, shots - errors + 0.5)) + upper = ( + 1.0 + if errors == shots + else float(beta.ppf(1.0 - alpha, errors + 0.5, shots - errors + 0.5)) + ) return lower, upper @@ -119,7 +123,8 @@ def standard_error(errors: int, shots: int) -> float: def z_score(left: Point, right: Point) -> float: denom = math.sqrt( - standard_error(left.errors, left.shots) ** 2 + standard_error(right.errors, right.shots) ** 2, + standard_error(left.errors, left.shots) ** 2 + + standard_error(right.errors, right.shots) ** 2, ) if denom == 0.0: return math.nan diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py index bde4bdfad..c1564562a 100644 --- a/examples/surface/dem_decomposition_diagnostics.py +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -685,7 +685,9 @@ def run_case( ), ] decoders.extend( - _timed_decode(name, callback, shots) for name, callback in graphlike_decoder_specs if name in decoder_names + _timed_decode(name, callback, shots) + for name, callback in graphlike_decoder_specs + if name in decoder_names ) return CaseResult( @@ -708,17 +710,15 @@ def run_case( "terminal_decomposed": dem_stats(terminal_decomposed), }, decoders=decoders, - pair_analysis=( - two_fault_pair_analysis( - native_raw=native_raw, - native_decomposed=native_decomposed, - stim_decomposed=stim_decomposed, - terminal_decomposed=terminal_decomposed, - max_effects=pair_analysis_max_effects, - ) - if pair_analysis - else None - ), + pair_analysis=two_fault_pair_analysis( + native_raw=native_raw, + native_decomposed=native_decomposed, + stim_decomposed=stim_decomposed, + terminal_decomposed=terminal_decomposed, + max_effects=pair_analysis_max_effects, + ) + if pair_analysis + else None, ) @@ -737,7 +737,8 @@ def print_case(result: CaseResult) -> None: ) if raw.only_native == 0 and raw.only_stim == 0 and raw.max_rel_probability_diff > 0: print( - " raw structures match; probability deltas reflect combination/rounding conventions.", + " raw structures match; probability deltas reflect " + "combination/rounding conventions.", ) print("DEM stats:") @@ -851,10 +852,13 @@ def load_cached_results(path: Path) -> tuple[list[dict[str, Any]], dict[tuple[An """Load resumable diagnostic results from a previous JSON file.""" payload = json.loads(path.read_text(encoding="utf-8")) if not isinstance(payload, list): - msg = f"Expected a list of case results in {path}" - raise TypeError(msg) + raise ValueError(f"Expected a list of case results in {path}") results = [item for item in payload if isinstance(item, dict)] - by_key = {key: result for result in results if (key := cached_case_key(result)) is not None} + by_key = { + key: result + for result in results + if (key := cached_case_key(result)) is not None + } return results, by_key @@ -898,8 +902,7 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() if args.resume and args.save_json is None: - msg = "--resume requires --save-json so completed cases have a source" - raise ValueError(msg) + raise ValueError("--resume requires --save-json so completed cases have a source") results: list[CaseResult | dict[str, Any]] = [] cached_by_key: dict[tuple[Any, ...], dict[str, Any]] = {} diff --git a/examples/surface/graphlike_dem_projection_benchmark.py b/examples/surface/graphlike_dem_projection_benchmark.py index 9e90d979f..c1bab4fe5 100644 --- a/examples/surface/graphlike_dem_projection_benchmark.py +++ b/examples/surface/graphlike_dem_projection_benchmark.py @@ -113,7 +113,8 @@ def build_case( ) print( - f"\n=== d={distance} r={rounds} basis={basis} basis2q={interaction_basis} p={p:g} shots={shots} ===", + f"\n=== d={distance} r={rounds} basis={basis} basis2q={interaction_basis} " + f"p={p:g} shots={shots} ===", flush=True, ) setup_timings: list[TimedValue] = [] @@ -176,7 +177,8 @@ def build_case( and raw_comparison["max_rel_probability_diff"] > 0 ): print( - "raw structures match; max_rel is a probability-combination/rounding delta.", + "raw structures match; max_rel is a probability-combination/" + "rounding delta.", flush=True, ) diff --git a/examples/surface/inner_decoder_study.py b/examples/surface/inner_decoder_study.py index 9e6ba9a84..e51a69123 100644 --- a/examples/surface/inner_decoder_study.py +++ b/examples/surface/inner_decoder_study.py @@ -41,6 +41,7 @@ import argparse import json +import math import time from dataclasses import asdict, dataclass from pathlib import Path @@ -186,7 +187,7 @@ def measure_cell( ler=wrong / n, build_seconds=t1 - t0, decode_seconds=t2 - t1, - ), + ) ) return cells @@ -201,7 +202,11 @@ def _append(path: Path, cells: list[Cell]) -> None: def _load(path: Path) -> list[Cell]: if not path.exists(): return [] - return [Cell(**json.loads(line)) for line in path.read_text().splitlines() if line.strip()] + out = [] + for line in path.read_text().splitlines(): + if line.strip(): + out.append(Cell(**json.loads(line))) + return out # --------------------------------------------------------------------------- # @@ -217,14 +222,7 @@ def run_suppress(path: Path) -> None: done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} plan = [ ("memory", [3, 5, 7], [0.002, 0.003, 0.005], CANDIDATES, [1, 2, 3], 100_000), - ( - "cx", - [3, 5, 7], - [0.002, 0.003, 0.005], - ["fusion_blossom_serial", "pecos_uf:bp", "belief_matching"], - [1, 2, 3], - 50_000, - ), + ("cx", [3, 5, 7], [0.002, 0.003, 0.005], ["fusion_blossom_serial", "pecos_uf:bp", "belief_matching"], [1, 2, 3], 50_000), ] for family, ds, ps, inners, seeds, n in plan: for d in ds: @@ -431,14 +429,12 @@ def w(s: str = "") -> None: if (fam, d, p, bench) not in agg or bi == bench: continue rk, rn = agg[(fam, d, p, bench)] - _blo, bhi = jeffreys_ci(bk, bn) - rlo, _rhi = jeffreys_ci(rk, rn) + blo, bhi = jeffreys_ci(bk, bn) + rlo, rhi = jeffreys_ci(rk, rn) sep = "DISJOINT" if bhi < rlo else "overlap" ratio = (rk / rn) / (bk / bn) if bk else float("inf") - w( - f"- d={d}: best **{bi}** {bk / bn:.2e} vs {bench} {rk / rn:.2e} " - f"({ratio:.1f}x) -- Jeffreys intervals {sep}", - ) + w(f"- d={d}: best **{bi}** {bk / bn:.2e} vs {bench} {rk / rn:.2e} " + f"({ratio:.1f}x) -- Jeffreys intervals {sep}") w() # Suppression check + exponent per inner (pooled across seeds). w(f"### {fam}: suppression exponent (LER ~ (p/p_th)^((d+1)/2))") @@ -450,14 +446,16 @@ def w(s: str = "") -> None: if len(seq) < 2: continue suppresses = all( - seq[i + 1][1][0] / seq[i + 1][1][1] < seq[i][1][0] / seq[i][1][1] for i in range(len(seq) - 1) + seq[i + 1][1][0] / seq[i + 1][1][1] < seq[i][1][0] / seq[i][1][1] + for i in range(len(seq) - 1) ) ratios = [ (seq[i][1][0] / seq[i][1][1]) / (seq[i + 1][1][0] / seq[i + 1][1][1]) for i in range(len(seq) - 1) ] tag = "suppresses" if suppresses else "NOT monotone" - w(f"- p={p} {inner}: {tag}; per-step LER ratio " + ", ".join(f"{r:.1f}x" for r in ratios)) + w(f"- p={p} {inner}: {tag}; per-step LER ratio " + + ", ".join(f"{r:.1f}x" for r in ratios)) w() if thr: @@ -488,10 +486,8 @@ def w(s: str = "") -> None: cross = p break w() - w( - f"- threshold estimate (d={d_hi} stops beating d={d_lo}): " - + (f"~{cross}" if cross else f"above {ps[-1]} (not reached)"), - ) + w(f"- threshold estimate (d={d_hi} stops beating d={d_lo}): " + + (f"~{cross}" if cross else f"above {ps[-1]} (not reached)")) w() if spd: @@ -501,7 +497,8 @@ def w(s: str = "") -> None: w("|---|---|---:|---:|---:|") for c in sorted(spd, key=lambda c: (c.family, c.decode_seconds)): us = c.decode_seconds / c.num_shots * 1e6 - w(f"| {c.family} | {c.inner} | {c.build_seconds * 1e3:.1f} | {c.decode_seconds:.2f} | {us:.1f} |") + w(f"| {c.family} | {c.inner} | {c.build_seconds * 1e3:.1f} | " + f"{c.decode_seconds:.2f} | {us:.1f} |") w() return "\n".join(lines) @@ -524,10 +521,8 @@ def main() -> None: cells = measure_cell("memory", 3, 3, 0.005, 1, ["fusion_blossom_serial", "pecos_uf:bp"], 2000) for c in cells: lo, hi = jeffreys_ci(c.num_errors, c.num_shots) - print( - f"smoke {c.inner}: {c.num_errors}/{c.num_shots} ler={c.ler:.4f} " - f"CI=[{lo:.4f},{hi:.4f}] build={c.build_seconds * 1e3:.1f}ms decode={c.decode_seconds:.3f}s", - ) + print(f"smoke {c.inner}: {c.num_errors}/{c.num_shots} ler={c.ler:.4f} " + f"CI=[{lo:.4f},{hi:.4f}] build={c.build_seconds * 1e3:.1f}ms decode={c.decode_seconds:.3f}s") cx = measure_cell("cx", 3, 3, 0.005, 1, ["fusion_blossom_serial"], 2000) print(f"smoke cx: {cx[0].num_errors}/{cx[0].num_shots} ler={cx[0].ler:.4f}") return diff --git a/examples/surface/szz_circuit_quality_report.py b/examples/surface/szz_circuit_quality_report.py index cba6aac91..493661e79 100644 --- a/examples/surface/szz_circuit_quality_report.py +++ b/examples/surface/szz_circuit_quality_report.py @@ -432,7 +432,8 @@ def build_case( and comparison["max_rel_probability_diff"] > 0 ): print( - "raw structures match; max_rel is a probability-combination/rounding delta.", + "raw structures match; max_rel is a probability-combination/" + "rounding delta.", flush=True, ) diff --git a/python/pecos-rslib-exp/src/sim_neo_bindings.rs b/python/pecos-rslib-exp/src/sim_neo_bindings.rs index d237a0df4..6b507559f 100644 --- a/python/pecos-rslib-exp/src/sim_neo_bindings.rs +++ b/python/pecos-rslib-exp/src/sim_neo_bindings.rs @@ -2023,6 +2023,7 @@ fn extract_commands(py_tc: &Bound<'_, PyAny>) -> PyResult { // Identity/Idle and tracked-Pauli metadata gates: skip (no-op for simulation) } + "TrackedPauli" | "TrackedPauliMeta" => {} _ => { return Err(PyErr::new::(format!( "Unsupported gate type '{name}' in extract_commands. \ diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index bbe89aa7f..f19be757d 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -76,8 +76,6 @@ use std::str::FromStr; type PyDemMechanismTuple = (f64, Vec, Vec); type PyDemFitResult = (Vec, Vec); -/// Per-shot detector rows paired with per-shot observable/DEM-output rows. -type PyDetectorObservableRows = (Vec>, Vec>); fn parse_p1_weights(weights: BTreeMap) -> PyResult { use pecos_core::pauli::{X, Y, Z}; @@ -125,7 +123,8 @@ fn parse_p2_weights(weights: BTreeMap) -> PyResult { let replacement_identity = replacement && label == "II"; if !replacement_identity && !PAULI_2Q_ORDER.contains(&label.as_str()) { let msg = format!( - "p2_weights keys must be one of {PAULI_2Q_ORDER:?} or prefixed with '*' for replacement branches, got {label:?}" + "p2_weights keys must be one of {:?} or prefixed with '*' for replacement branches, got {label:?}", + PAULI_2Q_ORDER ); return Err(pyo3::exceptions::PyValueError::new_err(msg)); } @@ -429,11 +428,11 @@ struct SendWrapper(Box); unsafe impl Send for SendWrapper {} unsafe impl Sync for SendWrapper {} impl pecos_decoders::ObservableDecoder for SendWrapper { - fn decode_obs( + fn decode_to_observables( &mut self, syndrome: &[u8], - ) -> Result { - self.0.decode_obs(syndrome) + ) -> Result { + self.0.decode_to_observables(syndrome) } } @@ -1140,7 +1139,7 @@ impl PyPauliFrameLookup { fn compute_mask_xor( &self, pauli_masks: &Bound<'_, pyo3::PyAny>, - ) -> PyResult { + ) -> PyResult<(Vec>, Vec>)> { let (values, rows, cols) = extract_pauli_mask_values(pauli_masks)?; self.inner .compute_mask_xor(&values, rows, cols) @@ -2142,10 +2141,10 @@ struct WeightedUfObservableDecoder { } impl pecos_decoders::ObservableDecoder for WeightedUfObservableDecoder { - fn decode_obs( + fn decode_to_observables( &mut self, syndrome: &[u8], - ) -> Result { + ) -> Result { let arr = ndarray::Array1::from_vec(syndrome.to_vec()); // bits_per_step=1: grow one bit at a time, sorted by LLR weight. // bits_per_step=0 with non-empty LLRs causes the C++ UF decoder to @@ -2156,7 +2155,7 @@ impl pecos_decoders::ObservableDecoder for WeightedUfObservableDecoder { .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string()))?; Ok(self .dcm - .observables_obsmask_from_correction(result.decoding.as_slice().unwrap_or(&[]))) + .observables_mask_from_correction(result.decoding.as_slice().unwrap_or(&[]))) } } @@ -2170,10 +2169,10 @@ struct RelabeledObservableDecoder { } impl pecos_decoders::ObservableDecoder for RelabeledObservableDecoder { - fn decode_obs( + fn decode_to_observables( &mut self, syndrome: &[u8], - ) -> Result { + ) -> Result { // Relabel syndrome into the expanded vertex space (detectors + virtual + gap) let expected = self.decoder.num_nodes(); let mut relabeled = vec![0u8; expected]; @@ -2190,10 +2189,10 @@ impl pecos_decoders::ObservableDecoder for RelabeledObservableDecoder { .decoder .decode(&arr.view()) .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string()))?; - let mut mask = pecos_decoder_core::obs_mask::ObsMask::new(); + let mut mask = 0u64; for (i, &v) in result.observable.iter().enumerate() { if v != 0 { - mask.set(i); + mask |= 1 << i; } } Ok(mask) @@ -2305,11 +2304,6 @@ fn create_observable_decoder( use pecos_decoders::{FusionBlossomConfig, FusionBlossomDecoder}; let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; - // Matching decoders pack observables into a u64; reject >64-observable - // DEMs rather than overflow-panicking in build_obs_masks. - graph - .ensure_observables_fit_u64() - .map_err(|e| PyErr::new::(e.to_string()))?; // Use absolute weight scaling. Fusion Blossom uses integer weights; // we multiply by 1000 for precision (matching the internal 1000x @@ -2353,11 +2347,6 @@ fn create_observable_decoder( let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; - // Matching decoders pack observables into a u64; reject >64-observable - // DEMs rather than overflow-panicking in build_obs_masks. - graph - .ensure_observables_fit_u64() - .map_err(|e| PyErr::new::(e.to_string()))?; let config = FusionBlossomConfig { num_nodes: Some(graph.num_detectors), @@ -2462,11 +2451,6 @@ fn create_observable_decoder( let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; - // Matching decoders pack observables into a u64; reject >64-observable - // DEMs rather than overflow-panicking in build_obs_masks. - graph - .ensure_observables_fit_u64() - .map_err(|e| PyErr::new::(e.to_string()))?; // Group detectors by time coordinate for round-contiguous relabeling. let mut round_groups: std::collections::BTreeMap> = @@ -2762,16 +2746,14 @@ fn create_observable_decoder( |e| PyErr::new::(e.to_string()), )?; - // Reject negative-weight edges (error priors p > 0.5) as a Python error - // rather than panicking on the negative-weight assert; the - // >64-observable guard now lives in `from_matching_graph`. + // Reject negative-weight edges (error priors p > 0.5) as a Python + // error rather than panicking in `from_matching_graph`. pecos_decoders::UfDecoder::check_non_negative_weights(&graph) .map_err(|e| PyErr::new::(e.to_string()))?; let uf = pecos_decoders::UfDecoder::from_matching_graph( &graph, pecos_decoders::UfDecoderConfig::balanced(), - ) - .map_err(|e| PyErr::new::(e.to_string()))?; + ); let two_pass = TwoPassDecoder::new(uf, base_weights, corr_table); Ok(Box::new(two_pass)) } @@ -2801,11 +2783,6 @@ fn create_observable_decoder( let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; - // Matching decoders pack observables into a u64; reject >64-observable - // DEMs rather than overflow-panicking in build_obs_masks. - graph - .ensure_observables_fit_u64() - .map_err(|e| PyErr::new::(e.to_string()))?; let config = FusionBlossomConfig { num_nodes: Some(graph.num_detectors), num_observables: graph.num_observables, @@ -2869,11 +2846,6 @@ fn create_observable_decoder( // Build Fusion Blossom as the matching backend. let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; - // Matching decoders pack observables into a u64; reject >64-observable - // DEMs rather than overflow-panicking in build_obs_masks. - graph - .ensure_observables_fit_u64() - .map_err(|e| PyErr::new::(e.to_string()))?; let config = FusionBlossomConfig { num_nodes: Some(graph.num_detectors), num_observables: graph.num_observables, @@ -2918,11 +2890,6 @@ fn create_observable_decoder( let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; - // Matching decoders pack observables into a u64; reject >64-observable - // DEMs rather than overflow-panicking in build_obs_masks. - graph - .ensure_observables_fit_u64() - .map_err(|e| PyErr::new::(e.to_string()))?; // Build Fusion Blossom. let config = FusionBlossomConfig { @@ -2989,11 +2956,6 @@ fn create_observable_decoder( // Build Fusion Blossom from decomposed DEM. let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; - // Matching decoders pack observables into a u64; reject >64-observable - // DEMs rather than overflow-panicking in build_obs_masks. - graph - .ensure_observables_fit_u64() - .map_err(|e| PyErr::new::(e.to_string()))?; let config = FusionBlossomConfig { num_nodes: Some(graph.num_detectors), num_observables: graph.num_observables, @@ -3374,33 +3336,8 @@ impl PySampleBatch { } } - /// Reject a batch that cannot be represented by the legacy `u64` observable - /// APIs (more than 64 observable columns). Callers with >64 observables must - /// use the wide `LogicalSubgraphDecoder` decode/decode_count paths, which - /// return arbitrary-precision Python ints. Call this up front in every - /// `u64`-returning public method before [`Self::extract_obs_mask`]. - fn ensure_narrow_observables(&self) -> PyResult<()> { - if self.obs_columns.len() > 64 { - return Err(pyo3::exceptions::PyValueError::new_err(format!( - "SampleBatch has {} observable columns, exceeding the 64-observable limit of \ - this u64-based API; use the wide LogicalSubgraphDecoder decode/decode_count \ - paths (arbitrary-precision int) for more than 64 observables", - self.obs_columns.len() - ))); - } - Ok(()) - } - - /// Extract observable mask for one shot (`u64`; observables 0..=63 only). - /// - /// The caller must have rejected wide batches via - /// [`Self::ensure_narrow_observables`] first; with >64 observable columns the - /// `1u64 << obs_idx` below would overflow. + /// Extract observable mask for one shot. fn extract_obs_mask(&self, shot: usize) -> u64 { - debug_assert!( - self.obs_columns.len() <= 64, - "extract_obs_mask requires <=64 observable columns; call ensure_narrow_observables first" - ); let word_idx = shot / 64; let bit_mask = 1u64 << (shot % 64); let mut mask = 0u64; @@ -3412,20 +3349,6 @@ impl PySampleBatch { mask } - /// Extract the observable mask for one shot as a wide [`ObsMask`], with no - /// 64-observable cap (the columnar storage already supports >64 columns). - fn extract_obs_mask_wide(&self, shot: usize) -> pecos_decoder_core::obs_mask::ObsMask { - let word_idx = shot / 64; - let bit_mask = 1u64 << (shot % 64); - let mut mask = pecos_decoder_core::obs_mask::ObsMask::new(); - for (obs_idx, col) in self.obs_columns.iter().enumerate() { - if col[word_idx] & bit_mask != 0 { - mask.set(obs_idx); - } - } - mask - } - /// Build from columnar data (from generate_samples). fn from_columnar( det_columns: Vec>, @@ -3441,12 +3364,8 @@ impl PySampleBatch { } } - /// Build from row-major data (from Python constructor). Observable masks are - /// wide [`ObsMask`]es, so more than 64 observables are stored without loss. - fn from_row_major( - detection_events: Vec>, - observable_masks: &[pecos_decoder_core::obs_mask::ObsMask], - ) -> Self { + /// Build from row-major data (from Python constructor). + fn from_row_major(detection_events: Vec>, observable_masks: Vec) -> Self { let num_shots = detection_events.len(); let num_detectors = detection_events.first().map_or(0, Vec::len); let num_words = num_shots.div_ceil(64); @@ -3463,19 +3382,20 @@ impl PySampleBatch { } } - // One observable column per observable index; sized to the highest set - // bit across all shots (supports >64 observables). + // Find max observable index let max_obs = observable_masks .iter() - .filter_map(|m| m.iter_set_bits().max()) + .map(|m| 64 - m.leading_zeros() as usize) .max() - .map_or(0, |b| b + 1); + .unwrap_or(0); let mut obs_columns = vec![vec![0u64; num_words]; max_obs]; - for (shot, mask) in observable_masks.iter().enumerate() { + for (shot, &mask) in observable_masks.iter().enumerate() { let word_idx = shot / 64; let bit_mask = 1u64 << (shot % 64); - for obs_idx in mask.iter_set_bits() { - obs_columns[obs_idx][word_idx] |= bit_mask; + for (obs_idx, obs_column) in obs_columns.iter_mut().enumerate().take(max_obs) { + if mask & (1u64 << obs_idx) != 0 { + obs_column[word_idx] |= bit_mask; + } } } @@ -3494,15 +3414,10 @@ impl PySampleBatch { /// /// Args: /// detection_events: List of syndromes, each a list of u8 (0/1). - /// observable_masks: List of true observable flip masks as Python ints - /// (arbitrary precision; bit ``i`` = observable ``i``, so more than 64 - /// observables are supported). + /// observable_masks: List of u64 true observable flip masks. #[new] #[pyo3(signature = (detection_events, observable_masks))] - fn new( - detection_events: Vec>, - observable_masks: Vec>, - ) -> PyResult { + fn new(detection_events: Vec>, observable_masks: Vec) -> PyResult { if detection_events.len() != observable_masks.len() { return Err(pyo3::exceptions::PyValueError::new_err(format!( "detection_events ({}) and observable_masks ({}) must have same length", @@ -3520,11 +3435,7 @@ impl PySampleBatch { ))); } } - let masks: Vec = observable_masks - .iter() - .map(py_to_obsmask) - .collect::>()?; - Ok(Self::from_row_major(detection_events, &masks)) + Ok(Self::from_row_major(detection_events, observable_masks)) } /// Number of shots in this batch. @@ -3546,9 +3457,8 @@ impl PySampleBatch { Ok(buf) } - /// Get the expected observable mask for shot `i` (`u64`; <=64 observables). + /// Get the expected observable mask for shot `i`. fn get_observable_mask(&self, i: usize) -> PyResult { - self.ensure_narrow_observables()?; if i >= self.num_shots { return Err(PyErr::new::(format!( "Shot index {i} out of range (num_shots={})", @@ -3558,18 +3468,6 @@ impl PySampleBatch { Ok(self.extract_obs_mask(i)) } - /// Observable mask for shot `i` as a Python ``int`` (arbitrary precision, so - /// more than 64 observables are not truncated). - fn get_observable_mask_wide(&self, py: Python<'_>, i: usize) -> PyResult> { - if i >= self.num_shots { - return Err(PyErr::new::(format!( - "Shot index {i} out of range (num_shots={})", - self.num_shots - ))); - } - obsmask_to_py(py, &self.extract_obs_mask_wide(i)) - } - /// Decode all samples with the given decoder type and return the error count. /// /// This runs entirely in Rust -- no per-shot Python crossing. @@ -3589,13 +3487,8 @@ impl PySampleBatch { let mut syndrome = vec![0u8; self.num_detectors]; for i in 0..self.num_shots { self.extract_syndrome(i, &mut syndrome); - // Wide ObsMask comparison: inline (one stack word) for the typical - // <=64 observables, correct without truncation beyond. A decode - // failure counts as a logical error (matching the prior sentinel). - let is_error = decoder - .decode_obs(&syndrome) - .map_or(true, |p| p != self.extract_obs_mask_wide(i)); - if is_error { + let predicted = decoder.decode_to_observables(&syndrome).unwrap_or(u64::MAX); + if predicted != self.extract_obs_mask(i) { errors += 1; } } @@ -3613,15 +3506,9 @@ impl PySampleBatch { /// `decoder_type`: Decoder type string. /// /// Returns: - /// List of predicted observable masks (Python ints; arbitrary precision, - /// so more than 64 observables are not truncated), one per shot. + /// List of predicted observable masks, one per shot. #[pyo3(signature = (dem, decoder_type="pymatching"))] - fn decode_each( - &self, - py: Python<'_>, - dem: &str, - decoder_type: &str, - ) -> PyResult>> { + fn decode_each(&self, dem: &str, decoder_type: &str) -> PyResult> { let mut decoder = create_observable_decoder(dem, decoder_type)?; let mut predictions = Vec::with_capacity(self.num_shots); let mut syndrome = vec![0u8; self.num_detectors]; @@ -3630,9 +3517,9 @@ impl PySampleBatch { // Propagate a decode failure rather than masking it as a sentinel // observable value (which would read as a spurious disagreement). let predicted = decoder - .decode_obs(&syndrome) + .decode_to_observables(&syndrome) .map_err(|e| PyErr::new::(e.to_string()))?; - predictions.push(obsmask_to_py(py, &predicted)?); + predictions.push(predicted); } Ok(predictions) } @@ -3676,8 +3563,7 @@ impl PySampleBatch { s }) .collect(); - let observable_masks: Vec = - (0..n).map(|i| self.extract_obs_mask_wide(i)).collect(); + let observable_masks: Vec = (0..n).map(|i| self.extract_obs_mask(i)).collect(); let total_errors: usize = pool.install(|| { (0..n) @@ -3685,11 +3571,10 @@ impl PySampleBatch { .map_init( || create_observable_decoder(&dem_str, &dt).unwrap(), |decoder, i| { - usize::from( - decoder - .decode_obs(&detection_events[i]) - .map_or(true, |p| p != observable_masks[i]), - ) + let predicted = decoder + .decode_to_observables(&detection_events[i]) + .unwrap_or(u64::MAX); + usize::from(predicted != observable_masks[i]) }, ) .sum() @@ -3736,19 +3621,17 @@ impl PySampleBatch { .decode_batch_with_config(&flat, self.num_shots, num_detectors, config) .map_err(|e| PyErr::new::(e.to_string()))?; - // Count errors by comparing predictions to true observable masks. The - // predicted mask is a wide ObsMask (inline for <=64 observables, correct - // beyond), so a DEM with more than 64 observables is not truncated. + // Count errors by comparing predictions to true observable masks let num_observables = decoder.num_observables(); let mut num_errors = 0usize; for (i, prediction) in result.predictions.iter().enumerate() { - let mut predicted = pecos_decoder_core::obs_mask::ObsMask::new(); + let mut predicted_mask = 0u64; for (j, &v) in prediction.iter().enumerate() { if v != 0 && j < num_observables { - predicted.set(j); + predicted_mask |= 1 << j; } } - if predicted != self.extract_obs_mask_wide(i) { + if predicted_mask != self.extract_obs_mask(i) { num_errors += 1; } } @@ -3780,10 +3663,10 @@ impl PySampleBatch { for i in 0..self.num_shots { self.extract_syndrome(i, &mut syndrome); let t0 = Instant::now(); - let predicted = decoder.decode_obs(&syndrome); + let predicted = decoder.decode_to_observables(&syndrome).unwrap_or(u64::MAX); let elapsed = t0.elapsed().as_secs_f64(); per_shot_seconds.push(elapsed); - if predicted.map_or(true, |p| p != self.extract_obs_mask_wide(i)) { + if predicted != self.extract_obs_mask(i) { num_errors += 1; } } @@ -3839,8 +3722,8 @@ impl PySampleBatch { s }) .collect(); - let observable_masks: Vec = (0..self.num_shots) - .map(|i| self.extract_obs_mask_wide(i)) + let observable_masks: Vec = (0..self.num_shots) + .map(|i| self.extract_obs_mask(i)) .collect(); // Each worker decodes a slice of shots and returns (errors, per_shot_times). @@ -3861,9 +3744,11 @@ impl PySampleBatch { for i in start..end { let t0 = std::time::Instant::now(); - let predicted = decoder.decode_obs(&detection_events[i]); + let predicted = decoder + .decode_to_observables(&detection_events[i]) + .unwrap_or(u64::MAX); times.push(t0.elapsed().as_secs_f64()); - if predicted.map_or(true, |p| p != observable_masks[i]) { + if predicted != observable_masks[i] { errors += 1; } } @@ -4380,7 +4265,7 @@ impl PyDemSampler { lookup: &PyPauliFrameLookup, pauli_masks: &Bound<'_, pyo3::PyAny>, seed: Option, - ) -> PyResult { + ) -> PyResult<(Vec>, Vec>)> { use pecos_random::PecosRng; use rand::RngExt; @@ -4623,14 +4508,9 @@ impl PyDemSampler { for _ in 0..num_shots { let (det_events, obs_flips) = self.inner.sample(&mut rng); let syndrome: Vec = det_events.iter().map(|&b| u8::from(b)).collect(); - let mut predicted = decoder - .decode_obs(&syndrome) - .map_err(|e| PyErr::new::(e.to_string()))?; - predicted &= &observable_mask; - let true_mask = self - .inner - .observable_mask_from_dem_output_flips(&obs_flips, &observable_mask); - if predicted != true_mask { + let predicted_mask = decoder.decode_to_observables(&syndrome).unwrap_or(u64::MAX); + let true_mask = self.inner.observable_mask_from_dem_output_flips(&obs_flips); + if (predicted_mask & observable_mask) != true_mask { errors += 1; } } @@ -4704,13 +4584,10 @@ impl PyDemSampler { for _ in 0..my_shots { let (det_events, obs_flips) = my_sampler.sample(&mut my_rng); let syndrome: Vec = det_events.iter().map(|&b| u8::from(b)).collect(); - let mut predicted = decoder - .decode_obs(&syndrome) - .unwrap_or_else(|_| observable_mask.clone()); - predicted &= &observable_mask; - let truth = my_sampler - .observable_mask_from_dem_output_flips(&obs_flips, &observable_mask); - if predicted != truth { + let predicted = + decoder.decode_to_observables(&syndrome).unwrap_or(u64::MAX); + let truth = my_sampler.observable_mask_from_dem_output_flips(&obs_flips); + if (predicted & observable_mask) != truth { errors += 1; } } @@ -5508,52 +5385,6 @@ impl PyCssUfDecoder { /// ... "`fusion_blossom_serial`", /// ... ) /// >>> obs = decoder.decode(syndrome) -/// Convert a wide observable mask to a Python integer (arbitrary precision). -/// -/// `<= 64` observables become a plain `int` from the single `u64` (identical to -/// the historical return); `> 64` observables become a big `int` built from the -/// mask's little-endian words, with no truncation. -fn obsmask_to_py( - py: Python<'_>, - mask: &pecos_decoder_core::obs_mask::ObsMask, -) -> PyResult> { - if let Some(v) = mask.to_u64() { - return Ok(v.into_pyobject(py)?.into_any().unbind()); - } - let mut bytes = Vec::with_capacity(mask.words().len() * 8); - for &word in mask.words() { - bytes.extend_from_slice(&word.to_le_bytes()); - } - let py_bytes = pyo3::types::PyBytes::new(py, &bytes); - let int_type = py.get_type::(); - Ok(int_type - .call_method1("from_bytes", (py_bytes, "little"))? - .unbind()) -} - -/// Convert a Python integer (arbitrary precision) to a wide observable mask. -/// -/// Inverse of [`obsmask_to_py`]: reads the int's little-endian bytes and packs -/// them into `u64` words, so observable indices >= 64 are preserved. -fn py_to_obsmask( - value: &pyo3::Bound<'_, pyo3::PyAny>, -) -> PyResult { - let bit_length: usize = value.call_method0("bit_length")?.extract()?; - let nbytes = bit_length.div_ceil(8).max(1); - let bytes: Vec = value - .call_method1("to_bytes", (nbytes, "little"))? - .extract()?; - let words: Vec = bytes - .chunks(8) - .map(|chunk| { - let mut buf = [0u8; 8]; - buf[..chunk.len()].copy_from_slice(chunk); - u64::from_le_bytes(buf) - }) - .collect(); - Ok(pecos_decoder_core::obs_mask::ObsMask::from_words(&words)) -} - #[pyclass(name = "LogicalSubgraphDecoder", module = "pecos_rslib.qec")] pub struct PyLogicalSubgraphDecoder { inner: pecos_decoder_core::logical_subgraph::LogicalSubgraphDecoder, @@ -5668,17 +5499,11 @@ impl PyLogicalSubgraphDecoder { } /// Decode a syndrome and return observable flip predictions. - /// - /// Returns a Python ``int`` (bit ``i`` = observable ``i``). The integer is - /// arbitrary precision, so decoders with more than 64 observables are - /// returned without truncation. - fn decode(&mut self, py: Python<'_>, syndrome: Vec) -> PyResult> { + fn decode(&mut self, syndrome: Vec) -> PyResult { use pecos_decoder_core::ObservableDecoder; - let mask = self - .inner - .decode_obs(&syndrome) - .map_err(|e| PyErr::new::(e.to_string()))?; - obsmask_to_py(py, &mask) + self.inner + .decode_to_observables(&syndrome) + .map_err(|e| PyErr::new::(e.to_string())) } /// Number of observables this decoder handles. @@ -5701,21 +5526,16 @@ impl PyLogicalSubgraphDecoder { /// syndromes: 2D numpy array of shape (`num_shots`, `num_detectors`). /// /// Returns: - /// List of observable flip masks (one Python ``int`` per shot; arbitrary - /// precision, so more than 64 observables are not truncated). - fn decode_batch( - &mut self, - py: Python<'_>, - syndromes: Vec>, - ) -> PyResult>> { + /// List of observable flip masks (one per shot). + fn decode_batch(&mut self, syndromes: Vec>) -> PyResult> { use pecos_decoder_core::ObservableDecoder; let mut results = Vec::with_capacity(syndromes.len()); for syn in &syndromes { - let mask = self + let obs = self .inner - .decode_obs(syn) + .decode_to_observables(syn) .map_err(|e| PyErr::new::(e.to_string()))?; - results.push(obsmask_to_py(py, &mask)?); + results.push(obs); } Ok(results) } @@ -5737,8 +5557,8 @@ impl PyLogicalSubgraphDecoder { s }) .collect(); - let observable_masks: Vec = (0..batch.num_shots) - .map(|i| batch.extract_obs_mask_wide(i)) + let observable_masks: Vec = (0..batch.num_shots) + .map(|i| batch.extract_obs_mask(i)) .collect(); self.inner .decode_count_batched(&detection_events, &observable_masks) @@ -5797,8 +5617,7 @@ impl PyLogicalSubgraphDecoder { s }) .collect(); - let masks: Vec = - (0..n).map(|i| batch.extract_obs_mask_wide(i)).collect(); + let masks: Vec = (0..n).map(|i| batch.extract_obs_mask(i)).collect(); let pool = rayon::ThreadPoolBuilder::new() .num_threads(num_workers.unwrap_or(0)) @@ -5834,8 +5653,7 @@ impl PyLogicalSubgraphDecoder { // Collect chunk syndromes and masks for batch decode let chunk_syns: Vec> = chunk.iter().map(|&i| events[i].clone()).collect(); - let chunk_masks: Vec = - chunk.iter().map(|&i| masks[i].clone()).collect(); + let chunk_masks: Vec = chunk.iter().map(|&i| masks[i]).collect(); dec.decode_count_batched(&chunk_syns, &chunk_masks) }) .try_reduce(|| 0, |a, b| Ok(a + b)) @@ -6001,13 +5819,11 @@ impl PyWindowedLogicalSubgraphDecoder { Ok(Self { inner }) } - fn decode(&mut self, py: Python<'_>, syndrome: Vec) -> PyResult> { + fn decode(&mut self, syndrome: Vec) -> PyResult { use pecos_decoder_core::ObservableDecoder; - let mask = self - .inner - .decode_obs(&syndrome) - .map_err(|e| PyErr::new::(e.to_string()))?; - obsmask_to_py(py, &mask) + self.inner + .decode_to_observables(&syndrome) + .map_err(|e| PyErr::new::(e.to_string())) } fn decode_count(&mut self, batch: &PySampleBatch) -> PyResult { @@ -6018,9 +5834,9 @@ impl PyWindowedLogicalSubgraphDecoder { batch.extract_syndrome(i, &mut syndrome); let predicted = self .inner - .decode_obs(&syndrome) + .decode_to_observables(&syndrome) .map_err(|e| PyErr::new::(e.to_string()))?; - if predicted != batch.extract_obs_mask_wide(i) { + if predicted != batch.extract_obs_mask(i) { errors += 1; } } @@ -6226,33 +6042,32 @@ impl PyLogicalAlgorithmDecoder { // -- Batch mode -- - /// Decode a single syndrome and return the observable flip mask as a Python - /// ``int`` (arbitrary precision; more than 64 observables are not truncated). - fn decode(&mut self, py: Python<'_>, syndrome: Vec) -> PyResult> { + /// Decode a single syndrome and return observable flip mask. + fn decode(&mut self, syndrome: Vec) -> PyResult { self.inner.reset(); - let mask = self - .inner - .decode_shot_obs(&syndrome) - .map_err(|e| PyErr::new::(e.to_string()))?; - obsmask_to_py(py, &mask) + self.inner + .decode_shot(&syndrome) + .map_err(|e| PyErr::new::(e.to_string())) } - /// Decode a batch of samples and count logical errors (wide observable masks). + /// Decode a batch of samples and count logical errors. fn decode_count(&mut self, batch: &PySampleBatch) -> PyResult { - let mut errors = 0usize; - let mut syndrome = vec![0u8; batch.num_detectors]; - for i in 0..batch.num_shots { - batch.extract_syndrome(i, &mut syndrome); - self.inner.reset(); - let predicted = self - .inner - .decode_shot_obs(&syndrome) - .map_err(|e| PyErr::new::(e.to_string()))?; - if predicted != batch.extract_obs_mask_wide(i) { - errors += 1; - } - } - Ok(errors) + let detection_events: Vec> = (0..batch.num_shots) + .map(|i| { + let mut s = vec![0u8; batch.num_detectors]; + batch.extract_syndrome(i, &mut s); + s + }) + .collect(); + let observable_masks: Vec = (0..batch.num_shots) + .map(|i| batch.extract_obs_mask(i)) + .collect(); + pecos_decoder_core::logical_algorithm::streaming_decode_count( + &mut self.inner, + &detection_events, + &observable_masks, + ) + .map_err(|e| PyErr::new::(e.to_string())) } // -- Streaming mode -- @@ -6632,33 +6447,29 @@ impl PyLogicalCircuitDecoder { self.can_window } - /// Decode a single syndrome. Returns a Python ``int`` (arbitrary precision; - /// more than 64 observables are not truncated). - fn decode(&mut self, py: Python<'_>, syndrome: Vec) -> PyResult> { + /// Decode a single syndrome. + fn decode(&mut self, syndrome: Vec) -> PyResult { use pecos_decoder_core::ObservableDecoder; - let mask = self - .inner - .decode_obs(&syndrome) - .map_err(|e| PyErr::new::(e.to_string()))?; - obsmask_to_py(py, &mask) + self.inner + .decode_to_observables(&syndrome) + .map_err(|e| PyErr::new::(e.to_string())) } - /// Decode a batch and count errors (wide observable masks). + /// Decode a batch and count errors. fn decode_count(&mut self, batch: &PySampleBatch) -> PyResult { - use pecos_decoder_core::ObservableDecoder; - let mut errors = 0usize; - let mut syndrome = vec![0u8; batch.num_detectors]; - for i in 0..batch.num_shots { - batch.extract_syndrome(i, &mut syndrome); - let predicted = self - .inner - .decode_obs(&syndrome) - .map_err(|e| PyErr::new::(e.to_string()))?; - if predicted != batch.extract_obs_mask_wide(i) { - errors += 1; - } - } - Ok(errors) + let detection_events: Vec> = (0..batch.num_shots) + .map(|i| { + let mut s = vec![0u8; batch.num_detectors]; + batch.extract_syndrome(i, &mut s); + s + }) + .collect(); + let observable_masks: Vec = (0..batch.num_shots) + .map(|i| batch.extract_obs_mask(i)) + .collect(); + self.inner + .decode_count(&detection_events, &observable_masks) + .map_err(|e| PyErr::new::(e.to_string())) } /// Number of segments. diff --git a/python/quantum-pecos/src/pecos/_engine_builders.py b/python/quantum-pecos/src/pecos/_engine_builders.py index c73f7f919..3c0115e24 100644 --- a/python/quantum-pecos/src/pecos/_engine_builders.py +++ b/python/quantum-pecos/src/pecos/_engine_builders.py @@ -233,7 +233,12 @@ def qis_engine() -> QisEngineBuilder: def _looks_like_library_path(value: str) -> bool: path = Path(value) - return path.exists() or "/" in value or "\\" in value or path.suffix in {".so", ".dylib", ".dll"} + return ( + path.exists() + or "/" in value + or "\\" in value + or path.suffix in {".so", ".dylib", ".dll"} + ) def _configure_selene_runtime(builder: object, runtime: object | None) -> object: diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index fd2d5bcb7..99e9d0bec 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -35,7 +35,7 @@ class _ModuleState: # Keyed by full patch identity + effective budget (dx, dz, orientation, # rotated, effective_budget) so distinct patch geometries -- e.g. rotated # vs non-rotated at the same dx/dz -- never collide on a cached module. - distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str, str, str | None, str], dict]] = {} + distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str, str, str | None, str, bool, int | None], dict]] = {} _state = _ModuleState() @@ -255,6 +255,7 @@ def generate_guppy_source( from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget from pecos.qec.surface._check_plan import ( ancilla_schedule_for_check_plan, + cnot_round_order_for_check_plan, require_current_surface_check_plan_renderer, ) from pecos.qec.surface.circuit_builder import ( @@ -278,9 +279,13 @@ def generate_guppy_source( context="Guppy surface-code source generation", ) ancilla_schedule = ancilla_schedule_for_check_plan(resolved_plan) + cnot_round_order = cnot_round_order_for_check_plan(resolved_plan) interaction_basis = resolved_plan.interaction_basis szz_runtime_barrier_policy = _normalize_szz_runtime_barrier_policy(szz_runtime_barriers) - if interaction_basis != "szz" and szz_runtime_barrier_policy != _SZZ_RUNTIME_BARRIER_POLICY_NONE: + if ( + interaction_basis != "szz" + and szz_runtime_barrier_policy != _SZZ_RUNTIME_BARRIER_POLICY_NONE + ): msg = "szz_runtime_barriers is only supported for interaction_basis='szz'" raise ValueError(msg) resolved_clifford_frame = _resolve_szz_clifford_frame_for_builder( @@ -674,7 +679,10 @@ def _szz_guppy_physical_prefix_for_pending( try: return _SZZ_FLOW_PHYSICAL_PREFIX_BY_PENDING[pending] except KeyError as exc: - msg = f"SZZ Guppy hosted-prefix lowering cannot lower pending Clifford {_szz_flow_clifford_name(pending)}" + msg = ( + "SZZ Guppy hosted-prefix lowering cannot lower pending " + f"Clifford {_szz_flow_clifford_name(pending)}" + ) raise ValueError(msg) from exc _szz_guppy_prefix_cache: dict[tuple[int, int], tuple[OpType, ...]] = {_SZZ_FLOW_IDENTITY: ()} @@ -780,7 +788,7 @@ def _append_szz_logical_pauli(target: list[str], indent: str, axis: str, qubit_e lines.extend(["", ""]) # Generate syndrome extraction with the selected parallel interaction schedule. - rounds = compute_cnot_schedule(patch) + rounds = compute_cnot_schedule(patch, round_order=cnot_round_order) szz_sign_by_touch: dict[tuple[str, int, int], int] = {} if interaction_basis == "szz": residual_plan = _szz_residual_plan_for_check_plan(patch, resolved_plan) @@ -847,15 +855,20 @@ def discharge_data_for_szz( if pending_by_data is None else pending_by_data.setdefault(data_q, _SZZ_FLOW_IDENTITY) ) - has_data_prefix = pending != _SZZ_FLOW_IDENTITY and not _szz_flow_is_virtual_z(pending) + has_data_prefix = ( + pending != _SZZ_FLOW_IDENTITY and not _szz_flow_is_virtual_z(pending) + ) sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] host_gate = OpType.SZZ if sign > 0 else OpType.SZZDG host_label_core = f"r{rnd_idx + 1}:{stab_type}{stab_idx}:d{data_q}:{host_gate.name}" host_label = ( - f"szz:{host_label_core}" if host_label_scope is None else f"szz:{host_label_scope}:{host_label_core}" + f"szz:{host_label_core}" + if host_label_scope is None + else f"szz:{host_label_scope}:{host_label_core}" ) if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_ALL or ( - szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX and has_data_prefix + szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX + and has_data_prefix ): target.append( f"{indent}{ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)} = " @@ -1078,7 +1091,9 @@ def discharge_data_for_szz( " ", rnd_idx, rnd_in_batch, - lambda stab_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[(stab_type, stab_idx)], + lambda stab_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[ + (stab_type, stab_idx) + ], _szz_data_expr, pending_by_data=szz_syndrome_pending_by_data, host_label_scope="syndrome_extraction", @@ -1276,13 +1291,13 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: " ", rnd_idx, rnd_in_batch, - lambda selected_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[ - (selected_type, stab_idx) - ], - _szz_data_expr, - pending_by_data=szz_init_pending_by_data, - host_label_scope=function_name, - ) + lambda selected_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[ + (selected_type, stab_idx) + ], + _szz_data_expr, + pending_by_data=szz_init_pending_by_data, + host_label_scope=function_name, + ) if interaction_basis == "cx": if stab_type == "X": @@ -1500,7 +1515,9 @@ def _append_inline_szz_syndrome_extraction( indent, rnd_idx, rnd_in_batch, - lambda stab_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[(stab_type, stab_idx)], + lambda stab_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[ + (stab_type, stab_idx) + ], _szz_data_expr, pending_by_data=pending_by_data, host_label_scope=host_label_scope, @@ -1541,7 +1558,10 @@ def _render_plain_szz_round_helper(round_idx: int) -> list[str]: f"def {helper_name}(surf: SurfaceCode_{dx}x{dz} @ owned) " f"-> tuple[SurfaceCode_{dx}x{dz}, Syndrome_{dx}x{dz}]:" ), - f' """Extract counted SZZ syndrome round {round_idx} with round-scoped hosted metadata."""', + ( + f' """Extract counted SZZ syndrome round {round_idx} ' + 'with round-scoped hosted metadata."""' + ), ] _append_inline_szz_syndrome_extraction( body, @@ -2302,14 +2322,19 @@ def _guppy_module_cache_key( ) interaction_basis = resolved_plan.interaction_basis szz_runtime_barrier_policy = _normalize_szz_runtime_barrier_policy(szz_runtime_barriers) - if interaction_basis != "szz" and szz_runtime_barrier_policy != _SZZ_RUNTIME_BARRIER_POLICY_NONE: + if ( + interaction_basis != "szz" + and szz_runtime_barrier_policy != _SZZ_RUNTIME_BARRIER_POLICY_NONE + ): msg = "szz_runtime_barriers is only supported for interaction_basis='szz'" raise ValueError(msg) interaction_part = "" if interaction_basis == "cx" else f"_ib{interaction_basis}" check_plan_part = "" if check_plan is None else f"_cp{resolved_plan.plan_id}" frame_part = "" if clifford_frame_policy is None else f"_cf{str(clifford_frame_policy).lower().replace('-', '_')}" runtime_barrier_part = ( - "" if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_NONE else f"_szzrb-{szz_runtime_barrier_policy}" + "" + if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_NONE + else f"_szzrb-{szz_runtime_barrier_policy}" ) trace_metadata_part = "" if trace_metadata else "_trace-metadata-off" base = ( @@ -2626,6 +2651,7 @@ def _round_scoped_surface_memory_factory( check_plan: str | None, clifford_frame_policy: str | None, szz_runtime_barriers: bool | str, + trace_metadata: bool, ) -> Callable[[int], object]: """Memory factory that scopes each call to a round-specific Guppy module. @@ -2645,6 +2671,7 @@ def factory(num_rounds: int) -> object: check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, szz_runtime_barriers=szz_runtime_barriers, + trace_metadata=trace_metadata, ) return scoped[f"make_memory_{basis}"](num_rounds) @@ -2680,9 +2707,6 @@ def _surface_code_module_for_patch( """ from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget - # Preserve the caller's original interaction-basis selector for the - # round-scoped factory wrappers below (the local is reassigned to the - # resolved basis just after). original_interaction_basis = interaction_basis resolved_plan = _resolve_surface_check_plan( interaction_basis=interaction_basis, @@ -2736,13 +2760,6 @@ def _surface_code_module_for_patch( module["resolved_check_plan_hash"] = resolved_plan.resolved_hash if num_rounds is None: - # Round-agnostic getter path: hand back memory factories that scope each - # call to a round-specific module. Calling e.g. make_memory_z(2) then - # make_memory_z(6) on a single round-agnostic module would define both - # factory-local @guppy bodies under the same module-qualified name, so - # guppylang would reuse the first compiled body (see - # _guppy_module_cache_key). Copy first so the shared _load_guppy_module - # namespace keeps its real factories for the round-scoped re-entry. module = dict(module) for basis in ("z", "x"): key = f"make_memory_{basis}" @@ -2754,7 +2771,8 @@ def _surface_code_module_for_patch( interaction_basis=original_interaction_basis, check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, - szz_runtime_barriers=szz_runtime_barriers, + szz_runtime_barriers=szz_runtime_barrier_policy, + trace_metadata=trace_metadata, ) _state.distance_module_cache[cache_key] = module @@ -2772,14 +2790,6 @@ def get_surface_code_module( ) -> dict: """Get a loaded surface code module for distance d. - The returned ``make_memory_z``/``make_memory_x`` factories scope each call to - a round-specific module: ``factory(n)`` always produces (and caches) the - experiment in a distinct ``pecos._generated.patch_..._r{n}`` module. The - round count is therefore taken solely from the factory argument -- there is - no separate module-level round count to keep in sync -- so building - experiments at several round counts in one process stays isolated (guppylang - caches compiled functions by module-qualified name). - Args: d: Code distance (must be odd >= 3) ancilla_budget: Optional cap on simultaneously live ancillas diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index fb1274d14..a711a19ba 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -37,6 +37,7 @@ EquivalenceResult, FaultLocation, InfluenceBuilder, + PauliFrameLookup, ParsedDem, PauliFrameLookup, assert_dems_equivalent, diff --git a/python/quantum-pecos/src/pecos/qec/reliable_observables.py b/python/quantum-pecos/src/pecos/qec/reliable_observables.py index 7b3554ae1..f8d9433b4 100644 --- a/python/quantum-pecos/src/pecos/qec/reliable_observables.py +++ b/python/quantum-pecos/src/pecos/qec/reliable_observables.py @@ -72,10 +72,9 @@ def reliable_observables(circuit: stim.Circuit) -> list[set[int]]: msg = f"`circuit` must be a stim.Circuit, got {type(circuit)}" raise TypeError(msg) - flat = circuit.flattened() - resets = _reset_pauli_regions(flat) + resets = _reset_pauli_regions(circuit) num_obs = circuit.num_observables - obs_regions = {o: _observing_region(flat, o) for o in range(num_obs)} + obs_regions = {o: _observing_region(circuit, o) for o in range(num_obs)} # A[reset, obs] = 1 iff the reset and the observable's region anticommute. a = np.zeros((len(resets), num_obs), dtype=np.uint8) @@ -93,12 +92,11 @@ def is_reliable(circuit: stim.Circuit, observable: set[int] | int) -> bool: A combination is reliable iff its region commutes with every reset. """ obs = {observable} if isinstance(observable, int) else set(observable) - flat = circuit.flattened() - resets = _reset_pauli_regions(flat) + resets = _reset_pauli_regions(circuit) # Combine the regions of the chosen observables by tick-wise Pauli product. combined: PauliRegion = {} for o in obs: - for tick, ps in _observing_region(flat, o).items(): + for tick, ps in _observing_region(circuit, o).items(): combined[tick] = combined[tick] * ps if tick in combined else ps return all(not _anticommute(r, combined) for r in resets.values()) @@ -108,12 +106,9 @@ def is_reliable(circuit: stim.Circuit, observable: set[int] | int) -> bool: # --------------------------------------------------------------------------- # -def _reset_pauli_regions(flat: stim.Circuit) -> dict[int, PauliRegion]: - """Per-reset single-tick Pauli region: the reset's Pauli on its qubit. - - ``flat`` must already be flattened (``circuit.flattened()``); callers flatten - once and share it across observables to avoid repeated flattening. - """ +def _reset_pauli_regions(circuit: stim.Circuit) -> dict[int, PauliRegion]: + """Per-reset single-tick Pauli region: the reset's Pauli on its qubit.""" + flat = circuit.flattened() n = flat.num_qubits resets: dict[int, PauliRegion] = {} reset_idx = 0 @@ -137,15 +132,14 @@ def _reset_pauli_regions(flat: stim.Circuit) -> dict[int, PauliRegion]: return resets -def _observing_region(flat: stim.Circuit, observable: int) -> PauliRegion: +def _observing_region(circuit: stim.Circuit, observable: int) -> PauliRegion: """Back-propagated observing region of one observable, via stim. - Rewrites the (already-flattened) circuit so only `observable` survives, - renamed to L0, then uses `stim.Circuit.detecting_regions` to get its - {tick: PauliString} region. + Rewrites the circuit so only `observable` survives, renamed to L0, then uses + `stim.Circuit.detecting_regions` to get its {tick: PauliString} region. """ new_circuit = stim.Circuit() - for instr in flat: + for instr in circuit.flattened(): if instr.name != "OBSERVABLE_INCLUDE": new_circuit.append(instr) continue @@ -153,15 +147,12 @@ def _observing_region(flat: stim.Circuit, observable: int) -> PauliRegion: continue new_circuit.append( stim.CircuitInstruction( - name="OBSERVABLE_INCLUDE", - gate_args=[0], - targets=instr.targets_copy(), - ), + name="OBSERVABLE_INCLUDE", gate_args=[0], targets=instr.targets_copy() + ) ) target = stim.DemTarget("L0") regions = new_circuit.detecting_regions( - targets=[target], - ignore_anticommutation_errors=True, + targets=[target], ignore_anticommutation_errors=True ) return regions.get(target, {}) diff --git a/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py index af61f2ca3..71314b8fa 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py @@ -50,7 +50,10 @@ def normalize_ancilla_schedule(ancilla_schedule: str | None = None) -> str: return DEFAULT_ANCILLA_SCHEDULE normalized = str(ancilla_schedule).lower().replace("_", "-") if normalized not in SUPPORTED_ANCILLA_SCHEDULES: - msg = f"ancilla_schedule must be one of {sorted(SUPPORTED_ANCILLA_SCHEDULES)}, got {ancilla_schedule!r}" + msg = ( + f"ancilla_schedule must be one of {sorted(SUPPORTED_ANCILLA_SCHEDULES)}, " + f"got {ancilla_schedule!r}" + ) raise ValueError(msg) return normalized diff --git a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py index 7cd4b355a..1b25d6af3 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py @@ -15,6 +15,12 @@ DEFAULT_ANCILLA_SCHEDULE, normalize_ancilla_schedule, ) +from pecos.qec.surface.schedule import ( + CNOT_ROUND_ORDER_1032, + CNOT_ROUND_ORDER_3102, + DEFAULT_CNOT_ROUND_ORDER, + normalize_cnot_round_order, +) CHECK_PLAN_METADATA_FORMAT = "pecos.surface.check_plan" CHECK_PLAN_METADATA_VERSION = 1 @@ -179,6 +185,66 @@ def _normalize_check_plan_id(check_plan: str) -> str: }, "prefix_policy": "forward_flow_virtual_z_v1", }, + "szz_balanced_data_round_order_3102_v1": { + "plan_id": "szz_balanced_data_round_order_3102_v1", + "interaction_basis": "szz", + "synthesis_identity": { + "family": "szz", + "szz_phase_pattern": "standard", + "interaction_order": "pecos-default", + "ancilla_schedule": "balanced-data-v1", + }, + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + "ancilla_batch_policy": "balanced-data-v1", + "round_order": CNOT_ROUND_ORDER_3102, + }, + "x_check": { + "template": "current_szz_x_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "z_check": { + "template": "current_szz_z_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "prefix_policy": "forward_flow_virtual_z_v1", + }, + "szz_balanced_data_round_order_1032_v1": { + "plan_id": "szz_balanced_data_round_order_1032_v1", + "interaction_basis": "szz", + "synthesis_identity": { + "family": "szz", + "szz_phase_pattern": "standard", + "interaction_order": "pecos-default", + "ancilla_schedule": "balanced-data-v1", + }, + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + "ancilla_batch_policy": "balanced-data-v1", + "round_order": CNOT_ROUND_ORDER_1032, + }, + "x_check": { + "template": "current_szz_x_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "z_check": { + "template": "current_szz_z_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "prefix_policy": "forward_flow_virtual_z_v1", + }, "szz_boundary_first_balanced_data_v1": { "plan_id": "szz_boundary_first_balanced_data_v1", "interaction_basis": "szz", @@ -301,6 +367,17 @@ def ancilla_schedule_for_check_plan(resolved_plan: ResolvedSurfaceCheckPlan) -> ) +def cnot_round_order_for_check_plan(resolved_plan: ResolvedSurfaceCheckPlan) -> str: + """Return the concrete windmill round-order policy encoded by a check plan.""" + schedule = resolved_plan.semantic_content.get("schedule", {}) + if not isinstance(schedule, dict): + msg = f"check_plan={resolved_plan.plan_id!r} has invalid schedule metadata" + raise TypeError(msg) + raw_round_order = str(schedule.get("round_order", DEFAULT_CNOT_ROUND_ORDER)) + normalize_cnot_round_order(raw_round_order) + return raw_round_order.lower().replace("_", "-") + + def require_current_surface_check_plan_renderer( resolved_plan: ResolvedSurfaceCheckPlan, *, @@ -317,6 +394,7 @@ def require_current_surface_check_plan_renderer( synthesis = resolved_plan.synthesis_identity schedule = semantic.get("schedule", {}) ancilla_schedule = ancilla_schedule_for_check_plan(resolved_plan) + round_order = cnot_round_order_for_check_plan(resolved_plan) if resolved_plan.interaction_basis == "cx": expected_synthesis = { @@ -351,6 +429,8 @@ def require_current_surface_check_plan_renderer( } if ancilla_schedule != DEFAULT_ANCILLA_SCHEDULE: expected_schedule["ancilla_batch_policy"] = ancilla_schedule + if round_order != DEFAULT_CNOT_ROUND_ORDER: + expected_schedule["round_order"] = round_order if synthesis != expected_synthesis or schedule != expected_schedule: msg = ( f"{context} cannot realize check_plan={resolved_plan.plan_id!r} " diff --git a/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py b/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py index 7967e670b..5269a4dd3 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py @@ -43,7 +43,9 @@ "checkerboard_zxxz", }, ) -_SUPPORTED_FRAME_POLICIES = _SUPPORTED_GLOBAL_FRAME_POLICIES | _SUPPORTED_CHECKERBOARD_FRAME_POLICIES +_SUPPORTED_FRAME_POLICIES = ( + _SUPPORTED_GLOBAL_FRAME_POLICIES | _SUPPORTED_CHECKERBOARD_FRAME_POLICIES +) @dataclass(frozen=True, order=True) @@ -210,7 +212,10 @@ def normalize_surface_frame_policy(policy: str) -> str: """Normalize and validate a named surface Clifford frame policy.""" normalized = str(policy).lower().replace("-", "_") if normalized not in _SUPPORTED_FRAME_POLICIES: - msg = f"unknown surface Clifford frame policy {policy!r}; expected one of {sorted(_SUPPORTED_FRAME_POLICIES)}" + msg = ( + f"unknown surface Clifford frame policy {policy!r}; expected one of " + f"{sorted(_SUPPORTED_FRAME_POLICIES)}" + ) raise ValueError(msg) return normalized @@ -239,7 +244,11 @@ def resolve_surface_clifford_frame( ) -> ResolvedSurfaceCliffordFrame: """Resolve source surface checks/logicals through a local Clifford frame.""" normalized = normalize_surface_frame_policy(policy) - frames = tuple(data_frames) if data_frames is not None else _surface_frame_for_policy(patch, normalized) + frames = ( + tuple(data_frames) + if data_frames is not None + else _surface_frame_for_policy(patch, normalized) + ) if len(frames) != patch.num_data: msg = f"data frame length {len(frames)} does not match patch.num_data={patch.num_data}" raise ValueError(msg) diff --git a/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py index 2f531d358..06da3d14e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py @@ -42,7 +42,10 @@ def extract_detection_events_and_observables( num_meas_meta = tick_circuit.get_meta("num_measurements") if num_meas_meta is None or num_meas_meta == "": - msg = "extract_detection_events_and_observables requires tick_circuit.get_meta('num_measurements') to be set" + msg = ( + "extract_detection_events_and_observables requires " + "tick_circuit.get_meta('num_measurements') to be set" + ) raise ValueError(msg) num_meas = int(num_meas_meta) @@ -51,7 +54,10 @@ def extract_detection_events_and_observables( for row in results: if len(row) != num_meas: - msg = f"result row has length {len(row)} but tick_circuit metadata declares num_measurements={num_meas}" + msg = ( + f"result row has length {len(row)} but tick_circuit metadata " + f"declares num_measurements={num_meas}" + ) raise ValueError(msg) fired_detectors: list[int] = [] diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py index 8d1ae6b13..c24fdf78e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py @@ -99,7 +99,10 @@ def validate_runtime_supported(self) -> None: encoding-incompatible behavior. """ if self.scheme not in _SUPPORTED_SCHEMES: - msg = f"TwirlConfig.scheme={self.scheme!r} is not supported; expected one of {_SUPPORTED_SCHEMES!r}" + msg = ( + f"TwirlConfig.scheme={self.scheme!r} is not supported; " + f"expected one of {_SUPPORTED_SCHEMES!r}" + ) raise ValueError(msg) if self.site_schedule not in _SUPPORTED_SITE_SCHEDULES: msg = ( diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 9c945abe0..27e375977 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -37,6 +37,7 @@ ) from pecos.qec.surface._check_plan import ( ancilla_schedule_for_check_plan, + cnot_round_order_for_check_plan, require_current_surface_check_plan_renderer, resolve_surface_check_plan, ) @@ -407,7 +408,9 @@ def _validate_szz_sign_vector( ) raise ValueError(msg) touch = next( - entry for entry in signs if entry.stabilizer_type == stabilizer_type and entry.data_qubit == data_qubit + entry + for entry in signs + if entry.stabilizer_type == stabilizer_type and entry.data_qubit == data_qubit ) gate = { ("X", 1): "SXDG", @@ -482,7 +485,10 @@ def _szz_memory_physical_axis( if resolved_clifford_frame is None: return source_basis # type: ignore[return-value] - axes = {frame.image(source_basis).axis for frame in resolved_clifford_frame.data_frames} + axes = { + frame.image(source_basis).axis + for frame in resolved_clifford_frame.data_frames + } if len(axes) != 1: msg = ( f"clifford frame policy {resolved_clifford_frame.policy!r} maps " @@ -934,6 +940,7 @@ def build_surface_code_circuit( context="abstract surface-code circuit generation", ) ancilla_schedule = ancilla_schedule_for_check_plan(resolved_plan) + cnot_round_order = cnot_round_order_for_check_plan(resolved_plan) interaction_basis = _normalize_interaction_basis(resolved_plan.interaction_basis) resolved_clifford_frame = _resolve_szz_clifford_frame_for_builder( patch, @@ -1030,7 +1037,7 @@ def emit_gate_local_twirl_layer( gate_twirl_site_idx += 1 # Get CNOT schedule - cnot_rounds = compute_cnot_schedule(patch) + cnot_rounds = compute_cnot_schedule(patch, round_order=cnot_round_order) if interaction_basis == "szz": if twirl is not None: @@ -1283,7 +1290,8 @@ def emit_gate_local_twirl_layer( cx_ops.append((data_q(data_idx), ancilla_q, f"Z{stab_idx}")) emit_gate_local_twirl_layer(ops, cx_ops) ops.extend( - SurfaceCircuitStep(OpType.CX, [control, target], label) for control, target, label in cx_ops + SurfaceCircuitStep(OpType.CX, [control, target], label) + for control, target, label in cx_ops ) ops.append(SurfaceCircuitStep(OpType.TICK)) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 5c6b8ee0b..933d6b8e5 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -914,14 +914,20 @@ def _validate_measurement_crosstalk_topology( return None if measurement_crosstalk_topology == "global_from_measurements": return measurement_crosstalk_topology - msg = "measurement_crosstalk_topology must be None, 'runtime_payloads', or 'global_from_measurements'" + msg = ( + "measurement_crosstalk_topology must be None, 'runtime_payloads', " + "or 'global_from_measurements'" + ) raise ValueError(msg) def _should_add_global_measurement_crosstalk_payload( measurement_crosstalk_topology: str | None, ) -> bool: - return _validate_measurement_crosstalk_topology(measurement_crosstalk_topology) == "global_from_measurements" + return ( + _validate_measurement_crosstalk_topology(measurement_crosstalk_topology) + == "global_from_measurements" + ) def _replay_qis_trace_into_tick_circuit( @@ -1656,7 +1662,10 @@ def _pauli_masks_as_int64(pauli_masks: Any) -> NDArray[np.int64]: """Return Pauli-mask input in the integer dtype accepted by Rust bindings.""" masks_arr = np.asarray(pauli_masks) if not np.issubdtype(masks_arr.dtype, np.integer): - msg = "pauli_masks must be an integer array with values 0=I, 1=X, 2=Y, 3=Z" + msg = ( + "pauli_masks must be an integer array with values " + "0=I, 1=X, 2=Y, 3=Z" + ) raise TypeError(msg) return np.asarray(masks_arr, dtype=np.int64) @@ -1935,7 +1944,9 @@ def _with_noise_compat( ) except TypeError as exc: unsupported = { - key: value for key, value in noise_kwargs.items() if key not in {"p_idle", "t1", "t2"} and value is not None + key: value + for key, value in noise_kwargs.items() + if key not in {"p_idle", "t1", "t2"} and value is not None } if unsupported: msg = ( @@ -2026,16 +2037,12 @@ def _surface_native_topology( tuple(_extract_measurement_order(tc)) if _metadata_uses_record_offsets(detectors_json, observables_json) else () ) num_measurements = int(tc.get_meta("num_measurements") or str(len(measurement_order))) - det_records = ( - [_metadata_record_offsets(detector, num_measurements) for detector in json.loads(detectors_json)] - if detectors_json - else [] - ) - obs_records = ( - [_metadata_record_offsets(observable, num_measurements) for observable in json.loads(observables_json)] - if observables_json - else [] - ) + det_records = [ + _metadata_record_offsets(detector, num_measurements) for detector in json.loads(detectors_json) + ] if detectors_json else [] + obs_records = [ + _metadata_record_offsets(observable, num_measurements) for observable in json.loads(observables_json) + ] if observables_json else [] pauli_frame_lookup = None num_pauli_sites = 0 @@ -2190,8 +2197,8 @@ def _cached_surface_native_dem_string( check_plan: str | None = None, resolved_check_plan_hash: str = "", clifford_frame_policy: str | None = None, - *, szz_runtime_barriers: bool | str = False, + *, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> str: @@ -2215,7 +2222,9 @@ def _cached_surface_native_dem_string( p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, ) szz_physical_prefixes = ( - interaction_basis == "szz" and circuit_source == "abstract" and (p1 > 0.0 or include_idle_gates) + interaction_basis == "szz" + and circuit_source == "abstract" + and (p1 > 0.0 or include_idle_gates) ) topology = _cached_surface_native_topology( patch_key, @@ -2979,7 +2988,9 @@ def _get_circuit_level_dem(self, basis: str) -> str: DEM string in Stim format """ dem_decomposition: NativeDemDecomposition = ( - "terminal_graphlike" if self.circuit_level_dem_mode == "native_terminal_graphlike" else "source_graphlike" + "terminal_graphlike" + if self.circuit_level_dem_mode == "native_terminal_graphlike" + else "source_graphlike" ) dem = generate_circuit_level_dem_from_builder( self.patch, @@ -4294,8 +4305,8 @@ def build_native_sampler( ] = "dem", # "mnm" accepted for compat, mapped to "influence_dem", check_plan: str | None = None, clifford_frame_policy: str | None = None, - *, szz_runtime_barriers: bool | str = False, + *, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> NativeSampler: @@ -4520,7 +4531,10 @@ def decode_native_samples( dem_str = dem if dem is not None else sampler.dem_string if dem_str is None: - msg = "decode_native_samples requires a DEM string; pass dem= or build the sampler with sampling_model='dem'" + msg = ( + "decode_native_samples requires a DEM string; " + "pass dem= or build the sampler with sampling_model='dem'" + ) raise ValueError(msg) masks_arr = _pauli_masks_as_int64(pauli_masks) if pauli_masks is not None else None @@ -4586,13 +4600,22 @@ def demask_pauli_frame_records( expected_obs = pauli_frame_lookup.num_observables expected_sites = pauli_frame_lookup.num_pauli_sites if events_arr.shape[1] != expected_det: - msg = f"raw_events width {events_arr.shape[1]} != pauli_frame_lookup.num_detectors {expected_det}" + msg = ( + f"raw_events width {events_arr.shape[1]} != " + f"pauli_frame_lookup.num_detectors {expected_det}" + ) raise ValueError(msg) if obs_arr.shape[1] != expected_obs: - msg = f"raw_obs width {obs_arr.shape[1]} != pauli_frame_lookup.num_observables {expected_obs}" + msg = ( + f"raw_obs width {obs_arr.shape[1]} != " + f"pauli_frame_lookup.num_observables {expected_obs}" + ) raise ValueError(msg) if masks_arr.shape[1] != expected_sites: - msg = f"pauli_masks width {masks_arr.shape[1]} != pauli_frame_lookup.num_pauli_sites {expected_sites}" + msg = ( + f"pauli_masks width {masks_arr.shape[1]} != " + f"pauli_frame_lookup.num_pauli_sites {expected_sites}" + ) raise ValueError(msg) det_xor, obs_xor = pauli_frame_lookup.compute_mask_xor(masks_arr) @@ -4657,7 +4680,10 @@ def _extract_pauli_masks_from_results( raise ValueError(msg) per_gate = results[tag] if len(per_gate) != num_shots: - msg = f"Pauli-mask tag {tag!r}: got {len(per_gate)} shots, expected {num_shots} shots" + msg = ( + f"Pauli-mask tag {tag!r}: got {len(per_gate)} shots, " + f"expected {num_shots} shots" + ) raise ValueError(msg) bits = np.asarray(per_gate, dtype=np.uint8) @@ -4700,7 +4726,10 @@ def _extract_pauli_masks_from_results( raise ValueError(msg) per_round = results[tag] if len(per_round) != num_shots: - msg = f"Pauli-mask tag {tag!r}: got {len(per_round)} shots, expected {num_shots} shots" + msg = ( + f"Pauli-mask tag {tag!r}: got {len(per_round)} shots, " + f"expected {num_shots} shots" + ) raise ValueError(msg) bits = np.asarray(per_round, dtype=np.uint8) @@ -4797,7 +4826,10 @@ def _extract_pauli_activations_from_results( raise ValueError(msg) per_gate = results[tag] if len(per_gate) != num_shots: - msg = f"Pauli-activation tag {tag!r}: got {len(per_gate)} shots, expected {num_shots} shots" + msg = ( + f"Pauli-activation tag {tag!r}: got {len(per_gate)} shots, " + f"expected {num_shots} shots" + ) raise ValueError(msg) bits = np.asarray(per_gate, dtype=bool) if bits.ndim != 2 or bits.shape[1] != 2: @@ -4822,7 +4854,10 @@ def _extract_pauli_activations_from_results( raise ValueError(msg) per_round = results[tag] if len(per_round) != num_shots: - msg = f"Pauli-activation tag {tag!r}: got {len(per_round)} shots, expected {num_shots} shots" + msg = ( + f"Pauli-activation tag {tag!r}: got {len(per_round)} shots, " + f"expected {num_shots} shots" + ) raise ValueError(msg) bits = np.asarray(per_round, dtype=bool) if bits.ndim != 2 or bits.shape[1] != num_data: diff --git a/python/quantum-pecos/src/pecos/qec/surface/schedule.py b/python/quantum-pecos/src/pecos/qec/surface/schedule.py index 8972fc934..2ad7473c3 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/schedule.py +++ b/python/quantum-pecos/src/pecos/qec/surface/schedule.py @@ -28,8 +28,50 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import Sequence + from pecos.qec.surface.patch import SurfacePatch +DEFAULT_CNOT_ROUND_ORDER = "default" +CNOT_ROUND_ORDER_3102 = "round-order-3102-v1" +CNOT_ROUND_ORDER_1032 = "round-order-1032-v1" +SUPPORTED_CNOT_ROUND_ORDERS = frozenset( + { + DEFAULT_CNOT_ROUND_ORDER, + CNOT_ROUND_ORDER_3102, + CNOT_ROUND_ORDER_1032, + }, +) + + +def normalize_cnot_round_order( + round_order: str | Sequence[int] | None = None, +) -> tuple[int, int, int, int]: + """Return a validated 4-layer windmill round order.""" + if round_order is None: + return (0, 1, 2, 3) + if isinstance(round_order, str): + normalized = round_order.lower().replace("_", "-") + if normalized == DEFAULT_CNOT_ROUND_ORDER: + return (0, 1, 2, 3) + if normalized == CNOT_ROUND_ORDER_3102: + return (3, 1, 0, 2) + if normalized == CNOT_ROUND_ORDER_1032: + return (1, 0, 3, 2) + msg = ( + f"round_order must be one of {sorted(SUPPORTED_CNOT_ROUND_ORDERS)} " + f"or a permutation of (0, 1, 2, 3), got {round_order!r}" + ) + raise ValueError(msg) + if tuple(round_order) not in { + (0, 1, 2, 3), + (3, 1, 0, 2), + (1, 0, 3, 2), + }: + msg = f"round_order must be a supported permutation of (0, 1, 2, 3), got {round_order!r}" + raise ValueError(msg) + return tuple(round_order) + def _classify_boundary(stab_type: str, data_qubits: tuple[int, ...], dx: int, dz: int) -> str: """Classify which boundary a weight-2 stabilizer sits on. @@ -107,11 +149,18 @@ def get_stab_schedule( raise ValueError(msg) -def compute_cnot_schedule(patch: SurfacePatch) -> list[list[tuple[str, int, int]]]: +def compute_cnot_schedule( + patch: SurfacePatch, + *, + round_order: str | Sequence[int] | None = None, +) -> list[list[tuple[str, int, int]]]: """Compute the 4-round parallel CNOT schedule for a surface code patch. Args: patch: A SurfacePatch instance. + round_order: Optional named or explicit permutation of the four windmill + touch layers. ``None`` and ``"default"`` preserve the historical + order ``(0, 1, 2, 3)``. Returns: List of 4 rounds, each a list of (stab_type, stab_index, data_qubit) @@ -136,4 +185,4 @@ def compute_cnot_schedule(patch: SurfacePatch) -> list[list[tuple[str, int, int] for rnd in rounds: rnd.sort(key=lambda g: (g[1], 0 if g[0] == "X" else 1)) - return rounds + return [rounds[index] for index in normalize_cnot_round_order(round_order)] diff --git a/python/quantum-pecos/src/pecos/quantum/hosted.py b/python/quantum-pecos/src/pecos/quantum/hosted.py index df642087a..009f88db7 100644 --- a/python/quantum-pecos/src/pecos/quantum/hosted.py +++ b/python/quantum-pecos/src/pecos/quantum/hosted.py @@ -139,7 +139,11 @@ def validate_hosted_operations( f"host gate{shared_clause} carrying the same host_id." ) raise ValueError(msg) - later_hosts = [candidate for candidate in host_candidates if _gate_order(candidate) > _gate_order(local)] + later_hosts = [ + candidate + for candidate in host_candidates + if _gate_order(candidate) > _gate_order(local) + ] if require_host_after_local and not later_hosts: nearest_host = host_candidates[-1] msg = ( @@ -202,12 +206,17 @@ def _raise_if_repeated_host_records( if record.local_role or not record.host_id: continue host_records_by_id.setdefault(record.host_id, []).append(record) - repeated = {host_id: host_records for host_id, host_records in host_records_by_id.items() if len(host_records) > 1} + repeated = { + host_id: host_records + for host_id, host_records in host_records_by_id.items() + if len(host_records) > 1 + } if not repeated: return host_id, host_records = next(iter(repeated.items())) first_locations = ", ".join( - f"{record.gate_name}@t{record.tick_index}/g{record.gate_index}" for record in host_records[:4] + f"{record.gate_name}@t{record.tick_index}/g{record.gate_index}" + for record in host_records[:4] ) extra_count = len(host_records) - 4 if extra_count > 0: @@ -284,7 +293,10 @@ def _iter_tick_gates( except AttributeError as exc: msg = f"{context}: expected TickCircuit ticks with gate_batches()." raise TypeError(msg) from exc - gate_locations.extend((tick_index, gate_index, gate) for gate_index, gate in enumerate(gate_batches)) + gate_locations.extend( + (tick_index, gate_index, gate) + for gate_index, gate in enumerate(gate_batches) + ) return tuple(gate_locations) diff --git a/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py b/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py index 3a9840e21..e811012c1 100644 --- a/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py +++ b/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py @@ -8,7 +8,7 @@ def test_hugr_to_llvm_compilation() -> None: try: from guppylang import guppy from guppylang.std.quantum import cx, h, measure, qubit - from pecos_rslib_llvm import compile_hugr_to_qis + from pecos_rslib import compile_hugr_to_qis except ImportError as e: pytest.skip(f"Required imports not available: {e}") @@ -40,7 +40,7 @@ def test_simple_hadamard_circuit() -> None: try: from guppylang import guppy from guppylang.std.quantum import h, measure, qubit - from pecos_rslib_llvm import compile_hugr_to_qis + from pecos_rslib import compile_hugr_to_qis except ImportError as e: pytest.skip(f"Required imports not available: {e}") @@ -69,7 +69,7 @@ def test_trace_metadata_helper_uses_public_symbol() -> None: from guppylang import guppy from guppylang.std.builtins import owned from guppylang.std.quantum import h, measure, qubit - from pecos_rslib_llvm import compile_hugr_to_qis + from pecos_rslib import compile_hugr_to_qis except ImportError as e: pytest.skip(f"Required imports not available: {e}") @@ -95,7 +95,7 @@ def test_runtime_barrier_pair_helper_uses_public_symbol() -> None: from guppylang import guppy from guppylang.std.builtins import owned from guppylang.std.quantum import cx, h, measure, qubit - from pecos_rslib_llvm import compile_hugr_to_qis + from pecos_rslib import compile_hugr_to_qis except ImportError as e: pytest.skip(f"Required imports not available: {e}") diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index 335f5fff4..cc52065e9 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -38,11 +38,15 @@ def _assert_szz_prefix_barrier_host_order(src: str) -> None: if "phased_x(" not in line: continue prefix_meta = next( - i for i in range(index - 1, -1, -1) if _line_has_trace_metadata(lines[i], "source_kind", "szz_data_prefix") + i + for i in range(index - 1, -1, -1) + if _line_has_trace_metadata(lines[i], "source_kind", "szz_data_prefix") ) barrier_index = next(i for i in range(prefix_meta - 1, -1, -1) if _SZZ_RUNTIME_BARRIER_CALL in lines[i]) host_meta = next( - i for i in range(index + 1, len(lines)) if _line_has_trace_metadata(lines[i], "source_kind", "szz_host") + i + for i in range(index + 1, len(lines)) + if _line_has_trace_metadata(lines[i], "source_kind", "szz_host") ) zz_phase_index = next(i for i in range(host_meta + 1, len(lines)) if "zz_phase(" in lines[i]) diff --git a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py index aa69079cc..46c9ce3df 100644 --- a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py +++ b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py @@ -16,6 +16,8 @@ from __future__ import annotations +from itertools import permutations + import pytest from pecos.qec.surface import SurfacePatch from pecos.qec.surface._ancilla_batching import ( @@ -24,6 +26,47 @@ normalize_ancilla_budget, normalize_ancilla_schedule, ) +from pecos.qec.surface.schedule import ( + CNOT_ROUND_ORDER_1032, + CNOT_ROUND_ORDER_3102, + compute_cnot_schedule, + normalize_cnot_round_order, +) + +StabilizerKey = tuple[str, int] +TouchGapMetrics = tuple[int, int] + + +def _touch_gap_metrics_for_batches( + patch: SurfacePatch, + batches: list[list[StabilizerKey]], + *, + round_order: tuple[int, int, int, int] = (0, 1, 2, 3), +) -> TouchGapMetrics: + cnot_rounds = compute_cnot_schedule(patch) + ordered_rounds = [cnot_rounds[index] for index in round_order] + events_by_data: dict[int, list[int]] = {} + time = 0 + for batch in batches: + batch_keys = set(batch) + for round_gates in ordered_rounds: + for stab_type, stab_idx, data_qubit in round_gates: + if (stab_type, stab_idx) in batch_keys: + events_by_data.setdefault(data_qubit, []).append(time) + time += 1 + period = time + worst_gap = 0 + squared_gap_sum = 0 + for touch_times in events_by_data.values(): + touch_times.sort() + gaps = [ + touch_times[index + 1] - touch_times[index] + for index in range(len(touch_times) - 1) + ] + gaps.append(period + touch_times[0] - touch_times[-1]) + worst_gap = max(worst_gap, *gaps) + squared_gap_sum += sum(gap * gap for gap in gaps) + return worst_gap, squared_gap_sum # --- normalize_ancilla_budget ----------------------------------------------- @@ -223,6 +266,95 @@ def test_balanced_data_schedule_spreads_d9_a17_batches() -> None: assert min(len(batch_indices) for batch_indices in touches_by_data.values()) > 1 +def test_cnot_round_order_candidates_permute_layers() -> None: + """The named round-order candidates preserve layer contents exactly.""" + patch = SurfacePatch.create(distance=5) + default_rounds = compute_cnot_schedule(patch) + + assert normalize_cnot_round_order(None) == (0, 1, 2, 3) + assert normalize_cnot_round_order("default") == (0, 1, 2, 3) + assert normalize_cnot_round_order(CNOT_ROUND_ORDER_3102) == (3, 1, 0, 2) + assert normalize_cnot_round_order(CNOT_ROUND_ORDER_1032) == (1, 0, 3, 2) + assert normalize_cnot_round_order((3, 1, 0, 2)) == (3, 1, 0, 2) + assert normalize_cnot_round_order((1, 0, 3, 2)) == (1, 0, 3, 2) + assert compute_cnot_schedule(patch, round_order=CNOT_ROUND_ORDER_3102) == [ + default_rounds[3], + default_rounds[1], + default_rounds[0], + default_rounds[2], + ] + assert compute_cnot_schedule(patch, round_order=CNOT_ROUND_ORDER_1032) == [ + default_rounds[1], + default_rounds[0], + default_rounds[3], + default_rounds[2], + ] + with pytest.raises(ValueError, match=r"round_order"): + normalize_cnot_round_order((0, 1, 3, 2)) + + +def test_balanced_data_d9_a17_batch_order_is_touch_gap_optimal() -> None: + """Batch reordering alone does not improve the d=9/a17 touch-gap proxy. + + This pins the result of the first idle-hotspot scheduler probe: the next + candidate should target lower-level touch/layer placement, not just + permuting the already-balanced ancilla batches. + """ + patch = SurfacePatch.create(distance=9) + balanced_batches = batched_stabilizers( + patch, + 17, + ancilla_schedule=BALANCED_DATA_ANCILLA_SCHEDULE, + ) + assert [len(batch) for batch in balanced_batches] == [16, 16, 16, 16, 16] + + baseline_metrics = _touch_gap_metrics_for_batches(patch, balanced_batches) + best_permutation_metrics = min( + _touch_gap_metrics_for_batches( + patch, + [balanced_batches[index] for index in permutation], + ) + for permutation in permutations(range(len(balanced_batches))) + ) + + assert baseline_metrics == best_permutation_metrics + + +def test_balanced_data_d9_a17_round_order_touch_gap_tradeoff() -> None: + """Round-order changes expose a real but correctness-gated tradeoff. + + The best max-gap round permutation improves the repeated-round data-touch + gap proxy, but increases the squared-gap proxy. Promoting such a candidate + needs a named check-plan schedule and hook/residual correctness checks, not + an ad hoc source-order edit. + """ + patch = SurfacePatch.create(distance=9) + balanced_batches = batched_stabilizers( + patch, + 17, + ancilla_schedule=BALANCED_DATA_ANCILLA_SCHEDULE, + ) + baseline_metrics = _touch_gap_metrics_for_batches(patch, balanced_batches) + + round_candidates = [ + (_touch_gap_metrics_for_batches(patch, balanced_batches, round_order=round_order), round_order) + for round_order in permutations(range(4)) + ] + best_max_gap = min(round_candidates) + best_sumsq_with_baseline_max_gap = min( + candidate + for candidate in round_candidates + if candidate[0][0] == baseline_metrics[0] + ) + + assert baseline_metrics == (14, 11292) + assert best_max_gap == ((13, 11464), normalize_cnot_round_order(CNOT_ROUND_ORDER_3102)) + assert best_sumsq_with_baseline_max_gap == ( + (14, 11220), + normalize_cnot_round_order(CNOT_ROUND_ORDER_1032), + ) + + # --- D1: pin emitted CX sequences for the constrained Guppy codegen -------- # The byte-identical traced-vs-traced DEM oracle and the lowered-qubit-stream # invariant catch many constrained-codegen errors, but not a wrong-CX-order / diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py index e3a85055a..8c83726b8 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -61,6 +61,8 @@ def test_check_plan_default_resolves_to_cx_metadata() -> None: assert surface_check_plan_ids() == ( "cx_balanced_data_v1", "cx_standard_v1", + "szz_balanced_data_round_order_1032_v1", + "szz_balanced_data_round_order_3102_v1", "szz_balanced_data_v1", "szz_boundary_first_balanced_data_v1", "szz_boundary_first_v1", @@ -80,12 +82,9 @@ def test_check_plan_default_resolves_to_cx_metadata() -> None: assert plan.resolved_metadata["hash_algorithm"] == "sha256" assert plan.resolved_metadata["hash_serialization"] == "canonical-json-v1" assert "metadata_version" not in plan.semantic_content - assert ( - plan.resolved_hash - == hashlib.sha256( - canonical_check_plan_json(plan.semantic_content).encode("utf-8"), - ).hexdigest() - ) + assert plan.resolved_hash == hashlib.sha256( + canonical_check_plan_json(plan.semantic_content).encode("utf-8"), + ).hexdigest() def test_check_plan_is_source_of_truth_for_basis() -> None: @@ -153,6 +152,40 @@ def test_balanced_data_check_plans_resolve_to_explicit_schedule( require_current_surface_check_plan_renderer(plan, context="unit-test") +def test_round_order_check_plan_resolves_to_explicit_schedule() -> None: + from pecos.qec.surface._check_plan import ( + cnot_round_order_for_check_plan, + require_current_surface_check_plan_renderer, + resolve_surface_check_plan, + ) + from pecos.qec.surface.schedule import CNOT_ROUND_ORDER_1032, CNOT_ROUND_ORDER_3102 + + cases = [ + ("szz_balanced_data_round_order_3102_v1", CNOT_ROUND_ORDER_3102), + ("szz_balanced_data_round_order_1032_v1", CNOT_ROUND_ORDER_1032), + ] + + for plan_id, expected_round_order in cases: + plan = resolve_surface_check_plan(check_plan=plan_id) + + assert plan.interaction_basis == "szz" + assert plan.synthesis_identity == { + "family": "szz", + "szz_phase_pattern": "standard", + "interaction_order": "pecos-default", + "ancilla_schedule": "balanced-data-v1", + } + assert plan.semantic_content["schedule"] == { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + "ancilla_batch_policy": "balanced-data-v1", + "round_order": expected_round_order, + } + assert cnot_round_order_for_check_plan(plan) == expected_round_order + require_current_surface_check_plan_renderer(plan, context="unit-test") + + def test_current_renderer_rejects_unimplemented_plan_semantics() -> None: from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan @@ -227,6 +260,8 @@ def test_guppy_surface_code_accepts_check_plan_as_source_of_truth() -> None: [ "cx_balanced_data_v1", "szz_balanced_data_v1", + "szz_balanced_data_round_order_1032_v1", + "szz_balanced_data_round_order_3102_v1", "szz_boundary_first_balanced_data_v1", ], ) @@ -510,7 +545,11 @@ def test_boundary_first_szz_check_plan_changes_source_gates_not_metadata() -> No ) def szz_gate_signature(ops: object) -> list[tuple[str, tuple[int, ...], str]]: - return [(op.op_type.name, tuple(op.qubits), op.label) for op in ops if op.op_type in {OpType.SZZ, OpType.SZZDG}] + return [ + (op.op_type.name, tuple(op.qubits), op.label) + for op in ops + if op.op_type in {OpType.SZZ, OpType.SZZDG} + ] assert szz_gate_signature(boundary_first_ops) != szz_gate_signature(current_ops) assert sum(op.op_type == OpType.SZZDG for op in boundary_first_ops) == sum( @@ -534,6 +573,102 @@ def szz_gate_signature(ops: object) -> list[tuple[str, tuple[int, ...], str]]: assert "Check plan: szz_boundary_first_v1" in source +@pytest.mark.parametrize( + ("round_order_plan", "expected_hosts"), + [ + ( + "szz_balanced_data_round_order_3102_v1", + [ + "szz:syndrome_extraction:r1:X0:d0:SZZ", + "szz:syndrome_extraction:r1:Z3:d5:SZZDG", + "szz:syndrome_extraction:r4:X0:d1:SZZDG", + "szz:syndrome_extraction:r4:Z3:d2:SZZ", + ], + ), + ( + "szz_balanced_data_round_order_1032_v1", + [ + "szz:syndrome_extraction:r3:X0:d0:SZZ", + "szz:syndrome_extraction:r3:Z3:d5:SZZDG", + "szz:syndrome_extraction:r4:X0:d1:SZZDG", + "szz:syndrome_extraction:r4:Z3:d2:SZZ", + ], + ), + ], +) +def test_round_order_szz_check_plan_changes_host_order_not_metadata( + round_order_plan: str, + expected_hosts: list[str], +) -> None: + from pecos.guppy.surface import generate_guppy_source + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.circuit_builder import ( + OpType, + build_surface_code_circuit, + generate_tick_circuit_from_patch, + ) + + patch = SurfacePatch.create(distance=3) + baseline_plan = "szz_balanced_data_v1" + + def szz_gate_signature(plan_id: str) -> list[tuple[str, tuple[int, ...], str]]: + ops, _ = build_surface_code_circuit( + patch, + num_rounds=1, + ancilla_budget=2, + check_plan=plan_id, + ) + return [ + (op.label, tuple(op.qubits), op.op_type.name) + for op in ops + if op.op_type in {OpType.SZZ, OpType.SZZDG} + ] + + baseline_signature = szz_gate_signature(baseline_plan) + round_order_signature = szz_gate_signature(round_order_plan) + assert round_order_signature != baseline_signature + assert round_order_signature[:4] == [ + ("X0", (9, 0), "SZZ"), + ("X0", (9, 1), "SZZDG"), + ("X3", (9, 7), "SZZ"), + ("X3", (9, 8), "SZZDG"), + ] + + baseline_tick = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + ancilla_budget=2, + check_plan=baseline_plan, + ) + round_order_tick = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + ancilla_budget=2, + check_plan=round_order_plan, + ) + assert round_order_tick.get_meta("detectors") == baseline_tick.get_meta("detectors") + assert round_order_tick.get_meta("observables") == baseline_tick.get_meta("observables") + + def guppy_syndrome_host_ids(plan_id: str) -> list[str]: + source = generate_guppy_source( + patch, + num_rounds=1, + ancilla_budget=2, + check_plan=plan_id, + ) + return [ + str(metadata["host_id"]) + for _, metadata in _packed_trace_metadata_records(source) + if metadata.get("source_kind") == "szz_host" + and str(metadata.get("host_id", "")).startswith("szz:syndrome_extraction:") + ] + + baseline_hosts = guppy_syndrome_host_ids(baseline_plan) + round_order_hosts = guppy_syndrome_host_ids(round_order_plan) + assert round_order_hosts != baseline_hosts + assert round_order_hosts[:4] == expected_hosts + + def test_direct_surface_renderers_reject_plan_basis_mismatch() -> None: from pecos.qec.surface import SurfacePatch from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch diff --git a/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py b/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py index e8fd46fb6..88bd2c0a9 100644 --- a/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py +++ b/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py @@ -852,15 +852,12 @@ def test_logical_subgraph_better_than_naive_on_cx(self, patch, nq): subgraph_errors = sum( 1 for i in range(20000) - if decoder.decode(det_events[i].tolist()) - != sum((1 << j) for j in range(obs_flips.shape[1]) if obs_flips[i, j]) + if decoder.decode(det_events[i].tolist()) != sum((1 << j) for j in range(obs_flips.shape[1]) if obs_flips[i, j]) ) subgraph_ler = subgraph_errors / 20000 # logical-subgraph decoder should be at least as good (usually much better) - assert ( - subgraph_ler <= naive_ler * 1.5 + 0.001 - ), f"logical-subgraph decoder ({subgraph_ler:.5f}) much worse than naive ({naive_ler:.5f})" + assert subgraph_ler <= naive_ler * 1.5 + 0.001, f"logical-subgraph decoder ({subgraph_ler:.5f}) much worse than naive ({naive_ler:.5f})" # --------------------------------------------------------------------------- diff --git a/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py index 401d0fbff..e2c09de11 100644 --- a/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py +++ b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py @@ -157,8 +157,16 @@ def test_checkerboard_frames_emit_mixed_szz_check_scaffold( clifford_frame_policy=policy, ) - assert any(op.op_type == OpType.H and op.label == f"prep_x_basis_d{rotated_data}:to_z" for op in ops) - assert any(op.op_type == OpType.H and op.label == f"measure_x_basis_d{rotated_data}:from_z" for op in ops) + assert any( + op.op_type == OpType.H + and op.label == f"prep_x_basis_d{rotated_data}:to_z" + for op in ops + ) + assert any( + op.op_type == OpType.H + and op.label == f"measure_x_basis_d{rotated_data}:from_z" + for op in ops + ) assert not any(op.label == f"prep_x_basis_d{unrotated_data}:to_z" for op in ops) assert any(f"szz_x_touch_pre:X1:d{x_touch_data}:to_z" in op.label for op in ops) assert any(f"szz_touch_comp:SXDG:X:X1:d{x_touch_data}" in op.label for op in ops) diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_lomatching_parity.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_lomatching_parity.py index ae41cff8c..4975b2ca9 100644 --- a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_lomatching_parity.py +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_lomatching_parity.py @@ -44,7 +44,9 @@ def _lomatching_reference_membership(dem_str: str, stab_coords) -> list[list[int """ dem = stim.DetectorErrorModel(dem_str).flattened() - det_to_coords = {d: tuple(map(float, c)) for d, c in dem.get_detector_coordinates().items()} + det_to_coords = { + d: tuple(map(float, c)) for d, c in dem.get_detector_coordinates().items() + } coords_to_det = {c: d for d, c in det_to_coords.items()} # spatial coord (all but the trailing time element) -> (logical qubit, stab type) @@ -66,7 +68,9 @@ def _lomatching_reference_membership(dem_str: str, stab_coords) -> list[list[int bd_edges_obs[o] += dets # (logical qubit, stab type, time) seeds for each observable - lst_obs: dict[int, set[tuple[int, str, float]]] = {o: set() for o in range(dem.num_observables)} + lst_obs: dict[int, set[tuple[int, str, float]]] = { + o: set() for o in range(dem.num_observables) + } for obs, dets in bd_edges_obs.items(): for det in dets: coords = det_to_coords[det] @@ -90,7 +94,9 @@ def _assert_parity(dem_str: str, stab_coords) -> None: pecos = [sorted(r) for r in decoder.observing_regions()] reference = _lomatching_reference_membership(dem_str, stab_coords) - assert len(pecos) == len(reference), f"observable count mismatch: PECOS {len(pecos)} vs lomatching {len(reference)}" + assert len(pecos) == len(reference), ( + f"observable count mismatch: PECOS {len(pecos)} vs lomatching {len(reference)}" + ) for obs, (p, r) in enumerate(zip(pecos, reference, strict=True)): assert p == r, f"observable {obs} membership differs:\n PECOS={p}\n lomatching={r}" diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py index bc92b49ea..80f97001e 100644 --- a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py @@ -82,7 +82,8 @@ def _groupfill_membership(dem_str: str, stab_coords, *, seed_all: bool) -> list[ elif ln.startswith("error("): toks = ln[ln.index(")") + 1 :].split() mechs.append( - ([int(t[1:]) for t in toks if t.startswith("D")], [int(t[1:]) for t in toks if t.startswith("L")]), + ([int(t[1:]) for t in toks if t.startswith("D")], + [int(t[1:]) for t in toks if t.startswith("L")]), ) coords_to_stab: dict[tuple, tuple[int, str]] = {} @@ -164,12 +165,14 @@ def test_coordinate_region_beats_raw_backprop_region(): # The coordinate group-fill region is dramatically better. The gap is large # and stable (~20x at d=3, p=0.001); assert a conservative margin. - assert ( - coord_ler < backprop_ler - ), f"expected coordinate region to beat raw back-prop: coord={coord_ler:.5f} backprop={backprop_ler:.5f}" - assert ( - coord_ler * 5 < backprop_ler - ), f"expected a large gap (group-fill essential): coord={coord_ler:.5f} backprop={backprop_ler:.5f}" + assert coord_ler < backprop_ler, ( + f"expected coordinate region to beat raw back-prop: " + f"coord={coord_ler:.5f} backprop={backprop_ler:.5f}" + ) + assert coord_ler * 5 < backprop_ler, ( + f"expected a large gap (group-fill essential): " + f"coord={coord_ler:.5f} backprop={backprop_ler:.5f}" + ) def test_coordinate_seeding_reproduces_shipping_path(): @@ -208,7 +211,9 @@ def test_coordinate_beats_backprop_seeded_groupfill(): backprop = LogicalSubgraphDecoder.from_membership(dem, backprop_membership, "pecos_uf:fast") coord_ler = coord.decode_count(batch) / n backprop_ler = backprop.decode_count(batch) / n - assert coord_ler < backprop_ler, f"coord={coord_ler:.5f} backprop-seeds={backprop_ler:.5f}" + assert coord_ler < backprop_ler, ( + f"coord={coord_ler:.5f} backprop-seeds={backprop_ler:.5f}" + ) def _mem_ler(d, p, n, seed, inner=None): @@ -232,7 +237,9 @@ def test_distance_suppression_memory(): ler_d3 = _mem_ler(3, p, n, seed=1) ler_d5 = _mem_ler(5, p, n, seed=1) # Below threshold, d=5 must beat d=3 by a clear margin (lomatching: ~13x). - assert ler_d5 < ler_d3 * 0.7, f"no distance suppression with default inner: d3={ler_d3:.5f} d5={ler_d5:.5f}" + assert ler_d5 < ler_d3 * 0.7, ( + f"no distance suppression with default inner: d3={ler_d3:.5f} d5={ler_d5:.5f}" + ) def test_native_bp_uf_inner_suppresses(): @@ -258,7 +265,9 @@ def test_native_bp_uf_inner_suppresses(): uf_d3 = _mem_ler(3, p, n, seed=1, inner="pecos_uf:bp") uf_d5 = _mem_ler(5, p, n, seed=1, inner="pecos_uf:bp") # Below threshold, d=5 must beat d=3 by a clear margin. - assert uf_d5 < uf_d3 * 0.7, f"default bp+uf inner no longer suppresses: d3={uf_d3:.5f} d5={uf_d5:.5f}" + assert uf_d5 < uf_d3 * 0.7, ( + f"default bp+uf inner no longer suppresses: d3={uf_d3:.5f} d5={uf_d5:.5f}" + ) def _mem_dem_batch(d, p, n, seed): @@ -339,10 +348,9 @@ def test_windowed_logical_subgraph_single_window_matches_nonwindowed(): non = _nonwindowed_mem_ler(d, rounds, p, n, seed=7, inner="pecos_uf:fast") assert nwin == 1, f"expected a single window, got {nwin}" # Same decode up to negligible tie-breaking differences. - assert abs(win - non) <= max( - 0.0005, - 0.15 * non, - ), f"single-window windowed != non-windowed at d={d}: win={win:.5f} non={non:.5f}" + assert abs(win - non) <= max(0.0005, 0.15 * non), ( + f"single-window windowed != non-windowed at d={d}: win={win:.5f} non={non:.5f}" + ) def test_windowed_logical_subgraph_known_limitation_no_full_suppression(): @@ -379,19 +387,18 @@ def test_windowed_logical_subgraph_known_limitation_no_full_suppression(): # Still does not fully suppress (the paper's windowed-LOM limitation): LER does # not fall with distance (it in fact grows). When the anti-snake machinery # lands and this suppresses, flip the assertions and update the test. - suppression_msg = ( + assert ler_d5 >= ler_d3 * 0.7 and ler_d7 >= ler_d5 * 0.7, ( f"windowed logical-subgraph now suppresses (d3={ler_d3:.5f} " f"d5={ler_d5:.5f} d7={ler_d7:.5f}) -- anti-snake machinery appears to " "have landed; update this test." ) - assert ler_d5 >= ler_d3 * 0.7, suppression_msg - assert ler_d7 >= ler_d5 * 0.7, suppression_msg # Guard against regressing to the old catastrophic anti-suppression (the # naive-XOR decoder reached ~0.1-0.25 here); the core-commit rewrite keeps it # well below that across distances. - assert ( - max(ler_d3, ler_d5, ler_d7) < 0.1 - ), f"windowed LER regressed toward catastrophic: d3={ler_d3:.5f} d5={ler_d5:.5f} d7={ler_d7:.5f}" + assert max(ler_d3, ler_d5, ler_d7) < 0.1, ( + f"windowed LER regressed toward catastrophic: " + f"d3={ler_d3:.5f} d5={ler_d5:.5f} d7={ler_d7:.5f}" + ) def test_decode_each_matches_decode_count(): diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py index be804f15f..eb1ac06d1 100644 --- a/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py @@ -45,7 +45,11 @@ def _canonicalize_raw_rows_from_masks( ) -> list[list[int]]: """Apply the same Pauli-frame rule as the generated canonical tracker.""" num_data = patch.geometry.num_data - init_count = len(patch.geometry.x_stabilizers) if basis.upper() == "Z" else len(patch.geometry.z_stabilizers) + init_count = ( + len(patch.geometry.x_stabilizers) + if basis.upper() == "Z" + else len(patch.geometry.z_stabilizers) + ) expected_width = init_count + num_rounds * patch.geometry.num_ancilla + num_data canonical_rows: list[list[int]] = [] diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py index e50730eb2..eb3934c47 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -229,7 +229,7 @@ def from_dem(cls, _dem: str) -> object: circuit_level_dem_mode="native_decomposed", ) - assert decoder._get_z_decoder() is not None + assert decoder._get_z_decoder() is not None # noqa: SLF001 assert seen == { "method": "from_dem_with_correlations", "dem": "error(0.01) D0 ^ D1 L0\n", @@ -270,7 +270,7 @@ def from_dem(cls, dem: str) -> object: circuit_level_dem_mode="native_decomposed", ) - assert decoder._get_z_decoder() is not None + assert decoder._get_z_decoder() is not None # noqa: SLF001 assert seen == { "method": "from_dem", "dem": "error(0.01) D0 ^ D1 L0\n", @@ -288,7 +288,7 @@ def test_correlated_pymatching_requires_circuit_level_dem(self) -> None: ) with pytest.raises(ValueError, match="requires circuit-level DEM"): - decoder._get_z_decoder() + decoder._get_z_decoder() # noqa: SLF001 def test_correlated_pymatching_requires_decomposed_dem_mode(self) -> None: """The explicit correlated option needs decomposed DEM metadata.""" @@ -302,15 +302,18 @@ def test_correlated_pymatching_requires_decomposed_dem_mode(self) -> None: ) with pytest.raises(ValueError, match="requires a decomposed"): - decoder._get_z_decoder() + decoder._get_z_decoder() # noqa: SLF001 def test_recommended_memory_workflow_uses_terminal_graphlike_for_pymatching(self) -> None: """The high-level memory helper should use the best measured graphlike projection.""" import pecos.qec.surface.decode as decode_module for decoder_type in ["pymatching", "pymatching_correlated", "pymatching_uncorrelated"]: - assert decode_module._recommended_graphlike_decomposition_for_decoder(decoder_type) == "terminal_graphlike" - assert decode_module._recommended_graphlike_decomposition_for_decoder("tesseract") == "source_graphlike" + assert ( + decode_module._recommended_graphlike_decomposition_for_decoder(decoder_type) # noqa: SLF001 + == "terminal_graphlike" + ) + assert decode_module._recommended_graphlike_decomposition_for_decoder("tesseract") == "source_graphlike" # noqa: SLF001 def test_get_dem(self) -> None: """Test DEM generation via decoder.""" @@ -766,15 +769,12 @@ def test_constrained_budget_dem_remains_strictly_decodable(self) -> None: dem = generate_dem_from_tick_circuit(tc, **params) sampler = ParsedDem.from_string(dem).to_dem_sampler() - assert ( - sampler.sample_decode_count( - dem, - 16, - decoder_type="pymatching", - seed=1234, - ) - >= 0 - ) + assert sampler.sample_decode_count( + dem, + 16, + decoder_type="pymatching", + seed=1234, + ) >= 0 def test_traced_qis_traces_the_given_patch_not_its_distance(self) -> None: """A non-rotated patch must be traced from its OWN Guppy program, not diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py b/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py index 7e01b5d1b..1eed66782 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py @@ -232,7 +232,9 @@ def test_tick_circuit_uses_explicit_prep_syndrome_baseline( ] assert init_tick_rounds - first_random_detector = next(det for det in detectors if det["coords"][1] == detector_y and det["coords"][2] == 0) + first_random_detector = next( + det for det in detectors if det["coords"][1] == detector_y and det["coords"][2] == 0 + ) assert len(first_random_detector["records"]) == 2 record_indices = [num_measurements + int(record) for record in first_random_detector["records"]] assert record_indices[0] >= init_count diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py index e96fcc4b1..d0fa4fa5a 100644 --- a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -522,6 +522,62 @@ def test_szz_noiseless_detector_record_equivalence(distance: int, basis: str) -> _assert_noiseless_record_metadata_is_zero(szz_text, szz_tick) +@pytest.mark.parametrize( + "check_plan", + [ + "szz_balanced_data_round_order_1032_v1", + "szz_balanced_data_round_order_3102_v1", + ], +) +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_round_order_szz_noiseless_detector_record_equivalence( + basis: str, + check_plan: str, +) -> None: + patch = SurfacePatch.create(distance=3) + baseline_plan = "szz_balanced_data_v1" + + baseline_text = generate_stim_from_patch( + patch, + num_rounds=3, + basis=basis, + ancilla_budget=2, + check_plan=baseline_plan, + ) + round_order_text = generate_stim_from_patch( + patch, + num_rounds=3, + basis=basis, + ancilla_budget=2, + check_plan=check_plan, + ) + baseline_tick = generate_tick_circuit_from_patch( + patch, + num_rounds=3, + basis=basis, + ancilla_budget=2, + check_plan=baseline_plan, + ) + round_order_tick = generate_tick_circuit_from_patch( + patch, + num_rounds=3, + basis=basis, + ancilla_budget=2, + check_plan=check_plan, + ) + + baseline_circuit = stim.Circuit(baseline_text) + round_order_circuit = stim.Circuit(round_order_text) + assert round_order_text != baseline_text + assert round_order_circuit.num_measurements == baseline_circuit.num_measurements + assert round_order_circuit.num_detectors == baseline_circuit.num_detectors + assert round_order_circuit.num_observables == baseline_circuit.num_observables + assert round_order_tick.get_meta("detectors") == baseline_tick.get_meta("detectors") + assert round_order_tick.get_meta("observables") == baseline_tick.get_meta("observables") + + _assert_noiseless_record_metadata_is_zero(round_order_text, round_order_tick) + + def test_szz_native_dem_path_uses_interaction_basis() -> None: patch = SurfacePatch.create(distance=3) noise = NoiseModel(p1=0.0, p2=0.01, p2_weights={"ZI": 1.0}, p_meas=0.001, p_prep=0.001) @@ -852,6 +908,67 @@ def test_szz_traced_qis_native_dem_matches_stim_for_p1(basis: str) -> None: ) +@pytest.mark.parametrize( + "check_plan", + [ + "szz_balanced_data_round_order_1032_v1", + "szz_balanced_data_round_order_3102_v1", + ], +) +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_round_order_szz_traced_qis_native_dem_matches_stim_for_p1( + basis: str, + check_plan: str, +) -> None: + from pecos.qec.surface.circuit_builder import normalize_traced_qis_tick_circuit + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + + patch = SurfacePatch.create(distance=3) + tick_circuit = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=1, + basis=basis, + ancilla_budget=2, + circuit_source="traced_qis", + check_plan=check_plan, + ) + normalize_traced_qis_tick_circuit(tick_circuit, context="round-order SZZ traced-QIS p1 test") + noise_args = { + "p1": 0.001, + "p2": 0.0, + "p_meas": 0.0, + "p_prep": 0.0, + } + + native_errors = _raw_dem_errors( + generate_dem_from_tick_circuit( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + stim_errors = _raw_dem_errors( + generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + + assert set(native_errors) == set(stim_errors) + for target, native_probability in native_errors.items(): + stim_probability = stim_errors[target] + rel_diff = abs(native_probability - stim_probability) / max( + native_probability, + stim_probability, + 1e-12, + ) + assert rel_diff < 0.005, ( + f"{basis} round-order traced-QIS SZZ p1 DEM mismatch for {target}: " + f"PECOS={native_probability:.8f}, Stim={stim_probability:.8f}" + ) + + def test_szz_public_native_dem_accepts_traced_qis_p1() -> None: patch = SurfacePatch.create(distance=3) dem = generate_circuit_level_dem_from_builder( diff --git a/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py b/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py index ff2d2e80e..ff6ff6738 100644 --- a/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py +++ b/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py @@ -11,6 +11,7 @@ import pytest from pecos_rslib import DagCircuit +from pecos_rslib.quantum import Gate, GateType from pecos_rslib.qec import ( DagFaultAnalyzer, DemBuilder, diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index 11af30ae6..1bf723e1e 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -98,13 +98,9 @@ def forbidden_stabilizer(): @pytest.mark.xfail( reason=( - "Guppy's generic public barrier(...) is optimized away (tket DCE) before PECOS " - "collects QIS operations, so no Barrier survives this path. The supported " - "barrier-preserving path is the SZZ runtime-barrier helper " - "(szz_runtime_barriers=...), covered by " - "test_szz_runtime_barrier_survives_into_qis_operation_trace below. strict=True so " - "this XPASSes (and must be removed) if a future tket/Guppy lowering ever " - "preserves the generic barrier." + "Guppy public barrier(...) is currently optimized away before PECOS " + "QIS operation collection; hosted SZZ prefix scheduling needs a " + "barrier-preserving or hosted-operation lowering path." ), strict=True, ) @@ -114,7 +110,11 @@ def test_guppy_barrier_survives_into_qis_operation_trace() -> None: num_qubits=2, seed=0, ) - operations = [operation for chunk in chunks for operation in chunk.get("operations", [])] + operations = [ + operation + for chunk in chunks + for operation in chunk.get("operations", []) + ] assert any(operation == "Barrier" or "Barrier" in operation for operation in operations) @@ -132,14 +132,22 @@ def test_szz_runtime_barrier_survives_into_qis_operation_trace() -> None: num_qubits=get_num_qubits(d=3, interaction_basis="szz"), seed=0, ) - operations = [operation for chunk in chunks for operation in chunk.get("operations", [])] + operations = [ + operation + for chunk in chunks + for operation in chunk.get("operations", []) + ] assert any(operation == "Barrier" or "Barrier" in str(operation) for operation in operations) def test_qubit_trace_metadata_stays_ordered_before_gate() -> None: chunks = capture_guppy_operation_trace(_metadata_before_h_gate, num_qubits=1, seed=0) - lowered_ops = [op for chunk in chunks for op in chunk.get("lowered_quantum_ops", [])] + lowered_ops = [ + op + for chunk in chunks + for op in chunk.get("lowered_quantum_ops", []) + ] assert lowered_ops[1]["gate_type"] == "R1XY" assert lowered_ops[1]["metadata"] == { @@ -758,7 +766,7 @@ def test_from_guppy_redundant_records_and_meas_ids_are_accepted() -> None: # --------------------------------------------------------------------------- -def _constrained_surface_via_guppy(*, d, basis, rounds, budget, noise): +def _constrained_surface_via_guppy(*, d, basis, rounds, budget, noise, check_plan: str | None = None): """Build the constrained-surface DEM through `from_guppy`.""" patch = SurfacePatch.create(distance=d) ref = _build_surface_tick_circuit_for_native_model( @@ -767,13 +775,20 @@ def _constrained_surface_via_guppy(*, d, basis, rounds, budget, noise): basis=basis, ancilla_budget=budget, circuit_source="traced_qis", + check_plan=check_plan, ) normalize_traced_qis_tick_circuit(ref, context="from_guppy constrained surface reference") ref_dem = DetectorErrorModel.from_circuit(ref, **noise).to_string() got = DetectorErrorModel.from_guppy( - make_surface_code(distance=d, num_rounds=rounds, basis=basis, ancilla_budget=budget), - num_qubits=get_num_qubits(d, ancilla_budget=budget), + make_surface_code( + distance=d, + num_rounds=rounds, + basis=basis, + ancilla_budget=budget, + check_plan=check_plan, + ), + num_qubits=get_num_qubits(d, ancilla_budget=budget, check_plan=check_plan), detectors_json=ref.get_meta("detectors"), observables_json=ref.get_meta("observables"), num_measurements=int(ref.get_meta("num_measurements")), @@ -809,12 +824,42 @@ def test_from_guppy_constrained_surface_dem_byte_identical( budget=budget, noise=noise, ) - assert ( - got == ref_dem - ), f"constrained surface from_guppy not byte-identical for d={d}, budget={budget}, basis={basis}, rounds={rounds}" + assert got == ref_dem, ( + f"constrained surface from_guppy not byte-identical for d={d}, budget={budget}, basis={basis}, rounds={rounds}" + ) + + +@pytest.mark.parametrize( + "check_plan", + [ + "szz_balanced_data_round_order_1032_v1", + "szz_balanced_data_round_order_3102_v1", + ], +) +def test_from_guppy_round_order_szz_surface_dem_byte_identical(check_plan: str) -> None: + """Round-order SZZ check plans must stay byte-identical through from_guppy.""" + noise = {"p1": 0.0, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} + ref_dem, got, _ = _constrained_surface_via_guppy( + d=3, + basis="X", + rounds=2, + budget=2, + noise=noise, + check_plan=check_plan, + ) + assert got == ref_dem -def test_constrained_surface_traced_metadata_matches_abstract() -> None: + +@pytest.mark.parametrize( + "check_plan", + [ + None, + "szz_balanced_data_round_order_1032_v1", + "szz_balanced_data_round_order_3102_v1", + ], +) +def test_constrained_surface_traced_metadata_matches_abstract(check_plan: str | None) -> None: """Traced surface metadata preserves structure but binds via MeasIds. Runtime traces may reorder measurements, so detector/observable metadata @@ -829,6 +874,7 @@ def test_constrained_surface_traced_metadata_matches_abstract() -> None: basis="Z", ancilla_budget=2, circuit_source="abstract", + check_plan=check_plan, ) traced_tc = _build_surface_tick_circuit_for_native_model( patch, @@ -836,6 +882,7 @@ def test_constrained_surface_traced_metadata_matches_abstract() -> None: basis="Z", ancilla_budget=2, circuit_source="traced_qis", + check_plan=check_plan, ) for key in ( "basis", diff --git a/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py b/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py index 1d7438dea..23f04dd23 100644 --- a/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py +++ b/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py @@ -11,7 +11,7 @@ import pytest from pecos.qec.surface import SurfacePatch from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model -from pecos_rslib_exp import depolarizing, meas_sampling, monte_carlo, sim_neo, stabilizer +from pecos_rslib_exp import depolarizing, meas_sampling, sim_neo, stabilizer @pytest.fixture @@ -32,18 +32,18 @@ def coherent(): class TestD3SurfaceCode57vs48: def test_raw_output_is_57_measurements(self, d3_tc, depol): - r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(10)).seed(42).run() + r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).shots(10).seed(42).run() assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_nondet_measurement_mean_half(self, d3_tc, depol): shots = 5000 - r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() + r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() mean_0 = sum(s[0] for s in r) / shots assert abs(mean_0 - 0.5) < 0.05, f"meas[0]={mean_0:.3f}" def test_det_measurement_mean_low(self, d3_tc, depol): shots = 5000 - r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() + r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() # Index 8 is the first round-1 Z-stabilizer measurement; on the |0_L> # memory experiment it is deterministic (+1). Indices 0-7 are the four # init-round X-stabilizer measurements plus round-1 X-stabilizers, which @@ -71,8 +71,8 @@ def rates(results): r[i] += 1.0 / len(results) return r - meas_r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() - stab_r = sim_neo(d3_tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() + meas_r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() + stab_r = sim_neo(d3_tc).quantum(stabilizer()).noise(depol).shots(shots).seed(42).run() meas_rates = rates(meas_r) stab_rates = rates(stab_r) @@ -108,8 +108,8 @@ def rates(results): r[i] += 1.0 / len(results) return r - meas_r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() - stab_r = sim_neo(d3_tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() + meas_r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() + stab_r = sim_neo(d3_tc).quantum(stabilizer()).noise(depol).shots(shots).seed(42).run() meas_rates = rates(meas_r) stab_rates = rates(stab_r) @@ -138,8 +138,8 @@ def rates(results): r[i] += 1.0 / len(results) return r - meas_r = sim_neo(d3_tc).quantum(meas_sampling()).noise(noise).sampling(monte_carlo(shots)).seed(42).run() - stab_r = sim_neo(d3_tc).quantum(stabilizer()).noise(noise).sampling(monte_carlo(shots)).seed(43).run() + meas_r = sim_neo(d3_tc).quantum(meas_sampling()).noise(noise).shots(shots).seed(42).run() + stab_r = sim_neo(d3_tc).quantum(stabilizer()).noise(noise).shots(shots).seed(43).run() meas_rates = rates(meas_r) stab_rates = rates(stab_r) @@ -153,29 +153,29 @@ def rates(results): class TestMethodDispatch: def test_auto_no_idle_rz(self, d3_tc, depol): - r = sim_neo(d3_tc).quantum(meas_sampling("auto")).noise(depol).sampling(monte_carlo(10)).seed(42).run() + r = sim_neo(d3_tc).quantum(meas_sampling("auto")).noise(depol).shots(10).seed(42).run() assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_auto_with_idle_rz(self, d3_tc, coherent): - r = sim_neo(d3_tc).quantum(meas_sampling("auto")).noise(coherent).sampling(monte_carlo(10)).seed(42).run() + r = sim_neo(d3_tc).quantum(meas_sampling("auto")).noise(coherent).shots(10).seed(42).run() assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_stochastic_rejects_idle_rz(self, d3_tc, coherent): with pytest.raises(Exception, match="idle_rz"): - sim_neo(d3_tc).quantum(meas_sampling("stochastic")).noise(coherent).sampling(monte_carlo(10)).seed(42).run() + sim_neo(d3_tc).quantum(meas_sampling("stochastic")).noise(coherent).shots(10).seed(42).run() def test_coherent_no_idle_rz(self, d3_tc, depol): - r = sim_neo(d3_tc).quantum(meas_sampling("coherent")).noise(depol).sampling(monte_carlo(10)).seed(42).run() + r = sim_neo(d3_tc).quantum(meas_sampling("coherent")).noise(depol).shots(10).seed(42).run() assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_coherent_with_idle_rz(self, d3_tc, coherent): - r = sim_neo(d3_tc).quantum(meas_sampling("coherent")).noise(coherent).sampling(monte_carlo(10)).seed(42).run() + r = sim_neo(d3_tc).quantum(meas_sampling("coherent")).noise(coherent).shots(10).seed(42).run() assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_invalid_method(self, d3_tc, depol): with pytest.raises(Exception, match="Unknown"): - sim_neo(d3_tc).quantum(meas_sampling("bogus")).noise(depol).sampling(monte_carlo(10)).seed(42).run() + sim_neo(d3_tc).quantum(meas_sampling("bogus")).noise(depol).shots(10).seed(42).run() def test_no_noise_errors(self, d3_tc): with pytest.raises(Exception, match="noise"): - sim_neo(d3_tc).quantum(meas_sampling()).sampling(monte_carlo(10)).seed(42).run() + sim_neo(d3_tc).quantum(meas_sampling()).shots(10).seed(42).run() diff --git a/python/quantum-pecos/tests/qec/test_raw_measurement_result.py b/python/quantum-pecos/tests/qec/test_raw_measurement_result.py index 9c6fd044f..facf6b587 100644 --- a/python/quantum-pecos/tests/qec/test_raw_measurement_result.py +++ b/python/quantum-pecos/tests/qec/test_raw_measurement_result.py @@ -11,7 +11,7 @@ import pytest from pecos.qec.surface import SurfacePatch from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model -from pecos_rslib_exp import depolarizing, meas_sampling, monte_carlo, sim_neo, stabilizer +from pecos_rslib_exp import depolarizing, meas_sampling, sim_neo, stabilizer @pytest.fixture @@ -26,8 +26,8 @@ def d3_results(): num_meas = int(tc.get_meta("num_measurements")) depol = depolarizing().p1(0.005).p2(0.005).p_meas(0.005).p_prep(0.005) - stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(100)).seed(42).run() - meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(100)).seed(42).run() + stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(100).seed(42).run() + meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(100).seed(42).run() return stab_r, meas_r, num_meas @@ -175,7 +175,7 @@ def test_generic_consumer_stabilizer(self): patch = SurfacePatch.create(distance=3) tc = _build_surface_tick_circuit_for_native_model(patch, 6, "Z", circuit_source="abstract") depol = depolarizing().p1(0.005).p2(0.005).p_meas(0.005).p_prep(0.005) - result = sim_neo(tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(1000)).seed(42).run() + result = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(1000).seed(42).run() means = self.compute_measurement_means(result) assert len(means) == int(tc.get_meta("num_measurements")) @@ -187,7 +187,7 @@ def test_generic_consumer_meas_sampling(self): patch = SurfacePatch.create(distance=3) tc = _build_surface_tick_circuit_for_native_model(patch, 6, "Z", circuit_source="abstract") depol = depolarizing().p1(0.005).p2(0.005).p_meas(0.005).p_prep(0.005) - result = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(1000)).seed(42).run() + result = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(1000).seed(42).run() means = self.compute_measurement_means(result) assert len(means) == int(tc.get_meta("num_measurements")) diff --git a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py index 50116aba0..25c162953 100644 --- a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py +++ b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py @@ -17,7 +17,6 @@ depolarizing, fault_catalog, meas_sampling, - monte_carlo, sim_neo, stabilizer, statevec, @@ -183,7 +182,7 @@ def test_meas_sampling_runs_on_lowered_traced_qis_surface_code(): tc = build_lowered_traced_qis_surface_code() shots = 8 - result = sim_neo(tc).quantum(meas_sampling()).noise(traced_qis_noise()).sampling(monte_carlo(shots)).seed(123).run() + result = sim_neo(tc).quantum(meas_sampling()).noise(traced_qis_noise()).shots(shots).seed(123).run() assert result.num_shots == shots assert result.num_measurements == int(tc.get_meta("num_measurements")) @@ -206,7 +205,7 @@ def test_lowered_traced_qis_pipeline_sampling_and_catalog_smoke(): tc = build_lowered_traced_qis_surface_code(rounds=2) noise = traced_qis_noise() - result = sim_neo(tc).quantum(meas_sampling()).noise(noise).sampling(monte_carlo(3)).seed(321).run() + result = sim_neo(tc).quantum(meas_sampling()).noise(noise).shots(3).seed(321).run() catalog = fault_catalog(tc, noise) first_fault = next(catalog.fault_configurations(1)) @@ -224,7 +223,9 @@ def test_normalize_traced_qis_tick_circuit_lowers_clifford_rzz(): normalize_traced_qis_tick_circuit(tc, context="test Clifford RZZ normalization") gate_names = [ - gate.gate_type.name for tick_index in range(tc.num_ticks()) for gate in tc.get_tick(tick_index).gate_batches() + gate.gate_type.name + for tick_index in range(tc.num_ticks()) + for gate in tc.get_tick(tick_index).gate_batches() ] assert "RZZ" not in gate_names assert "SZZ" in gate_names @@ -240,7 +241,9 @@ def test_normalize_traced_qis_tick_circuit_rejects_raw_rzz_after_lowering(): def _gate_names(tc): return [ - gate.gate_type.name for tick_index in range(tc.num_ticks()) for gate in tc.get_tick(tick_index).gate_batches() + gate.gate_type.name + for tick_index in range(tc.num_ticks()) + for gate in tc.get_tick(tick_index).gate_batches() ] @@ -321,7 +324,7 @@ def test_explicit_python_gate_names_map_to_rust_clifford_gates(): tc = build_explicit_clifford_gate_circuit() noise = depolarizing().p1(0.03).p2(0.15).p_meas(0).p_prep(0) - result = sim_neo(tc).quantum(meas_sampling()).noise(noise).sampling(monte_carlo(3)).seed(123).run() + result = sim_neo(tc).quantum(meas_sampling()).noise(noise).shots(3).seed(123).run() assert result.num_shots == 3 assert result.num_measurements == 2 @@ -343,7 +346,7 @@ def test_sim_neo_native_backends_accept_face_gates(): tc.set_meta("observables", "[]") for backend in (stabilizer(), statevec()): - result = sim_neo(tc).quantum(backend).noise(zero_noise()).sampling(monte_carlo(2)).seed(123).run() + result = sim_neo(tc).quantum(backend).noise(zero_noise()).shots(2).seed(123).run() assert result.num_measurements == 1 assert all(result[shot][0] == 0 for shot in range(result.num_shots)) @@ -384,7 +387,7 @@ def test_random_mirrored_standard_clifford_circuits_match_across_backends(): backend_results = {} for name, backend in (("stabilizer", stabilizer()), ("statevec", statevec())): - result = sim_neo(tc).quantum(backend).noise(zero_noise()).sampling(monte_carlo(4)).seed(seed).run() + result = sim_neo(tc).quantum(backend).noise(zero_noise()).shots(4).seed(seed).run() backend_results[name] = [list(row) for row in result.to_list()] backend_results["StabMps"] = [run_direct_wrapper_mirrored_circuit(StabMps(3, seed=seed), sequence)]